Files
household-bot/apps/bot/src/purchase-topic-ingestion.test.ts

2208 lines
59 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, test } from 'bun:test'
import { instantFromIso } from '@household/domain'
import type {
HouseholdConfigurationRepository,
TopicMessageHistoryRecord,
TopicMessageHistoryRepository
} from '@household/ports'
import { createTelegramBot } from './bot'
import {
buildPurchaseAcknowledgement,
extractPurchaseTopicCandidate,
registerConfiguredPurchaseTopicIngestion,
registerPurchaseTopicIngestion,
resolveConfiguredPurchaseTopicRecord,
type PurchaseMessageIngestionRepository,
type PurchaseTopicCandidate
} from './purchase-topic-ingestion'
const config = {
householdId: '11111111-1111-4111-8111-111111111111',
householdChatId: '-10012345',
purchaseTopicId: 777
}
function candidate(overrides: Partial<PurchaseTopicCandidate> = {}): PurchaseTopicCandidate {
return {
updateId: 1,
chatId: '-10012345',
messageId: '10',
threadId: '777',
senderTelegramUserId: '10002',
rawText: 'Bought toilet paper 30 gel',
messageSentAt: instantFromIso('2026-03-05T00:00:00.000Z'),
...overrides
}
}
function participants() {
return [
{
id: 'participant-1',
memberId: 'member-1',
displayName: 'Mia',
included: true
},
{
id: 'participant-2',
memberId: 'member-2',
displayName: 'Dima',
included: false
}
] as const
}
function purchaseUpdate(
text: string,
options: {
replyToBot?: boolean
threadId?: number
} = {}
) {
const commandToken = text.split(' ')[0] ?? text
return {
update_id: 1001,
message: {
message_id: 55,
date: Math.floor(Date.now() / 1000),
message_thread_id: options.threadId ?? 777,
is_topic_message: true,
chat: {
id: Number(config.householdChatId),
type: 'supergroup'
},
from: {
id: 10002,
is_bot: false,
first_name: 'Mia'
},
...(options.replyToBot
? {
reply_to_message: {
message_id: 12,
date: Math.floor(Date.now() / 1000),
chat: {
id: Number(config.householdChatId),
type: 'supergroup'
},
from: {
id: 999000,
is_bot: true,
first_name: 'Household Test Bot',
username: 'household_test_bot'
},
text: 'Which amount was that purchase?'
}
}
: {}),
text,
entities: text.startsWith('/')
? [
{
offset: 0,
length: commandToken.length,
type: 'bot_command'
}
]
: []
}
}
}
function callbackUpdate(data: string, fromId = 10002) {
return {
update_id: 1002,
callback_query: {
id: 'callback-1',
from: {
id: fromId,
is_bot: false,
first_name: 'Mia'
},
chat_instance: 'instance-1',
data,
message: {
message_id: 77,
date: Math.floor(Date.now() / 1000),
chat: {
id: Number(config.householdChatId),
type: 'supergroup'
},
text: 'placeholder'
}
}
}
}
function createTestBot() {
const bot = createTelegramBot('000000:test-token')
bot.botInfo = {
id: 999000,
is_bot: true,
first_name: 'Household Test Bot',
username: 'household_test_bot',
can_join_groups: true,
can_read_all_group_messages: false,
supports_inline_queries: false,
can_connect_to_business: false,
has_main_web_app: false,
has_topics_enabled: true,
allows_users_to_create_topics: false
}
return bot
}
function createTopicMessageHistoryRepository(): TopicMessageHistoryRepository {
const rows: TopicMessageHistoryRecord[] = []
return {
async saveMessage(input) {
rows.push(input)
},
async listRecentThreadMessages(input) {
return rows
.filter(
(row) =>
row.householdId === input.householdId &&
row.telegramChatId === input.telegramChatId &&
row.telegramThreadId === input.telegramThreadId
)
.slice(-input.limit)
},
async listRecentChatMessages(input) {
return rows
.filter(
(row) =>
row.householdId === input.householdId &&
row.telegramChatId === input.telegramChatId &&
row.messageSentAt &&
row.messageSentAt.epochMilliseconds >= input.sentAtOrAfter.epochMilliseconds
)
.slice(-input.limit)
}
}
}
describe('extractPurchaseTopicCandidate', () => {
test('returns record when message belongs to configured topic', () => {
const record = extractPurchaseTopicCandidate(candidate(), config)
expect(record).not.toBeNull()
expect(record?.householdId).toBe(config.householdId)
expect(record?.rawText).toBe('Bought toilet paper 30 gel')
})
test('skips message from other chat', () => {
const record = extractPurchaseTopicCandidate(candidate({ chatId: '-10099999' }), config)
expect(record).toBeNull()
})
test('skips message from other topic', () => {
const record = extractPurchaseTopicCandidate(candidate({ threadId: '778' }), config)
expect(record).toBeNull()
})
test('skips blank text after trim', () => {
const record = extractPurchaseTopicCandidate(candidate({ rawText: ' ' }), config)
expect(record).toBeNull()
})
test('skips slash commands in purchase topic', () => {
const record = extractPurchaseTopicCandidate(
candidate({ rawText: '/statement 2026-03' }),
config
)
expect(record).toBeNull()
})
})
describe('resolveConfiguredPurchaseTopicRecord', () => {
test('returns record when the configured topic role is purchase', () => {
const record = resolveConfiguredPurchaseTopicRecord(candidate(), {
householdId: 'household-1',
role: 'purchase',
telegramThreadId: '777',
topicName: 'Общие покупки'
})
expect(record).not.toBeNull()
expect(record?.householdId).toBe('household-1')
})
test('skips non-purchase topic bindings', () => {
const record = resolveConfiguredPurchaseTopicRecord(candidate(), {
householdId: 'household-1',
role: 'feedback',
telegramThreadId: '777',
topicName: 'Feedback'
})
expect(record).toBeNull()
})
})
describe('buildPurchaseAcknowledgement', () => {
test('returns proposal acknowledgement for a likely purchase', () => {
const result = buildPurchaseAcknowledgement({
status: 'pending_confirmation',
purchaseMessageId: 'proposal-1',
parsedAmountMinor: 3000n,
parsedCurrency: 'GEL',
parsedItemDescription: 'toilet paper',
amountSource: 'explicit',
calculationExplanation: null,
parserConfidence: 92,
parserMode: 'llm',
participants: participants()
})
expect(result).toBe(`I think this shared purchase was: toilet paper - 30.00 GEL.
Participants:
- Mia
- Dima (excluded)
Confirm or cancel below.`)
})
test('shows a calculation note when the llm computed the total', () => {
const result = buildPurchaseAcknowledgement({
status: 'pending_confirmation',
purchaseMessageId: 'proposal-1b',
parsedAmountMinor: 3000n,
parsedCurrency: 'GEL',
parsedItemDescription: 'water bottles',
amountSource: 'calculated',
calculationExplanation: '5 x 6 lari = 30 lari',
parserConfidence: 94,
parserMode: 'llm',
participants: participants()
})
expect(result).toBe(`I think this shared purchase was: water bottles - 30.00 GEL.
I calculated the total as 5 x 6 lari = 30 lari. Is that right?
Participants:
- Mia
- Dima (excluded)
Confirm or cancel below.`)
})
test('returns explicit clarification text from the interpreter', () => {
const result = buildPurchaseAcknowledgement({
status: 'clarification_needed',
purchaseMessageId: 'proposal-2',
clarificationQuestion: 'Which currency was this purchase in?',
parsedAmountMinor: 3000n,
parsedCurrency: null,
parsedItemDescription: 'toilet paper',
amountSource: 'explicit',
calculationExplanation: null,
parserConfidence: 61,
parserMode: 'llm'
})
expect(result).toBe('Which currency was this purchase in?')
})
test('returns fallback clarification when the interpreter question is missing', () => {
const result = buildPurchaseAcknowledgement({
status: 'clarification_needed',
purchaseMessageId: 'proposal-3',
clarificationQuestion: null,
parsedAmountMinor: null,
parsedCurrency: null,
parsedItemDescription: 'toilet paper',
amountSource: null,
calculationExplanation: null,
parserConfidence: 42,
parserMode: 'llm'
})
expect(result).toBe('What amount and currency should I record for this shared purchase?')
})
test('returns parse failure acknowledgement without guessing values', () => {
const result = buildPurchaseAcknowledgement({
status: 'parse_failed',
purchaseMessageId: 'proposal-4'
})
expect(result).toBe(
"I couldn't understand this as a shared purchase yet. Please restate it with item, amount, and currency."
)
})
test('does not acknowledge duplicates or non-purchase chatter', () => {
expect(
buildPurchaseAcknowledgement({
status: 'duplicate'
})
).toBeNull()
expect(
buildPurchaseAcknowledgement({
status: 'ignored_not_purchase',
purchaseMessageId: 'proposal-5'
})
).toBeNull()
})
test('returns Russian proposal text when requested', () => {
const result = buildPurchaseAcknowledgement(
{
status: 'pending_confirmation',
purchaseMessageId: 'proposal-6',
parsedAmountMinor: 3000n,
parsedCurrency: 'GEL',
parsedItemDescription: 'туалетная бумага',
amountSource: 'explicit',
calculationExplanation: null,
parserConfidence: 92,
parserMode: 'llm',
participants: participants()
},
'ru'
)
expect(result).toBe(`Похоже, это общая покупка: туалетная бумага - 30.00 GEL.
Участники:
- Mia
- Dima (не участвует)
Подтвердите или отмените ниже.`)
})
})
describe('registerPurchaseTopicIngestion', () => {
test('replies in-topic with a proposal and buttons for a likely purchase', async () => {
const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = []
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
return {
ok: true,
result: {
message_id: calls.length,
date: Math.floor(Date.now() / 1000),
chat: {
id: Number(config.householdChatId),
type: 'supergroup'
},
text: 'ok'
}
} as never
})
const repository: PurchaseMessageIngestionRepository = {
async hasClarificationContext() {
return false
},
async save() {
return {
status: 'pending_confirmation',
purchaseMessageId: 'proposal-1',
parsedAmountMinor: 3000n,
parsedCurrency: 'GEL',
parsedItemDescription: 'toilet paper',
parserConfidence: 92,
parserMode: 'llm',
participants: participants()
}
},
async confirm() {
throw new Error('not used')
},
async cancel() {
throw new Error('not used')
},
async toggleParticipant() {
throw new Error('not used')
}
}
registerPurchaseTopicIngestion(bot, config, repository)
await bot.handleUpdate(purchaseUpdate('Bought toilet paper 30 gel') as never)
expect(calls).toHaveLength(1)
expect(calls[0]?.method).toBe('sendMessage')
expect(calls[0]?.payload).toMatchObject({
chat_id: Number(config.householdChatId),
reply_parameters: {
message_id: 55
},
text: `I think this shared purchase was: toilet paper - 30.00 GEL.
Participants:
- Mia
- Dima (excluded)
Confirm or cancel below.`,
reply_markup: {
inline_keyboard: [
[
{
text: '✅ Mia',
callback_data: 'purchase:participant:participant-1'
}
],
[
{
text: '⬜ Dima',
callback_data: 'purchase:participant:participant-2'
}
],
[
{
text: 'Confirm',
callback_data: 'purchase:confirm:proposal-1'
},
{
text: 'Cancel',
callback_data: 'purchase:cancel:proposal-1'
}
]
]
}
})
})
test('replies with a clarification question for ambiguous purchases', async () => {
const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = []
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
return {
ok: true,
result: {
message_id: calls.length,
date: Math.floor(Date.now() / 1000),
chat: {
id: Number(config.householdChatId),
type: 'supergroup'
},
text: 'ok'
}
} as never
})
const repository: PurchaseMessageIngestionRepository = {
async hasClarificationContext() {
return false
},
async save() {
return {
status: 'clarification_needed',
purchaseMessageId: 'proposal-1',
clarificationQuestion: 'Which currency was this purchase in?',
parsedAmountMinor: 3000n,
parsedCurrency: null,
parsedItemDescription: 'toilet paper',
parserConfidence: 52,
parserMode: 'llm'
}
},
async confirm() {
throw new Error('not used')
},
async cancel() {
throw new Error('not used')
},
async toggleParticipant() {
throw new Error('not used')
}
}
registerPurchaseTopicIngestion(bot, config, repository)
await bot.handleUpdate(purchaseUpdate('Bought toilet paper for 30') as never)
expect(calls).toHaveLength(1)
expect(calls[0]?.payload).toMatchObject({
text: 'Which currency was this purchase in?'
})
})
test('keeps bare-amount purchase reports on the ingestion path', async () => {
const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = []
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
if (method === 'sendMessage') {
return {
ok: true,
result: {
message_id: calls.length,
date: Math.floor(Date.now() / 1000),
chat: {
id: Number(config.householdChatId),
type: 'supergroup'
},
text: (payload as { text?: string }).text ?? 'ok'
}
} as never
}
return {
ok: true,
result: true
} as never
})
const repository: PurchaseMessageIngestionRepository = {
async hasClarificationContext() {
return false
},
async save(record) {
expect(record.rawText).toBe('Bought toilet paper 30')
return {
status: 'clarification_needed',
purchaseMessageId: 'proposal-amount-only',
clarificationQuestion: 'Which currency was this purchase in?',
parsedAmountMinor: 3000n,
parsedCurrency: null,
parsedItemDescription: 'toilet paper',
parserConfidence: 58,
parserMode: 'llm'
}
},
async confirm() {
throw new Error('not used')
},
async cancel() {
throw new Error('not used')
},
async toggleParticipant() {
throw new Error('not used')
}
}
registerPurchaseTopicIngestion(bot, config, repository, {
interpreter: async () => ({
decision: 'clarification',
amountMinor: 3000n,
currency: null,
itemDescription: 'toilet paper',
confidence: 58,
parserMode: 'llm',
clarificationQuestion: 'Which currency was this purchase in?'
})
})
await bot.handleUpdate(purchaseUpdate('Bought toilet paper 30') as never)
expect(calls).toHaveLength(3)
expect(calls[0]).toMatchObject({
method: 'sendChatAction'
})
expect(calls[1]).toMatchObject({
method: 'sendMessage',
payload: {
text: 'Checking that purchase...'
}
})
expect(calls[2]).toMatchObject({
method: 'editMessageText',
payload: {
text: 'Which currency was this purchase in?'
}
})
})
test('sends a processing reply and edits it when an interpreter is configured', async () => {
const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = []
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
if (method === 'sendMessage') {
return {
ok: true,
result: {
message_id: calls.length,
date: Math.floor(Date.now() / 1000),
chat: {
id: Number(config.householdChatId),
type: 'supergroup'
},
text: (payload as { text?: string }).text ?? 'ok'
}
} as never
}
return {
ok: true,
result: true
} as never
})
const repository: PurchaseMessageIngestionRepository = {
async hasClarificationContext() {
return false
},
async save() {
return {
status: 'pending_confirmation',
purchaseMessageId: 'proposal-1',
parsedAmountMinor: 3000n,
parsedCurrency: 'GEL',
parsedItemDescription: 'toilet paper',
parserConfidence: 92,
parserMode: 'llm',
participants: participants()
}
},
async confirm() {
throw new Error('not used')
},
async cancel() {
throw new Error('not used')
},
async toggleParticipant() {
throw new Error('not used')
}
}
registerPurchaseTopicIngestion(bot, config, repository, {
interpreter: async () => ({
decision: 'purchase',
amountMinor: 3000n,
currency: 'GEL',
itemDescription: 'toilet paper',
confidence: 92,
parserMode: 'llm',
clarificationQuestion: null
})
})
await bot.handleUpdate(purchaseUpdate('Bought toilet paper 30 gel') as never)
expect(calls).toHaveLength(3)
expect(calls[0]).toMatchObject({
method: 'sendChatAction',
payload: {
chat_id: Number(config.householdChatId),
action: 'typing',
message_thread_id: config.purchaseTopicId
}
})
expect(calls[1]).toMatchObject({
method: 'sendMessage',
payload: {
chat_id: Number(config.householdChatId),
text: 'Checking that purchase...',
reply_parameters: {
message_id: 55
}
}
})
expect(calls[2]).toMatchObject({
method: 'editMessageText',
payload: {
chat_id: Number(config.householdChatId),
message_id: 2,
text: `I think this shared purchase was: toilet paper - 30.00 GEL.
Participants:
- Mia
- Dima (excluded)
Confirm or cancel below.`,
reply_markup: {
inline_keyboard: [
[
{
text: '✅ Mia',
callback_data: 'purchase:participant:participant-1'
}
],
[
{
text: '⬜ Dima',
callback_data: 'purchase:participant:participant-2'
}
],
[
{
text: 'Confirm',
callback_data: 'purchase:confirm:proposal-1'
},
{
text: 'Cancel',
callback_data: 'purchase:cancel:proposal-1'
}
]
]
}
}
})
})
test('stays silent for planning chatter even when an interpreter is configured', async () => {
const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = []
let saveCalls = 0
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
return {
ok: true,
result: true
} as never
})
const repository: PurchaseMessageIngestionRepository = {
async hasClarificationContext() {
return false
},
async save() {
saveCalls += 1
return {
status: 'ignored_not_purchase',
purchaseMessageId: 'ignored-1'
}
},
async confirm() {
throw new Error('not used')
},
async cancel() {
throw new Error('not used')
},
async toggleParticipant() {
throw new Error('not used')
}
}
registerPurchaseTopicIngestion(bot, config, repository, {
interpreter: async () => ({
decision: 'not_purchase',
amountMinor: null,
currency: null,
itemDescription: null,
confidence: 12,
parserMode: 'llm',
clarificationQuestion: null
})
})
await bot.handleUpdate(purchaseUpdate('We should buy toilet paper for 30 gel') as never)
expect(saveCalls).toBe(0)
expect(calls).toHaveLength(0)
})
test('treats colloquial completed purchase reports as likely purchases', async () => {
const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = []
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
if (method === 'sendMessage') {
return {
ok: true,
result: {
message_id: calls.length,
date: Math.floor(Date.now() / 1000),
chat: {
id: Number(config.householdChatId),
type: 'supergroup'
},
text: (payload as { text?: string }).text ?? 'ok'
}
} as never
}
return {
ok: true,
result: true
} as never
})
const repository: PurchaseMessageIngestionRepository = {
async hasClarificationContext() {
return false
},
async save(record) {
expect(record.rawText).toBe(
'Короч, сходил на рынок и взял этот долбаный ковер. Сторговался до 150 лари'
)
return {
status: 'pending_confirmation',
purchaseMessageId: 'proposal-carpet',
parsedAmountMinor: 15000n,
parsedCurrency: 'GEL',
parsedItemDescription: 'ковер',
parserConfidence: 91,
parserMode: 'llm',
participants: participants()
}
},
async confirm() {
throw new Error('not used')
},
async cancel() {
throw new Error('not used')
},
async toggleParticipant() {
throw new Error('not used')
}
}
registerPurchaseTopicIngestion(bot, config, repository, {
interpreter: async () => ({
decision: 'purchase',
amountMinor: 15000n,
currency: 'GEL',
itemDescription: 'ковер',
confidence: 91,
parserMode: 'llm',
clarificationQuestion: null
})
})
await bot.handleUpdate(
purchaseUpdate(
'Короч, сходил на рынок и взял этот долбаный ковер. Сторговался до 150 лари'
) as never
)
expect(calls).toHaveLength(3)
expect(calls[1]).toMatchObject({
method: 'sendMessage',
payload: {
text: 'Checking that purchase...'
}
})
expect(calls[2]).toMatchObject({
method: 'editMessageText',
payload: {
text: `I think this shared purchase was: ковер - 150.00 GEL.
Participants:
- Mia
- Dima (excluded)
Confirm or cancel below.`
}
})
})
test('uses dedicated buttons for calculated totals', async () => {
const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = []
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
if (method === 'sendMessage') {
return {
ok: true,
result: {
message_id: calls.length,
date: Math.floor(Date.now() / 1000),
chat: {
id: Number(config.householdChatId),
type: 'supergroup'
},
text: (payload as { text?: string }).text ?? 'ok'
}
} as never
}
return {
ok: true,
result: true
} as never
})
const repository: PurchaseMessageIngestionRepository = {
async hasClarificationContext() {
return false
},
async save() {
return {
status: 'pending_confirmation',
purchaseMessageId: 'proposal-calculated',
parsedAmountMinor: 3000n,
parsedCurrency: 'GEL',
parsedItemDescription: 'water bottles',
amountSource: 'calculated',
calculationExplanation: '5 x 6 lari = 30 lari',
parserConfidence: 94,
parserMode: 'llm',
participants: participants()
}
},
async confirm() {
throw new Error('not used')
},
async cancel() {
throw new Error('not used')
},
async toggleParticipant() {
throw new Error('not used')
}
}
registerPurchaseTopicIngestion(bot, config, repository, {
interpreter: async () => ({
decision: 'purchase',
amountMinor: 3000n,
currency: 'GEL',
itemDescription: 'water bottles',
amountSource: 'calculated',
calculationExplanation: '5 x 6 lari = 30 lari',
confidence: 94,
parserMode: 'llm',
clarificationQuestion: null
})
})
await bot.handleUpdate(purchaseUpdate('Bought 5 bottles of water, 6 lari each') as never)
expect(calls[2]).toMatchObject({
method: 'editMessageText',
payload: {
reply_markup: {
inline_keyboard: [
[
{
text: '✅ Mia',
callback_data: 'purchase:participant:participant-1'
}
],
[
{
text: '⬜ Dima',
callback_data: 'purchase:participant:participant-2'
}
],
[
{
text: 'Looks right',
callback_data: 'purchase:confirm:proposal-calculated'
},
{
text: 'Fix amount',
callback_data: 'purchase:fix_amount:proposal-calculated'
},
{
text: 'Cancel',
callback_data: 'purchase:cancel:proposal-calculated'
}
]
]
}
}
})
})
test('stays silent for stray amount chatter in the purchase topic', async () => {
const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = []
let saveCalls = 0
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
return {
ok: true,
result: true
} as never
})
const repository: PurchaseMessageIngestionRepository = {
async hasClarificationContext() {
return false
},
async save() {
saveCalls += 1
return {
status: 'ignored_not_purchase',
purchaseMessageId: 'ignored-2'
}
},
async confirm() {
throw new Error('not used')
},
async cancel() {
throw new Error('not used')
},
async toggleParticipant() {
throw new Error('not used')
}
}
registerPurchaseTopicIngestion(bot, config, repository, {
interpreter: async () => ({
decision: 'not_purchase',
amountMinor: null,
currency: null,
itemDescription: null,
confidence: 17,
parserMode: 'llm',
clarificationQuestion: null
})
})
await bot.handleUpdate(purchaseUpdate('This machine costs 300 gel, scary') as never)
expect(saveCalls).toBe(0)
expect(calls).toHaveLength(0)
})
test('does not reply for duplicate deliveries or non-purchase chatter', async () => {
const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = []
let saveCall = 0
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
return {
ok: true,
result: true
} as never
})
const repository: PurchaseMessageIngestionRepository = {
async hasClarificationContext() {
return false
},
async save() {
saveCall += 1
return saveCall === 1
? {
status: 'duplicate' as const
}
: {
status: 'ignored_not_purchase' as const,
purchaseMessageId: 'proposal-1'
}
},
async confirm() {
throw new Error('not used')
},
async cancel() {
throw new Error('not used')
},
async toggleParticipant() {
throw new Error('not used')
}
}
registerPurchaseTopicIngestion(bot, config, repository)
await bot.handleUpdate(purchaseUpdate('Bought toilet paper 30 gel') as never)
await bot.handleUpdate(purchaseUpdate('This is not a purchase') as never)
expect(calls).toHaveLength(0)
})
test('skips explicitly tagged bot messages in the purchase topic', async () => {
const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = []
let saveCalls = 0
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
return {
ok: true,
result: true
} as never
})
const repository: PurchaseMessageIngestionRepository = {
async hasClarificationContext() {
return false
},
async save() {
saveCalls += 1
return {
status: 'ignored_not_purchase' as const,
purchaseMessageId: 'ignored-1'
}
},
async confirm() {
throw new Error('not used')
},
async cancel() {
throw new Error('not used')
},
async toggleParticipant() {
throw new Error('not used')
}
}
registerPurchaseTopicIngestion(bot, config, repository)
await bot.handleUpdate(purchaseUpdate('@household_test_bot how is life?') as never)
expect(saveCalls).toBe(1)
expect(calls).toHaveLength(0)
})
test('still handles tagged purchase-like messages in the purchase topic', async () => {
const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = []
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
return {
ok: true,
result: true
} as never
})
const repository: PurchaseMessageIngestionRepository = {
async hasClarificationContext() {
return false
},
async save(record) {
expect(record.rawText).toBe('Bought toilet paper 30 gel')
return {
status: 'pending_confirmation',
purchaseMessageId: 'proposal-1',
parsedAmountMinor: 3000n,
parsedCurrency: 'GEL',
parsedItemDescription: 'toilet paper',
parserConfidence: 92,
parserMode: 'llm',
participants: participants()
}
},
async confirm() {
throw new Error('not used')
},
async cancel() {
throw new Error('not used')
},
async toggleParticipant() {
throw new Error('not used')
}
}
registerPurchaseTopicIngestion(bot, config, repository)
await bot.handleUpdate(
purchaseUpdate('@household_test_bot Bought toilet paper 30 gel') as never
)
expect(calls).toHaveLength(1)
expect(calls[0]).toMatchObject({
method: 'sendMessage',
payload: {
text: `I think this shared purchase was: toilet paper - 30.00 GEL.
Participants:
- Mia
- Dima (excluded)
Confirm or cancel below.`
}
})
})
test('does not send the purchase handoff for tagged non-purchase conversation', async () => {
const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = []
let saveCalls = 0
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
return {
ok: true,
result: true
} as never
})
const repository: PurchaseMessageIngestionRepository = {
async hasClarificationContext() {
return false
},
async save() {
saveCalls += 1
return {
status: 'ignored_not_purchase',
purchaseMessageId: 'ignored-3'
}
},
async confirm() {
throw new Error('not used')
},
async cancel() {
throw new Error('not used')
},
async toggleParticipant() {
throw new Error('not used')
}
}
registerPurchaseTopicIngestion(bot, config, repository, {
interpreter: async () => ({
decision: 'not_purchase',
amountMinor: null,
currency: null,
itemDescription: null,
confidence: 19,
parserMode: 'llm',
clarificationQuestion: null
})
})
await bot.handleUpdate(purchaseUpdate('@household_test_bot please ignore me today') as never)
expect(saveCalls).toBe(1)
expect(calls).toHaveLength(1)
expect(calls[0]).toMatchObject({
method: 'sendChatAction',
payload: {
chat_id: Number(config.householdChatId),
action: 'typing',
message_thread_id: config.purchaseTopicId
}
})
})
test('replies playfully to addressed banter with router and skips purchase save', async () => {
const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = []
let saveCalls = 0
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
return {
ok: true,
result: {
message_id: calls.length,
date: Math.floor(Date.now() / 1000),
chat: {
id: Number(config.householdChatId),
type: 'supergroup'
},
text: 'ok'
}
} as never
})
const repository: PurchaseMessageIngestionRepository = {
async hasClarificationContext() {
return false
},
async save() {
saveCalls += 1
throw new Error('not used')
},
async confirm() {
throw new Error('not used')
},
async cancel() {
throw new Error('not used')
},
async toggleParticipant() {
throw new Error('not used')
}
}
registerPurchaseTopicIngestion(bot, config, repository, {
router: async () => ({
route: 'chat_reply',
replyText: 'Тут. Если что-то реально купили, подключусь.',
helperKind: null,
shouldStartTyping: false,
shouldClearWorkflow: false,
confidence: 95,
reason: 'smalltalk'
})
})
await bot.handleUpdate(purchaseUpdate('@household_test_bot А ты тут?') as never)
expect(saveCalls).toBe(0)
expect(calls).toHaveLength(1)
expect(calls[0]).toMatchObject({
method: 'sendMessage',
payload: {
text: 'Тут. Если что-то реально купили, подключусь.'
}
})
})
test('clears active purchase clarification when router dismisses the workflow', async () => {
const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = []
let clearCalls = 0
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
return {
ok: true,
result: {
message_id: calls.length,
date: Math.floor(Date.now() / 1000),
chat: {
id: Number(config.householdChatId),
type: 'supergroup'
},
text: 'ok'
}
} as never
})
const repository: PurchaseMessageIngestionRepository = {
async hasClarificationContext() {
return true
},
async clearClarificationContext() {
clearCalls += 1
},
async save() {
throw new Error('not used')
},
async confirm() {
throw new Error('not used')
},
async cancel() {
throw new Error('not used')
},
async toggleParticipant() {
throw new Error('not used')
}
}
registerPurchaseTopicIngestion(bot, config, repository, {
router: async () => ({
route: 'dismiss_workflow',
replyText: 'Окей, молчу.',
helperKind: null,
shouldStartTyping: false,
shouldClearWorkflow: true,
confidence: 98,
reason: 'backoff'
})
})
await bot.handleUpdate(purchaseUpdate('Отстань') as never)
expect(clearCalls).toBe(1)
expect(calls).toHaveLength(1)
expect(calls[0]).toMatchObject({
method: 'sendMessage',
payload: {
text: 'Окей, молчу.'
}
})
})
test('continues purchase handling for replies to bot messages without a fresh mention', async () => {
const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = []
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
return {
ok: true,
result: {
message_id: calls.length,
date: Math.floor(Date.now() / 1000),
chat: {
id: Number(config.householdChatId),
type: 'supergroup'
},
text: 'ok'
}
} as never
})
const repository: PurchaseMessageIngestionRepository = {
async hasClarificationContext() {
return false
},
async save(record) {
expect(record.rawText).toBe('Actually it was 32 gel')
return {
status: 'clarification_needed',
purchaseMessageId: 'proposal-2',
clarificationQuestion: 'Was that for toilet paper?',
parsedAmountMinor: 3200n,
parsedCurrency: 'GEL',
parsedItemDescription: null,
parserConfidence: 61,
parserMode: 'llm'
}
},
async confirm() {
throw new Error('not used')
},
async cancel() {
throw new Error('not used')
},
async toggleParticipant() {
throw new Error('not used')
}
}
registerPurchaseTopicIngestion(bot, config, repository, {
interpreter: async () => ({
decision: 'clarification',
amountMinor: 3200n,
currency: 'GEL',
itemDescription: null,
confidence: 61,
parserMode: 'llm',
clarificationQuestion: 'Was that for toilet paper?'
})
})
await bot.handleUpdate(purchaseUpdate('Actually it was 32 gel', { replyToBot: true }) as never)
expect(calls).toHaveLength(2)
expect(calls[0]).toMatchObject({
method: 'sendChatAction'
})
expect(calls[1]).toMatchObject({
method: 'sendMessage',
payload: {
text: 'Was that for toilet paper?'
}
})
})
test('continues purchase handling for active clarification context without a fresh mention', async () => {
const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = []
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
return {
ok: true,
result: {
message_id: calls.length,
date: Math.floor(Date.now() / 1000),
chat: {
id: Number(config.householdChatId),
type: 'supergroup'
},
text: 'ok'
}
} as never
})
const repository: PurchaseMessageIngestionRepository = {
async hasClarificationContext() {
return true
},
async save(record) {
expect(record.rawText).toBe('32 gel')
return {
status: 'clarification_needed',
purchaseMessageId: 'proposal-3',
clarificationQuestion: 'What item was that for?',
parsedAmountMinor: 3200n,
parsedCurrency: 'GEL',
parsedItemDescription: null,
parserConfidence: 58,
parserMode: 'llm'
}
},
async confirm() {
throw new Error('not used')
},
async cancel() {
throw new Error('not used')
},
async toggleParticipant() {
throw new Error('not used')
}
}
registerPurchaseTopicIngestion(bot, config, repository, {
interpreter: async () => ({
decision: 'clarification',
amountMinor: 3200n,
currency: 'GEL',
itemDescription: null,
confidence: 58,
parserMode: 'llm',
clarificationQuestion: 'What item was that for?'
})
})
await bot.handleUpdate(purchaseUpdate('32 gel') as never)
expect(calls).toHaveLength(2)
expect(calls[0]).toMatchObject({
method: 'sendChatAction'
})
expect(calls[1]).toMatchObject({
method: 'sendMessage',
payload: {
text: 'What item was that for?'
}
})
})
test('toggles purchase participants before confirmation', async () => {
const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = []
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
return {
ok: true,
result: true
} as never
})
const repository: PurchaseMessageIngestionRepository = {
async hasClarificationContext() {
return false
},
async save() {
throw new Error('not used')
},
async confirm() {
throw new Error('not used')
},
async cancel() {
throw new Error('not used')
},
async toggleParticipant() {
return {
status: 'updated' as const,
purchaseMessageId: 'proposal-1',
householdId: config.householdId,
parsedAmountMinor: 3000n,
parsedCurrency: 'GEL' as const,
parsedItemDescription: 'toilet paper',
parserConfidence: 92,
parserMode: 'llm' as const,
participants: [
{
id: 'participant-1',
memberId: 'member-1',
displayName: 'Mia',
included: true
},
{
id: 'participant-2',
memberId: 'member-2',
displayName: 'Dima',
included: true
}
]
}
}
}
registerPurchaseTopicIngestion(bot, config, repository)
await bot.handleUpdate(callbackUpdate('purchase:participant:participant-2') as never)
expect(calls).toHaveLength(2)
expect(calls[0]).toMatchObject({
method: 'answerCallbackQuery',
payload: {
callback_query_id: 'callback-1'
}
})
expect(calls[1]).toMatchObject({
method: 'editMessageText',
payload: {
text: `I think this shared purchase was: toilet paper - 30.00 GEL.
Participants:
- Mia
- Dima
Confirm or cancel below.`,
reply_markup: {
inline_keyboard: [
[
{
text: '✅ Mia',
callback_data: 'purchase:participant:participant-1'
}
],
[
{
text: '✅ Dima',
callback_data: 'purchase:participant:participant-2'
}
],
[
{
text: 'Confirm',
callback_data: 'purchase:confirm:proposal-1'
},
{
text: 'Cancel',
callback_data: 'purchase:cancel:proposal-1'
}
]
]
}
}
})
})
test('blocks removing the last included participant', async () => {
const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = []
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
return {
ok: true,
result: true
} as never
})
const repository: PurchaseMessageIngestionRepository = {
async hasClarificationContext() {
return false
},
async save() {
throw new Error('not used')
},
async confirm() {
throw new Error('not used')
},
async cancel() {
throw new Error('not used')
},
async toggleParticipant() {
return {
status: 'at_least_one_required' as const,
householdId: config.householdId
}
}
}
registerPurchaseTopicIngestion(bot, config, repository)
await bot.handleUpdate(callbackUpdate('purchase:participant:participant-1') as never)
expect(calls).toHaveLength(1)
expect(calls[0]).toMatchObject({
method: 'answerCallbackQuery',
payload: {
callback_query_id: 'callback-1',
text: 'Keep at least one participant in the purchase split.',
show_alert: true
}
})
})
test('uses recent silent planning context for direct bot-address advice replies', async () => {
const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = []
const historyRepository = createTopicMessageHistoryRepository()
let sawDirectAddress = false
let recentTurnTexts: string[] = []
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
return {
ok: true,
result: true
} as never
})
const repository: PurchaseMessageIngestionRepository = {
async hasClarificationContext() {
return false
},
async save() {
throw new Error('not used')
},
async confirm() {
throw new Error('not used')
},
async cancel() {
throw new Error('not used')
},
async toggleParticipant() {
throw new Error('not used')
}
}
registerPurchaseTopicIngestion(bot, config, repository, {
historyRepository,
router: async (input) => {
if (input.messageText.includes('думаю купить')) {
return {
route: 'silent',
replyText: null,
helperKind: null,
shouldStartTyping: false,
shouldClearWorkflow: false,
confidence: 90,
reason: 'planning'
}
}
sawDirectAddress = input.isExplicitMention
recentTurnTexts = input.recentThreadMessages?.map((turn) => turn.text) ?? []
return {
route: 'chat_reply',
replyText: 'Если 5 кг стоят 20 лари, это 4 лари за кило. Я бы еще сравнил цену.',
helperKind: 'assistant',
shouldStartTyping: false,
shouldClearWorkflow: false,
confidence: 92,
reason: 'planning_advice'
}
}
})
await bot.handleUpdate(
purchaseUpdate('В общем, думаю купить 5 килограмм картошки за 20 лари') as never
)
await bot.handleUpdate(purchaseUpdate('Бот, что думаешь?') as never)
expect(sawDirectAddress).toBe(true)
expect(recentTurnTexts).toContain('В общем, думаю купить 5 килограмм картошки за 20 лари')
expect(calls).toHaveLength(1)
expect(calls[0]).toMatchObject({
method: 'sendMessage',
payload: {
text: 'Если 5 кг стоят 20 лари, это 4 лари за кило. Я бы еще сравнил цену.'
}
})
})
test('does not treat ordinary bot nouns as direct address', async () => {
const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = []
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
return {
ok: true,
result: true
} as never
})
const repository: PurchaseMessageIngestionRepository = {
async hasClarificationContext() {
return false
},
async save() {
throw new Error('not used')
},
async confirm() {
throw new Error('not used')
},
async cancel() {
throw new Error('not used')
},
async toggleParticipant() {
throw new Error('not used')
}
}
registerPurchaseTopicIngestion(bot, config, repository, {
router: async (input) => ({
route: input.isExplicitMention ? 'chat_reply' : 'silent',
replyText: input.isExplicitMention ? 'heard you' : null,
helperKind: input.isExplicitMention ? 'assistant' : null,
shouldStartTyping: false,
shouldClearWorkflow: false,
confidence: 90,
reason: 'test'
})
})
await bot.handleUpdate(purchaseUpdate('Думаю купить bot vacuum за 200 лари') as never)
expect(calls).toHaveLength(0)
})
test('keeps silent planning context scoped to the current purchase thread', async () => {
const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = []
const historyRepository = createTopicMessageHistoryRepository()
let recentTurnTexts: string[] = []
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
return {
ok: true,
result: true
} as never
})
const repository: PurchaseMessageIngestionRepository = {
async hasClarificationContext() {
return false
},
async save() {
throw new Error('not used')
},
async confirm() {
throw new Error('not used')
},
async cancel() {
throw new Error('not used')
},
async toggleParticipant() {
throw new Error('not used')
}
}
const householdConfigurationRepository = {
findHouseholdTopicByTelegramContext: async ({
telegramThreadId
}: {
telegramThreadId: string
}) => ({
householdId: config.householdId,
telegramThreadId,
role: 'purchase' as const,
topicName: null
}),
getHouseholdBillingSettings: async () => ({
householdId: config.householdId,
paymentBalanceAdjustmentPolicy: 'utilities' as const,
rentAmountMinor: null,
rentCurrency: 'USD' as const,
rentDueDay: 4,
rentWarningDay: 2,
utilitiesDueDay: 12,
utilitiesReminderDay: 10,
timezone: 'Asia/Tbilisi',
settlementCurrency: 'GEL' as const
}),
getHouseholdChatByHouseholdId: async () => ({
householdId: config.householdId,
householdName: 'Test household',
telegramChatId: config.householdChatId,
telegramChatType: 'supergroup',
title: 'Test household',
defaultLocale: 'en' as const
}),
getHouseholdAssistantConfig: async () => ({
householdId: config.householdId,
assistantContext: null,
assistantTone: null
})
} satisfies Pick<
HouseholdConfigurationRepository,
| 'findHouseholdTopicByTelegramContext'
| 'getHouseholdBillingSettings'
| 'getHouseholdChatByHouseholdId'
| 'getHouseholdAssistantConfig'
>
registerConfiguredPurchaseTopicIngestion(
bot,
householdConfigurationRepository as unknown as HouseholdConfigurationRepository,
repository,
{
historyRepository,
router: async (input) => {
if (input.messageText.includes('картошки')) {
return {
route: 'silent',
replyText: null,
helperKind: null,
shouldStartTyping: false,
shouldClearWorkflow: false,
confidence: 90,
reason: 'planning'
}
}
recentTurnTexts = input.recentThreadMessages?.map((turn) => turn.text) ?? []
return {
route: 'chat_reply',
replyText: 'No leaked context here.',
helperKind: 'assistant',
shouldStartTyping: false,
shouldClearWorkflow: false,
confidence: 91,
reason: 'thread_scoped'
}
}
}
)
await bot.handleUpdate(
purchaseUpdate('Думаю купить 5 килограмм картошки за 20 лари', { threadId: 777 }) as never
)
await bot.handleUpdate(purchaseUpdate('Бот, что думаешь?', { threadId: 778 }) as never)
expect(recentTurnTexts).not.toContain('Думаю купить 5 килограмм картошки за 20 лари')
expect(calls).toHaveLength(1)
expect(calls[0]).toMatchObject({
method: 'sendMessage',
payload: {
text: 'No leaked context here.'
}
})
})
test('confirms a pending proposal and edits the bot message', async () => {
const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = []
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
return {
ok: true,
result: true
} as never
})
const repository: PurchaseMessageIngestionRepository = {
async hasClarificationContext() {
return false
},
async save() {
return {
status: 'pending_confirmation',
purchaseMessageId: 'proposal-1',
parsedAmountMinor: 3000n,
parsedCurrency: 'GEL',
parsedItemDescription: 'toilet paper',
parserConfidence: 92,
parserMode: 'llm',
participants: participants()
}
},
async confirm() {
return {
status: 'confirmed' as const,
purchaseMessageId: 'proposal-1',
householdId: config.householdId,
parsedAmountMinor: 3000n,
parsedCurrency: 'GEL' as const,
parsedItemDescription: 'toilet paper',
parserConfidence: 92,
parserMode: 'llm' as const,
participants: participants()
}
},
async cancel() {
throw new Error('not used')
},
async toggleParticipant() {
throw new Error('not used')
}
}
registerPurchaseTopicIngestion(bot, config, repository)
await bot.handleUpdate(callbackUpdate('purchase:confirm:proposal-1') as never)
expect(calls).toHaveLength(2)
expect(calls[0]).toMatchObject({
method: 'answerCallbackQuery',
payload: {
callback_query_id: 'callback-1',
text: 'Purchase confirmed.'
}
})
expect(calls[1]).toMatchObject({
method: 'editMessageText',
payload: {
chat_id: Number(config.householdChatId),
message_id: 77,
text: `Purchase confirmed: toilet paper - 30.00 GEL
Participants:
- Mia
- Dima (excluded)`,
reply_markup: {
inline_keyboard: []
}
}
})
})
test('requests amount correction for calculated purchase proposals', async () => {
const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = []
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
return {
ok: true,
result: true
} as never
})
const repository: PurchaseMessageIngestionRepository = {
async hasClarificationContext() {
return false
},
async save() {
throw new Error('not used')
},
async confirm() {
throw new Error('not used')
},
async cancel() {
throw new Error('not used')
},
async toggleParticipant() {
throw new Error('not used')
},
async requestAmountCorrection() {
return {
status: 'requested',
purchaseMessageId: 'proposal-1',
householdId: config.householdId
}
}
}
registerPurchaseTopicIngestion(bot, config, repository)
await bot.handleUpdate(callbackUpdate('purchase:fix_amount:proposal-1') as never)
expect(calls).toHaveLength(2)
expect(calls[1]).toMatchObject({
method: 'editMessageText',
payload: {
text: 'Reply with the corrected total and currency in this topic, and I will re-check the purchase.',
reply_markup: {
inline_keyboard: []
}
}
})
})
test('handles duplicate confirm callbacks idempotently', async () => {
const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = []
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
return {
ok: true,
result: true
} as never
})
const repository: PurchaseMessageIngestionRepository = {
async hasClarificationContext() {
return false
},
async save() {
throw new Error('not used')
},
async confirm() {
return {
status: 'already_confirmed' as const,
purchaseMessageId: 'proposal-1',
householdId: config.householdId,
parsedAmountMinor: 3000n,
parsedCurrency: 'GEL' as const,
parsedItemDescription: 'toilet paper',
parserConfidence: 92,
parserMode: 'llm' as const,
participants: participants()
}
},
async cancel() {
throw new Error('not used')
},
async toggleParticipant() {
throw new Error('not used')
}
}
registerPurchaseTopicIngestion(bot, config, repository)
await bot.handleUpdate(callbackUpdate('purchase:confirm:proposal-1') as never)
expect(calls[0]).toMatchObject({
method: 'answerCallbackQuery',
payload: {
callback_query_id: 'callback-1',
text: 'This purchase was already confirmed.'
}
})
expect(calls[1]).toMatchObject({
method: 'editMessageText',
payload: {
text: `Purchase confirmed: toilet paper - 30.00 GEL
Participants:
- Mia
- Dima (excluded)`
}
})
})
test('cancels a pending proposal and edits the bot message', async () => {
const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = []
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
return {
ok: true,
result: true
} as never
})
const repository: PurchaseMessageIngestionRepository = {
async hasClarificationContext() {
return false
},
async save() {
throw new Error('not used')
},
async confirm() {
throw new Error('not used')
},
async cancel() {
return {
status: 'cancelled' as const,
purchaseMessageId: 'proposal-1',
householdId: config.householdId,
parsedAmountMinor: 3000n,
parsedCurrency: 'GEL' as const,
parsedItemDescription: 'toilet paper',
parserConfidence: 92,
parserMode: 'llm' as const,
participants: participants()
}
},
async toggleParticipant() {
throw new Error('not used')
}
}
registerPurchaseTopicIngestion(bot, config, repository)
await bot.handleUpdate(callbackUpdate('purchase:cancel:proposal-1') as never)
expect(calls[0]).toMatchObject({
method: 'answerCallbackQuery',
payload: {
callback_query_id: 'callback-1',
text: 'Purchase cancelled.'
}
})
expect(calls[1]).toMatchObject({
method: 'editMessageText',
payload: {
text: 'Purchase proposal cancelled: toilet paper - 30.00 GEL'
}
})
})
})