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]>
710 lines
23 KiB
TypeScript
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();
|
|
});
|
|
});
|