import { BadRequestException, NotFoundException } from '@nestjs/common'; import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('../prisma/prisma-tenant.extension', () => ({ forTenant: vi.fn((p: unknown) => p), forSystem: vi.fn((p: unknown) => p), })); vi.mock('./nextcloud-status-fetch', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, fetchNextcloudStatus: vi.fn() }; }); import { forSystem, forTenant } from '../prisma/prisma-tenant.extension'; import type { NextcloudReference } from './nextcloud-rating'; import { NextcloudStatusService, PUBLIC_SELECT } from './nextcloud-status.service'; import { fetchNextcloudStatus } from './nextcloud-status-fetch'; const REFERENCE: NextcloudReference = { fetchedAt: '2026-10-02T10:00:00.000Z', cycles: [{ cycle: 35, eol: '2099-09-30', latest: '35.0.1' }], }; function makeRow(over: Record = {}) { return { id: 'i1', tenantId: 't1', customerName: 'Kunde A', baseUrl: 'https://cloud.a.de', logoUrl: null, logoMime: null, logoVersion: 0, lastCheckedAt: new Date('2026-10-02T11:00:00Z'), reachable: true, maintenance: false, needsDbUpgrade: false, versionString: '35.0.1', edition: null, errorKind: null, errorDetail: null, ...over, }; } describe('NextcloudStatusService', () => { let prisma: { nextcloudInstance: Record> }; let release: { getReference: ReturnType }; let alerts: { subscribedInstanceIds: ReturnType; evaluateAfterCheck: ReturnType; }; let service: NextcloudStatusService; beforeEach(() => { vi.mocked(fetchNextcloudStatus).mockReset(); vi.mocked(forTenant).mockClear(); prisma = { nextcloudInstance: { findMany: vi.fn(), findFirst: vi.fn(), create: vi.fn(), update: vi.fn(), }, }; release = { getReference: vi.fn().mockResolvedValue(REFERENCE) }; alerts = { subscribedInstanceIds: vi.fn().mockResolvedValue(new Set()), 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 () => { prisma.nextcloudInstance.findMany.mockResolvedValue([ makeRow(), makeRow({ id: 'i2', logoMime: 'image/png', logoVersion: 3 }), ]); 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'); expect(args.where).toEqual({ tenantId: 't1' }); expect(forTenant).toHaveBeenCalledWith(prisma, 't1'); expect(result.reference).toEqual({ newestVersion: '35.0.1', fetchedAt: REFERENCE.fetchedAt }); expect(result.instances).toHaveLength(2); expect(result.instances[0].rating).toMatchObject({ level: 'green', reason: 'current' }); expect(result.instances[0].hasUploadedLogo).toBe(false); expect(result.instances[1]).toMatchObject({ hasUploadedLogo: true, logoVersion: 3 }); expect(JSON.stringify(result)).not.toContain('logoData'); }); it('listForTenant ohne Vergleichsdaten bewertet grau', async () => { release.getReference.mockResolvedValue(null); prisma.nextcloudInstance.findMany.mockResolvedValue([makeRow()]); const result = await service.listForTenant('t1', 'u1'); expect(result.reference).toEqual({ newestVersion: null, fetchedAt: null }); expect(result.instances[0].rating.level).toBe('unknown'); }); it('createInstance normalisiert die Adresse, legt an und prueft sofort', async () => { prisma.nextcloudInstance.create.mockResolvedValue({ id: 'i1' }); prisma.nextcloudInstance.findFirst.mockResolvedValue({ id: 'i1', baseUrl: 'https://cloud.a.de', }); vi.mocked(fetchNextcloudStatus).mockResolvedValue({ reachable: true, maintenance: false, needsDbUpgrade: false, versionString: '35.0.1', edition: null, productName: 'Nextcloud', errorKind: null, errorDetail: null, }); prisma.nextcloudInstance.update.mockResolvedValue(makeRow()); const view = await service.createInstance('t1', { customerName: ' Kunde A ', baseUrl: 'https://cloud.a.de/status.php/', }); expect(prisma.nextcloudInstance.create.mock.calls[0][0].data).toEqual({ tenantId: 't1', customerName: 'Kunde A', baseUrl: 'https://cloud.a.de', logoUrl: null, }); expect(fetchNextcloudStatus).toHaveBeenCalledWith('https://cloud.a.de'); expect(view.id).toBe('i1'); }); it('createInstance mit ungueltiger Adresse -> BadRequest mit deutscher Meldung', async () => { await expect( service.createInstance('t1', { customerName: 'X', baseUrl: 'ftp://x' }), ).rejects.toThrow(BadRequestException); await expect( service.createInstance('t1', { customerName: 'X', baseUrl: 'ftp://x' }), ).rejects.toThrow('Bitte geben Sie eine gültige Adresse mit http:// oder https:// ein.'); expect(prisma.nextcloudInstance.create).not.toHaveBeenCalled(); }); 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(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'); expect(prisma.nextcloudInstance.findFirst.mock.calls[0][0].where).toEqual({ id: 'i1', tenantId: 't1', }); const args = prisma.nextcloudInstance.update.mock.calls[0][0]; expect(args.data).toMatchObject({ reachable: false, maintenance: null, needsDbUpgrade: null, versionString: null, edition: null, errorKind: 'http-status', errorDetail: 'HTTP 503', consecutiveFailures: 2, }); 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 () => { prisma.nextcloudInstance.findFirst.mockResolvedValue(null); await expect(service.checkInstance('t1', 'nix')).rejects.toThrow(NotFoundException); expect(fetchNextcloudStatus).not.toHaveBeenCalled(); }); describe('Schreibwege (Aufgabe 2)', () => { const OK_RESULT = { reachable: true, maintenance: false, needsDbUpgrade: false, versionString: '35.0.1', edition: null, productName: null, errorKind: null, errorDetail: null, }; beforeEach(() => { prisma.nextcloudInstance.delete = vi.fn(); prisma.nextcloudInstance.findFirst.mockResolvedValue({ id: 'i1', baseUrl: 'https://cloud.a.de', }); prisma.nextcloudInstance.update.mockResolvedValue(makeRow()); vi.mocked(fetchNextcloudStatus).mockResolvedValue(OK_RESULT); }); it('updateInstance: nur Name -> kein Neuabruf', async () => { await service.updateInstance('t1', 'i1', { customerName: ' Neu ' }); expect(prisma.nextcloudInstance.update.mock.calls[0][0].data).toEqual({ customerName: 'Neu', }); expect(fetchNextcloudStatus).not.toHaveBeenCalled(); }); it('updateInstance: neue Adresse wird normalisiert und neu geprueft, 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({ baseUrl: 'https://neu.example.de', }); expect(fetchNextcloudStatus).toHaveBeenCalledTimes(1); vi.mocked(fetchNextcloudStatus).mockClear(); await service.updateInstance('t1', 'i1', { baseUrl: 'https://cloud.a.de/' }); expect(fetchNextcloudStatus).not.toHaveBeenCalled(); }); it('updateInstance: ungueltige Adresse -> BadRequest', async () => { await expect(service.updateInstance('t1', 'i1', { baseUrl: 'javascript:1' })).rejects.toThrow( BadRequestException, ); }); it('updateInstance: nicht leere Logo-Adresse ersetzt den Upload, leere entfernt nur die Adresse', async () => { await service.updateInstance('t1', 'i1', { logoUrl: 'https://logo.example.de/a.png' }); expect(prisma.nextcloudInstance.update.mock.calls[0][0].data).toEqual({ logoUrl: 'https://logo.example.de/a.png', logoData: null, logoMime: null, logoVersion: { increment: 1 }, }); await service.updateInstance('t1', 'i1', { logoUrl: '' }); expect(prisma.nextcloudInstance.update.mock.calls[1][0].data).toEqual({ logoUrl: null, logoVersion: { increment: 1 }, }); }); it('uploadLogo speichert Bytes und erkannten Typ, entfernt die Adresse und erhoeht die Version', async () => { const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2]); await service.uploadLogo('t1', 'i1', { buffer: png, originalname: 'x.exe', mimetype: 'text/html', size: png.length, }); const data = prisma.nextcloudInstance.update.mock.calls[0][0].data; expect(Buffer.from(data.logoData)).toEqual(png); expect(data).toMatchObject({ logoMime: 'image/png', logoUrl: null, logoVersion: { increment: 1 }, }); expect(prisma.nextcloudInstance.update.mock.calls[0][0].select).toBe(PUBLIC_SELECT); }); it('uploadLogo lehnt Nicht-Bilder, fehlende Dateien und fremde Kennungen ab', async () => { const html = Buffer.from(''); await expect( service.uploadLogo('t1', 'i1', { buffer: html, originalname: 'a.png', mimetype: 'image/png', size: html.length, }), ).rejects.toThrow( 'Bitte laden Sie ein Bild im Format PNG, JPEG, GIF oder WebP bis 1 MB hoch.', ); await expect(service.uploadLogo('t1', 'i1', undefined)).rejects.toThrow(BadRequestException); prisma.nextcloudInstance.findFirst.mockResolvedValue(null); const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); await expect( service.uploadLogo('t1', 'fremd', { buffer: png, originalname: 'a.png', mimetype: 'image/png', size: png.length, }), ).rejects.toThrow(NotFoundException); expect(prisma.nextcloudInstance.update).not.toHaveBeenCalled(); }); it('removeLogo loescht Bytes und Typ und erhoeht die Version', async () => { await service.removeLogo('t1', 'i1'); expect(prisma.nextcloudInstance.update.mock.calls[0][0].data).toEqual({ logoData: null, logoMime: null, logoVersion: { increment: 1 }, }); }); it('getLogo liefert Bytes und Typ, ohne Logo NotFound', async () => { prisma.nextcloudInstance.findFirst.mockResolvedValue({ logoData: new Uint8Array([1, 2, 3]), logoMime: 'image/png', }); const logo = await service.getLogo('t1', 'i1'); expect(logo.mime).toBe('image/png'); expect([...logo.data]).toEqual([1, 2, 3]); expect(prisma.nextcloudInstance.findFirst.mock.calls[0][0].where).toEqual({ id: 'i1', tenantId: 't1', }); prisma.nextcloudInstance.findFirst.mockResolvedValue({ logoData: null, logoMime: null }); await expect(service.getLogo('t1', 'i1')).rejects.toThrow(NotFoundException); prisma.nextcloudInstance.findFirst.mockResolvedValue(null); await expect(service.getLogo('t1', 'x')).rejects.toThrow(NotFoundException); }); it('deleteInstance loescht die Zeile; fremde Kennung -> NotFound', async () => { expect(await service.deleteInstance('t1', 'i1')).toBe(true); expect(prisma.nextcloudInstance.delete).toHaveBeenCalledWith({ where: { id: 'i1' } }); prisma.nextcloudInstance.findFirst.mockResolvedValue(null); await expect(service.deleteInstance('t1', 'fremd')).rejects.toThrow(NotFoundException); expect(prisma.nextcloudInstance.delete).toHaveBeenCalledTimes(1); }); it('checkAllForTenant prueft alle mit hoechstens vier gleichzeitig und liefert die frische Liste', async () => { const ids = Array.from({ length: 10 }, (_, i) => `i${i}`); prisma.nextcloudInstance.findMany.mockImplementation( async (args: { select: Record }) => args.select.id && Object.keys(args.select).length === 1 ? ids.map((id) => ({ id })) : [makeRow()], ); prisma.nextcloudInstance.findFirst.mockImplementation( async ({ where }: { where: { id: string } }) => ({ id: where.id, baseUrl: 'https://cloud.a.de', }), ); let inFlight = 0; let peak = 0; vi.mocked(fetchNextcloudStatus).mockImplementation(async () => { inFlight++; peak = Math.max(peak, inFlight); await new Promise((r) => setTimeout(r, 5)); inFlight--; return OK_RESULT; }); const result = await service.checkAllForTenant('t1', 'u1'); expect(fetchNextcloudStatus).toHaveBeenCalledTimes(10); expect(peak).toBeLessThanOrEqual(4); expect(peak).toBeGreaterThan(1); expect(result.instances).toHaveLength(1); }); it('checkAllForTenant: eine fehlerhafte Cloud stoppt die anderen nicht', async () => { prisma.nextcloudInstance.findMany.mockImplementation( async (args: { select: Record }) => Object.keys(args.select).length === 1 ? [{ id: 'a' }, { id: 'b' }] : [], ); prisma.nextcloudInstance.findFirst.mockImplementation( async ({ where }: { where: { id: string } }) => where.id === 'a' ? null : { id: where.id, baseUrl: 'https://cloud.a.de' }, ); await service.checkAllForTenant('t1', 'u1'); expect(fetchNextcloudStatus).toHaveBeenCalledTimes(1); }); it('loadAllInstancesForScheduler waehlt nur Kennung und Mandant ueber forSystem', async () => { prisma.nextcloudInstance.findMany.mockResolvedValue([{ id: 'i1', tenantId: 't1' }]); const rows = await service.loadAllInstancesForScheduler(); expect(forSystem).toHaveBeenCalledWith(prisma); expect(prisma.nextcloudInstance.findMany).toHaveBeenCalledWith({ select: { id: true, tenantId: true }, }); expect(rows).toEqual([{ id: 'i1', tenantId: 't1' }]); }); }); });