Files
ApiCore/ApiCore-Backend/test/hive-catalog.e2e-spec.ts
Nick SzaunigandClaude Opus 5.5 1a57de8573 feat: Beute L3–L5 (3D-Szene, Umbauen, Durchsicht, offline) und Diktat (T7 L1)
Beute L3 Runden 2+3 und Review-Fixes:
- Glas-Holo ohne warmes Leuchten, Wanderboden-Fuss, Fokusflug mit
  Geist-Ansicht, Inhalte im Fokus, Rähmchen mit Art-Farbe beim Anheben
- Hinweis-Geometrie je Zarge nur einmal je Layout gebaut; Drehen mit
  Pointer-Capture, endet auch bei Loslassen ausserhalb (rig-pointer.ts)
- Tiefenunschärfe ab 55 fps, Leistungsprobe bei jeder Bewegungsstufe,
  Context-Loss-Listener aufgeräumt, Trefferboxen als skalierte Einheitsbox

Beute L4 Umbauen (/colonies/[id]/hive): Entwurf mit Basis-Schnappschuss
beim ersten Schritt, Rückgängig/Wiederholen, Hinweis bei Serverstand-
Änderung, Konfliktblatt.

Beute L5:
- Durchsicht-Abschnitt „Beute“: Aufbau-Änderungen in der Durchsicht,
  Blatt-Formulare stoppen das Submit-Bubbling durch Portale (sonst
  speicherte „Speichern“ im Blatt die ganze Durchsicht), Fokus nach
  Rückgängig, Folgevorschläge beim Speichern
- Offline: Beute im Offline-Paket, Warteschlange pending-hive-changes
  (DB v5), Abarbeiten in Reihenfolge, keine Online-Änderung überholt
  wartende, Hinweis bei abgelehnter Änderung mit „Warteschlange öffnen“

T7 L1 Diktat: Spracherkennungs-Kapsel (Web Speech API), Einwilligung
standardmässig aus, Diktat-Knopf an Freitextfeldern, Seite
„Sprache & Datenschutz“; axe-core als exakte devDependency.

Backend: NotificationsService liest Empfänger und schreibt in einer
Transaktion mit FOR KEY SHARE (paralleles Löschen eines Benutzers
führte zu P2003 und verzögerten Vorschlägen; Ursache der wackeligen
Endtests). Zwei e2e-Prüfungen gegen geteilten DB-Zustand entschärft.

Gates: Frontend lint, typecheck, 2740 Tests, build; Backend lint,
typecheck, 4266 Unit-Tests, build; parallele e2e 3x grün (978).
Browser-Prüfung 1280/390 px, Tastatur, offline, reduced motion.

Refs #19 #20 #21 #22 #27 #32

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 09:12:04 +02:00

348 lines
11 KiB
TypeScript

