From 3b7fa31670a12624e174eab0ad9fc7b1067ae809 Mon Sep 17 00:00:00 2001 From: whekin Date: Thu, 5 Mar 2026 14:19:10 +0400 Subject: [PATCH 01/10] chore: add deterministic bun version --- .bun-version | 1 + 1 file changed, 1 insertion(+) create mode 100644 .bun-version diff --git a/.bun-version b/.bun-version new file mode 100644 index 0000000..0c00f61 --- /dev/null +++ b/.bun-version @@ -0,0 +1 @@ +1.3.10 From 0caab1e8e28930b4b595926a050f0ce9d683e645 Mon Sep 17 00:00:00 2001 From: whekin Date: Thu, 5 Mar 2026 14:21:18 +0400 Subject: [PATCH 02/10] chore: add lefthook for pre-commit checks --- bun.lock | 23 +++++++++++++++++++++++ lefthook.yml | 11 +++++++++++ package.json | 5 ++++- 3 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 lefthook.yml diff --git a/bun.lock b/bun.lock index d39779d..fe31d6d 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@types/bun": "1.3.10", "@typescript/native-preview": "7.0.0-dev.20260304.1", "drizzle-kit": "^0.31.4", + "lefthook": "2.1.2", "oxlint": "^1.51.0", "typescript": "^5.9.2", }, @@ -440,6 +441,28 @@ "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "lefthook": ["lefthook@2.1.2", "", { "optionalDependencies": { "lefthook-darwin-arm64": "2.1.2", "lefthook-darwin-x64": "2.1.2", "lefthook-freebsd-arm64": "2.1.2", "lefthook-freebsd-x64": "2.1.2", "lefthook-linux-arm64": "2.1.2", "lefthook-linux-x64": "2.1.2", "lefthook-openbsd-arm64": "2.1.2", "lefthook-openbsd-x64": "2.1.2", "lefthook-windows-arm64": "2.1.2", "lefthook-windows-x64": "2.1.2" }, "bin": { "lefthook": "bin/index.js" } }, "sha512-HdAMl4g47kbWSkrUkCx3Kucq54omFS6piMJtXwXNtmCAfB40UaybTJuYtFW4hNzZ5SvaEimtxTp7P/MNIkEfsA=="], + + "lefthook-darwin-arm64": ["lefthook-darwin-arm64@2.1.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-AgHu93YuJtj1l9bcKlCbo4Tg8N8xFl9iD6BjXCGaGMu46LSjFiXbJFlkUdpgrL8fIbwoCjJi5FNp3POpqs4Wdw=="], + + "lefthook-darwin-x64": ["lefthook-darwin-x64@2.1.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-exooc9Ectz13OLJJOXM9AzaFQbqzf9QCF8JuVvGfbr4RYABYK+BwwtydjlPQrA76/n/h4tsS11MH5bBULnLkYA=="], + + "lefthook-freebsd-arm64": ["lefthook-freebsd-arm64@2.1.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-E1QMlJPEU21n9eewv6ePfh+JmoTSg5R1jaYcKCky10kfbMdohNucI3xV91F2LcerE+p3UejKDqr/1wWO2RMGeQ=="], + + "lefthook-freebsd-x64": ["lefthook-freebsd-x64@2.1.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-/5zp+x8055Thj46x9S7hgnneZxvWhHQvPWkkgISCab1Lh6eLrbxvhE1qTb1lU3DqTnNmH9NeXdq1xPHc9uGluA=="], + + "lefthook-linux-arm64": ["lefthook-linux-arm64@2.1.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK5FvDTkwKO7tOznY8iEZzuTsM1jXMZAG5BMRs7olN1k1K6m2unR6oKABP0hCd0wDErK6DZKDJDJfB564Rzqtw=="], + + "lefthook-linux-x64": ["lefthook-linux-x64@2.1.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4eOtz4PNh8GbJ+nA8YVDfW/eMirQWdZqMP/V/MVtoVBGobf6oXvvuDOySvAPOgNYEFN0Boegytmuji/851Vstg=="], + + "lefthook-openbsd-arm64": ["lefthook-openbsd-arm64@2.1.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-lJXRJ6iJIBKwomuNBA3CUNSclj2/rKuxGAQoUra214B92VB6jL9zaY5YEs6h/ie9jQrzSnllEeg7xyDIsuVCrQ=="], + + "lefthook-openbsd-x64": ["lefthook-openbsd-x64@2.1.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-GyOje4W0DIqkmR7/Of5D+mZ0vWqMvtGAVedtJR6d1239xNeMzCS8Q+/a3O1xigceZa5xhlqq0BWlssB/QYPQnA=="], + + "lefthook-windows-arm64": ["lefthook-windows-arm64@2.1.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-MZKMqTULEpX/8N3fKXAR0A9RjsGKkEEY0japLqrHOIpxsJXry1DRz0FvQo2kkY4WW3rtFegV9m6eesOymuDrUg=="], + + "lefthook-windows-x64": ["lefthook-windows-x64@2.1.2", "", { "os": "win32", "cpu": "x64" }, "sha512-NZUgObuaSxc0EXAwC/CzkMf7TuQc++GGIk6TLPdaUpoSsNSJSZEwBVz5DtFB1cG+eMkfO/wOKplls+yjimTTtQ=="], + "lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="], diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..a729917 --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,11 @@ +# lefthook.yml + +pre-commit: + parallel: true + commands: + format: + glob: '*.{ts,tsx,js,jsx,json,md}' + run: bun run format:check + lint: + glob: '*.{ts,tsx,js,jsx}' + run: bun run lint diff --git a/package.json b/package.json index 5e5c898..d27e02d 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "build": "bun run --filter '*' build", "typecheck": "bun run --filter '*' typecheck", "test": "bun run --filter '*' test", + "prepare": "lefthook install", "lint": "oxlint .", "lint:fix": "oxlint --fix .", "format": "bunx oxfmt .", @@ -29,12 +30,14 @@ "docker:build:bot": "docker build -f apps/bot/Dockerfile -t household-bot:local .", "docker:build:miniapp": "docker build -f apps/miniapp/Dockerfile -t household-miniapp:local .", "docker:build": "bun run docker:build:bot && bun run docker:build:miniapp", - "docker:smoke": "docker compose up --build" + "docker:smoke": "docker compose up --build", + "test:e2e": "bun run scripts/e2e/billing-flow.ts" }, "devDependencies": { "@types/bun": "1.3.10", "@typescript/native-preview": "7.0.0-dev.20260304.1", "drizzle-kit": "^0.31.4", + "lefthook": "2.1.2", "oxlint": "^1.51.0", "typescript": "^5.9.2" } From 6f6873f25d459c56b0aae5b9aa7915b574135605 Mon Sep 17 00:00:00 2001 From: whekin Date: Thu, 5 Mar 2026 14:21:19 +0400 Subject: [PATCH 03/10] feat(infra): update terraform state backend and add missing runtime secrets --- infra/terraform/locals.tf | 6 +++++- infra/terraform/main.tf | 12 ++++++++++++ infra/terraform/variables.tf | 29 +++++++++++++++++++++++++++++ infra/terraform/versions.tf | 5 +++++ 4 files changed, 51 insertions(+), 1 deletion(-) diff --git a/infra/terraform/locals.tf b/infra/terraform/locals.tf index 7c2bbcd..79f74c5 100644 --- a/infra/terraform/locals.tf +++ b/infra/terraform/locals.tf @@ -16,7 +16,11 @@ locals { var.telegram_webhook_secret_id, var.scheduler_shared_secret_id, var.supabase_url_secret_id, - var.supabase_publishable_key_secret_id + var.supabase_publishable_key_secret_id, + var.database_url_secret_id, + var.telegram_bot_token_secret_id, + var.telegram_bot_username_secret_id, + var.openai_api_key_secret_id ])) api_services = toset([ diff --git a/infra/terraform/main.tf b/infra/terraform/main.tf index fe105f2..ff41a55 100644 --- a/infra/terraform/main.tf +++ b/infra/terraform/main.tf @@ -91,6 +91,18 @@ module "bot_api_service" { }, var.supabase_publishable_key_secret_id == null ? {} : { SUPABASE_PUBLISHABLE_KEY = var.supabase_publishable_key_secret_id + }, + var.database_url_secret_id == null ? {} : { + DATABASE_URL = var.database_url_secret_id + }, + var.telegram_bot_token_secret_id == null ? {} : { + TELEGRAM_BOT_TOKEN = var.telegram_bot_token_secret_id + }, + var.telegram_bot_username_secret_id == null ? {} : { + TELEGRAM_BOT_USERNAME = var.telegram_bot_username_secret_id + }, + var.openai_api_key_secret_id == null ? {} : { + OPENAI_API_KEY = var.openai_api_key_secret_id } ) diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf index dc817e1..15ef420 100644 --- a/infra/terraform/variables.tf +++ b/infra/terraform/variables.tf @@ -70,6 +70,35 @@ variable "supabase_publishable_key_secret_id" { nullable = true } +variable "database_url_secret_id" { + description = "Optional Secret Manager ID for DATABASE_URL" + type = string + default = null + nullable = true +} + +variable "telegram_bot_token_secret_id" { + description = "Optional Secret Manager ID for TELEGRAM_BOT_TOKEN" + type = string + default = null + nullable = true +} + +variable "telegram_bot_username_secret_id" { + description = "Optional Secret Manager ID for TELEGRAM_BOT_USERNAME" + type = string + default = null + nullable = true +} + +variable "openai_api_key_secret_id" { + description = "Optional Secret Manager ID for OPENAI_API_KEY" + type = string + default = null + nullable = true +} + + variable "scheduler_path" { description = "Reminder endpoint path on bot API" type = string diff --git a/infra/terraform/versions.tf b/infra/terraform/versions.tf index f5a1a2b..9439515 100644 --- a/infra/terraform/versions.tf +++ b/infra/terraform/versions.tf @@ -1,6 +1,11 @@ terraform { required_version = ">= 1.8.0" + backend "gcs" { + # The bucket will need to be configured via `terraform init -backend-config="bucket="` + # or you can hardcode the bucket name here. Since it's a generic module, we leave it to be configured via init args. + } + required_providers { google = { source = "hashicorp/google" From cfc8a521e42f6fa2b79d78a851f45ac498ce9b32 Mon Sep 17 00:00:00 2001 From: whekin Date: Thu, 5 Mar 2026 20:45:14 +0400 Subject: [PATCH 04/10] fix(test): configure scripts workspace and e2e typings --- bun.lock | 9 + package.json | 3 +- scripts/e2e/billing-flow.ts | 321 ++++++++++++++++++++++++++++++++++++ scripts/package.json | 12 ++ scripts/tsconfig.json | 9 + tsconfig.json | 39 ++++- 6 files changed, 383 insertions(+), 10 deletions(-) create mode 100644 scripts/e2e/billing-flow.ts create mode 100644 scripts/package.json create mode 100644 scripts/tsconfig.json diff --git a/bun.lock b/bun.lock index fe31d6d..12b03ee 100644 --- a/bun.lock +++ b/bun.lock @@ -69,6 +69,13 @@ "packages/ports": { "name": "@household/ports", }, + "scripts": { + "name": "@household/scripts", + "devDependencies": { + "@household/db": "workspace:*", + "drizzle-orm": "*", + }, + }, }, "packages": { "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], @@ -187,6 +194,8 @@ "@household/ports": ["@household/ports@workspace:packages/ports"], + "@household/scripts": ["@household/scripts@workspace:scripts"], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], diff --git a/package.json b/package.json index d27e02d..74545fd 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "type": "module", "workspaces": [ "apps/*", - "packages/*" + "packages/*", + "scripts" ], "scripts": { "build": "bun run --filter '*' build", diff --git a/scripts/e2e/billing-flow.ts b/scripts/e2e/billing-flow.ts new file mode 100644 index 0000000..1eafd58 --- /dev/null +++ b/scripts/e2e/billing-flow.ts @@ -0,0 +1,321 @@ +import assert from 'node:assert/strict' +import { randomUUID } from 'node:crypto' + +import { eq } from 'drizzle-orm' + +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 databaseUrl = process.env.DATABASE_URL +if (!databaseUrl) { + throw new Error('DATABASE_URL is required for e2e smoke test') +} + +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 run(): Promise { + const ids = { + household: randomUUID(), + admin: randomUUID(), + bob: randomUUID(), + carol: randomUUID() + } + + const telegram = { + admin: '900001', + bob: '900002', + carol: '900003' + } + + const coreClient = createDbClient(databaseUrl as string, { + max: 2, + prepare: false + }) + + const ingestionClient = createPurchaseMessageRepository(databaseUrl as string) + const financeService = createFinanceCommandsService(databaseUrl as string, { + householdId: ids.household + }) + + const bot = createTelegramBot('000000:test-token') + const replies: string[] = [] + + 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 + }) + + registerPurchaseTopicIngestion( + bot, + { + householdId: ids.household, + householdChatId: chatId, + purchaseTopicId + }, + ingestionClient.repository + ) + + financeService.register(bot) + + try { + await coreClient.db.insert(schema.households).values({ + id: ids.household, + name: '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 coreClient.db.delete(schema.households).where(eq(schema.households.id, ids.household)) + + await Promise.all([ + coreClient.queryClient.end({ timeout: 5 }), + ingestionClient.close(), + financeService.close() + ]) + } +} + +try { + await run() +} catch (error) { + console.error('E2E smoke failed', error) + process.exitCode = 1 +} diff --git a/scripts/package.json b/scripts/package.json new file mode 100644 index 0000000..af74a54 --- /dev/null +++ b/scripts/package.json @@ -0,0 +1,12 @@ +{ + "name": "@household/scripts", + "private": true, + "type": "module", + "scripts": { + "typecheck": "tsgo --project tsconfig.json --noEmit" + }, + "devDependencies": { + "drizzle-orm": "*", + "@household/db": "workspace:*" + } +} diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json new file mode 100644 index 0000000..6f29a6c --- /dev/null +++ b/scripts/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "paths": { + "@household/*": ["../packages/*/src", "../apps/*/src"] + } + }, + "include": ["**/*.ts", "../apps/bot/src/**/*.ts"] +} diff --git a/tsconfig.json b/tsconfig.json index d88127c..b1f49b0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,14 +1,35 @@ { "files": [], "references": [ - { "path": "./apps/bot" }, - { "path": "./apps/miniapp" }, - { "path": "./packages/domain" }, - { "path": "./packages/application" }, - { "path": "./packages/ports" }, - { "path": "./packages/contracts" }, - { "path": "./packages/observability" }, - { "path": "./packages/config" }, - { "path": "./packages/db" } + { + "path": "./apps/bot" + }, + { + "path": "./apps/miniapp" + }, + { + "path": "./packages/domain" + }, + { + "path": "./packages/application" + }, + { + "path": "./packages/ports" + }, + { + "path": "./packages/contracts" + }, + { + "path": "./packages/observability" + }, + { + "path": "./packages/config" + }, + { + "path": "./packages/db" + }, + { + "path": "./scripts" + } ] } From efc221f95e74c9e31eed94ef01a4a3b1c50d0c46 Mon Sep 17 00:00:00 2001 From: whekin Date: Thu, 5 Mar 2026 20:48:09 +0400 Subject: [PATCH 05/10] docs(test): add e2e runbook and use .bun-version in CI/CD --- .github/workflows/cd.yml | 2 +- .github/workflows/ci.yml | 2 +- docs/runbooks/e2e-tests.md | 64 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 docs/runbooks/e2e-tests.md diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index d7ae0a1..6bbc3c4 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -85,7 +85,7 @@ jobs: if: ${{ needs.check-secrets.outputs.db_secret_ok == 'true' }} uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.10 + bun-version-file: .bun-version - name: Install dependencies for migrations if: ${{ needs.check-secrets.outputs.db_secret_ok == 'true' }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c32c78d..176486f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.10 + bun-version-file: .bun-version - name: Restore Bun cache uses: actions/cache@v4 diff --git a/docs/runbooks/e2e-tests.md b/docs/runbooks/e2e-tests.md new file mode 100644 index 0000000..1c33b83 --- /dev/null +++ b/docs/runbooks/e2e-tests.md @@ -0,0 +1,64 @@ +# End-to-End Smoke Tests + +## Overview + +The `scripts/e2e/billing-flow.ts` script runs a deterministic end-to-end +smoke test for the billing pipeline. It exercises: + +- Purchase ingestion from a simulated topic message +- Utility bill entry via bot commands +- Monthly statement generation and balance verification + +## Prerequisites + +- Bun 1.3+ installed +- A running Supabase/Postgres database with the schema applied +- `DATABASE_URL` set (via `.env` or environment) + +## Running locally + +```bash +# 1. Ensure .env has a valid DATABASE_URL +cp .env.example .env +# edit .env with real DATABASE_URL + +# 2. Apply database migrations +bun run db:migrate + +# 3. Run the e2e smoke test +bun run test:e2e +``` + +The test seeds its own data (household + 3 roommates), runs the full +purchase → utility → statement flow, asserts deterministic totals, and +cleans up after itself. + +## Expected output + +On success: + +``` +E2E smoke passed: purchase ingestion, utility updates, and statements are deterministic +``` + +On failure the script exits with code 1 and prints the assertion error. + +## CI integration + +The e2e smoke test runs in CI as part of the quality matrix when the +`DATABASE_URL` secret is configured. Without the secret, the job is +skipped automatically. + +## Test data + +The test creates temporary records with random UUIDs: + +| Entity | Details | +| --------- | -------------------------- | +| Household | "E2E Smoke Household" | +| Alice | Admin, telegram ID 900001 | +| Bob | Member, telegram ID 900002 | +| Carol | Member, telegram ID 900003 | + +All test data is cleaned up in a `finally` block via cascade delete on +the household row. From 3675079a4cd43f146c23cad92b60625fc8b21ce1 Mon Sep 17 00:00:00 2001 From: whekin Date: Thu, 5 Mar 2026 21:13:38 +0400 Subject: [PATCH 06/10] fix(review): address CodeRabbit review feedback - Guard prepare script for Docker builds without .git - Add pre-push hook for heavier quality gates (typecheck/test/build) - Pin drizzle-orm version in scripts/package.json - Add E2E_SMOKE_ALLOW_WRITE opt-in guard via e2eEnv abstraction - Create @household/config env-e2e.ts using same t3-env + zod pattern - Make e2e teardown robust with optional chaining + allSettled - Fix markdown code block language identifier (MD040) - Fix CI integration docs to reflect actual workflow --- bun.lock | 3 +- docs/runbooks/e2e-tests.md | 12 +++--- lefthook.yml | 10 +++++ package.json | 2 +- packages/config/src/env-e2e.ts | 21 +++++++++++ packages/config/src/index.ts | 1 + scripts/e2e/billing-flow.ts | 67 +++++++++++++++++++--------------- scripts/package.json | 3 +- 8 files changed, 81 insertions(+), 38 deletions(-) create mode 100644 packages/config/src/env-e2e.ts diff --git a/bun.lock b/bun.lock index 12b03ee..ebf3887 100644 --- a/bun.lock +++ b/bun.lock @@ -72,8 +72,9 @@ "scripts": { "name": "@household/scripts", "devDependencies": { + "@household/config": "workspace:*", "@household/db": "workspace:*", - "drizzle-orm": "*", + "drizzle-orm": "^0.44.5", }, }, }, diff --git a/docs/runbooks/e2e-tests.md b/docs/runbooks/e2e-tests.md index 1c33b83..3db6f5a 100644 --- a/docs/runbooks/e2e-tests.md +++ b/docs/runbooks/e2e-tests.md @@ -14,6 +14,7 @@ smoke test for the billing pipeline. It exercises: - Bun 1.3+ installed - A running Supabase/Postgres database with the schema applied - `DATABASE_URL` set (via `.env` or environment) +- `E2E_SMOKE_ALLOW_WRITE=true` set explicitly (safety guard) ## Running locally @@ -26,7 +27,7 @@ cp .env.example .env bun run db:migrate # 3. Run the e2e smoke test -bun run test:e2e +E2E_SMOKE_ALLOW_WRITE=true bun run test:e2e ``` The test seeds its own data (household + 3 roommates), runs the full @@ -37,7 +38,7 @@ cleans up after itself. On success: -``` +```text E2E smoke passed: purchase ingestion, utility updates, and statements are deterministic ``` @@ -45,9 +46,10 @@ On failure the script exits with code 1 and prints the assertion error. ## CI integration -The e2e smoke test runs in CI as part of the quality matrix when the -`DATABASE_URL` secret is configured. Without the secret, the job is -skipped automatically. +Run the e2e smoke test with `bun run test:e2e` locally or in a dedicated +CI job. If you wire it into CI, gate it on `DATABASE_URL` and +`E2E_SMOKE_ALLOW_WRITE` to avoid false failures. The test is **not** +part of the standard CI quality matrix by default. ## Test data diff --git a/lefthook.yml b/lefthook.yml index a729917..3de18ee 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -9,3 +9,13 @@ pre-commit: lint: glob: '*.{ts,tsx,js,jsx}' run: bun run lint + +pre-push: + parallel: true + commands: + typecheck: + run: bun run typecheck + test: + run: bun run test + build: + run: bun run build diff --git a/package.json b/package.json index 74545fd..8b2025e 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "build": "bun run --filter '*' build", "typecheck": "bun run --filter '*' typecheck", "test": "bun run --filter '*' test", - "prepare": "lefthook install", + "prepare": "[ -d .git ] && lefthook install || true", "lint": "oxlint .", "lint:fix": "oxlint --fix .", "format": "bunx oxfmt .", diff --git a/packages/config/src/env-e2e.ts b/packages/config/src/env-e2e.ts new file mode 100644 index 0000000..3dda18a --- /dev/null +++ b/packages/config/src/env-e2e.ts @@ -0,0 +1,21 @@ +import { createEnv } from '@t3-oss/env-core' +import { z } from 'zod' + +const server = { + DATABASE_URL: z.string().url(), + E2E_SMOKE_ALLOW_WRITE: z + .enum(['true', 'false']) + .default('false') + .transform((v) => v === 'true') +} + +export const e2eEnv = createEnv({ + server, + runtimeEnv: process.env, + emptyStringAsUndefined: true, + onValidationError: (issues) => { + console.error('Invalid e2e environment variables:') + console.error(JSON.stringify(issues, null, 2)) + throw new Error('E2E environment validation failed') + } +}) diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 40238d6..4c860d9 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -1 +1,2 @@ export { env } from './env' +export { e2eEnv } from './env-e2e' diff --git a/scripts/e2e/billing-flow.ts b/scripts/e2e/billing-flow.ts index 1eafd58..ef36a40 100644 --- a/scripts/e2e/billing-flow.ts +++ b/scripts/e2e/billing-flow.ts @@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto' import { eq } from 'drizzle-orm' +import { e2eEnv } from '@household/config' import { createDbClient, schema } from '@household/db' import { createTelegramBot } from '../../apps/bot/src/bot' @@ -12,11 +13,12 @@ import { registerPurchaseTopicIngestion } from '../../apps/bot/src/purchase-topic-ingestion' -const databaseUrl = process.env.DATABASE_URL -if (!databaseUrl) { - throw new Error('DATABASE_URL is required for e2e smoke test') +if (!e2eEnv.E2E_SMOKE_ALLOW_WRITE) { + throw new Error('Set E2E_SMOKE_ALLOW_WRITE=true to run e2e smoke test') } +const databaseUrl: string = e2eEnv.DATABASE_URL + const chatId = '-100123456' const purchaseTopicId = 77 const commandChatIdNumber = -100123456 @@ -118,15 +120,9 @@ async function run(): Promise { carol: '900003' } - const coreClient = createDbClient(databaseUrl as string, { - max: 2, - prepare: false - }) - - const ingestionClient = createPurchaseMessageRepository(databaseUrl as string) - const financeService = createFinanceCommandsService(databaseUrl as string, { - householdId: ids.household - }) + let coreClient: ReturnType | undefined + let ingestionClient: ReturnType | undefined + let financeService: ReturnType | undefined const bot = createTelegramBot('000000:test-token') const replies: string[] = [] @@ -154,19 +150,29 @@ async function run(): Promise { return { ok: true, result: true } as any }) - registerPurchaseTopicIngestion( - bot, - { - householdId: ids.household, - householdChatId: chatId, - purchaseTopicId - }, - ingestionClient.repository - ) - - financeService.register(bot) - try { + coreClient = createDbClient(databaseUrl, { + max: 2, + prepare: false + }) + + ingestionClient = createPurchaseMessageRepository(databaseUrl) + financeService = createFinanceCommandsService(databaseUrl, { + householdId: ids.household + }) + + registerPurchaseTopicIngestion( + bot, + { + householdId: ids.household, + householdChatId: chatId, + purchaseTopicId + }, + ingestionClient.repository + ) + + financeService.register(bot) + await coreClient.db.insert(schema.households).values({ id: ids.household, name: 'E2E Smoke Household' @@ -303,12 +309,13 @@ async function run(): Promise { 'E2E smoke passed: purchase ingestion, utility updates, and statements are deterministic' ) } finally { - await coreClient.db.delete(schema.households).where(eq(schema.households.id, ids.household)) - - await Promise.all([ - coreClient.queryClient.end({ timeout: 5 }), - ingestionClient.close(), - financeService.close() + await Promise.allSettled([ + coreClient + ? coreClient.db.delete(schema.households).where(eq(schema.households.id, ids.household)) + : undefined, + coreClient?.queryClient.end({ timeout: 5 }), + ingestionClient?.close(), + financeService?.close() ]) } } diff --git a/scripts/package.json b/scripts/package.json index af74a54..3fd96a5 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -6,7 +6,8 @@ "typecheck": "tsgo --project tsconfig.json --noEmit" }, "devDependencies": { - "drizzle-orm": "*", + "drizzle-orm": "^0.44.5", + "@household/config": "workspace:*", "@household/db": "workspace:*" } } From 082d0f7dccc183469d58d75fc2549d0116d5fe9a Mon Sep 17 00:00:00 2001 From: whekin Date: Sun, 8 Mar 2026 20:10:28 +0400 Subject: [PATCH 07/10] fix(infra): align bot runtime config with terraform --- docs/runbooks/iac-terraform.md | 13 +++++++++++- infra/terraform/README.md | 11 +++++++++- infra/terraform/locals.tf | 1 - infra/terraform/main.tf | 23 +++++++++++++++------ infra/terraform/terraform.tfvars.example | 9 ++++++-- infra/terraform/variables.tf | 26 +++++++++++++++++++++--- 6 files changed, 69 insertions(+), 14 deletions(-) diff --git a/docs/runbooks/iac-terraform.md b/docs/runbooks/iac-terraform.md index 65343f9..8b63a6c 100644 --- a/docs/runbooks/iac-terraform.md +++ b/docs/runbooks/iac-terraform.md @@ -18,7 +18,7 @@ gcloud auth application-default login ```bash cp infra/terraform/terraform.tfvars.example infra/terraform/terraform.tfvars -terraform -chdir=infra/terraform init +terraform -chdir=infra/terraform init -backend-config="bucket=" terraform -chdir=infra/terraform plan terraform -chdir=infra/terraform apply ``` @@ -35,10 +35,21 @@ bun run infra:validate After first apply, add secret versions: ```bash +echo -n "" | gcloud secrets versions add telegram-bot-token --data-file=- --project echo -n "" | gcloud secrets versions add telegram-webhook-secret --data-file=- --project echo -n "" | gcloud secrets versions add scheduler-shared-secret --data-file=- --project ``` +If you set optional secret IDs such as `database_url_secret_id` or +`openai_api_key_secret_id`, add versions for those secrets too. + +Keep bot runtime config that is not secret in your `*.tfvars` file: + +- `bot_household_id` +- `bot_household_chat_id` +- `bot_purchase_topic_id` +- optional `bot_parser_model` + ## Environment strategy - Keep separate states for `dev` and `prod`. diff --git a/infra/terraform/README.md b/infra/terraform/README.md index a1858ff..2ecd84b 100644 --- a/infra/terraform/README.md +++ b/infra/terraform/README.md @@ -29,7 +29,7 @@ This directory contains baseline IaC for deploying the household bot platform on 1. Initialize: ```bash -terraform -chdir=infra/terraform init +terraform -chdir=infra/terraform init -backend-config="bucket=" ``` 2. Prepare variables: @@ -53,10 +53,14 @@ terraform -chdir=infra/terraform apply 5. Add secret values (after apply): ```bash +echo -n "" | gcloud secrets versions add telegram-bot-token --data-file=- --project echo -n "" | gcloud secrets versions add telegram-webhook-secret --data-file=- --project echo -n "" | gcloud secrets versions add scheduler-shared-secret --data-file=- --project ``` +If you configure optional secret IDs such as `database_url_secret_id` or +`openai_api_key_secret_id`, add versions for those secrets as well. + ## Environments Recommended approach: @@ -64,6 +68,11 @@ Recommended approach: - Keep one state per environment (dev/prod) using separate backend configs or workspaces - Use `terraform.tfvars` per environment (`dev.tfvars`, `prod.tfvars`) - Keep `project_id` separate for dev/prod when possible +- Keep non-secret bot config in `*.tfvars`: + - `bot_household_id` + - `bot_household_chat_id` + - `bot_purchase_topic_id` + - optional `bot_parser_model` ## CI validation diff --git a/infra/terraform/locals.tf b/infra/terraform/locals.tf index 79f74c5..aff78cb 100644 --- a/infra/terraform/locals.tf +++ b/infra/terraform/locals.tf @@ -19,7 +19,6 @@ locals { var.supabase_publishable_key_secret_id, var.database_url_secret_id, var.telegram_bot_token_secret_id, - var.telegram_bot_username_secret_id, var.openai_api_key_secret_id ])) diff --git a/infra/terraform/main.tf b/infra/terraform/main.tf index ff41a55..2c3538c 100644 --- a/infra/terraform/main.tf +++ b/infra/terraform/main.tf @@ -77,9 +77,23 @@ module "bot_api_service" { max_instance_count = var.bot_max_instances labels = local.common_labels - env = { - NODE_ENV = var.environment - } + env = merge( + { + NODE_ENV = var.environment + }, + var.bot_household_id == null ? {} : { + HOUSEHOLD_ID = var.bot_household_id + }, + var.bot_household_chat_id == null ? {} : { + TELEGRAM_HOUSEHOLD_CHAT_ID = var.bot_household_chat_id + }, + var.bot_purchase_topic_id == null ? {} : { + TELEGRAM_PURCHASE_TOPIC_ID = tostring(var.bot_purchase_topic_id) + }, + var.bot_parser_model == null ? {} : { + PARSER_MODEL = var.bot_parser_model + } + ) secret_env = merge( { @@ -98,9 +112,6 @@ module "bot_api_service" { var.telegram_bot_token_secret_id == null ? {} : { TELEGRAM_BOT_TOKEN = var.telegram_bot_token_secret_id }, - var.telegram_bot_username_secret_id == null ? {} : { - TELEGRAM_BOT_USERNAME = var.telegram_bot_username_secret_id - }, var.openai_api_key_secret_id == null ? {} : { OPENAI_API_KEY = var.openai_api_key_secret_id } diff --git a/infra/terraform/terraform.tfvars.example b/infra/terraform/terraform.tfvars.example index 5a03af3..a3cb7fd 100644 --- a/infra/terraform/terraform.tfvars.example +++ b/infra/terraform/terraform.tfvars.example @@ -5,8 +5,13 @@ service_prefix = "household" artifact_repository_id = "household-bot" -bot_api_image = "europe-west1-docker.pkg.dev/my-gcp-project/household-bot/bot-api:latest" -mini_app_image = "europe-west1-docker.pkg.dev/my-gcp-project/household-bot/mini-app:latest" +bot_api_image = "europe-west1-docker.pkg.dev/my-gcp-project/household-bot/bot:latest" +mini_app_image = "europe-west1-docker.pkg.dev/my-gcp-project/household-bot/miniapp:latest" + +bot_household_id = "11111111-1111-4111-8111-111111111111" +bot_household_chat_id = "-1001234567890" +bot_purchase_topic_id = 777 +bot_parser_model = "gpt-4.1-mini" scheduler_cron = "0 9 * * *" scheduler_timezone = "Asia/Tbilisi" diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf index 15ef420..4be4fa3 100644 --- a/infra/terraform/variables.tf +++ b/infra/terraform/variables.tf @@ -78,14 +78,34 @@ variable "database_url_secret_id" { } variable "telegram_bot_token_secret_id" { - description = "Optional Secret Manager ID for TELEGRAM_BOT_TOKEN" + description = "Secret Manager ID for TELEGRAM_BOT_TOKEN" + type = string + default = "telegram-bot-token" +} + +variable "bot_household_id" { + description = "Optional HOUSEHOLD_ID value for bot runtime" type = string default = null nullable = true } -variable "telegram_bot_username_secret_id" { - description = "Optional Secret Manager ID for TELEGRAM_BOT_USERNAME" +variable "bot_household_chat_id" { + description = "Optional TELEGRAM_HOUSEHOLD_CHAT_ID value for bot runtime" + type = string + default = null + nullable = true +} + +variable "bot_purchase_topic_id" { + description = "Optional TELEGRAM_PURCHASE_TOPIC_ID value for bot runtime" + type = number + default = null + nullable = true +} + +variable "bot_parser_model" { + description = "Optional PARSER_MODEL override for bot runtime" type = string default = null nullable = true From 3152858aacf395ba5a7b03a25830ce0d7e6713f7 Mon Sep 17 00:00:00 2001 From: whekin Date: Sun, 8 Mar 2026 20:14:36 +0400 Subject: [PATCH 08/10] feat(test): add local billing smoke test --- apps/bot/src/purchase-topic-ingestion.test.ts | 9 +++ apps/bot/src/purchase-topic-ingestion.ts | 8 +- .../HOUSEBOT-061-local-e2e-smoke-tests.md | 77 +++++++++++++++++++ scripts/e2e/billing-flow.ts | 14 ++++ 4 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 docs/specs/HOUSEBOT-061-local-e2e-smoke-tests.md diff --git a/apps/bot/src/purchase-topic-ingestion.test.ts b/apps/bot/src/purchase-topic-ingestion.test.ts index 9a941eb..e51e969 100644 --- a/apps/bot/src/purchase-topic-ingestion.test.ts +++ b/apps/bot/src/purchase-topic-ingestion.test.ts @@ -50,4 +50,13 @@ describe('extractPurchaseTopicCandidate', () => { expect(record).toBeNull() }) + + test('skips slash commands in purchase topic', () => { + const record = extractPurchaseTopicCandidate( + candidate({ rawText: '/statement 2026-03' }), + config + ) + + expect(record).toBeNull() + }) }) diff --git a/apps/bot/src/purchase-topic-ingestion.ts b/apps/bot/src/purchase-topic-ingestion.ts index 0c5bada..37e0af6 100644 --- a/apps/bot/src/purchase-topic-ingestion.ts +++ b/apps/bot/src/purchase-topic-ingestion.ts @@ -36,6 +36,10 @@ export function extractPurchaseTopicCandidate( value: PurchaseTopicCandidate, config: PurchaseTopicIngestionConfig ): PurchaseTopicRecord | null { + if (value.rawText.trim().startsWith('/')) { + return null + } + if (value.chatId !== config.householdChatId) { return null } @@ -195,14 +199,16 @@ export function registerPurchaseTopicIngestion( llmFallback?: PurchaseParserLlmFallback } = {} ): void { - bot.on('message:text', async (ctx) => { + bot.on('message:text', async (ctx, next) => { const candidate = toCandidateFromContext(ctx) if (!candidate) { + await next() return } const record = extractPurchaseTopicCandidate(candidate, config) if (!record) { + await next() return } diff --git a/docs/specs/HOUSEBOT-061-local-e2e-smoke-tests.md b/docs/specs/HOUSEBOT-061-local-e2e-smoke-tests.md new file mode 100644 index 0000000..ce03d6f --- /dev/null +++ b/docs/specs/HOUSEBOT-061-local-e2e-smoke-tests.md @@ -0,0 +1,77 @@ +# HOUSEBOT-061: Local End-to-End Smoke Tests for Billing Flow + +## Summary + +Add a pragmatic local smoke test that exercises the main billing path against a real database with deterministic assertions. + +## Goals + +- Provide `bun run test:e2e` for local pre-deploy confidence. +- Cover purchase ingestion, manual utility entry, and statement generation in one flow. +- Ensure smoke data is isolated and cleaned up automatically. + +## Non-goals + +- Full browser or Telegram API end-to-end automation. +- Running destructive write tests in the default CI quality matrix. +- Comprehensive scenario coverage for every finance edge case. + +## Scope + +- In: write-gated smoke script, docs, typed env for the smoke test, deterministic assertions, cleanup. +- Out: full staging environment orchestration. + +## Interfaces and Contracts + +- Command: `bun run test:e2e` +- Required env: + - `DATABASE_URL` + - `E2E_SMOKE_ALLOW_WRITE=true` +- Script behavior: + - creates temporary household/member/cycle data + - simulates Telegram topic purchase ingestion + - simulates finance commands for rent, utilities, and statements + - deletes created data in `finally` + +## Domain Rules + +- Use integer minor units only. +- Statement totals must match deterministic settlement behavior. +- Purchase-topic ingestion must not swallow non-purchase slash commands. + +## Data Model Changes + +- None. + +## Security and Privacy + +- Test writes are disabled unless `E2E_SMOKE_ALLOW_WRITE=true`. +- No production secrets are logged by the smoke script. + +## Observability + +- Script prints a single success line on pass. +- Failures surface assertion or runtime errors with non-zero exit code. + +## Edge Cases and Failure Modes + +- Missing `DATABASE_URL`: fail fast in env validation. +- Missing explicit write guard: fail fast before DB writes. +- Middleware ordering regression: smoke test should fail when commands stop emitting statements. + +## Test Plan + +- Unit: parser/topic candidate tests cover slash-command exclusion. +- Integration: `bun run test:e2e` against a migrated dev database. +- E2E: same smoke script verifies purchase ingestion -> statement -> recalculated statement after utility update. + +## Acceptance Criteria + +- [ ] `bun run test:e2e` executes locally with deterministic output. +- [ ] Purchase ingestion and utility updates are both covered in the same smoke flow. +- [ ] Docs explain required env and safety guard. + +## Rollout Plan + +- Keep the smoke test local-first. +- Consider adding an opt-in CI job later once a dedicated disposable database is available. diff --git a/scripts/e2e/billing-flow.ts b/scripts/e2e/billing-flow.ts index ef36a40..11dedc0 100644 --- a/scripts/e2e/billing-flow.ts +++ b/scripts/e2e/billing-flow.ts @@ -127,6 +127,20 @@ async function run(): Promise { 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 From 6dd601c7bda5f470098de9cc697d67a5f8866b3d Mon Sep 17 00:00:00 2001 From: whekin Date: Sun, 8 Mar 2026 20:20:23 +0400 Subject: [PATCH 09/10] fix(test): lazy-load e2e env config --- scripts/e2e/billing-flow.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/scripts/e2e/billing-flow.ts b/scripts/e2e/billing-flow.ts index 11dedc0..d67b799 100644 --- a/scripts/e2e/billing-flow.ts +++ b/scripts/e2e/billing-flow.ts @@ -3,7 +3,6 @@ import { randomUUID } from 'node:crypto' import { eq } from 'drizzle-orm' -import { e2eEnv } from '@household/config' import { createDbClient, schema } from '@household/db' import { createTelegramBot } from '../../apps/bot/src/bot' @@ -13,12 +12,6 @@ import { registerPurchaseTopicIngestion } from '../../apps/bot/src/purchase-topic-ingestion' -if (!e2eEnv.E2E_SMOKE_ALLOW_WRITE) { - throw new Error('Set E2E_SMOKE_ALLOW_WRITE=true to run e2e smoke test') -} - -const databaseUrl: string = e2eEnv.DATABASE_URL - const chatId = '-100123456' const purchaseTopicId = 77 const commandChatIdNumber = -100123456 @@ -106,7 +99,21 @@ function parseStatement(text: string): Map { 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(), From b96f97dbe02296209ecaa1edd20c528859faaa9d Mon Sep 17 00:00:00 2001 From: whekin Date: Sun, 8 Mar 2026 20:21:38 +0400 Subject: [PATCH 10/10] fix(infra): include scripts workspace in docker builds --- apps/bot/Dockerfile | 1 + apps/miniapp/Dockerfile | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/bot/Dockerfile b/apps/bot/Dockerfile index 9b6bcfb..a8745af 100644 --- a/apps/bot/Dockerfile +++ b/apps/bot/Dockerfile @@ -13,6 +13,7 @@ COPY packages/db/package.json packages/db/package.json COPY packages/domain/package.json packages/domain/package.json COPY packages/observability/package.json packages/observability/package.json COPY packages/ports/package.json packages/ports/package.json +COPY scripts/package.json scripts/package.json RUN bun install --frozen-lockfile diff --git a/apps/miniapp/Dockerfile b/apps/miniapp/Dockerfile index 7ce621c..b6a79f7 100644 --- a/apps/miniapp/Dockerfile +++ b/apps/miniapp/Dockerfile @@ -13,6 +13,7 @@ COPY packages/db/package.json packages/db/package.json COPY packages/domain/package.json packages/domain/package.json COPY packages/observability/package.json packages/observability/package.json COPY packages/ports/package.json packages/ports/package.json +COPY scripts/package.json scripts/package.json RUN bun install --frozen-lockfile