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 => ({ 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); }); });