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:
ClaudeandClaude Opus 5.5 committed 2026-10-04 19:59:08 +00:00
1 parent fc9e5f5577
commit 3051b3b3f9
10 files changed
+1491 -21

No files matched your search

+124
View File
@@ -0,0 +1,124 @@
import { createRequire } from 'node:module';
import { describe, expect, it } from 'vitest';
import { API_MESSAGES, apiMessage } from './api-messages';
import { EN } from './en';
import { setLang } from './i18n';
// MSG, alle Fehlertexte der API, liegt im Dienst (deploy/api/common.js, CommonJS ohne Abhängigkeiten); der Test lädt es von dort.
const { MSG } = createRequire(import.meta.url)('../../../deploy/api/common.js') as { MSG: Record<string, string> };
const GENERIC_EN = 'Something went wrong. Please try again.';
describe('Gleichlauf mit MSG im API-Dienst (deploy/api/common.js)', () => {
it('der Dienst liefert seine Texte (sonst bestünde der Vergleich leer)', () => {
expect(Object.keys(MSG).length).toBeGreaterThan(30);
expect(MSG.alreadyPublished).toBe('Diese Karte ist schon veröffentlicht.');
});
it('jeder Schlüssel von MSG steht mit demselben deutschen Text in API_MESSAGES', () => {
const different = Object.keys(MSG).filter((key) => API_MESSAGES[key] !== MSG[key]);
expect(different).toEqual([]);
});
it('darüber hinaus hat API_MESSAGES nur die Client-Codes generic und jwtUnsupported', () => {
const extra = Object.keys(API_MESSAGES).filter((key) => !Object.hasOwn(MSG, key));
expect(extra.sort()).toEqual(['generic', 'jwtUnsupported']);
});
it('jeder Text hat eine englische Übersetzung, die vom Deutschen abweicht', () => {
const untranslated = Object.values(API_MESSAGES).filter((text) => !Object.hasOwn(EN, text) || EN[text] === text);
expect(untranslated).toEqual([]);
});
});
describe('apiMessage: bekannter Code', () => {
it('Deutsch: der Text des Codes', () => {
expect(apiMessage('alreadyPublished', 409, 'Diese Karte ist schon veröffentlicht.')).toBe(
'Diese Karte ist schon veröffentlicht.',
);
expect(apiMessage('logoGone', 404, 'Dieses Logo ist nicht (mehr) verfügbar.')).toBe('Dieses Logo ist nicht (mehr) verfügbar.');
});
it('Englisch: die Übersetzung des Codes', () => {
setLang('en');
expect(apiMessage('alreadyPublished', 409, 'Diese Karte ist schon veröffentlicht.')).toBe('This card is already published.');
expect(apiMessage('logoGone', 404, 'Dieses Logo ist nicht (mehr) verfügbar.')).toBe('This logo is no longer available.');
expect(apiMessage('unavailable', 503, 'Der Dienst ist gerade nicht erreichbar.')).toBe('The service is currently unavailable.');
});
it('jeder Code in beiden Sprachen: deutsch wie im Wörterbuch, englisch laut en.ts', () => {
for (const [code, text] of Object.entries(API_MESSAGES)) {
setLang('de');
expect(apiMessage(code, 400, text)).toBe(text);
setLang('en');
expect(apiMessage(code, 400, text)).toBe(EN[text]);
}
});
it('Englisch: der Code entscheidet, nicht der mitgelieferte Text und nicht der Status', () => {
setLang('en');
expect(apiMessage('logoGone', 500, 'irgendein anderer Text')).toBe('This logo is no longer available.');
expect(apiMessage('logoGone', 404, '')).toBe('This logo is no longer available.');
});
it('die Sprache wird bei jedem Aufruf neu gelesen', () => {
expect(apiMessage('logoGone', 404, 'x')).toBe('Dieses Logo ist nicht (mehr) verfügbar.');
setLang('en');
expect(apiMessage('logoGone', 404, 'x')).toBe('This logo is no longer available.');
setLang('de');
expect(apiMessage('logoGone', 404, 'x')).toBe('Dieses Logo ist nicht (mehr) verfügbar.');
});
it('die Client-Codes generic und jwtUnsupported werden wie die des Dienstes übersetzt', () => {
setLang('en');
expect(apiMessage('generic', 500, 'x')).toBe(GENERIC_EN);
expect(apiMessage('jwtUnsupported', 501, 'x')).toBe(
'Token sign-in is turned off on the server – please let the site owner know.',
);
});
});
describe('apiMessage: dynamische Prüftexte (Code badRequest)', () => {
const dynamic = 'Der Titel ist zu lang (höchstens 60 Zeichen).';
it('Deutsch: der genauere Servertext statt „Ungültige Anfrage.“', () => {
expect(apiMessage('badRequest', 400, dynamic)).toBe(dynamic);
expect(apiMessage('badRequest', 400, 'Ungültige Anfrage.')).toBe('Ungültige Anfrage.');
});
it('Englisch: der allgemeine Text des Codes (der deutsche Prüftext lässt sich nicht übersetzen)', () => {
setLang('en');
expect(apiMessage('badRequest', 400, dynamic)).toBe('Invalid request.');
expect(apiMessage('badRequest', 400, 'Ungültige Anfrage.')).toBe('Invalid request.');
});
});
describe('apiMessage: unbekannter Code', () => {
it.each([null, 'error', 'neuerCode', ''])('Deutsch (%j): der Servertext', (code) => {
expect(apiMessage(code, 500, 'Der Server sagt etwas.')).toBe('Der Server sagt etwas.');
expect(apiMessage(code, 400, 'Der Server sagt etwas.')).toBe('Der Server sagt etwas.');
});
it.each([
[400, 'Invalid request.'],
[403, 'You are not allowed to do that.'],
[404, 'Not found.'],
[401, GENERIC_EN],
[409, GENERIC_EN],
[429, GENERIC_EN],
[500, GENERIC_EN],
[503, GENERIC_EN],
] as const)('Englisch: Status %i → „%s“', (status, expected) => {
setLang('en');
for (const code of [null, 'error', 'neuerCode']) expect(apiMessage(code, status, 'Der Server sagt etwas.')).toBe(expected);
});
it.each(['constructor', 'toString', '__proto__', 'hasOwnProperty'])(
'Namen aus Object.prototype (%s) sind kein Code',
(code) => {
expect(apiMessage(code, 500, 'Servertext')).toBe('Servertext');
setLang('en');
expect(apiMessage(code, 500, 'Servertext')).toBe(GENERIC_EN);
},
);
});