From 3051b3b3f9051dfb6ab177163a368b3f755c4e6d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 19:59:08 +0000 Subject: [PATCH] =?UTF-8?q?feat(logos):=20Datenschicht,=20API-Aufrufe=20un?= =?UTF-8?q?d=20=C3=BCbersetzbare=20API-Fehler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- web/src/account/appwrite.ts | 7 +- web/src/community/api.test.ts | 265 ++++++++++++++++- web/src/community/api.ts | 65 +++-- web/src/i18n/api-messages.test.ts | 124 ++++++++ web/src/i18n/api-messages.ts | 69 +++++ web/src/i18n/en.ts | 40 +++ web/src/logos/logo-api.test.ts | 260 +++++++++++++++++ web/src/logos/logo-api.ts | 61 ++++ web/src/logos/logos.test.ts | 465 ++++++++++++++++++++++++++++++ web/src/logos/logos.ts | 156 ++++++++++ 10 files changed, 1491 insertions(+), 21 deletions(-) create mode 100644 web/src/i18n/api-messages.test.ts create mode 100644 web/src/i18n/api-messages.ts create mode 100644 web/src/logos/logo-api.test.ts create mode 100644 web/src/logos/logo-api.ts create mode 100644 web/src/logos/logos.test.ts create mode 100644 web/src/logos/logos.ts diff --git a/web/src/account/appwrite.ts b/web/src/account/appwrite.ts index d99d971..6ba5f4b 100644 --- a/web/src/account/appwrite.ts +++ b/web/src/account/appwrite.ts @@ -7,7 +7,8 @@ export const APPWRITE_PROJECT: string = import.meta.env.VITE_APPWRITE_PROJECT_ID /** * Datenbank, Tabellen und Buckets des Projekts (siehe web/scripts/appwrite-setup.mjs). - * `table`/`bucket` sind die privaten Karten; `gallery`/`reports`/`galleryBucket` gehören zur Community-Galerie. + * `table`/`bucket` sind die privaten Karten; `gallery`/`reports`/`galleryBucket` gehören zur Community-Galerie, + * `logos`/`logoLikes`/`logoReports`/`logoBucket` zu den Community-Logos (Logos, Herzen, Meldungen, Logo-Dateien). */ export const IDS = { database: 'aircard', @@ -16,6 +17,10 @@ export const IDS = { gallery: 'gallery', reports: 'reports', galleryBucket: 'gallery-images', + logos: 'logos', + logoLikes: 'logo_likes', + logoReports: 'logo_reports', + logoBucket: 'logo-images', } as const; export const client: Client = new Client().setEndpoint(APPWRITE_ENDPOINT).setProject(APPWRITE_PROJECT); diff --git a/web/src/community/api.test.ts b/web/src/community/api.test.ts index fde2359..1f981b0 100644 --- a/web/src/community/api.test.ts +++ b/web/src/community/api.test.ts @@ -25,10 +25,18 @@ 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. +// Der JWT-Cache lebt im Modul; jeder Test bekommt eine frische Instanz. Das gilt auch für i18n: die frische Instanz +// beginnt mit der Browsersprache (im Test Englisch) und wird wie im Test-Setup auf Deutsch gestellt. async function freshApi(): Promise { vi.resetModules(); - return import('./api'); + const api = await import('./api'); + (await import('../i18n/i18n')).setLang('de', { persist: false }); + return api; +} + +/** Stellt die Sprache der frischen Instanz um (dieselbe, die `freshApi` geladen hat). */ +async function setLanguage(lang: 'de' | 'en'): Promise { + (await import('../i18n/i18n')).setLang(lang, { persist: false }); } const json = (status: number, body: unknown) => @@ -593,3 +601,256 @@ describe('withdrawCard, reportCard, setVisibility', () => { await expect(api.setVisibility('g1', true)).rejects.toMatchObject({ code: 403, message: 'Nur für Admins.' }); }); }); + +describe('serverCode: der Schlüssel der Fehlerantwort', () => { + const fail = (api: Api) => api.apiPost('/api/x', {}, { auth: false }).catch((e: unknown) => e); + + it('ApiError trägt ohne Angabe serverCode null; das Feld lässt sich setzen, ohne code und definitive zu ändern', async () => { + const api = await freshApi(); + expect(new api.ApiError(400, 'x').serverCode).toBeNull(); + expect(new api.ApiError(400, 'x', undefined, null).serverCode).toBeNull(); + const err = new api.ApiError(409, 'x', undefined, 'alreadyPublished'); + expect(err).toMatchObject({ code: 409, status: 409, message: 'x', definitive: true, serverCode: 'alreadyPublished' }); + expect(new api.ApiError(429, 'x', true, 'tooManyPublications')).toMatchObject({ definitive: true, serverCode: 'tooManyPublications' }); + }); + + it('wird aus {error, code} der Antwort gelesen; code bleibt der Status, message der deutsche Text', async () => { + const api = await freshApi(); + fetchMock.mockResolvedValue(json(403, { error: 'Nur für Admins.', code: 'adminOnly' })); + const err = await fail(api); + expect(err).toBeInstanceOf(api.ApiError); + expect(err).toMatchObject({ code: 403, status: 403, message: 'Nur für Admins.', serverCode: 'adminOnly' }); + }); + + it('ohne code in der Antwort (älterer Dienst, Proxy) bleibt serverCode null', async () => { + const api = await freshApi(); + fetchMock.mockResolvedValue(json(403, { error: 'Nur für Admins.' })); + expect(await fail(api)).toMatchObject({ code: 403, message: 'Nur für Admins.', serverCode: null }); + }); + + it.each([[42], [''], [null], [true], [['adminOnly']], [{ a: 1 }]])('ein code, der kein nichtleerer Text ist (%j), zählt nicht', async (code) => { + const api = await freshApi(); + fetchMock.mockResolvedValue(json(403, { error: 'Nur für Admins.', code })); + expect(await fail(api)).toMatchObject({ message: 'Nur für Admins.', serverCode: null }); + }); + + it('der Dienst sendet Dienst-Fehler mit ihrem Schlüssel: 429, 404, 409', async () => { + const api = await freshApi(); + for (const [status, error, code] of [ + [429, 'Zu viele Meldungen – bitte später nochmal.', 'tooManyReports'], + [404, 'Dieses Logo ist nicht (mehr) verfügbar.', 'logoGone'], + [409, 'Diese Datei wird schon für ein Logo verwendet.', 'logoTaken'], + ] as const) { + fetchMock.mockResolvedValueOnce(json(status, { error, code })); + expect(await fail(api)).toMatchObject({ code: status, message: error, serverCode: code }); + } + }); + + it('Netzfehler und Zeitlimit: unavailable', async () => { + const api = await freshApi(); + fetchMock.mockRejectedValue(new TypeError('Failed to fetch')); + expect(await fail(api)).toMatchObject({ code: 503, message: UNAVAILABLE, serverCode: 'unavailable' }); + vi.spyOn(AbortSignal, 'timeout').mockReturnValue(AbortSignal.abort(new DOMException('The operation timed out.', 'TimeoutError'))); + fetchMock.mockImplementation(async (_url, init) => { + throw init.signal?.reason; + }); + expect(await fail(api)).toMatchObject({ code: 503, serverCode: 'unavailable' }); + }); + + it.each([502, 503, 504])('Status %i vom Proxy (ohne code im Body, auch mit fremdem code): unavailable', async (status) => { + const api = await freshApi(); + fetchMock.mockResolvedValueOnce(json(status, { error: 'irgendwas' })); + expect(await fail(api)).toMatchObject({ code: 503, message: UNAVAILABLE, serverCode: 'unavailable' }); + fetchMock.mockResolvedValueOnce(json(status, { error: 'irgendwas', code: 'internal' })); + expect(await fail(api)).toMatchObject({ code: 503, message: UNAVAILABLE, serverCode: 'unavailable' }); + }); + + it('Fehlerseite ohne JSON, HTML mit Status 200 und JSON, das kein Objekt ist: unavailable', async () => { + const api = await freshApi(); + for (const response of [ + new Response('404 page not found', { status: 404 }), + new Response('AirCard', { status: 200 }), + new Response('"ok"', { status: 200 }), + ]) { + fetchMock.mockResolvedValueOnce(response); + expect(await fail(api)).toMatchObject({ code: 503, message: UNAVAILABLE, serverCode: 'unavailable' }); + } + }); + + it('eine abgebrochene Antwort (res.text() scheitert): unavailable', async () => { + const api = await freshApi(); + fetchMock.mockResolvedValue({ ok: true, status: 200, text: () => Promise.reject(new TypeError('network error')) } as unknown as Response); + expect(await fail(api)).toMatchObject({ code: 503, serverCode: 'unavailable' }); + }); + + it('JSON-Fehler ohne Text und ohne code: generic', async () => { + const api = await freshApi(); + fetchMock.mockResolvedValue(json(500, {})); + expect(await fail(api)).toMatchObject({ code: 500, message: 'Etwas ist schiefgelaufen. Bitte nochmal versuchen.', serverCode: 'generic' }); + }); + + it('JSON-Fehler ohne Text, aber mit code: der code des Dienstes', async () => { + const api = await freshApi(); + fetchMock.mockResolvedValue(json(404, { code: 'logoGone' })); + expect(await fail(api)).toMatchObject({ code: 404, message: 'Etwas ist schiefgelaufen. Bitte nochmal versuchen.', serverCode: 'logoGone' }); + }); + + it('createJWT mit user_auth_method_unsupported: jwtUnsupported (und eindeutig)', 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).toMatchObject({ code: 501, serverCode: 'jwtUnsupported', definitive: true }); + }); + + it('der zweite Versuch nach einem 401 liefert den Schlüssel der zweiten Antwort', async () => { + const api = await freshApi(); + fetchMock.mockImplementation(async () => json(401, { error: 'Bitte melde dich an.', code: 'unauthorized' })); + const err = await api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e); + expect(err).toMatchObject({ code: 401, message: 'Bitte melde dich an.', serverCode: 'unauthorized' }); + }); + + it('unavailableError(): 503, Ausgang offen, Schlüssel unavailable', async () => { + const api = await freshApi(); + const err = api.unavailableError(); + expect(err).toBeInstanceOf(api.ApiError); + expect(err).toMatchObject({ code: 503, message: UNAVAILABLE, serverCode: 'unavailable', definitive: false }); + expect(api.isDefinitiveFailure(err)).toBe(false); + }); + + describe('beim Veröffentlichen', () => { + const input = { + title: 'Meine Karte', + issuer: 'Beispielbank', + tags: ['visa'], + displayName: 'Kenso', + imageFileId: 'img1', + previewFileId: 'pre1', + sourceCardId: 'card1', + }; + + it('403 „Dateien belegt“: der Dienst sendet filesTaken, publishCard macht daraus 409 alreadyPublished', async () => { + const api = await freshApi(); + const text = 'Diese Bilder sind bereits veröffentlicht oder gesperrt.'; + fetchMock.mockImplementation(async () => json(403, { error: text, code: 'filesTaken' })); // je Aufruf eine neue Response + expect(await api.apiPost('/api/veroeffentlichen', input, { auth: true }).catch((e: unknown) => e)).toMatchObject({ + code: 403, + message: text, + serverCode: 'filesTaken', + }); + const err = await api.publishCard(input).catch((e: unknown) => e); + expect(err).toMatchObject({ code: 409, message: 'Diese Karte ist schon veröffentlicht.', serverCode: 'alreadyPublished' }); + expect(api.isAlreadyPublished(err)).toBe(true); + }); + + it('409 vom Dienst: alreadyPublished', async () => { + const api = await freshApi(); + fetchMock.mockResolvedValue(json(409, { error: 'Diese Karte ist schon veröffentlicht.', code: 'alreadyPublished' })); + expect(await api.publishCard(input).catch((e: unknown) => e)).toMatchObject({ code: 409, serverCode: 'alreadyPublished' }); + }); + + it('429 „schon 20 Karten“: tooManyPublications, eindeutig', async () => { + const api = await freshApi(); + const text = 'Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.'; + fetchMock.mockResolvedValue(json(429, { error: text, code: 'tooManyPublications' })); + const err = await api.publishCard(input).catch((e: unknown) => e); + expect(err).toMatchObject({ code: 429, message: text, serverCode: 'tooManyPublications', definitive: true }); + }); + + it('ohne die Schlüssel (Dienst von vorher) klappt die Umdeutung weiter über den deutschen Text', async () => { + const api = await freshApi(); + fetchMock.mockResolvedValueOnce(json(403, { error: 'Diese Bilder sind bereits veröffentlicht oder gesperrt.' })); + expect(await api.publishCard(input).catch((e: unknown) => e)).toMatchObject({ code: 409, serverCode: 'alreadyPublished' }); + fetchMock.mockResolvedValueOnce( + json(429, { error: 'Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.' }), + ); + expect(await api.publishCard(input).catch((e: unknown) => e)).toMatchObject({ code: 429, serverCode: 'tooManyPublications', definitive: true }); + }); + + it('eine Antwort ohne id: unavailable, Ausgang offen', async () => { + const api = await freshApi(); + fetchMock.mockResolvedValue(json(201, {})); + const err = await api.publishCard(input).catch((e: unknown) => e); + expect(err).toMatchObject({ code: 503, serverCode: 'unavailable', definitive: false }); + }); + + it('ein anderes 403 behält seinen Schlüssel', async () => { + const api = await freshApi(); + fetchMock.mockResolvedValue(json(403, { error: 'Diese Datei gehört dir nicht.', code: 'foreignFile' })); + expect(await api.publishCard(input).catch((e: unknown) => e)).toMatchObject({ code: 403, serverCode: 'foreignFile' }); + }); + }); +}); + +type ApiErr = InstanceType; + +/** Der ApiError, mit dem `promise` scheitert. */ +async function rejection(promise: Promise): Promise { + try { + await promise; + } catch (err) { + return err as ApiErr; + } + throw new Error('Die Anfrage ist nicht gescheitert.'); +} + +describe('apiErrorText: die Fehlermeldung zum Anzeigen', () => { + it('Deutsch: der Text des Dienstes, auch bei Fehlern ohne bekannten Schlüssel', async () => { + const api = await freshApi(); + expect(api.apiErrorText(new api.ApiError(409, 'Diese Karte ist schon veröffentlicht.', undefined, 'alreadyPublished'))).toBe( + 'Diese Karte ist schon veröffentlicht.', + ); + expect(api.apiErrorText(new api.ApiError(400, 'Der Titel ist zu lang (höchstens 60 Zeichen).', undefined, 'badRequest'))).toBe( + 'Der Titel ist zu lang (höchstens 60 Zeichen).', + ); + expect(api.apiErrorText(new api.ApiError(418, 'Ich bin eine Teekanne.'))).toBe('Ich bin eine Teekanne.'); + }); + + it('Englisch: übersetzt über den Schlüssel', async () => { + const api = await freshApi(); + await setLanguage('en'); + expect(api.apiErrorText(new api.ApiError(409, 'Diese Karte ist schon veröffentlicht.', undefined, 'alreadyPublished'))).toBe( + 'This card is already published.', + ); + expect(api.apiErrorText(new api.ApiError(403, 'Nur für Admins.', undefined, 'adminOnly'))).toBe('Admins only.'); + }); + + it('Englisch: Fehler, die der Client selbst erzeugt (Netz, JWT, Veröffentlichen), sind übersetzt', async () => { + const api = await freshApi(); + await setLanguage('en'); + fetchMock.mockRejectedValue(new TypeError('Failed to fetch')); + const network = await rejection(api.apiPost('/api/x', {}, { auth: false })); + expect(api.apiErrorText(network)).toBe('The service is currently unavailable.'); + + mocks.createJWT.mockRejectedValue(Object.assign(new Error('x'), { code: 501, type: 'user_auth_method_unsupported' })); + const jwt = await rejection(api.apiPost('/api/x', {}, { auth: true })); + expect(api.apiErrorText(jwt)).toBe('Token sign-in is turned off on the server – please let the site owner know.'); + + fetchMock.mockResolvedValue(json(500, {})); + const generic = await rejection(api.apiPost('/api/x', {}, { auth: false })); + expect(api.apiErrorText(generic)).toBe('Something went wrong. Please try again.'); + }); + + it('Englisch: unbekannter oder fehlender Schlüssel, allgemeine Meldung je Status (die ApiError.code)', async () => { + const api = await freshApi(); + await setLanguage('en'); + expect(api.apiErrorText(new api.ApiError(400, 'Das gibt es nur auf Deutsch.'))).toBe('Invalid request.'); + expect(api.apiErrorText(new api.ApiError(403, 'Das gibt es nur auf Deutsch.', undefined, 'neuerCode'))).toBe( + 'You are not allowed to do that.', + ); + expect(api.apiErrorText(new api.ApiError(404, 'Das gibt es nur auf Deutsch.'))).toBe('Not found.'); + expect(api.apiErrorText(new api.ApiError(500, 'Das gibt es nur auf Deutsch.'))).toBe('Something went wrong. Please try again.'); + }); + + it('übergibt Schlüssel, Status und Text an apiMessage', async () => { + const api = await freshApi(); + const { apiMessage } = await import('../i18n/api-messages'); + await setLanguage('en'); + for (const err of [ + new api.ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', undefined, 'logoGone'), + new api.ApiError(400, 'Der Name ist ungültig.', undefined, 'badRequest'), + new api.ApiError(403, 'Text', undefined, null), + ]) { + expect(api.apiErrorText(err)).toBe(apiMessage(err.serverCode, err.code, err.message)); + } + }); +}); diff --git a/web/src/community/api.ts b/web/src/community/api.ts index d67a5b0..8d9d23f 100644 --- a/web/src/community/api.ts +++ b/web/src/community/api.ts @@ -4,6 +4,7 @@ import { account } from '../account/appwrite'; import { currentUserId, onAuthChange, onLogout } from '../account/auth'; import { errorType } from '../account/errors'; +import { API_MESSAGES, apiMessage } from '../i18n/api-messages'; export type ReportReason = 'unangemessen' | 'kartendaten' | 'urheberrecht' | 'spam' | 'sonstiges'; @@ -17,14 +18,16 @@ export interface PublishInput { sourceCardId: string; } -const UNAVAILABLE = 'Der Dienst ist gerade nicht erreichbar.'; -const GENERIC = 'Etwas ist schiefgelaufen. Bitte nochmal versuchen.'; -const JWT_UNSUPPORTED = 'Anmeldung per Token ist auf dem Server ausgeschaltet – bitte den Betreiber informieren.'; -const ALREADY_PUBLISHED = 'Diese Karte ist schon veröffentlicht.'; -/** Wortlaut der API (deploy/api/app.js, MSG.filesTaken): die hochgeladenen Dateien sind schon belegt oder gesperrt. */ -const FILES_TAKEN = 'Diese Bilder sind bereits veröffentlicht oder gesperrt.'; +// Die deutschen Texte stehen einmal in i18n/api-messages.ts (dort mit MSG des Dienstes abgeglichen); `ApiError.message` bleibt +// Deutsch, damit der Vergleich mit ihnen weiter geht. Angezeigt wird über `apiErrorText`. +const UNAVAILABLE = API_MESSAGES.unavailable; +const GENERIC = API_MESSAGES.generic; +const JWT_UNSUPPORTED = API_MESSAGES.jwtUnsupported; +const ALREADY_PUBLISHED = API_MESSAGES.alreadyPublished; +/** Wortlaut der API (deploy/api/common.js, MSG.filesTaken): die hochgeladenen Dateien sind schon belegt oder gesperrt. */ +const FILES_TAKEN = API_MESSAGES.filesTaken; /** Wortlaut der API (MSG.tooManyPublications): das Limit von 20 Veröffentlichungen je Konto ist erreicht. */ -const TOO_MANY_PUBLICATIONS = 'Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.'; +const TOO_MANY_PUBLICATIONS = API_MESSAGES.tooManyPublications; /** Appwrite-JWTs gelten 15 Minuten; wir verwenden eines höchstens 10 Minuten. */ const JWT_TTL_MS = 10 * 60 * 1000; @@ -33,6 +36,9 @@ const REQUEST_TIMEOUT_MS = 15_000; /** * Fehler des API-Dienstes. `code` ist der HTTP-Status (`status` dasselbe), `message` der deutsche Servertext. + * `serverCode` ist der Schlüssel der Fehlerantwort (`code` in `{error, code}`, siehe MSG in deploy/api/common.js; Fehler, die + * der Client selbst erzeugt, tragen die passenden Schlüssel `unavailable`, `generic`, `jwtUnsupported`, …), null ohne Schlüssel. + * Übersetzt wird der Text nach `serverCode` (`apiErrorText`), nie nach `message`. * `definitive`: der Dienst hat die Anfrage eindeutig abgelehnt oder es ging gar keine raus, also wurde nichts angelegt. * Ohne Angabe gilt das für 4xx außer Zeitüberschreitung (408) und Drosselung (429); 5xx, 408, 429 und alles, was * keine verwertbare Antwort ergab (Netzfehler, Zeitlimit, Fehlerseite, fehlende `id`), lassen offen, ob die @@ -41,12 +47,14 @@ const REQUEST_TIMEOUT_MS = 15_000; export class ApiError extends Error { readonly code: number; readonly definitive: boolean; + readonly serverCode: string | null; - constructor(code: number, message: string, definitive?: boolean) { + constructor(code: number, message: string, definitive?: boolean, serverCode?: string | null) { super(message); this.name = 'ApiError'; this.code = code; this.definitive = definitive ?? (code >= 400 && code < 500 && code !== 408 && code !== 429); + this.serverCode = serverCode ?? null; } get status(): number { @@ -54,6 +62,16 @@ export class ApiError extends Error { } } +/** Der Dienst gab keine verwertbare Antwort: 503, Ausgang offen (die Anfrage kann trotzdem gewirkt haben). */ +export function unavailableError(): ApiError { + return new ApiError(503, UNAVAILABLE, false, 'unavailable'); +} + +/** Der Text eines Fehlers zum Anzeigen, in der Sprache der Seite (bekannter `serverCode` übersetzt, siehe `apiMessage`). */ +export function apiErrorText(err: ApiError): string { + return apiMessage(err.serverCode, err.code, err.message); +} + /** * Wahr, wenn eine fehlgeschlagene `apiPost`-Anfrage sicher nichts bewirkt hat: eindeutige Ablehnung des Dienstes, * oder ein Fehler vor der Anfrage (JWT holen). Falsch bei offenem Ausgang (siehe `ApiError.definitive`). @@ -96,7 +114,7 @@ async function mintJwt(userId: string | null, started: number): Promise if (errorType(err) === 'user_auth_method_unsupported') { const code = (err as { code?: unknown }).code; // Es ging keine Anfrage an den API-Dienst raus: eindeutig. - throw new ApiError(typeof code === 'number' ? code : 501, JWT_UNSUPPORTED, true); + throw new ApiError(typeof code === 'number' ? code : 501, JWT_UNSUPPORTED, true, 'jwtUnsupported'); } throw err; // z. B. 401 ohne Sitzung: withLogin erkennt es am Statuscode } @@ -134,6 +152,12 @@ function serverMessage(body: Json): string | null { return null; } +/** Der Schlüssel einer Fehlerantwort (`code` neben `error`), sonst null. */ +function serverCodeOf(body: Json): string | null { + const value = body.code; + return typeof value === 'string' && value !== '' ? value : null; +} + async function send(path: string, body: unknown, jwt: string | null): Promise { const headers: Record = { 'Content-Type': 'application/json' }; if (jwt) headers['X-Aircard-Jwt'] = jwt; @@ -152,18 +176,23 @@ async function send(path: string, body: unknown, jwt: string | null): Promise res = await apiPost('/api/veroeffentlichen', input, { auth: true }); } catch (err) { if (err instanceof ApiError && err.code === 403 && err.message === FILES_TAKEN) { - throw new ApiError(409, ALREADY_PUBLISHED); + throw new ApiError(409, ALREADY_PUBLISHED, undefined, 'alreadyPublished'); } if (err instanceof ApiError && err.code === 429 && err.message === TOO_MANY_PUBLICATIONS) { - throw new ApiError(429, TOO_MANY_PUBLICATIONS, true); + throw new ApiError(429, TOO_MANY_PUBLICATIONS, true, 'tooManyPublications'); } throw err; } - if (typeof res.id !== 'string' || res.id === '') throw new ApiError(503, UNAVAILABLE); + if (typeof res.id !== 'string' || res.id === '') throw unavailableError(); return { id: res.id }; } diff --git a/web/src/i18n/api-messages.test.ts b/web/src/i18n/api-messages.test.ts new file mode 100644 index 0000000..21e7766 --- /dev/null +++ b/web/src/i18n/api-messages.test.ts @@ -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 }; + +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); + }, + ); +}); diff --git a/web/src/i18n/api-messages.ts b/web/src/i18n/api-messages.ts new file mode 100644 index 0000000..25bb367 --- /dev/null +++ b/web/src/i18n/api-messages.ts @@ -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> = { + // 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; +} diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 4f09c09..3bfcf26 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -30,4 +30,44 @@ export const EN: Readonly> = { // 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.', }; diff --git a/web/src/logos/logo-api.test.ts b/web/src/logos/logo-api.test.ts new file mode 100644 index 0000000..676429d --- /dev/null +++ b/web/src/logos/logo-api.test.ts @@ -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>(); + +/** 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>['api']['ApiError']>; + +/** Der ApiError, mit dem `promise` scheitert. */ +async function rejection(promise: Promise): Promise { + 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; + +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('Bad Gateway', { 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', + }); + }); +}); diff --git a/web/src/logos/logo-api.ts b/web/src/logos/logo-api.ts new file mode 100644 index 0000000..684d8df --- /dev/null +++ b/web/src/logos/logo-api.ts @@ -0,0 +1,61 @@ +// Schreibzugriffe der Community-Logos: Aufrufe des Dienstes `aircard-api` (deploy/api) mit dem Nutzer-JWT, über `apiPost` und +// mit denselben Fehlern (`ApiError`) wie community/api.ts. Lesen läuft direkt über Appwrite (logos.ts). +import { ApiError, apiPost, unavailableError, type ReportReason } from '../community/api'; + +export interface LogoUploadInput { + name: string; + displayName: string; + mono: boolean; + /** Die Datei im Logo-Bucket (frisch hochgeladen, mit `delete("user:")`); ihre ID wird auch die ID des Logos. */ + fileId: string; +} + +/** + * Legt aus der hochgeladenen Datei ein Logo an (der Dienst prüft Felder, Datei, Maße und das Limit von 30 je Konto). + * Das Limit (429 `tooManyLogos`) ist eine eindeutige Ablehnung: es wurde nichts angelegt, die Datei darf aufgeräumt werden + * (wie beim Limit der Karten in `publishCard`); jedes andere 429 und alle 5xx lassen den Ausgang offen. Eine Antwort ohne + * `id` ist ein Fehler mit offenem Ausgang (die Zeile kann angelegt sein). + */ +export async function uploadLogo(input: LogoUploadInput): Promise<{ id: string }> { + const body: LogoUploadInput = { name: input.name, displayName: input.displayName, mono: input.mono, fileId: input.fileId }; + let res: Record; + try { + res = await apiPost('/api/logos/hochladen', body, { auth: true }); + } catch (err) { + if (err instanceof ApiError && err.code === 429 && err.serverCode === 'tooManyLogos') { + throw new ApiError(429, err.message, true, err.serverCode); + } + throw err; + } + if (typeof res.id !== 'string' || res.id === '') throw unavailableError(); + return { id: res.id }; +} + +/** + * Setzt oder nimmt mein Herz für ein Logo und liefert Zustand und Zähler, wie der Dienst sie nach dem Zählen der Zeilen kennt. + * Eine Antwort ohne beides ist ein Fehler mit offenem Ausgang. + */ +export async function setHeart(id: string, herz: boolean): Promise<{ herz: boolean; likeCount: number }> { + const res = await apiPost('/api/logos/herz', { id, herz }, { auth: true }); + const { likeCount } = res; + if (typeof res.herz !== 'boolean' || typeof likeCount !== 'number' || !Number.isInteger(likeCount) || likeCount < 0) { + throw unavailableError(); + } + return { herz: res.herz, likeCount }; +} + +/** Meldet ein Logo (ohne Anmeldung). `schonGemeldet`: dieser Melder hatte es schon gemeldet. */ +export async function reportLogo(id: string, reason: ReportReason): Promise<{ schonGemeldet: boolean }> { + const res = await apiPost('/api/logos/melden', { id, reason }, { auth: false }); + return { schonGemeldet: res.schonGemeldet === true }; +} + +/** Löscht ein Logo samt Datei, Herzen und Meldungen (Hochlader oder Admin). */ +export async function deleteLogo(id: string): Promise { + await apiPost('/api/logos/loeschen', { id }, { auth: true }); +} + +/** Admin: blendet ein Logo ein oder aus. */ +export async function setLogoVisibility(id: string, visible: boolean): Promise { + await apiPost('/api/admin/logo-sichtbarkeit', { id, sichtbar: visible }, { auth: true }); +} diff --git a/web/src/logos/logos.test.ts b/web/src/logos/logos.test.ts new file mode 100644 index 0000000..2c1757d --- /dev/null +++ b/web/src/logos/logos.test.ts @@ -0,0 +1,465 @@ +import { AppwriteException, Query } from 'appwrite'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + listRows: vi.fn(), + getRow: vi.fn(), + fetchFile: vi.fn(), +})); + +vi.mock('../account/appwrite', () => ({ + IDS: { + database: 'db', + table: 'cards-table', + bucket: 'cards-bucket', + gallery: 'gallery-table', + reports: 'reports-table', + galleryBucket: 'gallery-bucket', + logos: 'logos-table', + logoLikes: 'likes-table', + logoReports: 'logo-reports-table', + logoBucket: 'logo-bucket', + }, + account: {}, + client: {}, + storage: {}, + APPWRITE_ENDPOINT: 'https://aircard.test/v1', + tables: { listRows: mocks.listRows, getRow: mocks.getRow }, +})); + +// ID_PATTERN & Co. bleiben echt; nur das Laden der Datei geht nicht ins Netz. +vi.mock('../account/cards', async (importOriginal) => ({ + ...(await importOriginal()), + fetchFile: mocks.fetchFile, +})); + +import { + LOGO_PAGE_SIZE, + getLogo, + listLogoReports, + listLogos, + loadLogoBlob, + logoQueries, + myHearts, + toLogo, + type CommunityLogo, +} from './logos'; + +const row = (n: number, patch: Record = {}) => ({ + $id: `l${n}`, + $createdAt: `2026-10-0${(n % 9) + 1}T10:00:00.000Z`, + name: `Logo ${n}`, + displayName: 'Kenso', + fileId: `l${n}`, + ownerId: 'u1', + mono: false, + likeCount: n, + hidden: false, + reportCount: 0, + ...patch, +}); + +const notFound = (type = 'row_not_found') => Object.assign(new Error('Row not found'), { code: 404, type }); + +beforeEach(() => { + mocks.listRows.mockReset(); + mocks.getRow.mockReset(); + mocks.fetchFile.mockReset(); +}); + +describe('IDS', () => { + it('kennt die Logo-Tabellen und den Logo-Bucket (Namen wie im Setup-Skript)', async () => { + vi.stubEnv('VITE_APPWRITE_ENDPOINT', 'https://aircard.test/v1'); + try { + const { IDS } = await vi.importActual('../account/appwrite'); + expect(IDS).toMatchObject({ + logos: 'logos', + logoLikes: 'logo_likes', + logoReports: 'logo_reports', + logoBucket: 'logo-images', + }); + expect(IDS.database).toBe('aircard'); // und das Bestehende bleibt + expect(IDS.gallery).toBe('gallery'); + } finally { + vi.unstubAllEnvs(); + } + }); +}); + +describe('logoQueries', () => { + it('Beliebt: nur sichtbare Logos, meiste Herzen zuerst, bei Gleichstand die neuesten, 24 pro Seite', () => { + expect(logoQueries({ sort: 'popular' })).toEqual([ + Query.equal('hidden', false), + Query.orderDesc('likeCount'), + Query.orderDesc('$createdAt'), + Query.limit(24), + ]); + expect(LOGO_PAGE_SIZE).toBe(24); + }); + + it('die Abfragen sind genau die Appwrite-JSON-Strings', () => { + expect(logoQueries({ sort: 'popular' })).toEqual([ + JSON.stringify({ method: 'equal', attribute: 'hidden', values: [false] }), + JSON.stringify({ method: 'orderDesc', attribute: 'likeCount' }), + JSON.stringify({ method: 'orderDesc', attribute: '$createdAt' }), + JSON.stringify({ method: 'limit', values: [24] }), + ]); + }); + + it('Neu: neueste zuerst, ohne Sortierung nach likeCount', () => { + const queries = logoQueries({ sort: 'new' }); + expect(queries).toEqual([Query.equal('hidden', false), Query.orderDesc('$createdAt'), Query.limit(24)]); + expect(queries.join('')).not.toContain('likeCount'); + }); + + it("Admin-Filter 'all' ist die öffentliche Ansicht", () => { + expect(logoQueries({ sort: 'popular', filter: 'all' })).toEqual(logoQueries({ sort: 'popular' })); + expect(logoQueries({ sort: 'new', filter: 'all' })).toEqual(logoQueries({ sort: 'new' })); + }); + + it("Admin-Filter 'reported': reportCount > 0, ohne hidden-Bedingung (gemeldet und schon ausgeblendet)", () => { + const queries = logoQueries({ sort: 'new', filter: 'reported' }); + expect(queries).toEqual([Query.greaterThan('reportCount', 0), Query.orderDesc('$createdAt'), Query.limit(24)]); + expect(queries.join('')).not.toContain('hidden'); + }); + + it("Admin-Filter 'hidden': nur ausgeblendete Logos", () => { + expect(logoQueries({ sort: 'new', filter: 'hidden' })).toEqual([ + Query.equal('hidden', true), + Query.orderDesc('$createdAt'), + Query.limit(24), + ]); + }); + + it('Filter und Sortierung sind unabhängig: Gemeldet nach Beliebtheit', () => { + expect(logoQueries({ sort: 'popular', filter: 'reported' })).toEqual([ + Query.greaterThan('reportCount', 0), + Query.orderDesc('likeCount'), + Query.orderDesc('$createdAt'), + Query.limit(24), + ]); + }); + + it('mit Cursor: zusätzlich cursorAfter(letzte ID), als letzte Abfrage', () => { + expect(logoQueries({ sort: 'popular', cursor: 'l24' })).toEqual([ + Query.equal('hidden', false), + Query.orderDesc('likeCount'), + Query.orderDesc('$createdAt'), + Query.limit(24), + Query.cursorAfter('l24'), + ]); + expect(logoQueries({ sort: 'new', filter: 'hidden', cursor: 'l5' })).toEqual([ + Query.equal('hidden', true), + Query.orderDesc('$createdAt'), + Query.limit(24), + Query.cursorAfter('l5'), + ]); + }); + + it('ein leerer Cursor wird ignoriert', () => { + expect(logoQueries({ sort: 'new', cursor: '' })).toEqual(logoQueries({ sort: 'new' })); + }); +}); + +describe('toLogo', () => { + it('übernimmt alle Felder', () => { + expect(toLogo(row(1, { mono: true, hidden: true, reportCount: 3, likeCount: 7 }))).toEqual({ + id: 'l1', + name: 'Logo 1', + displayName: 'Kenso', + fileId: 'l1', + ownerId: 'u1', + mono: true, + likeCount: 7, + hidden: true, + reportCount: 3, + createdAt: row(1).$createdAt, + } satisfies CommunityLogo); + }); + + it('fehlende Felder bekommen leere Standardwerte', () => { + expect(toLogo({ $id: 'l9' })).toEqual({ + id: 'l9', + name: '', + displayName: '', + fileId: '', + ownerId: '', + mono: false, + likeCount: 0, + hidden: false, + reportCount: 0, + createdAt: '', + }); + }); + + it('Felder mit falschem Typ gelten als fehlend', () => { + expect( + toLogo({ + $id: 'l9', + name: 42, + displayName: null, + fileId: ['x'], + ownerId: { id: 'u1' }, + mono: 'ja', + likeCount: 'viele', + hidden: 1, + reportCount: '3', + $createdAt: 5, + }), + ).toEqual({ + id: 'l9', + name: '', + displayName: '', + fileId: '', + ownerId: '', + mono: false, + likeCount: 0, + hidden: false, + reportCount: 0, + createdAt: '', + }); + }); + + it('eine fehlende oder falsch typisierte $id wird leer', () => { + expect(toLogo({}).id).toBe(''); + expect(toLogo({ $id: 7 }).id).toBe(''); + }); + + it('mono und hidden sind nur bei true wahr', () => { + for (const value of [false, 'true', 1, null, undefined, 'ja']) { + const logo = toLogo({ $id: 'l1', mono: value, hidden: value }); + expect(logo.mono).toBe(false); + expect(logo.hidden).toBe(false); + } + expect(toLogo({ $id: 'l1', mono: true, hidden: true })).toMatchObject({ mono: true, hidden: true }); + }); + + it('likeCount und reportCount: nur endliche Zahlen größer als 0, sonst 0', () => { + for (const field of ['likeCount', 'reportCount'] as const) { + expect(toLogo({ $id: 'l1', [field]: 5 })[field]).toBe(5); + expect(toLogo({ $id: 'l1', [field]: 0 })[field]).toBe(0); + expect(toLogo({ $id: 'l1', [field]: -1 })[field]).toBe(0); + expect(toLogo({ $id: 'l1', [field]: -0.5 })[field]).toBe(0); + expect(toLogo({ $id: 'l1', [field]: Number.NaN })[field]).toBe(0); + expect(toLogo({ $id: 'l1', [field]: Number.POSITIVE_INFINITY })[field]).toBe(0); + expect(toLogo({ $id: 'l1', [field]: '5' })[field]).toBe(0); + expect(toLogo({ $id: 'l1', [field]: null })[field]).toBe(0); + } + }); +}); + +describe('listLogos', () => { + it('fragt die Logo-Tabelle mit den Abfragen ab und liefert Logos', async () => { + mocks.listRows.mockResolvedValue({ total: 2, rows: [row(1), row(2, { mono: true })] }); + const page = await listLogos({ sort: 'popular' }); + expect(mocks.listRows).toHaveBeenCalledOnce(); + expect(mocks.listRows.mock.calls[0][0]).toEqual({ + databaseId: 'db', + tableId: 'logos-table', + queries: logoQueries({ sort: 'popular' }), + }); + expect(page.logos.map((logo) => logo.id)).toEqual(['l1', 'l2']); + expect(page.logos[1]).toMatchObject({ name: 'Logo 2', mono: true, likeCount: 2 }); + }); + + it('weniger als 24 Zeilen: keine weitere Seite (nextCursor null)', async () => { + mocks.listRows.mockResolvedValue({ total: 23, rows: Array.from({ length: 23 }, (_, i) => row(i + 1)) }); + expect((await listLogos({ sort: 'new' })).nextCursor).toBeNull(); + }); + + it('keine Zeilen: leere Liste, nextCursor null', async () => { + mocks.listRows.mockResolvedValue({ total: 0, rows: [] }); + await expect(listLogos({ sort: 'new' })).resolves.toEqual({ logos: [], nextCursor: null }); + }); + + it('genau 24 Zeilen: nextCursor ist die ID des letzten Logos', async () => { + mocks.listRows.mockResolvedValue({ total: 60, rows: Array.from({ length: 24 }, (_, i) => row(i + 1)) }); + const page = await listLogos({ sort: 'popular' }); + expect(page.logos).toHaveLength(24); + expect(page.nextCursor).toBe('l24'); + }); + + it('die nächste Seite schickt den Cursor, die Admin-Ansicht den Filter mit', async () => { + mocks.listRows.mockResolvedValue({ total: 0, rows: [] }); + await listLogos({ sort: 'new', cursor: 'l24', filter: 'reported' }); + expect(mocks.listRows.mock.calls[0][0].queries).toEqual(logoQueries({ sort: 'new', cursor: 'l24', filter: 'reported' })); + expect(mocks.listRows.mock.calls[0][0].queries).toContain(Query.cursorAfter('l24')); + }); + + it('Fehler gehen durch', async () => { + const failure = Object.assign(new Error('down'), { code: 503 }); + mocks.listRows.mockRejectedValue(failure); + await expect(listLogos({ sort: 'new' })).rejects.toBe(failure); + }); +}); + +describe('getLogo', () => { + it('liefert das Logo', async () => { + mocks.getRow.mockResolvedValue(row(7, { mono: true })); + const logo = await getLogo('l7'); + expect(logo).toMatchObject({ id: 'l7', mono: true }); + expect(mocks.getRow).toHaveBeenCalledWith({ databaseId: 'db', tableId: 'logos-table', rowId: 'l7' }); + }); + + it('404 (nicht vorhanden oder ausgeblendet für mich) ergibt null', async () => { + mocks.getRow.mockRejectedValue(notFound()); + await expect(getLogo('l7')).resolves.toBeNull(); + }); + + it('404 ohne Typ (z. B. Proxy) ergibt ebenfalls null', async () => { + mocks.getRow.mockRejectedValue(Object.assign(new Error('not found'), { code: 404 })); + await expect(getLogo('l7')).resolves.toBeNull(); + }); + + it('eine fehlende Tabelle (404 table_not_found) ist ein Konfigurationsfehler, kein „Logo weg“', async () => { + const failure = notFound('table_not_found'); + mocks.getRow.mockRejectedValue(failure); + await expect(getLogo('l7')).rejects.toBe(failure); + }); + + it.each([500, 503, 401, 429])('Fehler %i geht durch', async (code) => { + const failure = Object.assign(new Error('x'), { code, type: 'general_server_error' }); + mocks.getRow.mockRejectedValue(failure); + await expect(getLogo('l7')).rejects.toBe(failure); + }); + + it('Netzfehler gehen durch', async () => { + const failure = new TypeError('Failed to fetch'); + mocks.getRow.mockRejectedValue(failure); + await expect(getLogo('l7')).rejects.toBe(failure); + }); + + it.each(['', 'kaputt id', '../x', 'a/b', 'a?b', '.hidden', '-x', 'x'.repeat(37), 'ä'])( + 'ungültige ID %j: null, ohne Anfrage', + async (id) => { + await expect(getLogo(id)).resolves.toBeNull(); + expect(mocks.getRow).not.toHaveBeenCalled(); + }, + ); +}); + +describe('myHearts', () => { + it('ohne Logo-IDs: leere Menge, ohne Anfrage', async () => { + const hearts = await myHearts('u', []); + expect(hearts).toBeInstanceOf(Set); + expect(hearts.size).toBe(0); + expect(mocks.listRows).not.toHaveBeenCalled(); + }); + + it('ohne Nutzer-ID: leere Menge, ohne Anfrage', async () => { + expect((await myHearts('', ['l1'])).size).toBe(0); + expect(mocks.listRows).not.toHaveBeenCalled(); + }); + + it('fragt die eigenen Herzen für genau diese Logos ab (höchstens 100)', async () => { + mocks.listRows.mockResolvedValue({ total: 2, rows: [{ $id: 'h1', logoId: 'l1', userId: 'u' }, { $id: 'h3', logoId: 'l3', userId: 'u' }] }); + const hearts = await myHearts('u', ['l1', 'l2', 'l3']); + expect(mocks.listRows).toHaveBeenCalledOnce(); + expect(mocks.listRows.mock.calls[0][0]).toEqual({ + databaseId: 'db', + tableId: 'likes-table', + queries: [Query.equal('userId', 'u'), Query.equal('logoId', ['l1', 'l2', 'l3']), Query.limit(100)], + }); + expect([...hearts].sort()).toEqual(['l1', 'l3']); + }); + + it('die Abfragen sind genau die Appwrite-JSON-Strings', async () => { + mocks.listRows.mockResolvedValue({ total: 0, rows: [] }); + await myHearts('u', ['l1', 'l2']); + expect(mocks.listRows.mock.calls[0][0].queries).toEqual([ + JSON.stringify({ method: 'equal', attribute: 'userId', values: ['u'] }), + JSON.stringify({ method: 'equal', attribute: 'logoId', values: ['l1', 'l2'] }), + JSON.stringify({ method: 'limit', values: [100] }), + ]); + }); + + it('ohne Herzen: leere Menge', async () => { + mocks.listRows.mockResolvedValue({ total: 0, rows: [] }); + expect((await myHearts('u', ['l1'])).size).toBe(0); + }); + + it('Zeilen ohne brauchbare logoId werden übersprungen', async () => { + mocks.listRows.mockResolvedValue({ + total: 3, + rows: [{ $id: 'h1', logoId: 'l1' }, { $id: 'h2' }, { $id: 'h3', logoId: 5 }], + }); + expect([...(await myHearts('u', ['l1', 'l2']))]).toEqual(['l1']); + }); + + it('Fehler gehen durch', async () => { + const failure = Object.assign(new Error('down'), { code: 503 }); + mocks.listRows.mockRejectedValue(failure); + await expect(myHearts('u', ['l1'])).rejects.toBe(failure); + }); +}); + +describe('listLogoReports', () => { + it('liest die Meldungen eines Logos (höchstens 100), neueste zuerst', async () => { + mocks.listRows.mockResolvedValue({ + total: 2, + rows: [ + { $id: 'r2', $createdAt: '2026-10-04T11:00:00.000Z', logoId: 'l1', reason: 'spam', reporterHash: 'abc' }, + { $id: 'r1', $createdAt: '2026-10-04T10:00:00.000Z', logoId: 'l1', reason: 'urheberrecht', reporterHash: 'def' }, + ], + }); + const reports = await listLogoReports('l1'); + expect(mocks.listRows.mock.calls[0][0]).toEqual({ + databaseId: 'db', + tableId: 'logo-reports-table', + queries: [Query.equal('logoId', 'l1'), Query.orderDesc('$createdAt'), Query.limit(100)], + }); + expect(reports).toEqual([ + { id: 'r2', logoId: 'l1', reason: 'spam', createdAt: '2026-10-04T11:00:00.000Z' }, + { id: 'r1', logoId: 'l1', reason: 'urheberrecht', createdAt: '2026-10-04T10:00:00.000Z' }, + ]); + }); + + it('der Melder-Hash wird nicht weitergereicht', async () => { + mocks.listRows.mockResolvedValue({ total: 1, rows: [{ $id: 'r1', logoId: 'l1', reason: 'spam', reporterHash: 'abc' }] }); + expect(JSON.stringify(await listLogoReports('l1'))).not.toContain('abc'); + }); + + it('fehlende Felder werden leer', async () => { + mocks.listRows.mockResolvedValue({ total: 1, rows: [{ $id: 'r1' }] }); + await expect(listLogoReports('l1')).resolves.toEqual([{ id: 'r1', logoId: '', reason: '', createdAt: '' }]); + }); + + it('ungültige ID: leere Liste, ohne Anfrage', async () => { + await expect(listLogoReports('../x')).resolves.toEqual([]); + await expect(listLogoReports('')).resolves.toEqual([]); + expect(mocks.listRows).not.toHaveBeenCalled(); + }); + + it('Fehler gehen durch', async () => { + const failure = Object.assign(new Error('forbidden'), { code: 401 }); + mocks.listRows.mockRejectedValue(failure); + await expect(listLogoReports('l1')).rejects.toBe(failure); + }); +}); + +describe('loadLogoBlob', () => { + const logo = toLogo(row(3)); + const blob = new Blob(['x'], { type: 'image/png' }); + + it('lädt die PNG-Datei aus dem Logo-Bucket', async () => { + mocks.fetchFile.mockResolvedValue(blob); + await expect(loadLogoBlob(logo)).resolves.toBe(blob); + expect(mocks.fetchFile).toHaveBeenCalledWith('logo-bucket', 'l3'); + }); + + it('Fehler beim Laden gehen durch (404: Datei fehlt)', async () => { + const failure = notFound('storage_file_not_found'); + mocks.fetchFile.mockRejectedValue(failure); + await expect(loadLogoBlob(logo)).rejects.toBe(failure); + }); + + it.each(['', '../x', 'a/b', 'a?b', '.hidden', 'x'.repeat(37)])( + 'eine Datei-ID, die keine Appwrite-ID sein kann (%j): 404 „Datei fehlt“, ohne Anfrage', + async (fileId) => { + const err = await loadLogoBlob({ ...logo, fileId }).catch((e: unknown) => e); + expect(err).toBeInstanceOf(AppwriteException); + expect(err).toMatchObject({ code: 404, type: 'storage_file_not_found' }); + expect(mocks.fetchFile).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/web/src/logos/logos.ts b/web/src/logos/logos.ts new file mode 100644 index 0000000..8767257 --- /dev/null +++ b/web/src/logos/logos.ts @@ -0,0 +1,156 @@ +// Die Community-Logos lesen: Tabelle `logos` direkt aus Appwrite (Gäste dürfen sichtbare Zeilen lesen), dazu die eigenen Herzen +// (`logo_likes`, jeder liest nur seine) und die Meldungen (`logo_reports`, nur Admins). Schreiben (Hochladen, Herz, Melden, +// Löschen, Ausblenden) läuft über den API-Dienst, siehe logo-api.ts. Aufbau wie community/gallery.ts. +import { AppwriteException, Query, type Models } from 'appwrite'; +import { ID_PATTERN, fetchFile } from '../account/cards'; +import { IDS, tables } from '../account/appwrite'; + +export interface CommunityLogo { + id: string; + name: string; + displayName: string; + fileId: string; + ownerId: string; + /** Einfarbig: nur die Deckkraft zählt, das Logo nimmt die Farbe der Karte an (hell/dunkel). */ + mono: boolean; + likeCount: number; + hidden: boolean; + reportCount: number; + createdAt: string; +} + +export interface LogoReport { + id: string; + logoId: string; + reason: string; + createdAt: string; +} + +/** Beliebt: meiste Herzen zuerst, bei Gleichstand die neuesten; Neu: neueste zuerst. */ +export type LogoSort = 'popular' | 'new'; + +/** Ansicht der Logos: öffentlich `all`; `reported`/`hidden` zeigen nur Admins etwas (die Rechte prüft der Server). */ +export type LogoFilter = 'all' | 'reported' | 'hidden'; + +export interface LogoListOptions { + sort: LogoSort; + filter?: LogoFilter; + cursor?: string; +} + +export const LOGO_PAGE_SIZE = 24; +/** Obergrenze für die Meldungen eines Logos und die Herzen einer Seite (eine Seite hat 24 Logos). */ +const LIST_MAX = 100; + +type Row = Record; +/** Eine Zeile, wie Appwrite sie liefert: Systemfelder (`$id`, …) plus die Spalten der Tabelle. */ +type TableRow = Models.Row & Row; + +function text(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +function count(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0; +} + +/** Eine Zeile der Tabelle `logos` als Logo; fehlende oder falsch typisierte Felder werden leer. */ +export function toLogo(row: Row): CommunityLogo { + return { + id: text(row.$id), + name: text(row.name), + displayName: text(row.displayName), + fileId: text(row.fileId), + ownerId: text(row.ownerId), + mono: row.mono === true, + likeCount: count(row.likeCount), + hidden: row.hidden === true, + reportCount: count(row.reportCount), + createdAt: text(row.$createdAt), + }; +} + +/** + * Abfragen für eine Seite der Logos. Öffentlich immer nur sichtbare; Admin-Filter: „Gemeldet“ = reportCount > 0 (auch schon + * ausgeblendete), „Ausgeblendet“ = hidden. Beliebt sortiert nach Herzen, dann nach Alter (beides absteigend), Neu nur nach Alter. + * Optional: Cursor (die ID des letzten Logos der Seite davor). + */ +export function logoQueries(opts: LogoListOptions): string[] { + const base = + opts.filter === 'reported' + ? Query.greaterThan('reportCount', 0) + : opts.filter === 'hidden' + ? Query.equal('hidden', true) + : Query.equal('hidden', false); + const order = opts.sort === 'popular' ? [Query.orderDesc('likeCount'), Query.orderDesc('$createdAt')] : [Query.orderDesc('$createdAt')]; + const queries = [base, ...order, Query.limit(LOGO_PAGE_SIZE)]; + if (opts.cursor) queries.push(Query.cursorAfter(opts.cursor)); + return queries; +} + +/** Eine Seite der Logos. `nextCursor` ist die ID des letzten Logos, solange die Seite voll war, sonst null. */ +export async function listLogos(opts: LogoListOptions): Promise<{ logos: CommunityLogo[]; nextCursor: string | null }> { + const list = await tables.listRows({ databaseId: IDS.database, tableId: IDS.logos, queries: logoQueries(opts) }); + const logos = list.rows.map((row) => toLogo(row)); + return { logos, nextCursor: logos.length === LOGO_PAGE_SIZE ? logos[logos.length - 1].id : null }; +} + +/** „Die Zeile gibt es nicht (oder nicht für mich)“; fehlende Tabelle oder Datenbank ist dagegen ein echter Fehler. */ +function isRowMissing(err: unknown): boolean { + if (typeof err !== 'object' || err === null) return false; + const { code, type } = err as { code?: unknown; type?: unknown }; + return code === 404 && (type === 'row_not_found' || type === undefined || type === ''); +} + +/** + * Ein Logo per ID. Gibt es das Logo nicht oder ist es für mich ausgeblendet oder gelöscht (Appwrite antwortet beides mit 404), + * ist das Ergebnis null. Eine ID, die keine Appwrite-ID sein kann, ergibt null ohne Anfrage. + */ +export async function getLogo(id: string): Promise { + if (!ID_PATTERN.test(id)) return null; + try { + return toLogo(await tables.getRow({ databaseId: IDS.database, tableId: IDS.logos, rowId: id })); + } catch (err) { + if (isRowMissing(err)) return null; + throw err; + } +} + +/** Von diesen Logos (einer Seite, höchstens 100) die, die ich mit einem Herz markiert habe; Gäste und Nutzer ohne Herzen: leer. */ +export async function myHearts(userId: string, logoIds: readonly string[]): Promise> { + const hearts = new Set(); + if (!userId || logoIds.length === 0) return hearts; + const list = await tables.listRows({ + databaseId: IDS.database, + tableId: IDS.logoLikes, + queries: [Query.equal('userId', userId), Query.equal('logoId', [...logoIds]), Query.limit(LIST_MAX)], + }); + for (const row of list.rows) { + const logoId = text(row.logoId); + if (logoId) hearts.add(logoId); + } + return hearts; +} + +/** Admin: die Meldungen eines Logos, neueste zuerst. Der Melder-Hash bleibt außen vor. */ +export async function listLogoReports(logoId: string): Promise { + if (!ID_PATTERN.test(logoId)) return []; + const list = await tables.listRows({ + databaseId: IDS.database, + tableId: IDS.logoReports, + queries: [Query.equal('logoId', logoId), Query.orderDesc('$createdAt'), Query.limit(LIST_MAX)], + }); + return list.rows.map((row) => ({ + id: text(row.$id), + logoId: text(row.logoId), + reason: text(row.reason), + createdAt: text(row.$createdAt), + })); +} + +/** Lädt die PNG-Datei eines Logos aus dem Logo-Bucket. */ +export async function loadLogoBlob(logo: CommunityLogo): Promise { + // Die ID landet im Pfad der Anfrage; was keine Appwrite-ID sein kann, gibt es nicht. + if (!ID_PATTERN.test(logo.fileId)) throw new AppwriteException('File not found', 404, 'storage_file_not_found', ''); + return fetchFile(IDS.logoBucket, logo.fileId); +}