- Schema III: per-user notifications (broadcast fan-out migration, category, link, requiredPermissions filter), notification preferences and settings endpoints, calendar withdrawal switch, occupiedFrames - Suggestions API: list/summary with permission visibility and paging, accept/dismiss/reopen incl. bulk, SuggestionView on all responses - Varroa traffic light by season and method, young colonies, treatment effect window, sample-based percentage; shared withdrawal-window helper on Berlin calendar days; GET /varroa-measurements/thresholds - ColonySplit and QueenAssigned domain events with calendar and webhook handlers - Outbox claims compare against now() rounded to timestamp(3); fixes the flaky e2e claims of rows written with SQL now() Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
144 lines
4.5 KiB
TypeScript
144 lines
4.5 KiB
TypeScript
import type { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
|
import type { PrismaService } from '../prisma/prisma.service';
|
|
import { SuggestionViewsService } from './suggestion-views.service';
|
|
|
|
const NOW = new Date('2026-05-05T08:00:00.000Z');
|
|
|
|
function row(id: string, colonyId: string) {
|
|
return {
|
|
id,
|
|
ruleKey: 'INSPECTION_INTERVAL',
|
|
source: 'RULE',
|
|
mode: 'DO',
|
|
intent: 'INSPECT',
|
|
status: 'OPEN',
|
|
priority: 'NORMAL',
|
|
relatedType: 'COLONY',
|
|
relatedId: colonyId,
|
|
colonyId,
|
|
colony: { code: 'B3', apiaryId: 'api_1', apiary: { name: 'Hang' } },
|
|
reasonCode: 'UNKNOWN_CODE',
|
|
evidence: { colonyId },
|
|
requiredPermission: 'inspections:read',
|
|
dueDate: new Date('2026-05-07T00:00:00.000Z'),
|
|
expiresAt: null,
|
|
plan: null,
|
|
decidedAt: null,
|
|
decisionNote: null,
|
|
createdAt: NOW,
|
|
};
|
|
}
|
|
|
|
function build(rows: ReturnType<typeof row>[]) {
|
|
const suggestionFindMany = jest.fn().mockResolvedValue(rows);
|
|
const colonyFindMany = jest
|
|
.fn()
|
|
.mockResolvedValue([{ id: 'col_1', code: 'B3' }]);
|
|
const prisma = {
|
|
suggestion: { findMany: suggestionFindMany, groupBy: jest.fn() },
|
|
colony: { findMany: colonyFindMany },
|
|
queen: { findMany: jest.fn().mockResolvedValue([]) },
|
|
treatment: { findMany: jest.fn().mockResolvedValue([]) },
|
|
apiary: { findMany: jest.fn().mockResolvedValue([]) },
|
|
stockItem: { findMany: jest.fn().mockResolvedValue([]) },
|
|
automationRule: { findMany: jest.fn().mockResolvedValue([]) },
|
|
$queryRaw: jest.fn().mockResolvedValue([{ cutoff: NOW }]),
|
|
};
|
|
const resolver = {
|
|
getPermissions: jest.fn().mockResolvedValue(new Set(['inspections:read'])),
|
|
};
|
|
const service = new SuggestionViewsService(
|
|
prisma as unknown as PrismaService,
|
|
resolver as unknown as PermissionResolverService,
|
|
);
|
|
return { service, prisma, suggestionFindMany, colonyFindMany };
|
|
}
|
|
|
|
describe('SuggestionViewsService', () => {
|
|
it('filters by requiredPermission in the query, not after loading', async () => {
|
|
const { service, suggestionFindMany } = build([]);
|
|
|
|
await service.list('user_1', {});
|
|
|
|
const [args] = suggestionFindMany.mock.calls[0] as [
|
|
{ where: { AND: unknown[] } },
|
|
];
|
|
expect(args.where.AND).toContainEqual({
|
|
OR: [
|
|
{ requiredPermission: null },
|
|
{ requiredPermission: { in: ['inspections:read'] } },
|
|
],
|
|
});
|
|
expect(service.visibleWhere(new Set(['a:read']))).toEqual({
|
|
OR: [
|
|
{ requiredPermission: null },
|
|
{ requiredPermission: { in: ['a:read'] } },
|
|
],
|
|
});
|
|
});
|
|
|
|
it('renders title and reasons with one lookup for all rows', async () => {
|
|
const { service, colonyFindMany } = build([
|
|
row('s1', 'col_1'),
|
|
row('s2', 'col_1'),
|
|
row('s3', 'col_1'),
|
|
]);
|
|
|
|
const views = await service.list('user_1', {});
|
|
|
|
expect(views.map((view) => view.id)).toEqual(['s1', 's2', 's3']);
|
|
expect(views[0]).toMatchObject({
|
|
title: 'Durchsicht fällig',
|
|
colonyCode: 'B3',
|
|
apiaryId: 'api_1',
|
|
apiaryName: 'Hang',
|
|
dueDate: '2026-05-07',
|
|
cleanup: null,
|
|
});
|
|
expect(colonyFindMany).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('lists a dismissed suggestion only within 30 days of its decision', async () => {
|
|
const { service, suggestionFindMany, prisma } = build([]);
|
|
|
|
await service.list('user_1', { status: 'DISMISSED' });
|
|
|
|
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
|
|
const [dismissedArgs] = suggestionFindMany.mock.calls[0] as [
|
|
{ where: unknown },
|
|
];
|
|
expect(JSON.stringify(dismissedArgs.where)).toContain(
|
|
'"decidedAt":{"gte":"2026-05-05T08:00:00.000Z"}',
|
|
);
|
|
});
|
|
|
|
it('reads the cleanup tasks from evidence.openTaskIds', async () => {
|
|
const cleanup = {
|
|
...row('s9', 'col_1'),
|
|
intent: 'CLEANUP',
|
|
evidence: { colonyId: 'col_1', openTaskIds: ['t1', 't2'], queenId: 'q1' },
|
|
};
|
|
const { service } = build([cleanup]);
|
|
|
|
const [view] = await service.list('user_1', {});
|
|
|
|
expect(view.cleanup).toEqual({ taskIds: ['t1', 't2'], queenId: 'q1' });
|
|
});
|
|
|
|
it('passes limit (default 200) and offset to the query', async () => {
|
|
const { service, suggestionFindMany } = build([]);
|
|
|
|
await service.list('user_1', {});
|
|
await service.list('user_1', { limit: 5, offset: 10 });
|
|
|
|
expect((suggestionFindMany.mock.calls[0] as unknown[])[0]).toMatchObject({
|
|
take: 200,
|
|
skip: 0,
|
|
});
|
|
expect((suggestionFindMany.mock.calls[1] as unknown[])[0]).toMatchObject({
|
|
take: 5,
|
|
skip: 10,
|
|
});
|
|
});
|
|
});
|