- 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]>
200 lines
6.1 KiB
TypeScript
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);
|
|
});
|
|
});
|