import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { HiveCatalogService } from '../src/hive/hive-catalog.service';
import { validateHiveState } from '../src/hive/hive-validate';
import { PrismaService } from '../src/prisma/prisma.service';
import { login } from './utils/api';
import { createTestApp } from './utils/create-test-app';
const SYSTEM_ID = 'hive-system-zander-liebig-10';
interface FixturePartType {
id: string;
kind: string;
name: string;
lengthMm: number;
widthMm: number;
heightMm: number;
frameSlots: number | null;
slotWidth: number | null;
stateSchema: unknown[];
active: boolean;
}
interface TemplateBody {
schemaVersion: number;
stack: { id: string; partTypeId?: string; inserts?: { id: string }[] }[];
}
interface SystemBody {
id: string;
partTypes: { id: string }[];
}
const fixture = JSON.parse(
readFileSync(join(__dirname, 'fixtures', 'hive-ops', 'catalog.json'), 'utf8'),
) as { systemId: string; partTypes: FixturePartType[] };
const FLOOR_SCHEMA: unknown[] =
fixture.partTypes.find((p) => p.kind === 'FLOOR')?.stateSchema ?? [];
/** Hive catalog against the real database: seed, permissions, row lock SQL. */
describe('Hive catalog (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const password = 'SuperSecret123!';
const roleIds: string[] = [];
const userIds: string[] = [];
const systemIds: string[] = [];
const tokens = { manager: '', reader: '' };
const api = () => request(app.getHttpServer());
const auth = (token: string) => ({ Authorization: `Bearer ${token}` });
const systemBody = (name: string) => ({
name,
frameWidthMm: 480,
frameHeightMm: 220,
footprintLMm: 520,
footprintWMm: 430,
});
const floorTypeBody = (stateSchema: unknown[]) => ({
kind: 'FLOOR',
name: `E2E Boden ${suffix}`,
lengthMm: 525,
widthMm: 430,
heightMm: 120,
stateSchema,
});
async function createUser(label: string, permissions: string[]) {
const role = await prisma.role.create({
data: {
key: `HIVE_CAT_${label.toUpperCase()}_${suffix}`,
name: `Hive catalog ${label}`,
permissions: {
create: permissions.map((p) => ({
permission: { connect: { key: p } },
})),
},
},
});
roleIds.push(role.id);
const email = `hive-cat-${label}-${suffix}@example.com`;
const user = await prisma.user.create({
data: {
email,
passwordHash: await bcrypt.hash(password, 12),
name: `Hive catalog ${label}`,
role: { connect: { id: role.id } },
},
});
userIds.push(user.id);
return (await login(app, email, password)).accessToken;
}
async function createSystem(name: string): Promise<string> {
const res = await api()
.post('/api/v1/hive-systems')
.set(auth(tokens.manager))
.send(systemBody(name))
.expect(201);
const id = (res.body as SystemBody).id;
systemIds.push(id);
return id;
}
async function createFloorType(systemId: string, stateSchema: unknown[]) {
const res = await api()
.post(`/api/v1/hive-systems/${systemId}/part-types`)
.set(auth(tokens.manager))
.send(floorTypeBody(stateSchema))
.expect(201);
return (res.body as { id: string }).id;
}
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
tokens.manager = await createUser('manager', [
'colonies:read',
'hive-systems:manage',
]);
tokens.reader = await createUser('reader', ['colonies:read']);
});
afterAll(async () => {
await prisma.hivePartType.deleteMany({
where: { systemId: { in: systemIds } },
});
await prisma.hiveSystem.deleteMany({ where: { id: { in: systemIds } } });
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } });
await prisma.refreshToken.deleteMany({
where: { userId: { in: userIds } },
});
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
await prisma.role.deleteMany({ where: { id: { in: roleIds } } });
await app.close();
});
it('seeds a catalog equal to test/fixtures/hive-ops/catalog.json', async () => {
const rows = await prisma.hivePartType.findMany({
where: { systemId: fixture.systemId },
orderBy: { id: 'asc' },
});
expect(rows.map((r) => r.id)).toEqual(
fixture.partTypes.map((p) => p.id).sort(),
);
for (const expected of fixture.partTypes) {
const row = rows.find((r) => r.id === expected.id);
expect({
id: row?.id,
kind: row?.kind,
name: row?.name,
lengthMm: row?.lengthMm,
widthMm: row?.widthMm,
heightMm: row?.heightMm,
frameSlots: row?.frameSlots,
slotWidth: row?.slotWidth,
stateSchema: row?.stateSchema,
active: row?.active,
}).toEqual(expected);
}
});
it('lists the systems with their part types for colonies:read', async () => {
const res = await api()
.get('/api/v1/hive-systems')
.set(auth(tokens.reader))
.expect(200);
const seeded = (res.body as SystemBody[]).find((s) => s.id === SYSTEM_ID);
expect(seeded?.partTypes).toHaveLength(fixture.partTypes.length);
});
it('refuses catalog writes without hive-systems:manage', async () => {
const attempts = [
() => api().post('/api/v1/hive-systems').send(systemBody('x')),
() =>
api().patch(`/api/v1/hive-systems/${SYSTEM_ID}`).send({ active: true }),
() =>
api()
.post(`/api/v1/hive-systems/${SYSTEM_ID}/part-types`)
.send(floorTypeBody([])),
() =>
api()
.patch(`/api/v1/hive-part-types/${fixture.partTypes[0].id}`)
.send({ name: 'x' }),
];
for (const attempt of attempts) {
await attempt().set(auth(tokens.reader)).expect(403);
}
});
it('creates and updates a system and a part type with the permission', async () => {
const systemId = await createSystem(`E2E System ${suffix}`);
await api()
.patch(`/api/v1/hive-systems/${systemId}`)
.set(auth(tokens.manager))
.send({ footprintLMm: 530 })
.expect(200);
const partTypeId = await createFloorType(systemId, FLOOR_SCHEMA);
const patched = await api()
.patch(`/api/v1/hive-part-types/${partTypeId}`)
.set(auth(tokens.manager))
.send({ heightMm: 125 })
.expect(200);
expect(patched.body).toMatchObject({ id: partTypeId, heightMm: 125 });
});
it('answers 400 SCHEMA_NOT_ADDITIVE when a state field is removed', async () => {
const systemId = await createSystem(`E2E Schema ${suffix}`);
const partTypeId = await createFloorType(systemId, FLOOR_SCHEMA);
const res = await api()
.patch(`/api/v1/hive-part-types/${partTypeId}`)
.set(auth(tokens.manager))
.send({ stateSchema: FLOOR_SCHEMA.slice(0, 1) })
.expect(400);
expect(res.body).toMatchObject({ code: 'SCHEMA_NOT_ADDITIVE' });
});
it('serialises two concurrent schema updates of one part type (row lock)', async () => {
const systemId = await createSystem(`E2E Lock ${suffix}`);
const partTypeId = await createFloorType(systemId, FLOOR_SCHEMA);
const url = `/api/v1/hive-part-types/${partTypeId}`;
const extra = (key: string) => ({
key,
label: key,
kind: 'choice',
options: ['A', 'B'],
optionLabels: ['a', 'b'],
});
const patch = (key: string) =>
api()
.patch(url)
.set(auth(tokens.manager))
.send({ stateSchema: [...FLOOR_SCHEMA, extra(key)] })
.then((res) => res);
const results = await Promise.all([patch('extraA'), patch('extraB')]);
// Without the lock both read the old schema and both would answer 200.
// Serialised, the loser sees the winner's field and is not additive.
expect(results.map((r) => r.status).sort()).toEqual([200, 400]);
const loser = results.find((r) => r.status === 400);
expect(loser?.body).toMatchObject({ code: 'SCHEMA_NOT_ADDITIVE' });
const row = await prisma.hivePartType.findUniqueOrThrow({
where: { id: partTypeId },
});
const keys = (row.stateSchema as unknown as { key: string }[]).map(
(f) => f.key,
);
expect(keys).toHaveLength(FLOOR_SCHEMA.length + 1);
});
it('answers 409 TEMPLATE_NOT_SET for a system without a template', async () => {
const systemId = await createSystem(`E2E Vorlage ${suffix}`);
const res = await api()
.get(`/api/v1/hive-systems/${systemId}/template`)
.set(auth(tokens.reader))
.expect(409);
expect(res.body).toMatchObject({ code: 'TEMPLATE_NOT_SET' });
});
it('answers 400 TEMPLATE_INVALID for a template with an empty stack', async () => {
const systemId = await createSystem(`E2E Leer ${suffix}`);
const res = await api()
.patch(`/api/v1/hive-systems/${systemId}`)
.set(auth(tokens.manager))
.send({ template: { schemaVersion: 1, stack: [] } })
.expect(400);
expect(res.body).toMatchObject({ code: 'TEMPLATE_INVALID' });
});
it('returns a valid template with fresh ids on every call', async () => {
const get = async () =>
(
await api()
.get(`/api/v1/hive-systems/${SYSTEM_ID}/template`)
.set(auth(tokens.reader))
.expect(200)
).body as TemplateBody;
const first = await get();
const second = await get();
const catalog = await app.get(HiveCatalogService).loadCatalog(SYSTEM_ID);
expect(validateHiveState(first, catalog)).toEqual([]);
const firstIds = first.stack.map((p) => p.id);
expect(firstIds.every((id) => typeof id === 'string' && id !== '')).toBe(
true,
);
expect(new Set(firstIds).size).toBe(firstIds.length);
const overlap = second.stack.filter((p) => firstIds.includes(p.id));
expect(overlap).toEqual([]);
});
it('accepts an id-less template (as the hive editor sends it) and serves fresh ids afterwards', async () => {
const current = (
await api()
.get(`/api/v1/hive-systems/${SYSTEM_ID}/template`)
.set(auth(tokens.reader))
.expect(200)
).body as TemplateBody;
const stripIds = (
part: Record<string, unknown>,
): Record<string, unknown> => {
const rest = { ...part };
delete rest.id;
const inserts = rest.inserts;
delete rest.inserts;
return Array.isArray(inserts)
? { ...rest, inserts: inserts.map(stripIds) }
: rest;
};
const idLess = {
schemaVersion: 1,
stack: current.stack.map((p) => stripIds(p as never)),
};
await api()
.patch(`/api/v1/hive-systems/${SYSTEM_ID}`)
.set(auth(tokens.manager))
.send({ template: idLess })
.expect(200);
const after = (
await api()
.get(`/api/v1/hive-systems/${SYSTEM_ID}/template`)
.set(auth(tokens.reader))
.expect(200)
).body as TemplateBody;
expect(after.stack.map((p) => p.partTypeId)).toEqual(
current.stack.map((p) => p.partTypeId),
);
expect(
after.stack.every((p) => typeof p.id === 'string' && p.id !== ''),
).toBe(true);
});
});