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

+117 -22
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 type { SavedCard } from '../account/cards';
// Die Standard-Abhängigkeiten (Appwrite, Canvas) kommen in diesen Tests nie zum Einsatz.
const mocks = vi.hoisted(() => ({ createJWT: vi.fn() }));
// Die Standard-Abhängigkeiten (Appwrite, Canvas) kommen in diesen Tests nie zum Einsatz;
// nur createJWT wird für den Test mit der echten API-Schicht gebraucht.
vi.mock('../account/appwrite', () => ({
IDS: { galleryBucket: 'gallery-bucket' },
account: {},
account: { createJWT: mocks.createJWT },
client: {},
tables: {},
storage: {},
APPWRITE_ENDPOINT: 'https://aircard.test/v1',
}));
import { ApiError } from './api';
import { ApiError, publishCard } from './api';
import { PNG_MAX_BYTES, normalizeTags, publishSavedCard, type PublishDeps } from './publish';
const card: SavedCard = {
@@ -100,25 +103,40 @@ describe('publishSavedCard', () => {
});
});
it('API-Fehler: beide hochgeladenen Dateien werden gelöscht, der Fehler kommt an', async () => {
const failure = new ApiError(400, 'Bitte gib einen Titel an.');
const fake = fakeDeps({ publishCard: async () => Promise.reject(failure) });
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
expect(fake.of('deleteFile').map((c) => c.args[0]).sort()).toEqual(['f1', 'f2']);
});
describe('Fehler der API: gelöscht wird nur bei eindeutiger Ablehnung', () => {
it.each([400, 401, 403, 404, 409])(
'eindeutig abgelehnt (%i): beide Dateien dieses Versuchs werden gelöscht, der Fehler kommt an',
async (code) => {
const failure = new ApiError(code, 'Abgelehnt.');
const fake = fakeDeps({ publishCard: async () => Promise.reject(failure) });
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
expect(fake.of('deleteFile').map((c) => c.args[0]).sort()).toEqual(['f1', 'f2']);
},
);
it('409 (schon veröffentlicht): Aufräumen wie bei jedem Fehler', async () => {
const failure = new ApiError(409, 'Diese Karte ist schon veröffentlicht.');
const fake = fakeDeps({ publishCard: async () => Promise.reject(failure) });
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
expect(fake.of('deleteFile').map((c) => c.args[0]).sort()).toEqual(['f1', 'f2']);
});
it.each([408, 429, 500, 502, 503, 504])(
'unklarer Ausgang (%i): die Zeile kann angelegt sein, die Dateien bleiben liegen, der Fehler kommt an',
async (code) => {
const failure = new ApiError(code, 'Unklar.');
const fake = fakeDeps({ publishCard: async () => Promise.reject(failure) });
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
expect(fake.of('deleteFile')).toHaveLength(0);
},
);
it('Netzfehler der API: Aufräumen, der Fehler kommt an', async () => {
const failure = new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.');
const fake = fakeDeps({ publishCard: async () => Promise.reject(failure) });
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
expect(fake.of('deleteFile')).toHaveLength(2);
it('JWT-Methode am Server aus (ApiError mit definitive-Flag, es ging keine Anfrage raus): gelöscht', async () => {
const failure = new ApiError(501, 'Anmeldung per Token ist auf dem Server ausgeschaltet.', true);
const fake = fakeDeps({ publishCard: async () => Promise.reject(failure) });
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
expect(fake.of('deleteFile')).toHaveLength(2);
});
it('ein anderer Fehler vor der Anfrage (z. B. createJWT mit 401): gelöscht', async () => {
const failure = Object.assign(new Error('missing scope'), { code: 401, type: 'general_unauthorized_scope' });
const fake = fakeDeps({ publishCard: async () => Promise.reject(failure) });
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
expect(fake.of('deleteFile')).toHaveLength(2);
});
});
it('zweiter Upload scheitert: nur der erste wird gelöscht, die API wird nicht gerufen', async () => {
@@ -154,7 +172,7 @@ describe('publishSavedCard', () => {
});
it('ein Fehler beim Aufräumen überdeckt den ursprünglichen Fehler nicht und stoppt das Aufräumen nicht', async () => {
const failure = new ApiError(500, 'Interner Fehler.');
const failure = new ApiError(400, 'Bitte gib einen Titel an.');
const attempted: string[] = [];
const fake = fakeDeps({
publishCard: async () => Promise.reject(failure),
@@ -224,6 +242,83 @@ describe('publishSavedCard', () => {
});
});
describe('mit der echten API-Schicht (publishCard, apiPost) und gestubbtem fetch', () => {
const json = (status: number, body: unknown) =>
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
const fetchMock = vi.fn<(url: string, init: RequestInit) => Promise<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', () => {
it('trennt an Kommas, trimmt, schreibt klein und entfernt Leere und Doppelte', () => {
expect(normalizeTags(' Visa, Gold ,, visa ,GOLD, Karte ')).toEqual(['visa', 'gold', 'karte']);