feat(community): Veröffentlichen aus Meine Karten

Neuer Dialog (publish-dialog.ts) laut Spec § 6: Titel (Pflicht, höchstens 60), Herausgeber/Bank (40),
Tags (bis 5, je 20, Komma-getrennt), Anzeigename (30, leer = Anonym, nie vorbelegt) und die
Pflicht-Checkbox im Wortlaut der Spec. Prüfung vor dem Senden mit deutschen Texten, während des Sendens
lässt sich der Dialog nicht schließen; Fehler stehen im Dialog („Veröffentlichen fehlgeschlagen – …“).

Meine Karten zeigt je Kachel den Status aus myPublications (einmal je Seitenaufbau, danach lokal
nachgeführt): Veröffentlicht + Ansehen + Zurückziehen, Ausgeblendet (gemeldet) + Zurückziehen, sonst
Veröffentlichen. Bei 409 wird der Status neu geholt; bleibt der Ausgang offen (503/5xx/429/408), wird vor
der Fehlermeldung nachgesehen und bei Erfolg der Erfolgszustand gezeigt. Zurückziehen fragt nach, 404 gilt
als erledigt. Nicht angemeldet läuft über withLogin. Das Löschen einer veröffentlichten Karte weist darauf
hin, dass die Veröffentlichung bleibt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 15:47:56 +00:00
1 parent be440bc8e9
commit f6b9ee61f4
7 files changed
+1403 -9

No files matched your search

+376
View File
@@ -0,0 +1,376 @@
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 { NOT_RENDERED, TOO_LARGE, type PublishForm } from './publish';
import {
CONSENT_TEXT,
publishDialog,
validatePublishForm,
type PublishFormValues,
type PublishOutcome,
} from './publish-dialog';
type Submit = (form: PublishForm) => Promise<{ id: string }>;
const submit = vi.fn<Submit>();
const dialogEl = () => document.querySelector<HTMLDialogElement>('dialog.publish-dialog');
const input = (name: string) => dialogEl()!.querySelector<HTMLInputElement>(`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<PublishFormValues> = {}): 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<PublishOutcome> {
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('nennt den Kartennamen als Text, nie als HTML', () => {
void open({ cardName: '<img src=x onerror=alert(1)>' });
expect(dialogEl()!.querySelector('img')).toBeNull();
expect(dialogEl()!.textContent).toContain('<img src=x onerror=alert(1)>');
});
});
describe('Veröffentlichen-Dialog: Prüfung vor dem Senden', () => {
const invalid = async (values: Partial<PublishFormValues>, 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');
});
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('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: 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);
});
});