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]>
333 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|