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.
809 lines
28 KiB
TypeScript
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}`]);
|
|
});
|
|
});
|