Files
ApiCore/ApiCore-Backend/test/shop-sync.e2e-spec.ts
T
Nick SzaunigandClaude Opus 5.5 c18860c75c
Backend Image / build-and-push (push) Successful in 6m34s
Frontend Image / build-and-push (push) Successful in 10m17s
feat: T6 wave 2 – medicine book, document rights, label particulars, herd events
- Veterinary medicine catalogue, lots and append-only movement ledger
  (rest always computed; corrections as counter-bookings; inactive
  medicines take no new stock; unit must match on lot moves).
- Documents of medicine lots, authority notices and restrictions need the
  domain right on top of documents:* (403 before any lookup).
- Label particulars: pure checkLabel with six mandatory gaps and four
  warnings, AFM font metrics, Berlin-day dates, geometric reading order
  for split addresses, new master-data fields with row-locked updates.
- Herd events ColonyCreated, ColonyMoved, ApiaryChanged and
  TskReferenceDayReached (ids only, never forwarded to webhooks); CSV
  import emits ColonyCreated with audit.
- ARCHITECTURE.md §12 Compliance (T6), Annahmen 59–70.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 13:43:44 +02:00

200 lines
6.1 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 ShopSyncFeedBody {
contractVersion: number;
generatedAt: string;
harvests: Record<string, unknown>[];
products: Record<string, unknown>[];
bottlings: Record<string, unknown>[];
}
/**
* The shop sync contract: one endpoint, an explicit field allowlist, and a
* permission that grants nothing else. Wabenhain relies on this shape.
*/
describe('Shop sync (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const password = 'SuperSecret123!';
let syncToken: string;
let readonlyToken: string;
let harvestId: string;
let bottlingId: string;
let productId: string;
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
const passwordHash = await bcrypt.hash(password, 12);
const syncRole = await prisma.role.create({
data: {
key: `SHOP_SYNC_${suffix}`,
name: 'Shop-Sync',
permissions: {
create: [{ permission: { connect: { key: 'shop-sync:read' } } }],
},
},
});
await prisma.user.create({
data: {
email: `shop-sync-${suffix}@example.com`,
passwordHash,
name: 'Shop Sync',
roleId: syncRole.id,
},
});
await prisma.user.create({
data: {
email: `shop-sync-ro-${suffix}@example.com`,
passwordHash,
name: 'Readonly',
role: { connect: { key: 'READONLY' } },
},
});
syncToken = (await login(app, `shop-sync-${suffix}@example.com`, password))
.accessToken;
readonlyToken = (
await login(app, `shop-sync-ro-${suffix}@example.com`, password)
).accessToken;
const owner = await prisma.user.create({
data: {
email: `shop-sync-owner-${suffix}@example.com`,
passwordHash,
name: 'Owner',
role: { connect: { key: 'ADMIN' } },
},
});
const apiary = await prisma.apiary.create({
data: { name: `Streuobst ${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 nach Varroa-Behandlung schwach',
},
});
harvestId = harvest.id;
const extraction = await prisma.extraction.create({
data: {
date: new Date('2026-07-20T00:00:00.000Z'),
userId: owner.id,
notes: 'intern',
harvests: { create: [{ harvestId }] },
},
});
const honeyBatch = await prisma.honeyBatch.create({
data: {
batchNumber: `HB-${suffix}`,
extractionId: extraction.id,
amount: 50,
},
});
const product = await prisma.product.create({
data: {
name: `Sommertracht 500 g ${suffix}`,
sku: `SKU-${suffix}`,
legalName: `Rechtsname-${suffix}`,
},
});
productId = product.id;
const bottling = await prisma.bottlingBatch.create({
data: {
honeyBatchId: honeyBatch.id,
productId,
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-${suffix}`,
},
});
bottlingId = bottling.id;
});
afterAll(async () => {
await app.close();
});
it('delivers the versioned feed to an account holding only shop-sync:read', async () => {
const response = await request(app.getHttpServer())
.get('/api/v1/shop-sync')
.set('Authorization', `Bearer ${syncToken}`)
.expect(200);
const body = response.body as ShopSyncFeedBody;
expect(body.contractVersion).toBe(1);
const harvest = body.harvests.find((h) => h.id === harvestId);
expect(harvest).toMatchObject({
apiaryName: `Streuobst ${suffix}`,
honeyType: 'Sommertracht',
netWeight: 41,
});
const bottling = body.bottlings.find((b) => b.id === bottlingId);
expect(bottling).toMatchObject({
lotNumber: `L-${suffix}`,
jarSize: 500,
jarCount: 60,
productId,
harvestIds: [harvestId],
});
expect(body.products.some((p) => p.id === productId)).toBe(true);
});
it('never carries internal notes or other unlisted fields', async () => {
const response = await request(app.getHttpServer())
.get('/api/v1/shop-sync')
.set('Authorization', `Bearer ${syncToken}`)
.expect(200);
const raw = JSON.stringify(response.body);
expect(raw).not.toContain('Varroa');
expect(raw).not.toContain('Schlüssel');
expect(raw).not.toContain('"notes"');
expect(raw).not.toContain('"userId"');
});
it('carries no label master data in the product payload', async () => {
const response = await request(app.getHttpServer())
.get('/api/v1/shop-sync')
.set('Authorization', `Bearer ${syncToken}`)
.expect(200);
const body = response.body as ShopSyncFeedBody;
const product = body.products.find((p) => p.id === productId);
expect(product).toBeDefined();
expect(product).not.toHaveProperty('legalName');
const raw = JSON.stringify(response.body);
expect(raw).not.toContain('legalName');
expect(raw).not.toContain('foodBusiness');
expect(raw).not.toContain(`Rechtsname-${suffix}`);
});
it('grants the sync account nothing beyond the feed', async () => {
await request(app.getHttpServer())
.get('/api/v1/harvests')
.set('Authorization', `Bearer ${syncToken}`)
.expect(403);
});
it('is not readable without the shop-sync permission', async () => {
await request(app.getHttpServer())
.get('/api/v1/shop-sync')
.set('Authorization', `Bearer ${readonlyToken}`)
.expect(403);
});
});