faed0d760f
- 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>
440 lines
18 KiB
TypeScript
440 lines
18 KiB
TypeScript
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<typeof import('./nextcloud-status-fetch')>();
|
|
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<string, unknown> = {}) {
|
|
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<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(() => {
|
|
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<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 () => {
|
|
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('<html></html>');
|
|
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<string, boolean> }) =>
|
|
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<string, boolean> }) =>
|
|
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' }]);
|
|
});
|
|
});
|
|
});
|