Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 140 additions & 2 deletions packages/social/pinterest/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,142 @@
import { smokeTest } from '@profullstack/sh1pt-core/testing';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { contractTestSocial, fakeConnectContext } from '@profullstack/sh1pt-core/testing';
import adapter from './index.js';

smokeTest(adapter, { idPrefix: 'social' });
contractTestSocial(adapter, {
sampleConfig: { boardId: 'board_123' },
samplePost: {
title: 'Hello Pinterest',
body: 'hello from sh1pt contract tests',
media: [{ file: 'https://cdn.example.com/pin.jpg', kind: 'image', alt: 'Example image' }],
},
requiredSecrets: ['PINTEREST_ACCESS_TOKEN'],
});

afterEach(() => {
vi.restoreAllMocks();
});

describe('social-pinterest posting', () => {
it('creates an image Pin from an HTTPS media URL', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
status: 201,
json: async () => ({
id: '654321654321654321',
created_at: '2026-05-16T19:00:00',
}),
} as Response);

const ctx = {
...fakeConnectContext({ PINTEREST_ACCESS_TOKEN: 'pinterest-token' }),
dryRun: false,
};

const result = await adapter.post(ctx as any, {
title: 'Launch visual',
body: 'Launch screenshot',
hashtags: ['ship', 'design'],
link: 'https://sh1pt.com',
media: [{ file: 'https://cdn.example.com/launch.jpg', kind: 'image', alt: 'Launch screenshot' }],
}, {
boardId: 'board_123',
boardSectionId: 'section_456',
isStandard: false,
});

expect(result).toEqual({
id: '654321654321654321',
url: 'https://www.pinterest.com/pin/654321654321654321/',
platform: 'pinterest',
publishedAt: '2026-05-16T19:00:00.000Z',
});
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe('https://api.pinterest.com/v5/pins');
expect((init as RequestInit).method).toBe('POST');
expect((init as RequestInit).headers).toMatchObject({
authorization: 'Bearer pinterest-token',
accept: 'application/json',
'content-type': 'application/json',
});
expect(JSON.parse(String((init as RequestInit).body))).toEqual({
board_id: 'board_123',
board_section_id: 'section_456',
title: 'Launch visual',
description: 'Launch screenshot #ship #design',
link: 'https://sh1pt.com',
alt_text: 'Launch screenshot',
media_source: {
source_type: 'image_url',
url: 'https://cdn.example.com/launch.jpg',
is_standard: false,
},
});
});

it('creates a video Pin from a pre-uploaded Pinterest media id', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
status: 201,
json: async () => ({ id: '987654321' }),
} as Response);

const ctx = {
...fakeConnectContext({ PINTEREST_ACCESS_TOKEN: 'pinterest-token' }),
dryRun: false,
};

await adapter.post(ctx as any, {
title: 'Video launch',
body: 'Video body',
media: [{ file: '/tmp/video.mp4', kind: 'video' }],
}, {
boardId: 'board_123',
videoMediaId: 'media_123',
coverImageUrl: 'https://cdn.example.com/cover.jpg',
});

const payload = JSON.parse(String((fetchMock.mock.calls[0]?.[1] as RequestInit).body));
expect(payload.media_source).toEqual({
source_type: 'video_id',
media_id: 'media_123',
cover_image_url: 'https://cdn.example.com/cover.jpg',
});
});

it('rejects local image paths because Pinterest image_url requires a URL', async () => {
const ctx = {
...fakeConnectContext({ PINTEREST_ACCESS_TOKEN: 'pinterest-token' }),
dryRun: false,
};

await expect(adapter.post(ctx as any, {
title: 'Local image',
body: 'Body',
media: [{ file: '/tmp/pin.jpg', kind: 'image' }],
}, {
boardId: 'board_123',
})).rejects.toThrow('http(s) image URL');
});

it('surfaces Pinterest API errors', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: false,
status: 400,
statusText: 'Bad Request',
json: async () => ({ code: 1, message: 'The Pin image is broken' }),
} as Response);

const ctx = {
...fakeConnectContext({ PINTEREST_ACCESS_TOKEN: 'pinterest-token' }),
dryRun: false,
};

