diff --git a/packages/social/mastodon/src/index.test.ts b/packages/social/mastodon/src/index.test.ts index e7c78aef..89438b80 100644 --- a/packages/social/mastodon/src/index.test.ts +++ b/packages/social/mastodon/src/index.test.ts @@ -1,4 +1,86 @@ -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: { instance: 'mastodon.social', visibility: 'unlisted' }, + samplePost: { title: 'Ignored title', body: 'hello from sh1pt contract tests' }, + requiredSecrets: ['MASTODON_TOKEN_MASTODON_SOCIAL'], +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('social-mastodon posting', () => { + it('creates a Mastodon status with token auth and visibility', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + id: '109246', + url: 'https://mastodon.social/@sh1pt/109246', + created_at: '2026-05-11T20:00:00Z', + }), + } as Response); + + const ctx = { + ...fakeConnectContext({ MASTODON_TOKEN_MASTODON_SOCIAL: 'mastodon-token' }), + dryRun: false, + }; + + const result = await adapter.post(ctx as any, { + body: 'Release notes', + hashtags: ['sh1pt', 'typescript'], + link: 'https://sh1pt.com', + }, { instance: 'https://mastodon.social/', visibility: 'unlisted' }); + + expect(result).toEqual({ + id: '109246', + url: 'https://mastodon.social/@sh1pt/109246', + platform: 'mastodon', + publishedAt: '2026-05-11T20:00:00.000Z', + }); + const [url, init] = fetchMock.mock.calls[0]!; + expect(url).toBe('https://mastodon.social/api/v1/statuses'); + expect((init as RequestInit).method).toBe('POST'); + expect((init as RequestInit).headers).toMatchObject({ + authorization: 'Bearer mastodon-token', + 'content-type': 'application/json', + }); + expect(JSON.parse(String((init as RequestInit).body))).toEqual({ + status: 'Release notes\n\nhttps://sh1pt.com #sh1pt #typescript', + visibility: 'unlisted', + }); + }); + + it('throws Mastodon API error messages when status creation fails', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: false, + status: 422, + statusText: 'Unprocessable Entity', + text: async () => JSON.stringify({ error: 'Validation failed: Text character limit of 500 exceeded' }), + } as Response); + + const ctx = { + ...fakeConnectContext({ MASTODON_TOKEN_MASTODON_SOCIAL: 'mastodon-token' }), + dryRun: false, + }; + + await expect(adapter.post(ctx as any, { + body: 'Release notes', + }, { instance: 'mastodon.social' })).rejects.toThrow('Text character limit'); + }); + + it('does not silently drop media attachments', async () => { + const ctx = { + ...fakeConnectContext({ MASTODON_TOKEN_MASTODON_SOCIAL: 'mastodon-token' }), + dryRun: false, + }; + + await expect(adapter.post(ctx as any, { + body: 'Release notes', + media: [{ file: './image.png', kind: 'image' }], + }, { instance: 'mastodon.social' })).rejects.toThrow('media uploads'); + }); +}); diff --git a/packages/social/mastodon/src/index.ts b/packages/social/mastodon/src/index.ts index 9398173f..ade443a2 100644 --- a/packages/social/mastodon/src/index.ts +++ b/packages/social/mastodon/src/index.ts @@ -1,4 +1,4 @@ -import { defineSocial, oauthSetup } from '@profullstack/sh1pt-core'; +import { defineSocial, oauthSetup, type SocialPost } from '@profullstack/sh1pt-core'; // Mastodon — federated. Each instance is its own server; same API. // POST /api/v1/statuses with access token scoped to 'write:statuses'. @@ -12,16 +12,42 @@ export default defineSocial({ label: 'Mastodon (Fediverse)', requires: { maxBodyChars: 500, maxHashtags: 20, hashtagsInBody: true }, async connect(ctx, config) { - if (!ctx.secret(`MASTODON_TOKEN_${config.instance.replace(/\./g, '_').toUpperCase()}`)) { - throw new Error(`Mastodon token for ${config.instance} not in vault`); + const instance = normalizeInstance(config.instance); + if (!ctx.secret(secretKeyForInstance(instance))) { + throw new Error(`Mastodon token for ${instance} not in vault`); } - return { accountId: config.instance }; + return { accountId: instance }; }, async post(ctx, post, config) { - ctx.log(`mastodon post · ${config.instance} · ${post.body.length} chars`); - if (ctx.dryRun) return { id: 'dry-run', url: `https://${config.instance}/`, platform: 'mastodon', publishedAt: new Date().toISOString() }; - // TODO: POST https://${instance}/api/v1/statuses with { status, visibility, media_ids } - return { id: `mst_${Date.now()}`, url: `https://${config.instance}/`, platform: 'mastodon', publishedAt: new Date().toISOString() }; + if (post.media?.length) throw new Error('Mastodon media uploads are not implemented yet'); + const instance = normalizeInstance(config.instance); + const token = ctx.secret(secretKeyForInstance(instance)); + if (!token) throw new Error(`Mastodon token for ${instance} not in vault`); + const status = formatMastodonStatus(post); + ctx.log(`mastodon post · ${instance} · ${status.length} chars`); + if (ctx.dryRun) return { id: 'dry-run', url: `https://${instance}/`, platform: 'mastodon', publishedAt: new Date().toISOString() }; + + const res = await fetch(`https://${instance}/api/v1/statuses`, { + method: 'POST', + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + status, + visibility: config.visibility ?? 'public', + }), + }); + if (!res.ok) throw new Error(await readMastodonError(res)); + + const toot = await res.json() as MastodonStatus; + if (!toot.id) throw new Error('Mastodon status response did not include an id'); + return { + id: toot.id, + url: toot.url ?? toot.uri ?? `https://${instance}/`, + platform: 'mastodon', + publishedAt: new Date(toot.created_at ?? Date.now()).toISOString(), + }; }, setup: oauthSetup({ @@ -29,9 +55,42 @@ export default defineSocial({ label: "Mastodon", vendorDocUrl: "https://docs.joinmastodon.org/client/token/", steps: [ - "Open your Mastodon instance \u2192 Preferences \u2192 Development \u2192 New Application", + "Open your Mastodon instance -> Preferences -> Development -> New Application", "Scopes: write:statuses write:media read:accounts", "Copy the access token shown after creation", ], }), }); + +interface MastodonStatus { + id?: string; + url?: string; + uri?: string; + created_at?: string; +} + +function formatMastodonStatus(post: SocialPost): string { + const body = post.link ? `${post.body}\n\n${post.link}` : post.body; + const tags = (post.hashtags ?? []).slice(0, 20).map((tag) => `#${tag}`).join(' '); + const status = tags ? `${body} ${tags}` : body; + return status.length > 500 ? `${status.slice(0, 497)}...` : status; +} + +function normalizeInstance(instance: string): string { + return instance.replace(/^https?:\/\//, '').replace(/\/+$/, ''); +} + +function secretKeyForInstance(instance: string): string { + return `MASTODON_TOKEN_${instance.replace(/\./g, '_').toUpperCase()}`; +} + +async function readMastodonError(res: Response): Promise { + const text = await res.text().catch(() => ''); + if (!text) return res.statusText; + try { + const data = JSON.parse(text) as { error?: string }; + return data.error ?? text; + } catch { + return text; + } +}