Skip to content

Commit c11d4ee

Browse files
thejaytangralyodio
andauthored
feat(social): implement pinterest pin posting (#254)
Co-authored-by: Anthony Ettinger <anthony@chovy.com>
1 parent d2608d3 commit c11d4ee

2 files changed

Lines changed: 235 additions & 5 deletions

File tree

Lines changed: 140 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,142 @@
1-
import { smokeTest } from '@profullstack/sh1pt-core/testing';
1+
import { afterEach, describe, expect, it, vi } from 'vitest';
2+
import { contractTestSocial, fakeConnectContext } from '@profullstack/sh1pt-core/testing';
23
import adapter from './index.js';
34

4-
smokeTest(adapter, { idPrefix: 'social' });
5+
contractTestSocial(adapter, {
6+
sampleConfig: { boardId: 'board_123' },
7+
samplePost: {
8+
title: 'Hello Pinterest',
9+
body: 'hello from sh1pt contract tests',
10+
media: [{ file: 'https://cdn.example.com/pin.jpg', kind: 'image', alt: 'Example image' }],
11+
},
12+
requiredSecrets: ['PINTEREST_ACCESS_TOKEN'],
13+
});
14+
15+
afterEach(() => {
16+
vi.restoreAllMocks();
17+
});
18+
19+
describe('social-pinterest posting', () => {
20+
it('creates an image Pin from an HTTPS media URL', async () => {
21+
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
22+
ok: true,
23+
status: 201,
24+
json: async () => ({
25+
id: '654321654321654321',
26+
created_at: '2026-05-16T19:00:00',
27+
}),
28+
} as Response);
29+
30+
const ctx = {
31+
...fakeConnectContext({ PINTEREST_ACCESS_TOKEN: 'pinterest-token' }),
32+
dryRun: false,
33+
};
34+
35+
const result = await adapter.post(ctx as any, {
36+
title: 'Launch visual',
37+
body: 'Launch screenshot',
38+
hashtags: ['ship', 'design'],
39+
link: 'https://sh1pt.com',
40+
media: [{ file: 'https://cdn.example.com/launch.jpg', kind: 'image', alt: 'Launch screenshot' }],
41+
}, {
42+
boardId: 'board_123',
43+
boardSectionId: 'section_456',
44+
isStandard: false,
45+
});
46+
47+
expect(result).toEqual({
48+
id: '654321654321654321',
49+
url: 'https://www.pinterest.com/pin/654321654321654321/',
50+
platform: 'pinterest',
51+
publishedAt: '2026-05-16T19:00:00.000Z',
52+
});
53+
const [url, init] = fetchMock.mock.calls[0]!;
54+
expect(url).toBe('https://api.pinterest.com/v5/pins');
55+
expect((init as RequestInit).method).toBe('POST');
56+
expect((init as RequestInit).headers).toMatchObject({
57+
authorization: 'Bearer pinterest-token',
58+
accept: 'application/json',
59+
'content-type': 'application/json',
60+
});
61+
expect(JSON.parse(String((init as RequestInit).body))).toEqual({
62+
board_id: 'board_123',
63+
board_section_id: 'section_456',
64+
title: 'Launch visual',
65+
description: 'Launch screenshot #ship #design',
66+
link: 'https://sh1pt.com',
67+
alt_text: 'Launch screenshot',
68+
media_source: {
69+
source_type: 'image_url',
70+
url: 'https://cdn.example.com/launch.jpg',
71+
is_standard: false,
72+
},
73+
});
74+
});
75+
76+
it('creates a video Pin from a pre-uploaded Pinterest media id', async () => {
77+
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
78+
ok: true,
79+
status: 201,
80+
json: async () => ({ id: '987654321' }),
81+
} as Response);
82+
83+
const ctx = {
84+
...fakeConnectContext({ PINTEREST_ACCESS_TOKEN: 'pinterest-token' }),
85+
dryRun: false,
86+
};
87+
88+
await adapter.post(ctx as any, {
89+
title: 'Video launch',
90+
body: 'Video body',
91+
media: [{ file: '/tmp/video.mp4', kind: 'video' }],
92+
}, {
93+
boardId: 'board_123',
94+
videoMediaId: 'media_123',
95+
coverImageUrl: 'https://cdn.example.com/cover.jpg',
96+
});
97+
98+
const payload = JSON.parse(String((fetchMock.mock.calls[0]?.[1] as RequestInit).body));
99+
expect(payload.media_source).toEqual({
100+
source_type: 'video_id',
101+
media_id: 'media_123',
102+
cover_image_url: 'https://cdn.example.com/cover.jpg',
103+
});
104+
});
105+
106+
it('rejects local image paths because Pinterest image_url requires a URL', async () => {
107+
const ctx = {
108+
...fakeConnectContext({ PINTEREST_ACCESS_TOKEN: 'pinterest-token' }),
109+
dryRun: false,
110+
};
111+
112+
await expect(adapter.post(ctx as any, {
113+
title: 'Local image',
114+
body: 'Body',
115+
media: [{ file: '/tmp/pin.jpg', kind: 'image' }],
116+
}, {
117+
boardId: 'board_123',
118+
})).rejects.toThrow('http(s) image URL');
119+
});
120+
121+
it('surfaces Pinterest API errors', async () => {
122+
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
123+
ok: false,
124+
status: 400,
125+
statusText: 'Bad Request',
126+
json: async () => ({ code: 1, message: 'The Pin image is broken' }),
127+
} as Response);
128+
129+
const ctx = {
130+
...fakeConnectContext({ PINTEREST_ACCESS_TOKEN: 'pinterest-token' }),
131+
dryRun: false,
132+
};
133+
134+
await expect(adapter.post(ctx as any, {
135+
title: 'Broken image',
136+
body: 'Body',
137+
media: [{ file: 'https://cdn.example.com/broken.jpg', kind: 'image' }],
138+
}, {
139+
boardId: 'board_123',
140+
})).rejects.toThrow('The Pin image is broken');
141+
});
142+
});

