122 lines
7.7 KiB
TypeScript
122 lines
7.7 KiB
TypeScript
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);
|
|
});
|
|
});
|