Files
AirCardIOS/web/src/logos/logo-api.test.ts
T
ClaudeandClaude Opus 5.5 3051b3b3f9 feat(logos): Datenschicht, API-Aufrufe und übersetzbare API-Fehler
- logos/logos.ts (Muster community/gallery.ts): CommunityLogo, toLogo, logoQueries (Beliebt/Neu, Admin-Filter Gemeldet und
  Ausgeblendet, Cursor), listLogos (nextCursor nur bei voller Seite von 24), getLogo (ungültige ID oder 404 -> null),
  myHearts (eigene Herzen einer Seite, ohne IDs keine Anfrage), listLogoReports (ohne Melder-Hash), loadLogoBlob.
- logos/logo-api.ts: uploadLogo, setHeart, reportLogo (ohne Anmeldung), deleteLogo, setLogoVisibility über apiPost.
  uploadLogo behandelt das Limit (429 tooManyLogos) wie publishCard als eindeutige Ablehnung (die hochgeladene Datei darf
  aufgeräumt werden); eine Antwort ohne id (und bei setHeart ohne Zustand und Zähler) ist 503 mit offenem Ausgang.
- account/appwrite.ts: IDS um logos, logoLikes, logoReports und logoBucket.
- community/api.ts: ApiError.serverCode (der Schlüssel aus {error, code}; code bleibt der HTTP-Status, message der deutsche
  Text). Client-Fehler tragen unavailable (auch 502/503/504 vom Proxy, vor dem Lesen des Bodys), generic, jwtUnsupported,
  alreadyPublished, tooManyPublications. Neu: unavailableError() und apiErrorText(err). Die deutschen Texte stehen einmal
  in api-messages.ts, die Vergleiche in publishCard laufen unverändert über err.message.
- i18n/api-messages.ts: API_MESSAGES (alle 33 Texte von MSG plus die Client-Codes generic und jwtUnsupported) und
  apiMessage: bekannter Code -> t(Text); unbekannt: Deutsch der Servertext, Englisch eine Meldung je Status (400, 403, 404,
  sonst allgemein). Der Code badRequest trägt auch die dynamischen Prüftexte des Dienstes, Deutsch zeigt dann den
  genaueren Servertext.
- i18n/en.ts: englische Texte zu allen API_MESSAGES.
- Tests: api-messages.test.ts lädt MSG aus deploy/api/common.js (createRequire) und prüft den Gleichlauf in beide
  Richtungen; logos.test.ts, logo-api.test.ts und neue Fälle in api.test.ts (der Helfer freshApi stellt die frische
  i18n-Instanz auf Deutsch). 156 neue Tests, die bisherigen unverändert grün (1285 gesamt).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:59:08 +00:00

261 lines
12 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() }));
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<Response>>();
/** 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<Awaited<ReturnType<typeof fresh>>['api']['ApiError']>;
/** Der ApiError, mit dem `promise` scheitert. */
async function rejection(promise: Promise<unknown>): Promise<ApiErr> {
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<string, string>;
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('<html>Bad Gateway</html>', { 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',
});
});
});