import assert from 'node:assert/strict' import { randomUUID } from 'node:crypto' import { eq } from 'drizzle-orm' import { createFinanceCommandService } from '@household/application' import { createDbFinanceRepository, createDbHouseholdConfigurationRepository } from '@household/adapters-db' import { createDbClient, schema } from '@household/db' import { createTelegramBot } from '../../apps/bot/src/bot' import { createFinanceCommandsService } from '../../apps/bot/src/finance-commands' import { createPurchaseMessageRepository, registerPurchaseTopicIngestion } from '../../apps/bot/src/purchase-topic-ingestion' const chatId = '-100123456' const purchaseTopicId = 77 const commandChatIdNumber = -100123456 function unixSeconds(year: number, month: number, day: number): number { return Math.floor(Date.UTC(year, month - 1, day, 12, 0, 0) / 1000) } function commandUpdate(params: { updateId: number fromUserId: string fromName: string text: string unixTime: number }) { const commandToken = params.text.split(' ')[0] ?? params.text return { update_id: params.updateId, message: { message_id: params.updateId, date: params.unixTime, chat: { id: commandChatIdNumber, type: 'supergroup' }, from: { id: Number(params.fromUserId), is_bot: false, first_name: params.fromName }, text: params.text, entities: [ { offset: 0, length: commandToken.length, type: 'bot_command' } ] } } } function topicPurchaseUpdate(params: { updateId: number fromUserId: string fromName: string text: string unixTime: number }) { return { update_id: params.updateId, message: { message_id: params.updateId, date: params.unixTime, chat: { id: commandChatIdNumber, type: 'supergroup' }, from: { id: Number(params.fromUserId), is_bot: false, first_name: params.fromName }, is_topic_message: true, message_thread_id: purchaseTopicId, text: params.text } } } function parseStatement(text: string): Map { const lines = text.split('\n').slice(1) const amounts = new Map() for (const line of lines) { const match = /^-\s(.+?):\s([+-]?\d+\.\d{2})\s(?:USD|GEL)$/.exec(line.trim()) if (!match) { continue } amounts.set(match[1]!, match[2]!) } return amounts } async function loadE2eConfig(): Promise<{ databaseUrl: string }> { const { e2eEnv } = await import('@household/config') if (!e2eEnv.E2E_SMOKE_ALLOW_WRITE) { throw new Error('Set E2E_SMOKE_ALLOW_WRITE=true to run e2e smoke test') } return { databaseUrl: e2eEnv.DATABASE_URL } } async function run(): Promise { const { databaseUrl } = await loadE2eConfig() const ids = { household: randomUUID(), admin: randomUUID(), bob: randomUUID(), carol: randomUUID() } const telegram = { admin: '900001', bob: '900002', carol: '900003' } let coreClient: ReturnType | undefined let ingestionClient: ReturnType | undefined let financeRepositoryClient: ReturnType | undefined let householdConfigurationRepositoryClient: | ReturnType | undefined const bot = createTelegramBot('000000:test-token') const replies: string[] = [] 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) => { if (method === 'sendMessage') { const p = payload as any const messageText = typeof p?.text === 'string' ? p.text : '' replies.push(messageText) return { ok: true, result: { message_id: replies.length, date: Math.floor(Date.now() / 1000), chat: { id: commandChatIdNumber, type: 'supergroup' }, text: messageText } } as any } return { ok: true, result: true } as any }) try { coreClient = createDbClient(databaseUrl, { max: 2, prepare: false }) ingestionClient = createPurchaseMessageRepository(databaseUrl) financeRepositoryClient = createDbFinanceRepository(databaseUrl, ids.household) householdConfigurationRepositoryClient = createDbHouseholdConfigurationRepository(databaseUrl) const financeService = createFinanceCommandService(financeRepositoryClient.repository) const financeCommands = createFinanceCommandsService({ householdConfigurationRepository: householdConfigurationRepositoryClient.repository, financeServiceForHousehold: () => financeService }) registerPurchaseTopicIngestion( bot, { householdId: ids.household, householdChatId: chatId, purchaseTopicId }, ingestionClient.repository ) financeCommands.register(bot) await coreClient.db.insert(schema.households).values({ id: ids.household, name: 'E2E Smoke Household' }) await coreClient.db.insert(schema.householdTelegramChats).values({ householdId: ids.household, telegramChatId: chatId, telegramChatType: 'supergroup', title: 'E2E Smoke Household' }) await coreClient.db.insert(schema.members).values([ { id: ids.admin, householdId: ids.household, telegramUserId: telegram.admin, displayName: 'Alice', isAdmin: 1 }, { id: ids.bob, householdId: ids.household, telegramUserId: telegram.bob, displayName: 'Bob', isAdmin: 0 }, { id: ids.carol, householdId: ids.household, telegramUserId: telegram.carol, displayName: 'Carol', isAdmin: 0 } ]) let updateId = 1000 const march12 = unixSeconds(2026, 3, 12) await bot.handleUpdate( commandUpdate({ updateId: ++updateId, fromUserId: telegram.admin, fromName: 'Alice', text: '/cycle_open 2026-03 USD', unixTime: march12 }) as never ) await bot.handleUpdate( commandUpdate({ updateId: ++updateId, fromUserId: telegram.admin, fromName: 'Alice', text: '/rent_set 700 USD 2026-03', unixTime: march12 }) as never ) await bot.handleUpdate( topicPurchaseUpdate({ updateId: ++updateId, fromUserId: telegram.bob, fromName: 'Bob', text: 'Bought soap 30 USD', unixTime: march12 }) as never ) await bot.handleUpdate( commandUpdate({ updateId: ++updateId, fromUserId: telegram.admin, fromName: 'Alice', text: '/utility_add electricity 120 USD', unixTime: march12 }) as never ) await bot.handleUpdate( commandUpdate({ updateId: ++updateId, fromUserId: telegram.admin, fromName: 'Alice', text: '/statement 2026-03', unixTime: march12 }) as never ) const firstStatement = replies.find((entry) => entry.startsWith('Statement for 2026-03')) assert.ok(firstStatement, 'First statement message was not emitted') const firstTotals = parseStatement(firstStatement) assert.equal(firstTotals.get('Alice'), '283.34') assert.equal(firstTotals.get('Bob'), '253.33') assert.equal(firstTotals.get('Carol'), '283.33') await bot.handleUpdate( commandUpdate({ updateId: ++updateId, fromUserId: telegram.admin, fromName: 'Alice', text: '/utility_add water 30 USD', unixTime: march12 }) as never ) await bot.handleUpdate( commandUpdate({ updateId: ++updateId, fromUserId: telegram.admin, fromName: 'Alice', text: '/statement 2026-03', unixTime: march12 }) as never ) const secondStatement = replies.at(-1) assert.ok(secondStatement?.startsWith('Statement for 2026-03'), 'Second statement missing') const secondTotals = parseStatement(secondStatement ?? '') assert.equal(secondTotals.get('Alice'), '293.34') assert.equal(secondTotals.get('Bob'), '263.33') assert.equal(secondTotals.get('Carol'), '293.33') const purchaseRows = await coreClient.db .select({ status: schema.purchaseMessages.processingStatus, amountMinor: schema.purchaseMessages.parsedAmountMinor, senderMemberId: schema.purchaseMessages.senderMemberId }) .from(schema.purchaseMessages) .where(eq(schema.purchaseMessages.householdId, ids.household)) assert.equal(purchaseRows.length, 1, 'Expected one ingested purchase message') assert.equal(purchaseRows[0]?.status, 'parsed') assert.equal(purchaseRows[0]?.amountMinor, 3000n) assert.equal(purchaseRows[0]?.senderMemberId, ids.bob) console.log( 'E2E smoke passed: purchase ingestion, utility updates, and statements are deterministic' ) } finally { await Promise.allSettled([ coreClient ? coreClient.db.delete(schema.households).where(eq(schema.households.id, ids.household)) : undefined, coreClient?.queryClient.end({ timeout: 5 }), ingestionClient?.close(), financeRepositoryClient?.close(), householdConfigurationRepositoryClient?.close() ]) } } try { await run() } catch (error) { console.error('E2E smoke failed', error) process.exitCode = 1 }