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
@@ -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']);
|
||||
|
||||
Reference in new issue
Block a user