srs-api/src/routes/ingest.test.ts
Aswin Biju 3640b2d785 feat(whatsapp): read slip photos and add them to the order
Shops can now send handwritten slips as WhatsApp photos. srs-api reads them
through OpenRouter in the background. The model only proposes text: the parser
decides what is an entry, and nothing is saved without the user's Save.
2026-11-04 13:46:50 +05:30

353 lines
14 KiB
TypeScript

import type { FastifyInstance } from 'fastify';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createApp } from '../app';
import type { Config } from '../config';
import type { Db } from '../db';
import { testConfig } from '../testing/config';
import { makeDb, seedFixture, type Fixture } from '../testing/helpers';
import type { Bridge } from '../whatsapp/bridge';
import { createSendQueue, type SendQueue } from '../whatsapp/sendQueue';
const SECRET = 'test-secret-0123456789';
const config: Config = testConfig({ srsSecret: SECRET });
let db: Db;
let f: Fixture;
let app: FastifyInstance;
let bridgeStatus: string | null;
let queue: SendQueue;
let sent: Array<{ chatId: string; message: string }>;
let sendFails: boolean;
let logs: Array<Record<string, unknown>>;
let clock: Date;
const fakeBridge: Bridge = {
async sendText(chatId, message) {
if (sendFails) throw new Error('bridge /send failed with HTTP 500');
sent.push({ chatId, message });
return { messageIds: [] };
},
async health() {
return bridgeStatus ? { status: bridgeStatus, queueLength: 0 } : null;
},
};
beforeEach(async () => {
db = makeDb();
f = seedFixture(db);
bridgeStatus = 'connected';
sent = [];
logs = [];
sendFails = false;
clock = new Date('2026-11-03T05:00:00Z'); // 10:30 IST
queue = createSendQueue(fakeBridge, (e) => logs.push(e));
app = createApp({ db, config, bridge: fakeBridge, sendQueue: queue, now: () => clock });
await app.ready();
});
afterEach(async () => {
await app.close();
db.close();
});
const jid = (phone: string) => `${phone}@s.whatsapp.net`;
function ingest(body: unknown, secret: string | null = SECRET) {
return app.inject({
method: 'POST',
url: '/ingest',
headers: { 'content-type': 'application/json', ...(secret ? { 'x-srs-secret': secret } : {}) },
payload: typeof body === 'string' ? body : JSON.stringify(body),
});
}
const text = (id: string, phone: string, t: string, extra: object = {}) => ({
kind: 'text',
message_id: id,
sender_jid: jid(phone),
chat_id: '145483561476218@lid',
text: t,
...extra,
});
const conversations = () => db.prepare('SELECT * FROM conversations').all() as Array<Record<string, unknown>>;
const logRows = () => db.prepare('SELECT * FROM ingest_log ORDER BY id').all() as Array<Record<string, unknown>>;
describe('POST /ingest: access', () => {
it('rejects a missing or wrong secret', async () => {
expect((await ingest(text('m1', f.admin.phone, '0888'), null)).statusCode).toBe(401);
expect((await ingest(text('m1', f.admin.phone, '0888'), 'wrong-secret-value-123')).statusCode).toBe(401);
expect(logRows()).toHaveLength(0);
});
it('checks the secret before reading the body', async () => {
const res = await ingest('{not json', null);
expect(res.statusCode).toBe(401);
});
it('rejects a malformed body', async () => {
const res = await ingest({ kind: 'text', message_id: 'm1' });
expect(res.statusCode).toBe(400);
expect(res.json<{ error: string }>().error).toBe('invalid_payload');
});
it('rejects broken JSON from an authorised caller', async () => {
const res = await ingest('{not json');
expect(res.statusCode).toBe(400);
expect(res.json()).toEqual({ error: 'invalid_json' });
});
});
describe('POST /ingest: who gets in', () => {
it('ignores an unknown number completely', async () => {
const res = await ingest(text('m1', '919111111111', '0888x2'));
expect(res.json()).toEqual({ handled: false });
expect(conversations()).toHaveLength(0);
const [row] = logRows();
expect(row?.outcome).toBe('ignored_unknown');
});
it('ignores a sender whose id hides the phone number (@lid)', async () => {
const res = await ingest({ ...text('m1', '919000000001', '0888'), sender_jid: '145483561476218@lid' });
expect(res.json()).toEqual({ handled: false });
expect(conversations()).toHaveLength(0);
});
it('ignores a disabled user', async () => {
const res = await ingest(text('m1', f.disabledUser.phone, '0888'));
expect(res.json()).toEqual({ handled: false });
expect(conversations()).toHaveLength(0);
expect(logRows()[0]?.outcome).toBe('ignored_inactive');
});
it('accepts a known user and starts collecting', async () => {
const res = await ingest(text('m1', f.shopUser.phone, '0888x2'));
expect(res.json()).toEqual({ handled: true, duplicate: false });
const [c] = conversations();
expect(c).toMatchObject({ user_id: f.shopUser.id, state: 'COLLECTING', buffer_text: '0888x2' });
expect(c?.chat_id).toBe('145483561476218@lid');
});
it('accepts a sender id that carries a device suffix', async () => {
// WhatsApp can send ids like 919000000001:7@s.whatsapp.net
const res = await ingest({ ...text('m1', f.admin.phone, '1111'), sender_jid: `${f.admin.phone}:7@s.whatsapp.net` });
expect(res.json()).toEqual({ handled: true, duplicate: false });
});
});
describe('POST /ingest: collecting text', () => {
it('adds later messages from the same user on new lines', async () => {
await ingest(text('m1', f.shopUser.phone, '0888x2'));
await ingest(text('m2', f.shopUser.phone, '3472'));
expect(conversations()[0]?.buffer_text).toBe('0888x2\n3472');
});
it('keeps long text byte for byte', async () => {
const long = Array.from({ length: 100 }, (_, i) => `${1000 + i}*${(i % 9) + 1}`).join('\n');
expect(long).toHaveLength(699);
await ingest(text('m1', f.shopUser.phone, long));
expect(conversations()[0]?.buffer_text).toBe(long);
});
it('keeps different users apart', async () => {
await ingest(text('m1', f.shopUser.phone, 'AAA1'));
await ingest(text('m2', f.admin.phone, 'BBB2'));
const byUser = Object.fromEntries(conversations().map((c) => [c.user_id as number, c.buffer_text]));
expect(byUser[f.shopUser.id]).toBe('AAA1');
expect(byUser[f.admin.id]).toBe('BBB2');
});
it('applies a repeated message id only once', async () => {
await ingest(text('m1', f.shopUser.phone, '0888x2'));
const again = await ingest(text('m1', f.shopUser.phone, '0888x2'));
expect(again.json()).toEqual({ handled: true, duplicate: true });
expect(conversations()[0]?.buffer_text).toBe('0888x2');
expect(logRows()).toHaveLength(1);
});
it('treats text as an answer, not order text, while the bot is waiting for one', async () => {
await ingest(text('m1', f.shopUser.phone, '0888x2'));
db.prepare("UPDATE conversations SET state = 'AWAITING_SHOP'").run();
await ingest(text('m2', f.shopUser.phone, '2'));
const [c] = conversations();
expect(c).toMatchObject({ state: 'AWAITING_SHOP', buffer_text: '0888x2' });
});
});
describe('POST /ingest: replies', () => {
const chat = '145483561476218@lid';
const say = async (id: string, phone: string, t: string) => {
const res = await ingest(text(id, phone, t));
await queue.idle();
return res;
};
it('does not reply to order text at once, and answers ? in the chat the message came from', async () => {
await say('m1', f.shopUser.phone, '8910=3');
expect(sent).toEqual([]);
await say('m2', f.shopUser.phone, '?');
expect(sent).toEqual([{ chatId: chat, message: '✅ *Got it*\n1 line, 3 tickets so far.\n\n*When the order is finished, reply:*\n1. Done\n2. Cancel' }]);
});
it('plays the whole dialog through /ingest and saves the order', async () => {
await say('m1', f.shopUser.phone, '8910=3');
await say('m2', f.shopUser.phone, '7810=1');
await say('m3', f.shopUser.phone, 'done');
expect(sent.at(-1)!.message).toContain('*Shop:* Shop A\n*Draw:* TODAY 3 Nov');
await say('m4', f.shopUser.phone, '1');
expect(sent.at(-1)!.message).toBe('✅ *Order #1 saved*\n\n*Shop:* Shop A\n*Tickets:* 4\n*Draw:* 3 Nov');
expect(sent.map((s) => s.chatId).every((c) => c === chat)).toBe(true);
expect(db.prepare('SELECT COUNT(*) AS n FROM order_lines').get()).toEqual({ n: 2 });
});
it('sends several replies of one message in order', async () => {
// cancelling while a late message is held answers: cancelled, then starting the next order
await say('m1', f.shopUser.phone, '8910=3');
db.prepare("UPDATE conversations SET reminded_at = '2026-11-03T05:05:00Z'").run();
await say('m2', f.shopUser.phone, '0838=2');
sent.length = 0;
await say('m3', f.shopUser.phone, 'cancel');
expect(sent.map((s) => s.message)).toEqual([
'❌ *Cancelled*\nNothing was saved.',
'Starting your next order with the message you sent.',
]);
});
it('sends nothing for a duplicate message id', async () => {
await say('m1', f.shopUser.phone, '8910=3');
sent.length = 0;
const again = await say('m1', f.shopUser.phone, '8910=3');
expect(again.json()).toEqual({ handled: true, duplicate: true });
expect(sent).toEqual([]);
});
it('sends nothing to unknown, @lid and inactive senders', async () => {
await say('m1', '919111111111', '8910=3');
await ingest({ ...text('m2', f.admin.phone, '8910=3'), sender_jid: '145483561476218@lid' });
await say('m3', f.disabledUser.phone, '8910=3');
expect(sent).toEqual([]);
});
it('sends nothing for chatter from a known user with no order in progress', async () => {
await say('m1', f.shopUser.phone, 'thanks');
expect(sent).toEqual([]);
});
it('a failing bridge does not fail the request or lose the state', async () => {
await say('m1', f.shopUser.phone, '8910=3');
sendFails = true;
const res = await say('m2', f.shopUser.phone, '?');
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ handled: true, duplicate: false });
expect(sent).toEqual([]);
expect(db.prepare('SELECT state FROM conversations').get()).toEqual({ state: 'COLLECTING' });
expect(logs).toHaveLength(1);
expect(JSON.stringify(logs[0])).not.toContain('8910');
});
it('after a failed send, ? gives the user the last prompt again', async () => {
await say('m1', f.shopUser.phone, '8910=3');
sendFails = true;
await say('m2', f.shopUser.phone, '?');
sendFails = false;
await say('m3', f.shopUser.phone, '?');
expect(sent.map((s) => s.message)).toEqual(['✅ *Got it*\n1 line, 3 tickets so far.\n\n*When the order is finished, reply:*\n1. Done\n2. Cancel']);
});
it('does not wait for the bridge before answering', async () => {
let release!: () => void;
const gate = new Promise<void>((r) => (release = r));
const slow: Bridge = { ...fakeBridge, sendText: async () => (await gate, { messageIds: [] }) };
const slowApp = createApp({ db, config, bridge: slow, sendQueue: createSendQueue(slow, () => {}), now: () => clock });
await slowApp.ready();
const res = await slowApp.inject({
method: 'POST',
url: '/ingest',
headers: { 'content-type': 'application/json', 'x-srs-secret': SECRET },
payload: JSON.stringify(text('m1', f.shopUser.phone, 'done')),
});
expect(res.statusCode).toBe(200); // answered while the send is still blocked
release();
await slowApp.close();
});
it('a retry after a crash rolls back with the transaction and replies once', async () => {
db.exec(`CREATE TRIGGER boom BEFORE INSERT ON conversations BEGIN SELECT RAISE(ABORT, 'boom'); END;`);
const failed = await ingest(text('m1', f.shopUser.phone, '8910=3'));
expect(failed.statusCode).toBe(500);
expect(db.prepare('SELECT COUNT(*) AS n FROM ingest_log').get()).toEqual({ n: 0 });
db.exec('DROP TRIGGER boom');
await say('m1', f.shopUser.phone, '8910=3');
expect(db.prepare('SELECT COUNT(*) AS n FROM ingest_log').get()).toEqual({ n: 1 });
expect(db.prepare('SELECT state, buffer_text, update_pending FROM conversations').all()).toEqual([
{ state: 'COLLECTING', buffer_text: '8910=3', update_pending: 1 },
]);
});
});
describe('GET /api/health and /internal/health', () => {
it('reports ok on the public route without details', async () => {
const res = await app.inject({ method: 'GET', url: '/api/health' });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ ok: true });
});
it('needs the secret for the internal route', async () => {
expect((await app.inject({ method: 'GET', url: '/internal/health' })).statusCode).toBe(401);
});
it('reports WhatsApp connected or not', async () => {
const get = () => app.inject({ method: 'GET', url: '/internal/health', headers: { 'x-srs-secret': SECRET } });
const good = await get();
expect(good.statusCode).toBe(200);
expect(good.json()).toMatchObject({ ok: true, whatsapp: 'connected' });
bridgeStatus = 'disconnected';
const bad = await get();
expect(bad.statusCode).toBe(503);
expect(bad.json()).toMatchObject({ ok: false, whatsapp: 'disconnected' });
bridgeStatus = null;
expect((await get()).json()).toMatchObject({ ok: false, whatsapp: 'unreachable' });
});
});
describe('unknown routes and odd requests', () => {
it('answers 404 as JSON', async () => {
const res = await app.inject({ method: 'GET', url: '/nope' });
expect(res.statusCode).toBe(404);
expect(res.json()).toEqual({ error: 'not_found' });
});
it('refuses a body above the size limit', async () => {
const huge = JSON.stringify({ kind: 'text', message_id: 'x', sender_jid: 'y', chat_id: 'z', text: 'a'.repeat(13 * 1024 * 1024) });
const res = await ingest(huge);
expect(res.statusCode).toBe(413);
expect(res.json()).toEqual({ error: 'payload_too_large' });
});
it('refuses plain text sent instead of JSON', async () => {
const res = await app.inject({
method: 'POST',
url: '/ingest',
headers: { 'content-type': 'text/plain', 'x-srs-secret': SECRET },
payload: 'hello',
});
expect(res.statusCode).toBe(400);
expect(res.json<{ error: string }>().error).toBe('invalid_payload');
});
it('refuses a content type it cannot read', async () => {
const res = await app.inject({
method: 'POST',
url: '/ingest',
headers: { 'content-type': 'application/xml', 'x-srs-secret': SECRET },
payload: '<a/>',
});
expect(res.statusCode).toBe(415);
expect(res.json()).toEqual({ error: 'unsupported_media_type' });
});
});