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, user: Record = {}) { 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; 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; 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((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; 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(); }); });