Datenquelle für den nächtlichen Inventar-Abgleich in ERPNext (ERPNext- Ausbau T8, Entscheidungen E2/E3). Neues Recht erp-inventory:read, auch als API-Schlüssel-Scope; es öffnet nur diese vier GET-Endpunkte: - /api/v1/export/inventar/produkte: Artikel (ohne Beschreibung) - .../chargen: Abfüllungen und Herstellungschargen mit Losnummer, MHD, Ausgangsmenge, Rest aus dem Bewegungsbuch, Füllmenge g und Sorte - .../bewegungen?seit=&nach=&limit=: Shop-Verkauf, Retoure, Entnahmen mit Grund (HOFLADEN = Vertrauensschrank, EIGENBEDARF, BRUCH, PROBE, SONSTIGES) und Inventur; Cursor über (createdAt, id), Bewegungen erst nach 5 Minuten Wartezeit, damit der Cursor keine offene Buchung überholt - .../ernten?jahr=: Nettogewicht kg je Kalenderjahr, wie die Statistik Alle Listen stabil sortiert, seitenweise (1-500), Parameter mit class-validator geprüft, Cursor je Endpunkt streng validiert. Feld- Allowlists: keine Notizen, Benutzer, Standorte, Rohstoffe. Die Gründe gab es im Datenmodell schon - keine Migration; das Recht legt der Katalog- Sync beim Start an und gibt es der Rolle Administrator. Vertrag mit Feldern, Einheiten, Fehlercodes und Beispielen: docs/erp-inventar-export.md. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
665 lines
22 KiB
TypeScript
665 lines
22 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 { encodeCursor } from '../src/erp-export/erp-cursor';
|
|
import { API_KEY_FAILURE_LIMIT } from '../src/api-keys/api-keys.service';
|
|
import { createTestApp } from './utils/create-test-app';
|
|
import { login } from './utils/api';
|
|
|
|
interface CreatedKeyBody {
|
|
apiKey: { id: string };
|
|
token: string;
|
|
}
|
|
|
|
interface PageBody<T> {
|
|
contractVersion: number;
|
|
generatedAt: string;
|
|
items: T[];
|
|
nextCursor: string | null;
|
|
hasMore: boolean;
|
|
settledUntil?: string;
|
|
}
|
|
|
|
type Item = Record<string, unknown> & { id: string };
|
|
|
|
interface HarvestYearsBody {
|
|
contractVersion: number;
|
|
years: {
|
|
year: number;
|
|
netWeightKg: number;
|
|
harvestCount: number;
|
|
harvestsWithoutNetWeight: number;
|
|
}[];
|
|
}
|
|
|
|
const BASE = '/api/v1/export/inventar';
|
|
const ENDPOINTS = ['produkte', 'chargen', 'bewegungen', 'ernten'] as const;
|
|
|
|
/**
|
|
* Inventar-Spiegel für ERPNext (T8): nur lesend, nur mit erp-inventory:read,
|
|
* nur freigegebene Felder, stabil und seitenweise. Der ERPNext-Importer wird
|
|
* nach docs/erp-inventar-export.md gebaut und verlässt sich auf diese Form.
|
|
*/
|
|
describe('ERP-Inventar-Export (e2e)', () => {
|
|
let app: INestApplication<App>;
|
|
let prisma: PrismaService;
|
|
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
|
const password = 'SuperSecret123!';
|
|
let adminToken: string;
|
|
let readonlyToken: string;
|
|
let erpKey: string;
|
|
let shopKey: string;
|
|
let honeyProductId: string;
|
|
let candleProductId: string;
|
|
let bottlingId: string;
|
|
let productionBatchId: string;
|
|
// Laufeigener Zeitraum weit in der Vergangenheit: so finden sich die
|
|
// Bewegungen dieses Laufs zwischen denen anderer Läufe und Tests.
|
|
const movementBase = Date.UTC(2001, 0, 1) + (Date.now() % 1_000_000_000);
|
|
const at = (seconds: number) => new Date(movementBase + seconds * 1000);
|
|
const movementIds: Record<string, string> = {};
|
|
|
|
const server = () => app.getHttpServer();
|
|
|
|
// Jeder Test tritt als eigener Client auf (X-Forwarded-For, im Test wird
|
|
// ein Proxy-Hop vertraut): diese Datei stellt mehr Anfragen, als der
|
|
// globale Throttler einer einzelnen Adresse je Minute erlaubt.
|
|
let clientCounter = 0;
|
|
let clientIp = '198.51.100.1';
|
|
beforeEach(() => {
|
|
clientCounter += 1;
|
|
clientIp = `198.51.100.${clientCounter}`;
|
|
});
|
|
|
|
function get(path: string, token: string, ip = clientIp) {
|
|
return request(server())
|
|
.get(`${BASE}/${path}`)
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.set('X-Forwarded-For', ip);
|
|
}
|
|
|
|
async function createKey(scopes: string[]): Promise<CreatedKeyBody> {
|
|
const response = await request(server())
|
|
.post('/api/v1/api-keys')
|
|
.set('Authorization', `Bearer ${adminToken}`)
|
|
.send({ name: `ERP-Test ${suffix}`, scopes })
|
|
.expect(201);
|
|
return response.body as CreatedKeyBody;
|
|
}
|
|
|
|
async function allItems(path: string): Promise<Item[]> {
|
|
const items: Item[] = [];
|
|
let nach: string | null = null;
|
|
for (let page = 0; page < 20; page++) {
|
|
const query = nach ? `?limit=500&nach=${nach}` : '?limit=500';
|
|
const body = (await get(`${path}${query}`, erpKey).expect(200))
|
|
.body as PageBody<Item>;
|
|
items.push(...body.items);
|
|
if (!body.hasMore) return items;
|
|
nach = body.nextCursor;
|
|
}
|
|
throw new Error('zu viele Seiten');
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
app = await createTestApp();
|
|
prisma = app.get(PrismaService);
|
|
const passwordHash = await bcrypt.hash(password, 12);
|
|
const owner = await prisma.user.create({
|
|
data: {
|
|
email: `erp-admin-${suffix}@example.com`,
|
|
passwordHash,
|
|
name: 'Erika Imkerin',
|
|
role: { connect: { key: 'ADMIN' } },
|
|
},
|
|
});
|
|
await prisma.user.create({
|
|
data: {
|
|
email: `erp-ro-${suffix}@example.com`,
|
|
passwordHash,
|
|
name: 'Nur Lesen',
|
|
role: { connect: { key: 'READONLY' } },
|
|
},
|
|
});
|
|
adminToken = (await login(app, `erp-admin-${suffix}@example.com`, password))
|
|
.accessToken;
|
|
readonlyToken = (await login(app, `erp-ro-${suffix}@example.com`, password))
|
|
.accessToken;
|
|
erpKey = (await createKey(['erp-inventory:read'])).token;
|
|
shopKey = (await createKey(['shop-sync:read', 'shop-sync:write'])).token;
|
|
|
|
const apiary = await prisma.apiary.create({
|
|
data: { name: `Waldrand ${suffix}`, notes: 'Schlüssel unter dem Stein' },
|
|
});
|
|
const harvest = await prisma.harvest.create({
|
|
data: {
|
|
apiaryId: apiary.id,
|
|
userId: owner.id,
|
|
date: new Date('2026-07-14T00:00:00.000Z'),
|
|
honeyType: 'Sommertracht',
|
|
netWeight: 41,
|
|
notes: 'Volk 7 schwach',
|
|
},
|
|
});
|
|
const extraction = await prisma.extraction.create({
|
|
data: {
|
|
date: new Date('2026-07-20T00:00:00.000Z'),
|
|
userId: owner.id,
|
|
harvests: { create: [{ harvestId: harvest.id }] },
|
|
},
|
|
});
|
|
const honeyBatch = await prisma.honeyBatch.create({
|
|
data: {
|
|
batchNumber: `HB-${suffix}`,
|
|
extractionId: extraction.id,
|
|
honeyType: 'Sommertracht',
|
|
amount: 50,
|
|
},
|
|
});
|
|
honeyProductId = (
|
|
await prisma.product.create({
|
|
data: {
|
|
name: `Sommertracht 500 g ${suffix}`,
|
|
sku: `ERP-${suffix}`,
|
|
description: 'Interne Notiz: Lieferant Gläser Huber',
|
|
packagingSize: 500,
|
|
weight: 500,
|
|
salesUnit: 'Glas',
|
|
},
|
|
})
|
|
).id;
|
|
candleProductId = (
|
|
await prisma.product.create({
|
|
data: {
|
|
name: `Bienenwachskerze ${suffix}`,
|
|
kind: 'MANUFACTURED',
|
|
shopCategory: 'KERZEN',
|
|
},
|
|
})
|
|
).id;
|
|
bottlingId = (
|
|
await prisma.bottlingBatch.create({
|
|
data: {
|
|
honeyBatchId: honeyBatch.id,
|
|
productId: honeyProductId,
|
|
userId: owner.id,
|
|
date: new Date('2026-08-01T00:00:00.000Z'),
|
|
bestBeforeDate: new Date('2028-08-01T00:00:00.000Z'),
|
|
jarSize: 500,
|
|
jarCount: 60,
|
|
totalAmount: 30,
|
|
lotNumber: `L-ERP-${suffix}`,
|
|
},
|
|
})
|
|
).id;
|
|
productionBatchId = (
|
|
await prisma.productionBatch.create({
|
|
data: {
|
|
productId: candleProductId,
|
|
userId: owner.id,
|
|
lotNumber: `K-ERP-${suffix}`,
|
|
date: new Date('2026-09-01T00:00:00.000Z'),
|
|
quantity: 20,
|
|
notes: 'Docht von Firma Meier',
|
|
},
|
|
})
|
|
).id;
|
|
const rawLot = await prisma.rawMaterialLot.create({
|
|
data: {
|
|
kind: 'BEESWAX',
|
|
lotNumber: `W-ERP-${suffix}`,
|
|
date: new Date('2026-07-20T00:00:00.000Z'),
|
|
quantity: 1000,
|
|
},
|
|
});
|
|
|
|
const book = async (
|
|
key: string,
|
|
data: Parameters<PrismaService['batchMovement']['create']>[0]['data'],
|
|
) => {
|
|
movementIds[key] = (await prisma.batchMovement.create({ data })).id;
|
|
};
|
|
const personal = {
|
|
notes: 'an Nachbar Müller verschenkt',
|
|
userId: owner.id,
|
|
};
|
|
await book('hofladen', {
|
|
bottlingBatchId: bottlingId,
|
|
type: 'WITHDRAWAL',
|
|
reason: 'HOFLADEN',
|
|
quantity: -6,
|
|
createdAt: at(1),
|
|
...personal,
|
|
});
|
|
await book('eigenbedarf', {
|
|
bottlingBatchId: bottlingId,
|
|
type: 'WITHDRAWAL',
|
|
reason: 'EIGENBEDARF',
|
|
quantity: -2,
|
|
createdAt: at(2),
|
|
...personal,
|
|
});
|
|
await book('verkauf', {
|
|
bottlingBatchId: bottlingId,
|
|
type: 'SHOP_SALE',
|
|
quantity: -3,
|
|
createdAt: at(2),
|
|
});
|
|
await book('bruch', {
|
|
productionBatchId,
|
|
type: 'WITHDRAWAL',
|
|
reason: 'BRUCH',
|
|
quantity: -1,
|
|
createdAt: at(3),
|
|
...personal,
|
|
});
|
|
await book('korrektur', {
|
|
bottlingBatchId: bottlingId,
|
|
type: 'CORRECTION',
|
|
quantity: 1,
|
|
createdAt: at(4),
|
|
...personal,
|
|
});
|
|
await book('rohstoff', {
|
|
rawMaterialLotId: rawLot.id,
|
|
type: 'WITHDRAWAL',
|
|
reason: 'SONSTIGES',
|
|
quantity: -10,
|
|
createdAt: at(5),
|
|
});
|
|
// Gerade eben gebucht: noch innerhalb der Wartezeit.
|
|
await book('frisch', {
|
|
bottlingBatchId: bottlingId,
|
|
type: 'WITHDRAWAL',
|
|
reason: 'PROBE',
|
|
quantity: -1,
|
|
});
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app.close();
|
|
});
|
|
|
|
describe('Zugang', () => {
|
|
it.each(ENDPOINTS)('%s ist mit dem ERP-Schlüssel lesbar', async (path) => {
|
|
await get(path, erpKey).expect(200);
|
|
});
|
|
|
|
it.each(ENDPOINTS)('%s verlangt einen Schlüssel (401)', async (path) => {
|
|
await request(server()).get(`${BASE}/${path}`).expect(401);
|
|
});
|
|
|
|
it('lehnt fehlerhafte und unbekannte Schlüssel ab (401)', async () => {
|
|
await get('produkte', 'ack_nope').expect(401);
|
|
await get('produkte', `ack_abcd1234_${'Z'.repeat(43)}`).expect(401);
|
|
});
|
|
|
|
it.each(ENDPOINTS)(
|
|
'%s bleibt dem Shop-Schlüssel verschlossen (403)',
|
|
async (path) => {
|
|
await get(path, shopKey).expect(403);
|
|
},
|
|
);
|
|
|
|
it('verweigert Benutzern ohne das Recht den Export (403)', async () => {
|
|
await get('produkte', readonlyToken).expect(403);
|
|
});
|
|
|
|
it('öffnet dem ERP-Schlüssel nichts außer dem Export', async () => {
|
|
const auth = { Authorization: `Bearer ${erpKey}` };
|
|
for (const path of [
|
|
'/api/v1/shop-sync',
|
|
'/api/v1/shop-sync/stock',
|
|
'/api/v1/products',
|
|
'/api/v1/harvests',
|
|
'/api/v1/bottling-batches',
|
|
'/api/v1/export/colonies.csv',
|
|
'/api/v1/users',
|
|
'/api/v1/auth/me',
|
|
]) {
|
|
await request(server()).get(path).set(auth).expect(403);
|
|
}
|
|
await request(server())
|
|
.post('/api/v1/shop-sync/movements')
|
|
.set(auth)
|
|
.send({ requests: [] })
|
|
.expect(403);
|
|
});
|
|
|
|
it('sperrt einen widerrufenen Schlüssel sofort (401)', async () => {
|
|
const created = await createKey(['erp-inventory:read']);
|
|
await get('produkte', created.token).expect(200);
|
|
await request(server())
|
|
.post(`/api/v1/api-keys/${created.apiKey.id}/revoke`)
|
|
.set('Authorization', `Bearer ${adminToken}`)
|
|
.expect(201);
|
|
await get('produkte', created.token).expect(401);
|
|
});
|
|
});
|
|
|
|
describe('Produkte', () => {
|
|
it('liefert nur die freigegebenen Felder, ohne Beschreibung', async () => {
|
|
const items = await allItems('produkte');
|
|
const honey = items.find((p) => p.id === honeyProductId);
|
|
const candle = items.find((p) => p.id === candleProductId);
|
|
|
|
expect(Object.keys(honey ?? {}).sort()).toEqual(
|
|
[
|
|
'id',
|
|
'kind',
|
|
'name',
|
|
'packagingSize',
|
|
'salesUnit',
|
|
'shopCategory',
|
|
'sku',
|
|
'status',
|
|
'updatedAt',
|
|
'weight',
|
|
].sort(),
|
|
);
|
|
expect(honey).toMatchObject({
|
|
name: `Sommertracht 500 g ${suffix}`,
|
|
sku: `ERP-${suffix}`,
|
|
kind: 'BOTTLED_HONEY',
|
|
shopCategory: 'HONIG',
|
|
status: 'ACTIVE',
|
|
packagingSize: 500,
|
|
weight: 500,
|
|
salesUnit: 'Glas',
|
|
});
|
|
expect(candle).toMatchObject({
|
|
kind: 'MANUFACTURED',
|
|
shopCategory: 'KERZEN',
|
|
});
|
|
expect(JSON.stringify(items)).not.toContain('Lieferant Gläser Huber');
|
|
});
|
|
|
|
it('blättert stabil nach id', async () => {
|
|
const first = (await get('produkte?limit=1', erpKey).expect(200))
|
|
.body as PageBody<Item>;
|
|
expect(first.contractVersion).toBe(1);
|
|
expect(first.items).toHaveLength(1);
|
|
expect(first.hasMore).toBe(true);
|
|
|
|
const second = (
|
|
await get(`produkte?limit=1&nach=${first.nextCursor}`, erpKey).expect(
|
|
200,
|
|
)
|
|
).body as PageBody<Item>;
|
|
expect(second.items).toHaveLength(1);
|
|
expect(second.items[0].id > first.items[0].id).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('Chargen', () => {
|
|
it('liefert Abfüllung und Herstellungscharge mit Rest aus dem Bewegungsbuch', async () => {
|
|
const items = await allItems('chargen');
|
|
const bottling = items.find((b) => b.id === bottlingId);
|
|
const production = items.find((b) => b.id === productionBatchId);
|
|
|
|
expect(bottling).toEqual({
|
|
id: bottlingId,
|
|
batchType: 'BOTTLING',
|
|
lotNumber: `L-ERP-${suffix}`,
|
|
productId: honeyProductId,
|
|
date: '2026-08-01T00:00:00.000Z',
|
|
bestBeforeDate: '2028-08-01T00:00:00.000Z',
|
|
initialQuantity: 60,
|
|
// 60 - 6 Hofladen - 2 Eigenbedarf - 3 Shop + 1 Inventur - 1 Probe
|
|
remainingQuantity: 49,
|
|
fillWeightGrams: 500,
|
|
honeyType: 'Sommertracht',
|
|
});
|
|
expect(production).toEqual({
|
|
id: productionBatchId,
|
|
batchType: 'PRODUCTION',
|
|
lotNumber: `K-ERP-${suffix}`,
|
|
productId: candleProductId,
|
|
date: '2026-09-01T00:00:00.000Z',
|
|
bestBeforeDate: null,
|
|
initialQuantity: 20,
|
|
remainingQuantity: 19,
|
|
fillWeightGrams: null,
|
|
honeyType: null,
|
|
});
|
|
// Abfüllungen vor Herstellungschargen.
|
|
const kinds = items.map((b) => b.batchType);
|
|
expect(kinds.lastIndexOf('BOTTLING')).toBeLessThan(
|
|
kinds.indexOf('PRODUCTION'),
|
|
);
|
|
const raw = JSON.stringify(items);
|
|
expect(raw).not.toContain('Docht von Firma Meier');
|
|
expect(raw).not.toContain('Erika Imkerin');
|
|
// Rohstoff-Chargen sind keine Ware.
|
|
expect(raw).not.toContain(`W-ERP-${suffix}`);
|
|
});
|
|
|
|
it('blättert ohne Lücken und Dubletten', async () => {
|
|
const all = (await allItems('chargen')).map((b) => b.id);
|
|
const limit = Math.max(1, Math.ceil(all.length / 3));
|
|
const seen: string[] = [];
|
|
let nach: string | null = null;
|
|
for (let page = 0; page < 20; page++) {
|
|
const query: string = nach
|
|
? `?limit=${limit}&nach=${nach}`
|
|
: `?limit=${limit}`;
|
|
const body = (await get(`chargen${query}`, erpKey).expect(200))
|
|
.body as PageBody<Item>;
|
|
expect(body.items.length).toBeLessThanOrEqual(limit);
|
|
seen.push(...body.items.map((b) => b.id));
|
|
nach = body.nextCursor;
|
|
if (!body.hasMore) break;
|
|
}
|
|
expect(new Set(seen).size).toBe(seen.length);
|
|
expect(seen).toEqual(
|
|
expect.arrayContaining([bottlingId, productionBatchId]),
|
|
);
|
|
// Andere Testdateien legen parallel Chargen an oder löschen welche:
|
|
// verglichen wird die Reihenfolge der Chargen, die beide Abrufe sahen.
|
|
const inBoth = new Set(all.filter((id) => seen.includes(id)));
|
|
expect(seen.filter((id) => inBoth.has(id))).toEqual(
|
|
all.filter((id) => inBoth.has(id)),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Bewegungen', () => {
|
|
async function myMovements(): Promise<{ items: Item[]; cursor: string }> {
|
|
const mine = new Set(Object.values(movementIds));
|
|
const found: Item[] = [];
|
|
let query = `bewegungen?limit=2&seit=${at(0).toISOString()}`;
|
|
for (let page = 0; page < 40; page++) {
|
|
const body = (await get(query, erpKey).expect(200))
|
|
.body as PageBody<Item>;
|
|
found.push(...body.items.filter((m) => mine.has(m.id)));
|
|
const last = body.items[body.items.length - 1];
|
|
const passedMine =
|
|
last !== undefined &&
|
|
new Date(last.createdAt as string).getTime() > at(5).getTime();
|
|
if (!body.hasMore || passedMine) {
|
|
return { items: found, cursor: body.nextCursor! };
|
|
}
|
|
query = `bewegungen?limit=2&nach=${body.nextCursor}`;
|
|
}
|
|
throw new Error('zu viele Seiten');
|
|
}
|
|
|
|
it('liefert Entnahmen mit Grund, Shop-Verkäufe und Inventur in stabiler Reihenfolge', async () => {
|
|
const { items } = await myMovements();
|
|
|
|
// Zeitpunkt, dann id: eigenbedarf und verkauf haben denselben Zeitpunkt.
|
|
const sameTime = [movementIds.eigenbedarf, movementIds.verkauf].sort();
|
|
expect(items.map((m) => m.id)).toEqual([
|
|
movementIds.hofladen,
|
|
...sameTime,
|
|
movementIds.bruch,
|
|
movementIds.korrektur,
|
|
]);
|
|
expect(items.find((m) => m.id === movementIds.hofladen)).toEqual({
|
|
id: movementIds.hofladen,
|
|
createdAt: at(1).toISOString(),
|
|
batchType: 'BOTTLING',
|
|
batchId: bottlingId,
|
|
lotNumber: `L-ERP-${suffix}`,
|
|
productId: honeyProductId,
|
|
type: 'WITHDRAWAL',
|
|
reason: 'HOFLADEN',
|
|
quantity: -6,
|
|
});
|
|
expect(items.find((m) => m.id === movementIds.bruch)).toMatchObject({
|
|
batchType: 'PRODUCTION',
|
|
batchId: productionBatchId,
|
|
productId: candleProductId,
|
|
reason: 'BRUCH',
|
|
quantity: -1,
|
|
});
|
|
expect(items.find((m) => m.id === movementIds.verkauf)).toMatchObject({
|
|
type: 'SHOP_SALE',
|
|
reason: null,
|
|
quantity: -3,
|
|
});
|
|
expect(items.find((m) => m.id === movementIds.korrektur)).toMatchObject({
|
|
type: 'CORRECTION',
|
|
quantity: 1,
|
|
});
|
|
});
|
|
|
|
it('lässt Rohstoffe, Notizen, Benutzer und noch offene Buchungen weg', async () => {
|
|
const { items } = await myMovements();
|
|
const ids = items.map((m) => m.id);
|
|
expect(ids).not.toContain(movementIds.rohstoff);
|
|
expect(ids).not.toContain(movementIds.frisch);
|
|
const raw = JSON.stringify(items);
|
|
expect(raw).not.toContain('Müller');
|
|
expect(raw).not.toContain('Erika');
|
|
expect(raw).not.toContain('userId');
|
|
});
|
|
|
|
it('meldet, bis wann die Liste vollständig ist', async () => {
|
|
const before = Date.now();
|
|
const body = (await get('bewegungen?limit=1', erpKey).expect(200))
|
|
.body as PageBody<Item>;
|
|
const settled = new Date(body.settledUntil!).getTime();
|
|
expect(settled).toBeLessThanOrEqual(before - 4 * 60 * 1000);
|
|
expect(settled).toBeGreaterThan(before - 6 * 60 * 1000);
|
|
});
|
|
|
|
it('setzt mit dem letzten Cursor fort, ohne etwas doppelt zu liefern', async () => {
|
|
const { cursor } = await myMovements();
|
|
const body = (
|
|
await get(`bewegungen?limit=500&nach=${cursor}`, erpKey).expect(200)
|
|
).body as PageBody<Item>;
|
|
const mine = new Set(Object.values(movementIds));
|
|
// Nur Bewegungen hinter dem Cursor - die eigenen lagen davor.
|
|
expect(body.items.filter((m) => mine.has(m.id))).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('Parameter', () => {
|
|
it.each([
|
|
['produkte?limit=0'],
|
|
['produkte?limit=501'],
|
|
['produkte?limit=zwei'],
|
|
['produkte?nach=kaputt!'],
|
|
['produkte?seite=2'],
|
|
['chargen?nach=bm90LWpzb24'],
|
|
['bewegungen?seit=gestern'],
|
|
['ernten?jahr=1800'],
|
|
['ernten?jahr=dieses'],
|
|
])('lehnt %s ab (400)', async (path) => {
|
|
await get(path, erpKey).expect(400);
|
|
});
|
|
|
|
it('lehnt einen Cursor eines anderen Endpunkts ab (400)', async () => {
|
|
const productCursor = encodeCursor({ k: 'produkte', id: honeyProductId });
|
|
await get(`bewegungen?nach=${productCursor}`, erpKey).expect(400);
|
|
});
|
|
|
|
it('lehnt seit und nach zusammen ab (400)', async () => {
|
|
const cursor = encodeCursor({
|
|
k: 'bewegungen',
|
|
ts: at(1).toISOString(),
|
|
id: movementIds.hofladen,
|
|
});
|
|
await get(
|
|
`bewegungen?seit=${at(0).toISOString()}&nach=${cursor}`,
|
|
erpKey,
|
|
).expect(400);
|
|
});
|
|
});
|
|
|
|
describe('Ernten', () => {
|
|
it('summiert das Nettogewicht eines Jahres und weist Ernten ohne Gewicht aus', async () => {
|
|
const year = 2003;
|
|
const read = async () =>
|
|
(
|
|
(await get(`ernten?jahr=${year}`, erpKey).expect(200))
|
|
.body as HarvestYearsBody
|
|
).years;
|
|
const before = await read();
|
|
expect(before).toHaveLength(1);
|
|
|
|
const owner = await prisma.user.findFirstOrThrow({
|
|
where: { email: `erp-admin-${suffix}@example.com` },
|
|
});
|
|
const apiary = await prisma.apiary.create({
|
|
data: { name: `Ernte-Stand ${suffix}` },
|
|
});
|
|
for (const [day, netWeight] of [
|
|
['2003-06-01', 12.5],
|
|
['2003-07-15', 7.25],
|
|
['2003-08-30', null],
|
|
['2004-01-01', 99],
|
|
] as const) {
|
|
await prisma.harvest.create({
|
|
data: {
|
|
apiaryId: apiary.id,
|
|
userId: owner.id,
|
|
date: new Date(`${day}T00:00:00.000Z`),
|
|
netWeight,
|
|
},
|
|
});
|
|
}
|
|
|
|
const [after] = await read();
|
|
expect(after.year).toBe(year);
|
|
expect(after.netWeightKg - before[0].netWeightKg).toBeCloseTo(19.75, 3);
|
|
expect(after.harvestCount - before[0].harvestCount).toBe(3);
|
|
expect(
|
|
after.harvestsWithoutNetWeight - before[0].harvestsWithoutNetWeight,
|
|
).toBe(1);
|
|
});
|
|
|
|
it('liefert ohne Jahr alle Jahre aufsteigend, ohne Standorte', async () => {
|
|
const body = (await get('ernten', erpKey).expect(200))
|
|
.body as HarvestYearsBody;
|
|
const years = body.years.map((y) => y.year);
|
|
expect(years).toEqual([...years].sort((a, b) => a - b));
|
|
expect(years).toContain(2026);
|
|
const raw = JSON.stringify(body);
|
|
expect(raw).not.toContain('Waldrand');
|
|
expect(raw).not.toContain('Volk 7');
|
|
});
|
|
});
|
|
|
|
describe('Fehlversuche', () => {
|
|
it('sperrt eine Adresse nach zu vielen falschen Schlüsseln mit 429, andere nicht', async () => {
|
|
const attacker = '203.0.113.50';
|
|
const wrong = `ack_abcd1234_${'Y'.repeat(43)}`;
|
|
for (let i = 0; i < API_KEY_FAILURE_LIMIT.maxFailures; i++) {
|
|
await get('produkte', wrong, attacker).expect(401);
|
|
}
|
|
const blocked = await get('produkte', wrong, attacker).expect(429);
|
|
expect(blocked.body).toMatchObject({
|
|
statusCode: 429,
|
|
message: expect.stringContaining('Fehlversuche') as string,
|
|
});
|
|
// Auch ein richtiger Schlüssel verrät von dort nichts mehr.
|
|
await get('produkte', erpKey, attacker).expect(429);
|
|
await get('produkte', erpKey, '203.0.113.51').expect(200);
|
|
});
|
|
});
|
|
});
|