mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 20:14:02 +00:00
feat(bot): add reminder utility entry flow
This commit is contained in:
445
apps/bot/src/reminder-topic-utilities.test.ts
Normal file
445
apps/bot/src/reminder-topic-utilities.test.ts
Normal file
@@ -0,0 +1,445 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import type { FinanceCommandService } from '@household/application'
|
||||
import { instantFromIso, nowInstant } from '@household/domain'
|
||||
import type { TelegramPendingActionRecord, TelegramPendingActionRepository } from '@household/ports'
|
||||
|
||||
import { createTelegramBot } from './bot'
|
||||
import {
|
||||
registerReminderTopicUtilities,
|
||||
REMINDER_UTILITY_GUIDED_CALLBACK,
|
||||
REMINDER_UTILITY_TEMPLATE_CALLBACK
|
||||
} from './reminder-topic-utilities'
|
||||
|
||||
function reminderCallbackUpdate(data: string, fromId = 10002) {
|
||||
return {
|
||||
update_id: 2001,
|
||||
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),
|
||||
message_thread_id: 555,
|
||||
is_topic_message: true,
|
||||
chat: {
|
||||
id: -10012345,
|
||||
type: 'supergroup'
|
||||
},
|
||||
text: 'Utilities reminder'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function reminderMessageUpdate(text: string, fromId = 10002) {
|
||||
return {
|
||||
update_id: 2002,
|
||||
message: {
|
||||
message_id: 88,
|
||||
date: Math.floor(Date.now() / 1000),
|
||||
message_thread_id: 555,
|
||||
is_topic_message: true,
|
||||
chat: {
|
||||
id: -10012345,
|
||||
type: 'supergroup'
|
||||
},
|
||||
from: {
|
||||
id: fromId,
|
||||
is_bot: false,
|
||||
first_name: 'Mia'
|
||||
},
|
||||
text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createPromptRepository(): TelegramPendingActionRepository & {
|
||||
current: () => TelegramPendingActionRecord | null
|
||||
expire: () => void
|
||||
} {
|
||||
let pending: TelegramPendingActionRecord | null = null
|
||||
|
||||
return {
|
||||
current: () => pending,
|
||||
expire: () => {
|
||||
if (!pending) {
|
||||
return
|
||||
}
|
||||
|
||||
pending = {
|
||||
...pending,
|
||||
expiresAt: instantFromIso('2000-01-01T00:00:00.000Z')
|
||||
}
|
||||
},
|
||||
async upsertPendingAction(input) {
|
||||
pending = input
|
||||
return input
|
||||
},
|
||||
async getPendingAction() {
|
||||
if (!pending) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (
|
||||
pending.expiresAt &&
|
||||
pending.expiresAt.epochMilliseconds <= nowInstant().epochMilliseconds
|
||||
) {
|
||||
pending = null
|
||||
return null
|
||||
}
|
||||
|
||||
return pending
|
||||
},
|
||||
async clearPendingAction() {
|
||||
pending = null
|
||||
},
|
||||
async clearPendingActionsForChat(telegramChatId, action) {
|
||||
if (!pending || pending.telegramChatId !== telegramChatId) {
|
||||
return
|
||||
}
|
||||
|
||||
if (action && pending.action !== action) {
|
||||
return
|
||||
}
|
||||
|
||||
pending = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createHouseholdRepository() {
|
||||
return {
|
||||
getTelegramHouseholdChat: async () => ({
|
||||
householdId: 'household-1',
|
||||
householdName: 'Kojori House',
|
||||
telegramChatId: '-10012345',
|
||||
telegramChatType: 'supergroup',
|
||||
title: 'Kojori House',
|
||||
defaultLocale: 'ru' as const
|
||||
}),
|
||||
getHouseholdChatByHouseholdId: async () => ({
|
||||
householdId: 'household-1',
|
||||
householdName: 'Kojori House',
|
||||
telegramChatId: '-10012345',
|
||||
telegramChatType: 'supergroup',
|
||||
title: 'Kojori House',
|
||||
defaultLocale: 'ru' as const
|
||||
}),
|
||||
findHouseholdTopicByTelegramContext: async () => ({
|
||||
householdId: 'household-1',
|
||||
role: 'reminders' as const,
|
||||
telegramThreadId: '555',
|
||||
topicName: 'Напоминания'
|
||||
}),
|
||||
getHouseholdBillingSettings: async () => ({
|
||||
householdId: 'household-1',
|
||||
settlementCurrency: 'GEL' as const,
|
||||
paymentBalanceAdjustmentPolicy: 'utilities' as const,
|
||||
rentAmountMinor: null,
|
||||
rentCurrency: 'USD' as const,
|
||||
rentDueDay: 20,
|
||||
rentWarningDay: 17,
|
||||
utilitiesDueDay: 4,
|
||||
utilitiesReminderDay: 3,
|
||||
timezone: 'Asia/Tbilisi'
|
||||
}),
|
||||
listHouseholdUtilityCategories: async () => [
|
||||
{
|
||||
id: 'cat-1',
|
||||
householdId: 'household-1',
|
||||
slug: 'electricity',
|
||||
name: 'Electricity',
|
||||
sortOrder: 1,
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
id: 'cat-2',
|
||||
householdId: 'household-1',
|
||||
slug: 'water',
|
||||
name: 'Water',
|
||||
sortOrder: 2,
|
||||
isActive: true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function createFinanceService(): FinanceCommandService & {
|
||||
addedUtilityBills: Array<{
|
||||
billName: string
|
||||
amountMajor: string
|
||||
createdByMemberId: string
|
||||
currency?: string
|
||||
}>
|
||||
} {
|
||||
return {
|
||||
addedUtilityBills: [],
|
||||
getMemberByTelegramUserId: async () => ({
|
||||
id: 'member-1',
|
||||
telegramUserId: '10002',
|
||||
displayName: 'Mia',
|
||||
rentShareWeight: 1,
|
||||
isAdmin: false
|
||||
}),
|
||||
getOpenCycle: async () => null,
|
||||
ensureExpectedCycle: async () => ({
|
||||
id: 'cycle-1',
|
||||
period: '2026-03',
|
||||
currency: 'GEL'
|
||||
}),
|
||||
getAdminCycleState: async () => ({
|
||||
cycle: null,
|
||||
rentRule: null,
|
||||
utilityBills: []
|
||||
}),
|
||||
openCycle: async () => ({
|
||||
id: 'cycle-1',
|
||||
period: '2026-03',
|
||||
currency: 'GEL'
|
||||
}),
|
||||
closeCycle: async () => null,
|
||||
setRent: async () => null,
|
||||
addUtilityBill: async function (billName, amountMajor, createdByMemberId, currencyArg) {
|
||||
if (currencyArg) {
|
||||
this.addedUtilityBills.push({
|
||||
billName,
|
||||
amountMajor,
|
||||
createdByMemberId,
|
||||
currency: currencyArg
|
||||
})
|
||||
} else {
|
||||
this.addedUtilityBills.push({
|
||||
billName,
|
||||
amountMajor,
|
||||
createdByMemberId
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
amount: undefined as never,
|
||||
currency: 'GEL',
|
||||
period: '2026-03'
|
||||
}
|
||||
},
|
||||
updateUtilityBill: async () => null,
|
||||
deleteUtilityBill: async () => false,
|
||||
updatePurchase: async () => null,
|
||||
deletePurchase: async () => false,
|
||||
addPayment: async () => null,
|
||||
updatePayment: async () => null,
|
||||
deletePayment: async () => false,
|
||||
generateDashboard: async () => null,
|
||||
generateStatement: async () => null
|
||||
}
|
||||
}
|
||||
|
||||
function setupBot() {
|
||||
const bot = createTelegramBot('000000:test-token')
|
||||
const calls: Array<{ method: string; payload: unknown }> = []
|
||||
const promptRepository = createPromptRepository()
|
||||
const financeService = createFinanceService()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
bot.api.config.use(async (_prev, method, payload) => {
|
||||
calls.push({ method, payload })
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
result: true
|
||||
} as never
|
||||
})
|
||||
|
||||
registerReminderTopicUtilities({
|
||||
bot,
|
||||
householdConfigurationRepository: createHouseholdRepository() as never,
|
||||
promptRepository,
|
||||
financeServiceForHousehold: () => financeService
|
||||
})
|
||||
|
||||
return {
|
||||
bot,
|
||||
calls,
|
||||
promptRepository,
|
||||
financeService
|
||||
}
|
||||
}
|
||||
|
||||
describe('registerReminderTopicUtilities', () => {
|
||||
test('runs the guided reminder flow and records utility bills on confirmation', async () => {
|
||||
const { bot, calls, financeService, promptRepository } = setupBot()
|
||||
|
||||
await bot.handleUpdate(reminderCallbackUpdate(REMINDER_UTILITY_GUIDED_CALLBACK) as never)
|
||||
expect(calls[0]).toMatchObject({
|
||||
method: 'answerCallbackQuery',
|
||||
payload: {
|
||||
callback_query_id: 'callback-1',
|
||||
text: 'Пошаговый ввод коммуналки запущен.'
|
||||
}
|
||||
})
|
||||
expect(calls[1]).toMatchObject({
|
||||
method: 'sendMessage',
|
||||
payload: {
|
||||
text: expect.stringContaining('Electricity'),
|
||||
message_thread_id: 555
|
||||
}
|
||||
})
|
||||
|
||||
calls.length = 0
|
||||
await bot.handleUpdate(reminderMessageUpdate('55') as never)
|
||||
expect(calls[0]).toMatchObject({
|
||||
method: 'sendMessage',
|
||||
payload: {
|
||||
text: expect.stringContaining('Water')
|
||||
}
|
||||
})
|
||||
|
||||
calls.length = 0
|
||||
await bot.handleUpdate(reminderMessageUpdate('12.5') as never)
|
||||
expect(calls[0]).toMatchObject({
|
||||
method: 'sendMessage',
|
||||
payload: {
|
||||
text: expect.stringContaining('Коммунальные начисления за 2026-03'),
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[
|
||||
{
|
||||
text: 'Сохранить коммуналку',
|
||||
callback_data: expect.stringMatching(/^reminder_util:confirm:[^:]+$/)
|
||||
},
|
||||
{
|
||||
text: 'Отменить',
|
||||
callback_data: expect.stringMatching(/^reminder_util:cancel:[^:]+$/)
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const confirmProposalId = (
|
||||
promptRepository.current()?.payload as {
|
||||
proposalId?: string
|
||||
} | null
|
||||
)?.proposalId
|
||||
const confirmCallbackData = `reminder_util:confirm:${confirmProposalId ?? 'missing'}`
|
||||
calls.length = 0
|
||||
await bot.handleUpdate(reminderCallbackUpdate(confirmCallbackData ?? 'missing') as never)
|
||||
|
||||
expect(financeService.addedUtilityBills).toEqual([
|
||||
{
|
||||
billName: 'Electricity',
|
||||
amountMajor: '55.00',
|
||||
createdByMemberId: 'member-1',
|
||||
currency: 'GEL'
|
||||
},
|
||||
{
|
||||
billName: 'Water',
|
||||
amountMajor: '12.50',
|
||||
createdByMemberId: 'member-1',
|
||||
currency: 'GEL'
|
||||
}
|
||||
])
|
||||
expect(calls[0]).toMatchObject({
|
||||
method: 'answerCallbackQuery',
|
||||
payload: {
|
||||
text: 'Сохранено 2 начислений коммуналки за 2026-03.'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('parses the filled template and turns it into a confirmation proposal', async () => {
|
||||
const { bot, calls } = setupBot()
|
||||
|
||||
await bot.handleUpdate(reminderCallbackUpdate(REMINDER_UTILITY_TEMPLATE_CALLBACK) as never)
|
||||
|
||||
expect(calls[1]).toMatchObject({
|
||||
method: 'sendMessage',
|
||||
payload: {
|
||||
text: expect.stringContaining('Electricity:'),
|
||||
message_thread_id: 555
|
||||
}
|
||||
})
|
||||
|
||||
calls.length = 0
|
||||
await bot.handleUpdate(reminderMessageUpdate('Electricity: 22\nWater: 0') as never)
|
||||
|
||||
expect(calls[0]).toMatchObject({
|
||||
method: 'sendMessage',
|
||||
payload: {
|
||||
text: expect.stringContaining('- Electricity: 22.00 GEL')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('treats expired pending reminder submissions as unavailable', async () => {
|
||||
const { bot, calls, promptRepository } = setupBot()
|
||||
|
||||
await bot.handleUpdate(reminderCallbackUpdate(REMINDER_UTILITY_GUIDED_CALLBACK) as never)
|
||||
await bot.handleUpdate(reminderMessageUpdate('55') as never)
|
||||
await bot.handleUpdate(reminderMessageUpdate('12') as never)
|
||||
const confirmProposalId = (
|
||||
promptRepository.current()?.payload as {
|
||||
proposalId?: string
|
||||
} | null
|
||||
)?.proposalId
|
||||
const confirmCallbackData = `reminder_util:confirm:${confirmProposalId ?? 'missing'}`
|
||||
promptRepository.expire()
|
||||
calls.length = 0
|
||||
|
||||
await bot.handleUpdate(reminderCallbackUpdate(confirmCallbackData ?? 'missing') as never)
|
||||
|
||||
expect(calls[0]).toMatchObject({
|
||||
method: 'answerCallbackQuery',
|
||||
payload: {
|
||||
text: 'Это предложение по коммуналке уже недоступно.',
|
||||
show_alert: true
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('does not re-confirm after the pending submission was already cleared', async () => {
|
||||
const { bot, calls, promptRepository } = setupBot()
|
||||
|
||||
await bot.handleUpdate(reminderCallbackUpdate(REMINDER_UTILITY_GUIDED_CALLBACK) as never)
|
||||
await bot.handleUpdate(reminderMessageUpdate('55') as never)
|
||||
await bot.handleUpdate(reminderMessageUpdate('12') as never)
|
||||
const confirmProposalId = (
|
||||
promptRepository.current()?.payload as {
|
||||
proposalId?: string
|
||||
} | null
|
||||
)?.proposalId
|
||||
const confirmCallbackData = `reminder_util:confirm:${confirmProposalId ?? 'missing'}`
|
||||
await bot.handleUpdate(reminderCallbackUpdate(confirmCallbackData ?? 'missing') as never)
|
||||
calls.length = 0
|
||||
|
||||
await bot.handleUpdate(reminderCallbackUpdate(confirmCallbackData ?? 'missing') as never)
|
||||
|
||||
expect(calls[0]).toMatchObject({
|
||||
method: 'answerCallbackQuery',
|
||||
payload: {
|
||||
text: 'Это предложение по коммуналке уже недоступно.',
|
||||
show_alert: true
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user