packages/social/pinterest/src/index.ts

Lines changed: 95 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
1-
import { defineSocial, oauthSetup } from '@profullstack/sh1pt-core';
1+
import { defineSocial, oauthSetup, type MediaAttachment, type SocialPost } from '@profullstack/sh1pt-core';
22

33
// Pinterest API v5. OAuth 2.0 with PKCE; pins live on boards owned by the
44
// authenticated user or business account.
55
interface Config {
66
boardId: string;
7+
boardSectionId?: string;
8+
isStandard?: boolean;
9+
videoMediaId?: string;
10+
coverImageUrl?: string;
11+
}
12+
13+
interface PinterestPinResponse {
14+
id?: string;
15+
created_at?: string;
16+
code?: number;
17+
message?: string;
718
}
819

920
export default defineSocial<Config>({
@@ -20,10 +31,30 @@ export default defineSocial<Config>({
2031
if (!post.media?.length) {
2132
throw new Error('Pinterest requires at least one image or video');
2233
}
34+
const token = ctx.secret('PINTEREST_ACCESS_TOKEN');
35+
if (!token) throw new Error('PINTEREST_ACCESS_TOKEN not in vault');
2336
ctx.log(`pinterest pin · board=${config.boardId} · media=${post.media.length}`);
2437
if (ctx.dryRun) return { id: 'dry-run', url: 'https://pinterest.com/', platform: 'pinterest', publishedAt: new Date().toISOString() };
25-
// TODO: POST /v5/pins with { board_id, media_source: { source_type: 'image_url'|'video_id', url } }
26-
return { id: `pin_${Date.now()}`, url: 'https://www.pinterest.com/', platform: 'pinterest', publishedAt: new Date().toISOString() };
38+
39+
const res = await fetch('https://api.pinterest.com/v5/pins', {
40+
method: 'POST',
41+
headers: {
42+
authorization: `Bearer ${token}`,
43+
accept: 'application/json',
44+
'content-type': 'application/json',
45+
},
46+
body: JSON.stringify(formatPinterestPin(post, config)),
47+
});
48+
const data = await readPinterestResponse(res);
49+
if (!res.ok) throw new Error(data.message ?? res.statusText);
50+
if (!data.id) throw new Error('Pinterest create Pin response did not include a Pin id');
51+
52+
return {
53+
id: data.id,
54+
url: `https://www.pinterest.com/pin/${data.id}/`,
55+
platform: 'pinterest',
56+
publishedAt: pinterestTimestamp(data.created_at),
57+
};
2758
},
2859

2960
setup: oauthSetup({
@@ -49,3 +80,64 @@ export default defineSocial<Config>({
4980
: {}),
5081
}),
5182
});
83+
84+
function formatPinterestPin(post: SocialPost, config: Config): Record<string, unknown> {
85+
const media = firstSupportedMedia(post.media ?? [], config);
86+
return {
87+
board_id: config.boardId,
88+
board_section_id: config.boardSectionId,
89+
title: post.title,
90+
description: formatDescription(post),
91+
link: post.link,
92+
alt_text: media.kind === 'image' ? media.alt : undefined,
93+
media_source: mediaSource(media, config),
94+
};
95+
}
96+
97+
function firstSupportedMedia(media: MediaAttachment[], config: Config): MediaAttachment {
98+
const selected = media.find((item) => item.kind === 'image' || item.kind === 'video');
99+
if (!selected) throw new Error('Pinterest requires an image or video media attachment');
100+
if (selected.kind === 'video' && (!config.videoMediaId || !config.coverImageUrl)) {
101+
throw new Error('Pinterest video Pins require config.videoMediaId and config.coverImageUrl from the media upload flow');
102+
}
103+
return selected;
104+
}
105+
106+
function mediaSource(media: MediaAttachment, config: Config): Record<string, unknown> {
107+
if (media.kind === 'video') {
108+
return {
109+
source_type: 'video_id',
110+
media_id: config.videoMediaId,
111+
cover_image_url: config.coverImageUrl,
112+
};
113+
}
114+
115+
if (!/^https?:\/\//.test(media.file)) {
116+
throw new Error('Pinterest image Pins require media.file to be an http(s) image URL');
117+
}
118+
return {
119+
source_type: 'image_url',
120+
url: media.file,
121+
is_standard: config.isStandard ?? true,
122+
};
123+
}
124+
125+
function formatDescription(post: SocialPost): string {
126+
const hashtags = (post.hashtags ?? []).slice(0, 20).map((tag) => `#${tag}`).join(' ');
127+
const description = hashtags ? `${post.body} ${hashtags}` : post.body;
128+
return description.slice(0, 500);
129+
}
130+
131+
async function readPinterestResponse(res: Response): Promise<PinterestPinResponse> {
132+
try {
133+
return await res.json() as PinterestPinResponse;
134+
} catch {
135+
return { message: res.statusText };
136+
}
137+
}
138+
139+
function pinterestTimestamp(value: string | undefined): string {
140+
if (!value) return new Date().toISOString();
141+
const hasTimezone = /(?:Z|[+-]\d{2}:\d{2})$/.test(value);
142+
return new Date(hasTimezone ? value : `${value}Z`).toISOString();
143+
}

0 commit comments

Comments
 (0)