faed0d760f
- 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>
317 lines
12 KiB
TypeScript
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();
|
|
});
|
|
});
|