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
+449 -48

No files matched your search

+159 -2
View File
@@ -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<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', () => {
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 () => {