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

809 lines
28 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 } from './conversation';
import * as msg from './messages';
const S = testConfig().orders;
// 2026-11-03 10:30 IST: well before the 15:00 cutoff
const MORNING = new Date('2026-11-03T05:00:00Z');
// 15:10 IST: inside the "ask today or tomorrow" window
const ASK_TIME = new Date('2026-11-03T09:40:00Z');
// 15:20 IST, 15:40 IST
const STILL_ASKING = new Date('2026-11-03T09:50:00Z');
const TOO_LATE = new Date('2026-11-03T10:10:00Z');
let db: Db;
let f: Fixture;
beforeEach(() => {
db = makeDb();
f = seedFixture(db);
});
afterEach(() => db.close());
/** A person chatting with the bot: `say(text, at)` returns the bot's replies. */
function chatAs(userId: number, chatId = 'chat-1@lid') {
const user = getUserById(db, userId)!;
return (text: string, at: Date = MORNING): string[] => handleMessage(db, S, user, chatId, text, at);
}
const conv = (userId: number) => getConversation(db, userId);
const state = (userId: number) => conv(userId)?.state ?? 'IDLE';
const orders = () => db.prepare('SELECT * FROM orders ORDER BY id').all() as Array<Record<string, unknown>>;
const lines = (orderId: number) =>
db.prepare('SELECT ending, qty FROM order_lines WHERE order_id = ? ORDER BY id').all(orderId) as Array<{
ending: string;
qty: number;
}>;
const SUMMARY_MENU = msg.SUMMARY_MENU;
describe('the walk-through from the plan (section 3.3)', () => {
it('plays out word for word for a user with two shops', () => {
const say = chatAs(f.multiUser.id);
expect(say('8910=3')).toEqual([]);
expect(say('7810=1')).toEqual([]);
expect(say('08?8=2')).toEqual([]);
expect(say('Total 50')).toEqual([]);
expect(say('1')).toEqual(['*Which shop?*\n\n1. Shop A\n2. Shop B']);
expect(say('2')).toEqual([
[
'*Order summary*',
'',
'*Shop:* Shop B',
'*Draw:* TODAY 3 Nov',
'*Total:* 2 lines, 4 tickets',
'',
'*Tickets*',
'8910 - 3',
'7810 - 1',
'',
'⚠️ *Not read, not in the order:*',
' 08?8=2',
'',
'*Reply:*\n1. Save\n2. Add more\n3. Cancel',
].join('\n'),
]);
expect(say('2')).toEqual(['*Add to the order*\nSend only the new or corrected lines, not the whole order.']);
expect(say('0838=2')).toEqual([
[
'*Order summary*',
'',
'*Shop:* Shop B',
'*Draw:* TODAY 3 Nov',
'*Total:* 3 lines, 6 tickets',
'',
'*Tickets*',
'8910 - 3',
'7810 - 1',
'0838 - 2',
'',
'⚠️ *Left out earlier, not in the order:*',
' 08?8=2',
'',
'*Reply:*\n1. Save\n2. Add more\n3. Cancel',
].join('\n'),
]);
expect(say('1')).toEqual(['✅ *Order #1 saved*\n\n*Shop:* Shop B\n*Tickets:* 6\n*Draw:* 3 Nov']);
const [order] = orders();
expect(order).toMatchObject({
shop_id: f.shops.b,
draw_date: '2026-11-03',
status: 'confirmed',
source: 'whatsapp_text',
created_by_user_id: f.multiUser.id,
});
expect(lines(1)).toEqual([
{ ending: '8910', qty: 3 },
{ ending: '7810', qty: 1 },
{ ending: '0838', qty: 2 },
]);
expect(state(f.multiUser.id)).toBe('IDLE');
});
it('keeps the original text and the added lines together in raw_text', () => {
const say = chatAs(f.shopUser.id);
say('8910=3\n08?8=2');
say('1');
say('2');
say('0838=2');
say('1');
expect(orders()[0]!.raw_text).toBe('8910=3\n08?8=2\n0838=2');
});
});
describe('who sees which shops', () => {
it('a one-shop user skips the shop pick', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
const [summary] = say('done');
expect(summary).toContain('*Shop:* Shop A\n*Draw:* TODAY 3 Nov');
expect(state(f.shopUser.id)).toBe('CONFIRM');
});
it('an admin sees every active shop', () => {
const say = chatAs(f.admin.id);
say('8910=3');
expect(say('1')).toEqual(['*Which shop?*\n\n1. Shop A\n2. Shop B\n3. Shop C']);
});
it('an admin can pick any shop and save', () => {
const say = chatAs(f.admin.id);
say('8910=3');
say('1');
expect(say('3')[0]).toContain('*Shop:* Shop C\n*Draw:* TODAY 3 Nov');
expect(say('1')).toEqual(['✅ *Order #1 saved*\n\n*Shop:* Shop C\n*Tickets:* 3\n*Draw:* 3 Nov']);
expect(orders()[0]).toMatchObject({ shop_id: f.shops.c, created_by_user_id: f.admin.id });
});
it('a user with no shop is told so and the conversation resets', () => {
db.prepare('DELETE FROM user_shops WHERE user_id = ?').run(f.shopUser.id);
const say = chatAs(f.shopUser.id);
say('8910=3');
expect(say('done')).toEqual([msg.noShop()]);
expect(state(f.shopUser.id)).toBe('IDLE');
expect(orders()).toHaveLength(0);
});
it('an out-of-range shop number repeats the list', () => {
const say = chatAs(f.multiUser.id);
say('8910=3');
say('1');
expect(say('9')).toEqual(['*Which shop?*\n\n1. Shop A\n2. Shop B']);
expect(state(f.multiUser.id)).toBe('AWAITING_SHOP');
});
it('a shop removed after the list was shown is refused and a fresh list is sent', () => {
const say = chatAs(f.multiUser.id);
say('8910=3');
say('1');
db.prepare('DELETE FROM user_shops WHERE user_id = ? AND shop_id = ?').run(f.multiUser.id, f.shops.b);
expect(say('2')).toEqual(['*Which shop?*\n\n1. Shop A']);
expect(state(f.multiUser.id)).toBe('AWAITING_SHOP');
const [summary] = say('1');
expect(summary).toContain('*Shop:* Shop A');
});
it('re-checks shop access at the moment of saving', () => {
const say = chatAs(f.multiUser.id);
say('8910=3');
say('1');
say('2');
db.prepare('DELETE FROM user_shops WHERE user_id = ? AND shop_id = ?').run(f.multiUser.id, f.shops.b);
expect(say('1')).toEqual([msg.noShop()]);
expect(orders()).toHaveLength(0);
expect(state(f.multiUser.id)).toBe('IDLE');
});
});
describe('collecting', () => {
it('never replies at once while collecting: the sweeper answers after a pause', () => {
const say = chatAs(f.shopUser.id);
expect(say('8910=3')).toEqual([]);
expect(conv(f.shopUser.id)!.update_pending).toBe(1);
expect(say('7810=1')).toEqual([]);
expect(say('0838=2')).toEqual([]);
});
it('accepts done and submit typed as words', () => {
for (const word of ['done', 'Submit.']) {
const say = chatAs(f.shopUser.id);
say('8910=3');
expect(say(word)[0]).toContain('Shop A');
say('3'); // cancel
}
});
it('treats "done" as the last line of an order message', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
const [summary] = say('7810=1\ndone');
expect(summary).toContain('2 lines, 4 tickets');
});
it('a whole order with done in the very first message needs no menu', () => {
const say = chatAs(f.shopUser.id);
const [summary] = say('0888\n0057\ndone');
expect(summary).toContain('2 lines, 2 tickets');
expect(state(f.shopUser.id)).toBe('CONFIRM');
});
it('a trailing 1 is Done when already collecting (Hermes merged two quick messages)', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
const [summary] = say('0888=2\n7810=1\n1');
expect(summary).toContain('3 lines, 6 tickets');
expect(summary).toContain('✅ Everything was read');
expect(state(f.shopUser.id)).toBe('CONFIRM');
});
it('a trailing 1 in the first message is order text, not Done', () => {
const say = chatAs(f.shopUser.id);
expect(say('0888=2\n1')).toEqual([]);
expect(state(f.shopUser.id)).toBe('COLLECTING');
expect(conv(f.shopUser.id)!.buffer_text).toBe('0888=2\n1');
});
it('1 Done, 2 Cancel from the menu', () => {
const a = chatAs(f.shopUser.id);
a('8910=3');
a('2');
expect(state(f.shopUser.id)).toBe('IDLE');
});
it('chatter while collecting is kept out of the way: no reply', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
expect(say('ok')).toEqual([]);
expect(state(f.shopUser.id)).toBe('COLLECTING');
});
it('records the latest chat id', () => {
chatAs(f.shopUser.id, 'old@lid')('8910=3');
chatAs(f.shopUser.id, 'new@lid')('7810=1');
expect(conv(f.shopUser.id)!.chat_id).toBe('new@lid');
});
});
describe('IDLE chatter does not start an order', () => {
it.each(['ok', 'thanks', '👍', '1', '2', 'yes', 'cancel', '?', 'Total 50'])('%j', (t) => {
const say = chatAs(f.shopUser.id);
expect(say(t)).toEqual([]);
expect(conv(f.shopUser.id)).toBeUndefined();
});
it('done with nothing to submit says so', () => {
expect(chatAs(f.shopUser.id)('done')).toEqual([msg.nothingToSubmit()]);
expect(conv(f.shopUser.id)).toBeUndefined();
});
it('a double-tapped Save after Saved creates nothing', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
say('done');
expect(say('1')[0]).toContain('*Order #1 saved*');
expect(say('1')).toEqual([]);
expect(say('thanks')).toEqual([]);
expect(orders()).toHaveLength(1);
expect(state(f.shopUser.id)).toBe('IDLE');
});
it('a message with one unreadable item does start an order', () => {
expect(chatAs(f.shopUser.id)('08?8=2')).toEqual([]);
expect(conv(f.shopUser.id)).toMatchObject({ state: 'COLLECTING', update_pending: 1 });
});
});
describe('unreadable text', () => {
it('saving without fixing it leaves it out', () => {
const say = chatAs(f.shopUser.id);
say('8910=3\n08?8=2');
const [summary] = say('done');
expect(summary).toContain('⚠️ *Not read, not in the order:*\n 08?8=2');
say('1');
expect(lines(1)).toEqual([{ ending: '8910', qty: 3 }]);
});
it('Add more keeps listing earlier unreadable text, and new unreadable text separately', () => {
const say = chatAs(f.shopUser.id);
say('8910=3\n08?8=2');
say('done');
say('2');
const [again] = say('0x38=1');
expect(again).toContain('⚠️ *Not read, not in the order:*\n 0x38=1');
expect(again).toContain('⚠️ *Left out earlier, not in the order:*\n 08?8=2');
// the new line is unreadable too, so nothing was added to the order
expect(again).toContain('1 line, 3 tickets');
expect(again).not.toContain('Everything was read');
});
it('adding a forgotten line does not hide the earlier unreadable one', () => {
const say = chatAs(f.shopUser.id);
say('8910=3\n08?8=2');
say('done');
say('2');
const [again] = say('1234=1'); // a forgotten line, not a correction
expect(again).toContain('2 lines, 4 tickets');
expect(again).toContain('⚠️ *Left out earlier, not in the order:*\n 08?8=2');
expect(again).not.toContain('Everything was read');
say('1');
expect(lines(1)).toEqual([
{ ending: '8910', qty: 3 },
{ ending: '1234', qty: 1 },
]);
});
it('says everything was read only when nothing was ever left out', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
say('done');
say('2');
const [again] = say('1234=1');
expect(again).toContain('*Total:* 2 lines, 4 tickets\n✅ Everything was read');
expect(again).not.toContain('Left out earlier');
});
it('an order that is only unreadable stops at Done and lists what was not read', () => {
const say = chatAs(f.multiUser.id); // two shops: it must not reach the shop question
say('08?8=2');
expect(say('done')).toEqual([msg.noTickets(['08?8=2'])]);
expect(orders()).toHaveLength(0);
expect(state(f.multiUser.id)).toBe('IDLE');
});
it('readable and unreadable together still go on to the shop question', () => {
const say = chatAs(f.multiUser.id);
say('08?8=2');
say('7810=1');
expect(say('done')[0]).toContain('*Which shop?*');
});
it('an order with nothing readable at all is refused at Done', () => {
// started with a leading chatter line so the order opened, then everything is words
const say = chatAs(f.shopUser.id);
say('08?8=2');
db.prepare("UPDATE conversations SET buffer_text = 'just words'").run();
expect(say('done')).toEqual([msg.noTickets()]);
expect(state(f.shopUser.id)).toBe('IDLE');
});
});
describe('repeated endings', () => {
it('are added up and shown once, not asked about', () => {
const say = chatAs(f.shopUser.id);
say('8910=3\n7810=1\n8910=3');
const [summary] = say('done');
expect(summary).toContain('3 lines, 7 tickets');
expect(summary).toContain('8910 - 6 (2 times)');
expect(summary).toContain('7810 - 1');
expect(state(f.shopUser.id)).toBe('CONFIRM');
});
it('are saved as written, not merged', () => {
const say = chatAs(f.shopUser.id);
say('8910=3\n7810=1\n8910=3');
say('done');
say('1');
expect(lines(1)).toEqual([
{ ending: '8910', qty: 3 },
{ ending: '7810', qty: 1 },
{ ending: '8910', qty: 3 },
]);
});
it('a whole order resent through Add more is visible as repeats', () => {
const say = chatAs(f.shopUser.id);
say('8910=3\n7810=1');
say('done');
say('2');
const [summary] = say('8910=3\n7810=1');
expect(summary).toContain('8910 - 6 (2 times)');
expect(summary).toContain('7810 - 2 (2 times)');
});
});
describe('the menus', () => {
it('Add more asks for new lines only and the next message is added', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
say('done');
expect(say('2')).toEqual([msg.addPrompt()]);
const [summary] = say('7810=1');
expect(summary).toContain('2 lines, 4 tickets');
});
it('1 and 3 keep their meaning while an Add more is pending', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
say('done');
say('2');
expect(say('1')[0]).toContain('*Order #1 saved*');
});
it('Cancel at the summary clears everything', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
say('done');
expect(say('3')).toEqual([msg.cancelled()]);
expect(state(f.shopUser.id)).toBe('IDLE');
expect(orders()).toHaveLength(0);
});
it('order text at the summary joins the order and shows the summary again, nothing saved yet', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
say('done');
const [reply] = say('7810=1');
expect(reply).toContain('*Order summary*');
expect(reply).toContain('*Total:* 2 lines, 4 tickets');
expect(reply).toContain('7810 - 1');
expect(reply).toContain(SUMMARY_MENU);
expect(state(f.shopUser.id)).toBe('CONFIRM');
expect(orders()).toHaveLength(0);
expect(say('1')[0]).toContain('saved*');
expect(lines(orders()[0]!.id as number)).toEqual([
{ ending: '8910', qty: 3 },
{ ending: '7810', qty: 1 },
]);
});
it('"2 Add more" still works at the summary', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
say('done');
expect(say('2')).toEqual([msg.addPrompt()]);
expect(say('7810=1')[0]).toContain('*Total:* 2 lines, 4 tickets');
expect(state(f.shopUser.id)).toBe('CONFIRM');
});
it('a message with no order content at the summary adds nothing and repeats the summary', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
const [summary] = say('done');
expect(say('save')).toEqual([summary]);
expect(JSON.parse(conv(f.shopUser.id)!.parsed_json!).segments).toEqual(['8910=3']);
});
it('what was not read earlier stays listed, as left out, after a message joins at the summary', () => {
const say = chatAs(f.shopUser.id);
say('8910=3\n08?8=2');
say('done');
const [reply] = say('7810=1');
expect(reply).toContain('Left out earlier');
expect(reply).toContain(' 08?8=2');
});
it('order text while the shop list is open is refused and not added', () => {
const say = chatAs(f.multiUser.id);
say('8910=3');
const [list] = say('1');
expect(say('7810=1')).toEqual([`${msg.numberFirst()}\n\n${list}`]);
expect(conv(f.multiUser.id)!.buffer_text).toBe('8910=3');
});
it('? repeats the last prompt in every waiting state', () => {
const say = chatAs(f.multiUser.id);
say('8910=3');
expect(say('?')).toEqual([msg.progressUpdate(1, 3)]);
const [list] = say('1');
expect(say('help')).toEqual([list]);
const [summary] = say('2');
expect(say('?')).toEqual([summary]);
});
it('never sends a blank message when there is no prompt on record', () => {
const say = chatAs(f.multiUser.id);
say('8910=3');
say('1');
db.prepare('UPDATE conversations SET last_prompt = NULL').run();
expect(say('?')).toEqual([]);
expect(say('thanks')).toEqual([]);
expect(say('7810=1')).toEqual([msg.numberFirst()]);
});
it('an unexpected reply repeats the prompt and changes nothing', () => {
const say = chatAs(f.multiUser.id);
say('8910=3');
const [list] = say('1');
expect(say('thanks')).toEqual([list]);
expect(say('7')).toEqual([list]);
expect(state(f.multiUser.id)).toBe('AWAITING_SHOP');
const [summary] = say('2');
expect(say('banana')).toEqual([summary]);
expect(say('9')).toEqual([summary]);
expect(orders()).toHaveLength(0);
});
});
describe('cancel in every state', () => {
it('COLLECTING', () => {
const say = chatAs(f.multiUser.id);
say('8910=3');
expect(say('cancel')).toEqual([msg.cancelled()]);
expect(state(f.multiUser.id)).toBe('IDLE');
});
it('AWAITING_SHOP', () => {
const say = chatAs(f.multiUser.id);
say('8910=3');
say('1');
expect(say('Cancel')).toEqual([msg.cancelled()]);
expect(state(f.multiUser.id)).toBe('IDLE');
});
it('ASKING (draw)', () => {
const say = chatAs(f.shopUser.id);
say('8910=3', ASK_TIME);
say('done', ASK_TIME);
expect(state(f.shopUser.id)).toBe('ASKING');
expect(say('cancel', ASK_TIME)).toEqual([msg.cancelled()]);
expect(state(f.shopUser.id)).toBe('IDLE');
});
it('CONFIRM', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
say('done');
expect(say('cancel')).toEqual([msg.cancelled()]);
expect(state(f.shopUser.id)).toBe('IDLE');
});
it('leaves a clean slate: a new order starts from nothing', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
say('done');
say('cancel');
say('7810=1');
expect(conv(f.shopUser.id)!.buffer_text).toBe('7810=1');
expect(conv(f.shopUser.id)!.parsed_json).toBeNull();
});
});
describe('the draw date', () => {
it('before 15:00 is today, no question', () => {
const say = chatAs(f.shopUser.id);
say('8910=3', new Date('2026-11-03T09:30:00Z')); // 15:00:00 exactly
const [summary] = say('done', new Date('2026-11-03T09:30:00Z'));
expect(summary).toContain('*Draw:* TODAY 3 Nov');
});
it('after 15:30 is tomorrow, no question', () => {
const say = chatAs(f.shopUser.id);
say('8910=3', TOO_LATE);
const [summary] = say('done', TOO_LATE);
expect(summary).toContain('*Draw:* TOMORROW 4 Nov');
});
it('between 15:00 and 15:30 asks, and Today is accepted inside the window', () => {
const say = chatAs(f.shopUser.id);
say('8910=3', ASK_TIME);
expect(say('done', ASK_TIME)).toEqual([msg.drawQuestion('2026-11-03', '2026-11-04')]);
const [summary] = say('1', STILL_ASKING);
expect(summary).toContain('*Draw:* TODAY 3 Nov');
say('1', STILL_ASKING);
expect(orders()[0]!.draw_date).toBe('2026-11-03');
});
it('Tomorrow is accepted', () => {
const say = chatAs(f.shopUser.id);
say('8910=3', ASK_TIME);
say('done', ASK_TIME);
const [summary] = say('2', STILL_ASKING);
expect(summary).toContain('*Draw:* TOMORROW 4 Nov');
say('1', STILL_ASKING);
expect(orders()[0]!.draw_date).toBe('2026-11-04');
});
it('Today answered after the window is refused and tomorrow or cancel is offered', () => {
const say = chatAs(f.shopUser.id);
say('8910=3', ASK_TIME);
say('done', ASK_TIME);
expect(say('1', TOO_LATE)).toEqual([msg.tooLateForToday('2026-11-04')]);
const [summary] = say('1', TOO_LATE);
expect(summary).toContain('*Draw:* TOMORROW 4 Nov');
});
it('after a refusal, 2 cancels', () => {
const say = chatAs(f.shopUser.id);
say('8910=3', ASK_TIME);
say('done', ASK_TIME);
say('1', TOO_LATE);
expect(say('2', TOO_LATE)).toEqual([msg.cancelled()]);
expect(orders()).toHaveLength(0);
});
it('Today answered after midnight is refused (it is a different day by then)', () => {
const say = chatAs(f.shopUser.id);
say('8910=3', ASK_TIME);
say('done', ASK_TIME);
// 00:05 IST on 4 Nov
expect(say('1', new Date('2026-11-03T18:35:00Z'))).toEqual([msg.tooLateForToday('2026-11-04')]);
});
it('the draw is fixed when Done was sent: a Save after 15:30 keeps today', () => {
const say = chatAs(f.shopUser.id);
say('8910=3', ASK_TIME);
say('done', ASK_TIME);
say('1', STILL_ASKING);
const [saved] = say('1', new Date('2026-11-03T11:00:00Z')); // 16:30 IST
expect(saved).toContain('*Draw:* 3 Nov');
expect(orders()[0]!.draw_date).toBe('2026-11-03');
});
it('does not offer Today when the shop pick used up the window: straight to tomorrow or cancel', () => {
const say = chatAs(f.multiUser.id);
say('8910=3', new Date('2026-11-03T09:55:00Z')); // 15:25 IST
say('done', new Date('2026-11-03T09:55:00Z'));
// picks the shop at 15:40, after the window closed
expect(say('1', TOO_LATE)).toEqual([msg.tooLateForToday('2026-11-04')]);
const c = conv(f.multiUser.id)!;
expect(c.state).toBe('ASKING');
const [summary] = say('1', TOO_LATE);
expect(summary).toContain('*Draw:* TOMORROW 4 Nov');
say('1', TOO_LATE);
expect(orders()[0]!.draw_date).toBe('2026-11-04');
});
it('after that early refusal, 2 cancels and the draw is never today', () => {
const say = chatAs(f.multiUser.id);
say('8910=3', new Date('2026-11-03T09:55:00Z'));
say('done', new Date('2026-11-03T09:55:00Z'));
say('1', TOO_LATE);
expect(say('2', TOO_LATE)).toEqual([msg.cancelled()]);
expect(orders()).toHaveLength(0);
});
it('still asks normally when the shop pick finishes inside the window', () => {
const say = chatAs(f.multiUser.id);
say('8910=3', ASK_TIME);
say('done', ASK_TIME);
expect(say('1', STILL_ASKING)).toEqual([msg.drawQuestion('2026-11-03', '2026-11-04')]);
});
it('the shop pick comes before the draw question', () => {
const say = chatAs(f.multiUser.id);
say('8910=3', ASK_TIME);
expect(say('done', ASK_TIME)[0]).toContain('*Which shop?*');
expect(say('1', ASK_TIME)[0]).toContain("today's draw or tomorrow's");
});
});
describe('saving', () => {
it('a double-sent 1 saves once', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
say('done');
say('1');
say('1');
expect(orders()).toHaveLength(1);
});
it('a failure while saving rolls everything back, including the conversation', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
say('done');
db.exec(`CREATE TRIGGER boom BEFORE INSERT ON order_lines BEGIN SELECT RAISE(ABORT, 'boom'); END;`);
expect(() => say('1')).toThrow(/boom/);
expect(orders()).toHaveLength(0);
expect(state(f.shopUser.id)).toBe('CONFIRM');
});
it('two users do not disturb each other', () => {
const a = chatAs(f.shopUser.id);
const b = chatAs(f.multiUser.id);
a('8910=3');
b('7810=1');
a('done');
b('done');
expect(state(f.shopUser.id)).toBe('CONFIRM');
expect(state(f.multiUser.id)).toBe('AWAITING_SHOP');
});
});
describe('late messages after the reminder', () => {
const remind = (userId: number) =>
db.prepare("UPDATE conversations SET reminded_at = '2026-11-03T05:10:00Z' WHERE user_id = ?").run(userId);
it('before the reminder, a second order is simply added', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
expect(say('7810=1')).toEqual([]);
expect(state(f.shopUser.id)).toBe('COLLECTING');
});
it('after the reminder, order text is held and the bot asks', () => {
const say = chatAs(f.shopUser.id);
say('8910=3\n7810=1');
remind(f.shopUser.id);
const reply = say('0838=2');
expect(reply).toEqual([msg.splitQuestion(2, 4)]);
const c = conv(f.shopUser.id)!;
expect(c.state).toBe('ASKING');
expect(c.ask_kind).toBe('split');
expect(c.held_text).toBe('0838=2');
expect(c.buffer_text).toBe('8910=3\n7810=1');
});
it('1 adds the message to the old order and collecting continues', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
remind(f.shopUser.id);
say('0838=2');
expect(say('1')).toEqual([]);
const c = conv(f.shopUser.id)!;
expect(c.state).toBe('COLLECTING');
expect(c.buffer_text).toBe('8910=3\n0838=2');
expect(c.held_text).toBeNull();
expect(c.reminded_at).toBeNull();
});
it('2 finishes the old order first, then starts the held one', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
remind(f.shopUser.id);
say('0838=2');
const [summary] = say('2');
expect(summary).toContain('8910 - 3');
expect(summary).not.toContain('0838');
const replies = say('1');
expect(replies[0]).toContain('*Order #1 saved*');
expect(replies[1]).toBe(msg.startingHeld());
expect(replies).toHaveLength(2);
expect(conv(f.shopUser.id)!.update_pending).toBe(1);
expect(lines(1)).toEqual([{ ending: '8910', qty: 3 }]);
expect(conv(f.shopUser.id)!.state).toBe('COLLECTING');
expect(conv(f.shopUser.id)!.buffer_text).toBe('0838=2');
});
it('2 then Cancel on the old order still starts the held one', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
remind(f.shopUser.id);
say('0838=2');
say('2');
const replies = say('3');
expect(replies).toEqual([msg.cancelled(), msg.startingHeld()]);
expect(conv(f.shopUser.id)!.buffer_text).toBe('0838=2');
expect(orders()).toHaveLength(0);
});
it('3 throws the old order away and starts the new one', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
remind(f.shopUser.id);
say('0838=2');
expect(say('3')).toEqual([]);
const c = conv(f.shopUser.id)!;
expect(c.state).toBe('COLLECTING');
expect(c.buffer_text).toBe('0838=2');
expect(c.reminded_at).toBeNull();
});
it('cancel while a message is held cancels the old order and starts the held one', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
remind(f.shopUser.id);
say('0838=2');
expect(say('cancel')).toEqual([msg.cancelled(), msg.startingHeld()]);
expect(conv(f.shopUser.id)!.update_pending).toBe(1);
expect(conv(f.shopUser.id)!.buffer_text).toBe('0838=2');
});
it('done, cancel and chatter after the reminder are not held', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
remind(f.shopUser.id);
expect(say('ok')).toEqual([]);
expect(conv(f.shopUser.id)!.state).toBe('COLLECTING');
expect(say('done')[0]).toContain('Shop A');
});
it('a late message that ends in done is held whole and finishes as its own order', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
remind(f.shopUser.id);
say('0838=2\ndone');
expect(conv(f.shopUser.id)!.held_text).toBe('0838=2\ndone');
const [summary] = say('3'); // throw the old one away; the held order is finished as a whole
expect(summary).toContain('0838 - 2');
expect(summary).not.toContain('8910');
});
it('a menu number while the question is open that is not 1-3 repeats the question', () => {
const say = chatAs(f.shopUser.id);
say('8910=3');
remind(f.shopUser.id);
const [question] = say('0838=2');
expect(say('7')).toEqual([question]);
expect(say('more text 0838')).toEqual([`${msg.numberFirst()}\n\n${question}`]);
});
});