Files
tessera-ctl/apps/api/src/nextcloud-status/nextcloud-alert.service.spec.ts
T
schalli faed0d760f feat(nextcloud-status): Benachrichtigung abonnieren und Mail bei Störung
- Glocke je Kachel (persönlich, Benutzen-Ebene), Tabelle NextcloudAlertSubscription mit RLS
- Zwei-Fehlschläge-Regel und gemeldeter Zustand auf der Zeile, Anspruch vor dem Mailversand
- Mail (nur Deutsch) über MailService, bis zu drei Versuche, Zugriff beim Senden erneut geprüft
- Fehlercodes in der Mail als lesbarer Hinweis

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 15:23:34 +02:00

317 lines
12 KiB
TypeScript

import { NotFoundException } from '@nestjs/common';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((p: unknown) => p),
forSystem: vi.fn((p: unknown) => p),
}));
import { forTenant } from '../prisma/prisma-tenant.extension';
import { ALERT_MAIL_RETRY_MS, NextcloudAlertService } from './nextcloud-alert.service';
import type { NextcloudRating } from './nextcloud-rating';
const NOW = new Date('2026-10-02T12:30:00Z');
const RED: NextcloudRating = {
level: 'red',
reason: 'unreachable',
updateTo: null,
eolDate: null,
cycle: null,
};
const GREEN: NextcloudRating = {
level: 'green',
reason: 'current',
updateTo: null,
eolDate: null,
cycle: 35,
};
const ROW = {
id: 'i1',
customerName: 'Kunde A',
baseUrl: 'https://cloud.a.de',
errorKind: 'network',
errorDetail: 'ECONNREFUSED',
alertState: 'ok',
};
function makeService() {
const prisma = {
nextcloudInstance: { findFirst: vi.fn(), updateMany: vi.fn() },
nextcloudAlertSubscription: {
findMany: vi.fn().mockResolvedValue([]),
upsert: vi.fn(),
deleteMany: vi.fn(),
},
user: { findMany: vi.fn().mockResolvedValue([]) },
};
const mail = { sendNextcloudAlertEmail: vi.fn().mockResolvedValue(true) };
const settings = { getSmtpConfig: vi.fn().mockResolvedValue({ host: 'smtp.example.invalid' }) };
const moduleAccess = {
getModuleAccessLevels: vi.fn().mockResolvedValue(new Map([['mod1', 'USE']])),
};
const moduleRegistry = { findBySlug: vi.fn().mockResolvedValue({ id: 'mod1' }) };
const service = new NextcloudAlertService(
prisma as never,
mail as never,
settings as never,
moduleAccess as never,
moduleRegistry as never,
);
const sleep = vi.fn().mockResolvedValue(undefined);
service.sleep = sleep;
const logSpy = vi.spyOn((service as any).logger, 'log').mockImplementation(() => undefined);
const warnSpy = vi.spyOn((service as any).logger, 'warn').mockImplementation(() => undefined);
vi.spyOn((service as any).logger, 'error').mockImplementation(() => undefined);
return { prisma, mail, settings, moduleAccess, moduleRegistry, service, sleep, logSpy, warnSpy };
}
function withSubscriber(ctx: ReturnType<typeof makeService>, user: Record<string, unknown> = {}) {
ctx.prisma.nextcloudInstance.updateMany.mockResolvedValue({ count: 1 });
ctx.prisma.nextcloudAlertSubscription.findMany.mockResolvedValue([{ userId: 'u1' }]);
ctx.prisma.user.findMany.mockResolvedValue([
{ id: 'u1', email: 'u1@example.invalid', role: 'USER', isActive: true, ...user },
]);
}
describe('NextcloudAlertService Abonnements', () => {
let ctx: ReturnType<typeof makeService>;
beforeEach(() => {
vi.mocked(forTenant).mockClear();
ctx = makeService();
});
it('subscribe ist idempotent (upsert) und bindet Benutzer und Mandant ueber den Klienten', async () => {
ctx.prisma.nextcloudInstance.findFirst.mockResolvedValue({ id: 'i1' });
expect(await ctx.service.subscribe('t1', 'u1', 'i1')).toEqual({ subscribed: true });
expect(await ctx.service.subscribe('t1', 'u1', 'i1')).toEqual({ subscribed: true });
expect(forTenant).toHaveBeenCalledWith(ctx.prisma, 't1', 'u1');
expect(ctx.prisma.nextcloudInstance.findFirst.mock.calls[0][0].where).toEqual({
id: 'i1',
tenantId: 't1',
});
const args = ctx.prisma.nextcloudAlertSubscription.upsert.mock.calls[0][0];
expect(args.where).toEqual({ instanceId_userId: { instanceId: 'i1', userId: 'u1' } });
expect(args.create).toEqual({ tenantId: 't1', userId: 'u1', instanceId: 'i1' });
expect(args.update).toEqual({});
});
it('subscribe fuer unbekannte oder fremde Cloud -> 404 und keine Zeile', async () => {
ctx.prisma.nextcloudInstance.findFirst.mockResolvedValue(null);
await expect(ctx.service.subscribe('t1', 'u1', 'fremd')).rejects.toThrow(NotFoundException);
expect(ctx.prisma.nextcloudAlertSubscription.upsert).not.toHaveBeenCalled();
});
it('unsubscribe loescht nur die Zeile des Aufrufers', async () => {
expect(await ctx.service.unsubscribe('t1', 'u1', 'i1')).toEqual({ subscribed: false });
expect(ctx.prisma.nextcloudAlertSubscription.deleteMany).toHaveBeenCalledWith({
where: { tenantId: 't1', userId: 'u1', instanceId: 'i1' },
});
});
it('subscribedInstanceIds liefert nur die Kennungen des Aufrufers', async () => {
ctx.prisma.nextcloudAlertSubscription.findMany.mockResolvedValue([
{ instanceId: 'a' },
{ instanceId: 'b' },
]);
const ids = await ctx.service.subscribedInstanceIds('t1', 'u1');
expect([...ids]).toEqual(['a', 'b']);
expect(ctx.prisma.nextcloudAlertSubscription.findMany.mock.calls[0][0].where).toEqual({
tenantId: 't1',
userId: 'u1',
});
});
});
describe('NextcloudAlertService.evaluateAfterCheck', () => {
let ctx: ReturnType<typeof makeService>;
beforeEach(() => {
ctx = makeService();
});
it('Anspruch gewonnen (count 1): Mail an den berechtigten Abonnenten, Zustand wird auf rot gesetzt', async () => {
withSubscriber(ctx);
const result = await ctx.service.evaluateAfterCheck('t1', ROW, RED, NOW);
expect(result.kind).toBe('down');
await result.delivery;
expect(ctx.prisma.nextcloudInstance.updateMany).toHaveBeenCalledWith({
where: { id: 'i1', tenantId: 't1', alertState: 'ok' },
data: { alertState: 'red', alertReason: 'unreachable', alertChangedAt: NOW },
});
expect(ctx.mail.sendNextcloudAlertEmail).toHaveBeenCalledTimes(1);
expect(ctx.mail.sendNextcloudAlertEmail).toHaveBeenCalledWith(
't1',
'u1@example.invalid',
expect.objectContaining({ kind: 'down', customerName: 'Kunde A', rating: RED, at: NOW }),
);
});
it('Anspruch verloren (count 0): keine Mail, auch kein Abonnentenlesen', async () => {
withSubscriber(ctx);
ctx.prisma.nextcloudInstance.updateMany.mockResolvedValue({ count: 0 });
const result = await ctx.service.evaluateAfterCheck('t1', ROW, RED, NOW);
expect(result).toEqual({ kind: null, delivery: null });
expect(ctx.mail.sendNextcloudAlertEmail).not.toHaveBeenCalled();
expect(ctx.prisma.nextcloudAlertSubscription.findMany).not.toHaveBeenCalled();
});
it('rot -> rot: kein Anspruch, keine Mail', async () => {
const result = await ctx.service.evaluateAfterCheck(
't1',
{ ...ROW, alertState: 'red' },
RED,
NOW,
);
expect(result).toEqual({ kind: null, delivery: null });
expect(ctx.prisma.nextcloudInstance.updateMany).not.toHaveBeenCalled();
});
it('gruen -> gruen und grau aendern nichts', async () => {
await ctx.service.evaluateAfterCheck('t1', ROW, GREEN, NOW);
await ctx.service.evaluateAfterCheck(
't1',
{ ...ROW, alertState: 'red' },
{ ...GREEN, level: 'unknown', reason: 'not-checked' },
NOW,
);
expect(ctx.prisma.nextcloudInstance.updateMany).not.toHaveBeenCalled();
});
it('rot -> gruen: genau eine "wieder in Ordnung"-Mail, Grund wird geleert', async () => {
withSubscriber(ctx);
const result = await ctx.service.evaluateAfterCheck(
't1',
{ ...ROW, alertState: 'red' },
GREEN,
NOW,
);
expect(result.kind).toBe('up');
await result.delivery;
expect(ctx.prisma.nextcloudInstance.updateMany).toHaveBeenCalledWith({
where: { id: 'i1', tenantId: 't1', alertState: 'red' },
data: { alertState: 'ok', alertReason: null, alertChangedAt: NOW },
});
expect(ctx.mail.sendNextcloudAlertEmail).toHaveBeenCalledTimes(1);
expect(ctx.mail.sendNextcloudAlertEmail.mock.calls[0][2]).toMatchObject({
kind: 'up',
rating: GREEN,
});
});
it('der Pruefpfad wartet nicht auf den Versand (Hintergrund)', async () => {
withSubscriber(ctx);
let release!: (v: boolean) => void;
ctx.mail.sendNextcloudAlertEmail.mockImplementation(
() => new Promise<boolean>((resolve) => (release = resolve)),
);
const result = await ctx.service.evaluateAfterCheck('t1', ROW, RED, NOW);
expect(result.kind).toBe('down');
await vi.waitFor(() => expect(ctx.mail.sendNextcloudAlertEmail).toHaveBeenCalledTimes(1));
release(true);
await result.delivery;
});
});
describe('NextcloudAlertService Empfaenger (L-06)', () => {
let ctx: ReturnType<typeof makeService>;
beforeEach(() => {
ctx = makeService();
});
async function run() {
const result = await ctx.service.evaluateAfterCheck('t1', ROW, RED, NOW);
await result.delivery;
}
it('deaktivierter Benutzer: uebersprungen und protokolliert', async () => {
withSubscriber(ctx, { isActive: false });
await run();
expect(ctx.mail.sendNextcloudAlertEmail).not.toHaveBeenCalled();
expect(ctx.logSpy.mock.calls.some((c) => String(c[0]).includes('Benutzer deaktiviert'))).toBe(
true,
);
});
it('ohne E-Mail-Adresse: uebersprungen und protokolliert', async () => {
withSubscriber(ctx, { email: null });
await run();
expect(ctx.mail.sendNextcloudAlertEmail).not.toHaveBeenCalled();
expect(ctx.logSpy.mock.calls.some((c) => String(c[0]).includes('keine E-Mail-Adresse'))).toBe(
true,
);
});
it('Modulzugriff inzwischen entzogen: uebersprungen und protokolliert', async () => {
withSubscriber(ctx);
ctx.moduleAccess.getModuleAccessLevels.mockResolvedValue(new Map());
await run();
expect(ctx.moduleAccess.getModuleAccessLevels).toHaveBeenCalledWith('t1', 'u1', 'USER');
expect(ctx.mail.sendNextcloudAlertEmail).not.toHaveBeenCalled();
expect(ctx.logSpy.mock.calls.some((c) => String(c[0]).includes('kein Modulzugriff'))).toBe(
true,
);
});
it('ohne SMTP-Einrichtung: uebersprungen und protokolliert, kein Zugriffs-Lookup', async () => {
withSubscriber(ctx);
ctx.settings.getSmtpConfig.mockResolvedValue(null);
await run();
expect(ctx.mail.sendNextcloudAlertEmail).not.toHaveBeenCalled();
expect(
ctx.logSpy.mock.calls.some((c) => String(c[0]).includes('kein E-Mail-Versand eingerichtet')),
).toBe(true);
});
it('ein Empfaenger ohne Zugriff haelt den anderen nicht auf', async () => {
ctx.prisma.nextcloudInstance.updateMany.mockResolvedValue({ count: 1 });
ctx.prisma.nextcloudAlertSubscription.findMany.mockResolvedValue([
{ userId: 'u1' },
{ userId: 'u2' },
]);
ctx.prisma.user.findMany.mockResolvedValue([
{ id: 'u1', email: 'u1@example.invalid', role: 'USER', isActive: true },
{ id: 'u2', email: 'u2@example.invalid', role: 'ADMIN', isActive: true },
]);
ctx.moduleAccess.getModuleAccessLevels.mockImplementation(async (_t: string, id: string) =>
id === 'u1' ? new Map() : new Map([['mod1', 'MANAGE']]),
);
await run();
expect(ctx.mail.sendNextcloudAlertEmail).toHaveBeenCalledTimes(1);
expect(ctx.mail.sendNextcloudAlertEmail.mock.calls[0][1]).toBe('u2@example.invalid');
});
it('false, false, dann true: genau drei Aufrufe, je 60 s Abstand', async () => {
withSubscriber(ctx);
ctx.mail.sendNextcloudAlertEmail
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true);
await run();
expect(ctx.mail.sendNextcloudAlertEmail).toHaveBeenCalledTimes(3);
expect(ctx.sleep).toHaveBeenCalledTimes(2);
expect(ctx.sleep).toHaveBeenCalledWith(ALERT_MAIL_RETRY_MS);
});
it('dreimal false: drei Aufrufe, dann Schluss', async () => {
withSubscriber(ctx);
ctx.mail.sendNextcloudAlertEmail.mockResolvedValue(false);
await run();
expect(ctx.mail.sendNextcloudAlertEmail).toHaveBeenCalledTimes(3);
expect(ctx.sleep).toHaveBeenCalledTimes(2);
expect(ctx.warnSpy).toHaveBeenCalled();
});
it('Erfolg beim ersten Versuch: kein Warten, ein Aufruf', async () => {
withSubscriber(ctx);
await run();
expect(ctx.mail.sendNextcloudAlertEmail).toHaveBeenCalledTimes(1);
expect(ctx.sleep).not.toHaveBeenCalled();
});
it('keine Abonnenten: nichts zu tun', async () => {
ctx.prisma.nextcloudInstance.updateMany.mockResolvedValue({ count: 1 });
await run();
expect(ctx.prisma.user.findMany).not.toHaveBeenCalled();
expect(ctx.mail.sendNextcloudAlertEmail).not.toHaveBeenCalled();
});
});