fix(community): nur bei eindeutigem Ausgang aufräumen, Tag- und Kurzsuche, kleine Härtungen

- publish.ts: bei Fehlern der API werden die hochgeladenen Dateien nur bei eindeutiger Ablehnung
  (4xx außer 408/429, JWT-Fehler vor der Anfrage) gelöscht; bei offenem Ausgang (Netzfehler,
  Zeitlimit, 5xx, 429, Antwort ohne id) bleiben sie liegen, damit keine öffentliche Zeile auf
  gelöschte Bilder zeigt
- api.ts: ApiError.definitive und isDefinitiveFailure, Zeitlimit 15 s, gleichzeitige createJWT
  werden geteilt
- gallery.ts: Tag-Filter (contains auf tags), Kurzsuche unter 3 Zeichen per contains,
  loadGalleryImage prüft die Datei-ID
- geometry.ts: PREVIEW_WIDTH/PREVIEW_QUALITY an einer Stelle (Editor und Veröffentlichen)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 14:48:06 +00:00
1 parent 89c86fd578
commit ae73dc4a0b
8 files changed
+441 -40

No files matched your search

+3
View File
@@ -6,6 +6,9 @@ export const CARD_H = 969;
export const MARGIN = 56; export const MARGIN = 56;
/** Nur für die Vorschau (CSS), nie im Export. */ /** Nur für die Vorschau (CSS), nie im Export. */
export const PREVIEW_RADIUS = 57; 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 { export interface Box {
x: number; x: number;
+159 -2
View File
@@ -57,6 +57,7 @@ beforeEach(() => {
afterEach(() => { afterEach(() => {
vi.useRealTimers(); vi.useRealTimers();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
vi.restoreAllMocks();
}); });
describe('apiPost', () => { describe('apiPost', () => {
@@ -149,6 +150,48 @@ describe('apiPost', () => {
expect(sentHeaders(1)['X-Aircard-Jwt']).toBe('jwt1'); 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 () => { it('ohne bekannte Nutzer-ID wird nichts gemerkt', async () => {
const api = await freshApi(); const api = await freshApi();
mocks.userId = null; 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', () => { describe('Fehler', () => {
it('ApiError trägt Statuscode (code = status) und den deutschen Servertext aus `message`', async () => { it('ApiError trägt Statuscode (code = status) und den deutschen Servertext aus `message`', async () => {
const api = await freshApi(); 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<Response>, 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('<html>Bad Gateway</html>', { status: 404 }),
async () => new Response('<!doctype html>', { 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', () => { describe('publishCard', () => {
const input = { const input = {
title: 'Meine Karte', 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.' }); 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(); const api = await freshApi();
fetchMock.mockResolvedValue(json(201, {})); 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 () => { it('isAlreadyPublished: nur ApiError mit code 409', async () => {
+50 -9
View File
@@ -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. */ /** Appwrite-JWTs gelten 15 Minuten; wir verwenden eines höchstens 10 Minuten. */
const JWT_TTL_MS = 10 * 60 * 1000; 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 { export class ApiError extends Error {
readonly code: number; readonly code: number;
readonly definitive: boolean;
constructor(code: number, message: string) { constructor(code: number, message: string, definitive?: boolean) {
super(message); super(message);
this.name = 'ApiError'; this.name = 'ApiError';
this.code = code; this.code = code;
this.definitive = definitive ?? (code >= 400 && code < 500 && code !== 408 && code !== 429);
} }
get status(): number { 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) ---------------------------------------------------------------------- // ---- JWT (je Nutzer-ID gemerkt) ----------------------------------------------------------------------
let cached: { userId: string; jwt: string; expires: number } | null = null; let cached: { userId: string; jwt: string; expires: number } | null = null;
@@ -62,11 +82,10 @@ function subscribe(): void {
onLogout(clearJwt); onLogout(clearJwt);
} }
async function getJwt(): Promise<string> { /** Das gerade laufende createJWT, damit gleichzeitige Aufrufe mit leerem Cache nur eines auslösen. */
subscribe(); let pending: { userId: string | null; generation: number; promise: Promise<string> } | null = null;
const userId = currentUserId();
if (userId && cached && cached.userId === userId && Date.now() < cached.expires) return cached.jwt; async function mintJwt(userId: string | null, started: number): Promise<string> {
const started = generation;
let jwt: string; let jwt: string;
try { try {
// Ohne `duration`: die Standarddauer (15 Minuten) genügt. // Ohne `duration`: die Standarddauer (15 Minuten) genügt.
@@ -74,7 +93,8 @@ async function getJwt(): Promise<string> {
} catch (err) { } catch (err) {
if (errorType(err) === 'user_auth_method_unsupported') { if (errorType(err) === 'user_auth_method_unsupported') {
const code = (err as { code?: unknown }).code; 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 throw err; // z. B. 401 ohne Sitzung: withLogin erkennt es am Statuscode
} }
@@ -82,6 +102,19 @@ async function getJwt(): Promise<string> {
return jwt; return jwt;
} }
function getJwt(): Promise<string> {
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<string> = mintJwt(userId, started).finally(() => {
if (pending?.promise === promise) pending = null;
});
pending = { userId, generation: started, promise };
return promise;
}
// ---- Anfrage ------------------------------------------------------------------------------------------ // ---- Anfrage ------------------------------------------------------------------------------------------
type Json = Record<string, unknown>; type Json = Record<string, unknown>;
@@ -103,12 +136,20 @@ async function send(path: string, body: unknown, jwt: string | null): Promise<Js
const headers: Record<string, string> = { 'Content-Type': 'application/json' }; const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (jwt) headers['X-Aircard-Jwt'] = jwt; if (jwt) headers['X-Aircard-Jwt'] = jwt;
const payload = JSON.stringify(body);
let res: Response; let res: Response;
let text: string; let text: string;
try { 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(); text = await res.text();
} catch { } catch {
// Netzfehler, Zeitlimit oder abgebrochene Antwort: die Anfrage kann trotzdem angekommen sein.
throw new ApiError(503, UNAVAILABLE); throw new ApiError(503, UNAVAILABLE);
} }
// Der Proxy antwortet, wenn der Dienst fehlt oder steht (502/503/504, Fehlerseite, Seitenfallback als HTML-200). // Der Proxy antwortet, wenn der Dienst fehlt oder steht (502/503/504, Fehlerseite, Seitenfallback als HTML-200).
+80 -1
View File
@@ -1,4 +1,4 @@
import { Query } from 'appwrite'; import { AppwriteException, Query } from 'appwrite';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({ 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', () => { it('Suche und Cursor zusammen', () => {
expect(galleryQueries({ q: 'visa', cursor: 'g24' })).toEqual([ expect(galleryQueries({ q: 'visa', cursor: 'g24' })).toEqual([
Query.equal('hidden', false), 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', () => { it('Admin-Filter lassen sich mit Suche und Cursor kombinieren', () => {
expect(galleryQueries({ filter: 'reported', q: 'visa', cursor: 'g5' })).toEqual([ expect(galleryQueries({ filter: 'reported', q: 'visa', cursor: 'g5' })).toEqual([
Query.greaterThan('reportCount', 0), Query.greaterThan('reportCount', 0),
@@ -237,6 +290,18 @@ describe('listGallery', () => {
expect(page.nextCursor).toBe('g24'); 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 () => { it('die nächste Seite schickt den Cursor mit', async () => {
mocks.listRows.mockResolvedValue({ total: 0, rows: [] }); mocks.listRows.mockResolvedValue({ total: 0, rows: [] });
await listGallery({ cursor: 'g24', filter: 'hidden' }); await listGallery({ cursor: 'g24', filter: 'hidden' });
@@ -377,4 +442,18 @@ describe('loadGalleryImage', () => {
await loadGalleryImage(card, 'preview'); await loadGalleryImage(card, 'preview');
expect(mocks.fetchFile).toHaveBeenCalledWith('gallery-bucket', 'pre3'); 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();
},
);
}); });
+20 -4
View File
@@ -1,6 +1,6 @@
// Die öffentliche Galerie lesen: Tabelle `gallery` direkt aus Appwrite (Gäste dürfen sichtbare Zeilen lesen). // 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. // 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 { ID_PATTERN, fetchFile } from '../account/cards';
import { IDS, tables } from '../account/appwrite'; import { IDS, tables } from '../account/appwrite';
@@ -30,7 +30,10 @@ export interface GalleryReport {
export type GalleryFilter = 'all' | 'reported' | 'hidden'; export type GalleryFilter = 'all' | 'reported' | 'hidden';
export interface GalleryOptions { export interface GalleryOptions {
/** Suchtext; ab 3 Zeichen Volltext, kürzer als Teilstring (der Volltext-Index findet so kurze Wörter nicht). */
q?: string; q?: string;
/** Nur Karten mit diesem Tag (Teilstring in `tags`, klein). */
tag?: string;
cursor?: string; cursor?: string;
filter?: GalleryFilter; 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; * Abfragen für eine Seite der Galerie, neueste zuerst. Öffentlich immer nur sichtbare Karten;
* Admin-Filter: „Gemeldet“ = reportCount > 0 (auch schon ausgeblendete), „Ausgeblendet“ = hidden. * 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[] { export function galleryQueries(opts: GalleryOptions): string[] {
const base = const base =
@@ -82,8 +89,14 @@ export function galleryQueries(opts: GalleryOptions): string[] {
: Query.equal('hidden', false); : Query.equal('hidden', false);
const queries = [base, Query.orderDesc('$createdAt'), Query.limit(PAGE_SIZE)]; const queries = [base, Query.orderDesc('$createdAt'), Query.limit(PAGE_SIZE)];
if (opts.cursor) queries.push(Query.cursorAfter(opts.cursor)); 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(); 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; return queries;
} }
@@ -152,6 +165,9 @@ export async function listReports(galleryId: string): Promise<GalleryReport[]> {
} }
/** Lädt das Kartenbild (PNG 1536 px) oder die Vorschau (WebP 480 px) aus dem Galerie-Bucket. */ /** 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<Blob> { export async function loadGalleryImage(card: GalleryCard, size: 'full' | 'preview'): Promise<Blob> {
return fetchFile(IDS.galleryBucket, size === 'full' ? card.imageFileId : card.previewFileId); 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);
} }
+109 -14
View File
@@ -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 { DEFAULT_CONFIG } from '../card/config';
import type { SavedCard } from '../account/cards'; 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', () => ({ vi.mock('../account/appwrite', () => ({
IDS: { galleryBucket: 'gallery-bucket' }, IDS: { galleryBucket: 'gallery-bucket' },
account: {}, account: { createJWT: mocks.createJWT },
client: {}, client: {},
tables: {}, tables: {},
storage: {}, storage: {},
APPWRITE_ENDPOINT: 'https://aircard.test/v1', 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'; import { PNG_MAX_BYTES, normalizeTags, publishSavedCard, type PublishDeps } from './publish';
const card: SavedCard = { const card: SavedCard = {
@@ -100,27 +103,42 @@ describe('publishSavedCard', () => {
}); });
}); });
it('API-Fehler: beide hochgeladenen Dateien werden gelöscht, der Fehler kommt an', async () => { describe('Fehler der API: gelöscht wird nur bei eindeutiger Ablehnung', () => {
const failure = new ApiError(400, 'Bitte gib einen Titel an.'); 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) }); const fake = fakeDeps({ publishCard: async () => Promise.reject(failure) });
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure); await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
expect(fake.of('deleteFile').map((c) => c.args[0]).sort()).toEqual(['f1', 'f2']); 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 () => { it.each([408, 429, 500, 502, 503, 504])(
const failure = new ApiError(409, 'Diese Karte ist schon veröffentlicht.'); '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) }); const fake = fakeDeps({ publishCard: async () => Promise.reject(failure) });
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure); await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
expect(fake.of('deleteFile').map((c) => c.args[0]).sort()).toEqual(['f1', 'f2']); expect(fake.of('deleteFile')).toHaveLength(0);
}); },
);
it('Netzfehler der API: Aufräumen, der Fehler kommt an', async () => { it('JWT-Methode am Server aus (ApiError mit definitive-Flag, es ging keine Anfrage raus): gelöscht', async () => {
const failure = new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'); const failure = new ApiError(501, 'Anmeldung per Token ist auf dem Server ausgeschaltet.', true);
const fake = fakeDeps({ publishCard: async () => Promise.reject(failure) }); const fake = fakeDeps({ publishCard: async () => Promise.reject(failure) });
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure); await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
expect(fake.of('deleteFile')).toHaveLength(2); 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 () => { it('zweiter Upload scheitert: nur der erste wird gelöscht, die API wird nicht gerufen', async () => {
const failure = new Error('upload kaputt'); const failure = new Error('upload kaputt');
let uploads = 0; let uploads = 0;
@@ -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 () => { 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 attempted: string[] = [];
const fake = fakeDeps({ const fake = fakeDeps({
publishCard: async () => Promise.reject(failure), 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<Response>>();
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<Response>) {
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('<html>503</html>', { status: 503 })],
['504 vom Proxy', async () => new Response('<html>504</html>', { 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('<!doctype html>', { 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', () => { describe('normalizeTags', () => {
it('trennt an Kommas, trimmt, schreibt klein und entfernt Leere und Doppelte', () => { it('trennt an Kommas, trimmt, schreibt klein und entfernt Leere und Doppelte', () => {
expect(normalizeTags(' Visa, Gold ,, visa ,GOLD, Karte ')).toEqual(['visa', 'gold', 'karte']); expect(normalizeTags(' Visa, Gold ,, visa ,GOLD, Karte ')).toEqual(['visa', 'gold', 'karte']);
+19 -7
View File
@@ -3,16 +3,14 @@
import { ID, Permission, Role } from 'appwrite'; import { ID, Permission, Role } from 'appwrite';
import { IDS, storage } from '../account/appwrite'; import { IDS, storage } from '../account/appwrite';
import { cardsService, requireUserId, toUploadFile, type SavedCard } from '../account/cards'; 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 { clampCrop } from '../card/crop';
import { exportCard, loadCardAssets } from '../card/render'; import { exportCard, loadCardAssets } from '../card/render';
import { decodeImage } from '../editor/upload'; 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). */ /** Der Galerie-Bucket nimmt Dateien bis 5 MiB an (wie der Server prüft). */
export const PNG_MAX_BYTES = 5_242_880; 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 TOO_LARGE = 'Das Kartenbild ist zu groß für die Galerie (max. 5 MB).';
const NOT_RENDERED = 'Das Kartenbild konnte nicht erzeugt werden.'; const NOT_RENDERED = 'Das Kartenbild konnte nicht erzeugt werden.';
const GALLERY_TYPES = ['image/png', 'image/webp']; const GALLERY_TYPES = ['image/png', 'image/webp'];
@@ -80,8 +78,15 @@ async function deleteQuietly(deps: PublishDeps, fileIds: string[]): Promise<void
/** /**
* Veröffentlicht die Karte: Bild laden, rendern, PNG und Vorschau in den Galerie-Bucket laden, API rufen. * Veröffentlicht die Karte: Bild laden, rendern, PNG und Vorschau in den Galerie-Bucket laden, API rufen.
* Schlägt nach dem ersten Upload etwas fehl (zweiter Upload, API, auch 409), werden die in diesem Versuch *
* hochgeladenen Dateien (und nur diese) gelöscht und der Fehler weitergeworfen. * Aufräumen (nur die zwei Dateien dieses Versuchs, nie etwas anderes):
* - Scheitert ein Upload, ist noch nichts veröffentlicht: der erste Upload wird gelöscht.
* - Scheitert die API eindeutig (4xx außer 408/429, auch 409, oder schon der JWT-Abruf), wurde nichts angelegt:
* beide Dateien werden gelöscht.
* - Bleibt der Ausgang offen (Netzfehler, Zeitlimit, 5xx, 408, 429, Antwort ohne `id`), kann die API die Zeile schon
* angelegt haben. Dann bleiben die Dateien liegen: sonst zeigte eine öffentliche Karte auf gelöschte Bilder.
* (Ein Waisen-Upload ist harmlos: unverlinkt, mit Zufalls-ID, vom Nutzer jederzeit löschbar.)
* Der Fehler wird in jedem Fall weitergeworfen.
*/ */
export async function publishSavedCard( export async function publishSavedCard(
card: SavedCard, card: SavedCard,
@@ -105,6 +110,13 @@ export async function publishSavedCard(
try { try {
uploaded.push((await deps.createFile(imageFile, permissions)).$id); uploaded.push((await deps.createFile(imageFile, permissions)).$id);
uploaded.push((await deps.createFile(previewFile, permissions)).$id); uploaded.push((await deps.createFile(previewFile, permissions)).$id);
} catch (err) {
// Die API wurde noch nicht gerufen: es gibt keine Zeile, die auf die Dateien zeigen könnte.
await deleteQuietly(deps, uploaded);
throw err;
}
try {
return await deps.publishCard({ return await deps.publishCard({
title: form.title.trim(), title: form.title.trim(),
issuer: form.issuer.trim(), issuer: form.issuer.trim(),
@@ -115,7 +127,7 @@ export async function publishSavedCard(
sourceCardId: card.id, sourceCardId: card.id,
}); });
} catch (err) { } catch (err) {
await deleteQuietly(deps, uploaded); if (isDefinitiveFailure(err)) await deleteQuietly(deps, uploaded);
throw err; throw err;
} }
} }
+1 -3
View File
@@ -7,7 +7,7 @@ import { requireLogin } from '../account/login-dialog';
import { LoginCancelled, withLogin } from '../account/with-login'; import { LoginCancelled, withLogin } from '../account/with-login';
import { serializeConfig, type BrandId, type CardConfig, type DetailsTone, type NetworkId, type TemplateId } from '../card/config'; import { serializeConfig, type BrandId, type CardConfig, type DetailsTone, type NetworkId, type TemplateId } from '../card/config';
import { clampCrop, panBy, zoomTo } from '../card/crop'; import { clampCrop, panBy, zoomTo } from '../card/crop';
import { CARD_W } from '../card/geometry'; import { CARD_W, PREVIEW_QUALITY, PREVIEW_WIDTH } from '../card/geometry';
import { drawCard, exportCard, loadCardAssets, type CardAssets, type CardImage } from '../card/render'; import { drawCard, exportCard, loadCardAssets, type CardAssets, type CardImage } from '../card/render';
import { dropCardParam } from './card-url'; import { dropCardParam } from './card-url';
import { confirmDialog } from './confirm-dialog'; import { confirmDialog } from './confirm-dialog';
@@ -25,8 +25,6 @@ const EXPORT_ERROR = 'Das PNG konnte nicht erzeugt werden.';
const NO_IMAGE = 'Bitte zuerst ein Bild wählen.'; const NO_IMAGE = 'Bitte zuerst ein Bild wählen.';
const LOADING_CARD = 'Karte wird geladen …'; const LOADING_CARD = 'Karte wird geladen …';
const LOGIN_TO_OPEN = 'Melde dich an, um diese Karte zu öffnen.'; const LOGIN_TO_OPEN = 'Melde dich an, um diese Karte zu öffnen.';
const PREVIEW_WIDTH = 480;
const PREVIEW_QUALITY = 0.85;
const SAVED_FLASH_MS = 2000; const SAVED_FLASH_MS = 2000;
type Logos = Pick<CardAssets, 'network' | 'brand'>; type Logos = Pick<CardAssets, 'network' | 'brand'>;