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