import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { User } from '../account/auth'; import { ApiError, type ReportReason } from '../community/api'; import type { CheckedImage, GalleryCard, GalleryReport } from '../community/gallery'; import { germanLeft } from '../i18n/english-check'; import { setLang } from '../i18n/i18n'; // gallery.ts, api.ts und auth.ts hängen am Appwrite-Client; die Seite bekommt alles über ihre Abhängigkeiten. vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '', })); import { LoginCancelled } from '../account/with-login'; import { mountCommunityPage, type CommunityDeps } from './community-page'; const card = (patch: Partial = {}): GalleryCard => ({ id: 'g1', title: 'Mitternachtsblau', issuer: 'Beispielbank', tags: ['dunkel', 'gold'], displayName: 'Kenso', imageFileId: 'img1', previewFileId: 'pre1', sourceCardId: 'src1', ownerId: 'u1', hidden: false, reportCount: 0, createdAt: '2026-10-04T12:00:00.000Z', ...patch, }); const report = (n: number, reason: string): GalleryReport => ({ id: `r${n}`, galleryId: 'g1', reason, createdAt: '2026-10-05T12:00:00.000Z', }); function deferred() { let resolve!: (value: T) => void; const promise = new Promise((res) => { resolve = res; }); return { promise, resolve }; } const settle = async () => { for (let i = 0; i < 40; i++) await Promise.resolve(); }; const blob = () => new Blob(['png'], { type: 'image/png' }); /** Ein geprüftes Bild, wie es loadGalleryImageChecked liefert: Blob plus fertige Object-URL. */ const image = (b: Blob = blob()): CheckedImage => ({ blob: b, url: `blob:detail-${++urlCounter}` }); const admin: User = { $id: 'a1', email: 'kenso@example.com', labels: ['admin'] }; const owner: User = { $id: 'u1', email: 'u@example.com', labels: [] }; const stranger: User = { $id: 'u2', email: 'x@example.com', labels: [] }; const UNAVAILABLE = 'Diese Karte ist nicht (mehr) verfügbar.'; let root: HTMLElement; let unmount: (() => void) | null; let user: User | null; let authListeners: Set<(u: User | null) => void>; let urlCounter: number; const revoked: string[] = []; const getCard = vi.fn<(id: string) => Promise>(); const listReports = vi.fn<(id: string) => Promise>(); const loadImage = vi.fn<(c: GalleryCard, size: 'full' | 'preview') => Promise>(); const reportCard = vi.fn<(id: string, reason: ReportReason) => Promise<{ schonGemeldet: boolean }>>(); const withdrawCard = vi.fn<(id: string) => Promise>(); const setVisibility = vi.fn<(id: string, visible: boolean) => Promise>(); const navigate = vi.fn<(path: string) => void>(); const saveBlobAs = vi.fn<(b: Blob, name: string) => void>(); const withLogin = vi.fn((fn: () => Promise): Promise => fn()); const getUser = vi.fn<() => Promise>(); /** Die Benutzernamen (eine Abfrage für die Konten der Seite) und die Hörer des eigenen Umbenennens. */ const usernamesFor = vi.fn(); let nameListeners: Set<(userId: string, username: string) => void>; const emitName = (userId: string, username: string) => { for (const fn of [...nameListeners]) fn(userId, username); }; function deps(): CommunityDeps { return { getCard, listReports, loadImage, reportCard, withdrawCard, setVisibility, currentUser: () => user, getUser, usernamesFor, onUsernameChange: (fn) => { nameListeners.add(fn); return () => nameListeners.delete(fn); }, onAuthChange: (fn) => { authListeners.add(fn); return () => authListeners.delete(fn); }, navigate, saveBlobAs, withLogin: (fn: () => Promise) => withLogin(fn) as Promise, }; } async function mount(id = 'g1'): Promise { unmount = mountCommunityPage(root, id, deps()); await settle(); } function login(next: User | null): void { user = next; for (const fn of [...authListeners]) fn(next); } const q = (sel: string) => root.querySelector(sel); const qa = (sel: string) => [...root.querySelectorAll(sel)]; const buttonByText = (text: string) => qa('button').find((b) => b.textContent === text) as HTMLButtonElement | undefined; const buttonTexts = () => qa('button').map((b) => b.textContent); const confirmEl = () => document.querySelector('dialog.confirm-dialog'); const confirmButton = (label: string) => [...confirmEl()!.querySelectorAll('button')].find((b) => b.textContent === label) as HTMLButtonElement; const reportEl = () => document.querySelector('dialog.report-dialog'); const errorText = () => q('[role="alert"]')?.textContent ?? ''; const statusText = () => q('.detail-info [role="status"]')?.textContent ?? ''; beforeEach(() => { for (const fn of [getCard, listReports, loadImage, reportCard, withdrawCard, setVisibility, navigate, saveBlobAs, withLogin, getUser, usernamesFor]) { fn.mockReset(); } getCard.mockResolvedValue(card()); listReports.mockResolvedValue([]); loadImage.mockImplementation(async () => image()); withdrawCard.mockResolvedValue(undefined); setVisibility.mockResolvedValue(undefined); getUser.mockResolvedValue(null); usernamesFor.mockResolvedValue(new Map()); // das Konto des Autors hat kein Profil: es gilt der alte Anzeigename user = null; authListeners = new Set(); nameListeners = new Set(); urlCounter = 0; revoked.length = 0; URL.revokeObjectURL = vi.fn((url: string) => { revoked.push(url); }); document.body.innerHTML = '
'; root = document.getElementById('app-root') as HTMLElement; unmount = null; document.title = 'AirCard-IOS'; }); afterEach(() => { unmount?.(); document.body.innerHTML = ''; }); describe('Detailseite: nicht (mehr) verfügbar', () => { it('Gast, Karte nicht vorhanden oder ausgeblendet (404): Text, Link zur Galerie, kein Bild, keine Knöpfe', async () => { getCard.mockResolvedValue(null); await mount('g1'); expect(getCard).toHaveBeenCalledWith('g1'); expect(q('h1')?.textContent).toBe(UNAVAILABLE); const link = q('a[href="/#community"]'); expect(link?.textContent).toBe('Zur Galerie'); expect(link?.hasAttribute('data-link')).toBe(true); expect(q('img')).toBeNull(); expect(buttonTexts()).toEqual([]); expect(loadImage).not.toHaveBeenCalled(); expect(listReports).not.toHaveBeenCalled(); expect(document.title).toBe('Community – AirCard-IOS'); }); it('eine ausgeblendete Karte für jemanden, der weder Autor noch Admin ist (Rechte-Fehler): ebenso, ohne Bild', async () => { user = stranger; getCard.mockResolvedValue(card({ hidden: true, reportCount: 3 })); await mount(); expect(q('h1')?.textContent).toBe(UNAVAILABLE); expect(q('img')).toBeNull(); expect(loadImage).not.toHaveBeenCalled(); expect(root.textContent).not.toContain('Mitternachtsblau'); }); it('Direktaufruf des Autors: der Anmeldestatus ist noch unbekannt, der Server hat die ausgeblendete Karte aber geliefert', async () => { getCard.mockResolvedValue(card({ hidden: true })); getUser.mockImplementation(async () => { user = owner; return owner; }); await mount(); expect(getUser).toHaveBeenCalledTimes(1); expect(q('h1')?.textContent).toBe('Mitternachtsblau'); expect(q('.detail-hidden')?.textContent).toContain('Ausgeblendet'); expect(buttonTexts()).toContain('Zurückziehen'); }); it('eine sichtbare Karte braucht keine Anmeldeabfrage', async () => { await mount(); expect(getUser).not.toHaveBeenCalled(); }); it('Ladefehler (Netz): Hinweis und „Nochmal versuchen“, danach erscheint die Karte', async () => { getCard.mockRejectedValueOnce(new TypeError('offline')); await mount(); expect(errorText()).toBe('Die Karte konnte gerade nicht geladen werden.'); expect(q('img')).toBeNull(); buttonByText('Nochmal versuchen')!.click(); await settle(); expect(getCard).toHaveBeenCalledTimes(2); expect(q('h1')?.textContent).toBe('Mitternachtsblau'); expect(errorText()).toBe(''); }); it('während des Ladens steht ein Hinweis', async () => { getCard.mockReturnValue(new Promise(() => {})); await mount(); expect(q('[role="status"]')?.textContent).toBe('Karte wird geladen …'); }); it('meldet sich der Autor einer ausgeblendeten Karte an, wird sie neu geladen und gezeigt', async () => { getCard.mockResolvedValueOnce(null); await mount(); expect(q('h1')?.textContent).toBe(UNAVAILABLE); getCard.mockResolvedValueOnce(card({ hidden: true })); login(owner); await settle(); expect(getCard).toHaveBeenCalledTimes(2); expect(q('h1')?.textContent).toBe('Mitternachtsblau'); expect(q('.detail-hidden')?.textContent).toContain('Ausgeblendet'); }); }); describe('Detailseite: Karte', () => { it('zeigt Titel, Herausgeber, „von …“, Datum, Tags und das große Bild; setzt den Seitentitel', async () => { await mount(); expect(q('h1')?.textContent).toBe('Mitternachtsblau'); expect(q('.detail-issuer')?.textContent).toBe('Beispielbank'); expect(q('.detail-meta')?.textContent).toBe('von Kenso · 04.10.2026'); const tags = qa('.detail-tag'); expect(tags.map((t) => t.textContent)).toEqual(['dunkel', 'gold']); expect(tags[1].getAttribute('href')).toBe('/?tag=gold#community'); expect(tags[1].hasAttribute('data-link')).toBe(true); expect(loadImage).toHaveBeenCalledWith(expect.objectContaining({ id: 'g1' }), 'full'); const img = q('.detail-card img'); expect(img?.getAttribute('src')).toBe('blob:detail-1'); expect(img?.getAttribute('alt')).toBe('Community-Karte „Mitternachtsblau“'); expect(document.title).toBe('Mitternachtsblau – AirCard-IOS'); expect(q('a[href="/#community"]')?.textContent).toContain('Zur Galerie'); }); it('ohne Anzeigenamen „von Anonym“; ohne Herausgeber und Tags fehlen die Zeilen', async () => { getCard.mockResolvedValue(card({ displayName: '', issuer: '', tags: [] })); await mount(); expect(q('.detail-meta')?.textContent).toBe('von Anonym · 04.10.2026'); expect(q('.detail-issuer')).toBeNull(); expect(q('.detail-tags')).toBeNull(); }); it('alles Nutzerseitige bleibt Text (kein HTML)', async () => { getCard.mockResolvedValue( card({ title: 'Fett', issuer: 'Bank', displayName: '', tags: [''], }), ); await mount(); expect(q('h1')?.textContent).toBe('Fett'); expect(q('.detail-issuer')?.textContent).toBe('Bank'); expect(q('.detail-meta')?.textContent).toContain(''); expect(q('.detail-tag')?.textContent).toBe(''); expect(root.querySelector('b, i, script')).toBeNull(); expect(qa('img')).toHaveLength(1); // nur das Kartenbild }); it('der Gast sieht Herunterladen, Als Vorlage gestalten, Melden – sonst nichts', async () => { await mount(); expect(buttonTexts()).toEqual(['Herunterladen', 'Als Vorlage gestalten', 'Melden']); expect(listReports).not.toHaveBeenCalled(); expect(q('.detail-admin')).toBeNull(); }); it('Bild lädt nicht: Hinweis mit „Nochmal versuchen“; Herunterladen holt das Bild selbst nach', async () => { loadImage.mockRejectedValueOnce(new TypeError('offline')); await mount(); expect(q('.detail-card')?.textContent).toContain('Das Bild konnte nicht geladen werden.'); expect(q('.detail-card img')).toBeNull(); // Herunterladen versucht es erneut und gelingt buttonByText('Herunterladen')!.click(); await settle(); expect(loadImage).toHaveBeenCalledTimes(2); expect(saveBlobAs).toHaveBeenCalledTimes(1); }); it('ein von der Pixelprüfung abgelehntes Bild (zu groß, nicht dekodierbar) zeigt den Hinweis statt eines Bildes', async () => { // so lehnt loadGalleryImageChecked ab: es wirft, noch bevor ein entsteht loadImage.mockRejectedValue(new Error('Das Bild konnte nicht geladen werden.')); await mount(); expect(q('.detail-card')?.textContent).toContain('Das Bild konnte nicht geladen werden.'); expect(q('.detail-card img')).toBeNull(); expect(q('h1')?.textContent).toBe('Mitternachtsblau'); // die Angaben und Knöpfe (Melden!) bleiben bedienbar expect(buttonByText('Melden')).toBeDefined(); // auch Herunterladen bekommt das Bild nicht buttonByText('Herunterladen')!.click(); await settle(); expect(saveBlobAs).not.toHaveBeenCalled(); expect(errorText()).toBe('Das Bild konnte nicht geladen werden.'); }); it('das Bild zum Herunterladen kommt aus der Prüfung; eine dabei angelegte URL wird sofort freigegeben', async () => { await mount(); q('.detail-card img')!.dispatchEvent(new Event('error')); // kein Bild im Speicher revoked.length = 0; buttonByText('Herunterladen')!.click(); await settle(); expect(saveBlobAs).toHaveBeenCalledTimes(1); expect(revoked).toEqual(['blob:detail-2']); }); it('„Nochmal versuchen“ beim Bild lädt es erneut', async () => { loadImage.mockRejectedValueOnce(new TypeError('offline')); await mount(); buttonByText('Nochmal versuchen')!.click(); await settle(); expect(loadImage).toHaveBeenCalledTimes(2); expect(q('.detail-card img')?.getAttribute('src')).toBe('blob:detail-1'); }); it('ein Bild, das der Browser nicht darstellen kann, zeigt denselben Hinweis und gibt die URL frei', async () => { await mount(); q('.detail-card img')!.dispatchEvent(new Event('error')); expect(q('.detail-card')?.textContent).toContain('Das Bild konnte nicht geladen werden.'); expect(revoked).toEqual(['blob:detail-1']); }); it('Object-URL wird beim Verlassen freigegeben; späte Antworten nach dem Verlassen bewirken nichts', async () => { await mount(); unmount?.(); unmount = null; expect(revoked).toEqual(['blob:detail-1']); // Karte kommt erst nach dem Verlassen let finishCard!: (c: GalleryCard) => void; getCard.mockReturnValue(new Promise((r) => (finishCard = r))); root.replaceChildren(); unmount = mountCommunityPage(root, 'g1', deps()); unmount(); unmount = null; finishCard(card()); await settle(); expect(loadImage).toHaveBeenCalledTimes(1); // nur vom ersten Aufruf expect(q('h1')).toBeNull(); // die Karte wird nicht mehr aufgebaut // Bild kommt erst nach dem Verlassen let finishImage!: (i: CheckedImage) => void; getCard.mockResolvedValue(card()); loadImage.mockReturnValue(new Promise((r) => (finishImage = r))); unmount = mountCommunityPage(root, 'g1', deps()); await settle(); unmount(); unmount = null; revoked.length = 0; finishImage({ blob: blob(), url: 'blob:detail-late' }); await settle(); expect(revoked).toEqual(['blob:detail-late']); // die verspätete URL wird sofort freigegeben expect(q('img')).toBeNull(); }); }); describe('Detailseite: „von @name“', () => { const meta = () => q('.detail-meta')?.textContent; it('fragt den Namen des Autors einmal je geladener Karte, mit seiner Konto-ID', async () => { await mount(); expect(usernamesFor).toHaveBeenCalledExactlyOnceWith(['u1']); }); it('zeigt „von @name“ und das Datum; ein Profil geht dem alten Anzeigenamen vor', async () => { usernamesFor.mockResolvedValue(new Map([['u1', 'Kenso.K']])); await mount(); // die Karte trägt noch den alten Anzeigenamen „Kenso“ expect(meta()).toBe('von @Kenso.K · 04.10.2026'); }); it('ohne Profil: der alte Anzeigename (ohne @), sonst „von Anonym“', async () => { await mount(); expect(meta()).toBe('von Kenso · 04.10.2026'); unmount?.(); getCard.mockResolvedValue(card({ displayName: '' })); await mount(); expect(meta()).toBe('von Anonym · 04.10.2026'); }); it('die Karte steht, ohne auf den Namen zu warten; er erscheint, sobald er da ist', async () => { const lookup = deferred>(); usernamesFor.mockReturnValue(lookup.promise); await mount(); expect(q('h1')?.textContent).toBe('Mitternachtsblau'); expect(q('.detail-card img')).not.toBeNull(); expect(meta()).toBe('von Kenso · 04.10.2026'); lookup.resolve(new Map([['u1', 'Kenso.K']])); await settle(); expect(meta()).toBe('von @Kenso.K · 04.10.2026'); }); it('schlägt die Abfrage fehl, bleibt es beim Ersatz: kein Fehlertext, die Seite geht weiter', async () => { usernamesFor.mockRejectedValue(new TypeError('Failed to fetch')); await mount(); expect(meta()).toBe('von Kenso · 04.10.2026'); expect(errorText()).toBe(''); expect(buttonTexts()).toEqual(['Herunterladen', 'Als Vorlage gestalten', 'Melden']); }); it('eine Karte ohne Konto-ID (Altbestand): keine Abfrage, der Ersatz', async () => { getCard.mockResolvedValue(card({ ownerId: '', displayName: '' })); await mount(); expect(usernamesFor).not.toHaveBeenCalled(); expect(meta()).toBe('von Anonym · 04.10.2026'); }); it('der Name erscheint als Text, nie als HTML', async () => { usernamesFor.mockResolvedValue(new Map([['u1', 'x']])); await mount(); expect(meta()).toBe('von @x · 04.10.2026'); expect(q('.detail-meta b')).toBeNull(); }); it('eigenes Umbenennen: die Detailseite zeigt sofort den neuen Namen; ein anderes Konto ändert nichts', async () => { usernamesFor.mockResolvedValue(new Map([['u1', 'Alt']])); await mount(); expect(meta()).toBe('von @Alt · 04.10.2026'); emitName('u2', 'Fremd'); expect(meta()).toBe('von @Alt · 04.10.2026'); emitName('u1', 'Neu'); expect(meta()).toBe('von @Neu · 04.10.2026'); }); it('der Autor ohne Profil wählt einen Namen (eigene Karte): „von @name“ ersetzt den Ersatz sofort', async () => { await mount(); expect(meta()).toBe('von Kenso · 04.10.2026'); emitName('u1', 'Kenso.K'); expect(meta()).toBe('von @Kenso.K · 04.10.2026'); }); it('dieselbe Meldung noch einmal schreibt die Zeile nicht neu', async () => { usernamesFor.mockResolvedValue(new Map([['u1', 'Kenso']])); await mount(); const line = q('.detail-meta')!; const text = line.firstChild; emitName('u1', 'Kenso'); expect(q('.detail-meta')).toBe(line); expect(line.firstChild).toBe(text); }); it('die Antwort für eine inzwischen verlassene Ansicht (die Karte ist nicht mehr zu sehen) ändert nichts', async () => { user = owner; getCard.mockResolvedValue(card({ hidden: true })); const lookup = deferred>(); usernamesFor.mockReturnValue(lookup.promise); await mount(); expect(q('h1')?.textContent).toBe('Mitternachtsblau'); login(null); // abgemeldet: die ausgeblendete Karte ist für niemand mehr sichtbar expect(q('h1')?.textContent).toBe(UNAVAILABLE); const html = root.innerHTML; lookup.resolve(new Map([['u1', 'Kenso.K']])); await settle(); expect(root.innerHTML).toBe(html); }); it('Aufräumen: der Hörer des Umbenennens ist abgemeldet, eine später eintreffende Antwort ändert nichts', async () => { const lookup = deferred>(); usernamesFor.mockReturnValue(lookup.promise); await mount(); expect(nameListeners.size).toBe(1); unmount?.(); unmount = null; expect(nameListeners.size).toBe(0); const html = root.innerHTML; lookup.resolve(new Map([['u1', 'Kenso.K']])); emitName('u1', 'Neu'); await settle(); expect(root.innerHTML).toBe(html); }); it('eine Karte, die nicht (mehr) verfügbar ist, fragt keinen Namen', async () => { getCard.mockResolvedValue(null); await mount(); expect(usernamesFor).not.toHaveBeenCalled(); }); it('Englisch: „by @name“', async () => { setLang('en', { persist: false }); usernamesFor.mockResolvedValue(new Map([['u1', 'Kenso.K']])); await mount(); expect(meta()).toBe('by @Kenso.K · Oct 4, 2026'); expect(germanLeft(root)).toEqual([]); }); }); describe('Detailseite: Rechte beim Ausführen', () => { it('Admin-Recht verloren, Knopf noch im DOM: Ausblenden sendet nichts', async () => { user = admin; await mount(); const hide = buttonByText('Ausblenden')!; user = stranger; // ohne auth-Ereignis: der alte Knopf steht noch da hide.click(); await settle(); expect(setVisibility).not.toHaveBeenCalled(); expect(withLogin).not.toHaveBeenCalled(); }); it('Admin-Recht verloren, Knopf noch im DOM: Löschen fragt nicht einmal nach', async () => { user = admin; await mount(); const del = buttonByText('Löschen')!; user = stranger; del.click(); await settle(); expect(confirmEl()).toBeNull(); expect(withdrawCard).not.toHaveBeenCalled(); }); it('Autor-Prüfung beim Ausführen: ein anderer Nutzer kann nicht zurückziehen', async () => { user = owner; await mount(); const withdraw = buttonByText('Zurückziehen')!; user = stranger; withdraw.click(); await settle(); expect(confirmEl()).toBeNull(); expect(withdrawCard).not.toHaveBeenCalled(); }); it('Recht geht während der Rückfrage verloren: nach „Löschen“ im Dialog wird nichts gesendet', async () => { user = admin; await mount(); buttonByText('Löschen')!.click(); await settle(); expect(confirmEl()?.open).toBe(true); login(stranger); confirmButton('Löschen').click(); await settle(); expect(withdrawCard).not.toHaveBeenCalled(); expect(navigate).not.toHaveBeenCalled(); expect(errorText()).toBe(''); }); it('Recht geht während der Rückfrage verloren: auch Zurückziehen sendet nichts', async () => { user = owner; await mount(); buttonByText('Zurückziehen')!.click(); await settle(); login(null); confirmButton('Zurückziehen').click(); await settle(); expect(withdrawCard).not.toHaveBeenCalled(); expect(navigate).not.toHaveBeenCalled(); }); it('ein Admin, der nicht Autor ist, kann nicht „zurückziehen“, aber löschen', async () => { user = admin; await mount(); expect(buttonTexts()).not.toContain('Zurückziehen'); expect(buttonTexts()).toContain('Löschen'); }); }); describe('Detailseite: Aufräumen', () => { it('beim Verlassen wird der Auth-Abonnent abgemeldet, danach ändert sich nichts mehr', async () => { await mount(); expect(authListeners.size).toBe(1); unmount?.(); unmount = null; expect(authListeners.size).toBe(0); }); it('Meldungen, die erst nach dem Verlassen eintreffen, werden verworfen', async () => { user = admin; let finish!: (r: GalleryReport[]) => void; listReports.mockReturnValue(new Promise((r) => (finish = r))); await mount(); unmount?.(); unmount = null; const html = root.innerHTML; finish([report(1, 'spam')]); await settle(); expect(root.innerHTML).toBe(html); }); }); describe('Detailseite: Herunterladen und Vorlage', () => { it('Herunterladen speichert das volle PNG als aircard-.png', async () => { const full = blob(); loadImage.mockImplementation(async () => image(full)); getCard.mockResolvedValue(card({ title: 'Grüner Wald!' })); await mount(); buttonByText('Herunterladen')!.click(); await settle(); expect(saveBlobAs).toHaveBeenCalledTimes(1); expect(saveBlobAs).toHaveBeenCalledWith(full, 'aircard-gruener-wald.png'); // das schon geladene Bild wird wiederverwendet expect(loadImage).toHaveBeenCalledTimes(1); }); it('Herunterladen scheitert: Meldung, der Knopf geht wieder', async () => { await mount(); q('.detail-card img')!.dispatchEvent(new Event('error')); // kein Bild im Speicher loadImage.mockRejectedValueOnce(new TypeError('offline')); buttonByText('Herunterladen')!.click(); await settle(); expect(saveBlobAs).not.toHaveBeenCalled(); expect(errorText()).toBe('Das Bild konnte nicht geladen werden.'); expect(buttonByText('Herunterladen')!.disabled).toBe(false); }); it('„Als Vorlage gestalten“ führt zu /gestalten?vorlage=', async () => { await mount(); buttonByText('Als Vorlage gestalten')!.click(); expect(navigate).toHaveBeenCalledWith('/gestalten?vorlage=g1'); }); }); describe('Detailseite: Melden', () => { const open = async () => { buttonByText('Melden')!.click(); await settle(); }; const submitReason = async (value: string) => { const radio = reportEl()!.querySelector(`input[value="${value}"]`)!; radio.checked = true; reportEl()!.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); await settle(); }; it('Dialog mit den fünf Gründen; die Wahl geht als API-Wert an reportCard', async () => { reportCard.mockResolvedValue({ schonGemeldet: false }); await mount(); await open(); expect([...reportEl()!.querySelectorAll('.report-reason')].map((l) => l.textContent)).toEqual([ 'Unangemessen', 'Echte Kartendaten', 'Urheberrecht/Marke', 'Spam', 'Sonstiges', ]); await submitReason('urheberrecht'); expect(reportCard).toHaveBeenCalledWith('g1', 'urheberrecht'); expect(reportEl()!.textContent).toContain('Danke für deine Meldung.'); }); it('doppelt gemeldet: „Danke, du hast diese Karte schon gemeldet.“', async () => { reportCard.mockResolvedValue({ schonGemeldet: true }); await mount(); await open(); await submitReason('spam'); expect(reportEl()!.textContent).toContain('Danke, du hast diese Karte schon gemeldet.'); }); it('zu viele Meldungen (429): der Servertext erscheint im Dialog', async () => { reportCard.mockRejectedValue(new ApiError(429, 'Zu viele Meldungen – bitte später nochmal.')); await mount(); await open(); await submitReason('spam'); expect(reportEl()!.querySelector('[role="alert"]')!.textContent).toBe('Zu viele Meldungen – bitte später nochmal.'); expect(reportEl()?.open).toBe(true); }); it('Verlassen der Seite schließt den offenen Dialog', async () => { await mount(); await open(); expect(reportEl()).not.toBeNull(); unmount?.(); unmount = null; await settle(); expect(reportEl()).toBeNull(); }); it('lässt sich der Dialog nicht darstellen (showModal wirft), erscheint ein Hinweis statt eines unbehandelten Fehlers', async () => { const spy = vi.spyOn(HTMLDialogElement.prototype, 'showModal').mockImplementation(() => { throw new Error('nicht darstellbar'); }); try { await mount(); buttonByText('Melden')!.click(); await settle(); expect(errorText()).toBe('Der Melden-Dialog konnte nicht geöffnet werden.'); expect(reportEl()).toBeNull(); expect(buttonByText('Melden')!.disabled).toBe(false); } finally { spy.mockRestore(); } }); it('eine ausgeblendete Karte (nur Autor/Admin sehen sie) hat keinen Melden-Knopf', async () => { user = owner; getCard.mockResolvedValue(card({ hidden: true })); await mount(); expect(buttonTexts()).not.toContain('Melden'); }); }); describe('Detailseite: Autor', () => { it('der Autor sieht Zurückziehen, ein Fremder nicht', async () => { user = owner; await mount(); expect(buttonTexts()).toEqual(['Herunterladen', 'Als Vorlage gestalten', 'Melden', 'Zurückziehen']); unmount?.(); unmount = null; user = stranger; await mount(); expect(buttonTexts()).not.toContain('Zurückziehen'); expect(buttonTexts()).not.toContain('Löschen'); expect(q('.detail-admin')).toBeNull(); }); it('die Knöpfe erscheinen auch, wenn der Nutzer erst nach dem Laden bekannt wird, und verschwinden beim Abmelden', async () => { await mount(); expect(buttonTexts()).not.toContain('Zurückziehen'); login(owner); expect(buttonTexts()).toContain('Zurückziehen'); login(null); expect(buttonTexts()).not.toContain('Zurückziehen'); }); it('Zurückziehen fragt nach; „Abbrechen“ lässt alles, „Zurückziehen“ ruft die API und geht zur Startseite', async () => { user = owner; await mount(); buttonByText('Zurückziehen')!.click(); await settle(); expect(confirmEl()?.open).toBe(true); expect(confirmEl()!.querySelector('h2')!.textContent).toBe('Karte „Mitternachtsblau“ zurückziehen?'); confirmButton('Abbrechen').click(); await settle(); expect(confirmEl()).toBeNull(); expect(withdrawCard).not.toHaveBeenCalled(); expect(navigate).not.toHaveBeenCalled(); buttonByText('Zurückziehen')!.click(); await settle(); confirmButton('Zurückziehen').click(); await settle(); expect(withdrawCard).toHaveBeenCalledWith('g1'); expect(withLogin).toHaveBeenCalledTimes(1); expect(navigate).toHaveBeenCalledWith('/'); }); it('Zurückziehen scheitert: Servertext, nicht navigiert, Knöpfe wieder bedienbar', async () => { user = owner; withdrawCard.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.')); await mount(); buttonByText('Zurückziehen')!.click(); await settle(); confirmButton('Zurückziehen').click(); await settle(); expect(errorText()).toBe('Der Dienst ist gerade nicht erreichbar.'); expect(navigate).not.toHaveBeenCalled(); expect(qa('.detail-actions button').every((b) => !b.disabled)).toBe(true); }); it('war die Karte schon weg (404), ist das Ziel erreicht: zur Startseite', async () => { user = owner; withdrawCard.mockRejectedValue(new ApiError(404, 'Diese Karte gibt es nicht (mehr).')); await mount(); buttonByText('Zurückziehen')!.click(); await settle(); confirmButton('Zurückziehen').click(); await settle(); expect(navigate).toHaveBeenCalledWith('/'); expect(errorText()).toBe(''); }); it('Anmeldung abgebrochen: Hinweis, nicht navigiert', async () => { user = owner; withLogin.mockRejectedValue(new LoginCancelled()); await mount(); buttonByText('Zurückziehen')!.click(); await settle(); confirmButton('Zurückziehen').click(); await settle(); expect(errorText()).toBe('Bitte melde dich an, um fortzufahren.'); expect(navigate).not.toHaveBeenCalled(); }); it('während die Anfrage läuft, ist alles gesperrt und es geht nur eine raus', async () => { user = owner; let finish!: () => void; withdrawCard.mockReturnValue(new Promise((r) => (finish = r))); await mount(); buttonByText('Zurückziehen')!.click(); await settle(); confirmButton('Zurückziehen').click(); await settle(); expect(qa('.detail-actions button').every((b) => b.disabled)).toBe(true); buttonByText('Zurückziehen')!.click(); await settle(); expect(confirmEl()).toBeNull(); expect(withdrawCard).toHaveBeenCalledTimes(1); finish(); await settle(); expect(navigate).toHaveBeenCalledWith('/'); }); it('verlässt der Nutzer die Seite, während gefragt wird, schließt der Dialog und nichts passiert', async () => { user = owner; await mount(); buttonByText('Zurückziehen')!.click(); await settle(); expect(confirmEl()).not.toBeNull(); unmount?.(); unmount = null; await settle(); expect(confirmEl()).toBeNull(); expect(withdrawCard).not.toHaveBeenCalled(); }); it('der Autor einer ausgeblendeten Karte sieht sie mit dem Hinweis „Ausgeblendet“', async () => { user = owner; getCard.mockResolvedValue(card({ hidden: true, reportCount: 3 })); await mount(); expect(q('h1')?.textContent).toBe('Mitternachtsblau'); expect(q('.detail-hidden')?.textContent).toContain('Ausgeblendet'); expect(q('.detail-hidden')?.textContent).toContain('für andere nicht sichtbar'); expect(q('.detail-card img')).not.toBeNull(); // Meldungszahl und Gründe sind Sache der Admins expect(root.textContent).not.toContain('Meldungen'); expect(listReports).not.toHaveBeenCalled(); }); }); describe('Detailseite: Admin', () => { it('Admin sieht Ausblenden, Löschen, die Meldungszahl und die Gründe', async () => { user = admin; getCard.mockResolvedValue(card({ reportCount: 3 })); listReports.mockResolvedValue([report(1, 'spam'), report(2, 'kartendaten'), report(3, 'unbekannt')]); await mount(); expect(listReports).toHaveBeenCalledWith('g1'); expect(buttonTexts()).toEqual(['Herunterladen', 'Als Vorlage gestalten', 'Melden', 'Ausblenden', 'Löschen']); expect(q('.detail-reports-count')?.textContent).toBe('3 Meldungen'); expect(qa('.detail-reasons li').map((li) => li.textContent)).toEqual([ 'Spam · 05.10.2026', 'Echte Kartendaten · 05.10.2026', 'unbekannt · 05.10.2026', ]); expect(buttonTexts()).not.toContain('Zurückziehen'); }); it('eine Meldung heißt „1 Meldung“, keine „0 Meldungen“ ohne Liste', async () => { user = admin; getCard.mockResolvedValue(card({ reportCount: 1 })); listReports.mockResolvedValue([report(1, 'spam')]); await mount(); expect(q('.detail-reports-count')?.textContent).toBe('1 Meldung'); unmount?.(); unmount = null; getCard.mockResolvedValue(card({ reportCount: 0 })); listReports.mockResolvedValue([]); await mount(); expect(q('.detail-reports-count')?.textContent).toBe('0 Meldungen'); expect(q('.detail-reasons')).toBeNull(); }); it('Gründe lassen sich nicht laden: Hinweis, Zahl und Knöpfe bleiben', async () => { user = admin; getCard.mockResolvedValue(card({ reportCount: 2 })); listReports.mockRejectedValue(new TypeError('offline')); await mount(); expect(q('.detail-reports-count')?.textContent).toBe('2 Meldungen'); expect(q('.detail-admin')?.textContent).toContain('Die Gründe konnten nicht geladen werden.'); expect(buttonTexts()).toContain('Ausblenden'); }); it('Ausblenden ruft die API, zeigt „Ausgeblendet“ und wird zu „Einblenden“', async () => { user = admin; getCard.mockResolvedValue(card({ reportCount: 1 })); listReports.mockResolvedValue([report(1, 'spam')]); await mount(); buttonByText('Ausblenden')!.click(); await settle(); expect(setVisibility).toHaveBeenCalledWith('g1', false); expect(withLogin).toHaveBeenCalledTimes(1); expect(buttonTexts()).toContain('Einblenden'); expect(buttonTexts()).not.toContain('Ausblenden'); expect(buttonTexts()).not.toContain('Melden'); expect(q('.detail-hidden')?.textContent).toContain('Ausgeblendet'); expect(statusText()).toBe('Karte ausgeblendet.'); // Die Meldungen bleiben beim Ausblenden expect(q('.detail-reports-count')?.textContent).toBe('1 Meldung'); }); it('Einblenden macht die Karte wieder sichtbar; der Server löscht die Meldungen, die Anzeige folgt', async () => { user = admin; getCard.mockResolvedValue(card({ hidden: true, reportCount: 3 })); listReports.mockResolvedValue([report(1, 'spam'), report(2, 'spam'), report(3, 'sonstiges')]); await mount(); expect(q('.detail-hidden')).not.toBeNull(); buttonByText('Einblenden')!.click(); await settle(); expect(setVisibility).toHaveBeenCalledWith('g1', true); expect(buttonTexts()).toContain('Ausblenden'); expect(buttonTexts()).toContain('Melden'); expect(q('.detail-hidden')).toBeNull(); expect(q('.detail-reports-count')?.textContent).toBe('0 Meldungen'); expect(q('.detail-reasons')).toBeNull(); expect(statusText()).toBe('Karte eingeblendet.'); }); it('Ausblenden scheitert: Servertext, Zustand unverändert', async () => { user = admin; setVisibility.mockRejectedValue(new ApiError(403, 'Nur für Administratoren.')); await mount(); buttonByText('Ausblenden')!.click(); await settle(); expect(errorText()).toBe('Nur für Administratoren.'); expect(buttonTexts()).toContain('Ausblenden'); expect(q('.detail-hidden')).toBeNull(); expect(buttonByText('Ausblenden')!.disabled).toBe(false); }); it('Löschen fragt nach und löscht über withdrawCard, danach zur Startseite', async () => { user = admin; await mount(); buttonByText('Löschen')!.click(); await settle(); expect(confirmEl()!.querySelector('h2')!.textContent).toBe('Karte „Mitternachtsblau“ löschen?'); confirmButton('Abbrechen').click(); await settle(); expect(withdrawCard).not.toHaveBeenCalled(); buttonByText('Löschen')!.click(); await settle(); confirmButton('Löschen').click(); await settle(); expect(withdrawCard).toHaveBeenCalledWith('g1'); expect(navigate).toHaveBeenCalledWith('/'); }); it('wird jemand erst später als Admin erkannt, erscheinen die Admin-Knöpfe und die Gründe; beim Verlust verschwinden sie', async () => { getCard.mockResolvedValue(card({ reportCount: 1 })); listReports.mockResolvedValue([report(1, 'spam')]); await mount(); expect(q('.detail-admin')).toBeNull(); expect(listReports).not.toHaveBeenCalled(); login(admin); await settle(); expect(listReports).toHaveBeenCalledTimes(1); expect(buttonTexts()).toContain('Ausblenden'); expect(q('.detail-reasons li')?.textContent).toBe('Spam · 05.10.2026'); login(stranger); expect(q('.detail-admin')).toBeNull(); expect(buttonTexts()).not.toContain('Löschen'); }); it('verliert ein Admin sein Recht, während er eine ausgeblendete Karte ansieht, wird sie unzugänglich', async () => { user = admin; getCard.mockResolvedValue(card({ hidden: true })); await mount(); expect(q('h1')?.textContent).toBe('Mitternachtsblau'); login(stranger); expect(q('h1')?.textContent).toBe(UNAVAILABLE); expect(q('img')).toBeNull(); }); }); describe('Detailseite auf Englisch', () => { beforeEach(() => setLang('en', { persist: false })); it('Ansichten ohne Karte: nicht verfügbar, Laden, Ladefehler', async () => { getCard.mockResolvedValue(null); await mount(); expect(q('h1')?.textContent).toBe('This card is no longer available.'); expect(q('a[href="/#community"]')?.textContent).toBe('Back to gallery'); expect(document.title).toBe('Community – AirCard-IOS'); expect(germanLeft(root)).toEqual([]); unmount?.(); getCard.mockReturnValue(new Promise(() => {})); await mount(); expect(q('[role="status"]')?.textContent).toBe('Loading card …'); unmount?.(); getCard.mockReset(); getCard.mockRejectedValueOnce(new TypeError('offline')); await mount(); expect(errorText()).toBe('The card couldn’t be loaded right now.'); expect(buttonByText('Try again')).toBeDefined(); expect(germanLeft(root)).toEqual([]); }); it('Karte eines Gastes: Datum, Verfasser, Alt-Text, Knöpfe, Bildfehler und Herunterladen', async () => { getCard.mockResolvedValue(card({ displayName: '' })); loadImage.mockReturnValueOnce(new Promise(() => {})); await mount(); expect(q('.detail-card')?.textContent).toBe('Loading image …'); unmount?.(); loadImage.mockImplementation(async () => image()); await mount(); expect(q('.detail-meta')?.textContent).toBe('by Anonymous · Oct 4, 2026'); expect(q('.detail-card img')?.getAttribute('alt')).toBe('Community card “Mitternachtsblau”'); expect(q('.detail-tag')?.getAttribute('title')).toBe('Show cards with this tag'); expect(q('.detail-back')?.textContent).toBe('← Back to gallery'); expect(buttonTexts()).toEqual(['Download', 'Use as template', 'Report']); expect(germanLeft(root)).toEqual([]); // Bild nicht darstellbar, Herunterladen holt es nach: unterwegs „Downloading …“, danach der Fehler q('.detail-card img')!.dispatchEvent(new Event('error')); expect(q('.detail-card')?.textContent).toContain('The image couldn’t be loaded.'); loadImage.mockReturnValueOnce(new Promise(() => {})); buttonByText('Download')!.click(); expect(buttonTexts()).toContain('Downloading …'); unmount?.(); await mount(); q('.detail-card img')!.dispatchEvent(new Event('error')); loadImage.mockRejectedValueOnce(new TypeError('offline')); buttonByText('Download')!.click(); await settle(); expect(errorText()).toBe('The image couldn’t be loaded.'); }); it('Melden-Dialog: Hinweis, wenn er sich nicht öffnen lässt, und der Dialog selbst auf Englisch', async () => { reportCard.mockRejectedValue(new ApiError(429, 'Zu viele Meldungen – bitte später nochmal.', undefined, 'tooManyReports')); await mount(); buttonByText('Report')!.click(); await settle(); expect([...reportEl()!.querySelectorAll('.report-reason')].map((l) => l.textContent)).toEqual([ 'Inappropriate', 'Real card data', 'Copyright/trademark', 'Spam', 'Other', ]); const radio = reportEl()!.querySelector('input[value="spam"]')!; radio.checked = true; reportEl()!.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); await settle(); expect(reportEl()!.querySelector('[role="alert"]')!.textContent).toBe('Too many reports – please try again later.'); unmount?.(); reportEl()?.remove(); const spy = vi.spyOn(HTMLDialogElement.prototype, 'showModal').mockImplementation(() => { throw new Error('nicht darstellbar'); }); try { await mount(); buttonByText('Report')!.click(); await settle(); expect(errorText()).toBe('The report dialog couldn’t be opened.'); } finally { spy.mockRestore(); } }); it('Autor: Zurückziehen mit Rückfrage und die Fehler als Text der Seite (Code, Anmeldung, Netz)', async () => { user = owner; await mount(); expect(buttonTexts()).toEqual(['Download', 'Use as template', 'Report', 'Withdraw']); buttonByText('Withdraw')!.click(); await settle(); expect(confirmEl()!.querySelector('h2')!.textContent).toBe('Withdraw card “Mitternachtsblau”?'); expect(confirmEl()!.querySelector('p')!.textContent).toBe( 'The card disappears from the gallery, and its image and reports are deleted. Your private card is kept.', ); expect([...confirmEl()!.querySelectorAll('button')].map((b) => b.textContent)).toEqual(['Cancel', 'Withdraw']); withdrawCard.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', undefined, 'unavailable')); confirmButton('Withdraw').click(); await settle(); expect(errorText()).toBe('The service is currently unavailable.'); // ohne bekannten Code zeigt Englisch die allgemeine Meldung zum Status withdrawCard.mockRejectedValueOnce(new ApiError(403, 'Nur für Administratoren.')); buttonByText('Withdraw')!.click(); await settle(); confirmButton('Withdraw').click(); await settle(); expect(errorText()).toBe('You are not allowed to do that.'); withLogin.mockRejectedValueOnce(new LoginCancelled()); buttonByText('Withdraw')!.click(); await settle(); confirmButton('Withdraw').click(); await settle(); expect(errorText()).toBe('Please sign in to continue.'); withLogin.mockRejectedValueOnce(new TypeError('offline')); buttonByText('Withdraw')!.click(); await settle(); confirmButton('Withdraw').click(); await settle(); expect(errorText()).toBe('Can’t reach the server.'); expect(germanLeft(root)).toEqual([]); }); it('Admin: Moderation, Meldungen mit Grund und Datum, Ausblenden/Einblenden mit Hinweis, Löschen mit Rückfrage', async () => { user = admin; getCard.mockResolvedValue(card({ reportCount: 3 })); listReports.mockResolvedValue([report(1, 'spam'), report(2, 'kartendaten'), report(3, 'unbekannt')]); await mount(); expect(q('.detail-admin-title')?.textContent).toBe('Moderation'); expect(q('.detail-reports-count')?.textContent).toBe('3 reports'); expect(qa('.detail-reasons li').map((li) => li.textContent)).toEqual([ 'Spam · Oct 5, 2026', 'Real card data · Oct 5, 2026', 'unbekannt · Oct 5, 2026', // unbekannte Werte der API bleiben unverändert ]); expect(buttonTexts()).toEqual(['Download', 'Use as template', 'Report', 'Hide', 'Delete']); expect(germanLeft(root)).toEqual([]); buttonByText('Hide')!.click(); await settle(); expect(q('.detail-hidden')?.textContent).toBe('Hidden – This card isn’t visible to others.'); expect(statusText()).toBe('Card hidden.'); buttonByText('Unhide')!.click(); await settle(); expect(statusText()).toBe('Card unhidden.'); buttonByText('Delete')!.click(); await settle(); expect(confirmEl()!.querySelector('h2')!.textContent).toBe('Delete card “Mitternachtsblau”?'); expect(confirmEl()!.querySelector('p')!.textContent).toBe('The card, its image and all reports will be deleted permanently.'); expect([...confirmEl()!.querySelectorAll('button')].map((b) => b.textContent)).toEqual(['Cancel', 'Delete']); confirmButton('Cancel').click(); await settle(); // die Gründe lassen sich nicht laden unmount?.(); listReports.mockRejectedValue(new TypeError('offline')); await mount(); expect(q('.detail-admin')?.textContent).toContain('The reasons couldn’t be loaded.'); }); });