import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ prepareLogo: vi.fn(), uploadPreparedLogo: vi.fn(), withLogin: vi.fn(), usernameDialog: vi.fn(), })); // api.ts und with-login.ts hängen am Appwrite-Client; `prepare` und `upload` kommen meist von außen, nur die Standardwerte // (prepareLogo, uploadPreparedLogo über withLogin) laufen über die Fakes hier. vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' })); vi.mock('../account/auth', () => ({ currentUserId: () => null, onAuthChange: () => () => {}, onLogout: () => () => {} })); vi.mock('../account/with-login', async (importOriginal) => ({ ...(await importOriginal()), withLogin: mocks.withLogin, })); vi.mock('./prepare-logo', async (importOriginal) => ({ ...(await importOriginal()), prepareLogo: mocks.prepareLogo, })); vi.mock('./upload-logo', () => ({ uploadPreparedLogo: mocks.uploadPreparedLogo })); // Der Namensdialog ist eine Attrappe (sein eigenes Verhalten steht in account/username-dialog.test.ts). vi.mock('../account/username-dialog', () => ({ usernameDialog: mocks.usernameDialog })); import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login'; import { ApiError } from '../community/api'; import { setLang, t } from '../i18n/i18n'; import { LOGO_CONSENT_TEXT, logoUploadDialog, validateLogoForm, type LogoDialogOptions } from './logo-dialog'; import type { CommunityLogo } from './logos'; import { LOGO_ACCEPT, LOGO_EMPTY_MESSAGE, type PreparedLogo } from './prepare-logo'; import type { LogoForm } from './upload-logo'; type Prepare = (file: File) => Promise; type Upload = (prepared: PreparedLogo, form: LogoForm) => Promise; const prepare = vi.fn(); const upload = vi.fn(); /** Die Blobs, für die der Dialog Object-URLs angelegt hat (blob:logo-1, blob:logo-2, …), und die wieder freigegebenen URLs. */ const created: Blob[] = []; const revoked: string[] = []; const prepared = (patch: Partial = {}): PreparedLogo => ({ png: new Blob(['png-bytes'], { type: 'image/png' }), width: 200, height: 80, hasTransparency: true, ...patch, }); const logo = (patch: Partial = {}): CommunityLogo => ({ id: 'L1', name: 'Beispielbank', displayName: '', fileId: 'L1', ownerId: 'u1', mono: false, likeCount: 0, hidden: false, reportCount: 0, createdAt: '2026-10-04T12:00:00.000Z', ...patch, }); const imageFile = (name = 'Beispielbank.png', type = 'image/png') => new File(['x'.repeat(100)], name, { type }); const dialogEl = () => document.querySelector('dialog.logo-dialog'); const input = (name: string) => dialogEl()!.querySelector(`input[name="${name}"]`)!; const buttonByText = (text: string) => [...(dialogEl()?.querySelectorAll('button') ?? [])].find((b) => b.textContent === text) as HTMLButtonElement | undefined; const form = () => dialogEl()!.querySelector('form')!; const send = () => form().dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); const errorText = () => dialogEl()!.querySelector('[role="alert"]')!.textContent; const statusText = () => dialogEl()!.querySelector('[role="status"]')!.textContent; const errorLine = () => dialogEl()!.querySelector('[role="alert"]')!; /** Die IDs in `aria-describedby` eines Feldes (leer, wenn es das Attribut nicht hat). */ const describedBy = (name: string) => (input(name).getAttribute('aria-describedby') ?? '').split(' ').filter(Boolean); const previews = () => [...dialogEl()!.querySelectorAll('.logo-preview')]; const settle = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); }; /** Wählt eine Datei im Datei-Feld (oder hebt die Auswahl auf), wie es der Browser mit `change` meldet. */ function choose(file: File | null): void { Object.defineProperty(input('file'), 'files', { value: file ? [file] : [], configurable: true }); input('file').dispatchEvent(new Event('change', { bubbles: true })); } /** Tippt in ein Textfeld, so dass auch das `input`-Ereignis kommt (das Setzen von `value` allein löst es nicht aus). */ function typeInto(name: string, value: string): void { input(name).value = value; input(name).dispatchEvent(new Event('input', { bubbles: true })); } function fill(values: { name?: string; consent?: boolean } = {}): void { input('name').value = values.name ?? 'Beispielbank'; input('consent').checked = values.consent ?? true; } function open(opts: LogoDialogOptions = {}): Promise { return logoUploadDialog({ prepare, upload, ...opts }); } /** Öffnet den Dialog und wählt eine Datei; wartet, bis die Verarbeitung durch ist. Das Ergebnis steckt in einem Objekt (kein Warten auf das Schließen). */ async function setup(opts: LogoDialogOptions = {}, file: File = imageFile()): Promise<{ outcome: Promise }> { const outcome = open(opts); choose(file); await settle(); return { outcome }; } beforeEach(() => { document.body.innerHTML = ''; for (const fn of [prepare, upload, mocks.prepareLogo, mocks.uploadPreparedLogo, mocks.withLogin, mocks.usernameDialog]) fn.mockReset(); mocks.withLogin.mockImplementation((fn: () => Promise) => fn()); mocks.usernameDialog.mockResolvedValue(null); prepare.mockResolvedValue(prepared()); created.length = 0; revoked.length = 0; let counter = 0; URL.createObjectURL = vi.fn((blob: Blob) => { created.push(blob); return `blob:logo-${++counter}`; }); URL.revokeObjectURL = vi.fn((url: string) => { revoked.push(url); }); }); afterEach(() => { document.body.innerHTML = ''; }); describe('validateLogoForm', () => { const ok = { hasFile: true, name: 'Beispielbank', consent: true }; it('gültige Werte: kein Problem', () => { expect(validateLogoForm(ok)).toBeNull(); }); it('ohne Datei', () => { expect(validateLogoForm({ ...ok, hasFile: false })).toEqual({ field: 'file', message: 'Bitte wähle eine Datei.' }); }); it('Name leer, auch nur aus Leerzeichen', () => { const problem = { field: 'name', message: 'Bitte gib einen Namen an.' }; expect(validateLogoForm({ ...ok, name: '' })).toEqual(problem); expect(validateLogoForm({ ...ok, name: ' ' })).toEqual(problem); }); it('Name länger als 40 Zeichen; 40 Zeichen gehen durch, Leerzeichen außen zählen nicht', () => { expect(validateLogoForm({ ...ok, name: 'x'.repeat(41) })).toEqual({ field: 'name', message: 'Der Name ist zu lang (höchstens 40 Zeichen).', }); expect(validateLogoForm({ ...ok, name: 'x'.repeat(40) })).toBeNull(); expect(validateLogoForm({ ...ok, name: ` ${'x'.repeat(40)} ` })).toBeNull(); }); // Dasselbe Muster wie der Dienst (deploy/api/common.js): Steuer-, Nullbreiten- und Bidi-Zeichen und das BOM. it.each([ ['Tabulator', 'a\tb'], ['Nullbreiten-Leerzeichen', 'a​b'], ['Rechts-nach-links-Überschreibung', 'logo‮gnp.exe'], ['C1-Steuerzeichen', 'a\u0085b'], ['BOM mitten im Namen', 'ab'], ])('Steuerzeichen im Namen werden abgelehnt: %s', (_label, name) => { expect(validateLogoForm({ ...ok, name })).toEqual({ field: 'name', message: 'Der Name enthält ungültige Zeichen.' }); }); it('ohne Häkchen', () => { expect(validateLogoForm({ ...ok, consent: false })).toEqual({ field: 'consent', message: 'Bitte bestätige, dass du das Logo teilen darfst.', }); }); it('meldet das erste Problem in Formularreihenfolge: Datei, Name, Häkchen', () => { const bad = { hasFile: false, name: '', consent: false }; expect(validateLogoForm(bad)?.field).toBe('file'); expect(validateLogoForm({ ...bad, hasFile: true })?.field).toBe('name'); expect(validateLogoForm({ ...bad, hasFile: true, name: 'Bank' })?.field).toBe('consent'); }); it('Englisch: die Meldungen kommen in der Sprache der Seite', () => { setLang('en'); expect(validateLogoForm({ ...ok, hasFile: false })?.message).toBe('Please choose a file.'); expect(validateLogoForm({ ...ok, name: '' })?.message).toBe('Please enter a name.'); expect(validateLogoForm({ ...ok, name: 'x'.repeat(41) })?.message).toBe('The name is too long (at most 40 characters).'); expect(validateLogoForm({ ...ok, consent: false })?.message).toBe('Please confirm that you’re allowed to share the logo.'); }); }); describe('Logo-Dialog: Aufbau', () => { it('ist ein eigenes mit Überschrift, den Feldern in Formularreihenfolge und beiden Knöpfen', () => { void open(); expect(dialogEl()?.open).toBe(true); expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Logo hochladen'); expect([...form().querySelectorAll('input')].map((i) => i.name)).toEqual(['file', 'mono', 'name', 'consent']); expect([...form().querySelectorAll('label')].map((l) => l.textContent)).toEqual([ 'Datei (Pflicht)', 'Einfarbig – passt sich Hell/Dunkel an', 'Name (Pflicht)', LOGO_CONSENT_TEXT, ]); expect(buttonByText('Abbrechen')).toBeTruthy(); expect(buttonByText('Hochladen')).toBeTruthy(); }); it('das Datei-Feld nimmt PNG, JPG, WebP und SVG und nennt die Grenzen der Spec', () => { void open(); expect(input('file').type).toBe('file'); expect(input('file').getAttribute('accept')).toBe(LOGO_ACCEPT); expect(dialogEl()!.textContent).toContain('PNG, JPG, WebP oder SVG, höchstens 5 MB und 40 Megapixel.'); }); it('beim Öffnen: Name leer, Häkchen ungesetzt, „Einfarbig“ gesperrt, keine Vorschau, Fokus im Datei-Feld', () => { void open(); expect(input('name').value).toBe(''); expect(input('consent').checked).toBe(false); expect(input('mono').checked).toBe(false); expect(input('mono').disabled).toBe(true); expect(previews()).toHaveLength(0); expect(document.activeElement).toBe(input('file')); expect(dialogEl()!.textContent).not.toMatch(/@/); }); it('es gibt kein Feld „Anzeigename“ mehr (die Logos zeigen den Benutzernamen, Spec § 5)', () => { void open(); expect(dialogEl()!.querySelector('input[name="displayName"]')).toBeNull(); expect(dialogEl()!.textContent).not.toMatch(/Anzeigename|Anonym/); }); it('das Pflicht-Häkchen hat genau den Wortlaut der Spec', () => { void open(); expect(LOGO_CONSENT_TEXT).toBe( 'Ich habe die Rechte an diesem Logo oder die Erlaubnis des Inhabers, und es darf öffentlich geteilt werden.', ); expect(input('consent').closest('label')!.textContent).toBe(LOGO_CONSENT_TEXT); }); it('jedes Feld hat sein Label, alle IDs sind eindeutig, der Titel des Dialogs ist die Überschrift', () => { void open(); const labels = [...form().querySelectorAll('label')]; const inputs = [...form().querySelectorAll('input')]; expect(labels.map((l) => l.control)).toEqual(inputs); for (const control of inputs) expect(control.labels).toHaveLength(1); const ids = [...dialogEl()!.querySelectorAll('[id]')].map((n) => n.id); expect(new Set(ids).size).toBe(ids.length); const heading = dialogEl()!.querySelector('h2')!; expect(dialogEl()!.getAttribute('aria-labelledby')).toBe(heading.id); expect(inputs.map((i) => i.id)).not.toContain(heading.id); }); it('Englisch: Überschrift, Beschriftungen und Knöpfe', () => { setLang('en'); void open(); expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Upload logo'); expect([...form().querySelectorAll('label')].map((l) => l.textContent)).toEqual([ 'File (required)', 'Single color – adapts to light/dark mode', 'Name (required)', 'I own the rights to this logo or have permission from the owner, and it may be shared publicly.', ]); expect(buttonByText('Cancel')).toBeTruthy(); expect(buttonByText('Upload')).toBeTruthy(); }); }); describe('Logo-Dialog: Datei wählen', () => { it('ruft prepare auf, zeigt „Logo wird verarbeitet …“ und danach zwei Vorschauen (dunkel, hell) mit dem fertigen PNG', async () => { const p = prepared(); prepare.mockResolvedValue(p); const file = imageFile(); void open(); choose(file); expect(prepare).toHaveBeenCalledExactlyOnceWith(file); expect(statusText()).toBe('Logo wird verarbeitet …'); expect(previews()).toHaveLength(0); await settle(); expect(statusText()).toBe(''); expect(errorText()).toBe(''); const [dark, light] = previews(); expect(previews()).toHaveLength(2); expect(dark.classList.contains('logo-preview-dark')).toBe(true); expect(light.classList.contains('logo-preview-light')).toBe(true); expect(dark.querySelector('figcaption')!.textContent).toBe('Dunkel'); expect(light.querySelector('figcaption')!.textContent).toBe('Hell'); // Beide zeigen dieselbe Object-URL des fertigen PNGs; sie wird nur einmal angelegt. expect(created).toHaveLength(1); expect(created[0]).toBe(p.png); for (const preview of previews()) { const img = preview.querySelector('img')!; expect(img.getAttribute('src')).toBe('blob:logo-1'); expect(img.getAttribute('alt')).toMatch(/^Logo-Vorschau auf (dunklem|hellem) Grund$/); } }); it.each([ ['einfacher Dateiname', 'Beispielbank.png', 'Beispielbank'], ['nur die letzte Endung fällt weg', 'Meine Bank.final.svg', 'Meine Bank.final'], ['ohne Endung', 'logo', 'logo'], ['Leerzeichen außen fallen weg', ' Mit Leerzeichen .webp', 'Mit Leerzeichen'], ['über 40 Zeichen: gekürzt', `${'x'.repeat(60)}.png`, 'x'.repeat(40)], ['nach dem Kürzen bleibt kein Leerzeichen am Ende', `${'a'.repeat(39)} bbb.png`, 'a'.repeat(39)], // 😀 belegt zwei Zeichen (UTF-16): ein Schnitt mitten darin würde ein halbes Zeichen hinterlassen. ['der Schnitt trennt kein Emoji', `${'a'.repeat(39)}😀b.png`, 'a'.repeat(39)], ])('der Name wird aus dem Dateinamen ohne Endung vorbelegt: %s', async (_label, fileName, expected) => { await setup({}, imageFile(fileName)); expect(input('name').value).toBe(expected); expect(validateLogoForm({ hasFile: true, name: input('name').value, consent: true })).toBeNull(); }); it('eine zweite Datei ersetzt den vorbelegten Namen, aber keinen selbst getippten', async () => { await setup({}, imageFile('Erste.png')); expect(input('name').value).toBe('Erste'); choose(imageFile('Zweite.png')); await settle(); expect(input('name').value).toBe('Zweite'); typeInto('name', 'Mein Name'); choose(imageFile('Dritte.png')); await settle(); expect(input('name').value).toBe('Mein Name'); // Wer das Feld leert, bekommt beim nächsten Logo wieder einen Vorschlag. typeInto('name', ''); choose(imageFile('Vierte.png')); await settle(); expect(input('name').value).toBe('Vierte'); }); it('„Einfarbig“ ist gesperrt, wenn das Logo keine transparenten Bereiche hat', async () => { prepare.mockResolvedValue(prepared({ hasTransparency: false })); await setup(); expect(input('mono').disabled).toBe(true); expect(input('mono').checked).toBe(false); }); it('„Einfarbig“ ist wählbar, wenn das Logo transparente Bereiche hat', async () => { await setup(); expect(input('mono').disabled).toBe(false); expect(input('mono').checked).toBe(false); }); it('„Einfarbig“ zeigt beide Vorschauen als Maske auf dieselbe URL (Weiß auf dunklem, #111111 auf hellem Grund); abwählen bringt das Bild zurück', async () => { await setup(); input('mono').click(); expect(input('mono').checked).toBe(true); expect(previews()).toHaveLength(2); const [dark, light] = previews(); for (const preview of previews()) { expect(preview.querySelector('img')).toBeNull(); const mask = preview.querySelector('.logo-preview-mask')!; expect(mask.getAttribute('style')).toContain('url("blob:logo-1")'); expect(mask.getAttribute('role')).toBe('img'); expect(mask.getAttribute('aria-label')).toMatch(/^Logo-Vorschau auf (dunklem|hellem) Grund$/); } // Gefüllt wie auf der Karte (TONE_COLOR): Weiß auf dunklem, #111111 auf hellem Grund. expect(dark.querySelector('.logo-preview-mask')!.style.getPropertyValue('background-color')).toBe('#FFFFFF'); expect(light.querySelector('.logo-preview-mask')!.style.getPropertyValue('background-color')).toBe('#111111'); expect(created).toHaveLength(1); // keine neue URL für die Maske input('mono').click(); expect(input('mono').checked).toBe(false); for (const preview of previews()) { expect(preview.querySelector('.logo-preview-mask')).toBeNull(); expect(preview.querySelector('img')!.getAttribute('src')).toBe('blob:logo-1'); } }); it('eine neue Datei gibt die Object-URL der alten frei und setzt „Einfarbig“ zurück', async () => { await setup({}, imageFile('Erste.png')); input('mono').click(); expect(input('mono').checked).toBe(true); prepare.mockResolvedValue(prepared()); choose(imageFile('Zweite.png')); expect(revoked).toEqual(['blob:logo-1']); // sofort beim Wählen, die Vorschau gehört nicht mehr zur Auswahl expect(previews()).toHaveLength(0); await settle(); expect(input('mono').checked).toBe(false); expect(previews()).toHaveLength(2); expect(previews()[0].querySelector('img')!.getAttribute('src')).toBe('blob:logo-2'); expect(revoked).toEqual(['blob:logo-1']); }); it('die Auswahl aufzuheben räumt Vorschau und Object-URL weg', async () => { await setup(); choose(null); await settle(); expect(previews()).toHaveLength(0); expect(revoked).toEqual(['blob:logo-1']); expect(input('mono').disabled).toBe(true); expect(buttonByText('Hochladen')!.disabled).toBe(false); }); it('falscher Dateityp oder zu groß: Meldung der Spec, prepare wird nicht gerufen, keine Vorschau', async () => { void open(); choose(imageFile('notizen.txt', 'text/plain')); await settle(); expect(prepare).not.toHaveBeenCalled(); expect(errorText()).toBe('Bitte ein PNG-, JPG-, WebP- oder SVG-Bild wählen.'); expect(previews()).toHaveLength(0); const big = imageFile('gross.png'); Object.defineProperty(big, 'size', { value: 5_242_881 }); choose(big); await settle(); expect(prepare).not.toHaveBeenCalled(); expect(errorText()).toBe('Die Datei ist größer als 5 MB.'); }); it('schlägt die Verarbeitung fehl, erscheint ihr Text (z. B. „Das Logo ist leer.“), ohne Vorschau und ohne Name', async () => { prepare.mockRejectedValue(new Error(t(LOGO_EMPTY_MESSAGE))); await setup(); expect(errorText()).toBe('Das Logo ist leer.'); expect(statusText()).toBe(''); expect(previews()).toHaveLength(0); expect(input('name').value).toBe(''); expect(created).toHaveLength(0); expect(buttonByText('Hochladen')!.disabled).toBe(false); }); it('der Text der Verarbeitung erscheint in der Sprache der Seite', async () => { setLang('en'); prepare.mockRejectedValue(new Error(t(LOGO_EMPTY_MESSAGE))); await setup(); expect(errorText()).toBe('The logo is empty.'); }); it('ein unerwarteter Fehler der Verarbeitung zeigt nicht seinen Text, sondern die allgemeine Meldung', async () => { prepare.mockRejectedValue(new Error('boom: interne Details')); await setup(); expect(errorText()).toBe('Etwas ist schiefgelaufen. Bitte nochmal versuchen.'); }); it('nach einer gescheiterten Verarbeitung gilt keine Datei als gewählt', async () => { prepare.mockRejectedValue(new Error(t(LOGO_EMPTY_MESSAGE))); await setup(); fill(); send(); expect(errorText()).toBe('Bitte wähle eine Datei.'); expect(upload).not.toHaveBeenCalled(); }); it('eine neue Wahl macht die laufende Verarbeitung der alten Datei hinfällig', async () => { let finishFirst!: (p: PreparedLogo) => void; const first = prepared({ png: new Blob(['erste'], { type: 'image/png' }) }); const second = prepared({ png: new Blob(['zweite'], { type: 'image/png' }), hasTransparency: false }); prepare.mockReturnValueOnce(new Promise((resolve) => (finishFirst = resolve))); prepare.mockResolvedValueOnce(second); void open(); choose(imageFile('Erste.png')); choose(imageFile('Zweite.png')); await settle(); expect(created).toHaveLength(1); expect(created[0]).toBe(second.png); expect(input('name').value).toBe('Zweite'); expect(input('mono').disabled).toBe(true); finishFirst(first); // kommt zu spät und bleibt folgenlos await settle(); expect(created).toHaveLength(1); expect(input('name').value).toBe('Zweite'); expect(input('mono').disabled).toBe(true); expect(previews()).toHaveLength(2); expect(statusText()).toBe(''); }); it('während der Verarbeitung ist „Hochladen“ gesperrt und ein Absenden bleibt wirkungslos', async () => { let finish!: (p: PreparedLogo) => void; prepare.mockReturnValue(new Promise((resolve) => (finish = resolve))); void open(); choose(imageFile()); fill(); expect(buttonByText('Hochladen')!.disabled).toBe(true); send(); expect(errorText()).toBe(''); expect(upload).not.toHaveBeenCalled(); finish(prepared()); await settle(); expect(buttonByText('Hochladen')!.disabled).toBe(false); }); it('eine Verarbeitung, die erst nach dem Schließen fertig wird, legt keine Object-URL mehr an', async () => { let finish!: (p: PreparedLogo) => void; prepare.mockReturnValue(new Promise((resolve) => (finish = resolve))); const outcome = open(); choose(imageFile()); buttonByText('Abbrechen')!.click(); await outcome; finish(prepared()); await settle(); expect(created).toHaveLength(0); }); it('der Dateiname erscheint als Text, nie als HTML', async () => { await setup({}, imageFile('.png')); expect(input('name').value).toBe(''); for (const img of dialogEl()!.querySelectorAll('img')) expect(img.getAttribute('src')).toBe('blob:logo-1'); expect(dialogEl()!.querySelectorAll('img')).toHaveLength(2); }); }); describe('Logo-Dialog: Prüfung vor dem Senden', () => { const invalid = async (values: Parameters[0], message: string, field: string) => { await setup(); fill(values); send(); await settle(); expect(errorText()).toBe(message); expect(upload).not.toHaveBeenCalled(); expect(dialogEl()?.open).toBe(true); expect(input(field).getAttribute('aria-invalid')).toBe('true'); expect(describedBy(field)).toContain(errorLine().id); // das ungültige Feld verweist auf die Fehlerzeile expect(document.activeElement).toBe(input(field)); }; it('keine Datei gewählt', () => { void open(); fill(); send(); expect(errorText()).toBe('Bitte wähle eine Datei.'); expect(upload).not.toHaveBeenCalled(); expect(input('file').getAttribute('aria-invalid')).toBe('true'); expect(document.activeElement).toBe(input('file')); }); it('Name leer', async () => { await invalid({ name: ' ' }, 'Bitte gib einen Namen an.', 'name'); }); it('Name länger als 40 Zeichen', async () => { await invalid({ name: 'x'.repeat(41) }, 'Der Name ist zu lang (höchstens 40 Zeichen).', 'name'); }); it('Steuerzeichen im Namen', async () => { await invalid({ name: 'a\tb' }, 'Der Name enthält ungültige Zeichen.', 'name'); }); it('Pflicht-Häkchen fehlt', async () => { await invalid({ consent: false }, 'Bitte bestätige, dass du das Logo teilen darfst.', 'consent'); }); it('eine behobene Eingabe verliert die Markierung beim nächsten Senden', async () => { upload.mockResolvedValue(logo()); await setup(); fill({ name: '' }); send(); expect(input('name').getAttribute('aria-invalid')).toBe('true'); fill({ name: 'Ok' }); send(); expect(input('name').getAttribute('aria-invalid')).toBeNull(); expect(errorText()).toBe(''); }); it('die Grenze ist inklusive: 40 Zeichen Name gehen durch', async () => { upload.mockResolvedValue(logo()); const { outcome } = await setup(); fill({ name: 'x'.repeat(40) }); send(); await outcome; expect(upload).toHaveBeenCalledOnce(); }); it('Umlaute, Emoji und Bindestriche bleiben erlaubt', async () => { upload.mockResolvedValue(logo()); const { outcome } = await setup(); fill({ name: 'Größe 😀 – Möwe-Bank' }); send(); await outcome; expect(upload).toHaveBeenCalledOnce(); }); }); describe('Logo-Dialog: Barrierefreiheit', () => { it('das Datei-Feld ist nativ „required“: aria-required gilt für ein Datei-Feld nicht', () => { void open(); expect(input('file').required).toBe(true); expect(input('file').hasAttribute('aria-required')).toBe(false); }); it('die Fehlerzeile hat eine ID, die im Dialog einmalig ist', () => { void open(); expect(errorLine().id).toBe('logo-upload-error'); expect(dialogEl()!.querySelectorAll(`#${errorLine().id}`)).toHaveLength(1); }); it('ohne Fehler verweist kein Feld auf die Fehlerzeile; die Hinweise bleiben', () => { void open(); expect(describedBy('file')).toEqual(['logo-upload-file-hint']); expect(describedBy('name')).toEqual([]); expect(describedBy('consent')).toEqual([]); }); it('ein ungültiges Feld verweist mit aria-describedby auf die Fehlerzeile, hinter seinem Hinweis, wenn es einen hat', async () => { void open(); fill(); send(); // keine Datei expect(describedBy('file')).toEqual(['logo-upload-file-hint', 'logo-upload-error']); expect(describedBy('name')).toEqual([]); // nur das Feld mit dem Problem await setup(); fill({ name: '' }); send(); expect(describedBy('name')).toEqual(['logo-upload-error']); fill({ consent: false }); send(); expect(describedBy('consent')).toEqual(['logo-upload-error']); expect(describedBy('name')).toEqual([]); // nur das Feld mit dem aktuellen Problem }); it('ist das Feld behoben, fällt die Verknüpfung beim nächsten Senden weg und der Hinweis bleibt', async () => { upload.mockResolvedValue(logo()); await setup(); fill({ consent: false }); send(); expect(describedBy('consent')).toEqual(['logo-upload-error']); fill({ consent: true }); send(); expect(describedBy('consent')).toEqual([]); expect(input('consent').hasAttribute('aria-invalid')).toBe(false); expect(describedBy('file')).toEqual(['logo-upload-file-hint']); // der Hinweis bleibt }); it('eine neue Dateiwahl löscht den Fehlertext und damit auch die Markierung der Felder, die darauf verwiesen', async () => { await setup(); fill({ name: '' }); send(); expect(describedBy('name')).toEqual(['logo-upload-error']); choose(imageFile('Zweite.png')); await settle(); expect(errorText()).toBe(''); expect(input('name').hasAttribute('aria-invalid')).toBe(false); expect(describedBy('name')).toEqual([]); }); }); describe('Logo-Dialog: Senden', () => { it('sendet den getrimmten Namen genau einmal (ohne Anzeigenamen) und löst mit dem Logo auf', async () => { const p = prepared(); prepare.mockResolvedValue(p); const result = logo({ id: 'L9' }); upload.mockResolvedValue(result); const { outcome } = await setup(); fill({ name: ' Meine Bank ' }); send(); await expect(outcome).resolves.toBe(result); expect(upload).toHaveBeenCalledOnce(); expect(upload).toHaveBeenCalledWith(p, { name: 'Meine Bank', mono: false }); expect(dialogEl()).toBeNull(); }); it('„Einfarbig“ geht mit: gewählt → mono true', async () => { upload.mockResolvedValue(logo()); const { outcome } = await setup(); input('mono').click(); fill(); send(); await outcome; expect(upload).toHaveBeenCalledWith(expect.anything(), { name: 'Beispielbank', mono: true }); }); it('ohne transparente Bereiche geht mono nie als true mit', async () => { prepare.mockResolvedValue(prepared({ hasTransparency: false })); upload.mockResolvedValue(logo()); const { outcome } = await setup(); input('mono').click(); // gesperrt: ändert nichts expect(input('mono').checked).toBe(false); input('mono').checked = true; // selbst ein von außen gesetztes Häkchen zählt nicht fill(); send(); await outcome; expect(upload).toHaveBeenCalledWith(expect.anything(), { name: 'Beispielbank', mono: false }); }); it('ein Doppelklick auf „Hochladen“ sendet nicht zweimal', async () => { let finish!: (l: CommunityLogo) => void; upload.mockReturnValue(new Promise((resolve) => (finish = resolve))); const { outcome } = await setup(); fill(); const dialogForm = form(); send(); send(); expect(upload).toHaveBeenCalledOnce(); finish(logo()); await outcome; // Ein verspätetes Absenden am schon geschlossenen Dialog bleibt wirkungslos. dialogForm.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); expect(upload).toHaveBeenCalledOnce(); }); it('während des Sendens: Knöpfe und Felder gesperrt, Statustext, danach alles wieder frei', async () => { let fail!: (e: Error) => void; upload.mockReturnValue(new Promise((_, reject) => (fail = reject))); await setup(); fill(); send(); expect(buttonByText('Hochladen')!.disabled).toBe(true); expect(buttonByText('Abbrechen')!.disabled).toBe(true); for (const name of ['file', 'mono', 'name', 'consent']) expect(input(name).disabled).toBe(true); expect(form().getAttribute('aria-busy')).toBe('true'); expect(statusText()).toBe('Wird hochgeladen …'); fail(new Error('weg')); await settle(); expect(buttonByText('Hochladen')!.disabled).toBe(false); expect(buttonByText('Abbrechen')!.disabled).toBe(false); for (const name of ['file', 'mono', 'name', 'consent']) expect(input(name).disabled).toBe(false); expect(form().getAttribute('aria-busy')).toBe('false'); expect(statusText()).toBe(''); }); it('während des Sendens schließen weder Esc noch ein Klick auf den Hintergrund den Dialog', async () => { let finish!: (l: CommunityLogo) => void; upload.mockReturnValue(new Promise((resolve) => (finish = resolve))); const { outcome } = await setup(); fill(); send(); const esc = new Event('cancel', { cancelable: true }); dialogEl()!.dispatchEvent(esc); expect(esc.defaultPrevented).toBe(true); dialogEl()!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); dialogEl()!.dispatchEvent(new MouseEvent('click', { bubbles: true })); expect(dialogEl()?.open).toBe(true); finish(logo()); await outcome; }); it('schließt der Browser den Dialog mitten im Senden, geht er wieder auf und löst erst mit dem echten Ergebnis auf', async () => { let finish!: (l: CommunityLogo) => void; upload.mockReturnValue(new Promise((resolve) => (finish = resolve))); let resolved: CommunityLogo | null | undefined; const result = logo(); const { outcome } = await setup(); void outcome.then((value) => (resolved = value)); fill(); send(); dialogEl()!.close(); // wie ein Browser, der Esc trotz verhindertem `cancel` auswertet await settle(); expect(dialogEl()?.open).toBe(true); dialogEl()!.dispatchEvent(new Event('close')); await settle(); expect(resolved).toBeUndefined(); expect(dialogEl()?.open).toBe(true); finish(result); await outcome; expect(resolved).toBe(result); expect(dialogEl()).toBeNull(); }); it('scheitert das Senden, nachdem der Browser den Dialog geschlossen hat, ist der Fehler sichtbar (Dialog wieder offen)', async () => { let fail!: (e: Error) => void; upload.mockReturnValue(new Promise((_, reject) => (fail = reject))); await setup(); fill(); send(); dialogEl()!.close(); fail(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable')); await settle(); expect(dialogEl()?.open).toBe(true); expect(errorText()).toBe('Hochladen fehlgeschlagen – Der Dienst ist gerade nicht erreichbar.'); }); it('Fehler des Dienstes: „Hochladen fehlgeschlagen – “, der Dialog bleibt offen, ein neuer Versuch ist möglich', async () => { upload.mockRejectedValueOnce( new ApiError( 429, 'Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.', true, 'tooManyLogos', ), ); const { outcome } = await setup(); fill({ name: 'Meine Bank' }); send(); await settle(); expect(errorText()).toBe( 'Hochladen fehlgeschlagen – Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.', ); expect(dialogEl()?.open).toBe(true); // Die Eingaben bleiben. expect(input('name').value).toBe('Meine Bank'); expect(input('consent').checked).toBe(true); upload.mockResolvedValueOnce(logo()); send(); await expect(outcome).resolves.toMatchObject({ id: 'L1' }); expect(upload).toHaveBeenCalledTimes(2); expect(upload.mock.calls[1][0]).toBe(upload.mock.calls[0][0]); // dasselbe fertige Logo, nicht neu verarbeitet expect(prepare).toHaveBeenCalledOnce(); }); it('Fehlertext des Dienstes in der Sprache der Seite (nach Fehlerschlüssel)', async () => { setLang('en'); upload.mockRejectedValue(new ApiError(403, 'Dazu hast du keine Berechtigung.', true, 'forbidden')); await setup(); fill(); send(); await settle(); expect(errorText()).toBe('Upload failed – You are not allowed to do that.'); }); it('Servertexte erscheinen als Text, nie als HTML', async () => { upload.mockRejectedValue(new ApiError(400, '')); await setup(); fill(); send(); await settle(); expect(errorText()).toBe('Hochladen fehlgeschlagen – '); expect(dialogEl()!.querySelector('[role="alert"] img')).toBeNull(); }); it('abgebrochene Anmeldung: Hinweis „Bitte melde dich an“ statt „Hochladen fehlgeschlagen“', async () => { upload.mockRejectedValue(new LoginCancelled()); await setup(); fill(); send(); await settle(); expect(errorText()).toBe(LOGIN_NEEDED); }); it('andere Fehler werden übersetzt, interne Details erscheinen nie', async () => { upload.mockRejectedValueOnce(new TypeError('Failed to fetch')); await setup(); fill(); send(); await settle(); expect(errorText()).toBe('Hochladen fehlgeschlagen – Keine Verbindung zum Server.'); upload.mockRejectedValueOnce(new Error('boom: interne Details')); send(); await settle(); expect(errorText()).toBe('Hochladen fehlgeschlagen – Etwas ist schiefgelaufen. Bitte nochmal versuchen.'); }); }); describe('Logo-Dialog: Benutzername nötig (403 usernameRequired)', () => { const required = () => new ApiError(403, 'Bitte wähle zuerst einen Benutzernamen.', true, 'usernameRequired'); const REASON = 'Hochladen fehlgeschlagen – Bitte wähle zuerst einen Benutzernamen.'; it('der Namensdialog „required“ öffnet sich (mit dem Signal der Seite); der Dialog bleibt offen und nennt den Grund', async () => { mocks.usernameDialog.mockReturnValue(new Promise(() => {})); // der Nutzer überlegt noch upload.mockRejectedValueOnce(required()); const controller = new AbortController(); await setup({ signal: controller.signal }); fill(); send(); await settle(); expect(mocks.usernameDialog).toHaveBeenCalledExactlyOnceWith({ mode: 'required', signal: controller.signal }); expect(dialogEl()?.open).toBe(true); expect(errorText()).toBe(REASON); expect(buttonByText('Hochladen')!.disabled).toBe(false); }); it('nach dem gespeicherten Namen ist der Grund weg, die Eingaben und das Logo bleiben, der Fokus liegt auf „Hochladen“ und ein neuer Versuch geht durch', async () => { mocks.usernameDialog.mockResolvedValue('Kenso'); upload.mockRejectedValueOnce(required()); const { outcome } = await setup(); fill({ name: 'Meine Bank' }); send(); await settle(); expect(errorText()).toBe(''); expect(input('name').value).toBe('Meine Bank'); expect(input('consent').checked).toBe(true); expect(previews()).toHaveLength(2); // das verarbeitete Logo steht noch expect(dialogEl()?.open).toBe(true); expect(document.activeElement).toBe(buttonByText('Hochladen')); upload.mockResolvedValueOnce(logo()); send(); await expect(outcome).resolves.toMatchObject({ id: 'L1' }); expect(upload).toHaveBeenCalledTimes(2); expect(upload.mock.calls[1][0]).toBe(upload.mock.calls[0][0]); expect(prepare).toHaveBeenCalledOnce(); expect(mocks.usernameDialog).toHaveBeenCalledTimes(1); }); it('„Abbrechen“ im Namensdialog: der Grund bleibt stehen, nichts ist hochgeladen, ein neuer Versuch fragt der Dienst wieder', async () => { mocks.usernameDialog.mockResolvedValue(null); upload.mockRejectedValue(required()); await setup(); fill(); send(); await settle(); expect(errorText()).toBe(REASON); expect(dialogEl()?.open).toBe(true); send(); await settle(); expect(upload).toHaveBeenCalledTimes(2); expect(mocks.usernameDialog).toHaveBeenCalledTimes(2); }); it('lässt sich der Namensdialog nicht öffnen, bleibt der Grund stehen und nichts wirft', async () => { mocks.usernameDialog.mockRejectedValue(new Error('showModal kaputt')); upload.mockRejectedValueOnce(required()); await setup(); fill(); send(); await settle(); expect(errorText()).toBe(REASON); expect(dialogEl()?.open).toBe(true); expect(buttonByText('Hochladen')!.disabled).toBe(false); }); it('wird der Dialog währenddessen geschlossen (Seite verlassen), bleibt die Antwort des Namensdialogs folgenlos', async () => { let answer!: (name: string | null) => void; mocks.usernameDialog.mockReturnValue(new Promise((resolve) => (answer = resolve))); upload.mockRejectedValueOnce(required()); const controller = new AbortController(); const { outcome } = await setup({ signal: controller.signal }); fill(); send(); await settle(); controller.abort(); await expect(outcome).resolves.toBeNull(); answer('Kenso'); await settle(); expect(dialogEl()).toBeNull(); expect(upload).toHaveBeenCalledOnce(); }); it('andere Fehler (auch andere 403) öffnen den Namensdialog nicht', async () => { upload.mockRejectedValueOnce(new ApiError(403, 'Dazu hast du keine Berechtigung.', true, 'forbidden')); await setup(); fill(); send(); await settle(); upload.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable')); send(); await settle(); upload.mockRejectedValueOnce(new TypeError('Failed to fetch')); send(); await settle(); expect(mocks.usernameDialog).not.toHaveBeenCalled(); }); it('Englisch: der Grund erscheint auf Englisch', async () => { setLang('en'); mocks.usernameDialog.mockResolvedValue(null); upload.mockRejectedValueOnce(required()); await setup(); fill(); send(); await settle(); expect(errorText()).toBe('Upload failed – Please choose a username first.'); }); }); describe('Logo-Dialog: Standardwerte', () => { it('ohne prepare und upload: prepareLogo, und uploadPreparedLogo über withLogin (abgelaufene Sitzung → Anmeldung)', async () => { const p = prepared(); mocks.prepareLogo.mockResolvedValue(p); const result = logo(); mocks.uploadPreparedLogo.mockResolvedValue(result); const file = imageFile(); const outcome = logoUploadDialog(); choose(file); await settle(); expect(mocks.prepareLogo).toHaveBeenCalledExactlyOnceWith(file); fill(); send(); await expect(outcome).resolves.toBe(result); expect(mocks.withLogin).toHaveBeenCalledOnce(); expect(mocks.uploadPreparedLogo).toHaveBeenCalledExactlyOnceWith(p, { name: 'Beispielbank', mono: false }); }); }); describe('Logo-Dialog: Schließen', () => { it('„Abbrechen“ schließt ohne zu senden und gibt die Object-URL frei', async () => { const { outcome } = await setup(); fill(); buttonByText('Abbrechen')!.click(); await expect(outcome).resolves.toBeNull(); expect(upload).not.toHaveBeenCalled(); expect(dialogEl()).toBeNull(); expect(revoked).toEqual(['blob:logo-1']); }); it('Esc (close-Ereignis) und Klick auf den Hintergrund schließen; ohne Senden ist Esc nicht verhindert', async () => { const first = open(); const esc = new Event('cancel', { cancelable: true }); dialogEl()!.dispatchEvent(esc); expect(esc.defaultPrevented).toBe(false); dialogEl()!.dispatchEvent(new Event('close')); await expect(first).resolves.toBeNull(); expect(dialogEl()).toBeNull(); const second = open(); dialogEl()!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); dialogEl()!.dispatchEvent(new MouseEvent('click', { bubbles: true })); await expect(second).resolves.toBeNull(); }); it('ein Markieren, das außerhalb endet, schließt nicht (Drücken im Formular, Loslassen auf dem Hintergrund)', () => { void open(); input('name').dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); dialogEl()!.dispatchEvent(new MouseEvent('click', { bubbles: true })); expect(dialogEl()?.open).toBe(true); }); it('das Abbrechen-Signal schließt den Dialog, auch während des Sendens; eine spätere Antwort bleibt folgenlos', async () => { const controller = new AbortController(); let finish!: (l: CommunityLogo) => void; upload.mockReturnValue(new Promise((resolve) => (finish = resolve))); const { outcome } = await setup({ signal: controller.signal }); fill(); send(); controller.abort(); await expect(outcome).resolves.toBeNull(); expect(dialogEl()).toBeNull(); expect(revoked).toEqual(['blob:logo-1']); finish(logo()); await settle(); expect(dialogEl()).toBeNull(); }); it('ein Fehler nach dem Schließen durch das Signal wird verschluckt', async () => { const controller = new AbortController(); let fail!: (e: Error) => void; upload.mockReturnValue(new Promise((_, reject) => (fail = reject))); const { outcome } = await setup({ signal: controller.signal }); fill(); send(); controller.abort(); await outcome; fail(new ApiError(503, 'weg')); await settle(); expect(dialogEl()).toBeNull(); }); it('ein schon abgebrochenes Signal öffnet gar nichts', async () => { const controller = new AbortController(); controller.abort(); await expect(open({ signal: controller.signal })).resolves.toBeNull(); expect(dialogEl()).toBeNull(); }); it('der Fokus kehrt zum auslösenden Knopf zurück', async () => { const opener = document.createElement('button'); document.body.append(opener); opener.focus(); const outcome = open(); buttonByText('Abbrechen')!.click(); await outcome; expect(document.activeElement).toBe(opener); }); });