- 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]>
564 lines
17 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|