import { describe, expect, mock, test } from 'bun:test' import type { ReminderJobResult, ReminderJobService } from '@household/application' import { Temporal } from '@household/domain' import type { ReminderTarget } from '@household/ports' import { createReminderJobsHandler } from './reminder-jobs' const target: ReminderTarget = { householdId: 'household-1', householdName: 'Kojori House', telegramChatId: '-1001', telegramThreadId: '12', locale: 'ru', timezone: 'Asia/Tbilisi', rentDueDay: 20, rentWarningDay: 17, utilitiesDueDay: 4, utilitiesReminderDay: 3 } const fixedNow = Temporal.Instant.from('2026-03-03T09:00:00Z') describe('createReminderJobsHandler', () => { test('returns per-household dispatch outcome with Telegram delivery metadata', async () => { const claimedResult: ReminderJobResult = { status: 'claimed', dedupeKey: '2026-03:utilities', payloadHash: 'hash', reminderType: 'utilities', period: '2026-03', messageText: 'Utilities reminder for 2026-03' } const reminderService: ReminderJobService = { handleJob: mock(async () => claimedResult) } const sendReminderMessage = mock(async () => {}) const handler = createReminderJobsHandler({ listReminderTargets: async () => [target], releaseReminderDispatch: mock(async () => {}), sendReminderMessage, reminderService, now: () => fixedNow, botUsername: 'household_test_bot' }) const response = await handler.handle( new Request('http://localhost/jobs/reminder/utilities', { method: 'POST', body: JSON.stringify({ period: '2026-03', jobId: 'job-1' }) }), 'utilities' ) expect(sendReminderMessage).toHaveBeenCalledTimes(1) expect(sendReminderMessage).toHaveBeenCalledWith( target, expect.objectContaining({ text: 'Напоминание по коммунальным платежам за 2026-03', replyMarkup: { inline_keyboard: [ [ { text: 'Ввести по шагам', callback_data: 'reminder_util:guided' }, { text: 'Шаблон', callback_data: 'reminder_util:template' } ], [ { text: 'Открыть дашборд', url: 'https://t.me/household_test_bot?start=dashboard' } ] ] } }) ) expect(response.status).toBe(200) expect(await response.json()).toEqual({ ok: true, jobId: 'job-1', reminderType: 'utilities', period: '2026-03', dryRun: false, totals: { targets: 1, claimed: 1, duplicate: 0, 'dry-run': 0, failed: 0 }, dispatches: [ { householdId: 'household-1', householdName: 'Kojori House', telegramChatId: '-1001', telegramThreadId: '12', period: '2026-03', dedupeKey: '2026-03:utilities', outcome: 'claimed', messageText: 'Напоминание по коммунальным платежам за 2026-03' } ] }) }) test('supports forced dry-run mode without posting to Telegram', async () => { const dryRunResult: ReminderJobResult = { status: 'dry-run', dedupeKey: '2026-03:rent-warning', payloadHash: 'hash', reminderType: 'rent-warning', period: '2026-03', messageText: 'Rent reminder for 2026-03: payment is coming up soon.' } const reminderService: ReminderJobService = { handleJob: mock(async () => dryRunResult) } const sendReminderMessage = mock(async () => {}) const handler = createReminderJobsHandler({ listReminderTargets: async () => [target], releaseReminderDispatch: mock(async () => {}), sendReminderMessage, reminderService, forceDryRun: true, now: () => fixedNow }) const response = await handler.handle( new Request('http://localhost/jobs/reminder/rent-warning', { method: 'POST', body: JSON.stringify({ period: '2026-03', jobId: 'job-2' }) }), 'rent-warning' ) expect(sendReminderMessage).toHaveBeenCalledTimes(0) expect(response.status).toBe(200) expect(await response.json()).toMatchObject({ dryRun: true, totals: { targets: 1, claimed: 0, duplicate: 0, 'dry-run': 1, failed: 0 } }) }) test('releases a dispatch claim when Telegram delivery fails', async () => { const failedResult: ReminderJobResult = { status: 'claimed', dedupeKey: '2026-03:rent-due', payloadHash: 'hash', reminderType: 'rent-due', period: '2026-03', messageText: 'Rent due reminder for 2026-03: please settle payment today.' } const reminderService: ReminderJobService = { handleJob: mock(async () => failedResult) } const releaseReminderDispatch = mock(async () => {}) const handler = createReminderJobsHandler({ listReminderTargets: async () => [target], releaseReminderDispatch, sendReminderMessage: mock(async () => { throw new Error('Telegram unavailable') }), reminderService, now: () => fixedNow }) const response = await handler.handle( new Request('http://localhost/jobs/reminder/rent-due', { method: 'POST', body: JSON.stringify({ period: '2026-03' }) }), 'rent-due' ) expect(releaseReminderDispatch).toHaveBeenCalledWith({ householdId: 'household-1', period: '2026-03', reminderType: 'rent-due' }) expect(response.status).toBe(200) expect(await response.json()).toMatchObject({ totals: { failed: 1 }, dispatches: [ expect.objectContaining({ outcome: 'failed', error: 'Telegram unavailable' }) ] }) }) test('rejects unsupported reminder type', async () => { const handler = createReminderJobsHandler({ listReminderTargets: async () => [target], releaseReminderDispatch: mock(async () => {}), sendReminderMessage: mock(async () => {}), reminderService: { handleJob: mock(async () => { throw new Error('should not be called') }) }, now: () => fixedNow }) const response = await handler.handle( new Request('http://localhost/jobs/reminder/unknown', { method: 'POST', body: JSON.stringify({ period: '2026-03' }) }), 'unknown' ) expect(response.status).toBe(400) expect(await response.json()).toEqual({ ok: false, error: 'Invalid reminder type' }) }) test('skips households whose configured reminder day does not match today when no period override is supplied', async () => { const reminderService: ReminderJobService = { handleJob: mock(async () => { throw new Error('should not be called') }) } const handler = createReminderJobsHandler({ listReminderTargets: async () => [ { ...target, utilitiesReminderDay: 31 } ], releaseReminderDispatch: mock(async () => {}), sendReminderMessage: mock(async () => {}), reminderService, now: () => fixedNow }) const response = await handler.handle( new Request('http://localhost/jobs/reminder/utilities', { method: 'POST', body: JSON.stringify({ jobId: 'job-3' }) }), 'utilities' ) expect(response.status).toBe(200) expect(await response.json()).toMatchObject({ ok: true, totals: { targets: 0 }, dispatches: [] }) }) test('honors explicit period overrides even when today is not the configured reminder day', async () => { const dryRunResult: ReminderJobResult = { status: 'dry-run', dedupeKey: '2026-03:rent-due', payloadHash: 'hash', reminderType: 'rent-due', period: '2026-03', messageText: 'Rent due reminder for 2026-03: please settle payment today.' } const reminderService: ReminderJobService = { handleJob: mock(async () => dryRunResult) } const handler = createReminderJobsHandler({ listReminderTargets: async () => [ { ...target, rentDueDay: 20 } ], releaseReminderDispatch: mock(async () => {}), sendReminderMessage: mock(async () => {}), reminderService, now: () => fixedNow }) const response = await handler.handle( new Request('http://localhost/jobs/reminder/rent-due', { method: 'POST', body: JSON.stringify({ period: '2026-03', dryRun: true }) }), 'rent-due' ) expect(response.status).toBe(200) expect(await response.json()).toMatchObject({ ok: true, totals: { targets: 1, 'dry-run': 1 }, dispatches: [ expect.objectContaining({ householdId: 'household-1', period: '2026-03', outcome: 'dry-run' }) ] }) }) })