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() nextId = 1 async createNotification(input: CreateAdHocNotificationInput): Promise { 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 { return this.notifications.get(notificationId) ?? null } async listUpcomingNotificationsForHousehold( householdId: string, asOf: Temporal.Instant ): Promise { return [...this.notifications.values()].filter( (notification) => notification.householdId === householdId && notification.status === 'scheduled' && notification.scheduledFor.epochMilliseconds > asOf.epochMilliseconds ) } async cancelNotification( input: CancelAdHocNotificationInput ): Promise { 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 { return [...this.notifications.values()].filter( (notification) => notification.status === 'scheduled' && notification.scheduledFor.epochMilliseconds <= asOf.epochMilliseconds ) } async markNotificationSent( notificationId: string, sentAt: Temporal.Instant ): Promise { 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 { return { notificationId, claimed: true } } async releaseNotificationDelivery(): Promise {} } function member( input: Partial & Pick ): 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 { 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') } }) })