feat: T4 wave 3 - notifications per user, suggestions API, varroa bands, split/queen events
- 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]>
This commit is contained in:
1 parent
d99a697739
commit
f468dc5f75
87 files changed
+6022
-510
No files matched your search
@@ -0,0 +1,143 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user