feat: T4b/T4c wave 9 - phenology reports, autopilot A4-A10, honey ledger, Telegram delivery
- 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]>
This commit is contained in:
1 parent
1971b2d9c4
commit
16cc557462
74 files changed
+9634
-340
No files matched your search
@@ -0,0 +1,465 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user