Files
ApiCore/ApiCore-Backend/test/authority-notices-herd.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

342 lines
11 KiB
TypeScript

import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { berlinToday } from '../src/common/dates';
import { findBaselineIn } from '../src/compliance/herd-status';
import { NotificationsService } from '../src/notifications/notifications.service';
import { DomainEventRegistry } from '../src/domain-events/domain-event-registry';
import { PrismaService } from '../src/prisma/prisma.service';
import { login } from './utils/api';
import { createTestApp } from './utils/create-test-app';
import { drainDomainEvents } from './utils/domain-events';
interface NoticeBody {
id: string;
totalColonies: number;
}
interface ColonyBody {
id: string;
}
/**
* The herd-change notification (T6 task 11): one unread notification per
* permitted user and topic. "Permitted" are the users this file creates with
* compliance:manage; other users of the shared test database do not count.
*/
describe('Authority notices: herd change notification (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const suffix = Date.now();
const password = 'SuperSecret123!';
const managerEmails = [
`herd-e2e-manager-a-${suffix}@example.com`,
`herd-e2e-manager-b-${suffix}@example.com`,
];
const staffEmail = `herd-e2e-staff-${suffix}@example.com`;
let managerIds: string[];
let staffId: string;
let managerToken: string;
let apiaryId: string;
let keyCounter = 0;
const colonyIds: string[] = [];
/** The notices this file created: the only ones it deletes. */
const noticeIds: string[] = [];
/** The shared settings row as found; restored in afterAll. */
let savedSettings: {
noticeOnColonyCountChange: boolean;
noticeOnApiaryChange: boolean;
};
const ONCE_TOPIC = `herd-e2e-once-${suffix}`;
const server = () => app.getHttpServer();
const as = (token: string) => ({ Authorization: `Bearer ${token}` });
const nextKey = () =>
`herd-e2e-${suffix}-${(keyCounter += 1).toString().padStart(4, '0')}`;
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
const passwordHash = await bcrypt.hash(password, 12);
const create = (email: string, roleKey: string) =>
prisma.user.create({
data: {
email,
passwordHash,
name: roleKey,
role: { connect: { key: roleKey } },
},
});
managerIds = [];
for (const email of managerEmails) {
managerIds.push((await create(email, 'BEEKEEPER')).id);
}
staffId = (await create(staffEmail, 'STAFF')).id;
const settings = await prisma.complianceSettings.upsert({
where: { id: 'singleton' },
update: {},
create: { id: 'singleton' },
});
savedSettings = {
noticeOnColonyCountChange: settings.noticeOnColonyCountChange,
noticeOnApiaryChange: settings.noticeOnApiaryChange,
};
managerToken = (await login(app, managerEmails[0], password)).accessToken;
apiaryId = (
await prisma.apiary.create({ data: { name: `Herd Stand ${suffix}` } })
).id;
colonyIds.push(
(
await prisma.colony.create({
data: {
code: `H-${suffix}-0`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
},
})
).id,
);
});
beforeEach(async () => {
await prisma.complianceSettings.update({
where: { id: 'singleton' },
data: { noticeOnColonyCountChange: true, noticeOnApiaryChange: true },
});
await prisma.notification.deleteMany({
where: { userId: { in: [...managerIds, staffId] } },
});
});
afterEach(async () => {
// The shared database holds other suites' notices: only own ids go.
await prisma.authorityNotice.deleteMany({
where: { id: { in: noticeIds.splice(0) } },
});
await prisma.notification.deleteMany({
where: { relatedType: 'ComplianceItem', relatedId: ONCE_TOPIC },
});
});
afterAll(async () => {
await prisma.complianceSettings.update({
where: { id: 'singleton' },
data: savedSettings,
});
const userIds = [...managerIds, staffId];
await prisma.notification.deleteMany({
where: { userId: { in: userIds } },
});
await prisma.domainEvent.deleteMany({
where: { aggregateId: { in: colonyIds } },
});
await prisma.colony.deleteMany({ where: { apiaryId } });
await prisma.domainEvent.deleteMany({ where: { aggregateId: apiaryId } });
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
await prisma.refreshToken.deleteMany({
where: { userId: { in: userIds } },
});
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
await app.close();
});
/** A notice sent today is the baseline: the current herd. */
async function sendBaseline(): Promise<void> {
const kind = (await findBaselineIn(prisma))
? 'HERD_CHANGE'
: 'REGISTRATION';
const response = await request(server())
.post('/api/v1/authority-notices')
.set(as(managerToken))
.set('Idempotency-Key', nextKey())
.send({ kind, sentOn: berlinToday() })
.expect(201);
noticeIds.push((response.body as NoticeBody).id);
}
async function addColony(): Promise<string> {
const response = await request(server())
.post('/api/v1/colonies')
.set(as(managerToken))
.send({
code: `H-${suffix}-${colonyIds.length}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
})
.expect(201);
const { id } = response.body as ColonyBody;
colonyIds.push(id);
await drainDomainEvents(app, [id]);
return id;
}
const unreadOf = (userIds: string[]) =>
prisma.notification.findMany({
where: {
userId: { in: userIds },
relatedType: 'ComplianceItem',
relatedId: 'herd-change',
read: false,
},
select: { userId: true, category: true, priority: true, message: true },
});
it('registers both handlers in the API', () => {
expect(app.get(DomainEventRegistry).handlerIds()).toEqual(
expect.arrayContaining([
'compliance.herd-change',
'compliance.tsk-reference-day',
]),
);
});
it('a new colony changes the herd: after draining one unread notification per permitted user', async () => {
await sendBaseline();
await addColony();
const unread = await unreadOf(managerIds);
expect(unread.map((row) => row.userId).sort()).toEqual(
[...managerIds].sort(),
);
expect(unread[0]).toMatchObject({
category: 'COMPLIANCE',
priority: 'HINWEIS',
});
expect(unread[0].message).toMatch(
/^Bestand geändert: jetzt \d+ Völker an \d+ Ständen\. Angezeigt: \d+ an \d+\. Anzeige ans Veterinäramt prüfen\.$/,
);
});
it('a second colony within one unread notice adds no second notification', async () => {
await sendBaseline();
await addColony();
await addColony();
expect(await unreadOf(managerIds)).toHaveLength(managerIds.length);
});
it('creating and sending a change notice removes the deviation; the next colony after reading notifies again', async () => {
await sendBaseline();
await addColony();
expect(await unreadOf(managerIds)).toHaveLength(managerIds.length);
const created = await request(server())
.post('/api/v1/authority-notices')
.set(as(managerToken))
.set('Idempotency-Key', nextKey())
.send({ kind: 'HERD_CHANGE' })
.expect(201);
const notice = created.body as NoticeBody;
noticeIds.push(notice.id);
await request(server())
.post(`/api/v1/authority-notices/${notice.id}/mark-sent`)
.set(as(managerToken))
.send({})
.expect(201);
await prisma.notification.updateMany({
where: { userId: { in: managerIds } },
data: { read: true },
});
// The sent notice is the new baseline. (Not asserted: "nothing deviates"
// against the live database - colonies that parallel files create in the
// same instant would make that flaky.)
expect((await findBaselineIn(prisma))?.id).toBe(notice.id);
expect(await unreadOf(managerIds)).toHaveLength(0);
await addColony();
expect(await unreadOf(managerIds)).toHaveLength(managerIds.length);
});
it('a user without compliance:manage gets no herd notification', async () => {
await sendBaseline();
await addColony();
expect(await unreadOf([staffId])).toHaveLength(0);
expect(await unreadOf(managerIds)).toHaveLength(managerIds.length);
});
async function patchApiary(body: Record<string, string>): Promise<void> {
await request(server())
.patch(`/api/v1/apiaries/${apiaryId}`)
.set(as(managerToken))
.send(body)
.expect(200);
await drainDomainEvents(app, [apiaryId]);
}
it('an apiary move (LOCATION_CHANGED) counts', async () => {
await sendBaseline();
await patchApiary({ location: `Neuer Standort ${suffix}` });
expect(await unreadOf(managerIds)).toHaveLength(managerIds.length);
});
it('a rename does not count', async () => {
await sendBaseline();
await patchApiary({ name: `Herd Stand umbenannt ${suffix}` });
expect(await unreadOf(managerIds)).toHaveLength(0);
});
it('noticeOnApiaryChange alone: a colony stays quiet, a moved apiary notifies', async () => {
await prisma.complianceSettings.update({
where: { id: 'singleton' },
data: { noticeOnColonyCountChange: false },
});
await sendBaseline();
await addColony();
expect(await unreadOf(managerIds)).toHaveLength(0);
await patchApiary({ location: `Ort wieder neu ${suffix}` });
expect(await unreadOf(managerIds)).toHaveLength(managerIds.length);
});
it('two parallel recordForPermissionOnce transactions write one row per user', async () => {
const notifications = app.get(NotificationsService);
const once = () =>
prisma.$transaction((tx) =>
notifications.recordForPermissionOnce(
'compliance:manage',
{
priority: 'INFO',
category: 'COMPLIANCE',
message: 'Parallel',
relatedType: 'ComplianceItem',
relatedId: ONCE_TOPIC,
link: '/compliance',
},
tx,
),
);
await Promise.all([once(), once()]);
const rows = await prisma.notification.groupBy({
by: ['userId'],
where: {
relatedType: 'ComplianceItem',
relatedId: ONCE_TOPIC,
read: false,
},
_count: { _all: true },
});
expect(rows.length).toBeGreaterThanOrEqual(managerIds.length);
for (const row of rows) {
expect(row._count._all).toBe(1);
}
expect(rows.map((row) => row.userId)).toEqual(
expect.arrayContaining(managerIds),
);
expect(rows.map((row) => row.userId)).not.toContain(staffId);
});
});