feat(nextcloud-status): Clouds verwalten, Logos, stündliche Prüfung und Sortierung
- Ändern, Entfernen, Logo-Upload und -Abruf, Sammelprüfung nur mit Verwalten - Stündlicher Auftrag beim Start registriert, Prüfung je Cloud an ihren Mandanten gebunden - Sortierung nach Kundenname, Status, Version und Support-Ende, je Benutzer gemerkt - Formular zum Anlegen und Bearbeiten, Zugriffsinventar angepasst Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,7 @@ vi.mock('./nextcloud-status-fetch', async (importOriginal) => {
|
||||
return { ...actual, fetchNextcloudStatus: vi.fn() };
|
||||
});
|
||||
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
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';
|
||||
@@ -177,4 +177,199 @@ describe('NextcloudStatusService', () => {
|
||||
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');
|
||||
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');
|
||||
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' }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user