watch: ingest sink (--notify ingest) posting events to a push ingest endpoint

This commit is contained in:
CRTkafa 2026-10-23 15:30:18 +08:00
parent 5b3905679b
commit 487e138f9d
9 changed files with 236 additions and 18 deletions

122
tests/watch-ingest.test.ts Normal file
View file

@ -0,0 +1,122 @@
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> = {}): 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<string, string> = {}) => ({ 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);
});
});

View file

@ -86,7 +86,7 @@ describe('watch diff', () => {
...diffApp(snap({ reviews: { play: [] } }), snap({ reviews: { play: [{ id: 'x', rating: 1, replied: false, date: AT, text: 't' }, { id: 'y', rating: 5, replied: false, date: AT, text: 't' }] } }), AT),
];
expect([...new Set(all.map(e => e.kind))].sort()).toEqual(['approved', 'crash_threshold', 'live', 'rejected', 'review_low', 'review_needs_reply', 'rollout_halted']);
for (const e of all) { expect(Object.keys(e).sort()).toEqual(['app', 'at', 'detail', 'from', 'kind', 'store', 'to', 'url', 'version']); expect(e.url).toMatch(/^https:\/\//); }
for (const e of all) { expect(Object.keys(e).filter(k => k !== 'ref').sort()).toEqual(['app', 'at', 'detail', 'from', 'kind', 'store', 'to', 'url', 'version']); expect('ref' in e).toBe(e.kind.startsWith('review_')); expect(e.url).toMatch(/^https:\/\//); }
});
});