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>; 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>; const logRows = () => db.prepare('SELECT * FROM ingest_log ORDER BY id').all() as Array>; 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 number, 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 number, 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((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: '', }); expect(res.statusCode).toBe(415); expect(res.json()).toEqual({ error: 'unsupported_media_type' }); }); });