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]>
This commit is contained in:
1 parent
16cc557462
commit
fab7f7e12f
91 files changed
+8463
-104
No files matched your search
@@ -0,0 +1,563 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user