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:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user