diff --git a/packages/social/tumblr/src/index.test.ts b/packages/social/tumblr/src/index.test.ts index e7c78aef..ef3666d7 100644 --- a/packages/social/tumblr/src/index.test.ts +++ b/packages/social/tumblr/src/index.test.ts @@ -1,4 +1,97 @@ -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: { blogIdentifier: 'sh1pt.tumblr.com' }, + samplePost: { title: 'Hello Tumblr', body: 'hello from sh1pt contract tests' }, + requiredSecrets: ['TUMBLR_ACCESS_TOKEN'], +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('social-tumblr posting', () => { + it('creates an NPF post with Tumblr OAuth2 bearer auth', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 201, + json: async () => ({ + meta: { status: 201, msg: 'Created' }, + response: { id: '1234567891234567' }, + }), + } as Response); + + const scheduled = new Date('2026-05-20T12:00:00.000Z'); + const ctx = { + ...fakeConnectContext({ TUMBLR_ACCESS_TOKEN: 'tumblr-token' }), + dryRun: false, + }; + + const result = await adapter.post(ctx as any, { + title: 'Release notes', + body: 'First paragraph.\n\nSecond paragraph.', + hashtags: ['ship', 'typescript', 'automation'], + link: 'https://sh1pt.com', + schedule: scheduled, + }, { + blogIdentifier: 'sh1pt.tumblr.com', + state: 'draft', + slug: 'release-notes', + sourceUrl: 'https://example.com/source', + }); + + expect(result).toEqual({ + id: '1234567891234567', + url: 'https://sh1pt.tumblr.com/post/1234567891234567', + platform: 'tumblr', + publishedAt: '2026-05-20T12:00:00.000Z', + }); + const [url, init] = fetchMock.mock.calls[0]!; + expect(url).toBe('https://api.tumblr.com/v2/blog/sh1pt.tumblr.com/posts'); + expect((init as RequestInit).method).toBe('POST'); + expect((init as RequestInit).headers).toMatchObject({ + authorization: 'Bearer tumblr-token', + 'content-type': 'application/json', + 'user-agent': '@profullstack/sh1pt-social-tumblr', + }); + expect(JSON.parse(String((init as RequestInit).body))).toEqual({ + content: [ + { type: 'text', subtype: 'heading1', text: 'Release notes' }, + { type: 'text', text: 'First paragraph.' }, + { type: 'text', text: 'Second paragraph.' }, + { type: 'link', url: 'https://sh1pt.com' }, + ], + state: 'queue', + publish_on: '2026-05-20T12:00:00.000Z', + tags: 'ship,typescript,automation', + source_url: 'https://example.com/source', + slug: 'release-notes', + }); + }); + + it('surfaces Tumblr API errors', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: false, + status: 400, + statusText: 'Bad Request', + json: async () => ({ + meta: { status: 400, msg: 'Bad Request' }, + errors: [{ title: "'content' must be an array", code: 8001 }], + }), + } as Response); + + const ctx = { + ...fakeConnectContext({ TUMBLR_ACCESS_TOKEN: 'tumblr-token' }), + dryRun: false, + }; + + await expect(adapter.post(ctx as any, { + title: 'Release notes', + body: 'Body', + }, { + blogIdentifier: 'sh1pt.tumblr.com', + })).rejects.toThrow("'content' must be an array"); + }); +}); diff --git a/packages/social/tumblr/src/index.ts b/packages/social/tumblr/src/index.ts index a7a27cb7..d405ee9e 100644 --- a/packages/social/tumblr/src/index.ts +++ b/packages/social/tumblr/src/index.ts @@ -1,9 +1,28 @@ -import { defineSocial, oauthSetup } from '@profullstack/sh1pt-core'; +import { defineSocial, oauthSetup, type SocialPost } from '@profullstack/sh1pt-core'; // Tumblr API v2. OAuth 1.0a (legacy) or OAuth 2.0 via the newer endpoints. // Posts are typed (text / photo / video / link / quote / chat / audio). interface Config { blogIdentifier: string; + state?: 'published' | 'draft' | 'queue' | 'private'; + publishOn?: string; + slug?: string; + sourceUrl?: string; +} + +interface TumblrPostResponse { + meta?: { + status?: number; + msg?: string; + }; + response?: { + id?: string; + }; + errors?: Array<{ + title?: string; + detail?: string; + code?: number; + }>; } export default defineSocial({ @@ -17,10 +36,31 @@ export default defineSocial({ }, async post(ctx, post, config) { + const token = ctx.secret('TUMBLR_ACCESS_TOKEN'); + if (!token) throw new Error('TUMBLR_ACCESS_TOKEN not in vault'); ctx.log(`tumblr post · blog=${config.blogIdentifier} · ${post.body.length} chars`); if (ctx.dryRun) return { id: 'dry-run', url: 'https://tumblr.com/', platform: 'tumblr', publishedAt: new Date().toISOString() }; - // TODO: POST /v2/blog/{blog-identifier}/posts with NPF content blocks (text / image / video). - return { id: `tu_${Date.now()}`, url: `https://${config.blogIdentifier}.tumblr.com/`, platform: 'tumblr', publishedAt: new Date().toISOString() }; + + const res = await fetch(`https://api.tumblr.com/v2/blog/${encodeURIComponent(config.blogIdentifier)}/posts`, { + method: 'POST', + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + 'user-agent': '@profullstack/sh1pt-social-tumblr', + }, + body: JSON.stringify(formatTumblrPost(post, config)), + }); + const data = await readTumblrResponse(res); + if (!res.ok) throw new Error(tumblrErrorMessage(data, res.statusText)); + + const id = data.response?.id; + if (!id) throw new Error('Tumblr create post response did not include a post id'); + return { + id, + url: postUrl(config.blogIdentifier, id), + platform: 'tumblr', + publishedAt: (post.schedule ?? new Date()).toISOString(), + }; }, setup: oauthSetup({ @@ -44,3 +84,51 @@ export default defineSocial({ : {}), }), }); + +type TumblrContentBlock = + | { type: 'text'; text: string; subtype?: 'heading1' } + | { type: 'link'; url: string }; + +function formatTumblrPost(post: SocialPost, config: Config): Record { + const scheduled = post.schedule?.toISOString(); + return { + content: formatContent(post), + state: scheduled ? 'queue' : config.state, + publish_on: scheduled ?? config.publishOn, + tags: (post.hashtags ?? []).slice(0, 30).join(','), + source_url: config.sourceUrl, + slug: config.slug, + }; +} + +function formatContent(post: SocialPost): TumblrContentBlock[] { + const blocks: TumblrContentBlock[] = []; + if (post.title) blocks.push({ type: 'text', subtype: 'heading1', text: post.title }); + for (const text of post.body.split(/\n{2,}/).map((part) => part.trim()).filter(Boolean)) { + blocks.push({ type: 'text', text }); + } + if (post.link) blocks.push({ type: 'link', url: post.link }); + return blocks; +} + +async function readTumblrResponse(res: Response): Promise { + try { + return await res.json() as TumblrPostResponse; + } catch { + return { meta: { status: res.status, msg: res.statusText } }; + } +} + +function tumblrErrorMessage(data: TumblrPostResponse, fallback: string): string { + const firstError = data.errors?.[0]; + if (firstError?.detail) return firstError.detail; + if (firstError?.title) return firstError.title; + return data.meta?.msg ?? fallback; +} + +function postUrl(blogIdentifier: string, id: string): string { + const cleaned = blogIdentifier.replace(/^https?:\/\//, '').replace(/\/+$/, ''); + if (cleaned.startsWith('t:')) return 'https://www.tumblr.com/'; + if (cleaned.includes('.')) return `https://${cleaned}/post/${id}`; + return `https://www.tumblr.com/${cleaned}/${id}`; +}