import { INestApplication } from '@nestjs/common'; import { withdrawalRangeText } from '../src/withdrawal/withdrawal-text'; import * as bcrypt from 'bcryptjs'; import request from 'supertest'; import { App } from 'supertest/types'; import { CalendarSyncService } from '../src/calendar-sync/calendar-sync.service'; import { CalendarSyncSettingsService } from '../src/calendar-sync/calendar-sync-settings.service'; import { DomainEventsService } from '../src/domain-events/domain-events.service'; import { PrismaService } from '../src/prisma/prisma.service'; import { createTestApp } from './utils/create-test-app'; import { login } from './utils/api'; import { drainDomainEvents } from './utils/domain-events'; interface Warning { code: string; severity: string; message: string; refs: { type: string; id: string }[]; } interface WithWarningsBody { id: string; warnings: Warning[]; } /** * The withdrawal (Wartezeit) chain against a real PostgreSQL: a harvest or * a bottling inside a treatment's window is written and answered with a * warning, leaves one notification per harvest and colony, the end of a * window tells that the colony may be harvested again, and the window * reaches the M365 calendar as a span. The notifications fan out to every * user holding the permissions, so the assertions only look at the reader * created here and every block removes the rows it caused. */ describe('Withdrawal chain (e2e)', () => { let app: INestApplication; let prisma: PrismaService; let events: DomainEventsService; let calendarSync: CalendarSyncService; const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; const password = 'SuperSecret123!'; let apiaryId: string; let adminId: string; let readerId: string; let roleId: string; const colonyIds: string[] = []; const harvestIds: string[] = []; const treatmentIds: string[] = []; const bottlingIds: string[] = []; const extractionIds: string[] = []; const honeyBatchIds: string[] = []; const rootIds: string[] = []; const server = () => app.getHttpServer(); const asAdmin = { Authorization: '' }; async function colonyWith( label: string, ): Promise<{ id: string; code: string }> { const colony = await prisma.colony.create({ data: { code: `WD-${label}-${suffix}`, apiaryId, colonyType: 'PRODUCTION_COLONY', }, }); colonyIds.push(colony.id); return { id: colony.id, code: colony.code }; } async function treat( colonyId: string, day: string, withdrawalPeriodDays: number | null, ): Promise { const treatment = await prisma.treatment.create({ data: { colonyId, userId: adminId, date: new Date(day.includes('T') ? day : `${day}T00:00:00.000Z`), product: 'Oxuvar', withdrawalPeriodDays, }, }); treatmentIds.push(treatment.id); return treatment.id; } async function postHarvest(colonyId: string | string[], day: string) { const response = await request(server()) .post('/api/v1/harvests') .set(asAdmin) .send({ apiaryId, date: `${day}T00:00:00.000Z`, colonyIds: Array.isArray(colonyId) ? colonyId : [colonyId], }) .expect(201); const body = response.body as WithWarningsBody; harvestIds.push(body.id); rootIds.push(body.id); return body; } const conflictRowsOf = (harvestId: string, colonyId: string) => prisma.notification.findMany({ where: { userId: readerId, dedupeKey: { startsWith: `withdrawal-conflict:${harvestId}:${colonyId}:`, }, }, }); /** Removes what this file's chain wrote to anybody, so other files' users stay clean. */ async function purgeNotifications(): Promise { await prisma.notification.deleteMany({ where: { category: 'WITHDRAWAL', OR: [ { relatedId: { in: [...harvestIds, ...colonyIds] } }, ...treatmentIds.map((id) => ({ dedupeKey: { startsWith: `withdrawal-ended:${id}:` }, })), ], }, }); } beforeAll(async () => { app = await createTestApp(); prisma = app.get(PrismaService); events = app.get(DomainEventsService); calendarSync = app.get(CalendarSyncService); const passwordHash = await bcrypt.hash(password, 12); const admin = await prisma.user.create({ data: { email: `withdrawal-e2e-admin-${suffix}@example.com`, passwordHash, name: 'Withdrawal admin', role: { connect: { key: 'ADMIN' } }, }, }); adminId = admin.id; asAdmin.Authorization = `Bearer ${(await login(app, admin.email, password)).accessToken}`; const role = await prisma.role.create({ data: { key: `WD_READER_${suffix}`, name: 'Wartezeit Leser', permissions: { create: ['harvests:read', 'harvests:create', 'treatments:read'].map( (key) => ({ permission: { connect: { key } } }), ), }, }, }); roleId = role.id; const reader = await prisma.user.create({ data: { email: `withdrawal-e2e-reader-${suffix}@example.com`, passwordHash, name: 'Withdrawal reader', roleId, }, }); readerId = reader.id; apiaryId = ( await prisma.apiary.create({ data: { name: `Wartezeit ${suffix}` } }) ).id; }); afterAll(async () => { await purgeNotifications(); const roots = await prisma.domainEvent.findMany({ where: { aggregateId: { in: rootIds } }, select: { correlationId: true }, }); await prisma.domainEvent.deleteMany({ where: { OR: [ { aggregateId: { in: rootIds } }, { correlationId: { in: roots.map((root) => root.correlationId) } }, ], }, }); await prisma.calendarSyncOutboxEntry.deleteMany({ where: { sourceId: { in: [...treatmentIds, ...harvestIds] }, }, }); await prisma.task.deleteMany({ where: { OR: [ { relatedId: { in: colonyIds } }, { sourceSuggestion: { colonyId: { in: colonyIds } } }, ], }, }); await prisma.suggestion.deleteMany({ where: { colonyId: { in: colonyIds } }, }); await prisma.auditLog.deleteMany({ where: { OR: [ { userId: { in: [adminId, readerId] } }, { entityId: { in: [...harvestIds, ...bottlingIds, ...treatmentIds], }, }, ...colonyIds.map((colonyId) => ({ entityType: 'Suggestion', afterData: { path: ['colonyId'], equals: colonyId }, })), ], }, }); await prisma.bottlingBatch.deleteMany({ where: { honeyBatchId: { in: honeyBatchIds } }, }); await prisma.honeyBatch.deleteMany({ where: { id: { in: honeyBatchIds } }, }); await prisma.extractionHarvest.deleteMany({ where: { extractionId: { in: extractionIds } }, }); await prisma.extraction.deleteMany({ where: { id: { in: extractionIds } }, }); await prisma.harvestColony.deleteMany({ where: { harvestId: { in: harvestIds } }, }); await prisma.harvest.deleteMany({ where: { id: { in: harvestIds } } }); await prisma.treatment.deleteMany({ where: { id: { in: treatmentIds } } }); await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } }); await prisma.apiary.deleteMany({ where: { id: apiaryId } }); await prisma.refreshToken.deleteMany({ where: { userId: { in: [adminId, readerId] } }, }); await prisma.user.deleteMany({ where: { id: { in: [adminId, readerId] } }, }); await prisma.role.deleteMany({ where: { id: roleId } }); await app.close(); }); describe('harvest and bottling inside the window', () => { let colony: { id: string; code: string }; let harvestId: string; afterAll(purgeNotifications); beforeAll(async () => { colony = await colonyWith('A'); // 2026-07-10 + 20 days: harvestable again from 2026-07-30. await treat(colony.id, '2026-07-10', 20); }); it('a harvest inside the window answers 201 with a warning and leaves one notification', async () => { const harvest = await postHarvest(colony.id, '2026-07-20'); harvestId = harvest.id; expect(harvest.warnings).toEqual([ { code: 'WITHDRAWAL_ACTIVE', severity: 'WARNUNG', message: `${colony.code}: Wartezeit nach Oxuvar ${withdrawalRangeText('2026-07-30')}.`, refs: [ { type: 'Colony', id: colony.id }, { type: 'Treatment', id: treatmentIds[0] }, { type: 'Harvest', id: harvestId }, ], }, ]); // The request wrote the harvest all the same. expect(await prisma.harvest.count({ where: { id: harvestId } })).toBe(1); await drainDomainEvents(app, [harvestId]); await drainDomainEvents(app, [harvestId]); const rows = await conflictRowsOf(harvestId, colony.id); expect(rows).toHaveLength(1); expect(rows[0]).toMatchObject({ priority: 'WARNUNG', category: 'WITHDRAWAL', message: `Wartezeit: ${colony.code} am 20.07. geerntet, Wartezeit nach Oxuvar ${withdrawalRangeText('2026-07-30')}.`, relatedType: 'Harvest', relatedId: harvestId, link: `/harvests/${harvestId}`, read: false, }); expect(rows[0].requiredPermissions).toEqual( expect.arrayContaining(['harvests:read', 'treatments:read']), ); }); it('a bottling of that harvest warns again but notifies no second time', async () => { const extraction = await prisma.extraction.create({ data: { date: new Date('2026-07-21T00:00:00.000Z'), userId: adminId }, }); extractionIds.push(extraction.id); await prisma.extractionHarvest.create({ data: { extractionId: extraction.id, harvestId }, }); const honeyBatch = await prisma.honeyBatch.create({ data: { batchNumber: `WD-HC-${suffix}`, extractionId: extraction.id, amount: 20, waterContent: 17, }, }); honeyBatchIds.push(honeyBatch.id); await drainDomainEvents(app, [harvestId]); expect(await conflictRowsOf(harvestId, colony.id)).toHaveLength(1); const response = await request(server()) .post('/api/v1/bottling-batches') .set(asAdmin) .send({ honeyBatchId: honeyBatch.id, date: '2026-07-22T00:00:00.000Z', jarSize: 500, jarCount: 4, bestBeforeDate: '2028-07-22T00:00:00.000Z', lotNumber: `WD-LOT-${suffix}`, }) .expect(201); const bottling = response.body as WithWarningsBody; bottlingIds.push(bottling.id); rootIds.push(bottling.id); expect(bottling.warnings).toHaveLength(1); expect(bottling.warnings[0]).toMatchObject({ code: 'WITHDRAWAL_ACTIVE', message: `${colony.code}: Wartezeit nach Oxuvar ${withdrawalRangeText('2026-07-30')}.`, }); await drainDomainEvents(app, [bottling.id]); expect(await conflictRowsOf(harvestId, colony.id)).toHaveLength(1); }); it('a harvest on the first harvestable day answers an empty warning list', async () => { const harvest = await postHarvest(colony.id, '2026-07-30'); expect(harvest.warnings).toEqual([]); await drainDomainEvents(app, [harvest.id]); expect(await conflictRowsOf(harvest.id, colony.id)).toHaveLength(0); }); it('a corrected harvest that moves out of the window answers an empty list; into it, a warning', async () => { const harvest = await postHarvest(colony.id, '2026-09-01'); expect(harvest.warnings).toEqual([]); const moved = await request(server()) .patch(`/api/v1/harvests/${harvest.id}`) .set(asAdmin) .send({ date: '2026-07-12T00:00:00.000Z' }) .expect(200); expect((moved.body as WithWarningsBody).warnings).toHaveLength(1); const back = await request(server()) .patch(`/api/v1/harvests/${harvest.id}`) .set(asAdmin) .send({ date: '2026-09-02T00:00:00.000Z' }) .expect(200); expect((back.body as WithWarningsBody).warnings).toEqual([]); }); }); describe('harvest of several colonies', () => { afterAll(purgeNotifications); it('warns and notifies only for the colony that is inside a window', async () => { const inside = await colonyWith('D1'); const outside = await colonyWith('D2'); await treat(inside.id, '2026-07-10', 20); const harvest = await postHarvest([inside.id, outside.id], '2026-07-20'); expect(harvest.warnings).toHaveLength(1); expect(harvest.warnings[0].refs).toContainEqual({ type: 'Colony', id: inside.id, }); expect(harvest.warnings[0].message.startsWith(`${inside.code}:`)).toBe( true, ); await drainDomainEvents(app, [harvest.id]); expect(await conflictRowsOf(harvest.id, inside.id)).toHaveLength(1); expect(await conflictRowsOf(harvest.id, outside.id)).toHaveLength(0); }); }); describe('treatment entered after the harvest', () => { afterAll(purgeNotifications); it('a backdated treatment notifies the harvests it covers, once; a correction that extends the window adds the newly covered ones', async () => { const colony = await colonyWith('E'); const early = await postHarvest(colony.id, '2026-07-20'); const late = await postHarvest(colony.id, '2026-08-05'); expect(early.warnings).toEqual([]); await drainDomainEvents(app, [early.id, late.id]); // The early harvest was bottled before the treatment was entered. const extraction = await prisma.extraction.create({ data: { date: new Date('2026-07-21T00:00:00.000Z'), userId: adminId }, }); extractionIds.push(extraction.id); await prisma.extractionHarvest.create({ data: { extractionId: extraction.id, harvestId: early.id }, }); const honeyBatch = await prisma.honeyBatch.create({ data: { batchNumber: `WD-HC2-${suffix}`, extractionId: extraction.id, amount: 5, }, }); honeyBatchIds.push(honeyBatch.id); await prisma.bottlingBatch.create({ data: { honeyBatchId: honeyBatch.id, userId: adminId, date: new Date('2026-07-22T00:00:00.000Z'), jarSize: 500, jarCount: 2, totalAmount: 1, bestBeforeDate: new Date('2028-07-22T00:00:00.000Z'), lotNumber: `WD-LOT2-${suffix}`, }, }); const created = await request(server()) .post('/api/v1/treatments') .set(asAdmin) .send({ colonyId: colony.id, date: '2026-07-10T00:00:00.000Z', product: 'Oxuvar', withdrawalPeriodDays: 20, }) .expect(201); const treatmentId = (created.body as { id: string }).id; treatmentIds.push(treatmentId); rootIds.push(treatmentId); await drainDomainEvents(app, [treatmentId]); // 2026-07-10 + 20 = harvestable from 2026-07-30: only the early harvest. const earlyRows = await conflictRowsOf(early.id, colony.id); expect(earlyRows).toHaveLength(1); expect(earlyRows[0].message).toContain( `Bereits abgefüllt: Los WD-LOT2-${suffix}.`, ); expect(await conflictRowsOf(late.id, colony.id)).toHaveLength(0); await request(server()) .patch(`/api/v1/treatments/${treatmentId}`) .set(asAdmin) .send({ withdrawalPeriodDays: 40 }) .expect(200); await drainDomainEvents(app, [treatmentId]); // Until 2026-08-19: the late harvest is covered now; the early one // is not notified a second time. expect(await conflictRowsOf(early.id, colony.id)).toHaveLength(1); expect(await conflictRowsOf(late.id, colony.id)).toHaveLength(1); }); }); describe('end of a window', () => { afterAll(purgeNotifications); const endedEvent = ( treatmentId: string, colonyId: string, endedOn: string, ) => prisma.$transaction((tx) => events.record(tx, { type: 'WithdrawalEnded', aggregateType: 'Treatment', aggregateId: treatmentId, payload: { colonyId, endedOn }, }), ); const readyRows = (colonyId: string) => prisma.notification.findMany({ where: { userId: readerId, relatedId: colonyId, category: 'WITHDRAWAL', dedupeKey: { startsWith: 'withdrawal-ended:' }, }, }); it('WithdrawalEnded yields one INFO only when no other window is running', async () => { const colony = await colonyWith('B'); // First window: 2026-06-01 + 10 = 2026-06-11. Second: 2026-06-05 + 20 = 2026-06-25. const first = await treat(colony.id, '2026-06-01', 10); const second = await treat(colony.id, '2026-06-05', 20); rootIds.push(first, second); await endedEvent(first, colony.id, '2026-06-11'); await drainDomainEvents(app, [first]); expect(await readyRows(colony.id)).toHaveLength(0); // Recorded twice: the job could deliver the same end again. await endedEvent(second, colony.id, '2026-06-25'); await endedEvent(second, colony.id, '2026-06-25'); await drainDomainEvents(app, [second]); const rows = await readyRows(colony.id); expect(rows).toHaveLength(1); expect(rows[0]).toMatchObject({ priority: 'INFO', category: 'WITHDRAWAL', message: `${colony.code}: Wartezeit nach Oxuvar ${withdrawalRangeText('2026-06-25')}.`, relatedType: 'Colony', link: `/colonies/${colony.id}`, }); expect(rows[0].requiredPermissions).toEqual( expect.arrayContaining(['harvests:create', 'treatments:read']), ); }); }); describe('end of a window across the Berlin day boundary', () => { afterAll(purgeNotifications); it('takes the end day from the Berlin day of a treatment stored with a time part', async () => { const colony = await colonyWith('F'); // 2026-06-01T22:30Z is 00:30 on 2026-06-02 in Berlin; one day later // the period ends on 2026-06-03 (the plain UTC day would say 06-02). const id = await treat(colony.id, '2026-06-01T22:30:00.000Z', 1); rootIds.push(id); const record = (endedOn: string) => prisma.$transaction((tx) => events.record(tx, { type: 'WithdrawalEnded', aggregateType: 'Treatment', aggregateId: id, payload: { colonyId: colony.id, endedOn }, }), ); await record('2026-06-02'); await drainDomainEvents(app, [id]); expect( await prisma.notification.count({ where: { userId: readerId, relatedId: colony.id }, }), ).toBe(0); await record('2026-06-03'); await drainDomainEvents(app, [id]); const rows = await prisma.notification.findMany({ where: { userId: readerId, relatedId: colony.id }, }); expect(rows).toHaveLength(1); expect(rows[0].message).toContain(withdrawalRangeText('2026-06-03')); expect(rows[0].message).toContain(colony.code); }); }); describe('calendar entry', () => { // The settings spy is this file's only mock; the shared singleton row is // never touched. afterEach(() => { jest.restoreAllMocks(); }); it('a treatment with withdrawal days leaves a WITHDRAWAL calendar entry; removing the days unregisters it', async () => { const colony = await colonyWith('C'); jest .spyOn(app.get(CalendarSyncSettingsService), 'get') .mockResolvedValue({ id: 'singleton', enabled: true, groupId: 'group-e2e', syncTask: true, syncInspection: true, syncTreatment: true, syncFeeding: true, syncHarvest: true, syncQueenAssignment: true, syncWithdrawal: true, updatedAt: new Date(), }); const created = await request(server()) .post('/api/v1/treatments') .set(asAdmin) .send({ colonyId: colony.id, date: '2026-08-03T00:00:00.000Z', product: 'Oxuvar', withdrawalPeriodDays: 20, }) .expect(201); const treatmentId = (created.body as { id: string }).id; treatmentIds.push(treatmentId); rootIds.push(treatmentId); await drainDomainEvents(app, [treatmentId]); const entry = await prisma.calendarSyncOutboxEntry.findUniqueOrThrow({ where: { sourceType_sourceId: { sourceType: 'WITHDRAWAL', sourceId: treatmentId, }, }, }); expect(entry.status).toBe('PENDING'); const buildEventInput = (id: string) => ( calendarSync as unknown as { buildEventInput(type: string, id: string): Promise; } ).buildEventInput('WITHDRAWAL', id); // Graph's end day is exclusive: the first harvestable day. expect(await buildEventInput(treatmentId)).toEqual({ title: `Wartezeit ${colony.code} · Oxuvar`, date: new Date('2026-08-03T00:00:00.000Z'), endDate: new Date('2026-08-23T00:00:00.000Z'), }); await request(server()) .patch(`/api/v1/treatments/${treatmentId}`) .set(asAdmin) .send({ withdrawalPeriodDays: null }) .expect(200); await drainDomainEvents(app, [treatmentId]); const after = await prisma.calendarSyncOutboxEntry.findUniqueOrThrow({ where: { id: entry.id }, }); // Enqueued again: the worker finds no span and removes the event. expect(after.revision).toBeGreaterThan(entry.revision); expect(after.status).toBe('PENDING'); expect(await buildEventInput(treatmentId)).toBeNull(); }); }); });