Files
ApiCore/ApiCore-Backend/src/apiaries/apiary-field.service.spec.ts
T
Nick SzaunigandClaude Opus 5.5 ef33e5549b feat: T4 wave 5 - colony end cleanup, withdrawal chain, next steps, inspection takeover UI
- Colony end releases the queen in the same transaction; open
  suggestions become obsolete and a CLEANUP suggestion lists open
  tasks and the queen; apply-cleanup validates evidence and blocks
  reactivated colonies
- Withdrawal chain: plausibility warnings on harvest and bottling,
  conflict notifications also when a treatment is entered or corrected
  after the harvest (naming bottled lots), withdrawal spans in the
  calendar and M365 sync, Berlin-day end events
- Next 3 steps per colony and the "Heute" endpoint with ranking and an
  explicit merge table by intent
- Inspection-triggered rule suggestions follow the colony's latest
  inspection (resolve when the finding is gone, refresh when it stays)
- Frontend: colony card takeover in the inspection form, feed status as
  condition, occupied frames 0-40; legacy offline entries keep syncing
- Notifications and preferences are deleted with their user

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

291 lines
9.5 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';
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 };
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()),
};
service = new ApiaryFieldService(
prisma as unknown as PrismaService,
permissionResolver as unknown as PermissionResolverService,
nextSteps as unknown as NextStepsService,
);
});
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('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);
});
});