Files
selfphoneng/apps/api/test/ftc-sync.test.ts
T
iamdoubz 49d529e185 api: download the FTC complaint list
SD3 (14 §2.2): spamFtcSync reads the FTC's Do Not Call complaints a day
at a time, 15 pages a minute, saving its place; failures are kept for
Settings → Spam check and retried the next minute. Download now starts
one. Old complaints are dropped after windowDays.

Follow-up: one real download with the owner's api.data.gov key.
2026-10-27 16:40:14 -05:00

178 lines
6.8 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
// SD3: the FTC complaint download (14 §2.2) against a fake api.ftc.gov with three days of data.
import { randomBytes } from 'node:crypto';
import { eq, sql } from 'drizzle-orm';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { SPAM_DEFAULTS } from '@selfphoneng/shared';
import { encrypt, setSecretKey } from '../src/crypto/secrets.ts';
import type { Db } from '../src/db/client.ts';
import { ftcComplaints, settings } from '../src/db/schema.ts';
import { ftcSync } from '../src/spam/ftc.ts';
import { getSetting, setSetting } from '../src/services/settings.ts';
import { createTestDb, hasTestDb } from './db.ts';
const KEY = 'datagov-secret-key-456';
const NOW = new Date('2026-10-27T18:00:00Z'); // 14:00 in Washington
const hour = 3_600_000;
const at = (day: string, i: number) =>
`${day} ${String(Math.floor(i / 60)).padStart(2, '0')}:${String(i % 60).padStart(2, '0')}:00`;
const ftc = (id: string, phone: string, created: string, robo = 'Y') => ({
type: 'dnc_complaint',
id,
attributes: {
'company-phone-number': phone,
'created-date': created,
subject: 'Warranties',
'recorded-message-or-robocall': robo,
},
});
/** 120 complaints on the 25th (three pages), 3 on the 26th, 3 today (one usable). */
const DATA = [
...Array.from({ length: 120 }, (_, i) => ftc(`a${i}`, '3125550100', at('2026-10-25', i))),
...[0, 1, 2].map((i) => ftc(`b${i}`, '3125550101', at('2026-10-26', i), 'N')),
ftc('c0', '3125550102', '2026-10-27 09:00:00', ''),
ftc('c1', '', '2026-10-27 09:01:00'), // no number
ftc('c2', '3125550102', '2026-10-27'), // no time
];
describe.skipIf(!hasTestDb)('FTC complaint download', () => {
let db: Db;
let drop: () => Promise<void>;
/** Each request's day and offset. */
const asked: string[] = [];
let fail: { at: number; status: number } | null = null;
const turnOn = (keyEnc = encrypt(KEY)) =>
setSetting(db, 'spam', {
...SPAM_DEFAULTS,
enabled: true,
ftc: { keyEnc, syncHours: 24, windowDays: 7 },
});
const stored = async () => (await db.select().from(ftcComplaints)).length;
const reset = async () => {
await db.delete(ftcComplaints);
await db.delete(settings).where(eq(settings.key, 'spam.state'));
};
beforeAll(async () => {
({ db, drop } = await createTestDb());
setSecretKey(randomBytes(32).toString('base64'));
});
afterAll(() => drop?.());
beforeEach(() => {
asked.length = 0;
fail = null;
vi.stubGlobal('fetch', async (url: string, init?: RequestInit) => {
const json = (j: unknown, status = 200) => new Response(JSON.stringify(j), { status });
if ((init?.headers as Record<string, string>)['X-Api-Key'] !== KEY) return json({}, 403);
const q = new URL(url).searchParams;
const day = /^"(\d{4}-\d{2}-\d{2}) 00:00:00"$/.exec(q.get('created_date_from')!)![1]!;
expect(q.get('created_date_to')).toBe(`"${day} 23:59:59"`);
expect(q.get('sort_order')).toBe('ASC');
const offset = Number(q.get('offset'));
asked.push(`${day}+${offset}`);
if (fail && asked.length === fail.at) return json({}, fail.status);
const all = DATA.filter((c) => c.attributes['created-date'].startsWith(day));
return json({ data: all.slice(offset, offset + 50), meta: { 'record-total': all.length } });
});
});
afterEach(async () => {
vi.unstubAllGlobals();
await reset();
});
it('downloads nothing while spam check or the FTC list is off', async () => {
await setSetting(db, 'spam', SPAM_DEFAULTS);
expect(await ftcSync(db, { now: NOW })).toBeNull();
await setSetting(db, 'spam', { ...SPAM_DEFAULTS, enabled: true });
expect(await ftcSync(db, { now: NOW })).toBeNull();
expect(asked).toEqual([]);
});
it('the first download fills the window a day at a time and drops old complaints', async () => {
await turnOn();
await db.insert(ftcComplaints).values({
id: 'old',
number: '13125550109',
reportedAt: sql`${NOW.toISOString()}::timestamptz - interval '8 days'`,
});
expect(await ftcSync(db, { now: NOW, budget: 100 })).toBeNull();
expect(asked).toEqual([
'2026-10-20+0',
'2026-10-21+0',
'2026-10-22+0',
'2026-10-23+0',
'2026-10-24+0',
'2026-10-25+0',
'2026-10-25+50',
'2026-10-25+100',
'2026-10-26+0',
'2026-10-27+0',
]);
expect(await stored()).toBe(124);
const row = async (id: string) =>
(await db.select().from(ftcComplaints).where(eq(ftcComplaints.id, id)))[0];
expect(await row('b0')).toMatchObject({
number: '13125550101',
subject: 'Warranties',
robocall: false,
reportedAt: new Date('2026-10-26T04:00:00Z'), // midnight in Washington
});
expect((await row('c0'))?.robocall).toBeNull();
expect(await getSetting(db, 'spam.state')).toMatchObject({
ftcCursor: null,
ftcLastSyncAt: NOW.toISOString(),
ftcLastError: null,
});
});
it('not again until syncHours; then from the newest day kept, less one', async () => {
await turnOn();
await ftcSync(db, { now: NOW, budget: 100 });
asked.length = 0;
await ftcSync(db, { now: new Date(NOW.getTime() + 23 * hour) });
expect(asked).toEqual([]);
await ftcSync(db, { now: new Date(NOW.getTime() + 24 * hour) });
expect(asked).toEqual(['2026-10-26+0', '2026-10-27+0', '2026-10-28+0']);
});
it('15 pages a minute, the place saved; a 429 stops there and the next minute carries on', async () => {
await turnOn();
await ftcSync(db, { now: NOW });
expect(asked).toHaveLength(10);
await reset();
asked.length = 0;
await ftcSync(db, { now: NOW, budget: 2 });
expect((await getSetting(db, 'spam.state')).ftcCursor).toEqual({
day: '2026-10-22',
offset: 0,
});
fail = { at: 7, status: 429 }; // the 25th's second page
const said = 'api.data.gov says the key is over its hourly limit';
expect(await ftcSync(db, { now: NOW })).toBe(said);
expect(await getSetting(db, 'spam.state')).toMatchObject({
ftcCursor: { day: '2026-10-25', offset: 50 },
ftcLastError: said,
ftcLastSyncAt: null,
});
fail = { at: 8, status: 429 }; // the same failure isn't returned (logged) twice
expect(await ftcSync(db, { now: NOW })).toBeNull();
fail = null;
expect(await ftcSync(db, { now: NOW })).toBeNull();
expect(await stored()).toBe(124);
expect(await getSetting(db, 'spam.state')).toMatchObject({
ftcCursor: null,
ftcLastError: null,
});
});
it('a refused key is saved for the Settings tab', async () => {
await turnOn(encrypt('wrong'));
expect(await ftcSync(db, { now: NOW })).toBe('api.data.gov refused the key');
expect((await getSetting(db, 'spam.state')).ftcLastError).toBe('api.data.gov refused the key');
});
});