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>
This commit is contained in:
2026-10-02 15:23:34 +02:00
parent 6829c44464
commit faed0d760f
23 changed files with 1796 additions and 103 deletions
@@ -44,6 +44,10 @@ function makeRow(over: Record<string, unknown> = {}) {
describe('NextcloudStatusService', () => {
let prisma: { nextcloudInstance: Record<string, ReturnType<typeof vi.fn>> };
let release: { getReference: ReturnType<typeof vi.fn> };
let alerts: {
subscribedInstanceIds: ReturnType<typeof vi.fn>;
evaluateAfterCheck: ReturnType<typeof vi.fn>;
};
let service: NextcloudStatusService;
beforeEach(() => {
@@ -58,7 +62,11 @@ describe('NextcloudStatusService', () => {
},
};
release = { getReference: vi.fn().mockResolvedValue(REFERENCE) };
service = new NextcloudStatusService(prisma as never, release as never);
alerts = {
subscribedInstanceIds: vi.fn().mockResolvedValue(new Set<string>()),
evaluateAfterCheck: vi.fn().mockResolvedValue({ kind: null, delivery: null }),
};
service = new NextcloudStatusService(prisma as never, release as never, alerts as never);
});
it('listForTenant waehlt keine Logo-Bytes und liefert Bewertung und neueste Version', async () => {
@@ -66,7 +74,7 @@ describe('NextcloudStatusService', () => {
makeRow(),
makeRow({ id: 'i2', logoMime: 'image/png', logoVersion: 3 }),
]);
const result = await service.listForTenant('t1');
const result = await service.listForTenant('t1', 'u1');
const args = prisma.nextcloudInstance.findMany.mock.calls[0][0];
expect(args.select).toBe(PUBLIC_SELECT);
expect(args.select).not.toHaveProperty('logoData');
@@ -83,7 +91,7 @@ describe('NextcloudStatusService', () => {
it('listForTenant ohne Vergleichsdaten bewertet grau', async () => {
release.getReference.mockResolvedValue(null);
prisma.nextcloudInstance.findMany.mockResolvedValue([makeRow()]);
const result = await service.listForTenant('t1');
const result = await service.listForTenant('t1', 'u1');
expect(result.reference).toEqual({ newestVersion: null, fetchedAt: null });
expect(result.instances[0].rating.level).toBe('unknown');
});
@@ -129,27 +137,31 @@ describe('NextcloudStatusService', () => {
expect(prisma.nextcloudInstance.create).not.toHaveBeenCalled();
});
it('checkInstance schreibt alle Statusspalten', async () => {
const FAILED_RESULT = {
reachable: false,
maintenance: null,
needsDbUpgrade: null,
versionString: null,
edition: null,
productName: null,
errorKind: 'http-status' as const,
errorDetail: 'HTTP 503',
};
it('checkInstance: zweiter Fehlschlag schreibt alle Statusspalten und den Zaehler 2', async () => {
prisma.nextcloudInstance.findFirst.mockResolvedValue({
id: 'i1',
baseUrl: 'https://cloud.a.de',
consecutiveFailures: 1,
});
vi.mocked(fetchNextcloudStatus).mockResolvedValue({
reachable: false,
maintenance: null,
needsDbUpgrade: null,
versionString: null,
edition: null,
productName: null,
errorKind: 'http-status',
errorDetail: 'HTTP 503',
});
vi.mocked(fetchNextcloudStatus).mockResolvedValue(FAILED_RESULT);
prisma.nextcloudInstance.update.mockResolvedValue(
makeRow({
reachable: false,
versionString: null,
errorKind: 'http-status',
errorDetail: 'HTTP 503',
alertState: 'ok',
}),
);
const view = await service.checkInstance('t1', 'i1');
@@ -157,8 +169,8 @@ describe('NextcloudStatusService', () => {
id: 'i1',
tenantId: 't1',
});
const data = prisma.nextcloudInstance.update.mock.calls[0][0].data;
expect(data).toMatchObject({
const args = prisma.nextcloudInstance.update.mock.calls[0][0];
expect(args.data).toMatchObject({
reachable: false,
maintenance: null,
needsDbUpgrade: null,
@@ -166,10 +178,62 @@ describe('NextcloudStatusService', () => {
edition: null,
errorKind: 'http-status',
errorDetail: 'HTTP 503',
consecutiveFailures: 2,
});
expect(data.lastCheckedAt).toBeInstanceOf(Date);
expect(prisma.nextcloudInstance.update.mock.calls[0][0].select).toBe(PUBLIC_SELECT);
expect(args.data.lastCheckedAt).toBeInstanceOf(Date);
expect(args.select).toMatchObject({ ...PUBLIC_SELECT, alertState: true });
expect(args.select).not.toHaveProperty('logoData');
expect(view.rating).toMatchObject({ level: 'red', reason: 'unreachable' });
// Die Meldung wird nach jeder Pruefung entschieden, mit dem Stand der Zeile
expect(alerts.evaluateAfterCheck).toHaveBeenCalledTimes(1);
const [tenant, row, rating] = alerts.evaluateAfterCheck.mock.calls[0];
expect(tenant).toBe('t1');
expect(row).toMatchObject({ id: 'i1', alertState: 'ok', errorKind: 'http-status' });
expect(rating).toMatchObject({ level: 'red', reason: 'unreachable' });
});
it('checkInstance: erster Fehlschlag schreibt nur Zaehler und Zeitpunkt, die Kachel behaelt den guten Stand', async () => {
prisma.nextcloudInstance.findFirst.mockResolvedValue({
id: 'i1',
baseUrl: 'https://cloud.a.de',
consecutiveFailures: 0,
});
vi.mocked(fetchNextcloudStatus).mockResolvedValue(FAILED_RESULT);
// Zeile bleibt im Zustand "gruen", nur der Zaehler steht auf 1
prisma.nextcloudInstance.update.mockResolvedValue(makeRow({ alertState: 'ok' }));
const view = await service.checkInstance('t1', 'i1');
const data = prisma.nextcloudInstance.update.mock.calls[0][0].data;
expect(Object.keys(data).sort()).toEqual(['consecutiveFailures', 'firstFailureAt']);
expect(data.consecutiveFailures).toBe(1);
expect(view.rating).toMatchObject({ level: 'green', reason: 'current' });
});
it('checkInstance: eine Stoerung der Meldung verwirft das Pruefergebnis nicht', async () => {
prisma.nextcloudInstance.findFirst.mockResolvedValue({
id: 'i1',
baseUrl: 'https://cloud.a.de',
consecutiveFailures: 0,
});
vi.mocked(fetchNextcloudStatus).mockResolvedValue({
...FAILED_RESULT,
reachable: true,
} as never);
prisma.nextcloudInstance.update.mockResolvedValue(makeRow({ alertState: 'ok' }));
alerts.evaluateAfterCheck.mockRejectedValue(new Error('db weg'));
vi.spyOn((service as any).logger, 'error').mockImplementation(() => undefined);
const view = await service.checkInstance('t1', 'i1');
expect(view.id).toBe('i1');
});
it('listForTenant liefert subscribed je Cloud nur fuer die Abonnements des Benutzers', async () => {
prisma.nextcloudInstance.findMany.mockResolvedValue([makeRow(), makeRow({ id: 'i2' })]);
alerts.subscribedInstanceIds.mockResolvedValue(new Set(['i2']));
const result = await service.listForTenant('t1', 'u1');
expect(alerts.subscribedInstanceIds).toHaveBeenCalledWith('t1', 'u1');
expect(result.instances.map((i) => [i.id, i.subscribed])).toEqual([
['i1', false],
['i2', true],
]);
});
it('checkInstance fuer unbekannte Kennung -> NotFound, kein Abruf', async () => {
@@ -342,7 +406,7 @@ describe('NextcloudStatusService', () => {
inFlight--;
return OK_RESULT;
});
const result = await service.checkAllForTenant('t1');
const result = await service.checkAllForTenant('t1', 'u1');
expect(fetchNextcloudStatus).toHaveBeenCalledTimes(10);
expect(peak).toBeLessThanOrEqual(4);
expect(peak).toBeGreaterThan(1);
@@ -358,7 +422,7 @@ describe('NextcloudStatusService', () => {
async ({ where }: { where: { id: string } }) =>
where.id === 'a' ? null : { id: where.id, baseUrl: 'https://cloud.a.de' },
);
await service.checkAllForTenant('t1');
await service.checkAllForTenant('t1', 'u1');
expect(fetchNextcloudStatus).toHaveBeenCalledTimes(1);
});