Files
AirCardIOS/web/src/logos/logo-dialog.test.ts
T
ClaudeandClaude Opus 5.5 56cb67be4b feat(community): „von @name“ statt Anzeigename, Benutzername vor dem Hochladen und Veröffentlichen
Logo-Kacheln, Logo-Infozeile und die Detailseite einer Community-Karte zeigen den Benutzernamen des Kontos (Hilfsfunktion byline: Profil,
sonst alter Anzeigename, sonst „Anonym“). Die Namen kommen je geladener Seite in einer Abfrage nach, ein Fehler dabei bleibt ohne Fehlertext,
ein eigenes Umbenennen aktualisiert die eigenen Einträge sofort.

Die Felder „Anzeigename“ in den Dialogen „Logo hochladen“ und „Veröffentlichen“ fallen weg, ebenso account/display-name.ts und der
localStorage-Schlüssel; der Client schickt displayName: ''. Vor dem Hochladen und Veröffentlichen verlangt requireUsername() einen Namen
(„Abbrechen“: es passiert nichts); antwortet der Dienst mit 403 usernameRequired, öffnet sich der Namensdialog, danach ist ein neuer Versuch möglich.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:06:44 +00:00

1050 lines
42 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<typeof import('../account/with-login')>()),
withLogin: mocks.withLogin,
}));
vi.mock('./prepare-logo', async (importOriginal) => ({
...(await importOriginal<typeof import('./prepare-logo')>()),
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<PreparedLogo>;
type Upload = (prepared: PreparedLogo, form: LogoForm) => Promise<CommunityLogo>;
const prepare = vi.fn<Prepare>();
const upload = vi.fn<Upload>();
/** 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> = {}): PreparedLogo => ({
png: new Blob(['png-bytes'], { type: 'image/png' }),
width: 200,
height: 80,
hasTransparency: true,
...patch,
});
const logo = (patch: Partial<CommunityLogo> = {}): 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<HTMLDialogElement>('dialog.logo-dialog');
const input = (name: string) => dialogEl()!.querySelector<HTMLInputElement>(`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<HTMLElement>('[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<HTMLElement>('.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<CommunityLogo | null> {
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<CommunityLogo | null> }> {
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<unknown>) => 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 <dialog> 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<HTMLElement>('.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<HTMLElement>('.logo-preview-mask')!.style.getPropertyValue('background-color')).toBe('#FFFFFF');
expect(light.querySelector<HTMLElement>('.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('<img src=x onerror=alert(1)>.png'));
expect(input('name').value).toBe('<img src=x onerror=alert(1)>');
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<typeof fill>[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 – <Text>“, 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, '<img src=x onerror=alert(1)>'));
await setup();
fill();
send();
await settle();
expect(errorText()).toBe('Hochladen fehlgeschlagen – <img src=x onerror=alert(1)>');
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<string | null>((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);
});
});