= {}): void {
- const v = { title: 'Meine Karte', issuer: '', tags: '', displayName: '', consent: true, ...values };
+ const v = { title: 'Meine Karte', issuer: '', tags: '', 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;
}
@@ -53,13 +54,13 @@ function open(opts: { cardName?: string; signal?: AbortSignal } = {}): Promise {
submit.mockReset();
+ mocks.usernameDialog.mockReset();
+ mocks.usernameDialog.mockResolvedValue(null);
document.body.innerHTML = '';
- localStorage.clear(); // der Anzeigename wird gemerkt und im nächsten Test wieder vorbelegt
});
afterEach(() => {
document.body.innerHTML = '';
- localStorage.clear();
vi.restoreAllMocks();
});
@@ -68,24 +69,23 @@ describe('Veröffentlichen-Dialog: Felder', () => {
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('input')].map((i) => i.name)).toEqual(['title', 'issuer', 'tags', '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('');
+ for (const name of ['title', 'issuer', 'tags']) 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)', () => {
+ it('es gibt kein Feld „Anzeigename“ mehr (die Karte zeigt den Benutzernamen, Spec § 5), und nie eine E-Mail im Dialog', () => {
void open();
- expect(input('displayName').value).toBe('');
- expect(input('displayName').placeholder).toBe('Anonym');
+ expect(dialogEl()!.querySelector('input[name="displayName"]')).toBeNull();
+ expect(dialogEl()!.textContent).not.toMatch(/Anzeigename|Anonym/);
expect(dialogEl()!.textContent).not.toMatch(/@/);
});
@@ -99,7 +99,7 @@ describe('Veröffentlichen-Dialog: Felder', () => {
void open();
const labels = [...form().querySelectorAll('label')];
const inputs = [...form().querySelectorAll('input')];
- expect(labels).toHaveLength(5);
+ expect(labels).toHaveLength(4);
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);
@@ -150,10 +150,6 @@ describe('Veröffentlichen-Dialog: Prüfung vor dem Senden', () => {
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');
});
@@ -178,7 +174,7 @@ describe('Veröffentlichen-Dialog: Prüfung vor dem Senden', () => {
await invalid({ title: '\u200b\u200d' }, 'Der Titel enthält ungültige Zeichen.', 'title');
});
- it('unsichtbare Zeichen in Herausgeber, Tags und Anzeigename', async () => {
+ it('unsichtbare Zeichen im Herausgeber', async () => {
await invalid({ issuer: 'Bank\u200f' }, 'Herausgeber / Bank enthält ungültige Zeichen.', 'issuer');
});
@@ -186,22 +182,18 @@ describe('Veröffentlichen-Dialog: Prüfung vor dem Senden', () => {
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, ✨' });
+ fill({ title: 'Größe 😀 – Möwe', issuer: 'Café-Bank', 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 () => {
+ it('Grenzen sind inklusive: 60/40 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,' });
+ fill({ title: 'x'.repeat(60), issuer: 'y'.repeat(40), tags: 'A, a, , b, c, d, e,' });
send();
await expect(outcome).resolves.toBe('published');
expect(submit).toHaveBeenCalledOnce();
@@ -229,7 +221,6 @@ describe('Veröffentlichen-Dialog: Prüfung vor dem Senden', () => {
it('ohne Fehler verweist kein Feld auf die Fehlerzeile; die Hinweise bleiben', () => {
void open();
expect(describedBy('tags')).toEqual(['publish-tags-hint']);
- expect(describedBy('displayName')).toEqual(['publish-displayName-hint']);
expect(describedBy('title')).toEqual([]);
expect(describedBy('consent')).toEqual([]);
});
@@ -245,14 +236,10 @@ describe('Veröffentlichen-Dialog: Prüfung vor dem Senden', () => {
expect(describedBy('tags')).toEqual(['publish-tags-hint', 'publish-error']);
expect(describedBy('title')).toEqual([]); // nur das Feld mit dem Problem
- fill({ displayName: 'x'.repeat(31) });
- send();
- expect(describedBy('displayName')).toEqual(['publish-displayName-hint', 'publish-error']);
- expect(describedBy('tags')).toEqual(['publish-tags-hint']);
-
fill({ consent: false });
send();
expect(describedBy('consent')).toEqual(['publish-error']);
+ expect(describedBy('tags')).toEqual(['publish-tags-hint']); // 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', () => {
@@ -269,7 +256,7 @@ describe('Veröffentlichen-Dialog: Prüfung vor dem Senden', () => {
});
it('validatePublishForm meldet das erste Problem in Formularreihenfolge, sonst null', () => {
- const ok: PublishFormValues = { title: 'T', issuer: '', tags: '', displayName: '', consent: true };
+ const ok: PublishFormValues = { title: 'T', issuer: '', tags: '', 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');
@@ -278,23 +265,23 @@ describe('Veröffentlichen-Dialog: Prüfung vor dem Senden', () => {
});
describe('Veröffentlichen-Dialog: Senden', () => {
- it('sendet die getrimmten Eingaben (Tags roh, Normalisierung macht publishSavedCard) und schließt bei Erfolg', async () => {
+ it('sendet die getrimmten Eingaben (Tags roh, Normalisierung macht publishSavedCard) und schließt bei Erfolg; einen Anzeigenamen gibt es nicht mehr', async () => {
submit.mockResolvedValue({ id: 'g1' });
const outcome = open();
- fill({ title: ' Mein Motiv ', issuer: ' Beispielbank ', tags: 'Visa, Gold', displayName: ' Kenso ' });
+ fill({ title: ' Mein Motiv ', issuer: ' Beispielbank ', tags: 'Visa, Gold' });
send();
await expect(outcome).resolves.toBe('published');
- expect(submit).toHaveBeenCalledWith({ title: 'Mein Motiv', issuer: 'Beispielbank', tags: 'Visa, Gold', displayName: 'Kenso' });
+ expect(submit).toHaveBeenCalledExactlyOnceWith({ title: 'Mein Motiv', issuer: 'Beispielbank', tags: 'Visa, Gold' });
expect(dialogEl()).toBeNull();
});
- it('leere optionale Felder werden leer gesendet (Anonym macht der Server)', async () => {
+ it('leere optionale Felder werden leer gesendet', async () => {
submit.mockResolvedValue({ id: 'g1' });
const outcome = open();
fill({ title: 'Nur Titel' });
send();
await outcome;
- expect(submit).toHaveBeenCalledWith({ title: 'Nur Titel', issuer: '', tags: '', displayName: '' });
+ expect(submit).toHaveBeenCalledExactlyOnceWith({ title: 'Nur Titel', issuer: '', tags: '' });
});
it('während des Sendens: „Veröffentlichen“ und „Abbrechen“ gesperrt, Felder gesperrt, zweites Senden wird ignoriert', async () => {
@@ -442,6 +429,119 @@ describe('Veröffentlichen-Dialog: Senden', () => {
});
});
+describe('Veröffentlichen-Dialog: Benutzername nötig (403 usernameRequired)', () => {
+ const required = () => new ApiError(403, 'Bitte wähle zuerst einen Benutzernamen.', true, 'usernameRequired');
+ const REASON = 'Veröffentlichen 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
+ submit.mockRejectedValueOnce(required());
+ const controller = new AbortController();
+ void open({ 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('Veröffentlichen')!.disabled).toBe(false);
+ });
+
+ it('nach dem gespeicherten Namen ist der Grund weg, die Eingaben bleiben, der Fokus liegt auf „Veröffentlichen“ und ein neuer Versuch geht durch', async () => {
+ mocks.usernameDialog.mockResolvedValue('Kenso');
+ submit.mockRejectedValueOnce(required());
+ const outcome = open();
+ fill({ title: 'Mein Motiv', issuer: 'Bank', tags: 'visa' });
+ send();
+ await settle();
+ expect(errorText()).toBe('');
+ expect(input('title').value).toBe('Mein Motiv');
+ expect(input('issuer').value).toBe('Bank');
+ expect(consent().checked).toBe(true);
+ expect(dialogEl()?.open).toBe(true);
+ expect(document.activeElement).toBe(buttonByText('Veröffentlichen'));
+
+ submit.mockResolvedValueOnce({ id: 'g1' });
+ send();
+ await expect(outcome).resolves.toBe('published');
+ expect(submit).toHaveBeenCalledTimes(2);
+ expect(mocks.usernameDialog).toHaveBeenCalledTimes(1);
+ });
+
+ it('„Abbrechen“ im Namensdialog: der Grund bleibt stehen, nichts ist veröffentlicht, der Dialog bleibt zum Abbrechen oder Wiederholen', async () => {
+ mocks.usernameDialog.mockResolvedValue(null);
+ submit.mockRejectedValueOnce(required());
+ void open();
+ fill();
+ send();
+ await settle();
+ expect(errorText()).toBe(REASON);
+ expect(dialogEl()?.open).toBe(true);
+ expect(submit).toHaveBeenCalledOnce();
+ // ein neuer Versuch fragt der Dienst wieder, und der Namensdialog erscheint wieder
+ submit.mockRejectedValueOnce(required());
+ send();
+ await settle();
+ 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'));
+ submit.mockRejectedValueOnce(required());
+ void open();
+ fill();
+ send();
+ await settle();
+ expect(errorText()).toBe(REASON);
+ expect(dialogEl()?.open).toBe(true);
+ expect(buttonByText('Veröffentlichen')!.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)));
+ submit.mockRejectedValueOnce(required());
+ const controller = new AbortController();
+ const outcome = open({ signal: controller.signal });
+ fill();
+ send();
+ await settle();
+ controller.abort();
+ await expect(outcome).resolves.toBe('cancelled');
+ answer('Kenso');
+ await settle();
+ expect(dialogEl()).toBeNull();
+ expect(submit).toHaveBeenCalledOnce();
+ });
+
+ it('andere Fehler (auch andere 403) öffnen den Namensdialog nicht', async () => {
+ submit.mockRejectedValueOnce(new ApiError(403, 'Dazu hast du keine Berechtigung.', true, 'forbidden'));
+ void open();
+ fill();
+ send();
+ await settle();
+ submit.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable'));
+ send();
+ await settle();
+ submit.mockRejectedValueOnce(new Error('boom'));
+ send();
+ await settle();
+ expect(mocks.usernameDialog).not.toHaveBeenCalled();
+ });
+
+ it('Englisch: der Grund erscheint auf Englisch', async () => {
+ setLang('en', { persist: false });
+ mocks.usernameDialog.mockResolvedValue(null);
+ submit.mockRejectedValueOnce(required());
+ void open();
+ fill();
+ send();
+ await settle();
+ expect(errorText()).toBe('Publishing failed – Please choose a username first.');
+ expect(germanLeft(dialogEl()!)).toEqual([]);
+ });
+});
+
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));
@@ -461,81 +561,6 @@ describe('Veröffentlichen-Dialog: fehlendes Logo der Karte', () => {
});
});
-describe('Veröffentlichen-Dialog: gemerkter Anzeigename', () => {
- it('der Anzeigename ist mit dem zuletzt benutzten Namen (aircard.displayName) vorbelegt, die anderen Felder bleiben leer', () => {
- expect(DISPLAY_NAME_KEY).toBe('aircard.displayName');
- localStorage.setItem('aircard.displayName', 'Kenso');
- void open();
- expect(input('displayName').value).toBe('Kenso');
- for (const name of ['title', 'issuer', 'tags']) expect(input(name).value).toBe('');
- expect(input('displayName').placeholder).toBe('Anonym');
- });
-
- it('gesperrter Speicher: das Feld bleibt leer, der Dialog öffnet sich trotzdem', () => {
- vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
- throw new DOMException('gesperrt', 'SecurityError');
- });
- void open();
- expect(dialogEl()?.open).toBe(true);
- expect(input('displayName').value).toBe('');
- });
-
- it('nach dem Veröffentlichen wird der (getrimmte) Anzeigename gemerkt', async () => {
- submit.mockResolvedValue({ id: 'g1' });
- const outcome = open();
- fill({ displayName: ' Kenso ' });
- send();
- await expect(outcome).resolves.toBe('published');
- expect(localStorage.getItem('aircard.displayName')).toBe('Kenso');
- });
-
- it('ein geleerter Anzeigename entfernt den gemerkten (Anonym)', async () => {
- localStorage.setItem('aircard.displayName', 'Kenso');
- submit.mockResolvedValue({ id: 'g1' });
- const outcome = open();
- fill({ displayName: '' });
- send();
- await outcome;
- expect(localStorage.getItem('aircard.displayName')).toBeNull();
- });
-
- it('bei einem Fehler wird nichts gemerkt, auch nicht bei „schon veröffentlicht“', async () => {
- localStorage.setItem('aircard.displayName', 'Alt');
- submit.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
- const outcome = open();
- fill({ displayName: 'Neu' });
- send();
- await settle();
- expect(localStorage.getItem('aircard.displayName')).toBe('Alt');
- submit.mockRejectedValueOnce(new ApiError(409, 'Diese Karte ist schon veröffentlicht.'));
- send();
- await settle();
- expect(localStorage.getItem('aircard.displayName')).toBe('Alt');
- buttonByText('Schließen')!.click();
- await expect(outcome).resolves.toBe('already');
- expect(localStorage.getItem('aircard.displayName')).toBe('Alt');
- });
-
- it('beim Abbrechen und beim Schließen durch das Signal wird nichts gemerkt', 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({ displayName: 'Kenso' });
- send();
- controller.abort();
- await outcome;
- finish({ id: 'g1' });
- await settle();
- expect(localStorage.getItem('aircard.displayName')).toBeNull();
-
- void open();
- fill({ displayName: 'Kenso' });
- buttonByText('Abbrechen')!.click();
- expect(localStorage.getItem('aircard.displayName')).toBeNull();
- });
-});
-
describe('Veröffentlichen-Dialog: Schließen', () => {
it('„Abbrechen“ schließt ohne zu senden', async () => {
const outcome = open();
@@ -624,11 +649,8 @@ describe('Veröffentlichen-Dialog auf Englisch', () => {
'Title (required)',
'Issuer / bank (optional)',
'Tags (optional)',
- 'Display name (optional)',
]);
expect(dialog.querySelector('#publish-tags-hint')!.textContent).toBe('Up to 5, separated by commas, e.g. “visa, gold”.');
- expect(dialog.querySelector('#publish-displayName-hint')!.textContent).toBe('Leave empty for “Anonymous”. Your email address is never shown.');
- expect(input('displayName').placeholder).toBe('Anonymous');
expect(consent().closest('label')!.textContent).toBe('The image is mine or I’m allowed to use it, and it contains no real card data.');
expect(buttonByText('Cancel')).toBeTruthy();
expect(buttonByText('Publish')).toBeTruthy();
@@ -642,7 +664,7 @@ describe('Veröffentlichen-Dialog auf Englisch', () => {
});
it('die Prüfung vor dem Senden meldet in der Sprache der Seite', () => {
- const ok: PublishFormValues = { title: 'T', issuer: '', tags: '', displayName: '', consent: true };
+ const ok: PublishFormValues = { title: 'T', issuer: '', tags: '', consent: true };
const problem = (values: Partial) => validatePublishForm({ ...ok, ...values })?.message;
expect(problem({ title: ' ' })).toBe('Please enter a title.');
expect(problem({ title: 'a\tb' })).toBe('The title contains invalid characters.');
@@ -652,8 +674,6 @@ describe('Veröffentlichen-Dialog auf Englisch', () => {
expect(problem({ tags: 'visa, gol\u200bd' })).toBe('Tags contain invalid characters.');
expect(problem({ tags: `visa, ${'x'.repeat(21)}` })).toBe('A tag can be at most 20 characters long.');
expect(problem({ tags: 'a, b, c, d, e, f' })).toBe('Please enter at most 5 tags.');
- expect(problem({ displayName: 'Ken\u2060so' })).toBe('The display name contains invalid characters.');
- expect(problem({ displayName: 'x'.repeat(31) })).toBe('The display name is too long (at most 30 characters).');
expect(problem({ consent: false })).toBe('Please confirm the statement about the image.');
});
diff --git a/web/src/community/publish-dialog.ts b/web/src/community/publish-dialog.ts
index 10a2324..d230afa 100644
--- a/web/src/community/publish-dialog.ts
+++ b/web/src/community/publish-dialog.ts
@@ -1,14 +1,16 @@
-// Veröffentlichen-Dialog von „Meine Karten“ (Spec § 6): Titel, Herausgeber/Bank, Tags, Anzeigename und die Pflicht-Checkbox.
+// Veröffentlichen-Dialog von „Meine Karten“ (Spec § 6): Titel, Herausgeber/Bank, Tags und die Pflicht-Checkbox. Einen Anzeigenamen gibt
+// es nicht mehr: die Detailseite zeigt den Benutzernamen des Kontos (Spec § 5). Hat das Konto keinen, antwortet der Dienst mit 403
+// `usernameRequired`: dann öffnet sich der Namensdialog, und mit dem gespeicherten Namen lässt es sich noch einmal versuchen.
// Eigenes