Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 117 additions & 1 deletion src/bot/handlers/message.handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,10 @@ mock.module('../services/expense-saver', () => ({

// ── ask (maybeSmartAdvice is fire-and-forget advice hook) ──
const maybeSmartAdviceMock = mock(() => Promise.resolve());
const handleAskQuestionMock = mock(() => Promise.resolve());
mock.module('../commands/ask', () => ({
maybeSmartAdvice: maybeSmartAdviceMock,
handleAskQuestion: handleAskQuestionMock,
}));

// ── dev pipeline — never trigger in these tests ──
Expand Down Expand Up @@ -243,7 +245,9 @@ mock.module('../services/category-wizard', () => ({
}));

// Dynamic-import target — must come AFTER all mock.module calls.
const { handleExpenseMessage, buildBudgetAlertStatus } = await import('./message.handler');
const { handleExpenseMessage, buildBudgetAlertStatus, routeTextMessage } = await import(
'./message.handler'
);

// ─── Fixtures ────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -336,6 +340,7 @@ function resetAllMocks(): void {
saveExpenseBatchMock.mockReset().mockResolvedValue();
saveReceiptExpensesMock.mockReset().mockResolvedValue();
maybeSmartAdviceMock.mockReset().mockResolvedValue();
handleAskQuestionMock.mockReset().mockResolvedValue();
isAwaitingCustomCurrencyMock.mockReset().mockReturnValue(false);
handleCustomCurrencyInputMock.mockReset().mockResolvedValue(true);
handleWizardInputMock.mockReset().mockResolvedValue(false);
Expand Down Expand Up @@ -791,3 +796,114 @@ describe('handleExpenseMessage — multi-line batch', () => {
expect(summaryCall).toBeDefined();
});
});

// Budget language is intent, even if every line otherwise matches expense syntax.
describe('budget routing safety regression', () => {
beforeEach(() => {
resetAllMocks();
mockGroups.findByTelegramGroupId.mockReturnValue(makeGroup());
mockCategories.exists.mockReturnValue(true);
});

test.each([
'бюджет',
'БЮДЖЕТ',
'бюджета',
'бюджету',
'бюджетом',
'бюджете',
'бюджеты',
'бюджетов',
'бюджетами',
'бюджетный',
'budget',
'BuDgEt',
'BUDGETS',
])('refuses all expense writes when %s appears in a parseable line', async (word) => {
const handled = await handleExpenseMessage(
fakeMessageCtx(`10 EUR еда\n20 EUR ${word}`),
fakeBot(),
);
expect(handled).toBe(false);
expect(mockPendingExpenses.create).not.toHaveBeenCalled();
expect(saveExpenseBatchMock).not.toHaveBeenCalled();
expect(sendMessageMock).not.toHaveBeenCalled();
});

test.each([
'Составь план\n€450 — квартира',
'€450 — квартира\nУточни остаток',
'\n\n',
'10 EUR еда\nБЮДЖЕТ\n20 EUR дом',
])('keeps the entire mixed message out of expense writes: %s', async (text) => {
expect(await handleExpenseMessage(fakeMessageCtx(text), fakeBot())).toBe(false);
expect(mockPendingExpenses.create).not.toHaveBeenCalled();
expect(saveExpenseBatchMock).not.toHaveBeenCalled();
});

test('euro prefix, em dash and blank lines remain valid for pure expenses', async () => {
expect(
await handleExpenseMessage(fakeMessageCtx('\n€450 — квартира\n\n€600 — еда\n'), fakeBot()),
).toBe(true);
expect(mockPendingExpenses.create).toHaveBeenCalledTimes(2);
expect(saveExpenseBatchMock).toHaveBeenCalledTimes(1);
});
});

describe('production text route forwards budget intent intact', () => {
beforeEach(() => {
resetAllMocks();
mockGroups.findByTelegramGroupId.mockReturnValue(makeGroup({ active_topic_id: 77 }));
mockCategories.exists.mockReturnValue(true);
});

test('exact October plan reaches AI once with original context and zero expense writes', async () => {
const text = `Сформируй бюджет на грядущий октябрь

€450 — квартира
€600 — еда
€300 — коммуналка
€200 — животные
€200 — машина
€700 — Лена
€700 — Алекс
€100 — развлечения
€620 — подписки
€100 — дом

Другие категории убери (перечили), сумма должна быть 6000€`;
const ctx = fakeMessageCtx(text, { isForum: true, threadId: 77 });
const bot = fakeBot();
await routeTextMessage(ctx, bot, true);
expect(handleAskQuestionMock).toHaveBeenCalledTimes(1);
expect(handleAskQuestionMock).toHaveBeenCalledWith(ctx, text, bot);
expect(mockPendingExpenses.create).not.toHaveBeenCalled();
expect(saveExpenseBatchMock).not.toHaveBeenCalled();
expect(saveReceiptExpensesMock).not.toHaveBeenCalled();
expect(sendMessageMock).not.toHaveBeenCalled();
});

test.each([
'100 EUR БЮДЖЕТА',
'Вводный текст\n€450 — квартира',
'€450 — квартира\nЗавершающий текст',
])('declined fastpath calls AI rather than dropping input: %s', async (text) => {
const ctx = fakeMessageCtx(text, { threadId: 77 });
const bot = fakeBot();
await routeTextMessage(ctx, bot, true);
expect(handleAskQuestionMock).toHaveBeenCalledTimes(1);
expect(handleAskQuestionMock).toHaveBeenCalledWith(ctx, text, bot);
expect(mockPendingExpenses.create).not.toHaveBeenCalled();
});

test('pure expenses are saved without invoking AI', async () => {
await routeTextMessage(
fakeMessageCtx('€450 — квартира\n\n€600 — еда', { threadId: 77 }),
fakeBot(),
true,
);
expect(mockPendingExpenses.create).toHaveBeenCalledTimes(2);
expect(saveExpenseBatchMock).toHaveBeenCalledTimes(1);
expect(handleAskQuestionMock).not.toHaveBeenCalled();
});
});
18 changes: 17 additions & 1 deletion src/bot/handlers/message.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { getErrorMessage } from '../../utils/error';
import { findBestCategoryMatchAsync } from '../../utils/fuzzy-search';
import { escapeHtml } from '../../utils/html';
import { createLogger } from '../../utils/logger.ts';
import { maybeSmartAdvice } from '../commands/ask';
import { handleAskQuestion, maybeSmartAdvice } from '../commands/ask';
import { consumePendingDesignEdit, getPipelineInstance } from '../commands/dev';
import { consumePendingFeedback, submitFeedback } from '../commands/feedback';
import { showNextPendingCategoryStep } from '../services/category-wizard';
Expand Down Expand Up @@ -421,6 +421,10 @@ function parseStrictLines(

logger.info(`[MSG] Processing ${lines.length} line(s)`);

if (/(?:^|[^\p{L}])(?:бюджет|budget)\p{L}*(?=$|[^\p{L}])/iu.test(text)) {
logger.info('[MSG] Budget intent — routing the whole message to AI');
return null;
}
const parsedLines = lines.map((line) => parseExpenseMessage(line, defaultCurrency));
if (parsedLines.length === 0 || parsedLines.some((parsed) => !validateParsedExpense(parsed))) {
logger.info('[MSG] Mixed/non-canonical message — routing the whole message to AI');
Expand Down Expand Up @@ -984,3 +988,15 @@ export function buildBudgetAlertStatus(
isWarning: progress.is_warning,
};
}

/** Preserve the full message and context when direct expense entry declines it. */
export async function routeTextMessage(
ctx: Ctx['Message'],
bot: BotInstance,
allowDirectAI: boolean,
): Promise<void> {
const expenseHandled = await handleExpenseMessage(ctx, bot);
if (!expenseHandled && allowDirectAI && ctx.text) {
await handleAskQuestion(ctx, ctx.text, bot);
}
}
3 changes: 3 additions & 0 deletions src/bot/handlers/message.test-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,10 @@ mock.module('../services/expense-saver', () => ({

// ── ask ──
export const maybeSmartAdviceMock = mock((_groupId: number) => Promise.resolve());
export const handleAskQuestionMock = mock((_ctx: unknown, _text: string) => Promise.resolve());
mock.module('../commands/ask', () => ({
maybeSmartAdvice: maybeSmartAdviceMock,
handleAskQuestion: handleAskQuestionMock,
}));

// ── dev pipeline ──
Expand Down Expand Up @@ -312,6 +314,7 @@ export function resetAllMocks(): void {
saveExpenseBatchMock.mockReset().mockResolvedValue();
saveReceiptExpensesMock.mockReset().mockResolvedValue();
maybeSmartAdviceMock.mockReset().mockResolvedValue();
handleAskQuestionMock.mockReset().mockResolvedValue();
consumePendingDesignEditMock.mockReset().mockReturnValue(null);
getPipelineInstanceMock.mockReset().mockReturnValue(null);
consumePendingFeedbackMock.mockReset().mockReturnValue(null);
Expand Down
7 changes: 2 additions & 5 deletions src/bot/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import {
} from './cron';
import { requireAdmin, requireGoogle, requireGroup } from './guards';
import { handleCallbackQuery } from './handlers/callback.handler';
import { handleExpenseMessage } from './handlers/message.handler';
import { routeTextMessage } from './handlers/message.handler';
import { handlePhotoMessage } from './handlers/photo.handler';
import { rateLimitOnResponseError, rateLimitPreRequest } from './rate-limit.hook';
import { sanitizeOutgoingMessages } from './sanitize-outgoing.hook';
Expand Down Expand Up @@ -196,10 +196,7 @@ export function createBot(): Bot {

// Deterministic expense input is handled locally; everything else may fall through
// to AI, which stores its own exact current-turn history row before reading context.
const expenseHandled = await handleExpenseMessage(ctx, bot);
if (!expenseHandled && allowDirectAI) {
await handleAskQuestion(ctx, text, bot);
}
await routeTextMessage(ctx, bot, allowDirectAI);
});

// Global error handler — catches unhandled errors in bot middleware/handlers
Expand Down
39 changes: 37 additions & 2 deletions src/integration/expense-flow.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,11 @@ mock.module('../utils/fuzzy-search', () => ({
normalizeCategoryName: (s: string) => s,
}));

mock.module('../bot/commands/ask', () => ({ maybeSmartAdvice: async () => {} }));
const handleAskQuestionMock = mock(async (..._args: unknown[]) => {});
mock.module('../bot/commands/ask', () => ({
maybeSmartAdvice: async () => {},
handleAskQuestion: handleAskQuestionMock,
}));
mock.module('../bot/commands/dev', () => ({
consumePendingDesignEdit: () => null,
getPipelineInstance: () => null,
Expand Down Expand Up @@ -162,7 +166,7 @@ mock.module('../database', () => ({

// ── Dynamic imports AFTER all mocks ──────────────────────────────────────

const { handleExpenseMessage } = await import('../bot/handlers/message.handler');
const { handleExpenseMessage, routeTextMessage } = await import('../bot/handlers/message.handler');

// ── Fixtures ─────────────────────────────────────────────────────────────

Expand All @@ -183,6 +187,7 @@ afterAll(() => {

beforeEach(() => {
clearTestDb(db);
handleAskQuestionMock.mockClear();
sentMessages.length = 0;
sendMessageMock.mockClear();
appendExpenseRowsMock.mockReset();
Expand Down Expand Up @@ -336,3 +341,33 @@ describe('expense-flow integration', () => {
expect(appendExpenseRowsMock).not.toHaveBeenCalled();
});
});

test('exact October budget replay reaches AI with zero real expense or pending rows', async () => {
seedGroupAndUser();
const text = `Сформируй бюджет на грядущий октябрь

€450 — квартира
€600 — еда
€300 — коммуналка
€200 — животные
€200 — машина
€700 — Лена
€700 — Алекс
€100 — развлечения
€620 — подписки
€100 — дом

Другие категории убери (перечили), сумма должна быть 6000€`;
const ctx = fakeCtx(text);
const bot = fakeBot();
await routeTextMessage(ctx, bot, true);
expect(handleAskQuestionMock).toHaveBeenCalledTimes(1);
expect(handleAskQuestionMock).toHaveBeenCalledWith(ctx, text, bot);
expect(db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM expenses').get()?.n).toBe(0);
expect(db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM pending_expenses').get()?.n).toBe(
0,
);
expect(appendExpenseRowsMock).not.toHaveBeenCalled();
expect(sentMessages).toEqual([]);
expect(logMock.error).not.toHaveBeenCalled();
});
Loading