import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // publish.ts (normalizeTags) und api.ts hängen am Appwrite-Client; der Dialog bekommt `submit` von außen. vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' })); vi.mock('../account/auth', () => ({ currentUserId: () => null, onAuthChange: () => () => {}, onLogout: () => () => {} })); import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login'; import { ApiError } from './api'; import { LOGO_MISSING_PUBLISH, NOT_RENDERED, TOO_LARGE, type PublishForm } from './publish'; import { CONSENT_TEXT, publishDialog, publishFailureText, validatePublishForm, type PublishFormValues, type PublishOutcome, } from './publish-dialog'; type Submit = (form: PublishForm) => Promise<{ id: string }>; const submit = vi.fn(); const dialogEl = () => document.querySelector('dialog.publish-dialog'); const input = (name: string) => dialogEl()!.querySelector(`input[name="${name}"]`)!; const consent = () => input('consent'); 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 settle = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); }; function fill(values: Partial = {}): void { const v = { title: 'Meine Karte', issuer: '', tags: '', displayName: '', consent: true, ...values }; input('title').value = v.title; input('issuer').value = v.issuer; input('tags').value = v.tags; input('displayName').value = v.displayName; consent().checked = v.consent; } function open(opts: { cardName?: string; signal?: AbortSignal } = {}): Promise { return publishDialog({ submit, ...opts }); } beforeEach(() => { submit.mockReset(); document.body.innerHTML = ''; }); afterEach(() => { document.body.innerHTML = ''; }); describe('Veröffentlichen-Dialog: Felder', () => { it('zeigt die Felder der Spec in Reihenfolge, alle leer, die Checkbox ungesetzt', () => { void open({ cardName: 'Urlaub' }); expect(dialogEl()?.open).toBe(true); expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Karte veröffentlichen'); expect([...form().querySelectorAll('input')].map((i) => i.name)).toEqual(['title', 'issuer', 'tags', 'displayName', 'consent']); expect([...form().querySelectorAll('.auth-label')].map((l) => l.textContent)).toEqual([ 'Titel (Pflicht)', 'Herausgeber / Bank (optional)', 'Tags (optional)', 'Anzeigename (optional)', ]); for (const name of ['title', 'issuer', 'tags', 'displayName']) expect(input(name).value).toBe(''); expect(consent().type).toBe('checkbox'); expect(consent().checked).toBe(false); expect(buttonByText('Abbrechen')).toBeTruthy(); expect(buttonByText('Veröffentlichen')).toBeTruthy(); }); it('der Anzeigename ist leer und nennt „Anonym“ als Platzhalter (nie eine E-Mail)', () => { void open(); expect(input('displayName').value).toBe(''); expect(input('displayName').placeholder).toBe('Anonym'); expect(dialogEl()!.textContent).not.toMatch(/@/); }); it('die Pflicht-Checkbox hat genau den Wortlaut der Spec', () => { void open(); expect(CONSENT_TEXT).toBe('Das Bild gehört mir bzw. ich darf es verwenden, und es enthält keine echten Kartendaten.'); expect(consent().closest('label')!.textContent).toBe(CONSENT_TEXT); }); it('jedes Feld hat sein Label (label.control === Eingabe), 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).toHaveLength(5); 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('nennt den Kartennamen als Text, nie als HTML', () => { void open({ cardName: '' }); expect(dialogEl()!.querySelector('img')).toBeNull(); expect(dialogEl()!.textContent).toContain(''); }); }); describe('Veröffentlichen-Dialog: Prüfung vor dem Senden', () => { const invalid = async (values: Partial, message: string, field: string) => { void open(); fill(values); send(); await settle(); expect(errorText()).toBe(message); expect(submit).not.toHaveBeenCalled(); expect(dialogEl()?.open).toBe(true); expect(input(field).getAttribute('aria-invalid')).toBe('true'); expect(document.activeElement).toBe(input(field)); }; it('Titel leer (auch nur Leerzeichen)', async () => { await invalid({ title: ' ' }, 'Bitte gib einen Titel ein.', 'title'); }); it('Titel länger als 60 Zeichen', async () => { await invalid({ title: 'x'.repeat(61) }, 'Der Titel ist zu lang (höchstens 60 Zeichen).', 'title'); }); it('Herausgeber länger als 40 Zeichen', async () => { await invalid({ issuer: 'x'.repeat(41) }, 'Herausgeber / Bank ist zu lang (höchstens 40 Zeichen).', 'issuer'); }); it('sechs Tags', async () => { await invalid({ tags: 'a, b, c, d, e, f' }, 'Bitte gib höchstens 5 Tags an.', 'tags'); }); it('ein Tag länger als 20 Zeichen', async () => { await invalid({ tags: `visa, ${'x'.repeat(21)}` }, 'Ein Tag darf höchstens 20 Zeichen lang sein.', 'tags'); }); it('Anzeigename länger als 30 Zeichen', async () => { await invalid({ displayName: 'x'.repeat(31) }, 'Der Anzeigename ist zu lang (höchstens 30 Zeichen).', 'displayName'); }); it('Checkbox fehlt', async () => { await invalid({ consent: false }, 'Bitte bestätige die Erklärung zum Bild.', 'consent'); }); it('Steuerzeichen werden abgelehnt (der Server täte es auch)', async () => { await invalid({ title: 'a\tb' }, 'Der Titel enthält ungültige Zeichen.', 'title'); }); // Dasselbe Muster wie deploy/api/app.js: C1-Steuerzeichen, Nullbreiten-, Bidi-Zeichen und BOM sind unsichtbar oder ordnen Text um. it.each([ ['Nullbreiten-Leerzeichen', 'a\u200bb'], ['Rechts-nach-links-Überschreibung', 'foto\u202egnp.exe'], ['Isolate', 'a\u2066b'], ['Wortverbinder', 'a\u2060b'], ['C1-Steuerzeichen', 'a\u0085b'], ['BOM mitten im Titel', 'a\ufeffb'], ])('unsichtbare Zeichen werden abgelehnt: %s (Titel)', async (_name, title) => { await invalid({ title }, 'Der Titel enthält ungültige Zeichen.', 'title'); }); it('ein Titel nur aus unsichtbaren Zeichen gilt als ungültig', async () => { await invalid({ title: '\u200b\u200d' }, 'Der Titel enthält ungültige Zeichen.', 'title'); }); it('unsichtbare Zeichen in Herausgeber, Tags und Anzeigename', async () => { await invalid({ issuer: 'Bank\u200f' }, 'Herausgeber / Bank enthält ungültige Zeichen.', 'issuer'); }); it('unsichtbares Zeichen in einem Tag', async () => { await invalid({ tags: 'visa, gol\u200bd' }, 'Tags enthalten ungültige Zeichen.', 'tags'); }); it('unsichtbares Zeichen im Anzeigenamen', async () => { await invalid({ displayName: 'Ken\u2060so' }, 'Der Anzeigename enthält ungültige Zeichen.', 'displayName'); }); it('normale Sonderzeichen (Umlaute, Emoji, Bindestrich, Zeilenabstand durch Leerzeichen) bleiben erlaubt', async () => { submit.mockResolvedValue({ id: 'g1' }); const outcome = open(); fill({ title: 'Größe 😀 – Möwe', issuer: 'Café-Bank', displayName: 'Zoë', tags: 'grün, ✨' }); send(); await expect(outcome).resolves.toBe('published'); }); it('Grenzen sind inklusive: 60/40/30 Zeichen und fünf Tags gehen durch; doppelte und leere Tags zählen nicht', async () => { submit.mockResolvedValue({ id: 'g1' }); const outcome = open(); fill({ title: 'x'.repeat(60), issuer: 'y'.repeat(40), displayName: 'z'.repeat(30), tags: 'A, a, , b, c, d, e,' }); send(); await expect(outcome).resolves.toBe('published'); expect(submit).toHaveBeenCalledOnce(); }); it('eine behobene Eingabe verliert die Markierung beim nächsten Senden', async () => { submit.mockResolvedValue({ id: 'g1' }); void open(); fill({ title: '' }); send(); expect(input('title').getAttribute('aria-invalid')).toBe('true'); fill({ title: 'Ok' }); send(); expect(input('title').getAttribute('aria-invalid')).toBeNull(); expect(errorText()).toBe(''); }); it('validatePublishForm meldet das erste Problem in Formularreihenfolge, sonst null', () => { const ok: PublishFormValues = { title: 'T', issuer: '', tags: '', displayName: '', consent: true }; expect(validatePublishForm(ok)).toBeNull(); expect(validatePublishForm({ ...ok, title: '', consent: false })?.field).toBe('title'); expect(validatePublishForm({ ...ok, tags: 'a,b,c,d,e,f', consent: false })?.field).toBe('tags'); expect(validatePublishForm({ ...ok, consent: false })?.field).toBe('consent'); }); }); describe('Veröffentlichen-Dialog: Senden', () => { it('sendet die getrimmten Eingaben (Tags roh, Normalisierung macht publishSavedCard) und schließt bei Erfolg', async () => { submit.mockResolvedValue({ id: 'g1' }); const outcome = open(); fill({ title: ' Mein Motiv ', issuer: ' Beispielbank ', tags: 'Visa, Gold', displayName: ' Kenso ' }); send(); await expect(outcome).resolves.toBe('published'); expect(submit).toHaveBeenCalledWith({ title: 'Mein Motiv', issuer: 'Beispielbank', tags: 'Visa, Gold', displayName: 'Kenso' }); expect(dialogEl()).toBeNull(); }); it('leere optionale Felder werden leer gesendet (Anonym macht der Server)', async () => { submit.mockResolvedValue({ id: 'g1' }); const outcome = open(); fill({ title: 'Nur Titel' }); send(); await outcome; expect(submit).toHaveBeenCalledWith({ title: 'Nur Titel', issuer: '', tags: '', displayName: '' }); }); it('während des Sendens: „Veröffentlichen“ und „Abbrechen“ gesperrt, Felder gesperrt, zweites Senden wird ignoriert', async () => { let finish!: (r: { id: string }) => void; submit.mockReturnValue(new Promise((r) => (finish = r))); const outcome = open(); fill(); send(); expect(buttonByText('Veröffentlichen')!.disabled).toBe(true); expect(buttonByText('Abbrechen')!.disabled).toBe(true); expect(input('title').disabled).toBe(true); expect(consent().disabled).toBe(true); expect(form().getAttribute('aria-busy')).toBe('true'); expect(statusText()).toBe('Wird veröffentlicht …'); send(); expect(submit).toHaveBeenCalledOnce(); finish({ id: 'g1' }); await expect(outcome).resolves.toBe('published'); }); it('während des Sendens schließen weder Esc noch ein Klick auf den Hintergrund den Dialog', async () => { let finish!: (r: { id: string }) => void; submit.mockReturnValue(new Promise((r) => (finish = r))); const outcome = open(); 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({ id: 'g1' }); await outcome; }); it('schließt der Browser den Dialog mitten im Senden (close-Ereignis), geht er wieder auf und löst erst mit dem echten Ergebnis auf', async () => { let finish!: (r: { id: string }) => void; submit.mockReturnValue(new Promise((r) => (finish = r))); let resolved: PublishOutcome | null = null; const outcome = open().then((o) => (resolved = o)); 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).toBeNull(); expect(dialogEl()?.open).toBe(true); finish({ id: 'g1' }); await outcome; expect(resolved).toBe('published'); expect(dialogEl()).toBeNull(); }); it('wurde der Dialog mitten im Senden geschlossen und das Senden scheitert, ist der Fehler sichtbar (Dialog wieder offen)', async () => { let fail!: (e: Error) => void; submit.mockReturnValue(new Promise((_, r) => (fail = r))); void open(); fill(); send(); dialogEl()!.close(); fail(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.')); await settle(); expect(dialogEl()?.open).toBe(true); expect(errorText()).toBe('Veröffentlichen fehlgeschlagen – Der Dienst ist gerade nicht erreichbar.'); }); it('Fehler des Dienstes: Servertext im Dialog, der Dialog bleibt offen, ein neuer Versuch ist möglich', async () => { submit.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.')); const outcome = open(); fill(); send(); await settle(); expect(errorText()).toBe('Veröffentlichen fehlgeschlagen – Der Dienst ist gerade nicht erreichbar.'); expect(dialogEl()?.open).toBe(true); expect(buttonByText('Veröffentlichen')!.disabled).toBe(false); expect(buttonByText('Abbrechen')!.disabled).toBe(false); expect(input('title').disabled).toBe(false); expect(input('title').value).toBe('Meine Karte'); expect(consent().checked).toBe(true); expect(statusText()).toBe(''); submit.mockResolvedValueOnce({ id: 'g1' }); send(); await expect(outcome).resolves.toBe('published'); expect(submit).toHaveBeenCalledTimes(2); }); it('Validierungsfehler des Dienstes (400) erscheinen mit ihrem Text', async () => { submit.mockRejectedValue(new ApiError(400, 'Der Titel enthält ungültige Zeichen.')); void open(); fill(); send(); await settle(); expect(errorText()).toBe('Veröffentlichen fehlgeschlagen – Der Titel enthält ungültige Zeichen.'); }); it('409: „Diese Karte ist schon veröffentlicht.“, kein neuer Versuch, Ergebnis „already“ nach „Schließen“', async () => { submit.mockRejectedValue(new ApiError(409, 'Diese Karte ist schon veröffentlicht.')); const outcome = open(); fill(); send(); await settle(); expect(statusText()).toBe('Diese Karte ist schon veröffentlicht.'); expect(errorText()).toBe(''); expect(form().querySelector('input')).toBeNull(); expect(buttonByText('Veröffentlichen')).toBeUndefined(); send(); expect(submit).toHaveBeenCalledOnce(); buttonByText('Schließen')!.click(); await expect(outcome).resolves.toBe('already'); expect(dialogEl()).toBeNull(); }); it('abgebrochene Anmeldung: Hinweis „Bitte melde dich an“', async () => { submit.mockRejectedValue(new LoginCancelled()); void open(); fill(); send(); await settle(); expect(errorText()).toBe(LOGIN_NEEDED); }); it('deutsche Meldungen aus dem Rendern und andere Fehler werden übersetzt', async () => { submit.mockRejectedValueOnce(new Error(TOO_LARGE)); void open(); fill(); send(); await settle(); expect(errorText()).toBe(`Veröffentlichen fehlgeschlagen – ${TOO_LARGE}`); submit.mockRejectedValueOnce(new Error(NOT_RENDERED)); send(); await settle(); expect(errorText()).toBe(`Veröffentlichen fehlgeschlagen – ${NOT_RENDERED}`); submit.mockRejectedValueOnce(new TypeError('Failed to fetch')); send(); await settle(); expect(errorText()).toBe('Veröffentlichen fehlgeschlagen – Keine Verbindung zum Server.'); submit.mockRejectedValueOnce(new Error('boom: interne Details')); send(); await settle(); expect(errorText()).toBe('Veröffentlichen fehlgeschlagen – Etwas ist schiefgelaufen. Bitte nochmal versuchen.'); }); }); describe('Veröffentlichen-Dialog: fehlendes Logo der Karte', () => { it('der Text zum fehlenden Logo kommt im Wortlaut an (nicht als „Etwas ist schiefgelaufen“)', async () => { submit.mockRejectedValueOnce(new Error(LOGO_MISSING_PUBLISH)); void open(); fill(); send(); await settle(); expect(errorText()).toBe( 'Veröffentlichen fehlgeschlagen – Das Logo dieser Karte ist nicht mehr verfügbar. Öffne die Karte im Editor und wähle ein anderes.', ); expect(errorText()).toContain(LOGO_MISSING_PUBLISH); expect(errorText()).not.toContain('schiefgelaufen'); }); it('publishFailureText reicht ihn ebenso durch', () => { expect(publishFailureText(new Error(LOGO_MISSING_PUBLISH))).toBe(`Veröffentlichen fehlgeschlagen – ${LOGO_MISSING_PUBLISH}`); }); }); describe('Veröffentlichen-Dialog: Schließen', () => { it('„Abbrechen“ schließt ohne zu senden', async () => { const outcome = open(); fill(); buttonByText('Abbrechen')!.click(); await expect(outcome).resolves.toBe('cancelled'); expect(submit).not.toHaveBeenCalled(); expect(dialogEl()).toBeNull(); }); it('Esc (close-Ereignis) und Klick auf den Hintergrund schließen', async () => { const first = open(); dialogEl()!.dispatchEvent(new Event('close')); await expect(first).resolves.toBe('cancelled'); expect(dialogEl()).toBeNull(); const second = open(); dialogEl()!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); dialogEl()!.dispatchEvent(new MouseEvent('click', { bubbles: true })); await expect(second).resolves.toBe('cancelled'); }); it('ein Markieren, das außerhalb endet, schließt nicht (Drücken im Formular, Loslassen auf dem Hintergrund)', () => { void open(); input('title').dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); dialogEl()!.dispatchEvent(new MouseEvent('click', { bubbles: true })); expect(dialogEl()?.open).toBe(true); }); it('das Abbrechen-Signal (Seite verlassen) schließt den Dialog, auch während des Sendens', async () => { const controller = new AbortController(); let finish!: (r: { id: string }) => void; submit.mockReturnValue(new Promise((r) => (finish = r))); const outcome = open({ signal: controller.signal }); fill(); send(); controller.abort(); await expect(outcome).resolves.toBe('cancelled'); expect(dialogEl()).toBeNull(); // Eine danach eintreffende Antwort bleibt folgenlos. finish({ id: 'g1' }); await settle(); }); it('ein schon abgebrochenes Signal öffnet gar nichts', async () => { const controller = new AbortController(); controller.abort(); await expect(open({ signal: controller.signal })).resolves.toBe('cancelled'); 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; submit.mockReturnValue(new Promise((_, r) => (fail = r))); const outcome = open({ signal: controller.signal }); fill(); send(); controller.abort(); await outcome; fail(new ApiError(503, 'weg')); await settle(); 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); }); });