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.
371 lines
12 KiB
TypeScript
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([]);
|
|
});
|
|
});
|