import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { DEFAULT_CONFIG, type TemplateId } from '../card/config'; import type { SavedCard } from '../account/cards'; const mocks = vi.hoisted(() => ({ createJWT: vi.fn(), decodeImage: vi.fn(), loadCardAssets: vi.fn(), exportCard: vi.fn(), getLogo: vi.fn(), loadLogoBlob: 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: { createJWT: mocks.createJWT }, client: {}, tables: {}, storage: {}, APPWRITE_ENDPOINT: 'https://aircard.test/v1', })); // Rendern (Bild dekodieren, Logos laden, Canvas) ist für renderForGallery ersetzt. vi.mock('../card/render', () => ({ loadCardAssets: mocks.loadCardAssets, exportCard: mocks.exportCard })); // Zeile und Datei eines Community-Logos kommen aus der Datenschicht; nur der Zwischenspeicher darüber (logo-asset.ts) ist echt. vi.mock('../logos/logos', () => ({ getLogo: mocks.getLogo, loadLogoBlob: mocks.loadLogoBlob })); vi.mock('../editor/upload', async (importOriginal) => ({ ...(await importOriginal()), decodeImage: mocks.decodeImage, })); import { forgetLogo, loadLogoAsset } from '../logos/logo-asset'; import type { CommunityLogo } from '../logos/logos'; import { ApiError, publishCard } from './api'; import { LOGO_MISSING_PUBLISH, PNG_MAX_BYTES, assertLogoAvailable, normalizeTags, publishSavedCard, renderForGallery, type PublishDeps, } from './publish'; const card: SavedCard = { id: 'card1', name: 'Meine Karte', imageFileId: 'privImage', previewFileId: 'privPreview', updatedAt: '2026-10-04T10:00:00.000Z', config: DEFAULT_CONFIG, }; const form = { title: 'Meine Karte', issuer: 'Beispielbank', tags: 'Visa, Gold', displayName: 'Kenso' }; const png = () => new Blob(['png'], { type: 'image/png' }); const webp = () => new Blob(['webp'], { type: 'image/webp' }); type Call = { name: string; args: unknown[] }; /** Fake-Abhängigkeiten, die jeden Aufruf in Reihenfolge aufzeichnen; Dateien bekommen die IDs f1, f2, … */ function fakeDeps(overrides: Partial = {}) { const calls: Call[] = []; let files = 0; const log = (name: string, args: unknown[], result: T): T => { calls.push({ name, args }); return result; }; const deps: PublishDeps = { userId: () => log('userId', [], 'u1'), loadImage: async (c) => log('loadImage', [c], new Blob(['src'], { type: 'image/jpeg' })), render: async (c, image) => log('render', [c, image], { png: png(), preview: webp() }), createFile: async (file, permissions) => log('createFile', [file, permissions], { $id: `f${++files}` }), deleteFile: async (id) => log('deleteFile', [id], undefined), publishCard: async (input) => log('publishCard', [input], { id: 'g1' }), ...overrides, }; return { deps, calls, names: () => calls.map((c) => c.name), of: (name: string) => calls.filter((c) => c.name === name), }; } describe('publishSavedCard', () => { it('Reihenfolge: Nutzer, Bild laden, rendern, Upload, Upload, API; liefert { id }', async () => { const fake = fakeDeps(); await expect(publishSavedCard(card, form, fake.deps)).resolves.toEqual({ id: 'g1' }); expect(fake.names()).toEqual(['userId', 'loadImage', 'render', 'createFile', 'createFile', 'publishCard']); expect(fake.of('loadImage')[0].args[0]).toBe(card); expect(fake.of('render')[0].args[0]).toBe(card); }); it('lädt zuerst das PNG, dann die Vorschau hoch; beide mit read(any) und delete(user:)', async () => { const fake = fakeDeps(); await publishSavedCard(card, form, fake.deps); const [first, second] = fake.of('createFile'); expect((first.args[0] as File).name).toBe('image.png'); expect((first.args[0] as File).type).toBe('image/png'); expect((second.args[0] as File).name).toBe('preview.webp'); expect((second.args[0] as File).type).toBe('image/webp'); for (const call of [first, second]) { expect(call.args[1]).toEqual(['read("any")', 'delete("user:u1")']); } }); it('benennt die Vorschau nach dem echten Blob-Typ (Browser ohne WebP-Export liefern PNG)', async () => { const fake = fakeDeps({ render: async () => ({ png: png(), preview: png() }) }); await publishSavedCard(card, form, fake.deps); const preview = fake.of('createFile')[1].args[0] as File; expect(preview.name).toBe('preview.png'); expect(preview.type).toBe('image/png'); }); it('schickt der API beide neuen Datei-IDs, die Karten-ID und die bereinigten Felder (Tags als Liste)', async () => { const fake = fakeDeps(); await publishSavedCard(card, { title: ' Meine Karte ', issuer: ' Bank ', tags: 'Visa, Gold , visa,,', displayName: ' K ' }, fake.deps); expect(fake.of('publishCard')[0].args[0]).toEqual({ title: 'Meine Karte', issuer: 'Bank', tags: ['visa', 'gold'], displayName: 'K', imageFileId: 'f1', previewFileId: 'f2', sourceCardId: 'card1', }); }); 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.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('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 () => { const failure = new Error('upload kaputt'); let uploads = 0; const fake = fakeDeps({ createFile: async (file, permissions) => { fake.calls.push({ name: 'createFile', args: [file, permissions] }); if (++uploads === 2) throw failure; return { $id: 'f1' }; }, }); await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure); expect(fake.of('deleteFile').map((c) => c.args[0])).toEqual(['f1']); expect(fake.of('publishCard')).toHaveLength(0); }); it('erster Upload scheitert: nichts zu löschen, kein zweiter Upload', async () => { const failure = new Error('upload kaputt'); const fake = fakeDeps({ createFile: async () => Promise.reject(failure) }); await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure); expect(fake.of('deleteFile')).toHaveLength(0); expect(fake.of('publishCard')).toHaveLength(0); }); it('löscht nie etwas anderes als die zwei Dateien dieses Versuchs (nicht die privaten Dateien der Karte)', async () => { const fake = fakeDeps({ publishCard: async () => Promise.reject(new ApiError(403, 'Dazu hast du keine Berechtigung.')) }); await publishSavedCard(card, form, fake.deps).catch(() => undefined); const deleted = fake.of('deleteFile').map((c) => c.args[0]); expect(deleted).not.toContain('privImage'); expect(deleted).not.toContain('privPreview'); expect(deleted.every((id) => id === 'f1' || id === 'f2')).toBe(true); }); it('ein Fehler beim Aufräumen überdeckt den ursprünglichen Fehler nicht und stoppt das Aufräumen nicht', async () => { const failure = new ApiError(400, 'Bitte gib einen Titel an.'); const attempted: string[] = []; const fake = fakeDeps({ publishCard: async () => Promise.reject(failure), deleteFile: async (id) => { attempted.push(id); throw new Error('delete kaputt'); }, }); await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure); expect(attempted.sort()).toEqual(['f1', 'f2']); }); it('bei Erfolg wird nichts gelöscht', async () => { const fake = fakeDeps(); await publishSavedCard(card, form, fake.deps); expect(fake.of('deleteFile')).toHaveLength(0); }); describe('vor dem Hochladen', () => { it('ein PNG über 5 MiB wird nicht hochgeladen: „Das Kartenbild ist zu groß für die Galerie (max. 5 MB).“', async () => { const big = new Blob([new Uint8Array(PNG_MAX_BYTES + 1)], { type: 'image/png' }); const fake = fakeDeps({ render: async () => ({ png: big, preview: webp() }) }); await expect(publishSavedCard(card, form, fake.deps)).rejects.toThrow( 'Das Kartenbild ist zu groß für die Galerie (max. 5 MB).', ); expect(fake.of('createFile')).toHaveLength(0); expect(fake.of('deleteFile')).toHaveLength(0); expect(fake.of('publishCard')).toHaveLength(0); }); it('genau 5 MiB sind erlaubt', async () => { const exact = new Blob([new Uint8Array(PNG_MAX_BYTES)], { type: 'image/png' }); const fake = fakeDeps({ render: async () => ({ png: exact, preview: webp() }) }); await expect(publishSavedCard(card, form, fake.deps)).resolves.toEqual({ id: 'g1' }); }); it('ein Bild mit unzulässigem Typ wird nicht hochgeladen', async () => { const fake = fakeDeps({ render: async () => ({ png: new Blob(['x'], { type: 'image/jpeg' }), preview: webp() }) }); await expect(publishSavedCard(card, form, fake.deps)).rejects.toThrow('Das Kartenbild konnte nicht erzeugt werden.'); expect(fake.of('createFile')).toHaveLength(0); }); it('scheitert das Rendern, wird nichts hochgeladen oder gelöscht', async () => { const failure = new Error('Das Bild konnte nicht gelesen werden.'); const fake = fakeDeps({ render: async () => Promise.reject(failure) }); await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure); expect(fake.names()).toEqual(['userId', 'loadImage']); }); it('scheitert das Laden des Bildes, passiert nichts weiter', async () => { const failure = Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' }); const fake = fakeDeps({ loadImage: async () => Promise.reject(failure) }); await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure); expect(fake.names()).toEqual(['userId']); }); it('ohne angemeldeten Nutzer wird weder geladen noch gerendert', async () => { const failure = Object.assign(new Error('unauthorized'), { code: 401 }); const fake = fakeDeps({ userId: () => { throw failure; }, }); await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure); expect(fake.names()).toEqual([]); }); }); }); describe('Logo der Karte beim Veröffentlichen', () => { const photo = { source: {}, w: 3000, h: 2000 }; const source = new Blob(['src'], { type: 'image/jpeg' }); const withLogo = (logo: string | null, template: TemplateId = DEFAULT_CONFIG.template): SavedCard => ({ ...card, config: { ...DEFAULT_CONFIG, template, logo }, }); /** Die Vorlagen, die das Community-Logo zeichnen (alle außer „Nur Bild“). */ const DRAWING = ['minimal', 'classic', 'signature'] as const; const assetsFor = (patch: Record = {}) => ({ image: photo, logo: null, brand: null, logoMissing: false, ...patch }); beforeEach(() => { mocks.decodeImage.mockReset().mockResolvedValue(photo); mocks.loadCardAssets.mockReset().mockResolvedValue(assetsFor()); mocks.exportCard .mockReset() .mockImplementation(async (_config: unknown, _assets: unknown, opts: { type: string }) => new Blob(['x'], { type: opts.type })); }); it('LOGO_MISSING_PUBLISH: der Wortlaut aus der Spec, auf Englisch übersetzt', async () => { const { t, setLang } = await import('../i18n/i18n'); expect(LOGO_MISSING_PUBLISH).toBe( 'Das Logo dieser Karte ist nicht mehr verfügbar. Öffne die Karte im Editor und wähle ein anderes.', ); try { setLang('en', { persist: false }); expect(t(LOGO_MISSING_PUBLISH)).toBe('This card’s logo is no longer available. Open the card in the editor and choose another one.'); } finally { setLang('de', { persist: false }); } }); it('renderForGallery: rendert mit dem Logo der Karte als PNG (1536 px) und WebP-Vorschau (480 px)', async () => { mocks.loadCardAssets.mockResolvedValue(assetsFor({ logo: { source: {}, w: 200, h: 100 } })); const out = await renderForGallery(withLogo('abc123'), source); expect(mocks.decodeImage).toHaveBeenCalledWith(source); expect(mocks.loadCardAssets).toHaveBeenCalledWith(expect.objectContaining({ logo: 'abc123' }), photo); expect(mocks.exportCard).toHaveBeenCalledTimes(2); expect(mocks.exportCard.mock.calls[0][2]).toEqual({ width: 1536, type: 'image/png' }); expect(mocks.exportCard.mock.calls[1][2]).toEqual({ width: 480, type: 'image/webp', quality: 0.85 }); expect([out.png.type, out.preview.type]).toEqual(['image/png', 'image/webp']); }); it('eine Karte ohne Logo wird ganz normal gerendert', async () => { const out = await renderForGallery(withLogo(null), source); expect(mocks.loadCardAssets).toHaveBeenCalledWith(expect.objectContaining({ logo: null }), photo); expect(out.png.type).toBe('image/png'); }); it.each(DRAWING)( 'fehlendes Logo (gelöscht oder ausgeblendet), Vorlage %s zeichnet es: Abbruch mit LOGO_MISSING_PUBLISH, es wird nichts gerendert', async (template) => { mocks.loadCardAssets.mockResolvedValue(assetsFor({ logoMissing: true })); await expect(renderForGallery(withLogo('gone', template), source)).rejects.toThrow(LOGO_MISSING_PUBLISH); expect(mocks.exportCard).not.toHaveBeenCalled(); }, ); it('fehlendes Logo bei „Nur Bild“ (image): die Vorlage zeichnet es nie, es wird ganz normal gerendert', async () => { mocks.loadCardAssets.mockResolvedValue(assetsFor({ logoMissing: true })); const out = await renderForGallery(withLogo('gone', 'image'), source); expect(mocks.exportCard).toHaveBeenCalledTimes(2); expect(mocks.exportCard.mock.calls[0][2]).toEqual({ width: 1536, type: 'image/png' }); expect([out.png.type, out.preview.type]).toEqual(['image/png', 'image/webp']); }); it('ein Ladefehler der Logos (Netz) ist kein fehlendes Logo: der Fehler kommt unverändert an', async () => { const failure = new TypeError('Failed to fetch'); mocks.loadCardAssets.mockRejectedValue(failure); await expect(renderForGallery(withLogo('abc123'), source)).rejects.toBe(failure); }); it('publishSavedCard: classic mit fehlendem Logo – nichts wird hochgeladen, nichts gelöscht, die API nicht gerufen', async () => { mocks.loadCardAssets.mockResolvedValue(assetsFor({ logoMissing: true })); const fake = fakeDeps({ render: renderForGallery }); await expect(publishSavedCard(withLogo('gone', 'classic'), form, fake.deps)).rejects.toThrow(LOGO_MISSING_PUBLISH); expect(fake.names()).toEqual(['userId', 'loadImage']); expect(fake.of('createFile')).toHaveLength(0); expect(fake.of('deleteFile')).toHaveLength(0); expect(fake.of('publishCard')).toHaveLength(0); }); it('publishSavedCard: „Nur Bild“ (image) mit fehlendem Logo – wird veröffentlicht, als wäre nichts', async () => { mocks.loadCardAssets.mockResolvedValue(assetsFor({ logoMissing: true })); const fake = fakeDeps({ render: renderForGallery }); await expect(publishSavedCard(withLogo('gone', 'image'), form, fake.deps)).resolves.toEqual({ id: 'g1' }); expect(fake.names()).toEqual(['userId', 'loadImage', 'createFile', 'createFile', 'publishCard']); expect(fake.of('deleteFile')).toHaveLength(0); }); it('publishSavedCard: mit vorhandenem Logo läuft alles wie sonst', async () => { const fake = fakeDeps({ render: renderForGallery }); await expect(publishSavedCard(withLogo('abc123'), form, fake.deps)).resolves.toEqual({ id: 'g1' }); expect(fake.names()).toEqual(['userId', 'loadImage', 'createFile', 'createFile', 'publishCard']); }); }); describe('Zwischenspeicher der Logos beim Veröffentlichen (ein ausgeblendetes Logo darf nicht in die Galerie)', () => { const photo = { source: {}, w: 3000, h: 2000 }; const source = new Blob(['src'], { type: 'image/jpeg' }); const logoRow = (patch: Partial = {}): CommunityLogo => ({ id: 'zw1', name: 'Mein Logo', displayName: 'Kenso', fileId: 'zw1', ownerId: 'u1', mono: false, likeCount: 0, hidden: false, reportCount: 0, createdAt: '2026-10-04T10:00:00.000Z', ...patch, }); const withLogo = (id: string, template: TemplateId = 'classic'): SavedCard => ({ ...card, config: { ...DEFAULT_CONFIG, template, logo: id } }); beforeEach(() => { mocks.decodeImage.mockReset().mockResolvedValue(photo); mocks.exportCard .mockReset() .mockImplementation(async (_config: unknown, _assets: unknown, opts: { type: string }) => new Blob(['x'], { type: opts.type })); mocks.getLogo.mockReset().mockResolvedValue(logoRow()); mocks.loadLogoBlob.mockReset().mockResolvedValue(new Blob(['png'], { type: 'image/png' })); // Wie der echte Lader (card/render.ts: loadCardAssets), mit dem echten Zwischenspeicher der Logos darunter. mocks.loadCardAssets.mockReset().mockImplementation(async (config: { logo: string | null; details: 'light' | 'dark' }, image: unknown) => { const logo = config.logo ? await loadLogoAsset(config.logo, config.details) : null; return { image, logo, brand: null, logoMissing: Boolean(config.logo) && logo === null }; }); vi.stubGlobal('createImageBitmap', async () => ({ width: 200, height: 100 })); }); afterEach(() => { forgetLogo('zw1'); vi.unstubAllGlobals(); }); it('ein Logo, das nach dem ersten Laden (Editor, Zwischenspeicher) ausgeblendet wurde, wird neu geprüft: Abbruch, nichts wird hochgeladen', async () => { await expect(loadLogoAsset('zw1', 'light')).resolves.not.toBeNull(); // der Editor hat es geladen: Zeile und Bild stecken im Zwischenspeicher mocks.getLogo.mockResolvedValue(logoRow({ hidden: true })); // dann hat ein Admin oder die Melder es ausgeblendet const fake = fakeDeps({ render: renderForGallery }); await expect(publishSavedCard(withLogo('zw1'), form, fake.deps)).rejects.toThrow(LOGO_MISSING_PUBLISH); expect(mocks.getLogo).toHaveBeenCalledTimes(2); expect(mocks.exportCard).not.toHaveBeenCalled(); expect(fake.of('createFile')).toHaveLength(0); expect(fake.of('publishCard')).toHaveLength(0); }); it('ein Logo, das nach dem ersten Laden gelöscht wurde, ebenso', async () => { await expect(loadLogoAsset('zw1', 'light')).resolves.not.toBeNull(); mocks.getLogo.mockResolvedValue(null); await expect(renderForGallery(withLogo('zw1'), source)).rejects.toThrow(LOGO_MISSING_PUBLISH); expect(mocks.exportCard).not.toHaveBeenCalled(); }); it('ein weiterhin sichtbares Logo wird mit frisch geholter Zeile und Datei gerendert (kein Zwischenspeicher-Treffer)', async () => { await expect(loadLogoAsset('zw1', 'light')).resolves.not.toBeNull(); const out = await renderForGallery(withLogo('zw1'), source); expect(out.png.type).toBe('image/png'); expect(mocks.getLogo).toHaveBeenCalledTimes(2); expect(mocks.loadLogoBlob).toHaveBeenCalledTimes(2); expect(mocks.exportCard.mock.calls[0][1]).toMatchObject({ logo: { w: 200, h: 100 } }); }); it('beide Tonarten des Logos werden vergessen, nicht nur die der Karte', async () => { await loadLogoAsset('zw1', 'light'); await loadLogoAsset('zw1', 'dark'); mocks.getLogo.mockResolvedValue(logoRow({ hidden: true })); await expect(renderForGallery({ ...withLogo('zw1'), config: { ...withLogo('zw1').config, details: 'dark' } }, source)).rejects.toThrow( LOGO_MISSING_PUBLISH, ); await expect(loadLogoAsset('zw1', 'light')).resolves.toBeNull(); // auch das helle Bild ist weg: es wird neu geprüft }); it('eine Karte ohne Logo vergisst und holt nichts', async () => { const out = await renderForGallery({ ...card, config: { ...DEFAULT_CONFIG, logo: null } }, source); expect(out.png.type).toBe('image/png'); expect(mocks.getLogo).not.toHaveBeenCalled(); }); it('„Nur Bild“ (image) zeichnet nie ein Logo: auch ein inzwischen ausgeblendetes Logo hindert das Veröffentlichen nicht', async () => { await loadLogoAsset('zw1', 'light'); mocks.getLogo.mockResolvedValue(logoRow({ hidden: true })); const out = await renderForGallery(withLogo('zw1', 'image'), source); expect(out.png.type).toBe('image/png'); expect(mocks.exportCard).toHaveBeenCalledTimes(2); }); }); describe('assertLogoAvailable', () => { const config = (template: TemplateId) => ({ ...DEFAULT_CONFIG, template, logo: 'abc123' }); const assets = (logoMissing: boolean) => ({ image: null, logo: null, brand: null, logoMissing }); it.each(['minimal', 'classic', 'signature'] as const)('Logo fehlt und Vorlage %s zeichnet es: wirft LOGO_MISSING_PUBLISH', (template) => { expect(() => assertLogoAvailable(config(template), assets(true))).toThrow(LOGO_MISSING_PUBLISH); }); it('Logo fehlt, aber „Nur Bild“ zeichnet nie ein Logo: wirft nicht', () => { expect(() => assertLogoAvailable(config('image'), assets(true))).not.toThrow(); }); it.each(['image', 'minimal', 'classic', 'signature'] as const)('Logo ist da (oder keins gewählt): Vorlage %s wirft nicht', (template) => { expect(() => assertLogoAvailable(config(template), assets(false))).not.toThrow(); }); }); 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>(); 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) { 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('503', { status: 503 })], ['504 vom Proxy', async () => new Response('504', { 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('', { 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']); }); it('leere Eingabe ergibt eine leere Liste', () => { expect(normalizeTags('')).toEqual([]); expect(normalizeTags(' , , ')).toEqual([]); }); it('behält die Reihenfolge der ersten Nennung', () => { expect(normalizeTags('b, a, b, c, a')).toEqual(['b', 'a', 'c']); }); it('kürzt nichts: Grenzen (5 Tags, 20 Zeichen) prüft der Server mit deutscher Meldung', () => { const long = 'x'.repeat(30); expect(normalizeTags(`a,b,c,d,e,f,${long}`)).toEqual(['a', 'b', 'c', 'd', 'e', 'f', long]); }); it('Umlaute werden mit klein geschrieben', () => { expect(normalizeTags('ÄÖÜ, Straße')).toEqual(['äöü', 'straße']); }); });