Files
ApiCore/ApiCore-Backend/test/stock.e2e-spec.ts
T
Nick SzaunigandClaude Opus 5.5 cdd30cf3ee feat: platform backbone and field capture complete (checkpoint)
Platform backbone (plan 2026-10-13-plattform-rueckgrat), all tasks reviewed:
- webhooks with delivery table, retries and stable delivery id
- calendar handlers; a finished task removes its Outlook event
- tasks, automations, inspections, varroa, treatments, feedings,
  harvests, colonies, wintering, stock and bottling record domain events
  in the same transaction; legacy post-commit calls removed
- row locks through one helper (lockRowForUpdate, lockRowsForShare,
  lockRowsForUpdate)
- job framework with JobRun, cli exit codes, cleanup, task templates,
  daily evaluation, hourly app-backup and the rekey job
- system API and page "Verwaltung > System" with retry
- @nestjs/schedule removed, guard tests against in-process schedulers

Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), all tasks reviewed:
- inspection photo offline without metadata
- bulk capture for treatments, feedings and varroa (backend + frontend)
- offline stand mode with service worker, offline pages and packs
- offline session, cold start and logout on shared devices

Docs: ARCHITECTURE.md assumptions and sections updated, TASKS.md.
Gates: backend lint, typecheck, 1770 unit, 420 e2e, build, no schema
drift; frontend lint, typecheck, 913 tests, build. Not rolled out.

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

