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