import { describe, expect, it } from "vitest"; import type { ConnectorConnection } from "@tactical-media-broadcast-hub/core"; import { CapabilityNotSupportedError, InMemoryCredentials } from "@tactical-media-broadcast-hub/connector-sdk"; import { connectorContractTests } from "@tactical-media-broadcast-hub/connector-sdk/testing"; import { AuthorizationRedirectRequired, createMastodonConnector, type MastodonAuthorizationRequest, } from "./index.js"; /** * Stubbed Mastodon instance: canned JSON per endpoint, real request/response * shapes. Every call is recorded so tests can assert on what was sent. */ interface StubCall { method: string; url: URL; init: RequestInit | undefined; } interface StubState { calls: StubCall[]; nextStatusId: number; /** Idempotency-Key → status id, like the instance's one-hour dedupe cache. */ idempotency?: Map; /** Statuses actually created (side effects, independent of responses). */ created?: string[]; } const INSTANCE = "https://mastodon.example"; const HOST = "mastodon.example"; const TOKEN = "stub-access-token"; const REDIRECT = "https://app.example.com/oauth/mastodon/callback"; const ACCOUNT = { id: "109000000000000001", username: "anarchoposter", acct: "anarchoposter", display_name: "AnarchoPoster", avatar: `${INSTANCE}/avatars/original/missing.png`, url: `${INSTANCE}/@anarchoposter`, }; const OTHER = { id: "109000000000000002", username: "alice", acct: "alice@other.example", display_name: "Alice", avatar: "", url: "https://other.example/@alice", }; const connection: ConnectorConnection = { id: "conn-mastodon", workspaceId: "ws-1", provider: "mastodon", channelId: "conn-mastodon", credentialId: { kind: "credential-ref", id: "cred-1" }, fields: { instanceUrl: HOST }, createdAt: new Date(), }; function status( id: string, overrides: Partial> = {}, ): Record { return { id, created_at: "2026-10-06T12:00:00.000Z", url: `${INSTANCE}/@tactical-media-broadcast-hub/${id}`, uri: `${INSTANCE}/users/anarchoposter/statuses/${id}`, content: "

hello

", visibility: "public", account: ACCOUNT, media_attachments: [], mentions: [], reblogs_count: 0, replies_count: 0, favourites_count: 0, in_reply_to_id: null, reblog: null, ...overrides, }; } function json(body: unknown, init: ResponseInit = {}): Response { return new Response(JSON.stringify(body), { status: 200, ...init, headers: { "content-type": "application/json", ...init.headers }, }); } function jsonBody(init: RequestInit | undefined): Record { return JSON.parse(String(init?.body ?? "{}")) as Record; } function formBody(init: RequestInit | undefined): URLSearchParams { return new URLSearchParams(String(init?.body ?? "")); } function headerOf(init: RequestInit | undefined, name: string): string | undefined { return new Headers(init?.headers).get(name) ?? undefined; } function stubInstance(state: StubState): typeof fetch { return async (input: Parameters[0], init?: RequestInit): Promise => { const raw = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; const url = new URL(raw); const method = (init?.method ?? "GET").toUpperCase(); state.calls.push({ method, url, init }); const route = `${method} ${url.pathname}`; if (route === "POST /api/v1/apps") { return json({ id: "563419", name: formBody(init).get("client_name"), client_id: "stub-client-id", client_secret: "stub-client-secret", redirect_uri: formBody(init).get("redirect_uris"), }); } if (route === "POST /oauth/token") { const form = formBody(init); if (form.get("code") !== "good-code") { return json({ error: "invalid_grant", error_description: "The provided authorization grant is invalid" }, { status: 400 }); } return json({ access_token: TOKEN, token_type: "Bearer", scope: "read write:statuses write:media", created_at: 1_760_000_000 }); } // Everything below is an authenticated API call. if (headerOf(init, "authorization") !== `Bearer ${TOKEN}`) { return json({ error: "The access token is invalid" }, { status: 401 }); } if (route === "GET /api/v1/accounts/verify_credentials") return json(ACCOUNT); if (route === "POST /api/v1/statuses") { const body = jsonBody(init); const text = String(body["status"]); if (text === "rate-limit-me") return json({ error: "Too many requests" }, { status: 429, headers: { "retry-after": "30" } }); if (text === "server-error-me") return json({ error: "Internal" }, { status: 500 }); if (text === "invalid-me") return json({ error: "Validation failed: Text can't be blank" }, { status: 422 }); const key = headerOf(init, "idempotency-key"); const seen = key === undefined ? undefined : state.idempotency?.get(key); if (seen !== undefined) return json(status(seen, { content: `

${text}

` })); const id = String((state.nextStatusId += 1)); if (key !== undefined) state.idempotency?.set(key, id); state.created?.push(id); if (typeof body["scheduled_at"] === "string") { return json({ id: `s${id}`, scheduled_at: body["scheduled_at"], params: { text: body["status"] }, media_attachments: [] }); } return json( status(id, { content: `

${String(body["status"])}

`, visibility: body["visibility"] ?? "public", ...(typeof body["quoted_status_id"] === "string" ? { quote: { state: "accepted", quoted_status: status(body["quoted_status_id"], { account: OTHER }) } } : {}), }), ); } if (route === "GET /api/v1/timelines/home") { return json( [ status("300", { account: OTHER, content: "

first & foremost
line two

", media_attachments: [ { id: "m1", type: "image", url: "https://files.example/m1.png", preview_url: null }, ], reblogs_count: 2, replies_count: 1, favourites_count: 5, }), status("299", { reblog: status("250", { account: OTHER, content: "

boosted

" }) }), ], { headers: { link: `<${INSTANCE}/api/v1/timelines/home?max_id=299>; rel="next", <${INSTANCE}/api/v1/timelines/home?min_id=300>; rel="prev"` } }, ); } const reblog = /^POST \/api\/v1\/statuses\/([^/]+)\/reblog$/.exec(route); if (reblog?.[1] !== undefined) { const original = status(decodeURIComponent(reblog[1]), { account: OTHER }); return json(status(String((state.nextStatusId += 1)), { reblog: original, content: "" })); } return json({ error: `stub: no route for ${route}` }, { status: 404 }); }; } interface Harness { state: StubState; connector: ReturnType; credentials: InMemoryCredentials; } async function createHarness(seedToken = true): Promise { const state: StubState = { calls: [], nextStatusId: 1000, idempotency: new Map(), created: [] }; const credentials = new InMemoryCredentials(); if (seedToken) await credentials.put(connection, "access_token", TOKEN); const connector = createMastodonConnector({ fetchImpl: stubInstance(state), requestsPerSecond: 1000, sleep: async () => Promise.resolve(), credentials, }); return { state, connector, credentials }; } const leg1: MastodonAuthorizationRequest = { redirectUri: REDIRECT, workspaceId: "ws-1", state: "csrf-1", fields: { instanceUrl: HOST }, }; const post = (text: string) => ({ text, mediaAssetIds: [], visibility: "public" as const, providerMetadata: {}, }); connectorContractTests((await createHarness()).connector, { connection }); describe("mastodon connector: authorize + identity", () => { it("leg 1 registers an app on the instance and redirects to its consent page", async () => { const { state, connector } = await createHarness(false); const error = await connector .authorize(leg1) .then( () => undefined, (e: unknown) => e, ); expect(error).toBeInstanceOf(AuthorizationRedirectRequired); const redirect = error as AuthorizationRedirectRequired; const consent = new URL(redirect.authorizationUrl); expect(consent.origin + consent.pathname).toBe(`${INSTANCE}/oauth/authorize`); expect(consent.searchParams.get("client_id")).toBe("stub-client-id"); expect(consent.searchParams.get("redirect_uri")).toBe(REDIRECT); expect(consent.searchParams.get("state")).toBe("csrf-1"); expect(redirect.connectionId).toBe(`mastodon:ws-1:${HOST}`); const app = state.calls.find((c) => c.url.pathname === "/api/v1/apps"); expect(app?.method).toBe("POST"); expect(formBody(app?.init).get("scopes")).toBe("read write:statuses write:media"); }); it("leg 2 exchanges the code and the identity roundtrips through getIdentity", async () => { const { state, connector } = await createHarness(false); await expect(connector.authorize(leg1)).rejects.toBeInstanceOf(AuthorizationRedirectRequired); const leg2: MastodonAuthorizationRequest = { ...leg1, providerMetadata: { code: "good-code" } }; const result = await connector.authorize(leg2); const token = state.calls.find((c) => c.url.pathname === "/oauth/token"); const form = formBody(token?.init); expect(form.get("grant_type")).toBe("authorization_code"); expect(form.get("client_id")).toBe("stub-client-id"); expect(form.get("client_secret")).toBe("stub-client-secret"); // Client registered once in leg 1, reused from the vault in leg 2. expect(state.calls.filter((c) => c.url.pathname === "/api/v1/apps")).toHaveLength(1); expect(result.connection.fields.instanceUrl).toBe(INSTANCE); expect(result.identity).toEqual({ provider: "mastodon", remoteId: ACCOUNT.id, handle: `@anarchoposter@${HOST}`, displayName: "AnarchoPoster", avatarUrl: ACCOUNT.avatar, }); expect(await connector.getIdentity(result.connection)).toEqual(result.identity); }); it("maps a rejected code to a ConnectorError with the instance's description", async () => { const { connector } = await createHarness(false); const request: MastodonAuthorizationRequest = { ...leg1, fields: { instanceUrl: HOST, clientId: "cid", clientSecret: "csecret" }, providerMetadata: { code: "bad-code" }, }; await expect(connector.authorize(request)).rejects.toThrow(/authorization grant is invalid/); }); it("getIdentity sends the bearer token and caches verify_credentials", async () => { const { state, connector } = await createHarness(); const first = await connector.getIdentity(connection); const second = await connector.getIdentity(connection); expect(second).toEqual(first); const calls = state.calls.filter((c) => c.url.pathname === "/api/v1/accounts/verify_credentials"); expect(calls).toHaveLength(1); expect(headerOf(calls[0]?.init, "authorization")).toBe(`Bearer ${TOKEN}`); }); }); describe("mastodon connector: publish", () => { it("returns a Publication carrying the status id and url", async () => { const { state, connector } = await createHarness(); const publication = await connector.publish?.(connection, post("hello fediverse")); const call = state.calls.find((c) => c.url.pathname === "/api/v1/statuses"); expect(call?.method).toBe("POST"); expect(jsonBody(call?.init)).toEqual({ status: "hello fediverse", visibility: "public" }); expect(publication?.remoteId).toBe("1001"); expect(publication?.remoteUrl).toBe(`${INSTANCE}/@tactical-media-broadcast-hub/1001`); expect(publication?.workspaceId).toBe("ws-1"); expect(publication?.connectionId).toBe("conn-mastodon"); expect(publication?.draftId).toBe("direct"); expect(publication?.publishedAt).toEqual(new Date("2026-10-06T12:00:00.000Z")); expect(publication?.providerMetadata).toMatchObject({ visibility: "public" }); }); it("surfaces a far-future schedule as provider_scheduled with a scheduled: remote id", async () => { const { connector } = await createHarness(); const scheduledAt = new Date(Date.now() + 60 * 60 * 1000); const publication = await connector.publish?.(connection, { ...post("later"), scheduledAt, providerMetadata: { draftId: "draft-from-user" }, }); expect(publication?.remoteId).toBe("scheduled:s1001"); expect(publication?.publishedAt).toEqual(scheduledAt); expect(publication?.providerMetadata).toMatchObject({ outcome: "provider_scheduled", scheduled: true, scheduledStatusId: "s1001", }); // Caller metadata never becomes the publication's draft identity. expect(publication?.draftId).toBe("direct"); }); it("sends the injected idempotency key and replays to the same status", async () => { const { state, connector } = await createHarness(); const key = "wp3:ws-1:draft-1:a1:publish"; const withKey = { ...post("once"), providerMetadata: { idempotencyKey: key, draftId: "draft-1" } }; const first = await connector.publish?.(connection, withKey); const replay = await connector.publish?.(connection, withKey); const posts = state.calls.filter((c) => c.method === "POST" && c.url.pathname === "/api/v1/statuses"); expect(posts.map((c) => headerOf(c.init, "idempotency-key"))).toEqual([key, key]); expect(replay?.remoteId).toBe(first?.remoteId); expect(state.created).toEqual(["1001"]); expect(first?.providerMetadata).toMatchObject({ outcome: "published", idempotencyKey: key }); expect(first?.draftId).toBe("direct"); }); it("never resends a status POST on 5xx and reports unknown delivery", async () => { const { state, connector } = await createHarness(); await expect(connector.publish?.(connection, post("server-error-me"))).rejects.toMatchObject({ name: "ProviderServerError", status: 500, delivery: "unknown", }); expect(state.calls.filter((c) => c.url.pathname === "/api/v1/statuses")).toHaveLength(1); }); it("surfaces a status POST 429 as a single-call RateLimitError with Retry-After", async () => { const { state, connector } = await createHarness(); await expect(connector.publish?.(connection, post("rate-limit-me"))).rejects.toMatchObject({ name: "RateLimitError", retryAfterMs: 30_000, delivery: "rejected", }); expect(state.calls.filter((c) => c.url.pathname === "/api/v1/statuses")).toHaveLength(1); }); it("maps a validation 422 to ProviderRejectedError", async () => { const { connector } = await createHarness(); await expect(connector.publish?.(connection, post("invalid-me"))).rejects.toMatchObject({ name: "ProviderRejectedError", status: 422, delivery: "rejected", }); }); it("maps a 401 to AuthError", async () => { const { connector, credentials } = await createHarness(); await credentials.put(connection, "access_token", "revoked"); await expect(connector.publish?.(connection, post("x"))).rejects.toMatchObject({ name: "AuthError" }); }); }); describe("mastodon connector: feed", () => { it("home returns a Page with plain text, unwrapped reblogs and a Link cursor", async () => { const { state, connector } = await createHarness(); const page = await connector.getFeed?.(connection, { kind: "home", limit: 20, cursor: "400" }); const call = state.calls.find((c) => c.url.pathname === "/api/v1/timelines/home"); expect(call?.url.searchParams.get("limit")).toBe("20"); expect(call?.url.searchParams.get("max_id")).toBe("400"); expect(page?.items).toHaveLength(2); expect(page?.nextCursor).toBe("299"); const [first, boosted] = page?.items ?? []; expect(first).toEqual({ id: "300", provider: "mastodon", remoteId: "300", authorHandle: "alice@other.example", text: "first & foremost\nline two", mediaUrls: ["https://files.example/m1.png"], createdAt: new Date("2026-10-06T12:00:00.000Z"), metrics: { reblogs: 2, replies: 1, favourites: 5 }, }); // The wrapper keeps its own id; content comes from the reblogged original. expect(boosted?.id).toBe("299"); expect(boosted?.remoteId).toBe("250"); expect(boosted?.text).toBe("boosted"); }); it("refuses search queries that are not a single hashtag", async () => { const { connector } = await createHarness(); await expect( connector.getFeed?.(connection, { kind: "search", query: "two words", limit: 10 }), ).rejects.toThrow(/single hashtag/); }); }); describe("mastodon connector: amplify", () => { it("repost reblogs the target and returns a Publication", async () => { const { state, connector } = await createHarness(); const publication = await connector.amplify?.(connection, { kind: "repost", targetRemoteId: "250" }); const call = state.calls.find((c) => c.url.pathname === "/api/v1/statuses/250/reblog"); expect(call?.method).toBe("POST"); expect(publication?.remoteId).toBe("1001"); expect(publication?.draftId).toBe("amplify:repost"); expect(publication?.remoteUrl).toBe(`${INSTANCE}/@tactical-media-broadcast-hub/1001`); expect(publication?.providerMetadata).toMatchObject({ amplifies: "250", kind: "repost" }); }); it("quote posts with quoted_status_id on a 4.5+ instance", async () => { const { state, connector } = await createHarness(); const publication = await connector.amplify?.(connection, { kind: "quote", targetRemoteId: "250", commentary: "worth a read", }); const call = state.calls.find((c) => c.url.pathname === "/api/v1/statuses"); expect(jsonBody(call?.init)).toEqual({ status: "worth a read", quoted_status_id: "250" }); expect(publication?.providerMetadata).toMatchObject({ kind: "quote", quoteState: "accepted" }); }); it("share is not a Mastodon amplification kind", async () => { const { connector } = await createHarness(); await expect( connector.amplify?.(connection, { kind: "share", targetRemoteId: "250" }), ).rejects.toBeInstanceOf(CapabilityNotSupportedError); }); });