import { INestApplication } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { createTestApp } from '../testing/test-app'; import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { MailRetentionService } from './mail-retention.service'; const DAY = 24 * 60 * 60 * 1000; /** * Mail outbox retention (issue #234): SENT and permanently FAILED rows past * `mail.outboxRetentionDays` are deleted; PENDING rows — including a * failed-but-retryable one — belong to the retry loop and stay untouched. */ describe.skipIf(!hasTestDb)('mail outbox retention (e2e, issue #234)', () => { let app: INestApplication; let prisma: PrismaClient; const suffix = uniqueSuffix(); const address = (name: string) => `${name}-${suffix}@example.org`; beforeAll(async () => { prisma = createTestPrisma(); // A short period so ages are unambiguous; written straight to the row // BEFORE the app boots (the settings cache is in-process and fills on // first read). The key is cleaned afterAll. await prisma.instanceSetting.upsert({ where: { key: 'mail.outboxRetentionDays' }, create: { key: 'mail.outboxRetentionDays', value: 10 }, update: { value: 10 }, }); app = await createTestApp(); }); afterAll(async () => { await prisma.instanceSetting.deleteMany({ where: { key: 'mail.outboxRetentionDays' } }); await prisma.mailOutbox.deleteMany({ where: { toAddress: { contains: suffix } } }); await prisma.$disconnect(); await app.close(); }); it('deletes terminal rows past the period, keeps the retry loop’s rows', async () => { await prisma.mailOutbox.createMany({ data: [ { toAddress: address('sent-old'), subject: 's', textBody: 't', htmlBody: 'h', status: 'SENT', sentAt: new Date(Date.now() - 15 * DAY), }, { toAddress: address('sent-fresh'), subject: 's', textBody: 't', htmlBody: 'h', status: 'SENT', sentAt: new Date(Date.now() - 5 * DAY), }, { // Gave up after MAX_ATTEMPTS; nextAttemptAt records the last try. toAddress: address('failed-old'), subject: 's', textBody: 't', htmlBody: 'h', status: 'FAILED', attempts: 5, nextAttemptAt: new Date(Date.now() - 15 * DAY), lastError: 'smtp gone', }, { // Failed once but retryable: still PENDING, still the worker's. toAddress: address('pending-retry'), subject: 's', textBody: 't', htmlBody: 'h', status: 'PENDING', attempts: 2, nextAttemptAt: new Date(Date.now() - 15 * DAY), lastError: 'transient', createdAt: new Date(Date.now() - 15 * DAY), }, ], }); const pruned = await app.get(MailRetentionService).pruneExpired(); expect(pruned).toBeGreaterThanOrEqual(2); const remaining = await prisma.mailOutbox.findMany({ where: { toAddress: { contains: suffix } }, }); expect(remaining.map((row) => row.toAddress).sort()).toEqual([ address('pending-retry'), address('sent-fresh'), ]); }); it('is a no-op when nothing is due', async () => { expect(await app.get(MailRetentionService).pruneExpired()).toBe(0); expect(await prisma.mailOutbox.count({ where: { toAddress: { contains: suffix } } })).toBe(2); }); });