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>
This commit is contained in:
1 parent
fc9e5f5577
commit
3051b3b3f9
10 files changed
+1491
-21
No files matched your search
@@ -0,0 +1,260 @@
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user