import { afterAll, beforeEach, describe, expect, test } from 'bun:test'; import { chmodSync, mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { testEvent, type WatchEvent } from '../cli/lib/watch'; import { ingestBody, INGEST_EVENTS, resetIngestLimit, sendEvent, sinkConfig, slugOf, type FetchLike } from '../cli/lib/watch-sinks'; const TOKEN = 'sekret-token-123'; const dir = mkdtempSync(join(tmpdir(), 'sb-ingest-')); const tokenFile = join(dir, 'token'); writeFileSync(tokenFile, ` ${TOKEN}\n`); chmodSync(tokenFile, 0o600); const ev = (o: Partial = {}): WatchEvent => ({ ...testEvent('2026-10-22T10:00:00.000Z'), app: 'Demo App!', version: '1.2.0', kind: 'rejected', detail: 'App Store 1.2.0: review ended in REJECTED', url: 'https://appstoreconnect.apple.com/apps/1', ...o }); let status = 202, replyText = 'ok'; const hits: { headers: Headers; body: string }[] = []; const server = Bun.serve({ port: 0, async fetch(req) { hits.push({ headers: req.headers, body: await req.text() }); return new Response(replyText, { status }); } }); afterAll(() => server.stop(true)); beforeEach(() => { hits.length = 0; status = 202; replyText = 'ok'; resetIngestLimit(); }); const cfg = (o: Record = {}) => ({ ingest: { url: `http://localhost:${server.port}/api/notifications/ingest`, tokenFile, category: 'storebound', ...o } }); const logs = (rep: unknown): string => JSON.stringify(rep); describe('watch ingest sink', () => { test('config: watch.json and defaults; needs url and tokenFile', () => { const f = join(dir, 'watch.json'); writeFileSync(f, JSON.stringify({ ingest: { url: 'https://push.example.com/api/notifications/ingest', tokenFile: '~/.ingest-token' } })); expect(sinkConfig({}, f).ingest).toEqual({ url: 'https://push.example.com/api/notifications/ingest', tokenFile: '~/.ingest-token', category: 'storebound' }); writeFileSync(f, JSON.stringify({ ingest: { url: 'https://push.example.com/x' } })); expect(sinkConfig({}, f).ingest).toBeUndefined(); expect(sinkConfig({}, f, ['ingest'])).toEqual({}); }); test('headers and the exact body fields', async () => { const rep = await sendEvent(ev(), cfg(), { retryMs: 1 }); expect(rep).toEqual([{ sink: 'ingest', ok: true }]); expect(hits).toHaveLength(1); expect(hits[0]!.headers.get('authorization')).toBe(`Bearer ${TOKEN}`); expect(hits[0]!.headers.get('content-type')).toBe('application/json'); expect(JSON.parse(hits[0]!.body)).toEqual({ category: 'storebound', event: 'review-rejected', app: 'demo-app', platform: 'ios', version: '1.2.0', detail: 'App Store 1.2.0: review ended in REJECTED', url: 'https://appstoreconnect.apple.com/apps/1', dedupeKey: 'demo-app:review-rejected:1.2.0', }); }); test('custom category; version omitted when null; crash-free detail', async () => { await sendEvent(ev({ kind: 'crash_threshold', store: 'play', version: null, to: '2.90 %', detail: 'x' }), cfg({ category: 'apps' }), { retryMs: 1 }); expect(JSON.parse(hits[0]!.body)).toEqual({ category: 'apps', event: 'crash-threshold', app: 'demo-app', platform: 'android', detail: 'crash-free 97.1%', url: 'https://appstoreconnect.apple.com/apps/1', dedupeKey: 'demo-app:crash-threshold:' }); }); test('mapping of every event kind; review events add the review id to the dedupe key', () => { expect(INGEST_EVENTS).toEqual({ rejected: 'review-rejected', approved: 'review-approved', live: 'live', rollout_halted: 'rollout-halted', crash_threshold: 'crash-threshold', review_low: 'low-reviews', review_needs_reply: 'review-reply' }); const low = ingestBody(ev({ kind: 'review_low', version: null, to: '2 stars', detail: '2-star review: meh', ref: 'r-9' }), { category: 'c' })!; expect(low).toMatchObject({ event: 'low-reviews', detail: '2 stars', dedupeKey: 'demo-app:low-reviews::r-9' }); expect(ingestBody(ev({ kind: 'review_needs_reply', ref: 'r-1', to: '4 stars' }), { category: 'c' })).toMatchObject({ event: 'review-reply', dedupeKey: 'demo-app:review-reply:1.2.0:r-1' }); expect(ingestBody(ev({ kind: 'bogus' as never }), { category: 'c' })).toBeNull(); }); test('an unmapped event is skipped: nothing sent, no report row', async () => { expect(await sendEvent(ev({ kind: 'bogus' as never }), cfg(), { retryMs: 1 })).toEqual([]); expect(hits).toHaveLength(0); }); test('slug', () => { expect(slugOf('My Cool_App 2!')).toBe('my-cool-app-2'); expect(slugOf('Café')).toBe('caf'); }); test('detail is cut to 300 chars and the body stays under 8 KiB', () => { const b = ingestBody(ev({ detail: 'x'.repeat(5000), url: 'https://example.com/' + 'u'.repeat(7600) }), { category: 'c' })!; expect(b.detail!.length).toBeLessThanOrEqual(300); expect(Buffer.byteLength(JSON.stringify(b))).toBeLessThanOrEqual(8192); expect(ingestBody(ev({ detail: 'y'.repeat(900) }), { category: 'c' })!.detail).toHaveLength(300); }); test('4xx: no retry; status and server message reported, token not', async () => { status = 400; replyText = `unknown field; token ${TOKEN}`; const rep = await sendEvent(ev(), cfg(), { retryMs: 1 }); expect(hits).toHaveLength(1); expect(rep[0]).toMatchObject({ sink: 'ingest', ok: false }); expect(rep[0]!.error).toContain('HTTP 400: unknown field'); expect(logs(rep)).not.toContain(TOKEN); }); test('5xx: retried once, then reported', async () => { status = 503; const rep = await sendEvent(ev(), cfg(), { retryMs: 1 }); expect(hits).toHaveLength(2); expect(rep[0]).toMatchObject({ ok: false }); expect(rep[0]!.error).toContain('HTTP 503'); }); test('a network error is retried once, then succeeds', async () => { let n = 0; const flaky: FetchLike = async () => { n++; if (n === 1) throw new Error('down'); return { ok: true, status: 202 }; }; expect(await sendEvent(ev(), cfg(), { fetch: flaky, retryMs: 1 })).toEqual([{ sink: 'ingest', ok: true }]); expect(n).toBe(2); }); test('missing or empty token file: warning naming the file, nothing sent, no token', async () => { const missing = join(dir, 'nope'); const rep = await sendEvent(ev(), cfg({ tokenFile: missing }), { retryMs: 1 }); expect(hits).toHaveLength(0); expect(rep[0]).toMatchObject({ sink: 'ingest', ok: false }); expect(rep[0]!.error).toContain(missing); const empty = join(dir, 'empty'); writeFileSync(empty, '\n'); chmodSync(empty, 0o600); expect((await sendEvent(ev(), cfg({ tokenFile: empty }), { retryMs: 1 }))[0]!.error).toContain('empty'); expect(hits).toHaveLength(0); }); test.skipIf(process.platform === 'win32')('a group- or world-readable token file is refused', async () => { const open = join(dir, 'open'); writeFileSync(open, TOKEN); chmodSync(open, 0o644); const rep = await sendEvent(ev(), cfg({ tokenFile: open }), { retryMs: 1 }); expect(hits).toHaveLength(0); expect(rep[0]!.error).toContain(open); expect(logs(rep)).not.toContain(TOKEN); }); test('client-side rate limit: 60 a minute, the rest dropped with a warning', async () => { const ok: FetchLike = async () => ({ ok: true, status: 202 }); let dropped = 0, sent = 0; for (let i = 0; i < 62; i++) { const r = (await sendEvent(ev(), cfg(), { fetch: ok, retryMs: 1 }))[0]!; if (r.ok) sent++; else { dropped++; expect(r.error).toContain('rate limit'); } } expect([sent, dropped]).toEqual([60, 2]); }); test('the test event reaches the ingest sink and the token never appears in the report', async () => { const rep = await sendEvent(testEvent(), cfg(), { retryMs: 1 }); expect(rep).toEqual([{ sink: 'ingest', ok: true }]); expect(JSON.parse(hits[0]!.body).event).toBe('live'); expect(logs(rep)).not.toContain(TOKEN); }); });