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
@@ -1,18 +1,30 @@
|
||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
import { assertAppLink } from './notification-link';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
|
||||
type CreateManyArg = { data: { userId: string; message: string }[] };
|
||||
type CreateManyArg = {
|
||||
data: {
|
||||
userId: string;
|
||||
message: string;
|
||||
dedupeKey: string | null;
|
||||
requiredPermissions: string[];
|
||||
category: string;
|
||||
link: string | null;
|
||||
}[];
|
||||
skipDuplicates: boolean;
|
||||
};
|
||||
|
||||
describe('NotificationsService', () => {
|
||||
let service: NotificationsService;
|
||||
let held: Set<string>;
|
||||
let prisma: {
|
||||
notification: {
|
||||
create: jest.Mock;
|
||||
createMany: jest.Mock;
|
||||
createManyAndReturn: jest.Mock;
|
||||
findMany: jest.Mock;
|
||||
findUnique: jest.Mock;
|
||||
update: jest.Mock;
|
||||
findFirstOrThrow: jest.Mock;
|
||||
updateMany: jest.Mock;
|
||||
};
|
||||
user: { findMany: jest.Mock };
|
||||
};
|
||||
@@ -20,177 +32,375 @@ describe('NotificationsService', () => {
|
||||
beforeEach(() => {
|
||||
prisma = {
|
||||
notification: {
|
||||
create: jest.fn(),
|
||||
createMany: jest
|
||||
createManyAndReturn: jest
|
||||
.fn()
|
||||
.mockImplementation(({ data }: CreateManyArg) =>
|
||||
Promise.resolve({ count: data.length }),
|
||||
Promise.resolve(data.map((row, i) => ({ id: `n${i}`, ...row }))),
|
||||
),
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
findFirstOrThrow: jest.fn(),
|
||||
updateMany: jest.fn(),
|
||||
},
|
||||
user: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
};
|
||||
service = new NotificationsService(prisma as unknown as PrismaService);
|
||||
});
|
||||
|
||||
it('records a notification with the given priority and message', async () => {
|
||||
const created = {
|
||||
id: 'n1',
|
||||
priority: 'KRITISCH',
|
||||
message: 'Keine Königin gesehen',
|
||||
};
|
||||
prisma.notification.create.mockResolvedValueOnce(created);
|
||||
|
||||
const result = await service.record({
|
||||
priority: 'KRITISCH',
|
||||
message: 'Keine Königin gesehen',
|
||||
});
|
||||
|
||||
expect(result).toEqual(created);
|
||||
expect(prisma.notification.create).toHaveBeenCalledWith({
|
||||
data: { priority: 'KRITISCH', message: 'Keine Königin gesehen' },
|
||||
});
|
||||
});
|
||||
|
||||
it('records through the transaction client when one is given', async () => {
|
||||
const tx = { notification: { create: jest.fn() } };
|
||||
|
||||
await service.record(
|
||||
{ priority: 'HINWEIS', message: 'Aufgabe fällig' },
|
||||
tx as never,
|
||||
held = new Set();
|
||||
service = new NotificationsService(
|
||||
prisma as unknown as PrismaService,
|
||||
{
|
||||
getPermissions: jest
|
||||
.fn()
|
||||
.mockImplementation(() => Promise.resolve(held)),
|
||||
} as unknown as PermissionResolverService,
|
||||
);
|
||||
});
|
||||
|
||||
expect(tx.notification.create).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.notification.create).not.toHaveBeenCalled();
|
||||
const lastCreate = () =>
|
||||
(
|
||||
prisma.notification.createManyAndReturn.mock.calls[0] as [CreateManyArg]
|
||||
)[0];
|
||||
|
||||
it('record without userId does not compile', () => {
|
||||
// @ts-expect-error userId fehlt
|
||||
void service.record({ priority: 'INFO', category: 'SYSTEM', message: 'm' });
|
||||
});
|
||||
|
||||
describe('record', () => {
|
||||
it('writes one row for an eligible recipient and returns it', async () => {
|
||||
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]);
|
||||
|
||||
const result = await service.record({
|
||||
userId: 'u1',
|
||||
priority: 'KRITISCH',
|
||||
category: 'SYSTEM',
|
||||
message: 'Keine Königin gesehen',
|
||||
dedupeKey: 'k:u1',
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ userId: 'u1', dedupeKey: 'k:u1' });
|
||||
expect(lastCreate().skipDuplicates).toBe(true);
|
||||
expect(lastCreate().data).toEqual([
|
||||
{
|
||||
userId: 'u1',
|
||||
priority: 'KRITISCH',
|
||||
category: 'SYSTEM',
|
||||
message: 'Keine Königin gesehen',
|
||||
relatedType: null,
|
||||
relatedId: null,
|
||||
link: null,
|
||||
requiredPermissions: [],
|
||||
dedupeKey: 'k:u1',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns null for a recipient without the permission', async () => {
|
||||
await expect(
|
||||
service.record({
|
||||
userId: 'u1',
|
||||
priority: 'INFO',
|
||||
category: 'SYSTEM',
|
||||
message: 'm',
|
||||
requiredPermissions: ['colonies:read'],
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
expect(prisma.notification.createManyAndReturn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns null for a repeated dedupe key', async () => {
|
||||
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]);
|
||||
prisma.notification.createManyAndReturn.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
service.record({
|
||||
userId: 'u1',
|
||||
priority: 'INFO',
|
||||
category: 'SYSTEM',
|
||||
message: 'm',
|
||||
dedupeKey: 'k:u1',
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('writes through the transaction client when one is given', async () => {
|
||||
const tx = {
|
||||
user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) },
|
||||
notification: {
|
||||
createManyAndReturn: jest.fn().mockResolvedValue([{ id: 'n' }]),
|
||||
},
|
||||
};
|
||||
|
||||
await service.record(
|
||||
{ userId: 'u1', priority: 'INFO', category: 'SYSTEM', message: 'm' },
|
||||
tx as never,
|
||||
);
|
||||
|
||||
expect(tx.notification.createManyAndReturn).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.notification.createManyAndReturn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an unsafe link before writing', async () => {
|
||||
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]);
|
||||
await expect(
|
||||
service.record({
|
||||
userId: 'u1',
|
||||
priority: 'INFO',
|
||||
category: 'SYSTEM',
|
||||
message: 'm',
|
||||
link: 'https://evil.example',
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
expect(prisma.notification.createManyAndReturn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordForPermission', () => {
|
||||
it('notifies every active user whose active role holds the permission', async () => {
|
||||
const input = {
|
||||
priority: 'WARNUNG' as const,
|
||||
category: 'SYSTEM' as const,
|
||||
message: 'Job „App-Backup“ fehlgeschlagen',
|
||||
};
|
||||
|
||||
it('writes one row per active user holding every permission, with a key per recipient', async () => {
|
||||
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }, { id: 'u2' }]);
|
||||
|
||||
await expect(
|
||||
service.recordForPermission('backup:configure', {
|
||||
priority: 'WARNUNG',
|
||||
message: 'Job „App-Backup“ fehlgeschlagen',
|
||||
}),
|
||||
).resolves.toBe(2);
|
||||
|
||||
const [[createArg]] = prisma.notification.createMany.mock.calls as [
|
||||
[CreateManyArg],
|
||||
];
|
||||
expect(createArg.data.map((n) => n.userId)).toEqual(['u1', 'u2']);
|
||||
expect(createArg.data[0]).toEqual({
|
||||
userId: 'u1',
|
||||
priority: 'WARNUNG',
|
||||
message: 'Job „App-Backup“ fehlgeschlagen',
|
||||
const rows = await service.recordForPermission('backup:configure', {
|
||||
...input,
|
||||
dedupeKeyPrefix: 'p',
|
||||
});
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(lastCreate().data.map((r) => [r.userId, r.dedupeKey])).toEqual([
|
||||
['u1', 'p:u1'],
|
||||
['u2', 'p:u2'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('selects recipients like the permission guard: active user, active role, granted key', async () => {
|
||||
await service.recordForPermission('backup:configure', {
|
||||
priority: 'WARNUNG',
|
||||
message: 'x',
|
||||
});
|
||||
it('selects recipients like the permission guard: active user, active role, every key granted', async () => {
|
||||
await service.recordForPermission(
|
||||
['backup:configure', 'colonies:read'],
|
||||
input,
|
||||
);
|
||||
|
||||
expect(prisma.user.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
isActive: true,
|
||||
role: {
|
||||
status: 'ACTIVE',
|
||||
permissions: {
|
||||
some: { permission: { key: 'backup:configure' } },
|
||||
},
|
||||
AND: [
|
||||
{
|
||||
permissions: {
|
||||
some: { permission: { key: 'backup:configure' } },
|
||||
},
|
||||
},
|
||||
{
|
||||
permissions: { some: { permission: { key: 'colonies:read' } } },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
});
|
||||
|
||||
it('writes nothing and returns 0 when nobody holds the permission', async () => {
|
||||
it('stores the union of recipient and content permissions', async () => {
|
||||
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]);
|
||||
|
||||
await service.recordForPermission('colonies:read', {
|
||||
...input,
|
||||
requiredPermissions: ['inspections:read', 'colonies:read'],
|
||||
});
|
||||
|
||||
expect(lastCreate().data[0].requiredPermissions).toEqual([
|
||||
'colonies:read',
|
||||
'inspections:read',
|
||||
]);
|
||||
});
|
||||
|
||||
it('throws for an empty permission list instead of broadcasting', async () => {
|
||||
await expect(service.recordForPermission([], input)).rejects.toThrow();
|
||||
expect(prisma.user.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes nothing when nobody holds the permission', async () => {
|
||||
await expect(
|
||||
service.recordForPermission('backup:configure', input),
|
||||
).resolves.toEqual([]);
|
||||
expect(prisma.notification.createManyAndReturn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a repeated dedupe key writes nothing', async () => {
|
||||
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]);
|
||||
prisma.notification.createManyAndReturn.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
service.recordForPermission('backup:configure', {
|
||||
priority: 'WARNUNG',
|
||||
message: 'x',
|
||||
...input,
|
||||
dedupeKeyPrefix: 'p',
|
||||
}),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(prisma.notification.createMany).not.toHaveBeenCalled();
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('reads and writes through the transaction client when one is given', async () => {
|
||||
const tx = {
|
||||
user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) },
|
||||
notification: {
|
||||
createMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
createManyAndReturn: jest.fn().mockResolvedValue([{ id: 'n1' }]),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
service.recordForPermission(
|
||||
'backup:configure',
|
||||
{ priority: 'WARNUNG', message: 'x' },
|
||||
tx as never,
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
service.recordForPermission('backup:configure', input, tx as never),
|
||||
).resolves.toHaveLength(1);
|
||||
|
||||
expect(tx.notification.createMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.user.findMany).not.toHaveBeenCalled();
|
||||
expect(prisma.notification.createMany).not.toHaveBeenCalled();
|
||||
expect(prisma.notification.createManyAndReturn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('lists notifications for a user including broadcast notifications', async () => {
|
||||
await service.findAll({ userId: 'user-1' });
|
||||
describe('recordForUsers', () => {
|
||||
it('skips users missing a required permission', async () => {
|
||||
prisma.user.findMany.mockResolvedValue([{ id: 'u2' }]);
|
||||
|
||||
const [[queryArg]] = prisma.notification.findMany.mock.calls as [
|
||||
[{ where: { OR?: { userId: string | null }[] } }],
|
||||
];
|
||||
expect(queryArg.where.OR).toEqual([{ userId: 'user-1' }, { userId: null }]);
|
||||
});
|
||||
const rows = await service.recordForUsers(['u1', 'u2'], {
|
||||
priority: 'INFO',
|
||||
category: 'SUGGESTION',
|
||||
message: 'm',
|
||||
requiredPermissions: ['colonies:read'],
|
||||
});
|
||||
|
||||
it('throws NotFoundException when marking an unknown notification as read', async () => {
|
||||
prisma.notification.findUnique.mockResolvedValueOnce(null);
|
||||
await expect(service.markRead('missing', 'user-1')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
expect(prisma.user.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
id: { in: ['u1', 'u2'] },
|
||||
}) as unknown,
|
||||
}),
|
||||
);
|
||||
expect(rows.map((r) => r.userId)).toEqual(['u2']);
|
||||
});
|
||||
|
||||
it("throws ForbiddenException when marking another user's notification as read", async () => {
|
||||
prisma.notification.findUnique.mockResolvedValueOnce({
|
||||
id: 'n1',
|
||||
userId: 'owner',
|
||||
read: false,
|
||||
it('does nothing for an empty user list', async () => {
|
||||
await expect(
|
||||
service.recordForUsers([], {
|
||||
priority: 'INFO',
|
||||
category: 'SYSTEM',
|
||||
message: 'm',
|
||||
}),
|
||||
).resolves.toEqual([]);
|
||||
expect(prisma.user.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
await expect(service.markRead('n1', 'someone-else')).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
it('marks a broadcast notification (no owner) as read for any user', async () => {
|
||||
prisma.notification.findUnique.mockResolvedValueOnce({
|
||||
id: 'n1',
|
||||
userId: null,
|
||||
read: false,
|
||||
describe('reading', () => {
|
||||
it('lists only the own notifications, newest first, at most 100', async () => {
|
||||
await service.findOwn('user-1', { unreadOnly: true });
|
||||
|
||||
expect(prisma.notification.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId: 'user-1', read: false },
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
take: 500,
|
||||
}),
|
||||
);
|
||||
});
|
||||
prisma.notification.update.mockResolvedValueOnce({ id: 'n1', read: true });
|
||||
|
||||
const result = await service.markRead('n1', 'any-user');
|
||||
it('hides a row whose permissions the user no longer holds (revoked after the write)', async () => {
|
||||
const row = (id: string, requiredPermissions: string[]) => ({
|
||||
id,
|
||||
priority: 'INFO',
|
||||
category: 'SYSTEM',
|
||||
message: 'm',
|
||||
relatedType: null,
|
||||
relatedId: null,
|
||||
link: null,
|
||||
read: false,
|
||||
createdAt: new Date(0),
|
||||
requiredPermissions,
|
||||
});
|
||||
prisma.notification.findMany.mockResolvedValue([
|
||||
row('open', []),
|
||||
row('revoked', ['colonies:read', 'varroa:read']),
|
||||
row('kept', ['colonies:read']),
|
||||
]);
|
||||
held.add('colonies:read');
|
||||
|
||||
expect(result.read).toBe(true);
|
||||
});
|
||||
const views = await service.findOwn('user-1');
|
||||
|
||||
it("marks the owner's own notification as read", async () => {
|
||||
prisma.notification.findUnique.mockResolvedValueOnce({
|
||||
id: 'n1',
|
||||
userId: 'user-1',
|
||||
read: false,
|
||||
expect(views.map((view) => view.id)).toEqual(['open', 'kept']);
|
||||
expect(views[0]).not.toHaveProperty('requiredPermissions');
|
||||
});
|
||||
prisma.notification.update.mockResolvedValueOnce({ id: 'n1', read: true });
|
||||
|
||||
const result = await service.markRead('n1', 'user-1');
|
||||
it('selects the view fields plus the permission list for filtering, never the dedupe key', async () => {
|
||||
await service.findOwn('user-1');
|
||||
|
||||
expect(result.read).toBe(true);
|
||||
const [[arg]] = prisma.notification.findMany.mock.calls as [
|
||||
[{ select: Record<string, true> }],
|
||||
];
|
||||
expect(Object.keys(arg.select).sort()).toEqual([
|
||||
'category',
|
||||
'createdAt',
|
||||
'id',
|
||||
'link',
|
||||
'message',
|
||||
'priority',
|
||||
'read',
|
||||
'relatedId',
|
||||
'relatedType',
|
||||
'requiredPermissions',
|
||||
]);
|
||||
});
|
||||
|
||||
it('answers 404 when the notification is foreign or unknown', async () => {
|
||||
prisma.notification.updateMany.mockResolvedValue({ count: 0 });
|
||||
await expect(service.markRead('n1', 'user-1')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
expect(prisma.notification.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: 'n1', userId: 'user-1' },
|
||||
data: { read: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('marks the own notification as read', async () => {
|
||||
prisma.notification.updateMany.mockResolvedValue({ count: 1 });
|
||||
prisma.notification.findFirstOrThrow.mockResolvedValue({
|
||||
id: 'n1',
|
||||
read: true,
|
||||
});
|
||||
|
||||
await expect(service.markRead('n1', 'user-1')).resolves.toMatchObject({
|
||||
read: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('marks only the own unread notifications as read', async () => {
|
||||
prisma.notification.updateMany.mockResolvedValue({ count: 3 });
|
||||
|
||||
await expect(service.markAllRead('user-1')).resolves.toEqual({
|
||||
updated: 3,
|
||||
});
|
||||
expect(prisma.notification.updateMany).toHaveBeenCalledWith({
|
||||
where: { userId: 'user-1', read: false },
|
||||
data: { read: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertAppLink', () => {
|
||||
it('accepts a relative app path', () => {
|
||||
expect(() => assertAppLink('/tasks?status=OPEN&x=1')).not.toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
'//evil.example',
|
||||
'https://evil.example',
|
||||
'/\\evil',
|
||||
'tasks',
|
||||
'/a b',
|
||||
])('rejects %s', (link) => expect(() => assertAppLink(link)).toThrow());
|
||||
|
||||
it('rejects a link over 300 characters', () => {
|
||||
expect(() => assertAppLink(`/${'a'.repeat(300)}`)).toThrow();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user