srs-api/src/whatsapp/conversationPhoto.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

371 lines
12 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { Db } from '../db';
import { getUserById } from '../services/users';
import { testConfig } from '../testing/config';
import { makeDb, seedFixture, type Fixture } from '../testing/helpers';
import { getConversation, handleMessage, handlePhotoText, photoGate } from './conversation';
import * as msg from './messages';
import { createConversationSweeper } from './sweep';
const S = testConfig().orders;
// 10:30 IST, before the cutoff
const MORNING = new Date('2026-11-03T05:00:00Z');
// 15:10 IST, inside the "today or tomorrow" window
const ASK_TIME = new Date('2026-11-03T09:40:00Z');
let db: Db;
let f: Fixture;
beforeEach(() => {
db = makeDb();
f = seedFixture(db);
});
afterEach(() => db.close());
const say = (userId: number, text: string, at: Date = MORNING): string[] =>
handleMessage(db, S, getUserById(db, userId)!, 'chat-1@lid', text, at);
const photo = (userId: number, text: string, at: Date = MORNING): string[] =>
handlePhotoText(db, S, getUserById(db, userId)!, 'chat-1@lid', text, at);
const conv = (userId: number) => getConversation(db, userId);
const orders = () => db.prepare('SELECT * FROM orders ORDER BY id').all() as Array<Record<string, unknown>>;
const lines = (orderId: unknown) =>
db.prepare('SELECT ending, qty FROM order_lines WHERE order_id = ? ORDER BY id').all(orderId);
const remind = (userId: number) =>
db.prepare("UPDATE conversations SET reminded_at = '2026-11-03T05:10:00Z' WHERE user_id = ?").run(userId);
/** Every place a conversation can be, built through the real flow. */
const STATES: [string, (id: number) => void, number?][] = [
['IDLE', () => {}],
['COLLECTING', (id) => void say(id, '6379 5')],
[
'COLLECTING after the reminder',
(id) => {
say(id, '6379 5');
remind(id);
},
],
[
'AWAITING_SHOP',
(id) => {
say(id, '6379 5');
say(id, '1');
},
],
[
'ASKING draw',
(id) => {
say(id, '6379 5', ASK_TIME);
say(id, '1', ASK_TIME);
say(id, '1', ASK_TIME);
},
],
[
'ASKING split',
(id) => {
say(id, '6379 5');
remind(id);
say(id, '9001 2');
},
],
[
'CONFIRM',
(id) => {
say(id, '6379 5');
say(id, '1');
say(id, '1');
},
],
[
'CONFIRM after Add more',
(id) => {
say(id, '6379 5');
say(id, '1');
say(id, '1');
say(id, '2');
},
],
];
const COMMAND_LIKE = [
'1',
'2',
'3',
'12',
'done',
'Done.',
'submit',
'cancel',
'?',
'help',
'6379 100\n1',
'6379 100\ndone',
];
describe('a photo is never a command', () => {
describe.each(STATES)('in %s', (_name, arrange) => {
it.each(COMMAND_LIKE)('a slip reading %j saves, cancels and changes nothing', (slip) => {
const id = f.multiUser.id;
arrange(id);
const before = conv(id);
expect(before?.state ?? 'IDLE').toBe(_name.split(' ')[0]);
const replies = photo(id, slip);
expect(replies).toHaveLength(1);
expect(orders()).toEqual([]);
const after = conv(id);
expect(after?.state).toBe(before?.state);
expect(after?.buffer_text).toBe(before?.buffer_text);
expect(after?.parsed_json).toBe(before?.parsed_json);
expect(after?.selected_shop_id).toBe(before?.selected_shop_id);
expect(after?.held_text).toBe(before?.held_text);
expect(after?.source ?? 'whatsapp_text').toBe('whatsapp_text');
});
});
it('shows what was read and asks for the order to be typed', () => {
expect(photo(f.shopUser.id, '1')).toEqual([msg.photoLooksLikeReply('1', null)]);
expect(photo(f.shopUser.id, '1')[0]).toContain('Please type the order');
});
});
describe('photoGate', () => {
const open = ['IDLE', 'COLLECTING', 'COLLECTING after the reminder', 'CONFIRM', 'CONFIRM after Add more'];
it.each(STATES)('%s', (name, arrange) => {
const id = f.multiUser.id;
arrange(id);
expect(photoGate(db, getUserById(db, id)!).ok).toBe(open.includes(name));
});
it('repeats the open question when it refuses, and the photo text is thrown away', () => {
const id = f.multiUser.id;
say(id, '6379 5');
const [question] = say(id, '1');
expect(photo(id, '9001 2')).toEqual([msg.photoAnswerFirst(question!)]);
expect(conv(id)?.state).toBe('AWAITING_SHOP');
expect(conv(id)?.buffer_text).toBe('6379 5');
});
});
describe('a readable photo', () => {
it('starts an order from IDLE and shows what was read', () => {
const id = f.shopUser.id;
expect(photo(id, '6379 100\n9001 10')).toEqual([msg.photoRead('6379 100\n9001 10', [], 2, 110)]);
expect(conv(id)).toMatchObject({
state: 'COLLECTING',
source: 'whatsapp_photo',
buffer_text: '6379 100\n9001 10',
update_pending: 0,
});
});
it('joins typed text and clears the pending "Got it", since its reply already has the totals', () => {
const id = f.shopUser.id;
say(id, '6379 5');
expect(conv(id)?.update_pending).toBe(1);
expect(photo(id, '9001 2')).toEqual([msg.photoRead('9001 2', [], 2, 7)]);
expect(conv(id)).toMatchObject({ source: 'whatsapp_photo', update_pending: 0, buffer_text: '6379 5\n9001 2' });
});
it('joins the open order after the reminder, without asking', () => {
const id = f.shopUser.id;
say(id, '6379 5');
remind(id);
expect(photo(id, '9001 2')).toEqual([msg.photoRead('9001 2', [], 2, 7)]);
expect(conv(id)).toMatchObject({
state: 'COLLECTING',
reminded_at: null,
held_text: null,
buffer_text: '6379 5\n9001 2',
});
});
it('is then finished like any order: Done, summary, Save, with source whatsapp_photo', () => {
const id = f.shopUser.id;
photo(id, '6379 100');
photo(id, '9001 10');
expect(say(id, '1')[0]).toContain('✅ Everything was read');
expect(say(id, '1')[0]).toContain('saved*');
const [order] = orders();
expect(order).toMatchObject({ source: 'whatsapp_photo', raw_text: '6379 100\n9001 10' });
expect(lines(order!.id)).toEqual([
{ ending: '6379', qty: 100 },
{ ending: '9001', qty: 10 },
]);
});
it('adds a segment after "2 Add more" and shows the new summary', () => {
const id = f.shopUser.id;
say(id, '6379 5');
say(id, '1');
say(id, '2');
const replies = photo(id, '9001 2');
expect(replies).toHaveLength(2);
expect(replies[0]).toBe(msg.photoAdded('9001 2'));
expect(replies[1]).toContain('9001 - 2');
expect(conv(id)?.state).toBe('CONFIRM');
say(id, '1');
expect(orders()[0]).toMatchObject({ source: 'whatsapp_photo' });
});
it('at the summary, without "2 Add more", joins the order and shows the summary again', () => {
const id = f.shopUser.id;
say(id, '6379 5');
say(id, '1');
expect(conv(id)?.state).toBe('CONFIRM');
const replies = photo(id, '9001 2');
expect(replies[0]).toBe(msg.photoAdded('9001 2'));
expect(replies[1]).toContain('*Total:* 2 lines, 7 tickets');
expect(conv(id)?.state).toBe('CONFIRM');
expect(orders()).toEqual([]);
say(id, '1');
expect(orders()[0]).toMatchObject({ source: 'whatsapp_photo' });
expect(lines(orders()[0]!.id)).toEqual([
{ ending: '6379', qty: 5 },
{ ending: '9001', qty: 2 },
]);
});
it('does not make the next typed order a photo order', () => {
const id = f.shopUser.id;
photo(id, '6379 5');
say(id, '1');
say(id, '1');
say(id, '9001 2');
say(id, '1');
say(id, '1');
expect(orders().map((o) => o.source)).toEqual(['whatsapp_photo', 'whatsapp_text']);
});
it('a late typed message on a photo order keeps the late-message question and stays a photo order', () => {
const id = f.shopUser.id;
photo(id, '6379 5');
remind(id);
say(id, '9001 2');
expect(conv(id)).toMatchObject({ state: 'ASKING', ask_kind: 'split' });
say(id, '1');
expect(conv(id)).toMatchObject({ state: 'COLLECTING', source: 'whatsapp_photo' });
});
});
describe('damaged lines never vanish', () => {
it('lists them whole under "Not read", so "Everything was read" cannot appear', () => {
const id = f.shopUser.id;
const slip = '6379 100\n935 40\n???? 10\nTotal 50';
expect(photo(id, slip)[0]).toBe(msg.photoRead(slip, ['935 40', '???? 10', 'Total 50'], 1, 100));
const [summary] = say(id, '1');
expect(summary).not.toContain('Everything was read');
expect(summary).toContain('Not read, not in the order');
for (const line of [' 935 40', ' ???? 10', ' Total 50']) expect(summary).toContain(line);
});
it('keeps showing them as "Left out earlier" after Add more', () => {
const id = f.shopUser.id;
photo(id, '6379 100\n935 40');
say(id, '1');
say(id, '2');
expect(say(id, '9001 2')[0]).toContain('Left out earlier');
expect(conv(id)?.parsed_json).toContain('9001 2');
const summary = conv(id)?.last_prompt ?? '';
expect(summary).toContain(' 935 40');
expect(summary).not.toContain('Everything was read');
});
it('does not list a line of plain words', () => {
expect(photo(f.shopUser.id, 'Ramu stores\n6379 100')[0]).toBe(
msg.photoRead('Ramu stores\n6379 100', [], 1, 100),
);
});
it('still lists what the parser calls unreadable, and a typed order is unchanged', () => {
expect(photo(f.shopUser.id, '6379 1?0')[0]).toBe(msg.photoRead('6379 1?0', ['1?0'], 1, 1));
const id = f.multiUser.id;
say(id, '6379 100\nTotal 50');
say(id, '1');
expect(say(id, '1')[0]).toContain('✅ Everything was read');
});
});
describe('a photo with no ticket lines', () => {
const NO_TICKETS: [string, string[]][] = [
['935 40', ['935 40']],
['024 50', ['024 50']],
['???? 10', ['???? 10']],
['Total 50', ['Total 50']],
['45?1 50', ['45?1', '50']],
['Ramu stores', []],
];
it.each(NO_TICKETS)('%s from IDLE opens no order and says so', (slip, notRead) => {
const id = f.shopUser.id;
expect(photo(id, slip)).toEqual([msg.photoNoTickets(slip, notRead, null)]);
expect(conv(id)?.state ?? 'IDLE').toBe('IDLE');
});
it.each(NO_TICKETS)('%s while collecting leaves the order as it was and offers the menu', (slip, notRead) => {
const id = f.shopUser.id;
say(id, '6379 5');
const [reply] = photo(id, slip);
expect(reply).toBe(msg.photoNoTickets(slip, notRead, msg.photoOpenOrder(1, 5)));
expect(reply).not.toContain('Got it');
expect(reply).toContain('1 line, 5 tickets so far.');
expect(conv(id)).toMatchObject({ state: 'COLLECTING', buffer_text: '6379 5', source: 'whatsapp_text' });
});
it.each(NO_TICKETS)('%s after Add more keeps waiting for the new lines', (slip, notRead) => {
const id = f.shopUser.id;
say(id, '6379 5');
say(id, '1');
say(id, '2');
expect(photo(id, slip)).toEqual([msg.photoNoTickets(slip, notRead, msg.addPrompt())]);
photo(id, '9001 2');
say(id, '1');
expect(lines(orders()[0]!.id)).toEqual([
{ ending: '6379', qty: 5 },
{ ending: '9001', qty: 2 },
]);
});
});
describe('time and the sweeper', () => {
it('decides the draw from the time Done arrived, as for text', () => {
const id = f.shopUser.id;
photo(id, '6379 5', ASK_TIME);
expect(say(id, '1', ASK_TIME)[0]).toContain('today');
});
it('counts a photo as activity for the reminder and expiry', () => {
const id = f.shopUser.id;
say(id, '6379 5', MORNING);
const later = new Date(MORNING.getTime() + 9 * 60_000);
photo(id, '9001 2', later);
const sent: string[] = [];
const sweeper = createConversationSweeper({
db,
settings: S,
queue: { enqueue: (_chat, m) => void sent.push(...m), idle: async () => {} },
log: () => {},
now: () => new Date(later.getTime() + 2 * 60_000),
});
expect(sweeper.tick()).toEqual({ reminded: 0, expired: 0 });
expect(sweeper.tickUpdates()).toBe(0);
expect(sent).toEqual([]);
});
});