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 { contractVersion: number; generatedAt: string; items: T[]; nextCursor: string | null; hasMore: boolean; settledUntil?: string; } type Item = Record & { 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; 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 = {}; 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 { 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 { 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; 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[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; 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; 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; 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; 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; 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; 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 einen Bewegungs-Cursor mit unmöglichem Jahr ab (400 statt 500)', async () => { const cursor = encodeCursor({ k: 'bewegungen', ts: '+275760-09-13T00:00:00.000Z', id: movementIds.hofladen, }); await get(`bewegungen?nach=${cursor}`, erpKey).expect(400); }); it('verträgt ein fernes seit (leere Seite statt Fehler)', async () => { const body = ( await get('bewegungen?seit=9999-12-31T00:00:00Z', erpKey).expect(200) ).body as PageBody; expect(body.items).toEqual([]); }); 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 nach zu vielen unbekannten Schlüsseln weitere ungültige mit 429, andere Adressen 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, }); await get('produkte', wrong, '203.0.113.51').expect(401); }); it('lässt einen gültigen Schlüssel auch von einer gesperrten Adresse durch', async () => { // Sonst könnte jeder, der die gemeinsame Adresse (Cluster-Ausgang, // Proxy) nutzt oder fälscht, ERPNext oder den Shop aussperren. const shared = '203.0.113.60'; const wrong = `ack_abcd1234_${'X'.repeat(43)}`; for (let i = 0; i < API_KEY_FAILURE_LIMIT.maxFailures; i++) { await get('produkte', wrong, shared).expect(401); } await get('produkte', wrong, shared).expect(429); await get('produkte', erpKey, shared).expect(200); await request(server()) .get('/api/v1/shop-sync/stock') .set('Authorization', `Bearer ${shopKey}`) .set('X-Forwarded-For', shared) .expect(200); }); it('zählt fehlerhaft aufgebaute Schlüssel nicht', async () => { const ip = '203.0.113.70'; for (let i = 0; i < API_KEY_FAILURE_LIMIT.maxFailures + 5; i++) { await get('produkte', 'ack_x', ip).expect(401); } await get('produkte', `ack_abcd1234_${'W'.repeat(43)}`, ip).expect(401); }); }); });