import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // api.ts hängt am Appwrite-Client; der Dialog bekommt `send` von außen, ApiError wird nur als Klasse gebraucht. vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' })); vi.mock('../account/auth', () => ({ currentUserId: () => null, onAuthChange: () => () => {}, onLogout: () => () => {} })); import { setLang } from '../i18n/i18n'; import { ApiError, type ReportReason } from './api'; import { REPORT_REASONS, reportDialog, type ReportOutcome } from './report-dialog'; type Send = (reason: ReportReason) => Promise<{ schonGemeldet: boolean }>; const send = vi.fn(); const dialogEl = () => document.querySelector('dialog.report-dialog'); const buttonByText = (text: string) => [...(dialogEl()?.querySelectorAll('button') ?? [])].find((b) => b.textContent === text) as HTMLButtonElement | undefined; const radios = () => [...(dialogEl()?.querySelectorAll('input[type="radio"]') ?? [])]; const choose = (value: string) => { const radio = radios().find((r) => r.value === value) as HTMLInputElement; radio.checked = true; radio.dispatchEvent(new Event('change', { bubbles: true })); }; const submit = () => dialogEl()!.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); const errorText = () => dialogEl()!.querySelector('[role="alert"]')!.textContent; const statusText = () => dialogEl()!.querySelector('[role="status"]')!.textContent; const settle = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); }; function open(opts: { cardTitle?: string; signal?: AbortSignal; subject?: 'card' | 'logo' } = {}): Promise { return reportDialog({ send, ...opts }); } beforeEach(() => { send.mockReset(); document.body.innerHTML = ''; }); afterEach(() => { document.body.innerHTML = ''; }); describe('Melden-Dialog', () => { it('zeigt die fünf Gründe in der Reihenfolge der Spec, keiner ist vorgewählt', () => { void open(); expect(dialogEl()?.open).toBe(true); expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Karte melden'); expect(REPORT_REASONS.map((r) => r.label)).toEqual([ 'Unangemessen', 'Echte Kartendaten', 'Urheberrecht/Marke', 'Spam', 'Sonstiges', ]); expect([...dialogEl()!.querySelectorAll('.report-reason')].map((l) => l.textContent)).toEqual([ 'Unangemessen', 'Echte Kartendaten', 'Urheberrecht/Marke', 'Spam', 'Sonstiges', ]); expect(radios().map((r) => r.value)).toEqual(['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges']); expect(radios().some((r) => r.checked)).toBe(false); expect(buttonByText('Abbrechen')).toBeTruthy(); expect(buttonByText('Melden')).toBeTruthy(); }); it('nennt den Kartentitel als Text, nie als HTML', () => { void open({ cardTitle: '' }); expect(dialogEl()!.querySelector('img')).toBeNull(); expect(dialogEl()!.textContent).toContain(''); }); it('ohne Grund: Hinweis, nichts wird gesendet', async () => { void open(); submit(); await settle(); expect(errorText()).toBe('Bitte wähle einen Grund.'); expect(send).not.toHaveBeenCalled(); expect(dialogEl()?.open).toBe(true); }); it.each([ ['unangemessen', 'unangemessen'], ['kartendaten', 'kartendaten'], ['urheberrecht', 'urheberrecht'], ['spam', 'spam'], ['sonstiges', 'sonstiges'], ])('Grund %s geht als %s an die API', async (value, expected) => { send.mockResolvedValue({ schonGemeldet: false }); void open(); choose(value); submit(); await settle(); expect(send).toHaveBeenCalledTimes(1); expect(send).toHaveBeenCalledWith(expected); }); it('neue Meldung: Dank im Dialog, „Schließen“ beendet mit „reported“', async () => { send.mockResolvedValue({ schonGemeldet: false }); const result = open(); choose('spam'); submit(); await settle(); expect(statusText()).toBe('Danke für deine Meldung.'); expect(errorText()).toBe(''); expect(radios()).toHaveLength(0); expect(buttonByText('Melden')).toBeUndefined(); expect(dialogEl()?.open).toBe(true); buttonByText('Schließen')!.click(); await expect(result).resolves.toBe('reported'); expect(dialogEl()).toBeNull(); }); it('doppelte Meldung: „Danke, du hast diese Karte schon gemeldet.“ und „duplicate“', async () => { send.mockResolvedValue({ schonGemeldet: true }); const result = open(); choose('spam'); submit(); await settle(); expect(statusText()).toBe('Danke, du hast diese Karte schon gemeldet.'); buttonByText('Schließen')!.click(); await expect(result).resolves.toBe('duplicate'); }); it('Drosselung (429): der Servertext erscheint, der Dialog bleibt und lässt einen neuen Versuch zu', async () => { send .mockRejectedValueOnce(new ApiError(429, 'Zu viele Meldungen – bitte später nochmal.')) .mockResolvedValueOnce({ schonGemeldet: false }); const result = open(); choose('spam'); submit(); await settle(); expect(errorText()).toBe('Zu viele Meldungen – bitte später nochmal.'); expect(dialogEl()?.open).toBe(true); expect(buttonByText('Melden')!.disabled).toBe(false); expect(radios().every((r) => !r.disabled)).toBe(true); // Die Auswahl bleibt stehen. expect(radios().find((r) => r.checked)?.value).toBe('spam'); submit(); await settle(); expect(send).toHaveBeenCalledTimes(2); expect(errorText()).toBe(''); expect(statusText()).toBe('Danke für deine Meldung.'); buttonByText('Schließen')!.click(); await expect(result).resolves.toBe('reported'); }); it('andere Fehler des Dienstes zeigen ihren Servertext (z. B. 404, 503)', async () => { send.mockRejectedValueOnce(new ApiError(404, 'Diese Karte gibt es nicht (mehr).')); void open(); choose('spam'); submit(); await settle(); expect(errorText()).toBe('Diese Karte gibt es nicht (mehr).'); send.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.')); submit(); await settle(); expect(errorText()).toBe('Der Dienst ist gerade nicht erreichbar.'); }); it('ein unerwarteter Fehler (kein ApiError) bekommt einen allgemeinen deutschen Text', async () => { send.mockRejectedValueOnce(new TypeError('offline')); void open(); choose('spam'); submit(); await settle(); expect(errorText()).toBe('Keine Verbindung zum Server.'); }); it('während des Sendens ist alles gesperrt, ein zweites Absenden sendet nichts', async () => { let finish!: (v: { schonGemeldet: boolean }) => void; send.mockReturnValue(new Promise((r) => (finish = r))); void open(); choose('spam'); submit(); await settle(); expect(buttonByText('Melden')!.disabled).toBe(true); expect(radios().every((r) => r.disabled)).toBe(true); expect(dialogEl()!.querySelector('form')!.getAttribute('aria-busy')).toBe('true'); submit(); await settle(); expect(send).toHaveBeenCalledTimes(1); finish({ schonGemeldet: false }); await settle(); expect(statusText()).toBe('Danke für deine Meldung.'); }); it('Abbrechen schließt ohne zu senden: „cancelled“', async () => { const result = open(); buttonByText('Abbrechen')!.click(); await expect(result).resolves.toBe('cancelled'); expect(dialogEl()).toBeNull(); expect(send).not.toHaveBeenCalled(); }); it('Esc (close-Ereignis) und Klick auf den Hintergrund schließen ebenfalls', async () => { const first = open(); dialogEl()!.dispatchEvent(new Event('close')); await expect(first).resolves.toBe('cancelled'); expect(dialogEl()).toBeNull(); const second = open(); const dialog = dialogEl()!; dialog.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); dialog.dispatchEvent(new MouseEvent('click', { bubbles: true })); await expect(second).resolves.toBe('cancelled'); expect(dialogEl()).toBeNull(); }); it('ein Klick im Inneren schließt nicht', () => { void open(); dialogEl()!.querySelector('.report-card')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); expect(dialogEl()?.open).toBe(true); }); it('das Signal (Seite verlassen) schließt den Dialog; eine spät eintreffende Antwort ändert nichts', async () => { let finish!: (v: { schonGemeldet: boolean }) => void; send.mockReturnValue(new Promise((r) => (finish = r))); const controller = new AbortController(); const result = open({ signal: controller.signal }); choose('spam'); submit(); await settle(); controller.abort(); await expect(result).resolves.toBe('cancelled'); expect(dialogEl()).toBeNull(); finish({ schonGemeldet: false }); await settle(); expect(dialogEl()).toBeNull(); }); it('ein schon abgebrochenes Signal öffnet gar nicht erst', async () => { const controller = new AbortController(); controller.abort(); await expect(open({ signal: controller.signal })).resolves.toBe('cancelled'); expect(dialogEl()).toBeNull(); }); }); describe('Melden-Dialog für Logos (subject: „logo“)', () => { const reasonLabels = () => [...dialogEl()!.querySelectorAll('.report-reason')].map((l) => l.textContent); it('Überschrift „Logo melden“, Einleitung nennt „dieses Logo“, vier Gründe ohne „Echte Kartendaten“', () => { void open({ subject: 'logo' }); expect(dialogEl()?.open).toBe(true); expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Logo melden'); expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Warum möchtest du dieses Logo melden?'); expect(reasonLabels()).toEqual(['Unangemessen', 'Urheberrecht/Marke', 'Spam', 'Sonstiges']); expect(radios().map((r) => r.value)).toEqual(['unangemessen', 'urheberrecht', 'spam', 'sonstiges']); expect(radios().some((r) => r.checked)).toBe(false); expect(buttonByText('Abbrechen')).toBeTruthy(); expect(buttonByText('Melden')).toBeTruthy(); }); it('die Gründe der Karten bleiben unverändert (REPORT_REASONS enthält weiter „Echte Kartendaten“)', () => { expect(REPORT_REASONS.map((r) => r.value)).toEqual(['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges']); }); it('der Titel der Karte spielt für Logos keine Rolle: die Einleitung nennt immer „dieses Logo“', () => { void open({ subject: 'logo', cardTitle: 'Urlaub' }); expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Warum möchtest du dieses Logo melden?'); }); it.each(['unangemessen', 'urheberrecht', 'spam', 'sonstiges'] as const)('Grund %s geht unverändert an send', async (value) => { send.mockResolvedValue({ schonGemeldet: false }); void open({ subject: 'logo' }); choose(value); submit(); await settle(); expect(send).toHaveBeenCalledExactlyOnceWith(value); }); it('ohne Grund: derselbe Hinweis, nichts wird gesendet', async () => { void open({ subject: 'logo' }); submit(); await settle(); expect(errorText()).toBe('Bitte wähle einen Grund.'); expect(send).not.toHaveBeenCalled(); }); it('neue Meldung: derselbe Dank, „reported“', async () => { send.mockResolvedValue({ schonGemeldet: false }); const result = open({ subject: 'logo' }); choose('spam'); submit(); await settle(); expect(statusText()).toBe('Danke für deine Meldung.'); buttonByText('Schließen')!.click(); await expect(result).resolves.toBe('reported'); }); it('doppelte Meldung: „Danke, du hast dieses Logo schon gemeldet.“ und „duplicate“', async () => { send.mockResolvedValue({ schonGemeldet: true }); const result = open({ subject: 'logo' }); choose('spam'); submit(); await settle(); expect(statusText()).toBe('Danke, du hast dieses Logo schon gemeldet.'); buttonByText('Schließen')!.click(); await expect(result).resolves.toBe('duplicate'); }); it('Fehler des Dienstes erscheinen wie bei Karten im Dialog, der offen bleibt', async () => { send.mockRejectedValueOnce(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone')); void open({ subject: 'logo' }); choose('spam'); submit(); await settle(); expect(errorText()).toBe('Dieses Logo ist nicht (mehr) verfügbar.'); expect(dialogEl()?.open).toBe(true); expect(buttonByText('Melden')!.disabled).toBe(false); }); it('ein Dialog mit subject „card“ ist derselbe wie ohne Angabe', () => { void open({ subject: 'card', cardTitle: 'Urlaub' }); expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Karte melden'); expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Warum möchtest du „Urlaub“ melden?'); expect(radios().map((r) => r.value)).toEqual(['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges']); }); }); describe('Melden-Dialog auf Englisch', () => { beforeEach(() => setLang('en', { persist: false })); it('Karte: Überschrift, Einleitung, Gründe, Knöpfe', () => { void open({ cardTitle: 'Holiday' }); expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Report card'); expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Why do you want to report “Holiday”?'); expect(dialogEl()!.querySelector('legend')!.textContent).toBe('Reason for the report'); expect([...dialogEl()!.querySelectorAll('.report-reason')].map((l) => l.textContent)).toEqual([ 'Inappropriate', 'Real card data', 'Copyright/trademark', 'Spam', 'Other', ]); expect(buttonByText('Cancel')).toBeTruthy(); expect(buttonByText('Report')).toBeTruthy(); // ohne Titel dialogEl()!.remove(); void open(); expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Why do you want to report this card?'); }); it('Logo: Überschrift, Einleitung, vier Gründe, Dank und Duplikat', async () => { send.mockResolvedValueOnce({ schonGemeldet: true }); const result = open({ subject: 'logo' }); expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Report logo'); expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Why do you want to report this logo?'); expect([...dialogEl()!.querySelectorAll('.report-reason')].map((l) => l.textContent)).toEqual([ 'Inappropriate', 'Copyright/trademark', 'Spam', 'Other', ]); submit(); // ohne Grund await settle(); expect(errorText()).toBe('Please choose a reason.'); choose('spam'); submit(); await settle(); expect(statusText()).toBe('Thanks, you’ve already reported this logo.'); buttonByText('Close')!.click(); await expect(result).resolves.toBe('duplicate'); }); it('Dank und Duplikat bei Karten', async () => { send.mockResolvedValueOnce({ schonGemeldet: false }).mockResolvedValueOnce({ schonGemeldet: true }); const first = open(); choose('spam'); submit(); await settle(); expect(statusText()).toBe('Thanks for your report.'); buttonByText('Close')!.click(); await first; const second = open(); choose('spam'); submit(); await settle(); expect(statusText()).toBe('Thanks, you’ve already reported this card.'); buttonByText('Close')!.click(); await second; }); it('Fehler des Dienstes erscheinen übersetzt, wenn der Schlüssel bekannt ist', async () => { send.mockRejectedValueOnce(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone')); void open({ subject: 'logo' }); choose('spam'); submit(); await settle(); expect(errorText()).toBe('This logo is no longer available.'); }); });