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>; 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}`]); }); });