import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { User } from '../account/auth'; import type { SavedCard } from '../account/cards'; import { DEFAULT_CONFIG } from '../card/config'; import { createStore, initialEditorState } from '../editor/state'; import type { GalleryCard } from '../community/gallery'; const mocks = vi.hoisted(() => ({ list: vi.fn(), loadPreview: vi.fn(), loadImage: vi.fn(), rename: vi.fn(), remove: vi.fn(), getCurrentUser: vi.fn(), requireLogin: vi.fn(), navigate: vi.fn(), decodeImage: vi.fn(), loadCardAssets: vi.fn(), exportCard: vi.fn(), saveBlobAs: vi.fn(), publishSavedCard: vi.fn(), withdrawCard: vi.fn(), myPublications: vi.fn(), authListeners: new Set<(user: User | null) => void>(), })); vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' })); vi.mock('../account/auth', () => ({ getCurrentUser: mocks.getCurrentUser, currentUserId: () => 'u1', onAuthChange: (fn: (user: User | null) => void) => { mocks.authListeners.add(fn); return () => mocks.authListeners.delete(fn); }, })); vi.mock('../account/login-dialog', () => ({ requireLogin: mocks.requireLogin })); vi.mock('./router', () => ({ navigate: mocks.navigate })); vi.mock('../editor/upload', () => ({ READ_MESSAGE: 'Das Bild konnte nicht gelesen werden.', PIXELS_MESSAGE: 'Das Bild ist zu groß (max. 40 Megapixel).', decodeImage: mocks.decodeImage, })); vi.mock('../card/render', () => ({ loadCardAssets: mocks.loadCardAssets, exportCard: mocks.exportCard })); vi.mock('../editor/download', async (importOriginal) => ({ ...(await importOriginal()), saveBlobAs: mocks.saveBlobAs, })); vi.mock('../account/cards', async (importOriginal) => ({ ...(await importOriginal()), cardsService: () => ({ list: mocks.list, loadPreview: mocks.loadPreview, loadImage: mocks.loadImage, rename: mocks.rename, remove: mocks.remove, }), })); vi.mock('../community/publish', async (importOriginal) => ({ ...(await importOriginal()), publishSavedCard: mocks.publishSavedCard, })); vi.mock('../community/api', async (importOriginal) => ({ ...(await importOriginal()), withdrawCard: mocks.withdrawCard, })); vi.mock('../community/gallery', () => ({ myPublications: mocks.myPublications })); import { ApiError } from '../community/api'; import { mountCardsPage } from './cards-page'; const user: User = { $id: 'u1', email: 'a@b.de' }; const card = (id: string, name: string, patch: Partial = {}): SavedCard => ({ id, name, imageFileId: `img-${id}`, previewFileId: `prev-${id}`, updatedAt: '2026-10-04T12:00:00.000Z', config: structuredClone(DEFAULT_CONFIG), ...patch, }); let root: HTMLElement; let unmount: (() => void) | null; let store: ReturnType; let urlCounter: number; const revoked: string[] = []; beforeEach(() => { for (const fn of Object.values(mocks)) if (typeof fn === 'function' && 'mockReset' in fn) fn.mockReset(); mocks.authListeners.clear(); revoked.length = 0; urlCounter = 0; URL.createObjectURL = vi.fn(() => `blob:preview-${++urlCounter}`); URL.revokeObjectURL = vi.fn((url: string) => { revoked.push(url); }); document.body.innerHTML = '
'; root = document.getElementById('app-root') as HTMLElement; store = createStore(initialEditorState()); mocks.getCurrentUser.mockResolvedValue(user); mocks.requireLogin.mockResolvedValue(true); mocks.myPublications.mockResolvedValue(new Map()); mocks.loadPreview.mockImplementation(async () => new Blob(['p'], { type: 'image/webp' })); unmount = null; }); afterEach(() => { unmount?.(); }); const mount = () => { unmount = mountCardsPage(root, store); }; const text = () => root.textContent ?? ''; const tiles = () => [...root.querySelectorAll('.card-tile')]; const tileFor = (id: string) => tiles().find((t) => t.dataset.card === id)!; const button = (scope: ParentNode, label: string) => [...scope.querySelectorAll('button')].find((b) => b.textContent === label) as HTMLButtonElement; const dialog = () => document.querySelector('dialog.confirm-dialog'); const submit = (form: HTMLFormElement) => form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); describe('Meine Karten: Zustände', () => { it('abgemeldet: Hinweis und Anmelden-Knopf, keine Anfrage an den Server', async () => { mocks.getCurrentUser.mockResolvedValue(null); mount(); await vi.waitFor(() => expect(text()).toContain('Melde dich an, um deine Karten zu sehen.')); expect(mocks.list).not.toHaveBeenCalled(); button(root, 'Anmelden').click(); expect(mocks.requireLogin).toHaveBeenCalledWith('login'); }); it('nach dem Anmelden (Konto-Menü oder Knopf) wird die Liste geladen', async () => { mocks.getCurrentUser.mockResolvedValue(null); mocks.list.mockResolvedValue([card('a', 'Alpha')]); mount(); await vi.waitFor(() => expect(text()).toContain('Melde dich an')); mocks.getCurrentUser.mockResolvedValue(user); for (const fn of mocks.authListeners) fn(user); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); }); it('leer: Hinweis und Link zum Editor', async () => { mocks.list.mockResolvedValue([]); mount(); await vi.waitFor(() => expect(text()).toContain('Noch keine Karten gespeichert.')); const link = root.querySelector('a[data-link]')!; expect(link.getAttribute('href')).toBe('/gestalten'); }); it('Fehler beim Laden: Meldung und „Nochmal versuchen“', async () => { mocks.list.mockRejectedValueOnce(new TypeError('offline')).mockResolvedValueOnce([card('a', 'Alpha')]); mount(); await vi.waitFor(() => expect(text()).toContain('Keine Verbindung zum Server.')); button(root, 'Nochmal versuchen').click(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); }); it('Sitzung läuft beim Laden ab: Anmelden, dann wird die Liste angezeigt', async () => { const unauthorized = Object.assign(new Error('Unauthorized'), { code: 401 }); mocks.list.mockRejectedValueOnce(unauthorized).mockResolvedValueOnce([card('a', 'Alpha')]); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); expect(mocks.requireLogin).toHaveBeenCalledOnce(); }); it('Sitzung läuft ab und der Dialog wird geschlossen: Anmelden-Ansicht', async () => { mocks.list.mockRejectedValue(Object.assign(new Error('Unauthorized'), { code: 401 })); mocks.requireLogin.mockResolvedValue(false); mount(); await vi.waitFor(() => expect(text()).toContain('Melde dich an, um deine Karten zu sehen.')); }); it('Abmelden räumt die Liste weg und gibt die Vorschau-URLs frei', async () => { mocks.list.mockResolvedValue([card('a', 'Alpha')]); mount(); await vi.waitFor(() => expect(tileFor('a').querySelector('img')).not.toBeNull()); for (const fn of mocks.authListeners) fn(null); expect(text()).toContain('Melde dich an, um deine Karten zu sehen.'); expect(tiles()).toHaveLength(0); expect(revoked).toEqual(['blob:preview-1']); }); }); describe('Meine Karten: Kacheln', () => { it('zeigt Name, Datum und Vorschau', async () => { const updatedAt = new Date(2026, 9, 4, 12).toISOString(); mocks.list.mockResolvedValue([card('a', 'Alpha', { updatedAt }), card('b', 'Beta')]); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(2)); expect(tileFor('a').querySelector('.card-name')!.textContent).toBe('Alpha'); expect(tileFor('a').querySelector('.card-date')!.textContent).toBe('04.10.2026'); await vi.waitFor(() => expect(tileFor('a').querySelector('img')).not.toBeNull()); await vi.waitFor(() => expect(tileFor('b').querySelector('img')).not.toBeNull()); expect(tileFor('a').querySelector('img')!.getAttribute('src')).toMatch(/^blob:preview-/); expect(mocks.loadPreview).toHaveBeenCalledTimes(2); }); it('eine Vorschau, die nicht lädt, zeigt „Keine Vorschau“, die Kachel bleibt bedienbar', async () => { mocks.list.mockResolvedValue([card('a', 'Alpha')]); mocks.loadPreview.mockRejectedValue(Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' })); mount(); await vi.waitFor(() => expect(tileFor('a').textContent).toContain('Keine Vorschau')); expect(button(tileFor('a'), 'Öffnen').disabled).toBe(false); }); it('lädt höchstens vier Vorschauen gleichzeitig', async () => { const cards = Array.from({ length: 10 }, (_, i) => card(`c${i}`, `Karte ${i}`)); mocks.list.mockResolvedValue(cards); let active = 0; let peak = 0; mocks.loadPreview.mockImplementation(async () => { active += 1; peak = Math.max(peak, active); await new Promise((r) => setTimeout(r, 5)); active -= 1; return new Blob(['p'], { type: 'image/webp' }); }); mount(); await vi.waitFor(() => expect(root.querySelectorAll('img')).toHaveLength(10)); expect(peak).toBeLessThanOrEqual(4); expect(peak).toBeGreaterThan(1); }); it('beim Verlassen der Seite werden alle Vorschau-URLs freigegeben', async () => { mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]); mount(); await vi.waitFor(() => expect(root.querySelectorAll('img')).toHaveLength(2)); unmount?.(); unmount = null; expect([...revoked].sort()).toEqual(['blob:preview-1', 'blob:preview-2']); }); it('eine Vorschau, die erst nach dem Verlassen ankommt, bekommt keine URL', async () => { mocks.list.mockResolvedValue([card('a', 'Alpha')]); let finish!: (b: Blob) => void; mocks.loadPreview.mockReturnValue(new Promise((r) => (finish = r))); mount(); await vi.waitFor(() => expect(mocks.loadPreview).toHaveBeenCalled()); unmount?.(); unmount = null; finish(new Blob(['p'], { type: 'image/webp' })); await new Promise((r) => setTimeout(r, 0)); expect(URL.createObjectURL).not.toHaveBeenCalled(); }); }); describe('Meine Karten: Öffnen und Herunterladen', () => { it('Öffnen navigiert in den Editor', async () => { mocks.list.mockResolvedValue([card('a', 'Alpha')]); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); button(tileFor('a'), 'Öffnen').click(); expect(mocks.navigate).toHaveBeenCalledWith('/gestalten?karte=a'); }); it('Herunterladen: Bild laden, als PNG rendern und unter aircard-.png speichern', async () => { const png = new Blob(['png'], { type: 'image/png' }); const image = { source: {}, w: 3000, h: 2000 }; mocks.list.mockResolvedValue([card('a', 'Mein Urlaub', { config: { ...DEFAULT_CONFIG, crop: { x: 99999, y: 0, zoom: 1 } } })]); mocks.loadImage.mockResolvedValue(new Blob(['i'], { type: 'image/jpeg' })); mocks.decodeImage.mockResolvedValue(image); mocks.loadCardAssets.mockResolvedValue({ image, network: null, brand: null }); mocks.exportCard.mockResolvedValue(png); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); button(tileFor('a'), 'Herunterladen').click(); await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalled()); expect(mocks.saveBlobAs).toHaveBeenCalledWith(png, 'aircard-mein-urlaub.png'); expect(mocks.exportCard).toHaveBeenCalledWith(expect.anything(), expect.anything(), { width: 1536, type: 'image/png' }); // Der gespeicherte Ausschnitt wird auf das Bild begrenzt, bevor gerendert wird. const rendered = mocks.exportCard.mock.calls[0][0]; expect(rendered.crop.x).toBeLessThan(99999); await vi.waitFor(() => expect(button(tileFor('a'), 'Herunterladen').disabled).toBe(false)); expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe(''); }); it('Herunterladen eines über 40 Megapixel großen Bildes zeigt die Größen-Meldung an der Kachel', async () => { mocks.list.mockResolvedValue([card('a', 'Alpha')]); mocks.loadImage.mockResolvedValue(new Blob(['i'], { type: 'image/png' })); mocks.decodeImage.mockRejectedValue(new Error('Das Bild ist zu groß (max. 40 Megapixel).')); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); button(tileFor('a'), 'Herunterladen').click(); await vi.waitFor(() => expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('Das Bild ist zu groß (max. 40 Megapixel).'), ); expect(mocks.saveBlobAs).not.toHaveBeenCalled(); }); it('Herunterladen ohne Bilddatei zeigt die Meldung an der Kachel', async () => { mocks.list.mockResolvedValue([card('a', 'Alpha')]); mocks.loadImage.mockRejectedValue(Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' })); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); button(tileFor('a'), 'Herunterladen').click(); await vi.waitFor(() => expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('Das Bild dieser Karte fehlt.')); expect(mocks.saveBlobAs).not.toHaveBeenCalled(); expect(button(tileFor('a'), 'Herunterladen').disabled).toBe(false); }); }); describe('Meine Karten: Sitzung weg, Appwrite antwortet mit 404 statt 401', () => { const fileGone = () => Object.assign(new Error('File not found'), { code: 404, type: 'storage_file_not_found' }); const rowGone = () => Object.assign(new Error('Row not found'), { code: 404, type: 'row_not_found' }); /** Beim Laden der Seite ist der Nutzer noch da; danach (Nutzerprüfung nach dem 404) nicht mehr. */ const sessionEndsAfterLoad = () => mocks.getCurrentUser.mockResolvedValueOnce(user).mockResolvedValue(null); it('Vorschau: erst anmelden, dann wird die Vorschau geladen (statt „Keine Vorschau“)', async () => { sessionEndsAfterLoad(); mocks.list.mockResolvedValue([card('a', 'Alpha')]); mocks.loadPreview.mockReset(); mocks.loadPreview .mockRejectedValueOnce(fileGone()) .mockImplementation(async () => new Blob(['p'], { type: 'image/webp' })); mount(); await vi.waitFor(() => expect(tileFor('a').querySelector('img')).not.toBeNull()); expect(mocks.requireLogin).toHaveBeenCalledOnce(); expect(mocks.loadPreview).toHaveBeenCalledTimes(2); }); it('Herunterladen: erst anmelden, dann wird das Bild geladen (statt „Das Bild dieser Karte fehlt.“)', async () => { const png = new Blob(['png'], { type: 'image/png' }); const image = { source: {}, w: 3000, h: 2000 }; sessionEndsAfterLoad(); mocks.list.mockResolvedValue([card('a', 'Alpha')]); mocks.loadImage.mockRejectedValueOnce(fileGone()).mockResolvedValueOnce(new Blob(['i'], { type: 'image/jpeg' })); mocks.decodeImage.mockResolvedValue(image); mocks.loadCardAssets.mockResolvedValue({ image, network: null, brand: null }); mocks.exportCard.mockResolvedValue(png); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); button(tileFor('a'), 'Herunterladen').click(); await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalledWith(png, 'aircard-alpha.png')); expect(mocks.requireLogin).toHaveBeenCalledOnce(); expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe(''); }); it('Löschen: die Karte gilt nicht als „schon gelöscht“, sondern wird nach dem Anmelden wirklich gelöscht', async () => { sessionEndsAfterLoad(); mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]); mocks.remove.mockRejectedValueOnce(rowGone()).mockResolvedValueOnce(undefined); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(2)); await vi.waitFor(() => expect(button(tileFor('b'), 'Löschen').disabled).toBe(false)); button(tileFor('b'), 'Löschen').click(); await vi.waitFor(() => expect(dialog()?.open).toBe(true)); button(dialog()!, 'Löschen').click(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); expect(mocks.requireLogin).toHaveBeenCalledOnce(); expect(mocks.remove).toHaveBeenCalledTimes(2); }); it('Löschen, Anmeldung abgebrochen: die Karte bleibt, Hinweis an der Kachel', async () => { sessionEndsAfterLoad(); mocks.requireLogin.mockResolvedValue(false); mocks.list.mockResolvedValue([card('a', 'Alpha')]); mocks.remove.mockRejectedValue(rowGone()); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); await vi.waitFor(() => expect(button(tileFor('a'), 'Löschen').disabled).toBe(false)); button(tileFor('a'), 'Löschen').click(); await vi.waitFor(() => expect(dialog()?.open).toBe(true)); button(dialog()!, 'Löschen').click(); await vi.waitFor(() => expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('Bitte melde dich an, um fortzufahren.'), ); expect(tiles()).toHaveLength(1); }); }); describe('Meine Karten: Umbenennen', () => { const startRename = async () => { mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(2)); button(tileFor('b'), 'Umbenennen').click(); return tileFor('b').querySelector('input')!; }; it('zeigt eine Eingabe mit dem Namen, Enter speichert, die Karte steht danach vorn', async () => { mocks.rename.mockImplementation(async (id: string, name: string) => card(id, name.trim(), { updatedAt: '2026-10-06T08:00:00.000Z' })); const input = await startRename(); expect(input.value).toBe('Beta'); expect(document.activeElement).toBe(input); input.value = ' Gamma '; submit(input.closest('form')!); await vi.waitFor(() => expect(tiles().map((t) => t.dataset.card)).toEqual(['b', 'a'])); expect(mocks.rename).toHaveBeenCalledWith('b', ' Gamma '); expect(tileFor('b').querySelector('.card-name')!.textContent).toBe('Gamma'); expect(tileFor('b').querySelector('input')).toBeNull(); }); it('der vorhandene Name ist beim Öffnen komplett markiert (Tippen ersetzt ihn, statt davor einzufügen)', async () => { const input = await startRename(); expect(document.activeElement).toBe(input); expect(input.selectionStart).toBe(0); expect(input.selectionEnd).toBe('Beta'.length); }); it('Esc bricht ab, ohne etwas zu speichern', async () => { const input = await startRename(); input.value = 'Anders'; input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); expect(tileFor('b').querySelector('input')).toBeNull(); expect(tileFor('b').querySelector('.card-name')!.textContent).toBe('Beta'); expect(mocks.rename).not.toHaveBeenCalled(); }); it('„Abbrechen“ bricht ab; ein unveränderter Name löst keine Anfrage aus', async () => { const input = await startRename(); button(tileFor('b'), 'Abbrechen').click(); expect(tileFor('b').querySelector('input')).toBeNull(); button(tileFor('b'), 'Umbenennen').click(); const again = tileFor('b').querySelector('input')!; again.value = ' Beta '; submit(again.closest('form')!); expect(tileFor('b').querySelector('input')).toBeNull(); expect(mocks.rename).not.toHaveBeenCalled(); expect(input).not.toBe(again); }); it('ein Fehler lässt die Eingabe offen und zeigt die Meldung', async () => { mocks.rename.mockRejectedValue(new TypeError('offline')); const input = await startRename(); input.value = 'Gamma'; submit(input.closest('form')!); await vi.waitFor(() => expect(tileFor('b').querySelector('.card-msg')!.textContent).toBe('Keine Verbindung zum Server.')); const open = tileFor('b').querySelector('input')!; expect(open.value).toBe('Gamma'); expect(open.readOnly).toBe(false); }); it('hält die Karte im Editor-Zustand aktuell (sonst überschreibt Speichern den neuen Namen)', async () => { store.set({ saved: { id: 'b', name: 'Beta', imageFileId: 'img-b', previewFileId: 'prev-b', updatedAt: '2026-10-04T12:00:00.000Z' }, cardName: 'Beta', }); mocks.rename.mockImplementation(async (id: string, name: string) => card(id, name, { updatedAt: '2026-10-06T08:00:00.000Z' })); const input = await startRename(); input.value = 'Gamma'; submit(input.closest('form')!); await vi.waitFor(() => expect(store.get().cardName).toBe('Gamma')); expect(store.get().saved).toMatchObject({ id: 'b', name: 'Gamma', updatedAt: '2026-10-06T08:00:00.000Z' }); }); }); describe('Meine Karten: Löschen', () => { afterEach(() => { vi.unstubAllGlobals(); }); /** Klickt „Löschen“ an der Kachel (sobald der Knopf wieder bedienbar ist) und wartet auf den Dialog. */ const openDialog = async (id: string) => { await vi.waitFor(() => expect(button(tileFor(id), 'Löschen').disabled).toBe(false)); button(tileFor(id), 'Löschen').click(); await vi.waitFor(() => expect(dialog()?.open).toBe(true)); }; const start = async () => { mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(2)); await vi.waitFor(() => expect(root.querySelectorAll('img')).toHaveLength(2)); }; it('fragt in einem eigenen Dialog nach und löscht erst nach „Löschen“', async () => { mocks.remove.mockResolvedValue(undefined); const confirm = vi.fn(); vi.stubGlobal('confirm', confirm); await start(); await openDialog('b'); expect(dialog()!.querySelector('h2')!.textContent).toBe('Karte „Beta“ löschen?'); expect(mocks.remove).not.toHaveBeenCalled(); button(dialog()!, 'Löschen').click(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); expect(mocks.remove).toHaveBeenCalledWith(expect.objectContaining({ id: 'b', imageFileId: 'img-b', previewFileId: 'prev-b' })); expect(tiles()[0].dataset.card).toBe('a'); expect(dialog()).toBeNull(); expect(confirm).not.toHaveBeenCalled(); expect(revoked).toHaveLength(1); }); it('„Abbrechen“, Esc und Klick auf den Hintergrund löschen nichts', async () => { await start(); await openDialog('b'); button(dialog()!, 'Abbrechen').click(); await vi.waitFor(() => expect(dialog()).toBeNull()); await openDialog('b'); dialog()!.dispatchEvent(new Event('close')); await vi.waitFor(() => expect(dialog()).toBeNull()); await openDialog('b'); dialog()!.dispatchEvent(new MouseEvent('click', { bubbles: true })); await vi.waitFor(() => expect(dialog()).toBeNull()); expect(mocks.remove).not.toHaveBeenCalled(); expect(tiles()).toHaveLength(2); await vi.waitFor(() => expect(button(tileFor('b'), 'Löschen').disabled).toBe(false)); }); it('ein Fehler beim Löschen lässt die Karte stehen und zeigt die Meldung', async () => { mocks.remove.mockRejectedValue(new TypeError('offline')); await start(); await openDialog('b'); button(dialog()!, 'Löschen').click(); await vi.waitFor(() => expect(tileFor('b').querySelector('.card-msg')!.textContent).toBe('Keine Verbindung zum Server.')); expect(tiles()).toHaveLength(2); expect(button(tileFor('b'), 'Löschen').disabled).toBe(false); }); it('eine schon gelöschte Karte (row_not_found) verschwindet ohne Fehlermeldung', async () => { mocks.remove.mockRejectedValue(Object.assign(new Error('weg'), { code: 404, type: 'row_not_found' })); await start(); await openDialog('b'); button(dialog()!, 'Löschen').click(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); }); it('die letzte Karte gelöscht: Leer-Ansicht', async () => { mocks.list.mockResolvedValue([card('a', 'Alpha')]); mocks.remove.mockResolvedValue(undefined); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); await openDialog('a'); button(dialog()!, 'Löschen').click(); await vi.waitFor(() => expect(text()).toContain('Noch keine Karten gespeichert.')); }); it('die im Editor geöffnete Karte wird dort „ungespeichert“, damit Speichern eine neue anlegt', async () => { store.set({ saved: { id: 'b', name: 'Beta', imageFileId: 'img-b', previewFileId: 'prev-b', updatedAt: '2026-10-04T12:00:00.000Z' }, }); mocks.remove.mockResolvedValue(undefined); await start(); await openDialog('b'); button(dialog()!, 'Löschen').click(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); expect(store.get().saved).toBeNull(); }); it('eine andere Karte im Editor bleibt unberührt', async () => { const saved = { id: 'a', name: 'Alpha', imageFileId: 'img-a', previewFileId: 'prev-a', updatedAt: '2026-10-04T12:00:00.000Z' }; store.set({ saved }); mocks.remove.mockResolvedValue(undefined); await start(); await openDialog('b'); button(dialog()!, 'Löschen').click(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); expect(store.get().saved).toEqual(saved); }); }); describe('Meine Karten: Seite wird verlassen', () => { const savedRef = (id: string, name: string) => ({ id, name, imageFileId: `img-${id}`, previewFileId: `prev-${id}`, updatedAt: '2026-10-04T12:00:00.000Z', }); afterEach(() => { vi.unstubAllGlobals(); }); it('ein offener Löschen-Dialog wird geschlossen, nichts wird gelöscht', async () => { mocks.list.mockResolvedValue([card('a', 'Alpha')]); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); button(tileFor('a'), 'Löschen').click(); await vi.waitFor(() => expect(dialog()?.open).toBe(true)); unmount?.(); unmount = null; expect(dialog()).toBeNull(); await new Promise((r) => setTimeout(r, 0)); expect(mocks.remove).not.toHaveBeenCalled(); }); it('Umbenennen, das erst nach dem Verlassen fertig wird, stellt den Editor-Zustand trotzdem richtig', async () => { store.set({ saved: savedRef('a', 'Alpha'), cardName: 'Alpha' }); mocks.list.mockResolvedValue([card('a', 'Alpha')]); let finish!: (c: SavedCard) => void; mocks.rename.mockReturnValue(new Promise((r) => (finish = r))); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); button(tileFor('a'), 'Umbenennen').click(); const input = tileFor('a').querySelector('input')!; input.value = 'Neu'; submit(input.closest('form')!); await vi.waitFor(() => expect(mocks.rename).toHaveBeenCalled()); unmount?.(); unmount = null; finish(card('a', 'Neu', { updatedAt: '2026-10-06T08:00:00.000Z' })); await vi.waitFor(() => expect(store.get().cardName).toBe('Neu')); expect(store.get().saved).toMatchObject({ id: 'a', name: 'Neu' }); }); it('Löschen, das erst nach dem Verlassen fertig wird, löst den Editor trotzdem von der Karte', async () => { store.set({ saved: savedRef('a', 'Alpha'), savedConfig: structuredClone(DEFAULT_CONFIG) }); mocks.list.mockResolvedValue([card('a', 'Alpha')]); let finish!: () => void; mocks.remove.mockReturnValue(new Promise((r) => (finish = r))); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); button(tileFor('a'), 'Löschen').click(); await vi.waitFor(() => expect(dialog()?.open).toBe(true)); button(dialog()!, 'Löschen').click(); await vi.waitFor(() => expect(mocks.remove).toHaveBeenCalled()); unmount?.(); unmount = null; finish(); await vi.waitFor(() => expect(store.get().saved).toBeNull()); expect(store.get().savedConfig).toBeNull(); }); it('eine zweite Vorschau derselben Datei legt keine zweite URL an (sonst bliebe die erste ungenutzt)', async () => { mocks.list.mockResolvedValue([card('a', 'Alpha')]); const pending: Array<(b: Blob) => void> = []; mocks.loadPreview.mockImplementation(() => new Promise((r) => pending.push(r))); mount(); await vi.waitFor(() => expect(pending).toHaveLength(1)); // Abmelden und wieder anmelden lädt die Liste neu, während die erste Vorschau noch unterwegs ist. for (const fn of mocks.authListeners) fn(null); for (const fn of mocks.authListeners) fn(user); await vi.waitFor(() => expect(pending).toHaveLength(2)); pending[0](new Blob(['p'], { type: 'image/webp' })); await vi.waitFor(() => expect(tileFor('a').querySelector('img')).not.toBeNull()); pending[1](new Blob(['p'], { type: 'image/webp' })); await new Promise((r) => setTimeout(r, 0)); expect(URL.createObjectURL).toHaveBeenCalledTimes(1); unmount?.(); unmount = null; expect(revoked).toEqual(['blob:preview-1']); }); }); const published = (id: string, sourceCardId: string, patch: Partial = {}): GalleryCard => ({ id, title: 'Mein Motiv', issuer: '', tags: [], displayName: '', imageFileId: `gi-${id}`, previewFileId: `gp-${id}`, sourceCardId, ownerId: 'u1', hidden: false, reportCount: 0, createdAt: '2026-10-04T12:00:00.000Z', ...patch, }); const labels = (scope: ParentNode) => [...scope.querySelectorAll('.card-buttons button, .card-buttons a')].map((n) => n.textContent); const badge = (id: string) => tileFor(id).querySelector('.card-pub')?.textContent ?? ''; const publishDialogEl = () => document.querySelector('dialog.publish-dialog'); const dlgInput = (name: string) => publishDialogEl()!.querySelector(`input[name="${name}"]`)!; const sendForm = () => publishDialogEl()!.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); const fillDialog = (title = 'Mein Motiv', values: Record = {}) => { dlgInput('title').value = title; for (const [name, value] of Object.entries(values)) dlgInput(name).value = value; dlgInput('consent').checked = true; }; const dlgError = () => publishDialogEl()!.querySelector('[role="alert"]')!.textContent; const openPublish = async (id: string) => { await vi.waitFor(() => expect(button(tileFor(id), 'Veröffentlichen').disabled).toBe(false)); button(tileFor(id), 'Veröffentlichen').click(); await vi.waitFor(() => expect(publishDialogEl()?.open).toBe(true)); }; describe('Meine Karten: Veröffentlichung (Status an der Kachel)', () => { const pubMap = (...cards: GalleryCard[]) => new Map(cards.map((c) => [c.sourceCardId, c])); const view = (id: string) => tileFor(id).querySelector('a[data-action="view"]'); const mountWith = async (publications: Map = new Map()) => { mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]); mocks.myPublications.mockResolvedValue(publications); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(2)); await vi.waitFor(() => expect(button(tileFor('a'), 'Löschen')).toBeTruthy()); await vi.waitFor(() => expect(tileFor('a').querySelector('[data-action="publish"], [data-action="withdraw"]')).not.toBeNull()); }; it('lädt den Status einmal je Seitenaufbau (für die angemeldete Nutzer-ID), nicht abgemeldet', async () => { await mountWith(); expect(mocks.myPublications).toHaveBeenCalledTimes(1); expect(mocks.myPublications).toHaveBeenCalledWith('u1'); unmount?.(); unmount = null; mocks.myPublications.mockClear(); mocks.getCurrentUser.mockResolvedValue(null); mount(); await vi.waitFor(() => expect(text()).toContain('Melde dich an')); expect(mocks.myPublications).not.toHaveBeenCalled(); }); it('nicht veröffentlicht: „Veröffentlichen“, kein Badge', async () => { await mountWith(); expect(labels(tileFor('a'))).toEqual(['Öffnen', 'Herunterladen', 'Umbenennen', 'Veröffentlichen', 'Löschen']); expect(badge('a')).toBe(''); expect(view('a')).toBeNull(); }); it('veröffentlicht: Badge „Veröffentlicht“, „Ansehen“ (/community/) und „Zurückziehen“ statt „Veröffentlichen“', async () => { await mountWith(pubMap(published('g1', 'a'))); expect(badge('a')).toBe('Veröffentlicht'); expect(labels(tileFor('a'))).toEqual(['Öffnen', 'Herunterladen', 'Umbenennen', 'Ansehen', 'Zurückziehen', 'Löschen']); expect(view('a')!.getAttribute('href')).toBe('/community/g1'); expect(view('a')!.hasAttribute('data-link')).toBe(true); // Die andere Kachel bleibt unberührt. expect(badge('b')).toBe(''); expect(labels(tileFor('b'))).toContain('Veröffentlichen'); }); it('ausgeblendet (gemeldet): Badge „Ausgeblendet (gemeldet)“ und „Zurückziehen“, aber kein „Ansehen“ und kein „Veröffentlichen“', async () => { await mountWith(pubMap(published('g1', 'a', { hidden: true, reportCount: 3 }))); expect(badge('a')).toBe('Ausgeblendet (gemeldet)'); expect(labels(tileFor('a'))).toEqual(['Öffnen', 'Herunterladen', 'Umbenennen', 'Zurückziehen', 'Löschen']); expect(view('a')).toBeNull(); }); it('solange der Status lädt, gibt es weder „Veröffentlichen“ noch „Zurückziehen“; danach erscheint der Stand', async () => { mocks.list.mockResolvedValue([card('a', 'Alpha')]); let finish!: (m: Map) => void; mocks.myPublications.mockReturnValue(new Promise((r) => (finish = r))); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); expect(labels(tileFor('a'))).toEqual(['Öffnen', 'Herunterladen', 'Umbenennen', 'Löschen']); finish(pubMap(published('g1', 'a'))); await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht')); }); it('Status lässt sich nicht laden: Hinweis mit „Nochmal versuchen“, „Veröffentlichen“ bleibt möglich (409 fängt Doppeltes ab)', async () => { mocks.list.mockResolvedValue([card('a', 'Alpha')]); mocks.myPublications.mockRejectedValueOnce(new TypeError('offline')); mount(); await vi.waitFor(() => expect(text()).toContain('Der Veröffentlichungsstatus konnte nicht geladen werden.')); expect(labels(tileFor('a'))).toContain('Veröffentlichen'); mocks.myPublications.mockResolvedValue(pubMap(published('g1', 'a'))); button(root, 'Nochmal versuchen').click(); await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht')); expect(text()).not.toContain('konnte nicht geladen werden'); }); it('Abmelden räumt den Status weg, nach dem Anmelden wird er neu geholt', async () => { await mountWith(pubMap(published('g1', 'a'))); for (const fn of mocks.authListeners) fn(null); expect(tiles()).toHaveLength(0); mocks.myPublications.mockResolvedValue(new Map()); for (const fn of mocks.authListeners) fn(user); await vi.waitFor(() => expect(tiles()).toHaveLength(2)); await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Veröffentlichen')); expect(badge('a')).toBe(''); expect(mocks.myPublications).toHaveBeenCalledTimes(2); }); it('der Status überlebt Umbenennen (Liste wird neu aufgebaut) ohne neue Anfrage', async () => { mocks.rename.mockImplementation(async (id: string, name: string) => card(id, name, { updatedAt: '2026-10-06T08:00:00.000Z' })); await mountWith(pubMap(published('g1', 'a'))); button(tileFor('a'), 'Umbenennen').click(); const input = tileFor('a').querySelector('input')!; input.value = 'Anders'; submit(input.closest('form')!); await vi.waitFor(() => expect(tileFor('a').querySelector('.card-name')!.textContent).toBe('Anders')); expect(badge('a')).toBe('Veröffentlicht'); expect(mocks.myPublications).toHaveBeenCalledTimes(1); }); it('Löschen einer veröffentlichten Karte weist darauf hin, dass die Veröffentlichung bleibt', async () => { await mountWith(pubMap(published('g1', 'a'))); button(tileFor('a'), 'Löschen').click(); await vi.waitFor(() => expect(dialog()?.open).toBe(true)); expect(dialog()!.textContent).toContain('bleibt in der Community-Galerie'); button(dialog()!, 'Abbrechen').click(); await vi.waitFor(() => expect(dialog()).toBeNull()); button(tileFor('b'), 'Löschen').click(); await vi.waitFor(() => expect(dialog()?.open).toBe(true)); expect(dialog()!.textContent).not.toContain('Community-Galerie'); }); }); describe('Meine Karten: Veröffentlichen', () => { const start = async () => { mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(2)); await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Veröffentlichen')); }; it('„Veröffentlichen“ öffnet den Dialog: leere Felder, nie die E-Mail im Anzeigenamen', async () => { await start(); await openPublish('a'); expect(dlgInput('title').value).toBe(''); expect(dlgInput('displayName').value).toBe(''); expect(dlgInput('displayName').placeholder).toBe('Anonym'); expect(publishDialogEl()!.textContent).not.toContain('a@b.de'); expect(publishDialogEl()!.textContent).toContain('„Alpha“'); expect(mocks.publishSavedCard).not.toHaveBeenCalled(); }); it('Abbrechen veröffentlicht nichts, Fokus zurück auf „Veröffentlichen“', async () => { await start(); await openPublish('a'); button(publishDialogEl()!, 'Abbrechen').click(); await vi.waitFor(() => expect(publishDialogEl()).toBeNull()); expect(mocks.publishSavedCard).not.toHaveBeenCalled(); await vi.waitFor(() => expect(document.activeElement).toBe(button(tileFor('a'), 'Veröffentlichen'))); }); it('ohne Pflicht-Checkbox wird nichts veröffentlicht', async () => { await start(); await openPublish('a'); dlgInput('title').value = 'Mein Motiv'; sendForm(); expect(dlgError()).toBe('Bitte bestätige die Erklärung zum Bild.'); expect(mocks.publishSavedCard).not.toHaveBeenCalled(); }); it('Erfolg: Badge „Veröffentlicht“ und „Ansehen“ erscheinen sofort, ohne die Liste neu zu laden', async () => { mocks.publishSavedCard.mockResolvedValue({ id: 'g9' }); await start(); await openPublish('a'); fillDialog('Mein Motiv', { issuer: 'Beispielbank', tags: 'Visa, Gold', displayName: 'Kenso' }); sendForm(); await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht')); expect(mocks.publishSavedCard).toHaveBeenCalledWith( expect.objectContaining({ id: 'a', name: 'Alpha' }), { title: 'Mein Motiv', issuer: 'Beispielbank', tags: 'Visa, Gold', displayName: 'Kenso' }, ); expect(publishDialogEl()).toBeNull(); const link = tileFor('a').querySelector('a[data-action="view"]')!; expect(link.textContent).toBe('Ansehen'); expect(link.getAttribute('href')).toBe('/community/g9'); expect(labels(tileFor('a'))).toEqual(['Öffnen', 'Herunterladen', 'Umbenennen', 'Ansehen', 'Zurückziehen', 'Löschen']); // Alle Knöpfe der Kachel sind wieder bedienbar, die andere Kachel ist unberührt. expect(button(tileFor('a'), 'Zurückziehen').disabled).toBe(false); expect(labels(tileFor('b'))).toContain('Veröffentlichen'); expect(mocks.list).toHaveBeenCalledTimes(1); expect(mocks.myPublications).toHaveBeenCalledTimes(1); // Der Fokus geht auf „Ansehen“ (der auslösende Knopf ist weg). expect(document.activeElement).toBe(link); }); it('während des Veröffentlichens sind die Kachel-Knöpfe gesperrt und der Dialog lässt sich nicht schließen', async () => { let finish!: (r: { id: string }) => void; mocks.publishSavedCard.mockReturnValue(new Promise((r) => (finish = r))); await start(); await openPublish('a'); fillDialog(); sendForm(); expect(button(publishDialogEl()!, 'Veröffentlichen').disabled).toBe(true); expect(button(tileFor('a'), 'Löschen').disabled).toBe(true); expect(tileFor('a').getAttribute('aria-busy')).toBe('true'); finish({ id: 'g9' }); await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht')); expect(tileFor('a').hasAttribute('aria-busy')).toBe(false); }); it('Fehler (400): Text im Dialog, kein Status geändert, keine Nachprüfung, neuer Versuch möglich', async () => { mocks.publishSavedCard.mockRejectedValueOnce(new ApiError(400, 'Der Titel enthält ungültige Zeichen.')); await start(); await openPublish('a'); fillDialog(); sendForm(); await vi.waitFor(() => expect(dlgError()).toBe('Veröffentlichen fehlgeschlagen – Der Titel enthält ungültige Zeichen.')); expect(publishDialogEl()?.open).toBe(true); expect(badge('a')).toBe(''); expect(mocks.myPublications).toHaveBeenCalledTimes(1); mocks.publishSavedCard.mockResolvedValueOnce({ id: 'g9' }); sendForm(); await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht')); }); it('409: „Diese Karte ist schon veröffentlicht.“, der Status wird neu geholt und zeigt „Veröffentlicht“', async () => { mocks.publishSavedCard.mockRejectedValue(new ApiError(409, 'Diese Karte ist schon veröffentlicht.')); await start(); await openPublish('a'); fillDialog(); mocks.myPublications.mockResolvedValue(new Map([['a', published('g1', 'a')]])); sendForm(); await vi.waitFor(() => expect(publishDialogEl()!.textContent).toContain('Diese Karte ist schon veröffentlicht.')); await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht')); expect(mocks.myPublications).toHaveBeenCalledTimes(2); button(publishDialogEl()!, 'Schließen').click(); await vi.waitFor(() => expect(publishDialogEl()).toBeNull()); expect(tileFor('a').querySelector('a[data-action="view"]')!.getAttribute('href')).toBe('/community/g1'); expect(mocks.publishSavedCard).toHaveBeenCalledOnce(); }); describe('Ausgang offen (503, 429, 408, 5xx): Nachprüfung vor der Fehlermeldung', () => { it('war die Karte doch veröffentlicht: Erfolgszustand statt Fehler', async () => { mocks.publishSavedCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.')); await start(); await openPublish('a'); fillDialog(); mocks.myPublications.mockResolvedValue(new Map([['a', published('g1', 'a')]])); sendForm(); await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht')); expect(publishDialogEl()).toBeNull(); expect(tileFor('a').querySelector('a[data-action="view"]')!.getAttribute('href')).toBe('/community/g1'); expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe(''); expect(mocks.myPublications).toHaveBeenCalledTimes(2); }); it.each([408, 429, 500, 504])('auch bei %i wird nachgeprüft', async (code) => { mocks.publishSavedCard.mockRejectedValue(new ApiError(code, 'Offen.')); await start(); await openPublish('a'); fillDialog(); mocks.myPublications.mockResolvedValue(new Map([['a', published('g1', 'a')]])); sendForm(); await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht')); }); it('war sie es nicht: Fehlermeldung im Dialog, Status unverändert', async () => { mocks.publishSavedCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.')); await start(); await openPublish('a'); fillDialog(); sendForm(); await vi.waitFor(() => expect(dlgError()).toBe('Veröffentlichen fehlgeschlagen – Der Dienst ist gerade nicht erreichbar.'), ); expect(mocks.myPublications).toHaveBeenCalledTimes(2); expect(badge('a')).toBe(''); expect(publishDialogEl()?.open).toBe(true); }); it('scheitert auch die Nachprüfung: die ursprüngliche Meldung bleibt', async () => { mocks.publishSavedCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.')); await start(); await openPublish('a'); fillDialog(); mocks.myPublications.mockRejectedValue(new TypeError('offline')); sendForm(); await vi.waitFor(() => expect(dlgError()).toBe('Veröffentlichen fehlgeschlagen – Der Dienst ist gerade nicht erreichbar.'), ); expect(badge('a')).toBe(''); }); it('die Nachprüfung findet nur die Veröffentlichung dieser Karte (eine andere zählt nicht)', async () => { mocks.publishSavedCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.')); await start(); await openPublish('a'); fillDialog(); mocks.myPublications.mockResolvedValue(new Map([['b', published('g2', 'b')]])); sendForm(); await vi.waitFor(() => expect(dlgError()).toContain('Der Dienst ist gerade nicht erreichbar.')); expect(badge('a')).toBe(''); expect(badge('b')).toBe('Veröffentlicht'); }); }); it('abgelaufene Sitzung (401): Anmelden, dann wird veröffentlicht', async () => { mocks.publishSavedCard .mockRejectedValueOnce(Object.assign(new Error('Unauthorized'), { code: 401 })) .mockResolvedValueOnce({ id: 'g9' }); await start(); await openPublish('a'); fillDialog(); sendForm(); await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht')); expect(mocks.requireLogin).toHaveBeenCalledOnce(); expect(mocks.publishSavedCard).toHaveBeenCalledTimes(2); }); it('Anmeldung abgebrochen: Hinweis im Dialog, nichts veröffentlicht', async () => { mocks.requireLogin.mockResolvedValue(false); mocks.publishSavedCard.mockRejectedValue(Object.assign(new Error('Unauthorized'), { code: 401 })); await start(); await openPublish('a'); fillDialog(); sendForm(); await vi.waitFor(() => expect(dlgError()).toBe('Bitte melde dich an, um fortzufahren.')); expect(badge('a')).toBe(''); }); it('die Seite wird verlassen: der Dialog geht zu, eine spätere Antwort ändert nichts mehr', async () => { let finish!: (r: { id: string }) => void; mocks.publishSavedCard.mockReturnValue(new Promise((r) => (finish = r))); await start(); await openPublish('a'); fillDialog(); sendForm(); unmount?.(); unmount = null; expect(publishDialogEl()).toBeNull(); finish({ id: 'g9' }); await new Promise((r) => setTimeout(r, 0)); expect(root.querySelector('.card-badge')).toBeNull(); }); it('die Seite wird verlassen, solange der Dialog offen ist: er geht zu, nichts wird veröffentlicht', async () => { await start(); await openPublish('a'); unmount?.(); unmount = null; expect(publishDialogEl()).toBeNull(); expect(mocks.publishSavedCard).not.toHaveBeenCalled(); }); it('Titel und Namen erscheinen als Text, nie als HTML', async () => { mocks.list.mockResolvedValue([card('a', '')]); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(1)); await openPublish('a'); expect(publishDialogEl()!.querySelector('img')).toBeNull(); expect(publishDialogEl()!.textContent).toContain(''); }); }); describe('Meine Karten: Zurückziehen', () => { const start = async (patch: Partial = {}) => { mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]); mocks.myPublications.mockResolvedValue(new Map([['a', published('g1', 'a', patch)]])); mount(); await vi.waitFor(() => expect(tiles()).toHaveLength(2)); await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Zurückziehen')); }; const openConfirm = async () => { button(tileFor('a'), 'Zurückziehen').click(); await vi.waitFor(() => expect(dialog()?.open).toBe(true)); }; it('fragt nach, zieht dann die Veröffentlichung (Galerie-ID) zurück; die Kachel zeigt wieder „Veröffentlichen“', async () => { mocks.withdrawCard.mockResolvedValue(undefined); await start(); await openConfirm(); expect(dialog()!.querySelector('h2')!.textContent).toBe('Karte „Mein Motiv“ zurückziehen?'); expect(dialog()!.textContent).toContain('Deine private Karte bleibt erhalten.'); expect(mocks.withdrawCard).not.toHaveBeenCalled(); button(dialog()!, 'Zurückziehen').click(); await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Veröffentlichen')); expect(mocks.withdrawCard).toHaveBeenCalledWith('g1'); expect(badge('a')).toBe(''); expect(labels(tileFor('a'))).toEqual(['Öffnen', 'Herunterladen', 'Umbenennen', 'Veröffentlichen', 'Löschen']); expect(dialog()).toBeNull(); expect(mocks.list).toHaveBeenCalledTimes(1); expect(mocks.myPublications).toHaveBeenCalledTimes(1); // Fokus auf dem neuen „Veröffentlichen“-Knopf. expect(document.activeElement).toBe(button(tileFor('a'), 'Veröffentlichen')); }); it('ausgeblendete Kacheln behalten „Zurückziehen“: danach lässt sich neu veröffentlichen', async () => { mocks.withdrawCard.mockResolvedValue(undefined); await start({ hidden: true, reportCount: 3 }); expect(badge('a')).toBe('Ausgeblendet (gemeldet)'); await openConfirm(); button(dialog()!, 'Zurückziehen').click(); await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Veröffentlichen')); expect(mocks.withdrawCard).toHaveBeenCalledWith('g1'); expect(badge('a')).toBe(''); }); it('„Abbrechen“ zieht nichts zurück', async () => { await start(); await openConfirm(); button(dialog()!, 'Abbrechen').click(); await vi.waitFor(() => expect(dialog()).toBeNull()); expect(mocks.withdrawCard).not.toHaveBeenCalled(); expect(badge('a')).toBe('Veröffentlicht'); await vi.waitFor(() => expect(button(tileFor('a'), 'Zurückziehen').disabled).toBe(false)); }); it('Fehler: Text des Dienstes an der Kachel, Status bleibt', async () => { mocks.withdrawCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.')); await start(); await openConfirm(); button(dialog()!, 'Zurückziehen').click(); await vi.waitFor(() => expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('Der Dienst ist gerade nicht erreichbar.'), ); expect(badge('a')).toBe('Veröffentlicht'); expect(button(tileFor('a'), 'Zurückziehen').disabled).toBe(false); }); it('die Veröffentlichung gibt es schon nicht mehr (404): Ziel erreicht, die Kachel zeigt „Veröffentlichen“', async () => { mocks.withdrawCard.mockRejectedValue(new ApiError(404, 'Diese Karte gibt es nicht.')); await start(); await openConfirm(); button(dialog()!, 'Zurückziehen').click(); await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Veröffentlichen')); expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe(''); }); it('abgelaufene Sitzung (401): Anmelden, dann wird zurückgezogen', async () => { mocks.withdrawCard .mockRejectedValueOnce(new ApiError(401, 'Bitte melde dich an.')) .mockResolvedValueOnce(undefined); await start(); await openConfirm(); button(dialog()!, 'Zurückziehen').click(); await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Veröffentlichen')); expect(mocks.requireLogin).toHaveBeenCalledOnce(); expect(mocks.withdrawCard).toHaveBeenCalledTimes(2); }); it('Seite verlassen mit offener Rückfrage: nichts wird zurückgezogen', async () => { await start(); await openConfirm(); unmount?.(); unmount = null; expect(dialog()).toBeNull(); await new Promise((r) => setTimeout(r, 0)); expect(mocks.withdrawCard).not.toHaveBeenCalled(); }); });