feat(bot): add anonymous feedback flow

This commit is contained in:
2026-03-08 22:50:55 +04:00
parent c6a9ade586
commit 7ffd81bda9
21 changed files with 2750 additions and 3 deletions

View File

@@ -0,0 +1,217 @@
import { describe, expect, test } from 'bun:test'
import type {
AnonymousFeedbackMemberRecord,
AnonymousFeedbackRepository,
AnonymousFeedbackSubmissionRecord
} from '@household/ports'
import { createAnonymousFeedbackService } from './anonymous-feedback-service'
class AnonymousFeedbackRepositoryStub implements AnonymousFeedbackRepository {
member: AnonymousFeedbackMemberRecord | null = {
id: 'member-1',
telegramUserId: '123',
displayName: 'Stan'
}
acceptedCountSince = 0
lastAcceptedAt: Date | null = null
duplicate = false
created: Array<{
rawText: string
sanitizedText: string | null
moderationStatus: string
moderationReason: string | null
}> = []
posted: Array<{ submissionId: string; postedThreadId: string; postedMessageId: string }> = []
failed: Array<{ submissionId: string; failureReason: string }> = []
async getMemberByTelegramUserId() {
return this.member
}
async getRateLimitSnapshot() {
return {
acceptedCountSince: this.acceptedCountSince,
lastAcceptedAt: this.lastAcceptedAt
}
}
async createSubmission(input: {
submittedByMemberId: string
rawText: string
sanitizedText: string | null
moderationStatus: 'accepted' | 'posted' | 'rejected' | 'failed'
moderationReason: string | null
telegramChatId: string
telegramMessageId: string
telegramUpdateId: string
}): Promise<{ submission: AnonymousFeedbackSubmissionRecord; duplicate: boolean }> {
this.created.push({
rawText: input.rawText,
sanitizedText: input.sanitizedText,
moderationStatus: input.moderationStatus,
moderationReason: input.moderationReason
})
return {
submission: {
id: 'submission-1',
moderationStatus: input.moderationStatus
},
duplicate: this.duplicate
}
}
async markPosted(input: {
submissionId: string
postedChatId: string
postedThreadId: string
postedMessageId: string
postedAt: Date
}) {
this.posted.push({
submissionId: input.submissionId,
postedThreadId: input.postedThreadId,
postedMessageId: input.postedMessageId
})
}
async markFailed(submissionId: string, failureReason: string) {
this.failed.push({ submissionId, failureReason })
}
}
describe('createAnonymousFeedbackService', () => {
test('accepts and sanitizes a valid submission', async () => {
const repository = new AnonymousFeedbackRepositoryStub()
const service = createAnonymousFeedbackService(repository)
const result = await service.submit({
telegramUserId: '123',
rawText: 'Please clean the kitchen tonight @roommate https://example.com',
telegramChatId: 'chat-1',
telegramMessageId: 'message-1',
telegramUpdateId: 'update-1',
now: new Date('2026-03-08T12:00:00.000Z')
})
expect(result).toEqual({
status: 'accepted',
submissionId: 'submission-1',
sanitizedText: 'Please clean the kitchen tonight [mention removed] [link removed]'
})
expect(repository.created[0]).toMatchObject({
moderationStatus: 'accepted'
})
})
test('rejects non-members before persistence', async () => {
const repository = new AnonymousFeedbackRepositoryStub()
repository.member = null
const service = createAnonymousFeedbackService(repository)
const result = await service.submit({
telegramUserId: '404',
rawText: 'Please wash the dishes tonight',
telegramChatId: 'chat-1',
telegramMessageId: 'message-1',
telegramUpdateId: 'update-1'
})
expect(result).toEqual({
status: 'rejected',
reason: 'not_member'
})
expect(repository.created).toHaveLength(0)
})
test('rejects blocklisted content and persists moderation outcome', async () => {
const repository = new AnonymousFeedbackRepositoryStub()
const service = createAnonymousFeedbackService(repository)
const result = await service.submit({
telegramUserId: '123',
rawText: 'You are an idiot and this is disgusting',
telegramChatId: 'chat-1',
telegramMessageId: 'message-1',
telegramUpdateId: 'update-1'
})
expect(result).toEqual({
status: 'rejected',
reason: 'blocklisted',
detail: 'idiot'
})
expect(repository.created[0]).toMatchObject({
moderationStatus: 'rejected',
moderationReason: 'blocklisted:idiot'
})
})
test('enforces cooldown and daily cap', async () => {
const repository = new AnonymousFeedbackRepositoryStub()
const service = createAnonymousFeedbackService(repository)
repository.lastAcceptedAt = new Date('2026-03-08T09:00:00.000Z')
const cooldownResult = await service.submit({
telegramUserId: '123',
rawText: 'Please take the trash out tonight',
telegramChatId: 'chat-1',
telegramMessageId: 'message-1',
telegramUpdateId: 'update-1',
now: new Date('2026-03-08T12:00:00.000Z')
})
expect(cooldownResult).toEqual({
status: 'rejected',
reason: 'cooldown'
})
repository.lastAcceptedAt = new Date('2026-03-07T00:00:00.000Z')
repository.acceptedCountSince = 3
const dailyCapResult = await service.submit({
telegramUserId: '123',
rawText: 'Please ventilate the bathroom after showers',
telegramChatId: 'chat-1',
telegramMessageId: 'message-2',
telegramUpdateId: 'update-2',
now: new Date('2026-03-08T12:00:00.000Z')
})
expect(dailyCapResult).toEqual({
status: 'rejected',
reason: 'daily_cap'
})
})
test('marks posted and failed submissions', async () => {
const repository = new AnonymousFeedbackRepositoryStub()
const service = createAnonymousFeedbackService(repository)
await service.markPosted({
submissionId: 'submission-1',
postedChatId: 'group-1',
postedThreadId: 'thread-1',
postedMessageId: 'post-1'
})
await service.markFailed('submission-2', 'telegram send failed')
expect(repository.posted).toEqual([
{
submissionId: 'submission-1',
postedThreadId: 'thread-1',
postedMessageId: 'post-1'
}
])
expect(repository.failed).toEqual([
{
submissionId: 'submission-2',
failureReason: 'telegram send failed'
}
])
})
})