import { INestApplication } from '@nestjs/common'; import * as bcrypt from 'bcryptjs'; import request from 'supertest'; import { App } from 'supertest/types'; import { PrismaService } from '../src/prisma/prisma.service'; import { SuggestionsService } from '../src/suggestions/suggestions.service'; import type { ProposeInput } from '../src/suggestions/suggestion.types'; import { berlinToday } from '../src/common/dates'; import { login } from './utils/api'; import { createTestApp } from './utils/create-test-app'; import { drainDomainEvents } from './utils/domain-events'; /** * The suggestion core against a real PostgreSQL: the dedupe key, the SQL * status changes that free it, the plan tasks and the 30-day reopen window. */ describe('Suggestions (e2e)', () => { let app: INestApplication; let prisma: PrismaService; let service: SuggestionsService; const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; let apiaryId: string; let colonyId: string; let userId: string; const roleIds: string[] = []; const extraUserIds: string[] = []; const password = 'SuperSecret123!'; const proposal = (anchor: string): ProposeInput => ({ ruleKey: 'SPLIT_SCHEDULE', source: 'CHAIN', mode: 'PLAN', intent: 'SPLIT_PLAN', relatedType: 'COLONY', relatedId: colonyId, colonyId, reasonCode: `SUGGESTIONS_E2E_${suffix}`, evidence: { colonyId, baseDate: '2026-05-01' }, requiredPermission: 'colonies:read', plan: [ { step: 'RAISE_CELLS_CHECK', offsetDays: 9 }, { step: 'MATING_CHECK', offsetDays: 21, windowDays: 3 }, ], anchor, }); const propose = (input: ProposeInput) => prisma.$transaction((tx) => service.proposeIn(tx, input)); const statusOf = async (id: string) => (await prisma.suggestion.findUniqueOrThrow({ where: { id } })).status; beforeAll(async () => { app = await createTestApp(); prisma = app.get(PrismaService); service = app.get(SuggestionsService); const apiary = await prisma.apiary.create({ data: { name: `Suggestions e2e ${suffix}` }, }); apiaryId = apiary.id; const colony = await prisma.colony.create({ data: { code: `SUG-${suffix}`, apiaryId, colonyType: 'PRODUCTION_COLONY', }, }); colonyId = colony.id; const user = await prisma.user.create({ data: { email: `suggestions-e2e-${suffix}@example.com`, passwordHash: 'x', name: 'Suggestions e2e', role: { connect: { key: 'STAFF' } }, }, }); userId = user.id; }); afterAll(async () => { await clearSuggestions(); const inspectionIds = ( await prisma.inspection.findMany({ where: { colonyId }, select: { id: true }, }) ).map((inspection) => inspection.id); await prisma.domainEvent.deleteMany({ where: { aggregateId: { in: inspectionIds } }, }); await prisma.calendarSyncOutboxEntry.deleteMany({ where: { sourceId: { in: inspectionIds } }, }); await prisma.inspection.deleteMany({ where: { colonyId } }); await prisma.colony.delete({ where: { id: colonyId } }); await prisma.apiary.delete({ where: { id: apiaryId } }); await prisma.refreshToken.deleteMany({ where: { userId: { in: extraUserIds } }, }); await prisma.auditLog.deleteMany({ where: { userId: { in: [userId, ...extraUserIds] } }, }); await prisma.user.deleteMany({ where: { id: { in: [userId, ...extraUserIds] } }, }); await prisma.role.deleteMany({ where: { id: { in: roleIds } } }); await app.close(); }); /** The colony's suggestions with what hangs on them: tasks, notifications, audit. */ async function clearSuggestions(): Promise { const ids = ( await prisma.suggestion.findMany({ where: { colonyId }, select: { id: true }, }) ).map((suggestion) => suggestion.id); await prisma.task.deleteMany({ where: { sourceSuggestion: { colonyId } }, }); await prisma.notification.deleteMany({ where: { relatedType: 'Suggestion', relatedId: { in: ids } }, }); await prisma.auditLog.deleteMany({ where: { OR: [ { entityType: 'Suggestion', entityId: { in: ids } }, { entityType: 'AutomationRule', afterData: { path: ['relatedId'], equals: colonyId }, }, ], }, }); await prisma.suggestion.deleteMany({ where: { colonyId } }); } afterEach(clearSuggestions); it('proposes once per anchor and writes an audit entry', async () => { const created = await propose(proposal('a1')); expect(created).not.toBeNull(); expect(created?.dedupeKey).toBe(`SPLIT_SCHEDULE:${colonyId}:a1`); expect( await prisma.auditLog.count({ where: { entityType: 'Suggestion', entityId: created!.id }, }), ).toBe(1); // Same anchor: the key exists. A second intent check would also stop it. expect(await propose(proposal('a1'))).toBeNull(); }); it('allows one open suggestion per intent and colony', async () => { expect(await propose(proposal('a1'))).not.toBeNull(); expect(await propose(proposal('a2'))).toBeNull(); expect( await propose({ ...proposal('a3'), ruleKey: 'QUEEN_ACCEPTANCE_CHECK', intent: 'QUEEN_ACCEPTANCE', plan: null, }), ).not.toBeNull(); }); it('is stopped by an open task of the same intent', async () => { const first = (await propose(proposal('a1')))!; await service.accept(first.id, { dueDate: '2026-05-12' }, userId); // The accepted plan left open tasks of this intent. expect(await propose(proposal('a2'))).toBeNull(); }); it('expireDue frees the key, dismiss keeps it', async () => { const dismissed = (await propose(proposal('d1')))!; await service.dismiss(dismissed.id, 'nein', userId); expect(await statusOf(dismissed.id)).toBe('DISMISSED'); expect(await propose(proposal('d1'))).toBeNull(); const expiring = (await propose({ ...proposal('e1'), expiresAt: '2026-05-01', }))!; expect(await service.expireDue('2026-05-02')).toBeGreaterThanOrEqual(1); const expired = await prisma.suggestion.findUniqueOrThrow({ where: { id: expiring.id }, }); expect(expired.status).toBe('EXPIRED'); expect(expired.dedupeKey).toBeNull(); expect(await propose(proposal('e1'))).not.toBeNull(); }); it('resolveIn frees the key in the same statement', async () => { const created = (await propose(proposal('r1')))!; expect( await prisma.$transaction((tx) => service.resolveIn(tx, [created.id], 'Bedingung entfällt'), ), ).toBe(1); const resolved = await prisma.suggestion.findUniqueOrThrow({ where: { id: created.id }, }); expect(resolved).toMatchObject({ status: 'RESOLVED', dedupeKey: null, decisionNote: 'Bedingung entfällt', }); expect(await propose(proposal('r1'))).not.toBeNull(); }); it('accept creates the plan tasks shifted to the chosen day', async () => { const created = (await propose(proposal('p1')))!; const result = await service.accept( created.id, { dueDate: '2026-05-12' }, userId, ); expect( result.tasks.map((t) => t.dueDate?.toISOString().slice(0, 10)), ).toEqual(['2026-05-12', '2026-05-24']); expect(result.tasks[1].description).toContain('bis 27.05.'); expect(result.tasks.every((t) => t.sourceSuggestionId === created.id)).toBe( true, ); expect(await statusOf(created.id)).toBe('ACCEPTED'); await expect(service.accept(created.id, {}, userId)).rejects.toMatchObject({ response: { status: 'ACCEPTED' }, }); }); it('bulkAccept rolls everything back when one suggestion is not open', async () => { const open = (await propose(proposal('b1')))!; const other = (await propose({ ...proposal('b2'), ruleKey: 'QUEEN_ACCEPTANCE_CHECK', intent: 'QUEEN_ACCEPTANCE', plan: null, dueDate: '2026-05-20', }))!; await service.dismiss(other.id, null, userId); await expect( service.bulkAccept([open.id, other.id], userId), ).rejects.toMatchObject({ status: 409 }); expect(await statusOf(open.id)).toBe('OPEN'); expect( await prisma.task.count({ where: { sourceSuggestionId: open.id } }), ).toBe(0); }); it('reopens within 30 days and answers 409 after that', async () => { const created = (await propose(proposal('o1')))!; await service.dismiss(created.id, null, userId); const reopened = await service.reopen(created.id, userId); expect(reopened).toMatchObject({ status: 'OPEN', decidedAt: null }); await service.dismiss(created.id, null, userId); await prisma.$executeRaw`UPDATE "Suggestion" SET "decidedAt" = now() - interval '31 days' WHERE id = ${created.id}`; await expect(service.reopen(created.id, userId)).rejects.toMatchObject({ status: 409, }); expect(await statusOf(created.id)).toBe('DISMISSED'); }); it('refuses to reopen while the same intent is open again', async () => { const dismissed = (await propose(proposal('q1')))!; await service.dismiss(dismissed.id, null, userId); expect(await propose(proposal('q2'))).not.toBeNull(); await expect(service.reopen(dismissed.id, userId)).rejects.toMatchObject({ status: 409, }); expect(await statusOf(dismissed.id)).toBe('DISMISSED'); }); it('answers 400 for an unknown or inactive assignee, never 500', async () => { const created = (await propose(proposal('u1')))!; const inactive = await prisma.user.create({ data: { email: `suggestions-e2e-inactive-${suffix}@example.com`, passwordHash: 'x', name: 'Inactive', isActive: false, role: { connect: { key: 'STAFF' } }, }, }); try { for (const assignedUserId of ['no-such-user', inactive.id]) { await expect( service.accept(created.id, { assignedUserId }, userId), ).rejects.toMatchObject({ status: 400 }); } expect(await statusOf(created.id)).toBe('OPEN'); const ok = await service.accept( created.id, { assignedUserId: userId }, userId, ); expect(ok.tasks.every((t) => t.assignedUserId === userId)).toBe(true); } finally { await prisma.task.deleteMany({ where: { sourceSuggestionId: created.id }, }); await prisma.user.delete({ where: { id: inactive.id } }); } }); describe('concurrency', () => { it('accept against dismiss on one suggestion: one wins, the other gets 409 with the status', async () => { const created = (await propose(proposal('c1')))!; const settled = await Promise.allSettled([ service.accept(created.id, { dueDate: '2026-05-12' }, userId), service.dismiss(created.id, null, userId), ]); expect(settled.filter((r) => r.status === 'fulfilled')).toHaveLength(1); const [failure] = settled.filter( (r): r is PromiseRejectedResult => r.status === 'rejected', ); expect(failure.reason).toMatchObject({ status: 409 }); const winner = await statusOf(created.id); expect(['ACCEPTED', 'DISMISSED']).toContain(winner); expect( (failure.reason as { response: { status: string } }).response.status, ).toBe(winner); }); it('two proposals for one colony and intent from different rules leave one row', async () => { const results = await Promise.all([ propose({ ...proposal('k1'), ruleKey: 'SPLIT_SCHEDULE' }), propose({ ...proposal('k2'), ruleKey: 'QUEEN_ACCEPTANCE_CHECK', source: 'RULE', }), propose({ ...proposal('k3'), ruleKey: 'INSPECTION_INTERVAL', source: 'AUTOPILOT', }), ]); expect(results.filter((r) => r !== null)).toHaveLength(1); expect( await prisma.suggestion.count({ where: { colonyId, intent: 'SPLIT_PLAN', status: 'OPEN' }, }), ).toBe(1); }); it('overlapping bulkAccept and accept do not deadlock and stay consistent', async () => { const ids: string[] = []; for (const [index, intent] of ( ['SPLIT_PLAN', 'QUEEN_ACCEPTANCE', 'FEED'] as const ).entries()) { const created = await propose({ ...proposal(`ov${index}`), ruleKey: intent === 'SPLIT_PLAN' ? 'SPLIT_SCHEDULE' : 'QUEEN_ACCEPTANCE_CHECK', intent, plan: null, dueDate: '2026-05-20', }); ids.push(created!.id); } const settled = await Promise.allSettled([ service.bulkAccept([ids[0], ids[1], ids[2]], userId), service.accept(ids[1], {}, userId), service.bulkAccept([ids[2], ids[1]], userId), ]); for (const result of settled) { if (result.status === 'rejected') { expect(result.reason).toMatchObject({ status: 409 }); } } expect(settled.some((r) => r.status === 'fulfilled')).toBe(true); for (const id of ids) { // Accepted exactly once, or not at all: never a task without it. const accepted = (await statusOf(id)) === 'ACCEPTED'; expect( await prisma.task.count({ where: { sourceSuggestionId: id } }), ).toBe(accepted ? 1 : 0); } }); }); it('answers 409 for accept and reopen once the colony has ended', async () => { const open = (await propose(proposal('end1')))!; const dismissed = (await propose({ ...proposal('end2'), ruleKey: 'QUEEN_ACCEPTANCE_CHECK', intent: 'QUEEN_ACCEPTANCE', plan: null, dueDate: '2026-05-20', }))!; await service.dismiss(dismissed.id, null, userId); await prisma.colony.update({ where: { id: colonyId }, data: { status: 'SOLD' }, }); try { await expect(service.accept(open.id, {}, userId)).rejects.toMatchObject({ status: 409, }); await expect(service.reopen(dismissed.id, userId)).rejects.toMatchObject({ status: 409, }); } finally { await prisma.colony.update({ where: { id: colonyId }, data: { status: 'ACTIVE' }, }); } }); it('proposes nothing for a colony that does not exist', async () => { expect( await propose({ ...proposal('nc1'), colonyId: 'no-such-colony' }), ).toBeNull(); }); it('bulkAccept answers 400 naming the DO suggestions without a date', async () => { const undated = (await propose({ ...proposal('bd1'), mode: 'DO', plan: null, dueDate: null, }))!; await expect( service.bulkAccept([undated.id], userId), ).rejects.toMatchObject({ status: 400, response: { issues: [{ field: 'ids', ids: [undated.id] }] }, }); expect(await statusOf(undated.id)).toBe('OPEN'); }); it('obsoleteForColonyIn closes open suggestions and keeps the key', async () => { const created = (await propose(proposal('x1')))!; expect( await prisma.$transaction((tx) => service.obsoleteForColonyIn(tx, colonyId), ), ).toBe(1); const obsolete = await prisma.suggestion.findUniqueOrThrow({ where: { id: created.id }, }); expect(obsolete.status).toBe('OBSOLETE'); expect(obsolete.dedupeKey).not.toBeNull(); }); describe('rule suggestions', () => { let writer: { id: string; token: string }; let withoutInspections: { id: string }; let withoutTasks: { id: string }; async function createUser( label: string, permissions: string[], ): Promise<{ id: string; token: string }> { const role = await prisma.role.create({ data: { key: `SUG_RULES_${label.toUpperCase()}_${suffix}`, name: `Suggestions rules ${label}`, permissions: { create: permissions.map((key) => ({ permission: { connect: { key } }, })), }, }, }); roleIds.push(role.id); const email = `suggestions-rules-${label}-${suffix}@example.com`; const user = await prisma.user.create({ data: { email, passwordHash: await bcrypt.hash(password, 12), name: `Suggestions rules ${label}`, role: { connect: { id: role.id } }, }, }); extraUserIds.push(user.id); return { id: user.id, token: (await login(app, email, password)).accessToken, }; } beforeAll(async () => { writer = await createUser('writer', [ 'inspections:create', 'inspections:read', 'tasks:read', ]); withoutInspections = await createUser('noinsp', ['tasks:read']); withoutTasks = await createUser('notasks', ['inspections:read']); }); /** Records an inspection and runs the handlers of its event. */ async function inspect(body: object): Promise { const response = await request(app.getHttpServer()) .post('/api/v1/inspections') .set('Authorization', `Bearer ${writer.token}`) .send({ colonyId, date: new Date().toISOString(), ...body }) .expect(201); const id = (response.body as { id: string }).id; await drainDomainEvents(app, [id]); return id; } const queenless = { queenSeen: false, eggsPresent: false }; const ruleSuggestions = () => prisma.suggestion.findMany({ where: { colonyId, ruleKey: 'QUEENLESS_NO_EGGS' }, }); it('an inspection without queen and eggs leaves one OPEN suggestion and no task', async () => { const inspectionId = await inspect(queenless); const suggestions = await ruleSuggestions(); expect(suggestions).toHaveLength(1); expect(suggestions[0]).toMatchObject({ status: 'OPEN', source: 'RULE', mode: 'PLAN', intent: 'QUEEN_CHECK', dedupeKey: `QUEENLESS_NO_EGGS:${colonyId}:${inspectionId}`, }); expect(await prisma.task.count({ where: { relatedId: colonyId } })).toBe( 0, ); }); it('a dismissed suggestion is not proposed again for the same inspection', async () => { const inspectionId = await inspect(queenless); const [first] = await ruleSuggestions(); await service.dismiss(first.id, null, userId); // The delivery runs again, as after a retry or a catch-up. await prisma.domainEvent.updateMany({ where: { aggregateId: inspectionId }, data: { status: 'PENDING', processedAt: null, claimedAt: null }, }); await prisma.domainEventDelivery.updateMany({ where: { handler: 'automation.inspection-rules', event: { aggregateId: inspectionId }, }, data: { status: 'PENDING', attempts: 0, completedAt: null }, }); await drainDomainEvents(app, [inspectionId]); const suggestions = await ruleSuggestions(); expect(suggestions).toHaveLength(1); expect(suggestions[0].status).toBe('DISMISSED'); }); it('a later inspection with the same finding proposes again', async () => { await inspect(queenless); const [first] = await ruleSuggestions(); await service.dismiss(first.id, null, userId); await inspect(queenless); const suggestions = await ruleSuggestions(); expect(suggestions.map((suggestion) => suggestion.status).sort()).toEqual( ['DISMISSED', 'OPEN'], ); }); it('accepting a rule suggestion without dueInDays creates a task due today', async () => { await inspect(queenless); const [open] = await ruleSuggestions(); expect(open.dueDate).toBeNull(); const result = await service.accept(open.id, {}, userId); expect(result.tasks).toHaveLength(1); expect(result.tasks[0].dueDate?.toISOString().slice(0, 10)).toBe( berlinToday(), ); expect(result.tasks[0]).toMatchObject({ sourceSuggestionId: open.id, sourceAutomationTrigger: 'QUEENLESS_NO_EGGS', priority: 'HIGH', }); }); it('a chain suggestion proposed with notify true notifies like a rule suggestion', async () => { const created = (await prisma.$transaction((tx) => service.proposeIn(tx, { ...proposal('notify-chain'), notify: true, requiredPermission: 'colonies:read', }), ))!; const rows = await prisma.notification.findMany({ where: { relatedType: 'Suggestion', relatedId: created.id }, }); expect(rows.length).toBeGreaterThan(0); expect(rows.every((row) => row.category === 'SUGGESTION')).toBe(true); expect(rows.every((row) => row.priority === 'WARNUNG')).toBe(true); expect(rows.every((row) => row.link === `/colonies/${colonyId}`)).toBe( true, ); }); it('notify writes a WARNUNG only to holders of tasks:read and inspections:read', async () => { await inspect(queenless); const [open] = await ruleSuggestions(); const rows = await prisma.notification.findMany({ where: { relatedType: 'Suggestion', relatedId: open.id }, }); const recipients = new Set(rows.map((row) => row.userId)); expect(recipients.has(writer.id)).toBe(true); expect(recipients.has(withoutInspections.id)).toBe(false); expect(recipients.has(withoutTasks.id)).toBe(false); expect(rows.every((row) => row.priority === 'WARNUNG')).toBe(true); expect( rows.every((row) => row.message.startsWith(`SUG-${suffix}: `)), ).toBe(true); }); }); });