Files
ApiCore/ApiCore-Backend/test/suggestions.e2e-spec.ts
T
Nick SzaunigandClaude Opus 5.5 d99a697739 feat: T4 waves 1-2 - rule configs, schema, suggestions core, season, rule forms
- Rule configs with typed parsers and registry; Berlin day helpers
- Schema: T4 enums, Suggestion table, feed vocabulary (feedStatus as
  ColonyCondition with legacy text mapping), split queen source,
  varroa sample size; migration harness for data migrations
- SuggestionsService: propose/refresh/resolve/obsolete/expire,
  accept/dismiss/reopen incl. bulk, lock order, concurrency e2e
- SeasonService: phenology-based segments, inspection/varroa due dates,
  queen age; TimeEventsStep with JobPartialFailure
- Frontend: per-rule settings forms with visible value sources

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

416 lines
14 KiB
TypeScript

import { INestApplication } from '@nestjs/common';
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 { createTestApp } from './utils/create-test-app';
/**
* 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<App>;
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 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 prisma.task.deleteMany({
where: { sourceSuggestion: { colonyId } },
});
await prisma.suggestion.deleteMany({ where: { colonyId } });
await prisma.colony.delete({ where: { id: colonyId } });
await prisma.apiary.delete({ where: { id: apiaryId } });
await prisma.auditLog.deleteMany({ where: { userId } });
await prisma.user.delete({ where: { id: userId } });
await app.close();
});
afterEach(async () => {
await prisma.task.deleteMany({
where: { sourceSuggestion: { colonyId } },
});
await prisma.suggestion.deleteMany({ where: { colonyId } });
});
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();
});
});