Files
ApiCore/ApiCore-Backend/test/data-integrity.e2e-spec.ts
T
Nick SzaunigandClaude Opus 5.5 cdd30cf3ee feat: platform backbone and field capture complete (checkpoint)
Platform backbone (plan 2026-10-13-plattform-rueckgrat), all tasks reviewed:
- webhooks with delivery table, retries and stable delivery id
- calendar handlers; a finished task removes its Outlook event
- tasks, automations, inspections, varroa, treatments, feedings,
  harvests, colonies, wintering, stock and bottling record domain events
  in the same transaction; legacy post-commit calls removed
- row locks through one helper (lockRowForUpdate, lockRowsForShare,
  lockRowsForUpdate)
- job framework with JobRun, cli exit codes, cleanup, task templates,
  daily evaluation, hourly app-backup and the rekey job
- system API and page "Verwaltung > System" with retry
- @nestjs/schedule removed, guard tests against in-process schedulers

Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), all tasks reviewed:
- inspection photo offline without metadata
- bulk capture for treatments, feedings and varroa (backend + frontend)
- offline stand mode with service worker, offline pages and packs
- offline session, cold start and logout on shared devices

Docs: ARCHITECTURE.md assumptions and sections updated, TASKS.md.
Gates: backend lint, typecheck, 1770 unit, 420 e2e, build, no schema
drift; frontend lint, typecheck, 913 tests, build. Not rolled out.

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

592 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.task.count({
where: {
relatedId: colony.id,
sourceAutomationTrigger: { not: null },
},
}),
).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('creates only one open reorder task 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.task.count({
where: {
relatedId: item.id,
sourceAutomationTrigger: 'STOCK_ITEM_LOW',
status: { in: ['OPEN', 'IN_PROGRESS'] },
},
}),
).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);
});
});
});