- Automation rules propose suggestions instead of creating tasks; notify per rule config; corrected varroa measurements refresh or resolve their suggestion - Inspection values can be taken over into the colony card (occupiedFrames 0-40, strength deprecated); warning counts use the same visibility rule as the notification list - Biotech chains: swarm check after queen cells, queen acceptance check, split schedules (raise own, queen cell, queen) - Season autopilot core with A1 inspection due, A2 varroa measurement, A3 recheck after treatment; colony sync and daily step - Frontend: suggestions page with groups, bulk accept, undo, paging, focus management and permission gating - Frontend: varroa traffic light from seasonal bands, sample size, one range request for the colony chart - Notifications: mark-read answers 404 for rows the user may no longer see Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
589 lines
19 KiB
TypeScript
589 lines
19 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 { createTestApp } from './utils/create-test-app';
|
||
import { login } from './utils/api';
|
||
import { drainDomainEvents } from './utils/domain-events';
|
||
|
||
/**
|
||
* Regression tests for data-integrity rules that cut across modules:
|
||
* repeated requests, concurrent quantity bookings, colony/queen state
|
||
* transitions and duplicate automation tasks.
|
||
*/
|
||
describe('Data integrity (e2e)', () => {
|
||
let app: INestApplication<App>;
|
||
let prisma: PrismaService;
|
||
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
||
const email = `integrity-e2e-${suffix}@example.com`;
|
||
const password = 'SuperSecret123!';
|
||
let token: string;
|
||
let userId: string;
|
||
let apiaryId: string;
|
||
|
||
const server = () => app.getHttpServer();
|
||
const auth = () => ({ Authorization: `Bearer ${token}` });
|
||
|
||
async function createColony(
|
||
code: string,
|
||
status: 'ACTIVE' | 'QUEENLESS' | 'WEAK' = 'ACTIVE',
|
||
) {
|
||
return prisma.colony.create({
|
||
data: {
|
||
code: `${code}-${suffix}`,
|
||
apiaryId,
|
||
colonyType: 'PRODUCTION_COLONY',
|
||
status,
|
||
},
|
||
});
|
||
}
|
||
|
||
async function createHoneyBatch(amount: number) {
|
||
const extraction = await prisma.extraction.create({
|
||
data: { date: new Date(), userId },
|
||
});
|
||
return prisma.honeyBatch.create({
|
||
data: {
|
||
batchNumber: `HB-${suffix}-${Math.round(Math.random() * 1e9)}`,
|
||
extractionId: extraction.id,
|
||
amount,
|
||
},
|
||
});
|
||
}
|
||
|
||
function bottle(honeyBatchId: string, jarCount: number) {
|
||
return request(server())
|
||
.post('/api/v1/bottling-batches')
|
||
.set(auth())
|
||
.send({
|
||
honeyBatchId,
|
||
date: '2026-08-01T00:00:00.000Z',
|
||
bestBeforeDate: '2028-08-01T00:00:00.000Z',
|
||
jarSize: 500,
|
||
jarCount,
|
||
lotNumber: `L-${suffix}-${Math.round(Math.random() * 1e9)}`,
|
||
});
|
||
}
|
||
|
||
beforeAll(async () => {
|
||
app = await createTestApp();
|
||
prisma = app.get(PrismaService);
|
||
const user = await prisma.user.create({
|
||
data: {
|
||
email,
|
||
passwordHash: await bcrypt.hash(password, 12),
|
||
name: 'Integrity',
|
||
role: { connect: { key: 'ADMIN' } },
|
||
},
|
||
});
|
||
userId = user.id;
|
||
token = (await login(app, email, password)).accessToken;
|
||
apiaryId = (
|
||
await prisma.apiary.create({ data: { name: `Integrity ${suffix}` } })
|
||
).id;
|
||
});
|
||
|
||
afterAll(async () => {
|
||
// The events this file's requests recorded (all by its own user) and
|
||
// the follow-up events their handlers recorded under the same
|
||
// correlation id - other e2e files share the database.
|
||
if (userId) {
|
||
const correlationIds = (
|
||
await prisma.domainEvent.findMany({
|
||
where: { actorUserId: userId },
|
||
select: { correlationId: true },
|
||
distinct: ['correlationId'],
|
||
})
|
||
).map((event) => event.correlationId);
|
||
await prisma.domainEvent.deleteMany({
|
||
where: { correlationId: { in: correlationIds } },
|
||
});
|
||
}
|
||
await app.close();
|
||
});
|
||
|
||
describe('Idempotency-Key', () => {
|
||
it('stores a repeated inspection only once and replays the first response', async () => {
|
||
const colony = await createColony('IDEM');
|
||
const key = `idem-${suffix}-inspection-0001`;
|
||
const body = {
|
||
colonyId: colony.id,
|
||
date: '2026-07-01T00:00:00.000Z',
|
||
queenSeen: false,
|
||
eggsPresent: false,
|
||
};
|
||
|
||
const first = await request(server())
|
||
.post('/api/v1/inspections')
|
||
.set(auth())
|
||
.set('Idempotency-Key', key)
|
||
.send(body)
|
||
.expect(201);
|
||
const second = await request(server())
|
||
.post('/api/v1/inspections')
|
||
.set(auth())
|
||
.set('Idempotency-Key', key)
|
||
.send(body)
|
||
.expect(201);
|
||
|
||
expect((second.body as { id: string }).id).toBe(
|
||
(first.body as { id: string }).id,
|
||
);
|
||
expect(
|
||
await prisma.inspection.count({ where: { colonyId: colony.id } }),
|
||
).toBe(1);
|
||
await drainDomainEvents(app, [(first.body as { id: string }).id]);
|
||
// The automation fired once, not once per delivery attempt.
|
||
expect(
|
||
await prisma.suggestion.count({
|
||
where: { colonyId: colony.id, source: 'RULE' },
|
||
}),
|
||
).toBe(1);
|
||
});
|
||
|
||
it('rejects a key reused for a different endpoint', async () => {
|
||
const colony = await createColony('IDEM2');
|
||
const key = `idem-${suffix}-crossuse-0001`;
|
||
await request(server())
|
||
.post('/api/v1/inspections')
|
||
.set(auth())
|
||
.set('Idempotency-Key', key)
|
||
.send({ colonyId: colony.id, date: '2026-07-01T00:00:00.000Z' })
|
||
.expect(201);
|
||
await request(server())
|
||
.post('/api/v1/feedings')
|
||
.set(auth())
|
||
.set('Idempotency-Key', key)
|
||
.send({
|
||
colonyId: colony.id,
|
||
date: '2026-07-01T00:00:00.000Z',
|
||
feedType: 'SUGAR_WATER',
|
||
amount: 2,
|
||
unit: 'l',
|
||
})
|
||
.expect(409);
|
||
});
|
||
|
||
it('ignores the header on endpoints that did not opt in, storing nothing', async () => {
|
||
const key = `idem-${suffix}-not-opted-in`;
|
||
await request(server())
|
||
.post('/api/v1/apiaries')
|
||
.set(auth())
|
||
.set('Idempotency-Key', key)
|
||
.send({ name: `Opt-out ${suffix}` })
|
||
.expect(201);
|
||
expect(
|
||
await prisma.idempotencyRecord.count({ where: { userId, key } }),
|
||
).toBe(0);
|
||
});
|
||
|
||
it('rejects a malformed key', () => {
|
||
return request(server())
|
||
.post('/api/v1/inspections')
|
||
.set(auth())
|
||
.set('Idempotency-Key', 'short')
|
||
.send({})
|
||
.expect(400);
|
||
});
|
||
|
||
it('releases the key when the request failed, so a corrected retry works', async () => {
|
||
const colony = await createColony('IDEM3');
|
||
const key = `idem-${suffix}-retry-00001`;
|
||
await request(server())
|
||
.post('/api/v1/inspections')
|
||
.set(auth())
|
||
.set('Idempotency-Key', key)
|
||
.send({ colonyId: colony.id })
|
||
.expect(400);
|
||
await request(server())
|
||
.post('/api/v1/inspections')
|
||
.set(auth())
|
||
.set('Idempotency-Key', key)
|
||
.send({ colonyId: colony.id, date: '2026-07-01T00:00:00.000Z' })
|
||
.expect(201);
|
||
});
|
||
});
|
||
|
||
describe('Bottling quantities', () => {
|
||
it('never over-books a honey batch under concurrent bottlings', async () => {
|
||
const honeyBatch = await createHoneyBatch(10);
|
||
// Each takes 6 kg of 10 kg - only one of them may succeed.
|
||
const results = await Promise.all([
|
||
bottle(honeyBatch.id, 12),
|
||
bottle(honeyBatch.id, 12),
|
||
]);
|
||
const statuses = results.map((r) => r.status).sort();
|
||
expect(statuses).toEqual([201, 400]);
|
||
const after = await prisma.honeyBatch.findUniqueOrThrow({
|
||
where: { id: honeyBatch.id },
|
||
});
|
||
expect(after.amount).toBeCloseTo(4, 3);
|
||
});
|
||
|
||
it('gives the amount back when a bottling is removed', async () => {
|
||
const honeyBatch = await createHoneyBatch(5);
|
||
const created = await bottle(honeyBatch.id, 3).expect(201);
|
||
await request(server())
|
||
.delete(
|
||
`/api/v1/bottling-batches/${(created.body as { id: string }).id}`,
|
||
)
|
||
.set(auth())
|
||
.expect(200);
|
||
const after = await prisma.honeyBatch.findUniqueOrThrow({
|
||
where: { id: honeyBatch.id },
|
||
});
|
||
expect(after.amount).toBeCloseTo(5, 3);
|
||
});
|
||
});
|
||
|
||
describe('Stock movements', () => {
|
||
it('refuses an outgoing movement larger than the stock', async () => {
|
||
const item = await prisma.stockItem.create({
|
||
data: {
|
||
name: `Gläser ${suffix}`,
|
||
category: 'CONSUMABLE',
|
||
unit: 'Stk',
|
||
quantity: 5,
|
||
},
|
||
});
|
||
await request(server())
|
||
.post(`/api/v1/stock-items/${item.id}/movements`)
|
||
.set(auth())
|
||
.send({ type: 'OUT', quantity: 6 })
|
||
.expect(400);
|
||
const after = await prisma.stockItem.findUniqueOrThrow({
|
||
where: { id: item.id },
|
||
});
|
||
expect(after.quantity).toBe(5);
|
||
});
|
||
|
||
it('proposes only one open reorder while the item stays below minimum', async () => {
|
||
const item = await prisma.stockItem.create({
|
||
data: {
|
||
name: `Deckel ${suffix}`,
|
||
category: 'CONSUMABLE',
|
||
unit: 'Stk',
|
||
quantity: 10,
|
||
minThreshold: 8,
|
||
},
|
||
});
|
||
for (const quantity of [3, 1, 1]) {
|
||
await request(server())
|
||
.post(`/api/v1/stock-items/${item.id}/movements`)
|
||
.set(auth())
|
||
.send({ type: 'OUT', quantity })
|
||
.expect(201);
|
||
}
|
||
expect(
|
||
await prisma.suggestion.count({
|
||
where: {
|
||
relatedId: item.id,
|
||
ruleKey: 'STOCK_ITEM_LOW',
|
||
status: 'OPEN',
|
||
},
|
||
}),
|
||
).toBe(1);
|
||
});
|
||
});
|
||
|
||
describe('Colony transitions', () => {
|
||
it('lets a queenless colony be united with another colony', async () => {
|
||
const survivor = await createColony('SURV');
|
||
const queenless = await createColony('QL', 'QUEENLESS');
|
||
await request(server())
|
||
.post(`/api/v1/colonies/${survivor.id}/merge`)
|
||
.set(auth())
|
||
.send({ mergedColonyIds: [queenless.id] })
|
||
.expect(201);
|
||
const merged = await prisma.colony.findUniqueOrThrow({
|
||
where: { id: queenless.id },
|
||
});
|
||
expect(merged.status).toBe('MERGED');
|
||
});
|
||
|
||
it('lets a weak colony be moved to another apiary', async () => {
|
||
const weak = await createColony('WEAK', 'WEAK');
|
||
const otherApiary = await prisma.apiary.create({
|
||
data: { name: `Other ${suffix}` },
|
||
});
|
||
await request(server())
|
||
.post(`/api/v1/colonies/${weak.id}/move`)
|
||
.set(auth())
|
||
.send({ apiaryId: otherApiary.id })
|
||
.expect(201);
|
||
});
|
||
|
||
it('refuses to merge a colony into itself', async () => {
|
||
const colony = await createColony('SELF');
|
||
await request(server())
|
||
.post(`/api/v1/colonies/${colony.id}/merge`)
|
||
.set(auth())
|
||
.send({ mergedColonyIds: [colony.id] })
|
||
.expect(400);
|
||
const after = await prisma.colony.findUniqueOrThrow({
|
||
where: { id: colony.id },
|
||
});
|
||
expect(after.status).toBe('ACTIVE');
|
||
});
|
||
|
||
it('refuses to dissolve a colony that is already dissolved', async () => {
|
||
const colony = await createColony('DISS');
|
||
await request(server())
|
||
.post(`/api/v1/colonies/${colony.id}/dissolve`)
|
||
.set(auth())
|
||
.expect(201);
|
||
await request(server())
|
||
.post(`/api/v1/colonies/${colony.id}/dissolve`)
|
||
.set(auth())
|
||
.expect(409);
|
||
});
|
||
});
|
||
|
||
describe('Queen assignment', () => {
|
||
it('moves a queen between colonies and leaves the old colony queenless', async () => {
|
||
const colonyA = await createColony('QA');
|
||
const colonyB = await createColony('QB', 'QUEENLESS');
|
||
const queen = await prisma.queen.create({
|
||
data: { code: `Q-${suffix}` },
|
||
});
|
||
|
||
await request(server())
|
||
.post(`/api/v1/queens/${queen.id}/assign-colony`)
|
||
.set(auth())
|
||
.send({ colonyId: colonyA.id })
|
||
.expect(201);
|
||
await request(server())
|
||
.post(`/api/v1/queens/${queen.id}/assign-colony`)
|
||
.set(auth())
|
||
.send({ colonyId: colonyB.id })
|
||
.expect(201);
|
||
|
||
const a = await prisma.colony.findUniqueOrThrow({
|
||
where: { id: colonyA.id },
|
||
});
|
||
const b = await prisma.colony.findUniqueOrThrow({
|
||
where: { id: colonyB.id },
|
||
});
|
||
expect(a.currentQueenId).toBeNull();
|
||
expect(a.status).toBe('QUEENLESS');
|
||
expect(b.currentQueenId).toBe(queen.id);
|
||
expect(b.status).toBe('ACTIVE');
|
||
const openHistory = await prisma.queenColonyHistory.findMany({
|
||
where: { queenId: queen.id, endDate: null },
|
||
});
|
||
expect(openHistory.map((h) => h.colonyId)).toEqual([colonyB.id]);
|
||
});
|
||
|
||
it('refuses to assign a queen to a dissolved colony', async () => {
|
||
const colony = await createColony('QD');
|
||
await prisma.colony.update({
|
||
where: { id: colony.id },
|
||
data: { status: 'DISSOLVED' },
|
||
});
|
||
const queen = await prisma.queen.create({
|
||
data: { code: `QD-${suffix}` },
|
||
});
|
||
await request(server())
|
||
.post(`/api/v1/queens/${queen.id}/assign-colony`)
|
||
.set(auth())
|
||
.send({ colonyId: colony.id })
|
||
.expect(409);
|
||
});
|
||
});
|
||
|
||
describe('Tasks', () => {
|
||
it('keeps the first completion when a task is completed twice', async () => {
|
||
const created = await request(server())
|
||
.post('/api/v1/tasks')
|
||
.set(auth())
|
||
.send({ title: `Doppelklick ${suffix}` })
|
||
.expect(201);
|
||
const id = (created.body as { id: string }).id;
|
||
const first = await request(server())
|
||
.post(`/api/v1/tasks/${id}/complete`)
|
||
.set(auth())
|
||
.expect(201);
|
||
const second = await request(server())
|
||
.post(`/api/v1/tasks/${id}/complete`)
|
||
.set(auth())
|
||
.expect(201);
|
||
expect((second.body as { completedAt: string }).completedAt).toBe(
|
||
(first.body as { completedAt: string }).completedAt,
|
||
);
|
||
// Announced once: a second TaskCompleted would send the webhook twice.
|
||
await expect(
|
||
prisma.domainEvent.count({
|
||
where: { aggregateId: id, type: 'TaskCompleted' },
|
||
}),
|
||
).resolves.toBe(1);
|
||
});
|
||
|
||
it('announces a completion once when two requests complete a task at the same time', async () => {
|
||
const created = await request(server())
|
||
.post('/api/v1/tasks')
|
||
.set(auth())
|
||
.send({ title: `Gleichzeitig erledigt ${suffix}` })
|
||
.expect(201);
|
||
const id = (created.body as { id: string }).id;
|
||
|
||
const [first, second] = await Promise.all([
|
||
request(server())
|
||
.post(`/api/v1/tasks/${id}/complete`)
|
||
.set(auth())
|
||
.expect(201),
|
||
request(server())
|
||
.post(`/api/v1/tasks/${id}/complete`)
|
||
.set(auth())
|
||
.expect(201),
|
||
]);
|
||
|
||
expect((second.body as { completedAt: string }).completedAt).toBe(
|
||
(first.body as { completedAt: string }).completedAt,
|
||
);
|
||
await expect(
|
||
prisma.domainEvent.count({
|
||
where: { aggregateId: id, type: 'TaskCompleted' },
|
||
}),
|
||
).resolves.toBe(1);
|
||
await expect(
|
||
prisma.auditLog.count({
|
||
where: { entityType: 'Task', entityId: id, action: 'UPDATE' },
|
||
}),
|
||
).resolves.toBe(1);
|
||
});
|
||
|
||
it('announces a completion once when an edit into DONE and "Erledigt" arrive at the same time', async () => {
|
||
const created = await request(server())
|
||
.post('/api/v1/tasks')
|
||
.set(auth())
|
||
.send({ title: `Bearbeitet und erledigt ${suffix}` })
|
||
.expect(201);
|
||
const id = (created.body as { id: string }).id;
|
||
|
||
const [edited, completed] = await Promise.all([
|
||
request(server())
|
||
.patch(`/api/v1/tasks/${id}`)
|
||
.set(auth())
|
||
.send({ status: 'DONE' })
|
||
.expect(200),
|
||
request(server())
|
||
.post(`/api/v1/tasks/${id}/complete`)
|
||
.set(auth())
|
||
.expect(201),
|
||
]);
|
||
|
||
expect((edited.body as { completedAt: string }).completedAt).toBe(
|
||
(completed.body as { completedAt: string }).completedAt,
|
||
);
|
||
await expect(
|
||
prisma.domainEvent.count({
|
||
where: { aggregateId: id, type: 'TaskCompleted' },
|
||
}),
|
||
).resolves.toBe(1);
|
||
});
|
||
|
||
it('clears completedAt when a done task is reopened', async () => {
|
||
const created = await request(server())
|
||
.post('/api/v1/tasks')
|
||
.set(auth())
|
||
.send({ title: `Wieder offen ${suffix}` })
|
||
.expect(201);
|
||
const id = (created.body as { id: string }).id;
|
||
await request(server())
|
||
.post(`/api/v1/tasks/${id}/complete`)
|
||
.set(auth())
|
||
.expect(201);
|
||
const reopened = await request(server())
|
||
.patch(`/api/v1/tasks/${id}`)
|
||
.set(auth())
|
||
.send({ status: 'OPEN' })
|
||
.expect(200);
|
||
expect(
|
||
(reopened.body as { completedAt: string | null }).completedAt,
|
||
).toBeNull();
|
||
});
|
||
|
||
it('gives a task generated from a recurring template its due date', async () => {
|
||
const dueDate = new Date('2026-01-15T00:00:00.000Z');
|
||
const template = await prisma.taskTemplate.create({
|
||
data: {
|
||
title: `Wiederkehrend ${suffix}`,
|
||
recurrenceType: 'INTERVAL_DAYS',
|
||
intervalDays: 7,
|
||
nextDueDate: dueDate,
|
||
},
|
||
});
|
||
await request(server()).get('/api/v1/tasks').set(auth()).expect(200);
|
||
const generated = await prisma.task.findMany({
|
||
where: { sourceTemplateId: template.id },
|
||
});
|
||
// Months of missed weekly occurrences collapse into one reminder
|
||
// instead of a pile of stale duplicates.
|
||
expect(generated).toHaveLength(1);
|
||
expect(generated[0].dueDate).not.toBeNull();
|
||
const refreshed = await prisma.taskTemplate.findUniqueOrThrow({
|
||
where: { id: template.id },
|
||
});
|
||
expect(refreshed.nextDueDate.getTime()).toBeGreaterThan(Date.now());
|
||
// One TaskCreated carrying that due date, so the calendar entry and
|
||
// the webhook follow the generated task like any other.
|
||
const events = await prisma.domainEvent.findMany({
|
||
where: { aggregateType: 'Task', aggregateId: generated[0].id },
|
||
});
|
||
expect(events).toEqual([
|
||
expect.objectContaining({
|
||
type: 'TaskCreated',
|
||
actorUserId: null,
|
||
payload: expect.objectContaining({
|
||
dueDate: generated[0].dueDate?.toISOString().slice(0, 10),
|
||
}) as unknown,
|
||
}),
|
||
]);
|
||
});
|
||
});
|
||
|
||
describe('Input plausibility', () => {
|
||
it('rejects an inspection rating outside the 1–5 scale', async () => {
|
||
const colony = await createColony('SCALE');
|
||
await request(server())
|
||
.post('/api/v1/inspections')
|
||
.set(auth())
|
||
.send({
|
||
colonyId: colony.id,
|
||
date: '2026-07-01T00:00:00.000Z',
|
||
gentleness: 9,
|
||
})
|
||
.expect(400);
|
||
});
|
||
|
||
it('rejects a harvest whose net weight exceeds its gross weight', async () => {
|
||
const colony = await createColony('HW');
|
||
await request(server())
|
||
.post('/api/v1/harvests')
|
||
.set(auth())
|
||
.send({
|
||
apiaryId,
|
||
colonyIds: [colony.id],
|
||
date: '2026-07-01T00:00:00.000Z',
|
||
grossWeight: 20,
|
||
netWeight: 25,
|
||
})
|
||
.expect(400);
|
||
});
|
||
|
||
it('rejects a harvest listing the same colony twice', async () => {
|
||
const colony = await createColony('HD');
|
||
await request(server())
|
||
.post('/api/v1/harvests')
|
||
.set(auth())
|
||
.send({
|
||
apiaryId,
|
||
colonyIds: [colony.id, colony.id],
|
||
date: '2026-07-01T00:00:00.000Z',
|
||
})
|
||
.expect(400);
|
||
});
|
||
});
|
||
});
|