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,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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
// Übersetzbare Fehlertexte der API: Der Dienst `aircard-api` (deploy/api) antwortet auf Fehler mit `{error, code}`. `error` ist der
|
||||
// deutsche Text, `code` sein Schlüssel in MSG (deploy/api/common.js). Hier steht jeder Text noch einmal unter seinem Schlüssel
|
||||
// (der Browser lädt den Dienst nicht); api-messages.test.ts lädt MSG und prüft den Gleichlauf. Dazu kommen zwei Client-Codes.
|
||||
// Übersetzt wird beim Anzeigen (`apiMessage`); `ApiError.message` bleibt der deutsche Text, damit Vergleiche damit weiter gehen.
|
||||
import { currentLang, msg, t } from './i18n';
|
||||
|
||||
export const API_MESSAGES: Readonly<Record<string, string>> = {
|
||||
// Die Schlüssel und Texte von MSG, in derselben Reihenfolge.
|
||||
unauthorized: msg('Bitte melde dich an.'),
|
||||
notFound: msg('Nicht gefunden.'),
|
||||
cardGone: msg('Diese Karte ist nicht (mehr) verfügbar.'),
|
||||
forbidden: msg('Dazu hast du keine Berechtigung.'),
|
||||
adminOnly: msg('Nur für Admins.'),
|
||||
foreignFile: msg('Diese Datei gehört dir nicht.'),
|
||||
fileMissing: msg('Bild nicht gefunden.'),
|
||||
fileType: msg('Das Bild muss eine PNG- oder WebP-Datei sein.'),
|
||||
fileSize: msg('Das Bild ist größer als 5 MB.'),
|
||||
alreadyPublished: msg('Diese Karte ist schon veröffentlicht.'),
|
||||
filesTaken: msg('Diese Bilder sind bereits veröffentlicht oder gesperrt.'),
|
||||
imageFormat: msg('Das Bild hat nicht das Kartenformat.'),
|
||||
notYourCard: msg('Diese Karte gehört nicht zu deinem Konto.'),
|
||||
tooManyPublications: msg('Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.'),
|
||||
tooManyReports: msg('Zu viele Meldungen – bitte später nochmal.'),
|
||||
badRequest: msg('Ungültige Anfrage.'),
|
||||
badCardId: msg('Ungültige Karten-ID.'),
|
||||
badReason: msg('Bitte wähle einen gültigen Grund.'),
|
||||
badVisibility: msg('Ungültige Angabe zur Sichtbarkeit.'),
|
||||
logoGone: msg('Dieses Logo ist nicht (mehr) verfügbar.'),
|
||||
badLogoId: msg('Ungültige Logo-ID.'),
|
||||
logoNameMissing: msg('Bitte gib einen Namen an.'),
|
||||
logoFileType: msg('Das Logo muss eine PNG-Datei sein.'),
|
||||
logoFileSize: msg('Das Logo ist größer als 1 MB.'),
|
||||
logoFormat: msg('Das Logo hat ungültige Maße (8 × 8 bis 720 × 250 Pixel).'),
|
||||
logoTaken: msg('Diese Datei wird schon für ein Logo verwendet.'),
|
||||
tooManyLogos: msg('Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.'),
|
||||
badHeart: msg('Ungültige Angabe zum Herz.'),
|
||||
badMono: msg('Ungültige Angabe zu „einfarbig“.'),
|
||||
tooLarge: msg('Die Anfrage ist zu groß.'),
|
||||
jsonOnly: msg('Nur application/json wird unterstützt.'),
|
||||
unavailable: msg('Der Dienst ist gerade nicht erreichbar.'),
|
||||
internal: msg('Interner Fehler.'),
|
||||
// Nur im Client (kein Schlüssel im Dienst): allgemeiner Fehler und „Anmeldung per Token ausgeschaltet“.
|
||||
generic: msg('Etwas ist schiefgelaufen. Bitte nochmal versuchen.'),
|
||||
jwtUnsupported: msg('Anmeldung per Token ist auf dem Server ausgeschaltet – bitte den Betreiber informieren.'),
|
||||
};
|
||||
|
||||
/** Die allgemeine Meldung zu einem Status, wenn der Code unbekannt ist (Spec § 7): 400, 403, 404, sonst „Etwas ist schiefgelaufen“. */
|
||||
function fallbackText(status: number): string {
|
||||
if (status === 400) return API_MESSAGES.badRequest;
|
||||
if (status === 403) return API_MESSAGES.forbidden;
|
||||
if (status === 404) return API_MESSAGES.notFound;
|
||||
return API_MESSAGES.generic;
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Text zu einem Fehler des Dienstes (oder einem Fehler, den der Client mit denselben Codes erzeugt) in der Sprache der Seite.
|
||||
* Ein bekannter Code wird über das Wörterbuch übersetzt. Ein unbekannter oder fehlender Code (älterer Dienst, Proxy) zeigt auf
|
||||
* Deutsch den Text des Dienstes, auf Englisch eine allgemeine Meldung je Status.
|
||||
*/
|
||||
export function apiMessage(serverCode: string | null, status: number, serverText: string): string {
|
||||
const english = currentLang() === 'en';
|
||||
if (serverCode !== null && Object.hasOwn(API_MESSAGES, serverCode)) {
|
||||
// `badRequest` tragen auch die dynamischen Prüftexte des Dienstes („Der Titel ist zu lang (höchstens 60 Zeichen).“):
|
||||
// Deutsch zeigt dann den genaueren Servertext, Englisch den allgemeinen Text des Codes.
|
||||
if (!english && serverCode === 'badRequest') return serverText;
|
||||
return t(API_MESSAGES[serverCode]);
|
||||
}
|
||||
return english ? t(fallbackText(status)) : serverText;
|
||||
}
|
||||
@@ -30,4 +30,44 @@ export const EN: Readonly<Record<string, string>> = {
|
||||
// Fußzeile (shell/footer.ts)
|
||||
'Inoffizielle Community-Seite für die App AirCard-iOS – kein Teil des Original-Projekts und nicht mit Apple verbunden. Logos und Motive stammen von Nutzern; alle Marken gehören ihren Inhabern.':
|
||||
'Unofficial community site for the AirCard-iOS app – not part of the original project and not affiliated with Apple. Logos and artwork are uploaded by users; all trademarks belong to their owners.',
|
||||
|
||||
// API-Fehlertexte (i18n/api-messages.ts). Schlüssel und deutsche Texte stammen aus MSG (deploy/api/common.js); api-messages.test.ts hält sie gleich.
|
||||
'Bitte melde dich an.': 'Please sign in.',
|
||||
'Nicht gefunden.': 'Not found.',
|
||||
'Diese Karte ist nicht (mehr) verfügbar.': 'This card is no longer available.',
|
||||
'Dazu hast du keine Berechtigung.': 'You are not allowed to do that.',
|
||||
'Nur für Admins.': 'Admins only.',
|
||||
'Diese Datei gehört dir nicht.': 'This file doesn’t belong to you.',
|
||||
'Bild nicht gefunden.': 'Image not found.',
|
||||
'Das Bild muss eine PNG- oder WebP-Datei sein.': 'The image must be a PNG or WebP file.',
|
||||
'Das Bild ist größer als 5 MB.': 'The image is larger than 5 MB.',
|
||||
'Diese Karte ist schon veröffentlicht.': 'This card is already published.',
|
||||
'Diese Bilder sind bereits veröffentlicht oder gesperrt.': 'These images are already published or blocked.',
|
||||
'Das Bild hat nicht das Kartenformat.': 'The image doesn’t match the card format.',
|
||||
'Diese Karte gehört nicht zu deinem Konto.': 'This card doesn’t belong to your account.',
|
||||
'Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.':
|
||||
'You’ve already published 20 cards. Withdraw one to publish a new one.',
|
||||
'Zu viele Meldungen – bitte später nochmal.': 'Too many reports – please try again later.',
|
||||
'Ungültige Anfrage.': 'Invalid request.',
|
||||
'Ungültige Karten-ID.': 'Invalid card ID.',
|
||||
'Bitte wähle einen gültigen Grund.': 'Please choose a valid reason.',
|
||||
'Ungültige Angabe zur Sichtbarkeit.': 'Invalid visibility value.',
|
||||
'Dieses Logo ist nicht (mehr) verfügbar.': 'This logo is no longer available.',
|
||||
'Ungültige Logo-ID.': 'Invalid logo ID.',
|
||||
'Bitte gib einen Namen an.': 'Please enter a name.',
|
||||
'Das Logo muss eine PNG-Datei sein.': 'The logo must be a PNG file.',
|
||||
'Das Logo ist größer als 1 MB.': 'The logo is larger than 1 MB.',
|
||||
'Das Logo hat ungültige Maße (8 × 8 bis 720 × 250 Pixel).': 'The logo has invalid dimensions (8 × 8 to 720 × 250 pixels).',
|
||||
'Diese Datei wird schon für ein Logo verwendet.': 'This file is already used for a logo.',
|
||||
'Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.':
|
||||
'You’ve already uploaded 30 logos. Delete one to upload a new one.',
|
||||
'Ungültige Angabe zum Herz.': 'Invalid heart value.',
|
||||
'Ungültige Angabe zu „einfarbig“.': 'Invalid value for “single color”.',
|
||||
'Die Anfrage ist zu groß.': 'The request is too large.',
|
||||
'Nur application/json wird unterstützt.': 'Only application/json is supported.',
|
||||
'Der Dienst ist gerade nicht erreichbar.': 'The service is currently unavailable.',
|
||||
'Interner Fehler.': 'Internal error.',
|
||||
'Etwas ist schiefgelaufen. Bitte nochmal versuchen.': 'Something went wrong. Please try again.',
|
||||
'Anmeldung per Token ist auf dem Server ausgeschaltet – bitte den Betreiber informieren.':
|
||||
'Token sign-in is turned off on the server – please let the site owner know.',
|
||||
};
|
||||
Reference in new issue
Block a user