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
@@ -0,0 +1,175 @@
import { describe, expect, it } from 'vitest';
import {
decideAlert,
FAILURES_FOR_RED,
planStatusWrite,
RETRY_DELAY_MS,
} from './nextcloud-alert-rules';
import { type NextcloudReference, rateNextcloud } from './nextcloud-rating';
import type { NextcloudCheckResult } from './nextcloud-status-fetch';
const NOW = new Date('2026-10-02T12:00:00Z');
const REFERENCE: NextcloudReference = {
fetchedAt: '2026-10-02T10:00:00.000Z',
cycles: [{ cycle: 35, eol: '2099-09-30', latest: '35.0.1' }],
};
const OK: NextcloudCheckResult = {
reachable: true,
maintenance: false,
needsDbUpgrade: false,
versionString: '35.0.1',
edition: 'enterprise',
productName: 'Nextcloud',
errorKind: null,
errorDetail: null,
};
const FAILED: NextcloudCheckResult = {
reachable: false,
maintenance: null,
needsDbUpgrade: null,
versionString: null,
edition: null,
productName: null,
errorKind: 'network',
errorDetail: 'ECONNREFUSED',
};
describe('Konstanten', () => {
it('zwei Fehlschlaege fuer Rot, Wiederholung nach fuenf Minuten', () => {
expect(FAILURES_FOR_RED).toBe(2);
expect(RETRY_DELAY_MS).toBe(5 * 60 * 1000);
});
});
describe('decideAlert', () => {
it.each([
['ok', 'red', 'down'],
['red', 'red', null],
['red', 'green', 'up'],
['red', 'yellow', 'up'],
['red', 'unknown', null],
['ok', 'green', null],
['ok', 'yellow', null],
['ok', 'unknown', null],
] as const)('%s + %s -> %s', (prev, level, expected) => {
expect(decideAlert(prev, level)).toBe(expected);
});
});
describe('planStatusWrite', () => {
it('erster Fehlschlag: nur Zaehler und Zeitpunkt, kein Statusfeld, kein lastCheckedAt', () => {
const plan = planStatusWrite(0, FAILED, NOW);
expect(plan.outcome).toBe('pending');
expect(plan.data).toEqual({ consecutiveFailures: 1, firstFailureAt: NOW });
expect(plan.data).not.toHaveProperty('lastCheckedAt');
expect(plan.data).not.toHaveProperty('reachable');
});
it('zweiter Fehlschlag: bestaetigt, schreibt die Fehlerfelder', () => {
const plan = planStatusWrite(1, FAILED, NOW);
expect(plan.outcome).toBe('confirmed');
expect(plan.data).toMatchObject({
reachable: false,
errorKind: 'network',
errorDetail: 'ECONNREFUSED',
lastCheckedAt: NOW,
consecutiveFailures: 2,
});
expect(plan.data).not.toHaveProperty('firstFailureAt');
});
it('weitere Fehlschlaege einer roten Cloud lassen den Zaehler bei 2', () => {
const plan = planStatusWrite(2, FAILED, NOW);
expect(plan.outcome).toBe('confirmed');
expect(plan.data.consecutiveFailures).toBe(2);
});
it('jeder Erfolg setzt Zaehler und Zeitpunkt zurueck und schreibt alle Statusfelder', () => {
for (const prev of [0, 1, 2]) {
const plan = planStatusWrite(prev, OK, NOW);
expect(plan.outcome).toBe('ok');
expect(plan.data).toMatchObject({
reachable: true,
maintenance: false,
needsDbUpgrade: false,
versionString: '35.0.1',
edition: 'enterprise',
productName: 'Nextcloud',
errorKind: null,
errorDetail: null,
lastCheckedAt: NOW,
consecutiveFailures: 0,
firstFailureAt: null,
});
}
});
it('Wartungsmodus ist ein Erfolg der Pruefung (sofort rot ueber die Bewertung)', () => {
const plan = planStatusWrite(0, { ...OK, maintenance: true }, NOW);
expect(plan.outcome).toBe('ok');
expect(plan.data.maintenance).toBe(true);
});
});
/** Zustand der Zeile nach dem Schreiben, als Bewertungseingabe. */
function rateStored(stored: Record<string, unknown>) {
return rateNextcloud(
{
checkedAt: (stored.lastCheckedAt as Date) ?? null,
reachable: (stored.reachable as boolean | null) ?? null,
maintenance: (stored.maintenance as boolean | null) ?? null,
needsDbUpgrade: (stored.needsDbUpgrade as boolean | null) ?? null,
versionString: (stored.versionString as string | null) ?? null,
errorKind: (stored.errorKind as string | null) ?? null,
},
REFERENCE,
NOW,
);
}
describe('Zusammenspiel mit der Bewertung', () => {
const greenStored = {
lastCheckedAt: new Date('2026-10-02T11:00:00Z'),
reachable: true,
maintenance: false,
needsDbUpgrade: false,
versionString: '35.0.1',
errorKind: null,
};
it('gruene Cloud + ein Fehlschlag: bleibt gruen, keine Meldung', () => {
const plan = planStatusWrite(0, FAILED, NOW);
const rating = rateStored({ ...greenStored, ...plan.data });
expect(rating.level).toBe('green');
expect(decideAlert('ok', rating.level)).toBeNull();
});
it('gruene Cloud + zweiter Fehlschlag: rot -> down', () => {
const plan = planStatusWrite(1, FAILED, NOW);
const rating = rateStored({ ...greenStored, ...plan.data });
expect(rating).toMatchObject({ level: 'red', reason: 'unreachable' });
expect(decideAlert('ok', rating.level)).toBe('down');
});
it('gruene Cloud + Wartungsantwort: sofort down', () => {
const plan = planStatusWrite(0, { ...OK, maintenance: true }, NOW);
const rating = rateStored({ ...greenStored, ...plan.data });
expect(rating).toMatchObject({ level: 'red', reason: 'maintenance' });
expect(decideAlert('ok', rating.level)).toBe('down');
});
it('rote Cloud + gruene Antwort: up', () => {
const plan = planStatusWrite(2, OK, NOW);
const rating = rateStored({ ...greenStored, ...plan.data });
expect(rating.level).toBe('green');
expect(decideAlert('red', rating.level)).toBe('up');
});
it('nie geprueft + ein Fehlschlag: bleibt grau, keine Meldung', () => {
const plan = planStatusWrite(0, FAILED, NOW);
const rating = rateStored({ ...plan.data, lastCheckedAt: null });
expect(rating.level).toBe('unknown');
expect(decideAlert('ok', rating.level)).toBeNull();
});
});