diff --git a/packages/social/x/src/index.test.ts b/packages/social/x/src/index.test.ts index e7c78aef..5c1c3b41 100644 --- a/packages/social/x/src/index.test.ts +++ b/packages/social/x/src/index.test.ts @@ -1,4 +1,121 @@ -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: { mode: 'api' }, + samplePost: { body: 'hello from sh1pt contract tests' }, + requiredSecrets: ['X_BEARER_TOKEN'], +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('social-x API posting', () => { + it('creates a text Post through X API v2', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 201, + json: async () => ({ + data: { + id: '1445880548472328192', + text: 'Release shipped #ship', + }, + }), + } as Response); + + const ctx = { + ...fakeConnectContext({ X_BEARER_TOKEN: 'x-token' }), + dryRun: false, + }; + + const result = await adapter.post(ctx as any, { + body: 'Release shipped', + hashtags: ['ship'], + }, { + mode: 'api', + }); + + expect(result).toEqual({ + id: '1445880548472328192', + url: 'https://x.com/i/web/status/1445880548472328192', + platform: 'x', + publishedAt: expect.any(String), + }); + const [url, init] = fetchMock.mock.calls[0]!; + expect(url).toBe('https://api.x.com/2/tweets'); + expect((init as RequestInit).method).toBe('POST'); + expect((init as RequestInit).headers).toMatchObject({ + authorization: 'Bearer x-token', + 'content-type': 'application/json', + }); + expect(JSON.parse(String((init as RequestInit).body))).toEqual({ + text: 'Release shipped #ship', + }); + }); + + it('passes reply and pre-uploaded media ids to the API payload', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 201, + json: async () => ({ data: { id: '1445880548472328193', text: 'Photo' } }), + } as Response); + + const ctx = { + ...fakeConnectContext({ X_BEARER_TOKEN: 'x-token' }), + dryRun: false, + }; + + await adapter.post(ctx as any, { + body: 'Photo', + media: [{ file: '/tmp/photo.jpg', kind: 'image' }], + }, { + mode: 'api', + mediaIds: ['1234567890123456789'], + replyToPostId: '1111111111111111111', + }); + + expect(JSON.parse(String((fetchMock.mock.calls[0]?.[1] as RequestInit).body))).toEqual({ + text: 'Photo', + media: { media_ids: ['1234567890123456789'] }, + reply: { in_reply_to_tweet_id: '1111111111111111111' }, + }); + }); + + it('rejects media attachments without pre-uploaded media ids', async () => { + const ctx = { + ...fakeConnectContext({ X_BEARER_TOKEN: 'x-token' }), + dryRun: false, + }; + + await expect(adapter.post(ctx as any, { + body: 'Photo', + media: [{ file: '/tmp/photo.jpg', kind: 'image' }], + }, { + mode: 'api', + })).rejects.toThrow('pre-uploaded media IDs'); + }); + + it('surfaces X API errors', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + json: async () => ({ + errors: [{ title: 'Unauthorized', detail: 'Invalid or expired token' }], + }), + } as Response); + + const ctx = { + ...fakeConnectContext({ X_BEARER_TOKEN: 'x-token' }), + dryRun: false, + }; + + await expect(adapter.post(ctx as any, { + body: 'Release shipped', + }, { + mode: 'api', + })).rejects.toThrow('Invalid or expired token'); + }); +}); diff --git a/packages/social/x/src/index.ts b/packages/social/x/src/index.ts index 2177ccc7..35b5f574 100644 --- a/packages/social/x/src/index.ts +++ b/packages/social/x/src/index.ts @@ -8,6 +8,21 @@ interface Config { mode: 'api' | 'browser'; username?: string; // for browser mode captchaSolver?: 'captcha-2captcha' | 'captcha-solver'; + mediaIds?: string[]; + replyToPostId?: string; + quotePostId?: string; + apiBaseUrl?: string; +} + +interface XCreatePostResponse { + data?: { + id?: string; + text?: string; + }; + errors?: Array<{ + title?: string; + detail?: string; + }>; } export default defineSocial({ @@ -25,16 +40,41 @@ export default defineSocial({ return { accountId: config.username ?? 'x' }; }, - async post(ctx, post) { + async post(ctx, post, config) { const { body } = adaptPost(post, { id: 'social-x', label: 'X', requires: { maxBodyChars: 280, maxHashtags: 10, hashtagsInBody: true }, } as any); ctx.log(`x post · ${body.length} chars · media=${post.media?.length ?? 0}`); if (ctx.dryRun) return { id: 'dry-run', url: 'https://x.com/', platform: 'x', publishedAt: new Date().toISOString() }; - // TODO: - // api: POST /2/tweets with { text, media: { media_ids } } — upload media first via /1.1/media/upload - // browser: Playwright → compose tweet → attach media → publish - return { id: `x_${Date.now()}`, url: 'https://x.com/', platform: 'x', publishedAt: new Date().toISOString() }; + + if (config.mode !== 'api') { + throw new Error('social-x browser mode is not implemented yet; use mode=api with X_BEARER_TOKEN'); + } + const token = ctx.secret('X_BEARER_TOKEN'); + if (!token) throw new Error('X_BEARER_TOKEN not in vault — `sh1pt secret set X_BEARER_TOKEN`'); + if (post.media?.length && !config.mediaIds?.length) { + throw new Error('X media posts require pre-uploaded media IDs in config.mediaIds'); + } + + const res = await fetch(`${config.apiBaseUrl ?? 'https://api.x.com'}/2/tweets`, { + method: 'POST', + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + }, + body: JSON.stringify(formatXPost(body, config)), + }); + const data = await readXResponse(res); + if (!res.ok) throw new Error(xErrorMessage(data, res.statusText)); + const id = data.data?.id; + if (!id) throw new Error('X create Post response did not include a Post id'); + + return { + id, + url: `https://x.com/i/web/status/${id}`, + platform: 'x', + publishedAt: new Date().toISOString(), + }; }, // Browser-mode by default \u2014 the v2 free tier blocks posting and the paid @@ -55,3 +95,25 @@ export default defineSocial({ ], }), }); + +function formatXPost(text: string, config: Config): Record { + return { + text, + media: config.mediaIds?.length ? { media_ids: config.mediaIds } : undefined, + reply: config.replyToPostId ? { in_reply_to_tweet_id: config.replyToPostId } : undefined, + quote_tweet_id: config.quotePostId, + }; +} + +async function readXResponse(res: Response): Promise { + try { + return await res.json() as XCreatePostResponse; + } catch { + return { errors: [{ detail: res.statusText }] }; + } +} + +function xErrorMessage(data: XCreatePostResponse, fallback: string): string { + const firstError = data.errors?.[0]; + return firstError?.detail ?? firstError?.title ?? fallback; +}