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 type { ProposeInput } from '../src/suggestions/suggestion.types'; import { login } from './utils/api'; import { createTestApp } from './utils/create-test-app'; import { drainDomainEvents } from './utils/domain-events'; import { proposeForTest } from './utils/suggestions'; interface ViewBody { id: string; title: string; reasons: string[]; colonyCode: string | null; apiaryName: string | null; status: string; } const VIEW_KEYS = [ 'apiaryId', 'apiaryName', 'cleanup', 'colonyCode', 'colonyId', 'createdAt', 'decidedAt', 'decisionNote', 'dueDate', 'expiresAt', 'id', 'intent', 'mode', 'plan', 'priority', 'reasons', 'relatedId', 'relatedType', 'ruleKey', 'source', 'status', 'title', ]; const INTERNAL_KEYS = [ 'evidence', 'dedupeKey', 'requiredPermission', 'decidedByUserId', 'reasonCode', 'sourceEventId', ]; function expectViewShape(view: object): void { expect(Object.keys(view).sort()).toEqual(VIEW_KEYS); for (const key of INTERNAL_KEYS) { expect(view).not.toHaveProperty(key); } } /** The suggestion routes against a real PostgreSQL, as real roles. */ describe('Suggestions API (e2e)', () => { let app: INestApplication; let prisma: PrismaService; const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; const password = 'SuperSecret123!'; const base = '/api/v1/suggestions'; const ADMIN_PERMISSIONS = [ 'tasks:read', 'tasks:create', 'tasks:update', 'inspections:read', 'varroa:read', 'colonies:read', ]; let apiaryId: string; let colonyId: string; let inactiveUserId: string; const roleIds: string[] = []; const userIds: string[] = []; const tokens: Record< 'full' | 'noVarroa' | 'readOnly' | 'noUpdate' | 'noTasks', string > = { full: '', noVarroa: '', readOnly: '', noUpdate: '', noTasks: '' }; let keyCounter = 0; const nextKey = () => `suggestions-api-${suffix}-${(keyCounter += 1)}-key`; const proposal = (anchor: string): ProposeInput => ({ ruleKey: 'INSPECTION_INTERVAL', source: 'RULE', mode: 'DO', intent: 'INSPECT', relatedType: 'COLONY', relatedId: colonyId, colonyId, reasonCode: `SUGGESTIONS_API_${suffix}`, evidence: { colonyId }, requiredPermission: 'inspections:read', dueDate: '2026-05-12', anchor, }); const get = (token: string, path: string) => request(app.getHttpServer()) .get(`${base}${path}`) .set('Authorization', `Bearer ${token}`); const post = (token: string, path: string, body: object = {}, key?: string) => request(app.getHttpServer()) .post(`${base}${path}`) .set('Authorization', `Bearer ${token}`) .set('Idempotency-Key', key ?? nextKey()) .send(body); const tasksOf = (suggestionId: string) => prisma.task.findMany({ where: { sourceSuggestionId: suggestionId } }); async function createUser( label: string, permissions: string[], isActive = true, ): Promise<{ id: string; token: string }> { const role = await prisma.role.create({ data: { key: `SUGGESTIONS_API_${label.toUpperCase()}_${suffix}`, name: `Suggestions API ${label}`, permissions: { create: permissions.map((key) => ({ permission: { connect: { key } }, })), }, }, }); roleIds.push(role.id); const email = `suggestions-api-${label}-${suffix}@example.com`; const user = await prisma.user.create({ data: { email, passwordHash: await bcrypt.hash(password, 12), name: `Suggestions API ${label}`, isActive, role: { connect: { id: role.id } }, }, }); userIds.push(user.id); return { id: user.id, token: isActive ? (await login(app, email, password)).accessToken : '', }; } beforeAll(async () => { app = await createTestApp(); prisma = app.get(PrismaService); apiaryId = ( await prisma.apiary.create({ data: { name: `Sug API ${suffix}` } }) ).id; colonyId = ( await prisma.colony.create({ data: { code: `SA-${suffix}`, apiaryId, colonyType: 'PRODUCTION_COLONY', }, }) ).id; tokens.full = (await createUser('full', ADMIN_PERMISSIONS)).token; tokens.noVarroa = ( await createUser( 'novarroa', ADMIN_PERMISSIONS.filter((key) => key !== 'varroa:read'), ) ).token; tokens.readOnly = (await createUser('readonly', ['tasks:read'])).token; tokens.noUpdate = ( await createUser('noupdate', ['tasks:read', 'tasks:create']) ).token; tokens.noTasks = (await createUser('notasks', ['colonies:read'])).token; inactiveUserId = (await createUser('inactive', ['tasks:read'], false)).id; }); const cleanDb = async () => { const ids = ( await prisma.suggestion.findMany({ where: { colonyId }, select: { id: true }, }) ).map((row) => row.id); const taskIds = ( await prisma.task.findMany({ where: { sourceSuggestionId: { in: ids } }, select: { id: true }, }) ).map((row) => row.id); await prisma.domainEvent.deleteMany({ where: { aggregateId: { in: taskIds } }, }); await prisma.calendarSyncOutboxEntry.deleteMany({ where: { sourceType: 'TASK', sourceId: { in: taskIds } }, }); await prisma.task.deleteMany({ where: { id: { in: taskIds } } }); await prisma.suggestion.deleteMany({ where: { colonyId } }); }; afterEach(cleanDb); afterAll(async () => { await cleanDb(); await prisma.colony.delete({ where: { id: colonyId } }); await prisma.apiary.delete({ where: { id: apiaryId } }); await prisma.idempotencyRecord.deleteMany({ where: { userId: { in: userIds } }, }); await prisma.refreshToken.deleteMany({ where: { userId: { in: userIds } }, }); await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } }); await prisma.user.deleteMany({ where: { id: { in: userIds } } }); await prisma.role.deleteMany({ where: { id: { in: roleIds } } }); await app.close(); }); it('lists open suggestions with rendered title and reasons', async () => { const created = await proposeForTest(app, proposal('a1')); const response = await get(tokens.full, `?colonyId=${colonyId}`).expect( 200, ); const [view] = response.body as ViewBody[]; expect(view).toMatchObject({ id: created.id, title: 'Durchsicht fällig', reasons: [], colonyCode: `SA-${suffix}`, apiaryName: `Sug API ${suffix}`, status: 'OPEN', dueDate: '2026-05-12', }); const byApiary = await get(tokens.full, `?apiaryId=${apiaryId}`).expect( 200, ); expect(byApiary.body).toHaveLength(1); const other = await get(tokens.full, '?apiaryId=none').expect(200); expect(other.body).toEqual([]); }); it('a user without varroa:read sees neither the varroa suggestion nor its count', async () => { await proposeForTest(app, { ...proposal('v1'), ruleKey: 'VARROA_THRESHOLD', intent: 'VARROA_TREAT', requiredPermission: 'varroa:read', }); const hidden = await get(tokens.noVarroa, `?colonyId=${colonyId}`); const hiddenSummary = await get(tokens.noVarroa, '/summary'); const seen = await get(tokens.full, `?colonyId=${colonyId}`); const seenSummary = await get(tokens.full, '/summary'); expect(hidden.body).toEqual([]); expect((hiddenSummary.body as { open: number }).open).toBe(0); expect(seen.body).toHaveLength(1); expect((seenSummary.body as { open: number }).open).toBeGreaterThanOrEqual( 1, ); expect( (seenSummary.body as { byPriority: Record }).byPriority, ).toEqual( expect.objectContaining({ LOW: 0, NORMAL: expect.any(Number) as number }), ); }); it('answers 404 to a decision on a suggestion the user may not see', async () => { const created = await proposeForTest(app, { ...proposal('v2'), ruleKey: 'VARROA_THRESHOLD', intent: 'VARROA_TREAT', requiredPermission: 'varroa:read', }); await post(tokens.noVarroa, `/${created.id}/accept`).expect(404); await post(tokens.noVarroa, `/${created.id}/dismiss`).expect(404); await post(tokens.noVarroa, '/bulk-accept', { ids: [created.id] }).expect( 404, ); await post(tokens.noVarroa, '/bulk-dismiss', { ids: [created.id] }).expect( 404, ); await post(tokens.full, `/${created.id}/dismiss`).expect(201); await post(tokens.noVarroa, `/${created.id}/reopen`).expect(404); expect(await tasksOf(created.id)).toHaveLength(0); }); it('accept creates exactly one task with sourceSuggestionId and a calendar delivery', async () => { const created = await proposeForTest(app, proposal('c1')); const response = await post(tokens.full, `/${created.id}/accept`).expect( 201, ); const tasks = await tasksOf(created.id); expect(tasks).toHaveLength(1); expect(tasks[0]).toMatchObject({ sourceSuggestionId: created.id, title: 'Durchsicht fällig', }); const body = response.body as { suggestion: object & { status: string }; tasks: { id: string }[]; }; expect(Object.keys(body).sort()).toEqual(['suggestion', 'tasks']); expectViewShape(body.suggestion); expect(body.suggestion.status).toBe('ACCEPTED'); expect(body.tasks.map((task) => task.id)).toEqual([tasks[0].id]); expect(body.tasks[0]).toMatchObject({ sourceSuggestionId: created.id, title: 'Durchsicht fällig', }); await drainDomainEvents(app, [tasks[0].id]); const events = await prisma.domainEvent.findMany({ where: { aggregateType: 'Task', aggregateId: tasks[0].id }, include: { deliveries: true }, }); expect(events).toHaveLength(1); expect(events[0].deliveries).toEqual( expect.arrayContaining([ expect.objectContaining({ handler: 'calendar-sync.task', status: 'DONE', }), ]), ); }); it('a second accept with another key answers 409 and leaves exactly one task', async () => { const created = await proposeForTest(app, proposal('c2')); await post(tokens.full, `/${created.id}/accept`).expect(201); const second = await post(tokens.full, `/${created.id}/accept`).expect(409); expect(second.body).toMatchObject({ message: 'Der Vorschlag ist nicht mehr offen.', status: 'ACCEPTED', }); expect(await tasksOf(created.id)).toHaveLength(1); }); it('the same Idempotency-Key replays the first accept', async () => { const created = await proposeForTest(app, proposal('c3')); const key = nextKey(); const first = await post( tokens.full, `/${created.id}/accept`, {}, key, ).expect(201); const replay = await post( tokens.full, `/${created.id}/accept`, {}, key, ).expect(201); expect(replay.body).toEqual(first.body); expect(await tasksOf(created.id)).toHaveLength(1); }); it('bulk-accept with one non-OPEN id answers 409 and creates no task', async () => { const open = await proposeForTest(app, proposal('b1')); const dismissed = await proposeForTest(app, { ...proposal('b2'), ruleKey: 'QUEEN_ACCEPTANCE_CHECK', intent: 'QUEEN_ACCEPTANCE', }); await post(tokens.full, `/${dismissed.id}/dismiss`).expect(201); await post(tokens.full, '/bulk-accept', { ids: [open.id, dismissed.id], }).expect(409); expect(await tasksOf(open.id)).toHaveLength(0); expect( (await prisma.suggestion.findUniqueOrThrow({ where: { id: open.id } })) .status, ).toBe('OPEN'); }); it('bulk endpoints reject 0 or 51 ids', async () => { const ids51 = Array.from({ length: 51 }, (_, index) => `id-${index}`); for (const path of ['/bulk-accept', '/bulk-dismiss']) { await post(tokens.full, path, { ids: [] }).expect(400); await post(tokens.full, path, { ids: ids51 }).expect(400); } }); it('bulk-dismiss dismisses all given suggestions', async () => { const first = await proposeForTest(app, proposal('d1')); const second = await proposeForTest(app, { ...proposal('d2'), ruleKey: 'QUEEN_ACCEPTANCE_CHECK', intent: 'QUEEN_ACCEPTANCE', }); const response = await post(tokens.full, '/bulk-dismiss', { ids: [first.id, second.id], }).expect(201); expect(response.body).toEqual({ dismissed: 2 }); }); it('accept with an unknown or inactive assignedUserId answers 400 and creates no task', async () => { const created = await proposeForTest(app, proposal('u1')); await post(tokens.full, `/${created.id}/accept`, { assignedUserId: 'does-not-exist', }).expect(400); const inactive = await post(tokens.full, `/${created.id}/accept`, { assignedUserId: inactiveUserId, }).expect(400); expect(inactive.body).toMatchObject({ issues: [{ field: 'assignedUserId' }], }); expect(await tasksOf(created.id)).toHaveLength(0); }); it('dismiss then reopen restores OPEN; the dismissed one is listed for 30 days', async () => { const created = await proposeForTest(app, proposal('r1')); const dismissResponse = await post(tokens.full, `/${created.id}/dismiss`, { note: 'später', }).expect(201); expectViewShape(dismissResponse.body as object); expect(dismissResponse.body).toMatchObject({ status: 'DISMISSED', decisionNote: 'später', }); const dismissed = await get( tokens.full, `?status=DISMISSED&colonyId=${colonyId}`, ).expect(200); expect(await get(tokens.full, `?colonyId=${colonyId}`)).toMatchObject({ body: [], }); await prisma.$executeRaw`UPDATE "Suggestion" SET "decidedAt" = now() - interval '31 days' WHERE id = ${created.id}`; const stale = await get( tokens.full, `?status=DISMISSED&colonyId=${colonyId}`, ).expect(200); await prisma.$executeRaw`UPDATE "Suggestion" SET "decidedAt" = now() WHERE id = ${created.id}`; const reopened = await post(tokens.full, `/${created.id}/reopen`).expect( 201, ); expect(dismissed.body).toHaveLength(1); expect((dismissed.body as { decisionNote: string }[])[0]).toMatchObject({ decisionNote: 'später', }); expect(stale.body).toEqual([]); expectViewShape(reopened.body as object); expect(reopened.body).toMatchObject({ status: 'OPEN' }); expect(await get(tokens.full, `?colonyId=${colonyId}`)).toMatchObject({ body: [expect.objectContaining({ id: created.id })], }); }); it('rejects a dismiss note over 500 characters', async () => { const created = await proposeForTest(app, proposal('n1')); await post(tokens.full, `/${created.id}/dismiss`, { note: 'x'.repeat(501), }).expect(400); }); it('403 without tasks:create on accept and without tasks:update on dismiss', async () => { const created = await proposeForTest(app, proposal('p1')); await post(tokens.readOnly, `/${created.id}/accept`).expect(403); await post(tokens.readOnly, '/bulk-accept', { ids: [created.id] }).expect( 403, ); await post(tokens.noUpdate, `/${created.id}/dismiss`).expect(403); await post(tokens.noUpdate, `/${created.id}/reopen`).expect(403); await post(tokens.noUpdate, '/bulk-dismiss', { ids: [created.id] }).expect( 403, ); await request(app.getHttpServer()).get(base).expect(401); }); it('bulk-accept answers one result per suggestion in the view shape', async () => { const first = await proposeForTest(app, proposal('ba1')); const second = await proposeForTest(app, { ...proposal('ba2'), ruleKey: 'QUEEN_ACCEPTANCE_CHECK', intent: 'QUEEN_ACCEPTANCE', }); const response = await post(tokens.full, '/bulk-accept', { ids: [first.id, second.id], }).expect(201); const results = response.body as { suggestion: { id: string }; tasks: object[]; }[]; expect(results.map((result) => result.suggestion.id)).toEqual([ first.id, second.id, ]); for (const result of results) { expectViewShape(result.suggestion); expect(result.tasks).toHaveLength(1); } }); it('403 on the list and the summary without tasks:read', async () => { await get(tokens.noTasks, '').expect(403); await get(tokens.noTasks, '/summary').expect(403); }); it('rejects an invalid status, a datetime dueDate and a bad limit with 400', async () => { const created = await proposeForTest(app, proposal('v400')); await get(tokens.full, '?status=NOPE').expect(400); await get(tokens.full, '?limit=501').expect(400); await get(tokens.full, '?limit=0').expect(400); await get(tokens.full, '?offset=-1').expect(400); const response = await post(tokens.full, `/${created.id}/accept`, { dueDate: '2026-05-12T10:00:00Z', }).expect(400); expect(JSON.stringify(response.body)).toContain('JJJJ-MM-TT'); expect(await tasksOf(created.id)).toHaveLength(0); }); it('limit and offset page through the list', async () => { await proposeForTest(app, proposal('l1')); await proposeForTest(app, { ...proposal('l2'), ruleKey: 'QUEEN_ACCEPTANCE_CHECK', intent: 'QUEEN_ACCEPTANCE', }); const all = (await get(tokens.full, `?colonyId=${colonyId}`).expect(200)) .body as ViewBody[]; const first = ( await get(tokens.full, `?colonyId=${colonyId}&limit=1`).expect(200) ).body as ViewBody[]; const second = ( await get(tokens.full, `?colonyId=${colonyId}&limit=1&offset=1`).expect( 200, ) ).body as ViewBody[]; expect(all).toHaveLength(2); expect(first.map((view) => view.id)).toEqual([all[0].id]); expect(second.map((view) => view.id)).toEqual([all[1].id]); }); it('combines the apiaryId filter with status and colonyId', async () => { const created = await proposeForTest(app, proposal('f1')); await post(tokens.full, `/${created.id}/dismiss`).expect(201); const hit = await get( tokens.full, `?apiaryId=${apiaryId}&colonyId=${colonyId}&status=DISMISSED`, ).expect(200); const wrongStatus = await get( tokens.full, `?apiaryId=${apiaryId}&colonyId=${colonyId}&status=OPEN`, ).expect(200); const wrongApiary = await get( tokens.full, `?apiaryId=none&colonyId=${colonyId}&status=DISMISSED`, ).expect(200); expect((hit.body as ViewBody[]).map((view) => view.id)).toEqual([ created.id, ]); expect(wrongStatus.body).toEqual([]); expect(wrongApiary.body).toEqual([]); }); it('sorts by due date (none last), then priority (highest first)', async () => { const mk = ( anchor: string, intent: ProposeInput['intent'], priority: ProposeInput['priority'], dueDate: string | null, ) => proposeForTest(app, { ...proposal(anchor), intent, priority, dueDate }); const undated = await mk('s1', 'QUEEN_CHECK', 'URGENT', null); const normal = await mk('s2', 'INSPECT', 'NORMAL', '2026-05-12'); const urgent = await mk('s3', 'SWARM_CHECK', 'URGENT', '2026-05-12'); const early = await mk('s4', 'HEALTH_CHECK', 'LOW', '2026-05-10'); const response = await get(tokens.full, `?colonyId=${colonyId}`).expect( 200, ); expect((response.body as ViewBody[]).map((view) => view.id)).toEqual([ early.id, urgent.id, normal.id, undated.id, ]); }); it('shows the cleanup payload from the openTaskIds evidence', async () => { await proposeForTest(app, { ...proposal('cl1'), ruleKey: 'COLONY_ENDED_CLEANUP', intent: 'CLEANUP', requiredPermission: 'colonies:read', evidence: { colonyId, status: 'DECEASED', endedOn: '2026-03-12', via: 'winter', openTaskIds: ['t_a', 't_b'], queenId: 'q_1', }, }); await proposeForTest(app, { ...proposal('cl2'), intent: 'QUEEN_CHECK' }); const views = (await get(tokens.full, `?colonyId=${colonyId}`).expect(200)) .body as { intent: string; cleanup: unknown }[]; expect(views.find((v) => v.intent === 'CLEANUP')?.cleanup).toEqual({ taskIds: ['t_a', 't_b'], queenId: 'q_1', }); expect(views.find((v) => v.intent === 'QUEEN_CHECK')?.cleanup).toBeNull(); }); it('bulk-dismiss answers { dismissed } only', async () => { const created = await proposeForTest(app, proposal('bd1')); const response = await post(tokens.full, '/bulk-dismiss', { ids: [created.id], }).expect(201); expect(response.body).toEqual({ dismissed: 1 }); }); });