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.
353 lines
14 KiB
TypeScript
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' });
|
|
});
|
|
});
|