mirror of
https://github.com/whekin/household-bot.git
synced 2026-04-01 00:14:03 +00:00
refactor(bot): replace reminder polling with scheduled dispatches
This commit is contained in:
294
packages/application/src/scheduled-dispatch-service.test.ts
Normal file
294
packages/application/src/scheduled-dispatch-service.test.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { Temporal } from '@household/domain'
|
||||
import type {
|
||||
HouseholdBillingSettingsRecord,
|
||||
HouseholdTelegramChatRecord,
|
||||
ReminderTarget,
|
||||
ScheduledDispatchRecord,
|
||||
ScheduledDispatchRepository,
|
||||
ScheduledDispatchScheduler
|
||||
} from '@household/ports'
|
||||
|
||||
import { createScheduledDispatchService } from './scheduled-dispatch-service'
|
||||
|
||||
class ScheduledDispatchRepositoryStub implements ScheduledDispatchRepository {
|
||||
dispatches = new Map<string, ScheduledDispatchRecord>()
|
||||
nextId = 1
|
||||
claims = new Set<string>()
|
||||
|
||||
async createScheduledDispatch(input: {
|
||||
householdId: string
|
||||
kind: ScheduledDispatchRecord['kind']
|
||||
dueAt: Temporal.Instant
|
||||
timezone: string
|
||||
provider: ScheduledDispatchRecord['provider']
|
||||
providerDispatchId?: string | null
|
||||
adHocNotificationId?: string | null
|
||||
period?: string | null
|
||||
}): Promise<ScheduledDispatchRecord> {
|
||||
const id = `dispatch-${this.nextId++}`
|
||||
const record: ScheduledDispatchRecord = {
|
||||
id,
|
||||
householdId: input.householdId,
|
||||
kind: input.kind,
|
||||
dueAt: input.dueAt,
|
||||
timezone: input.timezone,
|
||||
status: 'scheduled',
|
||||
provider: input.provider,
|
||||
providerDispatchId: input.providerDispatchId ?? null,
|
||||
adHocNotificationId: input.adHocNotificationId ?? null,
|
||||
period: input.period ?? null,
|
||||
sentAt: null,
|
||||
cancelledAt: null,
|
||||
createdAt: Temporal.Instant.from('2026-03-24T00:00:00Z'),
|
||||
updatedAt: Temporal.Instant.from('2026-03-24T00:00:00Z')
|
||||
}
|
||||
this.dispatches.set(id, record)
|
||||
return record
|
||||
}
|
||||
|
||||
async getScheduledDispatchById(dispatchId: string): Promise<ScheduledDispatchRecord | null> {
|
||||
return this.dispatches.get(dispatchId) ?? null
|
||||
}
|
||||
|
||||
async getScheduledDispatchByAdHocNotificationId(
|
||||
notificationId: string
|
||||
): Promise<ScheduledDispatchRecord | null> {
|
||||
return (
|
||||
[...this.dispatches.values()].find(
|
||||
(dispatch) => dispatch.adHocNotificationId === notificationId
|
||||
) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
async listScheduledDispatchesForHousehold(
|
||||
householdId: string
|
||||
): Promise<readonly ScheduledDispatchRecord[]> {
|
||||
return [...this.dispatches.values()].filter((dispatch) => dispatch.householdId === householdId)
|
||||
}
|
||||
|
||||
async updateScheduledDispatch(input: {
|
||||
dispatchId: string
|
||||
dueAt?: Temporal.Instant
|
||||
timezone?: string
|
||||
providerDispatchId?: string | null
|
||||
period?: string | null
|
||||
updatedAt: Temporal.Instant
|
||||
}): Promise<ScheduledDispatchRecord | null> {
|
||||
const current = this.dispatches.get(input.dispatchId)
|
||||
if (!current) {
|
||||
return null
|
||||
}
|
||||
|
||||
const next: ScheduledDispatchRecord = {
|
||||
...current,
|
||||
dueAt: input.dueAt ?? current.dueAt,
|
||||
timezone: input.timezone ?? current.timezone,
|
||||
providerDispatchId:
|
||||
input.providerDispatchId === undefined
|
||||
? current.providerDispatchId
|
||||
: input.providerDispatchId,
|
||||
period: input.period === undefined ? current.period : input.period,
|
||||
updatedAt: input.updatedAt
|
||||
}
|
||||
this.dispatches.set(input.dispatchId, next)
|
||||
return next
|
||||
}
|
||||
|
||||
async cancelScheduledDispatch(
|
||||
dispatchId: string,
|
||||
cancelledAt: Temporal.Instant
|
||||
): Promise<ScheduledDispatchRecord | null> {
|
||||
const current = this.dispatches.get(dispatchId)
|
||||
if (!current || current.status !== 'scheduled') {
|
||||
return null
|
||||
}
|
||||
|
||||
const next: ScheduledDispatchRecord = {
|
||||
...current,
|
||||
status: 'cancelled',
|
||||
cancelledAt
|
||||
}
|
||||
this.dispatches.set(dispatchId, next)
|
||||
return next
|
||||
}
|
||||
|
||||
async markScheduledDispatchSent(
|
||||
dispatchId: string,
|
||||
sentAt: Temporal.Instant
|
||||
): Promise<ScheduledDispatchRecord | null> {
|
||||
const current = this.dispatches.get(dispatchId)
|
||||
if (!current || current.status !== 'scheduled') {
|
||||
return null
|
||||
}
|
||||
|
||||
const next: ScheduledDispatchRecord = {
|
||||
...current,
|
||||
status: 'sent',
|
||||
sentAt
|
||||
}
|
||||
this.dispatches.set(dispatchId, next)
|
||||
return next
|
||||
}
|
||||
|
||||
async claimScheduledDispatchDelivery(dispatchId: string) {
|
||||
if (this.claims.has(dispatchId)) {
|
||||
return { dispatchId, claimed: false }
|
||||
}
|
||||
this.claims.add(dispatchId)
|
||||
return { dispatchId, claimed: true }
|
||||
}
|
||||
|
||||
async releaseScheduledDispatchDelivery(dispatchId: string) {
|
||||
this.claims.delete(dispatchId)
|
||||
}
|
||||
}
|
||||
|
||||
function createSchedulerStub(): ScheduledDispatchScheduler & {
|
||||
scheduled: Array<{ dispatchId: string; dueAt: string }>
|
||||
cancelled: string[]
|
||||
} {
|
||||
let nextId = 1
|
||||
|
||||
return {
|
||||
provider: 'gcp-cloud-tasks',
|
||||
scheduled: [],
|
||||
cancelled: [],
|
||||
async scheduleOneShotDispatch(input) {
|
||||
this.scheduled.push({
|
||||
dispatchId: input.dispatchId,
|
||||
dueAt: input.dueAt.toString()
|
||||
})
|
||||
return {
|
||||
providerDispatchId: `provider-${nextId++}`
|
||||
}
|
||||
},
|
||||
async cancelDispatch(providerDispatchId) {
|
||||
this.cancelled.push(providerDispatchId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function billingSettings(
|
||||
timezone = 'Asia/Tbilisi'
|
||||
): HouseholdBillingSettingsRecord & { householdId: string } {
|
||||
return {
|
||||
householdId: 'household-1',
|
||||
settlementCurrency: 'GEL',
|
||||
timezone,
|
||||
rentDueDay: 5,
|
||||
rentWarningDay: 3,
|
||||
utilitiesReminderDay: 12,
|
||||
utilitiesDueDay: 15,
|
||||
rentAmountMinor: null,
|
||||
rentCurrency: 'GEL',
|
||||
rentPaymentDestinations: null
|
||||
}
|
||||
}
|
||||
|
||||
function householdChat(): HouseholdTelegramChatRecord {
|
||||
return {
|
||||
householdId: 'household-1',
|
||||
householdName: 'Kojori',
|
||||
telegramChatId: 'chat-1',
|
||||
telegramChatType: 'supergroup',
|
||||
title: 'Kojori',
|
||||
defaultLocale: 'ru'
|
||||
}
|
||||
}
|
||||
|
||||
describe('createScheduledDispatchService', () => {
|
||||
test('schedules and reschedules ad hoc notifications via provider task', async () => {
|
||||
const repository = new ScheduledDispatchRepositoryStub()
|
||||
const scheduler = createSchedulerStub()
|
||||
const service = createScheduledDispatchService({
|
||||
repository,
|
||||
scheduler,
|
||||
householdConfigurationRepository: {
|
||||
async getHouseholdBillingSettings() {
|
||||
return billingSettings()
|
||||
},
|
||||
async getHouseholdChatByHouseholdId() {
|
||||
return householdChat()
|
||||
},
|
||||
async listReminderTargets(): Promise<readonly ReminderTarget[]> {
|
||||
return []
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const firstDueAt = Temporal.Instant.from('2026-03-25T08:00:00Z')
|
||||
const secondDueAt = Temporal.Instant.from('2026-03-25T09:00:00Z')
|
||||
|
||||
const first = await service.scheduleAdHocNotification({
|
||||
householdId: 'household-1',
|
||||
notificationId: 'notif-1',
|
||||
dueAt: firstDueAt,
|
||||
timezone: 'Asia/Tbilisi'
|
||||
})
|
||||
const second = await service.scheduleAdHocNotification({
|
||||
householdId: 'household-1',
|
||||
notificationId: 'notif-1',
|
||||
dueAt: secondDueAt,
|
||||
timezone: 'Asia/Tbilisi'
|
||||
})
|
||||
|
||||
expect(first.providerDispatchId).toBe('provider-1')
|
||||
expect(second.providerDispatchId).toBe('provider-2')
|
||||
expect(scheduler.cancelled).toEqual(['provider-1'])
|
||||
|
||||
await service.cancelAdHocNotification('notif-1', Temporal.Instant.from('2026-03-24T11:00:00Z'))
|
||||
|
||||
expect(scheduler.cancelled).toEqual(['provider-1', 'provider-2'])
|
||||
expect((await repository.getScheduledDispatchByAdHocNotificationId('notif-1'))?.status).toBe(
|
||||
'cancelled'
|
||||
)
|
||||
})
|
||||
|
||||
test('reconciles one future built-in dispatch per reminder kind', async () => {
|
||||
const repository = new ScheduledDispatchRepositoryStub()
|
||||
const scheduler = createSchedulerStub()
|
||||
const service = createScheduledDispatchService({
|
||||
repository,
|
||||
scheduler,
|
||||
householdConfigurationRepository: {
|
||||
async getHouseholdBillingSettings() {
|
||||
return billingSettings()
|
||||
},
|
||||
async getHouseholdChatByHouseholdId() {
|
||||
return householdChat()
|
||||
},
|
||||
async listReminderTargets(): Promise<readonly ReminderTarget[]> {
|
||||
return [
|
||||
{
|
||||
householdId: 'household-1',
|
||||
householdName: 'Kojori',
|
||||
telegramChatId: 'chat-1',
|
||||
telegramThreadId: '103',
|
||||
locale: 'ru',
|
||||
timezone: 'Asia/Tbilisi',
|
||||
utilitiesReminderDay: 12,
|
||||
utilitiesDueDay: 15,
|
||||
rentWarningDay: 3,
|
||||
rentDueDay: 5
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await service.reconcileAllBuiltInDispatches(Temporal.Instant.from('2026-03-24T00:00:00Z'))
|
||||
|
||||
const scheduled = [...repository.dispatches.values()].filter(
|
||||
(dispatch) => dispatch.status === 'scheduled'
|
||||
)
|
||||
expect(scheduled.map((dispatch) => dispatch.kind).sort()).toEqual([
|
||||
'rent_due',
|
||||
'rent_warning',
|
||||
'utilities'
|
||||
])
|
||||
expect(scheduler.scheduled).toHaveLength(3)
|
||||
expect(scheduled.every((dispatch) => dispatch.period === '2026-04')).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user