mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 13:54:02 +00:00
269 lines
8.9 KiB
TypeScript
269 lines
8.9 KiB
TypeScript
import { describe, expect, test } from 'bun:test'
|
||
|
||
import { Temporal } from '@household/domain'
|
||
import type {
|
||
AdHocNotificationRecord,
|
||
AdHocNotificationRepository,
|
||
CancelAdHocNotificationInput,
|
||
ClaimAdHocNotificationDeliveryResult,
|
||
CreateAdHocNotificationInput,
|
||
HouseholdConfigurationRepository,
|
||
HouseholdMemberRecord
|
||
} from '@household/ports'
|
||
|
||
import { createAdHocNotificationService } from './ad-hoc-notification-service'
|
||
|
||
class NotificationRepositoryStub implements AdHocNotificationRepository {
|
||
notifications = new Map<string, AdHocNotificationRecord>()
|
||
nextId = 1
|
||
|
||
async createNotification(input: CreateAdHocNotificationInput): Promise<AdHocNotificationRecord> {
|
||
const id = `notif-${this.nextId++}`
|
||
const record: AdHocNotificationRecord = {
|
||
id,
|
||
householdId: input.householdId,
|
||
creatorMemberId: input.creatorMemberId,
|
||
assigneeMemberId: input.assigneeMemberId ?? null,
|
||
originalRequestText: input.originalRequestText,
|
||
notificationText: input.notificationText,
|
||
timezone: input.timezone,
|
||
scheduledFor: input.scheduledFor,
|
||
timePrecision: input.timePrecision,
|
||
deliveryMode: input.deliveryMode,
|
||
dmRecipientMemberIds: input.dmRecipientMemberIds ?? [],
|
||
friendlyTagAssignee: input.friendlyTagAssignee,
|
||
status: 'scheduled',
|
||
sourceTelegramChatId: input.sourceTelegramChatId ?? null,
|
||
sourceTelegramThreadId: input.sourceTelegramThreadId ?? null,
|
||
sentAt: null,
|
||
cancelledAt: null,
|
||
cancelledByMemberId: null,
|
||
createdAt: Temporal.Instant.from('2026-03-23T09:00:00Z'),
|
||
updatedAt: Temporal.Instant.from('2026-03-23T09:00:00Z')
|
||
}
|
||
this.notifications.set(id, record)
|
||
return record
|
||
}
|
||
|
||
async getNotificationById(notificationId: string): Promise<AdHocNotificationRecord | null> {
|
||
return this.notifications.get(notificationId) ?? null
|
||
}
|
||
|
||
async listUpcomingNotificationsForHousehold(
|
||
householdId: string,
|
||
asOf: Temporal.Instant
|
||
): Promise<readonly AdHocNotificationRecord[]> {
|
||
return [...this.notifications.values()].filter(
|
||
(notification) =>
|
||
notification.householdId === householdId &&
|
||
notification.status === 'scheduled' &&
|
||
notification.scheduledFor.epochMilliseconds > asOf.epochMilliseconds
|
||
)
|
||
}
|
||
|
||
async cancelNotification(
|
||
input: CancelAdHocNotificationInput
|
||
): Promise<AdHocNotificationRecord | null> {
|
||
const record = this.notifications.get(input.notificationId)
|
||
if (!record || record.status !== 'scheduled') {
|
||
return null
|
||
}
|
||
|
||
const next = {
|
||
...record,
|
||
status: 'cancelled' as const,
|
||
cancelledAt: input.cancelledAt,
|
||
cancelledByMemberId: input.cancelledByMemberId
|
||
}
|
||
this.notifications.set(input.notificationId, next)
|
||
return next
|
||
}
|
||
|
||
async listDueNotifications(asOf: Temporal.Instant): Promise<readonly AdHocNotificationRecord[]> {
|
||
return [...this.notifications.values()].filter(
|
||
(notification) =>
|
||
notification.status === 'scheduled' &&
|
||
notification.scheduledFor.epochMilliseconds <= asOf.epochMilliseconds
|
||
)
|
||
}
|
||
|
||
async markNotificationSent(
|
||
notificationId: string,
|
||
sentAt: Temporal.Instant
|
||
): Promise<AdHocNotificationRecord | null> {
|
||
const record = this.notifications.get(notificationId)
|
||
if (!record || record.status !== 'scheduled') {
|
||
return null
|
||
}
|
||
|
||
const next = {
|
||
...record,
|
||
status: 'sent' as const,
|
||
sentAt
|
||
}
|
||
this.notifications.set(notificationId, next)
|
||
return next
|
||
}
|
||
|
||
async claimNotificationDelivery(
|
||
notificationId: string
|
||
): Promise<ClaimAdHocNotificationDeliveryResult> {
|
||
return {
|
||
notificationId,
|
||
claimed: true
|
||
}
|
||
}
|
||
|
||
async releaseNotificationDelivery(): Promise<void> {}
|
||
}
|
||
|
||
function member(
|
||
input: Partial<HouseholdMemberRecord> & Pick<HouseholdMemberRecord, 'id'>
|
||
): HouseholdMemberRecord {
|
||
return {
|
||
id: input.id,
|
||
householdId: input.householdId ?? 'household-1',
|
||
telegramUserId: input.telegramUserId ?? `${input.id}-tg`,
|
||
displayName: input.displayName ?? input.id,
|
||
status: input.status ?? 'active',
|
||
preferredLocale: input.preferredLocale ?? 'ru',
|
||
householdDefaultLocale: input.householdDefaultLocale ?? 'ru',
|
||
rentShareWeight: input.rentShareWeight ?? 1,
|
||
isAdmin: input.isAdmin ?? false
|
||
}
|
||
}
|
||
|
||
function createHouseholdRepository(
|
||
members: readonly HouseholdMemberRecord[]
|
||
): Pick<HouseholdConfigurationRepository, 'getHouseholdMember' | 'listHouseholdMembers'> {
|
||
return {
|
||
async getHouseholdMember(householdId, telegramUserId) {
|
||
return (
|
||
members.find(
|
||
(member) => member.householdId === householdId && member.telegramUserId === telegramUserId
|
||
) ?? null
|
||
)
|
||
},
|
||
async listHouseholdMembers(householdId) {
|
||
return members.filter((member) => member.householdId === householdId)
|
||
}
|
||
}
|
||
}
|
||
|
||
describe('createAdHocNotificationService', () => {
|
||
test('defaults date-only reminder to scheduled notification with topic delivery', async () => {
|
||
const repository = new NotificationRepositoryStub()
|
||
const members = [member({ id: 'creator' }), member({ id: 'assignee', displayName: 'Georgiy' })]
|
||
const service = createAdHocNotificationService({
|
||
repository,
|
||
householdConfigurationRepository: createHouseholdRepository(members)
|
||
})
|
||
|
||
const result = await service.scheduleNotification({
|
||
householdId: 'household-1',
|
||
creatorMemberId: 'creator',
|
||
assigneeMemberId: 'assignee',
|
||
originalRequestText: 'Напомни Георгию завтра',
|
||
notificationText: 'пошпынять Георгия о том, позвонил ли он',
|
||
timezone: 'Asia/Tbilisi',
|
||
scheduledFor: Temporal.Instant.from('2026-03-24T08:00:00Z'),
|
||
timePrecision: 'date_only_defaulted',
|
||
deliveryMode: 'topic'
|
||
})
|
||
|
||
expect(result.status).toBe('scheduled')
|
||
if (result.status === 'scheduled') {
|
||
expect(result.notification.deliveryMode).toBe('topic')
|
||
expect(result.notification.assigneeMemberId).toBe('assignee')
|
||
}
|
||
})
|
||
|
||
test('expands dm_all to all active members', async () => {
|
||
const repository = new NotificationRepositoryStub()
|
||
const members = [
|
||
member({ id: 'creator' }),
|
||
member({ id: 'alice' }),
|
||
member({ id: 'bob', status: 'away' }),
|
||
member({ id: 'carol' })
|
||
]
|
||
const service = createAdHocNotificationService({
|
||
repository,
|
||
householdConfigurationRepository: createHouseholdRepository(members)
|
||
})
|
||
|
||
const result = await service.scheduleNotification({
|
||
householdId: 'household-1',
|
||
creatorMemberId: 'creator',
|
||
originalRequestText: 'remind everyone tomorrow',
|
||
notificationText: 'pay rent',
|
||
timezone: 'Asia/Tbilisi',
|
||
scheduledFor: Temporal.Instant.from('2026-03-24T08:00:00Z'),
|
||
timePrecision: 'date_only_defaulted',
|
||
deliveryMode: 'dm_all'
|
||
})
|
||
|
||
expect(result.status).toBe('scheduled')
|
||
if (result.status === 'scheduled') {
|
||
expect(result.notification.dmRecipientMemberIds).toEqual(['creator', 'alice', 'carol'])
|
||
}
|
||
})
|
||
|
||
test('rejects friendly mode without assignee', async () => {
|
||
const repository = new NotificationRepositoryStub()
|
||
const service = createAdHocNotificationService({
|
||
repository,
|
||
householdConfigurationRepository: createHouseholdRepository([member({ id: 'creator' })])
|
||
})
|
||
|
||
const result = await service.scheduleNotification({
|
||
householdId: 'household-1',
|
||
creatorMemberId: 'creator',
|
||
originalRequestText: 'remind tomorrow',
|
||
notificationText: 'check rent',
|
||
timezone: 'Asia/Tbilisi',
|
||
scheduledFor: Temporal.Instant.from('2026-03-24T08:00:00Z'),
|
||
timePrecision: 'date_only_defaulted',
|
||
deliveryMode: 'topic',
|
||
friendlyTagAssignee: true
|
||
})
|
||
|
||
expect(result).toEqual({
|
||
status: 'invalid',
|
||
reason: 'friendly_assignee_missing'
|
||
})
|
||
})
|
||
|
||
test('allows admin to cancel someone else notification', async () => {
|
||
const repository = new NotificationRepositoryStub()
|
||
const creator = member({ id: 'creator', telegramUserId: 'creator-tg' })
|
||
const admin = member({ id: 'admin', telegramUserId: 'admin-tg', isAdmin: true })
|
||
const service = createAdHocNotificationService({
|
||
repository,
|
||
householdConfigurationRepository: createHouseholdRepository([creator, admin])
|
||
})
|
||
|
||
const created = await repository.createNotification({
|
||
householdId: 'household-1',
|
||
creatorMemberId: 'creator',
|
||
originalRequestText: 'remind tomorrow',
|
||
notificationText: 'call landlord',
|
||
timezone: 'Asia/Tbilisi',
|
||
scheduledFor: Temporal.Instant.from('2026-03-24T08:00:00Z'),
|
||
timePrecision: 'date_only_defaulted',
|
||
deliveryMode: 'topic',
|
||
friendlyTagAssignee: false
|
||
})
|
||
|
||
const result = await service.cancelNotification({
|
||
notificationId: created.id,
|
||
viewerMemberId: 'admin',
|
||
asOf: Temporal.Instant.from('2026-03-23T09:00:00Z')
|
||
})
|
||
|
||
expect(result.status).toBe('cancelled')
|
||
if (result.status === 'cancelled') {
|
||
expect(result.notification.cancelledByMemberId).toBe('admin')
|
||
}
|
||
})
|
||
})
|