503 lines
15 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';
interface StockBody {
summary: {
initial: number;
soldInShop: number;
withdrawn: number;
corrected: number;
remaining: number;
};
movements: { id: string; type: string; quantity: number }[];
}
interface ShopResult {
externalId: string;
status: 'created' | 'duplicate' | 'rejected';
error?: string;
allocations: { batchId: string; lotNumber: string; quantity: number }[];
remainingCount: number | null;
}
/**
* Stock ledger end to end: withdrawals and inventory corrections in ApiCore,
* the shop's sales and returns via API key, oldest-batch-first allocation,
* idempotency and the row lock against concurrent bookings.
*/
describe('Stock ledger (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const password = 'SuperSecret123!';
let beekeeperId: string;
let beekeeperToken: string;
let adminToken: string;
let writeKey: string;
let readKey: string;
let bottlingId: string;
let candleId: string;
let oldBatchId: string;
let newBatchId: string;
const server = () => app.getHttpServer();
const asBeekeeper = (req: request.Test) =>
req.set('Authorization', `Bearer ${beekeeperToken}`);
/** The StockChanged events recorded for one movement, shop request or batch. */
function stockChangedOf(aggregateId: string) {
return prisma.domainEvent.findMany({
where: { type: 'StockChanged', aggregateId },
select: { aggregateType: true, payload: true, actorUserId: true },
});
}
async function shopRequestId(externalId: string): Promise<string> {
return (
await prisma.shopStockRequest.findUniqueOrThrow({
where: { externalId },
select: { id: true },
})
).id;
}
function book(token: string, requests: Record<string, unknown>[]) {
return request(server())
.post('/api/v1/shop-sync/movements')
.set('Authorization', `Bearer ${token}`)
.send({ requests });
}
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
const passwordHash = await bcrypt.hash(password, 12);
const beekeeper = await prisma.user.create({
data: {
email: `stock-beekeeper-${suffix}@example.com`,
passwordHash,
name: 'Beekeeper',
role: { connect: { key: 'BEEKEEPER' } },
},
});
beekeeperId = beekeeper.id;
await prisma.user.create({
data: {
email: `stock-admin-${suffix}@example.com`,
passwordHash,
name: 'Admin',
role: { connect: { key: 'ADMIN' } },
},
});
beekeeperToken = (
await login(app, `stock-beekeeper-${suffix}@example.com`, password)
).accessToken;
adminToken = (
await login(app, `stock-admin-${suffix}@example.com`, password)
).accessToken;
const createKey = async (scopes: string[]) => {
const response = await request(server())
.post('/api/v1/api-keys')
.set('Authorization', `Bearer ${adminToken}`)
.send({ name: `Stock ${scopes.join('+')} ${suffix}`, scopes })
.expect(201);
return (response.body as { token: string }).token;
};
writeKey = await createKey(['shop-sync:write']);
readKey = await createKey(['shop-sync:read']);
// Honey chain: apiary -> harvest -> extraction -> honey batch -> bottling.
const apiary = await prisma.apiary.create({
data: { name: `Stand ${suffix}` },
});
const harvest = await prisma.harvest.create({
data: { apiaryId: apiary.id, date: new Date(), userId: beekeeper.id },
});
const extraction = await prisma.extraction.create({
data: { date: new Date(), userId: beekeeper.id },
});
await prisma.extractionHarvest.create({
data: { extractionId: extraction.id, harvestId: harvest.id },
});
const honeyBatch = await prisma.honeyBatch.create({
data: {
batchNumber: `HC-${suffix}`,
extractionId: extraction.id,
amount: 50,
},
});
const bottling = await asBeekeeper(
request(server()).post('/api/v1/bottling-batches'),
)
.send({
honeyBatchId: honeyBatch.id,
date: '2026-07-01',
jarSize: 500,
jarCount: 10,
bestBeforeDate: '2028-07-01',
lotNumber: `L-${suffix}`,
})
.expect(201);
bottlingId = (bottling.body as { id: string }).id;
const candle = await prisma.product.create({
data: {
name: `Kerze ${suffix}`,
kind: 'MANUFACTURED',
shopCategory: 'KERZEN',
},
});
candleId = candle.id;
const createBatch = async (lot: string, quantity: number, bbd: string) => {
const response = await asBeekeeper(
request(server()).post('/api/v1/production-batches'),
)
.send({
productId: candleId,
lotNumber: `${lot}-${suffix}`,
date: '2026-03-01',
quantity,
bestBeforeDate: bbd,
})
.expect(201);
return (response.body as { id: string }).id;
};
newBatchId = await createBatch('K-NEW', 5, '2027-06-01');
oldBatchId = await createBatch('K-OLD', 3, '2027-01-01');
});
afterAll(async () => {
// Events and audit entries of this file only, by its own ids - other
// e2e files share the database.
// Only ids that exist: an undefined id would drop the filter.
const batchIds = [bottlingId, newBatchId, oldBatchId].filter(Boolean);
const movementIds = (
await prisma.batchMovement.findMany({
where: {
OR: [
{ bottlingBatchId: { in: batchIds } },
{ productionBatchId: { in: batchIds } },
],
},
select: { id: true },
})
).map((movement) => movement.id);
const requestIds = (
await prisma.shopStockRequest.findMany({
where: { externalId: { endsWith: suffix } },
select: { id: true },
})
).map((shopRequest) => shopRequest.id);
const aggregateIds = [...batchIds, ...movementIds, ...requestIds];
await prisma.domainEvent.deleteMany({
where: { aggregateId: { in: aggregateIds } },
});
await prisma.auditLog.deleteMany({
where: { entityId: { in: aggregateIds } },
});
await app.close();
});
it('withdraws from a bottling and refuses more than the remaining stock', async () => {
const response = await asBeekeeper(
request(server()).post(
`/api/v1/bottling-batches/${bottlingId}/withdrawals`,
),
)
.send({ quantity: 2, reason: 'HOFLADEN' })
.expect(201);
expect((response.body as StockBody).summary).toMatchObject({
initial: 10,
withdrawn: 2,
remaining: 8,
});
await asBeekeeper(
request(server()).post(
`/api/v1/bottling-batches/${bottlingId}/withdrawals`,
),
)
.send({ quantity: 20, reason: 'BRUCH' })
.expect(400);
});
it('withdraw books the movement, the audit entry and StockChanged in one transaction', async () => {
// The withdrawal of 2 above - the refused one of 20 left no trace.
const movements = await prisma.batchMovement.findMany({
where: { bottlingBatchId: bottlingId },
select: { id: true, type: true, quantity: true },
});
expect(movements).toEqual([
expect.objectContaining({ type: 'WITHDRAWAL', quantity: -2 }),
]);
const [movement] = movements;
expect(
await prisma.auditLog.count({
where: { entityType: 'BatchMovement', entityId: movement.id },
}),
).toBe(1);
expect(await stockChangedOf(movement.id)).toEqual([
{
aggregateType: 'BatchMovement',
payload: {
bottlingBatchIds: [bottlingId],
productIds: [],
rawMaterialKinds: [],
},
actorUserId: beekeeperId,
},
]);
});
it('a production batch records StockChanged for its article', async () => {
expect(await stockChangedOf(newBatchId)).toEqual([
{
aggregateType: 'ProductionBatch',
payload: {
bottlingBatchIds: [],
productIds: [candleId],
rawMaterialKinds: [],
},
actorUserId: beekeeperId,
},
]);
});
it('books a shop sale once, even when it is sent twice', async () => {
const sale = {
externalId: `sale-honey-${suffix}`,
kind: 'SALE',
bottlingBatchId: bottlingId,
quantity: 3,
};
const first = await book(writeKey, [sale]).expect(200);
const [created] = (first.body as { results: ShopResult[] }).results;
expect(created.status).toBe('created');
expect(created.remainingCount).toBe(5);
const second = await book(writeKey, [sale]).expect(200);
const [duplicate] = (second.body as { results: ShopResult[] }).results;
expect(duplicate.status).toBe('duplicate');
expect(duplicate.remainingCount).toBe(5);
// One event for the booking - the repeat booked nothing.
expect(await stockChangedOf(await shopRequestId(sale.externalId))).toEqual([
{
aggregateType: 'ShopStockRequest',
payload: {
bottlingBatchIds: [bottlingId],
productIds: [],
rawMaterialKinds: [],
},
actorUserId: null,
},
]);
});
it('allocates an article sale to the batch with the earliest best-before date first', async () => {
const response = await book(writeKey, [
{
externalId: `sale-candle-${suffix}`,
kind: 'SALE',
productId: candleId,
quantity: 4,
},
]).expect(200);
const [result] = (response.body as { results: ShopResult[] }).results;
expect(result.status).toBe('created');
expect(
result.allocations.map((a) => [a.batchId, a.quantity]).sort(),
).toEqual(
[
[oldBatchId, 3],
[newBatchId, 1],
].sort(),
);
expect(result.remainingCount).toBe(4);
});
it('returns onto exactly the batches of the sale and refuses to return more', async () => {
const response = await book(writeKey, [
{
externalId: `return-candle-${suffix}`,
kind: 'RETURN',
productId: candleId,
quantity: 2,
reversesExternalId: `sale-candle-${suffix}`,
},
{
externalId: `return-too-much-${suffix}`,
kind: 'RETURN',
productId: candleId,
quantity: 5,
reversesExternalId: `sale-candle-${suffix}`,
},
]).expect(200);
const [returned, tooMuch] = (response.body as { results: ShopResult[] })
.results;
expect(returned.status).toBe('created');
const perBatch = new Map(
returned.allocations.map((a) => [a.batchId, a.quantity]),
);
expect(perBatch.get(newBatchId)).toBe(1);
expect(perBatch.get(oldBatchId)).toBe(1);
expect(returned.remainingCount).toBe(6);
expect(tooMuch.status).toBe('rejected');
});
it('a shop request records one StockChanged per accepted booking', async () => {
const sale = await stockChangedOf(
await shopRequestId(`sale-candle-${suffix}`),
);
const returned = await stockChangedOf(
await shopRequestId(`return-candle-${suffix}`),
);
for (const events of [sale, returned]) {
expect(events).toEqual([
{
aggregateType: 'ShopStockRequest',
payload: {
bottlingBatchIds: [],
productIds: [candleId],
rawMaterialKinds: [],
},
actorUserId: null,
},
]);
}
// The rejected return was rolled back together with its event.
expect(
await prisma.shopStockRequest.count({
where: { externalId: `return-too-much-${suffix}` },
}),
).toBe(0);
});
it('rejects a bad request but still books the rest of the batch', async () => {
const response = await book(writeKey, [
{
externalId: `bad-${suffix}`,
kind: 'SALE',
productId: 'does-not-exist',
quantity: 1,
},
{
externalId: `good-${suffix}`,
kind: 'SALE',
bottlingBatchId: bottlingId,
quantity: 1,
},
]).expect(200);
const [bad, good] = (response.body as { results: ShopResult[] }).results;
expect(bad.status).toBe('rejected');
expect(good.status).toBe('created');
expect(good.remainingCount).toBe(4);
});
it('needs shop-sync:write to book', async () => {
await book(readKey, [
{
externalId: `nope-${suffix}`,
kind: 'SALE',
bottlingBatchId: bottlingId,
quantity: 1,
},
]).expect(403);
});
it('serves the remaining stock to the shop', async () => {
const response = await request(server())
.get('/api/v1/shop-sync/stock')
.set('Authorization', `Bearer ${readKey}`)
.expect(200);
const body = response.body as {
bottlings: { id: string; remainingCount: number }[];
products: { id: string; remainingCount: number }[];
};
expect(
body.bottlings.find((b) => b.id === bottlingId)?.remainingCount,
).toBe(4);
expect(body.products.find((p) => p.id === candleId)?.remainingCount).toBe(
6,
);
});
it('serializes concurrent withdrawals so the stock never goes below zero', async () => {
// newBatch: 5 produced - 1 sold + 1 returned = 5 left; 8 parallel takes of 1.
const attempts = await Promise.all(
Array.from({ length: 8 }, () =>
asBeekeeper(
request(server()).post(
`/api/v1/production-batches/${newBatchId}/withdrawals`,
),
).send({ quantity: 1, reason: 'PROBE' }),
),
);
expect(attempts.filter((r) => r.status === 201)).toHaveLength(5);
expect(attempts.filter((r) => r.status === 400)).toHaveLength(3);
// One StockChanged per booked withdrawal, none for a refused one.
const withdrawals = await prisma.batchMovement.findMany({
where: { productionBatchId: newBatchId, type: 'WITHDRAWAL' },
select: { id: true },
});
expect(withdrawals).toHaveLength(5);
expect(
await prisma.domainEvent.count({
where: {
type: 'StockChanged',
aggregateId: { in: withdrawals.map((movement) => movement.id) },
},
}),
).toBe(5);
const stock = await asBeekeeper(
request(server()).get(`/api/v1/production-batches/${newBatchId}/stock`),
).expect(200);
expect((stock.body as StockBody).summary.remaining).toBe(0);
});
it('books an inventory count as the difference', async () => {
const response = await asBeekeeper(
request(server()).post(
`/api/v1/bottling-batches/${bottlingId}/corrections`,
),
)
.send({ countedQuantity: 6, notes: 'Zwei Gläser im Keller gefunden' })
.expect(201);
const body = response.body as StockBody;
expect(body.summary.corrected).toBe(2);
expect(body.summary.remaining).toBe(6);
});
it('refuses to delete a bottling with movements', async () => {
await request(server())
.delete(`/api/v1/bottling-batches/${bottlingId}`)
.set('Authorization', `Bearer ${adminToken}`)
.expect(409);
});
it('refuses a production batch for a honey article', async () => {
const honey = await prisma.product.create({
data: { name: `Honig ${suffix}` },
});
await asBeekeeper(request(server()).post('/api/v1/production-batches'))
.send({
productId: honey.id,
lotNumber: `X-${suffix}`,
date: '2026-03-01',
quantity: 1,
})
.expect(400);
});
});