- 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>
576 lines
26 KiB
TypeScript
576 lines
26 KiB
TypeScript
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.' });
|
||
});
|
||
});
|