import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ createFile: vi.fn(), deleteFile: vi.fn(), requireUserId: vi.fn(), uploadLogo: vi.fn(), })); // Appwrite, Nutzer und der API-Aufruf sind nur für den Test der Standard-Abhängigkeiten gebraucht; sonst kommen Fakes (deps) zum Einsatz. vi.mock('../account/appwrite', () => ({ IDS: { logoBucket: 'logo-images' }, account: {}, client: {}, tables: {}, storage: { createFile: mocks.createFile, deleteFile: mocks.deleteFile }, APPWRITE_ENDPOINT: 'https://aircard.test/v1', })); vi.mock('../account/cards', () => ({ requireUserId: mocks.requireUserId })); vi.mock('./logo-api', () => ({ uploadLogo: mocks.uploadLogo })); import { ID_PATTERN } from '../card/config'; import { ApiError } from '../community/api'; import type { PreparedLogo } from './prepare-logo'; import { uploadPreparedLogo, type LogoForm, type UploadLogoDeps } from './upload-logo'; const prepared = (patch: Partial = {}): PreparedLogo => ({ png: new Blob(['png-bytes'], { type: 'image/png' }), width: 200, height: 80, hasTransparency: true, ...patch, }); const form: LogoForm = { name: 'Beispielbank', displayName: 'Kenso', mono: false }; type Call = { name: string; args: unknown[] }; /** Fake-Abhängigkeiten, die jeden Aufruf in Reihenfolge aufzeichnen; die hochgeladene Datei bekommt die ID f1. */ function fakeDeps(overrides: Partial = {}) { const calls: Call[] = []; const log = (name: string, args: unknown[], result: T): T => { calls.push({ name, args }); return result; }; const deps: UploadLogoDeps = { userId: () => log('userId', [], 'u1'), createFile: async (file, permissions) => log('createFile', [file, permissions], { $id: 'f1' }), deleteFile: async (id) => log('deleteFile', [id], undefined), uploadLogo: async (input) => log('uploadLogo', [input], { id: 'f1' }), ...overrides, }; return { deps, calls, names: () => calls.map((c) => c.name), of: (name: string) => calls.filter((c) => c.name === name), }; } describe('uploadPreparedLogo', () => { it('Reihenfolge: Nutzer, Upload, API', async () => { const fake = fakeDeps(); await uploadPreparedLogo(prepared(), form, fake.deps); expect(fake.names()).toEqual(['userId', 'createFile', 'uploadLogo']); }); it('lädt das PNG als logo.png (image/png) mit read(any) und delete(user:) hoch', async () => { const fake = fakeDeps(); const png = prepared().png; await uploadPreparedLogo({ ...prepared(), png }, form, fake.deps); const [file, permissions] = fake.of('createFile')[0].args as [File, string[]]; expect(file.name).toBe('logo.png'); expect(file.type).toBe('image/png'); expect(file.size).toBe(png.size); expect(permissions).toEqual(['read("any")', 'delete("user:u1")']); }); it('schickt der API die neue Datei-ID und genau vier Felder, Name und Anzeigename getrimmt', async () => { const fake = fakeDeps(); await uploadPreparedLogo(prepared(), { name: ' Beispielbank ', displayName: ' Kenso\t', mono: false }, fake.deps); expect(fake.of('uploadLogo')[0].args[0]).toEqual({ name: 'Beispielbank', displayName: 'Kenso', mono: false, fileId: 'f1' }); }); it('ein leerer Anzeigename bleibt leer (der Server macht daraus „Anonym“)', async () => { const fake = fakeDeps(); await uploadPreparedLogo(prepared(), { ...form, displayName: ' ' }, fake.deps); expect(fake.of('uploadLogo')[0].args[0]).toMatchObject({ displayName: '' }); }); it.each([ { mono: true, hasTransparency: true, sent: true }, { mono: true, hasTransparency: false, sent: false }, { mono: false, hasTransparency: true, sent: false }, { mono: false, hasTransparency: false, sent: false }, ])('mono $mono bei hasTransparency $hasTransparency: gesendet wird $sent', async ({ mono, hasTransparency, sent }) => { const fake = fakeDeps(); const logo = await uploadPreparedLogo(prepared({ hasTransparency }), { ...form, mono }, fake.deps); expect(fake.of('uploadLogo')[0].args[0]).toMatchObject({ mono: sent }); expect(logo.mono).toBe(sent); }); it('Erfolg: das neue Logo (ID = Datei-ID, mein Konto, ohne Herzen und Meldungen), nichts wird gelöscht', async () => { const fake = fakeDeps(); const logo = await uploadPreparedLogo(prepared(), { name: ' Beispielbank ', displayName: 'Kenso', mono: true }, fake.deps); expect(logo).toEqual({ id: 'f1', name: 'Beispielbank', displayName: 'Kenso', fileId: 'f1', ownerId: 'u1', mono: true, likeCount: 0, hidden: false, reportCount: 0, createdAt: expect.any(String), }); expect(new Date(logo.createdAt).toISOString()).toBe(logo.createdAt); expect(fake.of('deleteFile')).toHaveLength(0); }); describe('Fehler der API: gelöscht wird nur bei eindeutiger Ablehnung', () => { it.each([400, 401, 403, 404])( 'eindeutig abgelehnt (%i): die Datei dieses Versuchs wird gelöscht, der Fehler kommt an', async (code) => { const failure = new ApiError(code, 'Abgelehnt.'); const fake = fakeDeps({ uploadLogo: async () => Promise.reject(failure) }); await expect(uploadPreparedLogo(prepared(), form, fake.deps)).rejects.toBe(failure); expect(fake.of('deleteFile').map((c) => c.args[0])).toEqual(['f1']); }, ); it('das Limit von 30 Logos (429 tooManyLogos, eindeutig): die Datei wird gelöscht', async () => { const failure = new ApiError(429, 'Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.', true, 'tooManyLogos'); const fake = fakeDeps({ uploadLogo: async () => Promise.reject(failure) }); await expect(uploadPreparedLogo(prepared(), form, fake.deps)).rejects.toBe(failure); expect(fake.of('deleteFile').map((c) => c.args[0])).toEqual(['f1']); }); it.each([408, 429, 500, 502, 503, 504])( 'unklarer Ausgang (%i): die Zeile kann angelegt sein, die Datei bleibt liegen, der Fehler kommt an', async (code) => { const failure = new ApiError(code, 'Unklar.'); const fake = fakeDeps({ uploadLogo: async () => Promise.reject(failure) }); await expect(uploadPreparedLogo(prepared(), form, fake.deps)).rejects.toBe(failure); expect(fake.of('deleteFile')).toHaveLength(0); }, ); it('der Dienst ist nicht erreichbar (unavailable): die Datei bleibt liegen', async () => { const failure = new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable'); const fake = fakeDeps({ uploadLogo: async () => Promise.reject(failure) }); await expect(uploadPreparedLogo(prepared(), form, fake.deps)).rejects.toBe(failure); expect(fake.of('deleteFile')).toHaveLength(0); }); it('409 logoTaken: die Datei gehört schon einem Logo und wird NICHT gelöscht, der Fehler kommt an', async () => { const failure = new ApiError(409, 'Diese Datei wird schon für ein Logo verwendet.', undefined, 'logoTaken'); expect(failure.definitive).toBe(true); const fake = fakeDeps({ uploadLogo: async () => Promise.reject(failure) }); await expect(uploadPreparedLogo(prepared(), form, fake.deps)).rejects.toBe(failure); expect(fake.of('deleteFile')).toHaveLength(0); }); it('entscheidend ist der Schlüssel logoTaken, nicht der Status: ein anderes 409 wird aufgeräumt', async () => { const failure = new ApiError(409, 'Anderer Konflikt.', undefined, 'alreadyPublished'); const fake = fakeDeps({ uploadLogo: async () => Promise.reject(failure) }); await expect(uploadPreparedLogo(prepared(), form, fake.deps)).rejects.toBe(failure); expect(fake.of('deleteFile').map((c) => c.args[0])).toEqual(['f1']); }); it('JWT-Methode am Server aus (ApiError mit definitive-Flag, es ging keine Anfrage raus): gelöscht', async () => { const failure = new ApiError(501, 'Anmeldung per Token ist auf dem Server ausgeschaltet.', true, 'jwtUnsupported'); const fake = fakeDeps({ uploadLogo: async () => Promise.reject(failure) }); await expect(uploadPreparedLogo(prepared(), form, fake.deps)).rejects.toBe(failure); expect(fake.of('deleteFile')).toHaveLength(1); }); it('ein anderer Fehler vor der Anfrage (z. B. createJWT mit 401): gelöscht', async () => { const failure = Object.assign(new Error('missing scope'), { code: 401, type: 'general_unauthorized_scope' }); const fake = fakeDeps({ uploadLogo: async () => Promise.reject(failure) }); await expect(uploadPreparedLogo(prepared(), form, fake.deps)).rejects.toBe(failure); expect(fake.of('deleteFile')).toHaveLength(1); }); it('löscht nie etwas anderes als die Datei dieses Versuchs', async () => { const fake = fakeDeps({ createFile: async () => ({ $id: 'neu42' }), uploadLogo: async () => Promise.reject(new ApiError(403, 'Dazu hast du keine Berechtigung.')), }); await uploadPreparedLogo(prepared(), form, fake.deps).catch(() => undefined); expect(fake.of('deleteFile').map((c) => c.args[0])).toEqual(['neu42']); }); it('ein Fehler beim Aufräumen (auch ein sofort geworfener) überdeckt den ursprünglichen Fehler nicht', async () => { const failure = new ApiError(400, 'Bitte gib einen Namen an.'); const rejecting = fakeDeps({ uploadLogo: async () => Promise.reject(failure), deleteFile: async () => Promise.reject(new Error('delete kaputt')), }); await expect(uploadPreparedLogo(prepared(), form, rejecting.deps)).rejects.toBe(failure); const throwing = fakeDeps({ uploadLogo: async () => Promise.reject(failure), deleteFile: () => { throw new Error('delete kaputt'); }, }); await expect(uploadPreparedLogo(prepared(), form, throwing.deps)).rejects.toBe(failure); }); }); describe('vor dem API-Aufruf', () => { it('scheitert der Upload, wird die API nicht gerufen und nichts gelöscht, der Fehler kommt an', async () => { const failure = new Error('upload kaputt'); const fake = fakeDeps({ createFile: async () => Promise.reject(failure) }); await expect(uploadPreparedLogo(prepared(), form, fake.deps)).rejects.toBe(failure); expect(fake.of('uploadLogo')).toHaveLength(0); expect(fake.of('deleteFile')).toHaveLength(0); }); it('ohne angemeldeten Nutzer wird nichts hochgeladen', async () => { const failure = Object.assign(new Error('unauthorized'), { code: 401 }); const fake = fakeDeps({ userId: () => { throw failure; }, }); await expect(uploadPreparedLogo(prepared(), form, fake.deps)).rejects.toBe(failure); expect(fake.names()).toEqual([]); }); }); }); describe('Standard-Abhängigkeiten (Appwrite-Bucket logo-images)', () => { beforeEach(() => { for (const fn of Object.values(mocks)) fn.mockReset(); mocks.requireUserId.mockReturnValue('u1'); // Wie Appwrite: die Datei hat die ID, mit der sie angelegt wurde. mocks.createFile.mockImplementation(async ({ fileId }: { fileId: string }) => ({ $id: fileId })); mocks.deleteFile.mockResolvedValue({}); mocks.uploadLogo.mockImplementation(async ({ fileId }: { fileId: string }) => ({ id: fileId })); }); it('lädt mit frischer ID in den Logo-Bucket und ruft die API mit genau dieser ID', async () => { const logo = await uploadPreparedLogo(prepared(), form); expect(mocks.createFile).toHaveBeenCalledTimes(1); const { bucketId, fileId, file, permissions } = mocks.createFile.mock.calls[0][0] as { bucketId: string; fileId: string; file: File; permissions: string[]; }; expect(bucketId).toBe('logo-images'); expect(fileId).toMatch(ID_PATTERN); expect(file.name).toBe('logo.png'); expect(permissions).toEqual(['read("any")', 'delete("user:u1")']); expect(mocks.uploadLogo).toHaveBeenCalledWith({ name: 'Beispielbank', displayName: 'Kenso', mono: false, fileId }); expect(logo.id).toBe(fileId); expect(mocks.deleteFile).not.toHaveBeenCalled(); }); it('jeder Versuch bekommt seine eigene Datei-ID', async () => { await uploadPreparedLogo(prepared(), form); await uploadPreparedLogo(prepared(), form); const [first, second] = mocks.createFile.mock.calls.map((call) => (call[0] as { fileId: string }).fileId); expect(first).not.toBe(second); }); it('räumt bei eindeutiger Ablehnung genau diese Datei im Logo-Bucket auf', async () => { mocks.uploadLogo.mockRejectedValue(new ApiError(403, 'Dazu hast du keine Berechtigung.')); await expect(uploadPreparedLogo(prepared(), form)).rejects.toBeInstanceOf(ApiError); const { fileId } = mocks.createFile.mock.calls[0][0] as { fileId: string }; expect(mocks.deleteFile).toHaveBeenCalledTimes(1); expect(mocks.deleteFile).toHaveBeenCalledWith({ bucketId: 'logo-images', fileId }); }); it('ohne angemeldeten Nutzer (401 aus requireUserId): nichts wird hochgeladen', async () => { const failure = Object.assign(new Error('unauthorized'), { code: 401 }); mocks.requireUserId.mockImplementation(() => { throw failure; }); await expect(uploadPreparedLogo(prepared(), form)).rejects.toBe(failure); expect(mocks.createFile).not.toHaveBeenCalled(); }); });