feat(nextcloud-status): erneute Prüfung nach Ausfall und Hinweis auf der Kachel

- Wiederholungsauftrag jede Minute für Clouds mit einem Fehlschlag älter als fünf Minuten
- Neue Adresse setzt den Prüfstand zurück, der gemeldete Zustand bleibt
- Kachel: Hinweis Prüfung fehlgeschlagen, Fehlercodes als lesbarer Text mit Tooltip

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 15:26:23 +02:00
parent faed0d760f
commit 11a70c9ee9
14 changed files with 515 additions and 34 deletions
@@ -271,11 +271,25 @@ describe('NextcloudStatusService', () => {
expect(fetchNextcloudStatus).not.toHaveBeenCalled();
});
it('updateInstance: neue Adresse wird normalisiert und neu geprueft, unveraenderte nicht', async () => {
it('updateInstance: neue Adresse wird normalisiert, setzt den Pruefstand zurueck (alertState bleibt) und prueft neu, unveraenderte nicht', async () => {
await service.updateInstance('t1', 'i1', { baseUrl: 'https://neu.example.de/index.php/' });
expect(prisma.nextcloudInstance.update.mock.calls[0][0].data).toEqual({
const data = prisma.nextcloudInstance.update.mock.calls[0][0].data;
expect(data).toEqual({
baseUrl: 'https://neu.example.de',
reachable: null,
maintenance: null,
needsDbUpgrade: null,
versionString: null,
edition: null,
productName: null,
errorKind: null,
errorDetail: null,
lastCheckedAt: null,
consecutiveFailures: 0,
firstFailureAt: null,
});
expect(data).not.toHaveProperty('alertState');
expect(data).not.toHaveProperty('alertReason');
expect(fetchNextcloudStatus).toHaveBeenCalledTimes(1);
vi.mocked(fetchNextcloudStatus).mockClear();
@@ -283,6 +297,20 @@ describe('NextcloudStatusService', () => {
expect(fetchNextcloudStatus).not.toHaveBeenCalled();
});
it('updateInstance: rote Cloud, korrigierte Adresse antwortet gruen -> Meldung "wieder in Ordnung" wird entschieden', async () => {
prisma.nextcloudInstance.findFirst.mockResolvedValue({
id: 'i1',
baseUrl: 'https://kaputt.example.de',
consecutiveFailures: 0,
});
prisma.nextcloudInstance.update.mockResolvedValue(makeRow({ alertState: 'red' }));
await service.updateInstance('t1', 'i1', { baseUrl: 'https://cloud.a.de' });
expect(alerts.evaluateAfterCheck).toHaveBeenCalledTimes(1);
const [, row, rating] = alerts.evaluateAfterCheck.mock.calls[0];
expect(row.alertState).toBe('red');
expect(rating).toMatchObject({ level: 'green', reason: 'current' });
});
it('updateInstance: ungueltige Adresse -> BadRequest', async () => {
await expect(service.updateInstance('t1', 'i1', { baseUrl: 'javascript:1' })).rejects.toThrow(
BadRequestException,
@@ -435,5 +463,25 @@ describe('NextcloudStatusService', () => {
});
expect(rows).toEqual([{ id: 'i1', tenantId: 't1' }]);
});
it('loadAllInstancesForScheduler mit retryDueBefore filtert auf genau einen Fehlschlag, Select bleibt Kennung und Mandant', async () => {
prisma.nextcloudInstance.findMany.mockResolvedValue([{ id: 'i1', tenantId: 't1' }]);
const due = new Date('2026-10-02T12:00:00Z');
await service.loadAllInstancesForScheduler({ retryDueBefore: due });
expect(prisma.nextcloudInstance.findMany).toHaveBeenCalledWith({
where: { consecutiveFailures: 1, firstFailureAt: { lte: due } },
select: { id: true, tenantId: true },
});
});
it('pendingRetry ist genau bei einem Fehlschlag wahr', async () => {
prisma.nextcloudInstance.findMany.mockResolvedValue([
makeRow({ id: 'a', consecutiveFailures: 0 }),
makeRow({ id: 'b', consecutiveFailures: 1 }),
makeRow({ id: 'c', consecutiveFailures: 2 }),
]);
const result = await service.listForTenant('t1', 'u1');
expect(result.instances.map((i) => i.status.pendingRetry)).toEqual([false, true, false]);
});
});
});