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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 14:25:24 +02:00
1 parent d99a697739
commit f468dc5f75
87 files changed
+6022 -510

No files matched your search

@@ -0,0 +1,183 @@
import { BadRequestException } from '@nestjs/common';
import { NotificationPreferencesService } from './notification-preferences.service';
import { PrismaService } from '../prisma/prisma.service';
import { lockRowForUpdate } from '../prisma/row-lock';
jest.mock('../prisma/row-lock', () => ({
lockRowForUpdate: jest.fn().mockResolvedValue(true),
}));
const stored = {
userId: 'u1',
taskDueEnabled: true,
digestEnabled: true,
digestTime: '06:30',
quietFrom: '21:00' as string | null,
quietUntil: '06:30' as string | null,
telegramEnabled: false,
telegramCategories: ['TASK_DUE', 'DIGEST'],
telegramDetail: true,
updatedAt: new Date(0),
};
describe('NotificationPreferencesService', () => {
let service: NotificationPreferencesService;
let tx: {
notificationPreference: {
upsert: jest.Mock;
findUniqueOrThrow: jest.Mock;
update: jest.Mock;
};
};
let prisma: {
notificationPreference: { upsert: jest.Mock };
$transaction: jest.Mock;
};
const order: string[] = [];
beforeEach(() => {
order.length = 0;
(lockRowForUpdate as jest.Mock).mockImplementation(() => {
order.push('lock');
return Promise.resolve(true);
});
tx = {
notificationPreference: {
upsert: jest.fn().mockImplementation(() => {
order.push('upsert');
return Promise.resolve(stored);
}),
findUniqueOrThrow: jest.fn().mockImplementation(() => {
order.push('read');
return Promise.resolve(stored);
}),
update: jest
.fn()
.mockImplementation(({ data }: { data: object }) =>
Promise.resolve({ ...stored, ...data }),
),
},
};
prisma = {
notificationPreference: {
upsert: jest.fn().mockResolvedValue(stored),
},
$transaction: jest
.fn()
.mockImplementation((work: (t: unknown) => Promise<unknown>) =>
work(tx),
),
};
service = new NotificationPreferencesService(
prisma as unknown as PrismaService,
);
});
it('creates the defaults on first read', async () => {
await expect(service.getOrCreate('u1')).resolves.toBe(stored);
expect(prisma.notificationPreference.upsert).toHaveBeenCalledWith({
where: { userId: 'u1' },
create: { userId: 'u1' },
update: {},
});
});
it('uses the given client for the first read', async () => {
await service.getOrCreate('u1', tx as never);
expect(tx.notificationPreference.upsert).toHaveBeenCalledTimes(1);
expect(prisma.notificationPreference.upsert).not.toHaveBeenCalled();
});
it.each(['04:30', '12:01', '00:00', '23:59'])(
'rejects %s as digest time with the German message',
async (digestTime) => {
const failure = service.update('u1', { digestTime });
await expect(failure).rejects.toBeInstanceOf(BadRequestException);
await expect(failure).rejects.toThrow(
'Bitte eine Uhrzeit zwischen 05:00 und 12:00 wählen.',
);
expect(tx.notificationPreference.update).not.toHaveBeenCalled();
},
);
it.each(['05:00', '12:00', '09:15'])(
'accepts %s as digest time',
async (digestTime) => {
await service.update('u1', { digestTime });
expect(tx.notificationPreference.update).toHaveBeenCalledWith({
where: { userId: 'u1' },
data: expect.objectContaining({ digestTime }) as unknown,
});
},
);
it('clears quiet hours only as a pair', async () => {
const failure = service.update('u1', { quietFrom: null });
await expect(failure).rejects.toBeInstanceOf(BadRequestException);
await expect(failure).rejects.toThrow(
'Bitte Beginn und Ende der Ruhezeit zusammen leeren.',
);
await service.update('u1', { quietFrom: null, quietUntil: null });
expect(tx.notificationPreference.update).toHaveBeenCalledWith({
where: { userId: 'u1' },
data: expect.objectContaining({
quietFrom: null,
quietUntil: null,
}) as unknown,
});
});
it('judges the pair rule on the merged values, not on the dto alone', async () => {
tx.notificationPreference.findUniqueOrThrow.mockResolvedValue({
...stored,
quietFrom: null,
quietUntil: null,
});
await expect(
service.update('u1', { quietFrom: '22:00' }),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
service.update('u1', { quietFrom: '22:00', quietUntil: '05:30' }),
).resolves.toBeDefined();
});
it('update locks the preference row by userId and merges with the stored values read inside the transaction', async () => {
tx.notificationPreference.findUniqueOrThrow.mockImplementation(() => {
order.push('read');
return Promise.resolve({ ...stored, digestTime: '07:00' });
});
const result = await service.update('u1', { telegramDetail: false });
expect(order).toEqual(['upsert', 'lock', 'read']);
expect(lockRowForUpdate).toHaveBeenCalledWith(
tx,
'NotificationPreference',
'u1',
);
expect(tx.notificationPreference.update).toHaveBeenCalledWith({
where: { userId: 'u1' },
data: {
taskDueEnabled: true,
digestEnabled: true,
digestTime: '07:00',
quietFrom: '21:00',
quietUntil: '06:30',
telegramCategories: ['TASK_DUE', 'DIGEST'],
telegramDetail: false,
},
});
expect(result.telegramDetail).toBe(false);
});
it('never writes telegramEnabled', async () => {
await service.update('u1', { telegramCategories: ['SYSTEM'] });
const [[arg]] = tx.notificationPreference.update.mock.calls as [
[{ data: Record<string, unknown> }],
];
expect(arg.data).not.toHaveProperty('telegramEnabled');
expect(arg.data.telegramCategories).toEqual(['SYSTEM']);
});
});