feat(account): Benutzername beim Registrieren, im Konto-Menü und als einmalige Abfrage
- Neu `account/profile.ts`: Muster, Sperrliste und Schlüssel (ein Test vergleicht sie mit `deploy/api/common.js`), Profile lesen (`getUsername`, `usernamesFor` gesammelt in einer Abfrage und zwischengespeichert, bei einem Fehler nur die bekannten Namen), `isUsernameTaken`, `saveUsername` (POST /api/profil, Zwischenspeicher + `onUsernameChange`), `myUsername`/`loadMyUsername` und der Merker `aircard.usernamePrompted`. `IDS.profiles`. - Neu `account/username-dialog.ts`: Dialog „Benutzername wählen“ in den Modi prompt („Später“), required („Abbrechen“) und change (vorbelegt), Fehler am Feld, Busy-Sperre, höchstens ein Dialog zur Zeit; dazu `requireUsername()`. - Registrieren: Pflichtfeld „Benutzername“ vor der E-Mail. Muster, Sperrliste und Verfügbarkeit werden vor dem Anlegen des Kontos geprüft (kein halbes Konto); danach wird der Name gespeichert, scheitert das, schließt der Login-Dialog und der Namensdialog fragt. - Konto-Menü: „@name“ oben, die E-Mail klein darunter, Eintrag „Benutzername ändern“ bzw. „Benutzername wählen“. - main.ts: Name nach Start und Anmeldung lesen; ohne Namen einmal pro Sitzung der Dialog „prompt“ (nicht, wenn der Name nicht lesbar ist). - Texte in `en.ts`, Stile in `account.css`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
19c8151c7e
commit
3e4bc21f00
14 files changed
+2660
-40
No files matched your search
@@ -7,6 +7,10 @@ const mocks = vi.hoisted(() => ({
|
|||||||
listeners: new Set<(user: TestUser | null) => void>(),
|
listeners: new Set<(user: TestUser | null) => void>(),
|
||||||
logout: vi.fn(),
|
logout: vi.fn(),
|
||||||
requireLogin: vi.fn(),
|
requireLogin: vi.fn(),
|
||||||
|
/** Der Benutzername des angemeldeten Kontos laut Zwischenspeicher (null: keiner oder unbekannt). */
|
||||||
|
name: null as string | null,
|
||||||
|
nameListeners: new Set<(userId: string, username: string) => void>(),
|
||||||
|
usernameDialog: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./auth', () => ({
|
vi.mock('./auth', () => ({
|
||||||
@@ -18,6 +22,14 @@ vi.mock('./auth', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
vi.mock('./login-dialog', () => ({ requireLogin: mocks.requireLogin }));
|
vi.mock('./login-dialog', () => ({ requireLogin: mocks.requireLogin }));
|
||||||
|
vi.mock('./profile', () => ({
|
||||||
|
myUsername: () => mocks.name,
|
||||||
|
onUsernameChange: (fn: (userId: string, username: string) => void) => {
|
||||||
|
mocks.nameListeners.add(fn);
|
||||||
|
return () => mocks.nameListeners.delete(fn);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock('./username-dialog', () => ({ usernameDialog: mocks.usernameDialog }));
|
||||||
|
|
||||||
import { mountAccountMenu, type AccountMenu } from './account-menu';
|
import { mountAccountMenu, type AccountMenu } from './account-menu';
|
||||||
|
|
||||||
@@ -33,11 +45,21 @@ function setUser(user: TestUser | null) {
|
|||||||
for (const fn of [...mocks.listeners]) fn(user);
|
for (const fn of [...mocks.listeners]) fn(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Der Name des Kontos ändert sich (eigene Änderung oder nach dem Lesen), wie `onUsernameChange` es meldet. */
|
||||||
|
function setName(userId: string, name: string) {
|
||||||
|
mocks.name = name;
|
||||||
|
for (const fn of [...mocks.nameListeners]) fn(userId, name);
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks.user = null;
|
mocks.user = null;
|
||||||
|
mocks.name = null;
|
||||||
mocks.listeners.clear();
|
mocks.listeners.clear();
|
||||||
|
mocks.nameListeners.clear();
|
||||||
mocks.logout.mockReset();
|
mocks.logout.mockReset();
|
||||||
mocks.requireLogin.mockReset();
|
mocks.requireLogin.mockReset();
|
||||||
|
mocks.usernameDialog.mockReset();
|
||||||
|
mocks.usernameDialog.mockResolvedValue(null);
|
||||||
document.body.innerHTML = '<div id="account-slot"></div>';
|
document.body.innerHTML = '<div id="account-slot"></div>';
|
||||||
slot = document.getElementById('account-slot') as HTMLElement;
|
slot = document.getElementById('account-slot') as HTMLElement;
|
||||||
menu = mountAccountMenu(slot);
|
menu = mountAccountMenu(slot);
|
||||||
@@ -130,11 +152,156 @@ describe('Konto-Menü', () => {
|
|||||||
expect(buttonByText('Anmelden')).toBeDefined();
|
expect(buttonByText('Anmelden')).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('dispose leert den Slot und meldet den Listener ab', () => {
|
it('dispose leert den Slot und meldet die Listener ab', () => {
|
||||||
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
|
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
|
||||||
menu.ready();
|
menu.ready();
|
||||||
menu.dispose();
|
menu.dispose();
|
||||||
expect(slot.childElementCount).toBe(0);
|
expect(slot.childElementCount).toBe(0);
|
||||||
expect(mocks.listeners.size).toBe(0);
|
expect(mocks.listeners.size).toBe(0);
|
||||||
|
expect(mocks.nameListeners.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Konto-Menü: Benutzername', () => {
|
||||||
|
const signedIn = () => {
|
||||||
|
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
|
||||||
|
};
|
||||||
|
const head = () => panel()!.querySelector<HTMLElement>('.account-head')!;
|
||||||
|
const nameLine = () => panel()!.querySelector('.account-name');
|
||||||
|
const items = () => [...panel()!.querySelectorAll('.account-item')].map((item) => item.textContent);
|
||||||
|
|
||||||
|
it('mit Namen steht „@name“ oben und die E-Mail klein darunter', () => {
|
||||||
|
signedIn();
|
||||||
|
mocks.name = 'Kenso';
|
||||||
|
menu.ready();
|
||||||
|
expect(nameLine()?.textContent).toBe('@Kenso');
|
||||||
|
expect(panel()!.querySelector('.account-email')?.textContent).toBe('kenso@example.com');
|
||||||
|
expect([...head().children].map((child) => child.className)).toEqual(['account-name', 'account-email']); // Name zuerst
|
||||||
|
expect(head().parentElement).toBe(panel());
|
||||||
|
expect(head().nextElementSibling?.textContent).toBe('Meine Karten'); // die Kopfzeile steht vor den Einträgen
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der Avatar bleibt bei der Initiale der E-Mail', () => {
|
||||||
|
signedIn();
|
||||||
|
mocks.name = 'Zebra';
|
||||||
|
menu.ready();
|
||||||
|
expect(avatar()?.textContent).toBe('K');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne Namen steht nur die E-Mail oben (wie bisher)', () => {
|
||||||
|
signedIn();
|
||||||
|
menu.ready();
|
||||||
|
expect(nameLine()).toBeNull();
|
||||||
|
expect(head().textContent).toBe('kenso@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Name und E-Mail erscheinen als Text, nie als HTML', () => {
|
||||||
|
mocks.user = { $id: 'u1', email: '<i>x</i>@example.com' };
|
||||||
|
mocks.name = '<b>Kenso</b>';
|
||||||
|
menu.ready();
|
||||||
|
expect(head().querySelector('b, i')).toBeNull();
|
||||||
|
expect(nameLine()?.textContent).toBe('@<b>Kenso</b>');
|
||||||
|
expect(nameLine()?.getAttribute('title')).toBe('@<b>Kenso</b>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Eintrag „Benutzername ändern“ mit Namen, „Benutzername wählen“ ohne – zwischen „Meine Karten“ und „Abmelden“', () => {
|
||||||
|
signedIn();
|
||||||
|
mocks.name = 'Kenso';
|
||||||
|
menu.ready();
|
||||||
|
expect(items()).toEqual(['Meine Karten', 'Benutzername ändern', 'Abmelden']);
|
||||||
|
setUser(null);
|
||||||
|
mocks.name = null;
|
||||||
|
setUser({ $id: 'u1', email: 'kenso@example.com' });
|
||||||
|
expect(items()).toEqual(['Meine Karten', 'Benutzername wählen', 'Abmelden']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der Eintrag öffnet den Dialog im Modus „change“ und schließt das Menü', () => {
|
||||||
|
signedIn();
|
||||||
|
mocks.name = 'Kenso';
|
||||||
|
menu.ready();
|
||||||
|
avatar()!.click();
|
||||||
|
buttonByText('Benutzername ändern')!.click();
|
||||||
|
expect(mocks.usernameDialog).toHaveBeenCalledExactlyOnceWith({ mode: 'change' });
|
||||||
|
expect(panel()?.hidden).toBe(true);
|
||||||
|
expect(avatar()?.getAttribute('aria-expanded')).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne Namen öffnet „Benutzername wählen“ denselben Dialog', () => {
|
||||||
|
signedIn();
|
||||||
|
menu.ready();
|
||||||
|
buttonByText('Benutzername wählen')!.click();
|
||||||
|
expect(mocks.usernameDialog).toHaveBeenCalledExactlyOnceWith({ mode: 'change' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nach dem Dialog geht der Fokus zurück an den Avatar (der Eintrag im geschlossenen Menü kann ihn nicht halten)', async () => {
|
||||||
|
signedIn();
|
||||||
|
mocks.name = 'Kenso';
|
||||||
|
menu.ready();
|
||||||
|
avatar()!.click();
|
||||||
|
buttonByText('Benutzername ändern')!.click();
|
||||||
|
await vi.waitFor(() => expect(document.activeElement).toBe(avatar()));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ändert sich der Name (Speichern im Dialog), zeigt das Menü sofort den neuen', () => {
|
||||||
|
signedIn();
|
||||||
|
mocks.name = 'Alt';
|
||||||
|
menu.ready();
|
||||||
|
expect(nameLine()?.textContent).toBe('@Alt');
|
||||||
|
setName('u1', 'Neu');
|
||||||
|
expect(nameLine()?.textContent).toBe('@Neu');
|
||||||
|
expect(items()).toContain('Benutzername ändern');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein erst später gelesener Name erscheint ohne weiteres Zutun (Menü steht schon, der Name kommt nach)', () => {
|
||||||
|
signedIn();
|
||||||
|
menu.ready();
|
||||||
|
expect(nameLine()).toBeNull();
|
||||||
|
expect(buttonByText('Benutzername wählen')).toBeDefined();
|
||||||
|
setName('u1', 'Kenso');
|
||||||
|
expect(nameLine()?.textContent).toBe('@Kenso');
|
||||||
|
expect(buttonByText('Benutzername ändern')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nach dem Abmelden ist das Menü unabhängig vom Namen wieder „Anmelden“', () => {
|
||||||
|
signedIn();
|
||||||
|
mocks.name = 'Kenso';
|
||||||
|
menu.ready();
|
||||||
|
setUser(null);
|
||||||
|
expect(slot.querySelector('.account-name')).toBeNull();
|
||||||
|
expect(buttonByText('Anmelden')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('vor ready() und nach dispose zeichnet auch eine Namensänderung nichts', () => {
|
||||||
|
signedIn();
|
||||||
|
setName('u1', 'Kenso');
|
||||||
|
expect(slot.childElementCount).toBe(0);
|
||||||
|
menu.ready();
|
||||||
|
menu.dispose();
|
||||||
|
setName('u1', 'Neu');
|
||||||
|
expect(slot.childElementCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt sich der Dialog nicht öffnen, bleibt es bei einer Meldung in der Konsole (kein unbehandelter Fehler), der Fokus geht trotzdem zurück', async () => {
|
||||||
|
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
mocks.usernameDialog.mockRejectedValue(new Error('showModal kaputt'));
|
||||||
|
signedIn();
|
||||||
|
menu.ready();
|
||||||
|
avatar()!.click();
|
||||||
|
buttonByText('Benutzername wählen')!.click();
|
||||||
|
await vi.waitFor(() => expect(error).toHaveBeenCalled());
|
||||||
|
await vi.waitFor(() => expect(document.activeElement).toBe(avatar()));
|
||||||
|
error.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auf Englisch: Einträge in der Sprache der Seite', async () => {
|
||||||
|
(await import('../i18n/i18n')).setLang('en', { persist: false });
|
||||||
|
signedIn();
|
||||||
|
mocks.name = 'Kenso';
|
||||||
|
menu.ready();
|
||||||
|
expect(items()).toEqual(['My cards', 'Change username', 'Sign out']);
|
||||||
|
expect(nameLine()?.textContent).toBe('@Kenso');
|
||||||
|
mocks.name = null;
|
||||||
|
setUser({ $id: 'u1', email: 'kenso@example.com' });
|
||||||
|
expect(items()).toEqual(['My cards', 'Choose a username', 'Sign out']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// Konto-Menü im Seitenkopf: abgemeldet „Anmelden“, angemeldet ein Avatar-Kreis mit Initiale
|
// Konto-Menü im Seitenkopf: abgemeldet „Anmelden“, angemeldet ein Avatar-Kreis mit Initiale und einem Menü (Benutzername und E-Mail,
|
||||||
// und einem Menü (E-Mail, „Meine Karten“, „Abmelden“). Die Texte stehen in der Sprache der Seite; nach einem Sprachwechsel
|
// „Meine Karten“, „Benutzername ändern“, „Abmelden“). Die Texte stehen in der Sprache der Seite; nach einem Sprachwechsel
|
||||||
// baut main.ts das Menü neu auf.
|
// baut main.ts das Menü neu auf.
|
||||||
import './account.css';
|
import './account.css';
|
||||||
import { el } from '../editor/dom';
|
import { el } from '../editor/dom';
|
||||||
@@ -7,6 +7,8 @@ import { t } from '../i18n/i18n';
|
|||||||
import { currentUser, logout, onAuthChange } from './auth';
|
import { currentUser, logout, onAuthChange } from './auth';
|
||||||
import { errorMessage } from './errors';
|
import { errorMessage } from './errors';
|
||||||
import { requireLogin } from './login-dialog';
|
import { requireLogin } from './login-dialog';
|
||||||
|
import { myUsername, onUsernameChange } from './profile';
|
||||||
|
import { usernameDialog } from './username-dialog';
|
||||||
|
|
||||||
export interface AccountMenu {
|
export interface AccountMenu {
|
||||||
/** Zeigt das Menü erst, wenn der Anmeldestatus bekannt ist (kein Aufblitzen von „Anmelden“ bei angemeldeten Nutzern). */
|
/** Zeigt das Menü erst, wenn der Anmeldestatus bekannt ist (kein Aufblitzen von „Anmelden“ bei angemeldeten Nutzern). */
|
||||||
@@ -46,11 +48,20 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
|
|||||||
},
|
},
|
||||||
initial,
|
initial,
|
||||||
);
|
);
|
||||||
|
// Mit Benutzernamen steht „@name“ oben und die E-Mail klein darunter; ohne Namen (noch nicht gelesen oder nicht gewählt) nur die E-Mail.
|
||||||
|
const name = myUsername();
|
||||||
const email = el('span', { class: 'account-email', title: user.email }, user.email);
|
const email = el('span', { class: 'account-email', title: user.email }, user.email);
|
||||||
|
const head = el(
|
||||||
|
'div',
|
||||||
|
{ class: 'account-head' },
|
||||||
|
...(name ? [el('span', { class: 'account-name', title: `@${name}` }, `@${name}`)] : []),
|
||||||
|
email,
|
||||||
|
);
|
||||||
const myCards = el('a', { class: 'account-item', href: '/karten', 'data-link': true }, t('Meine Karten'));
|
const myCards = el('a', { class: 'account-item', href: '/karten', 'data-link': true }, t('Meine Karten'));
|
||||||
|
const changeName = el('button', { type: 'button', class: 'account-item' }, name ? t('Benutzername ändern') : t('Benutzername wählen'));
|
||||||
const signOut = el('button', { type: 'button', class: 'account-item' }, t('Abmelden'));
|
const signOut = el('button', { type: 'button', class: 'account-item' }, t('Abmelden'));
|
||||||
const error = el('span', { class: 'account-error', role: 'alert' });
|
const error = el('span', { class: 'account-error', role: 'alert' });
|
||||||
const dropdown = el('div', { class: 'account-dropdown', id: 'account-dropdown', hidden: true }, email, myCards, signOut, error);
|
const dropdown = el('div', { class: 'account-dropdown', id: 'account-dropdown', hidden: true }, head, myCards, changeName, signOut, error);
|
||||||
const wrap = el('div', { class: 'account-menu', role: 'group', 'aria-label': t('Konto') }, avatar, dropdown);
|
const wrap = el('div', { class: 'account-menu', role: 'group', 'aria-label': t('Konto') }, avatar, dropdown);
|
||||||
|
|
||||||
let isOpen = false;
|
let isOpen = false;
|
||||||
@@ -79,6 +90,14 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
|
|||||||
document.removeEventListener('keydown', onKeydown);
|
document.removeEventListener('keydown', onKeydown);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
changeName.addEventListener('click', () => {
|
||||||
|
setOpen(false);
|
||||||
|
// Der Eintrag im geschlossenen (und nach dem Speichern neu gezeichneten) Menü kann den Fokus nicht halten: er geht an den Avatar.
|
||||||
|
usernameDialog({ mode: 'change' })
|
||||||
|
.catch((err: unknown) => console.error('Der Dialog „Benutzername“ konnte nicht geöffnet werden', err))
|
||||||
|
.finally(() => slot.querySelector<HTMLElement>('.account-avatar')?.focus());
|
||||||
|
});
|
||||||
|
|
||||||
signOut.addEventListener('click', () => {
|
signOut.addEventListener('click', () => {
|
||||||
signOut.disabled = true;
|
signOut.disabled = true;
|
||||||
error.textContent = '';
|
error.textContent = '';
|
||||||
@@ -92,6 +111,8 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const off = onAuthChange(render);
|
const off = onAuthChange(render);
|
||||||
|
// Der Name steht fest oder ändert sich (gelesen nach dem Start, gespeichert im Dialog): das Menü zeigt gleich den neuen.
|
||||||
|
const offName = onUsernameChange(render);
|
||||||
render();
|
render();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -101,6 +122,7 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
|
|||||||
},
|
},
|
||||||
dispose() {
|
dispose() {
|
||||||
off();
|
off();
|
||||||
|
offName();
|
||||||
detach?.();
|
detach?.();
|
||||||
detach = null;
|
detach = null;
|
||||||
slot.replaceChildren();
|
slot.replaceChildren();
|
||||||
|
|||||||
@@ -127,18 +127,38 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-email {
|
/* Kopf des Menüs: „@name“ (falls das Konto einen hat) und darunter die E-Mail */
|
||||||
|
|
||||||
|
.account-head {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
padding: 6px 12px 10px;
|
padding: 6px 12px 10px;
|
||||||
overflow: hidden;
|
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-name {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
font-weight: 700;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-email {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.account-name + .account-email {
|
||||||
|
font-size: 0.8rem; /* mit Namen steht die E-Mail klein darunter */
|
||||||
|
}
|
||||||
|
|
||||||
.account-item {
|
.account-item {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
display: block;
|
display: block;
|
||||||
@@ -346,6 +366,25 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dialog „Benutzername wählen“: Hülle, Felder, Hinweise, Meldungen und Knöpfe wie der Login-Dialog */
|
||||||
|
|
||||||
|
.username-dialog .auth-title {
|
||||||
|
margin-right: 0; /* kein Schließen-Knopf in der Ecke */
|
||||||
|
}
|
||||||
|
|
||||||
|
.username-text {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Seite /passwort-neu */
|
/* Seite /passwort-neu */
|
||||||
|
|
||||||
.reset-page {
|
.reset-page {
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ export const APPWRITE_PROJECT: string = import.meta.env.VITE_APPWRITE_PROJECT_ID
|
|||||||
/**
|
/**
|
||||||
* Datenbank, Tabellen und Buckets des Projekts (siehe web/scripts/appwrite-setup.mjs).
|
* Datenbank, Tabellen und Buckets des Projekts (siehe web/scripts/appwrite-setup.mjs).
|
||||||
* `table`/`bucket` sind die privaten Karten; `gallery`/`reports`/`galleryBucket` gehören zur Community-Galerie,
|
* `table`/`bucket` sind die privaten Karten; `gallery`/`reports`/`galleryBucket` gehören zur Community-Galerie,
|
||||||
* `logos`/`logoLikes`/`logoReports`/`logoBucket` zu den Community-Logos (Logos, Herzen, Meldungen, Logo-Dateien).
|
* `logos`/`logoLikes`/`logoReports`/`logoBucket` zu den Community-Logos (Logos, Herzen, Meldungen, Logo-Dateien),
|
||||||
|
* `profiles` zu den Benutzernamen (eine öffentlich lesbare Zeile je Konto; geschrieben wird nur über den API-Dienst).
|
||||||
*/
|
*/
|
||||||
export const IDS = {
|
export const IDS = {
|
||||||
database: 'aircard',
|
database: 'aircard',
|
||||||
@@ -21,6 +22,7 @@ export const IDS = {
|
|||||||
logoLikes: 'logo_likes',
|
logoLikes: 'logo_likes',
|
||||||
logoReports: 'logo_reports',
|
logoReports: 'logo_reports',
|
||||||
logoBucket: 'logo-images',
|
logoBucket: 'logo-images',
|
||||||
|
profiles: 'profiles',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const client: Client = new Client().setEndpoint(APPWRITE_ENDPOINT).setProject(APPWRITE_PROJECT);
|
export const client: Client = new Client().setEndpoint(APPWRITE_ENDPOINT).setProject(APPWRITE_PROJECT);
|
||||||
|
|||||||
@@ -9,9 +9,22 @@ const mocks = vi.hoisted(() => ({
|
|||||||
updateRecovery: vi.fn(),
|
updateRecovery: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Die Namensprüfung im Netz und der Namensdialog sind ersetzt; Muster, Sperrliste und der Merker „schon gefragt“ bleiben echt
|
||||||
|
// (profile.ts wird nach jedem Zurücksetzen der Module mit `doMock` neu ersetzt, siehe beforeEach).
|
||||||
|
const profileMocks = vi.hoisted(() => ({
|
||||||
|
isUsernameTaken: vi.fn(),
|
||||||
|
saveUsername: vi.fn(),
|
||||||
|
usernameDialog: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('./appwrite', () => ({ account: mocks }));
|
vi.mock('./appwrite', () => ({ account: mocks }));
|
||||||
|
vi.mock('./username-dialog', () => ({ usernameDialog: profileMocks.usernameDialog }));
|
||||||
|
|
||||||
const fail = (code: number, type: string, message = '') => Object.assign(new Error(message), { code, type });
|
const fail = (code: number, type: string, message = '') => Object.assign(new Error(message), { code, type });
|
||||||
|
const INVALID =
|
||||||
|
'Der Benutzername muss 3–20 Zeichen lang sein, mit einem Buchstaben oder einer Zahl beginnen und darf nur Buchstaben, Zahlen, Punkt, Unterstrich und Bindestrich enthalten.';
|
||||||
|
const TAKEN = 'Dieser Benutzername ist schon vergeben.';
|
||||||
|
const PROMPTED_KEY = 'aircard.usernamePrompted';
|
||||||
|
|
||||||
type Dialog = typeof import('./login-dialog');
|
type Dialog = typeof import('./login-dialog');
|
||||||
let requireLogin: Dialog['requireLogin'];
|
let requireLogin: Dialog['requireLogin'];
|
||||||
@@ -19,7 +32,17 @@ let opener: HTMLButtonElement;
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
for (const fn of Object.values(mocks)) fn.mockReset();
|
for (const fn of Object.values(mocks)) fn.mockReset();
|
||||||
|
for (const fn of Object.values(profileMocks)) fn.mockReset();
|
||||||
|
profileMocks.isUsernameTaken.mockResolvedValue(false); // der Name ist frei
|
||||||
|
profileMocks.saveUsername.mockImplementation(async (name: string) => name);
|
||||||
|
profileMocks.usernameDialog.mockResolvedValue(null);
|
||||||
|
sessionStorage.clear();
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
|
vi.doMock('./profile', async () => ({
|
||||||
|
...(await vi.importActual<typeof import('./profile')>('./profile')),
|
||||||
|
isUsernameTaken: profileMocks.isUsernameTaken,
|
||||||
|
saveUsername: profileMocks.saveUsername,
|
||||||
|
}));
|
||||||
document.body.innerHTML = '<div id="account-slot"></div><button id="opener">Speichern</button>';
|
document.body.innerHTML = '<div id="account-slot"></div><button id="opener">Speichern</button>';
|
||||||
opener = document.getElementById('opener') as HTMLButtonElement;
|
opener = document.getElementById('opener') as HTMLButtonElement;
|
||||||
opener.focus();
|
opener.focus();
|
||||||
@@ -51,6 +74,7 @@ describe('requireLogin: Öffnen und Schließen', () => {
|
|||||||
it('startet auf Wunsch mit Registrieren oder Passwort vergessen', () => {
|
it('startet auf Wunsch mit Registrieren oder Passwort vergessen', () => {
|
||||||
void requireLogin('register');
|
void requireLogin('register');
|
||||||
expect(title()).toBe('Registrieren');
|
expect(title()).toBe('Registrieren');
|
||||||
|
expect(input('username').autocomplete).toBe('username');
|
||||||
expect(input('password').autocomplete).toBe('new-password');
|
expect(input('password').autocomplete).toBe('new-password');
|
||||||
expect(input('password-repeat').autocomplete).toBe('new-password');
|
expect(input('password-repeat').autocomplete).toBe('new-password');
|
||||||
});
|
});
|
||||||
@@ -213,13 +237,14 @@ describe('Registrieren', () => {
|
|||||||
|
|
||||||
it('prüft vor dem Senden: Abgleich, dann Länge', () => {
|
it('prüft vor dem Senden: Abgleich, dann Länge', () => {
|
||||||
void requireLogin('register');
|
void requireLogin('register');
|
||||||
fill({ email: 'a@b.de', password: 'geheim123', 'password-repeat': 'geheim124' });
|
fill({ username: 'Kenso', email: 'a@b.de', password: 'geheim123', 'password-repeat': 'geheim124' });
|
||||||
submit();
|
submit();
|
||||||
expect(alertText()).toBe('Die Passwörter stimmen nicht überein.');
|
expect(alertText()).toBe('Die Passwörter stimmen nicht überein.');
|
||||||
fill({ password: 'kurz', 'password-repeat': 'kurz' });
|
fill({ password: 'kurz', 'password-repeat': 'kurz' });
|
||||||
submit();
|
submit();
|
||||||
expect(alertText()).toBe('Das Passwort muss 8 bis 256 Zeichen lang sein.');
|
expect(alertText()).toBe('Das Passwort muss 8 bis 256 Zeichen lang sein.');
|
||||||
expect(mocks.create).not.toHaveBeenCalled();
|
expect(mocks.create).not.toHaveBeenCalled();
|
||||||
|
expect(profileMocks.isUsernameTaken).not.toHaveBeenCalled(); // erst die Eingaben, die ohne Netz zu prüfen sind
|
||||||
});
|
});
|
||||||
|
|
||||||
it('legt das Konto an, meldet an und löst mit true auf', async () => {
|
it('legt das Konto an, meldet an und löst mit true auf', async () => {
|
||||||
@@ -227,7 +252,7 @@ describe('Registrieren', () => {
|
|||||||
mocks.createEmailPasswordSession.mockResolvedValue({});
|
mocks.createEmailPasswordSession.mockResolvedValue({});
|
||||||
mocks.get.mockResolvedValue({ $id: 'u2', email: 'neu@b.de' });
|
mocks.get.mockResolvedValue({ $id: 'u2', email: 'neu@b.de' });
|
||||||
const result = requireLogin('register');
|
const result = requireLogin('register');
|
||||||
fill({ email: 'neu@b.de', password: 'geheim123', 'password-repeat': 'geheim123' });
|
fill({ username: 'Neu.Konto', email: 'neu@b.de', password: 'geheim123', 'password-repeat': 'geheim123' });
|
||||||
submit();
|
submit();
|
||||||
await expect(result).resolves.toBe(true);
|
await expect(result).resolves.toBe(true);
|
||||||
expect(mocks.create).toHaveBeenCalledTimes(1);
|
expect(mocks.create).toHaveBeenCalledTimes(1);
|
||||||
@@ -237,7 +262,7 @@ describe('Registrieren', () => {
|
|||||||
it('bei vorhandener E-Mail steht die Meldung mit einem Link „Anmelden“', async () => {
|
it('bei vorhandener E-Mail steht die Meldung mit einem Link „Anmelden“', async () => {
|
||||||
mocks.create.mockRejectedValue(fail(409, 'user_already_exists'));
|
mocks.create.mockRejectedValue(fail(409, 'user_already_exists'));
|
||||||
void requireLogin('register');
|
void requireLogin('register');
|
||||||
fill({ email: 'a@b.de', password: 'geheim123', 'password-repeat': 'geheim123' });
|
fill({ username: 'Kenso', email: 'a@b.de', password: 'geheim123', 'password-repeat': 'geheim123' });
|
||||||
submit();
|
submit();
|
||||||
await vi.waitFor(() => expect(alertText()).toContain('Diese E-Mail ist schon registriert. Melde dich an.'));
|
await vi.waitFor(() => expect(alertText()).toContain('Diese E-Mail ist schon registriert. Melde dich an.'));
|
||||||
const link = dialogEl().querySelector<HTMLButtonElement>('[role="alert"] button')!;
|
const link = dialogEl().querySelector<HTMLButtonElement>('[role="alert"] button')!;
|
||||||
@@ -248,6 +273,305 @@ describe('Registrieren', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Registrieren: Benutzername', () => {
|
||||||
|
/** Meldet das Konto an wie ein erfolgreicher Server: anlegen, Sitzung, Nutzer. */
|
||||||
|
function serverAccepts(): void {
|
||||||
|
mocks.create.mockResolvedValue({});
|
||||||
|
mocks.createEmailPasswordSession.mockResolvedValue({});
|
||||||
|
mocks.get.mockResolvedValue({ $id: 'u2', email: 'neu@b.de' });
|
||||||
|
}
|
||||||
|
const fields = () => ({ username: 'Kenso', email: 'neu@b.de', password: 'geheim123', 'password-repeat': 'geheim123' });
|
||||||
|
const names = () => [...dialogEl().querySelectorAll('input')].map((i) => i.name);
|
||||||
|
|
||||||
|
it('das Pflichtfeld „Benutzername“ steht vor der E-Mail, mit Hinweis, und hat zuerst den Fokus', () => {
|
||||||
|
void requireLogin('register');
|
||||||
|
expect(names()).toEqual(['username', 'email', 'password', 'password-repeat']);
|
||||||
|
const field = input('username');
|
||||||
|
expect(field.type).toBe('text');
|
||||||
|
expect(field.required).toBe(true);
|
||||||
|
expect(field.autocomplete).toBe('username');
|
||||||
|
expect(field.getAttribute('autocapitalize')).toBe('none');
|
||||||
|
expect(field.getAttribute('spellcheck')).toBe('false');
|
||||||
|
expect(field.labels![0].textContent).toContain('Benutzername');
|
||||||
|
const hint = dialogEl().querySelector(`#${field.getAttribute('aria-describedby')}`)!;
|
||||||
|
expect(hint.textContent).toBe('3–20 Zeichen: Buchstaben, Zahlen, . _ -');
|
||||||
|
expect(document.activeElement).toBe(field);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die Anmelden-Ansicht hat kein Namensfeld', () => {
|
||||||
|
void requireLogin('login');
|
||||||
|
expect(names()).toEqual(['email', 'password']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['leer', '', INVALID],
|
||||||
|
['nur Leerzeichen', ' ', INVALID],
|
||||||
|
['zu kurz', 'ab', INVALID],
|
||||||
|
['zu lang', 'x'.repeat(21), INVALID],
|
||||||
|
['falsches erstes Zeichen', '_abc', INVALID],
|
||||||
|
['Leerzeichen im Namen', 'ke nso', INVALID],
|
||||||
|
['Umlaut', 'Käse', INVALID],
|
||||||
|
['gesperrt', 'Admin', 'Dieser Benutzername ist nicht erlaubt.'],
|
||||||
|
['gesperrt in anderer Schreibweise', 'AirCard-IOS', 'Dieser Benutzername ist nicht erlaubt.'],
|
||||||
|
])('%s: der Fehler steht am Feld, es gibt keine Anfrage und kein Konto', (_label, name, message) => {
|
||||||
|
void requireLogin('register');
|
||||||
|
fill({ ...fields(), username: name });
|
||||||
|
submit();
|
||||||
|
expect(alertText()).toBe(message);
|
||||||
|
const field = input('username');
|
||||||
|
expect(field.getAttribute('aria-invalid')).toBe('true');
|
||||||
|
expect(field.getAttribute('aria-describedby')!.split(' ')).toContain(dialogEl().querySelector('[role="alert"]')!.id);
|
||||||
|
expect(document.activeElement).toBe(field);
|
||||||
|
expect(profileMocks.isUsernameTaken).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.create).not.toHaveBeenCalled();
|
||||||
|
expect(dialogEl().open).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der Name wird vor den Passwörtern geprüft (Reihenfolge der Felder)', () => {
|
||||||
|
void requireLogin('register');
|
||||||
|
fill({ ...fields(), username: 'x', 'password-repeat': 'anders1234' });
|
||||||
|
submit();
|
||||||
|
expect(alertText()).toBe(INVALID);
|
||||||
|
expect(document.activeElement).toBe(input('username'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die Markierung des Feldes fällt beim nächsten Absenden weg; der Hinweis bleibt verknüpft', async () => {
|
||||||
|
serverAccepts();
|
||||||
|
void requireLogin('register');
|
||||||
|
fill({ ...fields(), username: 'x' });
|
||||||
|
submit();
|
||||||
|
expect(input('username').getAttribute('aria-describedby')!.split(' ')).toHaveLength(2);
|
||||||
|
fill({ username: 'Kenso' });
|
||||||
|
submit();
|
||||||
|
expect(input('username').hasAttribute('aria-invalid')).toBe(false);
|
||||||
|
expect(input('username').getAttribute('aria-describedby')!.split(' ')).toHaveLength(1);
|
||||||
|
await vi.waitFor(() => expect(mocks.create).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('vergebener Name (Review-Fokus): Fehler am Feld, bevor ein Konto angelegt wird – kein halbes Konto', async () => {
|
||||||
|
profileMocks.isUsernameTaken.mockResolvedValue(true);
|
||||||
|
void requireLogin('register');
|
||||||
|
fill({ ...fields(), username: ' kenso ' });
|
||||||
|
dialogEl().querySelector<HTMLButtonElement>('button[type="submit"]')!.focus(); // der Nutzer hat den Knopf gedrückt
|
||||||
|
submit();
|
||||||
|
await vi.waitFor(() => expect(alertText()).toBe(TAKEN));
|
||||||
|
expect(profileMocks.isUsernameTaken).toHaveBeenCalledExactlyOnceWith('kenso'); // getrimmt, so wie er gespeichert würde
|
||||||
|
expect(mocks.create).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.createEmailPasswordSession).not.toHaveBeenCalled();
|
||||||
|
expect(profileMocks.saveUsername).not.toHaveBeenCalled();
|
||||||
|
const field = input('username');
|
||||||
|
expect(field.getAttribute('aria-invalid')).toBe('true');
|
||||||
|
expect(field.getAttribute('aria-describedby')!.split(' ')).toContain(dialogEl().querySelector('[role="alert"]')!.id);
|
||||||
|
expect(document.activeElement).toBe(field); // der Fokus geht an das Feld, nicht zurück auf den Knopf
|
||||||
|
expect(dialogEl().open).toBe(true);
|
||||||
|
// das Formular ist wieder bedienbar
|
||||||
|
expect(dialogEl().querySelector<HTMLButtonElement>('button[type="submit"]')!.disabled).toBe(false);
|
||||||
|
expect(field.readOnly).toBe(false);
|
||||||
|
expect(input('password').value).toBe('geheim123'); // nichts wurde verworfen
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nach „vergeben“ geht ein anderer Name: dann wird das Konto angelegt', async () => {
|
||||||
|
serverAccepts();
|
||||||
|
profileMocks.isUsernameTaken.mockResolvedValueOnce(true);
|
||||||
|
const result = requireLogin('register');
|
||||||
|
fill(fields());
|
||||||
|
submit();
|
||||||
|
await vi.waitFor(() => expect(alertText()).toBe(TAKEN));
|
||||||
|
fill({ username: 'Kenso2' });
|
||||||
|
submit();
|
||||||
|
await expect(result).resolves.toBe(true);
|
||||||
|
expect(profileMocks.isUsernameTaken).toHaveBeenLastCalledWith('Kenso2');
|
||||||
|
expect(mocks.create).toHaveBeenCalledTimes(1);
|
||||||
|
expect(profileMocks.saveUsername).toHaveBeenCalledExactlyOnceWith('Kenso2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt sich die Verfügbarkeit nicht prüfen (kein Netz), wird kein Konto angelegt und der Fehler erklärt', async () => {
|
||||||
|
profileMocks.isUsernameTaken.mockRejectedValue(new TypeError('Failed to fetch'));
|
||||||
|
let settled = false;
|
||||||
|
void requireLogin('register').then(() => (settled = true));
|
||||||
|
fill(fields());
|
||||||
|
submit();
|
||||||
|
await vi.waitFor(() => expect(alertText()).toBe('Keine Verbindung zum Server.'));
|
||||||
|
expect(mocks.create).not.toHaveBeenCalled();
|
||||||
|
expect(settled).toBe(false);
|
||||||
|
expect(dialogEl().open).toBe(true);
|
||||||
|
expect(dialogEl().querySelector<HTMLButtonElement>('button[type="submit"]')!.disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der Ablauf: Verfügbarkeit prüfen, Konto anlegen, anmelden, Namen speichern – erst dann ist der Dialog fertig', async () => {
|
||||||
|
serverAccepts();
|
||||||
|
const order: string[] = [];
|
||||||
|
profileMocks.isUsernameTaken.mockImplementation(async () => (order.push('prüfen'), false));
|
||||||
|
mocks.create.mockImplementation(async () => (order.push('anlegen'), {}));
|
||||||
|
mocks.createEmailPasswordSession.mockImplementation(async () => (order.push('anmelden'), {}));
|
||||||
|
profileMocks.saveUsername.mockImplementation(async (name: string) => (order.push(`speichern ${name}`), name));
|
||||||
|
const result = requireLogin('register');
|
||||||
|
fill({ ...fields(), username: ' Kenso ' });
|
||||||
|
submit();
|
||||||
|
await expect(result).resolves.toBe(true);
|
||||||
|
expect(order).toEqual(['prüfen', 'anlegen', 'anmelden', 'speichern Kenso']);
|
||||||
|
expect(mocks.create.mock.calls[0][0]).toMatchObject({ email: 'neu@b.de', password: 'geheim123' });
|
||||||
|
expect(dialogEl().open).toBe(false);
|
||||||
|
expect(profileMocks.usernameDialog).not.toHaveBeenCalled(); // Name gespeichert: nichts mehr zu fragen
|
||||||
|
});
|
||||||
|
|
||||||
|
it('während geprüft und angelegt wird, ist das Formular gesperrt und ein zweites Absenden wird ignoriert', async () => {
|
||||||
|
serverAccepts();
|
||||||
|
let finishCheck!: (taken: boolean) => void;
|
||||||
|
profileMocks.isUsernameTaken.mockReturnValue(new Promise<boolean>((resolve) => (finishCheck = resolve)));
|
||||||
|
const result = requireLogin('register');
|
||||||
|
fill(fields());
|
||||||
|
submit();
|
||||||
|
submit();
|
||||||
|
expect(profileMocks.isUsernameTaken).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dialogEl().querySelector<HTMLButtonElement>('button[type="submit"]')!.disabled).toBe(true);
|
||||||
|
expect(input('username').readOnly).toBe(true);
|
||||||
|
finishCheck(false);
|
||||||
|
await expect(result).resolves.toBe(true);
|
||||||
|
expect(mocks.create).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scheitert das Speichern des Namens (Rennen, Netz), ist man trotzdem angemeldet: der Dialog schließt mit true, der Namensdialog erscheint', async () => {
|
||||||
|
serverAccepts();
|
||||||
|
profileMocks.saveUsername.mockRejectedValue(new Error('409'));
|
||||||
|
const result = requireLogin('register');
|
||||||
|
fill(fields());
|
||||||
|
submit();
|
||||||
|
await expect(result).resolves.toBe(true);
|
||||||
|
expect(dialogEl().open).toBe(false);
|
||||||
|
expect(mocks.get).toHaveBeenCalled(); // angemeldet
|
||||||
|
expect(profileMocks.usernameDialog).toHaveBeenCalledExactlyOnceWith({ mode: 'prompt' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der Namensdialog kommt erst, wenn der Login-Dialog zu ist, und ein Fehler beim Öffnen bleibt ohne Folgen', async () => {
|
||||||
|
serverAccepts();
|
||||||
|
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
profileMocks.saveUsername.mockRejectedValue(new Error('offline'));
|
||||||
|
let loginOpenWhenAsked: boolean | undefined;
|
||||||
|
profileMocks.usernameDialog.mockImplementation(async () => {
|
||||||
|
loginOpenWhenAsked = dialogEl().open;
|
||||||
|
throw new Error('showModal kaputt');
|
||||||
|
});
|
||||||
|
const result = requireLogin('register');
|
||||||
|
fill(fields());
|
||||||
|
submit();
|
||||||
|
await expect(result).resolves.toBe(true);
|
||||||
|
await vi.waitFor(() => expect(error).toHaveBeenCalled());
|
||||||
|
expect(loginOpenWhenAsked).toBe(false);
|
||||||
|
error.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('das Konto-Anlegen scheitert (E-Mail vergeben): kein Namensdialog, kein Speichern', async () => {
|
||||||
|
mocks.create.mockRejectedValue(fail(409, 'user_already_exists'));
|
||||||
|
void requireLogin('register');
|
||||||
|
fill(fields());
|
||||||
|
submit();
|
||||||
|
await vi.waitFor(() => expect(alertText()).toContain('Diese E-Mail ist schon registriert.'));
|
||||||
|
expect(profileMocks.saveUsername).not.toHaveBeenCalled();
|
||||||
|
expect(profileMocks.usernameDialog).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Merker „in dieser Sitzung schon gefragt“: gesetzt, bevor die Anmeldung läuft (sie löst die einmalige Abfrage aus), und danach noch da', async () => {
|
||||||
|
serverAccepts();
|
||||||
|
let flagWhenAccountCreated: string | null | undefined;
|
||||||
|
mocks.create.mockImplementation(async () => {
|
||||||
|
flagWhenAccountCreated = sessionStorage.getItem(PROMPTED_KEY);
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
const result = requireLogin('register');
|
||||||
|
fill(fields());
|
||||||
|
submit();
|
||||||
|
await expect(result).resolves.toBe(true);
|
||||||
|
expect(flagWhenAccountCreated).toBe('1');
|
||||||
|
expect(sessionStorage.getItem(PROMPTED_KEY)).toBe('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scheitert das Anlegen des Kontos, nimmt der Dialog den Merker wieder weg (wer sich danach anmeldet, wird noch gefragt)', async () => {
|
||||||
|
mocks.create.mockRejectedValue(fail(409, 'user_already_exists'));
|
||||||
|
void requireLogin('register');
|
||||||
|
fill(fields());
|
||||||
|
submit();
|
||||||
|
await vi.waitFor(() => expect(alertText()).toContain('schon registriert'));
|
||||||
|
expect(sessionStorage.getItem(PROMPTED_KEY)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('war schon gefragt worden, bleibt der Merker auch nach einem gescheiterten Anlegen', async () => {
|
||||||
|
sessionStorage.setItem(PROMPTED_KEY, '1');
|
||||||
|
mocks.create.mockRejectedValue(fail(409, 'user_already_exists'));
|
||||||
|
void requireLogin('register');
|
||||||
|
fill(fields());
|
||||||
|
submit();
|
||||||
|
await vi.waitFor(() => expect(alertText()).toContain('schon registriert'));
|
||||||
|
expect(sessionStorage.getItem(PROMPTED_KEY)).toBe('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wird der Dialog während der Prüfung geschlossen, entsteht kein Konto', async () => {
|
||||||
|
serverAccepts();
|
||||||
|
let finishCheck!: (taken: boolean) => void;
|
||||||
|
profileMocks.isUsernameTaken.mockReturnValue(new Promise<boolean>((resolve) => (finishCheck = resolve)));
|
||||||
|
const result = requireLogin('register');
|
||||||
|
fill(fields());
|
||||||
|
submit();
|
||||||
|
dialogEl().querySelector<HTMLButtonElement>('.auth-close')!.click();
|
||||||
|
await expect(result).resolves.toBe(false);
|
||||||
|
finishCheck(false);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
expect(mocks.create).not.toHaveBeenCalled();
|
||||||
|
expect(profileMocks.saveUsername).not.toHaveBeenCalled();
|
||||||
|
expect(sessionStorage.getItem(PROMPTED_KEY)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wird der Dialog während der Prüfung geschlossen und der Name ist vergeben, erscheint nichts mehr', async () => {
|
||||||
|
let finishCheck!: (taken: boolean) => void;
|
||||||
|
profileMocks.isUsernameTaken.mockReturnValue(new Promise<boolean>((resolve) => (finishCheck = resolve)));
|
||||||
|
const result = requireLogin('register');
|
||||||
|
fill(fields());
|
||||||
|
submit();
|
||||||
|
dialogEl().querySelector<HTMLButtonElement>('.auth-close')!.click();
|
||||||
|
await expect(result).resolves.toBe(false);
|
||||||
|
finishCheck(true);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
expect(dialogEl().querySelector('[role="alert"]')?.textContent ?? '').toBe('');
|
||||||
|
expect(mocks.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der getippte Name wandert beim Wechsel der Reiter mit (wie die E-Mail), Passwörter nie', () => {
|
||||||
|
void requireLogin('register');
|
||||||
|
fill({ username: 'Kenso', email: 'a@b.de', password: 'geheim123' });
|
||||||
|
buttonByText('Anmelden').click();
|
||||||
|
expect(title()).toBe('Anmelden');
|
||||||
|
expect(input('email').value).toBe('a@b.de');
|
||||||
|
buttonByText('Registrieren').click();
|
||||||
|
expect(input('username').value).toBe('Kenso');
|
||||||
|
expect(input('email').value).toBe('a@b.de');
|
||||||
|
expect(input('password').value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nach dem Schließen sind Name und E-Mail weg, ein neuer Aufruf beginnt leer', async () => {
|
||||||
|
const first = requireLogin('register');
|
||||||
|
fill({ username: 'Kenso', email: 'a@b.de' });
|
||||||
|
dialogEl().querySelector<HTMLButtonElement>('.auth-close')!.click();
|
||||||
|
await first;
|
||||||
|
void requireLogin('register');
|
||||||
|
expect(input('username').value).toBe('');
|
||||||
|
expect(input('email').value).toBe('');
|
||||||
|
expect(document.activeElement).toBe(input('username'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die anderen Ansichten bleiben unberührt: Anmelden meldet an, ohne Name zu prüfen oder zu speichern', async () => {
|
||||||
|
mocks.createEmailPasswordSession.mockResolvedValue({});
|
||||||
|
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
|
||||||
|
const result = requireLogin();
|
||||||
|
fill({ email: 'a@b.de', password: 'geheim123' });
|
||||||
|
submit();
|
||||||
|
await expect(result).resolves.toBe(true);
|
||||||
|
expect(profileMocks.isUsernameTaken).not.toHaveBeenCalled();
|
||||||
|
expect(profileMocks.saveUsername).not.toHaveBeenCalled();
|
||||||
|
expect(profileMocks.usernameDialog).not.toHaveBeenCalled();
|
||||||
|
expect(sessionStorage.getItem(PROMPTED_KEY)).toBeNull(); // die einmalige Abfrage ist Sache von main.ts
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Passwort vergessen', () => {
|
describe('Passwort vergessen', () => {
|
||||||
it('führt vom Anmelden zur E-Mail-Eingabe und sendet den Link', async () => {
|
it('führt vom Anmelden zur E-Mail-Eingabe und sendet den Link', async () => {
|
||||||
mocks.createRecovery.mockResolvedValue({});
|
mocks.createRecovery.mockResolvedValue({});
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
// Login-Dialog: ein gemeinsames <dialog> mit Anmelden, Registrieren und „Passwort vergessen“.
|
// Login-Dialog: ein gemeinsames <dialog> mit Anmelden, Registrieren (mit Benutzernamen) und „Passwort vergessen“.
|
||||||
import './account.css';
|
import './account.css';
|
||||||
import { el } from '../editor/dom';
|
import { el, setFieldInvalid } from '../editor/dom';
|
||||||
|
import { API_MESSAGES } from '../i18n/api-messages';
|
||||||
import { msg, t } from '../i18n/i18n';
|
import { msg, t } from '../i18n/i18n';
|
||||||
import { login, register, requestPasswordReset, validatePassword } from './auth';
|
import { login, register, requestPasswordReset, validatePassword } from './auth';
|
||||||
import { errorMessage, errorType, type ErrorContext } from './errors';
|
import { errorMessage, errorType, type ErrorContext } from './errors';
|
||||||
|
import {
|
||||||
|
USERNAME_HINT,
|
||||||
|
clearUsernamePrompted,
|
||||||
|
isUsernameTaken,
|
||||||
|
markUsernamePrompted,
|
||||||
|
saveUsername,
|
||||||
|
usernamePrompted,
|
||||||
|
validateUsername,
|
||||||
|
} from './profile';
|
||||||
|
import { usernameDialog } from './username-dialog';
|
||||||
|
|
||||||
export type LoginView = 'login' | 'register' | 'forgot';
|
export type LoginView = 'login' | 'register' | 'forgot';
|
||||||
|
|
||||||
@@ -15,6 +26,8 @@ const TITLES: Record<LoginView, string> = {
|
|||||||
|
|
||||||
const MISMATCH = msg('Die Passwörter stimmen nicht überein.');
|
const MISMATCH = msg('Die Passwörter stimmen nicht überein.');
|
||||||
const RESET_SENT = msg('Wir haben dir einen Link geschickt. Er gilt eine Stunde.');
|
const RESET_SENT = msg('Wir haben dir einen Link geschickt. Er gilt eine Stunde.');
|
||||||
|
/** Die Meldungszeile des Dialogs: ein Feld mit Fehler verweist mit `aria-describedby` auf sie. */
|
||||||
|
const ERROR_ID = 'auth-error';
|
||||||
|
|
||||||
type Field = { label: HTMLLabelElement; input: HTMLInputElement };
|
type Field = { label: HTMLLabelElement; input: HTMLInputElement };
|
||||||
|
|
||||||
@@ -29,6 +42,7 @@ type Panel = {
|
|||||||
function createLoginDialog(): (view: LoginView) => Promise<boolean> {
|
function createLoginDialog(): (view: LoginView) => Promise<boolean> {
|
||||||
let view: LoginView = 'login';
|
let view: LoginView = 'login';
|
||||||
let draftEmail = '';
|
let draftEmail = '';
|
||||||
|
let draftUsername = '';
|
||||||
let busy = false;
|
let busy = false;
|
||||||
// Zählt jedes Öffnen/Schließen; eine Antwort, die nach dem Schließen eintrifft, wird verworfen.
|
// Zählt jedes Öffnen/Schließen; eine Antwort, die nach dem Schließen eintrifft, wird verworfen.
|
||||||
let session = 0;
|
let session = 0;
|
||||||
@@ -90,7 +104,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
|
|||||||
function field(
|
function field(
|
||||||
labelText: string,
|
labelText: string,
|
||||||
name: string,
|
name: string,
|
||||||
type: 'email' | 'password',
|
type: 'text' | 'email' | 'password',
|
||||||
autocomplete: string,
|
autocomplete: string,
|
||||||
value = '',
|
value = '',
|
||||||
describedBy?: string,
|
describedBy?: string,
|
||||||
@@ -102,8 +116,9 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
|
|||||||
autocomplete,
|
autocomplete,
|
||||||
value,
|
value,
|
||||||
required: true,
|
required: true,
|
||||||
autocapitalize: type === 'email' ? 'none' : null,
|
// E-Mail-Adresse und Benutzername: keine automatische Großschreibung, keine Rechtschreibprüfung
|
||||||
spellcheck: type === 'email' ? 'false' : null,
|
autocapitalize: type === 'password' ? null : 'none',
|
||||||
|
spellcheck: type === 'password' ? null : 'false',
|
||||||
'aria-describedby': describedBy,
|
'aria-describedby': describedBy,
|
||||||
});
|
});
|
||||||
const label = el('label', { class: 'auth-field' }, el('span', { class: 'auth-label' }, labelText), input);
|
const label = el('label', { class: 'auth-field' }, el('span', { class: 'auth-label' }, labelText), input);
|
||||||
@@ -162,25 +177,35 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
|
|||||||
panel.errorEl.replaceChildren(text, ...(extra ? [' ', extra] : []));
|
panel.errorEl.replaceChildren(text, ...(extra ? [' ', extra] : []));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ein Fehler am Eingabefeld, das ihn verursacht: der Text steht in der Meldungszeile, das Feld gilt als ungültig und bekommt den Fokus. */
|
||||||
|
function showFieldError(panel: Panel, input: HTMLInputElement, text: string): void {
|
||||||
|
showError(panel, text);
|
||||||
|
setFieldInvalid(input, ERROR_ID, true);
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
|
||||||
function showOk(panel: Panel, text: string): void {
|
function showOk(panel: Panel, text: string): void {
|
||||||
panel.errorEl.textContent = '';
|
panel.errorEl.textContent = '';
|
||||||
panel.okEl.textContent = text;
|
panel.okEl.textContent = text;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Führt eine Anfrage aus: sperrt das Formular, zeigt Fehler und gibt danach den Fokus zurück. */
|
/**
|
||||||
|
* Führt eine Anfrage aus: sperrt das Formular, zeigt Fehler und gibt danach den Fokus zurück: an das Feld, das die Aktion nennt
|
||||||
|
* (sie hat dort einen Fehler gezeigt), sonst dorthin, wo er vorher war.
|
||||||
|
*/
|
||||||
async function run(
|
async function run(
|
||||||
panel: Panel,
|
panel: Panel,
|
||||||
ctx: ErrorContext,
|
ctx: ErrorContext,
|
||||||
action: () => Promise<void>,
|
action: () => Promise<HTMLElement | void>,
|
||||||
onError?: (err: unknown) => Node | undefined,
|
onError?: (err: unknown) => Node | undefined,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const mine = session;
|
const mine = session;
|
||||||
const focused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
let focused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||||
setBusy(panel, true);
|
setBusy(panel, true);
|
||||||
panel.errorEl.textContent = '';
|
panel.errorEl.textContent = '';
|
||||||
panel.okEl.textContent = '';
|
panel.okEl.textContent = '';
|
||||||
try {
|
try {
|
||||||
await action();
|
focused = (await action()) ?? focused;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (mine !== session) return;
|
if (mine !== session) return;
|
||||||
showError(panel, errorMessage(err, ctx), onError?.(err));
|
showError(panel, errorMessage(err, ctx), onError?.(err));
|
||||||
@@ -190,8 +215,51 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
|
|||||||
if (focused?.isConnected && dialog.contains(focused)) focused.focus();
|
if (focused?.isConnected && dialog.contains(focused)) focused.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registrieren mit Benutzernamen (Spec § 5): erst die Verfügbarkeit des Namens, dann Konto anlegen und anmelden, zuletzt den Namen speichern.
|
||||||
|
* Ist der Name vergeben, steht der Fehler am Feld, bevor ein Konto entsteht (sonst bliebe ein Konto ohne Namen zurück). Scheitert nur das
|
||||||
|
* Speichern (Rennen um den Namen, Netz weg), ist man trotzdem angemeldet: der Dialog schließt, und der Namensdialog fragt noch einmal.
|
||||||
|
*/
|
||||||
|
async function registerWithName(
|
||||||
|
panel: Panel,
|
||||||
|
nameInput: HTMLInputElement,
|
||||||
|
name: string,
|
||||||
|
address: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<HTMLElement | void> {
|
||||||
|
const mine = session;
|
||||||
|
const taken = await isUsernameTaken(name);
|
||||||
|
if (mine !== session) return; // der Dialog wurde inzwischen geschlossen: nichts mehr anlegen, nichts mehr zeigen
|
||||||
|
if (taken) {
|
||||||
|
showFieldError(panel, nameInput, t(API_MESSAGES.usernameTaken));
|
||||||
|
return nameInput;
|
||||||
|
}
|
||||||
|
// Schon die Anmeldung löst in main.ts die einmalige Abfrage nach dem Namen aus; hier wurde er gerade erst eingegeben. Darum steht der
|
||||||
|
// Merker „in dieser Sitzung schon gefragt“ vor dem Anlegen. Scheitert es, nimmt der Dialog ihn wieder weg (wer sich danach anmeldet, wird gefragt).
|
||||||
|
const asked = usernamePrompted();
|
||||||
|
markUsernamePrompted();
|
||||||
|
try {
|
||||||
|
await register(address, password);
|
||||||
|
} catch (err) {
|
||||||
|
if (!asked) clearUsernamePrompted();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
let saved = true;
|
||||||
|
try {
|
||||||
|
await saveUsername(name);
|
||||||
|
} catch {
|
||||||
|
saved = false;
|
||||||
|
}
|
||||||
|
finish(true);
|
||||||
|
if (!saved) {
|
||||||
|
usernameDialog({ mode: 'prompt' }).catch((err: unknown) => {
|
||||||
|
console.error('Der Dialog „Benutzername“ konnte nicht geöffnet werden', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function buildPanel(forView: LoginView): Panel {
|
function buildPanel(forView: LoginView): Panel {
|
||||||
const errorEl = el('p', { class: 'auth-msg auth-msg-error', role: 'alert' });
|
const errorEl = el('p', { class: 'auth-msg auth-msg-error', id: ERROR_ID, role: 'alert' });
|
||||||
const okEl = el('p', { class: 'auth-msg auth-msg-ok', role: 'status' });
|
const okEl = el('p', { class: 'auth-msg auth-msg-ok', role: 'status' });
|
||||||
const form = el('form', { class: 'auth-form' });
|
const form = el('form', { class: 'auth-form' });
|
||||||
const submit = (text: string) => el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, text);
|
const submit = (text: string) => el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, text);
|
||||||
@@ -215,17 +283,28 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (forView === 'register') {
|
if (forView === 'register') {
|
||||||
|
const username = field(t('Benutzername'), 'username', 'text', 'username', draftUsername, 'auth-name-hint');
|
||||||
const email = field(t('E-Mail'), 'email', 'email', 'email', draftEmail);
|
const email = field(t('E-Mail'), 'email', 'email', 'email', draftEmail);
|
||||||
const password = field(t('Passwort'), 'password', 'password', 'new-password', '', 'auth-pw-hint');
|
const password = field(t('Passwort'), 'password', 'password', 'new-password', '', 'auth-pw-hint');
|
||||||
const repeat = field(t('Passwort wiederholen'), 'password-repeat', 'password', 'new-password');
|
const repeat = field(t('Passwort wiederholen'), 'password-repeat', 'password', 'new-password');
|
||||||
|
const nameHint = el('p', { class: 'auth-hint', id: 'auth-name-hint' }, t(USERNAME_HINT));
|
||||||
const hint = el('p', { class: 'auth-hint', id: 'auth-pw-hint' }, t('Mindestens 8 Zeichen.'));
|
const hint = el('p', { class: 'auth-hint', id: 'auth-pw-hint' }, t('Mindestens 8 Zeichen.'));
|
||||||
form.append(email.label, password.label, hint, repeat.label, errorEl, okEl, submit(t('Registrieren')));
|
form.append(username.label, nameHint, email.label, password.label, hint, repeat.label, errorEl, okEl, submit(t('Registrieren')));
|
||||||
const panel: Panel = { form, first: draftEmail ? password.input : email.input, errorEl, okEl };
|
// Der Fokus geht an das erste Feld, das noch leer ist.
|
||||||
|
const first = !draftUsername ? username.input : !draftEmail ? email.input : password.input;
|
||||||
|
const panel: Panel = { form, first, errorEl, okEl };
|
||||||
form.addEventListener('submit', (event) => {
|
form.addEventListener('submit', (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
|
setFieldInvalid(username.input, ERROR_ID, false);
|
||||||
|
const name = username.input.value.trim(); // der Dienst trimmt nicht
|
||||||
const address = email.input.value.trim();
|
const address = email.input.value.trim();
|
||||||
// Der Fehler liegt am Feld, das ihn verursacht; verglichen wird nie mit dem (übersetzten) Text.
|
// Der Fehler liegt am Feld, das ihn verursacht (in der Reihenfolge der Felder); verglichen wird nie mit dem (übersetzten) Text.
|
||||||
|
const nameProblem = validateUsername(name);
|
||||||
|
if (nameProblem) {
|
||||||
|
showFieldError(panel, username.input, nameProblem);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const mismatch = password.input.value !== repeat.input.value;
|
const mismatch = password.input.value !== repeat.input.value;
|
||||||
const problem = mismatch ? t(MISMATCH) : validatePassword(password.input.value);
|
const problem = mismatch ? t(MISMATCH) : validatePassword(password.input.value);
|
||||||
if (problem) {
|
if (problem) {
|
||||||
@@ -236,10 +315,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
|
|||||||
void run(
|
void run(
|
||||||
panel,
|
panel,
|
||||||
'register',
|
'register',
|
||||||
async () => {
|
() => registerWithName(panel, username.input, name, address, password.input.value),
|
||||||
await register(address, password.input.value);
|
|
||||||
finish(true);
|
|
||||||
},
|
|
||||||
(err) => {
|
(err) => {
|
||||||
if (errorType(err) !== 'user_already_exists') return undefined;
|
if (errorType(err) !== 'user_already_exists') return undefined;
|
||||||
return linkButton(t('Anmelden'), () => showView('login'));
|
return linkButton(t('Anmelden'), () => showView('login'));
|
||||||
@@ -267,9 +343,11 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
|
|||||||
|
|
||||||
/** Zeigt eine Ansicht; `focus` bestimmt, ob danach das erste Feld oder der gewählte Reiter den Fokus bekommt. */
|
/** Zeigt eine Ansicht; `focus` bestimmt, ob danach das erste Feld oder der gewählte Reiter den Fokus bekommt. */
|
||||||
function showView(next: LoginView, focus: 'input' | 'tab' = 'input'): void {
|
function showView(next: LoginView, focus: 'input' | 'tab' = 'input'): void {
|
||||||
// Die eingegebene E-Mail wandert beim Wechsel in die neue Ansicht mit; Passwörter nie.
|
// Die eingegebene E-Mail und der Benutzername wandern beim Wechsel in die neue Ansicht mit; Passwörter nie.
|
||||||
const typed = content.querySelector<HTMLInputElement>('input[name="email"]');
|
const typed = content.querySelector<HTMLInputElement>('input[name="email"]');
|
||||||
if (typed) draftEmail = typed.value.trim();
|
if (typed) draftEmail = typed.value.trim();
|
||||||
|
const typedName = content.querySelector<HTMLInputElement>('input[name="username"]');
|
||||||
|
if (typedName) draftUsername = typedName.value.trim();
|
||||||
view = next;
|
view = next;
|
||||||
title.textContent = t(TITLES[next]);
|
title.textContent = t(TITLES[next]);
|
||||||
const panel = buildPanel(next);
|
const panel = buildPanel(next);
|
||||||
@@ -287,6 +365,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
|
|||||||
if (pending) return pending;
|
if (pending) return pending;
|
||||||
opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||||
draftEmail = '';
|
draftEmail = '';
|
||||||
|
draftUsername = '';
|
||||||
busy = false;
|
busy = false;
|
||||||
pending = new Promise<boolean>((resolve) => {
|
pending = new Promise<boolean>((resolve) => {
|
||||||
settle = resolve;
|
settle = resolve;
|
||||||
|
|||||||
@@ -0,0 +1,554 @@
|
|||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import { Query } from 'appwrite';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
listRows: vi.fn(),
|
||||||
|
getRow: vi.fn(),
|
||||||
|
apiPost: vi.fn(),
|
||||||
|
userId: null as string | null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./appwrite', () => ({
|
||||||
|
IDS: { database: 'db', profiles: 'profiles-table' },
|
||||||
|
tables: { listRows: mocks.listRows, getRow: mocks.getRow },
|
||||||
|
account: {},
|
||||||
|
client: {},
|
||||||
|
storage: {},
|
||||||
|
APPWRITE_ENDPOINT: '',
|
||||||
|
}));
|
||||||
|
vi.mock('./auth', () => ({ currentUserId: () => mocks.userId, onAuthChange: () => () => {}, onLogout: () => () => {} }));
|
||||||
|
// Nur der Netzaufruf ist ersetzt; ApiError und unavailableError bleiben echt.
|
||||||
|
vi.mock('../community/api', async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import('../community/api')>()),
|
||||||
|
apiPost: mocks.apiPost,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Muster, Sperrliste und Schlüssel liegen im Dienst (deploy/api/common.js, CommonJS ohne Abhängigkeiten); der Test lädt sie von dort.
|
||||||
|
const server = createRequire(import.meta.url)('../../../deploy/api/common.js') as {
|
||||||
|
USERNAME_PATTERN: RegExp;
|
||||||
|
RESERVED_USERNAMES: readonly string[];
|
||||||
|
usernameKey: (name: string) => string;
|
||||||
|
isReservedUsername: (name: string) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const INVALID = 'Der Benutzername muss 3–20 Zeichen lang sein, mit einem Buchstaben oder einer Zahl beginnen und darf nur Buchstaben, Zahlen, Punkt, Unterstrich und Bindestrich enthalten.';
|
||||||
|
const RESERVED = 'Dieser Benutzername ist nicht erlaubt.';
|
||||||
|
|
||||||
|
const notFound = (type = 'row_not_found') => Object.assign(new Error('Row not found'), { code: 404, type });
|
||||||
|
|
||||||
|
type Profile = typeof import('./profile');
|
||||||
|
let profile: Profile;
|
||||||
|
/** Das Modul hält den Zwischenspeicher der Namen: jeder Test bekommt eine frische Instanz (samt i18n und ApiError). */
|
||||||
|
let ApiError: typeof import('../community/api').ApiError;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
for (const fn of [mocks.listRows, mocks.getRow, mocks.apiPost]) fn.mockReset();
|
||||||
|
mocks.userId = null;
|
||||||
|
sessionStorage.clear();
|
||||||
|
vi.resetModules();
|
||||||
|
profile = await import('./profile');
|
||||||
|
({ ApiError } = await import('../community/api'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Regeln im Gleichlauf mit dem API-Dienst (deploy/api/common.js)', () => {
|
||||||
|
it('der Dienst liefert seine Regeln (sonst bestünde der Vergleich leer)', () => {
|
||||||
|
expect(server.RESERVED_USERNAMES.length).toBeGreaterThan(20);
|
||||||
|
expect(server.USERNAME_PATTERN.test('Kenso')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('das Muster ist dasselbe: genau ^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$', () => {
|
||||||
|
expect(profile.USERNAME_PATTERN.source).toBe('^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$');
|
||||||
|
expect(profile.USERNAME_PATTERN.source).toBe(server.USERNAME_PATTERN.source);
|
||||||
|
expect(profile.USERNAME_PATTERN.flags).toBe(server.USERNAME_PATTERN.flags);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die Sperrliste ist dieselbe (Einträge und Reihenfolge)', () => {
|
||||||
|
expect([...profile.RESERVED_USERNAMES]).toEqual([...server.RESERVED_USERNAMES]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('jeder gesperrte Schlüssel passt zum Muster (sonst wäre er wirkungslos: der Name scheiterte schon als ungültig)', () => {
|
||||||
|
const unusable = profile.RESERVED_USERNAMES.filter((key) => !profile.USERNAME_PATTERN.test(key));
|
||||||
|
expect(unusable).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Schlüssel und Sperre urteilen wie der Dienst', () => {
|
||||||
|
for (const name of ['Kenso', 'KENSO', 'Admin', 'AirCard-IOS', 'aircard.ios', 'Mod', 'modx', 'a.b_c-d', 'Hilfe']) {
|
||||||
|
expect(profile.usernameKey(name)).toBe(server.usernameKey(name));
|
||||||
|
expect(profile.validateUsername(name) === RESERVED).toBe(server.isReservedUsername(name));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('usernameKey', () => {
|
||||||
|
it('der Name in Kleinbuchstaben', () => {
|
||||||
|
expect(profile.usernameKey('Kenso')).toBe('kenso');
|
||||||
|
expect(profile.usernameKey('AirCard-IOS')).toBe('aircard-ios');
|
||||||
|
expect(profile.usernameKey('a.B_c-9')).toBe('a.b_c-9');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateUsername', () => {
|
||||||
|
it.each(['abc', 'a1b', 'Kenso', 'k.e_n-so', '0abc', '9..', 'x'.repeat(20), 'ABC', 'admin1', 'administrators', 'momo'])('gültig: %s', (name) => {
|
||||||
|
expect(profile.validateUsername(name)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Grenzen: 3 Zeichen sind das Minimum, 20 das Maximum', () => {
|
||||||
|
expect(profile.validateUsername('ab')).toBe(INVALID);
|
||||||
|
expect(profile.validateUsername('abc')).toBeNull();
|
||||||
|
expect(profile.validateUsername('x'.repeat(20))).toBeNull();
|
||||||
|
expect(profile.validateUsername('x'.repeat(21))).toBe(INVALID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('das erste Zeichen ist ein Buchstabe oder eine Zahl, kein Satzzeichen', () => {
|
||||||
|
for (const name of ['_abc', '.abc', '-abc']) expect(profile.validateUsername(name)).toBe(INVALID);
|
||||||
|
for (const name of ['a_c', 'a.c', 'a-c', '1bc']) expect(profile.validateUsername(name)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['', ' ', 'ab c', ' abc', 'abc ', 'abc\n', 'ab\tc', 'ab@c', 'ab/c', 'Käse', 'ünder', 'naïve', 'na😀me', 'ab:c', 'abc'])(
|
||||||
|
'ungültig (Muster): %j',
|
||||||
|
(name) => {
|
||||||
|
expect(profile.validateUsername(name)).toBe(INVALID);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('gesperrt, in jeder Schreibweise', () => {
|
||||||
|
for (const key of profile.RESERVED_USERNAMES) {
|
||||||
|
expect(profile.validateUsername(key), key).toBe(RESERVED);
|
||||||
|
expect(profile.validateUsername(key.toUpperCase()), key).toBe(RESERVED);
|
||||||
|
}
|
||||||
|
expect(profile.validateUsername('Admin')).toBe(RESERVED);
|
||||||
|
expect(profile.validateUsername('AirCard-IOS')).toBe(RESERVED);
|
||||||
|
expect(profile.validateUsername('aIrCaRd.IoS')).toBe(RESERVED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('das Muster geht vor der Sperrliste', () => {
|
||||||
|
expect(profile.validateUsername('_admin')).toBe(INVALID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der Text folgt der Sprache der Seite', async () => {
|
||||||
|
(await import('../i18n/i18n')).setLang('en', { persist: false });
|
||||||
|
expect(profile.validateUsername('a')).toBe(
|
||||||
|
'The username must be 3–20 characters long, start with a letter or a number, and may only contain letters, numbers, periods, underscores and hyphens.',
|
||||||
|
);
|
||||||
|
expect(profile.validateUsername('Admin')).toBe('This username is not allowed.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('IDS', () => {
|
||||||
|
it('kennt die Tabelle profiles (Name wie im Setup-Skript)', async () => {
|
||||||
|
vi.stubEnv('VITE_APPWRITE_ENDPOINT', 'https://aircard.test/v1');
|
||||||
|
try {
|
||||||
|
const { IDS } = await vi.importActual<typeof import('./appwrite')>('./appwrite');
|
||||||
|
expect(IDS.profiles).toBe('profiles');
|
||||||
|
expect(IDS.database).toBe('aircard'); // und das Bestehende bleibt
|
||||||
|
expect(IDS.logos).toBe('logos');
|
||||||
|
} finally {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getUsername', () => {
|
||||||
|
it('liest die Zeile mit der Konto-ID als Zeilen-ID', async () => {
|
||||||
|
mocks.getRow.mockResolvedValue({ $id: 'u1', username: 'Kenso', usernameKey: 'kenso' });
|
||||||
|
await expect(profile.getUsername('u1')).resolves.toBe('Kenso');
|
||||||
|
expect(mocks.getRow).toHaveBeenCalledExactlyOnceWith({ databaseId: 'db', tableId: 'profiles-table', rowId: 'u1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kein Profil (404) ergibt null', async () => {
|
||||||
|
mocks.getRow.mockRejectedValue(notFound());
|
||||||
|
await expect(profile.getUsername('u1')).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine Zeile ohne lesbaren Namen ergibt null', async () => {
|
||||||
|
mocks.getRow.mockResolvedValue({ $id: 'u1' });
|
||||||
|
await expect(profile.getUsername('u1')).resolves.toBeNull();
|
||||||
|
mocks.getRow.mockResolvedValue({ $id: 'u1', username: 42 });
|
||||||
|
await expect(profile.getUsername('u1')).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine ID, die keine Appwrite-ID sein kann, ergibt null ohne Anfrage', async () => {
|
||||||
|
for (const id of ['', '../x', 'a b', 'x'.repeat(40)]) await expect(profile.getUsername(id)).resolves.toBeNull();
|
||||||
|
expect(mocks.getRow).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('andere Fehler (Netz, 500, fehlende Tabelle) gehen durch', async () => {
|
||||||
|
const offline = new TypeError('Failed to fetch');
|
||||||
|
mocks.getRow.mockRejectedValueOnce(offline);
|
||||||
|
await expect(profile.getUsername('u1')).rejects.toBe(offline);
|
||||||
|
const server500 = Object.assign(new Error('boom'), { code: 500, type: 'general_server_error' });
|
||||||
|
mocks.getRow.mockRejectedValueOnce(server500);
|
||||||
|
await expect(profile.getUsername('u1')).rejects.toBe(server500);
|
||||||
|
const noTable = notFound('table_not_found');
|
||||||
|
mocks.getRow.mockRejectedValueOnce(noTable);
|
||||||
|
await expect(profile.getUsername('u1')).rejects.toBe(noTable);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('usernamesFor', () => {
|
||||||
|
const row = (id: string, username: string) => ({ $id: id, username, usernameKey: username.toLowerCase() });
|
||||||
|
|
||||||
|
it('sammelt die IDs in einer einzigen Abfrage (equal auf $id)', async () => {
|
||||||
|
mocks.listRows.mockResolvedValue({ rows: [row('u1', 'Kenso'), row('u2', 'Mia')] });
|
||||||
|
const names = await profile.usernamesFor(['u1', 'u2']);
|
||||||
|
expect([...names]).toEqual([
|
||||||
|
['u1', 'Kenso'],
|
||||||
|
['u2', 'Mia'],
|
||||||
|
]);
|
||||||
|
expect(mocks.listRows).toHaveBeenCalledExactlyOnceWith({
|
||||||
|
databaseId: 'db',
|
||||||
|
tableId: 'profiles-table',
|
||||||
|
queries: [Query.equal('$id', ['u1', 'u2']), Query.limit(2)],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('doppelte IDs zählen einmal, Unbrauchbares wird gar nicht erst gefragt', async () => {
|
||||||
|
mocks.listRows.mockResolvedValue({ rows: [row('u1', 'Kenso')] });
|
||||||
|
await profile.usernamesFor(['u1', 'u1', '', '../x', 'u1']);
|
||||||
|
expect(mocks.listRows.mock.calls[0][0].queries).toEqual([Query.equal('$id', ['u1']), Query.limit(1)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nichts zu fragen: leere Map ohne Anfrage', async () => {
|
||||||
|
expect((await profile.usernamesFor([])).size).toBe(0);
|
||||||
|
expect((await profile.usernamesFor(['', 'a b'])).size).toBe(0);
|
||||||
|
expect(mocks.listRows).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merkt sich die Namen: dieselben IDs fragen nicht noch einmal, neue IDs allein', async () => {
|
||||||
|
mocks.listRows.mockResolvedValueOnce({ rows: [row('u1', 'Kenso'), row('u2', 'Mia')] });
|
||||||
|
await profile.usernamesFor(['u1', 'u2']);
|
||||||
|
|
||||||
|
const again = await profile.usernamesFor(['u2', 'u1']);
|
||||||
|
expect([...again].sort()).toEqual([
|
||||||
|
['u1', 'Kenso'],
|
||||||
|
['u2', 'Mia'],
|
||||||
|
]);
|
||||||
|
expect(mocks.listRows).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
mocks.listRows.mockResolvedValueOnce({ rows: [row('u3', 'Lea')] });
|
||||||
|
const more = await profile.usernamesFor(['u1', 'u3']);
|
||||||
|
expect(mocks.listRows).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mocks.listRows.mock.calls[1][0].queries).toEqual([Query.equal('$id', ['u3']), Query.limit(1)]);
|
||||||
|
expect([...more].sort()).toEqual([
|
||||||
|
['u1', 'Kenso'],
|
||||||
|
['u3', 'Lea'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Konten ohne Profil fehlen in der Map und werden beim nächsten Mal wieder gefragt (sie können sich später einen Namen wählen)', async () => {
|
||||||
|
mocks.listRows.mockResolvedValue({ rows: [row('u1', 'Kenso')] });
|
||||||
|
const names = await profile.usernamesFor(['u1', 'u9']);
|
||||||
|
expect(names.has('u9')).toBe(false);
|
||||||
|
await profile.usernamesFor(['u9']);
|
||||||
|
expect(mocks.listRows).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Zeilen ohne lesbaren Namen zählen nicht', async () => {
|
||||||
|
mocks.listRows.mockResolvedValue({ rows: [{ $id: 'u1' }, { $id: 'u2', username: '' }, { $id: 'u3', username: 7 }, row('u4', 'Mia')] });
|
||||||
|
const names = await profile.usernamesFor(['u1', 'u2', 'u3', 'u4']);
|
||||||
|
expect([...names]).toEqual([['u4', 'Mia']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Fehler ergeben eine leere Map statt eines Fehlers und merken nichts', async () => {
|
||||||
|
mocks.listRows.mockRejectedValueOnce(new TypeError('Failed to fetch'));
|
||||||
|
const names = await profile.usernamesFor(['u1', 'u2']);
|
||||||
|
expect(names).toBeInstanceOf(Map);
|
||||||
|
expect(names.size).toBe(0);
|
||||||
|
|
||||||
|
mocks.listRows.mockResolvedValueOnce({ rows: [row('u1', 'Kenso')] });
|
||||||
|
expect([...(await profile.usernamesFor(['u1']))]).toEqual([['u1', 'Kenso']]); // der nächste Versuch fragt wieder
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bei einem Fehler bleiben die schon bekannten Namen erhalten', async () => {
|
||||||
|
mocks.listRows.mockResolvedValueOnce({ rows: [row('u1', 'Kenso')] });
|
||||||
|
await profile.usernamesFor(['u1']);
|
||||||
|
mocks.listRows.mockRejectedValueOnce(Object.assign(new Error('boom'), { code: 500 }));
|
||||||
|
const names = await profile.usernamesFor(['u1', 'u2']);
|
||||||
|
expect([...names]).toEqual([['u1', 'Kenso']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('viele IDs gehen in Gruppen zu je 100 (eine Gruppe pro Abfrage)', async () => {
|
||||||
|
const ids = Array.from({ length: 250 }, (_, i) => `u${i}`);
|
||||||
|
mocks.listRows.mockImplementation(async ({ queries }: { queries: string[] }) => {
|
||||||
|
const asked = (JSON.parse(queries[0]) as { values: string[] }).values;
|
||||||
|
return { rows: asked.map((id) => row(id, `Name${id}`)) };
|
||||||
|
});
|
||||||
|
const names = await profile.usernamesFor(ids);
|
||||||
|
expect(names.size).toBe(250);
|
||||||
|
expect(mocks.listRows.mock.calls.map((call) => (JSON.parse(call[0].queries[0]) as { values: string[] }).values.length)).toEqual([100, 100, 50]);
|
||||||
|
expect(mocks.listRows.mock.calls.map((call) => call[0].queries[1])).toEqual([Query.limit(100), Query.limit(100), Query.limit(50)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Name, der während der Abfrage durch Umbenennen im Speicher landet, ist neuer als die Antwort', async () => {
|
||||||
|
mocks.userId = 'u1';
|
||||||
|
let answer!: (value: unknown) => void;
|
||||||
|
mocks.listRows.mockReturnValue(new Promise((resolve) => (answer = resolve)));
|
||||||
|
const pending = profile.usernamesFor(['u1']);
|
||||||
|
mocks.apiPost.mockResolvedValue({ username: 'Neu' });
|
||||||
|
await profile.saveUsername('Neu');
|
||||||
|
answer({ rows: [row('u1', 'Alt')] });
|
||||||
|
expect([...(await pending)]).toEqual([['u1', 'Neu']]);
|
||||||
|
expect([...(await profile.usernamesFor(['u1']))]).toEqual([['u1', 'Neu']]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isUsernameTaken', () => {
|
||||||
|
it('fragt den Schlüssel (klein geschrieben) mit equal ab, eine Zeile genügt', async () => {
|
||||||
|
mocks.listRows.mockResolvedValue({ rows: [] });
|
||||||
|
await profile.isUsernameTaken('KenSo');
|
||||||
|
expect(mocks.listRows).toHaveBeenCalledExactlyOnceWith({
|
||||||
|
databaseId: 'db',
|
||||||
|
tableId: 'profiles-table',
|
||||||
|
queries: [Query.equal('usernameKey', 'kenso'), Query.limit(1)],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('vergeben, wenn ein Profil den Schlüssel hat', async () => {
|
||||||
|
mocks.listRows.mockResolvedValue({ rows: [{ $id: 'u2', username: 'kenso', usernameKey: 'kenso' }] });
|
||||||
|
await expect(profile.isUsernameTaken('Kenso')).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('frei, wenn keine Zeile passt', async () => {
|
||||||
|
mocks.listRows.mockResolvedValue({ rows: [] });
|
||||||
|
await expect(profile.isUsernameTaken('Kenso')).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der eigene Eintrag zählt nicht (nur die Schreibweise des eigenen Namens ändern ist erlaubt)', async () => {
|
||||||
|
mocks.userId = 'u1';
|
||||||
|
mocks.listRows.mockResolvedValue({ rows: [{ $id: 'u1', username: 'kenso', usernameKey: 'kenso' }] });
|
||||||
|
await expect(profile.isUsernameTaken('Kenso')).resolves.toBe(false);
|
||||||
|
mocks.listRows.mockResolvedValue({ rows: [{ $id: 'u2', username: 'kenso', usernameKey: 'kenso' }] });
|
||||||
|
await expect(profile.isUsernameTaken('Kenso')).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Fehler gehen durch (die Registrierung zeigt sie am Dialog)', async () => {
|
||||||
|
const offline = new TypeError('Failed to fetch');
|
||||||
|
mocks.listRows.mockRejectedValue(offline);
|
||||||
|
await expect(profile.isUsernameTaken('Kenso')).rejects.toBe(offline);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('saveUsername', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.userId = 'u1';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sendet POST /api/profil mit Anmeldung und gibt den Namen der Antwort zurück', async () => {
|
||||||
|
mocks.apiPost.mockResolvedValue({ username: 'Kenso' });
|
||||||
|
await expect(profile.saveUsername('Kenso')).resolves.toBe('Kenso');
|
||||||
|
expect(mocks.apiPost).toHaveBeenCalledExactlyOnceWith('/api/profil', { username: 'Kenso' }, { auth: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aktualisiert den Zwischenspeicher: myUsername und usernamesFor kennen den Namen sofort, ohne Abfrage', async () => {
|
||||||
|
expect(profile.myUsername()).toBeNull();
|
||||||
|
mocks.apiPost.mockResolvedValue({ username: 'Kenso' });
|
||||||
|
await profile.saveUsername('Kenso');
|
||||||
|
expect(profile.myUsername()).toBe('Kenso');
|
||||||
|
expect([...(await profile.usernamesFor(['u1']))]).toEqual([['u1', 'Kenso']]);
|
||||||
|
expect(mocks.listRows).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('meldet die Änderung mit Konto-ID und Name an alle Hörer; abgemeldete Hörer hören nichts mehr', async () => {
|
||||||
|
const first = vi.fn();
|
||||||
|
const second = vi.fn();
|
||||||
|
const off = profile.onUsernameChange(first);
|
||||||
|
profile.onUsernameChange(second);
|
||||||
|
mocks.apiPost.mockResolvedValueOnce({ username: 'Kenso' });
|
||||||
|
await profile.saveUsername('Kenso');
|
||||||
|
expect(first).toHaveBeenCalledExactlyOnceWith('u1', 'Kenso');
|
||||||
|
expect(second).toHaveBeenCalledExactlyOnceWith('u1', 'Kenso');
|
||||||
|
|
||||||
|
off();
|
||||||
|
mocks.apiPost.mockResolvedValueOnce({ username: 'Neu' });
|
||||||
|
await profile.saveUsername('Neu');
|
||||||
|
expect(first).toHaveBeenCalledTimes(1);
|
||||||
|
expect(second).toHaveBeenLastCalledWith('u1', 'Neu');
|
||||||
|
expect(profile.myUsername()).toBe('Neu');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die Schreibweise der Antwort gilt (der Dienst speichert den Namen, wie er eingegeben wurde)', async () => {
|
||||||
|
mocks.apiPost.mockResolvedValue({ username: 'KenSo' });
|
||||||
|
await expect(profile.saveUsername('kenso')).resolves.toBe('KenSo');
|
||||||
|
expect(profile.myUsername()).toBe('KenSo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine Antwort ohne Namen ist ein unklarer Ausgang: Fehler 503, nichts gemerkt, nichts gemeldet', async () => {
|
||||||
|
const listener = vi.fn();
|
||||||
|
profile.onUsernameChange(listener);
|
||||||
|
for (const body of [{}, { username: '' }, { username: 5 }]) {
|
||||||
|
mocks.apiPost.mockResolvedValueOnce(body);
|
||||||
|
const err = await profile.saveUsername('Kenso').catch((e: unknown) => e);
|
||||||
|
expect(err).toBeInstanceOf(ApiError);
|
||||||
|
expect((err as InstanceType<typeof ApiError>).serverCode).toBe('unavailable');
|
||||||
|
}
|
||||||
|
expect(profile.myUsername()).toBeNull();
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Fehler des Dienstes gehen unverändert durch, der Zwischenspeicher bleibt', async () => {
|
||||||
|
mocks.apiPost.mockResolvedValueOnce({ username: 'Kenso' });
|
||||||
|
await profile.saveUsername('Kenso');
|
||||||
|
const listener = vi.fn();
|
||||||
|
profile.onUsernameChange(listener);
|
||||||
|
const taken = new ApiError(409, 'Dieser Benutzername ist schon vergeben.', undefined, 'usernameTaken');
|
||||||
|
mocks.apiPost.mockRejectedValueOnce(taken);
|
||||||
|
await expect(profile.saveUsername('Mia')).rejects.toBe(taken);
|
||||||
|
expect(profile.myUsername()).toBe('Kenso');
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Hörer, der wirft, stört weder die anderen noch das Ergebnis', async () => {
|
||||||
|
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
const later = vi.fn();
|
||||||
|
profile.onUsernameChange(() => {
|
||||||
|
throw new Error('kaputt');
|
||||||
|
});
|
||||||
|
profile.onUsernameChange(later);
|
||||||
|
mocks.apiPost.mockResolvedValue({ username: 'Kenso' });
|
||||||
|
await expect(profile.saveUsername('Kenso')).resolves.toBe('Kenso');
|
||||||
|
expect(later).toHaveBeenCalledOnce();
|
||||||
|
expect(error).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne bekanntes Konto geht der Aufruf durch, gemerkt und gemeldet wird nichts', async () => {
|
||||||
|
mocks.userId = null;
|
||||||
|
const listener = vi.fn();
|
||||||
|
profile.onUsernameChange(listener);
|
||||||
|
mocks.apiPost.mockResolvedValue({ username: 'Kenso' });
|
||||||
|
await expect(profile.saveUsername('Kenso')).resolves.toBe('Kenso');
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
expect(profile.myUsername()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gemerkt wird für das Konto, das den Aufruf gestartet hat, auch wenn inzwischen ein anderes angemeldet ist', async () => {
|
||||||
|
let answer!: (value: unknown) => void;
|
||||||
|
mocks.apiPost.mockReturnValue(new Promise((resolve) => (answer = resolve)));
|
||||||
|
const saving = profile.saveUsername('Kenso');
|
||||||
|
mocks.userId = 'u2'; // Kontowechsel, während die Antwort unterwegs ist
|
||||||
|
answer({ username: 'Kenso' });
|
||||||
|
await saving;
|
||||||
|
expect(profile.myUsername()).toBeNull(); // u2 hat (noch) keinen Namen
|
||||||
|
mocks.userId = 'u1';
|
||||||
|
expect(profile.myUsername()).toBe('Kenso');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('myUsername', () => {
|
||||||
|
it('null ohne Konto und null, solange der Name nicht bekannt ist', () => {
|
||||||
|
expect(profile.myUsername()).toBeNull();
|
||||||
|
mocks.userId = 'u1';
|
||||||
|
expect(profile.myUsername()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der Name gehört zum angemeldeten Konto: bei einem Wechsel zeigt myUsername den des anderen (oder keinen)', async () => {
|
||||||
|
mocks.userId = 'u1';
|
||||||
|
mocks.getRow.mockResolvedValue({ $id: 'u1', username: 'Kenso' });
|
||||||
|
await profile.loadMyUsername();
|
||||||
|
expect(profile.myUsername()).toBe('Kenso');
|
||||||
|
mocks.userId = 'u2';
|
||||||
|
expect(profile.myUsername()).toBeNull();
|
||||||
|
mocks.userId = null;
|
||||||
|
expect(profile.myUsername()).toBeNull();
|
||||||
|
mocks.userId = 'u1';
|
||||||
|
expect(profile.myUsername()).toBe('Kenso');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadMyUsername', () => {
|
||||||
|
it('ohne Konto null und keine Anfrage', async () => {
|
||||||
|
await expect(profile.loadMyUsername()).resolves.toBeNull();
|
||||||
|
expect(mocks.getRow).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('liest das Profil des angemeldeten Kontos, merkt den Namen und meldet ihn', async () => {
|
||||||
|
mocks.userId = 'u1';
|
||||||
|
mocks.getRow.mockResolvedValue({ $id: 'u1', username: 'Kenso' });
|
||||||
|
const listener = vi.fn();
|
||||||
|
profile.onUsernameChange(listener);
|
||||||
|
await expect(profile.loadMyUsername()).resolves.toBe('Kenso');
|
||||||
|
expect(mocks.getRow).toHaveBeenCalledExactlyOnceWith({ databaseId: 'db', tableId: 'profiles-table', rowId: 'u1' });
|
||||||
|
expect(profile.myUsername()).toBe('Kenso');
|
||||||
|
expect(listener).toHaveBeenCalledExactlyOnceWith('u1', 'Kenso');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('liest bei jedem Aufruf neu (der Name kann auf einem anderen Gerät geändert worden sein)', async () => {
|
||||||
|
mocks.userId = 'u1';
|
||||||
|
mocks.getRow.mockResolvedValueOnce({ $id: 'u1', username: 'Alt' }).mockResolvedValueOnce({ $id: 'u1', username: 'Neu' });
|
||||||
|
await profile.loadMyUsername();
|
||||||
|
await expect(profile.loadMyUsername()).resolves.toBe('Neu');
|
||||||
|
expect(profile.myUsername()).toBe('Neu');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne Profil (404) ist das Ergebnis null, nichts wird gemeldet', async () => {
|
||||||
|
mocks.userId = 'u1';
|
||||||
|
mocks.getRow.mockRejectedValue(notFound());
|
||||||
|
const listener = vi.fn();
|
||||||
|
profile.onUsernameChange(listener);
|
||||||
|
await expect(profile.loadMyUsername()).resolves.toBeNull();
|
||||||
|
expect(profile.myUsername()).toBeNull();
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Name, der inzwischen gespeichert wurde, bleibt, auch wenn die Antwort „kein Profil“ sagt (Registrierung)', async () => {
|
||||||
|
mocks.userId = 'u1';
|
||||||
|
let answer!: (error: unknown) => void;
|
||||||
|
mocks.getRow.mockReturnValue(new Promise((_, reject) => (answer = reject)));
|
||||||
|
const loading = profile.loadMyUsername();
|
||||||
|
mocks.apiPost.mockResolvedValue({ username: 'Kenso' });
|
||||||
|
await profile.saveUsername('Kenso'); // das Konto wurde gerade angelegt: die Abfrage war schneller als das Speichern
|
||||||
|
answer(notFound());
|
||||||
|
await expect(loading).resolves.toBe('Kenso');
|
||||||
|
expect(profile.myUsername()).toBe('Kenso');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('andere Fehler gehen durch (der Aufrufer entscheidet; „unbekannt“ ist nicht „kein Name“)', async () => {
|
||||||
|
mocks.userId = 'u1';
|
||||||
|
const offline = new TypeError('Failed to fetch');
|
||||||
|
mocks.getRow.mockRejectedValue(offline);
|
||||||
|
await expect(profile.loadMyUsername()).rejects.toBe(offline);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Merker „in dieser Sitzung schon gefragt“', () => {
|
||||||
|
it('beginnt leer; markieren schreibt aircard.usernamePrompted in sessionStorage', () => {
|
||||||
|
expect(profile.usernamePrompted()).toBe(false);
|
||||||
|
profile.markUsernamePrompted();
|
||||||
|
expect(profile.usernamePrompted()).toBe(true);
|
||||||
|
expect(sessionStorage.getItem('aircard.usernamePrompted')).toBe('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gilt für die ganze Sitzung: auch eine frisch geladene Seite sieht ihn', async () => {
|
||||||
|
profile.markUsernamePrompted();
|
||||||
|
vi.resetModules();
|
||||||
|
const reloaded = await import('./profile');
|
||||||
|
expect(reloaded.usernamePrompted()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('löschen nimmt ihn wieder weg', () => {
|
||||||
|
profile.markUsernamePrompted();
|
||||||
|
profile.clearUsernamePrompted();
|
||||||
|
expect(profile.usernamePrompted()).toBe(false);
|
||||||
|
expect(sessionStorage.getItem('aircard.usernamePrompted')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ist der Speicher gesperrt, wirft nichts und der Merker gilt bis zum Neuladen der Seite', () => {
|
||||||
|
const denied = () => {
|
||||||
|
throw new DOMException('blockiert', 'SecurityError');
|
||||||
|
};
|
||||||
|
vi.stubGlobal('sessionStorage', { getItem: denied, setItem: denied, removeItem: denied });
|
||||||
|
try {
|
||||||
|
expect(profile.usernamePrompted()).toBe(false);
|
||||||
|
expect(() => profile.markUsernamePrompted()).not.toThrow();
|
||||||
|
expect(profile.usernamePrompted()).toBe(true);
|
||||||
|
expect(() => profile.clearUsernamePrompted()).not.toThrow();
|
||||||
|
expect(profile.usernamePrompted()).toBe(false);
|
||||||
|
} finally {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
// Benutzernamen (Spec 2026-10-05-aircard-benutzernamen-design.md): die Regeln für den Namen, Profile lesen (Tabelle `profiles`: eine
|
||||||
|
// öffentlich lesbare Zeile je Konto, Zeilen-ID = Konto-ID) und den eigenen Namen speichern (`POST /api/profil` des Dienstes
|
||||||
|
// `aircard-api`). Der Browser prüft nur vor; was gilt, entscheidet der Dienst: Er prüft Muster und Sperrliste noch einmal, und der
|
||||||
|
// Unique-Index auf `usernameKey` hält einen Namen auch bei zwei gleichzeitigen Anfragen nur einmal.
|
||||||
|
import { Query, type Models } from 'appwrite';
|
||||||
|
import { ID_PATTERN } from '../card/config';
|
||||||
|
import { apiPost, unavailableError } from '../community/api';
|
||||||
|
import { API_MESSAGES } from '../i18n/api-messages';
|
||||||
|
import { msg, t } from '../i18n/i18n';
|
||||||
|
import { IDS, tables } from './appwrite';
|
||||||
|
import { currentUserId } from './auth';
|
||||||
|
|
||||||
|
/** Wie im Dienst (deploy/api/common.js): 3–20 Zeichen, das erste ein Buchstabe oder eine Zahl, danach Buchstaben, Zahlen, `.`, `_`, `-`. */
|
||||||
|
export const USERNAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gesperrte Namen als Schlüssel (klein geschrieben, Spec § 2): Rollen, der Dienst selbst und Platzhalter, die nach einem Konto aussehen.
|
||||||
|
* Genau die Liste des Dienstes (deploy/api/common.js, RESERVED_USERNAMES); profile.test.ts vergleicht beide.
|
||||||
|
*/
|
||||||
|
export const RESERVED_USERNAMES: readonly string[] = [
|
||||||
|
'admin',
|
||||||
|
'administrator',
|
||||||
|
'root',
|
||||||
|
'system',
|
||||||
|
'support',
|
||||||
|
'moderator',
|
||||||
|
'mod',
|
||||||
|
'aircard',
|
||||||
|
'aircardios',
|
||||||
|
'aircard-ios',
|
||||||
|
'aircard.ios',
|
||||||
|
'aircard_ios',
|
||||||
|
'webklar',
|
||||||
|
'anonym',
|
||||||
|
'anonymous',
|
||||||
|
'null',
|
||||||
|
'undefined',
|
||||||
|
'team',
|
||||||
|
'staff',
|
||||||
|
'official',
|
||||||
|
'offiziell',
|
||||||
|
'hilfe',
|
||||||
|
'help',
|
||||||
|
'kontakt',
|
||||||
|
'contact',
|
||||||
|
'info',
|
||||||
|
];
|
||||||
|
const RESERVED = new Set(RESERVED_USERNAMES);
|
||||||
|
|
||||||
|
/** Hinweis unter dem Feld „Benutzername“ (Registrieren und Namensdialog); angezeigt wird er mit `t(USERNAME_HINT)`. */
|
||||||
|
export const USERNAME_HINT = msg('3–20 Zeichen: Buchstaben, Zahlen, . _ -');
|
||||||
|
|
||||||
|
/** Obergrenze der Konten-IDs in einer Abfrage (so viele Werte nimmt ein `equal` sicher an). */
|
||||||
|
const LOOKUP_MAX = 100;
|
||||||
|
|
||||||
|
type TableRow = Models.Row & Record<string, unknown>;
|
||||||
|
|
||||||
|
function text(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Schlüssel eines Namens (Spalte `usernameKey`): klein geschrieben. Über ihn ist ein Name nur einmal vergeben („Kenso“ = „kenso“). */
|
||||||
|
export function usernameKey(name: string): string {
|
||||||
|
return name.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft einen Namen vor dem Senden: null, wenn er passt, sonst der Text für den Nutzer in der Sprache der Seite (Muster, dann Sperrliste).
|
||||||
|
* Geprüft wird genau die Zeichenkette, die übergeben wird; wer sie vom Nutzer hat, trimmt sie vorher (der Dienst tut es nicht).
|
||||||
|
*/
|
||||||
|
export function validateUsername(name: string): string | null {
|
||||||
|
if (!USERNAME_PATTERN.test(name)) return t(API_MESSAGES.usernameInvalid);
|
||||||
|
if (RESERVED.has(usernameKey(name))) return t(API_MESSAGES.usernameReserved);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Namen merken und melden ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Konto-ID → Benutzername, nur gefundene (ein Konto ohne Profil kann sich später einen Namen wählen, also wird es wieder gefragt). */
|
||||||
|
const names = new Map<string, string>();
|
||||||
|
const listeners = new Set<(userId: string, username: string) => void>();
|
||||||
|
|
||||||
|
/** Merkt den Namen eines Kontos, dessen Stand diese Seite gerade selbst erfahren hat (Speichern, Lesen des eigenen Profils), und meldet ihn. */
|
||||||
|
function announce(userId: string, username: string): void {
|
||||||
|
names.set(userId, username);
|
||||||
|
// Kopie, damit sich ein Hörer beim Melden abmelden darf; ein werfender Hörer stört weder die anderen noch das Ergebnis.
|
||||||
|
for (const fn of [...listeners]) {
|
||||||
|
try {
|
||||||
|
fn(userId, username);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Ein Hörer der Namensänderung ist gescheitert', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Meldet, wenn der Name des angemeldeten Kontos feststeht oder sich ändert: nach dem Speichern und nach dem Lesen des eigenen Profils
|
||||||
|
* (`loadMyUsername`). Nicht gemeldet wird, was `usernamesFor` über andere erfährt. Gibt die Abmelde-Funktion zurück.
|
||||||
|
*/
|
||||||
|
export function onUsernameChange(fn: (userId: string, username: string) => void): () => void {
|
||||||
|
listeners.add(fn);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(fn);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Name des angemeldeten Kontos laut Zwischenspeicher; null, wenn es keinen hat, keiner angemeldet ist oder er noch nicht gelesen wurde. */
|
||||||
|
export function myUsername(): string | null {
|
||||||
|
const userId = currentUserId();
|
||||||
|
return userId ? (names.get(userId) ?? null) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Lesen --------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** „Es gibt kein Profil (oder keines für mich)“; eine fehlende Tabelle oder Datenbank ist dagegen ein echter Fehler. */
|
||||||
|
function isRowMissing(err: unknown): boolean {
|
||||||
|
if (typeof err !== 'object' || err === null) return false;
|
||||||
|
const { code, type } = err as { code?: unknown; type?: unknown };
|
||||||
|
return code === 404 && (type === 'row_not_found' || type === undefined || type === '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Name eines Kontos. Hat es kein Profil, ist das Ergebnis null (Appwrite antwortet mit 404); eine ID, die keine Appwrite-ID sein
|
||||||
|
* kann, ergibt null ohne Anfrage. Andere Fehler (Netz, Server) gehen durch.
|
||||||
|
*/
|
||||||
|
export async function getUsername(userId: string): Promise<string | null> {
|
||||||
|
if (!ID_PATTERN.test(userId)) return null;
|
||||||
|
try {
|
||||||
|
const row = await tables.getRow<TableRow>({ databaseId: IDS.database, tableId: IDS.profiles, rowId: userId });
|
||||||
|
return text(row.username) || null;
|
||||||
|
} catch (err) {
|
||||||
|
if (isRowMissing(err)) return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Namen der Konten für die Anzeige „von @name“: eine Abfrage für alle IDs, die noch nicht bekannt sind (Konten ohne Profil fehlen
|
||||||
|
* in der Map). Gefundene Namen werden gemerkt. Die Anzeige braucht den Namen nur als Beiwerk: schlägt die Abfrage fehl, ist das kein
|
||||||
|
* Fehler, es bleibt bei den schon bekannten Namen (ohne die ist die Map leer), und die Aufrufer greifen auf ihre Ersatznamen zurück.
|
||||||
|
*/
|
||||||
|
export async function usernamesFor(userIds: readonly string[]): Promise<Map<string, string>> {
|
||||||
|
const found = new Map<string, string>();
|
||||||
|
const missing: string[] = [];
|
||||||
|
for (const id of new Set(userIds)) {
|
||||||
|
if (!ID_PATTERN.test(id)) continue;
|
||||||
|
const known = names.get(id);
|
||||||
|
if (known === undefined) missing.push(id);
|
||||||
|
else found.set(id, known);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
for (let from = 0; from < missing.length; from += LOOKUP_MAX) {
|
||||||
|
const ids = missing.slice(from, from + LOOKUP_MAX);
|
||||||
|
const list = await tables.listRows<TableRow>({
|
||||||
|
databaseId: IDS.database,
|
||||||
|
tableId: IDS.profiles,
|
||||||
|
queries: [Query.equal('$id', ids), Query.limit(ids.length)],
|
||||||
|
});
|
||||||
|
for (const row of list.rows) {
|
||||||
|
const name = text(row.username);
|
||||||
|
if (name === '') continue;
|
||||||
|
// Steht für das Konto inzwischen ein Name im Speicher (es wurde während der Abfrage umbenannt), ist der neuer als die Antwort.
|
||||||
|
const current = names.get(row.$id) ?? name;
|
||||||
|
names.set(row.$id, current);
|
||||||
|
found.set(row.$id, current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// nur Beiwerk der Anzeige, siehe oben
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ist der Name schon an ein anderes Konto vergeben? Gefragt wird der Schlüssel (klein geschrieben), der eigene Eintrag des angemeldeten
|
||||||
|
* Kontos zählt nicht. Das ist die Vorabprüfung der Registrierung; bei zwei gleichzeitigen Anfragen entscheidet der Dienst.
|
||||||
|
* Fehler (Netz, Server) gehen durch.
|
||||||
|
*/
|
||||||
|
export async function isUsernameTaken(name: string): Promise<boolean> {
|
||||||
|
const list = await tables.listRows<TableRow>({
|
||||||
|
databaseId: IDS.database,
|
||||||
|
tableId: IDS.profiles,
|
||||||
|
queries: [Query.equal('usernameKey', usernameKey(name)), Query.limit(1)],
|
||||||
|
});
|
||||||
|
const own = currentUserId();
|
||||||
|
return list.rows.some((row) => row.$id !== own);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest den Namen des angemeldeten Kontos (nach dem Start und nach der Anmeldung), merkt und meldet ihn. Ergebnis: der Name, null ohne
|
||||||
|
* Profil oder ohne angemeldetes Konto. Lässt sich das Profil nicht lesen (Netz, Server), wirft der Aufruf: „unbekannt“ ist nicht „kein
|
||||||
|
* Name“, und nur bei „kein Name“ wird gefragt.
|
||||||
|
*/
|
||||||
|
export async function loadMyUsername(): Promise<string | null> {
|
||||||
|
const userId = currentUserId();
|
||||||
|
if (!userId) return null;
|
||||||
|
const name = await getUsername(userId);
|
||||||
|
// Kein Profil: war es inzwischen gespeichert (Registrierung: die Abfrage war schneller als das Speichern), gilt der gemerkte Name.
|
||||||
|
if (name === null) return names.get(userId) ?? null;
|
||||||
|
announce(userId, name);
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Speichern ----------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speichert den eigenen Namen (`POST /api/profil`): legt das Profil an oder ändert es, der alte Name ist danach frei. Ergebnis ist der
|
||||||
|
* Name, wie der Dienst ihn gespeichert hat. Der Zwischenspeicher gilt sofort, und alle Hörer von `onUsernameChange` erfahren es.
|
||||||
|
* Fehler sind `ApiError`: 400 `usernameInvalid`/`usernameReserved`, 409 `usernameTaken`, sonst Anmeldung, Netz, Dienst.
|
||||||
|
*/
|
||||||
|
export async function saveUsername(name: string): Promise<string> {
|
||||||
|
const userId = currentUserId(); // das Konto, das den Aufruf stellt, auch wenn inzwischen ein anderes angemeldet ist
|
||||||
|
const res = await apiPost('/api/profil', { username: name }, { auth: true });
|
||||||
|
const saved = res.username;
|
||||||
|
// Eine Antwort ohne Namen ist keine Antwort des Dienstes (Proxy, Fehlseite): der Ausgang ist offen.
|
||||||
|
if (typeof saved !== 'string' || saved === '') throw unavailableError();
|
||||||
|
if (userId) announce(userId, saved);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- „In dieser Sitzung schon gefragt“ ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Merker in sessionStorage (Spec § 5): gilt für die Sitzung des Tabs, nicht für ein Konto. */
|
||||||
|
const PROMPTED_KEY = 'aircard.usernamePrompted';
|
||||||
|
/** Zusätzlich im Speicher: ist sessionStorage gesperrt, gilt „schon gefragt“ wenigstens bis die Seite neu geladen wird. */
|
||||||
|
let promptedInMemory = false;
|
||||||
|
|
||||||
|
/** Wurde in dieser Sitzung schon nach einem Benutzernamen gefragt? (Jeder Zugriff auf den Speicher steht in try/catch.) */
|
||||||
|
export function usernamePrompted(): boolean {
|
||||||
|
if (promptedInMemory) return true;
|
||||||
|
try {
|
||||||
|
return sessionStorage.getItem(PROMPTED_KEY) === '1';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merkt, dass in dieser Sitzung nach einem Benutzernamen gefragt wird. */
|
||||||
|
export function markUsernamePrompted(): void {
|
||||||
|
promptedInMemory = true;
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(PROMPTED_KEY, '1');
|
||||||
|
} catch {
|
||||||
|
// gesperrt oder voll: es bleibt beim Merker im Speicher
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nimmt den Merker zurück (die Registrierung hat ihn gesetzt, ist dann aber gescheitert). */
|
||||||
|
export function clearUsernamePrompted(): void {
|
||||||
|
promptedInMemory = false;
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem(PROMPTED_KEY);
|
||||||
|
} catch {
|
||||||
|
// gesperrt: nichts zu entfernen
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,633 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { germanLeft } from '../i18n/english-check';
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
saveUsername: vi.fn(),
|
||||||
|
loadMyUsername: vi.fn(),
|
||||||
|
/** Der Name des angemeldeten Kontos laut Zwischenspeicher (myUsername). */
|
||||||
|
name: null as string | null,
|
||||||
|
userId: null as string | null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
|
||||||
|
vi.mock('./auth', () => ({ currentUserId: () => mocks.userId, onAuthChange: () => () => {}, onLogout: () => () => {} }));
|
||||||
|
|
||||||
|
const INVALID =
|
||||||
|
'Der Benutzername muss 3–20 Zeichen lang sein, mit einem Buchstaben oder einer Zahl beginnen und darf nur Buchstaben, Zahlen, Punkt, Unterstrich und Bindestrich enthalten.';
|
||||||
|
const TAKEN = 'Dieser Benutzername ist schon vergeben.';
|
||||||
|
|
||||||
|
type Dialog = typeof import('./username-dialog');
|
||||||
|
let dialog: Dialog;
|
||||||
|
/** Der Dialog hält einen Zustand (nur einer zur Zeit): jeder Test bekommt frische Module; ApiError stammt aus derselben Kopie. */
|
||||||
|
let ApiError: typeof import('../community/api').ApiError;
|
||||||
|
let opener: HTMLButtonElement;
|
||||||
|
|
||||||
|
const dialogEl = () => document.querySelector<HTMLDialogElement>('dialog.username-dialog');
|
||||||
|
const input = () => dialogEl()!.querySelector<HTMLInputElement>('input[name="username"]')!;
|
||||||
|
const form = () => dialogEl()!.querySelector('form')!;
|
||||||
|
const send = () => form().dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||||
|
const buttonByText = (text: string) =>
|
||||||
|
[...(dialogEl()?.querySelectorAll('button') ?? [])].find((b) => b.textContent === text) as HTMLButtonElement | undefined;
|
||||||
|
const errorEl = () => dialogEl()!.querySelector<HTMLElement>('[role="alert"]')!;
|
||||||
|
const errorText = () => errorEl().textContent;
|
||||||
|
const statusText = () => dialogEl()!.querySelector('[role="status"]')!.textContent;
|
||||||
|
const heading = () => dialogEl()!.querySelector('h2')!.textContent;
|
||||||
|
const intro = () => dialogEl()!.querySelector('.username-text')!.textContent;
|
||||||
|
/** Die IDs in `aria-describedby` des Feldes. */
|
||||||
|
const describedBy = () => (input().getAttribute('aria-describedby') ?? '').split(' ').filter(Boolean);
|
||||||
|
const settle = async () => {
|
||||||
|
for (let i = 0; i < 30; i++) await Promise.resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mocks.saveUsername.mockReset();
|
||||||
|
mocks.loadMyUsername.mockReset();
|
||||||
|
mocks.name = null;
|
||||||
|
mocks.userId = null;
|
||||||
|
document.body.innerHTML = '<button id="opener">Konto</button>';
|
||||||
|
opener = document.getElementById('opener') as HTMLButtonElement;
|
||||||
|
opener.focus();
|
||||||
|
vi.resetModules();
|
||||||
|
// Nur Netzzugriff und Zwischenspeicher sind ersetzt; Muster, Sperrliste und die Texte bleiben echt. `doMock` nach jedem Zurücksetzen:
|
||||||
|
// ein einmal berechnetes `vi.mock` bliebe über `resetModules` hinweg bei der ersten Kopie von profile.ts und i18n.ts stehen.
|
||||||
|
vi.doMock('./profile', async () => ({
|
||||||
|
...(await vi.importActual<typeof import('./profile')>('./profile')),
|
||||||
|
saveUsername: mocks.saveUsername,
|
||||||
|
loadMyUsername: mocks.loadMyUsername,
|
||||||
|
myUsername: () => mocks.name,
|
||||||
|
}));
|
||||||
|
dialog = await import('./username-dialog');
|
||||||
|
({ ApiError } = await import('../community/api'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
vi.doUnmock('./profile');
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Dialog „Benutzername wählen“: Aufbau', () => {
|
||||||
|
it('prompt: Überschrift, leeres und fokussiertes Feld, Hinweis, „Später“ und „Speichern“', () => {
|
||||||
|
void dialog.usernameDialog({ mode: 'prompt' });
|
||||||
|
expect(dialogEl()?.open).toBe(true);
|
||||||
|
expect(heading()).toBe('Benutzername wählen');
|
||||||
|
expect(intro()).toBe('Dein Benutzername ist öffentlich und steht bei deinen Logos und Karten.');
|
||||||
|
expect(input().value).toBe('');
|
||||||
|
expect(document.activeElement).toBe(input());
|
||||||
|
expect(dialogEl()!.querySelector('label')!.textContent).toBe('Benutzername');
|
||||||
|
expect(dialogEl()!.querySelector('.auth-hint')!.textContent).toBe('3–20 Zeichen: Buchstaben, Zahlen, . _ -');
|
||||||
|
expect([...dialogEl()!.querySelectorAll('button')].map((b) => b.textContent)).toEqual(['Später', 'Speichern']);
|
||||||
|
expect(buttonByText('Speichern')!.type).toBe('submit');
|
||||||
|
expect(buttonByText('Später')!.type).toBe('button');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('das Feld ist kein Anmeldefeld: keine Vorschläge des Browsers, keine Großschreibung, keine Rechtschreibprüfung', () => {
|
||||||
|
void dialog.usernameDialog({ mode: 'prompt' });
|
||||||
|
expect(input().autocomplete).toBe('off');
|
||||||
|
expect(input().getAttribute('autocapitalize')).toBe('none');
|
||||||
|
expect(input().getAttribute('spellcheck')).toBe('false');
|
||||||
|
expect(input().type).toBe('text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('required: „Abbrechen“ statt „Später“ und der Hinweis, dass zuerst ein Name nötig ist', () => {
|
||||||
|
void dialog.usernameDialog({ mode: 'required' });
|
||||||
|
expect(heading()).toBe('Benutzername wählen');
|
||||||
|
expect(intro()).toBe('Bitte wähle zuerst einen Benutzernamen.');
|
||||||
|
expect([...dialogEl()!.querySelectorAll('button')].map((b) => b.textContent)).toEqual(['Abbrechen', 'Speichern']);
|
||||||
|
expect(input().value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('change: das Feld ist mit dem aktuellen Namen vorbelegt (als Wert, nie als HTML), „Abbrechen“, Überschrift „Benutzername ändern“', () => {
|
||||||
|
mocks.name = '<b>Kenso</b>';
|
||||||
|
void dialog.usernameDialog({ mode: 'change' });
|
||||||
|
expect(heading()).toBe('Benutzername ändern');
|
||||||
|
expect(input().value).toBe('<b>Kenso</b>');
|
||||||
|
expect(dialogEl()!.querySelector('b')).toBeNull();
|
||||||
|
expect([...dialogEl()!.querySelectorAll('button')].map((b) => b.textContent)).toEqual(['Abbrechen', 'Speichern']);
|
||||||
|
expect(document.activeElement).toBe(input());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('change ohne bekannten Namen: wie „wählen“ mit „Abbrechen“, Feld leer', () => {
|
||||||
|
void dialog.usernameDialog({ mode: 'change' });
|
||||||
|
expect(heading()).toBe('Benutzername wählen');
|
||||||
|
expect(input().value).toBe('');
|
||||||
|
expect(buttonByText('Abbrechen')).toBeTruthy();
|
||||||
|
expect(buttonByText('Später')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prompt und required bleiben leer, auch wenn ein Name bekannt ist (vorbelegt wird nur beim Ändern)', () => {
|
||||||
|
mocks.name = 'Kenso';
|
||||||
|
void dialog.usernameDialog({ mode: 'prompt' });
|
||||||
|
expect(input().value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Beschriftung und Verknüpfungen: Label gehört zum Feld, der Hinweis ist verknüpft, alle IDs sind eindeutig, die Überschrift benennt den Dialog', () => {
|
||||||
|
void dialog.usernameDialog({ mode: 'prompt' });
|
||||||
|
const label = dialogEl()!.querySelector('label')!;
|
||||||
|
expect(label.control).toBe(input());
|
||||||
|
expect(input().labels).toHaveLength(1);
|
||||||
|
const hint = dialogEl()!.querySelector('.auth-hint')!;
|
||||||
|
expect(describedBy()).toEqual([hint.id]);
|
||||||
|
expect(input().getAttribute('aria-required')).toBe('true');
|
||||||
|
const ids = [...dialogEl()!.querySelectorAll('[id]')].map((n) => n.id);
|
||||||
|
expect(new Set(ids).size).toBe(ids.length);
|
||||||
|
expect(dialogEl()!.getAttribute('aria-labelledby')).toBe(dialogEl()!.querySelector('h2')!.id);
|
||||||
|
expect(errorEl().getAttribute('role')).toBe('alert');
|
||||||
|
expect(form().hasAttribute('novalidate')).toBe(true); // geprüft wird selbst, mit Texten der Seite
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Später, Abbrechen, Schließen', () => {
|
||||||
|
it('„Später“ löst mit null auf, speichert nichts, entfernt den Dialog und gibt den Fokus zurück', async () => {
|
||||||
|
const result = dialog.usernameDialog({ mode: 'prompt' });
|
||||||
|
input().value = 'Kenso'; // auch ein getippter Name wird dann nicht gespeichert
|
||||||
|
buttonByText('Später')!.click();
|
||||||
|
await expect(result).resolves.toBeNull();
|
||||||
|
expect(mocks.saveUsername).not.toHaveBeenCalled();
|
||||||
|
expect(dialogEl()).toBeNull();
|
||||||
|
expect(document.activeElement).toBe(opener);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['required', 'change'] as const)('%s: „Abbrechen“ löst mit null auf und speichert nichts', async (mode) => {
|
||||||
|
const result = dialog.usernameDialog({ mode });
|
||||||
|
buttonByText('Abbrechen')!.click();
|
||||||
|
await expect(result).resolves.toBeNull();
|
||||||
|
expect(mocks.saveUsername).not.toHaveBeenCalled();
|
||||||
|
expect(dialogEl()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Esc (close-Ereignis des Dialogs) löst mit null auf', async () => {
|
||||||
|
const result = dialog.usernameDialog({ mode: 'prompt' });
|
||||||
|
dialogEl()!.close();
|
||||||
|
await expect(result).resolves.toBeNull();
|
||||||
|
expect(dialogEl()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Klick auf den Hintergrund schließt, ein Klick im Formular oder Markieren, das außerhalb endet, nicht', async () => {
|
||||||
|
const result = dialog.usernameDialog({ mode: 'prompt' });
|
||||||
|
input().dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
||||||
|
input().click();
|
||||||
|
dialogEl()!.click(); // mousedown im Feld, click auf dem Dialog: Markieren
|
||||||
|
expect(dialogEl()?.open).toBe(true);
|
||||||
|
dialogEl()!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
||||||
|
dialogEl()!.click();
|
||||||
|
await expect(result).resolves.toBeNull();
|
||||||
|
expect(dialogEl()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('signal: ein Abbruch schließt den Dialog mit null; ein schon abgebrochenes Signal öffnet gar nichts', async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const result = dialog.usernameDialog({ mode: 'prompt', signal: controller.signal });
|
||||||
|
expect(dialogEl()?.open).toBe(true);
|
||||||
|
controller.abort();
|
||||||
|
await expect(result).resolves.toBeNull();
|
||||||
|
expect(dialogEl()).toBeNull();
|
||||||
|
|
||||||
|
controller.abort();
|
||||||
|
await expect(dialog.usernameDialog({ mode: 'prompt', signal: controller.signal })).resolves.toBeNull();
|
||||||
|
expect(dialogEl()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schlägt showModal fehl, wird aufgeräumt und der Fehler gemeldet; der nächste Aufruf öffnet normal', async () => {
|
||||||
|
const spy = vi.spyOn(HTMLDialogElement.prototype, 'showModal').mockImplementationOnce(() => {
|
||||||
|
throw new Error('showModal kaputt');
|
||||||
|
});
|
||||||
|
await expect(dialog.usernameDialog({ mode: 'prompt' })).rejects.toThrow('showModal kaputt');
|
||||||
|
expect(dialogEl()).toBeNull();
|
||||||
|
spy.mockRestore();
|
||||||
|
const again = dialog.usernameDialog({ mode: 'prompt' });
|
||||||
|
expect(dialogEl()?.open).toBe(true);
|
||||||
|
buttonByText('Später')!.click();
|
||||||
|
await expect(again).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Speichern', () => {
|
||||||
|
it('speichert den getrimmten Namen, löst mit dem Namen des Dienstes auf und schließt', async () => {
|
||||||
|
mocks.saveUsername.mockResolvedValue('KenSo');
|
||||||
|
const result = dialog.usernameDialog({ mode: 'prompt' });
|
||||||
|
input().value = ' Kenso ';
|
||||||
|
send();
|
||||||
|
await expect(result).resolves.toBe('KenSo');
|
||||||
|
expect(mocks.saveUsername).toHaveBeenCalledExactlyOnceWith('Kenso');
|
||||||
|
expect(dialogEl()).toBeNull();
|
||||||
|
expect(document.activeElement).toBe(opener);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der Knopf „Speichern“ löst das Absenden aus (Enter im Feld ebenso: es ist ein Formular)', async () => {
|
||||||
|
mocks.saveUsername.mockResolvedValue('Kenso');
|
||||||
|
const result = dialog.usernameDialog({ mode: 'change' });
|
||||||
|
input().value = 'Kenso';
|
||||||
|
expect(buttonByText('Speichern')!.closest('form')).toBe(form());
|
||||||
|
send();
|
||||||
|
await expect(result).resolves.toBe('Kenso');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prüft vor dem Senden: Muster und Sperrliste, Text am Feld, kein Aufruf', async () => {
|
||||||
|
void dialog.usernameDialog({ mode: 'prompt' });
|
||||||
|
const invalid = async (value: string, message: string) => {
|
||||||
|
input().value = value;
|
||||||
|
send();
|
||||||
|
await settle();
|
||||||
|
expect(errorText()).toBe(message);
|
||||||
|
expect(input().getAttribute('aria-invalid')).toBe('true');
|
||||||
|
expect(describedBy()).toContain(errorEl().id); // das Feld verweist auf die Fehlerzeile
|
||||||
|
expect(document.activeElement).toBe(input());
|
||||||
|
expect(mocks.saveUsername).not.toHaveBeenCalled();
|
||||||
|
expect(dialogEl()?.open).toBe(true);
|
||||||
|
};
|
||||||
|
await invalid('', INVALID);
|
||||||
|
await invalid(' ', INVALID);
|
||||||
|
await invalid('ab', INVALID);
|
||||||
|
await invalid('x'.repeat(21), INVALID);
|
||||||
|
await invalid('_abc', INVALID);
|
||||||
|
await invalid('na me', INVALID);
|
||||||
|
await invalid('Käse', INVALID);
|
||||||
|
await invalid('Admin', 'Dieser Benutzername ist nicht erlaubt.');
|
||||||
|
await invalid('AirCard-IOS', 'Dieser Benutzername ist nicht erlaubt.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine behobene Eingabe verliert die Markierung beim nächsten Senden; der Hinweis bleibt verknüpft', async () => {
|
||||||
|
mocks.saveUsername.mockResolvedValue('Kenso');
|
||||||
|
const result = dialog.usernameDialog({ mode: 'prompt' });
|
||||||
|
input().value = 'x';
|
||||||
|
send();
|
||||||
|
expect(describedBy()).toHaveLength(2);
|
||||||
|
input().value = 'Kenso';
|
||||||
|
send();
|
||||||
|
expect(input().hasAttribute('aria-invalid')).toBe(false);
|
||||||
|
expect(errorText()).toBe('');
|
||||||
|
expect(describedBy()).toHaveLength(1);
|
||||||
|
await result;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('vergeben (409 usernameTaken): der Text steht am Feld, der Dialog bleibt offen, ein neuer Name geht', async () => {
|
||||||
|
mocks.saveUsername.mockRejectedValueOnce(new ApiError(409, TAKEN, undefined, 'usernameTaken'));
|
||||||
|
const result = dialog.usernameDialog({ mode: 'prompt' });
|
||||||
|
input().value = 'Kenso';
|
||||||
|
send();
|
||||||
|
await settle();
|
||||||
|
expect(errorText()).toBe(TAKEN);
|
||||||
|
expect(input().getAttribute('aria-invalid')).toBe('true');
|
||||||
|
expect(describedBy()).toContain(errorEl().id);
|
||||||
|
expect(document.activeElement).toBe(input());
|
||||||
|
expect(dialogEl()?.open).toBe(true);
|
||||||
|
expect(input().value).toBe('Kenso');
|
||||||
|
expect(buttonByText('Speichern')!.disabled).toBe(false);
|
||||||
|
expect(buttonByText('Später')!.disabled).toBe(false);
|
||||||
|
expect(input().readOnly).toBe(false);
|
||||||
|
|
||||||
|
mocks.saveUsername.mockResolvedValueOnce('Kenso2');
|
||||||
|
input().value = 'Kenso2';
|
||||||
|
send();
|
||||||
|
await expect(result).resolves.toBe('Kenso2');
|
||||||
|
expect(mocks.saveUsername).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Muster und Sperrliste des Dienstes (400) stehen ebenfalls am Feld', async () => {
|
||||||
|
mocks.saveUsername.mockRejectedValueOnce(new ApiError(400, 'x', undefined, 'usernameReserved'));
|
||||||
|
void dialog.usernameDialog({ mode: 'prompt' });
|
||||||
|
input().value = 'Kenso';
|
||||||
|
send();
|
||||||
|
await settle();
|
||||||
|
expect(errorText()).toBe('Dieser Benutzername ist nicht erlaubt.');
|
||||||
|
expect(input().getAttribute('aria-invalid')).toBe('true');
|
||||||
|
|
||||||
|
mocks.saveUsername.mockRejectedValueOnce(new ApiError(400, 'x', undefined, 'usernameInvalid'));
|
||||||
|
send();
|
||||||
|
await settle();
|
||||||
|
expect(errorText()).toBe(INVALID);
|
||||||
|
expect(input().getAttribute('aria-invalid')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Dienst nicht erreichbar: der Text steht im Dialog, das Feld gilt nicht als ungültig, der Dialog bleibt für einen neuen Versuch offen', async () => {
|
||||||
|
mocks.saveUsername.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable'));
|
||||||
|
const result = dialog.usernameDialog({ mode: 'prompt' });
|
||||||
|
input().value = 'Kenso';
|
||||||
|
send();
|
||||||
|
await settle();
|
||||||
|
expect(errorText()).toBe('Der Dienst ist gerade nicht erreichbar.');
|
||||||
|
expect(input().hasAttribute('aria-invalid')).toBe(false);
|
||||||
|
expect(describedBy()).not.toContain(errorEl().id);
|
||||||
|
expect(dialogEl()?.open).toBe(true);
|
||||||
|
expect(input().value).toBe('Kenso');
|
||||||
|
expect(statusText()).toBe('');
|
||||||
|
|
||||||
|
mocks.saveUsername.mockResolvedValueOnce('Kenso');
|
||||||
|
send();
|
||||||
|
await expect(result).resolves.toBe('Kenso');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('andere Fehler: kein Netz, abgelaufene Anmeldung (401) und alles Unbekannte werden erklärt statt verschluckt', async () => {
|
||||||
|
void dialog.usernameDialog({ mode: 'prompt' });
|
||||||
|
input().value = 'Kenso';
|
||||||
|
|
||||||
|
mocks.saveUsername.mockRejectedValueOnce(new TypeError('Failed to fetch'));
|
||||||
|
send();
|
||||||
|
await settle();
|
||||||
|
expect(errorText()).toBe('Keine Verbindung zum Server.');
|
||||||
|
|
||||||
|
mocks.saveUsername.mockRejectedValueOnce(Object.assign(new Error('unauthorized'), { code: 401, type: 'general_unauthorized_scope' }));
|
||||||
|
send();
|
||||||
|
await settle();
|
||||||
|
expect(errorText()).toBe('Bitte melde dich an.');
|
||||||
|
|
||||||
|
mocks.saveUsername.mockRejectedValueOnce(new ApiError(401, 'Bitte melde dich an.', undefined, 'unauthorized'));
|
||||||
|
send();
|
||||||
|
await settle();
|
||||||
|
expect(errorText()).toBe('Bitte melde dich an.');
|
||||||
|
|
||||||
|
mocks.saveUsername.mockRejectedValueOnce(new Error('???'));
|
||||||
|
send();
|
||||||
|
await settle();
|
||||||
|
expect(errorText()).toBe('Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
|
||||||
|
expect(dialogEl()?.open).toBe(true);
|
||||||
|
expect(input().hasAttribute('aria-invalid')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der Fehler eines früheren Versuchs verschwindet beim nächsten', async () => {
|
||||||
|
mocks.saveUsername.mockRejectedValueOnce(new ApiError(409, TAKEN, undefined, 'usernameTaken'));
|
||||||
|
const result = dialog.usernameDialog({ mode: 'prompt' });
|
||||||
|
input().value = 'Kenso';
|
||||||
|
send();
|
||||||
|
await settle();
|
||||||
|
mocks.saveUsername.mockResolvedValueOnce('Kenso2');
|
||||||
|
input().value = 'Kenso2';
|
||||||
|
send();
|
||||||
|
expect(errorText()).toBe('');
|
||||||
|
expect(input().hasAttribute('aria-invalid')).toBe(false);
|
||||||
|
await result;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Busy-Sperre beim Speichern', () => {
|
||||||
|
it('während des Speicherns: Knöpfe und Feld gesperrt, Status sichtbar, ein zweites Absenden wird ignoriert', async () => {
|
||||||
|
let finish!: (name: string) => void;
|
||||||
|
mocks.saveUsername.mockReturnValue(new Promise<string>((resolve) => (finish = resolve)));
|
||||||
|
const result = dialog.usernameDialog({ mode: 'prompt' });
|
||||||
|
input().value = 'Kenso';
|
||||||
|
send();
|
||||||
|
expect(buttonByText('Speichern')!.disabled).toBe(true);
|
||||||
|
expect(buttonByText('Später')!.disabled).toBe(true);
|
||||||
|
expect(input().readOnly).toBe(true);
|
||||||
|
expect(form().getAttribute('aria-busy')).toBe('true');
|
||||||
|
expect(statusText()).toBe('Wird gespeichert …');
|
||||||
|
send();
|
||||||
|
expect(mocks.saveUsername).toHaveBeenCalledOnce();
|
||||||
|
finish('Kenso');
|
||||||
|
await expect(result).resolves.toBe('Kenso');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Esc und ein Klick auf den Hintergrund schließen den Dialog währenddessen nicht (Abbrechen wirkte sonst nur scheinbar)', async () => {
|
||||||
|
let finish!: (name: string) => void;
|
||||||
|
mocks.saveUsername.mockReturnValue(new Promise<string>((resolve) => (finish = resolve)));
|
||||||
|
const result = dialog.usernameDialog({ mode: 'required' });
|
||||||
|
input().value = 'Kenso';
|
||||||
|
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('Kenso');
|
||||||
|
await expect(result).resolves.toBe('Kenso');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schließt der Browser den Dialog trotzdem, geht er wieder auf und löst erst mit dem echten Ergebnis auf', async () => {
|
||||||
|
let finish!: (name: string) => void;
|
||||||
|
mocks.saveUsername.mockReturnValue(new Promise<string>((resolve) => (finish = resolve)));
|
||||||
|
let resolved: string | null | undefined;
|
||||||
|
const result = dialog.usernameDialog({ mode: 'prompt' }).then((name) => (resolved = name));
|
||||||
|
input().value = 'Kenso';
|
||||||
|
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('Kenso');
|
||||||
|
await result;
|
||||||
|
expect(resolved).toBe('Kenso');
|
||||||
|
expect(dialogEl()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scheitert das Speichern, nachdem der Dialog zwischendurch zuging, ist er wieder offen und zeigt den Fehler', async () => {
|
||||||
|
let fail!: (error: Error) => void;
|
||||||
|
mocks.saveUsername.mockReturnValue(new Promise<string>((_, reject) => (fail = reject)));
|
||||||
|
void dialog.usernameDialog({ mode: 'prompt' });
|
||||||
|
input().value = 'Kenso';
|
||||||
|
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('Der Dienst ist gerade nicht erreichbar.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nur `signal` schließt immer; eine danach eintreffende Antwort ändert nichts mehr', async () => {
|
||||||
|
let finish!: (name: string) => void;
|
||||||
|
mocks.saveUsername.mockReturnValue(new Promise<string>((resolve) => (finish = resolve)));
|
||||||
|
const controller = new AbortController();
|
||||||
|
const result = dialog.usernameDialog({ mode: 'prompt', signal: controller.signal });
|
||||||
|
input().value = 'Kenso';
|
||||||
|
send();
|
||||||
|
controller.abort();
|
||||||
|
await expect(result).resolves.toBeNull();
|
||||||
|
expect(dialogEl()).toBeNull();
|
||||||
|
finish('Kenso');
|
||||||
|
await settle();
|
||||||
|
expect(dialogEl()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ein Dialog zur Zeit', () => {
|
||||||
|
it('ein zweiter Aufruf, während einer offen ist, teilt sich Dialog und Ergebnis (keine zwei Dialoge übereinander)', async () => {
|
||||||
|
mocks.saveUsername.mockResolvedValue('Kenso');
|
||||||
|
const first = dialog.usernameDialog({ mode: 'prompt' });
|
||||||
|
const second = dialog.usernameDialog({ mode: 'required' });
|
||||||
|
expect(second).toBe(first);
|
||||||
|
expect(document.querySelectorAll('dialog.username-dialog')).toHaveLength(1);
|
||||||
|
expect(heading()).toBe('Benutzername wählen');
|
||||||
|
input().value = 'Kenso';
|
||||||
|
send();
|
||||||
|
await expect(second).resolves.toBe('Kenso');
|
||||||
|
await expect(first).resolves.toBe('Kenso');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nach dem Schließen öffnet der nächste Aufruf neu, ohne Reste', async () => {
|
||||||
|
const first = dialog.usernameDialog({ mode: 'prompt' });
|
||||||
|
input().value = 'Kenso';
|
||||||
|
buttonByText('Später')!.click();
|
||||||
|
await first;
|
||||||
|
const second = dialog.usernameDialog({ mode: 'required' });
|
||||||
|
expect(second).not.toBe(first);
|
||||||
|
expect(dialogEl()?.open).toBe(true);
|
||||||
|
expect(input().value).toBe('');
|
||||||
|
expect(errorText()).toBe('');
|
||||||
|
expect(intro()).toBe('Bitte wähle zuerst einen Benutzernamen.');
|
||||||
|
buttonByText('Abbrechen')!.click();
|
||||||
|
await second;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Englisch', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
(await import('../i18n/i18n')).setLang('en', { persist: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('alle Texte des Dialogs sind englisch, in jedem Modus', async () => {
|
||||||
|
mocks.name = 'Kenso';
|
||||||
|
const texts = () => [heading(), intro(), dialogEl()!.querySelector('label')!.textContent, dialogEl()!.querySelector('.auth-hint')!.textContent];
|
||||||
|
const labels = () => [...dialogEl()!.querySelectorAll('button')].map((b) => b.textContent);
|
||||||
|
|
||||||
|
const prompt = dialog.usernameDialog({ mode: 'prompt' });
|
||||||
|
expect(texts()).toEqual([
|
||||||
|
'Choose a username',
|
||||||
|
'Your username is public and shown on your logos and cards.',
|
||||||
|
'Username',
|
||||||
|
'3–20 characters: letters, numbers, . _ -',
|
||||||
|
]);
|
||||||
|
expect(labels()).toEqual(['Later', 'Save']);
|
||||||
|
expect(germanLeft(dialogEl()!)).toEqual([]);
|
||||||
|
buttonByText('Later')!.click();
|
||||||
|
await prompt;
|
||||||
|
|
||||||
|
const required = dialog.usernameDialog({ mode: 'required' });
|
||||||
|
expect(intro()).toBe('Please choose a username first.');
|
||||||
|
expect(labels()).toEqual(['Cancel', 'Save']);
|
||||||
|
buttonByText('Cancel')!.click();
|
||||||
|
await required;
|
||||||
|
|
||||||
|
const change = dialog.usernameDialog({ mode: 'change' });
|
||||||
|
expect(heading()).toBe('Change username');
|
||||||
|
expect(input().value).toBe('Kenso');
|
||||||
|
expect(germanLeft(dialogEl()!)).toEqual([]);
|
||||||
|
buttonByText('Cancel')!.click();
|
||||||
|
await change;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Fehler und Status ebenso', async () => {
|
||||||
|
let finish!: (name: string) => void;
|
||||||
|
mocks.saveUsername.mockReturnValueOnce(new Promise<string>((resolve) => (finish = resolve)));
|
||||||
|
const result = dialog.usernameDialog({ mode: 'prompt' });
|
||||||
|
input().value = 'x';
|
||||||
|
send();
|
||||||
|
expect(errorText()).toBe(
|
||||||
|
'The username must be 3–20 characters long, start with a letter or a number, and may only contain letters, numbers, periods, underscores and hyphens.',
|
||||||
|
);
|
||||||
|
input().value = 'Admin';
|
||||||
|
send();
|
||||||
|
expect(errorText()).toBe('This username is not allowed.');
|
||||||
|
|
||||||
|
input().value = 'Kenso';
|
||||||
|
send();
|
||||||
|
expect(statusText()).toBe('Saving …');
|
||||||
|
expect(germanLeft(dialogEl()!)).toEqual([]);
|
||||||
|
finish('Kenso');
|
||||||
|
await result;
|
||||||
|
|
||||||
|
mocks.saveUsername.mockRejectedValueOnce(new ApiError(409, TAKEN, undefined, 'usernameTaken'));
|
||||||
|
const again = dialog.usernameDialog({ mode: 'prompt' });
|
||||||
|
input().value = 'Kenso';
|
||||||
|
send();
|
||||||
|
await settle();
|
||||||
|
expect(errorText()).toBe('This username is already taken.');
|
||||||
|
buttonByText('Later')!.click();
|
||||||
|
await again;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('requireUsername', () => {
|
||||||
|
it('ohne angemeldetes Konto gibt es nichts zu fordern: true, kein Dialog, keine Anfrage (die Anmeldung kommt mit der Aktion selbst)', async () => {
|
||||||
|
await expect(dialog.requireUsername()).resolves.toBe(true);
|
||||||
|
expect(dialogEl()).toBeNull();
|
||||||
|
expect(mocks.loadMyUsername).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein bekannter Name genügt: true ohne Dialog und ohne Anfrage', async () => {
|
||||||
|
mocks.userId = 'u1';
|
||||||
|
mocks.name = 'Kenso';
|
||||||
|
await expect(dialog.requireUsername()).resolves.toBe(true);
|
||||||
|
expect(dialogEl()).toBeNull();
|
||||||
|
expect(mocks.loadMyUsername).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ist der Name noch nicht gelesen, wird er gelesen; hat das Konto einen, ist es true ohne Dialog', async () => {
|
||||||
|
mocks.userId = 'u1';
|
||||||
|
mocks.loadMyUsername.mockResolvedValue('Kenso');
|
||||||
|
await expect(dialog.requireUsername()).resolves.toBe(true);
|
||||||
|
expect(mocks.loadMyUsername).toHaveBeenCalledOnce();
|
||||||
|
expect(dialogEl()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne Namen erscheint der Dialog „required“; nach dem Speichern ist es true', async () => {
|
||||||
|
mocks.userId = 'u1';
|
||||||
|
mocks.loadMyUsername.mockResolvedValue(null);
|
||||||
|
mocks.saveUsername.mockResolvedValue('Kenso');
|
||||||
|
const result = dialog.requireUsername();
|
||||||
|
await settle();
|
||||||
|
expect(dialogEl()?.open).toBe(true);
|
||||||
|
expect(intro()).toBe('Bitte wähle zuerst einen Benutzernamen.');
|
||||||
|
expect(buttonByText('Abbrechen')).toBeTruthy();
|
||||||
|
input().value = 'Kenso';
|
||||||
|
send();
|
||||||
|
await expect(result).resolves.toBe(true);
|
||||||
|
expect(mocks.saveUsername).toHaveBeenCalledExactlyOnceWith('Kenso');
|
||||||
|
expect(dialogEl()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('„Abbrechen“ ergibt false: ohne Namen geht es nicht weiter', async () => {
|
||||||
|
mocks.userId = 'u1';
|
||||||
|
mocks.loadMyUsername.mockResolvedValue(null);
|
||||||
|
const result = dialog.requireUsername();
|
||||||
|
await settle();
|
||||||
|
buttonByText('Abbrechen')!.click();
|
||||||
|
await expect(result).resolves.toBe(false);
|
||||||
|
expect(mocks.saveUsername).not.toHaveBeenCalled();
|
||||||
|
expect(dialogEl()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Esc ergibt ebenfalls false', async () => {
|
||||||
|
mocks.userId = 'u1';
|
||||||
|
mocks.loadMyUsername.mockResolvedValue(null);
|
||||||
|
const result = dialog.requireUsername();
|
||||||
|
await settle();
|
||||||
|
dialogEl()!.close();
|
||||||
|
await expect(result).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt sich das Profil nicht lesen, entscheidet der Dienst (er antwortet ohne Profil mit 403): true, kein Dialog', async () => {
|
||||||
|
mocks.userId = 'u1';
|
||||||
|
mocks.loadMyUsername.mockRejectedValue(new TypeError('Failed to fetch'));
|
||||||
|
await expect(dialog.requireUsername()).resolves.toBe(true);
|
||||||
|
expect(dialogEl()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('läuft schon ein Namensdialog (etwa die einmalige Abfrage), teilt sich requireUsername ihn', async () => {
|
||||||
|
mocks.userId = 'u1';
|
||||||
|
mocks.loadMyUsername.mockResolvedValue(null);
|
||||||
|
mocks.saveUsername.mockResolvedValue('Kenso');
|
||||||
|
const prompt = dialog.usernameDialog({ mode: 'prompt' });
|
||||||
|
const required = dialog.requireUsername();
|
||||||
|
await settle();
|
||||||
|
expect(document.querySelectorAll('dialog.username-dialog')).toHaveLength(1);
|
||||||
|
input().value = 'Kenso';
|
||||||
|
send();
|
||||||
|
await expect(required).resolves.toBe(true);
|
||||||
|
await expect(prompt).resolves.toBe('Kenso');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('„Später“ in einem geteilten Dialog ergibt für requireUsername false', async () => {
|
||||||
|
mocks.userId = 'u1';
|
||||||
|
mocks.loadMyUsername.mockResolvedValue(null);
|
||||||
|
const prompt = dialog.usernameDialog({ mode: 'prompt' });
|
||||||
|
const required = dialog.requireUsername();
|
||||||
|
await settle();
|
||||||
|
buttonByText('Später')!.click();
|
||||||
|
await expect(prompt).resolves.toBeNull();
|
||||||
|
await expect(required).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
// Dialog „Benutzername wählen“ (Spec § 5): ein Feld, „Speichern“ und „Später“ bzw. „Abbrechen“. Drei Anlässe: die einmalige Abfrage nach
|
||||||
|
// dem Anmelden (`prompt`, mit „Später“), die Pflicht vor dem Hochladen und Veröffentlichen (`required`, nur „Abbrechen“) und das Ändern
|
||||||
|
// aus dem Konto-Menü (`change`, mit dem aktuellen Namen vorbelegt). Eigenes <dialog> nach dem Muster von publish-dialog.ts; alle Texte
|
||||||
|
// gehen als Text in den DOM, nie als HTML.
|
||||||
|
import './account.css';
|
||||||
|
import { ApiError, apiErrorText } from '../community/api';
|
||||||
|
import { el, setFieldInvalid } from '../editor/dom';
|
||||||
|
import { API_MESSAGES } from '../i18n/api-messages';
|
||||||
|
import { msg, t } from '../i18n/i18n';
|
||||||
|
import { currentUserId } from './auth';
|
||||||
|
import { errorMessage, isUnauthorized } from './errors';
|
||||||
|
import { USERNAME_HINT, loadMyUsername, myUsername, saveUsername, validateUsername } from './profile';
|
||||||
|
|
||||||
|
const RUNNING = msg('Wird gespeichert …');
|
||||||
|
const PUBLIC_NOTE = msg('Dein Benutzername ist öffentlich und steht bei deinen Logos und Karten.');
|
||||||
|
|
||||||
|
/** Die Fehlerzeile des Dialogs: ein ungültiges Feld verweist mit `aria-describedby` auf sie. */
|
||||||
|
const ERROR_ID = 'username-error';
|
||||||
|
/** Fehlercodes des Dienstes, die am Namen selbst liegen: ihr Text steht am Feld, und das Feld gilt als ungültig. */
|
||||||
|
const FIELD_CODES: ReadonlySet<string> = new Set(['usernameInvalid', 'usernameReserved', 'usernameTaken']);
|
||||||
|
|
||||||
|
/** `prompt`: einmalige Abfrage („Später“); `required`: ohne Namen geht es nicht weiter („Abbrechen“); `change`: aus dem Konto-Menü, vorbelegt. */
|
||||||
|
export type UsernameDialogMode = 'prompt' | 'required' | 'change';
|
||||||
|
|
||||||
|
export interface UsernameDialogOptions {
|
||||||
|
mode: UsernameDialogMode;
|
||||||
|
/** Schließt den Dialog, z. B. beim Verlassen der Seite; eine danach eintreffende Antwort wird verworfen. */
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Text zu einem fehlgeschlagenen Speichern in der Sprache der Seite; Fehler des Dienstes über ihren Code (`apiErrorText`). */
|
||||||
|
function failureText(err: unknown): string {
|
||||||
|
if (err instanceof ApiError) return apiErrorText(err);
|
||||||
|
if (isUnauthorized(err)) return t(API_MESSAGES.unauthorized); // die Anmeldung ist abgelaufen (schon der Abruf des JWT scheitert)
|
||||||
|
return errorMessage(err, 'save');
|
||||||
|
}
|
||||||
|
|
||||||
|
function build(opts: UsernameDialogOptions): Promise<string | null> {
|
||||||
|
return new Promise<string | null>((resolve, reject) => {
|
||||||
|
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||||
|
// Vorbelegt wird nur beim Ändern; kennt das Konto noch keinen Namen, ist es ein gewöhnliches „Wählen“.
|
||||||
|
const current = opts.mode === 'change' ? myUsername() : null;
|
||||||
|
let result: string | null = null;
|
||||||
|
let closed = false;
|
||||||
|
let busy = false;
|
||||||
|
|
||||||
|
const input = el('input', {
|
||||||
|
class: 'auth-input',
|
||||||
|
type: 'text',
|
||||||
|
name: 'username',
|
||||||
|
id: 'username-name',
|
||||||
|
// Kein Anmeldefeld: der Browser soll hier nicht die gespeicherte Anmeldung (E-Mail) vorschlagen.
|
||||||
|
autocomplete: 'off',
|
||||||
|
autocapitalize: 'none',
|
||||||
|
spellcheck: 'false',
|
||||||
|
'aria-required': 'true',
|
||||||
|
'aria-describedby': 'username-name-hint',
|
||||||
|
});
|
||||||
|
input.value = current ?? '';
|
||||||
|
const field = el(
|
||||||
|
'div',
|
||||||
|
{ class: 'auth-field' },
|
||||||
|
el('label', { class: 'auth-label', for: input.id }, t('Benutzername')),
|
||||||
|
input,
|
||||||
|
el('p', { class: 'auth-hint', id: 'username-name-hint' }, t(USERNAME_HINT)),
|
||||||
|
);
|
||||||
|
const intro = el('p', { class: 'username-text' }, opts.mode === 'required' ? t(API_MESSAGES.usernameRequired) : t(PUBLIC_NOTE));
|
||||||
|
const errorEl = el('p', { class: 'auth-msg auth-msg-error', id: ERROR_ID, role: 'alert' });
|
||||||
|
const statusEl = el('p', { class: 'auth-msg', role: 'status' });
|
||||||
|
const cancel = el('button', { type: 'button', class: 'auth-btn' }, opts.mode === 'prompt' ? t('Später') : t('Abbrechen'));
|
||||||
|
const save = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, t('Speichern'));
|
||||||
|
// novalidate: die Prüfung läuft selbst, mit Texten der Seite statt der des Browsers.
|
||||||
|
const form = el(
|
||||||
|
'form',
|
||||||
|
{ class: 'auth-form', novalidate: true },
|
||||||
|
intro,
|
||||||
|
field,
|
||||||
|
errorEl,
|
||||||
|
statusEl,
|
||||||
|
el('div', { class: 'username-actions' }, cancel, save),
|
||||||
|
);
|
||||||
|
const dialog = el(
|
||||||
|
'dialog',
|
||||||
|
{ class: 'auth-dialog username-dialog', 'aria-labelledby': 'username-heading' },
|
||||||
|
el(
|
||||||
|
'div',
|
||||||
|
{ class: 'auth-card' },
|
||||||
|
el('h2', { id: 'username-heading', class: 'auth-title' }, current ? t('Benutzername ändern') : t('Benutzername wählen')),
|
||||||
|
form,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const finish = () => {
|
||||||
|
if (closed) return;
|
||||||
|
closed = true;
|
||||||
|
opts.signal?.removeEventListener('abort', finish);
|
||||||
|
if (dialog.open) dialog.close();
|
||||||
|
dialog.remove();
|
||||||
|
if (opener?.isConnected) opener.focus();
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Öffnet den Dialog wieder, falls ihn der Browser geschlossen hat (Esc trotz verhindertem `cancel`). */
|
||||||
|
function reopen(): boolean {
|
||||||
|
if (dialog.open) return true;
|
||||||
|
try {
|
||||||
|
dialog.showModal();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBusy(value: boolean): void {
|
||||||
|
busy = value;
|
||||||
|
form.setAttribute('aria-busy', String(value));
|
||||||
|
input.readOnly = value; // bleibt fokussierbar, ein Screenreader verliert den Fokus nicht
|
||||||
|
save.disabled = value;
|
||||||
|
cancel.disabled = value;
|
||||||
|
statusEl.textContent = value ? t(RUNNING) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Problem mit dem Namen: der Text steht in der Fehlerzeile, das Feld gilt als ungültig und bekommt den Fokus. */
|
||||||
|
function showFieldProblem(text: string): void {
|
||||||
|
errorEl.textContent = text;
|
||||||
|
setFieldInvalid(input, ERROR_ID, true);
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(): Promise<void> {
|
||||||
|
setFieldInvalid(input, ERROR_ID, false);
|
||||||
|
errorEl.textContent = '';
|
||||||
|
const name = input.value.trim(); // der Dienst trimmt nicht
|
||||||
|
const problem = validateUsername(name);
|
||||||
|
if (problem) {
|
||||||
|
showFieldProblem(problem);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
result = await saveUsername(name);
|
||||||
|
} catch (err) {
|
||||||
|
if (closed) return;
|
||||||
|
setBusy(false);
|
||||||
|
if (err instanceof ApiError && err.serverCode !== null && FIELD_CODES.has(err.serverCode)) {
|
||||||
|
showFieldProblem(failureText(err)); // vergeben, gesperrt, ungültig: das liegt am Namen
|
||||||
|
} else {
|
||||||
|
errorEl.textContent = failureText(err); // Netz, Dienst, Anmeldung: der Name kann richtig sein
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
reopen(); // war der Dialog zwischendurch zu, soll der Fehler zu sehen sein
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (closed) return;
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener('submit', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (busy || closed) return;
|
||||||
|
void submit();
|
||||||
|
});
|
||||||
|
cancel.addEventListener('click', finish);
|
||||||
|
// Esc löst `cancel` aus (abbrechbar) und danach `close`; mitten im Speichern darf der Dialog nicht zugehen.
|
||||||
|
dialog.addEventListener('cancel', (event) => {
|
||||||
|
if (busy) event.preventDefault();
|
||||||
|
});
|
||||||
|
// Schließt der Browser den Dialog trotzdem mitten im Speichern, wird er wieder geöffnet; aufgelöst wird erst mit dem echten Ergebnis.
|
||||||
|
dialog.addEventListener('close', () => {
|
||||||
|
if (busy && !closed) {
|
||||||
|
reopen();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finish();
|
||||||
|
});
|
||||||
|
// Hintergrund = Klick auf das <dialog> selbst (die Karte füllt es ganz aus); Drücken und Loslassen müssen beide dort sein,
|
||||||
|
// sonst schließt ein Textmarkieren, das außerhalb endet, den Dialog.
|
||||||
|
let pressedOnBackdrop = false;
|
||||||
|
dialog.addEventListener('mousedown', (event) => {
|
||||||
|
pressedOnBackdrop = event.target === dialog;
|
||||||
|
});
|
||||||
|
dialog.addEventListener('click', (event) => {
|
||||||
|
if (event.target === dialog && pressedOnBackdrop && !busy) finish();
|
||||||
|
pressedOnBackdrop = false;
|
||||||
|
});
|
||||||
|
opts.signal?.addEventListener('abort', finish);
|
||||||
|
|
||||||
|
document.body.append(dialog);
|
||||||
|
try {
|
||||||
|
dialog.showModal();
|
||||||
|
} catch (err) {
|
||||||
|
closed = true;
|
||||||
|
opts.signal?.removeEventListener('abort', finish);
|
||||||
|
dialog.remove();
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
input.focus();
|
||||||
|
if (current) input.select(); // zum Ändern: gleich überschreiben können
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der offene Dialog; es gibt höchstens einen. */
|
||||||
|
let running: Promise<string | null> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Öffnet den Dialog. Löst auf, sobald er geschlossen ist: mit dem gespeicherten Namen (so, wie der Dienst ihn gespeichert hat), sonst mit
|
||||||
|
* null („Später“, „Abbrechen“, Esc, Hintergrund, `signal`). Fehler erscheinen im Dialog; er bleibt dann für einen neuen Versuch offen.
|
||||||
|
* Während des Speicherns lässt er sich nicht schließen (Esc und Hintergrund wirkungslos), damit niemand glaubt, etwas abgebrochen zu haben,
|
||||||
|
* das dann doch gespeichert wird; nur `signal` schließt immer.
|
||||||
|
* Läuft schon ein Namensdialog (etwa die einmalige Abfrage, wenn gleichzeitig der Dienst „Name nötig“ meldet), teilt sich der Aufruf ihn
|
||||||
|
* samt Ergebnis, statt einen zweiten darüberzulegen; für einen Aufrufer mit „Name nötig“ ist null dann ebenso ein „nicht weiter“.
|
||||||
|
*/
|
||||||
|
export function usernameDialog(opts: UsernameDialogOptions): Promise<string | null> {
|
||||||
|
if (opts.signal?.aborted) return Promise.resolve(null);
|
||||||
|
if (running) return running;
|
||||||
|
const promise: Promise<string | null> = build(opts).finally(() => {
|
||||||
|
if (running === promise) running = null;
|
||||||
|
});
|
||||||
|
running = promise;
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hat das angemeldete Konto einen Benutzernamen? Wenn nicht, fragt der Dialog `required` danach (Spec § 5: vor dem Hochladen eines Logos und
|
||||||
|
* vor dem Veröffentlichen). true: es kann weitergehen; false: der Nutzer hat „Abbrechen“ gewählt, es geschieht nichts.
|
||||||
|
* Ohne angemeldetes Konto gibt es nichts zu fordern (true): die Anmeldung kommt mit der Aktion selbst, und fehlt dem Konto dann der Name,
|
||||||
|
* antwortet der Dienst mit 403 `usernameRequired` (dann zeigt der Aufrufer den Dialog). Dasselbe gilt, wenn sich das Profil nicht lesen lässt:
|
||||||
|
* der Dienst entscheidet.
|
||||||
|
*/
|
||||||
|
export async function requireUsername(): Promise<boolean> {
|
||||||
|
if (currentUserId() === null) return true;
|
||||||
|
let name = myUsername();
|
||||||
|
if (name === null) {
|
||||||
|
try {
|
||||||
|
name = await loadMyUsername();
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (name !== null) return true;
|
||||||
|
return (await usernameDialog({ mode: 'required' })) !== null;
|
||||||
|
}
|
||||||
@@ -145,6 +145,17 @@ export const EN: Readonly<Record<string, string>> = {
|
|||||||
'Neuen Link anfordern': 'Request a new link',
|
'Neuen Link anfordern': 'Request a new link',
|
||||||
'Passwort geändert. Du kannst dich jetzt anmelden.': 'Password changed. You can now sign in.',
|
'Passwort geändert. Du kannst dich jetzt anmelden.': 'Password changed. You can now sign in.',
|
||||||
|
|
||||||
|
// Benutzername: Feld beim Registrieren, Dialog „Benutzername wählen“ und Konto-Menü (account/profile.ts, account/login-dialog.ts,
|
||||||
|
// account/username-dialog.ts, account/account-menu.ts). „Abbrechen“ und „Speichern“ stehen weiter oben, die Fehlertexte zum Namen
|
||||||
|
// („schon vergeben“, „nicht erlaubt“ …) bei den Texten der API.
|
||||||
|
'Benutzername': 'Username',
|
||||||
|
'3–20 Zeichen: Buchstaben, Zahlen, . _ -': '3–20 characters: letters, numbers, . _ -',
|
||||||
|
'Benutzername wählen': 'Choose a username',
|
||||||
|
'Benutzername ändern': 'Change username',
|
||||||
|
'Dein Benutzername ist öffentlich und steht bei deinen Logos und Karten.': 'Your username is public and shown on your logos and cards.',
|
||||||
|
'Später': 'Later',
|
||||||
|
'Wird gespeichert …': 'Saving …',
|
||||||
|
|
||||||
// Fehlermeldungen zu Konto und Karten (account/errors.ts, account/auth.ts)
|
// Fehlermeldungen zu Konto und Karten (account/errors.ts, account/auth.ts)
|
||||||
'Der Link ist ungültig oder abgelaufen.': 'The link is invalid or has expired.',
|
'Der Link ist ungültig oder abgelaufen.': 'The link is invalid or has expired.',
|
||||||
'E-Mail oder Passwort ist falsch.': 'Email or password is incorrect.',
|
'E-Mail oder Passwort ist falsch.': 'Email or password is incorrect.',
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ const mocks = vi.hoisted(() => ({
|
|||||||
createRecovery: vi.fn(),
|
createRecovery: vi.fn(),
|
||||||
updateRecovery: vi.fn(),
|
updateRecovery: vi.fn(),
|
||||||
},
|
},
|
||||||
|
/** Abfrage der Benutzernamen (Tabelle profiles): leere Antwort = der Name ist frei. */
|
||||||
|
listRows: vi.fn(),
|
||||||
getCard: vi.fn(),
|
getCard: vi.fn(),
|
||||||
saveCard: vi.fn(),
|
saveCard: vi.fn(),
|
||||||
getCurrentUser: vi.fn(),
|
getCurrentUser: vi.fn(),
|
||||||
@@ -23,7 +25,14 @@ const mocks = vi.hoisted(() => ({
|
|||||||
getGalleryCard: vi.fn(),
|
getGalleryCard: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../account/appwrite', () => ({ account: mocks.acct, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
|
vi.mock('../account/appwrite', () => ({
|
||||||
|
account: mocks.acct,
|
||||||
|
client: {},
|
||||||
|
tables: { listRows: mocks.listRows },
|
||||||
|
storage: {},
|
||||||
|
IDS: { database: 'aircard', profiles: 'profiles' },
|
||||||
|
APPWRITE_ENDPOINT: '',
|
||||||
|
}));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
@@ -80,7 +89,7 @@ describe('Konto-Menü und Login-Dialog auf Englisch', () => {
|
|||||||
expect(texts(slot)).toEqual(['Sign in']);
|
expect(texts(slot)).toEqual(['Sign in']);
|
||||||
mocks.acct.get.mockResolvedValue({ $id: 'u1', email: 'kenso@example.com' });
|
mocks.acct.get.mockResolvedValue({ $id: 'u1', email: 'kenso@example.com' });
|
||||||
await auth.getCurrentUser();
|
await auth.getCurrentUser();
|
||||||
expect(texts(slot)).toEqual(expect.arrayContaining(['Account menu', 'Account', 'My cards', 'Sign out']));
|
expect(texts(slot)).toEqual(expect.arrayContaining(['Account menu', 'Account', 'My cards', 'Choose a username', 'Sign out']));
|
||||||
expect(germanLeft(slot)).toEqual([]);
|
expect(germanLeft(slot)).toEqual([]);
|
||||||
mocks.acct.deleteSession.mockRejectedValue(new TypeError('Failed to fetch'));
|
mocks.acct.deleteSession.mockRejectedValue(new TypeError('Failed to fetch'));
|
||||||
[...slot.querySelectorAll('button')].find((b) => b.textContent === 'Sign out')!.click();
|
[...slot.querySelectorAll('button')].find((b) => b.textContent === 'Sign out')!.click();
|
||||||
@@ -111,10 +120,24 @@ describe('Konto-Menü und Login-Dialog auf Englisch', () => {
|
|||||||
|
|
||||||
button('Sign up').click();
|
button('Sign up').click();
|
||||||
expect(dialog().querySelector('h2')!.textContent).toBe('Sign up');
|
expect(dialog().querySelector('h2')!.textContent).toBe('Sign up');
|
||||||
expect(texts(dialog())).toEqual(expect.arrayContaining(['At least 8 characters.', 'Confirm password']));
|
expect(texts(dialog())).toEqual(
|
||||||
|
expect.arrayContaining(['Username', '3–20 characters: letters, numbers, . _ -', 'At least 8 characters.', 'Confirm password']),
|
||||||
|
);
|
||||||
|
expect(germanLeft(dialog())).toEqual([]);
|
||||||
|
input('username').value = 'a!';
|
||||||
input('email').value = 'a@b.de';
|
input('email').value = 'a@b.de';
|
||||||
input('password').value = 'passwort123';
|
input('password').value = 'passwort123';
|
||||||
input('password-repeat').value = 'passwort124';
|
input('password-repeat').value = 'passwort124';
|
||||||
|
// der Name zuerst, am Feld: Muster und Sperrliste in der Sprache der Seite
|
||||||
|
submit(dialog().querySelector('form'));
|
||||||
|
expect(alert()).toBe(
|
||||||
|
'The username must be 3–20 characters long, start with a letter or a number, and may only contain letters, numbers, periods, underscores and hyphens.',
|
||||||
|
);
|
||||||
|
expect(document.activeElement).toBe(input('username'));
|
||||||
|
input('username').value = 'Admin';
|
||||||
|
submit(dialog().querySelector('form'));
|
||||||
|
expect(alert()).toBe('This username is not allowed.');
|
||||||
|
input('username').value = 'Kenso';
|
||||||
submit(dialog().querySelector('form'));
|
submit(dialog().querySelector('form'));
|
||||||
expect(alert()).toBe('The passwords don’t match.');
|
expect(alert()).toBe('The passwords don’t match.');
|
||||||
expect(document.activeElement).toBe(input('password-repeat'));
|
expect(document.activeElement).toBe(input('password-repeat'));
|
||||||
@@ -125,11 +148,19 @@ describe('Konto-Menü und Login-Dialog auf Englisch', () => {
|
|||||||
expect(document.activeElement).toBe(input('password'));
|
expect(document.activeElement).toBe(input('password'));
|
||||||
expect(germanLeft(dialog())).toEqual([]);
|
expect(germanLeft(dialog())).toEqual([]);
|
||||||
|
|
||||||
// vorhandene E-Mail: Meldung mit dem Link „Sign in“
|
// vergebener Name: die Meldung steht am Feld, bevor ein Konto entsteht
|
||||||
mocks.acct.create.mockRejectedValue(fail(409, 'user_already_exists'));
|
mocks.listRows.mockResolvedValueOnce({ rows: [{ $id: 'u9', username: 'Kenso', usernameKey: 'kenso' }] });
|
||||||
input('password').value = 'passwort123';
|
input('password').value = 'passwort123';
|
||||||
input('password-repeat').value = 'passwort123';
|
input('password-repeat').value = 'passwort123';
|
||||||
submit(dialog().querySelector('form'));
|
submit(dialog().querySelector('form'));
|
||||||
|
await vi.waitFor(() => expect(alert()).toBe('This username is already taken.'));
|
||||||
|
expect(document.activeElement).toBe(input('username'));
|
||||||
|
expect(mocks.acct.create).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// vorhandene E-Mail: Meldung mit dem Link „Sign in“
|
||||||
|
mocks.listRows.mockResolvedValue({ rows: [] });
|
||||||
|
mocks.acct.create.mockRejectedValue(fail(409, 'user_already_exists'));
|
||||||
|
submit(dialog().querySelector('form'));
|
||||||
await vi.waitFor(() => expect(alert()).toBe('This email is already registered. Please sign in. Sign in'));
|
await vi.waitFor(() => expect(alert()).toBe('This email is already registered. Please sign in. Sign in'));
|
||||||
|
|
||||||
button('Sign in').click();
|
button('Sign in').click();
|
||||||
|
|||||||
+232
-6
@@ -1,12 +1,19 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
type TestUser = { $id: string; email: string };
|
||||||
|
|
||||||
// Der Einstieg läuft mit echtem Router, echter Kopfzeile, Banner, Fußzeile und Konto-Menü; Seiten, Anmeldung und Appwrite sind Attrappen.
|
// Der Einstieg läuft mit echtem Router, echter Kopfzeile, Banner, Fußzeile und Konto-Menü; Seiten, Anmeldung und Appwrite sind Attrappen.
|
||||||
|
// Vom Benutzernamen läuft das Echte (Merker in sessionStorage); ersetzt sind nur das Lesen des Profils und der Dialog.
|
||||||
const mocks = vi.hoisted(() => {
|
const mocks = vi.hoisted(() => {
|
||||||
const page = () => vi.fn((..._args: unknown[]) => vi.fn()); // baut eine Seite auf und gibt deren Aufräum-Funktion zurück
|
const page = () => vi.fn((..._args: unknown[]) => vi.fn()); // baut eine Seite auf und gibt deren Aufräum-Funktion zurück
|
||||||
return {
|
return {
|
||||||
setLocale: vi.fn(),
|
setLocale: vi.fn(),
|
||||||
getCurrentUser: vi.fn(),
|
getCurrentUser: vi.fn(),
|
||||||
authListeners: new Set<(user: null) => void>(),
|
/** Der angemeldete Nutzer, wie `currentUser`/`currentUserId` ihn nennen. */
|
||||||
|
user: null as TestUser | null,
|
||||||
|
authListeners: new Set<(user: TestUser | null) => void>(),
|
||||||
|
loadMyUsername: vi.fn(),
|
||||||
|
usernameDialog: vi.fn(),
|
||||||
pages: { editor: page(), cards: page(), community: page(), reset: page(), home: page() },
|
pages: { editor: page(), cards: page(), community: page(), reset: page(), home: page() },
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -14,14 +21,16 @@ const mocks = vi.hoisted(() => {
|
|||||||
vi.mock('./account/appwrite', () => ({ client: { setLocale: mocks.setLocale } }));
|
vi.mock('./account/appwrite', () => ({ client: { setLocale: mocks.setLocale } }));
|
||||||
vi.mock('./account/auth', () => ({
|
vi.mock('./account/auth', () => ({
|
||||||
getCurrentUser: mocks.getCurrentUser,
|
getCurrentUser: mocks.getCurrentUser,
|
||||||
currentUser: () => null,
|
currentUser: () => mocks.user,
|
||||||
|
currentUserId: () => mocks.user?.$id ?? null,
|
||||||
logout: vi.fn(),
|
logout: vi.fn(),
|
||||||
onAuthChange: (fn: (user: null) => void) => {
|
onAuthChange: (fn: (user: TestUser | null) => void) => {
|
||||||
mocks.authListeners.add(fn);
|
mocks.authListeners.add(fn);
|
||||||
return () => mocks.authListeners.delete(fn);
|
return () => mocks.authListeners.delete(fn);
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
vi.mock('./account/login-dialog', () => ({ requireLogin: vi.fn() }));
|
vi.mock('./account/login-dialog', () => ({ requireLogin: vi.fn() }));
|
||||||
|
vi.mock('./account/username-dialog', () => ({ usernameDialog: mocks.usernameDialog }));
|
||||||
vi.mock('./editor/auth-reset', () => ({ bindEditorToAuth: vi.fn() }));
|
vi.mock('./editor/auth-reset', () => ({ bindEditorToAuth: vi.fn() }));
|
||||||
vi.mock('./editor/editor-page', () => ({ mountEditor: mocks.pages.editor }));
|
vi.mock('./editor/editor-page', () => ({ mountEditor: mocks.pages.editor }));
|
||||||
vi.mock('./pages/cards-page', () => ({ mountCardsPage: mocks.pages.cards }));
|
vi.mock('./pages/cards-page', () => ({ mountCardsPage: mocks.pages.cards }));
|
||||||
@@ -32,6 +41,9 @@ vi.mock('./pages/home-page', () => ({ mountHomePage: mocks.pages.home }));
|
|||||||
const DE_FOOTER = 'Inoffizielle Community-Seite für die App AirCard-iOS';
|
const DE_FOOTER = 'Inoffizielle Community-Seite für die App AirCard-iOS';
|
||||||
const EN_FOOTER = 'Unofficial community site for the AirCard-iOS app';
|
const EN_FOOTER = 'Unofficial community site for the AirCard-iOS app';
|
||||||
|
|
||||||
|
/** Abonnenten von onAuthChange nach dem Start: das Konto-Menü und die Abfrage des Benutzernamens (main.ts); ein Sprachwechsel ändert die Zahl nicht. */
|
||||||
|
const AUTH_LISTENERS = 2;
|
||||||
|
|
||||||
const slot = () => document.getElementById('account-slot') as HTMLElement;
|
const slot = () => document.getElementById('account-slot') as HTMLElement;
|
||||||
const langButton = () => document.querySelector<HTMLButtonElement>('button.lang-btn')!;
|
const langButton = () => document.querySelector<HTMLButtonElement>('button.lang-btn')!;
|
||||||
const navTexts = () => [...document.querySelectorAll('nav.app-nav a[data-link]')].map((a) => a.textContent);
|
const navTexts = () => [...document.querySelectorAll('nav.app-nav a[data-link]')].map((a) => a.textContent);
|
||||||
@@ -41,6 +53,12 @@ const signIn = (label = 'Anmelden') => [...slot().querySelectorAll('button')].fi
|
|||||||
/** Startet main.ts frisch (es läuft beim Import) auf der Adresse `path`; `storedLang` ist die gespeicherte Sprachwahl (null: keine). */
|
/** Startet main.ts frisch (es läuft beim Import) auf der Adresse `path`; `storedLang` ist die gespeicherte Sprachwahl (null: keine). */
|
||||||
async function start(path = '/', storedLang: string | null = 'de') {
|
async function start(path = '/', storedLang: string | null = 'de') {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
|
// Das Profil: nur das Lesen im Netz ist ersetzt. Neu nach jedem Zurücksetzen, damit jede Kopie von main.ts ihren eigenen Merker
|
||||||
|
// „schon gefragt“ im Speicher hat (ein `vi.mock` bliebe bei der ersten Kopie von profile.ts stehen).
|
||||||
|
vi.doMock('./account/profile', async () => ({
|
||||||
|
...(await vi.importActual<typeof import('./account/profile')>('./account/profile')),
|
||||||
|
loadMyUsername: mocks.loadMyUsername,
|
||||||
|
}));
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
if (storedLang) localStorage.setItem('aircard.lang', storedLang);
|
if (storedLang) localStorage.setItem('aircard.lang', storedLang);
|
||||||
history.replaceState(null, '', path);
|
history.replaceState(null, '', path);
|
||||||
@@ -55,7 +73,11 @@ async function start(path = '/', storedLang: string | null = 'de') {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks.setLocale.mockReset();
|
mocks.setLocale.mockReset();
|
||||||
mocks.getCurrentUser.mockReset().mockResolvedValue(null); // Gast: der Anmeldestatus ist „abgemeldet“
|
mocks.getCurrentUser.mockReset().mockResolvedValue(null); // Gast: der Anmeldestatus ist „abgemeldet“
|
||||||
|
mocks.user = null;
|
||||||
mocks.authListeners.clear();
|
mocks.authListeners.clear();
|
||||||
|
mocks.loadMyUsername.mockReset().mockResolvedValue(null); // das Konto hat keinen Namen
|
||||||
|
mocks.usernameDialog.mockReset().mockResolvedValue(null); // „Später“
|
||||||
|
sessionStorage.clear();
|
||||||
for (const page of Object.values(mocks.pages)) page.mockReset();
|
for (const page of Object.values(mocks.pages)) page.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -170,12 +192,12 @@ describe('Sprachwechsel', () => {
|
|||||||
it('das Konto-Menü wird neu gebaut und zeigt sich sofort, wenn der Anmeldestatus schon bekannt ist', async () => {
|
it('das Konto-Menü wird neu gebaut und zeigt sich sofort, wenn der Anmeldestatus schon bekannt ist', async () => {
|
||||||
await start('/');
|
await start('/');
|
||||||
await vi.waitFor(() => expect(signIn()).toBeDefined());
|
await vi.waitFor(() => expect(signIn()).toBeDefined());
|
||||||
expect(mocks.authListeners.size).toBe(1);
|
expect(mocks.authListeners.size).toBe(AUTH_LISTENERS);
|
||||||
|
|
||||||
langButton().click();
|
langButton().click();
|
||||||
|
|
||||||
expect(signIn('Sign in')).toBeDefined(); // kein leerer Platz bis zur nächsten Änderung, jetzt auf Englisch
|
expect(signIn('Sign in')).toBeDefined(); // kein leerer Platz bis zur nächsten Änderung, jetzt auf Englisch
|
||||||
expect(mocks.authListeners.size).toBe(1); // das alte Menü hat sich abgemeldet
|
expect(mocks.authListeners.size).toBe(AUTH_LISTENERS); // das alte Menü hat sich abgemeldet
|
||||||
expect(mocks.getCurrentUser).toHaveBeenCalledTimes(1); // der Status wird nicht noch einmal geholt
|
expect(mocks.getCurrentUser).toHaveBeenCalledTimes(1); // der Status wird nicht noch einmal geholt
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -187,7 +209,7 @@ describe('Sprachwechsel', () => {
|
|||||||
|
|
||||||
langButton().click();
|
langButton().click();
|
||||||
expect(slot().childElementCount).toBe(0);
|
expect(slot().childElementCount).toBe(0);
|
||||||
expect(mocks.authListeners.size).toBe(1);
|
expect(mocks.authListeners.size).toBe(AUTH_LISTENERS);
|
||||||
|
|
||||||
answer(null);
|
answer(null);
|
||||||
await vi.waitFor(() => expect(signIn('Sign in')).toBeDefined());
|
await vi.waitFor(() => expect(signIn('Sign in')).toBeDefined());
|
||||||
@@ -214,3 +236,207 @@ describe('Sprachwechsel', () => {
|
|||||||
expect(document.activeElement).toBe(document.body);
|
expect(document.activeElement).toBe(document.body);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Benutzername abfragen (Spec § 5)', () => {
|
||||||
|
const kenso: TestUser = { $id: 'u1', email: 'kenso@example.com' };
|
||||||
|
const PROMPTED_KEY = 'aircard.usernamePrompted';
|
||||||
|
|
||||||
|
/** Das Konto ist schon beim Start angemeldet (Sitzung im Browser). */
|
||||||
|
const sessionAtStart = (user: TestUser = kenso) => {
|
||||||
|
mocks.user = user;
|
||||||
|
mocks.getCurrentUser.mockResolvedValue(user);
|
||||||
|
};
|
||||||
|
/** Der Nutzer meldet sich an: erst steht er fest, dann hören es alle Abonnenten (wie auth.ts). */
|
||||||
|
const userSignsIn = (user: TestUser = kenso) => {
|
||||||
|
mocks.user = user;
|
||||||
|
for (const fn of [...mocks.authListeners]) fn(user);
|
||||||
|
};
|
||||||
|
const settle = () => new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
it('Start mit Sitzung, Konto ohne Namen: der Dialog „prompt“ erscheint genau einmal; der Merker liegt in sessionStorage', async () => {
|
||||||
|
sessionAtStart();
|
||||||
|
await start('/');
|
||||||
|
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1));
|
||||||
|
expect(mocks.usernameDialog).toHaveBeenCalledWith({ mode: 'prompt' });
|
||||||
|
expect(sessionStorage.getItem(PROMPTED_KEY)).toBe('1');
|
||||||
|
await settle();
|
||||||
|
expect(mocks.usernameDialog).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der Merker steht schon, wenn der Dialog erscheint (ein zweiter Auslöser, solange er offen ist, fragt nicht noch einmal)', async () => {
|
||||||
|
let flagWhenShown: string | null | undefined;
|
||||||
|
let close!: () => void;
|
||||||
|
mocks.usernameDialog.mockImplementation(() => {
|
||||||
|
flagWhenShown = sessionStorage.getItem(PROMPTED_KEY);
|
||||||
|
return new Promise<null>((resolve) => (close = () => resolve(null)));
|
||||||
|
});
|
||||||
|
sessionAtStart();
|
||||||
|
await start('/');
|
||||||
|
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1));
|
||||||
|
expect(flagWhenShown).toBe('1');
|
||||||
|
userSignsIn(); // während der Dialog offen ist
|
||||||
|
await settle();
|
||||||
|
expect(mocks.usernameDialog).toHaveBeenCalledTimes(1);
|
||||||
|
close();
|
||||||
|
await settle();
|
||||||
|
userSignsIn(); // auch nach „Später“
|
||||||
|
await settle();
|
||||||
|
expect(mocks.usernameDialog).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Start mit Sitzung, Konto mit Namen: kein Dialog, der Merker bleibt leer', async () => {
|
||||||
|
mocks.loadMyUsername.mockResolvedValue('Kenso');
|
||||||
|
sessionAtStart();
|
||||||
|
await start('/');
|
||||||
|
await vi.waitFor(() => expect(mocks.loadMyUsername).toHaveBeenCalled());
|
||||||
|
await settle();
|
||||||
|
expect(mocks.usernameDialog).not.toHaveBeenCalled();
|
||||||
|
expect(sessionStorage.getItem(PROMPTED_KEY)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Gast: nichts wird gelesen und nichts gefragt', async () => {
|
||||||
|
await start('/');
|
||||||
|
await vi.waitFor(() => expect(signIn()).toBeDefined());
|
||||||
|
await settle();
|
||||||
|
expect(mocks.loadMyUsername).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.usernameDialog).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Anmeldung nach dem Start, Konto ohne Namen: „prompt“ genau einmal; eine weitere Anmeldung in derselben Sitzung fragt nicht noch einmal', async () => {
|
||||||
|
await start('/');
|
||||||
|
await vi.waitFor(() => expect(signIn()).toBeDefined());
|
||||||
|
userSignsIn();
|
||||||
|
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1));
|
||||||
|
expect(mocks.usernameDialog).toHaveBeenCalledWith({ mode: 'prompt' });
|
||||||
|
userSignsIn({ $id: 'u1', email: 'kenso@example.com' }); // z. B. ein Labelwechsel des Kontos
|
||||||
|
await settle();
|
||||||
|
expect(mocks.usernameDialog).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Anmeldung, Konto mit Namen: kein Dialog', async () => {
|
||||||
|
mocks.loadMyUsername.mockResolvedValue('Kenso');
|
||||||
|
await start('/');
|
||||||
|
userSignsIn();
|
||||||
|
await vi.waitFor(() => expect(mocks.loadMyUsername).toHaveBeenCalledTimes(1));
|
||||||
|
await settle();
|
||||||
|
expect(mocks.usernameDialog).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Abmelden löst nichts aus', async () => {
|
||||||
|
mocks.loadMyUsername.mockResolvedValue('Kenso');
|
||||||
|
sessionAtStart();
|
||||||
|
await start('/');
|
||||||
|
await vi.waitFor(() => expect(mocks.loadMyUsername).toHaveBeenCalledTimes(1));
|
||||||
|
mocks.user = null;
|
||||||
|
for (const fn of [...mocks.authListeners]) fn(null);
|
||||||
|
await settle();
|
||||||
|
expect(mocks.loadMyUsername).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.usernameDialog).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Start und Anmeldemeldung für dasselbe Konto zugleich (wie im echten Start): ein Lesen, ein Dialog', async () => {
|
||||||
|
mocks.loadMyUsername.mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve(null), 5)));
|
||||||
|
mocks.getCurrentUser.mockImplementation(async () => {
|
||||||
|
userSignsIn(); // getCurrentUser meldet den neuen Nutzer an alle Abonnenten, bevor es ihn zurückgibt
|
||||||
|
return kenso;
|
||||||
|
});
|
||||||
|
await start('/');
|
||||||
|
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1));
|
||||||
|
await settle();
|
||||||
|
expect(mocks.loadMyUsername).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.usernameDialog).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Neuladen in derselben Sitzung: der Merker aus sessionStorage hält die Abfrage zurück, der Name wird trotzdem gelesen', async () => {
|
||||||
|
sessionAtStart();
|
||||||
|
await start('/');
|
||||||
|
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
mocks.authListeners.clear(); // die erste Seite ist weg
|
||||||
|
mocks.loadMyUsername.mockClear();
|
||||||
|
await start('/');
|
||||||
|
await vi.waitFor(() => expect(mocks.loadMyUsername).toHaveBeenCalledTimes(1));
|
||||||
|
await settle();
|
||||||
|
expect(mocks.usernameDialog).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine neue Sitzung (anderer Tab) fragt wieder', async () => {
|
||||||
|
sessionAtStart();
|
||||||
|
await start('/');
|
||||||
|
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
sessionStorage.clear(); // neuer Tab = neue sessionStorage
|
||||||
|
mocks.authListeners.clear();
|
||||||
|
await start('/');
|
||||||
|
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der Name lässt sich nicht lesen: kein Dialog (das Konto könnte einen haben), kein Merker, kein Absturz', async () => {
|
||||||
|
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
mocks.loadMyUsername.mockRejectedValue(new TypeError('Failed to fetch'));
|
||||||
|
sessionAtStart();
|
||||||
|
await start('/');
|
||||||
|
await vi.waitFor(() => expect(error).toHaveBeenCalled());
|
||||||
|
expect(mocks.usernameDialog).not.toHaveBeenCalled();
|
||||||
|
expect(sessionStorage.getItem(PROMPTED_KEY)).toBeNull();
|
||||||
|
expect(slot().childElementCount).toBeGreaterThan(0); // die Seite steht
|
||||||
|
|
||||||
|
// Beim nächsten Versuch (neue Anmeldemeldung) wird gefragt, wenn der Name lesbar ist.
|
||||||
|
mocks.loadMyUsername.mockResolvedValue(null);
|
||||||
|
userSignsIn();
|
||||||
|
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1));
|
||||||
|
error.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scheitert der Dialog beim Öffnen, bleibt es bei einer Meldung in der Konsole', async () => {
|
||||||
|
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
mocks.usernameDialog.mockRejectedValue(new Error('showModal kaputt'));
|
||||||
|
sessionAtStart();
|
||||||
|
await start('/');
|
||||||
|
await vi.waitFor(() => expect(error).toHaveBeenCalled());
|
||||||
|
expect(slot().childElementCount).toBeGreaterThan(0);
|
||||||
|
error.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wird der Nutzer abgemeldet, während sein Name gelesen wird, erscheint kein Dialog', async () => {
|
||||||
|
let answer!: (name: string | null) => void;
|
||||||
|
mocks.loadMyUsername.mockReturnValue(new Promise((resolve) => (answer = resolve)));
|
||||||
|
await start('/');
|
||||||
|
userSignsIn();
|
||||||
|
await vi.waitFor(() => expect(mocks.loadMyUsername).toHaveBeenCalledTimes(1));
|
||||||
|
mocks.user = null; // abgemeldet, bevor die Antwort da ist
|
||||||
|
answer(null);
|
||||||
|
await settle();
|
||||||
|
expect(mocks.usernameDialog).not.toHaveBeenCalled();
|
||||||
|
expect(sessionStorage.getItem(PROMPTED_KEY)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wechselt das Konto, während gelesen wird, wird das andere Konto gefragt, nicht das alte', async () => {
|
||||||
|
const mia: TestUser = { $id: 'u2', email: 'mia@example.com' };
|
||||||
|
const answers: Array<(name: string | null) => void> = [];
|
||||||
|
mocks.loadMyUsername.mockImplementation(() => new Promise((resolve) => answers.push(resolve)));
|
||||||
|
await start('/');
|
||||||
|
userSignsIn(kenso);
|
||||||
|
await vi.waitFor(() => expect(answers).toHaveLength(1));
|
||||||
|
userSignsIn(mia); // Kontowechsel, bevor die erste Antwort da ist
|
||||||
|
await vi.waitFor(() => expect(answers).toHaveLength(2));
|
||||||
|
answers[0](null); // die Antwort für das alte Konto: ohne Wirkung
|
||||||
|
await settle();
|
||||||
|
expect(mocks.usernameDialog).not.toHaveBeenCalled();
|
||||||
|
answers[1](null);
|
||||||
|
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ist sessionStorage gesperrt, wirft nichts, und gefragt wird einmal pro Seitenaufruf', async () => {
|
||||||
|
const denied = () => {
|
||||||
|
throw new DOMException('blockiert', 'SecurityError');
|
||||||
|
};
|
||||||
|
vi.stubGlobal('sessionStorage', { getItem: denied, setItem: denied, removeItem: denied });
|
||||||
|
sessionAtStart();
|
||||||
|
await start('/');
|
||||||
|
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1));
|
||||||
|
userSignsIn();
|
||||||
|
await settle();
|
||||||
|
expect(mocks.usernameDialog).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
+34
-1
@@ -2,7 +2,9 @@ import '@fontsource-variable/nunito-sans';
|
|||||||
import './styles.css';
|
import './styles.css';
|
||||||
import { mountAccountMenu, type AccountMenu } from './account/account-menu';
|
import { mountAccountMenu, type AccountMenu } from './account/account-menu';
|
||||||
import { client } from './account/appwrite';
|
import { client } from './account/appwrite';
|
||||||
import { getCurrentUser } from './account/auth';
|
import { currentUserId, getCurrentUser, onAuthChange } from './account/auth';
|
||||||
|
import { loadMyUsername, markUsernamePrompted, usernamePrompted } from './account/profile';
|
||||||
|
import { usernameDialog } from './account/username-dialog';
|
||||||
import { DONATE_URL } from './config';
|
import { DONATE_URL } from './config';
|
||||||
import { currentLang, onLangChange, setLang } from './i18n/i18n';
|
import { currentLang, onLangChange, setLang } from './i18n/i18n';
|
||||||
import { legacyRedirect, rerender, startRouter } from './pages/router';
|
import { legacyRedirect, rerender, startRouter } from './pages/router';
|
||||||
@@ -64,10 +66,41 @@ function mountShell(): void {
|
|||||||
}
|
}
|
||||||
mountShell();
|
mountShell();
|
||||||
|
|
||||||
|
// Benutzername (Spec § 5): Den Namen des angemeldeten Kontos liest die Seite nach dem Start und nach jeder Anmeldung (das Konto-Menü zeigt
|
||||||
|
// ihn). Hat das Konto keinen, wird es einmal pro Sitzung gefragt (Merker in sessionStorage), mit „Später“. Lässt sich der Name nicht
|
||||||
|
// lesen, wird nicht gefragt: das Konto könnte einen haben.
|
||||||
|
let checkingUsernameOf: string | null = null;
|
||||||
|
|
||||||
|
async function checkUsername(userId: string): Promise<void> {
|
||||||
|
if (checkingUsernameOf === userId) return; // das Lesen für dieses Konto läuft schon (Start und Anmeldemeldung treffen zusammen)
|
||||||
|
checkingUsernameOf = userId;
|
||||||
|
try {
|
||||||
|
const name = await loadMyUsername();
|
||||||
|
// Nicht fragen, wenn das Konto einen Namen hat, inzwischen abgemeldet ist, ein anderes Konto angemeldet ist (dessen Prüfung läuft für
|
||||||
|
// sich) oder in dieser Sitzung schon gefragt wurde. Der Merker wird erst jetzt gelesen: die Registrierung setzt ihn, während der Name
|
||||||
|
// gelesen wird (login-dialog.ts).
|
||||||
|
if (name !== null || currentUserId() !== userId || usernamePrompted()) return;
|
||||||
|
markUsernamePrompted(); // vor dem Dialog: solange er offen ist, fragt kein zweiter Auslöser noch einmal
|
||||||
|
await usernameDialog({ mode: 'prompt' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Benutzername konnte nicht geprüft werden', error);
|
||||||
|
} finally {
|
||||||
|
if (checkingUsernameOf === userId) checkingUsernameOf = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getCurrentUser und die Anmeldung melden den Nutzer über onAuthChange; der Aufruf nach getCurrentUser unten ist das Netz darunter.
|
||||||
|
onAuthChange((user) => {
|
||||||
|
if (user) void checkUsername(user.$id);
|
||||||
|
});
|
||||||
|
|
||||||
// Der Anmeldestatus wird einmal beim Start geholt; schlägt das fehl (außer 401, das getCurrentUser selbst als „abgemeldet“ meldet),
|
// Der Anmeldestatus wird einmal beim Start geholt; schlägt das fehl (außer 401, das getCurrentUser selbst als „abgemeldet“ meldet),
|
||||||
// gilt der Nutzer als abgemeldet.
|
// gilt der Nutzer als abgemeldet.
|
||||||
if (accountSlot) {
|
if (accountSlot) {
|
||||||
getCurrentUser()
|
getCurrentUser()
|
||||||
|
.then((user) => {
|
||||||
|
if (user) void checkUsername(user.$id);
|
||||||
|
})
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
console.error('Anmeldestatus konnte nicht geprüft werden', error);
|
console.error('Anmeldestatus konnte nicht geprüft werden', error);
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in new issue
Block a user