import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ createJWT: vi.fn(), userId: null as string | null, authListeners: [] as Array<(user: unknown) => void>, logoutListeners: [] as Array<() => void>, })); vi.mock('../account/appwrite', () => ({ account: { createJWT: mocks.createJWT } })); vi.mock('../account/auth', () => ({ currentUserId: () => mocks.userId, onAuthChange: (fn: (user: unknown) => void) => { mocks.authListeners.push(fn); return () => undefined; }, onLogout: (fn: () => void) => { mocks.logoutListeners.push(fn); return () => undefined; }, })); type Api = typeof import('./api'); const UNAVAILABLE = 'Der Dienst ist gerade nicht erreichbar.'; const MINUTE = 60_000; // Der JWT-Cache lebt im Modul; jeder Test bekommt eine frische Instanz. async function freshApi(): Promise { vi.resetModules(); return import('./api'); } 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>(); function sentHeaders(call = 0): Record { return fetchMock.mock.calls[call][1].headers as Record; } beforeEach(() => { vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(new Date('2026-10-04T10:00:00Z')); mocks.createJWT.mockReset(); mocks.userId = 'u1'; mocks.authListeners.length = 0; mocks.logoutListeners.length = 0; 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.useRealTimers(); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); describe('apiPost', () => { it('sendet JSON per POST an den Pfad und liefert die JSON-Antwort', async () => { const api = await freshApi(); fetchMock.mockResolvedValue(json(200, { schonGemeldet: true })); await expect(api.apiPost('/api/melden', { id: 'c1', reason: 'spam' }, { auth: false })).resolves.toEqual({ schonGemeldet: true, }); expect(fetchMock).toHaveBeenCalledOnce(); const [url, init] = fetchMock.mock.calls[0]; expect(url).toBe('/api/melden'); expect(init.method).toBe('POST'); expect(sentHeaders()['Content-Type']).toBe('application/json'); expect(init.body).toBe(JSON.stringify({ id: 'c1', reason: 'spam' })); }); it('ohne auth: kein JWT, kein createJWT', async () => { const api = await freshApi(); await api.apiPost('/api/melden', {}, { auth: false }); expect(mocks.createJWT).not.toHaveBeenCalled(); expect(sentHeaders()['X-Aircard-Jwt']).toBeUndefined(); }); it('mit auth: sendet den Header X-Aircard-Jwt mit dem JWT von createJWT (ohne duration)', async () => { const api = await freshApi(); await api.apiPost('/api/zurueckziehen', { id: 'c1' }, { auth: true }); expect(mocks.createJWT).toHaveBeenCalledOnce(); expect(mocks.createJWT.mock.calls[0]).toEqual([]); expect(sentHeaders()['X-Aircard-Jwt']).toBe('jwt1'); }); describe('JWT-Cache', () => { it('der zweite Aufruf innerhalb von 10 Minuten holt kein neues JWT', async () => { const api = await freshApi(); await api.apiPost('/api/x', {}, { auth: true }); vi.setSystemTime(Date.now() + 9 * MINUTE + 59_000); await api.apiPost('/api/x', {}, { auth: true }); expect(mocks.createJWT).toHaveBeenCalledOnce(); expect(sentHeaders(1)['X-Aircard-Jwt']).toBe('jwt1'); }); it('nach 10 Minuten wird ein neues JWT geholt', async () => { const api = await freshApi(); await api.apiPost('/api/x', {}, { auth: true }); vi.setSystemTime(Date.now() + 10 * MINUTE); await api.apiPost('/api/x', {}, { auth: true }); expect(mocks.createJWT).toHaveBeenCalledTimes(2); expect(sentHeaders(1)['X-Aircard-Jwt']).toBe('jwt2'); }); it('gilt je Nutzer-ID: ein anderer Nutzer bekommt nie das JWT des vorigen', async () => { const api = await freshApi(); await api.apiPost('/api/x', {}, { auth: true }); mocks.userId = 'u2'; await api.apiPost('/api/x', {}, { auth: true }); expect(mocks.createJWT).toHaveBeenCalledTimes(2); expect(sentHeaders(1)['X-Aircard-Jwt']).toBe('jwt2'); }); it('wird bei einer Änderung des Nutzers (onAuthChange) geleert', async () => { const api = await freshApi(); await api.apiPost('/api/x', {}, { auth: true }); expect(mocks.authListeners).toHaveLength(1); mocks.authListeners[0](null); await api.apiPost('/api/x', {}, { auth: true }); expect(mocks.createJWT).toHaveBeenCalledTimes(2); }); it('wird beim Abmelden (onLogout) geleert', async () => { const api = await freshApi(); await api.apiPost('/api/x', {}, { auth: true }); expect(mocks.logoutListeners).toHaveLength(1); mocks.logoutListeners[0](); await api.apiPost('/api/x', {}, { auth: true }); expect(mocks.createJWT).toHaveBeenCalledTimes(2); }); it('ein JWT, das erst nach einem Nutzerwechsel eintrifft, wird nicht gemerkt', async () => { const api = await freshApi(); let release: (value: { jwt: string }) => void = () => undefined; mocks.createJWT.mockImplementationOnce(() => new Promise((resolve) => (release = resolve))); const first = api.apiPost('/api/x', {}, { auth: true }); await vi.waitFor(() => expect(mocks.createJWT).toHaveBeenCalledOnce()); mocks.authListeners[0](null); // Abmelden, während createJWT noch läuft release({ jwt: 'late' }); await first; await api.apiPost('/api/x', {}, { auth: true }); expect(mocks.createJWT).toHaveBeenCalledTimes(2); expect(sentHeaders(1)['X-Aircard-Jwt']).toBe('jwt1'); }); it('gleichzeitige Aufrufe mit leerem Cache teilen sich ein einziges createJWT', async () => { const api = await freshApi(); let release: (value: { jwt: string }) => void = () => undefined; mocks.createJWT.mockImplementationOnce(() => new Promise((resolve) => (release = resolve))); const first = api.apiPost('/api/x', { n: 1 }, { auth: true }); const second = api.apiPost('/api/x', { n: 2 }, { auth: true }); await vi.waitFor(() => expect(mocks.createJWT).toHaveBeenCalledOnce()); release({ jwt: 'shared' }); await Promise.all([first, second]); expect(mocks.createJWT).toHaveBeenCalledOnce(); expect(sentHeaders(0)['X-Aircard-Jwt']).toBe('shared'); expect(sentHeaders(1)['X-Aircard-Jwt']).toBe('shared'); }); it('scheitert das geteilte createJWT, bekommen beide den Fehler; der nächste Aufruf versucht es neu', async () => { const api = await freshApi(); const failure = Object.assign(new Error('missing scope'), { code: 401, type: 'general_unauthorized_scope' }); let reject: (reason: unknown) => void = () => undefined; mocks.createJWT.mockImplementationOnce(() => new Promise((_resolve, rej) => (reject = rej))); const first = api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e); const second = api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e); await vi.waitFor(() => expect(mocks.createJWT).toHaveBeenCalledOnce()); reject(failure); expect(await Promise.all([first, second])).toEqual([failure, failure]); await api.apiPost('/api/x', {}, { auth: true }); expect(mocks.createJWT).toHaveBeenCalledTimes(2); }); it('ein anderer Nutzer teilt sich kein laufendes createJWT', async () => { const api = await freshApi(); let release: (value: { jwt: string }) => void = () => undefined; mocks.createJWT.mockImplementationOnce(() => new Promise((resolve) => (release = resolve))); const first = api.apiPost('/api/x', {}, { auth: true }); await vi.waitFor(() => expect(mocks.createJWT).toHaveBeenCalledOnce()); mocks.userId = 'u2'; const second = api.apiPost('/api/x', {}, { auth: true }); await vi.waitFor(() => expect(mocks.createJWT).toHaveBeenCalledTimes(2)); release({ jwt: 'for-u1' }); await Promise.all([first, second]); expect(sentHeaders(0)['X-Aircard-Jwt']).not.toBe(sentHeaders(1)['X-Aircard-Jwt']); }); it('ohne bekannte Nutzer-ID wird nichts gemerkt', async () => { const api = await freshApi(); mocks.userId = null; await api.apiPost('/api/x', {}, { auth: true }); await api.apiPost('/api/x', {}, { auth: true }); expect(mocks.createJWT).toHaveBeenCalledTimes(2); }); }); describe('401 von der API', () => { it('holt ein neues JWT und wiederholt genau einmal', async () => { const api = await freshApi(); fetchMock.mockResolvedValueOnce(json(401, { error: 'Bitte melde dich an.' })); fetchMock.mockResolvedValueOnce(json(200, { ok: true })); await expect(api.apiPost('/api/x', { a: 1 }, { auth: true })).resolves.toEqual({ ok: true }); expect(mocks.createJWT).toHaveBeenCalledTimes(2); expect(fetchMock).toHaveBeenCalledTimes(2); expect(sentHeaders(0)['X-Aircard-Jwt']).toBe('jwt1'); expect(sentHeaders(1)['X-Aircard-Jwt']).toBe('jwt2'); expect(fetchMock.mock.calls[1][1].body).toBe(JSON.stringify({ a: 1 })); }); it('das neue JWT wird gemerkt, das alte nicht mehr', async () => { const api = await freshApi(); fetchMock.mockResolvedValueOnce(json(401, { error: 'Bitte melde dich an.' })); await api.apiPost('/api/x', {}, { auth: true }); await api.apiPost('/api/x', {}, { auth: true }); expect(mocks.createJWT).toHaveBeenCalledTimes(2); expect(sentHeaders(2)['X-Aircard-Jwt']).toBe('jwt2'); }); it('bei einem zweiten 401 ist Schluss: ApiError mit code 401, keine dritte Anfrage', async () => { const api = await freshApi(); fetchMock.mockImplementation(async () => json(401, { error: 'Bitte melde dich an.' })); const err = await api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e); expect(err).toBeInstanceOf(api.ApiError); expect(err).toMatchObject({ code: 401, message: 'Bitte melde dich an.' }); expect(fetchMock).toHaveBeenCalledTimes(2); expect(mocks.createJWT).toHaveBeenCalledTimes(2); }); it('ohne auth wird nicht wiederholt', async () => { const api = await freshApi(); fetchMock.mockResolvedValue(json(401, { error: 'Nein.' })); await expect(api.apiPost('/api/x', {}, { auth: false })).rejects.toMatchObject({ code: 401 }); expect(fetchMock).toHaveBeenCalledOnce(); }); it('andere Fehler (403) lösen keinen Wiederholungsversuch aus', async () => { const api = await freshApi(); fetchMock.mockResolvedValue(json(403, { error: 'Nur für Admins.' })); await expect(api.apiPost('/api/x', {}, { auth: true })).rejects.toMatchObject({ code: 403 }); expect(fetchMock).toHaveBeenCalledOnce(); expect(mocks.createJWT).toHaveBeenCalledOnce(); }); }); describe('Zeitlimit', () => { it('jede Anfrage trägt ein Zeitlimit von 15 Sekunden (AbortSignal.timeout)', async () => { const api = await freshApi(); const timeout = vi.spyOn(AbortSignal, 'timeout'); await api.apiPost('/api/x', {}, { auth: false }); expect(timeout).toHaveBeenCalledWith(15_000); expect(fetchMock.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal); }); it('Zeitüberschreitung gilt als verlorene Antwort: code 503, Ausgang offen', async () => { const api = await freshApi(); vi.spyOn(AbortSignal, 'timeout').mockReturnValue( AbortSignal.abort(new DOMException('The operation timed out.', 'TimeoutError')), ); fetchMock.mockImplementation(async (_url, init) => { throw init.signal?.reason; }); const err = await api.apiPost('/api/x', {}, { auth: false }).catch((e: unknown) => e); expect(err).toBeInstanceOf(api.ApiError); expect(err).toMatchObject({ code: 503, message: UNAVAILABLE }); expect(api.isDefinitiveFailure(err)).toBe(false); }); }); describe('Fehler', () => { it('ApiError trägt Statuscode (code = status) und den deutschen Servertext aus `message`', async () => { const api = await freshApi(); fetchMock.mockResolvedValue(json(400, { message: 'Bitte gib einen Titel an.' })); const err = await api.apiPost('/api/x', {}, { auth: false }).catch((e: unknown) => e); expect(err).toBeInstanceOf(api.ApiError); expect(err).toBeInstanceOf(Error); expect(err).toMatchObject({ code: 400, status: 400, message: 'Bitte gib einen Titel an.' }); }); it('liest den Text auch aus `error` (so antwortet aircard-api tatsächlich)', async () => { const api = await freshApi(); fetchMock.mockResolvedValue(json(429, { error: 'Zu viele Meldungen – bitte später nochmal.' })); await expect(api.apiPost('/api/melden', {}, { auth: false })).rejects.toMatchObject({ code: 429, message: 'Zu viele Meldungen – bitte später nochmal.', }); }); it('JSON ohne Text: allgemeine Meldung mit dem Statuscode', async () => { const api = await freshApi(); fetchMock.mockResolvedValue(json(500, {})); await expect(api.apiPost('/api/x', {}, { auth: false })).rejects.toMatchObject({ code: 500, message: 'Etwas ist schiefgelaufen. Bitte nochmal versuchen.', }); }); it('der Statuscode lässt isUnauthorized aus errors.ts greifen', async () => { const api = await freshApi(); const { isUnauthorized } = await import('../account/errors'); fetchMock.mockResolvedValue(json(401, { error: 'x' })); const err = await api.apiPost('/api/x', {}, { auth: false }).catch((e: unknown) => e); expect(isUnauthorized(err)).toBe(true); }); it('Netzfehler: code 503 und „Der Dienst ist gerade nicht erreichbar.“', async () => { const api = await freshApi(); fetchMock.mockRejectedValue(new TypeError('Failed to fetch')); const err = await api.apiPost('/api/x', {}, { auth: false }).catch((e: unknown) => e); expect(err).toBeInstanceOf(api.ApiError); expect(err).toMatchObject({ code: 503, message: UNAVAILABLE }); }); it.each([502, 503, 504])('Status %i (auch mit JSON) gilt als nicht erreichbar: code 503', async (status) => { const api = await freshApi(); fetchMock.mockResolvedValue(json(status, { error: 'irgendwas' })); await expect(api.apiPost('/api/x', {}, { auth: false })).rejects.toMatchObject({ code: 503, message: UNAVAILABLE }); }); it('eine Fehlerseite ohne JSON (z. B. Proxy) gilt als nicht erreichbar', async () => { const api = await freshApi(); fetchMock.mockResolvedValue(new Response('404 page not found', { status: 404 })); await expect(api.apiPost('/api/x', {}, { auth: false })).rejects.toMatchObject({ code: 503, message: UNAVAILABLE }); }); it('eine HTML-Antwort mit Status 200 (Seitenfallback ohne API-Route) gilt als nicht erreichbar', async () => { const api = await freshApi(); fetchMock.mockResolvedValue(new Response('AirCard', { status: 200 })); await expect(api.apiPost('/api/x', {}, { auth: false })).rejects.toMatchObject({ code: 503, message: UNAVAILABLE }); }); it('JSON, das kein Objekt ist, gilt als nicht erreichbar', async () => { const api = await freshApi(); fetchMock.mockResolvedValue(new Response('"ok"', { status: 200 })); await expect(api.apiPost('/api/x', {}, { auth: false })).rejects.toMatchObject({ code: 503 }); }); it('createJWT mit user_auth_method_unsupported: Hinweis an den Betreiber, keine Anfrage', async () => { const api = await freshApi(); mocks.createJWT.mockRejectedValue( Object.assign(new Error('JWT unsupported'), { code: 501, type: 'user_auth_method_unsupported' }), ); const err = await api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e); expect(err).toBeInstanceOf(api.ApiError); expect(err).toMatchObject({ code: 501, message: 'Anmeldung per Token ist auf dem Server ausgeschaltet – bitte den Betreiber informieren.', }); expect(fetchMock).not.toHaveBeenCalled(); }); it('createJWT mit 401 (keine Sitzung) geht unverändert durch, damit withLogin greift', async () => { const api = await freshApi(); const failure = Object.assign(new Error('missing scope'), { code: 401, type: 'general_unauthorized_scope' }); mocks.createJWT.mockRejectedValue(failure); await expect(api.apiPost('/api/x', {}, { auth: true })).rejects.toBe(failure); expect(fetchMock).not.toHaveBeenCalled(); }); }); }); describe('isDefinitiveFailure: hat der Server die Anfrage eindeutig abgelehnt?', () => { it.each([400, 401, 403, 404, 409, 422])('ApiError %i: ja', async (code) => { const api = await freshApi(); expect(new api.ApiError(code, 'x').definitive).toBe(true); expect(api.isDefinitiveFailure(new api.ApiError(code, 'x'))).toBe(true); }); it.each([408, 429, 500, 501, 502, 503, 504])('ApiError %i (Zeitüberschreitung, Drosselung, Serverfehler): nein', async (code) => { const api = await freshApi(); expect(api.isDefinitiveFailure(new api.ApiError(code, 'x'))).toBe(false); }); it('das Flag lässt sich setzen (JWT-Fehler: es ging keine Anfrage raus)', async () => { const api = await freshApi(); expect(api.isDefinitiveFailure(new api.ApiError(501, 'x', true))).toBe(true); expect(api.isDefinitiveFailure(new api.ApiError(400, 'x', false))).toBe(false); }); it('ein anderer Fehler (z. B. aus createJWT vor der Anfrage) ist eindeutig', async () => { const api = await freshApi(); expect(api.isDefinitiveFailure(Object.assign(new Error('x'), { code: 401 }))).toBe(true); expect(api.isDefinitiveFailure(new TypeError('Failed to fetch'))).toBe(true); }); const outcome = async (respond: () => Promise, api: Api) => { fetchMock.mockImplementation(respond); return api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e); }; it.each([400, 401, 403, 409])('Antwort %i vom Dienst: eindeutig', async (status) => { const api = await freshApi(); const err = await outcome(async () => json(status, { error: 'Nein.' }), api); expect(err).toMatchObject({ code: status }); expect(api.isDefinitiveFailure(err)).toBe(true); }); it.each([408, 429, 500])('Antwort %i vom Dienst: Ausgang offen', async (status) => { const api = await freshApi(); const err = await outcome(async () => json(status, { error: 'Nein.' }), api); expect(err).toMatchObject({ code: status }); expect(api.isDefinitiveFailure(err)).toBe(false); }); it.each([502, 503, 504])('Antwort %i (Proxy): Ausgang offen', async (status) => { const api = await freshApi(); const err = await outcome(async () => json(status, { error: 'x' }), api); expect(api.isDefinitiveFailure(err)).toBe(false); }); it('Netzfehler, Fehlerseite ohne JSON und HTML-200: Ausgang offen', async () => { const api = await freshApi(); for (const respond of [ async () => Promise.reject(new TypeError('Failed to fetch')), async () => new Response('Bad Gateway', { status: 404 }), async () => new Response('', { status: 200 }), ]) { expect(api.isDefinitiveFailure(await outcome(respond, api))).toBe(false); } }); it('der Text einer abgebrochenen Antwort (res.text() scheitert): Ausgang offen', async () => { const api = await freshApi(); const broken = { ok: true, status: 200, text: () => Promise.reject(new TypeError('network error')) } as unknown as Response; expect(api.isDefinitiveFailure(await outcome(async () => broken, api))).toBe(false); }); it('JWT-Methode am Server aus: eindeutig (es wurde nichts gesendet)', async () => { const api = await freshApi(); mocks.createJWT.mockRejectedValue(Object.assign(new Error('x'), { code: 501, type: 'user_auth_method_unsupported' })); const err = await api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e); expect(err).toBeInstanceOf(api.ApiError); expect(api.isDefinitiveFailure(err)).toBe(true); }); it('createJWT scheitert mit 401 vor der Anfrage: eindeutig', async () => { const api = await freshApi(); mocks.createJWT.mockRejectedValue(Object.assign(new Error('x'), { code: 401, type: 'general_unauthorized_scope' })); const err = await api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e); expect(api.isDefinitiveFailure(err)).toBe(true); }); }); describe('publishCard', () => { const input = { title: 'Meine Karte', issuer: 'Beispielbank', tags: ['visa', 'gold'], displayName: 'Kenso', imageFileId: 'img1', previewFileId: 'pre1', sourceCardId: 'card1', }; it('POST /api/veroeffentlichen mit JWT, Tags als Liste; liefert { id }', async () => { const api = await freshApi(); fetchMock.mockResolvedValue(json(201, { id: 'g1' })); await expect(api.publishCard(input)).resolves.toEqual({ id: 'g1' }); const [url, init] = fetchMock.mock.calls[0]; expect(url).toBe('/api/veroeffentlichen'); expect(JSON.parse(init.body as string)).toEqual(input); expect(sentHeaders()['X-Aircard-Jwt']).toBe('jwt1'); }); it('409: „Diese Karte ist schon veröffentlicht.“', async () => { const api = await freshApi(); fetchMock.mockResolvedValue(json(409, { error: 'Diese Karte ist schon veröffentlicht.' })); const err = await api.publishCard(input).catch((e: unknown) => e); expect(err).toMatchObject({ code: 409, message: 'Diese Karte ist schon veröffentlicht.' }); expect(api.isAlreadyPublished(err)).toBe(true); }); it('403 „bereits veröffentlicht oder gesperrt“ (Dateien belegt) gilt ebenfalls als schon veröffentlicht', async () => { const api = await freshApi(); fetchMock.mockResolvedValue(json(403, { error: 'Diese Bilder sind bereits veröffentlicht oder gesperrt.' })); const err = await api.publishCard(input).catch((e: unknown) => e); expect(err).toBeInstanceOf(api.ApiError); expect(err).toMatchObject({ code: 409, message: 'Diese Karte ist schon veröffentlicht.' }); expect(api.isAlreadyPublished(err)).toBe(true); }); it('ein anderes 403 bleibt ein 403 mit dem Servertext', async () => { const api = await freshApi(); fetchMock.mockResolvedValue(json(403, { error: 'Diese Datei gehört dir nicht.' })); const err = await api.publishCard(input).catch((e: unknown) => e); expect(err).toMatchObject({ code: 403, message: 'Diese Datei gehört dir nicht.' }); expect(api.isAlreadyPublished(err)).toBe(false); }); it('400 (Validierung) behält den Servertext', async () => { const api = await freshApi(); fetchMock.mockResolvedValue(json(400, { error: 'Bitte gib höchstens 5 Tags an.' })); await expect(api.publishCard(input)).rejects.toMatchObject({ code: 400, message: 'Bitte gib höchstens 5 Tags an.' }); }); it('eine Antwort ohne id ist ein Fehler mit offenem Ausgang (die Zeile kann angelegt sein)', async () => { const api = await freshApi(); fetchMock.mockResolvedValue(json(201, {})); const err = await api.publishCard(input).catch((e: unknown) => e); expect(err).toMatchObject({ code: 503, message: UNAVAILABLE }); expect(api.isDefinitiveFailure(err)).toBe(false); }); it('die umgedeutete 409 (Dateien belegt) ist eindeutig', async () => { const api = await freshApi(); fetchMock.mockResolvedValue(json(403, { error: 'Diese Bilder sind bereits veröffentlicht oder gesperrt.' })); expect(api.isDefinitiveFailure(await api.publishCard(input).catch((e: unknown) => e))).toBe(true); }); it('isAlreadyPublished: nur ApiError mit code 409', async () => { const api = await freshApi(); expect(api.isAlreadyPublished(new api.ApiError(409, 'x'))).toBe(true); expect(api.isAlreadyPublished(new api.ApiError(400, 'x'))).toBe(false); expect(api.isAlreadyPublished(Object.assign(new Error('x'), { code: 409 }))).toBe(false); expect(api.isAlreadyPublished(null)).toBe(false); }); }); describe('withdrawCard, reportCard, setVisibility', () => { it('withdrawCard: POST /api/zurueckziehen { id } mit JWT', async () => { const api = await freshApi(); await expect(api.withdrawCard('g1')).resolves.toBeUndefined(); const [url, init] = fetchMock.mock.calls[0]; expect(url).toBe('/api/zurueckziehen'); expect(JSON.parse(init.body as string)).toEqual({ id: 'g1' }); expect(sentHeaders()['X-Aircard-Jwt']).toBe('jwt1'); }); it('reportCard: POST /api/melden { id, reason } ohne JWT; { schonGemeldet: false } bei neuer Meldung', async () => { const api = await freshApi(); fetchMock.mockResolvedValue(json(200, { ok: true })); await expect(api.reportCard('g1', 'spam')).resolves.toEqual({ schonGemeldet: false }); const [url, init] = fetchMock.mock.calls[0]; expect(url).toBe('/api/melden'); expect(JSON.parse(init.body as string)).toEqual({ id: 'g1', reason: 'spam' }); expect(mocks.createJWT).not.toHaveBeenCalled(); }); it('reportCard: { schonGemeldet: true } bei doppelter Meldung', async () => { const api = await freshApi(); fetchMock.mockResolvedValue(json(200, { schonGemeldet: true })); await expect(api.reportCard('g1', 'urheberrecht')).resolves.toEqual({ schonGemeldet: true }); }); it('reportCard: 429 mit dem Servertext', async () => { const api = await freshApi(); fetchMock.mockResolvedValue(json(429, { error: 'Zu viele Meldungen – bitte später nochmal.' })); await expect(api.reportCard('g1', 'spam')).rejects.toMatchObject({ code: 429, message: 'Zu viele Meldungen – bitte später nochmal.', }); }); it('setVisibility: POST /api/admin/sichtbarkeit { id, sichtbar } mit JWT', async () => { const api = await freshApi(); await expect(api.setVisibility('g1', false)).resolves.toBeUndefined(); await api.setVisibility('g1', true); expect(fetchMock.mock.calls[0][0]).toBe('/api/admin/sichtbarkeit'); expect(JSON.parse(fetchMock.mock.calls[0][1].body as string)).toEqual({ id: 'g1', sichtbar: false }); expect(JSON.parse(fetchMock.mock.calls[1][1].body as string)).toEqual({ id: 'g1', sichtbar: true }); expect(sentHeaders()['X-Aircard-Jwt']).toBe('jwt1'); }); it('setVisibility: 403 für Nicht-Admins mit dem Servertext', async () => { const api = await freshApi(); fetchMock.mockResolvedValue(json(403, { error: 'Nur für Admins.' })); await expect(api.setVisibility('g1', true)).rejects.toMatchObject({ code: 403, message: 'Nur für Admins.' }); }); });