Files
ApiCore/ApiCore-Backend/test/bottling-quality.e2e-spec.ts
T
Nick SzaunigandClaude Opus 5.5 fab7f7e12f feat: T4 wave 10 – bottling quality, phenology and honey chain UI, Telegram link/receive and ops
- Bottling quality: water content above the legal limit (strict >) answers
  409 CONFIRMATION_REQUIRED bound to the measured value; prefill for jar
  size, best-before (shelfLifeMonths, month-end clamping) and water content;
  impossible dates give 400; water-content checks in src/common/quality.
- Frontend phenology: "Entwicklung melden" sheet, IndexedDB v4 queue,
  season header on the apiary page, confirmed revoke for all apiaries.
- Frontend honey chain: "Vorschlag aus …" hints with explicit take-over,
  prefill status note, movement ledger with withdraw/correct dialogs.
- Telegram: link tokens (hashed, single use), private-chat receiver with
  lease and offset CAS, bounded poison-update skip, Retry-After, pending
  deliveries skipped on disconnect; system API for deliveries and retry;
  worker-only secret wiring and manifest policy tests.
- ARCHITECTURE.md §7, §9, §10, §11.11–11.13, Annahmen 51–55.

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

564 lines
17 KiB
TypeScript

import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import { randomUUID } from 'node:crypto';
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';
interface Warning {
code: string;
severity: string;
message: string;
refs: { type: string; id: string }[];
}
interface BottlingBody {
id: string;
jarSize: number;
bestBeforeDate: string;
warnings: Warning[];
}
interface ConfirmationBody {
statusCode: number;
code: string;
waterContent: number;
message: string;
warnings: Warning[];
}
interface PrefillBody {
jarSize: number | null;
jarSizeSource: string | null;
bestBeforeDate: string | null;
shelfLifeMonths: number | null;
waterContent: number | null;
warnings: Warning[];
}
describe('Bottling quality (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const stamp = Date.now();
const email = `bottling-quality-e2e-${stamp}@example.com`;
const password = 'SuperSecret123!';
let userId: string;
let token: string;
let staffId: string;
let staffToken: string;
let extractionId: string;
const honeyBatchIds: string[] = [];
const productIds: string[] = [];
const bottlingIds: string[] = [];
const api = () => request(app.getHttpServer());
const auth = (req: request.Test) =>
req.set('Authorization', `Bearer ${token}`);
async function newBatch(
waterContent: number | null,
honeyType: string | null = null,
): Promise<string> {
const batch = await prisma.honeyBatch.create({
data: {
batchNumber: `BQ-${stamp}-${honeyBatchIds.length}`,
extractionId,
amount: 25,
initialAmount: 25,
waterContent,
honeyType,
},
});
honeyBatchIds.push(batch.id);
return batch.id;
}
async function newProduct(data: {
packagingSize?: number;
shelfLifeMonths?: number;
}): Promise<string> {
const product = await prisma.product.create({
data: {
name: `Qualität ${stamp}-${productIds.length}`,
kind: 'BOTTLED_HONEY',
...data,
},
});
productIds.push(product.id);
return product.id;
}
/** What a write would leave behind for the batch: movements, events, audit. */
async function writtenCounts(honeyBatchId: string) {
return {
movements: await prisma.batchMovement.count({ where: { honeyBatchId } }),
events: await prisma.domainEvent.count({
where: {
aggregateType: 'BottlingBatch',
payload: { path: ['honeyBatchId'], equals: honeyBatchId },
},
}),
audits: await prisma.auditLog.count({
where: { userId, entityType: 'BottlingBatch', action: 'CREATE' },
}),
};
}
function create(body: Record<string, unknown>, key?: string): request.Test {
const req = auth(api().post('/api/v1/bottling-batches'));
if (key) {
req.set('Idempotency-Key', key);
}
return req.send({
date: '2026-01-31',
jarCount: 2,
lotNumber: `BQ-L-${stamp}-${randomUUID()}`,
...body,
});
}
function remember(response: { body: unknown }): BottlingBody {
const body = response.body as BottlingBody;
bottlingIds.push(body.id);
return body;
}
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
const user = await prisma.user.create({
data: {
email,
passwordHash: await bcrypt.hash(password, 12),
name: 'Quality',
role: { connect: { key: 'BEEKEEPER' } },
},
});
userId = user.id;
token = (await login(app, email, password)).accessToken;
const extraction = await prisma.extraction.create({
data: { date: new Date(), userId },
});
extractionId = extraction.id;
// STAFF has no bottling:create.
const staff = await prisma.user.create({
data: {
email: `bottling-quality-staff-${stamp}@example.com`,
passwordHash: await bcrypt.hash(password, 12),
name: 'Staff',
role: { connect: { key: 'STAFF' } },
},
});
staffId = staff.id;
staffToken = (await login(app, staff.email, password)).accessToken;
});
afterAll(async () => {
await prisma.domainEvent.deleteMany({
where: {
aggregateType: 'BottlingBatch',
aggregateId: { in: bottlingIds },
},
});
await prisma.bottlingBatch.deleteMany({
where: { id: { in: bottlingIds } },
});
await prisma.batchMovement.deleteMany({
where: { honeyBatchId: { in: honeyBatchIds } },
});
await prisma.honeyMeasurement.deleteMany({
where: { honeyBatchId: { in: honeyBatchIds } },
});
await prisma.honeyBatch.deleteMany({
where: { id: { in: honeyBatchIds } },
});
await prisma.extraction.deleteMany({ where: { id: extractionId } });
await prisma.product.deleteMany({ where: { id: { in: productIds } } });
await prisma.idempotencyRecord.deleteMany({ where: { userId } });
const userIds = [userId, staffId];
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 app.close();
});
it('17 % -> 201 without a quality warning', async () => {
const honeyBatchId = await newBatch(17);
const response = await create({
honeyBatchId,
jarSize: 500,
bestBeforeDate: '2027-01-31',
}).expect(201);
expect(remember(response).warnings).toEqual([]);
});
it('19 % -> 201 with WATER_CONTENT_HIGH', async () => {
const honeyBatchId = await newBatch(19);
const response = await create({
honeyBatchId,
jarSize: 500,
bestBeforeDate: '2027-01-31',
}).expect(201);
const warnings = remember(response).warnings;
expect(warnings.map((w) => w.code)).toEqual(['WATER_CONTENT_HIGH']);
expect(warnings[0].severity).toBe('WARNUNG');
});
it('no water content at all -> 201 with the hint WATER_CONTENT_MISSING', async () => {
const honeyBatchId = await newBatch(null);
const response = await create({
honeyBatchId,
jarSize: 500,
bestBeforeDate: '2027-01-31',
}).expect(201);
expect(remember(response).warnings.map((w) => w.code)).toEqual([
'WATER_CONTENT_MISSING',
]);
});
it('20.5 % -> 409 CONFIRMATION_REQUIRED; confirmed retry with the same key -> 201', async () => {
const honeyBatchId = await newBatch(20.5);
const key = randomUUID();
const body = { honeyBatchId, jarSize: 500, bestBeforeDate: '2027-01-31' };
const countsBefore = await writtenCounts(honeyBatchId);
const refused = await create(body, key).expect(409);
const refusal = refused.body as ConfirmationBody;
expect(refusal.code).toBe('CONFIRMATION_REQUIRED');
expect(refusal.warnings.map((w) => [w.code, w.severity])).toEqual([
['WATER_CONTENT_ABOVE_LEGAL', 'BESTAETIGEN'],
]);
expect(refusal.warnings[0].refs).toEqual([
{ type: 'HoneyBatch', id: honeyBatchId },
]);
expect(refusal.waterContent).toBe(20.5);
// Nothing was written: no bottling, the rest is untouched.
expect(await prisma.bottlingBatch.count({ where: { honeyBatchId } })).toBe(
0,
);
expect(
(
await prisma.honeyBatch.findUniqueOrThrow({
where: { id: honeyBatchId },
})
).amount,
).toBe(25);
expect(await writtenCounts(honeyBatchId)).toEqual(countsBefore);
const confirmed = await create(
{
...body,
acknowledgedWarnings: ['WATER_CONTENT_ABOVE_LEGAL'],
acknowledgedWaterContent: 20.5,
},
key,
).expect(201);
expect(remember(confirmed).warnings.map((w) => w.code)).toEqual([
'WATER_CONTENT_ABOVE_LEGAL',
]);
expect(await prisma.bottlingBatch.count({ where: { honeyBatchId } })).toBe(
1,
);
// A third call with the same key replays the stored 201.
const replay = await create(
{
...body,
acknowledgedWarnings: ['WATER_CONTENT_ABOVE_LEGAL'],
acknowledgedWaterContent: 20.5,
},
key,
).expect(201);
expect((replay.body as BottlingBody).id).toBe(
(confirmed.body as BottlingBody).id,
);
expect(await prisma.bottlingBatch.count({ where: { honeyBatchId } })).toBe(
1,
);
});
it('a confirmation is bound to the value: another value gets a fresh 409', async () => {
const honeyBatchId = await newBatch(21);
const body = {
honeyBatchId,
jarSize: 500,
bestBeforeDate: '2027-01-31',
acknowledgedWarnings: ['WATER_CONTENT_ABOVE_LEGAL'],
};
// Confirmed for 20.5 %, but the batch holds 21 %.
const stale = await create({
...body,
acknowledgedWaterContent: 20.5,
}).expect(409);
expect((stale.body as ConfirmationBody).waterContent).toBe(21);
// The code alone is no confirmation either.
await create(body).expect(409);
await create({ ...body, acknowledgedWaterContent: 101 }).expect(400);
expect(await prisma.bottlingBatch.count({ where: { honeyBatchId } })).toBe(
0,
);
remember(
await create({ ...body, acknowledgedWaterContent: 21 }).expect(201),
);
});
it('exactly 20.0 % only warns: 201 with WATER_CONTENT_HIGH', async () => {
const honeyBatchId = await newBatch(20);
const response = await create({
honeyBatchId,
jarSize: 500,
bestBeforeDate: '2027-01-31',
}).expect(201);
expect(remember(response).warnings.map((w) => w.code)).toEqual([
'WATER_CONTENT_HIGH',
]);
});
it('an impossible day is a 400, never a 500', async () => {
const honeyBatchId = await newBatch(17);
await create({
honeyBatchId,
jarSize: 500,
date: '2026-02-30',
bestBeforeDate: '2027-01-31',
}).expect(400);
await create({
honeyBatchId,
jarSize: 500,
bestBeforeDate: '2027-02-30',
}).expect(400);
await auth(api().get('/api/v1/bottling-batches/prefill'))
.query({ honeyBatchId, date: '2026-02-30' })
.expect(400);
});
it('a newer low measurement lifts the stop; a newer high one sets it', async () => {
const honeyBatchId = await newBatch(21);
const userRow = { honeyBatchId, userId };
await prisma.honeyMeasurement.create({
data: {
...userRow,
waterContent: 16.8,
measuredAt: new Date('2026-08-01'),
},
});
const body = { honeyBatchId, jarSize: 500, bestBeforeDate: '2027-01-31' };
remember(await create(body).expect(201));
await prisma.honeyMeasurement.create({
data: {
...userRow,
waterContent: 22,
measuredAt: new Date('2026-09-01'),
},
});
const refused = await create(body).expect(409);
expect((refused.body as ConfirmationBody).code).toBe(
'CONFIRMATION_REQUIRED',
);
});
it('rejects an unknown acknowledged code and more than one', async () => {
const honeyBatchId = await newBatch(17);
const body = { honeyBatchId, jarSize: 500, bestBeforeDate: '2027-01-31' };
await create({ ...body, acknowledgedWarnings: ['SOMETHING'] }).expect(400);
await create({
...body,
acknowledgedWarnings: [
'WATER_CONTENT_ABOVE_LEGAL',
'WATER_CONTENT_ABOVE_LEGAL',
],
}).expect(400);
});
it('a correction reports quality findings only when it changes the jar size or honey batch', async () => {
const honeyBatchId = await newBatch(17);
const created = remember(
await create({
honeyBatchId,
jarSize: 500,
bestBeforeDate: '2027-01-31',
}).expect(201),
);
await prisma.honeyBatch.update({
where: { id: honeyBatchId },
data: { waterContent: 21 },
});
const lotOnly = await auth(
api().patch(`/api/v1/bottling-batches/${created.id}`),
)
.send({ lotNumber: `BQ-L-${stamp}-${randomUUID()}` })
.expect(200);
expect((lotOnly.body as BottlingBody).warnings).toEqual([]);
const patched = await auth(
api().patch(`/api/v1/bottling-batches/${created.id}`),
)
.send({ jarSize: 250 })
.expect(200);
expect((patched.body as BottlingBody).warnings.map((w) => w.code)).toEqual([
'WATER_CONTENT_ABOVE_LEGAL',
]);
});
it('a honey measurement above the limit answers warnings and is never refused', async () => {
const honeyBatchId = await newBatch(17);
const response = await auth(
api().post(`/api/v1/honey-batches/${honeyBatchId}/measurements`),
)
.send({ waterContent: 21, measuredAt: '2026-10-01T09:00:00.000Z' })
.expect(201);
const warnings = (response.body as { warnings: Warning[] }).warnings;
expect(warnings.map((w) => w.code)).toEqual(['WATER_CONTENT_ABOVE_LEGAL']);
expect(
await prisma.honeyMeasurement.count({ where: { honeyBatchId } }),
).toBe(1);
});
it('best before comes from shelfLifeMonths and a different jar size gives a hint', async () => {
const honeyBatchId = await newBatch(17);
const productId = await newProduct({
packagingSize: 500,
shelfLifeMonths: 1,
});
const fromArticle = remember(
await create({ honeyBatchId, productId }).expect(201),
);
expect(fromArticle.jarSize).toBe(500);
// 2026-01-31 plus one month, cut at the month end.
expect(fromArticle.bestBeforeDate.slice(0, 10)).toBe('2026-02-28');
expect(fromArticle.warnings).toEqual([]);
const otherJar = remember(
await create({ honeyBatchId, productId, jarSize: 250 }).expect(201),
);
expect(otherJar.jarSize).toBe(250);
expect(otherJar.warnings.map((w) => w.code)).toEqual(['JAR_SIZE_DIFFERS']);
const sentDate = remember(
await create({
honeyBatchId,
productId,
bestBeforeDate: '2030-05-05',
}).expect(201),
);
expect(sentDate.bestBeforeDate.slice(0, 10)).toBe('2030-05-05');
});
it('refuses with a clear text when jar size and best before are unknown', async () => {
const honeyBatchId = await newBatch(17);
const noShelfLife = await newProduct({ packagingSize: 500 });
const none = await create({ honeyBatchId }).expect(400);
expect((none.body as { message: string }).message).toBe(
'Glasgröße und MHD fehlen. Bitte einen Artikel wählen oder beides eintragen.',
);
const noDate = await create({
honeyBatchId,
productId: noShelfLife,
}).expect(400);
expect((noDate.body as { message: string }).message).toBe(
'Ohne Haltbarkeit am Artikel bitte ein MHD eintragen.',
);
});
describe('GET /bottling-batches/prefill', () => {
it('prefill returns jar size and best before from the product', async () => {
const honeyBatchId = await newBatch(19);
const productId = await newProduct({
packagingSize: 250,
shelfLifeMonths: 24,
});
const response = await auth(api().get('/api/v1/bottling-batches/prefill'))
.query({ honeyBatchId, productId, date: '2026-05-10' })
.expect(200);
const prefill = response.body as PrefillBody;
expect(prefill).toMatchObject({
jarSize: 250,
jarSizeSource: 'PRODUCT',
bestBeforeDate: '2028-05-10',
shelfLifeMonths: 24,
waterContent: 19,
});
expect(prefill.warnings.map((w) => w.code)).toEqual([
'WATER_CONTENT_HIGH',
]);
// Read only.
expect(
await prisma.bottlingBatch.count({ where: { honeyBatchId } }),
).toBe(0);
});
it('without an article it still reports the water content', async () => {
const honeyBatchId = await newBatch(null);
const response = await auth(api().get('/api/v1/bottling-batches/prefill'))
.query({ honeyBatchId })
.expect(200);
const prefill = response.body as PrefillBody;
expect(prefill.jarSize).toBeNull();
expect(prefill.bestBeforeDate).toBeNull();
expect(prefill.warnings.map((w) => w.code)).toEqual([
'WATER_CONTENT_MISSING',
]);
});
it('needs bottling:create: 403 for STAFF', async () => {
const honeyBatchId = await newBatch(17);
await api()
.get('/api/v1/bottling-batches/prefill')
.set('Authorization', `Bearer ${staffToken}`)
.query({ honeyBatchId })
.expect(403);
});
it('refuses an article that is not bottled honey with 400', async () => {
const honeyBatchId = await newBatch(17);
const product = await prisma.product.create({
data: { name: `Kerze ${stamp}`, kind: 'MANUFACTURED' },
});
productIds.push(product.id);
await auth(api().get('/api/v1/bottling-batches/prefill'))
.query({ honeyBatchId, productId: product.id })
.expect(400);
});
it('answers 404 for unknown ids and 400 without a honey batch', async () => {
await auth(api().get('/api/v1/bottling-batches/prefill'))
.query({ honeyBatchId: 'missing' })
.expect(404);
await auth(api().get('/api/v1/bottling-batches/prefill')).expect(400);
});
});
});