Files
ApiCore/ApiCore-Backend/src/notifications/notifications.service.spec.ts
T
Nick SzaunigandClaude Opus 5.5 1a57de8573 feat: Beute L3–L5 (3D-Szene, Umbauen, Durchsicht, offline) und Diktat (T7 L1)
Beute L3 Runden 2+3 und Review-Fixes:
- Glas-Holo ohne warmes Leuchten, Wanderboden-Fuss, Fokusflug mit
  Geist-Ansicht, Inhalte im Fokus, Rähmchen mit Art-Farbe beim Anheben
- Hinweis-Geometrie je Zarge nur einmal je Layout gebaut; Drehen mit
  Pointer-Capture, endet auch bei Loslassen ausserhalb (rig-pointer.ts)
- Tiefenunschärfe ab 55 fps, Leistungsprobe bei jeder Bewegungsstufe,
  Context-Loss-Listener aufgeräumt, Trefferboxen als skalierte Einheitsbox

Beute L4 Umbauen (/colonies/[id]/hive): Entwurf mit Basis-Schnappschuss
beim ersten Schritt, Rückgängig/Wiederholen, Hinweis bei Serverstand-
Änderung, Konfliktblatt.

Beute L5:
- Durchsicht-Abschnitt „Beute“: Aufbau-Änderungen in der Durchsicht,
  Blatt-Formulare stoppen das Submit-Bubbling durch Portale (sonst
  speicherte „Speichern“ im Blatt die ganze Durchsicht), Fokus nach
  Rückgängig, Folgevorschläge beim Speichern
- Offline: Beute im Offline-Paket, Warteschlange pending-hive-changes
  (DB v5), Abarbeiten in Reihenfolge, keine Online-Änderung überholt
  wartende, Hinweis bei abgelehnter Änderung mit „Warteschlange öffnen“

T7 L1 Diktat: Spracherkennungs-Kapsel (Web Speech API), Einwilligung
standardmässig aus, Diktat-Knopf an Freitextfeldern, Seite
„Sprache & Datenschutz“; axe-core als exakte devDependency.

Backend: NotificationsService liest Empfänger und schreibt in einer
Transaktion mit FOR KEY SHARE (paralleles Löschen eines Benutzers
führte zu P2003 und verzögerten Vorschlägen; Ursache der wackeligen
Endtests). Zwei e2e-Prüfungen gegen geteilten DB-Zustand entschärft.

Gates: Frontend lint, typecheck, 2740 Tests, build; Backend lint,
typecheck, 4266 Unit-Tests, build; parallele e2e 3x grün (978).
Browser-Prüfung 1280/390 px, Tastatur, offline, reduced motion.

Refs #19 #20 #21 #22 #27 #32

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 09:12:04 +02:00

