Files
ApiCore/ApiCore-Backend/test/suggestions-api.e2e-spec.ts
T
Nick SzaunigandClaude Opus 5.5 f468dc5f75 feat: T4 wave 3 - notifications per user, suggestions API, varroa bands, split/queen events
- Schema III: per-user notifications (broadcast fan-out migration,
  category, link, requiredPermissions filter), notification preferences
  and settings endpoints, calendar withdrawal switch, occupiedFrames
- Suggestions API: list/summary with permission visibility and paging,
  accept/dismiss/reopen incl. bulk, SuggestionView on all responses
- Varroa traffic light by season and method, young colonies, treatment
  effect window, sample-based percentage; shared withdrawal-window helper
  on Berlin calendar days; GET /varroa-measurements/thresholds
- ColonySplit and QueenAssigned domain events with calendar and webhook
  handlers
- Outbox claims compare against now() rounded to timestamp(3); fixes the
  flaky e2e claims of rows written with SQL now()

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 14:25:24 +02:00

649 lines
21 KiB
TypeScript

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<App>;
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<string, number> }).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 });
});
});