Platform backbone (plan 2026-10-13-plattform-rueckgrat), reviewed tasks: - run roles api/worker/job/all with shared startup checks (Task 1) - schema and migration for DomainEvent, deliveries, JobRun, ThrottlerHit (Task 2) - audit, notifications and domain events written in the same transaction (Task 3) - dispatcher with SKIP LOCKED claims, retries and stale release (Task 4) - worker building blocks: tick, LISTEN/NOTIFY wake-up, health (Task 5) - worker process and entry points (Task 6) - throttler counters in Postgres (Task 19) - separate keys per purpose with key ring (Task 20) - public origin page without exact location (Task 22) - shop keys split into read and write (Task 23) - Kustomize base, components, overlay encry, CI validation (Task 26) Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), reviewed tasks: - brood frames, read endpoints, apiary overview and offline pack - network basics, offline queue v3, navigation sheets, form building blocks - new inspection with last-time hints and weather, varroa per day, treatment prefill, apiary page with round - inspection photo (Task 10) is implemented, review pending Not rolled out. Remaining tasks follow in later commits. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
359 lines
11 KiB
TypeScript
359 lines
11 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: { 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 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}`);
|
|
|
|
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' } },
|
|
},
|
|
});
|
|
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 () => {
|
|
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('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);
|
|
});
|
|
|
|
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('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);
|
|
|
|
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);
|
|
});
|
|
});
|