diff --git a/packages/social/stackernews/src/index.test.ts b/packages/social/stackernews/src/index.test.ts index e7c78aef..fd8a27ad 100644 --- a/packages/social/stackernews/src/index.test.ts +++ b/packages/social/stackernews/src/index.test.ts @@ -1,4 +1,131 @@ -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: { territory: 'meta' }, + samplePost: { title: 'Hello Stacker News', body: 'hello from sh1pt contract tests' }, + requiredSecrets: ['STACKERNEWS_COOKIE'], +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('social-stackernews GraphQL posting', () => { + it('creates a link post with the Stacker News upsertLink mutation', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + data: { + upsertLink: { + id: 55, + createdAt: '2026-05-21T00:00:00.000Z', + item: { + id: 123, + title: 'Release shipped', + url: 'https://sh1pt.com', + createdAt: '2026-05-21T00:01:00.000Z', + }, + }, + }, + }), + } as any); + + const ctx = { + ...fakeConnectContext({ STACKERNEWS_COOKIE: 'next-auth.session-token=test' }), + dryRun: false, + }; + + const result = await adapter.post(ctx as any, { + title: 'Release shipped', + body: 'Article body', + link: 'https://sh1pt.com', + hashtags: ['bitcoin', 'shipping', 'automation', 'ignored'], + }, { + territory: 'bitcoin', + apiUrl: 'https://stacker.test/api/graphql', + }); + + expect(result).toEqual({ + id: '123', + url: 'https://stacker.news/items/123', + platform: 'stackernews', + publishedAt: '2026-05-21T00:01:00.000Z', + }); + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://stacker.test/api/graphql'); + expect(init.method).toBe('POST'); + expect(init.headers).toMatchObject({ + 'content-type': 'application/json', + cookie: 'next-auth.session-token=test', + }); + + const body = JSON.parse(String(init.body)); + expect(body.query).toContain('upsertLink'); + expect(body.variables).toEqual({ + subNames: ['bitcoin'], + title: 'Release shipped', + url: 'https://sh1pt.com', + text: 'Article body\n\n#bitcoin #shipping #automation', + }); + }); + + it('creates a discussion post when no link is present', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + data: { + upsertDiscussion: { + id: 56, + createdAt: '2026-05-21T00:00:00.000Z', + item: { id: 124, title: 'Ask SN', createdAt: '2026-05-21T00:02:00.000Z' }, + }, + }, + }), + } as any); + + const ctx = { + ...fakeConnectContext({ STACKERNEWS_COOKIE: 'next-auth.session-token=test' }), + dryRun: false, + }; + + await expect(adapter.post(ctx as any, { + title: 'Ask SN', + body: 'What should sh1pt support next?', + }, { territory: 'meta', apiUrl: 'https://stacker.test/api/graphql' })).resolves.toMatchObject({ + id: '124', + url: 'https://stacker.news/items/124', + }); + + const body = JSON.parse(String((fetchMock.mock.calls[0] as [string, RequestInit])[1].body)); + expect(body.query).toContain('upsertDiscussion'); + expect(body.variables).toMatchObject({ + subNames: ['meta'], + title: 'Ask SN', + text: 'What should sh1pt support next?', + }); + }); + + it('throws GraphQL error messages', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ errors: [{ message: 'insufficient sats' }] }), + } as any); + + const ctx = { + ...fakeConnectContext({ STACKERNEWS_COOKIE: 'next-auth.session-token=test' }), + dryRun: false, + }; + + await expect(adapter.post(ctx as any, { + title: 'Release shipped', + body: 'Article body', + link: 'https://sh1pt.com', + }, { apiUrl: 'https://stacker.test/api/graphql' })).rejects.toThrow('insufficient sats'); + }); +}); diff --git a/packages/social/stackernews/src/index.ts b/packages/social/stackernews/src/index.ts index aad495dc..5e36b2da 100644 --- a/packages/social/stackernews/src/index.ts +++ b/packages/social/stackernews/src/index.ts @@ -1,30 +1,49 @@ -import { defineSocial, tokenSetup } from '@profullstack/sh1pt-core'; +import { defineSocial, tokenSetup, type SocialPost } from '@profullstack/sh1pt-core'; // Stacker News (stacker.news) — Bitcoin Lightning-themed HN alternative. // Submissions cost satoshis to post (anti-spam). GraphQL API at // stacker.news/api/graphql. Auth via cookie-based login or nostr // NIP-46 — browser-mode is the current practical path. interface Config { - mode: 'browser'; + mode?: 'api' | 'browser'; territory?: string; // e.g. 'bitcoin', 'ai', 'meta' + cookieKey?: string; + apiUrl?: string; } +const DEFAULT_API = 'https://stacker.news/api/graphql'; +const DEFAULT_COOKIE_KEY = 'STACKERNEWS_COOKIE'; + export default defineSocial({ id: 'social-stackernews', label: 'Stacker News', requires: { maxBodyChars: 20_000, maxHashtags: 3 }, async connect(ctx) { - if (!ctx.secret('STACKERNEWS_COOKIE')) { - throw new Error('STACKERNEWS_COOKIE not in vault (export cookie from logged-in browser)'); + if (!ctx.secret(DEFAULT_COOKIE_KEY)) { + throw new Error(`${DEFAULT_COOKIE_KEY} not in vault (export cookie from logged-in browser)`); } return { accountId: 'stackernews' }; }, async post(ctx, post, config) { + if (!post.title) throw new Error('Stacker News requires a title'); + const cookieKey = config.cookieKey ?? DEFAULT_COOKIE_KEY; + const cookie = ctx.secret(cookieKey); + if (!cookie) throw new Error(`${cookieKey} not in vault (export cookie from logged-in browser)`); + ctx.log(`stacker news · territory=${config.territory ?? 'meta'} · note: submissions cost sats`); if (ctx.dryRun) return { id: 'dry-run', url: 'https://stacker.news/', platform: 'stackernews', publishedAt: new Date().toISOString() }; - // TODO: POST to /api/graphql with createItem mutation — note users must - // have satoshi balance on their SN account before calling. - return { id: `sn_${Date.now()}`, url: 'https://stacker.news/', platform: 'stackernews', publishedAt: new Date().toISOString() }; + + const payIn = post.link + ? await upsertLink(ctx, post, config, cookie) + : await upsertDiscussion(ctx, post, config, cookie); + const item = payIn.item; + const id = String(item?.id ?? payIn.id); + return { + id, + url: item?.id ? `https://stacker.news/items/${item.id}` : 'https://stacker.news/', + platform: 'stackernews', + publishedAt: new Date(item?.createdAt ?? payIn.createdAt ?? Date.now()).toISOString(), + }; }, setup: tokenSetup({ @@ -38,3 +57,106 @@ export default defineSocial({ ], }), }); + +async function upsertLink( + ctx: { log(m: string): void }, + post: SocialPost, + config: Config, + cookie: string, +): Promise { + const data = await stackerGraphql<{ upsertLink: StackerPayIn }>(config, cookie, { + query: ` + mutation UpsertStackerNewsLink($subNames: [String!], $title: String!, $url: String!, $text: String) { + upsertLink(subNames: $subNames, title: $title, url: $url, text: $text) { + id + createdAt + item { id title url createdAt } + } + } + `, + variables: { + subNames: [config.territory ?? 'meta'], + title: post.title, + url: post.link, + text: formatText(post), + }, + }); + ctx.log(`stacker news link submitted · payIn=${data.upsertLink.id}`); + return data.upsertLink; +} + +async function upsertDiscussion( + ctx: { log(m: string): void }, + post: SocialPost, + config: Config, + cookie: string, +): Promise { + const data = await stackerGraphql<{ upsertDiscussion: StackerPayIn }>(config, cookie, { + query: ` + mutation UpsertStackerNewsDiscussion($subNames: [String!], $title: String!, $text: String) { + upsertDiscussion(subNames: $subNames, title: $title, text: $text) { + id + createdAt + item { id title createdAt } + } + } + `, + variables: { + subNames: [config.territory ?? 'meta'], + title: post.title, + text: formatText(post), + }, + }); + ctx.log(`stacker news discussion submitted · payIn=${data.upsertDiscussion.id}`); + return data.upsertDiscussion; +} + +async function stackerGraphql( + config: Config, + cookie: string, + body: { query: string; variables: Record }, +): Promise { + const res = await fetch(config.apiUrl ?? DEFAULT_API, { + method: 'POST', + headers: { + 'content-type': 'application/json', + cookie, + }, + body: JSON.stringify(body), + }); + const payload = await readStackerResponse(res); + if (!res.ok || payload.errors?.length) { + throw new Error(payload.errors?.map((err) => err.message).join('; ') || `Stacker News API error ${res.status}`); + } + if (!payload.data) throw new Error('Stacker News API response did not include data'); + return payload.data; +} + +async function readStackerResponse(res: Response): Promise> { + try { + return await res.json() as StackerGraphqlResponse; + } catch { + return { errors: [{ message: res.statusText }] }; + } +} + +function formatText(post: SocialPost): string { + const tags = (post.hashtags ?? []).slice(0, 3).map((tag) => `#${tag}`).join(' '); + return [post.body, tags].filter(Boolean).join('\n\n').slice(0, 20_000); +} + +interface StackerGraphqlResponse { + data?: T; + errors?: Array<{ message: string }>; +} + +interface StackerPayIn { + id: number | string; + createdAt?: string; + item?: { + id: number | string; + title?: string; + url?: string; + createdAt?: string; + } | null; +}