- Phenology reports API with corrections and revokes; recompute per apiary via PhenologyRecomputeRequested, allowBackward for corrections; season changes are announced in the morning digest (changes since the previous digest, each once) - Season autopilot A4-A10 with per-apiary sync (Apiary before Colony locks) and WinterCheckRecorded - Honey batch ledger at runtime: initial amount, BOTTLING bookings, manual movements, one gram rounding, untracked bottlings never book - Telegram delivery: planning, Berlin delivery times incl. DST, quiet hours, permission re-check and stale skip at send time, rate limits, cleanup of old deliveries - ARCHITECTURE.md: assumptions 47-50 and updated sections 11.x Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
466 lines
14 KiB
TypeScript
466 lines
14 KiB
TypeScript
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 { INVARIANT_SQL } from './utils/honey-ledger';
|
|
|
|
interface HoneyBatchBody {
|
|
id: string;
|
|
amount: number | null;
|
|
initialAmount: number | null;
|
|
status: string;
|
|
warnings: { code: string }[];
|
|
}
|
|
|
|
interface BottlingBody {
|
|
id: string;
|
|
}
|
|
|
|
interface MovementBody {
|
|
id: string;
|
|
type: string;
|
|
quantityGrams: number;
|
|
reason: string | null;
|
|
notes: string | null;
|
|
sourceBottlingBatchId: string | null;
|
|
userId: string | null;
|
|
}
|
|
|
|
interface ErrorBody {
|
|
message: string;
|
|
}
|
|
|
|
/**
|
|
* The movement book of a honey batch (spec 4.6, 7.7): every change of the
|
|
* rest is a booking, and amount = initialAmount + sum(quantity) / 1000.
|
|
* Asserts only on this file's own batches - other files share the database.
|
|
*/
|
|
describe('Honey batch movement book (e2e)', () => {
|
|
let app: INestApplication<App>;
|
|
let prisma: PrismaService;
|
|
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
|
const password = 'SuperSecret123!';
|
|
const adminEmail = `honey-ledger-admin-${suffix}@example.com`;
|
|
const staffEmail = `honey-ledger-staff-${suffix}@example.com`;
|
|
let adminId: string;
|
|
let staffId: string;
|
|
let adminToken: string;
|
|
let staffToken: string;
|
|
let extractionId: string;
|
|
const honeyBatchIds: string[] = [];
|
|
const bottlingIds: string[] = [];
|
|
|
|
const server = () => app.getHttpServer();
|
|
const admin = () => ({ Authorization: `Bearer ${adminToken}` });
|
|
|
|
async function createBatch(amount?: number): Promise<HoneyBatchBody> {
|
|
const response = await request(server())
|
|
.post('/api/v1/honey-batches')
|
|
.set(admin())
|
|
.send({
|
|
batchNumber: `HL-${suffix}-${honeyBatchIds.length}`,
|
|
extractionId,
|
|
...(amount === undefined ? {} : { amount }),
|
|
})
|
|
.expect(201);
|
|
const body = response.body as HoneyBatchBody;
|
|
honeyBatchIds.push(body.id);
|
|
return body;
|
|
}
|
|
|
|
function bottle(honeyBatchId: string, jarCount: number) {
|
|
return request(server())
|
|
.post('/api/v1/bottling-batches')
|
|
.set(admin())
|
|
.send({
|
|
honeyBatchId,
|
|
date: '2026-08-01T00:00:00.000Z',
|
|
bestBeforeDate: '2028-08-01T00:00:00.000Z',
|
|
jarSize: 500,
|
|
jarCount,
|
|
lotNumber: `HL-L-${suffix}-${Math.round(Math.random() * 1e9)}`,
|
|
});
|
|
}
|
|
|
|
async function bottleOk(honeyBatchId: string, jarCount: number) {
|
|
const response = await bottle(honeyBatchId, jarCount).expect(201);
|
|
const body = response.body as BottlingBody;
|
|
bottlingIds.push(body.id);
|
|
return body;
|
|
}
|
|
|
|
async function stored(id: string) {
|
|
return prisma.honeyBatch.findUniqueOrThrow({ where: { id } });
|
|
}
|
|
|
|
async function brokenBatchIds(): Promise<string[]> {
|
|
const rows = await prisma.$queryRawUnsafe<{ id: string }[]>(INVARIANT_SQL);
|
|
return rows.map((row) => row.id);
|
|
}
|
|
|
|
async function movementsOf(id: string): Promise<MovementBody[]> {
|
|
const response = await request(server())
|
|
.get(`/api/v1/honey-batches/${id}/movements`)
|
|
.set(admin())
|
|
.expect(200);
|
|
return (response.body as { items: MovementBody[]; hasMore: boolean }).items;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
app = await createTestApp();
|
|
prisma = app.get(PrismaService);
|
|
const passwordHash = await bcrypt.hash(password, 12);
|
|
adminId = (
|
|
await prisma.user.create({
|
|
data: {
|
|
email: adminEmail,
|
|
passwordHash,
|
|
name: 'Ledger Admin',
|
|
role: { connect: { key: 'ADMIN' } },
|
|
},
|
|
})
|
|
).id;
|
|
staffId = (
|
|
await prisma.user.create({
|
|
data: {
|
|
email: staffEmail,
|
|
passwordHash,
|
|
name: 'Ledger Staff',
|
|
role: { connect: { key: 'STAFF' } },
|
|
},
|
|
})
|
|
).id;
|
|
adminToken = (await login(app, adminEmail, password)).accessToken;
|
|
staffToken = (await login(app, staffEmail, password)).accessToken;
|
|
extractionId = (
|
|
await prisma.extraction.create({
|
|
data: { date: new Date(), userId: adminId },
|
|
})
|
|
).id;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await prisma.batchMovement.deleteMany({
|
|
where: { honeyBatchId: { in: honeyBatchIds } },
|
|
});
|
|
await prisma.domainEvent.deleteMany({
|
|
where: {
|
|
aggregateId: { in: [...honeyBatchIds, ...bottlingIds] },
|
|
},
|
|
});
|
|
await prisma.bottlingBatch.deleteMany({
|
|
where: { id: { in: bottlingIds } },
|
|
});
|
|
await prisma.honeyBatch.deleteMany({
|
|
where: { id: { in: honeyBatchIds } },
|
|
});
|
|
await prisma.extraction.deleteMany({ where: { id: extractionId } });
|
|
const userIds = [adminId, staffId].filter(Boolean);
|
|
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('keeps the invariant after create, bottle, correct and remove', async () => {
|
|
const batch = await createBatch(10);
|
|
expect(batch.amount).toBe(10);
|
|
const row = await stored(batch.id);
|
|
expect(row.initialAmount).toBe(10);
|
|
// Creating with an amount books nothing.
|
|
expect(await movementsOf(batch.id)).toEqual([]);
|
|
|
|
const bottling = await bottleOk(batch.id, 8);
|
|
expect((await stored(batch.id)).amount).toBe(6);
|
|
|
|
await request(server())
|
|
.patch(`/api/v1/honey-batches/${batch.id}`)
|
|
.set(admin())
|
|
.send({ amount: 5 })
|
|
.expect(200);
|
|
expect((await stored(batch.id)).amount).toBe(5);
|
|
|
|
await request(server())
|
|
.patch(`/api/v1/bottling-batches/${bottling.id}`)
|
|
.set(admin())
|
|
.send({ jarCount: 4 })
|
|
.expect(200);
|
|
expect((await stored(batch.id)).amount).toBe(7);
|
|
|
|
await request(server())
|
|
.delete(`/api/v1/bottling-batches/${bottling.id}`)
|
|
.set(admin())
|
|
.expect(200);
|
|
const after = await stored(batch.id);
|
|
expect(after.amount).toBe(9);
|
|
expect(after.initialAmount).toBe(10);
|
|
expect(await brokenBatchIds()).not.toContain(batch.id);
|
|
|
|
const book = await movementsOf(batch.id);
|
|
expect(
|
|
book.map((movement) => [movement.type, movement.quantityGrams]).reverse(),
|
|
).toEqual([
|
|
['BOTTLING', -4000],
|
|
['CORRECTION', -1000],
|
|
['BOTTLING', 2000],
|
|
['BOTTLING', 2000],
|
|
]);
|
|
expect(book[0]).toMatchObject({ notes: 'Abfüllung entfernt' });
|
|
});
|
|
|
|
it('two parallel bottlings over the rest: exactly one 400', async () => {
|
|
const batch = await createBatch(10);
|
|
|
|
// 12 jars of 500 g = 6 kg each, 10 kg available.
|
|
const results = await Promise.all([
|
|
bottle(batch.id, 12),
|
|
bottle(batch.id, 12),
|
|
]);
|
|
|
|
const statuses = results.map((result) => result.status).sort();
|
|
expect(statuses).toEqual([201, 400]);
|
|
for (const result of results) {
|
|
if (result.status === 201) {
|
|
bottlingIds.push((result.body as BottlingBody).id);
|
|
}
|
|
}
|
|
expect((await stored(batch.id)).amount).toBe(4);
|
|
expect(await movementsOf(batch.id)).toHaveLength(1);
|
|
expect(await brokenBatchIds()).not.toContain(batch.id);
|
|
});
|
|
|
|
it('PATCH amount null with movements answers 400', async () => {
|
|
const batch = await createBatch(10);
|
|
await bottleOk(batch.id, 2);
|
|
|
|
const response = await request(server())
|
|
.patch(`/api/v1/honey-batches/${batch.id}`)
|
|
.set(admin())
|
|
.send({ amount: null })
|
|
.expect(400);
|
|
|
|
expect((response.body as ErrorBody).message).toBe(
|
|
'Die Menge wird über das Bewegungsbuch geführt. Bitte eine Korrektur buchen.',
|
|
);
|
|
expect((await stored(batch.id)).amount).toBe(9);
|
|
});
|
|
|
|
it('PATCH amount null without movements takes the amount away again', async () => {
|
|
const batch = await createBatch(10);
|
|
|
|
await request(server())
|
|
.patch(`/api/v1/honey-batches/${batch.id}`)
|
|
.set(admin())
|
|
.send({ amount: null })
|
|
.expect(200);
|
|
|
|
const row = await stored(batch.id);
|
|
expect(row.amount).toBeNull();
|
|
expect(row.initialAmount).toBeNull();
|
|
});
|
|
|
|
it('a batch without an amount gets one by PATCH without a booking', async () => {
|
|
const batch = await createBatch();
|
|
expect(batch.amount).toBeNull();
|
|
|
|
await request(server())
|
|
.patch(`/api/v1/honey-batches/${batch.id}`)
|
|
.set(admin())
|
|
.send({ amount: 7 })
|
|
.expect(200);
|
|
|
|
const row = await stored(batch.id);
|
|
expect(row.amount).toBe(7);
|
|
expect(row.initialAmount).toBe(7);
|
|
expect(await movementsOf(batch.id)).toEqual([]);
|
|
});
|
|
|
|
it('a batch without an amount is bottled without a booking', async () => {
|
|
const batch = await createBatch();
|
|
|
|
await bottleOk(batch.id, 2);
|
|
|
|
const row = await stored(batch.id);
|
|
expect(row.amount).toBeNull();
|
|
expect(row.status).toBe('BOTTLED');
|
|
expect(await movementsOf(batch.id)).toEqual([]);
|
|
});
|
|
|
|
it('a bottling made before the amount was recorded never changes the ledger', async () => {
|
|
const batch = await createBatch();
|
|
const untracked = await bottleOk(batch.id, 4);
|
|
await request(server())
|
|
.patch(`/api/v1/honey-batches/${batch.id}`)
|
|
.set(admin())
|
|
.send({ amount: 10 })
|
|
.expect(200);
|
|
|
|
await request(server())
|
|
.patch(`/api/v1/bottling-batches/${untracked.id}`)
|
|
.set(admin())
|
|
.send({ jarCount: 2 })
|
|
.expect(200);
|
|
expect((await stored(batch.id)).amount).toBe(10);
|
|
|
|
await request(server())
|
|
.delete(`/api/v1/bottling-batches/${untracked.id}`)
|
|
.set(admin())
|
|
.expect(200);
|
|
expect((await stored(batch.id)).amount).toBe(10);
|
|
expect(await movementsOf(batch.id)).toEqual([]);
|
|
expect(await brokenBatchIds()).not.toContain(batch.id);
|
|
});
|
|
|
|
it('rounds a PATCH amount to the gram and a correction may carry a reason', async () => {
|
|
const batch = await createBatch(10);
|
|
|
|
await request(server())
|
|
.patch(`/api/v1/honey-batches/${batch.id}`)
|
|
.set(admin())
|
|
.send({ amount: 12.3456 })
|
|
.expect(200);
|
|
expect((await stored(batch.id)).amount).toBe(12.346);
|
|
|
|
const response = await request(server())
|
|
.post(`/api/v1/honey-batches/${batch.id}/movements`)
|
|
.set(admin())
|
|
.send({ type: 'CORRECTION', deltaKg: -0.346, reason: 'SONSTIGES' })
|
|
.expect(201);
|
|
expect(response.body).toMatchObject({ reason: 'SONSTIGES' });
|
|
expect((await stored(batch.id)).amount).toBe(12);
|
|
expect(await brokenBatchIds()).not.toContain(batch.id);
|
|
});
|
|
|
|
it('GET movements lists BOTTLING with its source bottling', async () => {
|
|
const batch = await createBatch(10);
|
|
const bottling = await bottleOk(batch.id, 2);
|
|
|
|
const book = await movementsOf(batch.id);
|
|
|
|
expect(book).toEqual([
|
|
expect.objectContaining({
|
|
type: 'BOTTLING',
|
|
quantityGrams: -1000,
|
|
sourceBottlingBatchId: bottling.id,
|
|
userId: adminId,
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('GET movements needs the read permission and a known batch', async () => {
|
|
await request(server())
|
|
.get('/api/v1/honey-batches/unknown/movements')
|
|
.set(admin())
|
|
.expect(404);
|
|
await request(server())
|
|
.get('/api/v1/honey-batches/unknown/movements')
|
|
.expect(401);
|
|
});
|
|
|
|
describe('POST movements', () => {
|
|
it('books a withdrawal with its reason and a correction', async () => {
|
|
const batch = await createBatch(10);
|
|
|
|
const withdrawal = await request(server())
|
|
.post(`/api/v1/honey-batches/${batch.id}/movements`)
|
|
.set(admin())
|
|
.send({
|
|
type: 'WITHDRAWAL',
|
|
deltaKg: -1.5,
|
|
reason: 'EIGENBEDARF',
|
|
notes: 'Für die Familie',
|
|
})
|
|
.expect(201);
|
|
expect(withdrawal.body).toMatchObject({
|
|
type: 'WITHDRAWAL',
|
|
quantityGrams: -1500,
|
|
reason: 'EIGENBEDARF',
|
|
notes: 'Für die Familie',
|
|
userId: adminId,
|
|
});
|
|
expect((await stored(batch.id)).amount).toBe(8.5);
|
|
|
|
await request(server())
|
|
.post(`/api/v1/honey-batches/${batch.id}/movements`)
|
|
.set(admin())
|
|
.send({ type: 'CORRECTION', deltaKg: 0.25, notes: 'Nachgewogen' })
|
|
.expect(201);
|
|
expect((await stored(batch.id)).amount).toBe(8.75);
|
|
|
|
expect(await brokenBatchIds()).not.toContain(batch.id);
|
|
const audit = await prisma.auditLog.findMany({
|
|
where: {
|
|
entityType: 'HoneyBatch',
|
|
entityId: batch.id,
|
|
userId: adminId,
|
|
},
|
|
});
|
|
expect(audit.length).toBeGreaterThanOrEqual(3);
|
|
});
|
|
|
|
it('refuses what would take the rest below 0, with the German message', async () => {
|
|
const batch = await createBatch(1);
|
|
|
|
const response = await request(server())
|
|
.post(`/api/v1/honey-batches/${batch.id}/movements`)
|
|
.set(admin())
|
|
.send({ type: 'WITHDRAWAL', deltaKg: -2, reason: 'BRUCH' })
|
|
.expect(400);
|
|
|
|
expect((response.body as ErrorBody).message).toBe(
|
|
'Die Menge kann nicht unter 0 sinken.',
|
|
);
|
|
expect((await stored(batch.id)).amount).toBe(1);
|
|
expect(await movementsOf(batch.id)).toEqual([]);
|
|
});
|
|
|
|
it('validates the booking', async () => {
|
|
const batch = await createBatch(5);
|
|
const post = (body: object) =>
|
|
request(server())
|
|
.post(`/api/v1/honey-batches/${batch.id}/movements`)
|
|
.set(admin())
|
|
.send(body);
|
|
|
|
await post({ type: 'WITHDRAWAL', deltaKg: -1 }).expect(400);
|
|
await post({ type: 'WITHDRAWAL', deltaKg: 1, reason: 'BRUCH' }).expect(
|
|
400,
|
|
);
|
|
await post({ type: 'CORRECTION', deltaKg: 0 }).expect(400);
|
|
await post({ type: 'CORRECTION', deltaKg: 100001 }).expect(400);
|
|
await post({ type: 'BOTTLING', deltaKg: -1 }).expect(400);
|
|
await post({
|
|
type: 'CORRECTION',
|
|
deltaKg: 1,
|
|
notes: 'x'.repeat(501),
|
|
}).expect(400);
|
|
expect(await movementsOf(batch.id)).toEqual([]);
|
|
});
|
|
|
|
it('refuses a batch whose amount is not recorded', async () => {
|
|
const batch = await createBatch();
|
|
|
|
await request(server())
|
|
.post(`/api/v1/honey-batches/${batch.id}/movements`)
|
|
.set(admin())
|
|
.send({ type: 'CORRECTION', deltaKg: 1 })
|
|
.expect(400);
|
|
});
|
|
|
|
it('needs the update permission', async () => {
|
|
const batch = await createBatch(5);
|
|
|
|
await request(server())
|
|
.post(`/api/v1/honey-batches/${batch.id}/movements`)
|
|
.set({ Authorization: `Bearer ${staffToken}` })
|
|
.send({ type: 'CORRECTION', deltaKg: 1 })
|
|
.expect(403);
|
|
});
|
|
});
|
|
});
|