Files
ApiCore/ApiCore-Backend/src/apiaries/apiary-field.service.spec.ts
T
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

333 lines
11 KiB
TypeScript

import { NotFoundException } from '@nestjs/common';
import { ApiaryFieldService } from './apiary-field.service';
import { PrismaService } from '../prisma/prisma.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import type { ColonyNextSteps } from '../autopilot/next-step-ranking';
import { NextStepsService } from '../autopilot/next-steps.service';
import { HiveService } from '../hive/hive.service';
import { HiveCatalogService } from '../hive/hive-catalog.service';
describe('ApiaryFieldService', () => {
let service: ApiaryFieldService;
let prisma: {
apiary: { findUnique: jest.Mock };
colony: { findMany: jest.Mock };
inspection: { groupBy: jest.Mock; findMany: jest.Mock };
$queryRaw: jest.Mock;
queen: { findMany: jest.Mock };
task: { findMany: jest.Mock };
};
let permissionResolver: { getPermissions: jest.Mock };
let nextSteps: { forColonies: jest.Mock; warningCounts: jest.Mock };
let hives: { getMany: jest.Mock };
let hiveCatalogs: { listSystems: jest.Mock };
const stepsOf = (
colonyId: string,
overrides: Partial<ColonyNextSteps> = {},
): ColonyNextSteps => ({
colonyId,
segment: 'SCHWARMZEIT',
phase: null,
phaseSource: null,
inspectionDueOn: null,
withdrawalUntil: null,
steps: [],
...overrides,
});
const colonyRow = (id: string, code: string, queenId: string | null) => ({
id,
code,
apiaryId: 'apiary-1',
status: 'ACTIVE',
colonyType: 'PRODUCTION_COLONY',
hiveType: null,
strength: 5,
occupiedFrames: 12,
broodStatus: null,
feedStatus: null,
healthStatus: null,
currentQueenId: queenId,
notes: null,
});
const grant = (...keys: string[]) =>
permissionResolver.getPermissions.mockResolvedValue(new Set(keys));
beforeEach(() => {
prisma = {
apiary: {
findUnique: jest.fn().mockResolvedValue({
id: 'apiary-1',
name: 'Heimstand',
location: 'Garten',
status: 'ACTIVE',
}),
},
colony: {
findMany: jest
.fn()
.mockResolvedValue([
colonyRow('c10', 'V-10', 'q1'),
colonyRow('c2', 'V-2', null),
]),
},
inspection: {
groupBy: jest.fn().mockResolvedValue([]),
findMany: jest.fn().mockResolvedValue([]),
},
$queryRaw: jest.fn().mockResolvedValue([]),
queen: { findMany: jest.fn().mockResolvedValue([]) },
task: { findMany: jest.fn().mockResolvedValue([]) },
};
permissionResolver = { getPermissions: jest.fn() };
nextSteps = {
forColonies: jest.fn().mockResolvedValue(
new Map([
['c10', stepsOf('c10')],
['c2', stepsOf('c2')],
]),
),
warningCounts: jest.fn().mockResolvedValue(new Map()),
};
hives = { getMany: jest.fn().mockResolvedValue({}) };
hiveCatalogs = { listSystems: jest.fn().mockResolvedValue([]) };
service = new ApiaryFieldService(
prisma as unknown as PrismaService,
permissionResolver as unknown as PermissionResolverService,
nextSteps as unknown as NextStepsService,
hives as unknown as HiveService,
hiveCatalogs as unknown as HiveCatalogService,
);
});
it('throws NotFoundException for an unknown apiary', async () => {
grant('colonies:read');
prisma.apiary.findUnique.mockResolvedValue(null);
await expect(service.colonyOverview('x', 'u1')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('sorts living colonies by code with numeric collation', async () => {
grant('colonies:read');
const overview = await service.colonyOverview('apiary-1', 'u1');
expect(overview.rows.map((row) => row.code)).toEqual(['V-2', 'V-10']);
expect(prisma.colony.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ apiaryId: 'apiary-1' }) as unknown,
}),
);
});
it('leaves out inspections, queens and tasks without their read permission', async () => {
grant('colonies:read');
const pack = await service.offlinePack('apiary-1', 'u1');
expect(pack.inspections).toBeNull();
expect(pack.queens).toBeNull();
expect(pack.tasks).toBeNull();
expect(pack.overview.inspectionsVisible).toBe(false);
expect(pack.overview.rows.every((r) => r.lastInspectionDate === null)).toBe(
true,
);
expect(prisma.inspection.groupBy).not.toHaveBeenCalled();
expect(prisma.inspection.findMany).not.toHaveBeenCalled();
expect(prisma.queen.findMany).not.toHaveBeenCalled();
expect(prisma.task.findMany).not.toHaveBeenCalled();
});
it('carries the hives of the apiary colonies and the hive catalog', async () => {
grant('colonies:read');
const view = { colonyId: 'c2', systemId: 's1', version: 3 };
hives.getMany.mockResolvedValue({ c2: view });
const systems = [{ id: 's1', name: 'Zander', active: true, partTypes: [] }];
hiveCatalogs.listSystems.mockResolvedValue(systems);
const pack = await service.offlinePack('apiary-1', 'u1');
expect(hives.getMany).toHaveBeenCalledWith(['c2', 'c10']);
expect(pack.hives).toEqual({ c2: view });
expect(pack.hiveCatalog).toEqual(systems);
});
it('limits the hive catalog to active systems and those the hives use', async () => {
grant('colonies:read');
hives.getMany.mockResolvedValue({ c2: { colonyId: 'c2', systemId: 's2' } });
hiveCatalogs.listSystems.mockResolvedValue([
{ id: 's1', active: true },
{ id: 's2', active: false },
{ id: 's3', active: false },
]);
const pack = await service.offlinePack('apiary-1', 'u1');
expect(pack.hiveCatalog?.map((system) => system.id)).toEqual(['s1', 's2']);
});
it('keeps colonies without a hive setup out of the hives', async () => {
grant('colonies:read');
const pack = await service.offlinePack('apiary-1', 'u1');
expect(pack.hives).toEqual({});
});
it('fills the permitted sections and the last inspection date', async () => {
grant('colonies:read', 'inspections:read', 'queens:read', 'tasks:read');
const date = new Date('2026-05-01T10:00:00.000Z');
prisma.inspection.groupBy.mockResolvedValue([
{ colonyId: 'c2', _max: { date } },
]);
prisma.queen.findMany.mockResolvedValue([{ id: 'q1', code: 'K-1' }]);
prisma.task.findMany.mockResolvedValue([{ id: 't1', title: 'Futter' }]);
const pack = await service.offlinePack('apiary-1', 'u1');
expect(pack.overview.inspectionsVisible).toBe(true);
expect(pack.overview.rows[0].lastInspectionDate).toBe(date.toISOString());
expect(pack.overview.rows[1].lastInspectionDate).toBeNull();
expect(pack.queens).toEqual([{ id: 'q1', code: 'K-1' }]);
expect(prisma.queen.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: { in: ['q1'] } } }),
);
expect(pack.tasks).toEqual([{ id: 't1', title: 'Futter' }]);
expect(prisma.task.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
status: { in: ['OPEN', 'IN_PROGRESS'] },
OR: [
{ relatedType: 'APIARY', relatedId: 'apiary-1' },
{ relatedType: 'COLONY', relatedId: { in: ['c2', 'c10'] } },
],
},
}),
);
});
it('keeps at most three inspections per colony, newest first', async () => {
grant('colonies:read', 'inspections:read');
prisma.inspection.findMany.mockResolvedValue([{ id: 'i1' }]);
const pack = await service.offlinePack('apiary-1', 'u1');
expect(prisma.inspection.findMany).toHaveBeenCalledTimes(2);
expect(prisma.inspection.findMany).toHaveBeenCalledWith({
where: { colonyId: 'c2' },
take: 3,
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }],
omit: { userId: true },
});
expect(pack.inspections).toHaveLength(2);
});
it('never returns coordinates, address, apiary notes or inspection userId', async () => {
grant('colonies:read', 'inspections:read');
const pack = await service.offlinePack('apiary-1', 'u1');
expect(Object.keys(pack.apiary).sort()).toEqual([
'id',
'location',
'name',
'status',
]);
const apiarySelect = (
prisma.apiary.findUnique.mock.calls[0] as [{ select: object }]
)[0].select;
expect(apiarySelect).not.toHaveProperty('latitude');
expect(apiarySelect).not.toHaveProperty('longitude');
expect(apiarySelect).not.toHaveProperty('address');
expect(apiarySelect).not.toHaveProperty('notes');
const inspectionArgs = (
prisma.inspection.findMany.mock.calls[0] as [{ omit: object }]
)[0];
expect(inspectionArgs.omit).toEqual({ userId: true });
});
it('takes the warning counts from the open suggestions the caller may see', async () => {
grant('colonies:read', 'tasks:read');
nextSteps.warningCounts.mockResolvedValue(
new Map([['c2', { warnings: 2, critical: 1 }]]),
);
const overview = await service.colonyOverview('apiary-1', 'u1');
expect(nextSteps.warningCounts).toHaveBeenCalledWith(
['c2', 'c10'],
'u1',
new Set(['colonies:read', 'tasks:read']),
);
expect(overview.rows[0]).toMatchObject({
code: 'V-2',
openWarnings: 2,
openCritical: 1,
});
expect(overview.rows[1]).toMatchObject({
openWarnings: 0,
openCritical: 0,
});
expect(prisma.$queryRaw).not.toHaveBeenCalled();
});
it('carries inspectionDueOn, segment and the title of the first next step', async () => {
grant('colonies:read', 'tasks:read');
nextSteps.forColonies.mockResolvedValue(
new Map([
[
'c2',
stepsOf('c2', {
segment: 'SOMMER',
inspectionDueOn: '2026-05-10',
steps: [
{
key: 'suggestion:s1',
kind: 'SUGGESTION',
intent: 'INSPECT',
title: 'Durchsicht mit Schwarmkontrolle',
reasons: [],
suggestionIds: ['s1'],
dueOn: '2026-05-10',
urgency: 'OVERDUE',
action: { type: 'ACCEPT', suggestionId: 's1' },
dismissible: false,
},
],
}),
],
['c10', stepsOf('c10', { segment: 'WINTERRUHE' })],
]),
);
const overview = await service.colonyOverview('apiary-1', 'u1');
expect(overview.rows[0]).toMatchObject({
inspectionDueOn: '2026-05-10',
segment: 'SOMMER',
nextStepTitle: 'Durchsicht mit Schwarmkontrolle',
});
expect(overview.rows[1]).toMatchObject({
inspectionDueOn: null,
segment: 'WINTERRUHE',
nextStepTitle: null,
});
expect(nextSteps.forColonies).toHaveBeenCalledTimes(1);
});
it('leaves the segment null when the colony has no season state', async () => {
grant('colonies:read');
nextSteps.forColonies.mockResolvedValue(new Map());
const overview = await service.colonyOverview('apiary-1', 'u1');
expect(overview.rows.every((row) => row.segment === null)).toBe(true);
});
it('adds the next steps per colony to the offline pack', async () => {
grant('colonies:read');
const pack = await service.offlinePack('apiary-1', 'u1');
expect(pack.version).toBe(1);
expect(pack.nextSteps).toEqual({ c2: [], c10: [] });
expect(nextSteps.forColonies).toHaveBeenCalledTimes(1);
});
});