Files
AirCardIOS/web/src/community/api.test.ts
T
ClaudeandClaude Sonnet 5.5 ae73dc4a0b fix(community): nur bei eindeutigem Ausgang aufräumen, Tag- und Kurzsuche, kleine Härtungen
- publish.ts: bei Fehlern der API werden die hochgeladenen Dateien nur bei eindeutiger Ablehnung
  (4xx außer 408/429, JWT-Fehler vor der Anfrage) gelöscht; bei offenem Ausgang (Netzfehler,
  Zeitlimit, 5xx, 429, Antwort ohne id) bleiben sie liegen, damit keine öffentliche Zeile auf
  gelöschte Bilder zeigt
- api.ts: ApiError.definitive und isDefinitiveFailure, Zeitlimit 15 s, gleichzeitige createJWT
  werden geteilt
- gallery.ts: Tag-Filter (contains auf tags), Kurzsuche unter 3 Zeichen per contains,
  loadGalleryImage prüft die Datei-ID
- geometry.ts: PREVIEW_WIDTH/PREVIEW_QUALITY an einer Stelle (Editor und Veröffentlichen)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 14:48:06 +00:00

576 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Api> {
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<Response>>();
function sentHeaders(call = 0): Record<string, string> {
return fetchMock.mock.calls[call][1].headers as Record<string, string>;
}
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('<html>404 page not found</html>', { 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('<!doctype html><title>AirCard</title>', { 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<Response>, 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('<html>Bad Gateway</html>', { status: 404 }),
async () => new Response('<!doctype html>', { 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.' });
});
});