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:
1 parent
89c86fd578
commit
ae73dc4a0b
8 files changed
+449
-48
No files matched your search
@@ -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;
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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();
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -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,25 +103,40 @@ 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])(
|
||||||
const fake = fakeDeps({ publishCard: async () => Promise.reject(failure) });
|
'eindeutig abgelehnt (%i): beide Dateien dieses Versuchs werden gelöscht, der Fehler kommt an',
|
||||||
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
|
async (code) => {
|
||||||
expect(fake.of('deleteFile').map((c) => c.args[0]).sort()).toEqual(['f1', 'f2']);
|
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 () => {
|
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',
|
||||||
const fake = fakeDeps({ publishCard: async () => Promise.reject(failure) });
|
async (code) => {
|
||||||
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
|
const failure = new ApiError(code, 'Unklar.');
|
||||||
expect(fake.of('deleteFile').map((c) => c.args[0]).sort()).toEqual(['f1', 'f2']);
|
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 () => {
|
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 () => {
|
||||||
@@ -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']);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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'>;
|
||||||
|
|||||||
Reference in new issue
Block a user