import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ createJWT: vi.fn() })); vi.mock('../account/appwrite', () => ({ account: { createJWT: mocks.createJWT } })); vi.mock('../account/auth', () => ({ currentUserId: () => 'u1', onAuthChange: () => () => undefined, onLogout: () => () => undefined, })); const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); const fetchMock = vi.fn<(url: string, init: RequestInit) => Promise>(); /** Eine frische Kopie von logo-api.ts und community/api.ts (der JWT-Cache lebt im Modul); beide aus derselben Ladung. */ async function fresh() { vi.resetModules(); const logoApi = await import('./logo-api'); const api = await import('../community/api'); return { logoApi, api }; } type ApiErr = InstanceType>['api']['ApiError']>; /** Der ApiError, mit dem `promise` scheitert. */ async function rejection(promise: Promise): Promise { try { await promise; } catch (err) { return err as ApiErr; } throw new Error('Die Anfrage ist nicht gescheitert.'); } const sentBody = (call = 0) => JSON.parse(fetchMock.mock.calls[call][1].body as string) as unknown; const sentHeaders = (call = 0) => fetchMock.mock.calls[call][1].headers as Record; beforeEach(() => { mocks.createJWT.mockReset(); let n = 0; mocks.createJWT.mockImplementation(async () => ({ jwt: `jwt${++n}` })); fetchMock.mockReset(); fetchMock.mockImplementation(async () => json(200, { ok: true })); vi.stubGlobal('fetch', fetchMock); }); afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); }); describe('uploadLogo', () => { const input = { name: 'Beispielbank', displayName: 'Kenso', mono: true, fileId: 'file1' }; it('POST /api/logos/hochladen mit JWT und genau den vier Feldern; liefert { id }', async () => { const { logoApi } = await fresh(); fetchMock.mockResolvedValue(json(201, { id: 'file1' })); await expect(logoApi.uploadLogo(input)).resolves.toEqual({ id: 'file1' }); expect(fetchMock).toHaveBeenCalledOnce(); expect(fetchMock.mock.calls[0][0]).toBe('/api/logos/hochladen'); expect(fetchMock.mock.calls[0][1].method).toBe('POST'); expect(sentBody()).toEqual(input); expect(sentHeaders()['X-Aircard-Jwt']).toBe('jwt1'); }); it('weitere Felder des Aufrufers gehen nicht mit', async () => { const { logoApi } = await fresh(); fetchMock.mockResolvedValue(json(201, { id: 'file1' })); const wider = { ...input, ownerId: 'u2', hidden: false }; await logoApi.uploadLogo(wider); expect(sentBody()).toEqual(input); }); it.each([[{}], [{ id: '' }], [{ id: 42 }], [{ id: null }], [{ ok: true }]])( 'eine Antwort ohne brauchbare id (%j): ApiError 503, Ausgang offen (die Zeile kann angelegt sein)', async (body) => { const { logoApi, api } = await fresh(); fetchMock.mockResolvedValue(json(201, body)); const err = await rejection(logoApi.uploadLogo(input)); expect(err).toBeInstanceOf(api.ApiError); expect(err).toMatchObject({ code: 503, message: 'Der Dienst ist gerade nicht erreichbar.', serverCode: 'unavailable' }); expect(err.definitive).toBe(false); expect(api.isDefinitiveFailure(err)).toBe(false); }, ); it('429 „schon 30 Logos“ (Limit des Dienstes) ist eindeutig: es wurde nichts angelegt, die Datei darf aufgeräumt werden', async () => { const { logoApi, api } = await fresh(); const text = 'Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.'; fetchMock.mockResolvedValue(json(429, { error: text, code: 'tooManyLogos' })); const err = await rejection(logoApi.uploadLogo(input)); expect(err).toBeInstanceOf(api.ApiError); expect(err).toMatchObject({ code: 429, message: text, serverCode: 'tooManyLogos', definitive: true }); expect(api.isDefinitiveFailure(err)).toBe(true); }); it('ein anderes 429 (etwa vom Proxy) bleibt ein Fehler mit offenem Ausgang', async () => { const { logoApi, api } = await fresh(); fetchMock.mockResolvedValueOnce(json(429, { error: 'Zu viele Anfragen.' })); expect(api.isDefinitiveFailure(await rejection(logoApi.uploadLogo(input)))).toBe(false); fetchMock.mockResolvedValueOnce(json(429, { error: 'Zu viele Meldungen – bitte später nochmal.', code: 'tooManyReports' })); expect(api.isDefinitiveFailure(await rejection(logoApi.uploadLogo(input)))).toBe(false); }); it.each([ [400, 'logoFormat', 'Das Logo hat ungültige Maße (8 × 8 bis 720 × 250 Pixel).', true], [400, 'badRequest', 'Der Name ist zu lang (höchstens 40 Zeichen).', true], [401, 'unauthorized', 'Bitte melde dich an.', true], [403, 'foreignFile', 'Diese Datei gehört dir nicht.', true], [409, 'logoTaken', 'Diese Datei wird schon für ein Logo verwendet.', true], [500, 'internal', 'Interner Fehler.', false], ] as const)('Fehler %i %s: Status, Text und Schlüssel des Dienstes bleiben, eindeutig: %s', async (status, code, error, definitive) => { const { logoApi, api } = await fresh(); fetchMock.mockImplementation(async () => json(status, { error, code })); const err = await rejection(logoApi.uploadLogo(input)); expect(err).toMatchObject({ code: status, message: error, serverCode: code }); expect(api.isDefinitiveFailure(err)).toBe(definitive); }); it('der Dienst steht (502 vom Proxy, Netzfehler): nicht erreichbar, Ausgang offen', async () => { const { logoApi, api } = await fresh(); fetchMock.mockResolvedValueOnce(new Response('Bad Gateway', { status: 502 })); const proxy = await rejection(logoApi.uploadLogo(input)); expect(proxy).toMatchObject({ code: 503, serverCode: 'unavailable' }); fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch')); const network = await rejection(logoApi.uploadLogo(input)); expect(network).toMatchObject({ code: 503, serverCode: 'unavailable' }); expect(api.isDefinitiveFailure(proxy) || api.isDefinitiveFailure(network)).toBe(false); }); }); describe('setHeart', () => { it('POST /api/logos/herz { id, herz } mit JWT; liefert Zustand und Zähler des Dienstes', async () => { const { logoApi } = await fresh(); fetchMock.mockResolvedValue(json(200, { herz: true, likeCount: 3 })); await expect(logoApi.setHeart('l1', true)).resolves.toEqual({ herz: true, likeCount: 3 }); expect(fetchMock.mock.calls[0][0]).toBe('/api/logos/herz'); expect(sentBody()).toEqual({ id: 'l1', herz: true }); expect(sentHeaders()['X-Aircard-Jwt']).toBe('jwt1'); }); it('herz: false nimmt das Herz zurück', async () => { const { logoApi } = await fresh(); fetchMock.mockResolvedValue(json(200, { herz: false, likeCount: 0 })); await expect(logoApi.setHeart('l1', false)).resolves.toEqual({ herz: false, likeCount: 0 }); expect(sentBody()).toEqual({ id: 'l1', herz: false }); }); it.each([ [{}], [{ ok: true }], [{ herz: true }], [{ likeCount: 3 }], [{ herz: 'ja', likeCount: 3 }], [{ herz: true, likeCount: -1 }], [{ herz: true, likeCount: 2.5 }], [{ herz: true, likeCount: '3' }], ])( 'eine Antwort ohne brauchbaren Zustand oder Zähler (%j): ApiError 503, Ausgang offen', async (body) => { const { logoApi, api } = await fresh(); fetchMock.mockResolvedValue(json(200, body)); const err = await rejection(logoApi.setHeart('l1', true)); expect(err).toBeInstanceOf(api.ApiError); expect(err).toMatchObject({ code: 503, serverCode: 'unavailable' }); expect(api.isDefinitiveFailure(err)).toBe(false); }, ); it('404 bei fehlendem oder ausgeblendetem Logo: logoGone', async () => { const { logoApi } = await fresh(); fetchMock.mockResolvedValue(json(404, { error: 'Dieses Logo ist nicht (mehr) verfügbar.', code: 'logoGone' })); expect(await rejection(logoApi.setHeart('l1', true))).toMatchObject({ code: 404, message: 'Dieses Logo ist nicht (mehr) verfügbar.', serverCode: 'logoGone', }); }); it('ohne Sitzung (401 von der API): ein Versuch mit neuem JWT, dann der Fehler', async () => { const { logoApi } = await fresh(); fetchMock.mockImplementation(async () => json(401, { error: 'Bitte melde dich an.', code: 'unauthorized' })); expect(await rejection(logoApi.setHeart('l1', true))).toMatchObject({ code: 401, serverCode: 'unauthorized' }); expect(fetchMock).toHaveBeenCalledTimes(2); }); }); describe('reportLogo', () => { it('POST /api/logos/melden { id, reason } ohne JWT; { schonGemeldet: false } bei neuer Meldung', async () => { const { logoApi } = await fresh(); fetchMock.mockResolvedValue(json(200, { ok: true })); await expect(logoApi.reportLogo('l1', 'spam')).resolves.toEqual({ schonGemeldet: false }); expect(fetchMock.mock.calls[0][0]).toBe('/api/logos/melden'); expect(sentBody()).toEqual({ id: 'l1', reason: 'spam' }); expect(sentHeaders()['X-Aircard-Jwt']).toBeUndefined(); expect(mocks.createJWT).not.toHaveBeenCalled(); }); it('{ schonGemeldet: true } bei doppelter Meldung', async () => { const { logoApi } = await fresh(); fetchMock.mockResolvedValue(json(200, { schonGemeldet: true })); await expect(logoApi.reportLogo('l1', 'urheberrecht')).resolves.toEqual({ schonGemeldet: true }); }); it('429 Rate-Limit: Text und Schlüssel des Dienstes', async () => { const { logoApi } = await fresh(); fetchMock.mockResolvedValue(json(429, { error: 'Zu viele Meldungen – bitte später nochmal.', code: 'tooManyReports' })); expect(await rejection(logoApi.reportLogo('l1', 'spam'))).toMatchObject({ code: 429, message: 'Zu viele Meldungen – bitte später nochmal.', serverCode: 'tooManyReports', }); }); it('404 bei fehlendem oder ausgeblendetem Logo: logoGone', async () => { const { logoApi } = await fresh(); fetchMock.mockResolvedValue(json(404, { error: 'Dieses Logo ist nicht (mehr) verfügbar.', code: 'logoGone' })); expect(await rejection(logoApi.reportLogo('l1', 'spam'))).toMatchObject({ code: 404, serverCode: 'logoGone' }); }); }); describe('deleteLogo', () => { it('POST /api/logos/loeschen { id } mit JWT; liefert nichts', async () => { const { logoApi } = await fresh(); await expect(logoApi.deleteLogo('l1')).resolves.toBeUndefined(); expect(fetchMock.mock.calls[0][0]).toBe('/api/logos/loeschen'); expect(sentBody()).toEqual({ id: 'l1' }); expect(sentHeaders()['X-Aircard-Jwt']).toBe('jwt1'); }); it('403 bei fremdem Logo: Text und Schlüssel des Dienstes', async () => { const { logoApi } = await fresh(); fetchMock.mockResolvedValue(json(403, { error: 'Dazu hast du keine Berechtigung.', code: 'forbidden' })); expect(await rejection(logoApi.deleteLogo('l1'))).toMatchObject({ code: 403, serverCode: 'forbidden' }); }); }); describe('setLogoVisibility', () => { it('POST /api/admin/logo-sichtbarkeit { id, sichtbar } mit JWT; liefert nichts', async () => { const { logoApi } = await fresh(); await expect(logoApi.setLogoVisibility('l1', false)).resolves.toBeUndefined(); await logoApi.setLogoVisibility('l1', true); expect(fetchMock.mock.calls[0][0]).toBe('/api/admin/logo-sichtbarkeit'); expect(sentBody(0)).toEqual({ id: 'l1', sichtbar: false }); expect(sentBody(1)).toEqual({ id: 'l1', sichtbar: true }); expect(sentHeaders()['X-Aircard-Jwt']).toBe('jwt1'); }); it('403 für Nicht-Admins: Text und Schlüssel des Dienstes', async () => { const { logoApi } = await fresh(); fetchMock.mockResolvedValue(json(403, { error: 'Nur für Admins.', code: 'adminOnly' })); expect(await rejection(logoApi.setLogoVisibility('l1', true))).toMatchObject({ code: 403, message: 'Nur für Admins.', serverCode: 'adminOnly', }); }); });