await expect(adapter.post(ctx as any, {
title: 'Broken image',
body: 'Body',
media: [{ file: 'https://cdn.example.com/broken.jpg', kind: 'image' }],
}, {
boardId: 'board_123',
})).rejects.toThrow('The Pin image is broken');
});
});
98 changes: 95 additions & 3 deletions packages/social/pinterest/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
import { defineSocial, oauthSetup } from '@profullstack/sh1pt-core';
import { defineSocial, oauthSetup, type MediaAttachment, type SocialPost } from '@profullstack/sh1pt-core';

// Pinterest API v5. OAuth 2.0 with PKCE; pins live on boards owned by the
// authenticated user or business account.
interface Config {
boardId: string;
boardSectionId?: string;
isStandard?: boolean;
videoMediaId?: string;
coverImageUrl?: string;
}

interface PinterestPinResponse {
id?: string;
created_at?: string;
code?: number;
message?: string;
}

export default defineSocial<Config>({
Expand All @@ -20,10 +31,30 @@ export default defineSocial<Config>({
if (!post.media?.length) {
throw new Error('Pinterest requires at least one image or video');
}
const token = ctx.secret('PINTEREST_ACCESS_TOKEN');
if (!token) throw new Error('PINTEREST_ACCESS_TOKEN not in vault');
ctx.log(`pinterest pin · board=${config.boardId} · media=${post.media.length}`);
if (ctx.dryRun) return { id: 'dry-run', url: 'https://pinterest.com/', platform: 'pinterest', publishedAt: new Date().toISOString() };
// TODO: POST /v5/pins with { board_id, media_source: { source_type: 'image_url'|'video_id', url } }
return { id: `pin_${Date.now()}`, url: 'https://www.pinterest.com/', platform: 'pinterest', publishedAt: new Date().toISOString() };

const res = await fetch('https://api.pinterest.com/v5/pins', {
method: 'POST',
headers: {
authorization: `Bearer ${token}`,
accept: 'application/json',
'content-type': 'application/json',
},
body: JSON.stringify(formatPinterestPin(post, config)),
});
const data = await readPinterestResponse(res);
if (!res.ok) throw new Error(data.message ?? res.statusText);
if (!data.id) throw new Error('Pinterest create Pin response did not include a Pin id');

return {
id: data.id,
url: `https://www.pinterest.com/pin/${data.id}/`,
platform: 'pinterest',
publishedAt: pinterestTimestamp(data.created_at),
};
},

setup: oauthSetup({
Expand All @@ -49,3 +80,64 @@ export default defineSocial<Config>({
: {}),
}),
});

function formatPinterestPin(post: SocialPost, config: Config): Record<string, unknown> {
const media = firstSupportedMedia(post.media ?? [], config);
return {
board_id: config.boardId,
board_section_id: config.boardSectionId,
title: post.title,
description: formatDescription(post),
link: post.link,
alt_text: media.kind === 'image' ? media.alt : undefined,
media_source: mediaSource(media, config),
};
}

function firstSupportedMedia(media: MediaAttachment[], config: Config): MediaAttachment {
const selected = media.find((item) => item.kind === 'image' || item.kind === 'video');
if (!selected) throw new Error('Pinterest requires an image or video media attachment');
if (selected.kind === 'video' && (!config.videoMediaId || !config.coverImageUrl)) {
throw new Error('Pinterest video Pins require config.videoMediaId and config.coverImageUrl from the media upload flow');
}
return selected;
}

function mediaSource(media: MediaAttachment, config: Config): Record<string, unknown> {
if (media.kind === 'video') {
return {
source_type: 'video_id',
media_id: config.videoMediaId,
cover_image_url: config.coverImageUrl,
};
}

if (!/^https?:\/\//.test(media.file)) {
throw new Error('Pinterest image Pins require media.file to be an http(s) image URL');
}
return {
source_type: 'image_url',
url: media.file,
is_standard: config.isStandard ?? true,
};
}

function formatDescription(post: SocialPost): string {
const hashtags = (post.hashtags ?? []).slice(0, 20).map((tag) => `#${tag}`).join(' ');
const description = hashtags ? `${post.body} ${hashtags}` : post.body;
return description.slice(0, 500);
}

async function readPinterestResponse(res: Response): Promise<PinterestPinResponse> {
try {
return await res.json() as PinterestPinResponse;
} catch {
return { message: res.statusText };
}
}

function pinterestTimestamp(value: string | undefined): string {
if (!value) return new Date().toISOString();
const hasTimezone = /(?:Z|[+-]\d{2}:\d{2})$/.test(value);
return new Date(hasTimezone ? value : `${value}Z`).toISOString();
}
Loading