- 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]>
291 lines
9.5 KiB
TypeScript
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);
|
|
});
|
|
});
|