Files
ApiCore/ApiCore-Backend/test/raw-materials.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

459 lines
15 KiB
TypeScript

import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { Prisma } from '../generated/prisma';
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';
interface StockBody {
summary: {
initial: number;
withdrawn: number;
consumed: number;
remaining: number;
};
movements: {
id: string;
type: string;
quantity: number;
notes: string | null;
}[];
}
interface LotBody {
id: string;
remainingCount: number;
usedIn: { quantity: number; productionBatch: { id: string } }[];
}
interface BatchBody {
id: string;
inputs: { quantity: number; rawMaterialLot: { id: string } }[];
}
/** A shared rule's config as it was before a test changed it. */
function restoredConfig(rule: {
config: Prisma.JsonValue;
}): Prisma.InputJsonValue | typeof Prisma.DbNull {
return rule.config === null ? Prisma.DbNull : rule.config;
}
/**
* Raw material lots (wax, propolis) and their consumption in production:
* creating a production batch books the inputs off the raw lots in the same
* transaction, never more than is left; deleting a mistaken batch gives the
* material back as its own movement.
*/
describe('Raw materials and production (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const startedAt = new Date();
const password = 'SuperSecret123!';
let token: string;
let userId: string;
let waxId: string;
let propolisId: string;
let productId: string;
let batchId: string;
// Tasks the RAW_MATERIAL_LOW handler created for this file.
const ruleTaskIds: string[] = [];
const server = () => app.getHttpServer();
const auth = (req: request.Test) =>
req.set('Authorization', `Bearer ${token}`);
/** The StockChanged events recorded for one movement or batch. */
function stockChangedOf(aggregateId: string) {
return prisma.domainEvent.findMany({
where: { type: 'StockChanged', aggregateId },
select: { aggregateType: true, payload: true, actorUserId: true },
});
}
async function stockOf(lotId: string): Promise<StockBody> {
const response = await auth(
request(server()).get(`/api/v1/raw-material-lots/${lotId}/stock`),
).expect(200);
return response.body as StockBody;
}
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
const user = await prisma.user.create({
data: {
email: `raw-beekeeper-${suffix}@example.com`,
passwordHash: await bcrypt.hash(password, 12),
name: 'Beekeeper',
role: { connect: { key: 'BEEKEEPER' } },
},
});
userId = user.id;
token = (await login(app, `raw-beekeeper-${suffix}@example.com`, password))
.accessToken;
const createLot = async (kind: string, lot: string, grams: number) => {
const response = await auth(
request(server()).post('/api/v1/raw-material-lots'),
)
.send({
kind,
lotNumber: `${lot}-${suffix}`,
date: '2026-06-01',
quantity: grams,
})
.expect(201);
return (response.body as LotBody).id;
};
waxId = await createLot('BEESWAX', 'W', 1000);
propolisId = await createLot('PROPOLIS', 'P', 200);
const product = await prisma.product.create({
data: {
name: `Propolis-Salbe ${suffix}`,
kind: 'MANUFACTURED',
shopCategory: 'PROPOLIS',
},
});
productId = product.id;
});
afterAll(async () => {
// Only this file's rows, by its own ids - other e2e files share the
// database. An undefined id would drop a filter, hence the guards.
const lotIds = [waxId, propolisId].filter(Boolean);
const movementIds = (
await prisma.batchMovement.findMany({
where: { rawMaterialLotId: { in: lotIds } },
select: { id: true },
})
).map((movement) => movement.id);
const batchIds = [batchId].filter(Boolean);
// Rule tasks are written without a user: their TaskCreated, audit
// entries, warning and queued deliveries go by task id.
for (const taskId of ruleTaskIds) {
await prisma.auditLog.deleteMany({
where: {
entityType: 'AutomationRule',
afterData: { path: ['taskId'], equals: taskId },
},
});
await prisma.webhookDelivery.deleteMany({
where: { event: 'task.created', body: { contains: taskId } },
});
}
await prisma.calendarSyncOutboxEntry.deleteMany({
where: { sourceId: { in: ruleTaskIds } },
});
// Safe to scope by kind and time: RAW_MATERIAL_LOW is the only writer
// of GENERAL/PROPOLIS warnings, and only this file switches the rule on
// (default: off, no minimum) - so such a warning since this file
// started belongs to the task above.
if (ruleTaskIds.length > 0) {
await prisma.notification.deleteMany({
where: {
relatedType: 'GENERAL',
relatedId: 'PROPOLIS',
createdAt: { gte: startedAt },
},
});
}
const aggregateIds = [...batchIds, ...movementIds, ...ruleTaskIds];
await prisma.domainEvent.deleteMany({
where: { aggregateId: { in: aggregateIds } },
});
await prisma.auditLog.deleteMany({
where: { entityId: { in: [...lotIds, ...aggregateIds] } },
});
await prisma.task.deleteMany({ where: { id: { in: ruleTaskIds } } });
await app.close();
});
it('books the raw material when a production batch is made from it', async () => {
const response = await auth(
request(server()).post('/api/v1/production-batches'),
)
.send({
productId,
lotNumber: `S-${suffix}`,
date: '2026-06-10',
quantity: 20,
inputs: [
{ rawMaterialLotId: waxId, quantity: 300 },
{ rawMaterialLotId: propolisId, quantity: 50 },
],
})
.expect(201);
const batch = response.body as BatchBody;
batchId = batch.id;
expect(batch.inputs).toHaveLength(2);
expect((await stockOf(waxId)).summary).toMatchObject({
consumed: 300,
remaining: 700,
});
expect((await stockOf(propolisId)).summary.remaining).toBe(150);
const wax = await auth(
request(server()).get(`/api/v1/raw-material-lots/${waxId}`),
).expect(200);
expect(
(wax.body as LotBody).usedIn.map((u) => u.productionBatch.id),
).toEqual([batchId]);
// The article for the shop, the consumed kinds for the minimum rule.
const events = await stockChangedOf(batchId);
expect(events).toEqual([
{
aggregateType: 'ProductionBatch',
payload: {
bottlingBatchIds: [],
productIds: [productId],
rawMaterialKinds: expect.arrayContaining([
'BEESWAX',
'PROPOLIS',
]) as unknown,
},
actorUserId: userId,
},
]);
expect(
(events[0].payload as { rawMaterialKinds: string[] }).rawMaterialKinds,
).toHaveLength(2);
});
it('refuses a production batch that needs more raw material than is left - and creates nothing', async () => {
await auth(request(server()).post('/api/v1/production-batches'))
.send({
productId,
lotNumber: `S2-${suffix}`,
date: '2026-06-11',
quantity: 10,
inputs: [{ rawMaterialLotId: waxId, quantity: 800 }],
})
.expect(400);
expect(
await prisma.productionBatch.count({
where: { lotNumber: `S2-${suffix}` },
}),
).toBe(0);
expect((await stockOf(waxId)).summary.remaining).toBe(700);
});
it('withdraws raw material like any other stock', async () => {
const response = await auth(
request(server()).post(`/api/v1/raw-material-lots/${waxId}/withdrawals`),
)
.send({ quantity: 100, reason: 'SONSTIGES', notes: 'Wachstücher' })
.expect(201);
expect((response.body as StockBody).summary).toMatchObject({
withdrawn: 100,
consumed: 300,
remaining: 600,
});
});
it('a raw material withdrawal records StockChanged with the lot kind and no shop ids', async () => {
const [withdrawal] = await prisma.batchMovement.findMany({
where: { rawMaterialLotId: waxId, type: 'WITHDRAWAL' },
select: { id: true },
});
expect(await stockChangedOf(withdrawal.id)).toEqual([
{
aggregateType: 'BatchMovement',
payload: {
bottlingBatchIds: [],
productIds: [],
rawMaterialKinds: ['BEESWAX'],
},
actorUserId: userId,
},
]);
});
it('gives the raw material back when a mistaken production batch is deleted', async () => {
await auth(
request(server()).delete(`/api/v1/production-batches/${batchId}`),
).expect(200);
const wax = await stockOf(waxId);
expect(wax.summary).toMatchObject({ consumed: 0, remaining: 900 });
expect(wax.movements.some((m) => m.notes?.startsWith('Rückgabe'))).toBe(
true,
);
expect((await stockOf(propolisId)).summary.remaining).toBe(200);
// The give-back is a stock change of the article and both kinds too.
const events = await stockChangedOf(batchId);
expect(events).toHaveLength(2);
for (const event of events) {
expect(event.payload).toEqual({
bottlingBatchIds: [],
productIds: [productId],
rawMaterialKinds: expect.arrayContaining([
'BEESWAX',
'PROPOLIS',
]) as unknown,
});
}
});
it('refuses to correct a raw lot below what was already used or withdrawn', async () => {
// wax: 1000 g harvested, 100 g withdrawn -> at least 100 g must remain booked
await auth(request(server()).patch(`/api/v1/raw-material-lots/${waxId}`))
.send({ quantity: 50 })
.expect(400);
await auth(request(server()).patch(`/api/v1/raw-material-lots/${waxId}`))
.send({ quantity: 950 })
.expect(200);
expect((await stockOf(waxId)).summary.remaining).toBe(850);
});
it('needs the raw material withdraw permission to consume raw material', async () => {
const role = await prisma.role.create({
data: {
key: `PRODUCER_ONLY_${suffix}`,
name: `Nur Herstellung ${suffix}`,
permissions: {
create: (
await prisma.permission.findMany({
where: {
key: {
in: ['production-batch:read', 'production-batch:create'],
},
},
})
).map((permission) => ({ permissionId: permission.id })),
},
},
});
await prisma.user.create({
data: {
email: `raw-producer-${suffix}@example.com`,
passwordHash: await bcrypt.hash(password, 12),
name: 'Producer',
roleId: role.id,
},
});
const producerToken = (
await login(app, `raw-producer-${suffix}@example.com`, password)
).accessToken;
await request(server())
.post('/api/v1/production-batches')
.set('Authorization', `Bearer ${producerToken}`)
.send({
productId,
lotNumber: `S3-${suffix}`,
date: '2026-06-12',
quantity: 5,
inputs: [{ rawMaterialLotId: propolisId, quantity: 10 }],
})
.expect(403);
expect((await stockOf(propolisId)).summary.remaining).toBe(200);
});
it('keeps a raw lot with movements as history', async () => {
await auth(
request(server()).delete(`/api/v1/raw-material-lots/${waxId}`),
).expect(409);
});
it('creates the RAW_MATERIAL_LOW task only once its StockChanged event is dispatched', async () => {
// The rule is shared by every e2e file: switch it on with a minimum no
// test database reaches, and put it back right after.
const before = await prisma.automationRule.findUnique({
where: { triggerType: 'RAW_MATERIAL_LOW' },
});
const config = { minGrams: { PROPOLIS: 1_000_000_000_000 } };
// One open task per kind: a leftover from an aborted earlier run would
// keep the handler from creating a new one - close it first.
await prisma.task.updateMany({
where: {
sourceAutomationTrigger: 'RAW_MATERIAL_LOW',
relatedId: 'PROPOLIS',
status: { in: ['OPEN', 'IN_PROGRESS'] },
},
data: { status: 'DONE', completedAt: new Date() },
});
await prisma.automationRule.upsert({
where: { triggerType: 'RAW_MATERIAL_LOW' },
create: {
triggerType: 'RAW_MATERIAL_LOW',
active: true,
config,
actionTemplate: 'Rohstoff unter Mindestmenge: Nachschub einplanen.',
},
update: { active: true, config },
});
try {
const response = await auth(
request(server()).post(
`/api/v1/raw-material-lots/${propolisId}/withdrawals`,
),
)
.send({ quantity: 10, reason: 'PROBE' })
.expect(201);
// Newest first: the withdrawal just booked.
const [movement] = (response.body as StockBody).movements;
const [event] = await prisma.domainEvent.findMany({
where: { type: 'StockChanged', aggregateId: movement.id },
select: { correlationId: true },
});
const ruleTasksOfThisChange = async () =>
(
await prisma.domainEvent.findMany({
where: {
type: 'TaskCreated',
correlationId: event.correlationId,
},
select: { aggregateId: true },
})
).map((created) => created.aggregateId);
// The request itself creates no task - the handler does.
expect(await ruleTasksOfThisChange()).toEqual([]);
expect(
await prisma.task.count({
where: {
sourceAutomationTrigger: 'RAW_MATERIAL_LOW',
relatedId: 'PROPOLIS',
createdAt: { gte: startedAt },
},
}),
).toBe(0);
await drainDomainEvents(app, [movement.id]);
const taskIds = await ruleTasksOfThisChange();
ruleTaskIds.push(...taskIds);
expect(taskIds).toHaveLength(1);
expect(
await prisma.task.findUniqueOrThrow({ where: { id: taskIds[0] } }),
).toMatchObject({
sourceAutomationTrigger: 'RAW_MATERIAL_LOW',
relatedType: 'GENERAL',
relatedId: 'PROPOLIS',
status: 'OPEN',
});
} finally {
// Leave the rule as found: restored, or gone again if this test
// created it (ensureDefaults then recreates it with its defaults).
if (before === null) {
await prisma.automationRule.delete({
where: { triggerType: 'RAW_MATERIAL_LOW' },
});
} else {
await prisma.automationRule.update({
where: { triggerType: 'RAW_MATERIAL_LOW' },
data: { active: before.active, config: restoredConfig(before) },
});
}
}
});
});