diff --git a/web/src/card/geometry.ts b/web/src/card/geometry.ts index 8257929..9a3f692 100644 --- a/web/src/card/geometry.ts +++ b/web/src/card/geometry.ts @@ -6,6 +6,9 @@ export const CARD_H = 969; export const MARGIN = 56; /** Nur für die Vorschau (CSS), nie im Export. */ export const PREVIEW_RADIUS = 57; +/** Vorschaudatei (WebP) einer gespeicherten oder veröffentlichten Karte: Breite in px und Qualität des Exports. */ +export const PREVIEW_WIDTH = 480; +export const PREVIEW_QUALITY = 0.85; export interface Box { x: number; diff --git a/web/src/community/api.test.ts b/web/src/community/api.test.ts index a55d167..7fa1620 100644 --- a/web/src/community/api.test.ts +++ b/web/src/community/api.test.ts @@ -57,6 +57,7 @@ beforeEach(() => { afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); + vi.restoreAllMocks(); }); describe('apiPost', () => { @@ -149,6 +150,48 @@ describe('apiPost', () => { expect(sentHeaders(1)['X-Aircard-Jwt']).toBe('jwt1'); }); + it('gleichzeitige Aufrufe mit leerem Cache teilen sich ein einziges createJWT', async () => { + const api = await freshApi(); + let release: (value: { jwt: string }) => void = () => undefined; + mocks.createJWT.mockImplementationOnce(() => new Promise((resolve) => (release = resolve))); + const first = api.apiPost('/api/x', { n: 1 }, { auth: true }); + const second = api.apiPost('/api/x', { n: 2 }, { auth: true }); + await vi.waitFor(() => expect(mocks.createJWT).toHaveBeenCalledOnce()); + release({ jwt: 'shared' }); + await Promise.all([first, second]); + expect(mocks.createJWT).toHaveBeenCalledOnce(); + expect(sentHeaders(0)['X-Aircard-Jwt']).toBe('shared'); + expect(sentHeaders(1)['X-Aircard-Jwt']).toBe('shared'); + }); + + it('scheitert das geteilte createJWT, bekommen beide den Fehler; der nächste Aufruf versucht es neu', async () => { + const api = await freshApi(); + const failure = Object.assign(new Error('missing scope'), { code: 401, type: 'general_unauthorized_scope' }); + let reject: (reason: unknown) => void = () => undefined; + mocks.createJWT.mockImplementationOnce(() => new Promise((_resolve, rej) => (reject = rej))); + const first = api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e); + const second = api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e); + await vi.waitFor(() => expect(mocks.createJWT).toHaveBeenCalledOnce()); + reject(failure); + expect(await Promise.all([first, second])).toEqual([failure, failure]); + await api.apiPost('/api/x', {}, { auth: true }); + expect(mocks.createJWT).toHaveBeenCalledTimes(2); + }); + + it('ein anderer Nutzer teilt sich kein laufendes createJWT', async () => { + const api = await freshApi(); + let release: (value: { jwt: string }) => void = () => undefined; + mocks.createJWT.mockImplementationOnce(() => new Promise((resolve) => (release = resolve))); + const first = api.apiPost('/api/x', {}, { auth: true }); + await vi.waitFor(() => expect(mocks.createJWT).toHaveBeenCalledOnce()); + mocks.userId = 'u2'; + const second = api.apiPost('/api/x', {}, { auth: true }); + await vi.waitFor(() => expect(mocks.createJWT).toHaveBeenCalledTimes(2)); + release({ jwt: 'for-u1' }); + await Promise.all([first, second]); + expect(sentHeaders(0)['X-Aircard-Jwt']).not.toBe(sentHeaders(1)['X-Aircard-Jwt']); + }); + it('ohne bekannte Nutzer-ID wird nichts gemerkt', async () => { const api = await freshApi(); mocks.userId = null; @@ -206,6 +249,30 @@ describe('apiPost', () => { }); }); + describe('Zeitlimit', () => { + it('jede Anfrage trägt ein Zeitlimit von 15 Sekunden (AbortSignal.timeout)', async () => { + const api = await freshApi(); + const timeout = vi.spyOn(AbortSignal, 'timeout'); + await api.apiPost('/api/x', {}, { auth: false }); + expect(timeout).toHaveBeenCalledWith(15_000); + expect(fetchMock.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal); + }); + + it('Zeitüberschreitung gilt als verlorene Antwort: code 503, Ausgang offen', async () => { + const api = await freshApi(); + vi.spyOn(AbortSignal, 'timeout').mockReturnValue( + AbortSignal.abort(new DOMException('The operation timed out.', 'TimeoutError')), + ); + fetchMock.mockImplementation(async (_url, init) => { + throw init.signal?.reason; + }); + const err = await api.apiPost('/api/x', {}, { auth: false }).catch((e: unknown) => e); + expect(err).toBeInstanceOf(api.ApiError); + expect(err).toMatchObject({ code: 503, message: UNAVAILABLE }); + expect(api.isDefinitiveFailure(err)).toBe(false); + }); + }); + describe('Fehler', () => { it('ApiError trägt Statuscode (code = status) und den deutschen Servertext aus `message`', async () => { const api = await freshApi(); @@ -298,6 +365,88 @@ describe('apiPost', () => { }); }); +describe('isDefinitiveFailure: hat der Server die Anfrage eindeutig abgelehnt?', () => { + it.each([400, 401, 403, 404, 409, 422])('ApiError %i: ja', async (code) => { + const api = await freshApi(); + expect(new api.ApiError(code, 'x').definitive).toBe(true); + expect(api.isDefinitiveFailure(new api.ApiError(code, 'x'))).toBe(true); + }); + + it.each([408, 429, 500, 501, 502, 503, 504])('ApiError %i (Zeitüberschreitung, Drosselung, Serverfehler): nein', async (code) => { + const api = await freshApi(); + expect(api.isDefinitiveFailure(new api.ApiError(code, 'x'))).toBe(false); + }); + + it('das Flag lässt sich setzen (JWT-Fehler: es ging keine Anfrage raus)', async () => { + const api = await freshApi(); + expect(api.isDefinitiveFailure(new api.ApiError(501, 'x', true))).toBe(true); + expect(api.isDefinitiveFailure(new api.ApiError(400, 'x', false))).toBe(false); + }); + + it('ein anderer Fehler (z. B. aus createJWT vor der Anfrage) ist eindeutig', async () => { + const api = await freshApi(); + expect(api.isDefinitiveFailure(Object.assign(new Error('x'), { code: 401 }))).toBe(true); + expect(api.isDefinitiveFailure(new TypeError('Failed to fetch'))).toBe(true); + }); + + const outcome = async (respond: () => Promise, api: Api) => { + fetchMock.mockImplementation(respond); + return api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e); + }; + + it.each([400, 401, 403, 409])('Antwort %i vom Dienst: eindeutig', async (status) => { + const api = await freshApi(); + const err = await outcome(async () => json(status, { error: 'Nein.' }), api); + expect(err).toMatchObject({ code: status }); + expect(api.isDefinitiveFailure(err)).toBe(true); + }); + + it.each([408, 429, 500])('Antwort %i vom Dienst: Ausgang offen', async (status) => { + const api = await freshApi(); + const err = await outcome(async () => json(status, { error: 'Nein.' }), api); + expect(err).toMatchObject({ code: status }); + expect(api.isDefinitiveFailure(err)).toBe(false); + }); + + it.each([502, 503, 504])('Antwort %i (Proxy): Ausgang offen', async (status) => { + const api = await freshApi(); + const err = await outcome(async () => json(status, { error: 'x' }), api); + expect(api.isDefinitiveFailure(err)).toBe(false); + }); + + it('Netzfehler, Fehlerseite ohne JSON und HTML-200: Ausgang offen', async () => { + const api = await freshApi(); + for (const respond of [ + async () => Promise.reject(new TypeError('Failed to fetch')), + async () => new Response('Bad Gateway', { status: 404 }), + async () => new Response('', { status: 200 }), + ]) { + expect(api.isDefinitiveFailure(await outcome(respond, api))).toBe(false); + } + }); + + it('der Text einer abgebrochenen Antwort (res.text() scheitert): Ausgang offen', async () => { + const api = await freshApi(); + const broken = { ok: true, status: 200, text: () => Promise.reject(new TypeError('network error')) } as unknown as Response; + expect(api.isDefinitiveFailure(await outcome(async () => broken, api))).toBe(false); + }); + + it('JWT-Methode am Server aus: eindeutig (es wurde nichts gesendet)', 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).toBeInstanceOf(api.ApiError); + expect(api.isDefinitiveFailure(err)).toBe(true); + }); + + it('createJWT scheitert mit 401 vor der Anfrage: eindeutig', async () => { + const api = await freshApi(); + mocks.createJWT.mockRejectedValue(Object.assign(new Error('x'), { code: 401, type: 'general_unauthorized_scope' })); + const err = await api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e); + expect(api.isDefinitiveFailure(err)).toBe(true); + }); +}); + describe('publishCard', () => { const input = { title: 'Meine Karte', @@ -350,10 +499,18 @@ describe('publishCard', () => { await expect(api.publishCard(input)).rejects.toMatchObject({ code: 400, message: 'Bitte gib höchstens 5 Tags an.' }); }); - it('eine Antwort ohne id ist ein Fehler', async () => { + it('eine Antwort ohne id ist ein Fehler mit offenem Ausgang (die Zeile kann angelegt sein)', async () => { const api = await freshApi(); fetchMock.mockResolvedValue(json(201, {})); - await expect(api.publishCard(input)).rejects.toMatchObject({ code: 503, message: UNAVAILABLE }); + const err = await api.publishCard(input).catch((e: unknown) => e); + expect(err).toMatchObject({ code: 503, message: UNAVAILABLE }); + expect(api.isDefinitiveFailure(err)).toBe(false); + }); + + it('die umgedeutete 409 (Dateien belegt) ist eindeutig', async () => { + const api = await freshApi(); + fetchMock.mockResolvedValue(json(403, { error: 'Diese Bilder sind bereits veröffentlicht oder gesperrt.' })); + expect(api.isDefinitiveFailure(await api.publishCard(input).catch((e: unknown) => e))).toBe(true); }); it('isAlreadyPublished: nur ApiError mit code 409', async () => { diff --git a/web/src/community/api.ts b/web/src/community/api.ts index 330b4a3..5a9371e 100644 --- a/web/src/community/api.ts +++ b/web/src/community/api.ts @@ -26,14 +26,25 @@ const FILES_TAKEN = 'Diese Bilder sind bereits veröffentlicht oder gesperrt.'; /** Appwrite-JWTs gelten 15 Minuten; wir verwenden eines höchstens 10 Minuten. */ const JWT_TTL_MS = 10 * 60 * 1000; -/** Fehler des API-Dienstes. `code` ist der HTTP-Status (`status` dasselbe), `message` der deutsche Servertext. */ +/** Zeitlimit je Anfrage; danach gilt die Antwort als verloren (503, Ausgang offen). */ +const REQUEST_TIMEOUT_MS = 15_000; + +/** + * Fehler des API-Dienstes. `code` ist der HTTP-Status (`status` dasselbe), `message` der deutsche Servertext. + * `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 + * Anfrage trotzdem gewirkt hat. + */ export class ApiError extends Error { readonly code: number; + readonly definitive: boolean; - constructor(code: number, message: string) { + constructor(code: number, message: string, definitive?: boolean) { super(message); this.name = 'ApiError'; this.code = code; + this.definitive = definitive ?? (code >= 400 && code < 500 && code !== 408 && code !== 429); } get status(): number { @@ -41,6 +52,15 @@ export class ApiError extends Error { } } +/** + * 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`). + * Andere Fehler als `ApiError` kommen aus `apiPost` nur aus dem JWT-Abruf, also vor der Anfrage. + */ +export function isDefinitiveFailure(err: unknown): boolean { + return err instanceof ApiError ? err.definitive : true; +} + // ---- JWT (je Nutzer-ID gemerkt) ---------------------------------------------------------------------- let cached: { userId: string; jwt: string; expires: number } | null = null; @@ -62,11 +82,10 @@ function subscribe(): void { onLogout(clearJwt); } -async function getJwt(): Promise { - subscribe(); - const userId = currentUserId(); - if (userId && cached && cached.userId === userId && Date.now() < cached.expires) return cached.jwt; - const started = generation; +/** Das gerade laufende createJWT, damit gleichzeitige Aufrufe mit leerem Cache nur eines auslösen. */ +let pending: { userId: string | null; generation: number; promise: Promise } | null = null; + +async function mintJwt(userId: string | null, started: number): Promise { let jwt: string; try { // Ohne `duration`: die Standarddauer (15 Minuten) genügt. @@ -74,7 +93,8 @@ async function getJwt(): Promise { } catch (err) { if (errorType(err) === 'user_auth_method_unsupported') { const code = (err as { code?: unknown }).code; - throw new ApiError(typeof code === 'number' ? code : 501, JWT_UNSUPPORTED); + // Es ging keine Anfrage an den API-Dienst raus: eindeutig. + throw new ApiError(typeof code === 'number' ? code : 501, JWT_UNSUPPORTED, true); } throw err; // z. B. 401 ohne Sitzung: withLogin erkennt es am Statuscode } @@ -82,6 +102,19 @@ async function getJwt(): Promise { return jwt; } +function getJwt(): Promise { + subscribe(); + const userId = currentUserId(); + if (userId && cached && cached.userId === userId && Date.now() < cached.expires) return Promise.resolve(cached.jwt); + if (pending && pending.userId === userId && pending.generation === generation) return pending.promise; + const started = generation; + const promise: Promise = mintJwt(userId, started).finally(() => { + if (pending?.promise === promise) pending = null; + }); + pending = { userId, generation: started, promise }; + return promise; +} + // ---- Anfrage ------------------------------------------------------------------------------------------ type Json = Record; @@ -103,12 +136,20 @@ async function send(path: string, body: unknown, jwt: string | null): Promise = { 'Content-Type': 'application/json' }; if (jwt) headers['X-Aircard-Jwt'] = jwt; + const payload = JSON.stringify(body); let res: Response; let text: string; try { - res = await fetch(path, { method: 'POST', headers, body: JSON.stringify(body), credentials: 'omit' }); + res = await fetch(path, { + method: 'POST', + headers, + body: payload, + credentials: 'omit', + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); text = await res.text(); } catch { + // Netzfehler, Zeitlimit oder abgebrochene Antwort: die Anfrage kann trotzdem angekommen sein. throw new ApiError(503, UNAVAILABLE); } // Der Proxy antwortet, wenn der Dienst fehlt oder steht (502/503/504, Fehlerseite, Seitenfallback als HTML-200). diff --git a/web/src/community/gallery.test.ts b/web/src/community/gallery.test.ts index b4d6297..9da0563 100644 --- a/web/src/community/gallery.test.ts +++ b/web/src/community/gallery.test.ts @@ -1,4 +1,4 @@ -import { Query } from 'appwrite'; +import { AppwriteException, Query } from 'appwrite'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ @@ -102,6 +102,49 @@ describe('galleryQueries', () => { ]); }); + it('Suche mit höchstens 2 Zeichen: Teilstring (contains, klein) statt Volltext, der so kurze Wörter nicht findet', () => { + expect(galleryQueries({ q: 'Vi' })).toEqual([ + Query.equal('hidden', false), + Query.orderDesc('$createdAt'), + Query.limit(24), + Query.contains('searchText', 'vi'), + ]); + expect(galleryQueries({ q: ' A ' })).toContain(Query.contains('searchText', 'a')); + }); + + it('ab 3 Zeichen Volltext (die Grenze liegt bei 3)', () => { + expect(galleryQueries({ q: 'ab' })).toContain(Query.contains('searchText', 'ab')); + expect(galleryQueries({ q: 'abc' })).toContain(Query.search('searchText', 'abc')); + expect(galleryQueries({ q: 'abc' }).join('')).not.toContain('contains'); + expect(galleryQueries({ q: 'ä ö' })).toContain(Query.search('searchText', 'ä ö')); + }); + + it('Tag: contains auf tags, getrimmt und klein; die Galerie bleibt auf sichtbare Karten beschränkt', () => { + expect(galleryQueries({ tag: ' Visa ' })).toEqual([ + Query.equal('hidden', false), + Query.orderDesc('$createdAt'), + Query.limit(24), + Query.contains('tags', 'visa'), + ]); + expect(JSON.stringify(Query.contains('tags', 'visa'))).toContain('contains'); + }); + + it('ein leerer oder nur aus Leerraum bestehender Tag wird ignoriert', () => { + expect(galleryQueries({ tag: '' })).toEqual(galleryQueries({})); + expect(galleryQueries({ tag: ' ' })).toEqual(galleryQueries({})); + }); + + it('Tag, Suche und Cursor zusammen: immer mit hidden = false', () => { + expect(galleryQueries({ tag: 'Gold', q: 'visa', cursor: 'g24' })).toEqual([ + Query.equal('hidden', false), + Query.orderDesc('$createdAt'), + Query.limit(24), + Query.cursorAfter('g24'), + Query.contains('tags', 'gold'), + Query.search('searchText', 'visa'), + ]); + }); + it('Suche und Cursor zusammen', () => { expect(galleryQueries({ q: 'visa', cursor: 'g24' })).toEqual([ Query.equal('hidden', false), @@ -135,6 +178,16 @@ describe('galleryQueries', () => { ]); }); + it('Tag und kurze Suche mit Admin-Filter', () => { + expect(galleryQueries({ filter: 'hidden', tag: 'visa', q: 'x' })).toEqual([ + Query.equal('hidden', true), + Query.orderDesc('$createdAt'), + Query.limit(24), + Query.contains('tags', 'visa'), + Query.contains('searchText', 'x'), + ]); + }); + it('Admin-Filter lassen sich mit Suche und Cursor kombinieren', () => { expect(galleryQueries({ filter: 'reported', q: 'visa', cursor: 'g5' })).toEqual([ Query.greaterThan('reportCount', 0), @@ -237,6 +290,18 @@ describe('listGallery', () => { expect(page.nextCursor).toBe('g24'); }); + it('Tag und kurze Suche gehen an die Abfrage', async () => { + mocks.listRows.mockResolvedValue({ total: 0, rows: [] }); + await listGallery({ tag: 'Visa', q: 'ab' }); + expect(mocks.listRows.mock.calls[0][0].queries).toEqual([ + Query.equal('hidden', false), + Query.orderDesc('$createdAt'), + Query.limit(24), + Query.contains('tags', 'visa'), + Query.contains('searchText', 'ab'), + ]); + }); + it('die nächste Seite schickt den Cursor mit', async () => { mocks.listRows.mockResolvedValue({ total: 0, rows: [] }); await listGallery({ cursor: 'g24', filter: 'hidden' }); @@ -377,4 +442,18 @@ describe('loadGalleryImage', () => { await loadGalleryImage(card, 'preview'); expect(mocks.fetchFile).toHaveBeenCalledWith('gallery-bucket', 'pre3'); }); + + 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) => { + for (const size of ['full', 'preview'] as const) { + const err = await loadGalleryImage({ ...card, imageFileId: fileId, previewFileId: fileId }, size).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/community/gallery.ts b/web/src/community/gallery.ts index 4e5dc98..76d2ca9 100644 --- a/web/src/community/gallery.ts +++ b/web/src/community/gallery.ts @@ -1,6 +1,6 @@ // Die öffentliche Galerie lesen: Tabelle `gallery` direkt aus Appwrite (Gäste dürfen sichtbare Zeilen lesen). // Schreiben (Veröffentlichen, Zurückziehen, Melden, Ausblenden) läuft über den API-Dienst, siehe api.ts. -import { Query, type Models } from 'appwrite'; +import { AppwriteException, Query, type Models } from 'appwrite'; import { ID_PATTERN, fetchFile } from '../account/cards'; import { IDS, tables } from '../account/appwrite'; @@ -30,7 +30,10 @@ export interface GalleryReport { export type GalleryFilter = 'all' | 'reported' | 'hidden'; export interface GalleryOptions { + /** Suchtext; ab 3 Zeichen Volltext, kürzer als Teilstring (der Volltext-Index findet so kurze Wörter nicht). */ q?: string; + /** Nur Karten mit diesem Tag (Teilstring in `tags`, klein). */ + tag?: string; cursor?: string; filter?: GalleryFilter; } @@ -69,9 +72,13 @@ export function toGalleryCard(row: Row): GalleryCard { }; } +/** Ab dieser Länge sucht `q` im Volltext-Index; kürzere Texte finden dort nichts. */ +const FULLTEXT_MIN = 3; + /** * Abfragen für eine Seite der Galerie, neueste zuerst. Öffentlich immer nur sichtbare Karten; * Admin-Filter: „Gemeldet“ = reportCount > 0 (auch schon ausgeblendete), „Ausgeblendet“ = hidden. + * Optional: Cursor, Tag (`contains` auf `tags`) und Suchtext (Volltext ab 3 Zeichen, sonst `contains` auf `searchText`). */ export function galleryQueries(opts: GalleryOptions): string[] { const base = @@ -82,8 +89,14 @@ export function galleryQueries(opts: GalleryOptions): string[] { : Query.equal('hidden', false); const queries = [base, Query.orderDesc('$createdAt'), Query.limit(PAGE_SIZE)]; if (opts.cursor) queries.push(Query.cursorAfter(opts.cursor)); + const tag = (opts.tag ?? '').trim().toLowerCase(); + if (tag) queries.push(Query.contains('tags', tag)); const q = (opts.q ?? '').replace(/\s+/g, ' ').trim(); - if (q) queries.push(Query.search('searchText', q)); + if (q) { + queries.push( + [...q].length >= FULLTEXT_MIN ? Query.search('searchText', q) : Query.contains('searchText', q.toLowerCase()), + ); + } return queries; } @@ -152,6 +165,9 @@ export async function listReports(galleryId: string): Promise { } /** Lädt das Kartenbild (PNG 1536 px) oder die Vorschau (WebP 480 px) aus dem Galerie-Bucket. */ -export function loadGalleryImage(card: GalleryCard, size: 'full' | 'preview'): Promise { - return fetchFile(IDS.galleryBucket, size === 'full' ? card.imageFileId : card.previewFileId); +export async function loadGalleryImage(card: GalleryCard, size: 'full' | 'preview'): Promise { + const fileId = size === 'full' ? card.imageFileId : card.previewFileId; + // Die ID landet im Pfad der Anfrage; was keine Appwrite-ID sein kann, gibt es nicht. + if (!ID_PATTERN.test(fileId)) throw new AppwriteException('File not found', 404, 'storage_file_not_found', ''); + return fetchFile(IDS.galleryBucket, fileId); } diff --git a/web/src/community/publish.test.ts b/web/src/community/publish.test.ts index 981af42..acc1380 100644 --- a/web/src/community/publish.test.ts +++ b/web/src/community/publish.test.ts @@ -1,18 +1,21 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { DEFAULT_CONFIG } from '../card/config'; import type { SavedCard } from '../account/cards'; -// Die Standard-Abhängigkeiten (Appwrite, Canvas) kommen in diesen Tests nie zum Einsatz. +const mocks = vi.hoisted(() => ({ createJWT: vi.fn() })); + +// Die Standard-Abhängigkeiten (Appwrite, Canvas) kommen in diesen Tests nie zum Einsatz; +// nur createJWT wird für den Test mit der echten API-Schicht gebraucht. vi.mock('../account/appwrite', () => ({ IDS: { galleryBucket: 'gallery-bucket' }, - account: {}, + account: { createJWT: mocks.createJWT }, client: {}, tables: {}, storage: {}, APPWRITE_ENDPOINT: 'https://aircard.test/v1', })); -import { ApiError } from './api'; +import { ApiError, publishCard } from './api'; import { PNG_MAX_BYTES, normalizeTags, publishSavedCard, type PublishDeps } from './publish'; const card: SavedCard = { @@ -100,25 +103,40 @@ describe('publishSavedCard', () => { }); }); - it('API-Fehler: beide hochgeladenen Dateien werden gelöscht, der Fehler kommt an', async () => { - const failure = new ApiError(400, 'Bitte gib einen Titel an.'); - const fake = fakeDeps({ publishCard: async () => Promise.reject(failure) }); - await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure); - expect(fake.of('deleteFile').map((c) => c.args[0]).sort()).toEqual(['f1', 'f2']); - }); + describe('Fehler der API: gelöscht wird nur bei eindeutiger Ablehnung', () => { + it.each([400, 401, 403, 404, 409])( + 'eindeutig abgelehnt (%i): beide Dateien dieses Versuchs werden gelöscht, der Fehler kommt an', + async (code) => { + const failure = new ApiError(code, 'Abgelehnt.'); + const fake = fakeDeps({ publishCard: async () => Promise.reject(failure) }); + await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure); + expect(fake.of('deleteFile').map((c) => c.args[0]).sort()).toEqual(['f1', 'f2']); + }, + ); - it('409 (schon veröffentlicht): Aufräumen wie bei jedem Fehler', async () => { - const failure = new ApiError(409, 'Diese Karte ist schon veröffentlicht.'); - const fake = fakeDeps({ publishCard: async () => Promise.reject(failure) }); - await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure); - expect(fake.of('deleteFile').map((c) => c.args[0]).sort()).toEqual(['f1', 'f2']); - }); + it.each([408, 429, 500, 502, 503, 504])( + 'unklarer Ausgang (%i): die Zeile kann angelegt sein, die Dateien bleiben liegen, der Fehler kommt an', + async (code) => { + const failure = new ApiError(code, 'Unklar.'); + const fake = fakeDeps({ publishCard: async () => Promise.reject(failure) }); + await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure); + expect(fake.of('deleteFile')).toHaveLength(0); + }, + ); - it('Netzfehler der API: Aufräumen, der Fehler kommt an', async () => { - const failure = new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'); - const fake = fakeDeps({ publishCard: async () => Promise.reject(failure) }); - await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure); - expect(fake.of('deleteFile')).toHaveLength(2); + it('JWT-Methode am Server aus (ApiError mit definitive-Flag, es ging keine Anfrage raus): gelöscht', async () => { + const failure = new ApiError(501, 'Anmeldung per Token ist auf dem Server ausgeschaltet.', true); + const fake = fakeDeps({ publishCard: async () => Promise.reject(failure) }); + await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure); + expect(fake.of('deleteFile')).toHaveLength(2); + }); + + it('ein anderer Fehler vor der Anfrage (z. B. createJWT mit 401): gelöscht', async () => { + const failure = Object.assign(new Error('missing scope'), { code: 401, type: 'general_unauthorized_scope' }); + const fake = fakeDeps({ publishCard: async () => Promise.reject(failure) }); + await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure); + expect(fake.of('deleteFile')).toHaveLength(2); + }); }); it('zweiter Upload scheitert: nur der erste wird gelöscht, die API wird nicht gerufen', async () => { @@ -154,7 +172,7 @@ describe('publishSavedCard', () => { }); it('ein Fehler beim Aufräumen überdeckt den ursprünglichen Fehler nicht und stoppt das Aufräumen nicht', async () => { - const failure = new ApiError(500, 'Interner Fehler.'); + const failure = new ApiError(400, 'Bitte gib einen Titel an.'); const attempted: string[] = []; const fake = fakeDeps({ publishCard: async () => Promise.reject(failure), @@ -224,6 +242,83 @@ describe('publishSavedCard', () => { }); }); +describe('mit der echten API-Schicht (publishCard, apiPost) und gestubbtem fetch', () => { + 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>(); + + beforeEach(() => { + mocks.createJWT.mockReset(); + mocks.createJWT.mockResolvedValue({ jwt: 'jwt' }); + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + /** Läuft den ganzen Ablauf mit der echten publishCard; `respond` erzeugt bei jedem Aufruf eine neue Antwort. */ + async function run(respond: () => Promise) { + fetchMock.mockImplementation(respond); + const fake = fakeDeps({ publishCard }); + const result = await publishSavedCard(card, form, fake.deps).catch((e: unknown) => e); + return { fake, result, deleted: fake.of('deleteFile').map((c) => c.args[0]).sort() }; + } + + it('201 mit id: Erfolg, nichts gelöscht', async () => { + const { result, deleted } = await run(async () => json(201, { id: 'g1' })); + expect(result).toEqual({ id: 'g1' }); + expect(deleted).toEqual([]); + }); + + it.each([ + ['400 Validierung', async () => json(400, { error: 'Bitte gib einen Titel an.' })], + ['401 (auch nach der Wiederholung)', async () => json(401, { error: 'Bitte melde dich an.' })], + ['403 fremde Datei', async () => json(403, { error: 'Diese Datei gehört dir nicht.' })], + ['403 Dateien belegt oder gesperrt', async () => json(403, { error: 'Diese Bilder sind bereits veröffentlicht oder gesperrt.' })], + ['409 schon veröffentlicht', async () => json(409, { error: 'Diese Karte ist schon veröffentlicht.' })], + ])('%s: beide Dateien werden gelöscht', async (_name, respond) => { + const { result, deleted } = await run(respond); + expect(result).toBeInstanceOf(ApiError); + expect(deleted).toEqual(['f1', 'f2']); + }); + + it.each([ + ['429 Drosselung', async () => json(429, { error: 'Zu viele Anfragen.' })], + ['408 Zeitüberschreitung', async () => json(408, { error: 'Zeit abgelaufen.' })], + ['500 Serverfehler', async () => json(500, { error: 'Interner Fehler.' })], + ['502 Dienst nicht erreichbar', async () => json(502, { error: 'Der Dienst ist gerade nicht erreichbar.' })], + ['503 vom Proxy', async () => new Response('503', { status: 503 })], + ['504 vom Proxy', async () => new Response('504', { status: 504 })], + ['Netzfehler (Antwort verloren)', async () => Promise.reject(new TypeError('Failed to fetch'))], + ['Zeitüberschreitung des Clients', async () => Promise.reject(new DOMException('timed out', 'TimeoutError'))], + ['HTML mit Status 200', async () => new Response('', { status: 200 })], + ['201 ohne id', async () => json(201, {})], + ])('%s: Ausgang offen, die Dateien bleiben liegen', async (_name, respond) => { + const { result, deleted } = await run(respond); + expect(result).toBeInstanceOf(ApiError); + expect(deleted).toEqual([]); + }); + + it('createJWT scheitert mit 401 (keine Sitzung): es ging nichts raus, die Dateien werden gelöscht', async () => { + mocks.createJWT.mockRejectedValue(Object.assign(new Error('missing scope'), { code: 401, type: 'general_unauthorized_scope' })); + const { result, deleted } = await run(async () => json(201, { id: 'g1' })); + expect(result).toMatchObject({ code: 401 }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(deleted).toEqual(['f1', 'f2']); + }); + + it('JWT-Methode am Server aus: es ging nichts raus, die Dateien werden gelöscht', async () => { + mocks.createJWT.mockRejectedValue(Object.assign(new Error('x'), { code: 501, type: 'user_auth_method_unsupported' })); + const { result, deleted } = await run(async () => json(201, { id: 'g1' })); + expect(result).toBeInstanceOf(ApiError); + expect(fetchMock).not.toHaveBeenCalled(); + expect(deleted).toEqual(['f1', 'f2']); + }); +}); + describe('normalizeTags', () => { it('trennt an Kommas, trimmt, schreibt klein und entfernt Leere und Doppelte', () => { expect(normalizeTags(' Visa, Gold ,, visa ,GOLD, Karte ')).toEqual(['visa', 'gold', 'karte']); diff --git a/web/src/community/publish.ts b/web/src/community/publish.ts index c0f2561..df76943 100644 --- a/web/src/community/publish.ts +++ b/web/src/community/publish.ts @@ -3,16 +3,14 @@ import { ID, Permission, Role } from 'appwrite'; import { IDS, storage } from '../account/appwrite'; import { cardsService, requireUserId, toUploadFile, type SavedCard } from '../account/cards'; -import { CARD_W } from '../card/geometry'; +import { CARD_W, PREVIEW_QUALITY, PREVIEW_WIDTH } from '../card/geometry'; import { clampCrop } from '../card/crop'; import { exportCard, loadCardAssets } from '../card/render'; import { decodeImage } from '../editor/upload'; -import { publishCard, type PublishInput } from './api'; +import { isDefinitiveFailure, publishCard, type PublishInput } from './api'; /** Der Galerie-Bucket nimmt Dateien bis 5 MiB an (wie der Server prüft). */ export const PNG_MAX_BYTES = 5_242_880; -const PREVIEW_WIDTH = 480; -const PREVIEW_QUALITY = 0.85; const TOO_LARGE = 'Das Kartenbild ist zu groß für die Galerie (max. 5 MB).'; const NOT_RENDERED = 'Das Kartenbild konnte nicht erzeugt werden.'; const GALLERY_TYPES = ['image/png', 'image/webp']; @@ -80,8 +78,15 @@ async function deleteQuietly(deps: PublishDeps, fileIds: string[]): Promise;