710 lines
23 KiB
TypeScript

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';
import { NotificationDeliveryPlanner } from './delivery-planner';
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 planner: { planIn: jest.Mock };
let prisma: {
notification: {
createManyAndReturn: jest.Mock;
findMany: jest.Mock;
findFirstOrThrow: jest.Mock;
findFirst: jest.Mock;
updateMany: jest.Mock;
};
user: { findMany: jest.Mock };
$connect: jest.Mock;
$transaction: jest.Mock;
$queryRaw: jest.Mock;
};
beforeEach(() => {
planner = { planIn: jest.fn().mockResolvedValue(0) };
prisma = {
notification: {
createManyAndReturn: jest
.fn()
.mockImplementation(({ data }: CreateManyArg) =>
Promise.resolve(data.map((row, i) => ({ id: `n${i}`, ...row }))),
),
findMany: jest.fn().mockResolvedValue([]),
findFirstOrThrow: jest.fn(),
findFirst: jest.fn(),
updateMany: jest.fn(),
},
user: { findMany: jest.fn().mockResolvedValue([]) },
// A transaction client lacks $connect; the root client has it.
$connect: jest.fn(),
$transaction: jest.fn(),
// The FOR KEY SHARE lock on the recipients: every user still exists.
$queryRaw: jest.fn().mockImplementation(() => {
const found = prisma.user.findMany.mock.results.at(-1);
return Promise.resolve(found ? (found.value as unknown) : []);
}),
};
prisma.$transaction.mockImplementation((work: (tx: unknown) => unknown) => {
// A transaction client has no $connect; the code tells the two apart.
const txClient = Object.fromEntries(
Object.entries(prisma).filter(([key]) => key !== '$connect'),
);
return Promise.resolve(work(txClient));
});
held = new Set();
service = new NotificationsService(
prisma as unknown as PrismaService,
{
getPermissions: jest
.fn()
.mockImplementation(() => Promise.resolve(held)),
} as unknown as PermissionResolverService,
planner as unknown as NotificationDeliveryPlanner,
);
});
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('recipient locking', () => {
it('locks the recipients in the write transaction and skips a user deleted meanwhile', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }, { id: 'u2' }]);
prisma.$queryRaw.mockResolvedValue([{ id: 'u1' }]);
const rows = await service.recordForUsers(['u1', 'u2'], {
priority: 'INFO',
category: 'SYSTEM',
message: 'm',
});
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
expect(
(prisma.$queryRaw.mock.calls[0] as [{ sql: string }])[0].sql,
).toContain('FOR KEY SHARE');
expect(rows.map((row) => (row as { userId: string }).userId)).toEqual([
'u1',
]);
});
});
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' }]) },
$queryRaw: 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('Telegram delivery planning', () => {
const base = {
priority: 'INFO' as const,
category: 'SYSTEM' as const,
message: 'm',
};
it('plans the deliveries of the inserted rows right after the insert', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }, { id: 'u2' }]);
const rows = await service.recordForUsers(['u1', 'u2'], base);
expect(planner.planIn).toHaveBeenCalledTimes(1);
expect(planner.planIn).toHaveBeenCalledWith(
expect.objectContaining({ notification: prisma.notification }),
rows,
);
expect(
prisma.notification.createManyAndReturn.mock.invocationCallOrder[0],
).toBeLessThan(planner.planIn.mock.invocationCallOrder[0]);
});
it('plans with the caller transaction and opens none of its own', async () => {
const tx = {
user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) },
$queryRaw: jest.fn().mockResolvedValue([{ id: 'u1' }]),
notification: {
createManyAndReturn: jest.fn().mockResolvedValue([{ id: 'n' }]),
},
};
await service.record({ userId: 'u1', ...base }, tx as never);
expect(planner.planIn).toHaveBeenCalledWith(tx, [{ id: 'n' }]);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
it('without a caller transaction, rows and deliveries share one of their own', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]);
await service.record({ userId: 'u1', ...base });
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(planner.planIn).toHaveBeenCalledTimes(1);
});
it('plans only what was inserted: a repeated dedupe key plans nothing new', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]);
prisma.notification.createManyAndReturn.mockResolvedValue([]);
await service.record({ userId: 'u1', ...base, dedupeKey: 'k:u1' });
expect(planner.planIn).toHaveBeenCalledWith(expect.anything(), []);
});
it('plans nothing when no recipient is eligible', async () => {
await service.recordForUsers(['u1'], base);
expect(planner.planIn).not.toHaveBeenCalled();
});
});
describe('recordForPermission', () => {
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' }]);
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, every key granted', async () => {
await service.recordForPermission(
['backup:configure', 'colonies:read'],
input,
);
expect(prisma.user.findMany).toHaveBeenCalledWith({
where: {
isActive: true,
role: {
status: 'ACTIVE',
AND: [
{
permissions: {
some: { permission: { key: 'backup:configure' } },
},
},
{
permissions: { some: { permission: { key: 'colonies:read' } } },
},
],
},
},
select: { id: true },
orderBy: { id: 'asc' },
});
});
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', {
...input,
dedupeKeyPrefix: 'p',
}),
).resolves.toEqual([]);
});
it('reads and writes through the transaction client when one is given', async () => {
const tx = {
user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) },
$queryRaw: jest.fn().mockResolvedValue([{ id: 'u1' }]),
notification: {
createManyAndReturn: jest.fn().mockResolvedValue([{ id: 'n1' }]),
},
};
await expect(
service.recordForPermission('backup:configure', input, tx as never),
).resolves.toHaveLength(1);
expect(prisma.user.findMany).not.toHaveBeenCalled();
expect(prisma.notification.createManyAndReturn).not.toHaveBeenCalled();
});
});
describe('recordForPermissionOnce', () => {
const input = {
priority: 'HINWEIS' as const,
category: 'COMPLIANCE' as const,
message: 'Bestand geändert',
relatedType: 'ComplianceItem',
relatedId: 'herd-change',
link: '/compliance',
};
let calls: string[];
let tx: {
$executeRaw: jest.Mock;
$queryRaw: jest.Mock;
user: { findMany: jest.Mock };
notification: { findMany: jest.Mock; createManyAndReturn: jest.Mock };
};
beforeEach(() => {
calls = [];
tx = {
$executeRaw: jest.fn().mockImplementation(() => {
calls.push('lock');
return Promise.resolve(1);
}),
// The key-share lock keeps every user the read just returned.
$queryRaw: jest.fn().mockImplementation(() => {
const read = tx.user.findMany.mock.results.at(-1);
return Promise.resolve(read ? (read.value as unknown) : []);
}),
user: {
// Like the database: the id filter narrows the three eligible users.
findMany: jest
.fn()
.mockImplementation(
({ where }: { where: { id?: { in: string[] } } }) =>
Promise.resolve(
['u1', 'u2', 'u3']
.filter((id) => !where.id || where.id.in.includes(id))
.map((id) => ({ id })),
),
),
},
notification: {
findMany: jest.fn().mockImplementation(() => {
calls.push('unread');
return Promise.resolve([{ userId: 'u2' }]);
}),
createManyAndReturn: jest
.fn()
.mockImplementation(({ data }: CreateManyArg) =>
Promise.resolve(data.map((row, i) => ({ id: `n${i}`, ...row }))),
),
},
};
});
it('skips users with an unread notification of the same topic', async () => {
const rows = await service.recordForPermissionOnce(
'compliance:manage',
input,
tx as never,
);
expect(rows.map((row) => (row as { userId: string }).userId)).toEqual([
'u1',
'u3',
]);
expect(tx.notification.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
relatedType: 'ComplianceItem',
relatedId: 'herd-change',
read: false,
}) as unknown,
}),
);
});
it('writes again after the user read the earlier one', async () => {
tx.notification.findMany.mockResolvedValue([]);
const rows = await service.recordForPermissionOnce(
'compliance:manage',
input,
tx as never,
);
expect(rows).toHaveLength(3);
});
it('takes the advisory lock before reading the unread rows', async () => {
await service.recordForPermissionOnce(
'compliance:manage',
input,
tx as never,
);
expect(calls).toEqual(['lock', 'unread']);
});
it('stores no dedupe key and the recipient permission', async () => {
await service.recordForPermissionOnce(
'compliance:manage',
input,
tx as never,
);
const [arg] = tx.notification.createManyAndReturn.mock.calls[0] as [
CreateManyArg,
];
expect(arg.data.map((row) => row.dedupeKey)).toEqual([null, null]);
expect(arg.data[0].requiredPermissions).toEqual(['compliance:manage']);
});
it('plans the Telegram delivery for the new rows only', async () => {
const rows = await service.recordForPermissionOnce(
'compliance:manage',
input,
tx as never,
);
expect(planner.planIn).toHaveBeenCalledTimes(1);
expect(planner.planIn).toHaveBeenCalledWith(tx, rows);
expect(rows).toHaveLength(2);
});
it('writes nothing and plans nothing when everybody has an unread one', async () => {
tx.notification.findMany.mockResolvedValue([
{ userId: 'u1' },
{ userId: 'u2' },
{ userId: 'u3' },
]);
await expect(
service.recordForPermissionOnce(
'compliance:manage',
input,
tx as never,
),
).resolves.toEqual([]);
expect(tx.notification.createManyAndReturn).not.toHaveBeenCalled();
expect(planner.planIn).not.toHaveBeenCalled();
});
it('throws for an empty permission list instead of broadcasting', async () => {
await expect(
service.recordForPermissionOnce([], input, tx as never),
).rejects.toThrow();
});
});
it('recordForPermission is unchanged: it never reads unread rows', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]);
await service.recordForPermission('compliance:manage', {
priority: 'INFO',
category: 'SYSTEM',
message: 'm',
});
expect(prisma.notification.findMany).not.toHaveBeenCalled();
});
describe('recordForUsers', () => {
it('skips users missing a required permission', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'u2' }]);
const rows = await service.recordForUsers(['u1', 'u2'], {
priority: 'INFO',
category: 'SUGGESTION',
message: 'm',
requiredPermissions: ['colonies:read'],
});
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('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();
});
});
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,
}),
);
});
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');
const views = await service.findOwn('user-1');
expect(views.map((view) => view.id)).toEqual(['open', 'kept']);
expect(views[0]).not.toHaveProperty('requiredPermissions');
});
it('selects the view fields plus the permission list for filtering, never the dedupe key', async () => {
await service.findOwn('user-1');
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.findFirst.mockResolvedValue(null);
await expect(service.markRead('n1', 'user-1')).rejects.toBeInstanceOf(
NotFoundException,
);
expect(prisma.notification.findFirst).toHaveBeenCalledWith({
where: { id: 'n1', userId: 'user-1' },
select: { requiredPermissions: true },
});
expect(prisma.notification.updateMany).not.toHaveBeenCalled();
});
it('answers 404 and changes nothing for a row whose permissions are no longer held', async () => {
prisma.notification.findFirst.mockResolvedValue({
requiredPermissions: ['colonies:read'],
});
await expect(service.markRead('n1', 'user-1')).rejects.toBeInstanceOf(
NotFoundException,
);
expect(prisma.notification.updateMany).not.toHaveBeenCalled();
expect(prisma.notification.findFirstOrThrow).not.toHaveBeenCalled();
});
it('marks the own visible notification as read', async () => {
held.add('colonies:read');
prisma.notification.findFirst.mockResolvedValue({
requiredPermissions: ['colonies:read'],
});
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,
});
expect(prisma.notification.updateMany).toHaveBeenCalledWith({
where: { id: 'n1', userId: 'user-1' },
data: { read: true },
});
});
it('marks only the own unread notifications the user may see', async () => {
held.add('colonies:read');
prisma.notification.findMany.mockResolvedValue([
{ id: 'a', requiredPermissions: [] },
{ id: 'b', requiredPermissions: ['colonies:read'] },
{ id: 'c', requiredPermissions: ['varroa:read'] },
]);
prisma.notification.updateMany.mockResolvedValue({ count: 2 });
await expect(service.markAllRead('user-1')).resolves.toEqual({
updated: 2,
});
expect(prisma.notification.findMany).toHaveBeenCalledWith({
where: { userId: 'user-1', read: false },
select: { id: true, requiredPermissions: true },
});
expect(prisma.notification.updateMany).toHaveBeenCalledWith({
where: { id: { in: ['a', 'b'] }, 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();
});
});