Compare commits
30
Commits
ec0b51a949
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cdebb7102 | ||
|
|
4e9659ca85 | ||
|
|
dc6bd3362b | ||
|
|
56cb67be4b | ||
|
|
3440eb46c3 | ||
|
|
53bcecf3cb | ||
|
|
7c55912ebe | ||
|
|
f46a69df68 | ||
|
|
04404ffdae | ||
|
|
0ad51bf7c6 | ||
|
|
151e340fdd | ||
|
|
3e4bc21f00 | ||
|
|
19c8151c7e | ||
|
|
a65c075004 | ||
|
|
13b46da2e0 | ||
|
|
6e5fe3e199 | ||
|
|
691b28df19 | ||
|
|
e0f11f905e | ||
|
|
0e4759a05a | ||
|
|
02b01c9deb | ||
|
|
8167a18857 | ||
|
|
29c9b2efaf | ||
|
|
76c3c3aea2 | ||
|
|
1fd08d6373 | ||
|
|
0bcdfe258d | ||
|
|
af05e5f1f1 | ||
|
|
7cb1381566 | ||
|
|
6134fd26d7 | ||
|
|
738358fcac | ||
|
|
6d152c11b3 |
No files matched your search
@@ -77,7 +77,7 @@ Starten nur mit:
|
||||
docker compose up -d aircard-build aircard aircard-api
|
||||
```
|
||||
|
||||
`aircard-api` ist der kleine API-Dienst der Community-Galerie und der Community-Logos (`deploy/api/`, Route `aircard-ios.org/api`, siehe `deploy/api/README.md`).
|
||||
`aircard-api` ist der kleine API-Dienst der Community-Galerie, der Community-Logos und der Benutzernamen (`deploy/api/`, Route `aircard-ios.org/api`, siehe `deploy/api/README.md`).
|
||||
|
||||
Die Appwrite-API wird über `/home/webklar/apps/traefik/dynamic/aircard.yml` geroutet (Vorlage: `deploy/traefik/aircard.yml`): Requests auf `aircard-ios.org/v1` mit Header `X-Appwrite-Project: 6ac2130a00289343d6d5` gehen an den Appwrite-Container. Ohne diesen Header antwortet der nginx der Seite mit 404.
|
||||
|
||||
@@ -85,7 +85,7 @@ Die Appwrite-API wird über `/home/webklar/apps/traefik/dynamic/aircard.yml` ger
|
||||
|
||||
Projekt „AirCard“ auf `appwrite.webklar.com`:
|
||||
- Projekt-ID (öffentlich, von Appwrite generiert): `6ac2130a00289343d6d5`. Sie steht in `deploy/traefik/aircard.yml`, in `/home/webklar/apps/.env` (`AIRCARD_APPWRITE_PROJECT_ID`) und als Standard in `web/src/account/appwrite.ts`.
|
||||
- Datenbank: `aircard`; Tabellen: `cards` (private Karten), `gallery` und `reports` (Community-Galerie, Meldungen), `logos`, `logo_likes` und `logo_reports` (Community-Logos, Herzen, Meldungen); Buckets: `card-images`, `gallery-images` und `logo-images` (das Skript unten legt sie an und prüft sie)
|
||||
- Datenbank: `aircard`; Tabellen: `cards` (private Karten), `gallery` und `reports` (Community-Galerie, Meldungen), `logos`, `logo_likes` und `logo_reports` (Community-Logos, Herzen, Meldungen) und `profiles` (Benutzernamen, eine Zeile je Konto, Unique-Index auf dem klein geschriebenen Namen); Buckets: `card-images`, `gallery-images` und `logo-images` (das Skript unten legt sie an und prüft sie)
|
||||
- Console-Einstellungen laut `docs/appwrite-console.md` (Soll-Stand, nicht maschinell geprüft; `--check` prüft nur das Schema): Auth mit E-Mail + Passwort, Sitzungsdauer 30 Tage, max. 5 Sitzungen pro Nutzer, Nutzerlimit 2000, Web-Plattformen `aircard-ios.org` und `localhost`. Schritte 3 und 4 dort (Auth-Härtung, Dienste abschalten) sind optional und am Live-Projekt unbestätigt.
|
||||
|
||||
**Schema prüfen:**
|
||||
|
||||
+9
-8
@@ -10,7 +10,7 @@ Die Seite wird auf dem Webklar-Server aus dem Gitea-Repo `knso/AirCardIOS` gebau
|
||||
| `deploy/webhook-server.js` | `aircard-ios.org/webhook-server.js` | nimmt den Gitea-Webhook an, startet `deploy.sh` nacheinander, pollt alle 15 Minuten |
|
||||
| `deploy/nginx/aircard-ios.org.conf` | `aircard-ios.org/nginx/aircard-ios.org.conf` | nginx-Konfiguration der Seite |
|
||||
| `deploy/traefik/aircard.yml` | `traefik/dynamic/aircard.yml` | Route `/v1` mit Header `X-Appwrite-Project: 6ac2130a00289343d6d5` an Appwrite |
|
||||
| `deploy/api/appwrite.js`, `common.js`, `app.js`, `logos.js`, `server.js` | `aircard-ios.org/api/` (nur diese fünf Dateien) | API-Dienst `aircard-api` der Community-Galerie und der Community-Logos, siehe [api/README.md](api/README.md) |
|
||||
| `deploy/api/appwrite.js`, `common.js`, `app.js`, `logos.js`, `profiles.js`, `server.js` | `aircard-ios.org/api/` (nur diese sechs Dateien) | API-Dienst `aircard-api` der Community-Galerie, der Community-Logos und der Benutzernamen, siehe [api/README.md](api/README.md) |
|
||||
| `deploy/compose-snippet.yml` | `docker-compose.yml` (nach dem eship-Block) | Dienste `aircard-build`, `aircard` und `aircard-api` |
|
||||
|
||||
Weitere Ordner unter `aircard-ios.org/` (nicht im Repo): `build/` (Klon des Repos, Arbeitsordner des Build-Containers), `out/` (gebaute Seite, von nginx ausgeliefert), `dev/` (Arbeitskopie für die Entwicklung, nicht gemountet).
|
||||
@@ -25,7 +25,7 @@ mkdir -p $APP/aircard-ios.org/build $APP/aircard-ios.org/out $APP/aircard-ios.or
|
||||
cp $SRC/deploy.sh $APP/aircard-ios.org/deploy.sh && chmod +x $APP/aircard-ios.org/deploy.sh
|
||||
cp $SRC/webhook-server.js $APP/aircard-ios.org/webhook-server.js
|
||||
cp $SRC/nginx/aircard-ios.org.conf $APP/aircard-ios.org/nginx/aircard-ios.org.conf
|
||||
cp $SRC/api/appwrite.js $SRC/api/common.js $SRC/api/app.js $SRC/api/logos.js $SRC/api/server.js $APP/aircard-ios.org/api/
|
||||
cp $SRC/api/appwrite.js $SRC/api/common.js $SRC/api/app.js $SRC/api/logos.js $SRC/api/profiles.js $SRC/api/server.js $APP/aircard-ios.org/api/
|
||||
|
||||
# Compose: Backup, dann den Inhalt von compose-snippet.yml unter `services:` direkt nach dem eship-Block einfügen
|
||||
cp $APP/docker-compose.yml $APP/docker-compose.yml.bak-2026-10-04-aircard
|
||||
@@ -55,12 +55,13 @@ Ohne `AIRCARD_WEBHOOK_TOKEN` (also ohne `GITEA_DEPLOY_WEBHOOK_SECRET` in der `.e
|
||||
|
||||
## API-Dienst `aircard-api`
|
||||
|
||||
Kleiner Node-Dienst ohne Abhängigkeiten (`deploy/api/`), Container `webklar-aircard-api`. Er führt alle Schreibzugriffe der Community-Galerie und der Community-Logos mit dem Appwrite-Server-Key aus (Veröffentlichen mit Bildmaß-Prüfung und höchstens 20 Karten je Konto, Zurückziehen, Melden mit Auto-Ausblenden ab 3 Meldern aus verschiedenen Netzen, Admin-Sichtbarkeit; bei den Logos Hochladen mit höchstens 30 Logos je Konto, Herzen, Melden, Löschen, Admin-Sichtbarkeit). Endpunkte, Statuscodes, Fehlercodes und Betrieb stehen in [api/README.md](api/README.md).
|
||||
Kleiner Node-Dienst ohne Abhängigkeiten (`deploy/api/`), Container `webklar-aircard-api`. Er führt alle Schreibzugriffe der Community-Galerie, der Community-Logos und der Benutzernamen mit dem Appwrite-Server-Key aus (Veröffentlichen mit Bildmaß-Prüfung und höchstens 20 Karten je Konto, Zurückziehen, Melden mit Auto-Ausblenden ab 3 Meldern aus verschiedenen Netzen, Admin-Sichtbarkeit; bei den Logos Hochladen mit höchstens 30 Logos je Konto, Herzen, Melden, Löschen, Admin-Sichtbarkeit; Benutzernamen anlegen und ändern, Veröffentlichen und Hochladen verlangen einen). Endpunkte, Statuscodes, Fehlercodes und Betrieb stehen in [api/README.md](api/README.md).
|
||||
|
||||
- **Route**: Traefik-Label am Dienst, Router `aircard-gallery-api` (``Host(`aircard-ios.org`) && PathPrefix(`/api`)``, Priorität 200, Port 8088). Der Name ist absichtlich nicht `aircard-api`: so heißt schon die Appwrite-Route (`/v1`) in `traefik/dynamic/aircard.yml`.
|
||||
- **Prüfen** nach dem Start: `curl -s https://aircard-ios.org/api/gesundheit` liefert `{"ok":true}`; `docker logs webklar-aircard-api` zeigt `listening on :8088`. Läuft der Dienst nicht, fehlt auch sein Traefik-Router: nginx antwortet dann auf `/api/*` mit `503` und `{"error":"Der Dienst ist gerade nicht erreichbar."}` (Content-Type `application/json`), nie mit der App.
|
||||
- **Ändern**: Dateien mit `cp` nach `aircard-ios.org/api/` ersetzen (alle fünf: `appwrite.js`, `common.js`, `app.js`, `logos.js`, `server.js`; `app.js` lädt `common.js` und `logos.js`, ohne sie startet der Dienst nicht), dann `cd /home/webklar/apps && docker compose restart aircard-api`. Nach einer Änderung der `.env` (Key, Salz) reicht `restart` nicht: `docker compose up -d aircard-api`.
|
||||
- **Ändern**: Dateien mit `cp` nach `aircard-ios.org/api/` ersetzen (alle sechs: `appwrite.js`, `common.js`, `app.js`, `logos.js`, `profiles.js`, `server.js`; `app.js` lädt `common.js`, `logos.js` und `profiles.js`, `logos.js` lädt `profiles.js`, ohne sie startet der Dienst nicht), dann `cd /home/webklar/apps && docker compose restart aircard-api`. Nach einer Änderung der `.env` (Key, Salz) reicht `restart` nicht: `docker compose up -d aircard-api`.
|
||||
- **Logos brauchen das Schema**: Die Logo-Endpunkte schreiben in die Tabellen `logos`, `logo_likes`, `logo_reports` und den Bucket `logo-images`. Das Schema-Skript `web/scripts/appwrite-setup.mjs` legt sie an (zuerst mit `--check` ansehen, was fehlt, siehe [../README.md](../README.md)); das gehört vor den Neustart des API-Dienstes mit den neuen Dateien. Ohne die Tabellen antworten die Logo-Endpunkte mit `500` „Interner Fehler.“, die Karten-Endpunkte laufen weiter. Die ganze Reihenfolge steht im nächsten Abschnitt.
|
||||
- **Benutzernamen brauchen das Schema**: `POST /api/profil` schreibt in die Tabelle `profiles` (Zeilen-ID = Konto-ID, Unique-Index `username_key` auf `usernameKey`); dieselbe Tabelle fragen `/api/veroeffentlichen` und `/api/logos/hochladen` als Erstes ab (ohne Zeile für das Konto: `403` `usernameRequired`). Das Schema-Skript legt sie an, dieselben Scopes des Keys `aircard-api` genügen, in der Console ist nichts zu tun. Ohne die Tabelle antworten Hochladen, Veröffentlichen und `/api/profil` mit `500` „Interner Fehler.“, nicht mit `403`. Zwischen dem Neustart des Dienstes und dem Livegang der neuen Seite kann die alte Seite Konten ohne Benutzernamen nicht mehr hochladen oder veröffentlichen lassen (sie zeigt den Text des Dienstes, „Bitte wähle zuerst einen Benutzernamen.“): die neue Seite direkt danach live bringen.
|
||||
- **Log-Grenze**: Der Docker-Daemon des Servers hat keine eigenen Log-Optionen, deshalb steht im Service `logging: json-file` mit `max-size: 5m` und `max-file: 2` (höchstens 10 MB). Der Dienst selbst schreibt kein Zugriffsprotokoll und höchstens 120 Zeilen pro Minute.
|
||||
- **Kein Port nach außen**: der Dienst vertraut dem Header `X-Real-Ip` (setzt Traefik) für Rate-Limit und Melder-Hash, deshalb nie `ports:` ergänzen. Im Docker-Netz `webklar-network` erreichen ihn andere Container trotzdem und könnten `X-Real-Ip` setzen: akzeptiertes Risiko, siehe [api/README.md](api/README.md) (Client-IP). Vor einem AAAA-Eintrag für aircard-ios.org die Client-IP-Ermittlung prüfen.
|
||||
|
||||
@@ -68,14 +69,14 @@ Kleiner Node-Dienst ohne Abhängigkeiten (`deploy/api/`), Container `webklar-air
|
||||
|
||||
Bringt eine Änderung neue Tabellen, neue Endpunkte und eine Seite mit, die beides braucht (wie die Community-Logos), gilt immer diese Reihenfolge. Die Seite geht zuletzt live: sonst ruft sie Endpunkte auf, die es noch nicht gibt, und ein Push von `main` baut sie sofort.
|
||||
|
||||
1. **Schema**: `node --env-file=/home/webklar/apps/.env web/scripts/appwrite-setup.mjs --check` zeigt, was fehlt (`FEHLT: …`). Ohne `--check` legt das Skript es an. Danach noch einmal mit `--check`: die Ausgabe muss mit `OK: Schema vollständig` enden (Exit-Code 0). Für die Logos sind das die Tabellen `logos`, `logo_likes`, `logo_reports` und der Bucket `logo-images`.
|
||||
2. **API**: alle fünf Dateien (`appwrite.js`, `common.js`, `app.js`, `logos.js`, `server.js`) mit `cp` nach `aircard-ios.org/api/` kopieren, dann `cd /home/webklar/apps && docker compose restart aircard-api`. `docker logs webklar-aircard-api` zeigt `listening on :8088`. Prüfen, dass die Logo-Endpunkte da sind: ein `POST` auf `/api/logos/herz` ohne JWT muss mit `401` und `"code":"unauthorized"` antworten (der Header `Content-Type: application/json` gehört dazu, sonst kommt `415`):
|
||||
1. **Schema**: `node --env-file=/home/webklar/apps/.env web/scripts/appwrite-setup.mjs --check` zeigt, was fehlt (`FEHLT: …`). Ohne `--check` legt das Skript es an. Danach noch einmal mit `--check`: die Ausgabe muss mit `OK: Schema vollständig` enden (Exit-Code 0). Für die Logos sind das die Tabellen `logos`, `logo_likes`, `logo_reports` und der Bucket `logo-images`, für die Benutzernamen die Tabelle `profiles` mit dem Unique-Index `username_key`.
|
||||
2. **API**: alle sechs Dateien (`appwrite.js`, `common.js`, `app.js`, `logos.js`, `profiles.js`, `server.js`) mit `cp` nach `aircard-ios.org/api/` kopieren, dann `cd /home/webklar/apps && docker compose restart aircard-api`. `docker logs webklar-aircard-api` zeigt `listening on :8088`. Prüfen, dass die Logo-Endpunkte da sind: ein `POST` auf `/api/logos/herz` ohne JWT muss mit `401` und `"code":"unauthorized"` antworten (der Header `Content-Type: application/json` gehört dazu, sonst kommt `415`):
|
||||
|
||||
```sh
|
||||
curl -s -i -X POST -H 'Content-Type: application/json' -d '{}' https://aircard-ios.org/api/logos/herz
|
||||
```
|
||||
|
||||
Antwortet er mit `404`, läuft noch die alte API (Dateien oder Neustart prüfen). Das Schema prüft dieser Aufruf nicht, denn die Anmeldung kommt vor jedem Zugriff auf Appwrite: dafür ist Schritt 1 da.
|
||||
Antwortet er mit `404`, läuft noch die alte API (Dateien oder Neustart prüfen); dasselbe gilt für `POST /api/profil` (ohne JWT ebenfalls `401`). Das Schema prüft dieser Aufruf nicht, denn die Anmeldung kommt vor jedem Zugriff auf Appwrite: dafür ist Schritt 1 da.
|
||||
3. **Seite**: erst jetzt `main` pushen (Gitea-Webhook, siehe unten). Der Build führt `npm ci` und `npm run build` aus: Typprüfung ohne Testdateien (`web/tsconfig.build.json`), dann `vite build`.
|
||||
|
||||
## Manuell deployen
|
||||
@@ -118,7 +119,7 @@ Repo `knso/AirCardIOS` → Einstellungen → Webhooks → Gitea:
|
||||
|
||||
- `deploy.sh` ändern: Datei auf dem Server mit `cp` überschreiben (nicht `mv`, sonst zeigt der Datei-Mount auf die alte Datei). Gilt beim nächsten Lauf.
|
||||
- `webhook-server.js` ändern: ebenso mit `cp` ersetzen, dann `cd /home/webklar/apps && docker compose restart aircard-build`.
|
||||
- `deploy/api/*.js` ändern (die fünf Dateien des Dienstes, nicht die Tests und nicht `fake-appwrite.js`): ebenso mit `cp` ersetzen, dann `cd /home/webklar/apps && docker compose restart aircard-api`.
|
||||
- `deploy/api/*.js` ändern (die sechs Dateien des Dienstes, nicht die Tests und nicht `fake-appwrite.js`): ebenso mit `cp` ersetzen, dann `cd /home/webklar/apps && docker compose restart aircard-api`.
|
||||
- nginx-Konfiguration ändern: mit `cp` ersetzen, dann `docker exec webklar-aircard nginx -t && docker exec webklar-aircard nginx -s reload`.
|
||||
- Traefik-Route ändern: Datei atomar ersetzen wie oben; Löschen der Datei entfernt die Route wieder.
|
||||
|
||||
|
||||
+21
-18
@@ -1,8 +1,8 @@
|
||||
# aircard-api
|
||||
|
||||
Kleiner HTTP-Dienst (Node 22, **keine Abhängigkeiten**) für die Community-Galerie und die Community-Logos von aircard-ios.org. Er führt alle Schreibzugriffe mit dem Appwrite-Server-Key aus: bei den Karten Veröffentlichen, Zurückziehen, Melden (Auto-Ausblenden ab 3 verschiedenen Meldern) und die Admin-Sichtbarkeit, bei den Logos Hochladen, Herz setzen, Melden, Löschen und die Admin-Sichtbarkeit. Die Website liest Galerie und Logos direkt aus Appwrite; nur das Schreiben läuft hierüber.
|
||||
Kleiner HTTP-Dienst (Node 22, **keine Abhängigkeiten**) für die Community-Galerie, die Community-Logos und die Benutzernamen von aircard-ios.org. Er führt alle Schreibzugriffe mit dem Appwrite-Server-Key aus: bei den Karten Veröffentlichen, Zurückziehen, Melden (Auto-Ausblenden ab 3 verschiedenen Meldern) und die Admin-Sichtbarkeit, bei den Logos Hochladen, Herz setzen, Melden, Löschen und die Admin-Sichtbarkeit, bei den Benutzernamen Anlegen und Ändern. Hochladen und Veröffentlichen gehen nur mit Benutzernamen. Die Website liest Galerie, Logos und Benutzernamen direkt aus Appwrite; nur das Schreiben läuft hierüber.
|
||||
|
||||
Spec: `docs/superpowers/specs/2026-10-04-aircard-community-design.md` §7, §8, §11 (Karten) und `docs/superpowers/specs/2026-10-04-aircard-logos-sprache-design.md` §6, §8, §9 (Logos). Deployment: [../README.md](../README.md).
|
||||
Spec: `docs/superpowers/specs/2026-10-04-aircard-community-design.md` §7, §8, §11 (Karten) und `docs/superpowers/specs/2026-10-04-aircard-logos-sprache-design.md` §6, §8, §9 (Logos) und `docs/superpowers/specs/2026-10-05-aircard-benutzernamen-design.md` §2–4 (Benutzernamen). Deployment: [../README.md](../README.md).
|
||||
|
||||
## Dateien
|
||||
|
||||
@@ -11,11 +11,12 @@ Spec: `docs/superpowers/specs/2026-10-04-aircard-community-design.md` §7, §8,
|
||||
| `server.js` | Start: liest die Umgebung, `http.createServer`, Zeitlimits, sauberes Beenden |
|
||||
| `app.js` | HTTP-Anbindung (Routing, Body, Anmeldung, Rate-Limit, Log, Fehlerantworten) und die Karten-Endpunkte (`createApp`) |
|
||||
| `logos.js` | die fünf Logo-Endpunkte (`createLogoRoutes`); bekommt Anmeldung, Sperre je Schlüssel, Rate-Limit und Log von `app.js` |
|
||||
| `common.js` | gemeinsame Helfer: `MSG` (alle Fehlertexte, Schlüssel = `code`), `HttpError`, Prüfungen, Rechte-Strings, Melder-Hash, Bildmaße |
|
||||
| `profiles.js` | der Benutzernamen-Endpunkt (`createProfileRoutes`) und `hasProfile`, das `app.js` und `logos.js` vor dem Veröffentlichen und Hochladen fragen; bekommt Anmeldung, Sperre je Schlüssel und Log von `app.js` |
|
||||
| `common.js` | gemeinsame Helfer: `MSG` (alle Fehlertexte, Schlüssel = `code`), `HttpError`, Prüfungen, Rechte-Strings, Melder-Hash, Bildmaße, Namensmuster und Sperrliste der Benutzernamen |
|
||||
| `appwrite.js` | REST-Client mit Server-Key (`createAppwrite`, auch `countRows`) |
|
||||
| `*.test.js`, `fake-appwrite.js` | Tests und Fake-Appwrite im Speicher (gehören nicht auf den Server) |
|
||||
|
||||
Auf den Server (`/home/webklar/apps/aircard-ios.org/api/`) kommen nur die fünf Dateien `appwrite.js`, `common.js`, `app.js`, `logos.js` und `server.js`.
|
||||
Auf den Server (`/home/webklar/apps/aircard-ios.org/api/`) kommen nur die sechs Dateien `appwrite.js`, `common.js`, `app.js`, `logos.js`, `profiles.js` und `server.js`.
|
||||
|
||||
## Umgebung
|
||||
|
||||
@@ -35,15 +36,16 @@ Alle Antworten sind JSON; Fehler haben die Form `{"error": "<deutscher Text>", "
|
||||
|
||||
| Endpunkt | Auth | Antworten |
|
||||
|---|---|---|
|
||||
| `POST /api/veroeffentlichen` `{title, issuer?, tags?, displayName?, imageFileId, previewFileId, sourceCardId}` | Nutzer-JWT | `201 {id}` · `401` · `400` Validierung, Datei fehlt („Bild nicht gefunden.“), falscher Typ oder > 5 MiB, falsche Bildmaße („Das Bild hat nicht das Kartenformat.“) · `403` Datei nicht meine / falscher Bucket („Diese Datei gehört dir nicht.“), Datei schon veröffentlicht oder gesperrt („Diese Bilder sind bereits veröffentlicht oder gesperrt.“), Karte nicht meine („Diese Karte gehört nicht zu deinem Konto.“) · `409` „Diese Karte ist schon veröffentlicht.“ · `429` „Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.“ |
|
||||
| `POST /api/veroeffentlichen` `{title, issuer?, tags?, imageFileId, previewFileId, sourceCardId}` | Nutzer-JWT | `201 {id}` · `401` · `403` Konto ohne Benutzernamen („Bitte wähle zuerst einen Benutzernamen.”, vor allen anderen Prüfungen) · `400` Validierung, Datei fehlt („Bild nicht gefunden.”), falscher Typ oder > 5 MiB, falsche Bildmaße („Das Bild hat nicht das Kartenformat.”) · `403` Datei nicht meine / falscher Bucket („Diese Datei gehört dir nicht.”), Datei schon veröffentlicht oder gesperrt („Diese Bilder sind bereits veröffentlicht oder gesperrt.”), Karte nicht meine („Diese Karte gehört nicht zu deinem Konto.”) · `409` „Diese Karte ist schon veröffentlicht.” · `429` „Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.” |
|
||||
| `POST /api/zurueckziehen` `{id}` | Nutzer-JWT (Autor oder Admin) | `200 {ok:true}` · `401` · `400` · `403` fremd · `404` fehlt |
|
||||
| `POST /api/melden` `{id, reason}` | keine | `200 {ok:true}` oder `200 {schonGemeldet:true}` · `400` Grund oder ID · `404` fehlt oder ausgeblendet · `429` „Zu viele Meldungen – bitte später nochmal.“ (mit `Retry-After`) |
|
||||
| `POST /api/admin/sichtbarkeit` `{id, sichtbar}` | Admin-JWT | `200 {ok:true}` · `401` · `403` kein Admin · `400` · `404` |
|
||||
| `POST /api/logos/hochladen` `{name, displayName?, mono?, fileId}` | Nutzer-JWT | `201 {id}` (= Datei-ID) · `401` · `400` Validierung, Datei fehlt („Bild nicht gefunden.“), kein PNG („Das Logo muss eine PNG-Datei sein.“), größer als 1 MiB („Das Logo ist größer als 1 MB.“), Maße außerhalb 8 × 8 bis 720 × 250 („Das Logo hat ungültige Maße (8 × 8 bis 720 × 250 Pixel).“) · `403` Datei nicht meine / falscher Bucket („Diese Datei gehört dir nicht.“), Datei gesperrt („Diese Bilder sind bereits veröffentlicht oder gesperrt.“) · `409` „Diese Datei wird schon für ein Logo verwendet.“ · `429` „Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.“ |
|
||||
| `POST /api/logos/hochladen` `{name, mono?, fileId}` | Nutzer-JWT | `201 {id}` (Zeilen-ID des Logos, ein Hash aus Konto und Datei, nicht die Datei-ID) · `401` · `403` Konto ohne Benutzernamen (wie beim Veröffentlichen) · `400` Validierung, Datei fehlt („Bild nicht gefunden.”), kein PNG („Das Logo muss eine PNG-Datei sein.”), größer als 1 MiB („Das Logo ist größer als 1 MB.”), Maße außerhalb 8 × 8 bis 720 × 250 („Das Logo hat ungültige Maße (8 × 8 bis 720 × 250 Pixel).”) · `403` Datei nicht meine / falscher Bucket („Diese Datei gehört dir nicht.”), Datei gesperrt („Diese Bilder sind bereits veröffentlicht oder gesperrt.”) · `409` „Diese Datei wird schon für ein Logo verwendet.” · `429` „Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.” |
|
||||
| `POST /api/logos/herz` `{id, herz}` | Nutzer-JWT | `200 {herz, likeCount}` · `401` · `400` ID ungültig oder `herz` kein Boolean · `404` fehlt oder ausgeblendet („Dieses Logo ist nicht (mehr) verfügbar.“) |
|
||||
| `POST /api/logos/melden` `{id, reason}` | keine | `200 {ok:true}` oder `200 {schonGemeldet:true}` · `400` Grund oder ID · `404` fehlt oder ausgeblendet · `429` „Zu viele Meldungen – bitte später nochmal.“ (mit `Retry-After`; dasselbe Limit wie `/api/melden`) |
|
||||
| `POST /api/logos/loeschen` `{id}` | Nutzer-JWT (Hochlader oder Admin) | `200 {ok:true}` · `401` · `400` · `403` fremd · `404` fehlt |
|
||||
| `POST /api/admin/logo-sichtbarkeit` `{id, sichtbar}` | Admin-JWT | `200 {ok:true}` · `401` · `403` kein Admin · `400` · `404` |
|
||||
| `POST /api/profil` `{username}` | Nutzer-JWT | `200 {username}` (Name angelegt oder geändert) · `401` · `400` Muster („Der Benutzername muss 3–20 Zeichen lang sein, …“) oder gesperrter Name („Dieser Benutzername ist nicht erlaubt.“) · `409` „Dieser Benutzername ist schon vergeben.“ |
|
||||
| `GET /api/gesundheit` | keine | `200 {ok:true}` |
|
||||
|
||||
Ist Appwrite nicht erreichbar, überlastet (429) oder antwortet mit einem Serverfehler (5xx), antwortet der Dienst `502` „Der Dienst ist gerade nicht erreichbar.“ (`code` `unavailable`). Jede andere unerwartete Appwrite-Antwort (falscher Key, fehlende Tabelle oder Datenbank, fehlender Bucket, unbekannte Route, ungültige Antwort) und jeder unerwartete Fehler ist `500` „Interner Fehler.“ (`code` `internal`). Details stehen nur im Log, ohne Secrets. Ein 404 von Appwrite gilt nur mit dem Typ `row_not_found` bzw. `storage_file_not_found` als „fehlt“; ein fehlendes Konfigurationsobjekt wird so nie zu „Karte nicht verfügbar“ oder „Logo nicht verfügbar“.
|
||||
@@ -54,11 +56,11 @@ Jede Fehlerantwort trägt neben dem deutschen Text einen `code`: den Schlüssel
|
||||
|
||||
| Status | `code` · Text |
|
||||
|---|---|
|
||||
| `400` | `badRequest` „Ungültige Anfrage.“ (auch jeder dynamische Prüftext) · `badCardId` „Ungültige Karten-ID.“ · `badLogoId` „Ungültige Logo-ID.“ · `badReason` „Bitte wähle einen gültigen Grund.“ · `badVisibility` „Ungültige Angabe zur Sichtbarkeit.“ · `badHeart` „Ungültige Angabe zum Herz.“ · `badMono` „Ungültige Angabe zu „einfarbig“.“ · `logoNameMissing` „Bitte gib einen Namen an.“ · `fileMissing` „Bild nicht gefunden.“ · `fileType` „Das Bild muss eine PNG- oder WebP-Datei sein.“ · `fileSize` „Das Bild ist größer als 5 MB.“ · `imageFormat` „Das Bild hat nicht das Kartenformat.“ · `logoFileType` „Das Logo muss eine PNG-Datei sein.“ · `logoFileSize` „Das Logo ist größer als 1 MB.“ · `logoFormat` „Das Logo hat ungültige Maße (8 × 8 bis 720 × 250 Pixel).“ |
|
||||
| `400` | `badRequest` „Ungültige Anfrage.“ (auch jeder dynamische Prüftext) · `badCardId` „Ungültige Karten-ID.“ · `badLogoId` „Ungültige Logo-ID.“ · `badReason` „Bitte wähle einen gültigen Grund.“ · `badVisibility` „Ungültige Angabe zur Sichtbarkeit.“ · `badHeart` „Ungültige Angabe zum Herz.“ · `badMono` „Ungültige Angabe zu „einfarbig“.“ · `logoNameMissing` „Bitte gib einen Namen an.“ · `fileMissing` „Bild nicht gefunden.“ · `fileType` „Das Bild muss eine PNG- oder WebP-Datei sein.“ · `fileSize` „Das Bild ist größer als 5 MB.“ · `imageFormat` „Das Bild hat nicht das Kartenformat.“ · `logoFileType` „Das Logo muss eine PNG-Datei sein.“ · `logoFileSize` „Das Logo ist größer als 1 MB.“ · `logoFormat` „Das Logo hat ungültige Maße (8 × 8 bis 720 × 250 Pixel).“ · `usernameInvalid` „Der Benutzername muss 3–20 Zeichen lang sein, …“ · `usernameReserved` „Dieser Benutzername ist nicht erlaubt.“ |
|
||||
| `401` | `unauthorized` „Bitte melde dich an.“ |
|
||||
| `403` | `forbidden` „Dazu hast du keine Berechtigung.“ · `adminOnly` „Nur für Admins.“ · `foreignFile` „Diese Datei gehört dir nicht.“ · `filesTaken` „Diese Bilder sind bereits veröffentlicht oder gesperrt.“ · `notYourCard` „Diese Karte gehört nicht zu deinem Konto.“ |
|
||||
| `403` | `forbidden` „Dazu hast du keine Berechtigung.“ · `adminOnly` „Nur für Admins.“ · `foreignFile` „Diese Datei gehört dir nicht.“ · `filesTaken` „Diese Bilder sind bereits veröffentlicht oder gesperrt.“ · `notYourCard` „Diese Karte gehört nicht zu deinem Konto.“ · `usernameRequired` „Bitte wähle zuerst einen Benutzernamen.“ |
|
||||
| `404` | `notFound` „Nicht gefunden.“ · `cardGone` „Diese Karte ist nicht (mehr) verfügbar.“ · `logoGone` „Dieses Logo ist nicht (mehr) verfügbar.“ |
|
||||
| `409` | `alreadyPublished` „Diese Karte ist schon veröffentlicht.“ · `logoTaken` „Diese Datei wird schon für ein Logo verwendet.“ |
|
||||
| `409` | `alreadyPublished` „Diese Karte ist schon veröffentlicht.“ · `logoTaken` „Diese Datei wird schon für ein Logo verwendet.“ · `usernameTaken` „Dieser Benutzername ist schon vergeben.“ |
|
||||
| `413` `415` | `tooLarge` „Die Anfrage ist zu groß.“ · `jsonOnly` „Nur application/json wird unterstützt.“ |
|
||||
| `429` | `tooManyPublications` „Du hast schon 20 Karten veröffentlicht. …“ · `tooManyLogos` „Du hast schon 30 Logos hochgeladen. …“ · `tooManyReports` „Zu viele Meldungen – bitte später nochmal.“ |
|
||||
| `500` `502` | `internal` „Interner Fehler.“ · `unavailable` „Der Dienst ist gerade nicht erreichbar.“ |
|
||||
@@ -66,21 +68,22 @@ Jede Fehlerantwort trägt neben dem deutschen Text einen `code`: den Schlüssel
|
||||
### Regeln im Einzelnen
|
||||
|
||||
- **IDs**: Jede ID im Body (`id`, `imageFileId`, `previewFileId`, `sourceCardId`) muss `^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$` erfüllen (nur Zeichenketten), sonst `400`. `appwrite.js` kodiert zusätzlich jedes Pfadsegment und lehnt `.` und `..` ab.
|
||||
- **Felder**: Titel 1–60, Herausgeber ≤ 40, Anzeigename ≤ 30 Zeichen (getrimmt). Nicht erlaubt in Titel, Herausgeber, Anzeigename und Tags: Steuerzeichen (U+0000–001F, U+007F–009F), Nullbreiten- und Bidi-Zeichen (U+200B–200F, U+202A–202E, U+2060–206F) und das BOM (U+FEFF); sie machen Titel unsichtbar oder ordnen Text um. Dasselbe Muster prüft der Veröffentlichen-Dialog. `tags` ist ein `string[]`: getrimmt, klein, Leere und Doppelte entfernt, höchstens 5 mit je höchstens 20 Zeichen, kein Komma. Gespeichert wird die Spalte `tags` als `,tag1,tag2,` (Komma an beiden Enden, leer bei keinen Tags): der Tag-Filter der Website sucht `,<tag>,` und trifft so nur ganze Tags. Bild und Vorschau müssen verschiedene Dateien sein.
|
||||
- **Felder**: Titel 1–60, Herausgeber ≤ 40 Zeichen (getrimmt). Nicht erlaubt in Titel, Herausgeber und Tags: Steuerzeichen (U+0000–001F, U+007F–009F), Nullbreiten- und Bidi-Zeichen (U+200B–200F, U+202A–202E, U+2060–206F) und das BOM (U+FEFF); sie machen Titel unsichtbar oder ordnen Text um. Dasselbe Muster prüft der Veröffentlichen-Dialog. `tags` ist ein `string[]`: getrimmt, klein, Leere und Doppelte entfernt, höchstens 5 mit je höchstens 20 Zeichen, kein Komma. Gespeichert wird die Spalte `tags` als `,tag1,tag2,` (Komma an beiden Enden, leer bei keinen Tags): der Tag-Filter der Website sucht `,<tag>,` und trifft so nur ganze Tags. Bild und Vorschau müssen verschiedene Dateien sein.
|
||||
- **Quellkarte**: `sourceCardId` muss eine Zeile der Tabelle `cards` sein, deren `$permissions` `read("user:<ich>")` enthalten, sonst `403` „Diese Karte gehört nicht zu deinem Konto.“ (auch wenn es die Zeile nicht gibt).
|
||||
- **Limit**: höchstens 20 Veröffentlichungen je Konto (Zeilen mit `ownerId` = ich, auch ausgeblendete); ab da `429` mit dem Text aus der Tabelle. Zurückziehen gibt einen Platz frei. Das Limit liest die Zeilen des Autors über `ownerId` (dort gibt es einen Index) mit `limit(20)`.
|
||||
- **Dateien** (beide): liegen im Bucket `gallery-images`, tragen `delete("user:<ich>")`, sind `image/png` oder `image/webp` und höchstens 5 MiB. Eine Datei, die es nicht gibt, ergibt `400`; eine fremde `403` (Typ und Größe fremder Dateien nennt die API nicht). **Bildmaße**: Die API liest die ersten 64 Bytes beider Dateien (`GET …/files/<id>/view` mit Server-Key, der Rest der Übertragung wird abgebrochen) und liest PNG-IHDR bzw. WebP (`VP8 `, `VP8L`, `VP8X`) aus. Das Bild muss genau 1536 × 969 sein, die Vorschau 480 breit und 302–304 hoch (die Karte ist 1536 × 969, die Vorschau 480 × 303), sonst `400` „Das Bild hat nicht das Kartenformat.“ Das ist Defense in Depth gegen Dekompressionsbomben; die maßgebliche Prüfung beim Anzeigen macht der Client (Pixelgrenze 4 Megapixel). **Keine Wiederverwendung**: Beide Dateien müssen `read("any")` tragen (frisch hochgeladen; ausgeblendete Dateien nie) und dürfen von keiner Galerie-Zeile des Autors schon als Bild oder Vorschau verwendet werden (auch überkreuz, auch von ausgeblendeten Karten), sonst `403` „Diese Bilder sind bereits veröffentlicht oder gesperrt.“ So lassen sich die Dateien einer gemeldeten oder bereits veröffentlichten Karte nicht unter einer anderen `sourceCardId` neu einstellen. Die Prüfung läuft zusammen mit Limit, Quellkarte und Anlegen je Autor nacheinander. Nach dem Anlegen der Zeile setzt die API beide Dateien auf genau `read("any")` (**kein `delete("user:<ich>")` mehr**, kanonischer Zustand, auch wenn der Client mehr Rechte vergeben hat); scheitert das, wird die Zeile zurückgenommen und schon festgelegte Dateien bekommen `delete("user:<ich>")` zurück, damit der Client den Fehlversuch aufräumen kann (scheitert schon das Zurücknehmen, steht `rollback failed for card <id>` im Log). Ein 409 fasst die Dateirechte nie an. Bei Appwrite 1.8 liefert eine Datei aus einem anderen Bucket schon beim Abruf 404, das erscheint dann ebenfalls als `400` „Bild nicht gefunden.“
|
||||
- **Logo-Felder** (`/api/logos/hochladen`): `name` 1–40 und `displayName` ≤ 30 Zeichen (getrimmt; leerer Anzeigename = „Anonym“; dieselben verbotenen Zeichen wie bei den Karten-Texten), `mono` fehlt = `false`, sonst muss es ein Boolean sein (`badMono`), `fileId` nach dem ID-Muster. Die Zeilen-ID des Logos ist die Datei-ID: so kann eine Datei nie zwei Logos tragen.
|
||||
- **Logo-Datei und Reihenfolge**: Die Datei liegt im Bucket `logo-images`, trägt `delete("user:<ich>")` und `read("any")`, ist `image/png`, höchstens 1 MiB (1048576 Byte) und hat einen PNG-Kopf mit 8 × 8 bis 720 × 250 Pixeln (die API liest nur die ersten 64 Bytes). Die Prüfungen laufen in dieser Reihenfolge, damit fremde Dateien nichts verraten: JWT (`401`) → Body (`400`) → Datei da und im richtigen Bucket (`400`/`403`) → je Konto nacheinander (Sperre `logo-owner:<id>`): schon 30 Logos (`429`, ausgeblendete zählen mit) → diese Datei ist schon ein Logo des Kontos (`409`) → Besitz (`403`: kein `delete("user:<ich>")` oder kein `read("any")`) → Typ und Größe (`400`) → Maße (`400`) → Zeile anlegen (`409`, wenn die ID schon vergeben ist) → Datei auf genau `read("any")` setzen (**kein `delete("user:<ich>")` mehr**, sonst ließe sich das Bild unter derselben ID tauschen). Scheitert das Festlegen, wird die Zeile zurückgenommen und nur dann bekommt die Datei `delete("user:<ich>")` zurück (scheitert schon das Zurücknehmen, steht `rollback failed for logo <id>` im Log). **Zweite Prüfung**: Danach liest die API den Dateianfang noch einmal und prüft PNG-Kopf und Maße erneut. Jetzt kann der Hochlader die Datei nicht mehr ändern; so lässt sich der Inhalt nicht zwischen der ersten Prüfung und dem Festlegen unter derselben ID austauschen (Löschen und Neuanlegen der Datei). Fällt die Datei jetzt durch oder fehlt sie, löscht die API die Datei mit dem Server-Key und die Zeile (beides wird versucht, auch wenn das erste scheitert) und antwortet `400` `logoFormat` bzw. `fileMissing`; Log: `logo <id> removed after upload check (<code>)`, bleibt etwas stehen `rollback failed for logo <id>`. Ein Appwrite-Fehler beim zweiten Lesen wird wie ein Fehler beim Festlegen behandelt (`502`, Zeile zurückgenommen, die Datei bekommt `delete("user:<ich>")` zurück). Fremde, schon veröffentlichte oder gesperrte Dateien ergeben `403`, ohne dass die API ihren Inhalt liest.
|
||||
- **Herzen** (`/api/logos/herz`): ein Herz je Konto und Logo, Zeilen-ID `sha256(<logoId>|<userId>)` (erste 32 Hex), Recht `read("user:<id>")`. `herz: true` legt die Zeile an (`409` = schon da, kein Fehler), `false` löscht sie (schon weg = kein Fehler). Danach ist `likeCount` immer `countRows` aller Herzen des Logos (neu gezählt, nie hoch- oder runtergezählt) und wird nur geschrieben, wenn er abweicht. Fehlendes oder ausgeblendetes Logo: `404`, auch beim Entfernen. Pro Logo läuft immer nur eine Änderung gleichzeitig (Sperre `logo:<id>`). Herzen werden nicht geloggt.
|
||||
- **Logo-Felder** (`/api/logos/hochladen`): `name` 1–40 Zeichen (getrimmt; dieselben verbotenen Zeichen wie bei den Karten-Texten), `mono` fehlt = `false`, sonst muss es ein Boolean sein (`badMono`), `fileId` nach dem ID-Muster. **Zeilen-ID des Logos** = `sha256(<ownerId>|<fileId>)` (erste 32 Hex), **nicht** die Datei-ID; die Antwort `201 {id}` trägt diese ID. Dieselbe Datei desselben Kontos ergibt dieselbe ID (`409`). Die ID eines gelöschten Logos steht öffentlich in der Liste und in gespeicherten Karten; wäre sie die Datei-ID, könnte jeder eine Datei mit genau dieser ID anlegen, als Logo hochladen und so die alte ID neu belegen (gespeicherte Karten anderer zeigten dann sein Bild). Mit dem Konto im Hash geht das nicht. Dass eine Datei nie zwei Logos trägt, sichert der Besitz: nach dem Anlegen hat die Datei kein `delete("user:<ich>")` mehr, ein anderes Konto bekommt sie nicht (`403`). **Altlogos** (vor dieser Regel angelegt) haben noch Zeilen-ID = Datei-ID: Herz, Melden, Löschen und Sichtbarkeit adressieren die Zeile nur über ihre ID und die Datei über das Feld `fileId`, darum gehen beide Arten; dieselbe Datei eines Altlogos noch einmal hochzuladen ist ebenfalls `409`.
|
||||
- **Logo-Datei und Reihenfolge**: Die Datei liegt im Bucket `logo-images`, trägt `delete("user:<ich>")` und `read("any")`, ist `image/png`, höchstens 1 MiB (1048576 Byte) und hat einen PNG-Kopf mit 8 × 8 bis 720 × 250 Pixeln (die API liest nur die ersten 64 Bytes). Die Prüfungen laufen in dieser Reihenfolge, damit fremde Dateien nichts verraten: JWT (`401`) → Benutzername (`403` `usernameRequired`) → Body (`400`) → Datei da und im richtigen Bucket (`400`/`403`) → je Konto nacheinander (Sperre `logo-owner:<id>`): schon 30 Logos (`429`, ausgeblendete zählen mit) → diese Datei ist schon ein Logo des Kontos (`409`: Zeile mit dieser Zeilen-ID oder mit dieser `fileId`, also auch ein Altlogo) → Besitz (`403`: kein `delete("user:<ich>")` oder kein `read("any")`) → Typ und Größe (`400`) → Maße (`400`) → ab hier zusätzlich die Sperre des Logos `logo:<Zeilen-ID>` (immer erst Konto, dann Logo) bis zur zweiten Prüfung unten: Zeile anlegen (`409`, wenn die ID schon vergeben ist) → Datei auf genau `read("any")` setzen (**kein `delete("user:<ich>")` mehr**, sonst ließe sich das Bild unter derselben ID tauschen). Scheitert das Festlegen, wird die Zeile zurückgenommen und nur dann bekommt die Datei `delete("user:<ich>")` zurück (scheitert schon das Zurücknehmen, steht `rollback failed for logo <id> (file <Datei-ID>)` im Log; scheitert danach die Rückgabe von `delete("user:<ich>")`, bleibt die Datei ohne Zeile und ohne dieses Recht liegen und `restore failed for logo <id> (file <Datei-ID>)` steht im Log, die Antwort bleibt in beiden Fällen der ursprüngliche Fehler). **Zweite Prüfung**: Danach liest die API den Dateianfang noch einmal und prüft PNG-Kopf und Maße erneut. Jetzt kann der Hochlader die Datei nicht mehr ändern; so lässt sich der Inhalt nicht zwischen der ersten Prüfung und dem Festlegen unter derselben ID austauschen (Löschen und Neuanlegen der Datei). Fällt die Datei jetzt durch oder fehlt sie, löscht die API die Datei mit dem Server-Key und die Zeile (beides wird versucht, auch wenn das erste scheitert) und antwortet `400` `logoFormat` bzw. `fileMissing`; Log: `logo <id> removed after upload check (<code>)`, bleibt etwas stehen `rollback failed for logo <id> (file <Datei-ID>)`. Ein Appwrite-Fehler beim zweiten Lesen wird wie ein Fehler beim Festlegen behandelt (`502`, Zeile zurückgenommen, die Datei bekommt `delete("user:<ich>")` zurück). Fremde, schon veröffentlichte oder gesperrte Dateien ergeben `403`, ohne dass die API ihren Inhalt liest. **Sperre des Logos beim Hochladen**: Die Zeile steht ab dem Anlegen öffentlich in der Liste; ein Herz, eine Meldung oder ein Löschen, das bis zum Ende der zweiten Prüfung eintrifft, wartet auf die Sperre und sieht danach den Endzustand (das fertige Logo oder `404`, wenn die Prüfung es entfernt hat). So bleibt keine Herz- oder Meldungszeile ohne Logo zurück.
|
||||
- **Herzen** (`/api/logos/herz`): ein Herz je Konto und Logo, Zeilen-ID `sha256(<logoId>|<userId>)` (erste 32 Hex), Recht `read("user:<id>")`. `herz: true` legt die Zeile an (`409` = schon da, kein Fehler), `false` löscht sie (schon weg = kein Fehler). Danach ist `likeCount` immer `countRows` aller Herzen des Logos (neu gezählt, nie hoch- oder runtergezählt) und wird nur geschrieben, wenn er abweicht. Fehlendes oder ausgeblendetes Logo: `404`, auch beim Entfernen. Pro Logo läuft immer nur eine Änderung gleichzeitig (Sperre `logo:<id>`, auch gegen ein gerade laufendes Hochladen dieses Logos). Herzen werden nicht geloggt.
|
||||
- **Logo melden** (`/api/logos/melden`): wie `/api/melden`, mit denselben Gründen, demselben Melder (Netz, gehasht) und **demselben Rate-Limit** (ein gemeinsamer Zähler je Anschluss für Karten und Logos: 6 Karten- und 5 Logo-Meldungen vom selben Anschluss ergeben bei der 11. ein `429`). Die Zeilen-ID der Meldung ist `sha256(logo|<logoId>|<reporterHash>)`, `reportCount` ist `countRows` der Meldungen des Logos, ab 3 wird ausgeblendet (Datei und Zeile auf `read("user:<owner>")` und `read("label:admin")`).
|
||||
- **Logo löschen und Sichtbarkeit**: Löschen (Hochlader oder Admin) entfernt Datei, Herzen, Meldungen und zuletzt die Zeile; Herzen und Meldungen in Runden zu 100 (`equal logoId`, höchstens 50 Runden). Die Admin-Sichtbarkeit verhält sich wie bei den Karten: Ausblenden setzt Datei und Zeile auf die ausgeblendeten Rechte (`hidden: true`), Einblenden setzt sie auf `read("any")`, löscht die Meldungen und setzt `reportCount` auf 0 (die Herzen bleiben).
|
||||
- **Logo löschen und Sichtbarkeit**: Löschen (Hochlader oder Admin) blendet das Logo zuerst aus (Datei, dann Zeile auf die ausgeblendeten Rechte mit `hidden: true`; ein schon ausgeblendetes Logo überspringt das) und entfernt dann Datei, Herzen, Meldungen und zuletzt die Zeile; Herzen und Meldungen in Runden zu 100 (`equal logoId`, höchstens 50 Runden). Das Ausblenden zuerst verhindert, dass nach einem Abbruch (502) eine öffentliche Zeile mit gelöschter Datei stehen bleibt: unter deren Datei-ID könnte jedes Konto eine neue Datei anlegen, und alle gespeicherten Karten zeigten sie. Es bleibt höchstens eine unsichtbare Zeile (nur Hochlader und Admins lesen sie); die Wiederholung vollendet das Löschen. Die Admin-Sichtbarkeit verhält sich wie bei den Karten: Ausblenden setzt Datei und Zeile auf die ausgeblendeten Rechte (`hidden: true`), Einblenden setzt sie auf `read("any")`, löscht die Meldungen und setzt `reportCount` auf 0 (die Herzen bleiben).
|
||||
- **Doppelt veröffentlichen**: Die Zeilen-ID der Galerie ist `sha256(ownerId|sourceCardId)` (erste 32 Hex). Eine zweite Veröffentlichung derselben Karte scheitert deshalb atomar an Appwrite (409), auch bei gleichzeitigen Anfragen. Nach dem Zurückziehen ist die Karte wieder frei.
|
||||
- **Melden**: Rate-Limit 10 pro Stunde und Anschluss (Speicher, geht bei Neustart verloren; abgelaufene Einträge werden entfernt, höchstens 50 000 Einträge; Schlüssel: gehashte IPv4, IPv6 auf /64 gekürzt, IPv4-gemappte IPv6 als IPv4). Der Melder-Hash ist `HMAC-SHA256(salt, netz)` mit dem **Netz** der Adresse (IPv4 → `/24`, IPv6 → `/48`, IPv4-gemappte IPv6 wie IPv4): drei Adressen aus einem Netz sind ein Melder und blenden eine Karte nicht aus (sonst genügten drei VPN-/Mobilfunk-Adressen). Die Zeilen-ID der Meldung ist `sha256(galleryId|reporterHash)`, eine zweite Meldung desselben Melders ergibt `{schonGemeldet:true}`. `reportCount` = Anzahl der Meldungszeilen der Karte; ab 3 wird ausgeblendet. Auch eine doppelte Meldung zählt neu und heilt so einen früher steckengebliebenen Zählerstand. Pro Karte läuft immer nur eine Änderung gleichzeitig.
|
||||
- **Client-IP**: `X-Real-Ip` (setzt Traefik), sonst erster Eintrag von `X-Forwarded-For`, sonst die Socket-Adresse. Der Dienst veröffentlicht nie `ports:`, ist aber im Docker-Netz `webklar-network` für andere Container erreichbar, und dort gilt `X-Real-Ip` als vertrauenswürdig: ein Container im selben Netz könnte beliebige Melder-Adressen vortäuschen. Akzeptiertes Risiko (kein Code dagegen); eine Abhilfe wäre eine Traefik-Middleware mit geheimem Header, den `clientIp` prüft, oder ein eigenes Netz nur für Traefik und `aircard-api`. Vor einem AAAA-Eintrag für aircard-ios.org die Client-IP-Ermittlung prüfen.
|
||||
- **Rechte** (genau diese Strings; Abweichung von Spec §7, siehe unten): sichtbar Zeile `read("any")`; ausgeblendet Zeile `read("user:<owner>")`, `read("label:admin")`; Datei hochgeladen (noch nicht veröffentlicht, vom Client gesetzt) `read("any")`, `delete("user:<owner>")`; Datei veröffentlicht `read("any")`; Datei ausgeblendet `read("user:<owner>")`, `read("label:admin")`. Veröffentlichte und ausgeblendete Dateien tragen **kein** `delete(owner)`: sonst könnte der Autor eine Datei nach dem Veröffentlichen löschen und unter derselben ID mit anderem Inhalt neu anlegen (Bild-Tausch). Zurückziehen und Admin-Aktionen löschen serverseitig mit dem Key. Meldungszeilen haben keine eigenen Rechte (Tabelle: nur `read("label:admin")`). Für Logos gilt dasselbe (Zeile und Datei sichtbar `read("any")`, ausgeblendet `read("user:<owner>")` und `read("label:admin")`, hochgeladen `read("any")` und `delete("user:<owner>")`); Herzen tragen `read("user:<id>")` des Kontos.
|
||||
- **Reihenfolge bei Mehrschritt-Aktionen** (jede Aktion ist wiederholbar): Ausblenden/Einblenden ändert zuerst die Dateirechte, beim Einblenden danach die Meldungen löschen, die Zeile zuletzt. Zurückziehen löscht Dateien, Meldungen, dann die Zeile; Logo löschen ebenso (Datei, Herzen, Meldungen, Zeile). Bleibt etwas hängen (502), heilt der nächste Versuch.
|
||||
- **Logs**: Zeilen beginnen mit `[aircard-api]`. Es gibt kein Zugriffsprotokoll je Anfrage (`/api/melden` ist ohne Anmeldung erreichbar, ein Angreifer würde sonst das Log-Volumen bestimmen): geloggt werden nur Ereignisse (veröffentlicht bzw. Logo hochgeladen oder nach der zweiten Prüfung entfernt, gemeldet, ausgeblendet, zurückgezogen bzw. Logo gelöscht, Admin-Aktion, fehlgeschlagenes Zurücknehmen; keine Herzen) und Serverfehler (5xx), zusammen höchstens 120 Zeilen pro Minute (der Rest wird gezählt und als Sammelzeile „N log lines suppressed“ nachgetragen). Die Zeilen nennen Route, Status, Karten- bzw. Logo-ID und Fehlertypen, nie Key, Salz, JWT, IPs oder Fehlertexte von Appwrite. Zusätzlich deckelt Compose die Logdatei (`logging: json-file`, 5 MB, 2 Dateien).
|
||||
- **Rechte** (genau diese Strings; Abweichung von Spec §7, siehe unten): sichtbar Zeile `read("any")`; ausgeblendet Zeile `read("user:<owner>")`, `read("label:admin")`; Datei hochgeladen (noch nicht veröffentlicht, vom Client gesetzt) `read("any")`, `delete("user:<owner>")`; Datei veröffentlicht `read("any")`; Datei ausgeblendet `read("user:<owner>")`, `read("label:admin")`. Veröffentlichte und ausgeblendete Dateien tragen **kein** `delete(owner)`: sonst könnte der Autor eine Datei nach dem Veröffentlichen löschen und unter derselben ID mit anderem Inhalt neu anlegen (Bild-Tausch). Zurückziehen und Admin-Aktionen löschen serverseitig mit dem Key. Meldungszeilen haben keine eigenen Rechte (Tabelle: nur `read("label:admin")`). Für Logos gilt dasselbe (Zeile und Datei sichtbar `read("any")`, ausgeblendet `read("user:<owner>")` und `read("label:admin")`, hochgeladen `read("any")` und `delete("user:<owner>")`); Herzen tragen `read("user:<id>")` des Kontos; Profile (Benutzernamen) tragen genau `read("any")`.
|
||||
- **Benutzernamen** (`/api/profil`, `profiles.js`): `username` muss `^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$` erfüllen (3–20 Zeichen, nur ASCII, kein Trimmen: Leerzeichen sind ungültig), sonst `400` `usernameInvalid` (auch wenn es keine Zeichenkette ist). Eindeutig ist der **Schlüssel** `usernameKey` = der Name klein geschrieben („Kenso“ und „kenso“ sind derselbe Name); gespeichert und angezeigt wird der Name, wie er eingegeben wurde. **Gesperrte Schlüssel** (`400` `usernameReserved`, in jeder Schreibweise): `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`. Die Zeile steht in der Tabelle `profiles` (Spalten `username`, `usernameKey`, je höchstens 20 Zeichen): Zeilen-ID = Konto-ID (ein Name je Konto, das Konto kommt aus dem JWT, nie aus dem Body), Recht genau `read("any")` (öffentlich lesbar; der Client liest die Namen direkt), Tabellenrechte keine. Ablauf je Konto nacheinander (Sperre `profile:<Konto-ID>`): Gehört der Schlüssel einem **anderen** Konto (Abfrage `equal usernameKey`), kommt `409` `usernameTaken`; die eigene Zeile zählt nicht, die Schreibweise des eigenen Namens zu ändern („kenso“ → „Kenso“) oder denselben Namen nochmal zu senden geht also. Sonst wird die Zeile des Kontos geändert (der alte Name ist damit sofort frei) oder, wenn es noch keine gibt, angelegt. **Gleichzeitigkeit**: Die Vorabprüfung ist nur der Normalfall mit klarer Antwort; entscheiden tut der Unique-Index `username_key` auf `usernameKey` (Schema-Skript). Wollen zwei Konten gleichzeitig denselben Namen, meldet Appwrite dem zweiten beim Anlegen bzw. Ändern `409`, und das wird ebenfalls `usernameTaken`: genau ein Konto bekommt den Namen. **Pflicht**: `/api/veroeffentlichen` und `/api/logos/hochladen` antworten `403` `usernameRequired`, wenn das Konto keine Zeile in `profiles` hat (`hasProfile`, ein `GET` der Zeile mit der Konto-ID). Die Prüfung läuft nach der Anmeldung und vor allen anderen Prüfungen (Body, Dateien, Karte), es wird nichts angelegt oder angefasst; bestehende Logos und Karten bleiben unberührt. Fehlt die Tabelle `profiles` (Schema nicht eingespielt), antworten beide Endpunkte `500` „Interner Fehler.“.
|
||||
- **Reihenfolge bei Mehrschritt-Aktionen** (jede Aktion ist wiederholbar): Ausblenden/Einblenden ändert zuerst die Dateirechte, beim Einblenden danach die Meldungen löschen, die Zeile zuletzt. Zurückziehen löscht Dateien, Meldungen, dann die Zeile; Logo löschen blendet zuerst aus und löscht dann Datei, Herzen, Meldungen, Zeile. Bleibt etwas hängen (502), heilt der nächste Versuch.
|
||||
- **Logs**: Zeilen beginnen mit `[aircard-api]`. Es gibt kein Zugriffsprotokoll je Anfrage (`/api/melden` ist ohne Anmeldung erreichbar, ein Angreifer würde sonst das Log-Volumen bestimmen): geloggt werden nur Ereignisse (veröffentlicht bzw. Logo hochgeladen oder nach der zweiten Prüfung entfernt, gemeldet, ausgeblendet, zurückgezogen bzw. Logo gelöscht, Admin-Aktion, fehlgeschlagenes Zurücknehmen oder Wiederherstellen von `delete(owner)`, `profile created` bzw. `profile updated` (Benutzername angelegt bzw. geändert, ohne Namen und Konto-ID); keine Herzen) und Serverfehler (5xx), zusammen höchstens 120 Zeilen pro Minute (der Rest wird gezählt und als Sammelzeile „N log lines suppressed“ nachgetragen). Die Zeilen nennen Route, Status, Karten- bzw. Logo-ID (bei einem Fehler beim Aufräumen eines Logos auch die Datei-ID) und Fehlertypen, nie Key, Salz, JWT, IPs oder Fehlertexte von Appwrite. Zusätzlich deckelt Compose die Logdatei (`logging: json-file`, 5 MB, 2 Dateien).
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -88,4 +91,4 @@ Jede Fehlerantwort trägt neben dem deutschen Text einen `code`: den Schlüssel
|
||||
node --test "deploy/api/*.test.js"
|
||||
```
|
||||
|
||||
Die Tests starten den Dienst auf Port 0 gegen einen Fake-Appwrite im Speicher (`fake-appwrite.js`, mit den Spaltenlängen der echten Tabellen) und greifen nie auf das echte Appwrite zu.
|
||||
Die Tests starten den Dienst auf Port 0 gegen einen Fake-Appwrite im Speicher (`fake-appwrite.js`, mit den Spaltenlängen der echten Tabellen und dem Unique-Index von `profiles`, der eine zweite Zeile mit demselben `usernameKey` mit `409` abweist) und greifen nie auf das echte Appwrite zu.
|
||||
+13
-5
@@ -2,12 +2,13 @@
|
||||
|
||||
/**
|
||||
* Routen und Logik des API-Dienstes `aircard-api` (Node 22, nur Bordmittel). Alle Schreibzugriffe auf die
|
||||
* Community-Galerie und die Community-Logos laufen hier über den Appwrite-Server-Key (siehe appwrite.js).
|
||||
* Gemeinsame Helfer, Fehlertexte und -codes: common.js. Die Logo-Endpunkte stehen in logos.js; dieses Modul gibt ihnen
|
||||
* Anmeldung, Sperre je Schlüssel, das (gemeinsame) Rate-Limit und das Log.
|
||||
* Community-Galerie, die Community-Logos und die Benutzernamen laufen hier über den Appwrite-Server-Key (siehe appwrite.js).
|
||||
* Gemeinsame Helfer, Fehlertexte und -codes: common.js. Die Logo-Endpunkte stehen in logos.js, der Endpunkt der Benutzernamen in
|
||||
* profiles.js; dieses Modul gibt ihnen Anmeldung, Sperre je Schlüssel, das (gemeinsame) Rate-Limit und das Log.
|
||||
*
|
||||
* POST /api/veroeffentlichen Nutzer-JWT {title, issuer?, tags?, displayName?, imageFileId, previewFileId, sourceCardId}
|
||||
* 401 kein/ungültiges JWT · 400 Validierung oder Datei fehlt/falsches Format/falsche Bildmaße · 403 Datei nicht meine /
|
||||
* 401 kein/ungültiges JWT · 403 `usernameRequired` (das Konto hat noch keinen Benutzernamen; vor allen anderen Prüfungen) ·
|
||||
* 400 Validierung oder Datei fehlt/falsches Format/falsche Bildmaße · 403 Datei nicht meine /
|
||||
* falscher Bucket / Datei gesperrt (kein read("any")) oder von einer Karte des Autors schon verwendet / Karte nicht
|
||||
* meine (`cards`) · 409 schon veröffentlicht · 429 schon 20 Veröffentlichungen · 201 {id}
|
||||
* POST /api/zurueckziehen Nutzer-JWT (Autor oder Admin) {id}
|
||||
@@ -18,12 +19,14 @@
|
||||
* 401 · 403 kein Admin · 400 · 404 · 200 {ok:true}
|
||||
* POST /api/logos/hochladen · /api/logos/herz · /api/logos/melden · /api/logos/loeschen · /api/admin/logo-sichtbarkeit
|
||||
* siehe logos.js
|
||||
* POST /api/profil siehe profiles.js
|
||||
* GET /api/gesundheit {ok:true}
|
||||
* alles andere 404; Body höchstens 8 KB (413), nur application/json (415)
|
||||
*
|
||||
* Das Nutzer-JWT kommt im Header X-Aircard-Jwt und wird bei Appwrite (GET /account) geprüft; Admin = Label `admin`.
|
||||
* Der Appwrite-Key braucht die Console-Scopes documents.read/documents.write (= rows.* der API) und files.read/files.write.
|
||||
* Reihenfolge je Anfrage: Content-Type (415) -> Body lesen (413) -> JWT (401) -> JSON und Felder (400) -> Logik.
|
||||
* Reihenfolge je Anfrage: Content-Type (415) -> Body lesen (413) -> JWT (401) -> Benutzername (403, nur Veröffentlichen und
|
||||
* Logo hochladen) -> JSON und Felder (400) -> Logik.
|
||||
* Fehlerantworten sind immer {error: <deutscher Text>, code: <Schlüssel in MSG>} (siehe common.js).
|
||||
* Appwrite-Fehler: 502 (keine Antwort, 429, 5xx; code `unavailable`) bzw. 500 (alle anderen, z. B. falscher Key oder
|
||||
* fehlende Tabelle; code `internal`).
|
||||
@@ -60,6 +63,7 @@ const {
|
||||
checkFileOwnership,
|
||||
} = require('./common.js');
|
||||
const { createLogoRoutes } = require('./logos.js');
|
||||
const { createProfileRoutes, hasProfile } = require('./profiles.js');
|
||||
|
||||
const TABLE_GALLERY = 'gallery';
|
||||
const TABLE_REPORTS = 'reports';
|
||||
@@ -417,6 +421,8 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
|
||||
// ---- Endpunkte
|
||||
async function publish({ req, raw }) {
|
||||
const user = await requireUser(req);
|
||||
// Ohne Benutzernamen geht nichts weiter: vor allen anderen Prüfungen, es wird nichts angelegt oder angefasst.
|
||||
if (!(await hasProfile(appwrite, user.id))) throw new HttpError(403, MSG.usernameRequired);
|
||||
const validation = validatePublish(parseJsonObject(raw));
|
||||
if (!validation.ok) throw new HttpError(400, validation.message);
|
||||
const data = validation.data;
|
||||
@@ -572,6 +578,8 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
|
||||
['POST /api/admin/sichtbarkeit', { name: 'admin/sichtbarkeit', json: true, run: setVisibility }],
|
||||
// Logos (logos.js): bekommen Anmeldung, Sperre je Schlüssel, das gemeinsame Rate-Limit und das Log von hier.
|
||||
...createLogoRoutes({ appwrite, salt, say, withLock, rateLimit, requireUser }),
|
||||
// Benutzernamen (profiles.js): bekommen Anmeldung, Sperre je Schlüssel und das Log von hier.
|
||||
...createProfileRoutes({ appwrite, say, withLock, requireUser }),
|
||||
]);
|
||||
|
||||
async function handle(req, res) {
|
||||
|
||||
@@ -33,9 +33,12 @@ const SALT = 'test-salt-0123456789abcdef0123456789abcdef';
|
||||
const ME = 'user-me';
|
||||
const OTHER = 'user-other';
|
||||
const ADMIN = 'user-admin';
|
||||
/** Konto ohne Benutzernamen (alle anderen haben aus `setup` ein Profil): Veröffentlichen geht erst mit Profil. */
|
||||
const NEWBIE = 'user-new';
|
||||
const JWT_ME = 'eyJhbGciOiJI.payload-me.signature-me';
|
||||
const JWT_OTHER = 'eyJhbGciOiJI.payload-other.signature-other';
|
||||
const JWT_ADMIN = 'eyJhbGciOiJI.payload-admin.signature-admin';
|
||||
const JWT_NEW = 'eyJhbGciOiJI.payload-new.signature-new';
|
||||
const MIB5 = 5 * 1024 * 1024;
|
||||
const TAKEN = 'Diese Bilder sind bereits veröffentlicht oder gesperrt.';
|
||||
const NOT_CARD_FORMAT = 'Das Bild hat nicht das Kartenformat.';
|
||||
@@ -54,6 +57,9 @@ async function setup() {
|
||||
fake.addUser(JWT_ME, { id: ME });
|
||||
fake.addUser(JWT_OTHER, { id: OTHER });
|
||||
fake.addUser(JWT_ADMIN, { id: ADMIN, labels: ['admin'] });
|
||||
fake.addUser(JWT_NEW, { id: NEWBIE });
|
||||
// Veröffentlichen verlangt einen Benutzernamen (Tabelle `profiles`): ME, OTHER und ADMIN haben einen, NEWBIE nicht.
|
||||
for (const id of [ME, OTHER, ADMIN]) fake.addProfile(id);
|
||||
|
||||
const logs = [];
|
||||
const clock = { value: 1_700_000_000_000 };
|
||||
@@ -406,6 +412,50 @@ test('Veröffentlichen ohne oder mit ungültigem JWT: 401 und nichts angelegt',
|
||||
assert.equal(fake.calls.filter((c) => c.path === '/v1/account').length, 1, 'fehlendes oder kaputtes JWT erreicht Appwrite gar nicht erst');
|
||||
});
|
||||
|
||||
test('Veröffentlichen ohne Benutzernamen: 403 usernameRequired noch vor jeder anderen Prüfung (auch vor Body, Dateien und Karte), nichts angelegt', async () => {
|
||||
const { post, fake } = await setup();
|
||||
seedUploads(fake, NEWBIE);
|
||||
const publish = (body) => post('/api/veroeffentlichen', body, JWT_NEW);
|
||||
|
||||
expectError(await publish(publishBody()), 403, 'usernameRequired', 'gültiger Body');
|
||||
expectError(await publish({}), 403, 'usernameRequired', 'ungültiger Body (kein Titel, keine Dateien)');
|
||||
expectError(await publish(publishBody({ title: '' })), 403, 'usernameRequired', 'Titel fehlt');
|
||||
expectError(await publish(publishBody({ imageFileId: 'gibts-nicht' })), 403, 'usernameRequired', 'Datei gibt es nicht');
|
||||
expectError(await publish(publishBody({ sourceCardId: 'fremde-karte' })), 403, 'usernameRequired', 'Karte gibt es nicht');
|
||||
expectError(await publish(null), 403, 'usernameRequired', 'kein Body');
|
||||
|
||||
assert.equal(fake.rows('gallery').length, 0);
|
||||
assert.equal(storageCalls(fake).length, 0, 'keine Datei wird angefasst');
|
||||
assert.deepEqual(fake.file('img1').$permissions, ['read("any")', 'delete("user:user-new")'], 'die Dateien bleiben, wie sie waren');
|
||||
// Appwrite sieht je Versuch nur die Prüfung des JWT und die Suche nach dem Profil (Zeilen-ID = Konto-ID)
|
||||
assert.deepEqual(
|
||||
fake.calls.filter((c) => c.path !== '/v1/account').map((c) => `${c.method} ${c.path}`),
|
||||
Array(6).fill('GET /v1/tablesdb/aircard/tables/profiles/rows/user-new'),
|
||||
);
|
||||
});
|
||||
|
||||
test('Veröffentlichen: das Profil gehört dem Konto – ein Profil der anderen Konten hilft nicht, mit eigenem Profil (über /api/profil) geht es', async () => {
|
||||
const { post, fake } = await setup();
|
||||
seedUploads(fake, NEWBIE);
|
||||
expectError(await post('/api/veroeffentlichen', publishBody(), JWT_NEW), 403, 'usernameRequired');
|
||||
|
||||
const named = await post('/api/profil', { username: 'Neuling' }, JWT_NEW);
|
||||
assert.deepEqual(named.json, { username: 'Neuling' });
|
||||
const res = await post('/api/veroeffentlichen', publishBody(), JWT_NEW);
|
||||
assert.equal(res.status, 201);
|
||||
assert.equal(fake.row('gallery', res.json.id).ownerId, NEWBIE);
|
||||
});
|
||||
|
||||
test('Veröffentlichen: lässt sich das Profil nicht prüfen (Appwrite-Fehler), 502 und nichts wird angelegt; der nächste Versuch gelingt', async () => {
|
||||
const { post, fake } = await setup();
|
||||
seedUploads(fake);
|
||||
fake.failNext((c) => c.method === 'GET' && c.path.endsWith('/tables/profiles/rows/user-me'), { status: 500 });
|
||||
expectError(await post('/api/veroeffentlichen', publishBody(), JWT_ME), 502, 'unavailable');
|
||||
assert.equal(fake.rows('gallery').length, 0);
|
||||
assert.equal(storageCalls(fake).length, 0);
|
||||
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 201);
|
||||
});
|
||||
|
||||
test('Veröffentlichen mit fremder Datei: 403 und nichts angelegt', async () => {
|
||||
const { post, fake } = await setup();
|
||||
fake.addFile({ id: 'img1', owner: OTHER });
|
||||
|
||||
+61
-2
@@ -1,8 +1,9 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Gemeinsame Helfer des API-Dienstes `aircard-api` für die Karten (app.js) und die Logos (logos.js): Fehlertexte und
|
||||
* -codes, Prüfungen, Rechte-Strings, Melder-Hash, Bildmaße. Nur reine Funktionen und Konstanten, kein Zustand.
|
||||
* Gemeinsame Helfer des API-Dienstes `aircard-api` für die Karten (app.js), die Logos (logos.js) und die Benutzernamen
|
||||
* (profiles.js): Fehlertexte und -codes, Prüfungen, Rechte-Strings, Melder-Hash, Bildmaße, Muster und Sperrliste der Benutzernamen.
|
||||
* Nur reine Funktionen und Konstanten, kein Zustand.
|
||||
*
|
||||
* Fehlerantworten sind immer {error: <deutscher Text>, code: <Schlüssel in MSG>}. Texte ohne festen Schlüssel (dynamische
|
||||
* Prüftexte wie „Der Titel ist zu lang (höchstens 60 Zeichen).“) haben bei Status 400 den code `badRequest`, sonst `error`.
|
||||
@@ -23,6 +24,41 @@ const IMAGE_HEAD_BYTES = 64;
|
||||
const REASONS = ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'];
|
||||
|
||||
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/;
|
||||
/** Benutzername: 3-20 Zeichen, das erste ein Buchstabe oder eine Zahl, danach Buchstaben, Zahlen, Punkt, Unterstrich, Bindestrich (nur ASCII). */
|
||||
const USERNAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$/;
|
||||
/**
|
||||
* Gesperrte Benutzernamen als Schlüssel (klein geschrieben, siehe `usernameKey`): Rollen, der Dienst selbst und Platzhalter, die nach
|
||||
* einem Konto aussehen. Jeder Eintrag passt zu USERNAME_PATTERN (sonst wäre er wirkungslos: der Name scheiterte schon als ungültig).
|
||||
*/
|
||||
const RESERVED_USERNAMES = Object.freeze([
|
||||
'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_USERNAME_SET = new Set(RESERVED_USERNAMES);
|
||||
// Steuerzeichen (C0, DEL, C1), Nullbreiten- und Bidi-Zeichen (U+200B-200F, U+202A-202E, U+2060-206F) und das BOM (U+FEFF):
|
||||
// sie sind unsichtbar, ordnen Text um oder lassen Titel leer aussehen. Dasselbe Muster steht in publish-dialog.ts.
|
||||
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/;
|
||||
@@ -63,6 +99,12 @@ const MSG = {
|
||||
tooManyLogos: `Du hast schon ${MAX_LOGOS} Logos hochgeladen. Lösche eins, um ein neues hochzuladen.`,
|
||||
badHeart: 'Ungültige Angabe zum Herz.',
|
||||
badMono: 'Ungültige Angabe zu „einfarbig“.',
|
||||
// Benutzernamen
|
||||
usernameInvalid:
|
||||
'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.',
|
||||
usernameReserved: 'Dieser Benutzername ist nicht erlaubt.',
|
||||
usernameTaken: 'Dieser Benutzername ist schon vergeben.',
|
||||
usernameRequired: 'Bitte wähle zuerst einen Benutzernamen.',
|
||||
tooLarge: 'Die Anfrage ist zu groß.',
|
||||
jsonOnly: 'Nur application/json wird unterstützt.',
|
||||
unavailable: 'Der Dienst ist gerade nicht erreichbar.',
|
||||
@@ -97,6 +139,19 @@ function isId(value) {
|
||||
return typeof value === 'string' && ID_PATTERN.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Schlüssel eines Benutzernamens (Spalte `usernameKey`): der Name klein geschrieben. Über ihn ist ein Name nur einmal
|
||||
* vergeben, egal wie er geschrieben ist („Kenso“ und „kenso“ sind derselbe). Für Zeichenketten, die schon USERNAME_PATTERN erfüllen.
|
||||
*/
|
||||
function usernameKey(name) {
|
||||
return name.toLowerCase();
|
||||
}
|
||||
|
||||
/** Steht der Schlüssel dieses Namens auf der Sperrliste? (In jeder Schreibweise: „Admin“, „AirCard-IOS“.) */
|
||||
function isReservedUsername(name) {
|
||||
return RESERVED_USERNAME_SET.has(usernameKey(name));
|
||||
}
|
||||
|
||||
function assertOwner(owner) {
|
||||
if (!isId(owner)) throw new TypeError('Ungültige Nutzer-ID');
|
||||
return owner;
|
||||
@@ -309,11 +364,15 @@ module.exports = {
|
||||
IMAGE_HEAD_BYTES,
|
||||
REASONS,
|
||||
ID_PATTERN,
|
||||
USERNAME_PATTERN,
|
||||
RESERVED_USERNAMES,
|
||||
CONTROL_CHARACTERS,
|
||||
MSG,
|
||||
codeFor,
|
||||
HttpError,
|
||||
isId,
|
||||
usernameKey,
|
||||
isReservedUsername,
|
||||
isPlainObject,
|
||||
isDuplicate,
|
||||
parseJsonObject,
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
* GET /v1/storage/buckets/{b}/files/{f}/view Dateiinhalt (Bytes aus `content`)
|
||||
*
|
||||
* Die Tabelle `cards` ist nur zum Lesen da (Zeilen legt der Test mit `addCard` an). Es gibt außerdem die Tabellen
|
||||
* `gallery`, `reports`, `logos`, `logo_likes` und `logo_reports` und die Buckets `gallery-images`, `card-images`, `logo-images`.
|
||||
* `gallery`, `reports`, `logos`, `logo_likes`, `logo_reports` und `profiles` und die Buckets `gallery-images`, `card-images`,
|
||||
* `logo-images`. `profiles` hat wie in Appwrite einen Unique-Index auf `usernameKey` (UNIQUE_INDEXES): eine zweite Zeile mit
|
||||
* demselben Schlüssel, angelegt oder per PATCH geändert, antwortet 409 `row_already_exists` (so meldet Appwrite auch eine
|
||||
* Verletzung des Unique-Index); Testhilfe: `addProfile`.
|
||||
*
|
||||
* Abfragen: `queries[]=<json>` mit den Methoden equal, cursorAfter und limit. Die Antwort enthält wie bei Appwrite `total`:
|
||||
* die Zahl aller Treffer der equal-Abfragen, unabhängig von limit und cursorAfter (darauf baut `countRows`).
|
||||
@@ -18,8 +21,8 @@
|
||||
* Test auffallen.
|
||||
* Dateien werden nach ID gesucht und liefern ihren echten `bucketId` zurück (der Bucket in der URL zählt nicht),
|
||||
* so lässt sich der Fall „Datei liegt in einem anderen Bucket“ nachstellen.
|
||||
* Testhilfen: failNext/hangNext/delayNext (Aufruf scheitert, hängt, wird verzögert) und swapContentAfterPermissions
|
||||
* (Dateiinhalt ändert sich nach dem nächsten Rechte-Update).
|
||||
* Testhilfen: failNext/hangNext/delayNext/holdNext (Aufruf scheitert, hängt, wird verzögert, wartet auf die Freigabe des Tests)
|
||||
* und swapContentAfterPermissions (Dateiinhalt ändert sich nach dem nächsten Rechte-Update).
|
||||
*/
|
||||
|
||||
const http = require('node:http');
|
||||
@@ -105,6 +108,16 @@ const COLUMNS = {
|
||||
reason: { enum: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true },
|
||||
reporterHash: { size: 64, required: true },
|
||||
},
|
||||
// Benutzernamen: Zeilen-ID = Konto-ID, `usernameKey` = der Name klein geschrieben (Unique-Index)
|
||||
profiles: {
|
||||
username: { size: 20, required: true },
|
||||
usernameKey: { size: 20, required: true },
|
||||
},
|
||||
};
|
||||
|
||||
/** Unique-Indizes wie in web/scripts/appwrite-setup.mjs: je Tabelle die Spalten, deren Werte nur in einer Zeile vorkommen dürfen. */
|
||||
const UNIQUE_INDEXES = {
|
||||
profiles: ['usernameKey'],
|
||||
};
|
||||
|
||||
/** Standardwerte der Spalten, die Appwrite beim Anlegen einsetzt, wenn die Anfrage sie weglässt. */
|
||||
@@ -121,6 +134,7 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
|
||||
logos: new Map(),
|
||||
logo_likes: new Map(),
|
||||
logo_reports: new Map(),
|
||||
profiles: new Map(),
|
||||
};
|
||||
const files = new Map();
|
||||
const contents = new Map();
|
||||
@@ -162,6 +176,11 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
|
||||
return addRow('cards', id, { name: 'Karte' }, permissions || [`read("user:${owner}")`, `update("user:${owner}")`, `delete("user:${owner}")`]);
|
||||
}
|
||||
|
||||
/** Benutzername eines Kontos (Tabelle `profiles`) mit den Rechten, die die API vergibt: Zeilen-ID = Konto-ID, öffentlich lesbar. */
|
||||
function addProfile(userId, username = userId) {
|
||||
return addRow('profiles', userId, { username, usernameKey: username.toLowerCase() }, ['read("any")']);
|
||||
}
|
||||
|
||||
function addRow(table, id, data, permissions) {
|
||||
const row = { $id: id, $createdAt: new Date(1_700_000_000_000 + (++sequence) * 1000).toISOString(), $permissions: permissions, ...data };
|
||||
tables[table].set(id, row);
|
||||
@@ -183,6 +202,20 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
|
||||
injections.push({ predicate, delayMs });
|
||||
}
|
||||
|
||||
/**
|
||||
* Nächster passender Aufruf wartet, bis die zurückgegebene Funktion aufgerufen wird (er steht dann schon in `calls`). So erzwingt
|
||||
* ein Test eine Reihenfolge ohne Zeitannahmen, etwa: beide Anfragen haben vorab geprüft, bevor eine von ihnen schreibt.
|
||||
* @returns {() => void} gibt den Aufruf frei
|
||||
*/
|
||||
function holdNext(predicate) {
|
||||
let release;
|
||||
const gate = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
injections.push({ predicate, gate });
|
||||
return release;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nach dem nächsten Rechte-Update (PUT) der Datei `id` hat sie den Inhalt `content` (einmalig). So lässt sich eine Datei
|
||||
* nachstellen, die zwischen der ersten Prüfung und dem Festlegen der Rechte ausgetauscht wurde: der Anfang ist beim ersten
|
||||
@@ -213,6 +246,13 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Hat eine andere Zeile der Tabelle schon diese Werte in den Spalten eines Unique-Index? (`values` = die Zeile, wie sie danach aussähe.) */
|
||||
function violatesUnique(table, id, values) {
|
||||
const columns = UNIQUE_INDEXES[table];
|
||||
if (!columns) return false;
|
||||
return [...tables[table].values()].some((row) => row.$id !== id && columns.every((name) => row[name] === values[name]));
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const chunks = [];
|
||||
req.on('data', (chunk) => chunks.push(chunk));
|
||||
@@ -244,7 +284,9 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
|
||||
if (injectionIndex !== -1) {
|
||||
const [entry] = injections.splice(injectionIndex, 1);
|
||||
if (entry.hang) return;
|
||||
if (entry.delayMs) {
|
||||
if (entry.gate) {
|
||||
await entry.gate;
|
||||
} else if (entry.delayMs) {
|
||||
await new Promise((resolve) => setTimeout(resolve, entry.delayMs));
|
||||
} else {
|
||||
return error(entry.status, entry.type);
|
||||
@@ -279,7 +321,9 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
|
||||
const data = body.data || {};
|
||||
const problem = validateData(table, data, { partial: false });
|
||||
if (problem) return error(400, 'document_invalid_structure', problem);
|
||||
const row = addRow(table, body.rowId, { ...DEFAULTS[table], ...data }, body.permissions || []);
|
||||
const values = { ...DEFAULTS[table], ...data };
|
||||
if (violatesUnique(table, body.rowId, values)) return error(409, 'row_already_exists');
|
||||
const row = addRow(table, body.rowId, values, body.permissions || []);
|
||||
return reply(201, row);
|
||||
}
|
||||
|
||||
@@ -302,6 +346,7 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
|
||||
const data = body.data || {};
|
||||
const problem = validateData(table, data, { partial: true });
|
||||
if (problem) return error(400, 'document_invalid_structure', problem);
|
||||
if (violatesUnique(table, segments[6], { ...row, ...data })) return error(409, 'row_already_exists');
|
||||
Object.assign(row, data);
|
||||
if (Array.isArray(body.permissions)) row.$permissions = body.permissions;
|
||||
return reply(200, row);
|
||||
@@ -360,10 +405,12 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
|
||||
addRawUser,
|
||||
addFile,
|
||||
addCard,
|
||||
addProfile,
|
||||
addRow,
|
||||
failNext,
|
||||
hangNext,
|
||||
delayNext,
|
||||
holdNext,
|
||||
swapContentAfterPermissions,
|
||||
row: (table, id) => tables[table].get(id) || null,
|
||||
rows: (table) => [...tables[table].values()],
|
||||
|
||||
+97
-61
@@ -6,9 +6,10 @@
|
||||
* app.js hängt die Routen an und gibt Anmeldung, Sperre je Schlüssel, das gemeinsame Rate-Limit und das Log mit.
|
||||
*
|
||||
* POST /api/logos/hochladen Nutzer-JWT {name, displayName?, mono?, fileId}
|
||||
* 401 · 400 Validierung, Datei fehlt, kein PNG, größer als 1 MiB, Maße nicht 8 × 8 bis 720 × 250 · 403 Datei nicht
|
||||
* 401 · 403 `usernameRequired` (das Konto hat noch keinen Benutzernamen; vor allen anderen Prüfungen) · 400 Validierung,
|
||||
* Datei fehlt, kein PNG, größer als 1 MiB, Maße nicht 8 × 8 bis 720 × 250 · 403 Datei nicht
|
||||
* meine / falscher Bucket / Datei gesperrt (kein read("any")) · 409 Datei schon für ein Logo verwendet · 429 schon
|
||||
* 30 Logos · 201 {id} (id = Datei-ID = Zeilen-ID)
|
||||
* 30 Logos · 201 {id} (id = Zeilen-ID des Logos, ein Hash aus Konto und Datei, nicht die Datei-ID)
|
||||
* Nach dem Festlegen der Datei auf read("any") wird ihr Anfang noch einmal geprüft (der Hochlader kann sie dann nicht
|
||||
* mehr ändern): fällt sie durch oder fehlt sie, werden Datei und Zeile gelöscht und die Antwort ist 400.
|
||||
* POST /api/logos/herz Nutzer-JWT {id, herz}
|
||||
@@ -17,12 +18,15 @@
|
||||
* 400 · 404 fehlt oder ausgeblendet · 429 Rate-Limit · 200 {ok:true} oder {schonGemeldet:true}
|
||||
* POST /api/logos/loeschen Nutzer-JWT (Hochlader oder Admin) {id}
|
||||
* 401 · 400 · 403 fremd · 404 fehlt · 200 {ok:true}
|
||||
* Blendet das Logo zuerst aus (Datei und Zeile), löscht dann Datei, Herzen, Meldungen und zuletzt die Zeile: ein Abbruch
|
||||
* hinterlässt höchstens eine unsichtbare Zeile, nie eine öffentliche mit gelöschter Datei.
|
||||
* POST /api/admin/logo-sichtbarkeit Admin-JWT {id, sichtbar}
|
||||
* 401 · 403 kein Admin · 400 · 404 · 200 {ok:true}
|
||||
*
|
||||
* Tabellen `logos` (Zeilen-ID = Datei-ID, damit eine Datei nie zwei Logos tragen kann), `logo_likes`, `logo_reports`,
|
||||
* Bucket `logo-images`. `likeCount` und `reportCount` werden immer aus der Zahl der Zeilen gesetzt (countRows), nie hoch-
|
||||
* oder runtergezählt. Sperren: `logo-owner:<Nutzer>` beim Hochladen, `logo:<id>` für alles je Logo.
|
||||
* Tabellen `logos` (Zeilen-ID = hash32(`<Konto>|<Datei>`), siehe logoRowId; Altlogos haben noch Zeilen-ID = Datei-ID), `logo_likes`,
|
||||
* `logo_reports`, Bucket `logo-images`. `likeCount` und `reportCount` werden immer aus der Zahl der Zeilen gesetzt (countRows),
|
||||
* nie hoch- oder runtergezählt. Sperren: `logo-owner:<Nutzer>` beim Hochladen, `logo:<id>` für alles je Logo (auch beim Hochladen,
|
||||
* vom Anlegen der Zeile bis zur zweiten Prüfung, innerhalb der Sperre des Kontos).
|
||||
* Logs nennen nur Logo-IDs und Zahlen, nie Namen, IPs, Hashes oder JWTs; Herzen werden nicht geloggt.
|
||||
*/
|
||||
|
||||
@@ -54,6 +58,7 @@ const {
|
||||
checkFileExists,
|
||||
checkFileOwnership,
|
||||
} = require('./common.js');
|
||||
const { hasProfile } = require('./profiles.js');
|
||||
|
||||
const TABLE_LOGOS = 'logos';
|
||||
const TABLE_LIKES = 'logo_likes';
|
||||
@@ -76,6 +81,17 @@ const LOGO_TEXT_FIELDS = [
|
||||
|
||||
// ---- Reine Helfer ----------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Feste Zeilen-ID je Konto und Datei: dieselbe Datei desselben Kontos ergibt dieselbe ID (Appwrite-409 beim Anlegen). Die ID ist
|
||||
* nicht die Datei-ID: die Zeilen-ID eines gelöschten Logos steht öffentlich in der Liste und in gespeicherten Karten, und jeder
|
||||
* könnte eine Datei mit genau dieser ID anlegen und als Logo hochladen. Mit dem Konto im Hash lässt sich die alte ID so nicht neu belegen.
|
||||
* Ältere Logos haben noch Zeilen-ID = Datei-ID; alle anderen Endpunkte adressieren die Zeile nur über ihre ID und lesen die Datei
|
||||
* aus `fileId`, daher gehen beide Arten.
|
||||
*/
|
||||
function logoRowId(ownerId, fileId) {
|
||||
return hash32(`${ownerId}|${fileId}`);
|
||||
}
|
||||
|
||||
/** Feste Zeilen-ID je Logo und Nutzer: ein Herz pro Konto, Appwrite-409 ersetzt jede Doppelt-Prüfung. */
|
||||
function logoLikeId(logoId, userId) {
|
||||
return hash32(`${logoId}|${userId}`);
|
||||
@@ -200,87 +216,103 @@ function createLogoRoutes({ appwrite, salt, say, withLock, rateLimit, requireUse
|
||||
/**
|
||||
* Das Logo ist nach dem Festlegen der Rechte durchgefallen (Inhalt ausgetauscht oder Datei weg): die Datei mit dem
|
||||
* Server-Key löschen, dann die Zeile. Beides wird versucht, auch wenn das erste scheitert: die Zeile macht das Logo für
|
||||
* andere sichtbar. Bleibt etwas stehen, steht `rollback failed for logo <id>` im Log.
|
||||
* andere sichtbar. Bleibt etwas stehen, steht `rollback failed for logo <id> (file <id>)` im Log (mit der Datei-ID: sie
|
||||
* folgt nicht aus der Zeilen-ID, und bleibt nur die Datei liegen, gibt es keine Zeile mehr, an der man sie fände).
|
||||
*/
|
||||
async function removeRejectedLogo(id, problem) {
|
||||
async function removeRejectedLogo(rowId, fileId, problem) {
|
||||
let clean = true;
|
||||
await appwrite.deleteFile(BUCKET, id).catch(() => {
|
||||
await appwrite.deleteFile(BUCKET, fileId).catch(() => {
|
||||
clean = false;
|
||||
});
|
||||
await appwrite.deleteRow(TABLE_LOGOS, id).catch(() => {
|
||||
await appwrite.deleteRow(TABLE_LOGOS, rowId).catch(() => {
|
||||
clean = false;
|
||||
});
|
||||
say(clean ? `logo ${id} removed after upload check (${problem.code})` : `rollback failed for logo ${id}`);
|
||||
say(clean ? `logo ${rowId} removed after upload check (${problem.code})` : `rollback failed for logo ${rowId} (file ${fileId})`);
|
||||
}
|
||||
|
||||
async function uploadLogo({ req, raw }) {
|
||||
const user = await requireUser(req);
|
||||
// Ohne Benutzernamen geht nichts weiter: vor allen anderen Prüfungen, es wird nichts angelegt oder angefasst.
|
||||
if (!(await hasProfile(appwrite, user.id))) throw new HttpError(403, MSG.usernameRequired);
|
||||
const validation = validateLogoUpload(parseJsonObject(raw));
|
||||
if (!validation.ok) throw new HttpError(400, validation.message);
|
||||
const data = validation.data;
|
||||
|
||||
const file = await appwrite.getFile(BUCKET, data.fileId);
|
||||
checkFileExists(file, BUCKET);
|
||||
const rowId = logoRowId(user.id, data.fileId);
|
||||
|
||||
// Je Konto nacheinander: Limit, Prüfung „Datei schon verwendet“ und Anlegen der Zeile dürfen sich nicht überholen.
|
||||
return withLock(`logo-owner:${user.id}`, async () => {
|
||||
// Die Zeilen dieses Kontos (auch ausgeblendete), höchstens MAX_LOGOS: ab dort ist das Limit erreicht.
|
||||
const rows = await appwrite.listRows(TABLE_LOGOS, [queryEqual('ownerId', [user.id]), queryLimit(MAX_LOGOS)]);
|
||||
if (rows.length >= MAX_LOGOS) throw new HttpError(429, MSG.tooManyLogos);
|
||||
// Zeilen-ID = Datei-ID: dieses Logo gibt es schon (seine Datei trägt dann kein delete(owner) mehr, darum vor der Besitzprüfung).
|
||||
if (rows.some((row) => row.$id === data.fileId)) throw new HttpError(409, MSG.logoTaken);
|
||||
// Dieses Logo gibt es schon (seine Datei trägt dann kein delete(owner) mehr, darum vor der Besitzprüfung): entweder unter dieser
|
||||
// Zeilen-ID oder, bei einem Altlogo mit Zeilen-ID = Datei-ID, über die `fileId` der Zeile.
|
||||
if (rows.some((row) => row.$id === rowId || row.fileId === data.fileId)) throw new HttpError(409, MSG.logoTaken);
|
||||
checkFileOwnership(file, user.id);
|
||||
checkLogoContent(file);
|
||||
await checkLogoSize(data.fileId);
|
||||
|
||||
try {
|
||||
await appwrite.createRow(
|
||||
TABLE_LOGOS,
|
||||
data.fileId,
|
||||
{
|
||||
name: data.name,
|
||||
displayName: data.displayName,
|
||||
fileId: data.fileId,
|
||||
ownerId: user.id,
|
||||
mono: data.mono,
|
||||
likeCount: 0,
|
||||
hidden: false,
|
||||
reportCount: 0,
|
||||
},
|
||||
visibleRowPermissions(),
|
||||
);
|
||||
} catch (err) {
|
||||
// Eine Zeile mit dieser ID gehört schon jemandem (z. B. der liegengebliebene Anker eines halb gelöschten Logos).
|
||||
if (isDuplicate(err)) throw new HttpError(409, MSG.logoTaken);
|
||||
throw err;
|
||||
}
|
||||
// Ab dem Anlegen steht die Zeile öffentlich in der Liste. Solange die Prüfung das Logo noch entfernen kann, dürfen Herz, Meldung
|
||||
// und Löschen nicht dazwischenkommen (ihre Zeilen blieben ohne Logo zurück): der Upload hält die Sperre des Logos bis zum Ende.
|
||||
// Nur das Hochladen nimmt zwei Sperren, immer erst die des Kontos, dann die des Logos; alle anderen Anfragen nehmen nur die des
|
||||
// Logos, so kann sich nichts verklemmen.
|
||||
return withLogoLock(rowId, async () => {
|
||||
try {
|
||||
await appwrite.createRow(
|
||||
TABLE_LOGOS,
|
||||
rowId,
|
||||
{
|
||||
name: data.name,
|
||||
displayName: data.displayName,
|
||||
fileId: data.fileId,
|
||||
ownerId: user.id,
|
||||
mono: data.mono,
|
||||
likeCount: 0,
|
||||
hidden: false,
|
||||
reportCount: 0,
|
||||
},
|
||||
visibleRowPermissions(),
|
||||
);
|
||||
} catch (err) {
|
||||
// Eine Zeile mit dieser ID gibt es schon, steht aber nicht in der Liste des Kontos (ein Rest): das Logo ist vergeben.
|
||||
if (isDuplicate(err)) throw new HttpError(409, MSG.logoTaken);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Kanonischer Zustand: die Datei trägt genau read("any") (kein delete(owner): sonst ließe sich das Bild tauschen).
|
||||
// Erst nach dem Anlegen der Zeile: ein 409 fasst die Datei nie an. Scheitert das Festlegen, wird die Zeile zurückgenommen.
|
||||
let problem = null;
|
||||
try {
|
||||
const pinned = await appwrite.updateFilePermissions(BUCKET, data.fileId, visibleFilePermissions());
|
||||
if (pinned === null) throw new HttpError(400, MSG.fileMissing);
|
||||
// Jetzt hat der Hochlader kein delete mehr und kann die Datei nicht mehr ändern: den Anfang noch einmal prüfen. So
|
||||
// lässt sich die Datei nicht zwischen der ersten Prüfung und dem Festlegen unter derselben ID austauschen. Ein
|
||||
// Appwrite-Fehler hier wird wie ein Fehler beim Festlegen behandelt (Rücknahme unten, nichts bleibt veröffentlicht).
|
||||
problem = await logoSizeProblem(data.fileId);
|
||||
} catch (err) {
|
||||
let rolledBack = true;
|
||||
await appwrite.deleteRow(TABLE_LOGOS, data.fileId).catch(() => {
|
||||
rolledBack = false;
|
||||
say(`rollback failed for logo ${data.fileId}`);
|
||||
});
|
||||
// Nur wenn keine Zeile mehr auf die Datei zeigt, bekommt sie delete(owner) zurück (der Client räumt damit auf).
|
||||
if (rolledBack) await appwrite.updateFilePermissions(BUCKET, data.fileId, uploadFilePermissions(user.id)).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
if (problem !== null) {
|
||||
await removeRejectedLogo(data.fileId, problem);
|
||||
throw problem;
|
||||
}
|
||||
say(`logo ${data.fileId} uploaded`);
|
||||
return { status: 201, body: { id: data.fileId } };
|
||||
// Kanonischer Zustand: die Datei trägt genau read("any") (kein delete(owner): sonst ließe sich das Bild tauschen).
|
||||
// Erst nach dem Anlegen der Zeile: ein 409 fasst die Datei nie an. Scheitert das Festlegen, wird die Zeile zurückgenommen.
|
||||
let problem = null;
|
||||
try {
|
||||
const pinned = await appwrite.updateFilePermissions(BUCKET, data.fileId, visibleFilePermissions());
|
||||
if (pinned === null) throw new HttpError(400, MSG.fileMissing);
|
||||
// Jetzt hat der Hochlader kein delete mehr und kann die Datei nicht mehr ändern: den Anfang noch einmal prüfen. So
|
||||
// lässt sich die Datei nicht zwischen der ersten Prüfung und dem Festlegen unter derselben ID austauschen. Ein
|
||||
// Appwrite-Fehler hier wird wie ein Fehler beim Festlegen behandelt (Rücknahme unten, nichts bleibt veröffentlicht).
|
||||
problem = await logoSizeProblem(data.fileId);
|
||||
} catch (err) {
|
||||
let rolledBack = true;
|
||||
await appwrite.deleteRow(TABLE_LOGOS, rowId).catch(() => {
|
||||
rolledBack = false;
|
||||
say(`rollback failed for logo ${rowId} (file ${data.fileId})`);
|
||||
});
|
||||
// Nur wenn keine Zeile mehr auf die Datei zeigt, bekommt sie delete(owner) zurück (der Client räumt damit auf). Scheitert
|
||||
// auch das, bleibt die Datei ohne Zeile und ohne delete(owner) liegen: die Logzeile nennt ihre ID, nur so findet man sie.
|
||||
if (rolledBack) {
|
||||
await appwrite.updateFilePermissions(BUCKET, data.fileId, uploadFilePermissions(user.id)).catch(() => {
|
||||
say(`restore failed for logo ${rowId} (file ${data.fileId})`);
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (problem !== null) {
|
||||
await removeRejectedLogo(rowId, data.fileId, problem);
|
||||
throw problem;
|
||||
}
|
||||
say(`logo ${rowId} uploaded`);
|
||||
return { status: 201, body: { id: rowId } };
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -353,7 +385,11 @@ function createLogoRoutes({ appwrite, salt, say, withLock, rateLimit, requireUse
|
||||
if (!row) throw new HttpError(404, MSG.logoGone);
|
||||
if (row.ownerId !== user.id && !user.isAdmin) throw new HttpError(403, MSG.forbidden);
|
||||
|
||||
// Zeile zuletzt: scheitert etwas, bleibt sie als Anker für die Wiederholung stehen.
|
||||
// Zuerst nicht mehr öffentlich (wie beim Ausblenden: Datei, dann Zeile). Bricht das Löschen ab, nachdem die Datei weg ist, bliebe
|
||||
// sonst eine öffentliche Zeile mit einer Datei-ID zurück, unter der jedes Konto eine neue Datei anlegen könnte: alle gespeicherten
|
||||
// Karten zeigten dann deren Bild. So bleibt höchstens eine unsichtbare Zeile; die Wiederholung (Hochlader oder Admin) vollendet es.
|
||||
if (row.hidden !== true) await hideLogo(id, row);
|
||||
// Dann Datei, Herzen, Meldungen, die Zeile zuletzt: scheitert etwas, bleibt sie als Anker für die Wiederholung stehen.
|
||||
if (isId(row.fileId)) await appwrite.deleteFile(BUCKET, row.fileId);
|
||||
await deleteRowsOf(TABLE_LIKES, id);
|
||||
await deleteRowsOf(TABLE_REPORTS, id);
|
||||
@@ -398,4 +434,4 @@ function createLogoRoutes({ appwrite, salt, say, withLock, rateLimit, requireUse
|
||||
];
|
||||
}
|
||||
|
||||
module.exports = { createLogoRoutes, logoLikeId, logoReportId, validateLogoUpload };
|
||||
module.exports = { createLogoRoutes, logoRowId, logoLikeId, logoReportId, validateLogoUpload };
|
||||
+395
-56
@@ -11,9 +11,10 @@ const http = require('node:http');
|
||||
const { createAppwrite } = require('./appwrite.js');
|
||||
const { startFakeAppwrite, pngHeader, webpHeader } = require('./fake-appwrite.js');
|
||||
const { createApp } = require('./app.js');
|
||||
const { createLogoRoutes, logoLikeId, logoReportId, validateLogoUpload } = require('./logos.js');
|
||||
const { createLogoRoutes, logoLikeId, logoReportId, logoRowId, validateLogoUpload } = require('./logos.js');
|
||||
const {
|
||||
MSG,
|
||||
isId,
|
||||
visibleRowPermissions,
|
||||
hiddenRowPermissions,
|
||||
likeRowPermissions,
|
||||
@@ -26,9 +27,12 @@ const SALT = 'test-salt-0123456789abcdef0123456789abcdef';
|
||||
const ME = 'user-me';
|
||||
const OTHER = 'user-other';
|
||||
const ADMIN = 'user-admin';
|
||||
/** Konto ohne Benutzernamen (alle anderen haben aus `setup` ein Profil): Hochladen geht erst mit Profil. */
|
||||
const NEWBIE = 'user-new';
|
||||
const JWT_ME = 'eyJhbGciOiJI.payload-me.signature-me';
|
||||
const JWT_OTHER = 'eyJhbGciOiJI.payload-other.signature-other';
|
||||
const JWT_ADMIN = 'eyJhbGciOiJI.payload-admin.signature-admin';
|
||||
const JWT_NEW = 'eyJhbGciOiJI.payload-new.signature-new';
|
||||
const MIB = 1024 * 1024;
|
||||
const LOGO_BUCKET = 'logo-images';
|
||||
/** Rechte einer frisch hochgeladenen Logo-Datei von ME. */
|
||||
@@ -47,6 +51,9 @@ async function setup() {
|
||||
fake.addUser(JWT_ME, { id: ME });
|
||||
fake.addUser(JWT_OTHER, { id: OTHER });
|
||||
fake.addUser(JWT_ADMIN, { id: ADMIN, labels: ['admin'] });
|
||||
fake.addUser(JWT_NEW, { id: NEWBIE });
|
||||
// Hochladen verlangt einen Benutzernamen (Tabelle `profiles`): ME, OTHER und ADMIN haben einen, NEWBIE nicht.
|
||||
for (const id of [ME, OTHER, ADMIN]) fake.addProfile(id);
|
||||
|
||||
const logs = [];
|
||||
const clock = { value: 1_700_000_000_000 };
|
||||
@@ -115,10 +122,18 @@ function logoFile(fake, id, owner = ME, { width = 200, height = 100, ...rest } =
|
||||
fake.addFile({ id, bucket: LOGO_BUCKET, owner, content: pngHeader(width, height), ...rest });
|
||||
}
|
||||
|
||||
/** Veröffentlichtes Logo: Zeile und Datei mit genau den Rechten, die die API vergibt. */
|
||||
function seedLogo(fake, { id = 'logo-1', owner = ME, hidden = false, reportCount = 0, likeCount = 0, name = 'Logo', mono = false } = {}) {
|
||||
/** Zeilen-ID des Logos, das `owner` aus der Datei `fileId` anlegt (so vergibt sie die API). */
|
||||
const rowOf = (fileId, owner = ME) => logoRowId(owner, fileId);
|
||||
/** Datei-ID, die `seedLogo` einer Logo-Zeile gibt: nie gleich der Zeilen-ID (nur Altlogos haben Zeilen-ID = Datei-ID). */
|
||||
const fileOf = (id) => `file-of-${id}`;
|
||||
|
||||
/**
|
||||
* Veröffentlichtes Logo: Zeile und Datei mit genau den Rechten, die die API vergibt. Die Datei hat die ID `fileId`
|
||||
* (Standard `fileOf(id)`); ein Altlogo mit Zeilen-ID = Datei-ID legt der Test mit `fileId: id` an.
|
||||
*/
|
||||
function seedLogo(fake, { id = 'logo-1', fileId = fileOf(id), owner = ME, hidden = false, reportCount = 0, likeCount = 0, name = 'Logo', mono = false } = {}) {
|
||||
fake.addFile({
|
||||
id,
|
||||
id: fileId,
|
||||
bucket: LOGO_BUCKET,
|
||||
owner,
|
||||
permissions: hidden ? hiddenFilePermissions(owner) : visibleFilePermissions(),
|
||||
@@ -127,7 +142,7 @@ function seedLogo(fake, { id = 'logo-1', owner = ME, hidden = false, reportCount
|
||||
fake.addRow(
|
||||
'logos',
|
||||
id,
|
||||
{ name, displayName: '', fileId: id, ownerId: owner, mono, likeCount, hidden, reportCount },
|
||||
{ name, displayName: '', fileId, ownerId: owner, mono, likeCount, hidden, reportCount },
|
||||
hidden ? hiddenRowPermissions(owner) : visibleRowPermissions(),
|
||||
);
|
||||
}
|
||||
@@ -136,7 +151,7 @@ function seedLogo(fake, { id = 'logo-1', owner = ME, hidden = false, reportCount
|
||||
function seedLogoRows(fake, owner, count) {
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const id = `${owner}-l${i}`;
|
||||
fake.addRow('logos', id, { name: `L${i}`, displayName: '', fileId: id, ownerId: owner, mono: false, likeCount: 0, hidden: i % 2 === 1, reportCount: 0 }, visibleRowPermissions());
|
||||
fake.addRow('logos', id, { name: `L${i}`, displayName: '', fileId: fileOf(id), ownerId: owner, mono: false, likeCount: 0, hidden: i % 2 === 1, reportCount: 0 }, visibleRowPermissions());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,16 +165,29 @@ function seedReports(fake, logoId, hashes) {
|
||||
|
||||
const sha32 = (text) => crypto.createHash('sha256').update(text).digest('hex').slice(0, 32);
|
||||
const nonAccountCalls = (fake) => fake.calls.filter((c) => c.path !== '/v1/account');
|
||||
/** Die Aufrufe bei Appwrite außer dem Konto und der Suche nach dem Profil von ME (die macht jede Anfrage zum Hochladen als Erstes). */
|
||||
const callsBeyondProfile = (fake) => nonAccountCalls(fake).filter((c) => !(c.method === 'GET' && c.path.endsWith('/tables/profiles/rows/user-me')));
|
||||
const filePuts = (fake, id) => fake.calls.filter((c) => c.method === 'PUT' && c.path.endsWith(`/files/${id}`));
|
||||
const fileReads = (fake, id) => fake.calls.filter((c) => c.method === 'GET' && c.path.endsWith(`/files/${id}/view`));
|
||||
|
||||
/** Kann ein Gast diese Zeile lesen? (Appwrite-Rollen `any` und `guests`.) */
|
||||
const guestCanRead = (row) => row.$permissions.some((permission) => permission === 'read("any")' || permission === 'read("guests")');
|
||||
|
||||
/** Wartet (höchstens etwa 2 s), bis `condition()` stimmt: so fängt ein Test den Moment ab, in dem ein Aufruf bei Appwrite noch läuft. */
|
||||
async function until(condition) {
|
||||
for (let i = 0; i < 400 && !condition(); i += 1) await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
assert.ok(condition(), 'der erwartete Zustand wurde nicht erreicht');
|
||||
}
|
||||
|
||||
/** Die Aufrufe zu einem Logo in der Reihenfolge, in der sie ankamen (Datei abrufen, lesen, festlegen, löschen; Zeile anlegen, löschen). */
|
||||
function logoSteps(fake, id) {
|
||||
function logoSteps(fake, fileId) {
|
||||
const rowId = rowOf(fileId);
|
||||
const steps = [];
|
||||
for (const c of fake.calls) {
|
||||
if (c.path.endsWith(`/files/${id}/view`)) steps.push('Anfang lesen');
|
||||
else if (c.path.endsWith(`/files/${id}`)) steps.push({ GET: 'Datei abrufen', PUT: 'Rechte festlegen', DELETE: 'Datei löschen' }[c.method]);
|
||||
if (c.path.endsWith(`/files/${fileId}/view`)) steps.push('Anfang lesen');
|
||||
else if (c.path.endsWith(`/files/${fileId}`)) steps.push({ GET: 'Datei abrufen', PUT: 'Rechte festlegen', DELETE: 'Datei löschen' }[c.method]);
|
||||
else if (c.method === 'POST' && c.path.endsWith('/tables/logos/rows')) steps.push('Zeile anlegen');
|
||||
else if (c.method === 'DELETE' && c.path.endsWith(`/tables/logos/rows/${id}`)) steps.push('Zeile löschen');
|
||||
else if (c.method === 'DELETE' && c.path.endsWith(`/tables/logos/rows/${rowId}`)) steps.push('Zeile löschen');
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
@@ -179,6 +207,16 @@ test('logoLikeId und logoReportId: erste 32 Hex von sha256, Logo-Meldungen mit d
|
||||
assert.notEqual(logoReportId('l1', 'h1'), logoReportId('l1', 'h2'));
|
||||
});
|
||||
|
||||
test('logoRowId: erste 32 Hex von sha256("<Konto>|<Datei>"), eine gültige ID, nie die Datei-ID, je Konto und Datei verschieden', () => {
|
||||
assert.equal(logoRowId('u1', 'f1'), sha32('u1|f1'));
|
||||
assert.match(logoRowId('u1', 'f1'), /^[0-9a-f]{32}$/);
|
||||
assert.ok(isId(logoRowId('u1', 'f1')));
|
||||
assert.notEqual(logoRowId('u1', 'f1'), 'f1');
|
||||
assert.equal(logoRowId('u1', 'f1'), logoRowId('u1', 'f1'), 'dieselbe Datei desselben Kontos: dieselbe ID');
|
||||
assert.notEqual(logoRowId('u1', 'f1'), logoRowId('u2', 'f1'), 'dieselbe Datei-ID bei einem anderen Konto: andere Zeilen-ID');
|
||||
assert.notEqual(logoRowId('u1', 'f1'), logoRowId('u1', 'f2'));
|
||||
});
|
||||
|
||||
test('likeRowPermissions: genau read("user:<id>"), unzulässige Nutzer-IDs werden abgelehnt', () => {
|
||||
assert.deepEqual(likeRowPermissions('abc'), ['read("user:abc")']);
|
||||
for (const bad of ['a")', 'x","read("any', '', '../x', 5, null]) assert.throws(() => likeRowPermissions(bad));
|
||||
@@ -234,15 +272,18 @@ test('createLogoRoutes liefert die fünf Routen mit Name, json und run', () => {
|
||||
|
||||
// ---- Hochladen --------------------------------------------------------------------------------------
|
||||
|
||||
test('Logo hochladen: legt eine sichtbare Zeile an (Zeilen-ID = Datei-ID), die Datei trägt danach genau read("any")', async () => {
|
||||
test('Logo hochladen: legt eine sichtbare Zeile an (Zeilen-ID = Hash aus Konto und Datei, nicht die Datei-ID), die Datei trägt danach genau read("any")', async () => {
|
||||
const { upload, fake, logs } = await setup();
|
||||
logoFile(fake, 'logo1');
|
||||
|
||||
const res = await upload(uploadBody({ name: ' Mein Logo ', mono: true }));
|
||||
const rowId = rowOf('logo1');
|
||||
assert.equal(res.status, 201);
|
||||
assert.deepEqual(res.json, { id: 'logo1' });
|
||||
assert.deepEqual(res.json, { id: rowId });
|
||||
assert.match(rowId, /^[0-9a-f]{32}$/);
|
||||
assert.equal(fake.row('logos', 'logo1'), null, 'unter der Datei-ID gibt es keine Zeile');
|
||||
|
||||
const row = fake.row('logos', 'logo1');
|
||||
const row = fake.row('logos', rowId);
|
||||
assert.deepEqual(row.$permissions, ['read("any")']);
|
||||
assert.equal(row.name, 'Mein Logo');
|
||||
assert.equal(row.displayName, 'Kenso');
|
||||
@@ -260,7 +301,7 @@ test('Logo hochladen: legt eine sichtbare Zeile an (Zeilen-ID = Datei-ID), die D
|
||||
assert.equal(c.headers['x-appwrite-key'], fake.key);
|
||||
assert.equal(c.headers['x-appwrite-jwt'], undefined);
|
||||
}
|
||||
assert.equal(logs.filter((line) => line.endsWith('logo logo1 uploaded')).length, 1);
|
||||
assert.equal(logs.filter((line) => line.endsWith(`logo ${rowId} uploaded`)).length, 1);
|
||||
});
|
||||
|
||||
test('Logo hochladen: Anzeigename ist optional, mono fehlt = false', async () => {
|
||||
@@ -268,7 +309,7 @@ test('Logo hochladen: Anzeigename ist optional, mono fehlt = false', async () =>
|
||||
logoFile(fake, 'logo1');
|
||||
const res = await upload({ name: 'Nur Name', fileId: 'logo1' });
|
||||
assert.equal(res.status, 201);
|
||||
const row = fake.row('logos', 'logo1');
|
||||
const row = fake.row('logos', rowOf('logo1'));
|
||||
assert.equal(row.displayName, '');
|
||||
assert.equal(row.mono, false);
|
||||
});
|
||||
@@ -286,6 +327,47 @@ test('Logo hochladen ohne oder mit ungültigem JWT: 401, nichts angelegt, die Da
|
||||
assert.equal(fake.calls.filter((c) => c.path === '/v1/account').length, 1, 'fehlendes oder kaputtes JWT erreicht Appwrite gar nicht erst');
|
||||
});
|
||||
|
||||
test('Logo hochladen ohne Benutzernamen: 403 usernameRequired noch vor jeder anderen Prüfung (auch vor Body und Datei), nichts angelegt, Datei unberührt', async () => {
|
||||
const { upload, post, fake } = await setup();
|
||||
logoFile(fake, 'logo1', NEWBIE);
|
||||
expectError(await upload(uploadBody(), JWT_NEW), 403, 'usernameRequired', 'gültiger Body');
|
||||
expectError(await upload({}, JWT_NEW), 403, 'usernameRequired', 'ungültiger Body (kein Name, keine Datei)');
|
||||
expectError(await upload(uploadBody({ fileId: 'gibts-nicht' }), JWT_NEW), 403, 'usernameRequired', 'Datei gibt es nicht');
|
||||
expectError(await upload(uploadBody({ fileId: 'logo1', mono: 'ja' }), JWT_NEW), 403, 'usernameRequired', 'mono ungültig');
|
||||
expectError(await post('/api/logos/hochladen', null, JWT_NEW), 403, 'usernameRequired', 'kein Body');
|
||||
|
||||
assert.equal(fake.rows('logos').length, 0);
|
||||
assert.deepEqual(fake.file('logo1').$permissions, ['read("any")', 'delete("user:user-new")'], 'die Datei bleibt, wie sie war');
|
||||
assert.equal(fake.calls.filter((c) => c.path.startsWith('/v1/storage/')).length, 0, 'keine Datei wird angefasst');
|
||||
// Appwrite sieht je Versuch nur die Prüfung des JWT und die Suche nach dem Profil (Zeilen-ID = Konto-ID)
|
||||
assert.deepEqual(
|
||||
nonAccountCalls(fake).map((c) => `${c.method} ${c.path}`),
|
||||
Array(5).fill('GET /v1/tablesdb/aircard/tables/profiles/rows/user-new'),
|
||||
);
|
||||
});
|
||||
|
||||
test('Logo hochladen: das Profil gehört dem Konto – ein Profil der anderen Konten hilft nicht, mit eigenem Profil (über /api/profil) geht es', async () => {
|
||||
const { upload, post, fake } = await setup();
|
||||
logoFile(fake, 'logo1', NEWBIE);
|
||||
expectError(await upload(uploadBody(), JWT_NEW), 403, 'usernameRequired');
|
||||
|
||||
const named = await post('/api/profil', { username: 'Neuling' }, JWT_NEW);
|
||||
assert.deepEqual(named.json, { username: 'Neuling' });
|
||||
const res = await upload(uploadBody(), JWT_NEW);
|
||||
assert.equal(res.status, 201);
|
||||
assert.equal(fake.row('logos', rowOf('logo1', NEWBIE)).ownerId, NEWBIE);
|
||||
});
|
||||
|
||||
test('Logo hochladen: lässt sich das Profil nicht prüfen (Appwrite-Fehler), 502 und nichts wird angelegt; der nächste Versuch gelingt', async () => {
|
||||
const { upload, fake } = await setup();
|
||||
logoFile(fake, 'logo1');
|
||||
fake.failNext((c) => c.method === 'GET' && c.path.endsWith('/tables/profiles/rows/user-me'), { status: 500 });
|
||||
expectError(await upload(uploadBody()), 502, 'unavailable');
|
||||
assert.equal(fake.rows('logos').length, 0);
|
||||
assert.deepEqual(fake.file('logo1').$permissions, UPLOAD_PERMISSIONS);
|
||||
assert.equal((await upload(uploadBody())).status, 201);
|
||||
});
|
||||
|
||||
test('Logo hochladen mit fremder, falscher oder gesperrter Datei: 403, keine Zeile, Datei unverändert, Inhalt wird nicht gelesen', async () => {
|
||||
const { upload, fake } = await setup();
|
||||
logoFile(fake, 'fremd', OTHER);
|
||||
@@ -392,18 +474,29 @@ test('Logo hochladen: dieselbe Datei zweimal ergibt 409 logoTaken, Datei und Zei
|
||||
expectError(await upload(uploadBody({ name: 'Anderer Name' })), 409, 'logoTaken');
|
||||
assert.deepEqual(fake.file('logo1').$permissions, ['read("any")']);
|
||||
assert.equal(fake.rows('logos').length, 1);
|
||||
assert.equal(fake.row('logos', 'logo1').name, 'Mein Logo');
|
||||
assert.equal(fake.row('logos', rowOf('logo1')).name, 'Mein Logo');
|
||||
|
||||
// dieselbe ID als frisch hochgeladene Datei (z. B. nach Löschen und Neuanlegen): 409, die Rechte der Datei bleiben unangetastet
|
||||
// dieselbe Datei-ID als frisch hochgeladene Datei (z. B. nach Löschen und Neuanlegen): 409, die Rechte der Datei bleiben unangetastet
|
||||
fake.addFile({ id: 'logo1', bucket: LOGO_BUCKET, owner: ME, permissions: [...UPLOAD_PERMISSIONS, 'update("user:user-me")'], content: pngHeader(50, 50) });
|
||||
expectError(await upload(uploadBody()), 409, 'logoTaken');
|
||||
assert.deepEqual(fake.file('logo1').$permissions, [...UPLOAD_PERMISSIONS, 'update("user:user-me")']);
|
||||
assert.equal(filePuts(fake, 'logo1').length, 1, 'nur der erste, erfolgreiche Upload hat Rechte gesetzt');
|
||||
});
|
||||
|
||||
test('Logo hochladen: eine Zeile mit dieser ID gehört schon jemand anderem (liegengebliebener Anker): 409 logoTaken, Datei unangetastet', async () => {
|
||||
test('Logo hochladen: ein anderes Konto bekommt eine schon verwendete Datei nicht (403), die Zeilen-ID allein hält nicht zwei Konten auseinander', async () => {
|
||||
const { upload, fake } = await setup();
|
||||
fake.addRow('logos', 'anker', { name: 'Alt', displayName: '', fileId: 'anker', ownerId: OTHER, mono: false, likeCount: 0, hidden: false, reportCount: 0 }, visibleRowPermissions());
|
||||
logoFile(fake, 'logo1');
|
||||
assert.equal((await upload(uploadBody())).status, 201);
|
||||
|
||||
// je Konto eine eigene Zeilen-ID: dass eine Datei nie zwei Logos trägt, sichert der Besitz (nach dem Anlegen kein delete(owner) mehr)
|
||||
expectError(await upload(uploadBody(), JWT_OTHER), 403, 'foreignFile');
|
||||
assert.equal(fake.rows('logos').length, 1);
|
||||
assert.equal(fake.row('logos', rowOf('logo1', OTHER)), null);
|
||||
});
|
||||
|
||||
test('Logo hochladen: steht unter der Zeilen-ID schon eine Zeile, die nicht in der Liste des Kontos ist (Rest): 409 logoTaken, Datei unangetastet', async () => {
|
||||
const { upload, fake } = await setup();
|
||||
fake.addRow('logos', rowOf('anker'), { name: 'Alt', displayName: '', fileId: 'andere-datei', ownerId: OTHER, mono: false, likeCount: 0, hidden: false, reportCount: 0 }, visibleRowPermissions());
|
||||
logoFile(fake, 'anker');
|
||||
expectError(await upload(uploadBody({ fileId: 'anker' })), 409, 'logoTaken');
|
||||
assert.deepEqual(fake.file('anker').$permissions, UPLOAD_PERMISSIONS);
|
||||
@@ -411,6 +504,23 @@ test('Logo hochladen: eine Zeile mit dieser ID gehört schon jemand anderem (lie
|
||||
assert.equal(filePuts(fake, 'anker').length, 0, 'ein 409 fasst die Dateirechte nie an');
|
||||
});
|
||||
|
||||
test('Logo hochladen: die ID eines gelöschten Logos lässt sich nicht mit einer Datei gleicher ID neu belegen', async () => {
|
||||
const { upload, remove, fake } = await setup();
|
||||
logoFile(fake, 'logo1');
|
||||
const { id: victimRowId } = (await upload(uploadBody())).json;
|
||||
assert.equal((await remove(victimRowId)).status, 200);
|
||||
assert.equal(fake.row('logos', victimRowId), null);
|
||||
|
||||
// die Zeilen-ID ist öffentlich (Liste, gespeicherte Karten): jemand legt eine Datei mit genau dieser ID an und lädt sie hoch
|
||||
logoFile(fake, victimRowId, OTHER);
|
||||
const res = await upload(uploadBody({ fileId: victimRowId }), JWT_OTHER);
|
||||
assert.equal(res.status, 201);
|
||||
assert.notEqual(res.json.id, victimRowId, 'die alte Zeilen-ID bleibt unbelegt');
|
||||
assert.equal(fake.row('logos', victimRowId), null);
|
||||
assert.equal(fake.row('logos', res.json.id).fileId, victimRowId);
|
||||
assert.equal(fake.row('logos', res.json.id).ownerId, OTHER);
|
||||
});
|
||||
|
||||
test('Logo hochladen: höchstens 30 Logos je Konto (ausgeblendete zählen mit), das 31. ergibt 429 tooManyLogos', async () => {
|
||||
const { upload, fake } = await setup();
|
||||
seedLogoRows(fake, ME, 30);
|
||||
@@ -481,16 +591,38 @@ test('Logo hochladen: scheitert auch das Zurücknehmen, bleibt die Zeile stehen,
|
||||
assert.equal(filePuts(fake, 'logo1').length, 1, 'ohne erfolgreiches Zurücknehmen bekommt die Datei delete(owner) nicht zurück');
|
||||
const lines = logs.filter((line) => line.includes('rollback failed'));
|
||||
assert.equal(lines.length, 1);
|
||||
assert.match(lines[0], /^\[aircard-api\] \S+ rollback failed for logo logo1$/);
|
||||
assert.match(lines[0], new RegExp(`^\\[aircard-api\\] \\S+ rollback failed for logo ${rowOf('logo1')} \\(file logo1\\)$`));
|
||||
assert.equal(logs.filter((line) => line.includes('restore failed')).length, 0, 'die Datei wird gar nicht erst angefasst');
|
||||
assert.ok(!logs.join('\n').includes(JWT_ME));
|
||||
});
|
||||
|
||||
test('Logo hochladen: scheitert nach dem Zurücknehmen der Zeile die Rückgabe von delete(owner), steht `restore failed for logo <id> (file <id>)` im Log, die Antwort bleibt der ursprüngliche Fehler', async () => {
|
||||
const { upload, fake, logs } = await setup();
|
||||
logoFile(fake, 'logo1');
|
||||
let reads = 0;
|
||||
let puts = 0;
|
||||
fake.failNext((c) => c.method === 'GET' && c.path.endsWith('/files/logo1/view') && (reads += 1) === 2, { status: 500 }); // zweite Prüfung: Rücknahme
|
||||
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/logo1') && (puts += 1) === 2, { status: 500 }); // das 2. PUT gibt delete(owner) zurück
|
||||
expectError(await upload(uploadBody()), 502, 'unavailable');
|
||||
assert.equal(fake.rows('logos').length, 0, 'die Zeile ist zurückgenommen');
|
||||
assert.deepEqual(fake.file('logo1').$permissions, ['read("any")'], 'die Datei blieb festgelegt, ohne delete(owner): der Client kann sie nicht mehr löschen');
|
||||
assert.equal(filePuts(fake, 'logo1').length, 2);
|
||||
|
||||
// eine Zeile im Log, mit Zeilen- und Datei-ID (ohne die Datei findet niemand mehr den Rest: die Zeile ist ja weg), nie JWT oder Konto
|
||||
const lines = logs.filter((line) => line.includes('restore failed'));
|
||||
assert.equal(lines.length, 1);
|
||||
assert.match(lines[0], new RegExp(`^\\[aircard-api\\] \\S+ restore failed for logo ${rowOf('logo1')} \\(file logo1\\)$`));
|
||||
assert.equal(logs.filter((line) => line.includes('rollback failed')).length, 0, 'die Zeile ließ sich zurücknehmen');
|
||||
assert.ok(!logs.join('\n').includes(JWT_ME));
|
||||
assert.ok(!logs.join('\n').includes(ME));
|
||||
});
|
||||
|
||||
test('Logo hochladen: gelingt das Zurücknehmen, steht keine Rollback-Fehlerzeile im Log', async () => {
|
||||
const { upload, fake, logs } = await setup();
|
||||
logoFile(fake, 'logo1');
|
||||
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/logo1'), { status: 500 });
|
||||
assert.equal((await upload(uploadBody())).status, 502);
|
||||
assert.equal(logs.filter((line) => line.includes('rollback failed')).length, 0);
|
||||
assert.equal(logs.filter((line) => line.includes('rollback failed') || line.includes('restore failed')).length, 0);
|
||||
});
|
||||
|
||||
// ---- Hochladen: der Inhalt wird nach dem Festlegen der Rechte noch einmal geprüft --------------------------
|
||||
@@ -508,7 +640,7 @@ test('Logo hochladen: ein nach dem Festlegen anderer, aber weiterhin gültiger I
|
||||
logoFile(fake, 'logo1');
|
||||
fake.swapContentAfterPermissions('logo1', pngHeader(720, 250));
|
||||
assert.equal((await upload(uploadBody())).status, 201);
|
||||
assert.ok(fake.row('logos', 'logo1'));
|
||||
assert.ok(fake.row('logos', rowOf('logo1')));
|
||||
assert.deepEqual(fake.file('logo1').$permissions, ['read("any")']);
|
||||
});
|
||||
|
||||
@@ -530,7 +662,7 @@ test('Logo hochladen: wird die Datei zwischen Prüfung und Festlegen ausgetausch
|
||||
'Zeile löschen',
|
||||
]);
|
||||
// ein Ereignis im Log (nur ID und Code), nie „uploaded“
|
||||
assert.deepEqual(logEvents(logs, 'logo1'), ['logo logo1 removed after upload check (logoFormat)']);
|
||||
assert.deepEqual(logEvents(logs, rowOf('logo1')), [`logo ${rowOf('logo1')} removed after upload check (logoFormat)`]);
|
||||
});
|
||||
|
||||
test('Logo hochladen: nach dem Festlegen zählt der Inhalt noch einmal (kein PNG-Kopf, zu klein, zu groß): 400 logoFormat, Datei und Zeile sind weg', async () => {
|
||||
@@ -550,7 +682,7 @@ test('Logo hochladen: nach dem Festlegen zählt der Inhalt noch einmal (kein PNG
|
||||
logoFile(fake, id);
|
||||
fake.swapContentAfterPermissions(id, content);
|
||||
expectError(await upload(uploadBody({ fileId: id })), 400, 'logoFormat', label);
|
||||
assert.equal(fake.row('logos', id), null, `${label}: keine Zeile`);
|
||||
assert.equal(fake.row('logos', rowOf(id)), null, `${label}: keine Zeile`);
|
||||
assert.equal(fake.file(id), null, `${label}: Datei gelöscht`);
|
||||
}
|
||||
assert.equal(fake.rows('logos').length, 0);
|
||||
@@ -564,7 +696,7 @@ test('Logo hochladen: ist die Datei nach dem Festlegen nicht mehr lesbar, ist da
|
||||
expectError(await upload(uploadBody()), 400, 'fileMissing');
|
||||
assert.equal(fake.rows('logos').length, 0);
|
||||
assert.equal(fake.file('logo1'), null);
|
||||
assert.deepEqual(logEvents(logs, 'logo1'), ['logo logo1 removed after upload check (fileMissing)']);
|
||||
assert.deepEqual(logEvents(logs, rowOf('logo1')), [`logo ${rowOf('logo1')} removed after upload check (fileMissing)`]);
|
||||
});
|
||||
|
||||
test('Logo hochladen: Appwrite-Fehler beim zweiten Lesen ist 502, nichts bleibt veröffentlicht, die Datei bekommt delete(owner) zurück, die Wiederholung klappt', async () => {
|
||||
@@ -584,37 +716,38 @@ test('Logo hochladen: Appwrite-Fehler beim zweiten Lesen ist 502, nichts bleibt
|
||||
'Zeile löschen',
|
||||
'Rechte festlegen',
|
||||
]);
|
||||
assert.deepEqual(logEvents(logs, 'logo1'), [], 'ein Fehler ohne Urteil ist kein Ereignis');
|
||||
assert.deepEqual(logEvents(logs, rowOf('logo1')), [], 'ein Fehler ohne Urteil ist kein Ereignis');
|
||||
|
||||
assert.equal((await upload(uploadBody())).status, 201);
|
||||
assert.deepEqual(fake.file('logo1').$permissions, ['read("any")']);
|
||||
});
|
||||
|
||||
test('Logo hochladen: scheitert das Aufräumen nach einer Beanstandung, steht `rollback failed for logo <id>` im Log, die Antwort bleibt 400 logoFormat', async () => {
|
||||
test('Logo hochladen: scheitert das Aufräumen nach einer Beanstandung, steht `rollback failed for logo <id> (file <id>)` im Log, die Antwort bleibt 400 logoFormat', async () => {
|
||||
const { upload, remove, fake, logs } = await setup();
|
||||
|
||||
// die Datei lässt sich nicht löschen: die Zeile wird trotzdem entfernt (sie macht das Logo für andere sichtbar)
|
||||
// die Datei lässt sich nicht löschen: die Zeile wird trotzdem entfernt (sie macht das Logo für andere sichtbar); die Zeile ist weg,
|
||||
// darum nennt die Logzeile die Datei-ID, die sich aus der Zeilen-ID nicht ableiten lässt
|
||||
logoFile(fake, 'logo1');
|
||||
fake.swapContentAfterPermissions('logo1', pngHeader(4000, 4000));
|
||||
fake.failNext((c) => c.method === 'DELETE' && c.path.endsWith('/files/logo1'), { status: 500 });
|
||||
expectError(await upload(uploadBody()), 400, 'logoFormat');
|
||||
assert.equal(fake.row('logos', 'logo1'), null);
|
||||
assert.equal(fake.row('logos', rowOf('logo1')), null);
|
||||
assert.deepEqual(fake.file('logo1').$permissions, ['read("any")'], 'die Datei blieb liegen');
|
||||
assert.deepEqual(logEvents(logs, 'logo1'), ['rollback failed for logo logo1']);
|
||||
assert.deepEqual(logEvents(logs, rowOf('logo1')), [`rollback failed for logo ${rowOf('logo1')} (file logo1)`]);
|
||||
|
||||
// die Zeile lässt sich nicht löschen: die Datei ist weg, die Zeile bleibt als Anker und der Hochlader räumt sie über /api/logos/loeschen auf
|
||||
logoFile(fake, 'logo2');
|
||||
fake.swapContentAfterPermissions('logo2', pngHeader(4000, 4000));
|
||||
fake.failNext((c) => c.method === 'DELETE' && c.path.includes('/tables/logos/rows/logo2'), { status: 500 });
|
||||
fake.failNext((c) => c.method === 'DELETE' && c.path.includes(`/tables/logos/rows/${rowOf('logo2')}`), { status: 500 });
|
||||
expectError(await upload(uploadBody({ fileId: 'logo2' })), 400, 'logoFormat');
|
||||
assert.equal(fake.file('logo2'), null);
|
||||
assert.ok(fake.row('logos', 'logo2'));
|
||||
assert.deepEqual(logEvents(logs, 'logo2'), ['rollback failed for logo logo2']);
|
||||
assert.equal((await remove('logo2')).status, 200);
|
||||
assert.equal(fake.row('logos', 'logo2'), null);
|
||||
assert.ok(fake.row('logos', rowOf('logo2')));
|
||||
assert.deepEqual(logEvents(logs, rowOf('logo2')), [`rollback failed for logo ${rowOf('logo2')} (file logo2)`]);
|
||||
assert.equal((await remove(rowOf('logo2'))).status, 200);
|
||||
assert.equal(fake.row('logos', rowOf('logo2')), null);
|
||||
});
|
||||
|
||||
test('Logo hochladen: Validierung liefert 400 mit dem passenden Code und fasst Appwrite (außer dem Konto) nicht an', async () => {
|
||||
test('Logo hochladen: Validierung liefert 400 mit dem passenden Code und fasst Appwrite (außer Konto und Profil) nicht an', async () => {
|
||||
const { upload, fake } = await setup();
|
||||
logoFile(fake, 'logo1');
|
||||
|
||||
@@ -657,7 +790,7 @@ test('Logo hochladen: Validierung liefert 400 mit dem passenden Code und fasst A
|
||||
}
|
||||
|
||||
assert.equal(fake.rows('logos').length, 0);
|
||||
assert.equal(nonAccountCalls(fake).length, 0, 'ungültige Anfragen erreichen Appwrite (außer dem Konto) nie');
|
||||
assert.equal(callsBeyondProfile(fake).length, 0, 'ungültige Anfragen erreichen Appwrite (außer Konto und Profil) nie');
|
||||
});
|
||||
|
||||
test('Logo hochladen: kaputtes JSON, Array oder leerer Body ist 400 badRequest', async () => {
|
||||
@@ -666,7 +799,7 @@ test('Logo hochladen: kaputtes JSON, Array oder leerer Body ist 400 badRequest',
|
||||
const res = await call('POST', '/api/logos/hochladen', { raw, headers: { 'X-Aircard-Jwt': JWT_ME } });
|
||||
expectError(res, 400, 'badRequest', JSON.stringify(raw));
|
||||
}
|
||||
assert.equal(nonAccountCalls(fake).length, 0);
|
||||
assert.equal(callsBeyondProfile(fake).length, 0);
|
||||
});
|
||||
|
||||
test('Logo hochladen: gleichzeitig dieselbe Datei oder zwei Dateien am Limit, genau eine Anfrage gelingt (Sperre je Konto)', async () => {
|
||||
@@ -687,6 +820,71 @@ test('Logo hochladen: gleichzeitig dieselbe Datei oder zwei Dateien am Limit, ge
|
||||
assert.equal(fake.rows('logos').filter((row) => row.ownerId === ME).length, 30);
|
||||
});
|
||||
|
||||
// ---- Hochladen: die Sperre des Logos gilt vom Anlegen der Zeile bis zur zweiten Prüfung ----------------------------
|
||||
|
||||
test('Logo hochladen: Herz und Meldung, die nach dem Anlegen der Zeile eintreffen, warten auf die zweite Prüfung und hinterlassen nichts, wenn sie das Logo entfernt', async () => {
|
||||
const { upload, heart, report, fake } = await setup();
|
||||
const rowId = rowOf('logo1');
|
||||
logoFile(fake, 'logo1');
|
||||
fake.swapContentAfterPermissions('logo1', pngHeader(4000, 4000)); // die zweite Prüfung beanstandet das Logo ...
|
||||
let reads = 0;
|
||||
fake.delayNext((c) => c.method === 'GET' && c.path.endsWith('/files/logo1/view') && (reads += 1) === 2, 150); // ... und liest langsam
|
||||
|
||||
const uploading = upload(uploadBody());
|
||||
await until(() => fileReads(fake, 'logo1').length === 2);
|
||||
assert.ok(fake.row('logos', rowId), 'die Zeile steht schon (öffentlich), die zweite Prüfung läuft noch');
|
||||
|
||||
const [up, liked, reported] = await Promise.all([uploading, heart(rowId, true), report(rowId, '203.0.1.9')]);
|
||||
expectError(up, 400, 'logoFormat');
|
||||
expectError(liked, 404, 'logoGone', 'Herz');
|
||||
expectError(reported, 404, 'logoGone', 'Meldung');
|
||||
assert.equal(fake.rows('logos').length, 0);
|
||||
assert.equal(fake.rows('logo_likes').length, 0, 'kein Herz ohne Logo');
|
||||
assert.equal(fake.rows('logo_reports').length, 0, 'keine Meldung ohne Logo');
|
||||
});
|
||||
|
||||
test('Logo hochladen: auch bei der Rücknahme nach einem Fehler (zweites Lesen scheitert) bleibt kein Herz zurück, das in der Zwischenzeit eintraf', async () => {
|
||||
const { upload, heart, fake } = await setup();
|
||||
const rowId = rowOf('logo1');
|
||||
logoFile(fake, 'logo1');
|
||||
let puts = 0;
|
||||
let reads = 0;
|
||||
fake.delayNext((c) => c.method === 'PUT' && c.path.endsWith('/files/logo1') && (puts += 1) === 1, 150); // das Festlegen dauert, die Zeile steht schon
|
||||
fake.failNext((c) => c.method === 'GET' && c.path.endsWith('/files/logo1/view') && (reads += 1) === 2, { status: 500 }); // zweite Prüfung scheitert
|
||||
|
||||
const uploading = upload(uploadBody());
|
||||
await until(() => filePuts(fake, 'logo1').length === 1);
|
||||
assert.ok(fake.row('logos', rowId));
|
||||
|
||||
const [up, liked] = await Promise.all([uploading, heart(rowId, true)]);
|
||||
expectError(up, 502, 'unavailable');
|
||||
expectError(liked, 404, 'logoGone', 'Herz');
|
||||
assert.equal(fake.rows('logos').length, 0);
|
||||
assert.equal(fake.rows('logo_likes').length, 0, 'kein Herz ohne Logo');
|
||||
assert.deepEqual(fake.file('logo1').$permissions, UPLOAD_PERMISSIONS, 'die Datei bekam delete(owner) zurück');
|
||||
});
|
||||
|
||||
test('Logo hochladen: ein Löschen, das während der zweiten Prüfung eintrifft, wartet: der Upload gelingt, danach ist das fertige Logo weg', async () => {
|
||||
const { upload, remove, fake } = await setup();
|
||||
const rowId = rowOf('logo1');
|
||||
logoFile(fake, 'logo1');
|
||||
let reads = 0;
|
||||
fake.delayNext((c) => c.method === 'GET' && c.path.endsWith('/files/logo1/view') && (reads += 1) === 2, 150);
|
||||
|
||||
const done = [];
|
||||
const uploading = upload(uploadBody()).then((res) => (done.push('upload'), res));
|
||||
await until(() => fileReads(fake, 'logo1').length === 2);
|
||||
const deleting = remove(rowId).then((res) => (done.push('delete'), res));
|
||||
|
||||
const [up, del] = await Promise.all([uploading, deleting]);
|
||||
assert.equal(up.status, 201, 'das Löschen nimmt dem Upload nicht mitten in der Prüfung die Datei weg');
|
||||
assert.deepEqual(up.json, { id: rowId });
|
||||
assert.equal(del.status, 200);
|
||||
assert.deepEqual(done, ['upload', 'delete']);
|
||||
assert.equal(fake.row('logos', rowId), null);
|
||||
assert.equal(fake.file('logo1'), null);
|
||||
});
|
||||
|
||||
// ---- Herzen -----------------------------------------------------------------------------------------
|
||||
|
||||
test('Herz: an, nochmal an (bleibt 1), aus, nochmal aus (bleibt 0); Like-Zeile mit ID logoLikeId und Recht read("user:<ich>")', async () => {
|
||||
@@ -821,7 +1019,7 @@ test('Logo melden: drei verschiedene Netze blenden das Logo aus (Zeilen- und Dat
|
||||
assert.deepEqual(res.json, { ok: true });
|
||||
assert.equal(fake.row('logos', 'logo-1').reportCount, 2);
|
||||
assert.deepEqual(fake.row('logos', 'logo-1').$permissions, ['read("any")']);
|
||||
assert.deepEqual(fake.file('logo-1').$permissions, ['read("any")']);
|
||||
assert.deepEqual(fake.file(fileOf('logo-1')).$permissions, ['read("any")']);
|
||||
|
||||
res = await report('logo-1', '203.0.3.9', 'sonstiges');
|
||||
assert.equal(res.status, 200);
|
||||
@@ -830,7 +1028,7 @@ test('Logo melden: drei verschiedene Netze blenden das Logo aus (Zeilen- und Dat
|
||||
assert.equal(row.hidden, true);
|
||||
assert.equal(row.reportCount, 3);
|
||||
assert.deepEqual(row.$permissions, HIDDEN_PERMISSIONS);
|
||||
assert.deepEqual(fake.file('logo-1').$permissions, HIDDEN_PERMISSIONS);
|
||||
assert.deepEqual(fake.file(fileOf('logo-1')).$permissions, HIDDEN_PERMISSIONS);
|
||||
assert.equal(fake.rows('logo_reports').length, 3);
|
||||
|
||||
// ausgeblendetes Logo melden: 404
|
||||
@@ -964,7 +1162,7 @@ test('Logo melden: eine doppelte Meldung heilt einen früher steckengebliebenen
|
||||
assert.deepEqual(res.json, { schonGemeldet: true });
|
||||
assert.equal(fake.row('logos', 'logo-1').hidden, true);
|
||||
assert.equal(fake.row('logos', 'logo-1').reportCount, 3);
|
||||
assert.deepEqual(fake.file('logo-1').$permissions, HIDDEN_PERMISSIONS);
|
||||
assert.deepEqual(fake.file(fileOf('logo-1')).$permissions, HIDDEN_PERMISSIONS);
|
||||
});
|
||||
|
||||
test('Logo melden: Ausfall beim Anlegen der Meldung ist 502 und zählt nicht, Ausfall beim Ausblenden endet mit 502 und der nächste Versuch blendet aus', async () => {
|
||||
@@ -977,13 +1175,13 @@ test('Logo melden: Ausfall beim Anlegen der Meldung ist 502 und zählt nicht, Au
|
||||
|
||||
await report('logo-1', '203.0.1.9');
|
||||
await report('logo-1', '203.0.2.9');
|
||||
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/logo-1'), { status: 500 });
|
||||
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith(`/files/${fileOf('logo-1')}`), { status: 500 });
|
||||
expectError(await report('logo-1', '203.0.3.9'), 502, 'unavailable');
|
||||
assert.equal(fake.row('logos', 'logo-1').hidden, false, 'Dateien zuerst, Zeile zuletzt: die Zeile ist noch sichtbar');
|
||||
const retry = await report('logo-1', '203.0.3.9');
|
||||
assert.equal(retry.status, 200);
|
||||
assert.equal(fake.row('logos', 'logo-1').hidden, true);
|
||||
assert.deepEqual(fake.file('logo-1').$permissions, HIDDEN_PERMISSIONS);
|
||||
assert.deepEqual(fake.file(fileOf('logo-1')).$permissions, HIDDEN_PERMISSIONS);
|
||||
});
|
||||
|
||||
// ---- Löschen ----------------------------------------------------------------------------------------
|
||||
@@ -1001,16 +1199,16 @@ test('Logo löschen durch den Hochlader: Datei, Herzen, Meldungen und Zeile sind
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(res.json, { ok: true });
|
||||
assert.equal(fake.row('logos', 'logo-1'), null);
|
||||
assert.equal(fake.file('logo-1'), null);
|
||||
assert.equal(fake.file(fileOf('logo-1')), null);
|
||||
assert.deepEqual(fake.rows('logo_likes').map((r) => r.logoId), ['logo-2']);
|
||||
assert.deepEqual(fake.rows('logo_reports').map((r) => r.logoId), ['logo-2']);
|
||||
assert.ok(fake.row('logos', 'logo-2'));
|
||||
assert.ok(fake.file('logo-2'));
|
||||
assert.ok(fake.file(fileOf('logo-2')));
|
||||
assert.equal(logs.filter((line) => line.endsWith('logo logo-1 deleted')).length, 1);
|
||||
|
||||
// Reihenfolge: Datei, Herzen, Meldungen, zuletzt die Zeile
|
||||
const order = fake.calls.filter((c) => c.method === 'DELETE').map((c) => c.path.split('/').slice(-3).join('/'));
|
||||
assert.ok(order[0].endsWith('files/logo-1'), order[0]);
|
||||
assert.ok(order[0].endsWith(`files/${fileOf('logo-1')}`), order[0]);
|
||||
assert.ok(order[order.length - 1].endsWith('logos/rows/logo-1'), order[order.length - 1]);
|
||||
});
|
||||
|
||||
@@ -1041,9 +1239,10 @@ test('Logo löschen durch Admin geht, auch bei ausgeblendetem Logo', async () =>
|
||||
const res = await remove('logo-1', JWT_ADMIN);
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(fake.row('logos', 'logo-1'), null);
|
||||
assert.equal(fake.file('logo-1'), null);
|
||||
assert.equal(fake.file(fileOf('logo-1')), null);
|
||||
assert.equal(fake.rows('logo_reports').length, 0);
|
||||
assert.equal(logs.filter((line) => line.endsWith('logo logo-1 deleted by admin')).length, 1);
|
||||
assert.equal(fake.calls.filter((c) => c.method === 'PUT' || c.method === 'PATCH').length, 0, 'ein schon ausgeblendetes Logo wird nicht noch einmal ausgeblendet');
|
||||
});
|
||||
|
||||
test('Logo löschen durch Fremde: 403 forbidden und nichts gelöscht', async () => {
|
||||
@@ -1052,9 +1251,11 @@ test('Logo löschen durch Fremde: 403 forbidden und nichts gelöscht', async ()
|
||||
seedLikes(fake, 'logo-1', ['u1']);
|
||||
expectError(await remove('logo-1', JWT_OTHER), 403, 'forbidden');
|
||||
assert.ok(fake.row('logos', 'logo-1'));
|
||||
assert.ok(fake.file('logo-1'));
|
||||
assert.ok(fake.file(fileOf('logo-1')));
|
||||
assert.equal(fake.rows('logo_likes').length, 1);
|
||||
assert.equal(fake.calls.filter((c) => c.method === 'DELETE').length, 0);
|
||||
assert.deepEqual(fake.row('logos', 'logo-1').$permissions, ['read("any")'], 'auch nicht ausgeblendet: wer nicht löschen darf, ändert nichts');
|
||||
assert.equal(fake.calls.filter((c) => c.method === 'PUT' || c.method === 'PATCH').length, 0);
|
||||
});
|
||||
|
||||
test('Logo löschen: 401 ohne JWT, 404 logoGone bei fehlendem Logo, 400 badLogoId bei ungültiger ID', async () => {
|
||||
@@ -1074,14 +1275,95 @@ test('Logo löschen ist wiederholbar, wenn die Zeile zuletzt scheitert (die Zeil
|
||||
fake.failNext((c) => c.method === 'DELETE' && c.path.includes('/tables/logos/rows/'), { status: 500 });
|
||||
expectError(await remove('logo-1'), 502, 'unavailable');
|
||||
assert.ok(fake.row('logos', 'logo-1'), 'Zeile bleibt, bis alles andere weg ist');
|
||||
assert.equal(fake.file('logo-1'), null);
|
||||
assert.equal(fake.file(fileOf('logo-1')), null);
|
||||
assert.equal(fake.rows('logo_likes').length, 0);
|
||||
assert.deepEqual(fake.row('logos', 'logo-1').$permissions, HIDDEN_PERMISSIONS, 'der Anker ist unsichtbar');
|
||||
|
||||
const retry = await remove('logo-1');
|
||||
assert.equal(retry.status, 200, 'die Datei fehlt schon, das ist kein Fehler');
|
||||
assert.equal(fake.row('logos', 'logo-1'), null);
|
||||
});
|
||||
|
||||
test('Logo löschen: zuerst wird das Logo ausgeblendet (Datei, dann Zeile, genau die ausgeblendeten Rechte), danach folgen Datei, Herzen, Meldungen und zuletzt die Zeile', async () => {
|
||||
const { remove, fake } = await setup();
|
||||
seedLogo(fake);
|
||||
seedLikes(fake, 'logo-1', ['u1']);
|
||||
seedReports(fake, 'logo-1', ['h1']);
|
||||
const file = fileOf('logo-1');
|
||||
assert.equal((await remove('logo-1')).status, 200);
|
||||
|
||||
const at = (predicate) => fake.calls.findIndex(predicate);
|
||||
const hideFile = at((c) => c.method === 'PUT' && c.path.endsWith(`/files/${file}`));
|
||||
const hideRow = at((c) => c.method === 'PATCH' && c.path.endsWith('/tables/logos/rows/logo-1'));
|
||||
const dropFile = at((c) => c.method === 'DELETE' && c.path.endsWith(`/files/${file}`));
|
||||
const dropLike = at((c) => c.method === 'DELETE' && c.path.includes('/tables/logo_likes/rows/'));
|
||||
const dropReport = at((c) => c.method === 'DELETE' && c.path.includes('/tables/logo_reports/rows/'));
|
||||
const dropRow = at((c) => c.method === 'DELETE' && c.path.endsWith('/tables/logos/rows/logo-1'));
|
||||
const order = [hideFile, hideRow, dropFile, dropLike, dropReport, dropRow];
|
||||
assert.ok(order.every((index) => index !== -1), `jeder Schritt kam vor: ${order}`);
|
||||
assert.deepEqual(order, [...order].sort((a, b) => a - b), 'Reihenfolge: Datei ausblenden, Zeile ausblenden, Datei, Herzen, Meldungen, Zeile löschen');
|
||||
assert.deepEqual(fake.calls[hideFile].body, { permissions: HIDDEN_PERMISSIONS });
|
||||
assert.deepEqual(fake.calls[hideRow].body, { data: { hidden: true }, permissions: HIDDEN_PERMISSIONS });
|
||||
assert.equal(fake.row('logos', 'logo-1'), null);
|
||||
assert.equal(fake.file(file), null);
|
||||
});
|
||||
|
||||
test('Logo löschen: bricht es ab, nachdem die Datei gelöscht ist, bleibt höchstens eine unsichtbare Zeile (genau die ausgeblendeten Rechte des Hochladers), die Wiederholung vollendet es', async () => {
|
||||
const failures = [
|
||||
['Herzen lesen', (c) => c.method === 'GET' && c.path.endsWith('/tables/logo_likes/rows')],
|
||||
['Meldungen lesen', (c) => c.method === 'GET' && c.path.endsWith('/tables/logo_reports/rows')],
|
||||
['Zeile löschen', (c) => c.method === 'DELETE' && c.path.includes('/tables/logos/rows/')],
|
||||
];
|
||||
// der Hochlader löscht sein Logo, und ein Admin löscht das Logo eines anderen (die Rechte der Zeile bleiben die des Hochladers)
|
||||
for (const [who, owner, jwt] of [['Hochlader', ME, JWT_ME], ['Admin', OTHER, JWT_ADMIN]]) {
|
||||
for (const [step, failing] of failures) {
|
||||
const label = `${who}, Abbruch bei „${step}“`;
|
||||
const { remove, fake } = await setup();
|
||||
seedLogo(fake, { owner });
|
||||
seedLikes(fake, 'logo-1', ['u1', 'u2']);
|
||||
seedReports(fake, 'logo-1', ['h1']);
|
||||
assert.equal(guestCanRead(fake.row('logos', 'logo-1')), true, `${label}: vorher öffentlich`);
|
||||
|
||||
fake.failNext(failing, { status: 500 });
|
||||
expectError(await remove('logo-1', jwt), 502, 'unavailable', label);
|
||||
|
||||
// die Datei ist weg (unter ihrer ID könnte jemand eine neue anlegen), die Zeile steht noch, aber kein Gast liest sie mehr
|
||||
const row = fake.row('logos', 'logo-1');
|
||||
assert.equal(fake.file(fileOf('logo-1')), null, `${label}: Datei gelöscht`);
|
||||
assert.ok(row, `${label}: die Zeile bleibt als Anker`);
|
||||
assert.equal(row.hidden, true, `${label}: hidden`);
|
||||
assert.deepEqual(row.$permissions, hiddenRowPermissions(owner), `${label}: genau die ausgeblendeten Rechte`);
|
||||
assert.equal(guestCanRead(row), false, `${label}: kein Gast liest die Zeile`);
|
||||
|
||||
// die Wiederholung vollendet das Löschen, ohne noch einmal auszublenden
|
||||
const seen = fake.calls.length;
|
||||
assert.equal((await remove('logo-1', jwt)).status, 200, `${label}: Wiederholung`);
|
||||
assert.equal(fake.row('logos', 'logo-1'), null, `${label}: Zeile weg`);
|
||||
assert.equal(fake.rows('logo_likes').length, 0, `${label}: Herzen weg`);
|
||||
assert.equal(fake.rows('logo_reports').length, 0, `${label}: Meldungen weg`);
|
||||
assert.equal(fake.calls.slice(seen).filter((c) => c.method === 'PUT' || c.method === 'PATCH').length, 0, `${label}: nicht noch einmal ausgeblendet`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Logo löschen: scheitert schon das Ausblenden, ist noch nichts gelöscht (Zeile öffentlich, Datei und Herzen da), die Wiederholung klappt', async () => {
|
||||
const { remove, fake } = await setup();
|
||||
seedLogo(fake);
|
||||
seedLikes(fake, 'logo-1', ['u1']);
|
||||
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith(`/files/${fileOf('logo-1')}`), { status: 500 });
|
||||
expectError(await remove('logo-1'), 502, 'unavailable');
|
||||
assert.deepEqual(fake.row('logos', 'logo-1').$permissions, ['read("any")']);
|
||||
assert.equal(fake.row('logos', 'logo-1').hidden, false);
|
||||
assert.ok(fake.file(fileOf('logo-1')));
|
||||
assert.equal(fake.rows('logo_likes').length, 1);
|
||||
assert.equal(fake.calls.filter((c) => c.method === 'DELETE').length, 0, 'bis hierher wurde nichts gelöscht');
|
||||
|
||||
assert.equal((await remove('logo-1')).status, 200);
|
||||
assert.equal(fake.row('logos', 'logo-1'), null);
|
||||
assert.equal(fake.file(fileOf('logo-1')), null);
|
||||
assert.equal(fake.rows('logo_likes').length, 0);
|
||||
});
|
||||
|
||||
// ---- Admin: Sichtbarkeit ------------------------------------------------------------------------------
|
||||
|
||||
test('Logo-Sichtbarkeit: Nicht-Admin 403 adminOnly, ohne JWT 401, nichts ändert sich', async () => {
|
||||
@@ -1092,7 +1374,7 @@ test('Logo-Sichtbarkeit: Nicht-Admin 403 adminOnly, ohne JWT 401, nichts ändert
|
||||
expectError(await setVisible('logo-1', false, null), 401, 'unauthorized');
|
||||
assert.deepEqual(fake.row('logos', 'logo-1').$permissions, ['read("any")']);
|
||||
assert.equal(fake.row('logos', 'logo-1').hidden, false);
|
||||
assert.deepEqual(fake.file('logo-1').$permissions, ['read("any")']);
|
||||
assert.deepEqual(fake.file(fileOf('logo-1')).$permissions, ['read("any")']);
|
||||
});
|
||||
|
||||
test('Admin blendet ein Logo aus: Zeilen- und Dateirechte, hidden=true, Meldungen und Herzen bleiben', async () => {
|
||||
@@ -1109,7 +1391,7 @@ test('Admin blendet ein Logo aus: Zeilen- und Dateirechte, hidden=true, Meldunge
|
||||
assert.equal(row.reportCount, 1);
|
||||
assert.equal(row.likeCount, 2);
|
||||
assert.deepEqual(row.$permissions, HIDDEN_PERMISSIONS);
|
||||
assert.deepEqual(fake.file('logo-1').$permissions, HIDDEN_PERMISSIONS);
|
||||
assert.deepEqual(fake.file(fileOf('logo-1')).$permissions, HIDDEN_PERMISSIONS);
|
||||
assert.equal(fake.rows('logo_reports').length, 1);
|
||||
assert.equal(fake.rows('logo_likes').length, 2);
|
||||
assert.equal(logs.filter((line) => line.endsWith('logo logo-1 hidden by admin')).length, 1);
|
||||
@@ -1130,7 +1412,7 @@ test('Admin blendet ein Logo ein: sichtbare Rechte, hidden=false, reportCount=0,
|
||||
assert.equal(row.reportCount, 0);
|
||||
assert.equal(row.likeCount, 2);
|
||||
assert.deepEqual(row.$permissions, ['read("any")']);
|
||||
assert.deepEqual(fake.file('logo-1').$permissions, ['read("any")']);
|
||||
assert.deepEqual(fake.file(fileOf('logo-1')).$permissions, ['read("any")']);
|
||||
assert.deepEqual(fake.rows('logo_reports').map((r) => r.logoId), ['logo-2']);
|
||||
assert.equal(fake.rows('logo_likes').length, 2);
|
||||
assert.equal(logs.filter((line) => line.endsWith('logo logo-1 made visible by admin')).length, 1);
|
||||
@@ -1152,7 +1434,7 @@ test('Logo-Sichtbarkeit: Body-Prüfung und fehlendes Logo', async () => {
|
||||
test('Logo-Sichtbarkeit: scheitert das Setzen der Dateirechte, bleibt die Zeile unverändert (Dateien zuerst, Zeile zuletzt)', async () => {
|
||||
const { setVisible, fake } = await setup();
|
||||
seedLogo(fake);
|
||||
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/logo-1'), { status: 500 });
|
||||
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith(`/files/${fileOf('logo-1')}`), { status: 500 });
|
||||
expectError(await setVisible('logo-1', false), 502, 'unavailable');
|
||||
assert.equal(fake.row('logos', 'logo-1').hidden, false);
|
||||
assert.deepEqual(fake.row('logos', 'logo-1').$permissions, ['read("any")']);
|
||||
@@ -1160,6 +1442,63 @@ test('Logo-Sichtbarkeit: scheitert das Setzen der Dateirechte, bleibt die Zeile
|
||||
assert.equal(fake.row('logos', 'logo-1').hidden, true);
|
||||
});
|
||||
|
||||
// ---- Altlogos: Zeilen-ID = Datei-ID (vor dem Hash-Schema angelegt) -------------------------------------------
|
||||
|
||||
test('Altlogo (Zeilen-ID = Datei-ID): Herz, Meldung, Ausblenden und Einblenden gehen weiter über die Zeilen-ID, die Datei über fileId', async () => {
|
||||
const { heart, report, setVisible, fake } = await setup();
|
||||
seedLogo(fake, { id: 'alt1', fileId: 'alt1' });
|
||||
|
||||
assert.deepEqual((await heart('alt1', true)).json, { herz: true, likeCount: 1 });
|
||||
assert.deepEqual((await heart('alt1', true, JWT_OTHER)).json, { herz: true, likeCount: 2 });
|
||||
assert.deepEqual((await heart('alt1', false)).json, { herz: false, likeCount: 1 });
|
||||
assert.equal(fake.row('logos', 'alt1').likeCount, 1);
|
||||
|
||||
assert.deepEqual((await report('alt1', '203.0.1.9')).json, { ok: true });
|
||||
assert.equal(fake.row('logos', 'alt1').reportCount, 1);
|
||||
|
||||
assert.equal((await setVisible('alt1', false)).status, 200);
|
||||
assert.equal(fake.row('logos', 'alt1').hidden, true);
|
||||
assert.deepEqual(fake.file('alt1').$permissions, HIDDEN_PERMISSIONS);
|
||||
expectError(await heart('alt1', true, JWT_ADMIN), 404, 'logoGone', 'ausgeblendet');
|
||||
assert.equal((await setVisible('alt1', true)).status, 200);
|
||||
assert.equal(fake.row('logos', 'alt1').hidden, false);
|
||||
assert.deepEqual(fake.file('alt1').$permissions, ['read("any")']);
|
||||
});
|
||||
|
||||
test('Altlogo löschen: Datei, Herzen, Meldungen und Zeile sind weg, die alte ID lässt sich danach nicht neu belegen', async () => {
|
||||
const { heart, report, remove, upload, fake } = await setup();
|
||||
seedLogo(fake, { id: 'alt1', fileId: 'alt1' });
|
||||
seedLogo(fake, { id: 'alt2', fileId: 'alt2', owner: OTHER }); // bleibt unberührt
|
||||
await heart('alt1', true);
|
||||
await report('alt1', '203.0.1.9');
|
||||
assert.equal(fake.rows('logo_likes').length, 1);
|
||||
assert.equal(fake.rows('logo_reports').length, 1);
|
||||
|
||||
assert.equal((await remove('alt1')).status, 200);
|
||||
assert.equal(fake.row('logos', 'alt1'), null);
|
||||
assert.equal(fake.file('alt1'), null);
|
||||
assert.equal(fake.rows('logo_likes').length, 0);
|
||||
assert.equal(fake.rows('logo_reports').length, 0);
|
||||
assert.ok(fake.row('logos', 'alt2'));
|
||||
assert.ok(fake.file('alt2'));
|
||||
|
||||
// jemand legt eine Datei mit der alten ID an und lädt sie hoch: es entsteht eine Zeile mit neuer ID, die alte bleibt frei
|
||||
logoFile(fake, 'alt1', OTHER);
|
||||
const res = await upload(uploadBody({ fileId: 'alt1' }), JWT_OTHER);
|
||||
assert.equal(res.status, 201);
|
||||
assert.equal(res.json.id, rowOf('alt1', OTHER));
|
||||
assert.equal(fake.row('logos', 'alt1'), null);
|
||||
});
|
||||
|
||||
test('Altlogo: dieselbe Datei noch einmal hochladen ist 409 logoTaken (die Zeile trägt sie schon), keine zweite Zeile', async () => {
|
||||
const { upload, fake } = await setup();
|
||||
seedLogo(fake, { id: 'alt1', fileId: 'alt1' });
|
||||
expectError(await upload(uploadBody({ fileId: 'alt1' })), 409, 'logoTaken');
|
||||
assert.equal(fake.rows('logos').length, 1);
|
||||
assert.deepEqual(fake.file('alt1').$permissions, ['read("any")']);
|
||||
assert.equal(filePuts(fake, 'alt1').length, 0);
|
||||
});
|
||||
|
||||
// ---- Routen, Logs, Fehler ---------------------------------------------------------------------------
|
||||
|
||||
test('Logo-Routen: nur POST mit application/json (sonst 404 bzw. 415), Appwrite wird dabei nicht berührt', async () => {
|
||||
@@ -1215,11 +1554,11 @@ test('Logs der Logo-Endpunkte enthalten nie Key, Salz, JWT, IPs oder Namen, aber
|
||||
await report('logo-9', '203.0.9.9');
|
||||
await setVisible('logo-9', true);
|
||||
fake.failNext((c) => c.method === 'GET' && c.path.includes('/tables/logos/rows/'), { status: 500 });
|
||||
await remove('logo1');
|
||||
await remove('logo1');
|
||||
await remove(rowOf('logo1'));
|
||||
await remove(rowOf('logo1'));
|
||||
|
||||
assert.ok(logs.length > 0);
|
||||
assert.ok(logs.some((line) => line.endsWith('logo logo2 removed after upload check (logoFormat)')));
|
||||
assert.ok(logs.some((line) => line.endsWith(`logo ${rowOf('logo2')} removed after upload check (logoFormat)`)));
|
||||
const all = logs.join('\n');
|
||||
for (const secret of [fake.key, SALT, JWT_ME, JWT_OTHER, JWT_ADMIN, 'xxx.yyy.zzz', '203.0.', '127.0.0.1', reporterHash(SALT, '203.0.7.9'), 'GEHEIMER']) {
|
||||
assert.ok(!all.includes(secret), `Log enthält ${secret.slice(0, 12)}…`);
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Benutzernamen im API-Dienst `aircard-api` (Node 22, nur Bordmittel): ein Name je Konto, eindeutig ohne Rücksicht auf Groß- und
|
||||
* Kleinschreibung. app.js hängt die Route an und gibt Anmeldung, Sperre je Schlüssel und das Log mit (wie bei logos.js).
|
||||
*
|
||||
* POST /api/profil Nutzer-JWT {username}
|
||||
* 401 · 400 `usernameInvalid` (Muster) · 400 `usernameReserved` (Sperrliste) · 409 `usernameTaken` (ein anderes Konto hat den
|
||||
* Namen) · 200 {username}
|
||||
* Legt die Zeile des Kontos an (Zeilen-ID = Konto-ID, Recht genau read("any")) oder ändert sie: der alte Name ist damit sofort
|
||||
* wieder frei. Die eigene Schreibweise zu ändern („kenso“ → „Kenso“) ist erlaubt.
|
||||
*
|
||||
* Tabelle `profiles`: `username` (der Name, wie eingegeben) und `usernameKey` (klein geschrieben). Eindeutig ist `usernameKey`: die
|
||||
* Vorabprüfung fängt den Normalfall mit einer klaren Antwort ab, bei einem Rennen zweier Konten entscheidet der Unique-Index
|
||||
* `username_key` (Appwrite antwortet 409, das wird ebenfalls `usernameTaken`). Sperre je Konto: `profile:<Konto>`.
|
||||
* Hochladen und Veröffentlichen verlangen ein Profil (`hasProfile`, 403 `usernameRequired`), siehe logos.js und app.js.
|
||||
* Logs nennen nie Namen, Schlüssel, Konto-IDs oder JWTs: nur „profile created“ bzw. „profile updated“.
|
||||
*/
|
||||
|
||||
const { queryEqual, queryLimit } = require('./appwrite.js');
|
||||
const {
|
||||
MSG,
|
||||
USERNAME_PATTERN,
|
||||
HttpError,
|
||||
isDuplicate,
|
||||
parseJsonObject,
|
||||
visibleRowPermissions,
|
||||
usernameKey,
|
||||
isReservedUsername,
|
||||
} = require('./common.js');
|
||||
|
||||
const TABLE_PROFILES = 'profiles';
|
||||
|
||||
/**
|
||||
* Hat das Konto einen Benutzernamen? Die Zeilen-ID des Profils ist die Konto-ID. Ein Appwrite-Fehler wird nicht verschluckt
|
||||
* (auch eine fehlende Tabelle nicht): der Aufrufer antwortet dann mit 502 bzw. 500, nie mit „kein Profil“.
|
||||
* @param {object} appwrite Objekt aus createAppwrite
|
||||
* @param {string} userId Konto-ID
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function hasProfile(appwrite, userId) {
|
||||
return (await appwrite.getRow(TABLE_PROFILES, userId)) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {object} options.appwrite Objekt aus createAppwrite
|
||||
* @param {(message: string) => void} options.say Ereigniszeile ins Log (mit Zeilenbudget)
|
||||
* @param {(key: string, fn: () => Promise<any>) => Promise<any>} options.withLock Je Schlüssel nacheinander
|
||||
* @param {(req) => Promise<{id: string, isAdmin: boolean}>} options.requireUser wirft 401 ohne gültiges JWT
|
||||
* @returns {Array<[string, {name: string, json: true, run: (ctx: {req, raw: string}) => Promise<{status: number, body: object}>}]>}
|
||||
*/
|
||||
function createProfileRoutes({ appwrite, say, withLock, requireUser }) {
|
||||
async function saveProfile({ req, raw }) {
|
||||
const user = await requireUser(req);
|
||||
const body = parseJsonObject(raw);
|
||||
// Kein Trimmen: das Muster lässt keine Leerzeichen zu, der Client schickt den Namen schon bereinigt.
|
||||
const { username } = body;
|
||||
if (typeof username !== 'string' || !USERNAME_PATTERN.test(username)) throw new HttpError(400, MSG.usernameInvalid);
|
||||
if (isReservedUsername(username)) throw new HttpError(400, MSG.usernameReserved);
|
||||
const key = usernameKey(username);
|
||||
|
||||
// Je Konto nacheinander: Nachsehen, ob es schon eine Zeile gibt, und Anlegen bzw. Ändern dürfen sich nicht überholen
|
||||
// (zwei Anfragen desselben Kontos würden sonst beide anlegen wollen und die zweite fälschlich „vergeben“ melden).
|
||||
return withLock(`profile:${user.id}`, async () => {
|
||||
// Vorabprüfung: hat ein anderes Konto diesen Schlüssel? Der eigene Eintrag zählt nicht (nur die Schreibweise ändern ist erlaubt).
|
||||
const holders = await appwrite.listRows(TABLE_PROFILES, [queryEqual('usernameKey', [key]), queryLimit(1)]);
|
||||
if (holders.some((row) => row.$id !== user.id)) throw new HttpError(409, MSG.usernameTaken);
|
||||
|
||||
const own = await appwrite.getRow(TABLE_PROFILES, user.id);
|
||||
try {
|
||||
if (own) await appwrite.updateRow(TABLE_PROFILES, user.id, { username, usernameKey: key });
|
||||
else await appwrite.createRow(TABLE_PROFILES, user.id, { username, usernameKey: key }, visibleRowPermissions());
|
||||
} catch (err) {
|
||||
// Ein anderes Konto war schneller: der Unique-Index auf usernameKey meldet 409, beim Anlegen wie beim Ändern. Ein 409 wegen der
|
||||
// eigenen Zeilen-ID gibt es nicht: unter der Sperre des Kontos gibt es die Zeile jetzt (dann ändern wir) oder nicht (dann legen wir an).
|
||||
if (isDuplicate(err)) throw new HttpError(409, MSG.usernameTaken);
|
||||
throw err;
|
||||
}
|
||||
say(own ? 'profile updated' : 'profile created');
|
||||
return { status: 200, body: { username } };
|
||||
});
|
||||
}
|
||||
|
||||
return [['POST /api/profil', { name: 'profil', json: true, run: saveProfile }]];
|
||||
}
|
||||
|
||||
module.exports = { createProfileRoutes, hasProfile };
|
||||
@@ -0,0 +1,496 @@
|
||||
'use strict';
|
||||
|
||||
// Tests für deploy/api/profiles.js (Benutzernamen): echte HTTP-Anfragen auf Port 0 gegen createApp, dahinter ein Fake-Appwrite im
|
||||
// Speicher (fake-appwrite.js, Tabelle `profiles` mit Unique-Index auf `usernameKey`) und das echte appwrite.js.
|
||||
// Aufruf: node --test "deploy/api/*.test.js"
|
||||
|
||||
const { test, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const http = require('node:http');
|
||||
|
||||
const { createAppwrite, AppwriteError } = require('./appwrite.js');
|
||||
const { startFakeAppwrite } = require('./fake-appwrite.js');
|
||||
const { createApp } = require('./app.js');
|
||||
const { createProfileRoutes, hasProfile } = require('./profiles.js');
|
||||
const { MSG, USERNAME_PATTERN, RESERVED_USERNAMES, usernameKey, isReservedUsername } = require('./common.js');
|
||||
|
||||
const SALT = 'test-salt-0123456789abcdef0123456789abcdef';
|
||||
const ME = 'user-me';
|
||||
const OTHER = 'user-other';
|
||||
const JWT_ME = 'eyJhbGciOiJI.payload-me.signature-me';
|
||||
const JWT_OTHER = 'eyJhbGciOiJI.payload-other.signature-other';
|
||||
|
||||
/** Die Sperrliste der Spec (§ 2), wörtlich: der Test hält sie fest, damit eine Änderung auffällt. */
|
||||
const SPEC_RESERVED = [
|
||||
'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',
|
||||
];
|
||||
|
||||
// ---- Aufbau ---------------------------------------------------------------------------------
|
||||
|
||||
const cleanups = [];
|
||||
afterEach(async () => {
|
||||
for (const fn of cleanups.splice(0).reverse()) await fn();
|
||||
});
|
||||
|
||||
async function setup() {
|
||||
const fake = await startFakeAppwrite();
|
||||
fake.addUser(JWT_ME, { id: ME });
|
||||
fake.addUser(JWT_OTHER, { id: OTHER });
|
||||
|
||||
const logs = [];
|
||||
const appwrite = createAppwrite({ endpoint: fake.endpoint, project: fake.project, key: fake.key });
|
||||
const app = createApp({ appwrite, salt: SALT, log: (line) => logs.push(String(line)) });
|
||||
const server = http.createServer(app);
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const { port } = server.address();
|
||||
|
||||
cleanups.push(async () => {
|
||||
server.closeAllConnections();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
await fake.close();
|
||||
});
|
||||
|
||||
const call = (method, path, options) => request(port, method, path, options);
|
||||
const post = (path, body, jwt) => call('POST', path, { body, headers: jwt ? { 'X-Aircard-Jwt': jwt } : {} });
|
||||
/** POST /api/profil mit dem Namen (Standard: als ME). */
|
||||
const profile = (username, jwt = JWT_ME) => post('/api/profil', { username }, jwt);
|
||||
|
||||
return { fake, appwrite, logs, call, post, profile };
|
||||
}
|
||||
|
||||
function request(port, method, path, { headers = {}, body, raw, contentType = 'application/json' } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const payload = raw !== undefined ? raw : body === undefined ? undefined : JSON.stringify(body);
|
||||
const requestHeaders = { ...headers };
|
||||
if (payload !== undefined && contentType) requestHeaders['Content-Type'] = contentType;
|
||||
if (payload !== undefined) requestHeaders['Content-Length'] = Buffer.byteLength(payload);
|
||||
const req = http.request({ host: '127.0.0.1', port, method, path, headers: requestHeaders }, (res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (chunk) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
const text = Buffer.concat(chunks).toString('utf8');
|
||||
let json = null;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
// kein JSON
|
||||
}
|
||||
resolve({ status: res.statusCode, headers: res.headers, text, json });
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
if (payload !== undefined) req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/** Erwartet Status und genau {error: <Text des MSG-Schlüssels>, code: <Schlüssel>}. */
|
||||
function expectError(res, status, code, label = code) {
|
||||
assert.equal(res.status, status, `${label}: Status`);
|
||||
assert.deepEqual(res.json, { error: MSG[code], code }, label);
|
||||
}
|
||||
|
||||
const nonAccountCalls = (fake) => fake.calls.filter((c) => c.path !== '/v1/account');
|
||||
/** Aufrufe bei Appwrite auf `profiles`: Zeile anlegen, per Abfrage suchen (die Vorabprüfung), Zeile ändern. */
|
||||
const isCreate = (c) => c.method === 'POST' && c.path.endsWith('/tables/profiles/rows');
|
||||
const isList = (c) => c.method === 'GET' && c.path.endsWith('/tables/profiles/rows');
|
||||
const isUpdate = (c) => c.method === 'PATCH' && c.path.includes('/tables/profiles/rows/');
|
||||
|
||||
/** Wartet (höchstens etwa 2 s), bis `condition()` stimmt: so fängt ein Test den Moment ab, in dem ein Aufruf bei Appwrite noch läuft. */
|
||||
async function until(condition) {
|
||||
for (let i = 0; i < 400 && !condition(); i += 1) await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
assert.ok(condition(), 'der erwartete Zustand wurde nicht erreicht');
|
||||
}
|
||||
|
||||
/**
|
||||
* Zwei Anfragen, die beide vorab geprüft haben („Name frei“), bevor eine davon schreibt: das Schreiben (`isWrite`) wird festgehalten, bis
|
||||
* beide Abfragen bei Appwrite angekommen sind. So entscheidet erst der Unique-Index, nicht die Vorabprüfung.
|
||||
*/
|
||||
async function raceTwo(fake, isWrite, first, second) {
|
||||
const releases = [fake.holdNext(isWrite), fake.holdNext(isWrite)];
|
||||
const pending = Promise.all([first(), second()]);
|
||||
await until(() => fake.calls.filter(isList).length === 2);
|
||||
for (const release of releases) release();
|
||||
return pending;
|
||||
}
|
||||
|
||||
// ---- Reine Helfer (common.js) -----------------------------------------------------------------------
|
||||
|
||||
test('USERNAME_PATTERN: 3–20 Zeichen, erstes ein Buchstabe oder eine Zahl, danach Buchstaben, Zahlen, Punkt, Unterstrich, Bindestrich', () => {
|
||||
assert.equal(USERNAME_PATTERN.source, '^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$');
|
||||
for (const ok of ['abc', 'Kenso', 'k3nso', '007', 'a.b', 'a_b', 'a-b', 'A.b_c-d9', 'a'.repeat(20), 'Z9.']) {
|
||||
assert.ok(USERNAME_PATTERN.test(ok), ok);
|
||||
}
|
||||
const bad = ['', 'a', 'ab', '_abc', '.abc', '-abc', 'a'.repeat(21), 'a b', ' abc', 'abc ', 'abc\n', 'a\nbc', 'Jürgen', 'abc', 'a@b', 'a/b', 'a+b', 'abc😀'];
|
||||
for (const name of bad) assert.ok(!USERNAME_PATTERN.test(name), JSON.stringify(name));
|
||||
});
|
||||
|
||||
test('usernameKey: der Name klein geschrieben, sonst unverändert', () => {
|
||||
assert.equal(usernameKey('Kenso'), 'kenso');
|
||||
assert.equal(usernameKey('AirCard-IOS'), 'aircard-ios');
|
||||
assert.equal(usernameKey('a.B_c-D9'), 'a.b_c-d9');
|
||||
assert.equal(usernameKey('kenso'), 'kenso');
|
||||
});
|
||||
|
||||
test('Sperrliste: genau die Schlüssel der Spec (§ 2), gesperrt in jeder Schreibweise; andere Namen sind frei', () => {
|
||||
assert.deepEqual([...RESERVED_USERNAMES].sort(), [...SPEC_RESERVED].sort());
|
||||
for (const key of SPEC_RESERVED) {
|
||||
assert.ok(isReservedUsername(key), key);
|
||||
assert.ok(isReservedUsername(key.toUpperCase()), key.toUpperCase());
|
||||
assert.ok(isReservedUsername(`${key[0].toUpperCase()}${key.slice(1)}`), `${key} mit großem Anfang`);
|
||||
// jeder gesperrte Name passt zum Muster (sonst wäre der Eintrag wirkungslos: er scheiterte schon als „ungültig“)
|
||||
assert.match(key, USERNAME_PATTERN);
|
||||
}
|
||||
for (const free of ['kenso', 'admins', 'admin1', 'root_', 'air-card-ios', 'aircard-ios2', 'team.x', 'xinfo', 'helpme']) {
|
||||
assert.ok(!isReservedUsername(free), free);
|
||||
}
|
||||
});
|
||||
|
||||
test('MSG: die vier Benutzernamen-Texte stehen wörtlich fest (Spec § 4)', () => {
|
||||
assert.equal(
|
||||
MSG.usernameInvalid,
|
||||
'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.',
|
||||
);
|
||||
assert.equal(MSG.usernameReserved, 'Dieser Benutzername ist nicht erlaubt.');
|
||||
assert.equal(MSG.usernameTaken, 'Dieser Benutzername ist schon vergeben.');
|
||||
assert.equal(MSG.usernameRequired, 'Bitte wähle zuerst einen Benutzernamen.');
|
||||
});
|
||||
|
||||
test('createProfileRoutes liefert genau die Route POST /api/profil mit Name, json und run', () => {
|
||||
const routes = createProfileRoutes({
|
||||
appwrite: {},
|
||||
say: () => {},
|
||||
withLock: (key, fn) => fn(),
|
||||
requireUser: async () => ({ id: ME, isAdmin: false }),
|
||||
});
|
||||
assert.deepEqual(
|
||||
routes.map(([key]) => key),
|
||||
['POST /api/profil'],
|
||||
);
|
||||
const [[, route]] = routes;
|
||||
assert.equal(route.name, 'profil');
|
||||
assert.equal(route.json, true);
|
||||
assert.equal(typeof route.run, 'function');
|
||||
});
|
||||
|
||||
test('hasProfile: true mit Zeile (Zeilen-ID = Konto-ID), false ohne; ein Appwrite-Fehler wird nicht verschluckt', async () => {
|
||||
const { fake, appwrite } = await setup();
|
||||
assert.equal(await hasProfile(appwrite, ME), false);
|
||||
fake.addProfile(ME, 'Kenso');
|
||||
assert.equal(await hasProfile(appwrite, ME), true);
|
||||
assert.equal(await hasProfile(appwrite, OTHER), false, 'ein anderes Konto hat nichts davon');
|
||||
|
||||
fake.failNext((c) => c.method === 'GET' && c.path.endsWith('/tables/profiles/rows/user-other'), { status: 500 });
|
||||
await assert.rejects(hasProfile(appwrite, OTHER), (err) => err instanceof AppwriteError && err.status === 500);
|
||||
// fehlt die Tabelle ganz (Schema nicht eingespielt), ist das ein Fehler und kein „kein Profil“
|
||||
fake.failNext((c) => c.method === 'GET' && c.path.endsWith('/tables/profiles/rows/user-other'), { status: 404, type: 'table_not_found' });
|
||||
await assert.rejects(hasProfile(appwrite, OTHER), (err) => err instanceof AppwriteError && err.status === 404);
|
||||
});
|
||||
|
||||
test('Fake-Appwrite: der Unique-Index auf usernameKey weist eine zweite Zeile (Anlegen und Ändern) mit 409 ab', async () => {
|
||||
const { fake, appwrite } = await setup();
|
||||
const isConflict = (err) => err instanceof AppwriteError && err.status === 409;
|
||||
await appwrite.createRow('profiles', 'a', { username: 'Kenso', usernameKey: 'kenso' }, ['read("any")']);
|
||||
await assert.rejects(appwrite.createRow('profiles', 'b', { username: 'KENSO', usernameKey: 'kenso' }, ['read("any")']), isConflict);
|
||||
assert.equal(fake.row('profiles', 'b'), null);
|
||||
|
||||
await appwrite.createRow('profiles', 'b', { username: 'Other', usernameKey: 'other' }, ['read("any")']);
|
||||
await assert.rejects(appwrite.updateRow('profiles', 'b', { username: 'Kenso', usernameKey: 'kenso' }), isConflict);
|
||||
assert.equal(fake.row('profiles', 'b').usernameKey, 'other');
|
||||
// die eigene Zeile ändern (nur die Schreibweise) ist kein Verstoß
|
||||
await appwrite.updateRow('profiles', 'a', { username: 'KENSO', usernameKey: 'kenso' });
|
||||
assert.equal(fake.row('profiles', 'a').username, 'KENSO');
|
||||
// Spaltenlängen wie in der echten Tabelle (username und usernameKey: 20)
|
||||
await assert.rejects(appwrite.createRow('profiles', 'c', { username: 'x'.repeat(21), usernameKey: 'x' }, []), (err) => err.status === 400);
|
||||
});
|
||||
|
||||
// ---- Anlegen und Ändern ----------------------------------------------------------------------------
|
||||
|
||||
test('Profil anlegen: 200 {username}, Zeilen-ID = Konto-ID, Rechte genau read("any"), usernameKey klein, der Name wie eingegeben', async () => {
|
||||
const { profile, fake } = await setup();
|
||||
const res = await profile('Kenso.IOS');
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(res.json, { username: 'Kenso.IOS' });
|
||||
|
||||
assert.equal(fake.rows('profiles').length, 1);
|
||||
const row = fake.row('profiles', ME);
|
||||
assert.equal(row.username, 'Kenso.IOS');
|
||||
assert.equal(row.usernameKey, 'kenso.ios');
|
||||
assert.deepEqual(row.$permissions, ['read("any")']);
|
||||
|
||||
// der Server-Key hängt an jedem Datenaufruf, das JWT nur am Konto-Aufruf
|
||||
assert.equal(fake.calls.find((c) => c.path === '/v1/account').headers['x-appwrite-jwt'], JWT_ME);
|
||||
for (const c of nonAccountCalls(fake)) {
|
||||
assert.equal(c.headers['x-appwrite-key'], fake.key);
|
||||
assert.equal(c.headers['x-appwrite-jwt'], undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test('Profil anlegen: die Zeile gehört dem Konto aus dem JWT, andere Felder im Body (Konto-ID, Schlüssel, Rechte) werden ignoriert', async () => {
|
||||
const { post, fake } = await setup();
|
||||
const res = await post(
|
||||
'/api/profil',
|
||||
{ username: 'Kenso', usernameKey: 'anders', userId: OTHER, ownerId: OTHER, $id: OTHER, $permissions: ['read("any")', 'update("any")'] },
|
||||
JWT_ME,
|
||||
);
|
||||
assert.deepEqual(res.json, { username: 'Kenso' });
|
||||
assert.equal(fake.row('profiles', OTHER), null);
|
||||
const row = fake.row('profiles', ME);
|
||||
assert.equal(row.usernameKey, 'kenso');
|
||||
assert.deepEqual(row.$permissions, ['read("any")']);
|
||||
assert.deepEqual(Object.keys(row).filter((key) => !key.startsWith('$')).sort(), ['username', 'usernameKey']);
|
||||
});
|
||||
|
||||
test('Profil anlegen an den Grenzen: 3 und 20 Zeichen gehen', async () => {
|
||||
const { profile, fake } = await setup();
|
||||
assert.deepEqual((await profile('abc')).json, { username: 'abc' });
|
||||
assert.deepEqual((await profile('X'.repeat(20), JWT_OTHER)).json, { username: 'X'.repeat(20) });
|
||||
assert.equal(fake.row('profiles', OTHER).usernameKey, 'x'.repeat(20));
|
||||
});
|
||||
|
||||
test('Profil ändern: dieselbe Zeile wird aktualisiert, der alte Schlüssel ist sofort für ein anderes Konto frei, der neue vergeben', async () => {
|
||||
const { profile, fake } = await setup();
|
||||
assert.equal((await profile('Kenso')).status, 200);
|
||||
expectError(await profile('kenso', JWT_OTHER), 409, 'usernameTaken', 'solange der Name vergeben ist');
|
||||
|
||||
const res = await profile('Kenso.neu');
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(res.json, { username: 'Kenso.neu' });
|
||||
assert.equal(fake.rows('profiles').length, 1, 'eine Zeile je Konto');
|
||||
assert.equal(fake.row('profiles', ME).username, 'Kenso.neu');
|
||||
assert.equal(fake.row('profiles', ME).usernameKey, 'kenso.neu');
|
||||
assert.deepEqual(fake.row('profiles', ME).$permissions, ['read("any")']);
|
||||
assert.equal(fake.calls.filter(isCreate).length, 1, 'beim Ändern wird nichts neu angelegt');
|
||||
assert.equal(fake.calls.filter(isUpdate).length, 1);
|
||||
|
||||
// der alte Name ist frei: ein anderes Konto nimmt ihn, das alte Konto bekommt ihn nicht mehr zurück
|
||||
assert.deepEqual((await profile('kenso', JWT_OTHER)).json, { username: 'kenso' });
|
||||
expectError(await profile('Kenso'), 409, 'usernameTaken', 'zurück');
|
||||
assert.equal(fake.row('profiles', ME).username, 'Kenso.neu', 'nach dem Fehlschlag bleibt das Profil, wie es war');
|
||||
assert.equal(fake.row('profiles', OTHER).usernameKey, 'kenso');
|
||||
});
|
||||
|
||||
test('„kenso“ → „Kenso“ beim selben Konto ist erlaubt (nur die Schreibweise), ebenso derselbe Name nochmal', async () => {
|
||||
const { profile, fake } = await setup();
|
||||
assert.deepEqual((await profile('kenso')).json, { username: 'kenso' });
|
||||
assert.deepEqual((await profile('Kenso')).json, { username: 'Kenso' });
|
||||
assert.equal(fake.row('profiles', ME).username, 'Kenso');
|
||||
assert.equal(fake.row('profiles', ME).usernameKey, 'kenso');
|
||||
assert.deepEqual((await profile('Kenso')).json, { username: 'Kenso' }, 'derselbe Name nochmal');
|
||||
assert.deepEqual((await profile('KENSO')).json, { username: 'KENSO' });
|
||||
assert.equal(fake.rows('profiles').length, 1);
|
||||
assert.equal(fake.row('profiles', ME).username, 'KENSO');
|
||||
});
|
||||
|
||||
test('vergebener Name (in jeder Schreibweise): 409 usernameTaken, es wird nichts angelegt oder geändert', async () => {
|
||||
const { profile, fake } = await setup();
|
||||
fake.addProfile(OTHER, 'Kenso');
|
||||
for (const name of ['kenso', 'KENSO', 'Kenso', 'kEnSo']) expectError(await profile(name), 409, 'usernameTaken', name);
|
||||
assert.equal(fake.row('profiles', ME), null, 'kein Profil angelegt');
|
||||
assert.equal(fake.rows('profiles').length, 1);
|
||||
|
||||
// wer schon ein Profil hat, behält es
|
||||
fake.addProfile(ME, 'Me.me');
|
||||
expectError(await profile('kenso'), 409, 'usernameTaken');
|
||||
assert.equal(fake.row('profiles', ME).username, 'Me.me');
|
||||
assert.equal(fake.row('profiles', OTHER).username, 'Kenso', 'das andere Profil ist unberührt');
|
||||
});
|
||||
|
||||
// ---- Gleichzeitigkeit --------------------------------------------------------------------------------
|
||||
|
||||
test('Rennen: zwei Konten wollen gleichzeitig denselben Namen – genau eines bekommt ihn, das andere 409 (Unique-Index, nicht nur die Vorabprüfung)', async () => {
|
||||
const { profile, fake } = await setup();
|
||||
const results = await raceTwo(
|
||||
fake,
|
||||
isCreate,
|
||||
() => profile('Rennen', JWT_ME),
|
||||
() => profile('rennen', JWT_OTHER),
|
||||
);
|
||||
|
||||
assert.deepEqual(results.map((res) => res.status).sort(), [200, 409]);
|
||||
const winner = results.findIndex((res) => res.status === 200);
|
||||
const loser = results.find((res) => res.status === 409);
|
||||
assert.deepEqual(loser.json, { error: MSG.usernameTaken, code: 'usernameTaken' });
|
||||
|
||||
assert.equal(fake.rows('profiles').length, 1, 'genau eine Zeile');
|
||||
const [row] = fake.rows('profiles');
|
||||
assert.equal(row.$id, winner === 0 ? ME : OTHER);
|
||||
assert.equal(row.usernameKey, 'rennen');
|
||||
assert.equal(results[winner].json.username, row.username);
|
||||
// beide Vorabprüfungen haben den Namen für frei gehalten und beide haben angelegt: erst der Unique-Index hat entschieden
|
||||
assert.equal(fake.calls.filter(isCreate).length, 2);
|
||||
});
|
||||
|
||||
test('Rennen beim Umbenennen: zwei Konten wollen denselben neuen Namen – genau eines bekommt ihn, das andere behält seinen alten', async () => {
|
||||
const { profile, fake } = await setup();
|
||||
fake.addProfile(ME, 'Alt.eins');
|
||||
fake.addProfile(OTHER, 'Alt.zwei');
|
||||
const results = await raceTwo(
|
||||
fake,
|
||||
isUpdate,
|
||||
() => profile('Gemeinsam', JWT_ME),
|
||||
() => profile('GEMEINSAM', JWT_OTHER),
|
||||
);
|
||||
|
||||
assert.deepEqual(results.map((res) => res.status).sort(), [200, 409]);
|
||||
assert.deepEqual(results.find((res) => res.status === 409).json, { error: MSG.usernameTaken, code: 'usernameTaken' });
|
||||
assert.equal(fake.calls.filter(isUpdate).length, 2, 'beide haben ändern wollen: erst der Unique-Index hat entschieden');
|
||||
const [winner, loser] = results[0].status === 200 ? [ME, OTHER] : [OTHER, ME];
|
||||
assert.equal(fake.row('profiles', winner).usernameKey, 'gemeinsam');
|
||||
assert.equal(fake.row('profiles', loser).username, loser === ME ? 'Alt.eins' : 'Alt.zwei', 'wer verliert, behält den alten Namen');
|
||||
assert.equal(fake.rows('profiles').length, 2);
|
||||
});
|
||||
|
||||
test('Sperre je Konto (profile:<id>): zwei gleichzeitige Anfragen desselben Kontos laufen nacheinander, beide gelingen, am Ende gilt die zweite', async () => {
|
||||
const { profile, fake } = await setup();
|
||||
const release = fake.holdNext(isCreate);
|
||||
const first = profile('Alpha');
|
||||
await until(() => fake.calls.some(isCreate)); // die erste steht beim Anlegen
|
||||
const second = profile('Beta');
|
||||
await until(() => fake.calls.filter((c) => c.path === '/v1/account').length === 2); // die zweite ist angemeldet ...
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
assert.equal(fake.calls.filter(isList).length, 1, '... wartet aber auf die Sperre, statt schon abzufragen');
|
||||
|
||||
release();
|
||||
assert.deepEqual((await first).json, { username: 'Alpha' });
|
||||
assert.deepEqual((await second).json, { username: 'Beta' });
|
||||
assert.equal(fake.rows('profiles').length, 1);
|
||||
assert.equal(fake.row('profiles', ME).username, 'Beta');
|
||||
assert.equal(fake.row('profiles', ME).usernameKey, 'beta');
|
||||
assert.equal(fake.calls.filter(isCreate).length, 1, 'die zweite hat die Zeile der ersten geändert, nicht noch einmal angelegt');
|
||||
assert.equal(fake.calls.filter(isUpdate).length, 1);
|
||||
});
|
||||
|
||||
// ---- Prüfungen -----------------------------------------------------------------------------------------
|
||||
|
||||
test('gesperrte Namen (Spec § 2, in jeder Schreibweise): 400 usernameReserved, ohne Abfrage bei Appwrite', async () => {
|
||||
const { profile, fake } = await setup();
|
||||
for (const name of ['Admin', 'AirCard-IOS', 'AIRCARD.IOS', 'Aircard_Ios', 'ROOT', 'Null', ...SPEC_RESERVED]) {
|
||||
expectError(await profile(name), 400, 'usernameReserved', name);
|
||||
}
|
||||
assert.equal(fake.rows('profiles').length, 0);
|
||||
assert.equal(nonAccountCalls(fake).length, 0, 'die Prüfung braucht Appwrite nur für das JWT');
|
||||
});
|
||||
|
||||
test('ungültige Namen (Muster): 400 usernameInvalid, nichts angelegt, ohne Abfrage bei Appwrite', async () => {
|
||||
const { profile, fake } = await setup();
|
||||
const cases = [
|
||||
['zu kurz', 'ab'],
|
||||
['ein Zeichen', 'a'],
|
||||
['leer', ''],
|
||||
['Unterstrich vorn', '_abc'],
|
||||
['Punkt vorn', '.abc'],
|
||||
['Bindestrich vorn', '-abc'],
|
||||
['21 Zeichen', 'a'.repeat(21)],
|
||||
['Leerzeichen', 'a b'],
|
||||
['Leerzeichen vorn', ' abc'],
|
||||
['Leerzeichen hinten', 'abc '],
|
||||
['Zeilenumbruch', 'abc\n'],
|
||||
['Umlaut', 'Jürgen'],
|
||||
['Emoji', 'abc😀'],
|
||||
['Klammeraffe', '@kenso'],
|
||||
['Schrägstrich', 'a/b'],
|
||||
];
|
||||
for (const [label, name] of cases) expectError(await profile(name), 400, 'usernameInvalid', label);
|
||||
assert.equal(fake.rows('profiles').length, 0);
|
||||
assert.equal(nonAccountCalls(fake).length, 0);
|
||||
});
|
||||
|
||||
test('kein Text als Name (fehlt, null, Zahl, Liste, Objekt, Wahrheitswert): 400 usernameInvalid; kein JSON-Objekt: 400 badRequest', async () => {
|
||||
const { post, call, fake } = await setup();
|
||||
for (const body of [{}, { username: null }, { username: 7 }, { username: ['abc'] }, { username: { a: 1 } }, { username: true }, { name: 'Kenso' }]) {
|
||||
expectError(await post('/api/profil', body, JWT_ME), 400, 'usernameInvalid', JSON.stringify(body));
|
||||
}
|
||||
for (const raw of ['[]', '"abc"', '7', '{kaputt']) {
|
||||
const res = await call('POST', '/api/profil', { raw, headers: { 'X-Aircard-Jwt': JWT_ME } });
|
||||
expectError(res, 400, 'badRequest', raw);
|
||||
}
|
||||
assert.equal(fake.rows('profiles').length, 0);
|
||||
});
|
||||
|
||||
test('ohne oder mit ungültigem JWT: 401, noch vor der Prüfung des Namens, nichts angelegt', async () => {
|
||||
const { post, profile, fake } = await setup();
|
||||
expectError(await post('/api/profil', { username: 'Kenso' }), 401, 'unauthorized', 'ohne JWT');
|
||||
expectError(await post('/api/profil', {}), 401, 'unauthorized', 'ohne JWT, ungültiger Body');
|
||||
expectError(await profile('Kenso', 'xxx.yyy.zzz'), 401, 'unauthorized', 'unbekanntes JWT');
|
||||
expectError(await profile('Kenso', 'zwei teile'), 401, 'unauthorized', 'unzulässige Zeichen');
|
||||
expectError(await profile('Admin', ''), 401, 'unauthorized', 'leeres JWT');
|
||||
assert.equal(fake.rows('profiles').length, 0);
|
||||
assert.equal(nonAccountCalls(fake).length, 0, 'ohne gültiges JWT wird nichts abgefragt oder geschrieben');
|
||||
});
|
||||
|
||||
test('nur POST: GET /api/profil gibt es nicht (404)', async () => {
|
||||
const { call } = await setup();
|
||||
expectError(await call('GET', '/api/profil'), 404, 'notFound');
|
||||
});
|
||||
|
||||
// ---- Fehler bei Appwrite -----------------------------------------------------------------------------------
|
||||
|
||||
test('Appwrite antwortet mit Fehler: 502 unavailable ohne halben Zustand, der nächste Versuch gelingt', async () => {
|
||||
const { profile, fake } = await setup();
|
||||
// die Vorabprüfung scheitert
|
||||
fake.failNext(isList, { status: 500 });
|
||||
expectError(await profile('Kenso'), 502, 'unavailable', 'Vorabprüfung');
|
||||
assert.equal(fake.rows('profiles').length, 0);
|
||||
// das Anlegen scheitert
|
||||
fake.failNext(isCreate, { status: 503 });
|
||||
expectError(await profile('Kenso'), 502, 'unavailable', 'Anlegen');
|
||||
assert.equal(fake.rows('profiles').length, 0);
|
||||
assert.deepEqual((await profile('Kenso')).json, { username: 'Kenso' });
|
||||
// das Ändern scheitert: das alte Profil bleibt
|
||||
fake.failNext(isUpdate, { status: 500 });
|
||||
expectError(await profile('Neu.name'), 502, 'unavailable', 'Ändern');
|
||||
assert.equal(fake.row('profiles', ME).username, 'Kenso');
|
||||
assert.deepEqual((await profile('Neu.name')).json, { username: 'Neu.name' });
|
||||
assert.equal(fake.row('profiles', ME).usernameKey, 'neu.name');
|
||||
});
|
||||
|
||||
test('fehlt die Tabelle `profiles` (Schema nicht eingespielt): 500 internal, kein Absturz', async () => {
|
||||
const { profile, fake } = await setup();
|
||||
fake.failNext(isList, { status: 404, type: 'table_not_found' });
|
||||
expectError(await profile('Kenso'), 500, 'internal');
|
||||
});
|
||||
|
||||
// ---- Logs -----------------------------------------------------------------------------------------------------
|
||||
|
||||
test('Logs: eine Ereigniszeile je Anlegen und Ändern, nie Name, Konto-ID, Schlüssel oder JWT', async () => {
|
||||
const { profile, logs, fake } = await setup();
|
||||
await profile('GEHEIMER.Name'); // anlegen
|
||||
await profile('GEHEIMER.Neu'); // ändern
|
||||
await profile('ab'); // 400
|
||||
await profile('Admin'); // 400
|
||||
await profile('geheimer.neu', JWT_OTHER); // 409
|
||||
await profile('x', 'xxx.yyy.zzz'); // 401
|
||||
|
||||
const lines = logs.map((line) => line.replace(/^\[aircard-api\] \S+ /, ''));
|
||||
assert.deepEqual(lines, ['profile created', 'profile updated']);
|
||||
assert.ok(logs.every((line) => line.startsWith('[aircard-api] ')));
|
||||
for (const secret of [fake.key, SALT, JWT_ME, JWT_OTHER, 'GEHEIMER', 'geheimer', ME, OTHER]) {
|
||||
assert.ok(!logs.join('\n').includes(secret), `Log enthält „${secret}“`);
|
||||
}
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Klickliste für Kenso. Dauer: etwa 5 Minuten. Console: <https://appwrite.webklar.com/console>
|
||||
|
||||
Alles passiert in der bestehenden Organisation. Das Schema (Datenbank, Tabellen `cards`, `gallery`, `reports`, `logos`, `logo_likes` und `logo_reports` mit Spalten und Indizes, Buckets `card-images`, `gallery-images` und `logo-images`) legt danach das Skript `web/scripts/appwrite-setup.mjs` an – dafür musst du nichts anklicken.
|
||||
Alles passiert in der bestehenden Organisation. Das Schema (Datenbank, Tabellen `cards`, `gallery`, `reports`, `logos`, `logo_likes`, `logo_reports` und `profiles` mit Spalten und Indizes, Buckets `card-images`, `gallery-images` und `logo-images`) legt danach das Skript `web/scripts/appwrite-setup.mjs` an – dafür musst du nichts anklicken.
|
||||
|
||||
**Hinweis:** Das Projekt wurde mit der automatisch generierten ID `6ac2130a00289343d6d5` angelegt (nicht mit einer selbst gewählten ID). Diese ID steht in `/home/webklar/apps/.env` unter `AIRCARD_APPWRITE_PROJECT_ID`; das Skript liest sie von dort, der Wert muss beim Aufruf verfügbar sein.
|
||||
|
||||
@@ -58,7 +58,7 @@ Dieser Key gehört nur dem Setup-Skript. Die Website selbst braucht ihn nicht.
|
||||
4. **Create** klicken.
|
||||
5. Auf der Seite des neuen Keys den **Secret**-Wert kopieren (Auge-Symbol zum Einblenden, dann kopieren).
|
||||
|
||||
Die Indizes der Tabellen `gallery`, `reports`, `logos`, `logo_likes` und `logo_reports` legt das Skript mit denselben Scopes an; ein eigener Index-Scope ist nicht nötig.
|
||||
Die Indizes der Tabellen `gallery`, `reports`, `logos`, `logo_likes`, `logo_reports` und `profiles` legt das Skript mit denselben Scopes an; ein eigener Index-Scope ist nicht nötig.
|
||||
|
||||
## 6. Key auf dem Server eintragen
|
||||
|
||||
@@ -112,4 +112,4 @@ Dieser Key gehört nur dem API-Dienst `aircard-api`, nicht dem Setup-Skript und
|
||||
|
||||
## 8. Bescheid geben
|
||||
|
||||
Schreib Claude „Console fertig“. Danach legt Claude Datenbank `aircard`, die Tabellen `cards`, `gallery`, `reports`, `logos`, `logo_likes`, `logo_reports` (mit Spalten und Indizes) und die Buckets `card-images`, `gallery-images`, `logo-images` mit dem Skript an und prüft sie. Für die Community-Galerie schreib zusätzlich „Label und Key sind gesetzt“.
|
||||
Schreib Claude „Console fertig”. Danach legt Claude Datenbank `aircard`, die Tabellen `cards`, `gallery`, `reports`, `logos`, `logo_likes`, `logo_reports` und `profiles` (mit Spalten und Indizes) und die Buckets `card-images`, `gallery-images`, `logo-images` mit dem Skript an und prüft sie. Für die Community-Galerie schreib zusätzlich „Label und Key sind gesetzt”.
|
||||
@@ -0,0 +1,113 @@
|
||||
# AirCard-IOS Benutzernamen – Implementierungsplan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Eindeutige Benutzernamen pro Konto (Pflicht, änderbar), angezeigt als „von @name“ an Logos und Community-Karten.
|
||||
|
||||
**Architecture:** Neue öffentliche Tabelle `profiles` (Zeile = Konto, Unique-Index auf dem kleingeschriebenen Namen), geschrieben nur über `aircard-api` (`POST /api/profil`, neues Modul `deploy/api/profiles.js`); Hochladen/Veröffentlichen verlangen serverseitig ein Profil. Der Client liest Profile direkt aus Appwrite, fragt den Namen beim Registrieren bzw. nach dem Anmelden ab und zeigt ihn statt des alten Anzeigenamens.
|
||||
|
||||
**Tech Stack:** wie bisher (TypeScript, Vite, Vitest + happy-dom, appwrite 22.4.1; Node 22 ohne Abhängigkeiten; Appwrite 1.8.1 TablesDB).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-10-05-aircard-benutzernamen-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Arbeitskopie `/home/webklar/apps/aircard-ios.org/dev`, Branch `main`, direkt committen, **nicht pushen** (Controller pusht).
|
||||
- Keine neuen Abhängigkeiten; Versionen unverändert; deutsche Kommentare im Stil des Bestands.
|
||||
- Alle neuen sichtbaren Texte über `t()`/`msg()` mit Eintrag in `web/src/i18n/en.ts` (Schlüssel eindeutig, Vorhandenes wiederverwenden); Tests laufen auf Deutsch; `coverage.test.ts` (Abdeckung, Heuristik, AST-Wache) bleibt grün.
|
||||
- Namensmuster genau `^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$`; Schlüssel = `toLowerCase()`; Sperrliste genau wie Spec § 2.
|
||||
- Tabelle `profiles`: Zeilen-ID = Nutzer-ID, Spalten `username` (string 20, Pflicht), `usernameKey` (string 20, Pflicht), Index `username_key` unique auf `usernameKey`, rowSecurity an, keine Tabellenrechte, Zeilenrecht genau `['read("any")']`.
|
||||
- API-Codes und Texte genau wie Spec § 4 (`usernameInvalid`, `usernameReserved`, `usernameTaken`, `usernameRequired`); Client-Tabelle `API_MESSAGES` bleibt im Gleichlauf mit `deploy/api/common.js` `MSG`.
|
||||
- `sessionStorage`-Schlüssel `aircard.usernamePrompted`; jeder Storage-Zugriff in try/catch.
|
||||
- Tests: `cd web && npx vitest run && npx tsc --noEmit && npm run build`; `node --test "deploy/api/*.test.js"`; `node --test web/scripts/appwrite-setup.test.mjs`.
|
||||
- Commits enden mit `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
|
||||
- Subagents: `sonnet` (Umsetzung/Review), `haiku` für Kleinstes; nie Subagents aus Subagents; keine Browser-/Headless-Prüfungen (macht der Controller live).
|
||||
|
||||
## Review Focus
|
||||
|
||||
- **Zwei Konten wollen gleichzeitig denselben Namen**: genau eines bekommt ihn, das andere 409 `usernameTaken` (Unique-Index, nicht nur Vorabprüfung). Test in Task 1.
|
||||
- **Registrierung mit vergebenem Namen**: Fehler am Feld, **bevor** das Konto angelegt wird (kein halbes Konto). Test in Task 2.
|
||||
- **Altes Konto ohne Namen lädt hoch / veröffentlicht**: erst der Namensdialog; „Abbrechen“ → nichts passiert; Server würde ohne Profil 403 liefern. Tests in Tasks 1 und 3.
|
||||
- **Umbenennen**: alter Name sofort für andere frei, eigene Kacheln/Infozeile/Detailseite zeigen sofort den neuen. Tests in Tasks 1 und 3.
|
||||
- **Profil nicht lesbar / gelöscht / Netzfehler bei der Anzeige**: Fallback auf alten Anzeigenamen bzw. „Anonym“, keine Fehlermeldung, kein Absturz. Test in Task 3.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Server – Schema `profiles`, `POST /api/profil`, Pflicht beim Hochladen/Veröffentlichen
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/scripts/appwrite-setup.mjs` (+ Test): Tabelle `profiles` nach `logo_reports` mit Spalten und Unique-Index aus den Global Constraints.
|
||||
- Create: `deploy/api/profiles.js`, `deploy/api/profiles.test.js`
|
||||
- Modify: `deploy/api/common.js` (`MSG` + 4 Texte, Muster/Sperrliste/Schlüssel-Helfer), `deploy/api/app.js` (Routen einhängen; `publish` prüft Profil), `deploy/api/logos.js` (`hochladen` prüft Profil), `deploy/api/fake-appwrite.js` (Tabelle `profiles`, Unique-Index → 409 bei doppeltem `usernameKey`), `deploy/api/app.test.js`/`logos.test.js` (Profil in bestehenden Hochladen-/Veröffentlichen-Tests anlegen; neue 403-Tests), `deploy/api/README.md`, `deploy/README.md` (sechs Dateien kopieren)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
```js
|
||||
// common.js
|
||||
const USERNAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$/;
|
||||
function usernameKey(name) // name.toLowerCase()
|
||||
function isReservedUsername(name) // Schlüssel in der Sperrliste
|
||||
// profiles.js
|
||||
function createProfileRoutes({ appwrite, say, withLock, requireUser }) // → [['POST /api/profil', {name:'profil', json:true, run}]]
|
||||
async function hasProfile(appwrite, userId) // getRow('profiles', userId) !== null — von logos.js und app.js genutzt
|
||||
```
|
||||
|
||||
- [ ] **Step 1: Tests schreiben:** anlegen → 200 `{username}`, Zeile ID = Nutzer-ID, Rechte genau `['read("any")']`, `usernameKey` klein; ändern → alter Schlüssel frei (anderes Konto kann ihn danach nehmen); „kenso“ → „Kenso“ beim selben Konto erlaubt; vergeben → 409 `usernameTaken`; Rennen zweier Konten (Verzögerung im Fake) → genau ein 200, ein 409; gesperrt (`Admin`, `AirCard-IOS`) → 400 `usernameReserved`; Muster (`ab`, `_abc`, 21 Zeichen, `a b`, Umlaut) → 400 `usernameInvalid`; ohne JWT 401; `hochladen` und `veroeffentlichen` ohne Profil → 403 `usernameRequired` (vor allen anderen Prüfungen, nichts angelegt); Setup-Skript: `profiles` samt Unique-Index wird angelegt/geprüft.
|
||||
- [ ] **Step 2:** `node --test "deploy/api/*.test.js"` und `node --test web/scripts/appwrite-setup.test.mjs` → FAIL.
|
||||
- [ ] **Step 3:** Umsetzen.
|
||||
- [ ] **Step 4:** beide Suiten grün; `cd web && npx vitest run` grün (Gleichlauftest `API_MESSAGES` erst in Task 2 — falls er hier rot wird, die vier Texte in `web/src/i18n/api-messages.ts` + `en.ts` gleich mit ergänzen).
|
||||
- [ ] **Step 5:** Commit `feat(api): Benutzernamen (Profil-Tabelle, /api/profil, Pflicht beim Hochladen und Veröffentlichen)`.
|
||||
|
||||
### Task 2: Client – Profil-Daten, Dialog „Benutzername wählen“, Registrieren, Konto-Menü
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/account/profile.ts` (+ Test), `web/src/account/username-dialog.ts` (+ Test)
|
||||
- Modify: `web/src/account/appwrite.ts` (`IDS.profiles = 'profiles'`), `web/src/account/login-dialog.ts` (+ Test), `web/src/account/account-menu.ts` (+ Test), `web/src/main.ts` (+ Test: Abfrage einmal pro Sitzung nach Start/Anmeldung), `web/src/i18n/api-messages.ts`, `web/src/i18n/en.ts`, `web/src/account/account.css`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
```ts
|
||||
// account/profile.ts
|
||||
export const USERNAME_PATTERN: RegExp;
|
||||
export function usernameKey(name: string): string;
|
||||
export function validateUsername(name: string): string | null; // übersetzter Text oder null (Muster, Sperrliste)
|
||||
export function getUsername(userId: string): Promise<string | null>; // Profil lesen, 404 → null
|
||||
export function usernamesFor(userIds: readonly string[]): Promise<Map<string, string>>; // gesammelt, zwischengespeichert, Fehler → leere Map
|
||||
export function isUsernameTaken(name: string): Promise<boolean>; // Abfrage equal('usernameKey', key)
|
||||
export function saveUsername(name: string): Promise<string>; // POST /api/profil, aktualisiert Cache + meldet Änderung
|
||||
export function myUsername(): string | null; // Cache des angemeldeten Kontos (null = keiner/unbekannt)
|
||||
export function loadMyUsername(): Promise<string | null>; // nach Anmeldung/Start
|
||||
export function onUsernameChange(fn: (userId: string, username: string) => void): () => void;
|
||||
// account/username-dialog.ts
|
||||
export function usernameDialog(opts: { mode: 'prompt' | 'required' | 'change'; signal?: AbortSignal }): Promise<string | null>; // null = Später/Abbrechen
|
||||
export function requireUsername(): Promise<boolean>; // hat Namen → true; sonst Dialog 'required'
|
||||
```
|
||||
- [ ] **Step 1: Tests:** `validateUsername` (Grenzen 3/20, erstes Zeichen, erlaubte Zeichen, Sperrliste case-insensitiv); `usernamesFor` sammelt IDs, eine Abfrage pro Aufruf, Cache, Fehler → leere Map; Registrieren: Feld „Benutzername“ Pflicht, Vorabprüfung (Muster, Sperre, `isUsernameTaken`) **vor** `register()`, Fehler am Feld; nach erfolgreichem `register` → `saveUsername`; scheitert das, schließt der Login-Dialog trotzdem erfolgreich und der Namensdialog erscheint; Dialog: Modi prompt („Später“), required („Abbrechen“), change (vorbelegt), Speichern ruft `saveUsername`, 409 → Text am Feld, Busy-Sperre; Konto-Menü: „@name“ + E-Mail klein, Eintrag „Benutzername ändern“ bzw. „Benutzername wählen“; `main.ts`: nach Start mit Sitzung bzw. nach Anmeldung ohne Namen genau einmal pro Sitzung Dialog `prompt`.
|
||||
- [ ] **Step 2:** `cd web && npx vitest run src/account src/main.test.ts` → FAIL.
|
||||
- [ ] **Step 3:** Umsetzen (Dialog nach dem Muster von `logo-dialog.ts`/`publish-dialog.ts`, Texte nur als Text).
|
||||
- [ ] **Step 4:** `cd web && npx vitest run && npx tsc --noEmit && npm run build` grün.
|
||||
- [ ] **Step 5:** Commit `feat(account): Benutzername beim Registrieren, im Konto-Menü und als einmalige Abfrage`.
|
||||
|
||||
### Task 3: Client – Anzeige „von @name“, Anzeigename-Felder weg, Pflicht vor Hochladen/Veröffentlichen
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/logos/logo-picker.ts`, `web/src/logos/logo-info.ts`, `web/src/pages/community-page.ts`, `web/src/logos/logo-dialog.ts`, `web/src/logos/upload-logo.ts`, `web/src/community/publish-dialog.ts`, `web/src/community/publish.ts`, `web/src/pages/cards-page.ts` (Veröffentlichen-Aufruf), Tests dazu; `web/src/i18n/en.ts`
|
||||
- Delete: `web/src/account/display-name.ts` (+ Test)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 2 (`usernamesFor`, `myUsername`, `onUsernameChange`, `requireUsername`, `currentUserId`).
|
||||
- Byline-Regel (eine Hilfsfunktion, z. B. in `profile.ts`): `byline(ownerId, legacyDisplayName, names)` → `t('von @{name}', {name})` wenn Profil, sonst `t('von {name}', {name: legacy})` wenn Altname, sonst `t('von {name}', {name: t('Anonym')})`.
|
||||
|
||||
- [ ] **Step 1: Tests:** Kacheln/Infozeile/Detailseite zeigen „von @name“ (eine `usernamesFor`-Abfrage pro geladener Seite), Fallback Altname bzw. „Anonym“, Fehler beim Nachschlagen → Fallback ohne Fehlertext; eigene Umbenennung (`onUsernameChange`) aktualisiert eigene Einträge sofort; Dialoge „Logo hochladen“ und „Veröffentlichen“ ohne Anzeigename-Feld, senden `displayName: ''`; vor Hochladen bzw. Veröffentlichen `requireUsername()` → false = nichts passiert; Serverantwort 403 `usernameRequired` → Namensdialog `required`, danach erneuter Versuch möglich; `display-name.ts` und `aircard.displayName` nirgends mehr genutzt.
|
||||
- [ ] **Step 2:** `cd web && npx vitest run src/logos src/community src/pages` → FAIL.
|
||||
- [ ] **Step 3:** Umsetzen.
|
||||
- [ ] **Step 4:** `cd web && npx vitest run && npx tsc --noEmit && npm run build` grün.
|
||||
- [ ] **Step 5:** Commit `feat(community): „von @name“ statt Anzeigename, Benutzername vor dem Hochladen und Veröffentlichen`.
|
||||
|
||||
### Task 4: Betrieb und Abnahme (Controller)
|
||||
|
||||
- [ ] Schema: Setup-Skript `--check` → anlegen → `--check` Exit 0 (Unique-Index `username_key`).
|
||||
- [ ] API: Backup, sechs Dateien kopieren, `docker compose restart aircard-api`, Gesundheit + `POST /api/profil` ohne JWT = 401 `unauthorized`.
|
||||
- [ ] Push `main` → Build → neues Bundle live.
|
||||
- [ ] Live als Gast: Kacheln/Detailseite zeigen „von …“ (Altdaten „Anonym“). Kenso: registrieren bzw. Abfrage nach Anmeldung, Name ändern, Name am eigenen Logo sehen.
|
||||
- [ ] Review (ein `sonnet`-Reviewer über alles), Fix-Welle nur für Critical/Important, Memory, Bericht.
|
||||
@@ -100,7 +100,7 @@ Pro Konto sind höchstens **30 Logos** erlaubt, ausgeblendete zählen mit. Darü
|
||||
- Ein Melder ist ein IP-Netz (IPv4 /24, IPv6 /48), gespeichert nur als Hash.
|
||||
- Rate-Limit: 10 Meldungen pro Stunde je Anschluss, gemeinsam mit den Karten-Meldungen.
|
||||
- Ab **3** verschiedenen Meldern wird das Logo ausgeblendet. Zeile und Datei sind dann nur noch für den Hochlader und Admins lesbar.
|
||||
- **Löschen** (Hochlader oder Admin) entfernt Datei, Herzen, Meldungen und zuletzt die Zeile. Gespeicherte Karten zeigen das Logo danach nicht mehr (Hinweis siehe § 2). Bereits veröffentlichte Kartenbilder bleiben unverändert, weil das Logo im Bild steckt.
|
||||
- **Löschen** (Hochlader oder Admin) blendet das Logo zuerst aus (Datei und Zeile), damit nach einem Abbruch keine öffentliche Zeile mit gelöschter Datei zurückbleibt, und entfernt dann Datei, Herzen, Meldungen und zuletzt die Zeile. Gespeicherte Karten zeigen das Logo danach nicht mehr (Hinweis siehe § 2). Bereits veröffentlichte Kartenbilder bleiben unverändert, weil das Logo im Bild steckt.
|
||||
- **Admin** (Label `admin`):
|
||||
- Filter „Gemeldet“ (`reportCount > 0`) und „Ausgeblendet“.
|
||||
- Ein- und Ausblenden. Einblenden löscht die Meldungen und setzt `reportCount` auf 0.
|
||||
@@ -170,7 +170,7 @@ Pro Konto sind höchstens **30 Logos** erlaubt, ausgeblendete zählen mit. Darü
|
||||
|
||||
Indizes: `owner` (key, `ownerId`), `hidden` (key, `hidden`), `likes` (key, `likeCount`).
|
||||
|
||||
Zeilen-ID = Datei-ID. Damit kann eine Datei nie zwei Logos tragen, und ein zweiter Versuch ergibt 409.
|
||||
Zeilen-ID = `hash32("<ownerId>|<fileId>")` (seit 2026-10-05; zuerst war sie die Datei-ID, die Altlogos behalten sie). Dieselbe Datei desselben Kontos ergibt dieselbe ID, ein zweiter Versuch also 409. Die ID eines gelöschten Logos steht öffentlich in der Liste und in gespeicherten Karten und lässt sich so nicht mit einer Datei gleicher ID neu belegen. Dass eine Datei nie zwei Logos trägt, sichert der Besitz: nach dem Anlegen hat sie kein `delete(owner)` mehr.
|
||||
|
||||
Zeilenrechte (setzt nur die API): sichtbar `['read("any")']`, ausgeblendet `['read("user:<owner>")','read("label:admin")']`.
|
||||
|
||||
@@ -205,7 +205,7 @@ Die neuen Endpunkte stehen in einem eigenen Modul `deploy/api/logos.js`. Reine g
|
||||
| `POST /api/logos/hochladen` `{name, displayName?, mono, fileId}` | Nutzer-JWT | Prüft Felder; die Datei existiert, liegt in `logo-images`, trägt `delete("user:<ich>")` und `read("any")`, ist PNG, höchstens 1 MiB, Maße zwischen 8 × 8 und 720 × 250 (aus dem PNG-Kopf); höchstens 30 Logos je Konto. Legt die Zeile an (sichtbar), setzt die Datei auf `read("any")` und nimmt die Zeile zurück, wenn das scheitert. → `201 {id}`; 409 bei schon verwendeter Datei; 429 bei Limit. |
|
||||
| `POST /api/logos/herz` `{id, herz}` | Nutzer-JWT | 404 bei fehlendem oder ausgeblendetem Logo. `herz: true` legt die Like-Zeile an (409 = schon da, kein Fehler), `false` löscht sie (404 = schon weg, kein Fehler). Danach `likeCount = countRows`. → `200 {herz, likeCount}` |
|
||||
| `POST /api/logos/melden` `{id, reason}` | keine | Wie `/api/melden`: Rate-Limit (gemeinsam), Melder-Hash, Duplikat → `200 {schonGemeldet:true}`, `reportCount` = Zeilen, ab 3 ausblenden. 404 bei fehlendem oder ausgeblendetem Logo. |
|
||||
| `POST /api/logos/loeschen` `{id}` | Nutzer-JWT (Hochlader **oder** Admin) | Löscht Datei, Herzen, Meldungen und zuletzt die Zeile. 403 bei fremdem Logo, 404 bei fehlendem. → `200 {ok:true}` |
|
||||
| `POST /api/logos/loeschen` `{id}` | Nutzer-JWT (Hochlader **oder** Admin) | Blendet das Logo zuerst aus, löscht dann Datei, Herzen, Meldungen und zuletzt die Zeile. 403 bei fremdem Logo, 404 bei fehlendem. → `200 {ok:true}` |
|
||||
| `POST /api/admin/logo-sichtbarkeit` `{id, sichtbar}` | Admin-JWT | Wie `/api/admin/sichtbarkeit` für Logos (Datei- und Zeilenrechte, `hidden`; Einblenden löscht Meldungen, `reportCount = 0`). |
|
||||
|
||||
Alle Fehlerantworten (auch die bestehenden) haben die Form `{error: <deutscher Text>, code: <MSG-Schlüssel>}`. Dynamische Prüftexte ohne festen Schlüssel bekommen `code: 'badRequest'`.
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# AirCard-IOS – Benutzernamen – Design
|
||||
|
||||
Stand: 2026-10-05 · Status: Entwurf im Chat von Kenso freigegeben („ja passt, mach weiter“), Spec + Plan + Umsetzung ohne weitere Rückfrage · baut auf `2026-10-04-aircard-logos-sprache-design.md` auf (live, main 13b46da)
|
||||
|
||||
## 1. Ziel und Rahmen
|
||||
|
||||
Jedes Konto bekommt einen eindeutigen Benutzernamen. Logos und Community-Karten zeigen „von @name“ statt „Anonym“, damit man Leute wiedererkennt.
|
||||
|
||||
| Punkt | Entscheidung (Kenso) |
|
||||
|---|---|
|
||||
| Eindeutig | Ja, jeder Name nur einmal; Groß-/Kleinschreibung zählt beim Vergleich nicht. |
|
||||
| Pflicht | Ja, auch für bestehende Konten. Registrieren nur mit Name; alte Konten werden nach dem Anmelden einmal gefragt; Hochladen und Veröffentlichen gehen erst mit Name. |
|
||||
| Anzeige | Immer der Benutzername; das Feld „Anzeigename“ fällt weg; gilt auch für vorhandene Einträge des Kontos. |
|
||||
| Ändern | Ja, im Konto-Menü; danach überall der neue Name, der alte wird frei. |
|
||||
|
||||
## 2. Regeln für den Namen
|
||||
|
||||
- Muster: `^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$`. Das sind 3–20 Zeichen, das erste ein Buchstabe oder eine Zahl, danach Buchstaben, Zahlen, `.`, `_`, `-`.
|
||||
- Schlüssel für die Eindeutigkeit: der Name in Kleinbuchstaben (`usernameKey`). Gespeichert und angezeigt wird der Name, wie er eingegeben wurde.
|
||||
- Gesperrte Schlüssel: `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`.
|
||||
- Anzeige mit `@` davor: „von @name“ bzw. „by @name“.
|
||||
|
||||
## 3. Daten (Appwrite, Datenbank `aircard`, Setup-Skript erweitert)
|
||||
|
||||
**Tabelle `profiles`** (rowSecurity an, keine Tabellenrechte für Clients):
|
||||
|
||||
| Spalte | Typ | Inhalt |
|
||||
|---|---|---|
|
||||
| `username` | string 20, Pflicht | Name wie eingegeben |
|
||||
| `usernameKey` | string 20, Pflicht | Name klein geschrieben |
|
||||
|
||||
- Zeilen-ID = Nutzer-ID (eine Zeile pro Konto).
|
||||
- Index `username_key`: **unique** auf `usernameKey`. Damit kann auch bei Gleichzeitigkeit kein Name doppelt vergeben werden.
|
||||
- Zeilenrecht genau `['read("any")']`, öffentlich lesbar. Schreiben nur über die API.
|
||||
|
||||
## 4. API-Dienst `aircard-api`
|
||||
|
||||
Eigenes Modul `deploy/api/profiles.js`, eingebunden wie `logos.js`. Der Betrieb kopiert künftig sechs Dateien: `appwrite.js`, `common.js`, `app.js`, `logos.js`, `profiles.js`, `server.js`.
|
||||
|
||||
`POST /api/profil` `{username}`, Nutzer-JWT:
|
||||
|
||||
- 401 ohne Anmeldung.
|
||||
- 400 `usernameInvalid` bei falschem Muster.
|
||||
- 400 `usernameReserved` bei gesperrtem Schlüssel.
|
||||
- 409 `usernameTaken`, wenn ein anderes Konto den Schlüssel hat. Das wird vorab per Abfrage geprüft; bei einem Rennen meldet Appwrite 409 auf dem Unique-Index, das wird ebenfalls zu `usernameTaken`.
|
||||
- Sonst: Hat das Konto schon eine Zeile, wird sie aktualisiert (der alte Name ist damit frei); sonst wird sie mit ID = Nutzer-ID und `read("any")` angelegt. Antwort `200 {username}`.
|
||||
- Groß-/Kleinschreibung des eigenen Namens ändern („kenso“ → „Kenso“) ist erlaubt.
|
||||
- Sperre je Konto (`profile:<userId>`).
|
||||
|
||||
**Pflicht serverseitig:** `POST /api/logos/hochladen` und `POST /api/veroeffentlichen` antworten **403 `usernameRequired`**, wenn das Konto keine Zeile in `profiles` hat. Die Prüfung läuft nach der Anmeldung und vor allen anderen Prüfungen. Bestehende Logos und Karten bleiben unberührt.
|
||||
|
||||
Neue `MSG`-Texte:
|
||||
|
||||
- `usernameInvalid`: „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.“
|
||||
- `usernameReserved`: „Dieser Benutzername ist nicht erlaubt.“
|
||||
- `usernameTaken`: „Dieser Benutzername ist schon vergeben.“
|
||||
- `usernameRequired`: „Bitte wähle zuerst einen Benutzernamen.“
|
||||
|
||||
## 5. Oberfläche
|
||||
|
||||
- **Registrieren** (Login-Dialog, Reiter „Registrieren“): neues Pflichtfeld „Benutzername“ vor der E-Mail, `autocomplete="nickname"` (angemeldet wird per E-Mail; so speichern Passwortmanager weiter die E-Mail), Hinweis „3–20 Zeichen: Buchstaben, Zahlen, . _ -“.
|
||||
- Vor dem Anlegen des Kontos werden Muster, Sperrliste und Verfügbarkeit geprüft (Abfrage auf `profiles`). Ein Fehler steht am Feld.
|
||||
- Danach: Konto anlegen → anmelden → `POST /api/profil`.
|
||||
- Scheitert dieser letzte Schritt (Rennen, Netz), ist man trotzdem angemeldet, und der Dialog „Benutzername wählen“ erscheint.
|
||||
- **Dialog „Benutzername wählen“** mit Feld, „Speichern“ und „Später“ bzw. „Abbrechen“:
|
||||
- Nach dem Anmelden oder beim Seitenstart mit Sitzung, wenn das Konto keinen Namen hat. Einmal pro Sitzung (Merker in `sessionStorage` `aircard.usernamePrompted`, Zugriffe in try/catch), mit „Später“.
|
||||
- Vor dem Hochladen eines Logos und vor dem Veröffentlichen, wenn kein Name da ist. Hier gibt es „Abbrechen“ statt „Später“; ohne Namen geht es nicht weiter.
|
||||
- Antwortet der Server mit `usernameRequired`, erscheint der Dialog ebenfalls.
|
||||
- **Konto-Menü**: Statt der E-Mail steht „@name“ oben (die E-Mail darunter klein). Neuer Eintrag „Benutzername ändern“, der den Dialog vorbelegt öffnet. Ohne Namen heißt der Eintrag „Benutzername wählen“.
|
||||
- **Anzeige „von @name“**: Logo-Kacheln, Logo-Infozeile, Detailseite einer Community-Karte.
|
||||
- Der Name wird über `ownerId` in `profiles` nachgeschlagen, gesammelt je Seite (`equal('$id', ids)`) und zwischengespeichert.
|
||||
- Ohne Profil: der früher gespeicherte `displayName` (ohne `@`), sonst „Anonym“.
|
||||
- Nach einer eigenen Namensänderung aktualisieren Kacheln, Infozeile und Detailseite die eigenen Einträge sofort.
|
||||
- **Weg fällt** das Feld „Anzeigename“ in den Dialogen „Logo hochladen“ und „Veröffentlichen“ samt `account/display-name.ts` und dem `localStorage`-Schlüssel `aircard.displayName`. Der Client schickt `displayName: ''`; die API nimmt das Feld weiter an (Altbestand).
|
||||
|
||||
Alle Texte gibt es auf Deutsch und Englisch.
|
||||
|
||||
## 6. Fehlerbehandlung
|
||||
|
||||
| Fall | Meldung |
|
||||
|---|---|
|
||||
| Muster falsch / gesperrt / vergeben | die drei `MSG`-Texte aus § 4, am Feld |
|
||||
| Server meldet `usernameRequired` | Dialog „Benutzername wählen“ |
|
||||
| Netz/API weg beim Speichern | „Der Dienst ist gerade nicht erreichbar.“, Dialog bleibt offen |
|
||||
| Profil lässt sich nicht lesen (Anzeige) | Fallback `displayName` bzw. „Anonym“, kein Fehlertext |
|
||||
|
||||
## 7. Tests
|
||||
|
||||
- **node:test:**
|
||||
- `/api/profil`: anlegen, ändern, Groß/klein am eigenen Namen, vergeben 409, gesperrt 400, Muster 400, ohne JWT 401.
|
||||
- Rennen mit zwei Konten: genau eines bekommt den Namen.
|
||||
- Zeilenrechte genau `read("any")`.
|
||||
- Hochladen und Veröffentlichen ohne Profil → 403 `usernameRequired`.
|
||||
- Setup-Skript deckt `profiles` mit Unique-Index ab.
|
||||
- **Vitest:**
|
||||
- Prüfung des Namens (Muster, Sperrliste, Schlüssel).
|
||||
- Registrieren mit Vorabprüfung und Fehlern am Feld.
|
||||
- Dialog: Später, Abbrechen, Speichern, Fehler.
|
||||
- Einmal-pro-Sitzung-Abfrage.
|
||||
- Konto-Menü mit „@name“ und Ändern.
|
||||
- Anzeige „von @name“ mit Fallbacks; sofortige Aktualisierung nach Umbenennen.
|
||||
- Dialoge ohne „Anzeigename“.
|
||||
- Hochladen und Veröffentlichen fragen vorher nach dem Namen.
|
||||
- Gleichlauf der API-Codes, i18n-Abdeckung.
|
||||
- **Live:** Gast sieht „von …“-Zeilen. Kenso registriert bzw. wird gefragt, ändert den Namen und sieht ihn an seinem Logo.
|
||||
|
||||
## 8. Nicht im Umfang
|
||||
|
||||
Profilseiten, Suche nach Benutzernamen, Moderation von Namen, Konto löschen, Avatar-Bilder, Benutzername als Anmeldename (angemeldet wird weiter per E-Mail).
|
||||
@@ -3,9 +3,9 @@
|
||||
* AirCard – Appwrite-Schema-Setup (idempotent)
|
||||
*
|
||||
* Legt im selbst gehosteten Appwrite (Projekt `aircard`) die Datenbank `aircard`,
|
||||
* die Tabellen `cards`, `gallery`, `reports`, `logos`, `logo_likes` und
|
||||
* `logo_reports` (TablesDB, mit Spalten und Indizes) und die Buckets
|
||||
* `card-images`, `gallery-images` und `logo-images` an.
|
||||
* die Tabellen `cards`, `gallery`, `reports`, `logos`, `logo_likes`,
|
||||
* `logo_reports` und `profiles` (TablesDB, mit Spalten und Indizes) und die
|
||||
* Buckets `card-images`, `gallery-images` und `logo-images` an.
|
||||
* Reines `fetch`, keine Abhängigkeiten, Node 22+.
|
||||
*
|
||||
* Aufruf (vom Repo-Wurzelverzeichnis):
|
||||
@@ -41,7 +41,7 @@ const str = (key, size, required) => ({ key, type: 'string', size, required });
|
||||
|
||||
export const DESIRED = {
|
||||
database: { id: 'aircard', name: 'AirCard' },
|
||||
// Reihenfolge ist Ablauf: cards, gallery, reports, logos, logo_likes, logo_reports (je Tabelle: Spalten, dann Indizes).
|
||||
// Reihenfolge ist Ablauf: cards, gallery, reports, logos, logo_likes, logo_reports, profiles (je Tabelle: Spalten, dann Indizes).
|
||||
tables: [
|
||||
{
|
||||
id: 'cards',
|
||||
@@ -137,6 +137,16 @@ export const DESIRED = {
|
||||
],
|
||||
indexes: [{ key: 'logo', type: 'key', columns: ['logoId'] }],
|
||||
},
|
||||
{
|
||||
// Benutzernamen: eine Zeile je Konto, Zeilen-ID = Konto-ID, öffentlich lesbar (Zeilenrecht read("any") setzt die API); geschrieben wird nur
|
||||
// über die API. `usernameKey` ist der Name klein geschrieben: der Unique-Index macht ihn auch bei gleichzeitigen Anfragen nur einmal vergebbar.
|
||||
id: 'profiles',
|
||||
name: 'Profiles',
|
||||
permissions: [],
|
||||
rowSecurity: true,
|
||||
columns: [str('username', 20, true), str('usernameKey', 20, true)],
|
||||
indexes: [{ key: 'username_key', type: 'unique', columns: ['usernameKey'] }],
|
||||
},
|
||||
],
|
||||
buckets: [
|
||||
{
|
||||
|
||||
@@ -270,9 +270,9 @@ describe('DESIRED', () => {
|
||||
const bucket = (id) => DESIRED.buckets.find((b) => b.id === id);
|
||||
const str = (key, size, required) => ({ key, type: 'string', size, required });
|
||||
|
||||
test('Reihenfolge: cards zuerst, dann gallery, reports, logos, logo_likes, logo_reports; Buckets card-images, gallery-images, logo-images', () => {
|
||||
test('Reihenfolge: cards zuerst, dann gallery, reports, logos, logo_likes, logo_reports, profiles; Buckets card-images, gallery-images, logo-images', () => {
|
||||
assert.deepEqual(DESIRED.database, { id: 'aircard', name: 'AirCard' });
|
||||
assert.deepEqual(DESIRED.tables.map((t) => t.id), ['cards', 'gallery', 'reports', 'logos', 'logo_likes', 'logo_reports']);
|
||||
assert.deepEqual(DESIRED.tables.map((t) => t.id), ['cards', 'gallery', 'reports', 'logos', 'logo_likes', 'logo_reports', 'profiles']);
|
||||
assert.deepEqual(DESIRED.buckets.map((b) => b.id), ['card-images', 'gallery-images', 'logo-images']);
|
||||
});
|
||||
|
||||
@@ -393,6 +393,16 @@ describe('DESIRED', () => {
|
||||
assert.deepEqual(t.indexes, [{ key: 'logo', type: 'key', columns: ['logoId'] }]);
|
||||
});
|
||||
|
||||
test('profiles entspricht Benutzernamen-Spec §3 (Unique-Index username_key auf usernameKey)', () => {
|
||||
const t = table('profiles');
|
||||
assert.equal(t.name, 'Profiles');
|
||||
assert.deepEqual(t.permissions, []);
|
||||
assert.equal(t.rowSecurity, true);
|
||||
assert.deepEqual(t.columns, [str('username', 20, true), str('usernameKey', 20, true)]);
|
||||
// unique: nur so kann bei Gleichzeitigkeit kein Name doppelt vergeben werden
|
||||
assert.deepEqual(t.indexes, [{ key: 'username_key', type: 'unique', columns: ['usernameKey'] }]);
|
||||
});
|
||||
|
||||
test('logo-images entspricht Logo-Spec §8', () => {
|
||||
assert.deepEqual(bucket('logo-images'), {
|
||||
id: 'logo-images',
|
||||
@@ -622,6 +632,19 @@ describe('compareIndex', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareIndex (unique)', () => {
|
||||
const usernameKey = DESIRED.tables.find((t) => t.id === 'profiles').indexes[0];
|
||||
const ok = () => ({ key: 'username_key', type: 'unique', columns: ['usernameKey'], lengths: [], orders: [], status: 'available' });
|
||||
|
||||
test('Unique-Index profiles.username_key: Typ, Spalte und Status werden verglichen', () => {
|
||||
assert.deepEqual(compareIndex(ok(), usernameKey), []);
|
||||
// ein normaler Index verhindert keine doppelten Namen: das muss auffallen
|
||||
assert.deepEqual(compareIndex({ ...ok(), type: 'key' }, usernameKey), ['type ist key, erwartet unique']);
|
||||
assert.deepEqual(compareIndex({ ...ok(), columns: ['username'] }, usernameKey), ['columns ist [username], erwartet [usernameKey]']);
|
||||
assert.deepEqual(compareIndex({ ...ok(), status: 'processing' }, usernameKey), ['status ist processing, erwartet available']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareBucket', () => {
|
||||
const CARD_IMAGES = DESIRED.buckets[0];
|
||||
const ok = () => ({
|
||||
@@ -741,6 +764,10 @@ describe('main gegen Fake-Server', () => {
|
||||
'Spalte logo_reports.reason',
|
||||
'Spalte logo_reports.reporterHash',
|
||||
'Index logo_reports.logo',
|
||||
'Tabelle profiles',
|
||||
'Spalte profiles.username',
|
||||
'Spalte profiles.usernameKey',
|
||||
'Index profiles.username_key',
|
||||
'Bucket card-images',
|
||||
'Bucket gallery-images',
|
||||
'Bucket logo-images',
|
||||
@@ -748,6 +775,8 @@ describe('main gegen Fake-Server', () => {
|
||||
// Erweiterung um die Galerie (gallery, reports, gallery-images) bzw. um die Logos (logos, logo_likes, logo_reports, logo-images)
|
||||
const GALLERY_OBJECTS = EXPECTED_OBJECTS.filter((o) => /^(Tabelle|Spalte|Index) (gallery|reports)(\.|$)|^Bucket gallery-images$/.test(o));
|
||||
const LOGO_OBJECTS = EXPECTED_OBJECTS.filter((o) => /^(Tabelle|Spalte|Index) logo(s|_likes|_reports)(\.|$)|^Bucket logo-images$/.test(o));
|
||||
// Erweiterung um die Benutzernamen (profiles)
|
||||
const PROFILE_OBJECTS = EXPECTED_OBJECTS.filter((o) => /^(Tabelle|Spalte|Index) profiles(\.|$)/.test(o));
|
||||
|
||||
const T = '/v1/tablesdb/aircard/tables';
|
||||
const str = (table, key, size, required) => [`${T}/${table}/columns/string`, { key, size, required }];
|
||||
@@ -805,6 +834,11 @@ describe('main gegen Fake-Server', () => {
|
||||
}],
|
||||
str('logo_reports', 'reporterHash', 64, true),
|
||||
[`${T}/logo_reports/indexes`, { key: 'logo', type: 'key', columns: ['logoId'] }],
|
||||
[T, { tableId: 'profiles', name: 'Profiles', permissions: [], rowSecurity: true }],
|
||||
str('profiles', 'username', 20, true),
|
||||
str('profiles', 'usernameKey', 20, true),
|
||||
// unique: das Skript legt den Index mit dem Typ `unique` an (Appwrite meldet dann 409 statt eine zweite Zeile mit demselben Schlüssel anzulegen)
|
||||
[`${T}/profiles/indexes`, { key: 'username_key', type: 'unique', columns: ['usernameKey'] }],
|
||||
['/v1/storage/buckets', {
|
||||
bucketId: 'card-images', name: 'Card Images', permissions: ['create("users")'], fileSecurity: true,
|
||||
maximumFileSize: 5242880, allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp'],
|
||||
@@ -822,15 +856,16 @@ describe('main gegen Fake-Server', () => {
|
||||
}],
|
||||
];
|
||||
const isLogoPost = ([path, body]) => /\/tables\/logo/.test(path) || /^logo/.test(body.tableId ?? body.bucketId ?? '');
|
||||
const isProfilePost = ([path, body]) => /\/tables\/profiles(\/|$)/.test(path) || body.tableId === 'profiles';
|
||||
|
||||
test('--check auf leerem Projekt: Exit 1, alle 51 Objekte FEHLT, nichts angelegt', async () => {
|
||||
test('--check auf leerem Projekt: Exit 1, alle 55 Objekte FEHLT, nichts angelegt', async () => {
|
||||
await withFake({}, async (fake) => {
|
||||
const { code, lines } = await run(fake, { check: true });
|
||||
assert.equal(code, 1);
|
||||
assert.equal(EXPECTED_OBJECTS.length, 51);
|
||||
assert.equal(EXPECTED_OBJECTS.length, 55);
|
||||
assert.deepEqual(startsWith(lines, 'FEHLT'), EXPECTED_OBJECTS.map((o) => `FEHLT: ${o}`));
|
||||
assert.equal(startsWith(lines, 'OK').length, 0);
|
||||
assert.ok(lines.includes('UNVOLLSTÄNDIG: 51 fehlend.'), lines.join('\n'));
|
||||
assert.ok(lines.includes('UNVOLLSTÄNDIG: 55 fehlend.'), lines.join('\n'));
|
||||
assert.equal(fake.posts().length, 0);
|
||||
assert.equal(fake.otherWrites().length, 0);
|
||||
});
|
||||
@@ -859,8 +894,8 @@ describe('main gegen Fake-Server', () => {
|
||||
assert.ok(!first.lines.join('\n').includes(KEY));
|
||||
|
||||
// Alle Spalten und Indizes wurden vor dem Ende ausgewartet (Status available)
|
||||
assert.equal(fake.state.columns.size, 31);
|
||||
assert.equal(fake.state.indexes.size, 10);
|
||||
assert.equal(fake.state.columns.size, 33);
|
||||
assert.equal(fake.state.indexes.size, 11);
|
||||
for (const col of fake.state.columns.values()) assert.ok(col.gets >= 2, `Spalte ${col.key} nicht bis available geprüft`);
|
||||
for (const idx of fake.state.indexes.values()) assert.ok(idx.gets >= 2, `Index ${idx.key} nicht bis available geprüft`);
|
||||
|
||||
@@ -868,6 +903,7 @@ describe('main gegen Fake-Server', () => {
|
||||
assert.deepEqual(fake.state.indexPosts.map((p) => `${p.table}.${p.key}`), [
|
||||
'gallery.search_text', 'gallery.owner', 'gallery.hidden', 'reports.gallery',
|
||||
'logos.owner', 'logos.hidden', 'logos.likes', 'logo_likes.logo', 'logo_likes.user_logo', 'logo_reports.logo',
|
||||
'profiles.username_key',
|
||||
]);
|
||||
assert.ok(fake.state.indexPosts.every((p) => p.allColumnsAvailable), JSON.stringify(fake.state.indexPosts));
|
||||
|
||||
@@ -920,7 +956,7 @@ describe('main gegen Fake-Server', () => {
|
||||
dropFromFake(fake, { tables: ['logos', 'logo_likes', 'logo_reports'], buckets: ['logo-images'] });
|
||||
const kept = EXPECTED_OBJECTS.filter((o) => !LOGO_OBJECTS.includes(o));
|
||||
assert.equal(LOGO_OBJECTS.length, 23);
|
||||
assert.equal(kept.length, 28);
|
||||
assert.equal(kept.length, 32);
|
||||
const requestsBefore = fake.state.requests.length;
|
||||
const postsBefore = fake.posts().length;
|
||||
|
||||
@@ -947,8 +983,8 @@ describe('main gegen Fake-Server', () => {
|
||||
assert.ok(fake.state.indexPosts.every((p) => p.allColumnsAvailable));
|
||||
// Alles Bestehende wurde nur gelesen
|
||||
assert.equal(fake.otherWrites().length, 0);
|
||||
assert.equal(fake.state.columns.size, 31);
|
||||
assert.equal(fake.state.indexes.size, 10);
|
||||
assert.equal(fake.state.columns.size, 33);
|
||||
assert.equal(fake.state.indexes.size, 11);
|
||||
|
||||
// danach ist das Schema vollständig
|
||||
const again = await run(fake, { check: true });
|
||||
@@ -956,6 +992,42 @@ describe('main gegen Fake-Server', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('Bestehendes Schema ohne `profiles` wird um die Tabelle samt Unique-Index ergänzt, alles Bestehende bleibt unberührt', async () => {
|
||||
await withFake({}, async (fake) => {
|
||||
assert.equal((await run(fake)).code, 0);
|
||||
// Stand vor den Benutzernamen nachstellen: alles außer profiles
|
||||
dropFromFake(fake, { tables: ['profiles'] });
|
||||
const kept = EXPECTED_OBJECTS.filter((o) => !PROFILE_OBJECTS.includes(o));
|
||||
assert.equal(PROFILE_OBJECTS.length, 4);
|
||||
assert.equal(kept.length, 51);
|
||||
const requestsBefore = fake.state.requests.length;
|
||||
const postsBefore = fake.posts().length;
|
||||
|
||||
// --check: genau die vier neuen Objekte fehlen, nichts wird angelegt
|
||||
const check = await run(fake, { check: true });
|
||||
assert.equal(check.code, 1);
|
||||
assert.deepEqual(startsWith(check.lines, 'FEHLT'), PROFILE_OBJECTS.map((o) => `FEHLT: ${o}`));
|
||||
assert.equal(startsWith(check.lines, 'ABWEICHUNG').length, 0);
|
||||
assert.deepEqual(startsWith(check.lines, 'OK: ').filter((l) => l !== 'OK: Schema vollständig'), kept.map((o) => `OK: ${o}`));
|
||||
assert.ok(check.lines.includes('UNVOLLSTÄNDIG: 4 fehlend.'), check.lines.join('\n'));
|
||||
assert.equal(fake.posts().length, postsBefore);
|
||||
|
||||
// Anlegen: Tabelle, beide Spalten, dann der Unique-Index (erst wenn die Spalten available sind)
|
||||
const apply = await run(fake);
|
||||
assert.equal(apply.code, 0, apply.lines.join('\n'));
|
||||
assert.deepEqual(startsWith(apply.lines, 'ANGELEGT'), PROFILE_OBJECTS.map((o) => `ANGELEGT: ${o}`));
|
||||
assert.deepEqual(startsWith(apply.lines, 'OK: ').filter((l) => l !== 'OK: Schema vollständig'), kept.map((o) => `OK: ${o}`));
|
||||
const newPosts = fake.state.requests.slice(requestsBefore).filter((r) => r.method === 'POST');
|
||||
assert.deepEqual(newPosts.map((r) => [r.path, r.body]), EXPECTED_POSTS.filter(isProfilePost));
|
||||
assert.deepEqual(newPosts.at(-1).body, { key: 'username_key', type: 'unique', columns: ['usernameKey'] });
|
||||
assert.equal(fake.state.indexes.get('aircard/profiles/username_key').type, 'unique');
|
||||
assert.ok(fake.state.indexPosts.every((p) => p.allColumnsAvailable));
|
||||
assert.equal(fake.otherWrites().length, 0);
|
||||
|
||||
assert.equal((await run(fake, { check: true })).code, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('Fehlende Indizes bei vorhandener Tabelle: --check meldet FEHLT, Anlegen legt nur sie an', async () => {
|
||||
await withFake({}, async (fake) => {
|
||||
assert.equal((await run(fake)).code, 0);
|
||||
@@ -1118,6 +1190,33 @@ describe('main gegen Fake-Server', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('Abweichende Benutzernamen-Tabelle (Rechte, Länge, Pflicht, Index nur `key` statt `unique`) wird gemeldet, aber nicht verändert (Exit 1)', async () => {
|
||||
await withFake({}, async (fake) => {
|
||||
assert.equal((await run(fake)).code, 0);
|
||||
fake.state.tables.get('aircard/profiles').$permissions = ['read("any")'];
|
||||
fake.state.tables.get('aircard/profiles').rowSecurity = false;
|
||||
fake.state.columns.get('aircard/profiles/username').size = 30;
|
||||
fake.state.columns.get('aircard/profiles/usernameKey').required = false;
|
||||
fake.state.indexes.get('aircard/profiles/username_key').type = 'key';
|
||||
const requestsBefore = fake.state.requests.length;
|
||||
|
||||
for (const check of [true, false]) {
|
||||
const { code, lines } = await run(fake, { check });
|
||||
assert.equal(code, 1);
|
||||
assert.deepEqual(startsWith(lines, 'ABWEICHUNG'), [
|
||||
'ABWEICHUNG: profiles.$permissions ist [read("any")], erwartet []',
|
||||
'ABWEICHUNG: profiles.rowSecurity ist false, erwartet true',
|
||||
'ABWEICHUNG: profiles.username.size ist 30, erwartet 20',
|
||||
'ABWEICHUNG: profiles.usernameKey.required ist false, erwartet true',
|
||||
'ABWEICHUNG: profiles.username_key.type ist key, erwartet unique',
|
||||
]);
|
||||
assert.equal(startsWith(lines, 'ANGELEGT').length, 0);
|
||||
assert.ok(!lines.includes('OK: Schema vollständig'));
|
||||
}
|
||||
assert.ok(fake.state.requests.slice(requestsBefore).every((r) => r.method === 'GET'));
|
||||
});
|
||||
});
|
||||
|
||||
test('Integer-Spalte mit riesigen Standard-min/max der Antwort gilt als in Ordnung', async () => {
|
||||
await withFake({}, async (fake) => {
|
||||
assert.equal((await run(fake)).code, 0);
|
||||
@@ -1303,6 +1402,8 @@ describe('CLI', () => {
|
||||
assert.match(stdout, /FEHLT: Tabelle logos/);
|
||||
assert.match(stdout, /FEHLT: Index logo_likes\.user_logo/);
|
||||
assert.match(stdout, /FEHLT: Tabelle logo_reports/);
|
||||
assert.match(stdout, /FEHLT: Tabelle profiles/);
|
||||
assert.match(stdout, /FEHLT: Index profiles\.username_key/);
|
||||
assert.match(stdout, /FEHLT: Bucket logo-images/);
|
||||
assert.ok(!(stdout + stderr).includes(KEY));
|
||||
assert.equal(fake.posts().length, 0);
|
||||
|
||||
@@ -7,6 +7,10 @@ const mocks = vi.hoisted(() => ({
|
||||
listeners: new Set<(user: TestUser | null) => void>(),
|
||||
logout: 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', () => ({
|
||||
@@ -18,6 +22,14 @@ vi.mock('./auth', () => ({
|
||||
},
|
||||
}));
|
||||
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';
|
||||
|
||||
@@ -33,11 +45,21 @@ function setUser(user: TestUser | null) {
|
||||
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(() => {
|
||||
mocks.user = null;
|
||||
mocks.name = null;
|
||||
mocks.listeners.clear();
|
||||
mocks.nameListeners.clear();
|
||||
mocks.logout.mockReset();
|
||||
mocks.requireLogin.mockReset();
|
||||
mocks.usernameDialog.mockReset();
|
||||
mocks.usernameDialog.mockResolvedValue(null);
|
||||
document.body.innerHTML = '<div id="account-slot"></div>';
|
||||
slot = document.getElementById('account-slot') as HTMLElement;
|
||||
menu = mountAccountMenu(slot);
|
||||
@@ -130,11 +152,207 @@ describe('Konto-Menü', () => {
|
||||
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' };
|
||||
menu.ready();
|
||||
menu.dispose();
|
||||
expect(slot.childElementCount).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('meldet onUsernameChange denselben Namen noch einmal (Lesen des eigenen Profils nach dem Start), bleibt das Menü unangetastet: offen und mit dem Fokus', () => {
|
||||
signedIn();
|
||||
mocks.name = 'Kenso';
|
||||
menu.ready();
|
||||
avatar()!.click();
|
||||
const before = { avatar: avatar(), panel: panel(), head: head() };
|
||||
const signOut = buttonByText('Abmelden')!;
|
||||
signOut.focus();
|
||||
setName('u1', 'Kenso');
|
||||
expect(avatar()).toBe(before.avatar); // dieselben Elemente, nichts wurde neu gebaut
|
||||
expect(panel()).toBe(before.panel);
|
||||
expect(head()).toBe(before.head);
|
||||
expect(panel()?.hidden).toBe(false);
|
||||
expect(avatar()?.getAttribute('aria-expanded')).toBe('true');
|
||||
expect(document.activeElement).toBe(signOut);
|
||||
expect(nameLine()?.textContent).toBe('@Kenso');
|
||||
});
|
||||
|
||||
it('ein anderer Name oder nur eine andere Schreibweise zeichnet dagegen neu', () => {
|
||||
signedIn();
|
||||
mocks.name = 'kenso';
|
||||
menu.ready();
|
||||
const before = avatar();
|
||||
setName('u1', 'Kenso'); // „kenso“ → „Kenso“ ist erlaubt (Spec § 4) und soll zu sehen sein
|
||||
expect(avatar()).not.toBe(before);
|
||||
expect(nameLine()?.textContent).toBe('@Kenso');
|
||||
const second = avatar();
|
||||
setName('u1', 'Mia');
|
||||
expect(avatar()).not.toBe(second);
|
||||
expect(nameLine()?.textContent).toBe('@Mia');
|
||||
});
|
||||
|
||||
it('die Namensmeldung eines anderen Kontos ändert nichts am Menü des angemeldeten', () => {
|
||||
signedIn();
|
||||
mocks.name = 'Kenso'; // der Name des angemeldeten Kontos bleibt
|
||||
menu.ready();
|
||||
avatar()!.click();
|
||||
const before = panel();
|
||||
for (const fn of [...mocks.nameListeners]) fn('u2', 'Mia'); // das Konto u2 hat sich umbenannt (etwa in einem eben beendeten Aufruf)
|
||||
expect(panel()).toBe(before);
|
||||
expect(panel()?.hidden).toBe(false);
|
||||
expect(nameLine()?.textContent).toBe('@Kenso');
|
||||
});
|
||||
|
||||
it('abgemeldet bleibt der Anmelden-Knopf stehen, auch wenn ein Name gemeldet wird (das Konto, das speicherte, ist nicht mehr angemeldet)', () => {
|
||||
menu.ready();
|
||||
const signIn = buttonByText('Anmelden');
|
||||
for (const fn of [...mocks.nameListeners]) fn('u2', 'Mia');
|
||||
expect(buttonByText('Anmelden')).toBe(signIn);
|
||||
});
|
||||
|
||||
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
|
||||
// und einem Menü (E-Mail, „Meine Karten“, „Abmelden“). Die Texte stehen in der Sprache der Seite; nach einem Sprachwechsel
|
||||
// Konto-Menü im Seitenkopf: abgemeldet „Anmelden“, angemeldet ein Avatar-Kreis mit Initiale und einem Menü (Benutzername und E-Mail,
|
||||
// „Meine Karten“, „Benutzername ändern“, „Abmelden“). Die Texte stehen in der Sprache der Seite; nach einem Sprachwechsel
|
||||
// baut main.ts das Menü neu auf.
|
||||
import './account.css';
|
||||
import { el } from '../editor/dom';
|
||||
@@ -7,6 +7,8 @@ import { t } from '../i18n/i18n';
|
||||
import { currentUser, logout, onAuthChange } from './auth';
|
||||
import { errorMessage } from './errors';
|
||||
import { requireLogin } from './login-dialog';
|
||||
import { myUsername, onUsernameChange } from './profile';
|
||||
import { usernameDialog } from './username-dialog';
|
||||
|
||||
export interface AccountMenu {
|
||||
/** Zeigt das Menü erst, wenn der Anmeldestatus bekannt ist (kein Aufblitzen von „Anmelden“ bei angemeldeten Nutzern). */
|
||||
@@ -18,10 +20,14 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
|
||||
let isReady = false;
|
||||
// Hebt die Dokument-Listener des gerade offenen Menüs wieder auf (null: kein Menü gezeichnet).
|
||||
let detach: (() => void) | null = null;
|
||||
// Der Name, den das gezeichnete Menü zeigt (null: keiner oder gar kein Konto-Menü). Meldet `onUsernameChange` denselben Namen noch einmal
|
||||
// (etwa das Lesen des eigenen Profils nach dem Start), bleibt das Menü stehen: ein neu gebautes Dropdown wäre zu und verlöre den Fokus.
|
||||
let shownName: string | null = null;
|
||||
|
||||
function render(): void {
|
||||
detach?.();
|
||||
detach = null;
|
||||
shownName = null;
|
||||
if (!isReady) {
|
||||
slot.replaceChildren();
|
||||
return;
|
||||
@@ -46,11 +52,21 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
|
||||
},
|
||||
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();
|
||||
shownName = name;
|
||||
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 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 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);
|
||||
|
||||
let isOpen = false;
|
||||
@@ -79,6 +95,14 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
|
||||
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.disabled = true;
|
||||
error.textContent = '';
|
||||
@@ -92,6 +116,11 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
|
||||
}
|
||||
|
||||
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(() => {
|
||||
if (myUsername() === shownName) return; // nichts Neues zu zeigen (derselbe Name, oder ein anderes Konto hat gespeichert)
|
||||
render();
|
||||
});
|
||||
render();
|
||||
|
||||
return {
|
||||
@@ -101,6 +130,7 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
|
||||
},
|
||||
dispose() {
|
||||
off();
|
||||
offName();
|
||||
detach?.();
|
||||
detach = null;
|
||||
slot.replaceChildren();
|
||||
|
||||
@@ -127,18 +127,38 @@
|
||||
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;
|
||||
margin-bottom: 4px;
|
||||
padding: 6px 12px 10px;
|
||||
overflow: hidden;
|
||||
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);
|
||||
font-size: 0.9rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.account-name + .account-email {
|
||||
font-size: 0.8rem; /* mit Namen steht die E-Mail klein darunter */
|
||||
}
|
||||
|
||||
.account-item {
|
||||
appearance: none;
|
||||
display: block;
|
||||
@@ -346,6 +366,25 @@
|
||||
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 */
|
||||
|
||||
.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).
|
||||
* `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 = {
|
||||
database: 'aircard',
|
||||
@@ -21,6 +22,7 @@ export const IDS = {
|
||||
logoLikes: 'logo_likes',
|
||||
logoReports: 'logo_reports',
|
||||
logoBucket: 'logo-images',
|
||||
profiles: 'profiles',
|
||||
} as const;
|
||||
|
||||
export const client: Client = new Client().setEndpoint(APPWRITE_ENDPOINT).setProject(APPWRITE_PROJECT);
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DISPLAY_NAME_KEY, rememberDisplayName, rememberedDisplayName } from './display-name';
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('gemerkter Anzeigename (localStorage)', () => {
|
||||
it('der Schlüssel heißt aircard.displayName', () => {
|
||||
expect(DISPLAY_NAME_KEY).toBe('aircard.displayName');
|
||||
});
|
||||
|
||||
it('ohne gespeicherten Namen ist er leer', () => {
|
||||
expect(rememberedDisplayName()).toBe('');
|
||||
});
|
||||
|
||||
it('liest den gespeicherten Namen', () => {
|
||||
localStorage.setItem(DISPLAY_NAME_KEY, 'Kenso');
|
||||
expect(rememberedDisplayName()).toBe('Kenso');
|
||||
});
|
||||
|
||||
it('speichert den Namen getrimmt unter dem Schlüssel und überschreibt den alten', () => {
|
||||
rememberDisplayName(' Kenso ');
|
||||
expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBe('Kenso');
|
||||
rememberDisplayName('Zoë');
|
||||
expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBe('Zoë');
|
||||
expect(rememberedDisplayName()).toBe('Zoë');
|
||||
});
|
||||
|
||||
it.each([[''], [' ']])('ein leerer Name (%j) entfernt den Schlüssel („Anonym“)', (empty) => {
|
||||
localStorage.setItem(DISPLAY_NAME_KEY, 'Kenso');
|
||||
rememberDisplayName(empty);
|
||||
expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBeNull();
|
||||
expect(rememberedDisplayName()).toBe('');
|
||||
});
|
||||
|
||||
it('gesperrter Speicher beim Lesen: leer, kein Fehler', () => {
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw new DOMException('gesperrt', 'SecurityError');
|
||||
});
|
||||
expect(rememberedDisplayName()).toBe('');
|
||||
});
|
||||
|
||||
it('gesperrter Speicher beim Schreiben und beim Entfernen: kein Fehler', () => {
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new DOMException('voll', 'QuotaExceededError');
|
||||
});
|
||||
vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => {
|
||||
throw new DOMException('gesperrt', 'SecurityError');
|
||||
});
|
||||
expect(() => rememberDisplayName('Kenso')).not.toThrow();
|
||||
expect(() => rememberDisplayName('')).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
// Der zuletzt benutzte Anzeigename (Spec § 4): Der Veröffentlichen-Dialog und der Dialog „Logo hochladen“ lesen und schreiben
|
||||
// denselben Schlüssel in localStorage. Jeder Zugriff steht in try/catch: ist der Speicher gesperrt (z. B. privates Fenster),
|
||||
// bleibt das Feld leer und der Name wird nicht gemerkt, ohne dass etwas scheitert.
|
||||
|
||||
export const DISPLAY_NAME_KEY = 'aircard.displayName';
|
||||
|
||||
/** Der gemerkte Anzeigename; leer, wenn keiner gemerkt ist oder der Speicher nicht lesbar ist. */
|
||||
export function rememberedDisplayName(): string {
|
||||
try {
|
||||
return localStorage.getItem(DISPLAY_NAME_KEY) ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Merkt den Anzeigenamen (getrimmt); ein leerer Name („Anonym“) entfernt den gemerkten. */
|
||||
export function rememberDisplayName(name: string): void {
|
||||
const trimmed = name.trim();
|
||||
try {
|
||||
if (trimmed === '') localStorage.removeItem(DISPLAY_NAME_KEY);
|
||||
else localStorage.setItem(DISPLAY_NAME_KEY, trimmed);
|
||||
} catch {
|
||||
// Gesperrt oder voll: der Name wird dann nur nicht gemerkt.
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { errorMessage, errorType, isInvalidLinkError, isUnauthorized, type ErrorContext } from './errors';
|
||||
import { errorMessage, errorType, isInvalidLinkError, isRowMissing, isUnauthorized, type ErrorContext } from './errors';
|
||||
|
||||
/** Fehler wie vom SDK (AppwriteException): Felder code, type, message. */
|
||||
const fail = (code: number, type: string, message = '') => ({ code, type, message });
|
||||
@@ -149,6 +149,33 @@ describe('errorType', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRowMissing', () => {
|
||||
it('404 mit row_not_found heißt: die Zeile gibt es nicht (oder nicht für mich)', () => {
|
||||
expect(isRowMissing(fail(404, 'row_not_found'))).toBe(true);
|
||||
});
|
||||
|
||||
it('404 ohne Typ (leer oder fehlend) zählt ebenso', () => {
|
||||
expect(isRowMissing(fail(404, ''))).toBe(true);
|
||||
expect(isRowMissing({ code: 404 })).toBe(true);
|
||||
});
|
||||
|
||||
it('eine fehlende Tabelle, Datenbank oder Datei ist ein echter Fehler, kein „Zeile weg“', () => {
|
||||
for (const type of ['table_not_found', 'database_not_found', 'storage_file_not_found']) {
|
||||
expect(isRowMissing(fail(404, type)), type).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('andere Status und Nicht-Fehler nicht', () => {
|
||||
expect(isRowMissing(fail(401, 'row_not_found'))).toBe(false);
|
||||
expect(isRowMissing(fail(500, 'row_not_found'))).toBe(false);
|
||||
expect(isRowMissing({ code: '404', type: 'row_not_found' })).toBe(false);
|
||||
expect(isRowMissing(new TypeError('Failed to fetch'))).toBe(false);
|
||||
expect(isRowMissing('row_not_found')).toBe(false);
|
||||
expect(isRowMissing(null)).toBe(false);
|
||||
expect(isRowMissing(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInvalidLinkError (Passwort-neu-Seite)', () => {
|
||||
it('Link-Fehler', () => {
|
||||
expect(isInvalidLinkError(fail(401, 'user_invalid_token'))).toBe(true);
|
||||
|
||||
@@ -24,6 +24,15 @@ export function isUnauthorized(err: unknown): boolean {
|
||||
return field(err, 'code') === 401;
|
||||
}
|
||||
|
||||
/**
|
||||
* „Die Zeile gibt es nicht (oder nicht für mich)“: 404 `row_not_found` (auch ein 404 ohne Typ). Eine fehlende Tabelle, Datenbank oder
|
||||
* Datei ist dagegen ein echter Fehler. Die Einzel-Abfragen der Tabellen (`getGalleryCard`, `getLogo`, `getUsername`) werten damit 404 als „nicht da“.
|
||||
*/
|
||||
export function isRowMissing(err: unknown): boolean {
|
||||
const type = field(err, 'type');
|
||||
return field(err, 'code') === 404 && (type === 'row_not_found' || type === undefined || type === '');
|
||||
}
|
||||
|
||||
/** Bei `general_argument_invalid` nennt die Nachricht den Parameter: „Invalid `email` param: …“. */
|
||||
function invalidParam(err: unknown): string | null {
|
||||
const message = field(err, 'message');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
@@ -9,9 +9,22 @@ const mocks = vi.hoisted(() => ({
|
||||
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('./username-dialog', () => ({ usernameDialog: profileMocks.usernameDialog }));
|
||||
|
||||
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');
|
||||
let requireLogin: Dialog['requireLogin'];
|
||||
@@ -19,7 +32,17 @@ let opener: HTMLButtonElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
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.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>';
|
||||
opener = document.getElementById('opener') as HTMLButtonElement;
|
||||
opener.focus();
|
||||
@@ -51,6 +74,7 @@ describe('requireLogin: Öffnen und Schließen', () => {
|
||||
it('startet auf Wunsch mit Registrieren oder Passwort vergessen', () => {
|
||||
void requireLogin('register');
|
||||
expect(title()).toBe('Registrieren');
|
||||
expect(input('username').autocomplete).toBe('nickname');
|
||||
expect(input('password').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', () => {
|
||||
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();
|
||||
expect(alertText()).toBe('Die Passwörter stimmen nicht überein.');
|
||||
fill({ password: 'kurz', 'password-repeat': 'kurz' });
|
||||
submit();
|
||||
expect(alertText()).toBe('Das Passwort muss 8 bis 256 Zeichen lang sein.');
|
||||
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 () => {
|
||||
@@ -227,7 +252,7 @@ describe('Registrieren', () => {
|
||||
mocks.createEmailPasswordSession.mockResolvedValue({});
|
||||
mocks.get.mockResolvedValue({ $id: 'u2', email: 'neu@b.de' });
|
||||
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();
|
||||
await expect(result).resolves.toBe(true);
|
||||
expect(mocks.create).toHaveBeenCalledTimes(1);
|
||||
@@ -237,7 +262,7 @@ describe('Registrieren', () => {
|
||||
it('bei vorhandener E-Mail steht die Meldung mit einem Link „Anmelden“', async () => {
|
||||
mocks.create.mockRejectedValue(fail(409, 'user_already_exists'));
|
||||
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();
|
||||
await vi.waitFor(() => expect(alertText()).toContain('Diese E-Mail ist schon registriert. Melde dich an.'));
|
||||
const link = dialogEl().querySelector<HTMLButtonElement>('[role="alert"] button')!;
|
||||
@@ -248,6 +273,330 @@ 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('nickname');
|
||||
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('kein Feld trägt autocomplete „username“: angemeldet wird per E-Mail, für Passwortmanager bleibt sie der Anmeldename', () => {
|
||||
const tokens = () => [...dialogEl().querySelectorAll('input')].map((i) => i.autocomplete);
|
||||
void requireLogin('register');
|
||||
expect(tokens()).toEqual(['nickname', 'email', 'new-password', 'new-password']);
|
||||
buttonByText('Anmelden').click();
|
||||
expect(tokens()).toEqual(['email', 'current-password']);
|
||||
buttonByText('Passwort vergessen?').click();
|
||||
expect(tokens()).toEqual(['email']);
|
||||
});
|
||||
|
||||
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();
|
||||
const failure = new Error('409');
|
||||
profileMocks.saveUsername.mockRejectedValue(failure);
|
||||
const result = requireLogin('register');
|
||||
fill(fields());
|
||||
submit();
|
||||
await expect(result).resolves.toBe(true);
|
||||
expect(dialogEl().open).toBe(false);
|
||||
expect(mocks.get).toHaveBeenCalled(); // angemeldet
|
||||
// Der Namensdialog öffnet mit dem getippten Namen und dem Grund (er steht im Dialog), nicht leer und stumm.
|
||||
expect(profileMocks.usernameDialog).toHaveBeenCalledExactlyOnceWith({ mode: 'prompt', initial: 'Kenso', failure });
|
||||
});
|
||||
|
||||
it('der getippte Name geht getrimmt in den Namensdialog, genau der Fehler des Speicherns als Grund', async () => {
|
||||
serverAccepts();
|
||||
const taken = Object.assign(new Error('vergeben'), { code: 409 });
|
||||
profileMocks.saveUsername.mockRejectedValue(taken);
|
||||
const result = requireLogin('register');
|
||||
fill({ ...fields(), username: ' Mia.K ' });
|
||||
submit();
|
||||
await result;
|
||||
expect(profileMocks.saveUsername).toHaveBeenCalledExactlyOnceWith('Mia.K');
|
||||
expect(profileMocks.usernameDialog.mock.calls[0][0]).toMatchObject({ initial: 'Mia.K' });
|
||||
expect(profileMocks.usernameDialog.mock.calls[0][0].failure).toBe(taken);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
it('führt vom Anmelden zur E-Mail-Eingabe und sendet den Link', async () => {
|
||||
mocks.createRecovery.mockResolvedValue({});
|
||||
@@ -277,3 +626,57 @@ describe('Passwort vergessen', () => {
|
||||
expect(title()).toBe('Anmelden');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Registrieren: die Fehlerzeile bleibt im Sichtbaren', () => {
|
||||
const fields = () => ({ username: 'Kenso', email: 'neu@b.de', password: 'geheim123', 'password-repeat': 'geheim123' });
|
||||
const errorLine = () => dialogEl().querySelector('[role="alert"]');
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('ein Fehler am Namensfeld scrollt die gemeinsame Fehlerzeile (sie steht unter allen Feldern) ins Sichtbare, nur so weit wie nötig', () => {
|
||||
const scroll = vi.spyOn(Element.prototype, 'scrollIntoView');
|
||||
void requireLogin('register');
|
||||
fill({ ...fields(), username: 'x' });
|
||||
submit();
|
||||
expect(alertText()).toBe(INVALID);
|
||||
expect(scroll).toHaveBeenCalledExactlyOnceWith({ block: 'nearest' });
|
||||
expect(scroll.mock.contexts[0]).toBe(errorLine());
|
||||
expect(document.activeElement).toBe(input('username')); // der Fokus bleibt am Feld
|
||||
});
|
||||
|
||||
it('ein gesperrter Name ebenso, jeder neue Fehler scrollt wieder', () => {
|
||||
const scroll = vi.spyOn(Element.prototype, 'scrollIntoView');
|
||||
void requireLogin('register');
|
||||
fill({ ...fields(), username: 'Admin' });
|
||||
submit();
|
||||
fill({ username: 'ab' });
|
||||
submit();
|
||||
expect(scroll).toHaveBeenCalledTimes(2);
|
||||
expect(scroll.mock.contexts.every((node) => node === errorLine())).toBe(true);
|
||||
});
|
||||
|
||||
it('„vergeben“ (nach der Abfrage am Dienst) ebenso', async () => {
|
||||
const scroll = vi.spyOn(Element.prototype, 'scrollIntoView');
|
||||
profileMocks.isUsernameTaken.mockResolvedValue(true);
|
||||
void requireLogin('register');
|
||||
fill(fields());
|
||||
submit();
|
||||
await vi.waitFor(() => expect(alertText()).toBe(TAKEN));
|
||||
expect(scroll).toHaveBeenCalledExactlyOnceWith({ block: 'nearest' });
|
||||
expect(scroll.mock.contexts[0]).toBe(errorLine());
|
||||
});
|
||||
|
||||
it('ohne Fehler am Feld wird nicht gescrollt (gültige Eingabe, Anmelden-Ansicht)', async () => {
|
||||
const scroll = vi.spyOn(Element.prototype, 'scrollIntoView');
|
||||
mocks.create.mockResolvedValue({});
|
||||
mocks.createEmailPasswordSession.mockResolvedValue({});
|
||||
mocks.get.mockResolvedValue({ $id: 'u2', email: 'neu@b.de' });
|
||||
const registered = requireLogin('register');
|
||||
fill(fields());
|
||||
submit();
|
||||
await registered;
|
||||
expect(scroll).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+106
-18
@@ -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 { el } from '../editor/dom';
|
||||
import { el, setFieldInvalid } from '../editor/dom';
|
||||
import { API_MESSAGES } from '../i18n/api-messages';
|
||||
import { msg, t } from '../i18n/i18n';
|
||||
import { login, register, requestPasswordReset, validatePassword } from './auth';
|
||||
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';
|
||||
|
||||
@@ -15,6 +26,8 @@ const TITLES: Record<LoginView, string> = {
|
||||
|
||||
const MISMATCH = msg('Die Passwörter stimmen nicht überein.');
|
||||
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 };
|
||||
|
||||
@@ -29,6 +42,7 @@ type Panel = {
|
||||
function createLoginDialog(): (view: LoginView) => Promise<boolean> {
|
||||
let view: LoginView = 'login';
|
||||
let draftEmail = '';
|
||||
let draftUsername = '';
|
||||
let busy = false;
|
||||
// Zählt jedes Öffnen/Schließen; eine Antwort, die nach dem Schließen eintrifft, wird verworfen.
|
||||
let session = 0;
|
||||
@@ -90,7 +104,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
|
||||
function field(
|
||||
labelText: string,
|
||||
name: string,
|
||||
type: 'email' | 'password',
|
||||
type: 'text' | 'email' | 'password',
|
||||
autocomplete: string,
|
||||
value = '',
|
||||
describedBy?: string,
|
||||
@@ -102,8 +116,9 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
|
||||
autocomplete,
|
||||
value,
|
||||
required: true,
|
||||
autocapitalize: type === 'email' ? 'none' : null,
|
||||
spellcheck: type === 'email' ? 'false' : null,
|
||||
// E-Mail-Adresse und Benutzername: keine automatische Großschreibung, keine Rechtschreibprüfung
|
||||
autocapitalize: type === 'password' ? null : 'none',
|
||||
spellcheck: type === 'password' ? null : 'false',
|
||||
'aria-describedby': describedBy,
|
||||
});
|
||||
const label = el('label', { class: 'auth-field' }, el('span', { class: 'auth-label' }, labelText), input);
|
||||
@@ -162,25 +177,40 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
|
||||
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.
|
||||
* Die Meldungszeile steht unter allen Feldern, bei einem Fehler am ersten (dem Namen) kann sie auf einem kleinen Bildschirm außerhalb
|
||||
* liegen: sie wird ins Sichtbare gerückt, nur so weit wie nötig.
|
||||
*/
|
||||
function showFieldError(panel: Panel, input: HTMLInputElement, text: string): void {
|
||||
showError(panel, text);
|
||||
setFieldInvalid(input, ERROR_ID, true);
|
||||
input.focus();
|
||||
panel.errorEl.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
function showOk(panel: Panel, text: string): void {
|
||||
panel.errorEl.textContent = '';
|
||||
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(
|
||||
panel: Panel,
|
||||
ctx: ErrorContext,
|
||||
action: () => Promise<void>,
|
||||
action: () => Promise<HTMLElement | void>,
|
||||
onError?: (err: unknown) => Node | undefined,
|
||||
): Promise<void> {
|
||||
const mine = session;
|
||||
const focused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
let focused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
setBusy(panel, true);
|
||||
panel.errorEl.textContent = '';
|
||||
panel.okEl.textContent = '';
|
||||
try {
|
||||
await action();
|
||||
focused = (await action()) ?? focused;
|
||||
} catch (err) {
|
||||
if (mine !== session) return;
|
||||
showError(panel, errorMessage(err, ctx), onError?.(err));
|
||||
@@ -190,8 +220,54 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
|
||||
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 failure: unknown;
|
||||
let saved = true;
|
||||
try {
|
||||
await saveUsername(name);
|
||||
} catch (err) {
|
||||
saved = false;
|
||||
failure = err;
|
||||
}
|
||||
finish(true);
|
||||
if (!saved) {
|
||||
// Der Namensdialog beginnt mit dem getippten Namen und nennt den Grund (vergeben, kein Netz …), statt leer und stumm zu erscheinen.
|
||||
usernameDialog({ mode: 'prompt', initial: name, failure }).catch((err: unknown) => {
|
||||
console.error('Der Dialog „Benutzername“ konnte nicht geöffnet werden', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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 form = el('form', { class: 'auth-form' });
|
||||
const submit = (text: string) => el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, text);
|
||||
@@ -215,17 +291,29 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
|
||||
}
|
||||
|
||||
if (forView === 'register') {
|
||||
// `nickname`, nicht `username`: angemeldet wird per E-Mail, und ein Passwortmanager soll das E-Mail-Feld als Anmeldenamen behalten.
|
||||
const username = field(t('Benutzername'), 'username', 'text', 'nickname', draftUsername, 'auth-name-hint');
|
||||
const email = field(t('E-Mail'), 'email', 'email', 'email', draftEmail);
|
||||
const password = field(t('Passwort'), 'password', 'password', 'new-password', '', 'auth-pw-hint');
|
||||
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.'));
|
||||
form.append(email.label, password.label, hint, repeat.label, errorEl, okEl, submit(t('Registrieren')));
|
||||
const panel: Panel = { form, first: draftEmail ? password.input : email.input, errorEl, okEl };
|
||||
form.append(username.label, nameHint, email.label, password.label, hint, repeat.label, errorEl, okEl, submit(t('Registrieren')));
|
||||
// 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) => {
|
||||
event.preventDefault();
|
||||
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();
|
||||
// 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 problem = mismatch ? t(MISMATCH) : validatePassword(password.input.value);
|
||||
if (problem) {
|
||||
@@ -236,10 +324,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
|
||||
void run(
|
||||
panel,
|
||||
'register',
|
||||
async () => {
|
||||
await register(address, password.input.value);
|
||||
finish(true);
|
||||
},
|
||||
() => registerWithName(panel, username.input, name, address, password.input.value),
|
||||
(err) => {
|
||||
if (errorType(err) !== 'user_already_exists') return undefined;
|
||||
return linkButton(t('Anmelden'), () => showView('login'));
|
||||
@@ -267,9 +352,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. */
|
||||
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"]');
|
||||
if (typed) draftEmail = typed.value.trim();
|
||||
const typedName = content.querySelector<HTMLInputElement>('input[name="username"]');
|
||||
if (typedName) draftUsername = typedName.value.trim();
|
||||
view = next;
|
||||
title.textContent = t(TITLES[next]);
|
||||
const panel = buildPanel(next);
|
||||
@@ -287,6 +374,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
|
||||
if (pending) return pending;
|
||||
opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
draftEmail = '';
|
||||
draftUsername = '';
|
||||
busy = false;
|
||||
pending = new Promise<boolean>((resolve) => {
|
||||
settle = resolve;
|
||||
|
||||
@@ -0,0 +1,661 @@
|
||||
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);
|
||||
});
|
||||
|
||||
it('eine Antwort, die vor dem Umbenennen abgeschickt wurde, meldet den alten Namen nicht noch einmal: der neuere Name gilt', async () => {
|
||||
mocks.userId = 'u1';
|
||||
let answer!: (row: unknown) => void;
|
||||
mocks.getRow.mockReturnValue(new Promise((resolve) => (answer = resolve)));
|
||||
const listener = vi.fn();
|
||||
profile.onUsernameChange(listener);
|
||||
const loading = profile.loadMyUsername(); // das Lesen läuft (Stand vor dem Umbenennen)
|
||||
mocks.apiPost.mockResolvedValue({ username: 'Neu' });
|
||||
await profile.saveUsername('Neu'); // währenddessen umbenannt
|
||||
answer({ $id: 'u1', username: 'Alt' }); // die Antwort ist älter als das Umbenennen
|
||||
await expect(loading).resolves.toBe('Neu');
|
||||
expect(profile.myUsername()).toBe('Neu');
|
||||
expect(listener).toHaveBeenCalledExactlyOnceWith('u1', 'Neu'); // „Alt“ wurde nie gemeldet
|
||||
expect([...(await profile.usernamesFor(['u1']))]).toEqual([['u1', 'Neu']]);
|
||||
});
|
||||
|
||||
it('dasselbe gilt für eine Antwort „kein Profil“, die vor dem Speichern abgeschickt wurde (Registrierung)', async () => {
|
||||
mocks.userId = 'u1';
|
||||
let answer!: (error: unknown) => void;
|
||||
mocks.getRow.mockReturnValue(new Promise((_, reject) => (answer = reject)));
|
||||
const listener = vi.fn();
|
||||
profile.onUsernameChange(listener);
|
||||
const loading = profile.loadMyUsername();
|
||||
mocks.apiPost.mockResolvedValue({ username: 'Kenso' });
|
||||
await profile.saveUsername('Kenso');
|
||||
answer(notFound());
|
||||
await expect(loading).resolves.toBe('Kenso');
|
||||
expect(listener).toHaveBeenCalledExactlyOnceWith('u1', 'Kenso');
|
||||
});
|
||||
|
||||
it('ein Lesen, das erst nach dem Umbenennen beginnt, gilt wieder (der Name kann auf einem anderen Gerät geändert worden sein)', async () => {
|
||||
mocks.userId = 'u1';
|
||||
mocks.apiPost.mockResolvedValue({ username: 'Neu' });
|
||||
await profile.saveUsername('Neu');
|
||||
mocks.getRow.mockResolvedValue({ $id: 'u1', username: 'Anderswo' });
|
||||
const listener = vi.fn();
|
||||
profile.onUsernameChange(listener);
|
||||
await expect(profile.loadMyUsername()).resolves.toBe('Anderswo');
|
||||
expect(profile.myUsername()).toBe('Anderswo');
|
||||
expect(listener).toHaveBeenCalledExactlyOnceWith('u1', 'Anderswo');
|
||||
});
|
||||
|
||||
it('das Umbenennen eines anderen Kontos macht ein laufendes Lesen nicht hinfällig', async () => {
|
||||
mocks.userId = 'u1';
|
||||
let answer!: (row: unknown) => void;
|
||||
mocks.getRow.mockReturnValue(new Promise((resolve) => (answer = resolve)));
|
||||
const loading = profile.loadMyUsername();
|
||||
mocks.userId = 'u2'; // der Aufruf gehört einem anderen Konto
|
||||
mocks.apiPost.mockResolvedValue({ username: 'Mia' });
|
||||
await profile.saveUsername('Mia');
|
||||
mocks.userId = 'u1';
|
||||
answer({ $id: 'u1', username: 'Kenso' });
|
||||
await expect(loading).resolves.toBe('Kenso');
|
||||
expect(profile.myUsername()).toBe('Kenso');
|
||||
});
|
||||
});
|
||||
|
||||
describe('byline („von @name“)', () => {
|
||||
const names = new Map([
|
||||
['u1', 'Kenso'],
|
||||
['u2', 'Mia.K'],
|
||||
]);
|
||||
|
||||
it('mit Profil: „von @name“ (der Name, wie er gespeichert ist), der alte Anzeigename zählt dann nicht', () => {
|
||||
expect(profile.byline('u1', '', names)).toBe('von @Kenso');
|
||||
expect(profile.byline('u2', 'Alter Name', names)).toBe('von @Mia.K');
|
||||
});
|
||||
|
||||
it('ohne Profil: der früher gespeicherte Anzeigename, ohne @', () => {
|
||||
expect(profile.byline('u9', 'Anna', names)).toBe('von Anna');
|
||||
expect(profile.byline('u9', 'Anna', new Map())).toBe('von Anna');
|
||||
});
|
||||
|
||||
it('ohne Profil und ohne Anzeigenamen: „von Anonym“', () => {
|
||||
expect(profile.byline('u9', '', names)).toBe('von Anonym');
|
||||
expect(profile.byline('u9', '', new Map())).toBe('von Anonym');
|
||||
});
|
||||
|
||||
it('ein Eintrag ohne Konto-ID (Altbestand) fällt ebenso zurück, auch wenn die Map einen leeren Schlüssel hätte', () => {
|
||||
expect(profile.byline('', 'Anna', names)).toBe('von Anna');
|
||||
expect(profile.byline('', '', names)).toBe('von Anonym');
|
||||
expect(profile.byline('', '', new Map([['', 'Spuk']]))).toBe('von Anonym');
|
||||
});
|
||||
|
||||
it('ein leerer Name in der Map zählt nicht als Profil', () => {
|
||||
expect(profile.byline('u3', 'Anna', new Map([['u3', '']]))).toBe('von Anna');
|
||||
});
|
||||
|
||||
it('Werte mit geschweiften Klammern oder Markup bleiben unverändert (der eingesetzte Text wird nicht noch einmal ausgewertet)', () => {
|
||||
expect(profile.byline('u9', '{name}', new Map())).toBe('von {name}');
|
||||
expect(profile.byline('u9', '<b>fett</b>', new Map())).toBe('von <b>fett</b>');
|
||||
});
|
||||
|
||||
it('Englisch: „by @name“, „by Anna“, „by Anonymous“', async () => {
|
||||
(await import('../i18n/i18n')).setLang('en', { persist: false });
|
||||
expect(profile.byline('u1', '', names)).toBe('by @Kenso');
|
||||
expect(profile.byline('u9', 'Anna', names)).toBe('by Anna');
|
||||
expect(profile.byline('u9', '', names)).toBe('by Anonymous');
|
||||
});
|
||||
|
||||
it('liest nur die übergebene Map: eine Änderung dort zeigt die nächste Zeile sofort (eigene Umbenennung)', () => {
|
||||
const own = new Map([['u1', 'Alt']]);
|
||||
expect(profile.byline('u1', '', own)).toBe('von @Alt');
|
||||
own.set('u1', 'Neu');
|
||||
expect(profile.byline('u1', '', own)).toBe('von @Neu');
|
||||
});
|
||||
});
|
||||
|
||||
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,269 @@
|
||||
// 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. Dazu die Zeile „von @name“ für
|
||||
// Logos und Karten (`byline`).
|
||||
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';
|
||||
import { isRowMissing } from './errors';
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Zeile „von @name“ zu einem Eintrag (Logo-Kachel, Infozeile, Detailseite einer Karte): der Benutzername des Kontos `ownerId`, soweit
|
||||
* `names` (aus `usernamesFor`) ihn kennt. Ohne Profil gilt der früher gespeicherte Anzeigename (ohne @), sonst „Anonym“. In der Sprache der Seite.
|
||||
*/
|
||||
export function byline(ownerId: string, legacyDisplayName: string, names: ReadonlyMap<string, string>): string {
|
||||
const name = ownerId === '' ? '' : (names.get(ownerId) ?? '');
|
||||
if (name !== '') return t('von @{name}', { name });
|
||||
return t('von {name}', { name: legacyDisplayName !== '' ? legacyDisplayName : t('Anonym') });
|
||||
}
|
||||
|
||||
// ---- 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>();
|
||||
/** Zählt die Namen, die diese Seite gespeichert hat; `savedAt` merkt je Konto den Stand beim letzten Speichern (siehe `loadMyUsername`). */
|
||||
let saves = 0;
|
||||
const savedAt = new Map<string, number>();
|
||||
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 --------------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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 started = saves;
|
||||
const name = await getUsername(userId);
|
||||
// Wurde der Name währenddessen gespeichert (Umbenennen, Registrierung: die Abfrage war schneller als das Speichern), ist die Antwort
|
||||
// älter als der gemerkte Name: er gilt, und der alte wird nicht noch einmal gemeldet. Kein Profil heißt sonst ebenfalls „der gemerkte“.
|
||||
if (name === null || (savedAt.get(userId) ?? 0) > started) 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) {
|
||||
savedAt.set(userId, ++saves);
|
||||
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,741 @@
|
||||
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 ein Spitzname, kein Anmeldename (autocomplete „nickname“, nie „username“): keine Großschreibung, keine Rechtschreibprüfung', () => {
|
||||
void dialog.usernameDialog({ mode: 'prompt' });
|
||||
expect(input().autocomplete).toBe('nickname');
|
||||
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('Vorbelegung und Grund beim Öffnen (der Rückfall nach einer Registrierung, deren letzter Schritt scheiterte)', () => {
|
||||
it('`initial` belegt das Feld vor, auch im Modus „prompt“ (Überschrift „wählen“, „Später“), als Wert und nie als HTML; der Text ist markiert', () => {
|
||||
void dialog.usernameDialog({ mode: 'prompt', initial: '<b>Kenso</b>' });
|
||||
expect(input().value).toBe('<b>Kenso</b>');
|
||||
expect(dialogEl()!.querySelector('b')).toBeNull();
|
||||
expect(heading()).toBe('Benutzername wählen');
|
||||
expect(buttonByText('Später')).toBeTruthy();
|
||||
expect(document.activeElement).toBe(input());
|
||||
expect(input().selectionStart).toBe(0);
|
||||
expect(input().selectionEnd).toBe('<b>Kenso</b>'.length);
|
||||
expect(errorText()).toBe(''); // ohne `failure` keine Meldung
|
||||
});
|
||||
|
||||
it('`initial` geht dem Namen des Kontos im Modus „change“ vor', () => {
|
||||
mocks.name = 'Alt';
|
||||
void dialog.usernameDialog({ mode: 'change', initial: 'Getippt' });
|
||||
expect(input().value).toBe('Getippt');
|
||||
expect(heading()).toBe('Benutzername ändern');
|
||||
});
|
||||
|
||||
it('`failure` mit einem Fehler am Namen (vergeben): der Grund steht schon da, am Feld, mit Markierung und Fokus', () => {
|
||||
void dialog.usernameDialog({ mode: 'prompt', initial: 'Kenso', failure: new ApiError(409, TAKEN, undefined, 'usernameTaken') });
|
||||
expect(errorText()).toBe(TAKEN);
|
||||
expect(input().getAttribute('aria-invalid')).toBe('true');
|
||||
expect(describedBy()).toContain(errorEl().id);
|
||||
expect(document.activeElement).toBe(input());
|
||||
expect(input().value).toBe('Kenso');
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
});
|
||||
|
||||
it('`failure` ohne Bezug zum Namen (Netz, Dienst): der Grund steht als Text, das Feld gilt nicht als ungültig, der Name bleibt zum Wiederholen', () => {
|
||||
void dialog.usernameDialog({
|
||||
mode: 'prompt',
|
||||
initial: 'Kenso',
|
||||
failure: new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable'),
|
||||
});
|
||||
expect(errorText()).toBe('Der Dienst ist gerade nicht erreichbar.');
|
||||
expect(input().hasAttribute('aria-invalid')).toBe(false);
|
||||
expect(describedBy()).not.toContain(errorEl().id);
|
||||
expect(input().value).toBe('Kenso');
|
||||
expect(document.activeElement).toBe(input());
|
||||
});
|
||||
|
||||
it('andere Fehler als `failure` werden wie beim Speichern erklärt (kein Netz, abgelaufene Anmeldung, Unbekanntes)', async () => {
|
||||
void dialog.usernameDialog({ mode: 'prompt', failure: new TypeError('Failed to fetch') });
|
||||
expect(errorText()).toBe('Keine Verbindung zum Server.');
|
||||
buttonByText('Später')!.click();
|
||||
await settle();
|
||||
void dialog.usernameDialog({ mode: 'prompt', failure: Object.assign(new Error('x'), { code: 401 }) });
|
||||
expect(errorText()).toBe('Bitte melde dich an.');
|
||||
buttonByText('Später')!.click();
|
||||
await settle();
|
||||
void dialog.usernameDialog({ mode: 'prompt', failure: new Error('???') });
|
||||
expect(errorText()).toBe('Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
|
||||
});
|
||||
|
||||
it('der Grund verschwindet beim nächsten Speichern; ein anderer Name geht durch', async () => {
|
||||
mocks.saveUsername.mockResolvedValue('Kenso2');
|
||||
const result = dialog.usernameDialog({ mode: 'prompt', initial: 'Kenso', failure: new ApiError(409, TAKEN, undefined, 'usernameTaken') });
|
||||
input().value = 'Kenso2';
|
||||
send();
|
||||
expect(errorText()).toBe('');
|
||||
expect(input().hasAttribute('aria-invalid')).toBe(false);
|
||||
await expect(result).resolves.toBe('Kenso2');
|
||||
expect(mocks.saveUsername).toHaveBeenCalledExactlyOnceWith('Kenso2');
|
||||
});
|
||||
|
||||
it('beides gilt nur für diesen Aufruf: der nächste Dialog beginnt leer und ohne Meldung', async () => {
|
||||
const first = dialog.usernameDialog({ mode: 'prompt', initial: 'Kenso', failure: new TypeError('offline') });
|
||||
buttonByText('Später')!.click();
|
||||
await first;
|
||||
void dialog.usernameDialog({ mode: 'prompt' });
|
||||
expect(input().value).toBe('');
|
||||
expect(errorText()).toBe('');
|
||||
});
|
||||
|
||||
it('Englisch: der Grund erscheint in der Sprache der Seite', async () => {
|
||||
(await import('../i18n/i18n')).setLang('en', { persist: false });
|
||||
void dialog.usernameDialog({ mode: 'prompt', initial: 'Kenso', failure: new ApiError(409, TAKEN, undefined, 'usernameTaken') });
|
||||
expect(errorText()).toBe('This username is already taken.');
|
||||
expect(germanLeft(dialogEl()!)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
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('das Signal geht an den Dialog: das Verlassen der Seite schließt ihn, das Ergebnis ist false', async () => {
|
||||
mocks.userId = 'u1';
|
||||
mocks.loadMyUsername.mockResolvedValue(null);
|
||||
const controller = new AbortController();
|
||||
const result = dialog.requireUsername(controller.signal);
|
||||
await settle();
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
controller.abort();
|
||||
await expect(result).resolves.toBe(false);
|
||||
expect(dialogEl()).toBeNull();
|
||||
expect(mocks.saveUsername).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ein schon abgebrochenes Signal öffnet keinen Dialog (false); mit bekanntem Namen ist es trotzdem true', async () => {
|
||||
mocks.userId = 'u1';
|
||||
mocks.loadMyUsername.mockResolvedValue(null);
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await expect(dialog.requireUsername(controller.signal)).resolves.toBe(false);
|
||||
expect(dialogEl()).toBeNull();
|
||||
mocks.name = 'Kenso';
|
||||
await expect(dialog.requireUsername(controller.signal)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
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,255 @@
|
||||
// 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 Name, mit dem das Feld beginnt (geht dem aktuellen Namen im Modus `change` vor), etwa der zuvor getippte nach einem Fehler. */
|
||||
initial?: string;
|
||||
/** Ein Fehler, dessen Grund beim Öffnen schon im Dialog steht, als hätte das Speichern ihn eben gemeldet. */
|
||||
failure?: unknown;
|
||||
}
|
||||
|
||||
/** 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',
|
||||
// `nickname`, nicht `username`: der Name ist ein Spitzname, kein Anmeldename (angemeldet wird per E-Mail); so bleibt für Passwortmanager
|
||||
// das E-Mail-Feld der Anmeldung der Anmeldename, und der Browser schlägt hier nicht die gespeicherte Anmeldung vor.
|
||||
autocomplete: 'nickname',
|
||||
autocapitalize: 'none',
|
||||
spellcheck: 'false',
|
||||
'aria-required': 'true',
|
||||
'aria-describedby': 'username-name-hint',
|
||||
});
|
||||
input.value = opts.initial ?? 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();
|
||||
}
|
||||
|
||||
/** Der Grund eines gescheiterten Speicherns: am Namen (vergeben, gesperrt, ungültig) am Feld, sonst (Netz, Dienst, Anmeldung) nur als Text. */
|
||||
function showFailure(err: unknown): void {
|
||||
if (err instanceof ApiError && err.serverCode !== null && FIELD_CODES.has(err.serverCode)) {
|
||||
showFieldProblem(failureText(err));
|
||||
} else {
|
||||
errorEl.textContent = failureText(err); // der Name kann richtig sein
|
||||
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);
|
||||
showFailure(err);
|
||||
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 (input.value !== '') input.select(); // zum Ändern (oder nach einem Fehler): gleich überschreiben können
|
||||
if (opts.failure !== undefined) showFailure(opts.failure);
|
||||
});
|
||||
}
|
||||
|
||||
/** 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 (seine Angaben `signal`, `initial` und `failure` gelten dann nicht); 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. `signal` schließt den Dialog (Seite verlassen); dann ist das Ergebnis false.
|
||||
*/
|
||||
export async function requireUsername(signal?: AbortSignal): 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', signal })) !== null;
|
||||
}
|
||||
@@ -373,6 +373,37 @@ describe('apiPost', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('isUsernameRequired: verlangt der Dienst einen Benutzernamen?', () => {
|
||||
it('ja bei 403 mit dem Schlüssel usernameRequired (Hochladen und Veröffentlichen ohne Profil)', async () => {
|
||||
const api = await freshApi();
|
||||
const err = await (async () => {
|
||||
fetchMock.mockImplementation(async () => json(403, { error: 'Bitte wähle zuerst einen Benutzernamen.', code: 'usernameRequired' }));
|
||||
return api.apiPost('/api/veroeffentlichen', {}, { auth: true }).catch((e: unknown) => e);
|
||||
})();
|
||||
expect(err).toMatchObject({ code: 403, serverCode: 'usernameRequired' });
|
||||
expect(api.isUsernameRequired(err)).toBe(true);
|
||||
expect(api.isDefinitiveFailure(err)).toBe(true); // es wurde nichts angelegt: die Dateien dieses Versuchs dürfen aufgeräumt werden
|
||||
});
|
||||
|
||||
it('der Schlüssel entscheidet, nicht der Status und nicht der Text', async () => {
|
||||
const api = await freshApi();
|
||||
expect(api.isUsernameRequired(new api.ApiError(403, 'x', undefined, 'usernameRequired'))).toBe(true);
|
||||
expect(api.isUsernameRequired(new api.ApiError(400, 'x', undefined, 'usernameRequired'))).toBe(true);
|
||||
expect(api.isUsernameRequired(new api.ApiError(403, 'Bitte wähle zuerst einen Benutzernamen.'))).toBe(false); // ohne Schlüssel
|
||||
});
|
||||
|
||||
it('andere Fehler nicht: andere 403, Netz, Dienst nicht erreichbar, kein ApiError', async () => {
|
||||
const api = await freshApi();
|
||||
expect(api.isUsernameRequired(new api.ApiError(403, 'x', undefined, 'forbidden'))).toBe(false);
|
||||
expect(api.isUsernameRequired(new api.ApiError(409, 'x', undefined, 'usernameTaken'))).toBe(false);
|
||||
expect(api.isUsernameRequired(api.unavailableError())).toBe(false);
|
||||
expect(api.isUsernameRequired(new TypeError('Failed to fetch'))).toBe(false);
|
||||
expect(api.isUsernameRequired(Object.assign(new Error('x'), { code: 403, serverCode: 'usernameRequired' }))).toBe(false);
|
||||
expect(api.isUsernameRequired(null)).toBe(false);
|
||||
expect(api.isUsernameRequired(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDefinitiveFailure: hat der Server die Anfrage eindeutig abgelehnt?', () => {
|
||||
it.each([400, 401, 403, 404, 409, 422])('ApiError %i: ja', async (code) => {
|
||||
const api = await freshApi();
|
||||
|
||||
@@ -218,6 +218,14 @@ export function isAlreadyPublished(err: unknown): boolean {
|
||||
return err instanceof ApiError && err.code === 409;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wahr, wenn der Dienst einen Benutzernamen verlangt (403 `usernameRequired`): das Konto hat noch keinen, und Hochladen und
|
||||
* Veröffentlichen gehen erst mit Namen (Spec § 4). Der Aufrufer zeigt dann den Namensdialog; angelegt wurde nichts.
|
||||
*/
|
||||
export function isUsernameRequired(err: unknown): boolean {
|
||||
return err instanceof ApiError && err.serverCode === 'usernameRequired';
|
||||
}
|
||||
|
||||
/**
|
||||
* Veröffentlicht eine Karte. Doppelt veröffentlicht (409) und „Dateien schon belegt oder gesperrt“ (403) kommen
|
||||
* beide als ApiError 409 „Diese Karte ist schon veröffentlicht.“ an. Das Limit des Dienstes (429 mit dem Limit-Text) ist
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// Direkt veröffentlichen: nach dem Speichern (Editor) und beim Mehrfach-Upload („Meine Karten“) ohne Dialog.
|
||||
// Titel = Name der Karte. Ist die Karte schon veröffentlicht, wird die alte Veröffentlichung zurückgezogen und die neue Fassung
|
||||
// veröffentlicht, damit die Galerie immer den gespeicherten Stand zeigt. Fehlt der Benutzername, fragt der Namensdialog einmal nach.
|
||||
import type { SavedCard } from '../account/cards';
|
||||
import { requireUserId } from '../account/cards';
|
||||
import { usernameDialog } from '../account/username-dialog';
|
||||
import { withLogin } from '../account/with-login';
|
||||
import { isAlreadyPublished, isUsernameRequired, withdrawCard } from './api';
|
||||
import { myPublications } from './gallery';
|
||||
import { publishSavedCard, type PublishForm } from './publish';
|
||||
|
||||
const TITLE_MAX = 60;
|
||||
|
||||
export function autoPublishForm(name: string): PublishForm {
|
||||
const title = name.trim().slice(0, TITLE_MAX).trim();
|
||||
return { title: title === '' ? 'AirCard' : title, issuer: '', tags: '' };
|
||||
}
|
||||
|
||||
export async function autoPublish(card: SavedCard, signal?: AbortSignal): Promise<{ id: string }> {
|
||||
const form = autoPublishForm(card.name);
|
||||
const publish = () => withLogin(() => publishSavedCard(card, form));
|
||||
let askedName = false;
|
||||
let replaced = false;
|
||||
for (;;) {
|
||||
try {
|
||||
return await publish();
|
||||
} catch (err) {
|
||||
if (isUsernameRequired(err) && !askedName) {
|
||||
askedName = true;
|
||||
if ((await usernameDialog({ mode: 'required', signal })) === null) throw err;
|
||||
continue;
|
||||
}
|
||||
if (isAlreadyPublished(err) && !replaced) {
|
||||
replaced = true;
|
||||
const old = (await myPublications(requireUserId())).get(card.id);
|
||||
if (!old) throw err;
|
||||
await withLogin(() => withdrawCard(old.id));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -564,6 +564,12 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Solange der Name des Autors noch gesucht wird, ist die Zeile leer (kein Knoten darin). Ein Zeichen ohne Breite (U+200B) hält die Zeile
|
||||
in der Höhe des Textes, damit die Seite nicht springt, wenn „von @name · Datum“ dazukommt. */
|
||||
.detail-meta:empty::before {
|
||||
content: '\200b';
|
||||
}
|
||||
|
||||
.detail-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { AppwriteException, Query, type Models } from 'appwrite';
|
||||
import { ID_PATTERN, fetchFile } from '../account/cards';
|
||||
import { IDS, tables } from '../account/appwrite';
|
||||
import { isRowMissing } from '../account/errors';
|
||||
import { msg } from '../i18n/i18n';
|
||||
|
||||
export interface GalleryCard {
|
||||
@@ -112,13 +113,6 @@ export async function listGallery(opts: GalleryOptions): Promise<{ cards: Galler
|
||||
return { cards, nextCursor: cards.length === PAGE_SIZE ? cards[cards.length - 1].id : null };
|
||||
}
|
||||
|
||||
/** „Die Zeile gibt es nicht (oder nicht für mich)“; 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 === '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Karte per ID. Gibt es sie nicht oder ist sie für mich ausgeblendet (Appwrite antwortet beides mit 404), ist das
|
||||
* Ergebnis null. Eine ID, die keine Appwrite-ID sein kann, ergibt null ohne Anfrage.
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// publish.ts (normalizeTags) und api.ts hängen am Appwrite-Client; der Dialog bekommt `submit` von außen.
|
||||
const mocks = vi.hoisted(() => ({ usernameDialog: vi.fn() }));
|
||||
|
||||
// publish.ts (normalizeTags) und api.ts hängen am Appwrite-Client; der Dialog bekommt `submit` von außen. Der Namensdialog ist eine Attrappe.
|
||||
vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
|
||||
vi.mock('../account/auth', () => ({ currentUserId: () => null, onAuthChange: () => () => {}, onLogout: () => () => {} }));
|
||||
vi.mock('../account/username-dialog', () => ({ usernameDialog: mocks.usernameDialog }));
|
||||
|
||||
import { DISPLAY_NAME_KEY } from '../account/display-name';
|
||||
import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login';
|
||||
import { germanLeft } from '../i18n/english-check';
|
||||
import { setLang } from '../i18n/i18n';
|
||||
@@ -31,16 +33,18 @@ const form = () => dialogEl()!.querySelector('form')!;
|
||||
const send = () => form().dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||
const errorText = () => dialogEl()!.querySelector('[role="alert"]')!.textContent;
|
||||
const statusText = () => dialogEl()!.querySelector('[role="status"]')!.textContent;
|
||||
const errorLine = () => dialogEl()!.querySelector<HTMLElement>('[role="alert"]')!;
|
||||
/** Die IDs in `aria-describedby` eines Feldes (leer, wenn es das Attribut nicht hat). */
|
||||
const describedBy = (name: string) => (input(name).getAttribute('aria-describedby') ?? '').split(' ').filter(Boolean);
|
||||
const settle = async () => {
|
||||
for (let i = 0; i < 30; i++) await Promise.resolve();
|
||||
};
|
||||
|
||||
function fill(values: Partial<PublishFormValues> = {}): void {
|
||||
const v = { title: 'Meine Karte', issuer: '', tags: '', displayName: '', consent: true, ...values };
|
||||
const v = { title: 'Meine Karte', issuer: '', tags: '', consent: true, ...values };
|
||||
input('title').value = v.title;
|
||||
input('issuer').value = v.issuer;
|
||||
input('tags').value = v.tags;
|
||||
input('displayName').value = v.displayName;
|
||||
consent().checked = v.consent;
|
||||
}
|
||||
|
||||
@@ -50,13 +54,13 @@ function open(opts: { cardName?: string; signal?: AbortSignal } = {}): Promise<P
|
||||
|
||||
beforeEach(() => {
|
||||
submit.mockReset();
|
||||
mocks.usernameDialog.mockReset();
|
||||
mocks.usernameDialog.mockResolvedValue(null);
|
||||
document.body.innerHTML = '';
|
||||
localStorage.clear(); // der Anzeigename wird gemerkt und im nächsten Test wieder vorbelegt
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -65,24 +69,23 @@ describe('Veröffentlichen-Dialog: Felder', () => {
|
||||
void open({ cardName: 'Urlaub' });
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Karte veröffentlichen');
|
||||
expect([...form().querySelectorAll('input')].map((i) => i.name)).toEqual(['title', 'issuer', 'tags', 'displayName', 'consent']);
|
||||
expect([...form().querySelectorAll('input')].map((i) => i.name)).toEqual(['title', 'issuer', 'tags', 'consent']);
|
||||
expect([...form().querySelectorAll('.auth-label')].map((l) => l.textContent)).toEqual([
|
||||
'Titel (Pflicht)',
|
||||
'Herausgeber / Bank (optional)',
|
||||
'Tags (optional)',
|
||||
'Anzeigename (optional)',
|
||||
]);
|
||||
for (const name of ['title', 'issuer', 'tags', 'displayName']) expect(input(name).value).toBe('');
|
||||
for (const name of ['title', 'issuer', 'tags']) expect(input(name).value).toBe('');
|
||||
expect(consent().type).toBe('checkbox');
|
||||
expect(consent().checked).toBe(false);
|
||||
expect(buttonByText('Abbrechen')).toBeTruthy();
|
||||
expect(buttonByText('Veröffentlichen')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('der Anzeigename ist leer und nennt „Anonym“ als Platzhalter (nie eine E-Mail)', () => {
|
||||
it('es gibt kein Feld „Anzeigename“ mehr (die Karte zeigt den Benutzernamen, Spec § 5), und nie eine E-Mail im Dialog', () => {
|
||||
void open();
|
||||
expect(input('displayName').value).toBe('');
|
||||
expect(input('displayName').placeholder).toBe('Anonym');
|
||||
expect(dialogEl()!.querySelector('input[name="displayName"]')).toBeNull();
|
||||
expect(dialogEl()!.textContent).not.toMatch(/Anzeigename|Anonym/);
|
||||
expect(dialogEl()!.textContent).not.toMatch(/@/);
|
||||
});
|
||||
|
||||
@@ -96,7 +99,7 @@ describe('Veröffentlichen-Dialog: Felder', () => {
|
||||
void open();
|
||||
const labels = [...form().querySelectorAll('label')];
|
||||
const inputs = [...form().querySelectorAll('input')];
|
||||
expect(labels).toHaveLength(5);
|
||||
expect(labels).toHaveLength(4);
|
||||
expect(labels.map((l) => l.control)).toEqual(inputs);
|
||||
for (const control of inputs) expect(control.labels).toHaveLength(1);
|
||||
const ids = [...dialogEl()!.querySelectorAll('[id]')].map((n) => n.id);
|
||||
@@ -123,6 +126,7 @@ describe('Veröffentlichen-Dialog: Prüfung vor dem Senden', () => {
|
||||
expect(submit).not.toHaveBeenCalled();
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
expect(input(field).getAttribute('aria-invalid')).toBe('true');
|
||||
expect(describedBy(field)).toContain(errorLine().id); // das ungültige Feld verweist auf die Fehlerzeile
|
||||
expect(document.activeElement).toBe(input(field));
|
||||
};
|
||||
|
||||
@@ -146,10 +150,6 @@ describe('Veröffentlichen-Dialog: Prüfung vor dem Senden', () => {
|
||||
await invalid({ tags: `visa, ${'x'.repeat(21)}` }, 'Ein Tag darf höchstens 20 Zeichen lang sein.', 'tags');
|
||||
});
|
||||
|
||||
it('Anzeigename länger als 30 Zeichen', async () => {
|
||||
await invalid({ displayName: 'x'.repeat(31) }, 'Der Anzeigename ist zu lang (höchstens 30 Zeichen).', 'displayName');
|
||||
});
|
||||
|
||||
it('Checkbox fehlt', async () => {
|
||||
await invalid({ consent: false }, 'Bitte bestätige die Erklärung zum Bild.', 'consent');
|
||||
});
|
||||
@@ -174,7 +174,7 @@ describe('Veröffentlichen-Dialog: Prüfung vor dem Senden', () => {
|
||||
await invalid({ title: '\u200b\u200d' }, 'Der Titel enthält ungültige Zeichen.', 'title');
|
||||
});
|
||||
|
||||
it('unsichtbare Zeichen in Herausgeber, Tags und Anzeigename', async () => {
|
||||
it('unsichtbare Zeichen im Herausgeber', async () => {
|
||||
await invalid({ issuer: 'Bank\u200f' }, 'Herausgeber / Bank enthält ungültige Zeichen.', 'issuer');
|
||||
});
|
||||
|
||||
@@ -182,22 +182,18 @@ describe('Veröffentlichen-Dialog: Prüfung vor dem Senden', () => {
|
||||
await invalid({ tags: 'visa, gol\u200bd' }, 'Tags enthalten ungültige Zeichen.', 'tags');
|
||||
});
|
||||
|
||||
it('unsichtbares Zeichen im Anzeigenamen', async () => {
|
||||
await invalid({ displayName: 'Ken\u2060so' }, 'Der Anzeigename enthält ungültige Zeichen.', 'displayName');
|
||||
});
|
||||
|
||||
it('normale Sonderzeichen (Umlaute, Emoji, Bindestrich, Zeilenabstand durch Leerzeichen) bleiben erlaubt', async () => {
|
||||
submit.mockResolvedValue({ id: 'g1' });
|
||||
const outcome = open();
|
||||
fill({ title: 'Größe 😀 – Möwe', issuer: 'Café-Bank', displayName: 'Zoë', tags: 'grün, ✨' });
|
||||
fill({ title: 'Größe 😀 – Möwe', issuer: 'Café-Bank', tags: 'grün, ✨' });
|
||||
send();
|
||||
await expect(outcome).resolves.toBe('published');
|
||||
});
|
||||
|
||||
it('Grenzen sind inklusive: 60/40/30 Zeichen und fünf Tags gehen durch; doppelte und leere Tags zählen nicht', async () => {
|
||||
it('Grenzen sind inklusive: 60/40 Zeichen und fünf Tags gehen durch; doppelte und leere Tags zählen nicht', async () => {
|
||||
submit.mockResolvedValue({ id: 'g1' });
|
||||
const outcome = open();
|
||||
fill({ title: 'x'.repeat(60), issuer: 'y'.repeat(40), displayName: 'z'.repeat(30), tags: 'A, a, , b, c, d, e,' });
|
||||
fill({ title: 'x'.repeat(60), issuer: 'y'.repeat(40), tags: 'A, a, , b, c, d, e,' });
|
||||
send();
|
||||
await expect(outcome).resolves.toBe('published');
|
||||
expect(submit).toHaveBeenCalledOnce();
|
||||
@@ -215,8 +211,52 @@ describe('Veröffentlichen-Dialog: Prüfung vor dem Senden', () => {
|
||||
expect(errorText()).toBe('');
|
||||
});
|
||||
|
||||
describe('Fehlerzeile und Felder (Barrierefreiheit)', () => {
|
||||
it('die Fehlerzeile hat eine ID, die im Dialog einmalig ist', () => {
|
||||
void open();
|
||||
expect(errorLine().id).toBe('publish-error');
|
||||
expect(dialogEl()!.querySelectorAll(`#${errorLine().id}`)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ohne Fehler verweist kein Feld auf die Fehlerzeile; die Hinweise bleiben', () => {
|
||||
void open();
|
||||
expect(describedBy('tags')).toEqual(['publish-tags-hint']);
|
||||
expect(describedBy('title')).toEqual([]);
|
||||
expect(describedBy('consent')).toEqual([]);
|
||||
});
|
||||
|
||||
it('ein ungültiges Feld verweist mit aria-describedby auf die Fehlerzeile, hinter seinem Hinweis, wenn es einen hat', () => {
|
||||
void open();
|
||||
fill({ title: '' });
|
||||
send();
|
||||
expect(describedBy('title')).toEqual(['publish-error']);
|
||||
|
||||
fill({ tags: 'a,b,c,d,e,f' });
|
||||
send();
|
||||
expect(describedBy('tags')).toEqual(['publish-tags-hint', 'publish-error']);
|
||||
expect(describedBy('title')).toEqual([]); // nur das Feld mit dem Problem
|
||||
|
||||
fill({ consent: false });
|
||||
send();
|
||||
expect(describedBy('consent')).toEqual(['publish-error']);
|
||||
expect(describedBy('tags')).toEqual(['publish-tags-hint']); // nur das Feld mit dem aktuellen Problem
|
||||
});
|
||||
|
||||
it('ist das Feld behoben, fällt die Verknüpfung beim nächsten Senden weg und der Hinweis bleibt', () => {
|
||||
submit.mockResolvedValue({ id: 'g1' });
|
||||
void open();
|
||||
fill({ tags: 'a,b,c,d,e,f' });
|
||||
send();
|
||||
expect(describedBy('tags')).toEqual(['publish-tags-hint', 'publish-error']);
|
||||
fill({ tags: 'a,b' });
|
||||
send();
|
||||
expect(describedBy('tags')).toEqual(['publish-tags-hint']);
|
||||
expect(input('tags').hasAttribute('aria-invalid')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('validatePublishForm meldet das erste Problem in Formularreihenfolge, sonst null', () => {
|
||||
const ok: PublishFormValues = { title: 'T', issuer: '', tags: '', displayName: '', consent: true };
|
||||
const ok: PublishFormValues = { title: 'T', issuer: '', tags: '', consent: true };
|
||||
expect(validatePublishForm(ok)).toBeNull();
|
||||
expect(validatePublishForm({ ...ok, title: '', consent: false })?.field).toBe('title');
|
||||
expect(validatePublishForm({ ...ok, tags: 'a,b,c,d,e,f', consent: false })?.field).toBe('tags');
|
||||
@@ -225,23 +265,23 @@ describe('Veröffentlichen-Dialog: Prüfung vor dem Senden', () => {
|
||||
});
|
||||
|
||||
describe('Veröffentlichen-Dialog: Senden', () => {
|
||||
it('sendet die getrimmten Eingaben (Tags roh, Normalisierung macht publishSavedCard) und schließt bei Erfolg', async () => {
|
||||
it('sendet die getrimmten Eingaben (Tags roh, Normalisierung macht publishSavedCard) und schließt bei Erfolg; einen Anzeigenamen gibt es nicht mehr', async () => {
|
||||
submit.mockResolvedValue({ id: 'g1' });
|
||||
const outcome = open();
|
||||
fill({ title: ' Mein Motiv ', issuer: ' Beispielbank ', tags: 'Visa, Gold', displayName: ' Kenso ' });
|
||||
fill({ title: ' Mein Motiv ', issuer: ' Beispielbank ', tags: 'Visa, Gold' });
|
||||
send();
|
||||
await expect(outcome).resolves.toBe('published');
|
||||
expect(submit).toHaveBeenCalledWith({ title: 'Mein Motiv', issuer: 'Beispielbank', tags: 'Visa, Gold', displayName: 'Kenso' });
|
||||
expect(submit).toHaveBeenCalledExactlyOnceWith({ title: 'Mein Motiv', issuer: 'Beispielbank', tags: 'Visa, Gold' });
|
||||
expect(dialogEl()).toBeNull();
|
||||
});
|
||||
|
||||
it('leere optionale Felder werden leer gesendet (Anonym macht der Server)', async () => {
|
||||
it('leere optionale Felder werden leer gesendet', async () => {
|
||||
submit.mockResolvedValue({ id: 'g1' });
|
||||
const outcome = open();
|
||||
fill({ title: 'Nur Titel' });
|
||||
send();
|
||||
await outcome;
|
||||
expect(submit).toHaveBeenCalledWith({ title: 'Nur Titel', issuer: '', tags: '', displayName: '' });
|
||||
expect(submit).toHaveBeenCalledExactlyOnceWith({ title: 'Nur Titel', issuer: '', tags: '' });
|
||||
});
|
||||
|
||||
it('während des Sendens: „Veröffentlichen“ und „Abbrechen“ gesperrt, Felder gesperrt, zweites Senden wird ignoriert', async () => {
|
||||
@@ -389,6 +429,119 @@ describe('Veröffentlichen-Dialog: Senden', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Veröffentlichen-Dialog: Benutzername nötig (403 usernameRequired)', () => {
|
||||
const required = () => new ApiError(403, 'Bitte wähle zuerst einen Benutzernamen.', true, 'usernameRequired');
|
||||
const REASON = 'Veröffentlichen fehlgeschlagen – Bitte wähle zuerst einen Benutzernamen.';
|
||||
|
||||
it('der Namensdialog „required“ öffnet sich (mit dem Signal der Seite); der Dialog bleibt offen und nennt den Grund', async () => {
|
||||
mocks.usernameDialog.mockReturnValue(new Promise(() => {})); // der Nutzer überlegt noch
|
||||
submit.mockRejectedValueOnce(required());
|
||||
const controller = new AbortController();
|
||||
void open({ signal: controller.signal });
|
||||
fill();
|
||||
send();
|
||||
await settle();
|
||||
expect(mocks.usernameDialog).toHaveBeenCalledExactlyOnceWith({ mode: 'required', signal: controller.signal });
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
expect(errorText()).toBe(REASON);
|
||||
expect(buttonByText('Veröffentlichen')!.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('nach dem gespeicherten Namen ist der Grund weg, die Eingaben bleiben, der Fokus liegt auf „Veröffentlichen“ und ein neuer Versuch geht durch', async () => {
|
||||
mocks.usernameDialog.mockResolvedValue('Kenso');
|
||||
submit.mockRejectedValueOnce(required());
|
||||
const outcome = open();
|
||||
fill({ title: 'Mein Motiv', issuer: 'Bank', tags: 'visa' });
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe('');
|
||||
expect(input('title').value).toBe('Mein Motiv');
|
||||
expect(input('issuer').value).toBe('Bank');
|
||||
expect(consent().checked).toBe(true);
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
expect(document.activeElement).toBe(buttonByText('Veröffentlichen'));
|
||||
|
||||
submit.mockResolvedValueOnce({ id: 'g1' });
|
||||
send();
|
||||
await expect(outcome).resolves.toBe('published');
|
||||
expect(submit).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.usernameDialog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('„Abbrechen“ im Namensdialog: der Grund bleibt stehen, nichts ist veröffentlicht, der Dialog bleibt zum Abbrechen oder Wiederholen', async () => {
|
||||
mocks.usernameDialog.mockResolvedValue(null);
|
||||
submit.mockRejectedValueOnce(required());
|
||||
void open();
|
||||
fill();
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe(REASON);
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
expect(submit).toHaveBeenCalledOnce();
|
||||
// ein neuer Versuch fragt der Dienst wieder, und der Namensdialog erscheint wieder
|
||||
submit.mockRejectedValueOnce(required());
|
||||
send();
|
||||
await settle();
|
||||
expect(mocks.usernameDialog).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('lässt sich der Namensdialog nicht öffnen, bleibt der Grund stehen und nichts wirft', async () => {
|
||||
mocks.usernameDialog.mockRejectedValue(new Error('showModal kaputt'));
|
||||
submit.mockRejectedValueOnce(required());
|
||||
void open();
|
||||
fill();
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe(REASON);
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
expect(buttonByText('Veröffentlichen')!.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('wird der Dialog währenddessen geschlossen (Seite verlassen), bleibt die Antwort des Namensdialogs folgenlos', async () => {
|
||||
let answer!: (name: string | null) => void;
|
||||
mocks.usernameDialog.mockReturnValue(new Promise<string | null>((resolve) => (answer = resolve)));
|
||||
submit.mockRejectedValueOnce(required());
|
||||
const controller = new AbortController();
|
||||
const outcome = open({ signal: controller.signal });
|
||||
fill();
|
||||
send();
|
||||
await settle();
|
||||
controller.abort();
|
||||
await expect(outcome).resolves.toBe('cancelled');
|
||||
answer('Kenso');
|
||||
await settle();
|
||||
expect(dialogEl()).toBeNull();
|
||||
expect(submit).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('andere Fehler (auch andere 403) öffnen den Namensdialog nicht', async () => {
|
||||
submit.mockRejectedValueOnce(new ApiError(403, 'Dazu hast du keine Berechtigung.', true, 'forbidden'));
|
||||
void open();
|
||||
fill();
|
||||
send();
|
||||
await settle();
|
||||
submit.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable'));
|
||||
send();
|
||||
await settle();
|
||||
submit.mockRejectedValueOnce(new Error('boom'));
|
||||
send();
|
||||
await settle();
|
||||
expect(mocks.usernameDialog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Englisch: der Grund erscheint auf Englisch', async () => {
|
||||
setLang('en', { persist: false });
|
||||
mocks.usernameDialog.mockResolvedValue(null);
|
||||
submit.mockRejectedValueOnce(required());
|
||||
void open();
|
||||
fill();
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe('Publishing failed – Please choose a username first.');
|
||||
expect(germanLeft(dialogEl()!)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Veröffentlichen-Dialog: fehlendes Logo der Karte', () => {
|
||||
it('der Text zum fehlenden Logo kommt im Wortlaut an (nicht als „Etwas ist schiefgelaufen“)', async () => {
|
||||
submit.mockRejectedValueOnce(new Error(LOGO_MISSING_PUBLISH));
|
||||
@@ -408,81 +561,6 @@ describe('Veröffentlichen-Dialog: fehlendes Logo der Karte', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Veröffentlichen-Dialog: gemerkter Anzeigename', () => {
|
||||
it('der Anzeigename ist mit dem zuletzt benutzten Namen (aircard.displayName) vorbelegt, die anderen Felder bleiben leer', () => {
|
||||
expect(DISPLAY_NAME_KEY).toBe('aircard.displayName');
|
||||
localStorage.setItem('aircard.displayName', 'Kenso');
|
||||
void open();
|
||||
expect(input('displayName').value).toBe('Kenso');
|
||||
for (const name of ['title', 'issuer', 'tags']) expect(input(name).value).toBe('');
|
||||
expect(input('displayName').placeholder).toBe('Anonym');
|
||||
});
|
||||
|
||||
it('gesperrter Speicher: das Feld bleibt leer, der Dialog öffnet sich trotzdem', () => {
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw new DOMException('gesperrt', 'SecurityError');
|
||||
});
|
||||
void open();
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
expect(input('displayName').value).toBe('');
|
||||
});
|
||||
|
||||
it('nach dem Veröffentlichen wird der (getrimmte) Anzeigename gemerkt', async () => {
|
||||
submit.mockResolvedValue({ id: 'g1' });
|
||||
const outcome = open();
|
||||
fill({ displayName: ' Kenso ' });
|
||||
send();
|
||||
await expect(outcome).resolves.toBe('published');
|
||||
expect(localStorage.getItem('aircard.displayName')).toBe('Kenso');
|
||||
});
|
||||
|
||||
it('ein geleerter Anzeigename entfernt den gemerkten (Anonym)', async () => {
|
||||
localStorage.setItem('aircard.displayName', 'Kenso');
|
||||
submit.mockResolvedValue({ id: 'g1' });
|
||||
const outcome = open();
|
||||
fill({ displayName: '' });
|
||||
send();
|
||||
await outcome;
|
||||
expect(localStorage.getItem('aircard.displayName')).toBeNull();
|
||||
});
|
||||
|
||||
it('bei einem Fehler wird nichts gemerkt, auch nicht bei „schon veröffentlicht“', async () => {
|
||||
localStorage.setItem('aircard.displayName', 'Alt');
|
||||
submit.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
|
||||
const outcome = open();
|
||||
fill({ displayName: 'Neu' });
|
||||
send();
|
||||
await settle();
|
||||
expect(localStorage.getItem('aircard.displayName')).toBe('Alt');
|
||||
submit.mockRejectedValueOnce(new ApiError(409, 'Diese Karte ist schon veröffentlicht.'));
|
||||
send();
|
||||
await settle();
|
||||
expect(localStorage.getItem('aircard.displayName')).toBe('Alt');
|
||||
buttonByText('Schließen')!.click();
|
||||
await expect(outcome).resolves.toBe('already');
|
||||
expect(localStorage.getItem('aircard.displayName')).toBe('Alt');
|
||||
});
|
||||
|
||||
it('beim Abbrechen und beim Schließen durch das Signal wird nichts gemerkt', async () => {
|
||||
const controller = new AbortController();
|
||||
let finish!: (r: { id: string }) => void;
|
||||
submit.mockReturnValue(new Promise((r) => (finish = r)));
|
||||
const outcome = open({ signal: controller.signal });
|
||||
fill({ displayName: 'Kenso' });
|
||||
send();
|
||||
controller.abort();
|
||||
await outcome;
|
||||
finish({ id: 'g1' });
|
||||
await settle();
|
||||
expect(localStorage.getItem('aircard.displayName')).toBeNull();
|
||||
|
||||
void open();
|
||||
fill({ displayName: 'Kenso' });
|
||||
buttonByText('Abbrechen')!.click();
|
||||
expect(localStorage.getItem('aircard.displayName')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Veröffentlichen-Dialog: Schließen', () => {
|
||||
it('„Abbrechen“ schließt ohne zu senden', async () => {
|
||||
const outcome = open();
|
||||
@@ -571,11 +649,8 @@ describe('Veröffentlichen-Dialog auf Englisch', () => {
|
||||
'Title (required)',
|
||||
'Issuer / bank (optional)',
|
||||
'Tags (optional)',
|
||||
'Display name (optional)',
|
||||
]);
|
||||
expect(dialog.querySelector('#publish-tags-hint')!.textContent).toBe('Up to 5, separated by commas, e.g. “visa, gold”.');
|
||||
expect(dialog.querySelector('#publish-displayName-hint')!.textContent).toBe('Leave empty for “Anonymous”. Your email address is never shown.');
|
||||
expect(input('displayName').placeholder).toBe('Anonymous');
|
||||
expect(consent().closest('label')!.textContent).toBe('The image is mine or I’m allowed to use it, and it contains no real card data.');
|
||||
expect(buttonByText('Cancel')).toBeTruthy();
|
||||
expect(buttonByText('Publish')).toBeTruthy();
|
||||
@@ -589,7 +664,7 @@ describe('Veröffentlichen-Dialog auf Englisch', () => {
|
||||
});
|
||||
|
||||
it('die Prüfung vor dem Senden meldet in der Sprache der Seite', () => {
|
||||
const ok: PublishFormValues = { title: 'T', issuer: '', tags: '', displayName: '', consent: true };
|
||||
const ok: PublishFormValues = { title: 'T', issuer: '', tags: '', consent: true };
|
||||
const problem = (values: Partial<PublishFormValues>) => validatePublishForm({ ...ok, ...values })?.message;
|
||||
expect(problem({ title: ' ' })).toBe('Please enter a title.');
|
||||
expect(problem({ title: 'a\tb' })).toBe('The title contains invalid characters.');
|
||||
@@ -599,8 +674,6 @@ describe('Veröffentlichen-Dialog auf Englisch', () => {
|
||||
expect(problem({ tags: 'visa, gol\u200bd' })).toBe('Tags contain invalid characters.');
|
||||
expect(problem({ tags: `visa, ${'x'.repeat(21)}` })).toBe('A tag can be at most 20 characters long.');
|
||||
expect(problem({ tags: 'a, b, c, d, e, f' })).toBe('Please enter at most 5 tags.');
|
||||
expect(problem({ displayName: 'Ken\u2060so' })).toBe('The display name contains invalid characters.');
|
||||
expect(problem({ displayName: 'x'.repeat(31) })).toBe('The display name is too long (at most 30 characters).');
|
||||
expect(problem({ consent: false })).toBe('Please confirm the statement about the image.');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
// Veröffentlichen-Dialog von „Meine Karten“ (Spec § 6): Titel, Herausgeber/Bank, Tags, Anzeigename und die Pflicht-Checkbox.
|
||||
// Veröffentlichen-Dialog von „Meine Karten“ (Spec § 6): Titel, Herausgeber/Bank, Tags und die Pflicht-Checkbox. Einen Anzeigenamen gibt
|
||||
// es nicht mehr: die Detailseite zeigt den Benutzernamen des Kontos (Spec § 5). Hat das Konto keinen, antwortet der Dienst mit 403
|
||||
// `usernameRequired`: dann öffnet sich der Namensdialog, und mit dem gespeicherten Namen lässt es sich noch einmal versuchen.
|
||||
// Eigenes <dialog> wie report-dialog.ts; alle Texte gehen als Text in den DOM, nie als HTML. Übersetzt wird beim Aufbau mit t().
|
||||
import '../account/account.css';
|
||||
import './community.css';
|
||||
import { rememberDisplayName, rememberedDisplayName } from '../account/display-name';
|
||||
import { errorMessage } from '../account/errors';
|
||||
import { usernameDialog } from '../account/username-dialog';
|
||||
import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login';
|
||||
import { el } from '../editor/dom';
|
||||
import { el, setFieldInvalid } from '../editor/dom';
|
||||
import { PIXELS_MESSAGE, READ_MESSAGE } from '../editor/upload';
|
||||
import { msg, t } from '../i18n/i18n';
|
||||
import { ApiError, apiErrorText, isAlreadyPublished } from './api';
|
||||
import { ApiError, apiErrorText, isAlreadyPublished, isUsernameRequired } from './api';
|
||||
import { LOGO_MISSING_PUBLISH, NOT_RENDERED, TOO_LARGE, normalizeTags, type PublishForm } from './publish';
|
||||
|
||||
/** Wortlaut aus Spec § 6. */
|
||||
@@ -20,14 +22,14 @@ const RUNNING = msg('Wird veröffentlicht …');
|
||||
/** Grenzen wie der Server (deploy/api/app.js): Länge in UTF-16-Einheiten, nach dem Trimmen. */
|
||||
const TITLE_MAX = 60;
|
||||
const ISSUER_MAX = 40;
|
||||
/** Auch für den Anzeigenamen im Dialog „Logo hochladen“ (logos/logo-dialog.ts). */
|
||||
export const DISPLAY_NAME_MAX = 30;
|
||||
const TAGS_MAX = 5;
|
||||
const TAG_MAX = 20;
|
||||
/** Die Fehlerzeile des Dialogs: ein ungültiges Feld verweist mit `aria-describedby` auf sie. */
|
||||
const ERROR_ID = 'publish-error';
|
||||
/**
|
||||
* Steuerzeichen (C0, DEL, C1), Nullbreiten- und Bidi-Zeichen (U+200B-200F, U+202A-202E, U+2060-206F) und das BOM (U+FEFF):
|
||||
* unsichtbar oder umordnend. Dasselbe Muster prüft der Server (deploy/api/app.js, CONTROL_CHARACTERS).
|
||||
* Auch für Name und Anzeigename im Dialog „Logo hochladen“ (logos/logo-dialog.ts).
|
||||
* Auch für den Namen im Dialog „Logo hochladen“ (logos/logo-dialog.ts).
|
||||
*/
|
||||
export const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/;
|
||||
|
||||
@@ -35,7 +37,7 @@ export interface PublishFormValues extends PublishForm {
|
||||
consent: boolean;
|
||||
}
|
||||
|
||||
export type PublishField = 'title' | 'issuer' | 'tags' | 'displayName' | 'consent';
|
||||
export type PublishField = 'title' | 'issuer' | 'tags' | 'consent';
|
||||
|
||||
/** Das erste Problem in Formularreihenfolge (mit Text in der Sprache der Seite) oder null, wenn alles passt. Die Grenzen prüft der Server noch einmal. */
|
||||
export function validatePublishForm(values: PublishFormValues): { field: PublishField; message: string } | null {
|
||||
@@ -59,17 +61,6 @@ export function validatePublishForm(values: PublishFormValues): { field: Publish
|
||||
}
|
||||
if (tags.length > TAGS_MAX) return { field: 'tags', message: t('Bitte gib höchstens {max} Tags an.', { max: TAGS_MAX }) };
|
||||
|
||||
const displayName = values.displayName.trim();
|
||||
if (CONTROL_CHARACTERS.test(displayName)) {
|
||||
return { field: 'displayName', message: t('Der Anzeigename enthält ungültige Zeichen.') };
|
||||
}
|
||||
if (displayName.length > DISPLAY_NAME_MAX) {
|
||||
return {
|
||||
field: 'displayName',
|
||||
message: t('Der Anzeigename ist zu lang (höchstens {max} Zeichen).', { max: DISPLAY_NAME_MAX }),
|
||||
};
|
||||
}
|
||||
|
||||
if (!values.consent) return { field: 'consent', message: t('Bitte bestätige die Erklärung zum Bild.') };
|
||||
return null;
|
||||
}
|
||||
@@ -124,14 +115,11 @@ export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcom
|
||||
const titleInput = text('title', { 'aria-required': 'true' });
|
||||
const issuerInput = text('issuer');
|
||||
const tagsInput = text('tags', { 'aria-describedby': 'publish-tags-hint' });
|
||||
const nameInput = text('displayName', { placeholder: t('Anonym'), 'aria-describedby': 'publish-displayName-hint' });
|
||||
nameInput.value = rememberedDisplayName(); // der zuletzt benutzte Name, auch aus dem Dialog „Logo hochladen“
|
||||
const consentInput = el('input', { type: 'checkbox', name: 'consent', id: 'publish-consent' });
|
||||
const inputs: Record<PublishField, HTMLInputElement> = {
|
||||
title: titleInput,
|
||||
issuer: issuerInput,
|
||||
tags: tagsInput,
|
||||
displayName: nameInput,
|
||||
consent: consentInput,
|
||||
};
|
||||
|
||||
@@ -157,10 +145,9 @@ export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcom
|
||||
field(t('Titel (Pflicht)'), titleInput),
|
||||
field(t('Herausgeber / Bank (optional)'), issuerInput),
|
||||
field(t('Tags (optional)'), tagsInput, t('Bis zu 5, mit Komma getrennt, z. B. „visa, gold“.')),
|
||||
field(t('Anzeigename (optional)'), nameInput, t('Leer = „Anonym“. Deine E-Mail-Adresse wird nie angezeigt.')),
|
||||
el('label', { class: 'publish-consent' }, consentInput, el('span', {}, t(CONSENT_TEXT))),
|
||||
);
|
||||
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 statusEl = el('p', { class: 'auth-msg', role: 'status' });
|
||||
const cancel = el('button', { type: 'button', class: 'auth-btn' }, t('Abbrechen'));
|
||||
const send = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, t('Veröffentlichen'));
|
||||
@@ -205,7 +192,7 @@ export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcom
|
||||
}
|
||||
|
||||
function clearInvalid(): void {
|
||||
for (const control of Object.values(inputs)) control.removeAttribute('aria-invalid');
|
||||
for (const control of Object.values(inputs)) setFieldInvalid(control, ERROR_ID, false);
|
||||
}
|
||||
|
||||
function showAlready(): void {
|
||||
@@ -220,12 +207,27 @@ export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcom
|
||||
close.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Dienst verlangt einen Benutzernamen: der Namensdialog öffnet sich über dem Dialog. Mit gespeichertem Namen ist der Hinweis erledigt
|
||||
* und der Fokus liegt auf „Veröffentlichen“ (erneut versuchen); bei „Abbrechen“ oder wenn der Namensdialog nicht aufgeht, bleibt der Grund stehen.
|
||||
*/
|
||||
async function askForUsername(): Promise<void> {
|
||||
let saved: string | null = null;
|
||||
try {
|
||||
saved = await usernameDialog({ mode: 'required', signal: opts.signal });
|
||||
} catch {
|
||||
// der Namensdialog lässt sich nicht öffnen: der Grund oben steht, der Nutzer kann den Namen im Konto-Menü wählen
|
||||
}
|
||||
if (closed || saved === null) return;
|
||||
errorEl.textContent = '';
|
||||
send.focus();
|
||||
}
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
const values: PublishFormValues = {
|
||||
title: titleInput.value.trim(),
|
||||
issuer: issuerInput.value.trim(),
|
||||
tags: tagsInput.value,
|
||||
displayName: nameInput.value.trim(),
|
||||
consent: consentInput.checked,
|
||||
};
|
||||
clearInvalid();
|
||||
@@ -233,13 +235,13 @@ export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcom
|
||||
const problem = validatePublishForm(values);
|
||||
if (problem) {
|
||||
errorEl.textContent = problem.message;
|
||||
inputs[problem.field].setAttribute('aria-invalid', 'true');
|
||||
setFieldInvalid(inputs[problem.field], ERROR_ID, true);
|
||||
inputs[problem.field].focus();
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await opts.submit({ title: values.title, issuer: values.issuer, tags: values.tags, displayName: values.displayName });
|
||||
await opts.submit({ title: values.title, issuer: values.issuer, tags: values.tags });
|
||||
} catch (err) {
|
||||
if (closed) return;
|
||||
if (isAlreadyPublished(err)) {
|
||||
@@ -251,10 +253,10 @@ export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcom
|
||||
setBusy(false);
|
||||
errorEl.textContent = publishFailureText(err);
|
||||
reopen(); // war der Dialog zwischendurch zu, soll der Fehler zu sehen sein
|
||||
if (isUsernameRequired(err)) await askForUsername();
|
||||
return;
|
||||
}
|
||||
if (closed) return;
|
||||
rememberDisplayName(values.displayName);
|
||||
outcome = 'published';
|
||||
finish();
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ const card: SavedCard = {
|
||||
config: DEFAULT_CONFIG,
|
||||
};
|
||||
|
||||
const form = { title: 'Meine Karte', issuer: 'Beispielbank', tags: 'Visa, Gold', displayName: 'Kenso' };
|
||||
const form = { title: 'Meine Karte', issuer: 'Beispielbank', tags: 'Visa, Gold' };
|
||||
|
||||
const png = () => new Blob(['png'], { type: 'image/png' });
|
||||
const webp = () => new Blob(['webp'], { type: 'image/webp' });
|
||||
@@ -114,20 +114,26 @@ describe('publishSavedCard', () => {
|
||||
expect(preview.type).toBe('image/png');
|
||||
});
|
||||
|
||||
it('schickt der API beide neuen Datei-IDs, die Karten-ID und die bereinigten Felder (Tags als Liste)', async () => {
|
||||
it('schickt der API beide neuen Datei-IDs, die Karten-ID und die bereinigten Felder (Tags als Liste); der Anzeigename ist immer leer (die Seite zeigt den Benutzernamen)', async () => {
|
||||
const fake = fakeDeps();
|
||||
await publishSavedCard(card, { title: ' Meine Karte ', issuer: ' Bank ', tags: 'Visa, Gold , visa,,', displayName: ' K ' }, fake.deps);
|
||||
await publishSavedCard(card, { title: ' Meine Karte ', issuer: ' Bank ', tags: 'Visa, Gold , visa,,' }, fake.deps);
|
||||
expect(fake.of('publishCard')[0].args[0]).toEqual({
|
||||
title: 'Meine Karte',
|
||||
issuer: 'Bank',
|
||||
tags: ['visa', 'gold'],
|
||||
displayName: 'K',
|
||||
displayName: '',
|
||||
imageFileId: 'f1',
|
||||
previewFileId: 'f2',
|
||||
sourceCardId: 'card1',
|
||||
});
|
||||
});
|
||||
|
||||
it('ein von einem alten Aufrufer mitgegebener Anzeigename geht nicht mehr mit', async () => {
|
||||
const fake = fakeDeps();
|
||||
await publishSavedCard(card, { ...form, displayName: 'Kenso' } as typeof form, fake.deps);
|
||||
expect(fake.of('publishCard')[0].args[0]).toMatchObject({ displayName: '' });
|
||||
});
|
||||
|
||||
describe('Fehler der API: gelöscht wird nur bei eindeutiger Ablehnung', () => {
|
||||
it.each([400, 401, 403, 404, 409])(
|
||||
'eindeutig abgelehnt (%i): beide Dateien dieses Versuchs werden gelöscht, der Fehler kommt an',
|
||||
|
||||
@@ -29,9 +29,14 @@ export interface PublishForm {
|
||||
issuer: string;
|
||||
/** Rohe Eingabe, Komma-getrennt; wird mit `normalizeTags` zur Liste. */
|
||||
tags: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Anzeigename, den der Client mitschickt: keiner. Seit den Benutzernamen (Spec § 5) zeigt die Detailseite den Namen des Kontos;
|
||||
* der Dienst nimmt das Feld weiter an (der Altbestand trägt noch Anzeigenamen).
|
||||
*/
|
||||
const NO_DISPLAY_NAME = '';
|
||||
|
||||
/** Alles, was `publishSavedCard` von außen braucht (im Test ersetzbar, ohne Netz und Canvas). */
|
||||
export interface PublishDeps {
|
||||
/** Die ID des angemeldeten Nutzers; ohne Nutzer ein Fehler mit `code` 401. */
|
||||
@@ -105,8 +110,8 @@ async function deleteQuietly(deps: PublishDeps, fileIds: string[]): Promise<void
|
||||
*
|
||||
* Aufräumen (nur die zwei Dateien dieses Versuchs, nie etwas anderes):
|
||||
* - Scheitert ein Upload, ist noch nichts veröffentlicht: der erste Upload wird gelöscht.
|
||||
* - Scheitert die API eindeutig (4xx außer 408/429, auch 409, oder schon der JWT-Abruf), wurde nichts angelegt:
|
||||
* beide Dateien werden gelöscht.
|
||||
* - Scheitert die API eindeutig (4xx außer 408/429, auch 409 und 403 `usernameRequired`, oder schon der JWT-Abruf), wurde nichts
|
||||
* angelegt: beide Dateien werden gelöscht.
|
||||
* - Bleibt der Ausgang offen (Netzfehler, Zeitlimit, 5xx, 408, 429, Antwort ohne `id`), kann die API die Zeile schon
|
||||
* angelegt haben. Dann bleiben die Dateien liegen: sonst zeigte eine öffentliche Karte auf gelöschte Bilder.
|
||||
* (Ein Waisen-Upload ist harmlos: unverlinkt, mit Zufalls-ID, vom Nutzer jederzeit löschbar.)
|
||||
@@ -145,7 +150,7 @@ export async function publishSavedCard(
|
||||
title: form.title.trim(),
|
||||
issuer: form.issuer.trim(),
|
||||
tags: normalizeTags(form.tags),
|
||||
displayName: form.displayName.trim(),
|
||||
displayName: NO_DISPLAY_NAME,
|
||||
imageFileId: uploaded[0],
|
||||
previewFileId: uploaded[1],
|
||||
sourceCardId: card.id,
|
||||
|
||||
@@ -44,7 +44,8 @@ export interface ReportDialogOptions {
|
||||
|
||||
/**
|
||||
* Öffnet den Dialog. Löst auf, sobald er geschlossen ist: mit dem Ergebnis der letzten Meldung, sonst mit `cancelled`.
|
||||
* Fehler des Dienstes erscheinen mit ihrem Servertext im Dialog; der Dialog bleibt dann offen für einen neuen Versuch.
|
||||
* Fehler des Dienstes erscheinen im Dialog in der Sprache der Seite (`apiErrorText`: der Text zum Schlüssel des Fehlers, nicht der
|
||||
* Servertext); der Dialog bleibt dann offen für einen neuen Versuch.
|
||||
*/
|
||||
export function reportDialog(opts: ReportDialogOptions): Promise<ReportOutcome> {
|
||||
return new Promise<ReportOutcome>((resolve, reject) => {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { el, setFieldInvalid } from './dom';
|
||||
|
||||
describe('setFieldInvalid', () => {
|
||||
const field = (describedBy?: string) => el('input', { 'aria-describedby': describedBy });
|
||||
|
||||
it('markiert das Feld und verweist auf die Fehlerzeile', () => {
|
||||
const input = field();
|
||||
setFieldInvalid(input, 'fehler', true);
|
||||
expect(input.getAttribute('aria-invalid')).toBe('true');
|
||||
expect(input.getAttribute('aria-describedby')).toBe('fehler');
|
||||
});
|
||||
|
||||
it('der Hinweis des Feldes bleibt davor stehen', () => {
|
||||
const input = field('hinweis');
|
||||
setFieldInvalid(input, 'fehler', true);
|
||||
expect(input.getAttribute('aria-describedby')).toBe('hinweis fehler');
|
||||
});
|
||||
|
||||
it('mehrfach markiert verweist es nur einmal auf die Fehlerzeile', () => {
|
||||
const input = field('a b');
|
||||
setFieldInvalid(input, 'fehler', true);
|
||||
setFieldInvalid(input, 'fehler', true);
|
||||
expect(input.getAttribute('aria-describedby')).toBe('a b fehler');
|
||||
});
|
||||
|
||||
it('zurücknehmen entfernt Markierung und Verweis, die Hinweise bleiben', () => {
|
||||
const input = field('hinweis');
|
||||
setFieldInvalid(input, 'fehler', true);
|
||||
setFieldInvalid(input, 'fehler', false);
|
||||
expect(input.hasAttribute('aria-invalid')).toBe(false);
|
||||
expect(input.getAttribute('aria-describedby')).toBe('hinweis');
|
||||
});
|
||||
|
||||
it('ohne Hinweis verschwindet aria-describedby ganz', () => {
|
||||
const input = field();
|
||||
setFieldInvalid(input, 'fehler', true);
|
||||
setFieldInvalid(input, 'fehler', false);
|
||||
expect(input.hasAttribute('aria-describedby')).toBe(false);
|
||||
expect(input.hasAttribute('aria-invalid')).toBe(false);
|
||||
});
|
||||
|
||||
it('ein Feld, das nie markiert war, bleibt beim Zurücknehmen unberührt', () => {
|
||||
const plain = field();
|
||||
setFieldInvalid(plain, 'fehler', false);
|
||||
expect(plain.getAttributeNames()).toEqual([]);
|
||||
const withHint = field('hinweis');
|
||||
setFieldInvalid(withHint, 'fehler', false);
|
||||
expect(withHint.getAttribute('aria-describedby')).toBe('hinweis');
|
||||
});
|
||||
|
||||
it('eine andere Fehlerzeile bleibt, wo sie ist (nur die genannte ID wird verwaltet)', () => {
|
||||
const input = field('hinweis anderer');
|
||||
setFieldInvalid(input, 'fehler', true);
|
||||
setFieldInvalid(input, 'fehler', false);
|
||||
expect(input.getAttribute('aria-describedby')).toBe('hinweis anderer');
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,23 @@ export function el<K extends keyof HTMLElementTagNameMap>(
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Markiert ein Formularfeld als ungültig (`aria-invalid`) und verknüpft es mit der Fehlerzeile `errorId` (`aria-describedby`, hinter einem
|
||||
* Hinweis, den das Feld schon hat): ein Screenreader liest den Fehler dann mit dem Feld vor. Mit `invalid = false` fallen beide wieder
|
||||
* weg, der Hinweis bleibt.
|
||||
*/
|
||||
export function setFieldInvalid(control: HTMLElement, errorId: string, invalid: boolean): void {
|
||||
const described = (control.getAttribute('aria-describedby') ?? '').split(/\s+/).filter((id) => id !== '' && id !== errorId);
|
||||
if (invalid) {
|
||||
control.setAttribute('aria-invalid', 'true');
|
||||
described.push(errorId);
|
||||
} else {
|
||||
control.removeAttribute('aria-invalid');
|
||||
}
|
||||
if (described.length > 0) control.setAttribute('aria-describedby', described.join(' '));
|
||||
else control.removeAttribute('aria-describedby');
|
||||
}
|
||||
|
||||
// Feste Symbole (Strichstärke aus dem Stylesheet); die Zeichenketten sind Konstanten, keine Nutzereingaben.
|
||||
const ICONS = {
|
||||
imagePlus:
|
||||
|
||||
@@ -55,6 +55,7 @@ vi.mock('../community/gallery', () => ({
|
||||
loadGalleryImage: mocks.loadGalleryImage,
|
||||
}));
|
||||
// Der Bereich „Logo unten rechts“ hat seine eigenen Tests (logos/logo-picker.test.ts); hier zählt nur die Verdrahtung.
|
||||
vi.mock('../community/auto-publish', () => ({ autoPublish: vi.fn(async () => ({ id: 'g1' })) }));
|
||||
vi.mock('../logos/logo-picker', () => ({ createLogoPicker: mocks.createLogoPicker }));
|
||||
// Zeile und Datei eines Community-Logos kommen aus der Datenschicht; nur der Zwischenspeicher darüber (logo-asset.ts) ist echt.
|
||||
vi.mock('../logos/logos', () => ({ getLogo: mocks.getLogo, loadLogoBlob: mocks.loadLogoBlob }));
|
||||
@@ -85,7 +86,7 @@ let store: EditorStore;
|
||||
let unmount: (() => void) | null;
|
||||
/** Der Attrappen-Picker und die Funktionen, die der Editor ihm mitgegeben hat. */
|
||||
let picker: { el: HTMLElement; select: Mock; selected: Mock; dispose: Mock };
|
||||
let pickerHandlers: { onSelect(id: string | null): void };
|
||||
let pickerHandlers: { onSelect(id: string | null): void; onLogoChanged(id: string): void };
|
||||
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(mocks)) if (typeof fn === 'function') fn.mockReset();
|
||||
@@ -143,7 +144,7 @@ describe('Speichern', () => {
|
||||
saveButton().click();
|
||||
expect(saveButton().disabled).toBe(true);
|
||||
expect(saveButton().textContent).toBe('Speichert …');
|
||||
await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert'));
|
||||
await vi.waitFor(() => expect(saveButton().textContent).toBe('Veröffentlicht'));
|
||||
|
||||
expect(mocks.getCurrentUser).toHaveBeenCalled();
|
||||
expect(mocks.exportCard).toHaveBeenCalledWith(expect.objectContaining({ logo: 'abc123' }), expect.anything(), {
|
||||
@@ -176,10 +177,10 @@ describe('Speichern', () => {
|
||||
mount();
|
||||
saveButton().click();
|
||||
// vi.waitFor würde die falschen Uhren selbst vorstellen; deshalb nur die Zusagen abarbeiten.
|
||||
for (let i = 0; i < 50 && saveButton().textContent !== 'Gespeichert'; i += 1) await vi.advanceTimersByTimeAsync(0);
|
||||
expect(saveButton().textContent).toBe('Gespeichert');
|
||||
for (let i = 0; i < 50 && saveButton().textContent !== 'Veröffentlicht'; i += 1) await vi.advanceTimersByTimeAsync(0);
|
||||
expect(saveButton().textContent).toBe('Veröffentlicht');
|
||||
await vi.advanceTimersByTimeAsync(1999);
|
||||
expect(saveButton().textContent).toBe('Gespeichert');
|
||||
expect(saveButton().textContent).toBe('Veröffentlicht');
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(saveButton().textContent).toBe('Speichern');
|
||||
});
|
||||
@@ -191,7 +192,7 @@ describe('Speichern', () => {
|
||||
mocks.save.mockResolvedValue(savedCard({ imageFileId: 'img0', previewFileId: 'prev9' }));
|
||||
mount();
|
||||
saveButton().click();
|
||||
await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert'));
|
||||
await vi.waitFor(() => expect(saveButton().textContent).toBe('Veröffentlicht'));
|
||||
expect(mocks.save.mock.calls[0][0]).toMatchObject({ previous: ref, imageChanged: false });
|
||||
expect(store.get().saved?.previewFileId).toBe('prev9');
|
||||
});
|
||||
@@ -216,7 +217,7 @@ describe('Speichern', () => {
|
||||
saveButton().click();
|
||||
await vi.waitFor(() => expect(mocks.save).toHaveBeenCalled());
|
||||
finish(savedCard());
|
||||
await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert'));
|
||||
await vi.waitFor(() => expect(saveButton().textContent).toBe('Veröffentlicht'));
|
||||
expect(mocks.save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -235,7 +236,7 @@ describe('Speichern', () => {
|
||||
expect(errorText()).toBe('');
|
||||
|
||||
saveButton().click();
|
||||
await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert'));
|
||||
await vi.waitFor(() => expect(saveButton().textContent).toBe('Veröffentlicht'));
|
||||
expect(mocks.save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -246,7 +247,7 @@ describe('Speichern', () => {
|
||||
.mockResolvedValueOnce(savedCard());
|
||||
mount();
|
||||
saveButton().click();
|
||||
await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert'));
|
||||
await vi.waitFor(() => expect(saveButton().textContent).toBe('Veröffentlicht'));
|
||||
expect(mocks.requireLogin).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.save).toHaveBeenCalledTimes(2);
|
||||
expect(errorText()).toBe('');
|
||||
@@ -264,7 +265,7 @@ describe('Speichern', () => {
|
||||
expect(store.get().saved).toBeNull();
|
||||
|
||||
saveButton().click();
|
||||
await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert'));
|
||||
await vi.waitFor(() => expect(saveButton().textContent).toBe('Veröffentlicht'));
|
||||
expect(errorText()).toBe('');
|
||||
});
|
||||
|
||||
@@ -289,7 +290,7 @@ describe('Speichern', () => {
|
||||
expect(store.get().image).toBe(img);
|
||||
|
||||
finish(savedCard());
|
||||
await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert'));
|
||||
await vi.waitFor(() => expect(saveButton().textContent).toBe('Veröffentlicht'));
|
||||
expect(replaceButton().disabled).toBe(false);
|
||||
expect(fileInput().disabled).toBe(false);
|
||||
});
|
||||
@@ -299,7 +300,7 @@ describe('Speichern', () => {
|
||||
mocks.save.mockResolvedValue(savedCard());
|
||||
mount();
|
||||
saveButton().click();
|
||||
await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert'));
|
||||
await vi.waitFor(() => expect(saveButton().textContent).toBe('Veröffentlicht'));
|
||||
mocks.decodeImage.mockResolvedValue(image(1000, 1000));
|
||||
const file = new File(['x'], 'neu.png', { type: 'image/png' });
|
||||
const drop = new Event('drop', { bubbles: true, cancelable: true }) as Event & { dataTransfer: unknown };
|
||||
@@ -995,7 +996,7 @@ describe('Speichern: Schnappschuss beim Klick', () => {
|
||||
});
|
||||
mount();
|
||||
saveButton().click();
|
||||
await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert'));
|
||||
await vi.waitFor(() => expect(saveButton().textContent).toBe('Veröffentlicht'));
|
||||
expect(mocks.save).toHaveBeenCalledTimes(2);
|
||||
for (const [input] of mocks.save.mock.calls) {
|
||||
expect(input.name).toBe('Beim Klick');
|
||||
@@ -1207,6 +1208,59 @@ describe('Community-Logo auf der Karte', () => {
|
||||
expect(mocks.drawCard.mock.calls.every(([, config]) => config.logo === 'A')).toBe(true);
|
||||
});
|
||||
|
||||
it('ein Download, der die Logos doch lädt, übernimmt sie: Vorschau und Miniaturen zeigen das Logo jetzt wie das PNG, der Fehler ist weg', async () => {
|
||||
const attempts = flaky(1);
|
||||
withImage();
|
||||
store.set({ config: withLogo('A') });
|
||||
mount();
|
||||
await vi.waitFor(() => expect(errorText()).toBe(LOGO_ERROR));
|
||||
await vi.waitFor(() => expect(mocks.drawCard).toHaveBeenCalled());
|
||||
expect(lastLogo()).toBeNull();
|
||||
|
||||
mocks.drawCard.mockClear();
|
||||
downloadButton().click();
|
||||
await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalled());
|
||||
expect(attempts()).toBe(2); // der Download lädt selbst
|
||||
expect(mocks.exportCard.mock.calls[0][1].logo).toBe(FOUND.logo); // das PNG hat das Logo
|
||||
await vi.waitFor(() => expect(lastLogo()).toBe(FOUND.logo)); // die Vorschau folgt
|
||||
expect(new Set(mocks.drawCard.mock.calls.map(([canvas]) => canvas)).size).toBe(5);
|
||||
expect(errorText()).toBe('');
|
||||
expect(warningText()).toBe('');
|
||||
});
|
||||
|
||||
it('nach der Übernahme gilt die Karte als geladen: weitere Änderungen laden die Logos nicht noch einmal', async () => {
|
||||
const attempts = flaky(1);
|
||||
withImage();
|
||||
store.set({ config: withLogo('A') });
|
||||
mount();
|
||||
await vi.waitFor(() => expect(errorText()).toBe(LOGO_ERROR));
|
||||
downloadButton().click();
|
||||
await vi.waitFor(() => expect(lastLogo()).toBe(FOUND.logo));
|
||||
store.set({ cardName: 'Andere Karte' });
|
||||
pickerHandlers.onSelect('A'); // ein Klick auf das gewählte Logo
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(attempts()).toBe(2);
|
||||
expect(lastLogo()).toBe(FOUND.logo);
|
||||
});
|
||||
|
||||
it('fehlt das Logo inzwischen (der Download meldet es als nicht verfügbar), übernimmt die Vorschau auch das: Hinweis statt Fehler, Karte ohne Logo', async () => {
|
||||
let attempts = 0;
|
||||
mocks.loadCardAssets.mockImplementation(async (config: { logo: string | null }) => {
|
||||
if (config.logo === 'A' && ++attempts === 1) throw new TypeError('Failed to fetch');
|
||||
return config.logo === 'A' ? { ...MISSING, brand: BRAND } : { image: null, logo: null, brand: BRAND, logoMissing: false };
|
||||
});
|
||||
withImage();
|
||||
store.set({ config: withLogo('A') });
|
||||
mount();
|
||||
await vi.waitFor(() => expect(errorText()).toBe(LOGO_ERROR));
|
||||
await vi.waitFor(() => expect(mocks.drawCard).toHaveBeenCalled());
|
||||
downloadButton().click();
|
||||
await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalled());
|
||||
await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE));
|
||||
expect(errorText()).toBe('');
|
||||
expect(lastLogo()).toBeNull();
|
||||
});
|
||||
|
||||
it('scheitert auch die Wiederholung, bleibt es bei Fehler und Karte ohne Logo; es gibt keinen Wiederholungs-Loop, jede Wahl versucht es genau einmal', async () => {
|
||||
const attempts = flaky(Infinity);
|
||||
withImage();
|
||||
@@ -1297,6 +1351,37 @@ describe('Community-Logo auf der Karte', () => {
|
||||
await vi.waitFor(() => expect(errorText()).toBe(''));
|
||||
});
|
||||
|
||||
it('der Hinweis „Logo nicht mehr verfügbar“ einer früheren Auswahl bleibt nicht neben einem neuen Ladefehler stehen', async () => {
|
||||
mocks.loadCardAssets.mockImplementation(async (config: { logo: string | null }) => {
|
||||
if (config.logo === 'A') throw new TypeError('Failed to fetch');
|
||||
return config.logo === 'gone' ? MISSING : { image: null, logo: null, brand: {}, logoMissing: false };
|
||||
});
|
||||
withImage();
|
||||
store.set({ config: withLogo('gone') });
|
||||
mount();
|
||||
await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE));
|
||||
store.set({ config: withLogo('A') });
|
||||
await vi.waitFor(() => expect(errorText()).toBe(LOGO_ERROR));
|
||||
expect(warningText()).toBe(''); // sofort: der Hinweis galt dem Logo davor
|
||||
await vi.waitFor(() => expect(mocks.drawCard).toHaveBeenCalled()); // die Karte steht ohne Logo da
|
||||
expect(warningText()).toBe('');
|
||||
});
|
||||
|
||||
it('andere Hinweise (etwa die fehlende Bilddatei) überleben das: nur der Logo-Hinweis geht', async () => {
|
||||
history.replaceState(null, '', '/gestalten?karte=k1');
|
||||
mocks.get.mockResolvedValue(savedCard({ config: { ...structuredClone(DEFAULT_CONFIG), logo: 'gone' } }));
|
||||
mocks.loadImage.mockRejectedValue(Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' }));
|
||||
mocks.loadCardAssets.mockImplementation(async (config: { logo: string | null }) => {
|
||||
if (config.logo === 'A') throw new TypeError('Failed to fetch');
|
||||
return config.logo === 'gone' ? MISSING : { image: null, logo: null, brand: {}, logoMissing: false };
|
||||
});
|
||||
mount();
|
||||
await vi.waitFor(() => expect(warningText()).toBe(`Das Bild dieser Karte fehlt. ${LOGO_GONE}`));
|
||||
store.set({ config: withLogo('A') });
|
||||
await vi.waitFor(() => expect(errorText()).toBe(LOGO_ERROR));
|
||||
expect(warningText()).toBe('Das Bild dieser Karte fehlt.');
|
||||
});
|
||||
|
||||
it('ein anderer Fehler im Feld bleibt stehen, auch wenn danach ein Logo lädt', async () => {
|
||||
mocks.loadCardAssets.mockImplementation(failA);
|
||||
store.set({ config: withLogo('A') });
|
||||
@@ -1362,6 +1447,128 @@ describe('Community-Logo auf der Karte', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Das gewählte Logo wird ausgeblendet, eingeblendet oder gelöscht: Vorschau und Miniaturen laden es neu (onLogoChanged)', () => {
|
||||
const lastLogo = () => mocks.drawCard.mock.calls.at(-1)?.[2].logo;
|
||||
/** Ein Lader, dessen Antwort für das Logo „A“ sich ändern lässt: ausgeblendet = nicht verfügbar. */
|
||||
let hiddenNow: boolean;
|
||||
beforeEach(() => {
|
||||
hiddenNow = false;
|
||||
mocks.loadCardAssets.mockImplementation(async (config: { logo: string | null }) => (config.logo === 'A' && hiddenNow ? MISSING : FOUND));
|
||||
});
|
||||
|
||||
it('Ausblenden: das Logo wird neu geladen, Vorschau und alle vier Miniaturen werden ohne es gezeichnet, der Hinweis erscheint', async () => {
|
||||
withImage();
|
||||
store.set({ config: withLogo('A') });
|
||||
mount();
|
||||
await vi.waitFor(() => expect(lastLogo()).toBe(FOUND.logo));
|
||||
expect(warningText()).toBe('');
|
||||
|
||||
hiddenNow = true;
|
||||
mocks.drawCard.mockClear();
|
||||
pickerHandlers.onLogoChanged('A');
|
||||
await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE));
|
||||
await vi.waitFor(() => expect(new Set(mocks.drawCard.mock.calls.map(([canvas]) => canvas)).size).toBe(5));
|
||||
for (const [, config, assets] of mocks.drawCard.mock.calls) {
|
||||
expect(config.logo).toBe('A'); // die Auswahl bleibt
|
||||
expect(assets.logo).toBeNull();
|
||||
}
|
||||
expect(mocks.loadCardAssets).toHaveBeenCalledTimes(2);
|
||||
expect(errorText()).toBe('');
|
||||
});
|
||||
|
||||
it('Einblenden: das Logo ist wieder da, der Hinweis geht, die Karte wird mit Logo gezeichnet', async () => {
|
||||
hiddenNow = true;
|
||||
withImage();
|
||||
store.set({ config: withLogo('A') });
|
||||
mount();
|
||||
await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE));
|
||||
await vi.waitFor(() => expect(mocks.drawCard).toHaveBeenCalled());
|
||||
expect(lastLogo()).toBeNull();
|
||||
|
||||
hiddenNow = false;
|
||||
mocks.drawCard.mockClear();
|
||||
pickerHandlers.onLogoChanged('A');
|
||||
await vi.waitFor(() => expect(lastLogo()).toBe(FOUND.logo));
|
||||
expect(warningText()).toBe('');
|
||||
expect(new Set(mocks.drawCard.mock.calls.map(([canvas]) => canvas)).size).toBe(5);
|
||||
});
|
||||
|
||||
it('der Hinweis zum Logo geht gleich, der Größenhinweis des Downloads bleibt', async () => {
|
||||
hiddenNow = true;
|
||||
mocks.exportCard.mockResolvedValue(new Blob([new Uint8Array(4_000_001)], { type: 'image/png' }));
|
||||
withImage();
|
||||
store.set({ config: withLogo('A') });
|
||||
mount();
|
||||
await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE));
|
||||
downloadButton().click();
|
||||
await vi.waitFor(() => expect(warningText()).toBe(`${PNG_SIZE_WARNING} ${LOGO_GONE}`));
|
||||
hiddenNow = false;
|
||||
pickerHandlers.onLogoChanged('A');
|
||||
await vi.waitFor(() => expect(warningText()).toBe(PNG_SIZE_WARNING));
|
||||
});
|
||||
|
||||
it('ein anderes Logo als das gewählte: nichts wird neu geladen oder gezeichnet', async () => {
|
||||
withImage();
|
||||
store.set({ config: withLogo('A') });
|
||||
mount();
|
||||
await vi.waitFor(() => expect(lastLogo()).toBe(FOUND.logo));
|
||||
mocks.drawCard.mockClear();
|
||||
pickerHandlers.onLogoChanged('B');
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(mocks.loadCardAssets).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.drawCard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ohne gewähltes Logo (auch nach dem Löschen, wenn die Auswahl schon aufgehoben ist): nichts wird neu geladen', async () => {
|
||||
withImage();
|
||||
store.set({ config: withLogo('A') });
|
||||
mount();
|
||||
await vi.waitFor(() => expect(lastLogo()).toBe(FOUND.logo));
|
||||
pickerHandlers.onSelect(null); // Löschen: der Picker hebt die Auswahl auf …
|
||||
await vi.waitFor(() => expect(mocks.loadCardAssets).toHaveBeenCalledTimes(2));
|
||||
pickerHandlers.onLogoChanged('A'); // … und meldet danach das gelöschte Logo
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(mocks.loadCardAssets).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('ein noch laufender Ladevorgang wird überholt: seine späte Antwort ändert nichts mehr', async () => {
|
||||
let finishFirst!: (v: typeof FOUND) => void;
|
||||
mocks.loadCardAssets.mockImplementationOnce(() => new Promise((resolve) => (finishFirst = resolve as (v: typeof FOUND) => void)));
|
||||
withImage();
|
||||
store.set({ config: withLogo('A') });
|
||||
mount();
|
||||
await vi.waitFor(() => expect(mocks.loadCardAssets).toHaveBeenCalledTimes(1));
|
||||
hiddenNow = true;
|
||||
pickerHandlers.onLogoChanged('A'); // das Logo wurde ausgeblendet, während die Vorschau noch lädt
|
||||
await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE));
|
||||
finishFirst(FOUND); // die alte Antwort mit Logo kommt zu spät
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(warningText()).toBe(LOGO_GONE);
|
||||
expect(lastLogo()).toBeNull();
|
||||
});
|
||||
|
||||
it('nach einem Ladefehler versucht die Meldung es noch einmal: gelingt es, verschwindet der Fehler', async () => {
|
||||
let attempts = 0;
|
||||
mocks.loadCardAssets.mockImplementation(async (config: { logo: string | null }) => {
|
||||
if (config.logo === 'A' && ++attempts === 1) throw new TypeError('Failed to fetch');
|
||||
return config.logo ? FOUND : { image: null, logo: null, brand: {}, logoMissing: false };
|
||||
});
|
||||
withImage();
|
||||
store.set({ config: withLogo('A') });
|
||||
mount();
|
||||
await vi.waitFor(() => expect(errorText()).toBe('Die Logos konnten nicht geladen werden.'));
|
||||
pickerHandlers.onLogoChanged('A');
|
||||
await vi.waitFor(() => expect(errorText()).toBe(''));
|
||||
await vi.waitFor(() => expect(lastLogo()).toBe(FOUND.logo));
|
||||
expect(attempts).toBe(2);
|
||||
});
|
||||
|
||||
it('der Picker wird bei seiner Erzeugung mit beiden Rückrufen versorgt', () => {
|
||||
mount();
|
||||
expect(Object.keys(pickerHandlers).sort()).toEqual(['onLogoChanged', 'onSelect']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Download und der Zwischenspeicher der Logos (ein ausgeblendetes Logo darf nicht ins PNG)', () => {
|
||||
const row = (patch: Record<string, unknown> = {}) => ({
|
||||
id: 'zw3',
|
||||
@@ -1413,6 +1620,46 @@ describe('Community-Logo auf der Karte', () => {
|
||||
expect(store.get().config.logo).toBe('zw3'); // die Auswahl bleibt
|
||||
});
|
||||
|
||||
it('Ausblenden im Picker (der Zwischenspeicher ist geleert, der Editor wird gerufen): die Zeile wird frisch geholt, Vorschau und Hinweis folgen', async () => {
|
||||
withImage();
|
||||
store.set({ config: withLogo('zw3') });
|
||||
mount();
|
||||
await vi.waitFor(drawnWithLogo);
|
||||
mocks.getLogo.mockResolvedValue(row({ hidden: true }));
|
||||
forgetLogo('zw3'); // das tut die Infozeile des Pickers vor dem Rückruf
|
||||
mocks.drawCard.mockClear();
|
||||
pickerHandlers.onLogoChanged('zw3');
|
||||
await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE));
|
||||
await vi.waitFor(() => expect(mocks.drawCard).toHaveBeenCalled());
|
||||
expect(mocks.getLogo).toHaveBeenCalledTimes(2);
|
||||
expect(lastDrawnLogo()).toBeNull();
|
||||
});
|
||||
|
||||
it('Speichern prüft Zeile und Bild des Logos frisch: ein inzwischen ausgeblendetes Logo kommt nicht ins Vorschaubild der Karte', async () => {
|
||||
withImage();
|
||||
store.set({ config: withLogo('zw3') });
|
||||
mocks.save.mockResolvedValue(savedCard({ id: 'neu' }));
|
||||
mount();
|
||||
await vi.waitFor(drawnWithLogo);
|
||||
expect(mocks.getLogo).toHaveBeenCalledTimes(1);
|
||||
|
||||
mocks.getLogo.mockResolvedValue(row({ hidden: true })); // jetzt wird es ausgeblendet
|
||||
saveButton().click();
|
||||
await vi.waitFor(() => expect(mocks.save).toHaveBeenCalled());
|
||||
expect(mocks.getLogo).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.exportCard.mock.calls[0][1].logo).toBeNull(); // das Vorschaubild: ohne Logo
|
||||
expect(mocks.exportCard.mock.calls[0][2]).toMatchObject({ type: 'image/webp' });
|
||||
});
|
||||
|
||||
it('Speichern ohne gewähltes Logo holt nichts', async () => {
|
||||
withImage();
|
||||
mocks.save.mockResolvedValue(savedCard({ id: 'neu' }));
|
||||
mount();
|
||||
saveButton().click();
|
||||
await vi.waitFor(() => expect(mocks.save).toHaveBeenCalled());
|
||||
expect(mocks.getLogo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('der Hinweis steht zusammen mit dem Größenhinweis des Downloads', async () => {
|
||||
mocks.exportCard.mockResolvedValue(new Blob([new Uint8Array(4_000_001)], { type: 'image/png' }));
|
||||
withImage();
|
||||
|
||||
@@ -9,7 +9,9 @@ import { serializeConfig, type BrandId, type CardConfig, type DetailsTone, type
|
||||
import { clampCrop, panBy, zoomTo } from '../card/crop';
|
||||
import { CARD_W, PREVIEW_QUALITY, PREVIEW_WIDTH } from '../card/geometry';
|
||||
import { drawCard, exportCard, loadCardAssets, type CardAssets, type CardImage, type LoadedAssets } from '../card/render';
|
||||
import { autoPublish } from '../community/auto-publish';
|
||||
import { getGalleryCard, loadGalleryImage } from '../community/gallery';
|
||||
import { publishFailureText } from '../community/publish-dialog';
|
||||
import { msg, t } from '../i18n/i18n';
|
||||
import { LOGO_MISSING, forgetLogo } from '../logos/logo-asset';
|
||||
import { createLogoPicker } from '../logos/logo-picker';
|
||||
@@ -80,6 +82,17 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
|
||||
if (logo === store.get().config.logo) failedKey = '';
|
||||
patchConfig({ logo });
|
||||
},
|
||||
// Das gewählte Logo wurde ausgeblendet, eingeblendet oder gelöscht (der Zwischenspeicher der Logos ist schon geleert): was der Editor
|
||||
// von ihm gezeichnet hat, gilt nicht mehr. Es wird neu geladen, damit Vorschau, Miniaturen und Hinweis dem neuen Stand folgen.
|
||||
onLogoChanged(id) {
|
||||
const { config } = store.get();
|
||||
if (config.logo !== id) return;
|
||||
logos = null;
|
||||
pendingKey = '';
|
||||
failedKey = '';
|
||||
showWarning();
|
||||
ensureLogos(config);
|
||||
},
|
||||
});
|
||||
options.logoSlot.append(logoPicker.el);
|
||||
|
||||
@@ -195,17 +208,24 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
|
||||
|
||||
/**
|
||||
* Der Download hat Zeile und Bild des Logos frisch geholt (siehe download). Hat sich dabei geändert, ob es das Logo gibt (es wurde
|
||||
* ausgeblendet oder gelöscht, oder ist wieder da), folgen Hinweis und Vorschau: das PNG und die Vorschau zeigen dasselbe.
|
||||
* ausgeblendet oder gelöscht, oder ist wieder da), oder stand die Karte nach einem Ladefehler ohne Logo da, folgen Hinweis und
|
||||
* Vorschau: das PNG und die Vorschau zeigen dasselbe.
|
||||
*/
|
||||
function followDownload(config: CardConfig, assets: LoadedAssets): void {
|
||||
if (!logos || logos.key !== logoKey(config) || logos.missing === assets.logoMissing) return;
|
||||
logos = { ...logos, logo: assets.logo, missing: assets.logoMissing };
|
||||
if (!logos || logos.key !== logoKey(config)) return;
|
||||
// Nach einem Ladefehler steht die Karte ohne Logo da (`failed`): hat der Download die Logos doch geladen, übernimmt die Vorschau sie,
|
||||
// damit sie dasselbe zeigt wie das PNG, und die Sperre dieses Schlüssels fällt weg.
|
||||
if (logos.failed) failedKey = '';
|
||||
else if (logos.missing === assets.logoMissing) return;
|
||||
logos = { logo: assets.logo, brand: assets.brand, missing: assets.logoMissing, key: logos.key };
|
||||
showWarning();
|
||||
schedule();
|
||||
}
|
||||
|
||||
// Speichern: Anmeldung prüfen, Karte speichern, bei abgelaufener Sitzung einmal neu anmelden (siehe save.ts).
|
||||
let savedFlash = false;
|
||||
/** Gespeichert, aber das direkte Veröffentlichen ist gescheitert: der Text erscheint nach dem Speichern. */
|
||||
let publishError: string | null = null;
|
||||
let flashTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function onSaveStatus(status: SaveStatus, message?: string): void {
|
||||
@@ -217,7 +237,8 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
|
||||
if (!disposed) sync(store.get());
|
||||
}, SAVED_FLASH_MS);
|
||||
}
|
||||
if (!disposed) preview.setError(status === 'error' ? (message ?? null) : null);
|
||||
if (!disposed) preview.setError(status === 'error' ? (message ?? null) : status === 'saved' ? publishError : null);
|
||||
publishError = null;
|
||||
store.set({ saving: status === 'saving' });
|
||||
}
|
||||
|
||||
@@ -231,6 +252,9 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
|
||||
async function doSave(): Promise<void> {
|
||||
const { config, image, imageBlob, imageChanged, cardName, saved } = clickSnapshot ?? takeSnapshot();
|
||||
if (!image || !imageBlob) throw new Error(NO_IMAGE);
|
||||
// Zeile und Bild des Logos neu vom Server prüfen (wie beim Download): ein inzwischen ausgeblendetes oder gelöschtes Logo käme sonst aus
|
||||
// dem Zwischenspeicher ins Vorschaubild der gespeicherten Karte.
|
||||
if (config.logo) forgetLogo(config.logo);
|
||||
const assets = await loadCardAssets(config, image);
|
||||
const previewBlob = await exportCard(config, assets, {
|
||||
width: PREVIEW_WIDTH,
|
||||
@@ -250,6 +274,13 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
|
||||
if (now.imageBlob === imageBlob && now.saved?.id === saved?.id) {
|
||||
store.set({ saved: toRef(card), savedConfig: config, imageChanged: false, cardName: card.name });
|
||||
}
|
||||
// Gespeichert heißt veröffentlicht: die Karte geht direkt (oder in neuer Fassung) in die Community-Galerie.
|
||||
publishError = null;
|
||||
try {
|
||||
await autoPublish(card);
|
||||
} catch (err) {
|
||||
publishError = publishFailureText(err);
|
||||
}
|
||||
}
|
||||
|
||||
const saveCard = createSaveAction({
|
||||
@@ -450,6 +481,11 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
|
||||
if (disposed || token !== logoToken) return;
|
||||
pendingKey = '';
|
||||
failedKey = key;
|
||||
// Die Logos der früheren Auswahl gehören nicht zu diesem Zustand, auch nicht ihr Hinweis „nicht mehr verfügbar“.
|
||||
if (logos?.key !== key) {
|
||||
logos = null;
|
||||
showWarning();
|
||||
}
|
||||
preview.setError(t(LOGO_ERROR));
|
||||
logoErrorShown = true;
|
||||
drawWithoutLogo(config, key, token);
|
||||
|
||||
@@ -40,7 +40,7 @@ export interface Preview {
|
||||
const NO_IMAGE_HINT = msg('Erst ein Bild wählen');
|
||||
const SAVE_LABEL = msg('Speichern');
|
||||
const SAVING_LABEL = msg('Speichert …');
|
||||
const SAVED_LABEL = msg('Gespeichert');
|
||||
const SAVED_LABEL = msg('Veröffentlicht');
|
||||
const KEY_STEP = 24;
|
||||
const KEY_STEP_BIG = 96;
|
||||
const KEY_ZOOM_STEP = 0.1;
|
||||
@@ -223,7 +223,7 @@ export function createPreview(handlers: PreviewHandlers): Preview {
|
||||
el('div', { class: 'action-buttons' }, replaceButton, downloadButton, saveButton),
|
||||
);
|
||||
|
||||
const note = el('p', { class: 'note' }, icon('lock', 'note-icon'), t('Dein Bild bleibt auf deinem Gerät, bis du speicherst.'));
|
||||
const note = el('p', { class: 'note' }, icon('lock', 'note-icon'), t('Speichern veröffentlicht die Karte direkt in der Community. Nur eigene Bilder, keine echten Kartendaten.'));
|
||||
|
||||
const inner = el('div', { class: 'preview-inner' }, boundRow, stage, zoomRow, errorBox, warningBox, noticeBox, actions, note, input);
|
||||
const section = el('section', { class: 'editor-center', 'aria-label': t('Kartenvorschau') }, inner);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DEFAULT_CONFIG, DEFAULT_CROP } from '../card/config';
|
||||
import type { CardImage } from '../card/render';
|
||||
import { createStore, initialEditorState, setImage } from './state';
|
||||
import { setLang } from '../i18n/i18n';
|
||||
import { createStore, defaultCardName, followLanguage, initialEditorState, setImage, type SavedCardRef } from './state';
|
||||
|
||||
const fakeImage = (w = 2000, h = 1000): CardImage => ({ source: {} as CanvasImageSource, w, h });
|
||||
const fakeBlob = () => new Blob(['x'], { type: 'image/png' });
|
||||
@@ -119,3 +120,110 @@ describe('setImage', () => {
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultCardName', () => {
|
||||
it('der Standardname in der Sprache der Seite; initialEditorState trägt ihn', () => {
|
||||
expect(defaultCardName()).toBe('Meine Karte');
|
||||
expect(initialEditorState().cardName).toBe('Meine Karte');
|
||||
setLang('en', { persist: false });
|
||||
expect(defaultCardName()).toBe('My card');
|
||||
expect(initialEditorState().cardName).toBe('My card');
|
||||
});
|
||||
});
|
||||
|
||||
describe('followLanguage: der Standardname einer neuen Karte folgt der Sprache', () => {
|
||||
/** Abmeldungen der Hörer dieses Tests; sie werden nach jedem Test aufgehoben (die Hörerliste von i18n.ts lebt so lange wie die Datei). */
|
||||
const stops: Array<() => void> = [];
|
||||
afterEach(() => {
|
||||
for (const stop of stops.splice(0)) stop();
|
||||
});
|
||||
|
||||
const bound = (patch: Partial<ReturnType<typeof initialEditorState>> = {}) => {
|
||||
const store = createStore({ ...initialEditorState(), ...patch });
|
||||
stops.push(followLanguage(store));
|
||||
return store;
|
||||
};
|
||||
const ref = (name: string): SavedCardRef => ({ id: 'k1', name, imageFileId: 'i', previewFileId: 'p', updatedAt: '2026-10-05T10:00:00.000Z' });
|
||||
|
||||
it('der unberührte Standardname wechselt mit der Sprache, hin und zurück', () => {
|
||||
const store = bound();
|
||||
expect(store.get().cardName).toBe('Meine Karte');
|
||||
setLang('en', { persist: false });
|
||||
expect(store.get().cardName).toBe('My card');
|
||||
setLang('de', { persist: false });
|
||||
expect(store.get().cardName).toBe('Meine Karte');
|
||||
});
|
||||
|
||||
it('auch wenn der Editor auf Englisch beginnt', () => {
|
||||
setLang('en', { persist: false });
|
||||
const store = bound();
|
||||
expect(store.get().cardName).toBe('My card');
|
||||
setLang('de', { persist: false });
|
||||
expect(store.get().cardName).toBe('Meine Karte');
|
||||
});
|
||||
|
||||
it('ein selbst gewählter Name bleibt, in jeder Richtung', () => {
|
||||
const store = bound({ cardName: 'Mein Urlaub' });
|
||||
setLang('en', { persist: false });
|
||||
expect(store.get().cardName).toBe('Mein Urlaub');
|
||||
store.set({ cardName: 'Holiday' });
|
||||
setLang('de', { persist: false });
|
||||
expect(store.get().cardName).toBe('Holiday');
|
||||
});
|
||||
|
||||
it('der Name wurde geändert und wieder auf den Standardtext zurückgesetzt: er gilt als unberührt', () => {
|
||||
const store = bound();
|
||||
store.set({ cardName: 'Urlaub' });
|
||||
store.set({ cardName: 'Meine Karte' });
|
||||
setLang('en', { persist: false });
|
||||
expect(store.get().cardName).toBe('My card');
|
||||
});
|
||||
|
||||
it('eine gespeicherte Karte behält ihren Namen, auch wenn er dem Standard gleicht', () => {
|
||||
const store = bound({ saved: ref('Meine Karte'), cardName: 'Meine Karte' });
|
||||
setLang('en', { persist: false });
|
||||
expect(store.get().cardName).toBe('Meine Karte');
|
||||
});
|
||||
|
||||
it('wird die Karte gespeichert, bevor die Sprache wechselt, bleibt der Name', () => {
|
||||
const store = bound();
|
||||
store.set({ saved: ref('Meine Karte') }); // Speichern unter dem Standardnamen
|
||||
setLang('en', { persist: false });
|
||||
expect(store.get().cardName).toBe('Meine Karte');
|
||||
});
|
||||
|
||||
it('ein leerer oder fremder Name wird nicht angefasst', () => {
|
||||
const store = bound({ cardName: '' });
|
||||
setLang('en', { persist: false });
|
||||
expect(store.get().cardName).toBe('');
|
||||
});
|
||||
|
||||
it('der Rest des Zustands bleibt unberührt (Bild, Konfiguration), und es gibt genau eine Benachrichtigung', () => {
|
||||
const store = bound();
|
||||
const image = fakeImage();
|
||||
store.set({ image, imageBlob: fakeBlob(), imageChanged: true });
|
||||
const before = store.get();
|
||||
const fn = vi.fn();
|
||||
store.subscribe(fn);
|
||||
setLang('en', { persist: false });
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
expect(store.get()).toEqual({ ...before, cardName: 'My card' });
|
||||
expect(store.get().image).toBe(image);
|
||||
});
|
||||
|
||||
it('derselbe Wechsel ohne Wirkung: gleiche Sprache noch einmal setzen ändert nichts und benachrichtigt nicht', () => {
|
||||
const store = bound();
|
||||
const fn = vi.fn();
|
||||
store.subscribe(fn);
|
||||
setLang('de', { persist: false });
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('nach der Abmeldung des Hörers folgt der Name nicht mehr', () => {
|
||||
const store = createStore(initialEditorState());
|
||||
const stop = followLanguage(store);
|
||||
stop();
|
||||
setLang('en', { persist: false });
|
||||
expect(store.get().cardName).toBe('Meine Karte');
|
||||
});
|
||||
});
|
||||
+22
-2
@@ -2,7 +2,7 @@
|
||||
// Der Store wird einmal in main.ts erzeugt und überlebt Seitenwechsel.
|
||||
import { DEFAULT_CONFIG, DEFAULT_CROP, type CardConfig } from '../card/config';
|
||||
import type { CardImage } from '../card/render';
|
||||
import { t } from '../i18n/i18n';
|
||||
import { onLangChange, t } from '../i18n/i18n';
|
||||
|
||||
export interface SavedCardRef {
|
||||
id: string;
|
||||
@@ -50,6 +50,11 @@ export function createStore(initial: EditorState) {
|
||||
|
||||
export type EditorStore = ReturnType<typeof createStore>;
|
||||
|
||||
/** Der Standardname einer neuen Karte in der Sprache der Seite. */
|
||||
export function defaultCardName(): string {
|
||||
return t('Meine Karte');
|
||||
}
|
||||
|
||||
/** Frischer Anfangszustand (der Standardname der Karte in der Sprache der Seite); DEFAULT_CONFIG und DEFAULT_CROP werden kopiert, nie geteilt. */
|
||||
export function initialEditorState(): EditorState {
|
||||
return {
|
||||
@@ -59,11 +64,26 @@ export function initialEditorState(): EditorState {
|
||||
imageChanged: false,
|
||||
saved: null,
|
||||
savedConfig: null,
|
||||
cardName: t('Meine Karte'),
|
||||
cardName: defaultCardName(),
|
||||
saving: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Standardname einer neuen Karte folgt der Sprache der Seite, solange ihn niemand geändert hat: Nach einem Sprachwechsel bekommt eine
|
||||
* Karte, die noch nicht gespeichert ist und den unberührten Standardnamen der bisherigen Sprache trägt, den der neuen. Ein selbst gewählter
|
||||
* Name bleibt, ebenso der Name einer gespeicherten Karte. Gibt die Abmeldung zurück.
|
||||
*/
|
||||
export function followLanguage(store: EditorStore): () => void {
|
||||
let known = defaultCardName(); // der Standardname in der Sprache, die bis zum nächsten Wechsel gilt
|
||||
return onLangChange(() => {
|
||||
const previous = known;
|
||||
known = defaultCardName();
|
||||
const { saved, cardName } = store.get();
|
||||
if (saved === null && cardName === previous && previous !== known) store.set({ cardName: known });
|
||||
});
|
||||
}
|
||||
|
||||
/** Übernimmt ein neues Bild und setzt den Ausschnitt zurück (der alte passt nicht zum neuen Bild). */
|
||||
export function setImage(store: EditorStore, image: CardImage, blob: Blob): void {
|
||||
store.set({
|
||||
|
||||
@@ -35,6 +35,12 @@ export const API_MESSAGES: Readonly<Record<string, string>> = {
|
||||
tooManyLogos: msg('Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.'),
|
||||
badHeart: msg('Ungültige Angabe zum Herz.'),
|
||||
badMono: msg('Ungültige Angabe zu „einfarbig“.'),
|
||||
usernameInvalid: msg(
|
||||
'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.',
|
||||
),
|
||||
usernameReserved: msg('Dieser Benutzername ist nicht erlaubt.'),
|
||||
usernameTaken: msg('Dieser Benutzername ist schon vergeben.'),
|
||||
usernameRequired: msg('Bitte wähle zuerst einen Benutzernamen.'),
|
||||
tooLarge: msg('Die Anfrage ist zu groß.'),
|
||||
jsonOnly: msg('Nur application/json wird unterstützt.'),
|
||||
unavailable: msg('Der Dienst ist gerade nicht erreichbar.'),
|
||||
|
||||
+37
-13
@@ -57,9 +57,6 @@ export const EN: Readonly<Record<string, string>> = {
|
||||
'Einfarbig – passt sich Hell/Dunkel an': 'Single color – adapts to light/dark mode',
|
||||
'Nur wählbar, wenn das Logo transparente Bereiche hat.': 'Only available if the logo has transparent areas.',
|
||||
'Name (Pflicht)': 'Name (required)',
|
||||
'Anzeigename (optional)': 'Display name (optional)',
|
||||
'Anonym': 'Anonymous',
|
||||
'Leer = „Anonym“. Deine E-Mail-Adresse wird nie angezeigt.': 'Leave empty for “Anonymous”. Your email address is never shown.',
|
||||
'Ich habe die Rechte an diesem Logo oder die Erlaubnis des Inhabers, und es darf öffentlich geteilt werden.':
|
||||
'I own the rights to this logo or have permission from the owner, and it may be shared publicly.',
|
||||
'Abbrechen': 'Cancel',
|
||||
@@ -70,11 +67,9 @@ export const EN: Readonly<Record<string, string>> = {
|
||||
'Bitte wähle eine Datei.': 'Please choose a file.',
|
||||
'Der Name enthält ungültige Zeichen.': 'The name contains invalid characters.',
|
||||
'Der Name ist zu lang (höchstens {max} Zeichen).': 'The name is too long (at most {max} characters).',
|
||||
'Der Anzeigename enthält ungültige Zeichen.': 'The display name contains invalid characters.',
|
||||
'Der Anzeigename ist zu lang (höchstens {max} Zeichen).': 'The display name is too long (at most {max} characters).',
|
||||
'Bitte bestätige, dass du das Logo teilen darfst.': 'Please confirm that you’re allowed to share the logo.',
|
||||
|
||||
// Bereich „Logo unten rechts“ im Editor (logos/logo-picker.ts). „Community“, „Hochladen“ und „Anonym“ stehen weiter oben.
|
||||
// Bereich „Logo unten rechts“ im Editor (logos/logo-picker.ts). „Community“ und „Hochladen“ stehen weiter oben, die Zeile „von @name“ bei den Benutzernamen.
|
||||
'Logo unten rechts': 'Bottom-right logo',
|
||||
'Logos werden von der Community hochgeladen.': 'Logos are uploaded by the community.',
|
||||
'Sortierung': 'Sort order',
|
||||
@@ -83,7 +78,6 @@ export const EN: Readonly<Record<string, string>> = {
|
||||
'Gemeldet': 'Reported',
|
||||
'Ausgeblendet': 'Hidden',
|
||||
'Kein Logo': 'No logo',
|
||||
'von {name}': 'by {name}',
|
||||
'Herz für „{name}“: {count}': 'Heart for “{name}”: {count}',
|
||||
'Noch keine Community-Logos – lade das erste hoch.': 'No community logos yet – upload the first one.',
|
||||
'Keine Logos in dieser Ansicht.': 'No logos in this view.',
|
||||
@@ -91,7 +85,7 @@ export const EN: Readonly<Record<string, string>> = {
|
||||
'Nochmal versuchen': 'Try again',
|
||||
'Mehr laden': 'Load more',
|
||||
|
||||
// Infozeile zum gewählten Logo (logos/logo-info.ts). „von {name}“, „Anonym“, „Ausgeblendet“ und „Melden“ stehen an anderer Stelle,
|
||||
// Infozeile zum gewählten Logo (logos/logo-info.ts). „von @name“, „Ausgeblendet“ und „Melden“ stehen an anderer Stelle,
|
||||
// der Hinweis „Das gewählte Logo ist nicht mehr verfügbar.“ weiter oben (Logo auf der Karte).
|
||||
'Löschen': 'Delete',
|
||||
'Ausblenden': 'Hide',
|
||||
@@ -145,6 +139,23 @@ export const EN: Readonly<Record<string, string>> = {
|
||||
'Neuen Link anfordern': 'Request a new link',
|
||||
'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 …',
|
||||
|
||||
// Zeile „von @name“ bei Logo-Kacheln, Logo-Infozeile und Community-Karten (account/profile.ts, benutzt von logos/logo-picker.ts,
|
||||
// logos/logo-info.ts, pages/community-page.ts): mit Profil „von @name“, sonst der alte Anzeigename („von {name}“), sonst „Anonym“.
|
||||
'von @{name}': 'by @{name}',
|
||||
'von {name}': 'by {name}',
|
||||
'Anonym': 'Anonymous',
|
||||
|
||||
// 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.',
|
||||
'E-Mail oder Passwort ist falsch.': 'Email or password is incorrect.',
|
||||
@@ -198,11 +209,11 @@ export const EN: Readonly<Record<string, string>> = {
|
||||
'Herunterladen': 'Download',
|
||||
'Speichern': 'Save',
|
||||
'Speichert …': 'Saving …',
|
||||
'Gespeichert': 'Saved',
|
||||
'Erst ein Bild wählen': 'Choose an image first',
|
||||
'Neue Karte': 'New card',
|
||||
'Bearbeitet: {name}': 'Editing: {name}',
|
||||
'Dein Bild bleibt auf deinem Gerät, bis du speicherst.': 'Your image stays on your device until you save.',
|
||||
'Speichern veröffentlicht die Karte direkt in der Community. Nur eigene Bilder, keine echten Kartendaten.':
|
||||
'Saving publishes the card to the community right away. Only your own images, no real card details.',
|
||||
|
||||
// Editor, Meldungen und Rückfragen (editor/editor-page.ts, editor/upload.ts, editor/download.ts, editor/save.ts, card/render.ts)
|
||||
'Bitte zuerst ein Bild wählen.': 'Please choose an image first.',
|
||||
@@ -232,8 +243,8 @@ export const EN: Readonly<Record<string, string>> = {
|
||||
'1 Meldung': '1 report',
|
||||
'{count} Meldungen': '{count} reports',
|
||||
|
||||
// Veröffentlichen-Dialog (community/publish-dialog.ts, community/publish.ts). „Abbrechen“, „Schließen“, „Anonym“, „Anzeigename (optional)“,
|
||||
// die Erklärung dazu und „Diese Karte ist schon veröffentlicht.“ stehen an anderer Stelle.
|
||||
// Veröffentlichen-Dialog (community/publish-dialog.ts, community/publish.ts). „Abbrechen“, „Schließen“ und
|
||||
// „Diese Karte ist schon veröffentlicht.“ stehen an anderer Stelle.
|
||||
'Karte veröffentlichen': 'Publish card',
|
||||
'„{name}“ wird mit Bild öffentlich in der Community-Galerie gezeigt.':
|
||||
'“{name}” will be shown publicly in the community gallery, including its image.',
|
||||
@@ -287,7 +298,7 @@ export const EN: Readonly<Record<string, string>> = {
|
||||
'Nichts gefunden für „{query}“': 'Nothing found for “{query}”',
|
||||
|
||||
// Community-Detailseite (pages/community-page.ts). „Ausgeblendet“, „Melden“, „Löschen“, „Ausblenden“, „Einblenden“, „Herunterladen“,
|
||||
// „Nochmal versuchen“, „von {name}“, „Anonym“, „Karte wird geladen …“, „Diese Karte ist nicht (mehr) verfügbar.“ und
|
||||
// „Nochmal versuchen“, „von @name“, „Karte wird geladen …“, „Diese Karte ist nicht (mehr) verfügbar.“ und
|
||||
// „Die Gründe konnten nicht geladen werden.“ stehen an anderer Stelle.
|
||||
'Die Karte konnte gerade nicht geladen werden.': 'The card couldn’t be loaded right now.',
|
||||
'Bild wird geladen …': 'Loading image …',
|
||||
@@ -361,6 +372,11 @@ export const EN: Readonly<Record<string, string>> = {
|
||||
'You’ve already uploaded 30 logos. Delete one to upload a new one.',
|
||||
'Ungültige Angabe zum Herz.': 'Invalid heart value.',
|
||||
'Ungültige Angabe zu „einfarbig“.': 'Invalid value for “single color”.',
|
||||
'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.':
|
||||
'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.',
|
||||
'Dieser Benutzername ist nicht erlaubt.': 'This username is not allowed.',
|
||||
'Dieser Benutzername ist schon vergeben.': 'This username is already taken.',
|
||||
'Bitte wähle zuerst einen Benutzernamen.': 'Please choose a username first.',
|
||||
'Die Anfrage ist zu groß.': 'The request is too large.',
|
||||
'Nur application/json wird unterstützt.': 'Only application/json is supported.',
|
||||
'Der Dienst ist gerade nicht erreichbar.': 'The service is currently unavailable.',
|
||||
@@ -368,4 +384,12 @@ export const EN: Readonly<Record<string, string>> = {
|
||||
'Etwas ist schiefgelaufen. Bitte nochmal versuchen.': 'Something went wrong. Please try again.',
|
||||
'Anmeldung per Token ist auf dem Server ausgeschaltet – bitte den Betreiber informieren.':
|
||||
'Token sign-in is turned off on the server – please let the site owner know.',
|
||||
|
||||
// Mehrere Karten hochladen (pages/bulk-upload.ts, pages/cards-page.ts)
|
||||
'Mehrere Karten hochladen': 'Upload several cards',
|
||||
'Name der Karte': 'Card name',
|
||||
'Alle veröffentlichen': 'Publish all',
|
||||
'Gib jeder Karte einen Namen. Alle werden gespeichert und direkt in der Community veröffentlicht.':
|
||||
'Give each card a name. All of them are saved and published to the community right away.',
|
||||
'Bitte gib jeder Karte einen Namen.': 'Please give every card a name.',
|
||||
};
|
||||
@@ -13,6 +13,8 @@ const mocks = vi.hoisted(() => ({
|
||||
createRecovery: vi.fn(),
|
||||
updateRecovery: vi.fn(),
|
||||
},
|
||||
/** Abfrage der Benutzernamen (Tabelle profiles): leere Antwort = der Name ist frei. */
|
||||
listRows: vi.fn(),
|
||||
getCard: vi.fn(),
|
||||
saveCard: vi.fn(),
|
||||
getCurrentUser: vi.fn(),
|
||||
@@ -23,7 +25,15 @@ const mocks = vi.hoisted(() => ({
|
||||
getGalleryCard: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../account/appwrite', () => ({ account: mocks.acct, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
|
||||
vi.mock('../community/auto-publish', () => ({ autoPublish: vi.fn(async () => ({ id: 'g1' })) }));
|
||||
vi.mock('../account/appwrite', () => ({
|
||||
account: mocks.acct,
|
||||
client: {},
|
||||
tables: { listRows: mocks.listRows },
|
||||
storage: {},
|
||||
IDS: { database: 'aircard', profiles: 'profiles' },
|
||||
APPWRITE_ENDPOINT: '',
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
@@ -80,7 +90,7 @@ describe('Konto-Menü und Login-Dialog auf Englisch', () => {
|
||||
expect(texts(slot)).toEqual(['Sign in']);
|
||||
mocks.acct.get.mockResolvedValue({ $id: 'u1', email: 'kenso@example.com' });
|
||||
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([]);
|
||||
mocks.acct.deleteSession.mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
[...slot.querySelectorAll('button')].find((b) => b.textContent === 'Sign out')!.click();
|
||||
@@ -111,10 +121,24 @@ describe('Konto-Menü und Login-Dialog auf Englisch', () => {
|
||||
|
||||
button('Sign up').click();
|
||||
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('password').value = 'passwort123';
|
||||
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'));
|
||||
expect(alert()).toBe('The passwords don’t match.');
|
||||
expect(document.activeElement).toBe(input('password-repeat'));
|
||||
@@ -125,11 +149,19 @@ describe('Konto-Menü und Login-Dialog auf Englisch', () => {
|
||||
expect(document.activeElement).toBe(input('password'));
|
||||
expect(germanLeft(dialog())).toEqual([]);
|
||||
|
||||
// vorhandene E-Mail: Meldung mit dem Link „Sign in“
|
||||
mocks.acct.create.mockRejectedValue(fail(409, 'user_already_exists'));
|
||||
// vergebener Name: die Meldung steht am Feld, bevor ein Konto entsteht
|
||||
mocks.listRows.mockResolvedValueOnce({ rows: [{ $id: 'u9', username: 'Kenso', usernameKey: 'kenso' }] });
|
||||
input('password').value = 'passwort123';
|
||||
input('password-repeat').value = 'passwort123';
|
||||
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'));
|
||||
|
||||
button('Sign in').click();
|
||||
@@ -293,7 +325,7 @@ describe('Editor auf Englisch', () => {
|
||||
await vi.waitFor(() => expect(errorText()).toBe('Saving failed – Can’t reach the server.'));
|
||||
mocks.saveCard.mockResolvedValueOnce({ id: 'k1', name: 'Urlaub', imageFileId: 'i', previewFileId: 'p', updatedAt: 'x', config: store.get().config });
|
||||
button('Save').click();
|
||||
await vi.waitFor(() => expect(button('Saved')).toBeDefined());
|
||||
await vi.waitFor(() => expect(button('Published')).toBeDefined());
|
||||
expect(root.querySelector('.bound-label')!.textContent).toBe('Editing: Urlaub');
|
||||
|
||||
// Download: Logos nicht ladbar, dann ein PNG über der Wallet-Grenze, dann ein Export-Fehler mit deutscher Meldung aus card/render.ts
|
||||
|
||||
@@ -179,6 +179,45 @@ describe('onLangChange', () => {
|
||||
expect(calls).toEqual(['en']);
|
||||
});
|
||||
|
||||
it('ein Hörer, der wirft, hält die späteren nicht auf: die Sprache steht, der Fehler geht in die Konsole', () => {
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const boom = new Error('boom');
|
||||
const before = listen();
|
||||
stops.push(
|
||||
onLangChange(() => {
|
||||
throw boom;
|
||||
}),
|
||||
);
|
||||
const after = listen();
|
||||
expect(() => setLang('en')).not.toThrow();
|
||||
expect(currentLang()).toBe('en');
|
||||
expect(document.documentElement.lang).toBe('en');
|
||||
expect(before).toEqual(['en']);
|
||||
expect(after).toEqual(['en']);
|
||||
expect(error).toHaveBeenCalledTimes(1);
|
||||
expect(error).toHaveBeenCalledWith(expect.any(String), boom);
|
||||
// der werfende Hörer bleibt angemeldet und stört auch den nächsten Wechsel nicht
|
||||
expect(() => setLang('de')).not.toThrow();
|
||||
expect(after).toEqual(['en', 'de']);
|
||||
expect(error).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('mehrere werfende Hörer: jeder wird gemeldet, alle übrigen laufen', () => {
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const calls: string[] = [];
|
||||
for (const name of ['a', 'b', 'c']) {
|
||||
stops.push(
|
||||
onLangChange(() => {
|
||||
calls.push(name);
|
||||
if (name !== 'b') throw new Error(name);
|
||||
}),
|
||||
);
|
||||
}
|
||||
setLang('en');
|
||||
expect(calls).toEqual(['a', 'b', 'c']);
|
||||
expect(error).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('ein Hörer darf sich beim Melden abmelden, die übrigen werden trotzdem gerufen', () => {
|
||||
const calls: string[] = [];
|
||||
const stopA = onLangChange(() => {
|
||||
|
||||
+12
-4
@@ -11,9 +11,9 @@ export const LANG_KEY = 'aircard.lang';
|
||||
* Startsprache: die gespeicherte Wahl (`de` oder `en`), sonst Deutsch, wenn die erste Browsersprache mit `de` beginnt,
|
||||
* andernfalls Englisch. Dieselbe Regel steht im Skript in index.html, das <html lang> vor dem ersten Paint setzt.
|
||||
*/
|
||||
export function initialLang(stored: string | null, browserLanguages: readonly string[]): Lang {
|
||||
export function initialLang(stored: string | null, languages: readonly string[]): Lang {
|
||||
if (stored === 'de' || stored === 'en') return stored;
|
||||
return (browserLanguages[0] ?? '').toLowerCase().startsWith('de') ? 'de' : 'en';
|
||||
return (languages[0] ?? '').toLowerCase().startsWith('de') ? 'de' : 'en';
|
||||
}
|
||||
|
||||
function readStored(): string | null {
|
||||
@@ -42,7 +42,8 @@ export function currentLang(): Lang {
|
||||
|
||||
/**
|
||||
* Stellt die Sprache um: setzt <html lang>, merkt sie sich (außer mit `persist: false`) und meldet einen echten Wechsel an
|
||||
* die Hörer. Das Neuzeichnen von Kopfzeile und Seite ist Sache der Hörer.
|
||||
* die Hörer. Das Neuzeichnen von Kopfzeile und Seite ist Sache der Hörer; wirft einer, kommt der Fehler in die Konsole und die
|
||||
* übrigen werden trotzdem gerufen.
|
||||
*/
|
||||
export function setLang(next: Lang, opts: { persist?: boolean } = {}): void {
|
||||
const changed = next !== currentLang();
|
||||
@@ -55,8 +56,15 @@ export function setLang(next: Lang, opts: { persist?: boolean } = {}): void {
|
||||
// gilt dann nur für diese Sitzung
|
||||
}
|
||||
}
|
||||
if (!changed) return;
|
||||
// Kopie, damit sich ein Hörer beim Melden abmelden darf.
|
||||
if (changed) for (const fn of [...listeners]) fn(next);
|
||||
for (const fn of [...listeners]) {
|
||||
try {
|
||||
fn(next);
|
||||
} catch (err) {
|
||||
console.error('Ein Hörer des Sprachwechsels ist gescheitert', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Meldet jeden Sprachwechsel (nicht den bloßen Aufruf mit der aktuellen Sprache). Gibt die Abmeldung zurück. */
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// Als Text lesen: vitest verarbeitet CSS-Importe (auch ?raw) nicht zu Text (wie community/live-regions.test.ts).
|
||||
const logosCss = readFileSync(resolve(import.meta.dirname, 'logos.css'), 'utf8');
|
||||
const communityCss = readFileSync(resolve(import.meta.dirname, '../community/community.css'), 'utf8');
|
||||
|
||||
/** Die Deklarationen einer Regel, deren Auswahlkriterium genau `selector` ist (mehrere Regeln: alle zusammen). */
|
||||
function declarations(css: string, selector: string): string {
|
||||
const found: string[] = [];
|
||||
for (const rule of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
||||
const selectors = rule[1].replace(/\/\*[\s\S]*?\*\//g, '').split(',').map((part) => part.trim());
|
||||
if (selectors.includes(selector)) found.push(rule[2]);
|
||||
}
|
||||
return found.join('\n');
|
||||
}
|
||||
|
||||
// Solange der Name des Hochladers noch gesucht wird, ist die Zeile „von …“ leer (logo-picker.ts, community-page.ts: kein Knoten im Element).
|
||||
// Ein leeres Block-Element hätte keine Höhe: die Kachel bzw. die Detailseite spränge, wenn der Name dazukommt. Darum hält ein Zeichen ohne
|
||||
// Breite (U+200B) vor dem leeren Inhalt eine Zeile in der Höhe des Textes.
|
||||
describe('Die leere Zeile „von …“ behält ihre Zeilenhöhe', () => {
|
||||
it.each([
|
||||
['logos.css', '.logo-tile-by:empty::before', logosCss],
|
||||
['community.css', '.detail-meta:empty::before', communityCss],
|
||||
])('%s: %s setzt ein Zeichen ohne Breite (U+200B) davor', (_file, selector, css) => {
|
||||
const rule = declarations(css, selector);
|
||||
expect(rule).not.toBe('');
|
||||
expect(rule).toMatch(/content\s*:\s*['"]\\200b['"]/i);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['logos.css', '.logo-tile-by', logosCss],
|
||||
['community.css', '.detail-meta', communityCss],
|
||||
])('%s: %s selbst bleibt im Layout (kein display: none, kein visibility: hidden), sonst entfiele die Zeile ganz', (_file, selector, css) => {
|
||||
const rule = declarations(css, selector);
|
||||
expect(rule).not.toMatch(/display\s*:\s*none/);
|
||||
expect(rule).not.toMatch(/visibility\s*:\s*hidden/);
|
||||
});
|
||||
|
||||
it('die Kachelzeile ist ein Block mit einer einzigen Zeile (kein Umbruch): die Höhe des Zeichens ist die Höhe der Zeile', () => {
|
||||
const rule = declarations(logosCss, '.logo-tile-by');
|
||||
expect(rule).toMatch(/display\s*:\s*block/);
|
||||
expect(rule).toMatch(/white-space\s*:\s*nowrap/);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ export interface LogoUploadInput {
|
||||
name: string;
|
||||
displayName: string;
|
||||
mono: boolean;
|
||||
/** Die Datei im Logo-Bucket (frisch hochgeladen, mit `delete("user:<ich>")`); ihre ID wird auch die ID des Logos. */
|
||||
/** Die Datei im Logo-Bucket (frisch hochgeladen, mit `delete("user:<ich>")`). Das Logo bekommt vom Dienst eine eigene ID (Antwort), nicht die der Datei. */
|
||||
fileId: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,9 @@ const logo = (patch: Partial<CommunityLogo> = {}): CommunityLogo => ({
|
||||
...patch,
|
||||
});
|
||||
|
||||
const bitmap = (w: number, h: number) => ({ tag: 'bitmap', width: w, height: h }) as unknown as ImageBitmap;
|
||||
/** Ein dekodiertes Bild; `close` zeigt, ob es freigegeben wurde (ein `Image` aus dem Ersatzweg hat keins). */
|
||||
const bitmap = (w: number, h: number) => ({ tag: 'bitmap', width: w, height: h, close: vi.fn() }) as unknown as ImageBitmap;
|
||||
const closed = (b: ImageBitmap) => (b.close as unknown as ReturnType<typeof vi.fn>).mock.calls.length;
|
||||
const file = () => new Blob(['png'], { type: 'image/png' });
|
||||
const fileGone = () => Object.assign(new Error('File not found'), { code: 404, type: 'storage_file_not_found' });
|
||||
|
||||
@@ -130,6 +132,26 @@ describe('loadLogoAsset: Laden', () => {
|
||||
await expect(loadLogoAsset('l2', 'light')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('ein 404 mit leerem Typ gilt wie ein 404 ohne Typ als fehlende Datei', async () => {
|
||||
const { loadLogoAsset } = await fresh();
|
||||
mocks.loadLogoBlob.mockRejectedValue(Object.assign(new Error('weg'), { code: 404, type: '' }));
|
||||
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['der Bucket fehlt (404 storage_bucket_not_found)', { code: 404, type: 'storage_bucket_not_found' }],
|
||||
['das Projekt fehlt (404 project_not_found)', { code: 404, type: 'project_not_found' }],
|
||||
['ein anderer 404 (general_route_not_found)', { code: 404, type: 'general_route_not_found' }],
|
||||
['der Typ „Datei nicht gefunden“ ohne Status 404', { type: 'storage_file_not_found' }],
|
||||
['der Typ „Datei nicht gefunden“ mit einem anderen Status', { code: 500, type: 'storage_file_not_found' }],
|
||||
])('%s ist ein echter Fehler, kein fehlendes Logo: er wird weitergereicht und nicht zwischengespeichert', async (_name, fields) => {
|
||||
const { loadLogoAsset } = await fresh();
|
||||
const failure = Object.assign(new Error('Bucket'), fields);
|
||||
mocks.loadLogoBlob.mockRejectedValueOnce(failure);
|
||||
await expect(loadLogoAsset('l1', 'light')).rejects.toBe(failure);
|
||||
await expect(loadLogoAsset('l1', 'light')).resolves.toMatchObject({ w: 200, h: 100 }); // der nächste Aufruf versucht es neu
|
||||
});
|
||||
|
||||
it('ein echter Ladefehler (Netz, 5xx) ist kein fehlendes Logo: er wird weitergereicht', async () => {
|
||||
const { loadLogoAsset } = await fresh();
|
||||
const failure = Object.assign(new Error('Bad gateway'), { code: 502, type: 'general_server_error' });
|
||||
@@ -167,6 +189,79 @@ describe('loadLogoAsset: Laden', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadLogoAsset: dekodierte Bilder, die nicht bleiben, werden freigegeben (ImageBitmap.close)', () => {
|
||||
it('zu groß (mehrfarbig): das Bild wird geschlossen, das Ergebnis ist null', async () => {
|
||||
const { loadLogoAsset } = await fresh();
|
||||
const decoded = bitmap(1001, 1000);
|
||||
mocks.createImageBitmap.mockResolvedValue(decoded);
|
||||
await expect(loadLogoAsset('big', 'light')).resolves.toBeNull();
|
||||
expect(closed(decoded)).toBe(1);
|
||||
});
|
||||
|
||||
it('zu groß (einfarbig): das Bild wird geschlossen, ohne dass ein Canvas angelegt wird', async () => {
|
||||
const { loadLogoAsset } = await fresh();
|
||||
mocks.getLogo.mockResolvedValue(logo({ mono: true }));
|
||||
const decoded = bitmap(30000, 30000);
|
||||
mocks.createImageBitmap.mockResolvedValue(decoded);
|
||||
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
|
||||
expect(closed(decoded)).toBe(1);
|
||||
expect(canvases).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ohne Fläche (0 × 0): das Bild wird geschlossen', async () => {
|
||||
const { loadLogoAsset } = await fresh();
|
||||
const decoded = bitmap(0, 0);
|
||||
mocks.createImageBitmap.mockResolvedValue(decoded);
|
||||
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
|
||||
expect(closed(decoded)).toBe(1);
|
||||
});
|
||||
|
||||
it('einfarbig: nach dem Einfärben ist das Zwischenbild geschlossen (erst danach), die Quelle des Ergebnisses ist der Canvas', async () => {
|
||||
const { loadLogoAsset } = await fresh();
|
||||
mocks.getLogo.mockResolvedValue(logo({ mono: true }));
|
||||
const decoded = bitmap(200, 100);
|
||||
let drawnBeforeClose: string[] | null = null;
|
||||
(decoded.close as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
||||
drawnBeforeClose = canvases[0].calls.map((c) => c.name);
|
||||
});
|
||||
mocks.createImageBitmap.mockResolvedValue(decoded);
|
||||
const asset = await loadLogoAsset('l1', 'light');
|
||||
expect(asset?.source).toBe(canvases[0]);
|
||||
expect(closed(decoded)).toBe(1);
|
||||
expect(drawnBeforeClose).toEqual(['drawImage', 'fillRect']); // geschlossen erst, nachdem der Canvas gefüllt ist
|
||||
});
|
||||
|
||||
it('einfarbig, aber kein 2D-Kontext: der Fehler wird weitergereicht und das Zwischenbild trotzdem geschlossen', async () => {
|
||||
const { loadLogoAsset } = await fresh();
|
||||
stubCanvas({ noContext: true });
|
||||
mocks.getLogo.mockResolvedValue(logo({ mono: true }));
|
||||
const decoded = bitmap(200, 100);
|
||||
mocks.createImageBitmap.mockResolvedValue(decoded);
|
||||
await expect(loadLogoAsset('l1', 'light')).rejects.toThrow(/Canvas/);
|
||||
expect(closed(decoded)).toBe(1);
|
||||
});
|
||||
|
||||
it('mehrfarbig und brauchbar: das Bild bleibt offen, es ist ja das Ergebnis', async () => {
|
||||
const { loadLogoAsset } = await fresh();
|
||||
const decoded = bitmap(200, 100);
|
||||
mocks.createImageBitmap.mockResolvedValue(decoded);
|
||||
const asset = await loadLogoAsset('l1', 'light');
|
||||
expect(asset?.source).toBe(decoded);
|
||||
expect(closed(decoded)).toBe(0);
|
||||
});
|
||||
|
||||
it('jede Tonart eines einfarbigen Logos dekodiert für sich und schließt ihr eigenes Zwischenbild', async () => {
|
||||
const { loadLogoAsset } = await fresh();
|
||||
mocks.getLogo.mockResolvedValue(logo({ mono: true }));
|
||||
const first = bitmap(200, 100);
|
||||
const second = bitmap(200, 100);
|
||||
mocks.createImageBitmap.mockResolvedValueOnce(first).mockResolvedValueOnce(second);
|
||||
await loadLogoAsset('l1', 'light');
|
||||
await loadLogoAsset('l1', 'dark');
|
||||
expect([closed(first), closed(second)]).toEqual([1, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadLogoAsset: einfarbige Logos', () => {
|
||||
it.each([
|
||||
['light', '#FFFFFF'],
|
||||
@@ -389,12 +484,18 @@ describe('loadLogoAsset: ohne createImageBitmap', () => {
|
||||
expect(revoked).toEqual(['blob:fake/1']);
|
||||
});
|
||||
|
||||
it('auch hier gilt die Pixelgrenze', async () => {
|
||||
it('auch hier gilt die Pixelgrenze (ein Image hat kein close; das wirft nicht)', async () => {
|
||||
const { loadLogoAsset } = await fresh();
|
||||
FakeImage.size = { w: 2000, h: 1000 };
|
||||
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('einfarbig, mit Image: das Einfärben schließt nichts und wirft nicht', async () => {
|
||||
const { loadLogoAsset } = await fresh();
|
||||
mocks.getLogo.mockResolvedValue(logo({ mono: true }));
|
||||
await expect(loadLogoAsset('l1', 'light')).resolves.toMatchObject({ w: 200, h: 100 });
|
||||
});
|
||||
|
||||
it('einfarbig: das Image wird in den Canvas gezeichnet und gefüllt', async () => {
|
||||
const { loadLogoAsset } = await fresh();
|
||||
mocks.getLogo.mockResolvedValue(logo({ mono: true }));
|
||||
|
||||
@@ -56,11 +56,14 @@ function rowFor(id: string): Promise<CommunityLogo | null> {
|
||||
return request;
|
||||
}
|
||||
|
||||
/** Die Datei gibt es nicht (mehr): Appwrite antwortet mit 404 / `storage_file_not_found`. */
|
||||
/**
|
||||
* Die Datei gibt es nicht (mehr): Appwrite antwortet mit 404 / `storage_file_not_found` (ohne oder mit leerem Typ gilt ein 404 ebenso).
|
||||
* Jedes andere 404, etwa ein fehlender Bucket (`storage_bucket_not_found`), ist ein echter Fehler (wie `isRowMissing` in logos.ts).
|
||||
*/
|
||||
function isFileMissing(err: unknown): boolean {
|
||||
if (typeof err !== 'object' || err === null) return false;
|
||||
const { code, type } = err as { code?: unknown; type?: unknown };
|
||||
return code === 404 || type === 'storage_file_not_found';
|
||||
return code === 404 && (type === 'storage_file_not_found' || type === undefined || type === '');
|
||||
}
|
||||
|
||||
/** Ersatz ohne `createImageBitmap`: ein `Image` aus einer Object-URL (die URL ist nach dem Laden wieder frei). */
|
||||
@@ -74,6 +77,11 @@ function decodeWithImage(blob: Blob): Promise<Decoded> {
|
||||
}).finally(() => URL.revokeObjectURL(url));
|
||||
}
|
||||
|
||||
/** Gibt den Speicher eines dekodierten Bildes frei, das nicht bleibt (ein `ImageBitmap` hat `close`, ein `Image` nicht). */
|
||||
function release({ source }: Decoded): void {
|
||||
if ('close' in source && typeof source.close === 'function') source.close();
|
||||
}
|
||||
|
||||
/** Dekodiert die PNG-Datei; null, wenn sie sich nicht lesen lässt oder keine Fläche hat. */
|
||||
async function decode(blob: Blob): Promise<Decoded | null> {
|
||||
try {
|
||||
@@ -84,7 +92,9 @@ async function decode(blob: Blob): Promise<Decoded | null> {
|
||||
} else {
|
||||
decoded = await decodeWithImage(blob);
|
||||
}
|
||||
return decoded.w > 0 && decoded.h > 0 ? decoded : null;
|
||||
if (decoded.w > 0 && decoded.h > 0) return decoded;
|
||||
release(decoded);
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -120,9 +130,18 @@ async function build(id: string, tone: DetailsTone): Promise<LogoAsset | null> {
|
||||
}
|
||||
|
||||
const decoded = await decode(blob);
|
||||
if (!decoded || decoded.w * decoded.h > MAX_LOGO_PIXELS) return null;
|
||||
if (!decoded) return null;
|
||||
if (decoded.w * decoded.h > MAX_LOGO_PIXELS) {
|
||||
release(decoded);
|
||||
return null;
|
||||
}
|
||||
if (!logo.mono) return decoded;
|
||||
return { source: fillShape(decoded, TONE_COLOR[tone]), w: decoded.w, h: decoded.h };
|
||||
// Einfarbig bleibt nur der gefüllte Canvas; das dekodierte Bild darunter wird danach nicht mehr gebraucht (auch nicht, wenn das Füllen scheitert).
|
||||
try {
|
||||
return { source: fillShape(decoded, TONE_COLOR[tone]), w: decoded.w, h: decoded.h };
|
||||
} finally {
|
||||
release(decoded);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,7 @@ const mocks = vi.hoisted(() => ({
|
||||
prepareLogo: vi.fn(),
|
||||
uploadPreparedLogo: vi.fn(),
|
||||
withLogin: vi.fn(),
|
||||
usernameDialog: vi.fn(),
|
||||
}));
|
||||
|
||||
// api.ts und with-login.ts hängen am Appwrite-Client; `prepare` und `upload` kommen meist von außen, nur die Standardwerte
|
||||
@@ -19,8 +20,9 @@ vi.mock('./prepare-logo', async (importOriginal) => ({
|
||||
prepareLogo: mocks.prepareLogo,
|
||||
}));
|
||||
vi.mock('./upload-logo', () => ({ uploadPreparedLogo: mocks.uploadPreparedLogo }));
|
||||
// Der Namensdialog ist eine Attrappe (sein eigenes Verhalten steht in account/username-dialog.test.ts).
|
||||
vi.mock('../account/username-dialog', () => ({ usernameDialog: mocks.usernameDialog }));
|
||||
|
||||
import { DISPLAY_NAME_KEY } from '../account/display-name';
|
||||
import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login';
|
||||
import { ApiError } from '../community/api';
|
||||
import { setLang, t } from '../i18n/i18n';
|
||||
@@ -70,6 +72,9 @@ const form = () => dialogEl()!.querySelector('form')!;
|
||||
const send = () => form().dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||
const errorText = () => dialogEl()!.querySelector('[role="alert"]')!.textContent;
|
||||
const statusText = () => dialogEl()!.querySelector('[role="status"]')!.textContent;
|
||||
const errorLine = () => dialogEl()!.querySelector<HTMLElement>('[role="alert"]')!;
|
||||
/** Die IDs in `aria-describedby` eines Feldes (leer, wenn es das Attribut nicht hat). */
|
||||
const describedBy = (name: string) => (input(name).getAttribute('aria-describedby') ?? '').split(' ').filter(Boolean);
|
||||
const previews = () => [...dialogEl()!.querySelectorAll<HTMLElement>('.logo-preview')];
|
||||
const settle = async () => {
|
||||
for (let i = 0; i < 30; i++) await Promise.resolve();
|
||||
@@ -87,9 +92,8 @@ function typeInto(name: string, value: string): void {
|
||||
input(name).dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
function fill(values: { name?: string; displayName?: string; consent?: boolean } = {}): void {
|
||||
function fill(values: { name?: string; consent?: boolean } = {}): void {
|
||||
input('name').value = values.name ?? 'Beispielbank';
|
||||
input('displayName').value = values.displayName ?? '';
|
||||
input('consent').checked = values.consent ?? true;
|
||||
}
|
||||
|
||||
@@ -106,10 +110,10 @@ async function setup(opts: LogoDialogOptions = {}, file: File = imageFile()): Pr
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.body.innerHTML = '';
|
||||
for (const fn of [prepare, upload, mocks.prepareLogo, mocks.uploadPreparedLogo, mocks.withLogin]) fn.mockReset();
|
||||
for (const fn of [prepare, upload, mocks.prepareLogo, mocks.uploadPreparedLogo, mocks.withLogin, mocks.usernameDialog]) fn.mockReset();
|
||||
mocks.withLogin.mockImplementation((fn: () => Promise<unknown>) => fn());
|
||||
mocks.usernameDialog.mockResolvedValue(null);
|
||||
prepare.mockResolvedValue(prepared());
|
||||
created.length = 0;
|
||||
revoked.length = 0;
|
||||
@@ -125,15 +129,13 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('validateLogoForm', () => {
|
||||
const ok = { hasFile: true, name: 'Beispielbank', displayName: '', consent: true };
|
||||
const ok = { hasFile: true, name: 'Beispielbank', consent: true };
|
||||
|
||||
it('gültige Werte: kein Problem', () => {
|
||||
expect(validateLogoForm(ok)).toBeNull();
|
||||
expect(validateLogoForm({ ...ok, displayName: 'Kenso' })).toBeNull();
|
||||
});
|
||||
|
||||
it('ohne Datei', () => {
|
||||
@@ -166,19 +168,6 @@ describe('validateLogoForm', () => {
|
||||
expect(validateLogoForm({ ...ok, name })).toEqual({ field: 'name', message: 'Der Name enthält ungültige Zeichen.' });
|
||||
});
|
||||
|
||||
it('Anzeigename: höchstens 30 Zeichen, keine Steuerzeichen, leer ist erlaubt', () => {
|
||||
expect(validateLogoForm({ ...ok, displayName: 'x'.repeat(31) })).toEqual({
|
||||
field: 'displayName',
|
||||
message: 'Der Anzeigename ist zu lang (höchstens 30 Zeichen).',
|
||||
});
|
||||
expect(validateLogoForm({ ...ok, displayName: 'x'.repeat(30) })).toBeNull();
|
||||
expect(validateLogoForm({ ...ok, displayName: ' ' })).toBeNull();
|
||||
expect(validateLogoForm({ ...ok, displayName: 'Kenso' })).toEqual({
|
||||
field: 'displayName',
|
||||
message: 'Der Anzeigename enthält ungültige Zeichen.',
|
||||
});
|
||||
});
|
||||
|
||||
it('ohne Häkchen', () => {
|
||||
expect(validateLogoForm({ ...ok, consent: false })).toEqual({
|
||||
field: 'consent',
|
||||
@@ -186,12 +175,11 @@ describe('validateLogoForm', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('meldet das erste Problem in Formularreihenfolge: Datei, Name, Anzeigename, Häkchen', () => {
|
||||
const bad = { hasFile: false, name: '', displayName: 'x'.repeat(31), consent: false };
|
||||
it('meldet das erste Problem in Formularreihenfolge: Datei, Name, Häkchen', () => {
|
||||
const bad = { hasFile: false, name: '', consent: false };
|
||||
expect(validateLogoForm(bad)?.field).toBe('file');
|
||||
expect(validateLogoForm({ ...bad, hasFile: true })?.field).toBe('name');
|
||||
expect(validateLogoForm({ ...bad, hasFile: true, name: 'Bank' })?.field).toBe('displayName');
|
||||
expect(validateLogoForm({ ...bad, hasFile: true, name: 'Bank', displayName: '' })?.field).toBe('consent');
|
||||
expect(validateLogoForm({ ...bad, hasFile: true, name: 'Bank' })?.field).toBe('consent');
|
||||
});
|
||||
|
||||
it('Englisch: die Meldungen kommen in der Sprache der Seite', () => {
|
||||
@@ -199,9 +187,6 @@ describe('validateLogoForm', () => {
|
||||
expect(validateLogoForm({ ...ok, hasFile: false })?.message).toBe('Please choose a file.');
|
||||
expect(validateLogoForm({ ...ok, name: '' })?.message).toBe('Please enter a name.');
|
||||
expect(validateLogoForm({ ...ok, name: 'x'.repeat(41) })?.message).toBe('The name is too long (at most 40 characters).');
|
||||
expect(validateLogoForm({ ...ok, displayName: 'x'.repeat(31) })?.message).toBe(
|
||||
'The display name is too long (at most 30 characters).',
|
||||
);
|
||||
expect(validateLogoForm({ ...ok, consent: false })?.message).toBe('Please confirm that you’re allowed to share the logo.');
|
||||
});
|
||||
});
|
||||
@@ -211,12 +196,11 @@ describe('Logo-Dialog: Aufbau', () => {
|
||||
void open();
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Logo hochladen');
|
||||
expect([...form().querySelectorAll('input')].map((i) => i.name)).toEqual(['file', 'mono', 'name', 'displayName', 'consent']);
|
||||
expect([...form().querySelectorAll('input')].map((i) => i.name)).toEqual(['file', 'mono', 'name', 'consent']);
|
||||
expect([...form().querySelectorAll('label')].map((l) => l.textContent)).toEqual([
|
||||
'Datei (Pflicht)',
|
||||
'Einfarbig – passt sich Hell/Dunkel an',
|
||||
'Name (Pflicht)',
|
||||
'Anzeigename (optional)',
|
||||
LOGO_CONSENT_TEXT,
|
||||
]);
|
||||
expect(buttonByText('Abbrechen')).toBeTruthy();
|
||||
@@ -233,8 +217,6 @@ describe('Logo-Dialog: Aufbau', () => {
|
||||
it('beim Öffnen: Name leer, Häkchen ungesetzt, „Einfarbig“ gesperrt, keine Vorschau, Fokus im Datei-Feld', () => {
|
||||
void open();
|
||||
expect(input('name').value).toBe('');
|
||||
expect(input('displayName').value).toBe('');
|
||||
expect(input('displayName').placeholder).toBe('Anonym');
|
||||
expect(input('consent').checked).toBe(false);
|
||||
expect(input('mono').checked).toBe(false);
|
||||
expect(input('mono').disabled).toBe(true);
|
||||
@@ -243,10 +225,10 @@ describe('Logo-Dialog: Aufbau', () => {
|
||||
expect(dialogEl()!.textContent).not.toMatch(/@/);
|
||||
});
|
||||
|
||||
it('der Anzeigename ist mit dem zuletzt benutzten Namen vorbelegt', () => {
|
||||
localStorage.setItem(DISPLAY_NAME_KEY, 'Kenso');
|
||||
it('es gibt kein Feld „Anzeigename“ mehr (die Logos zeigen den Benutzernamen, Spec § 5)', () => {
|
||||
void open();
|
||||
expect(input('displayName').value).toBe('Kenso');
|
||||
expect(dialogEl()!.querySelector('input[name="displayName"]')).toBeNull();
|
||||
expect(dialogEl()!.textContent).not.toMatch(/Anzeigename|Anonym/);
|
||||
});
|
||||
|
||||
it('das Pflicht-Häkchen hat genau den Wortlaut der Spec', () => {
|
||||
@@ -270,7 +252,7 @@ describe('Logo-Dialog: Aufbau', () => {
|
||||
expect(inputs.map((i) => i.id)).not.toContain(heading.id);
|
||||
});
|
||||
|
||||
it('Englisch: Überschrift, Beschriftungen, Platzhalter und Knöpfe', () => {
|
||||
it('Englisch: Überschrift, Beschriftungen und Knöpfe', () => {
|
||||
setLang('en');
|
||||
void open();
|
||||
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Upload logo');
|
||||
@@ -278,10 +260,8 @@ describe('Logo-Dialog: Aufbau', () => {
|
||||
'File (required)',
|
||||
'Single color – adapts to light/dark mode',
|
||||
'Name (required)',
|
||||
'Display name (optional)',
|
||||
'I own the rights to this logo or have permission from the owner, and it may be shared publicly.',
|
||||
]);
|
||||
expect(input('displayName').placeholder).toBe('Anonymous');
|
||||
expect(buttonByText('Cancel')).toBeTruthy();
|
||||
expect(buttonByText('Upload')).toBeTruthy();
|
||||
});
|
||||
@@ -329,7 +309,7 @@ describe('Logo-Dialog: Datei wählen', () => {
|
||||
])('der Name wird aus dem Dateinamen ohne Endung vorbelegt: %s', async (_label, fileName, expected) => {
|
||||
await setup({}, imageFile(fileName));
|
||||
expect(input('name').value).toBe(expected);
|
||||
expect(validateLogoForm({ hasFile: true, name: input('name').value, displayName: '', consent: true })).toBeNull();
|
||||
expect(validateLogoForm({ hasFile: true, name: input('name').value, consent: true })).toBeNull();
|
||||
});
|
||||
|
||||
it('eine zweite Datei ersetzt den vorbelegten Namen, aber keinen selbst getippten', async () => {
|
||||
@@ -533,6 +513,7 @@ describe('Logo-Dialog: Prüfung vor dem Senden', () => {
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
expect(input(field).getAttribute('aria-invalid')).toBe('true');
|
||||
expect(describedBy(field)).toContain(errorLine().id); // das ungültige Feld verweist auf die Fehlerzeile
|
||||
expect(document.activeElement).toBe(input(field));
|
||||
};
|
||||
|
||||
@@ -558,10 +539,6 @@ describe('Logo-Dialog: Prüfung vor dem Senden', () => {
|
||||
await invalid({ name: 'a\tb' }, 'Der Name enthält ungültige Zeichen.', 'name');
|
||||
});
|
||||
|
||||
it('Anzeigename länger als 30 Zeichen', async () => {
|
||||
await invalid({ displayName: 'x'.repeat(31) }, 'Der Anzeigename ist zu lang (höchstens 30 Zeichen).', 'displayName');
|
||||
});
|
||||
|
||||
it('Pflicht-Häkchen fehlt', async () => {
|
||||
await invalid({ consent: false }, 'Bitte bestätige, dass du das Logo teilen darfst.', 'consent');
|
||||
});
|
||||
@@ -578,10 +555,10 @@ describe('Logo-Dialog: Prüfung vor dem Senden', () => {
|
||||
expect(errorText()).toBe('');
|
||||
});
|
||||
|
||||
it('Grenzen sind inklusive: 40 Zeichen Name und 30 Zeichen Anzeigename gehen durch', async () => {
|
||||
it('die Grenze ist inklusive: 40 Zeichen Name gehen durch', async () => {
|
||||
upload.mockResolvedValue(logo());
|
||||
const { outcome } = await setup();
|
||||
fill({ name: 'x'.repeat(40), displayName: 'y'.repeat(30) });
|
||||
fill({ name: 'x'.repeat(40) });
|
||||
send();
|
||||
await outcome;
|
||||
expect(upload).toHaveBeenCalledOnce();
|
||||
@@ -590,26 +567,89 @@ describe('Logo-Dialog: Prüfung vor dem Senden', () => {
|
||||
it('Umlaute, Emoji und Bindestriche bleiben erlaubt', async () => {
|
||||
upload.mockResolvedValue(logo());
|
||||
const { outcome } = await setup();
|
||||
fill({ name: 'Größe 😀 – Möwe-Bank', displayName: 'Zoë' });
|
||||
fill({ name: 'Größe 😀 – Möwe-Bank' });
|
||||
send();
|
||||
await outcome;
|
||||
expect(upload).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Logo-Dialog: Barrierefreiheit', () => {
|
||||
it('das Datei-Feld ist nativ „required“: aria-required gilt für ein Datei-Feld nicht', () => {
|
||||
void open();
|
||||
expect(input('file').required).toBe(true);
|
||||
expect(input('file').hasAttribute('aria-required')).toBe(false);
|
||||
});
|
||||
|
||||
it('die Fehlerzeile hat eine ID, die im Dialog einmalig ist', () => {
|
||||
void open();
|
||||
expect(errorLine().id).toBe('logo-upload-error');
|
||||
expect(dialogEl()!.querySelectorAll(`#${errorLine().id}`)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ohne Fehler verweist kein Feld auf die Fehlerzeile; die Hinweise bleiben', () => {
|
||||
void open();
|
||||
expect(describedBy('file')).toEqual(['logo-upload-file-hint']);
|
||||
expect(describedBy('name')).toEqual([]);
|
||||
expect(describedBy('consent')).toEqual([]);
|
||||
});
|
||||
|
||||
it('ein ungültiges Feld verweist mit aria-describedby auf die Fehlerzeile, hinter seinem Hinweis, wenn es einen hat', async () => {
|
||||
void open();
|
||||
fill();
|
||||
send(); // keine Datei
|
||||
expect(describedBy('file')).toEqual(['logo-upload-file-hint', 'logo-upload-error']);
|
||||
expect(describedBy('name')).toEqual([]); // nur das Feld mit dem Problem
|
||||
|
||||
await setup();
|
||||
fill({ name: '' });
|
||||
send();
|
||||
expect(describedBy('name')).toEqual(['logo-upload-error']);
|
||||
|
||||
fill({ consent: false });
|
||||
send();
|
||||
expect(describedBy('consent')).toEqual(['logo-upload-error']);
|
||||
expect(describedBy('name')).toEqual([]); // nur das Feld mit dem aktuellen Problem
|
||||
});
|
||||
|
||||
it('ist das Feld behoben, fällt die Verknüpfung beim nächsten Senden weg und der Hinweis bleibt', async () => {
|
||||
upload.mockResolvedValue(logo());
|
||||
await setup();
|
||||
fill({ consent: false });
|
||||
send();
|
||||
expect(describedBy('consent')).toEqual(['logo-upload-error']);
|
||||
fill({ consent: true });
|
||||
send();
|
||||
expect(describedBy('consent')).toEqual([]);
|
||||
expect(input('consent').hasAttribute('aria-invalid')).toBe(false);
|
||||
expect(describedBy('file')).toEqual(['logo-upload-file-hint']); // der Hinweis bleibt
|
||||
});
|
||||
|
||||
it('eine neue Dateiwahl löscht den Fehlertext und damit auch die Markierung der Felder, die darauf verwiesen', async () => {
|
||||
await setup();
|
||||
fill({ name: '' });
|
||||
send();
|
||||
expect(describedBy('name')).toEqual(['logo-upload-error']);
|
||||
choose(imageFile('Zweite.png'));
|
||||
await settle();
|
||||
expect(errorText()).toBe('');
|
||||
expect(input('name').hasAttribute('aria-invalid')).toBe(false);
|
||||
expect(describedBy('name')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Logo-Dialog: Senden', () => {
|
||||
it('sendet getrimmte Eingaben genau einmal, merkt den Anzeigenamen und löst mit dem Logo auf', async () => {
|
||||
it('sendet den getrimmten Namen genau einmal (ohne Anzeigenamen) und löst mit dem Logo auf', async () => {
|
||||
const p = prepared();
|
||||
prepare.mockResolvedValue(p);
|
||||
const result = logo({ id: 'L9' });
|
||||
upload.mockResolvedValue(result);
|
||||
const { outcome } = await setup();
|
||||
fill({ name: ' Meine Bank ', displayName: ' Kenso ' });
|
||||
fill({ name: ' Meine Bank ' });
|
||||
send();
|
||||
await expect(outcome).resolves.toBe(result);
|
||||
expect(upload).toHaveBeenCalledOnce();
|
||||
expect(upload).toHaveBeenCalledWith(p, { name: 'Meine Bank', displayName: 'Kenso', mono: false });
|
||||
expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBe('Kenso');
|
||||
expect(upload).toHaveBeenCalledWith(p, { name: 'Meine Bank', mono: false });
|
||||
expect(dialogEl()).toBeNull();
|
||||
});
|
||||
|
||||
@@ -620,7 +660,7 @@ describe('Logo-Dialog: Senden', () => {
|
||||
fill();
|
||||
send();
|
||||
await outcome;
|
||||
expect(upload).toHaveBeenCalledWith(expect.anything(), { name: 'Beispielbank', displayName: '', mono: true });
|
||||
expect(upload).toHaveBeenCalledWith(expect.anything(), { name: 'Beispielbank', mono: true });
|
||||
});
|
||||
|
||||
it('ohne transparente Bereiche geht mono nie als true mit', async () => {
|
||||
@@ -633,18 +673,7 @@ describe('Logo-Dialog: Senden', () => {
|
||||
fill();
|
||||
send();
|
||||
await outcome;
|
||||
expect(upload).toHaveBeenCalledWith(expect.anything(), { name: 'Beispielbank', displayName: '', mono: false });
|
||||
});
|
||||
|
||||
it('ein geleerter Anzeigename entfernt den gemerkten (Anonym)', async () => {
|
||||
localStorage.setItem(DISPLAY_NAME_KEY, 'Alt');
|
||||
upload.mockResolvedValue(logo());
|
||||
const { outcome } = await setup();
|
||||
expect(input('displayName').value).toBe('Alt');
|
||||
fill({ displayName: '' });
|
||||
send();
|
||||
await outcome;
|
||||
expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBeNull();
|
||||
expect(upload).toHaveBeenCalledWith(expect.anything(), { name: 'Beispielbank', mono: false });
|
||||
});
|
||||
|
||||
it('ein Doppelklick auf „Hochladen“ sendet nicht zweimal', async () => {
|
||||
@@ -671,7 +700,7 @@ describe('Logo-Dialog: Senden', () => {
|
||||
send();
|
||||
expect(buttonByText('Hochladen')!.disabled).toBe(true);
|
||||
expect(buttonByText('Abbrechen')!.disabled).toBe(true);
|
||||
for (const name of ['file', 'mono', 'name', 'displayName', 'consent']) expect(input(name).disabled).toBe(true);
|
||||
for (const name of ['file', 'mono', 'name', 'consent']) expect(input(name).disabled).toBe(true);
|
||||
expect(form().getAttribute('aria-busy')).toBe('true');
|
||||
expect(statusText()).toBe('Wird hochgeladen …');
|
||||
|
||||
@@ -679,7 +708,7 @@ describe('Logo-Dialog: Senden', () => {
|
||||
await settle();
|
||||
expect(buttonByText('Hochladen')!.disabled).toBe(false);
|
||||
expect(buttonByText('Abbrechen')!.disabled).toBe(false);
|
||||
for (const name of ['file', 'mono', 'name', 'displayName', 'consent']) expect(input(name).disabled).toBe(false);
|
||||
for (const name of ['file', 'mono', 'name', 'consent']) expect(input(name).disabled).toBe(false);
|
||||
expect(form().getAttribute('aria-busy')).toBe('false');
|
||||
expect(statusText()).toBe('');
|
||||
});
|
||||
@@ -745,18 +774,16 @@ describe('Logo-Dialog: Senden', () => {
|
||||
),
|
||||
);
|
||||
const { outcome } = await setup();
|
||||
fill({ displayName: 'Kenso' });
|
||||
fill({ name: 'Meine Bank' });
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe(
|
||||
'Hochladen fehlgeschlagen – Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.',
|
||||
);
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
// Die Eingaben bleiben, und der Anzeigename wird erst bei Erfolg gemerkt.
|
||||
expect(input('name').value).toBe('Beispielbank');
|
||||
expect(input('displayName').value).toBe('Kenso');
|
||||
// Die Eingaben bleiben.
|
||||
expect(input('name').value).toBe('Meine Bank');
|
||||
expect(input('consent').checked).toBe(true);
|
||||
expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBeNull();
|
||||
|
||||
upload.mockResolvedValueOnce(logo());
|
||||
send();
|
||||
@@ -764,7 +791,6 @@ describe('Logo-Dialog: Senden', () => {
|
||||
expect(upload).toHaveBeenCalledTimes(2);
|
||||
expect(upload.mock.calls[1][0]).toBe(upload.mock.calls[0][0]); // dasselbe fertige Logo, nicht neu verarbeitet
|
||||
expect(prepare).toHaveBeenCalledOnce();
|
||||
expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBe('Kenso');
|
||||
});
|
||||
|
||||
it('Fehlertext des Dienstes in der Sprache der Seite (nach Fehlerschlüssel)', async () => {
|
||||
@@ -810,6 +836,118 @@ describe('Logo-Dialog: Senden', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Logo-Dialog: Benutzername nötig (403 usernameRequired)', () => {
|
||||
const required = () => new ApiError(403, 'Bitte wähle zuerst einen Benutzernamen.', true, 'usernameRequired');
|
||||
const REASON = 'Hochladen fehlgeschlagen – Bitte wähle zuerst einen Benutzernamen.';
|
||||
|
||||
it('der Namensdialog „required“ öffnet sich (mit dem Signal der Seite); der Dialog bleibt offen und nennt den Grund', async () => {
|
||||
mocks.usernameDialog.mockReturnValue(new Promise(() => {})); // der Nutzer überlegt noch
|
||||
upload.mockRejectedValueOnce(required());
|
||||
const controller = new AbortController();
|
||||
await setup({ signal: controller.signal });
|
||||
fill();
|
||||
send();
|
||||
await settle();
|
||||
expect(mocks.usernameDialog).toHaveBeenCalledExactlyOnceWith({ mode: 'required', signal: controller.signal });
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
expect(errorText()).toBe(REASON);
|
||||
expect(buttonByText('Hochladen')!.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('nach dem gespeicherten Namen ist der Grund weg, die Eingaben und das Logo bleiben, der Fokus liegt auf „Hochladen“ und ein neuer Versuch geht durch', async () => {
|
||||
mocks.usernameDialog.mockResolvedValue('Kenso');
|
||||
upload.mockRejectedValueOnce(required());
|
||||
const { outcome } = await setup();
|
||||
fill({ name: 'Meine Bank' });
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe('');
|
||||
expect(input('name').value).toBe('Meine Bank');
|
||||
expect(input('consent').checked).toBe(true);
|
||||
expect(previews()).toHaveLength(2); // das verarbeitete Logo steht noch
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
expect(document.activeElement).toBe(buttonByText('Hochladen'));
|
||||
|
||||
upload.mockResolvedValueOnce(logo());
|
||||
send();
|
||||
await expect(outcome).resolves.toMatchObject({ id: 'L1' });
|
||||
expect(upload).toHaveBeenCalledTimes(2);
|
||||
expect(upload.mock.calls[1][0]).toBe(upload.mock.calls[0][0]);
|
||||
expect(prepare).toHaveBeenCalledOnce();
|
||||
expect(mocks.usernameDialog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('„Abbrechen“ im Namensdialog: der Grund bleibt stehen, nichts ist hochgeladen, ein neuer Versuch fragt der Dienst wieder', async () => {
|
||||
mocks.usernameDialog.mockResolvedValue(null);
|
||||
upload.mockRejectedValue(required());
|
||||
await setup();
|
||||
fill();
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe(REASON);
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
send();
|
||||
await settle();
|
||||
expect(upload).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.usernameDialog).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('lässt sich der Namensdialog nicht öffnen, bleibt der Grund stehen und nichts wirft', async () => {
|
||||
mocks.usernameDialog.mockRejectedValue(new Error('showModal kaputt'));
|
||||
upload.mockRejectedValueOnce(required());
|
||||
await setup();
|
||||
fill();
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe(REASON);
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
expect(buttonByText('Hochladen')!.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('wird der Dialog währenddessen geschlossen (Seite verlassen), bleibt die Antwort des Namensdialogs folgenlos', async () => {
|
||||
let answer!: (name: string | null) => void;
|
||||
mocks.usernameDialog.mockReturnValue(new Promise<string | null>((resolve) => (answer = resolve)));
|
||||
upload.mockRejectedValueOnce(required());
|
||||
const controller = new AbortController();
|
||||
const { outcome } = await setup({ signal: controller.signal });
|
||||
fill();
|
||||
send();
|
||||
await settle();
|
||||
controller.abort();
|
||||
await expect(outcome).resolves.toBeNull();
|
||||
answer('Kenso');
|
||||
await settle();
|
||||
expect(dialogEl()).toBeNull();
|
||||
expect(upload).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('andere Fehler (auch andere 403) öffnen den Namensdialog nicht', async () => {
|
||||
upload.mockRejectedValueOnce(new ApiError(403, 'Dazu hast du keine Berechtigung.', true, 'forbidden'));
|
||||
await setup();
|
||||
fill();
|
||||
send();
|
||||
await settle();
|
||||
upload.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable'));
|
||||
send();
|
||||
await settle();
|
||||
upload.mockRejectedValueOnce(new TypeError('Failed to fetch'));
|
||||
send();
|
||||
await settle();
|
||||
expect(mocks.usernameDialog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Englisch: der Grund erscheint auf Englisch', async () => {
|
||||
setLang('en');
|
||||
mocks.usernameDialog.mockResolvedValue(null);
|
||||
upload.mockRejectedValueOnce(required());
|
||||
await setup();
|
||||
fill();
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe('Upload failed – Please choose a username first.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Logo-Dialog: Standardwerte', () => {
|
||||
it('ohne prepare und upload: prepareLogo, und uploadPreparedLogo über withLogin (abgelaufene Sitzung → Anmeldung)', async () => {
|
||||
const p = prepared();
|
||||
@@ -821,11 +959,11 @@ describe('Logo-Dialog: Standardwerte', () => {
|
||||
choose(file);
|
||||
await settle();
|
||||
expect(mocks.prepareLogo).toHaveBeenCalledExactlyOnceWith(file);
|
||||
fill({ displayName: 'Kenso' });
|
||||
fill();
|
||||
send();
|
||||
await expect(outcome).resolves.toBe(result);
|
||||
expect(mocks.withLogin).toHaveBeenCalledOnce();
|
||||
expect(mocks.uploadPreparedLogo).toHaveBeenCalledExactlyOnceWith(p, { name: 'Beispielbank', displayName: 'Kenso', mono: false });
|
||||
expect(mocks.uploadPreparedLogo).toHaveBeenCalledExactlyOnceWith(p, { name: 'Beispielbank', mono: false });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -838,7 +976,6 @@ describe('Logo-Dialog: Schließen', () => {
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
expect(dialogEl()).toBeNull();
|
||||
expect(revoked).toEqual(['blob:logo-1']);
|
||||
expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('Esc (close-Ereignis) und Klick auf den Hintergrund schließen; ohne Senden ist Esc nicht verhindert', async () => {
|
||||
@@ -868,7 +1005,7 @@ describe('Logo-Dialog: Schließen', () => {
|
||||
let finish!: (l: CommunityLogo) => void;
|
||||
upload.mockReturnValue(new Promise((resolve) => (finish = resolve)));
|
||||
const { outcome } = await setup({ signal: controller.signal });
|
||||
fill({ displayName: 'Kenso' });
|
||||
fill();
|
||||
send();
|
||||
controller.abort();
|
||||
await expect(outcome).resolves.toBeNull();
|
||||
@@ -877,7 +1014,6 @@ describe('Logo-Dialog: Schließen', () => {
|
||||
finish(logo());
|
||||
await settle();
|
||||
expect(dialogEl()).toBeNull();
|
||||
expect(localStorage.getItem(DISPLAY_NAME_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('ein Fehler nach dem Schließen durch das Signal wird verschluckt', async () => {
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
// Dialog „Logo hochladen“ (Spec § 4): Datei wählen, im Browser verarbeiten (prepare-logo.ts), auf dunklem und hellem Grund ansehen,
|
||||
// Name, Anzeigename und Pflicht-Häkchen angeben, hochladen (upload-logo.ts). Eigenes <dialog> wie publish-dialog.ts; alle Texte
|
||||
// Name und Pflicht-Häkchen angeben, hochladen (upload-logo.ts). Einen Anzeigenamen gibt es nicht mehr: Kachel und Infozeile zeigen den
|
||||
// Benutzernamen des Kontos (Spec § 5). Hat das Konto keinen, antwortet der Dienst mit 403 `usernameRequired`: dann öffnet sich der
|
||||
// Namensdialog, und mit dem gespeicherten Namen lässt es sich noch einmal versuchen. Eigenes <dialog> wie publish-dialog.ts; alle Texte
|
||||
// gehen als Text in den DOM, nie als HTML.
|
||||
import '../account/account.css';
|
||||
import '../community/community.css';
|
||||
import './logos.css';
|
||||
import { rememberDisplayName, rememberedDisplayName } from '../account/display-name';
|
||||
import { errorMessage } from '../account/errors';
|
||||
import { usernameDialog } from '../account/username-dialog';
|
||||
import { LOGIN_NEEDED, LoginCancelled, withLogin } from '../account/with-login';
|
||||
import { TONE_COLOR } from '../card/config';
|
||||
import { ApiError, apiErrorText } from '../community/api';
|
||||
import { CONTROL_CHARACTERS, DISPLAY_NAME_MAX } from '../community/publish-dialog';
|
||||
import { el } from '../editor/dom';
|
||||
import { ApiError, apiErrorText, isUsernameRequired } from '../community/api';
|
||||
import { CONTROL_CHARACTERS } from '../community/publish-dialog';
|
||||
import { el, setFieldInvalid } from '../editor/dom';
|
||||
import { msg, t } from '../i18n/i18n';
|
||||
import type { CommunityLogo } from './logos';
|
||||
import {
|
||||
@@ -32,8 +34,10 @@ export const LOGO_CONSENT_TEXT = msg(
|
||||
'Ich habe die Rechte an diesem Logo oder die Erlaubnis des Inhabers, und es darf öffentlich geteilt werden.',
|
||||
);
|
||||
|
||||
/** Grenze wie der Dienst (deploy/api/logos.js): Länge in UTF-16-Einheiten, nach dem Trimmen. Der Anzeigename: wie beim Veröffentlichen. */
|
||||
/** Grenze wie der Dienst (deploy/api/logos.js): Länge in UTF-16-Einheiten, nach dem Trimmen. */
|
||||
const NAME_MAX = 40;
|
||||
/** Die Fehlerzeile des Dialogs: ein ungültiges Feld verweist mit `aria-describedby` auf sie. */
|
||||
const ERROR_ID = 'logo-upload-error';
|
||||
|
||||
/** Die Meldungen von `prepareLogo` und `validateLogoFile`: Sie kommen mit fertig übersetztem Text im Fehler an. */
|
||||
const PREPARE_MESSAGES = [
|
||||
@@ -49,11 +53,10 @@ export interface LogoFormValues {
|
||||
/** Gibt es ein fertig verarbeitetes Logo? */
|
||||
hasFile: boolean;
|
||||
name: string;
|
||||
displayName: string;
|
||||
consent: boolean;
|
||||
}
|
||||
|
||||
export type LogoField = 'file' | 'name' | 'displayName' | 'consent';
|
||||
export type LogoField = 'file' | 'name' | 'consent';
|
||||
|
||||
/**
|
||||
* Das erste Problem in Formularreihenfolge (mit Text in der Sprache der Seite) oder null, wenn alles passt.
|
||||
@@ -69,17 +72,6 @@ export function validateLogoForm(values: LogoFormValues): { field: LogoField; me
|
||||
return { field: 'name', message: t('Der Name ist zu lang (höchstens {max} Zeichen).', { max: NAME_MAX }) };
|
||||
}
|
||||
|
||||
const displayName = values.displayName.trim();
|
||||
if (CONTROL_CHARACTERS.test(displayName)) {
|
||||
return { field: 'displayName', message: t('Der Anzeigename enthält ungültige Zeichen.') };
|
||||
}
|
||||
if (displayName.length > DISPLAY_NAME_MAX) {
|
||||
return {
|
||||
field: 'displayName',
|
||||
message: t('Der Anzeigename ist zu lang (höchstens {max} Zeichen).', { max: DISPLAY_NAME_MAX }),
|
||||
};
|
||||
}
|
||||
|
||||
if (!values.consent) return { field: 'consent', message: t('Bitte bestätige, dass du das Logo teilen darfst.') };
|
||||
return null;
|
||||
}
|
||||
@@ -177,7 +169,7 @@ export function logoUploadDialog(opts: LogoDialogOptions = {}): Promise<Communit
|
||||
name: 'file',
|
||||
id: 'logo-upload-file',
|
||||
accept: LOGO_ACCEPT,
|
||||
'aria-required': 'true',
|
||||
required: true, // nativ: aria-required gilt für ein Datei-Feld nicht (das Formular prüft selbst, siehe novalidate)
|
||||
'aria-describedby': 'logo-upload-file-hint',
|
||||
});
|
||||
const monoInput = el('input', {
|
||||
@@ -187,13 +179,10 @@ export function logoUploadDialog(opts: LogoDialogOptions = {}): Promise<Communit
|
||||
'aria-describedby': 'logo-upload-mono-hint',
|
||||
});
|
||||
const nameInput = text('name', { 'aria-required': 'true' });
|
||||
const displayInput = text('displayName', { placeholder: t('Anonym'), 'aria-describedby': 'logo-upload-displayName-hint' });
|
||||
displayInput.value = rememberedDisplayName(); // der zuletzt benutzte Name, auch aus dem Veröffentlichen-Dialog
|
||||
const consentInput = el('input', { type: 'checkbox', name: 'consent', id: 'logo-upload-consent' });
|
||||
const inputs: Record<LogoField, HTMLInputElement> = {
|
||||
file: fileInput,
|
||||
name: nameInput,
|
||||
displayName: displayInput,
|
||||
consent: consentInput,
|
||||
};
|
||||
|
||||
@@ -220,14 +209,9 @@ export function logoUploadDialog(opts: LogoDialogOptions = {}): Promise<Communit
|
||||
el('p', { class: 'auth-hint', id: 'logo-upload-mono-hint' }, t('Nur wählbar, wenn das Logo transparente Bereiche hat.')),
|
||||
),
|
||||
field(t('Name (Pflicht)'), nameInput),
|
||||
field(
|
||||
t('Anzeigename (optional)'),
|
||||
displayInput,
|
||||
t('Leer = „Anonym“. Deine E-Mail-Adresse wird nie angezeigt.'),
|
||||
),
|
||||
el('label', { class: 'publish-consent' }, consentInput, el('span', {}, t(LOGO_CONSENT_TEXT))),
|
||||
);
|
||||
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 statusEl = el('p', { class: 'auth-msg', role: 'status' });
|
||||
const cancel = el('button', { type: 'button', class: 'auth-btn' }, t('Abbrechen'));
|
||||
const send = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, t('Hochladen'));
|
||||
@@ -274,7 +258,7 @@ export function logoUploadDialog(opts: LogoDialogOptions = {}): Promise<Communit
|
||||
/** Sperren, Statustext und „Einfarbig“ passend zum Zustand (Hochladen läuft, Datei wird verarbeitet, Logo fertig). */
|
||||
function refresh(): void {
|
||||
form.setAttribute('aria-busy', String(busy));
|
||||
for (const control of [fileInput, nameInput, displayInput, consentInput]) control.disabled = busy;
|
||||
for (const control of [fileInput, nameInput, consentInput]) control.disabled = busy;
|
||||
monoInput.disabled = busy || !prepared?.hasTransparency;
|
||||
send.disabled = busy || preparing;
|
||||
cancel.disabled = busy;
|
||||
@@ -303,8 +287,9 @@ export function logoUploadDialog(opts: LogoDialogOptions = {}): Promise<Communit
|
||||
refresh();
|
||||
}
|
||||
|
||||
/** Kein Feld gilt mehr als ungültig (der Fehlertext, auf den sie verwiesen, ist weg oder wird neu gesetzt). */
|
||||
function clearInvalid(): void {
|
||||
for (const control of Object.values(inputs)) control.removeAttribute('aria-invalid');
|
||||
for (const control of Object.values(inputs)) setFieldInvalid(control, ERROR_ID, false);
|
||||
}
|
||||
|
||||
/** Den Namen aus dem Dateinamen vorbelegen, solange der Nutzer keinen eigenen eingetippt hat. */
|
||||
@@ -320,7 +305,7 @@ export function logoUploadDialog(opts: LogoDialogOptions = {}): Promise<Communit
|
||||
preparing = false;
|
||||
setPrepared(null);
|
||||
errorEl.textContent = '';
|
||||
fileInput.removeAttribute('aria-invalid');
|
||||
clearInvalid();
|
||||
if (!file) return;
|
||||
|
||||
const check = validateLogoFile(file);
|
||||
@@ -348,22 +333,37 @@ export function logoUploadDialog(opts: LogoDialogOptions = {}): Promise<Communit
|
||||
suggestName(file.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Dienst verlangt einen Benutzernamen: der Namensdialog öffnet sich über dem Dialog. Mit gespeichertem Namen ist der Hinweis erledigt
|
||||
* und der Fokus liegt auf „Hochladen“ (erneut versuchen); bei „Abbrechen“ oder wenn der Namensdialog nicht aufgeht, bleibt der Grund stehen.
|
||||
*/
|
||||
async function askForUsername(): Promise<void> {
|
||||
let saved: string | null = null;
|
||||
try {
|
||||
saved = await usernameDialog({ mode: 'required', signal: opts.signal });
|
||||
} catch {
|
||||
// der Namensdialog lässt sich nicht öffnen: der Grund oben steht, der Nutzer kann den Namen im Konto-Menü wählen
|
||||
}
|
||||
if (closed || saved === null) return;
|
||||
errorEl.textContent = '';
|
||||
send.focus();
|
||||
}
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
clearInvalid();
|
||||
errorEl.textContent = '';
|
||||
const problem = validateLogoForm({
|
||||
hasFile: prepared !== null,
|
||||
name: nameInput.value,
|
||||
displayName: displayInput.value,
|
||||
consent: consentInput.checked,
|
||||
});
|
||||
if (problem) {
|
||||
errorEl.textContent = problem.message;
|
||||
inputs[problem.field].setAttribute('aria-invalid', 'true');
|
||||
setFieldInvalid(inputs[problem.field], ERROR_ID, true);
|
||||
inputs[problem.field].focus();
|
||||
return;
|
||||
}
|
||||
const logoForm: LogoForm = { name: nameInput.value.trim(), displayName: displayInput.value.trim(), mono: isMono() };
|
||||
const logoForm: LogoForm = { name: nameInput.value.trim(), mono: isMono() };
|
||||
busy = true; // vor dem ersten await: ein zweiter Klick findet den Dialog schon gesperrt
|
||||
refresh();
|
||||
let logo: CommunityLogo;
|
||||
@@ -376,10 +376,10 @@ export function logoUploadDialog(opts: LogoDialogOptions = {}): Promise<Communit
|
||||
errorEl.textContent =
|
||||
err instanceof LoginCancelled ? t(LOGIN_NEEDED) : t('Hochladen fehlgeschlagen – {reason}', { reason: failureReason(err) });
|
||||
reopen(); // war der Dialog zwischendurch zu, soll der Fehler zu sehen sein
|
||||
if (isUsernameRequired(err)) await askForUsername();
|
||||
return;
|
||||
}
|
||||
if (closed) return;
|
||||
rememberDisplayName(logoForm.displayName);
|
||||
result = logo;
|
||||
finish();
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ vi.mock('./logo-asset', async (importOriginal) => ({
|
||||
forgetLogo: mocks.forgetLogo,
|
||||
}));
|
||||
|
||||
import { byline } from '../account/profile';
|
||||
import { ApiError } from '../community/api';
|
||||
import { setLang } from '../i18n/i18n';
|
||||
import { createLogoInfo, failureText, type LogoInfo, type LogoInfoDeps, type LogoInfoHost } from './logo-info';
|
||||
@@ -61,6 +62,10 @@ let row: CommunityLogo | null;
|
||||
let gone: boolean;
|
||||
let user: string | null;
|
||||
let admin: boolean;
|
||||
/** Die Benutzernamen, die der Picker kennt (Konto-ID → Name). */
|
||||
let owners: Map<string, string>;
|
||||
/** Die Hochlader, deren Name der Picker noch sucht: ihre Zeile „von …“ ist leer. */
|
||||
let waiting: Set<string>;
|
||||
let abort: AbortController;
|
||||
let info: LogoInfo;
|
||||
|
||||
@@ -68,6 +73,8 @@ const host = {
|
||||
selectedId: () => selectedId,
|
||||
selected: () => row,
|
||||
gone: () => gone,
|
||||
// So bildet der Picker die Zeile: Profil, alter Anzeigename oder „Anonym“, solange der Name aber noch gesucht wird, nichts.
|
||||
byline: (logo: CommunityLogo) => (waiting.has(logo.ownerId) ? '' : byline(logo.ownerId, logo.displayName, owners)),
|
||||
patchRow: vi.fn((id: string, patch: Partial<CommunityLogo>) => {
|
||||
if (row?.id === id) row = { ...row, ...patch };
|
||||
}),
|
||||
@@ -95,6 +102,8 @@ beforeEach(() => {
|
||||
gone = false;
|
||||
user = 'u1';
|
||||
admin = false;
|
||||
owners = new Map();
|
||||
waiting = new Set();
|
||||
abort = new AbortController();
|
||||
fx.reportLogo.mockResolvedValue({ schonGemeldet: false });
|
||||
fx.deleteLogo.mockResolvedValue(undefined);
|
||||
@@ -159,24 +168,54 @@ describe('Anzeige', () => {
|
||||
expect(message()).toBe('');
|
||||
});
|
||||
|
||||
it('gewähltes Logo: „<Name> · von <Anzeigename> · ♥ <Zahl>“ und „Melden“ – auch für Gäste', async () => {
|
||||
it('gewähltes Logo: „<Name> · von @<Benutzername> · ♥ <Zahl>“ und „Melden“ – auch für Gäste', async () => {
|
||||
user = null;
|
||||
await mount();
|
||||
expect(line()).toBe('Logo A · von Kenso · ♥ 12');
|
||||
owners.set('u9', 'Kenso.K');
|
||||
await mount(logo({ displayName: '' }));
|
||||
expect(line()).toBe('Logo A · von @Kenso.K · ♥ 12');
|
||||
expect(buttons()).toEqual(['Melden']);
|
||||
expect(button('Melden').type).toBe('button');
|
||||
});
|
||||
|
||||
it('ohne Anzeigename steht „von Anonym“', async () => {
|
||||
await mount(logo({ displayName: '' }));
|
||||
it('der Benutzername geht dem alten Anzeigenamen vor', async () => {
|
||||
owners.set('u9', 'Kenso.K');
|
||||
await mount(logo({ displayName: 'Alter Name' }));
|
||||
expect(line()).toBe('Logo A · von @Kenso.K · ♥ 12');
|
||||
});
|
||||
|
||||
it('ohne Profil steht der früher gespeicherte Anzeigename (ohne @), sonst „von Anonym“', async () => {
|
||||
await mount();
|
||||
expect(line()).toBe('Logo A · von Kenso · ♥ 12');
|
||||
choose(logo({ displayName: '' }));
|
||||
expect(line()).toBe('Logo A · von Anonym · ♥ 12');
|
||||
});
|
||||
|
||||
it('Name und Anzeigename stehen nur als Text, nie als HTML', async () => {
|
||||
it('ein Profil, das nicht zum Besitzer des gewählten Logos gehört, zählt nicht', async () => {
|
||||
owners.set('u1', 'Fremd');
|
||||
await mount(logo({ ownerId: 'u9', displayName: '' }));
|
||||
expect(line()).toBe('Logo A · von Anonym · ♥ 12');
|
||||
});
|
||||
|
||||
it('Name, Anzeigename und Benutzername stehen nur als Text, nie als HTML', async () => {
|
||||
await mount(logo({ name: '<img src=x onerror=alert(1)>', displayName: '<b>fett</b>' }));
|
||||
expect(line()).toBe('<img src=x onerror=alert(1)> · von <b>fett</b> · ♥ 12');
|
||||
expect(info.el.querySelector('img')).toBeNull();
|
||||
expect(info.el.querySelector('b')).toBeNull();
|
||||
owners.set('u9', '<i>x</i>');
|
||||
info.update();
|
||||
expect(line()).toBe('<img src=x onerror=alert(1)> · von @<i>x</i> · ♥ 12');
|
||||
expect(info.el.querySelector('i')).toBeNull();
|
||||
});
|
||||
|
||||
it('update() zeigt einen inzwischen bekannten oder geänderten Benutzernamen sofort (eigenes Umbenennen)', async () => {
|
||||
await mount(logo({ displayName: '' }));
|
||||
expect(line()).toBe('Logo A · von Anonym · ♥ 12');
|
||||
owners.set('u9', 'Alt');
|
||||
info.update();
|
||||
expect(line()).toBe('Logo A · von @Alt · ♥ 12');
|
||||
owners.set('u9', 'Neu');
|
||||
info.update();
|
||||
expect(line()).toBe('Logo A · von @Neu · ♥ 12');
|
||||
});
|
||||
|
||||
it('update() folgt der Zeile des Pickers: neue Zahl, anderes Logo, danach wieder keins', async () => {
|
||||
@@ -191,6 +230,73 @@ describe('Anzeige', () => {
|
||||
expect(buttons()).toEqual([]);
|
||||
});
|
||||
|
||||
describe('solange der Name des Hochladers noch gesucht wird', () => {
|
||||
it('steht keine Zeile „von …“, auch kein Ersatz und kein Trenner: „<Name> · ♥ <Zahl>“', async () => {
|
||||
waiting.add('u9');
|
||||
await mount(logo({ displayName: 'Alter Name' }));
|
||||
expect(line()).toBe('Logo A · ♥ 12');
|
||||
expect(info.el.querySelector('.logo-info-by')).toBeNull(); // die Zeile ist gar nicht eingehängt
|
||||
expect(buttons()).toEqual(['Melden']); // der Rest der Infozeile ist da
|
||||
});
|
||||
|
||||
it('ist der Name gefunden, steht die Zeile samt Trenner zwischen Name und Herzen (update())', async () => {
|
||||
waiting.add('u9');
|
||||
await mount(logo({ displayName: '' }));
|
||||
owners.set('u9', 'Kenso');
|
||||
waiting.delete('u9');
|
||||
info.update();
|
||||
expect(line()).toBe('Logo A · von @Kenso · ♥ 12');
|
||||
expect([...info.el.querySelector('.logo-info-text')!.children].map((node) => node.className)).toEqual([
|
||||
'logo-info-name',
|
||||
'logo-info-by',
|
||||
'logo-info-hearts',
|
||||
]);
|
||||
});
|
||||
|
||||
it('gibt es kein Profil (oder schlug die Suche fehl), steht danach der Ersatz: alter Anzeigename bzw. „von Anonym“', async () => {
|
||||
waiting.add('u9');
|
||||
await mount(logo({ displayName: 'Anna' }));
|
||||
expect(line()).toBe('Logo A · ♥ 12');
|
||||
waiting.delete('u9');
|
||||
info.update();
|
||||
expect(line()).toBe('Logo A · von Anna · ♥ 12');
|
||||
choose(logo({ displayName: '' }));
|
||||
expect(line()).toBe('Logo A · von Anonym · ♥ 12');
|
||||
});
|
||||
|
||||
it('ein Wechsel der Auswahl zwischen wartendem und fertigem Hochlader baut die Zeile jedes Mal richtig auf', async () => {
|
||||
owners.set('u9', 'Kenso');
|
||||
waiting.add('u8');
|
||||
await mount();
|
||||
expect(line()).toBe('Logo A · von @Kenso · ♥ 12');
|
||||
choose(logo({ id: 'b', name: 'Logo B', ownerId: 'u8', likeCount: 3 }));
|
||||
expect(line()).toBe('Logo B · ♥ 3');
|
||||
choose(logo({ id: 'c', name: 'Logo C', ownerId: 'u9', likeCount: 4 }));
|
||||
expect(line()).toBe('Logo C · von @Kenso · ♥ 4');
|
||||
choose(null);
|
||||
expect(line()).toBe('');
|
||||
});
|
||||
|
||||
it('die Zählung der Herzen und die Knöpfe laufen währenddessen weiter (update() ohne neue Zeile)', async () => {
|
||||
waiting.add('u9');
|
||||
await mount();
|
||||
row = logo({ likeCount: 13 });
|
||||
info.update();
|
||||
expect(line()).toBe('Logo A · ♥ 13');
|
||||
});
|
||||
|
||||
it('Englisch: dieselbe Reihenfolge, „by @name“ erst mit dem Namen', async () => {
|
||||
setLang('en', { persist: false });
|
||||
waiting.add('u9');
|
||||
await mount(logo({ displayName: '' }));
|
||||
expect(line()).toBe('Logo A · ♥ 12');
|
||||
owners.set('u9', 'Kenso');
|
||||
waiting.delete('u9');
|
||||
info.update();
|
||||
expect(line()).toBe('Logo A · by @Kenso · ♥ 12');
|
||||
});
|
||||
});
|
||||
|
||||
it('ein Logo, dessen Zeile noch fehlt (wird gerade geholt), zeigt weder Zeile noch Hinweis', async () => {
|
||||
await mount(null);
|
||||
selectedId = 'x'; // gewählt, die Zeile ist noch unbekannt
|
||||
@@ -246,6 +352,9 @@ describe('Anzeige', () => {
|
||||
setLang('en', { persist: false });
|
||||
await mount(logo({ displayName: '' }));
|
||||
expect(line()).toBe('Logo A · by Anonymous · ♥ 12');
|
||||
owners.set('u9', 'Kenso');
|
||||
info.update();
|
||||
expect(line()).toBe('Logo A · by @Kenso · ♥ 12');
|
||||
expect(buttons()).toEqual(['Report']);
|
||||
choose(null);
|
||||
selectedId = 'x';
|
||||
@@ -673,6 +782,37 @@ describe('Admin', () => {
|
||||
expect(fx.listLogoReports).toHaveBeenCalledTimes(1); // nichts mehr zu holen
|
||||
});
|
||||
|
||||
it('nach „Einblenden“ (0 Meldungen) sind die alten Gründe verworfen: kommen später wieder Meldungen, werden sie frisch geholt, auch bei gleicher Zahl', async () => {
|
||||
fx.listLogoReports.mockResolvedValueOnce([report('spam'), report('sonstiges')]);
|
||||
await mount(logo({ hidden: true, reportCount: 2 }));
|
||||
expect(reasons()).toEqual(['Spam', 'Sonstiges']);
|
||||
button('Einblenden').click();
|
||||
await flush();
|
||||
expect(reportCount()).toBe('0 Meldungen');
|
||||
expect(reasons()).toEqual([]);
|
||||
expect(fx.listLogoReports).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Zwei neue Meldungen, wieder genau zwei: gleicher Schlüssel wie vorher, aber andere Gründe.
|
||||
fx.listLogoReports.mockResolvedValueOnce([report('urheberrecht', 'neu1'), report('unangemessen', 'neu2')]);
|
||||
row = logo({ reportCount: 2 });
|
||||
info.update();
|
||||
await flush();
|
||||
expect(fx.listLogoReports).toHaveBeenCalledTimes(2);
|
||||
expect(reasons()).toEqual(['Urheberrecht/Marke', 'Unangemessen']);
|
||||
});
|
||||
|
||||
it('eine späte Antwort der alten Gründe, die nach dem Einblenden eintrifft, wird verworfen', async () => {
|
||||
const slow = deferred<LogoReport[]>();
|
||||
fx.listLogoReports.mockReturnValueOnce(slow.promise);
|
||||
await mount(logo({ hidden: true, reportCount: 2 }));
|
||||
button('Einblenden').click();
|
||||
await flush();
|
||||
slow.resolve([report('spam'), report('sonstiges')]);
|
||||
await flush();
|
||||
expect(reportCount()).toBe('0 Meldungen');
|
||||
expect(reasons()).toEqual([]);
|
||||
});
|
||||
|
||||
it('Fehler beim Ein- oder Ausblenden: Text in der Meldezeile, die Zeile bleibt, kein forgetLogo', async () => {
|
||||
fx.setLogoVisibility.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable'));
|
||||
await mount(logo());
|
||||
@@ -730,6 +870,191 @@ describe('Admin', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Aktionen: Zustand während sie laufen', () => {
|
||||
const busy = () => info.el.getAttribute('aria-busy');
|
||||
const disabled = () => ['Melden', 'Löschen', 'Ausblenden'].filter((label) => button(label)?.disabled);
|
||||
|
||||
beforeEach(() => {
|
||||
admin = true; // alle drei Knöpfe: Melden, Löschen, Ausblenden
|
||||
});
|
||||
|
||||
it('im Ruhezustand: nicht beschäftigt, kein Knopf gesperrt', async () => {
|
||||
await mount();
|
||||
expect(busy()).toBe('false');
|
||||
expect(buttons()).toEqual(['Melden', 'Löschen', 'Ausblenden']);
|
||||
expect(disabled()).toEqual([]);
|
||||
});
|
||||
|
||||
it('läuft eine Anfrage (Löschen): aria-busy="true" an der Infozeile, die anderen Knöpfe sind gesperrt, der gedrückte nicht und behält den Fokus', async () => {
|
||||
const request = deferred<void>();
|
||||
fx.deleteLogo.mockReturnValue(request.promise);
|
||||
await mount();
|
||||
button('Löschen').focus();
|
||||
button('Löschen').click();
|
||||
await flush(); // die Rückfrage ist beantwortet, die Anfrage läuft
|
||||
expect(fx.deleteLogo).toHaveBeenCalledTimes(1);
|
||||
expect(busy()).toBe('true');
|
||||
expect(disabled()).toEqual(['Melden', 'Ausblenden']);
|
||||
expect(button('Löschen').disabled).toBe(false);
|
||||
expect(document.activeElement).toBe(button('Löschen'));
|
||||
request.resolve();
|
||||
await flush();
|
||||
expect(busy()).toBe('false');
|
||||
expect(disabled()).toEqual([]);
|
||||
});
|
||||
|
||||
it('schon die Rückfrage vor dem Löschen zählt: beschäftigt, solange sie offen ist', async () => {
|
||||
const answer = deferred<boolean>();
|
||||
fx.confirm.mockReturnValue(answer.promise);
|
||||
await mount();
|
||||
button('Löschen').click();
|
||||
expect(busy()).toBe('true');
|
||||
expect(disabled()).toEqual(['Melden', 'Ausblenden']);
|
||||
answer.resolve(false); // abgelehnt
|
||||
await flush();
|
||||
expect(busy()).toBe('false');
|
||||
expect(disabled()).toEqual([]);
|
||||
});
|
||||
|
||||
it('der Melden-Dialog zählt ebenso; der gedrückte Knopf ist „Melden“', async () => {
|
||||
const open = deferred<'reported'>();
|
||||
fx.reportDialog.mockReturnValue(open.promise);
|
||||
await mount();
|
||||
button('Melden').click();
|
||||
expect(busy()).toBe('true');
|
||||
expect(disabled()).toEqual(['Löschen', 'Ausblenden']);
|
||||
open.resolve('reported');
|
||||
await flush();
|
||||
expect(busy()).toBe('false');
|
||||
expect(disabled()).toEqual([]);
|
||||
});
|
||||
|
||||
it('Ausblenden: der gedrückte Knopf ist „Ausblenden“; nach dem Umschalten ist alles wieder frei', async () => {
|
||||
const request = deferred<void>();
|
||||
fx.setLogoVisibility.mockReturnValue(request.promise);
|
||||
await mount();
|
||||
button('Ausblenden').click();
|
||||
expect(busy()).toBe('true');
|
||||
expect(disabled()).toEqual(['Melden', 'Löschen']);
|
||||
request.resolve();
|
||||
await flush();
|
||||
expect(busy()).toBe('false');
|
||||
expect(disabled()).toEqual([]);
|
||||
expect(buttons()).toEqual(['Löschen', 'Einblenden']);
|
||||
});
|
||||
|
||||
it('scheitert die Aktion, ist alles wieder frei, und der Fehler steht da', async () => {
|
||||
fx.deleteLogo.mockRejectedValueOnce(new ApiError(403, 'Dazu hast du keine Berechtigung.', true, 'forbidden'));
|
||||
await mount();
|
||||
button('Löschen').click();
|
||||
await flush();
|
||||
expect(message()).toBe('Dazu hast du keine Berechtigung.');
|
||||
expect(busy()).toBe('false');
|
||||
expect(disabled()).toEqual([]);
|
||||
});
|
||||
|
||||
it('nach dem Verlassen der Seite (abort) ist die Infozeile auch wieder frei', async () => {
|
||||
const request = deferred<void>();
|
||||
fx.deleteLogo.mockReturnValue(request.promise);
|
||||
await mount();
|
||||
button('Löschen').click();
|
||||
await flush();
|
||||
abort.abort();
|
||||
request.resolve();
|
||||
await flush();
|
||||
expect(busy()).toBe('false');
|
||||
expect(disabled()).toEqual([]);
|
||||
});
|
||||
|
||||
it('ein Klick auf einen gesperrten oder auf denselben Knopf noch einmal bleibt ohne Wirkung', async () => {
|
||||
const request = deferred<void>();
|
||||
fx.deleteLogo.mockReturnValue(request.promise);
|
||||
await mount();
|
||||
button('Löschen').click();
|
||||
await flush();
|
||||
button('Melden').click(); // gesperrt: ein Klick auf einen gesperrten Knopf erreicht nichts
|
||||
button('Ausblenden').click();
|
||||
button('Löschen').click(); // läuft schon
|
||||
await flush();
|
||||
expect(fx.reportDialog).not.toHaveBeenCalled();
|
||||
expect(fx.setLogoVisibility).not.toHaveBeenCalled();
|
||||
expect(fx.confirm).toHaveBeenCalledTimes(1);
|
||||
request.resolve();
|
||||
await flush();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Aktionen: Ergebnis gehört zur Auswahl, für die sie gestartet wurden', () => {
|
||||
it('ein Fehler, der erst eintrifft, nachdem ein anderes Logo gewählt wurde, steht nicht unter dem neuen Logo', async () => {
|
||||
user = 'u9';
|
||||
const request = deferred<void>();
|
||||
fx.deleteLogo.mockReturnValue(request.promise);
|
||||
await mount(logo({ id: 'a', name: 'Logo A' }));
|
||||
button('Löschen').click();
|
||||
await flush();
|
||||
choose(logo({ id: 'b', name: 'Logo B' })); // der Nutzer wählt inzwischen B
|
||||
request.reject(new ApiError(403, 'Dazu hast du keine Berechtigung.', true, 'forbidden'));
|
||||
await flush();
|
||||
expect(message()).toBe('');
|
||||
expect(line()).toBe('Logo B · von Kenso · ♥ 12');
|
||||
});
|
||||
|
||||
it('wählt der Nutzer „Kein Logo“ und danach wieder dasselbe Logo, ist es für die Anzeige eine neue Auswahl: kein später Fehler dort', async () => {
|
||||
user = 'u9';
|
||||
const request = deferred<void>();
|
||||
fx.deleteLogo.mockReturnValue(request.promise);
|
||||
await mount(logo({ id: 'a' }));
|
||||
button('Löschen').click();
|
||||
await flush();
|
||||
choose(null);
|
||||
request.reject(new TypeError('offline'));
|
||||
await flush();
|
||||
expect(message()).toBe('');
|
||||
});
|
||||
|
||||
it('bleibt die Auswahl dieselbe, steht der Fehler da (auch wenn sich die Zeile zwischendurch aktualisiert hat)', async () => {
|
||||
user = 'u9';
|
||||
const request = deferred<void>();
|
||||
fx.deleteLogo.mockReturnValue(request.promise);
|
||||
await mount(logo({ id: 'a' }));
|
||||
button('Löschen').click();
|
||||
await flush();
|
||||
row = logo({ id: 'a', likeCount: 13 });
|
||||
info.update();
|
||||
request.reject(new ApiError(403, 'Dazu hast du keine Berechtigung.', true, 'forbidden'));
|
||||
await flush();
|
||||
expect(message()).toBe('Dazu hast du keine Berechtigung.');
|
||||
});
|
||||
|
||||
it('ein Fehler beim Ein- oder Ausblenden folgt derselben Regel', async () => {
|
||||
admin = true;
|
||||
const request = deferred<void>();
|
||||
fx.setLogoVisibility.mockReturnValue(request.promise);
|
||||
await mount(logo({ id: 'a' }));
|
||||
button('Ausblenden').click();
|
||||
await flush();
|
||||
choose(logo({ id: 'b', name: 'Logo B' }));
|
||||
request.reject(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable'));
|
||||
await flush();
|
||||
expect(message()).toBe('');
|
||||
});
|
||||
|
||||
it('ein gelungenes Ein- oder Ausblenden ändert die Zeile des Logos, für das es gestartet wurde, auch wenn inzwischen ein anderes gewählt ist', async () => {
|
||||
admin = true;
|
||||
const request = deferred<void>();
|
||||
fx.setLogoVisibility.mockReturnValue(request.promise);
|
||||
await mount(logo({ id: 'a' }));
|
||||
button('Ausblenden').click();
|
||||
await flush();
|
||||
choose(logo({ id: 'b', name: 'Logo B' }));
|
||||
request.resolve();
|
||||
await flush();
|
||||
expect(host.patchRow).toHaveBeenCalledExactlyOnceWith('a', { hidden: true });
|
||||
expect(mocks.forgetLogo).toHaveBeenCalledExactlyOnceWith('a');
|
||||
expect(buttons()).toEqual(['Melden', 'Löschen', 'Ausblenden']); // B ist unberührt
|
||||
});
|
||||
});
|
||||
|
||||
describe('Meldezeile', () => {
|
||||
it('zeigt einen Text in einer Live-Region (role="alert") und löscht ihn mit einem leeren Text', async () => {
|
||||
await mount();
|
||||
|
||||
+43
-15
@@ -1,10 +1,11 @@
|
||||
// Infozeile zum gewählten Logo unter dem Raster (Spec § 3 und § 6): „<Name> · von <Anzeigename> · ♥ <Zahl>“ und „Melden“. Wer das Logo
|
||||
// Infozeile zum gewählten Logo unter dem Raster (Spec § 3 und § 6): „<Name> · von @<Benutzername> · ♥ <Zahl>“ und „Melden“. Wer das Logo
|
||||
// hochgeladen hat, sieht dazu „Löschen“ (mit Rückfrage), Admins die Meldungen mit ihren Gründen sowie „Ausblenden“/„Einblenden“
|
||||
// und „Löschen“. Zugleich die Meldezeile des Bereichs: Fehler von Herz, Hochladen und diesen Aktionen erscheinen hier, so bleibt
|
||||
// der Statusbereich des Pickers den Ladefehlern der Liste vorbehalten.
|
||||
// Die Zeilen und die Auswahl gehören dem Picker (`host`); die Infozeile liest sie und ändert sie nur über `patchRow` und `removeLogo`.
|
||||
// Der Aufbau steht einmal da und `update()` frischt nur Texte und Sichtbarkeit auf: ein Knopf, der gerade den Fokus hat, behält ihn.
|
||||
// Alle Texte gehen als Text in den DOM, nie als HTML.
|
||||
// Läuft eine Aktion, ist die Infozeile `aria-busy` und die übrigen Knöpfe sind gesperrt (der gedrückte nicht, er behält den Fokus);
|
||||
// ein Fehler erscheint nur, wenn noch dasselbe Logo gewählt ist wie beim Start. Alle Texte gehen als Text in den DOM, nie als HTML.
|
||||
import '../account/account.css';
|
||||
import './logos.css';
|
||||
import { errorMessage } from '../account/errors';
|
||||
@@ -39,6 +40,11 @@ export interface LogoInfoHost {
|
||||
selected(): CommunityLogo | null;
|
||||
/** Das gewählte Logo gibt es nicht (mehr): die Abfrage danach hat es nicht gefunden. */
|
||||
gone(): boolean;
|
||||
/**
|
||||
* Die Zeile „von …“ des Logos (Benutzername, sonst alter Anzeigename, sonst „Anonym“). Leer, solange der Name des Hochladers noch gesucht
|
||||
* wird: ein Ersatz, der gleich wieder wechselt, läse sich falsch.
|
||||
*/
|
||||
byline(logo: CommunityLogo): string;
|
||||
/** Ändert Felder der bekannten Zeile von `id` (Kachel und Infozeile folgen). */
|
||||
patchRow(id: string, patch: Partial<CommunityLogo>): void;
|
||||
/** Das Logo ist gelöscht: Kachel und Zeile entfernen, steht es in der Auswahl, wird sie aufgehoben. */
|
||||
@@ -47,7 +53,7 @@ export interface LogoInfoHost {
|
||||
|
||||
export interface LogoInfo {
|
||||
el: HTMLElement;
|
||||
/** Frischt die Anzeige nach dem Stand des Pickers auf (Auswahl, Zeile, Zähler, Anmeldung). */
|
||||
/** Frischt die Anzeige nach dem Stand des Pickers auf (Auswahl, Zeile, Zähler, Anmeldung, Benutzernamen). */
|
||||
update(): void;
|
||||
/** Zeigt einen Fehlertext in der Meldezeile; ein leerer Text löscht ihn. */
|
||||
setMessage(text: string): void;
|
||||
@@ -78,7 +84,7 @@ interface Reasons {
|
||||
|
||||
/** `signal` schließt offene Dialoge und beendet laufende Aktionen (Seite verlassen). */
|
||||
export function createLogoInfo(host: LogoInfoHost, deps: LogoInfoDeps, signal: AbortSignal): LogoInfo {
|
||||
/** Eine Aktion (Rückfrage, Dialog oder Anfrage) läuft: weitere Klicks bleiben ohne Wirkung. */
|
||||
/** Eine Aktion (Rückfrage, Dialog oder Anfrage) läuft: weitere Klicks bleiben ohne Wirkung (die Knöpfe sind dann auch gesperrt). */
|
||||
let busy = false;
|
||||
/** Für diese Auswahl wurde zuletzt gezeichnet: ein Wechsel räumt Meldung und Gründe ab. */
|
||||
let shownId: string | null = null;
|
||||
@@ -90,6 +96,8 @@ export function createLogoInfo(host: LogoInfoHost, deps: LogoInfoDeps, signal: A
|
||||
const byEl = el('span', { class: 'logo-info-by' });
|
||||
const heartsEl = el('span', { class: 'logo-info-hearts' });
|
||||
const textEl = el('p', { class: 'logo-info-text' }, nameEl, ' · ', byEl, ' · ', heartsEl);
|
||||
/** Steht die Zeile „von …“ (mit ihrem Trenner) in `textEl`? Ohne sie, solange der Name noch gesucht wird, heißt es „<Name> · ♥ <Zahl>“. */
|
||||
let byShown = true;
|
||||
const hiddenEl = el('span', { class: 'logo-badge logo-info-hidden' }, t('Ausgeblendet'));
|
||||
const reportButton = el('button', { type: 'button', class: 'auth-btn auth-btn-small logo-info-report' }, t('Melden'));
|
||||
const deleteButton = el('button', { type: 'button', class: 'auth-btn auth-btn-small auth-btn-danger logo-info-delete' }, t('Löschen'));
|
||||
@@ -103,7 +111,8 @@ export function createLogoInfo(host: LogoInfoHost, deps: LogoInfoDeps, signal: A
|
||||
// Immer da (leer nehmen sie keinen Platz ein), damit Screenreader den Text melden, der später hineinkommt.
|
||||
const goneEl = el('p', { class: 'auth-msg logo-info-gone', role: 'status' });
|
||||
const messageEl = el('p', { class: 'auth-msg auth-msg-error logo-info-msg', role: 'alert' });
|
||||
const root = el('div', { class: 'logo-info' }, cardEl, goneEl, messageEl);
|
||||
const root = el('div', { class: 'logo-info', 'aria-busy': 'false' }, cardEl, goneEl, messageEl);
|
||||
const actionButtons = [reportButton, deleteButton, visibilityButton];
|
||||
|
||||
// ---- Anzeige --------------------------------------------------------------------------------------------------------------
|
||||
|
||||
@@ -136,7 +145,10 @@ export function createLogoInfo(host: LogoInfoHost, deps: LogoInfoDeps, signal: A
|
||||
function renderReports(logo: CommunityLogo): void {
|
||||
reportsEl.textContent = reportsLabel(logo.reportCount);
|
||||
const key = `${logo.id}:${logo.reportCount}`;
|
||||
if (logo.reportCount > 0 && reasons?.key !== key) {
|
||||
if (logo.reportCount === 0) {
|
||||
// Keine Meldungen (auch nach „Einblenden“, das sie löscht): nichts, was später bei gleicher Zahl wiederverwendet werden dürfte.
|
||||
reasons = null;
|
||||
} else if (reasons?.key !== key) {
|
||||
reasons = { key, status: 'loading', list: [] };
|
||||
void loadReasons(logo.id, key);
|
||||
}
|
||||
@@ -159,7 +171,12 @@ export function createLogoInfo(host: LogoInfoHost, deps: LogoInfoDeps, signal: A
|
||||
|
||||
const admin = deps.isAdmin();
|
||||
nameEl.textContent = logo.name;
|
||||
byEl.textContent = t('von {name}', { name: logo.displayName || t('Anonym') });
|
||||
const by = host.byline(logo);
|
||||
byEl.textContent = by;
|
||||
if ((by !== '') !== byShown) {
|
||||
byShown = by !== '';
|
||||
textEl.replaceChildren(nameEl, ...(byShown ? [' · ', byEl] : []), ' · ', heartsEl);
|
||||
}
|
||||
heartsEl.textContent = `♥ ${logo.likeCount}`;
|
||||
hiddenEl.hidden = !logo.hidden;
|
||||
// Ein ausgeblendetes Logo kennt der Dienst für Besucher nicht: es lässt sich nicht melden.
|
||||
@@ -174,17 +191,28 @@ export function createLogoInfo(host: LogoInfoHost, deps: LogoInfoDeps, signal: A
|
||||
|
||||
// ---- Aktionen -------------------------------------------------------------------------------------------------------------
|
||||
|
||||
/** Führt eine Aktion aus: immer nur eine, die Meldezeile wird vorher geleert, ein Fehler erscheint dort als Text. */
|
||||
async function run(action: () => Promise<void>): Promise<void> {
|
||||
/** Setzt, ob eine Aktion läuft: `aria-busy` an der Infozeile und die anderen Aktionsknöpfe gesperrt. Der gedrückte bleibt bedienbar und behält so den Fokus. */
|
||||
function setBusy(value: boolean, pressed?: HTMLButtonElement): void {
|
||||
busy = value;
|
||||
root.setAttribute('aria-busy', String(value));
|
||||
for (const button of actionButtons) button.disabled = value && button !== pressed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt eine Aktion aus: immer nur eine, die Meldezeile wird vorher geleert, ein Fehler erscheint dort als Text. Er gehört zum Logo,
|
||||
* für das die Aktion gestartet wurde: hat der Nutzer inzwischen ein anderes gewählt, steht er nicht unter diesem.
|
||||
*/
|
||||
async function run(action: () => Promise<void>, pressed: HTMLButtonElement): Promise<void> {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
const startedFor = host.selectedId();
|
||||
setBusy(true, pressed);
|
||||
setMessage('');
|
||||
try {
|
||||
await action();
|
||||
} catch (err) {
|
||||
if (!signal.aborted) setMessage(failureText(err));
|
||||
if (!signal.aborted && host.selectedId() === startedFor) setMessage(failureText(err));
|
||||
} finally {
|
||||
busy = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,9 +262,9 @@ export function createLogoInfo(host: LogoInfoHost, deps: LogoInfoDeps, signal: A
|
||||
render();
|
||||
}
|
||||
|
||||
reportButton.addEventListener('click', () => void run(report));
|
||||
deleteButton.addEventListener('click', () => void run(remove));
|
||||
visibilityButton.addEventListener('click', () => void run(toggleVisibility));
|
||||
reportButton.addEventListener('click', () => void run(report, reportButton));
|
||||
deleteButton.addEventListener('click', () => void run(remove, deleteButton));
|
||||
visibilityButton.addEventListener('click', () => void run(toggleVisibility, visibilityButton));
|
||||
|
||||
render();
|
||||
return { el: root, update: render, setMessage };
|
||||
|
||||
@@ -79,7 +79,16 @@ const fx = {
|
||||
listLogoReports: vi.fn<LogoPickerDeps['listLogoReports']>(),
|
||||
confirm: vi.fn<LogoPickerDeps['confirm']>(),
|
||||
reportDialog: vi.fn<LogoPickerDeps['reportDialog']>(),
|
||||
usernamesFor: vi.fn<LogoPickerDeps['usernamesFor']>(),
|
||||
requireUsername: vi.fn<LogoPickerDeps['requireUsername']>(),
|
||||
onSelect: vi.fn<(id: string | null) => void>(),
|
||||
onLogoChanged: vi.fn<(id: string) => void>(),
|
||||
};
|
||||
|
||||
// Die Benutzernamen: `usernamesFor` liefert die Namen der Konten (eine Abfrage für alle IDs), `onUsernameChange` meldet das eigene Umbenennen.
|
||||
const nameListeners = new Set<(userId: string, username: string) => void>();
|
||||
const emitName = (userId: string, username: string) => {
|
||||
for (const fn of [...nameListeners]) fn(userId, username);
|
||||
};
|
||||
|
||||
const deps: LogoPickerDeps = {
|
||||
@@ -102,6 +111,12 @@ const deps: LogoPickerDeps = {
|
||||
listLogoReports: fx.listLogoReports,
|
||||
confirm: fx.confirm,
|
||||
reportDialog: fx.reportDialog,
|
||||
usernamesFor: fx.usernamesFor,
|
||||
onUsernameChange(fn) {
|
||||
nameListeners.add(fn);
|
||||
return () => nameListeners.delete(fn);
|
||||
},
|
||||
requireUsername: fx.requireUsername,
|
||||
};
|
||||
|
||||
// Bildprüfung: `loadLogoBlob` liefert einen Blob, den die Attrappe einem Logo zuordnet; die Object-URL erbt das, und das
|
||||
@@ -140,6 +155,7 @@ beforeEach(() => {
|
||||
user = 'u1';
|
||||
admin = false;
|
||||
authListeners.clear();
|
||||
nameListeners.clear();
|
||||
sizes.clear();
|
||||
broken.clear();
|
||||
urlOwner.clear();
|
||||
@@ -162,6 +178,8 @@ beforeEach(() => {
|
||||
fx.listLogoReports.mockResolvedValue([]);
|
||||
fx.confirm.mockResolvedValue(true);
|
||||
fx.reportDialog.mockResolvedValue('cancelled');
|
||||
fx.usernamesFor.mockResolvedValue(new Map()); // kein Konto hat ein Profil: es gelten die alten Anzeigenamen
|
||||
fx.requireUsername.mockResolvedValue(true);
|
||||
URL.createObjectURL = vi.fn((blob: Blob) => {
|
||||
const url = `blob:logo-${created.length + 1}`;
|
||||
created.push(url);
|
||||
@@ -184,7 +202,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
async function mount(): Promise<LogoPicker> {
|
||||
picker = createLogoPicker({ onSelect: fx.onSelect }, deps);
|
||||
picker = createLogoPicker({ onSelect: fx.onSelect, onLogoChanged: fx.onLogoChanged }, deps);
|
||||
host.append(picker.el);
|
||||
await flush();
|
||||
return picker;
|
||||
@@ -235,13 +253,29 @@ describe('Aufbau', () => {
|
||||
expect(picker.el.querySelector('.logo-picker-note')!.textContent).toBe('Logos werden von der Community hochgeladen.');
|
||||
});
|
||||
|
||||
it('das Raster ist eine Liste mit role="list" (list-style: none nähme sie sonst weg); den Namen trägt die Gruppe, nicht die Liste doppelt', async () => {
|
||||
await mount();
|
||||
const heading = picker.el.querySelector('h3')!;
|
||||
const grid = picker.el.querySelector('ul.logo-grid')!;
|
||||
expect(grid.getAttribute('role')).toBe('list');
|
||||
expect(grid.hasAttribute('aria-labelledby')).toBe(false);
|
||||
expect(picker.el.getAttribute('role')).toBe('group');
|
||||
expect(picker.el.getAttribute('aria-labelledby')).toBe(heading.id);
|
||||
expect(picker.el.querySelectorAll('[aria-labelledby]')).toHaveLength(0); // unterhalb der Gruppe verweist nichts mehr auf die Überschrift
|
||||
});
|
||||
|
||||
it('die Überschrift lässt sich per Skript fokussieren (tabindex −1), ohne in der Tab-Reihenfolge zu stehen', async () => {
|
||||
await mount();
|
||||
expect(picker.el.querySelector('h3')!.getAttribute('tabindex')).toBe('-1');
|
||||
});
|
||||
|
||||
it('Umschalter „Beliebt“ (gedrückt) und „Neu“; Nicht-Admins sehen „Gemeldet“ und „Ausgeblendet“ nicht', async () => {
|
||||
await mount();
|
||||
expect(sortButtons().map((b) => b.textContent)).toEqual(['Beliebt', 'Neu']);
|
||||
expect(pressedSort()).toEqual(['Beliebt']);
|
||||
});
|
||||
|
||||
it('erste Kachel „Kein Logo“, dann je Logo eine Kachel mit Name, „von <Anzeigename>“ bzw. „von Anonym“ und Herz-Knopf mit der Zahl', async () => {
|
||||
it('erste Kachel „Kein Logo“, dann je Logo eine Kachel mit Name, „von <alter Anzeigename>“ bzw. „von Anonym“ (ohne Profil) und Herz-Knopf mit der Zahl', async () => {
|
||||
fx.listLogos.mockResolvedValue({
|
||||
logos: [logo('a', { likeCount: 12 }), logo('b', { displayName: '' }), logo('c', { displayName: 'Anna', likeCount: 3 })],
|
||||
nextCursor: null,
|
||||
@@ -311,6 +345,362 @@ describe('Aufbau', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Benutzernamen („von @name“)', () => {
|
||||
const by = (name: string) => cell(name).querySelector('.logo-tile-by')!.textContent;
|
||||
const owned = (id: string, ownerId: string, displayName = '') => logo(id, { ownerId, displayName });
|
||||
|
||||
beforeEach(() => {
|
||||
fx.listLogos.mockResolvedValue({ logos: [owned('a', 'u1'), owned('b', 'u2', 'Anna'), owned('c', 'u3')], nextCursor: null });
|
||||
});
|
||||
|
||||
it('eine einzige Abfrage für alle Hochlader der geladenen Seite, ohne Doppelte und ohne Einträge ohne Konto-ID', async () => {
|
||||
fx.listLogos.mockResolvedValue({
|
||||
logos: [owned('a', 'u1'), owned('b', 'u2'), owned('c', 'u1'), owned('d', '')],
|
||||
nextCursor: null,
|
||||
});
|
||||
await mount();
|
||||
expect(fx.usernamesFor).toHaveBeenCalledExactlyOnceWith(['u1', 'u2']);
|
||||
});
|
||||
|
||||
it('eine leere Seite oder nur Einträge ohne Konto-ID: keine Abfrage', async () => {
|
||||
fx.listLogos.mockResolvedValue({ logos: [owned('a', '')], nextCursor: null });
|
||||
await mount();
|
||||
expect(fx.usernamesFor).not.toHaveBeenCalled();
|
||||
picker.dispose();
|
||||
fx.listLogos.mockResolvedValue({ logos: [], nextCursor: null });
|
||||
await mount();
|
||||
expect(fx.usernamesFor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('die Kachel zeigt „von @name“, wo es ein Profil gibt; sonst den alten Anzeigenamen (ohne @), sonst „von Anonym“', async () => {
|
||||
fx.usernamesFor.mockResolvedValue(new Map([['u1', 'Kenso']]));
|
||||
await mount();
|
||||
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['von @Kenso', 'von Anna', 'von Anonym']);
|
||||
});
|
||||
|
||||
it('ein Profil geht dem alten Anzeigenamen des Eintrags vor', async () => {
|
||||
fx.listLogos.mockResolvedValue({ logos: [owned('a', 'u1', 'Alter Name')], nextCursor: null });
|
||||
fx.usernamesFor.mockResolvedValue(new Map([['u1', 'Kenso']]));
|
||||
await mount();
|
||||
expect(by('Logo A')).toBe('von @Kenso');
|
||||
});
|
||||
|
||||
it('die Liste wartet nicht auf die Namen: die Kacheln stehen sofort, ihre Zeile „von …“ folgt mit den Namen', async () => {
|
||||
const lookup = deferred<Map<string, string>>();
|
||||
fx.usernamesFor.mockReturnValue(lookup.promise);
|
||||
await mount();
|
||||
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']);
|
||||
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['', '', '']);
|
||||
lookup.resolve(new Map([['u1', 'Kenso'], ['u2', 'Mia']]));
|
||||
await flush();
|
||||
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['von @Kenso', 'von @Mia', 'von Anonym']);
|
||||
});
|
||||
|
||||
describe('solange der Name seines Hochladers noch gesucht wird, ist die Zeile „von …“ leer (kein Ersatz, der gleich wieder wechselt)', () => {
|
||||
const line = (name: string) => cell(name).querySelector('.logo-tile-by')!;
|
||||
|
||||
it('gefunden: danach „von @name“, im selben Element; leer heißt ohne jeden Knoten (die Zeilenhöhe hält die CSS-Regel für :empty)', async () => {
|
||||
const lookup = deferred<Map<string, string>>();
|
||||
fx.usernamesFor.mockReturnValue(lookup.promise);
|
||||
await mount();
|
||||
const span = line('Logo A');
|
||||
expect(span.textContent).toBe('');
|
||||
expect(span.childNodes).toHaveLength(0); // sonst griffe :empty nicht
|
||||
lookup.resolve(new Map([['u1', 'Kenso']]));
|
||||
await flush();
|
||||
expect(line('Logo A')).toBe(span);
|
||||
expect(span.textContent).toBe('von @Kenso');
|
||||
});
|
||||
|
||||
it('kein Profil: erst jetzt der Ersatz, der alte Anzeigename (ohne @) bzw. „von Anonym“', async () => {
|
||||
const lookup = deferred<Map<string, string>>();
|
||||
fx.usernamesFor.mockReturnValue(lookup.promise);
|
||||
await mount();
|
||||
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['', '', '']);
|
||||
lookup.resolve(new Map());
|
||||
await flush();
|
||||
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['von Anonym', 'von Anna', 'von Anonym']);
|
||||
});
|
||||
|
||||
it('die Suche scheitert: erst jetzt der Ersatz, kein Fehlertext', async () => {
|
||||
const lookup = deferred<Map<string, string>>();
|
||||
fx.usernamesFor.mockReturnValue(lookup.promise);
|
||||
await mount();
|
||||
expect(by('Logo B')).toBe('');
|
||||
lookup.reject(new TypeError('Failed to fetch'));
|
||||
await flush();
|
||||
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['von Anonym', 'von Anna', 'von Anonym']);
|
||||
expect(alertText()).toBe('');
|
||||
expect(infoMessage()).toBe('');
|
||||
});
|
||||
|
||||
it('nur die Hochlader, deren Name noch gesucht wird, sind leer; die anderen Zeilen stehen schon (Mehr laden: Namen und „kein Profil“ von der ersten Seite gelten sofort)', async () => {
|
||||
fx.listLogos.mockResolvedValueOnce({ logos: [owned('a', 'u1'), owned('b', 'u2', 'Anna')], nextCursor: 'b' });
|
||||
fx.listLogos.mockResolvedValueOnce({
|
||||
logos: [owned('c', 'u3'), owned('d', 'u1'), owned('e', 'u2', 'Berta')],
|
||||
nextCursor: null,
|
||||
});
|
||||
fx.usernamesFor.mockResolvedValueOnce(new Map([['u1', 'Kenso']])); // erste Seite: u1 hat ein Profil, u2 keins
|
||||
const second = deferred<Map<string, string>>();
|
||||
fx.usernamesFor.mockReturnValueOnce(second.promise);
|
||||
await mount();
|
||||
expect(['Logo A', 'Logo B'].map(by)).toEqual(['von @Kenso', 'von Anna']);
|
||||
moreButton().click();
|
||||
await flush();
|
||||
expect(by('Logo D')).toBe('von @Kenso'); // der Name steht schon
|
||||
expect(by('Logo E')).toBe('von Berta'); // „kein Profil“ steht schon fest
|
||||
expect(by('Logo C')).toBe(''); // u3 wird noch gesucht
|
||||
second.resolve(new Map([['u3', 'Zora']]));
|
||||
await flush();
|
||||
expect(by('Logo C')).toBe('von @Zora');
|
||||
expect(by('Logo D')).toBe('von @Kenso');
|
||||
});
|
||||
|
||||
it('Einträge ohne Konto-ID haben nichts zu suchen: ihre Zeile steht sofort', async () => {
|
||||
fx.listLogos.mockResolvedValue({ logos: [owned('a', 'u1'), owned('b', '', 'Anna'), owned('c', '')], nextCursor: null });
|
||||
fx.usernamesFor.mockReturnValue(new Promise(() => {}));
|
||||
await mount();
|
||||
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['', 'von Anna', 'von Anonym']);
|
||||
});
|
||||
|
||||
it('die Infozeile: ohne Zeile „von …“ („<Name> · ♥ <Zahl>“), danach mit dem Namen oder dem Ersatz', async () => {
|
||||
const lookup = deferred<Map<string, string>>();
|
||||
fx.usernamesFor.mockReturnValue(lookup.promise);
|
||||
await mount();
|
||||
picker.select('a');
|
||||
expect(infoLine()).toBe('Logo A · ♥ 0');
|
||||
picker.select('b');
|
||||
expect(infoLine()).toBe('Logo B · ♥ 0');
|
||||
lookup.resolve(new Map([['u1', 'Kenso']]));
|
||||
await flush();
|
||||
expect(infoLine()).toBe('Logo B · von Anna · ♥ 0'); // u2: kein Profil, der alte Anzeigename
|
||||
picker.select('a');
|
||||
expect(infoLine()).toBe('Logo A · von @Kenso · ♥ 0');
|
||||
});
|
||||
|
||||
it('die Infozeile bei gescheiterter Suche: der Ersatz', async () => {
|
||||
const lookup = deferred<Map<string, string>>();
|
||||
fx.usernamesFor.mockReturnValue(lookup.promise);
|
||||
await mount();
|
||||
picker.select('b');
|
||||
expect(infoLine()).toBe('Logo B · ♥ 0');
|
||||
lookup.reject(new Error('weg'));
|
||||
await flush();
|
||||
expect(infoLine()).toBe('Logo B · von Anna · ♥ 0');
|
||||
expect(infoMessage()).toBe('');
|
||||
});
|
||||
|
||||
it('ein gemeldeter Name (Umbenennen, Lesen des eigenen Profils) beendet das Warten dieses Kontos sofort, die anderen bleiben leer', async () => {
|
||||
const lookup = deferred<Map<string, string>>();
|
||||
fx.usernamesFor.mockReturnValue(lookup.promise);
|
||||
await mount();
|
||||
picker.select('a');
|
||||
emitName('u1', 'Kenso');
|
||||
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['von @Kenso', '', '']);
|
||||
expect(infoLine()).toBe('Logo A · von @Kenso · ♥ 0');
|
||||
lookup.resolve(new Map([['u1', 'Kenso']]));
|
||||
await flush();
|
||||
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['von @Kenso', 'von Anna', 'von Anonym']);
|
||||
});
|
||||
|
||||
it('das angeheftete Logo (gewählt, nicht in der Liste): leer, bis sein Hochlader nachgeschlagen ist', async () => {
|
||||
fx.getLogo.mockResolvedValue(owned('x', 'u7'));
|
||||
const lookup = deferred<Map<string, string>>();
|
||||
fx.usernamesFor.mockResolvedValueOnce(new Map()); // die Liste
|
||||
fx.usernamesFor.mockReturnValueOnce(lookup.promise); // das angeheftete Logo
|
||||
await mount();
|
||||
picker.select('x');
|
||||
await flush();
|
||||
expect(by('Logo X')).toBe('');
|
||||
expect(infoLine()).toBe('Logo X · ♥ 0');
|
||||
lookup.resolve(new Map([['u7', 'Zora']]));
|
||||
await flush();
|
||||
expect(by('Logo X')).toBe('von @Zora');
|
||||
expect(infoLine()).toBe('Logo X · von @Zora · ♥ 0');
|
||||
});
|
||||
|
||||
it('ein frisch hochgeladenes Logo eines noch unbekannten Hochladers: leer, bis sein Name da ist', async () => {
|
||||
const lookup = deferred<Map<string, string>>();
|
||||
fx.usernamesFor.mockResolvedValueOnce(new Map()); // die Liste
|
||||
fx.usernamesFor.mockReturnValueOnce(lookup.promise); // das neue Logo
|
||||
fx.uploadDialog.mockResolvedValue(logo('n', { name: 'Neues Logo', ownerId: 'u5', displayName: '' }));
|
||||
await mount();
|
||||
uploadButton().click();
|
||||
await flush();
|
||||
expect(by('Neues Logo')).toBe('');
|
||||
lookup.resolve(new Map([['u5', 'Kenso']]));
|
||||
await flush();
|
||||
expect(by('Neues Logo')).toBe('von @Kenso');
|
||||
});
|
||||
|
||||
it('wird die Seite aufgeräumt, bevor die Suche fertig ist, ändert eine spätere Antwort nichts', async () => {
|
||||
const lookup = deferred<Map<string, string>>();
|
||||
fx.usernamesFor.mockReturnValue(lookup.promise);
|
||||
await mount();
|
||||
picker.dispose();
|
||||
lookup.resolve(new Map([['u1', 'Kenso']]));
|
||||
await flush();
|
||||
expect(by('Logo A')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
it('der Name als Text, nie als HTML', async () => {
|
||||
fx.usernamesFor.mockResolvedValue(new Map([['u1', '<b>fett</b>']]));
|
||||
await mount();
|
||||
expect(by('Logo A')).toBe('von @<b>fett</b>');
|
||||
expect(cell('Logo A').querySelector('b')).toBeNull();
|
||||
});
|
||||
|
||||
it('die Infozeile des gewählten Logos zeigt „von @name“, auch wenn die Namen erst nach der Auswahl eintreffen', async () => {
|
||||
const lookup = deferred<Map<string, string>>();
|
||||
fx.usernamesFor.mockReturnValue(lookup.promise);
|
||||
await mount();
|
||||
picker.select('a');
|
||||
expect(infoLine()).toBe('Logo A · ♥ 0'); // der Name wird noch gesucht: keine Zeile „von …“
|
||||
lookup.resolve(new Map([['u1', 'Kenso']]));
|
||||
await flush();
|
||||
expect(infoLine()).toBe('Logo A · von @Kenso · ♥ 0');
|
||||
picker.select('b');
|
||||
expect(infoLine()).toBe('Logo B · von Anna · ♥ 0'); // u2 hat kein Profil
|
||||
});
|
||||
|
||||
it('schlägt die Abfrage der Namen fehl, bleibt es beim Ersatz: kein Fehlertext, nichts bricht, Herzen und Auswahl gehen weiter', async () => {
|
||||
fx.usernamesFor.mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
await mount();
|
||||
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['von Anonym', 'von Anna', 'von Anonym']);
|
||||
expect(alertText()).toBe('');
|
||||
picker.select('a');
|
||||
expect(infoLine()).toBe('Logo A · von Anonym · ♥ 0');
|
||||
expect(infoMessage()).toBe('');
|
||||
expect(fx.myHearts).toHaveBeenCalled();
|
||||
heart('Logo A').click();
|
||||
await flush();
|
||||
expect(fx.setHeart).toHaveBeenCalledWith('a', true);
|
||||
});
|
||||
|
||||
it('„Mehr laden“: eine weitere Abfrage für die Hochlader der neuen Seite', async () => {
|
||||
fx.listLogos.mockResolvedValueOnce({ logos: [owned('a', 'u1'), owned('b', 'u2')], nextCursor: 'b' });
|
||||
fx.listLogos.mockResolvedValueOnce({ logos: [owned('c', 'u3'), owned('d', 'u1')], nextCursor: null });
|
||||
fx.usernamesFor.mockImplementation(async (ids) => new Map(ids.map((id) => [id, `Name-${id}`])));
|
||||
await mount();
|
||||
expect(fx.usernamesFor).toHaveBeenCalledTimes(1);
|
||||
moreButton().click();
|
||||
await flush();
|
||||
expect(fx.usernamesFor).toHaveBeenCalledTimes(2);
|
||||
expect(fx.usernamesFor).toHaveBeenLastCalledWith(['u3', 'u1']);
|
||||
expect(['Logo A', 'Logo B', 'Logo C', 'Logo D'].map(by)).toEqual(['von @Name-u1', 'von @Name-u2', 'von @Name-u3', 'von @Name-u1']);
|
||||
});
|
||||
|
||||
it('ein Wechsel der Ansicht lädt die Liste neu und fragt die Namen dieser Seite', async () => {
|
||||
await mount();
|
||||
sortButton('Neu').click();
|
||||
await flush();
|
||||
expect(fx.usernamesFor).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('das gewählte Logo außerhalb der Liste (angeheftet) bekommt seinen Namen nach', async () => {
|
||||
fx.getLogo.mockResolvedValue(owned('x', 'u7'));
|
||||
fx.usernamesFor.mockImplementation(async (ids) => new Map(ids.includes('u7') ? [['u7', 'Zora']] : []));
|
||||
await mount();
|
||||
picker.select('x');
|
||||
await flush();
|
||||
expect(by('Logo X')).toBe('von @Zora');
|
||||
expect(infoLine()).toBe('Logo X · von @Zora · ♥ 0');
|
||||
});
|
||||
|
||||
it('ein frisch hochgeladenes Logo zeigt den Namen seines Hochladers (eine eigene Abfrage, wenn er der Liste noch fehlt)', async () => {
|
||||
fx.usernamesFor.mockImplementation(async (ids) => new Map(ids.includes('u5') ? [['u5', 'Kenso']] : []));
|
||||
fx.uploadDialog.mockResolvedValue(logo('n', { name: 'Neues Logo', ownerId: 'u5', displayName: '' }));
|
||||
await mount();
|
||||
expect(fx.usernamesFor).toHaveBeenCalledTimes(1);
|
||||
uploadButton().click();
|
||||
await flush();
|
||||
expect(fx.usernamesFor).toHaveBeenCalledTimes(2);
|
||||
expect(fx.usernamesFor).toHaveBeenLastCalledWith(['u5']);
|
||||
expect(by('Neues Logo')).toBe('von @Kenso');
|
||||
});
|
||||
|
||||
it('eine neue Auswahl oder ein Herz zeichnet die Kachel neu, schreibt die Zeile „von …“ aber nicht neu (derselbe Textknoten)', async () => {
|
||||
fx.usernamesFor.mockResolvedValue(new Map([['u1', 'Kenso']]));
|
||||
await mount();
|
||||
const span = cell('Logo A').querySelector('.logo-tile-by')!;
|
||||
const text = span.firstChild;
|
||||
picker.select('a');
|
||||
picker.select('b');
|
||||
heart('Logo A').click();
|
||||
await flush();
|
||||
expect(cell('Logo A').querySelector('.logo-tile-by')).toBe(span);
|
||||
expect(span.firstChild).toBe(text);
|
||||
expect(by('Logo A')).toBe('von @Kenso');
|
||||
});
|
||||
|
||||
it('eigenes Umbenennen: Kacheln und Infozeile des Kontos zeigen sofort den neuen Namen, andere Einträge bleiben', async () => {
|
||||
fx.listLogos.mockResolvedValue({ logos: [owned('a', 'u1'), owned('b', 'u2', 'Anna'), owned('c', 'u1')], nextCursor: null });
|
||||
fx.usernamesFor.mockResolvedValue(new Map([['u1', 'Alt']]));
|
||||
await mount();
|
||||
picker.select('a');
|
||||
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['von @Alt', 'von Anna', 'von @Alt']);
|
||||
expect(infoLine()).toBe('Logo A · von @Alt · ♥ 0');
|
||||
|
||||
emitName('u1', 'Neu');
|
||||
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['von @Neu', 'von Anna', 'von @Neu']);
|
||||
expect(infoLine()).toBe('Logo A · von @Neu · ♥ 0');
|
||||
});
|
||||
|
||||
it('der Name eines Kontos, das noch kein Profil hatte (Konto ohne Namen wählt einen), ersetzt den Ersatz sofort', async () => {
|
||||
await mount();
|
||||
expect(by('Logo A')).toBe('von Anonym');
|
||||
emitName('u1', 'Kenso');
|
||||
expect(by('Logo A')).toBe('von @Kenso');
|
||||
expect(by('Logo B')).toBe('von Anna');
|
||||
});
|
||||
|
||||
it('dieselbe Meldung noch einmal (auch das Lesen des eigenen Profils meldet) ändert nichts: die Kachel wird nicht neu beschrieben', async () => {
|
||||
fx.usernamesFor.mockResolvedValue(new Map([['u1', 'Kenso']]));
|
||||
await mount();
|
||||
const span = cell('Logo A').querySelector('.logo-tile-by')!;
|
||||
const text = span.firstChild;
|
||||
emitName('u1', 'Kenso');
|
||||
emitName('u1', 'Kenso');
|
||||
expect(cell('Logo A').querySelector('.logo-tile-by')).toBe(span);
|
||||
expect(span.firstChild).toBe(text);
|
||||
expect(by('Logo A')).toBe('von @Kenso');
|
||||
});
|
||||
|
||||
it('eine Umbenennung vor dem Eintreffen einer älteren Antwort der Abfrage bleibt bestehen', async () => {
|
||||
const lookup = deferred<Map<string, string>>();
|
||||
fx.usernamesFor.mockReturnValue(lookup.promise);
|
||||
await mount();
|
||||
emitName('u1', 'Neu');
|
||||
lookup.resolve(new Map([['u1', 'Neu']])); // `usernamesFor` selbst liefert nie Älteres als das Umbenannte
|
||||
await flush();
|
||||
expect(by('Logo A')).toBe('von @Neu');
|
||||
});
|
||||
|
||||
it('aufgeräumt: der Hörer ist abgemeldet, eine später eintreffende Antwort ändert nichts und wirft nichts', async () => {
|
||||
const lookup = deferred<Map<string, string>>();
|
||||
fx.usernamesFor.mockReturnValue(lookup.promise);
|
||||
await mount();
|
||||
expect(nameListeners.size).toBe(1);
|
||||
picker.dispose();
|
||||
expect(nameListeners.size).toBe(0);
|
||||
lookup.resolve(new Map([['u1', 'Kenso']]));
|
||||
await flush();
|
||||
expect(by('Logo A')).toBe(''); // die Antwort kam nach dem Aufräumen: nichts ändert sich
|
||||
emitName('u1', 'Neu');
|
||||
expect(by('Logo A')).toBe('');
|
||||
});
|
||||
|
||||
it('Englisch: „by @name“, „by Anna“, „by Anonymous“', async () => {
|
||||
setLang('en', { persist: false });
|
||||
fx.usernamesFor.mockResolvedValue(new Map([['u1', 'Kenso']]));
|
||||
await mount();
|
||||
expect(['Logo A', 'Logo B', 'Logo C'].map(by)).toEqual(['by @Kenso', 'by Anna', 'by Anonymous']);
|
||||
picker.select('a');
|
||||
expect(infoLine()).toBe('Logo A · by @Kenso · ♥ 0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sortierung', () => {
|
||||
it('„Neu“ lädt mit sort:new neu, der Umschalter folgt, die Kacheln der alten Liste fallen weg', async () => {
|
||||
await mount();
|
||||
@@ -461,6 +851,135 @@ describe('Mehr laden', () => {
|
||||
expect(retryButton().hidden).toBe(true);
|
||||
});
|
||||
|
||||
describe('Fokus: ein Knopf, der unter dem Fokus verschwindet, lässt ihn nicht verloren gehen', () => {
|
||||
const heading = () => picker.el.querySelector<HTMLElement>('h3')!;
|
||||
|
||||
it('„Mehr laden“ lädt die letzte Seite und verschwindet: der Fokus geht auf die erste neue Kachel', async () => {
|
||||
fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b')], nextCursor: 'b' });
|
||||
await mount();
|
||||
moreButton().focus();
|
||||
expect(document.activeElement).toBe(moreButton());
|
||||
fx.listLogos.mockResolvedValue(page2());
|
||||
moreButton().click();
|
||||
await flush();
|
||||
expect(moreButton().hidden).toBe(true);
|
||||
expect(document.activeElement).toBe(tile('Logo D'));
|
||||
});
|
||||
|
||||
it('„Mehr laden“ bleibt (es gibt noch eine Seite): es bleibt auch gewählt, während es lädt und danach', async () => {
|
||||
fx.listLogos.mockResolvedValue({ logos: [logo('a')], nextCursor: 'a' });
|
||||
await mount();
|
||||
moreButton().focus();
|
||||
const slow = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>();
|
||||
fx.listLogos.mockReturnValue(slow.promise);
|
||||
moreButton().click();
|
||||
// Ein gesperrter Knopf verliert den Fokus: solange er ihn hat, bleibt er bedienbar, ein zweiter Druck ist wirkungslos.
|
||||
expect(moreButton().disabled).toBe(false);
|
||||
expect(document.activeElement).toBe(moreButton());
|
||||
moreButton().click();
|
||||
expect(fx.listLogos).toHaveBeenCalledTimes(2);
|
||||
slow.resolve({ logos: [logo('d')], nextCursor: 'd' });
|
||||
await flush();
|
||||
expect(moreButton().hidden).toBe(false);
|
||||
expect(moreButton().disabled).toBe(false);
|
||||
expect(document.activeElement).toBe(moreButton());
|
||||
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo D']);
|
||||
});
|
||||
|
||||
it('hat der Knopf den Fokus nicht, bleibt er während des Ladens gesperrt', async () => {
|
||||
fx.listLogos.mockResolvedValue({ logos: [logo('a')], nextCursor: 'a' });
|
||||
await mount();
|
||||
fx.listLogos.mockReturnValue(new Promise(() => {}));
|
||||
moreButton().click(); // ein Mausklick im Test: der Knopf bekommt den Fokus dabei nicht
|
||||
expect(document.activeElement).not.toBe(moreButton());
|
||||
expect(moreButton().disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('scheitert das Nachladen unter dem Fokus, geht er auf „Nochmal versuchen“, das den Platz von „Mehr laden“ einnimmt', async () => {
|
||||
fx.listLogos.mockResolvedValue({ logos: [logo('a')], nextCursor: 'a' });
|
||||
await mount();
|
||||
moreButton().focus();
|
||||
fx.listLogos.mockRejectedValueOnce(new Error('offline'));
|
||||
moreButton().click();
|
||||
await flush();
|
||||
expect(moreButton().hidden).toBe(true);
|
||||
expect(retryButton().hidden).toBe(false);
|
||||
expect(document.activeElement).toBe(retryButton());
|
||||
});
|
||||
|
||||
it('„Nochmal versuchen” verschwindet beim Klick: der Fokus geht sofort auf die Überschrift, nicht ins Leere', async () => {
|
||||
fx.listLogos.mockRejectedValueOnce(new Error('offline'));
|
||||
await mount();
|
||||
expect(retryButton().hidden).toBe(false);
|
||||
retryButton().focus();
|
||||
expect(document.activeElement).toBe(retryButton());
|
||||
const slow = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>();
|
||||
fx.listLogos.mockReturnValue(slow.promise);
|
||||
const focusSpy = vi.spyOn(heading(), 'focus');
|
||||
retryButton().click();
|
||||
expect(retryButton().hidden).toBe(true);
|
||||
expect(document.activeElement).toBe(heading());
|
||||
expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true });
|
||||
slow.resolve({ logos: [logo('a')], nextCursor: null });
|
||||
await flush();
|
||||
expect(names()).toEqual(['Kein Logo', 'Logo A']);
|
||||
expect(document.activeElement).toBe(heading()); // niemand hat den Fokus wegbewegt: er bleibt
|
||||
});
|
||||
|
||||
it('„Nochmal versuchen“ nach einem gescheiterten Nachladen: ebenso', async () => {
|
||||
fx.listLogos.mockResolvedValue({ logos: [logo('a')], nextCursor: 'a' });
|
||||
await mount();
|
||||
fx.listLogos.mockRejectedValueOnce(new Error('offline'));
|
||||
moreButton().click();
|
||||
await flush();
|
||||
expect(retryButton().hidden).toBe(false);
|
||||
retryButton().focus();
|
||||
fx.listLogos.mockResolvedValue(page2());
|
||||
retryButton().click();
|
||||
await flush();
|
||||
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo D', 'Logo E']);
|
||||
expect(picker.el.contains(document.activeElement)).toBe(true);
|
||||
expect(document.activeElement).not.toBe(document.body);
|
||||
expect(document.activeElement).not.toBe(retryButton());
|
||||
});
|
||||
|
||||
it('der Fokus wird nicht weggenommen, wenn er nicht auf dem verschwindenden Knopf lag', async () => {
|
||||
fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b')], nextCursor: 'b' });
|
||||
await mount();
|
||||
tile('Logo A').focus();
|
||||
fx.listLogos.mockResolvedValue(page2());
|
||||
moreButton().click();
|
||||
await flush();
|
||||
expect(moreButton().hidden).toBe(true);
|
||||
expect(document.activeElement).toBe(tile('Logo A'));
|
||||
});
|
||||
|
||||
it('lädt die letzte Seite nur Logos, die schon da waren, geht der Fokus auf die Überschrift', async () => {
|
||||
fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b')], nextCursor: 'b' });
|
||||
await mount();
|
||||
moreButton().focus();
|
||||
fx.listLogos.mockResolvedValue({ logos: [logo('b')], nextCursor: null });
|
||||
moreButton().click();
|
||||
await flush();
|
||||
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B']);
|
||||
expect(document.activeElement).toBe(heading());
|
||||
});
|
||||
|
||||
it('nach dem Aufräumen wird nichts mehr fokussiert', async () => {
|
||||
fx.listLogos.mockResolvedValue({ logos: [logo('a')], nextCursor: 'a' });
|
||||
await mount();
|
||||
moreButton().focus();
|
||||
const slow = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>();
|
||||
fx.listLogos.mockReturnValue(slow.promise);
|
||||
moreButton().click();
|
||||
picker.dispose();
|
||||
document.body.focus();
|
||||
slow.resolve(page2());
|
||||
await flush();
|
||||
expect(document.activeElement).toBe(document.body);
|
||||
});
|
||||
});
|
||||
|
||||
describe('der Cursor ist weg (das Logo dahinter wurde von jemandem gelöscht oder ausgeblendet)', () => {
|
||||
/** Appwrite: 400 `general_cursor_not_found`. */
|
||||
const cursorGone = () => Object.assign(new Error('The current cursor could not be found.'), { code: 400, type: 'general_cursor_not_found' });
|
||||
@@ -641,7 +1160,7 @@ describe('Auswahl', () => {
|
||||
it('solange die Liste noch lädt, wird nicht nachgefragt: steht das Logo auf der ersten Seite, gibt es kein getLogo und keinen Sprung', async () => {
|
||||
const list = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>();
|
||||
fx.listLogos.mockReturnValue(list.promise);
|
||||
picker = createLogoPicker({ onSelect: fx.onSelect }, deps);
|
||||
picker = createLogoPicker({ onSelect: fx.onSelect, onLogoChanged: fx.onLogoChanged }, deps);
|
||||
host.append(picker.el);
|
||||
picker.select('b');
|
||||
await flush();
|
||||
@@ -659,7 +1178,7 @@ describe('Auswahl', () => {
|
||||
fx.getLogo.mockResolvedValue(logo('x'));
|
||||
const list = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>();
|
||||
fx.listLogos.mockReturnValue(list.promise);
|
||||
picker = createLogoPicker({ onSelect: fx.onSelect }, deps);
|
||||
picker = createLogoPicker({ onSelect: fx.onSelect, onLogoChanged: fx.onLogoChanged }, deps);
|
||||
host.append(picker.el);
|
||||
picker.select('x');
|
||||
await flush();
|
||||
@@ -673,7 +1192,7 @@ describe('Auswahl', () => {
|
||||
it('scheitert das Laden der Liste, wird das gewählte Logo trotzdem geholt und angeheftet', async () => {
|
||||
fx.getLogo.mockResolvedValue(logo('x'));
|
||||
fx.listLogos.mockRejectedValue(new Error('offline'));
|
||||
picker = createLogoPicker({ onSelect: fx.onSelect }, deps);
|
||||
picker = createLogoPicker({ onSelect: fx.onSelect, onLogoChanged: fx.onLogoChanged }, deps);
|
||||
host.append(picker.el);
|
||||
picker.select('x');
|
||||
await flush();
|
||||
@@ -981,7 +1500,7 @@ describe('Leer und Ladefehler', () => {
|
||||
it('leere Liste: „Noch keine Community-Logos – lade das erste hoch.“ (erst nach dem Laden; „Kein Logo“ bleibt)', async () => {
|
||||
const list = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>();
|
||||
fx.listLogos.mockReturnValue(list.promise);
|
||||
picker = createLogoPicker({ onSelect: fx.onSelect }, deps);
|
||||
picker = createLogoPicker({ onSelect: fx.onSelect, onLogoChanged: fx.onLogoChanged }, deps);
|
||||
host.append(picker.el);
|
||||
await flush();
|
||||
expect(emptyText()).toBe('');
|
||||
@@ -1075,6 +1594,97 @@ describe('Hochladen', () => {
|
||||
expect(picker.selected()).toEqual(fresh());
|
||||
});
|
||||
|
||||
it('vor dem Dialog wird der Benutzername verlangt (Spec § 5); das Signal der Seite geht mit, damit das Verlassen der Seite den Namensdialog schließt', async () => {
|
||||
const order: string[] = [];
|
||||
fx.requireUsername.mockImplementation(async () => {
|
||||
order.push('name');
|
||||
return true;
|
||||
});
|
||||
fx.uploadDialog.mockImplementation(async () => {
|
||||
order.push('dialog');
|
||||
return null;
|
||||
});
|
||||
await mount();
|
||||
uploadButton().click();
|
||||
await flush();
|
||||
expect(order).toEqual(['name', 'dialog']);
|
||||
const signal = fx.requireUsername.mock.calls[0][0];
|
||||
expect(signal).toBe(fx.uploadDialog.mock.calls[0][0]);
|
||||
picker.dispose();
|
||||
expect(signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('Gast: erst die Anmeldung, dann der Name, dann der Dialog', async () => {
|
||||
user = null;
|
||||
const order: string[] = [];
|
||||
fx.requireLogin.mockImplementation(async () => (order.push('login'), true));
|
||||
fx.requireUsername.mockImplementation(async () => (order.push('name'), true));
|
||||
fx.uploadDialog.mockImplementation(async () => (order.push('dialog'), null));
|
||||
await mount();
|
||||
uploadButton().click();
|
||||
await flush();
|
||||
expect(order).toEqual(['login', 'name', 'dialog']);
|
||||
});
|
||||
|
||||
it('der Nutzer bricht den Namensdialog ab (false): der Upload-Dialog öffnet nicht, nichts ändert sich, kein Fehlertext', async () => {
|
||||
fx.requireUsername.mockResolvedValue(false);
|
||||
await mount();
|
||||
uploadButton().click();
|
||||
await flush();
|
||||
expect(fx.requireUsername).toHaveBeenCalledTimes(1);
|
||||
expect(fx.uploadDialog).not.toHaveBeenCalled();
|
||||
expect(fx.onSelect).not.toHaveBeenCalled();
|
||||
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo C']);
|
||||
expect(alertText()).toBe('');
|
||||
expect(infoMessage()).toBe('');
|
||||
// der Knopf geht danach wieder, und ein neuer Versuch fragt noch einmal
|
||||
fx.requireUsername.mockResolvedValue(true);
|
||||
uploadButton().click();
|
||||
await flush();
|
||||
expect(fx.requireUsername).toHaveBeenCalledTimes(2);
|
||||
expect(fx.uploadDialog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Gast bricht die Anmeldung ab: der Name wird gar nicht erst verlangt', async () => {
|
||||
user = null;
|
||||
fx.requireLogin.mockResolvedValue(false);
|
||||
await mount();
|
||||
uploadButton().click();
|
||||
await flush();
|
||||
expect(fx.requireUsername).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lässt sich der Namensdialog nicht öffnen (Fehler), steht der Grund in der Infozeile und es öffnet kein Upload-Dialog', async () => {
|
||||
fx.requireUsername.mockRejectedValue(new Error('kein <dialog>'));
|
||||
await mount();
|
||||
uploadButton().click();
|
||||
await flush();
|
||||
expect(infoMessage()).toBe('Hochladen fehlgeschlagen – Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
|
||||
expect(fx.uploadDialog).not.toHaveBeenCalled();
|
||||
expect(alertText()).toBe('');
|
||||
});
|
||||
|
||||
it('wird die Seite verlassen, während der Namensdialog offen ist, öffnet danach kein Upload-Dialog mehr', async () => {
|
||||
const asking = deferred<boolean>();
|
||||
fx.requireUsername.mockReturnValue(asking.promise);
|
||||
await mount();
|
||||
uploadButton().click();
|
||||
await flush();
|
||||
picker.dispose();
|
||||
asking.resolve(true);
|
||||
await flush();
|
||||
expect(fx.uploadDialog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ein zweiter Klick, solange der Namensdialog offen ist, öffnet keinen zweiten', async () => {
|
||||
fx.requireUsername.mockReturnValue(new Promise(() => {}));
|
||||
await mount();
|
||||
uploadButton().click();
|
||||
uploadButton().click();
|
||||
await flush();
|
||||
expect(fx.requireUsername).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Gast: zuerst requireLogin, dann der Dialog', async () => {
|
||||
user = null;
|
||||
const order: string[] = [];
|
||||
@@ -1229,7 +1839,7 @@ describe('dispose', () => {
|
||||
it('Antworten, die nach dem Aufräumen eintreffen, ändern nichts und werfen nichts', async () => {
|
||||
const list = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>();
|
||||
fx.listLogos.mockReturnValue(list.promise);
|
||||
picker = createLogoPicker({ onSelect: fx.onSelect }, deps);
|
||||
picker = createLogoPicker({ onSelect: fx.onSelect, onLogoChanged: fx.onLogoChanged }, deps);
|
||||
host.append(picker.el);
|
||||
picker.select('x');
|
||||
picker.dispose();
|
||||
@@ -1364,7 +1974,7 @@ describe('Infozeile', () => {
|
||||
it('solange die Liste noch lädt und das Logo noch unbekannt ist, steht nichts da; danach die Zeile aus der Liste', async () => {
|
||||
const list = deferred<{ logos: CommunityLogo[]; nextCursor: string | null }>();
|
||||
fx.listLogos.mockReturnValue(list.promise);
|
||||
picker = createLogoPicker({ onSelect: fx.onSelect }, deps);
|
||||
picker = createLogoPicker({ onSelect: fx.onSelect, onLogoChanged: fx.onLogoChanged }, deps);
|
||||
host.append(picker.el);
|
||||
picker.select('b');
|
||||
await flush();
|
||||
@@ -1606,6 +2216,58 @@ describe('Löschen (mit Raster und Auswahl)', () => {
|
||||
expect(names()).toEqual(['Kein Logo', 'Logo A', 'Logo B', 'Logo D']);
|
||||
});
|
||||
|
||||
it('war das gelöschte Logo der Cursor und steht keins davor, beginnt die Liste von vorn (Seite 1 der Ansicht), statt „Mehr laden“ zu verlieren', async () => {
|
||||
fx.listLogos.mockResolvedValue({ logos: [logo('a', { ownerId: 'u1' })], nextCursor: 'a' });
|
||||
await mount();
|
||||
picker.select('a');
|
||||
fx.listLogos.mockClear();
|
||||
fx.listLogos.mockResolvedValue({ logos: [logo('x'), logo('y')], nextCursor: 'y' });
|
||||
infoButton('Löschen').click();
|
||||
await flush();
|
||||
expect(fx.listLogos).toHaveBeenCalledExactlyOnceWith({ sort: 'popular', filter: 'all' }); // Seite 1, ohne Cursor
|
||||
expect(names()).toEqual(['Kein Logo', 'Logo X', 'Logo Y']);
|
||||
expect(moreButton().hidden).toBe(false); // es geht mit dem neuen Cursor weiter
|
||||
fx.listLogos.mockResolvedValue({ logos: [logo('z')], nextCursor: null });
|
||||
moreButton().click();
|
||||
await flush();
|
||||
expect(fx.listLogos).toHaveBeenLastCalledWith({ sort: 'popular', filter: 'all', cursor: 'y' });
|
||||
});
|
||||
|
||||
it('dasselbe in der Admin-Ansicht „Gemeldet“: neu geladen wird mit derselben Sortierung und demselben Filter', async () => {
|
||||
admin = true;
|
||||
await mount();
|
||||
fx.listLogos.mockResolvedValue({ logos: [logo('a', { reportCount: 2 })], nextCursor: 'a' });
|
||||
sortButton('Gemeldet').click();
|
||||
await flush();
|
||||
picker.select('a');
|
||||
fx.listLogos.mockClear();
|
||||
fx.listLogos.mockResolvedValue({ logos: [logo('x', { reportCount: 1 })], nextCursor: null });
|
||||
infoButton('Löschen').click();
|
||||
await flush();
|
||||
expect(fx.listLogos).toHaveBeenCalledExactlyOnceWith({ sort: 'new', filter: 'reported' });
|
||||
expect(names()).toEqual(['Kein Logo', 'Logo X']);
|
||||
expect(pressedSort()).toEqual(['Gemeldet']);
|
||||
});
|
||||
|
||||
it('war das gelöschte Logo nicht der Cursor, oder war keine weitere Seite da, wird nichts neu geladen', async () => {
|
||||
fx.listLogos.mockResolvedValue({ logos: [logo('a', { ownerId: 'u1' }), logo('b')], nextCursor: 'b' });
|
||||
await mount();
|
||||
picker.select('a');
|
||||
infoButton('Löschen').click();
|
||||
await flush();
|
||||
expect(fx.listLogos).toHaveBeenCalledTimes(1);
|
||||
picker.dispose();
|
||||
|
||||
fx.listLogos.mockClear();
|
||||
fx.listLogos.mockResolvedValue({ logos: [logo('c', { ownerId: 'u1' })], nextCursor: null });
|
||||
await mount();
|
||||
picker.select('c');
|
||||
infoButton('Löschen').click();
|
||||
await flush();
|
||||
expect(fx.listLogos).toHaveBeenCalledTimes(1);
|
||||
expect(moreButton().hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('ein anderes gelöschtes Logo lässt den Cursor für „Mehr laden“ stehen', async () => {
|
||||
fx.listLogos.mockResolvedValue({ logos: [logo('a'), logo('b', { ownerId: 'u1' }), logo('c')], nextCursor: 'c' });
|
||||
await mount();
|
||||
@@ -1714,3 +2376,99 @@ describe('Ausblenden und Einblenden (Admin, mit Raster und Auswahl)', () => {
|
||||
expect(picker.selected()).toMatchObject({ id: 'b', hidden: true, likeCount: 9 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Der Editor erfährt, dass sich ein Logo geändert hat (onLogoChanged)', () => {
|
||||
beforeEach(() => {
|
||||
admin = true;
|
||||
fx.listLogos.mockResolvedValue({
|
||||
logos: [logo('a'), logo('b', { ownerId: 'u1' }), logo('c', { hidden: true, reportCount: 3 })],
|
||||
nextCursor: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('„Ausblenden“ des gewählten Logos: onLogoChanged(id), erst nachdem die Zeile des Pickers hidden ist', async () => {
|
||||
let hiddenWhenTold: boolean | undefined;
|
||||
fx.onLogoChanged.mockImplementation(() => {
|
||||
hiddenWhenTold = picker.selected()?.hidden;
|
||||
});
|
||||
await mount();
|
||||
picker.select('a');
|
||||
infoButton('Ausblenden').click();
|
||||
await flush();
|
||||
expect(fx.onLogoChanged).toHaveBeenCalledExactlyOnceWith('a');
|
||||
expect(hiddenWhenTold).toBe(true);
|
||||
expect(mocks.forgetLogo).toHaveBeenCalledWith('a'); // der Zwischenspeicher ist vorher geleert
|
||||
});
|
||||
|
||||
it('„Einblenden“: ebenso, die Zeile ist dann sichtbar', async () => {
|
||||
let hiddenWhenTold: boolean | undefined;
|
||||
fx.onLogoChanged.mockImplementation(() => {
|
||||
hiddenWhenTold = picker.selected()?.hidden;
|
||||
});
|
||||
await mount();
|
||||
picker.select('c');
|
||||
infoButton('Einblenden').click();
|
||||
await flush();
|
||||
expect(fx.onLogoChanged).toHaveBeenCalledExactlyOnceWith('c');
|
||||
expect(hiddenWhenTold).toBe(false);
|
||||
});
|
||||
|
||||
it('„Löschen“ des gewählten Logos: onLogoChanged(id) nach onSelect(null), der Editor hat die Auswahl dann schon aufgehoben', async () => {
|
||||
const order: string[] = [];
|
||||
fx.onSelect.mockImplementation((id) => void order.push(`select:${id}`));
|
||||
fx.onLogoChanged.mockImplementation((id) => void order.push(`changed:${id}`));
|
||||
await mount();
|
||||
picker.select('b');
|
||||
infoButton('Löschen').click();
|
||||
await flush();
|
||||
expect(order).toEqual(['select:null', 'changed:b']);
|
||||
});
|
||||
|
||||
it('„Löschen“ eines Logos, das nicht gewählt war (die Auswahl wechselte währenddessen): onLogoChanged meldet das gelöschte Logo, onSelect schweigt', async () => {
|
||||
const request = deferred<void>();
|
||||
fx.deleteLogo.mockReturnValue(request.promise);
|
||||
await mount();
|
||||
picker.select('b');
|
||||
infoButton('Löschen').click();
|
||||
await flush();
|
||||
picker.select('a');
|
||||
request.resolve();
|
||||
await flush();
|
||||
expect(fx.onLogoChanged).toHaveBeenCalledExactlyOnceWith('b');
|
||||
expect(fx.onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('scheitert die Aktion, meldet der Picker nichts', async () => {
|
||||
fx.setLogoVisibility.mockRejectedValue(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable'));
|
||||
fx.deleteLogo.mockRejectedValue(new ApiError(403, 'Dazu hast du keine Berechtigung.', true, 'forbidden'));
|
||||
await mount();
|
||||
picker.select('b');
|
||||
infoButton('Ausblenden').click();
|
||||
await flush();
|
||||
infoButton('Löschen').click();
|
||||
await flush();
|
||||
expect(fx.onLogoChanged).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Herz, Melden und Auswahl melden nichts', async () => {
|
||||
await mount();
|
||||
picker.select('a');
|
||||
heart('Logo A').click();
|
||||
infoButton('Melden').click();
|
||||
await flush();
|
||||
expect(fx.onLogoChanged).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('nach dem Aufräumen der Seite meldet der Picker nichts mehr', async () => {
|
||||
const request = deferred<void>();
|
||||
fx.setLogoVisibility.mockReturnValue(request.promise);
|
||||
await mount();
|
||||
picker.select('a');
|
||||
infoButton('Ausblenden').click();
|
||||
await flush();
|
||||
picker.dispose();
|
||||
request.resolve();
|
||||
await flush();
|
||||
expect(fx.onLogoChanged).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+131
-10
@@ -3,10 +3,17 @@
|
||||
// über `deps` (Standard: logos.ts, logo-api.ts, Anmeldung, Dialoge); alle Texte gehen als Text in den DOM, nie als HTML. Die Auswahl
|
||||
// gehört dem Editor (`config.logo`): ein Klick meldet nur `onSelect`, markiert wird über `select()`. Fehler von Herz und Hochladen
|
||||
// zeigt die Infozeile; der Statusbereich des Pickers ist den Ladefehlern der Liste vorbehalten.
|
||||
// „von @name“ (Spec § 5): Kacheln und Infozeile zeigen den Benutzernamen des Hochladers. Die Namen kommen nach dem Laden einer Seite in
|
||||
// einer Abfrage für alle ihre Hochlader nach. Solange der Name eines Hochladers noch gesucht wird, ist seine Zeile „von …“ leer (ein
|
||||
// Ersatz, der gleich wieder wechselt, läse sich falsch; die Kachel behält ihre Zeilenhöhe, siehe logos.css); danach steht der Name,
|
||||
// ohne Profil oder bei einem Fehler der alte Anzeigename bzw. „Anonym“. Schon bekannte Hochlader zeigen ihre Zeile sofort. Ein eigenes
|
||||
// Umbenennen aktualisiert die eigenen Einträge sofort. Hochladen verlangt vorher einen Benutzernamen.
|
||||
import '../account/account.css';
|
||||
import './logos.css';
|
||||
import { currentUser, currentUserId, isAdmin, onAuthChange } from '../account/auth';
|
||||
import { requireLogin } from '../account/login-dialog';
|
||||
import { byline, onUsernameChange, usernamesFor } from '../account/profile';
|
||||
import { requireUsername } from '../account/username-dialog';
|
||||
import { reportDialog } from '../community/report-dialog';
|
||||
import { confirmDialog } from '../editor/confirm-dialog';
|
||||
import { el, icon } from '../editor/dom';
|
||||
@@ -29,6 +36,12 @@ export interface LogoPickerDeps extends LogoInfoDeps {
|
||||
onAuthChange(fn: () => void): () => void;
|
||||
/** Öffnet den Dialog „Logo hochladen“; `signal` schließt ihn (Seite verlassen). Das neue Logo, null bei Abbruch. */
|
||||
uploadDialog(signal: AbortSignal): Promise<CommunityLogo | null>;
|
||||
/** Die Benutzernamen der Konten, eine Abfrage für alle; scheitert sie, fehlen die Namen (kein Fehler). */
|
||||
usernamesFor(userIds: readonly string[]): Promise<Map<string, string>>;
|
||||
/** Meldet, wenn der eigene Benutzername feststeht oder sich ändert; gibt die Abmeldung zurück. */
|
||||
onUsernameChange(fn: (userId: string, username: string) => void): () => void;
|
||||
/** Verlangt für das Hochladen einen Benutzernamen und fragt danach, wenn das Konto keinen hat; false: abgebrochen. `signal` schließt die Frage (Seite verlassen). */
|
||||
requireUsername(signal: AbortSignal): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface LogoPicker {
|
||||
@@ -72,6 +85,8 @@ interface Tile {
|
||||
logo: CommunityLogo;
|
||||
cell: HTMLLIElement;
|
||||
select: HTMLButtonElement;
|
||||
/** Die Zeile „von @name“. */
|
||||
by: HTMLElement;
|
||||
heart: HTMLButtonElement;
|
||||
count: HTMLElement;
|
||||
/** Gibt das Bild frei (Object-URL); eine später eintreffende Datei wird dann nicht mehr angezeigt. */
|
||||
@@ -90,6 +105,9 @@ function defaultDeps(): LogoPickerDeps {
|
||||
requireLogin: () => requireLogin('login'),
|
||||
onAuthChange,
|
||||
uploadDialog: (signal) => logoUploadDialog({ signal }),
|
||||
usernamesFor,
|
||||
onUsernameChange,
|
||||
requireUsername: (signal) => requireUsername(signal),
|
||||
reportLogo,
|
||||
deleteLogo,
|
||||
setLogoVisibility,
|
||||
@@ -99,7 +117,18 @@ function defaultDeps(): LogoPickerDeps {
|
||||
};
|
||||
}
|
||||
|
||||
export function createLogoPicker(handlers: { onSelect(id: string | null): void }, deps: LogoPickerDeps = defaultDeps()): LogoPicker {
|
||||
/** Was der Picker dem Editor meldet. */
|
||||
export interface LogoPickerHandlers {
|
||||
/** Ein Klick auf eine Kachel („Kein Logo“: null). Die Auswahl selbst gehört dem Editor. */
|
||||
onSelect(id: string | null): void;
|
||||
/**
|
||||
* Das Logo `id` wurde ausgeblendet, eingeblendet oder gelöscht (Infozeile): seine Zeile und sein Bild haben sich geändert. Hat der
|
||||
* Editor es gerade gezeichnet, muss er es neu laden; der Zwischenspeicher der Logos ist dann schon geleert (forgetLogo).
|
||||
*/
|
||||
onLogoChanged(id: string): void;
|
||||
}
|
||||
|
||||
export function createLogoPicker(handlers: LogoPickerHandlers, deps: LogoPickerDeps = defaultDeps()): LogoPicker {
|
||||
const abort = new AbortController();
|
||||
let disposed = false;
|
||||
|
||||
@@ -109,6 +138,8 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
||||
let order: string[] = [];
|
||||
let nextCursor: string | null = null;
|
||||
let loading = false;
|
||||
/** Die erste Kachel, die der jüngste Ladevorgang angehängt hat: dorthin geht der Fokus, wenn „Mehr laden“ danach verschwindet. */
|
||||
let firstNew: HTMLElement | null = null;
|
||||
/** Die erste Seite der aktuellen Ansicht ist da. */
|
||||
let loaded = false;
|
||||
/** Welcher Ladevorgang zuletzt scheiterte (und mit „Nochmal versuchen“ wiederholt wird). */
|
||||
@@ -120,6 +151,15 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
||||
/** Jede bekannte Zeile (Liste, angehefteter Eintrag, Upload), je ID die jüngste. */
|
||||
const known = new Map<string, CommunityLogo>();
|
||||
const tiles = new Map<string, Tile>();
|
||||
/** Die Benutzernamen der Hochlader (Konto-ID → Name), soweit bekannt; wer fehlt, hat kein Profil oder wurde noch nicht gefragt. */
|
||||
const ownerNames = new Map<string, string>();
|
||||
/** Die Hochlader, deren Name nachgeschlagen ist (gefunden oder nicht, auch bei einem Fehler): ihre Zeile „von …“ steht fest. */
|
||||
const settledOwners = new Set<string>();
|
||||
/** Wird der Name dieses Hochladers noch gesucht? Wer schon einen Namen hat oder nachgeschlagen ist, nicht; Einträge ohne Konto-ID haben nichts zu suchen. */
|
||||
const ownerPending = (ownerId: string): boolean => ownerId !== '' && !ownerNames.has(ownerId) && !settledOwners.has(ownerId);
|
||||
/** Die Zeile „von …“ eines Logos; leer, solange der Name seines Hochladers noch gesucht wird. */
|
||||
const bylineOf = (logo: CommunityLogo): string =>
|
||||
ownerPending(logo.ownerId) ? '' : byline(logo.ownerId, logo.displayName, ownerNames);
|
||||
let selectedId: string | null = null;
|
||||
/** Das gewählte Logo, wenn es nicht in der Liste steht: zweite Kachel direkt nach „Kein Logo“. */
|
||||
let pinnedId: string | null = null;
|
||||
@@ -149,9 +189,11 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
||||
selectedId: () => selectedId,
|
||||
selected: selectedRow,
|
||||
gone: () => selectedId !== null && goneId === selectedId,
|
||||
byline: bylineOf,
|
||||
patchRow(id, patch) {
|
||||
const row = known.get(id);
|
||||
if (row) setRow({ ...row, ...patch });
|
||||
handlers.onLogoChanged(id); // ausgeblendet oder eingeblendet: die Karte im Editor zeigt es sonst weiter wie vorher
|
||||
},
|
||||
removeLogo,
|
||||
},
|
||||
@@ -169,19 +211,22 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
||||
el('span', { class: 'logo-tile-name' }, t('Kein Logo')),
|
||||
);
|
||||
const noneCell = el('li', { class: 'logo-cell' }, noneButton);
|
||||
const grid = el('ul', { class: 'logo-grid', 'aria-labelledby': 'logo-picker-heading' }, noneCell);
|
||||
// role="list": list-style: none nimmt dem <ul> in manchen Screenreadern die Listensemantik. Den Namen trägt die Gruppe (root).
|
||||
const grid = el('ul', { class: 'logo-grid', role: 'list' }, noneCell);
|
||||
const emptyEl = el('p', { class: 'logo-empty' });
|
||||
// Ladefehler der Liste. Immer da (leer nimmt sie keinen Platz ein), damit Screenreader den Text melden, der später hineinkommt.
|
||||
const statusEl = el('p', { class: 'auth-msg auth-msg-error logo-status', role: 'alert' });
|
||||
const retryButton = el('button', { type: 'button', class: 'auth-btn auth-btn-small logo-retry', hidden: true }, t('Nochmal versuchen'));
|
||||
const moreButton = el('button', { type: 'button', class: 'auth-btn auth-btn-small logo-more', hidden: true }, t('Mehr laden'));
|
||||
// tabindex −1: dorthin geht der Fokus, wenn „Mehr laden“ oder „Nochmal versuchen“ unter ihm verschwindet (siehe renderState).
|
||||
const heading = el('h3', { id: 'logo-picker-heading', class: 'logo-picker-title', tabindex: '-1' }, t('Logo unten rechts'));
|
||||
const root = el(
|
||||
'div',
|
||||
{ class: 'logo-picker', role: 'group', 'aria-labelledby': 'logo-picker-heading' },
|
||||
el(
|
||||
'div',
|
||||
{ class: 'logo-picker-head' },
|
||||
el('h3', { id: 'logo-picker-heading', class: 'logo-picker-title' }, t('Logo unten rechts')),
|
||||
heading,
|
||||
el('span', { class: 'logo-badge' }, t('Community')),
|
||||
uploadButton,
|
||||
),
|
||||
@@ -197,9 +242,11 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
||||
|
||||
// ---- Kacheln --------------------------------------------------------------------------------------------------------------
|
||||
|
||||
/** Auswahl, Herz und Zähler einer Kachel nach dem aktuellen Stand. */
|
||||
/** Auswahl, „von @name“, Herz und Zähler einer Kachel nach dem aktuellen Stand. */
|
||||
function renderTile(tile: Tile): void {
|
||||
const { logo } = tile;
|
||||
const by = bylineOf(logo);
|
||||
if (tile.by.textContent !== by) tile.by.textContent = by; // unverändert bleibt der Text stehen (nichts wird neu gezeichnet)
|
||||
tile.select.setAttribute('aria-pressed', String(logo.id === selectedId));
|
||||
tile.heart.setAttribute('aria-pressed', String(hearts.has(logo.id)));
|
||||
tile.heart.setAttribute('aria-label', t('Herz für „{name}“: {count}', { name: logo.name, count: logo.likeCount }));
|
||||
@@ -219,18 +266,19 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
||||
|
||||
function createTile(logo: CommunityLogo): Tile {
|
||||
const art = el('span', { class: 'logo-tile-art' });
|
||||
const select = el(
|
||||
const by = el('span', { class: 'logo-tile-by' }); // den Text setzt renderTile
|
||||
const selectButton = el(
|
||||
'button',
|
||||
{ type: 'button', class: 'tile logo-tile', 'aria-pressed': 'false', title: logo.name },
|
||||
art,
|
||||
el('span', { class: 'logo-tile-name' }, logo.name),
|
||||
el('span', { class: 'logo-tile-by' }, t('von {name}', { name: logo.displayName || t('Anonym') })),
|
||||
by,
|
||||
);
|
||||
const count = el('span', { class: 'logo-heart-count' });
|
||||
const heart = el('button', { type: 'button', class: 'logo-heart', 'aria-pressed': 'false' }, icon('heart'), count);
|
||||
const release = showLogoImage(art, logo, deps.loadLogoBlob); // das PNG auf dem dunklen Kartengrund
|
||||
const tile: Tile = { logo, cell: el('li', { class: 'logo-cell' }, select, heart), select, heart, count, release };
|
||||
select.addEventListener('click', () => handlers.onSelect(logo.id));
|
||||
const tile: Tile = { logo, cell: el('li', { class: 'logo-cell' }, selectButton, heart), select: selectButton, by, heart, count, release };
|
||||
selectButton.addEventListener('click', () => handlers.onSelect(logo.id));
|
||||
heart.addEventListener('click', () => void toggleHeart(logo.id));
|
||||
tiles.set(logo.id, tile);
|
||||
renderTile(tile);
|
||||
@@ -253,7 +301,8 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
||||
known.delete(id);
|
||||
order = order.filter((other) => other !== id);
|
||||
// „Mehr laden“ setzt hinter dem letzten geladenen Logo an (Cursor); die Abfrage braucht ein Logo, das es noch gibt.
|
||||
if (nextCursor === id) nextCursor = order[order.length - 1] ?? null;
|
||||
const wasCursor = nextCursor === id;
|
||||
if (wasCursor) nextCursor = order[order.length - 1] ?? null;
|
||||
hearts.delete(id); // eine später vergebene gleiche ID erbt das Herz nicht
|
||||
if (pinnedId === id) pinnedId = null;
|
||||
renderState(); // aus der letzten Kachel wird der Leer-Text
|
||||
@@ -264,6 +313,10 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
||||
if (focused) noneButton.focus();
|
||||
}
|
||||
info.update();
|
||||
// Der Cursor war das einzige geladene Logo: davor gibt es keins, an dem „Mehr laden“ ansetzen könnte, und die weiteren Seiten dürfen
|
||||
// nicht verloren gehen. Die Ansicht beginnt wieder bei Seite 1.
|
||||
if (wasCursor && nextCursor === null) void load('reset');
|
||||
handlers.onLogoChanged(id); // nach onSelect(null): der Editor zeichnet das Logo dann ohnehin nicht mehr
|
||||
}
|
||||
|
||||
/** Das gewählte Logo steht direkt nach „Kein Logo“, solange es nicht in der geladenen Liste ist (und seine Zeile bekannt ist). */
|
||||
@@ -297,6 +350,7 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
||||
added.push(cellOf(logo));
|
||||
}
|
||||
grid.append(...added);
|
||||
firstNew ??= added[0]?.querySelector<HTMLElement>('.logo-tile') ?? null;
|
||||
syncPin();
|
||||
}
|
||||
|
||||
@@ -312,15 +366,30 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
||||
|
||||
/** Laden, „Mehr laden“, Leer-Text und Fehlerknopf nach dem aktuellen Stand. */
|
||||
function renderState(): void {
|
||||
const focused = document.activeElement;
|
||||
grid.setAttribute('aria-busy', String(loading));
|
||||
moreButton.hidden = nextCursor === null || failed !== null;
|
||||
moreButton.disabled = loading;
|
||||
// Ein gesperrter Knopf verliert den Fokus (Tastatur): solange „Mehr laden“ ihn hat, bleibt es bedienbar; ein zweiter Druck ist
|
||||
// wirkungslos (load).
|
||||
moreButton.disabled = loading && focused !== moreButton;
|
||||
retryButton.hidden = failed === null;
|
||||
let empty = '';
|
||||
if (loaded && order.length === 0) {
|
||||
empty = VIEWS[view].admin ? t('Keine Logos in dieser Ansicht.') : t('Noch keine Community-Logos – lade das erste hoch.');
|
||||
}
|
||||
emptyEl.textContent = empty;
|
||||
// Verschwindet der Knopf, auf dem der Fokus lag, geht er weiter statt auf der Seite verloren zu gehen: an den Knopf, der seinen
|
||||
// Platz einnimmt („Nochmal versuchen“ nach einem Fehler), sonst an die erste neue Kachel, sonst an die Überschrift.
|
||||
if ((focused === moreButton && moreButton.hidden) || (focused === retryButton && retryButton.hidden)) {
|
||||
const successor = [moreButton, retryButton].find((button) => !button.hidden && !button.disabled);
|
||||
if (successor) {
|
||||
successor.focus();
|
||||
} else if (firstNew?.isConnected) {
|
||||
firstNew.focus();
|
||||
} else {
|
||||
heading.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Lädt die erste Seite der Ansicht neu (`reset`) oder die nächste hinten an (`more`). Nur die jüngste Antwort zählt. */
|
||||
@@ -328,6 +397,7 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
||||
if (mode === 'more' && (loading || nextCursor === null)) return;
|
||||
const token = ++loadToken;
|
||||
const cursor = mode === 'more' ? nextCursor : null;
|
||||
firstNew = null;
|
||||
loading = true;
|
||||
failed = null;
|
||||
statusEl.textContent = '';
|
||||
@@ -345,6 +415,7 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
||||
nextCursor = page.nextCursor;
|
||||
addLogos(page.logos);
|
||||
void loadHearts(page.logos.map((logo) => logo.id));
|
||||
void loadOwners(page.logos);
|
||||
} catch (err) {
|
||||
if (disposed || token !== loadToken) return;
|
||||
loading = false;
|
||||
@@ -417,6 +488,7 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
||||
setRow(row);
|
||||
syncPin();
|
||||
void loadHearts([id]);
|
||||
void loadOwners([row]);
|
||||
}
|
||||
|
||||
function select(id: string | null): void {
|
||||
@@ -431,6 +503,47 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
||||
if (loaded || failed !== null) void resolveSelection();
|
||||
}
|
||||
|
||||
// ---- Benutzernamen --------------------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Hält fest, was über Hochlader feststeht: `asked` sind nachgeschlagen (auch ohne Profil oder bei einem Fehler), `found` sind ihre Namen
|
||||
* (auch ein gemeldeter eigener Name). Zeichnet die Zeilen „von …“ der Kacheln (und der Infozeile) neu, die sich dadurch ändern: die
|
||||
* der Hochlader, die gerade fertig wurden oder einen neuen Namen haben.
|
||||
*/
|
||||
function learnOwners(asked: Iterable<string>, found: Iterable<readonly [string, string]>): void {
|
||||
const changed = new Set<string>();
|
||||
for (const userId of asked) {
|
||||
if (settledOwners.has(userId)) continue;
|
||||
settledOwners.add(userId);
|
||||
changed.add(userId);
|
||||
}
|
||||
for (const [userId, name] of found) {
|
||||
if (name === '' || ownerNames.get(userId) === name) continue;
|
||||
ownerNames.set(userId, name);
|
||||
changed.add(userId);
|
||||
}
|
||||
if (changed.size === 0) return;
|
||||
for (const tile of tiles.values()) if (changed.has(tile.logo.ownerId)) renderTile(tile);
|
||||
const selected = selectedRow();
|
||||
if (selected && changed.has(selected.ownerId)) info.update();
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt die Benutzernamen der Hochlader dieser Zeilen, eine Abfrage für alle (jeder nur einmal). Die Anzeige braucht sie nur als
|
||||
* Beiwerk: schlägt die Abfrage fehl, steht danach der Ersatz (alter Anzeigename bzw. „Anonym“), ohne Fehlertext.
|
||||
*/
|
||||
async function loadOwners(rows: readonly CommunityLogo[]): Promise<void> {
|
||||
const ids = [...new Set(rows.map((row) => row.ownerId).filter((id) => id !== ''))];
|
||||
if (ids.length === 0) return;
|
||||
let found = new Map<string, string>();
|
||||
try {
|
||||
found = await deps.usernamesFor(ids);
|
||||
} catch {
|
||||
// nur Beiwerk: es bleibt beim Ersatz
|
||||
}
|
||||
if (!disposed) learnOwners(ids, found);
|
||||
}
|
||||
|
||||
// ---- Herzen ---------------------------------------------------------------------------------------------------------------
|
||||
|
||||
/** Setzt Herz und Zähler eines Logos (optimistisch, Antwort des Dienstes oder Rücksprung). */
|
||||
@@ -511,10 +624,14 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
||||
try {
|
||||
if (deps.userId() === null && !(await deps.requireLogin())) return; // Gast: zuerst anmelden; abgebrochen: es passiert nichts
|
||||
if (disposed) return;
|
||||
// Ohne Benutzernamen geht es nicht weiter (Spec § 5): erst der Namensdialog; „Abbrechen“ heißt, es passiert nichts.
|
||||
if (!(await deps.requireUsername(abort.signal))) return;
|
||||
if (disposed) return;
|
||||
const logo = await deps.uploadDialog(abort.signal);
|
||||
if (!logo || disposed) return;
|
||||
rememberLogo(logo);
|
||||
addFirst(logo);
|
||||
void loadOwners([logo]); // der Hochlader ist meist man selbst: sein Name steht schon im Speicher von usernamesFor (dann ohne Anfrage)
|
||||
handlers.onSelect(logo.id);
|
||||
} catch (err) {
|
||||
if (!disposed) info.setMessage(t('Hochladen fehlgeschlagen – {reason}', { reason: failureText(err) }));
|
||||
@@ -530,6 +647,9 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
||||
moreButton.addEventListener('click', () => void load('more'));
|
||||
retryButton.addEventListener('click', () => void load(failed ?? 'reset'));
|
||||
|
||||
// Das eigene Umbenennen (und das Lesen des eigenen Namens nach dem Start): die Einträge des Kontos zeigen gleich den neuen Namen.
|
||||
const offName = deps.onUsernameChange((userId, username) => learnOwners([], [[userId, username]]));
|
||||
|
||||
// Anmelden oder Abmelden: Admin-Knöpfe, Herzen. Ohne Admin-Rechte gibt es die Admin-Ansichten nicht mehr.
|
||||
const offAuth = deps.onAuthChange(() => {
|
||||
if (!deps.isAdmin() && VIEWS[view].admin) {
|
||||
@@ -559,6 +679,7 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
||||
disposed = true;
|
||||
abort.abort();
|
||||
offAuth();
|
||||
offName();
|
||||
for (const tile of tiles.values()) tile.release();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -295,6 +295,12 @@
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Solange der Name des Hochladers noch gesucht wird, ist die Zeile leer (kein Knoten darin). Ein Zeichen ohne Breite (U+200B) hält die
|
||||
Zeile in der Höhe des Textes, damit die Kachel nicht springt, wenn der Name dazukommt. */
|
||||
.logo-tile-by:empty::before {
|
||||
content: '\200b';
|
||||
}
|
||||
|
||||
/* Name und „von …“ lassen rechts Platz für das Herz (nur Logo-Kacheln: „Kein Logo“ hat keins). */
|
||||
.logo-tile:not(.logo-tile-none) .logo-tile-name,
|
||||
.logo-tile:not(.logo-tile-none) .logo-tile-by {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { AppwriteException, Query, type Models } from 'appwrite';
|
||||
import { ID_PATTERN, fetchFile } from '../account/cards';
|
||||
import { IDS, tables } from '../account/appwrite';
|
||||
import { isRowMissing } from '../account/errors';
|
||||
|
||||
export interface CommunityLogo {
|
||||
id: string;
|
||||
@@ -95,13 +96,6 @@ export async function listLogos(opts: LogoListOptions): Promise<{ logos: Communi
|
||||
return { logos, nextCursor: logos.length === LOGO_PAGE_SIZE ? logos[logos.length - 1].id : null };
|
||||
}
|
||||
|
||||
/** „Die Zeile gibt es nicht (oder nicht für mich)“; 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 === '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Logo per ID. Gibt es das Logo nicht oder ist es für mich ausgeblendet oder gelöscht (Appwrite antwortet beides mit 404),
|
||||
* ist das Ergebnis null. Eine ID, die keine Appwrite-ID sein kann, ergibt null ohne Anfrage.
|
||||
|
||||
@@ -3,8 +3,8 @@ import { setLang } from '../i18n/i18n';
|
||||
import {
|
||||
ALPHA_EMPTY,
|
||||
LOGO_ACCEPT,
|
||||
LOGO_MAX_H,
|
||||
LOGO_MAX_W,
|
||||
LOGO_FILE_MAX_H,
|
||||
LOGO_FILE_MAX_W,
|
||||
LOGO_MIN,
|
||||
LOGO_SOURCE_MAX_BYTES,
|
||||
LOGO_SOURCE_MAX_PIXELS,
|
||||
@@ -48,7 +48,7 @@ describe('LOGO_ACCEPT', () => {
|
||||
|
||||
describe('Konstanten', () => {
|
||||
it('die Werte der Spec', () => {
|
||||
expect([LOGO_MAX_W, LOGO_MAX_H, LOGO_MIN, ALPHA_EMPTY, SVG_RASTER_MAX]).toEqual([720, 250, 8, 8, 2048]);
|
||||
expect([LOGO_FILE_MAX_W, LOGO_FILE_MAX_H, LOGO_MIN, ALPHA_EMPTY, SVG_RASTER_MAX]).toEqual([720, 250, 8, 8, 2048]);
|
||||
expect([LOGO_SOURCE_MAX_BYTES, LOGO_SOURCE_MAX_PIXELS]).toEqual([5_242_880, 40_000_000]);
|
||||
});
|
||||
});
|
||||
@@ -407,6 +407,116 @@ describe('sizeSvg', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('das echte Wurzelelement: Prolog (Deklaration, Anweisungen, Kommentare, DOCTYPE) wird übersprungen, ein „<svg“ darin zählt nicht', () => {
|
||||
const ROOT_BEFORE = '<svg width="200" height="60">';
|
||||
const ROOT_AFTER = '<svg width="2048" height="614" viewBox="0 0 200 60">';
|
||||
const converted = (head: string) => ({ svg: `${head}${ROOT_AFTER}${BODY}`, w: 2048, h: 614 });
|
||||
|
||||
it('ein auskommentiertes <svg …> vor dem Wurzelelement wird nicht umgeschrieben', () => {
|
||||
const head = '<!-- Vorlage: <svg width="9" height="9"> bitte nicht ändern -->';
|
||||
expect(sizeSvg(`${head}${ROOT_BEFORE}${BODY}`)).toEqual(converted(head));
|
||||
});
|
||||
|
||||
it('ein <svg in einer Verarbeitungsanweisung zählt nicht', () => {
|
||||
const head = '<?xml version="1.0"?><?editor <svg width="9" height="9"> ?>';
|
||||
expect(sizeSvg(`${head}${ROOT_BEFORE}${BODY}`)).toEqual(converted(head));
|
||||
});
|
||||
|
||||
it('ein <svg im DOCTYPE (in einer Entität der eigenen DTD, samt „>“ und „]“ in Anführungszeichen) zählt nicht', () => {
|
||||
const head =
|
||||
'<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [\n' +
|
||||
' <!ENTITY ns_svg "http://www.w3.org/2000/svg">\n' +
|
||||
' <!ENTITY stuck "<svg width=\'9\' height=\'9\'>]>">\n' +
|
||||
' <!-- ein Kommentar mit ]> und <svg width="9" height="9"> -->\n' +
|
||||
' <?pi ]> <svg width="9" height="9"> ?>\n' +
|
||||
']>\n';
|
||||
expect(sizeSvg(`${head}${ROOT_BEFORE}${BODY}`)).toEqual(converted(head));
|
||||
});
|
||||
|
||||
it('ein typischer Kopf aus Adobe Illustrator (Deklaration, Kommentar, DOCTYPE mit Entitäten, Namensräume über &ns_svg;) wird übersprungen', () => {
|
||||
const head =
|
||||
'<?xml version="1.0" encoding="utf-8"?>\n' +
|
||||
'<!-- Generator: Adobe Illustrator 24.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->\n' +
|
||||
'<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [\n' +
|
||||
'\t<!ENTITY ns_extend "http://ns.adobe.com/Extensibility/1.0/">\n' +
|
||||
'\t<!ENTITY ns_ai "http://ns.adobe.com/AdobeIllustrator/10.0/">\n' +
|
||||
'\t<!ENTITY ns_custom "http://ns.adobe.com/GenericCustomNamespace/1.0/">\n' +
|
||||
']>\n';
|
||||
const root = '<svg version="1.1" id="Layer_1" xmlns="&ns_svg;" x="0px" y="0px" width="200px" height="60px" viewBox="0 0 200 60">';
|
||||
const result = sizeSvg(`${head}${root}${BODY}`);
|
||||
expect(result.w).toBe(2048);
|
||||
expect(result.h).toBe(614);
|
||||
expect(result.svg).toBe(
|
||||
`${head}<svg width="2048" height="614" version="1.1" id="Layer_1" xmlns="&ns_svg;" x="0px" y="0px" viewBox="0 0 200 60">${BODY}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('Byte-Order-Marke und Leerraum vor dem Prolog stören nicht', () => {
|
||||
const head = '\uFEFF \n\t<?xml version="1.0"?>\n\n<!-- x -->\r\n';
|
||||
expect(sizeSvg(`${head}${ROOT_BEFORE}${BODY}`)).toEqual(converted(head));
|
||||
});
|
||||
|
||||
it('mehrere Kommentare und Anweisungen hintereinander', () => {
|
||||
const head = '<!--a--><?b?><!--c--> <?d e?><!---->';
|
||||
expect(sizeSvg(`${head}${ROOT_BEFORE}${BODY}`)).toEqual(converted(head));
|
||||
});
|
||||
|
||||
it.each([
|
||||
['mit Namensraum-Präfix', '<svg:svg xmlns:svg="http://www.w3.org/2000/svg" width="200" height="60">'],
|
||||
['mit Bindestrich', '<svg-icon width="200" height="60">'],
|
||||
['mit Punkt', '<svg.x width="200" height="60">'],
|
||||
['mit Ziffer', '<svg2 width="200" height="60">'],
|
||||
])('ein Wurzelelement, das nur mit „svg“ beginnt (%s), ist keins: der Text bleibt unverändert, quadratisch', (_name, root) => {
|
||||
expect(sizeSvg(`${root}${BODY}`)).toEqual({ svg: `${root}${BODY}`, w: 2048, h: 2048 });
|
||||
});
|
||||
|
||||
it('ein „<svg:svg“ im Inneren oder in einem Kommentar macht daraus ebenfalls kein Wurzelelement', () => {
|
||||
const text = `<!-- <svg width="9" height="9"> --><svg:svg width="200" height="60">${BODY}`;
|
||||
expect(sizeSvg(text)).toEqual({ svg: text, w: 2048, h: 2048 });
|
||||
});
|
||||
|
||||
it('vor dem Wurzelelement steht etwas anderes (Text, ein anderes Element): es ist kein SVG, der Text bleibt unverändert', () => {
|
||||
for (const text of [`Hallo ${ROOT_BEFORE}${BODY}`, `<html><body>${ROOT_BEFORE}${BODY}</body></html>`, `<g/>${ROOT_BEFORE}${BODY}`]) {
|
||||
expect(sizeSvg(text)).toEqual({ svg: text, w: 2048, h: 2048 });
|
||||
}
|
||||
});
|
||||
|
||||
it('<svg ohne Attribute, mit Zeilenumbruch oder mit „/“ direkt dahinter ist ein Wurzelelement', () => {
|
||||
expect(sizeSvg('<svg>' + BODY)).toEqual({ svg: '<svg width="2048" height="2048">' + BODY, w: 2048, h: 2048 });
|
||||
expect(sizeSvg('<svg\nwidth="200"\nheight="60">' + BODY).svg).toBe(ROOT_AFTER + BODY);
|
||||
expect(sizeSvg('<svg/>').svg).toBe('<svg width="2048" height="2048"/>');
|
||||
});
|
||||
|
||||
it('ein Prolog, der nicht endet (Kommentar, Anweisung, DOCTYPE, Anführungszeichen), ergibt kein Wurzelelement: unverändert, quadratisch', () => {
|
||||
for (const text of [
|
||||
`<!-- offen ${ROOT_BEFORE}${BODY}`,
|
||||
`<?xml version="1.0" ${ROOT_BEFORE}${BODY}`,
|
||||
`<!DOCTYPE svg [ ${ROOT_BEFORE}${BODY}`,
|
||||
`<!DOCTYPE svg PUBLIC "offen ${ROOT_BEFORE}${BODY}`,
|
||||
'<!DOCTYPE svg',
|
||||
]) {
|
||||
expect(sizeSvg(text)).toEqual({ svg: text, w: 2048, h: 2048 });
|
||||
}
|
||||
});
|
||||
|
||||
it('ein feindlicher Prolog braucht nur linear viel Zeit (kein Hängen im Tab)', () => {
|
||||
const hostile = [
|
||||
'<!--'.repeat(100_000),
|
||||
'<?'.repeat(100_000),
|
||||
`<!DOCTYPE x [${'<!-- '.repeat(50_000)}`,
|
||||
`<!DOCTYPE x ${'" '.repeat(100_000)}`,
|
||||
`<!DOCTYPE x [${"'".repeat(100_000)}`,
|
||||
`<!DOCTYPE x [${'<?a '.repeat(50_000)}`,
|
||||
`${' '.repeat(200_000)}<!--`,
|
||||
'<!---->'.repeat(30_000) + `${ROOT_BEFORE}${BODY}`,
|
||||
];
|
||||
const start = performance.now();
|
||||
for (const text of hostile) sizeSvg(text);
|
||||
expect(performance.now() - start).toBeLessThan(500);
|
||||
expect(sizeSvg(hostile.at(-1)!).svg.endsWith(`${ROOT_AFTER}${BODY}`)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('ein selbstschließendes Wurzelelement bleibt gültig', () => {
|
||||
expect(sizeSvg(`<svg ${NS}/>`).svg).toBe(`<svg width="2048" height="2048" ${NS}/>`);
|
||||
});
|
||||
@@ -449,8 +559,14 @@ describe('prepareLogo (Bild und Canvas gestellt)', () => {
|
||||
/** Argumente aller `drawImage`-Aufrufe. */
|
||||
draws: unknown[][];
|
||||
smoothing: string;
|
||||
/** Die Größe, als zum ersten Mal darauf gezeichnet oder von ihm gelesen wurde (danach kann der Canvas freigegeben, also 0 × 0, sein). */
|
||||
used: [number, number] | null;
|
||||
/** Wie oft seine Pixel gelesen wurden (`getImageData`). */
|
||||
reads: number;
|
||||
}
|
||||
let canvases: FakeCanvas[];
|
||||
/** Die Größen aller Canvas im Moment, in dem das PNG erzeugt wird (`toBlob`). */
|
||||
let sizesAtBlob: Array<[number, number]> | null;
|
||||
let blobs: Blob[];
|
||||
let revoked: string[];
|
||||
let images: FakeImage[];
|
||||
@@ -502,6 +618,8 @@ describe('prepareLogo (Bild und Canvas gestellt)', () => {
|
||||
resultHole?: boolean;
|
||||
noBlob?: boolean;
|
||||
noContext?: boolean;
|
||||
/** Nur der Ergebnis-Canvas (der zweite) hat keinen 2D-Kontext. */
|
||||
resultNoContext?: boolean;
|
||||
/** `drawImage` wirft diesen Fehler. */
|
||||
drawError?: unknown;
|
||||
} = {},
|
||||
@@ -510,16 +628,18 @@ describe('prepareLogo (Bild und Canvas gestellt)', () => {
|
||||
vi.spyOn(document, 'createElement').mockImplementation(((tag: string) => {
|
||||
if (tag !== 'canvas') return create(tag);
|
||||
const isSource = canvases.length === 0;
|
||||
const canvas: FakeCanvas = { width: 0, height: 0, draws: [], smoothing: '' };
|
||||
const canvas: FakeCanvas = { width: 0, height: 0, draws: [], smoothing: '', used: null, reads: 0 };
|
||||
const ctx = {
|
||||
set imageSmoothingQuality(value: string) {
|
||||
canvas.smoothing = value;
|
||||
},
|
||||
drawImage: (...args: unknown[]) => {
|
||||
canvas.used ??= [canvas.width, canvas.height];
|
||||
if (opts.drawError) throw opts.drawError;
|
||||
canvas.draws.push(args);
|
||||
},
|
||||
getImageData: (_x: number, _y: number, w: number, h: number) => ({
|
||||
...((canvas.used ??= [canvas.width, canvas.height]), (canvas.reads += 1), {}),
|
||||
data: isSource
|
||||
? opts.blank
|
||||
? new Uint8ClampedArray(w * h * 4)
|
||||
@@ -529,8 +649,11 @@ describe('prepareLogo (Bild und Canvas gestellt)', () => {
|
||||
};
|
||||
canvases.push(canvas);
|
||||
return Object.assign(canvas, {
|
||||
getContext: () => (opts.noContext ? null : ctx),
|
||||
toBlob: (done: BlobCallback, type?: string) => done(opts.noBlob ? null : new Blob(['png'], { type })),
|
||||
getContext: () => (opts.noContext || (opts.resultNoContext && !isSource) ? null : ctx),
|
||||
toBlob: (done: BlobCallback, type?: string) => {
|
||||
sizesAtBlob = canvases.map((c) => [c.width, c.height]);
|
||||
done(opts.noBlob ? null : new Blob(['png'], { type }));
|
||||
},
|
||||
}) as unknown as HTMLElement;
|
||||
}) as typeof document.createElement);
|
||||
}
|
||||
@@ -540,6 +663,7 @@ describe('prepareLogo (Bild und Canvas gestellt)', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
canvases = [];
|
||||
sizesAtBlob = null;
|
||||
blobs = [];
|
||||
revoked = [];
|
||||
images = [];
|
||||
@@ -572,7 +696,7 @@ describe('prepareLogo (Bild und Canvas gestellt)', () => {
|
||||
expect(decodeCalls).toBe(1);
|
||||
expect(revoked).toEqual(['blob:test-1']);
|
||||
expect(canvases).toHaveLength(2);
|
||||
expect(canvases[0]).toMatchObject({ width: 100, height: 40 });
|
||||
expect(canvases[0].used).toEqual([100, 40]);
|
||||
expect(canvases[0].draws).toEqual([[images[0], 0, 0, 100, 40]]);
|
||||
// Ergebnis: genau der sichtbare Ausschnitt, 1:1 (nie vergrößert).
|
||||
expect(canvases[1]).toMatchObject({ width: 20, height: 10, smoothing: 'high' });
|
||||
@@ -620,7 +744,7 @@ describe('prepareLogo (Bild und Canvas gestellt)', () => {
|
||||
stubCanvas({ blank: true });
|
||||
await expect(prepareLogo(rasterFile())).rejects.toThrow(EMPTY);
|
||||
expect(decodeCalls).toBe(1);
|
||||
expect(canvases[0]).toMatchObject({ width: 8000, height: 5000 });
|
||||
expect(canvases[0].used).toEqual([8000, 5000]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -681,7 +805,7 @@ describe('prepareLogo (Bild und Canvas gestellt)', () => {
|
||||
// Das SVG kommt nie in die Seite, Skripte laufen darum nie.
|
||||
expect(document.querySelector('svg, script, img')).toBeNull();
|
||||
|
||||
expect(canvases[0]).toMatchObject({ width: 2048, height: 683 });
|
||||
expect(canvases[0].used).toEqual([2048, 683]);
|
||||
expect(canvases[0].draws).toEqual([[images[0], 0, 0, 2048, 683]]);
|
||||
// Zuschnitt auf das Sichtbare (2048 × 483), dann auf die Zielgröße verkleinert.
|
||||
expect(canvases[1]).toMatchObject({ width: 720, height: 170 });
|
||||
@@ -701,7 +825,7 @@ describe('prepareLogo (Bild und Canvas gestellt)', () => {
|
||||
expect(await blobs[0].text()).toBe(
|
||||
'<svg width="2048" height="614" viewBox="0 0 200 60" xmlns="http://www.w3.org/2000/svg"><rect width="200" height="60"/></svg>',
|
||||
);
|
||||
expect(canvases[0]).toMatchObject({ width: 2048, height: 614 });
|
||||
expect(canvases[0].used).toEqual([2048, 614]);
|
||||
expect(canvases[0].draws).toEqual([[images[0], 0, 0, 2048, 614]]);
|
||||
expect(prepared).toMatchObject({ width: 720, height: 216 });
|
||||
});
|
||||
@@ -715,7 +839,7 @@ describe('prepareLogo (Bild und Canvas gestellt)', () => {
|
||||
expect(await blobs[0].text()).toBe(
|
||||
'<svg width="2048" height="2048" xmlns="http://www.w3.org/2000/svg"><rect width="200" height="100"/></svg>',
|
||||
);
|
||||
expect(canvases[0]).toMatchObject({ width: 2048, height: 2048 });
|
||||
expect(canvases[0].used).toEqual([2048, 2048]);
|
||||
expect(canvases[0].draws).toEqual([[images[0], 0, 0, 2048, 2048]]);
|
||||
});
|
||||
|
||||
@@ -739,6 +863,71 @@ describe('prepareLogo (Bild und Canvas gestellt)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Speicher: der Quell-Canvas wird frühzeitig freigegeben (0 × 0), der Pixelpuffer nur einmal gelesen', () => {
|
||||
const released = (canvas: FakeCanvas) => [canvas.width, canvas.height];
|
||||
|
||||
it('Rasterbild: nach dem Zuschnitt ist der Quell-Canvas frei, schon bevor das PNG entsteht; das Ergebnis behält seine Größe', async () => {
|
||||
stubCanvas({ visible: only(10, 5, 20, 10) });
|
||||
await prepareLogo(rasterFile());
|
||||
expect(canvases[0].used).toEqual([100, 40]);
|
||||
expect(released(canvases[0])).toEqual([0, 0]);
|
||||
expect(sizesAtBlob).toEqual([
|
||||
[0, 0],
|
||||
[20, 10],
|
||||
]);
|
||||
expect(canvases[1]).toMatchObject({ width: 20, height: 10 });
|
||||
});
|
||||
|
||||
it('SVG: ebenso', async () => {
|
||||
natural = { w: 2048, h: 614 };
|
||||
stubCanvas({ visible: only(0, 0, 2048, 614) });
|
||||
await prepareLogo(svgFile('<svg xmlns="http://www.w3.org/2000/svg" width="200" height="60"/>'));
|
||||
expect(canvases[0].used).toEqual([2048, 614]);
|
||||
expect(sizesAtBlob?.[0]).toEqual([0, 0]);
|
||||
});
|
||||
|
||||
it('der Pixelpuffer des Quellbildes wird genau einmal gelesen (für den Zuschnitt), nicht aufgehoben', async () => {
|
||||
stubCanvas({ visible: only(10, 5, 20, 10) });
|
||||
await prepareLogo(rasterFile());
|
||||
expect(canvases[0].reads).toBe(1);
|
||||
expect(canvases[1].reads).toBe(1); // das Ergebnis: nur für „hat Transparenz“
|
||||
});
|
||||
|
||||
it.each([
|
||||
['leer', { visible: () => 0 }, EMPTY],
|
||||
['zu klein', { visible: only(10, 5, 7, 7) }, SMALL],
|
||||
])('auch wenn das Logo %s ist: der Quell-Canvas ist freigegeben', async (_name, opts, message) => {
|
||||
stubCanvas(opts);
|
||||
await expect(prepareLogo(rasterFile())).rejects.toThrow(message);
|
||||
expect(canvases).toHaveLength(1); // ein Ergebnis-Canvas wird gar nicht erst angelegt
|
||||
expect(released(canvases[0])).toEqual([0, 0]);
|
||||
});
|
||||
|
||||
it('auch wenn das Zeichnen des Quellbildes scheitert', async () => {
|
||||
stubCanvas({ drawError: new DOMException('kaputt', 'InvalidStateError') });
|
||||
await expect(prepareLogo(rasterFile())).rejects.toThrow(READ);
|
||||
expect(released(canvases[0])).toEqual([0, 0]);
|
||||
});
|
||||
|
||||
it('auch wenn der Ergebnis-Canvas keinen 2D-Kontext hat', async () => {
|
||||
stubCanvas({ visible: only(10, 5, 20, 10), resultNoContext: true });
|
||||
await expect(prepareLogo(rasterFile())).rejects.toThrow(READ);
|
||||
expect(canvases).toHaveLength(2);
|
||||
expect(released(canvases[0])).toEqual([0, 0]);
|
||||
});
|
||||
|
||||
it('auch wenn das PNG nicht entsteht', async () => {
|
||||
stubCanvas({ visible: only(10, 5, 20, 10), noBlob: true });
|
||||
await expect(prepareLogo(rasterFile())).rejects.toThrow(READ);
|
||||
expect(released(canvases[0])).toEqual([0, 0]);
|
||||
});
|
||||
|
||||
it('ohne 2D-Canvas für das Quellbild bleibt es bei der Meldung (es gibt nichts freizugeben)', async () => {
|
||||
stubCanvas({ noContext: true });
|
||||
await expect(prepareLogo(rasterFile())).rejects.toThrow(READ);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unerwartete Fehler: eine Warnung mit dem Originalfehler in der Konsole, der Nutzer liest weiter „nicht gelesen“', () => {
|
||||
it('der Browser wirft beim Zeichnen: genau eine Warnung mit genau diesem Fehler, die Meldung bleibt unverändert', async () => {
|
||||
const boom = new DOMException('The source image could not be decoded.', 'InvalidStateError');
|
||||
|
||||
+103
-38
@@ -2,6 +2,7 @@
|
||||
// Ränder abschneiden, auf höchstens 720 × 250 px verkleinern (nie vergrößern) und als PNG ausgeben. Die Maße sind die des Dienstes
|
||||
// (deploy/api/logos.js), der trotzdem selbst prüft. Zuschnitt, Skalierung, Transparenz und die Größe eines SVGs sind reine Helfer
|
||||
// (einzeln testbar); `prepareLogo` fügt sie mit dem Canvas des Browsers zusammen. Kein Netzwerk, kein Appwrite.
|
||||
// Das Quellbild belegt bis zu 40 Megapixel: sein Canvas und der Pixelpuffer sind nach dem Zuschnitt sofort wieder frei.
|
||||
import { viewBoxAspect } from '../card/marks';
|
||||
import type { CardImage } from '../card/render';
|
||||
import { MAX_IMAGE_PIXELS, PIXELS_MESSAGE, decodeImage } from '../editor/upload';
|
||||
@@ -10,9 +11,12 @@ import { msg, t } from '../i18n/i18n';
|
||||
/** Für das `accept`-Attribut des Datei-Felds: die erlaubten Typen, dazu `.svg` für Systeme, die den Typ nicht kennen. */
|
||||
export const LOGO_ACCEPT = 'image/png,image/jpeg,image/webp,image/svg+xml,.svg';
|
||||
|
||||
/** Höchstmaße und Mindestmaß des fertigen Logos in Pixeln (wie der Dienst sie prüft). */
|
||||
export const LOGO_MAX_W = 720;
|
||||
export const LOGO_MAX_H = 250;
|
||||
/**
|
||||
* Höchstmaße und Mindestmaß der fertigen Logo-Datei in Pixeln (wie der Dienst sie prüft). Nicht zu verwechseln mit `LOGO_MAX_H`
|
||||
* in card/geometry.ts: das ist die Höhe, bis zu der das Logo auf der Karte wächst.
|
||||
*/
|
||||
export const LOGO_FILE_MAX_W = 720;
|
||||
export const LOGO_FILE_MAX_H = 250;
|
||||
export const LOGO_MIN = 8;
|
||||
/** Pixel mit Alpha bis hierher gelten als leer (nicht sichtbar). */
|
||||
export const ALPHA_EMPTY = 8;
|
||||
@@ -123,7 +127,7 @@ export function hasTransparency(data: Uint8ClampedArray): boolean {
|
||||
export function planLogo(data: Uint8ClampedArray, w: number, h: number): { box: Box; size: Size } {
|
||||
const box = trimBox(data, w, h);
|
||||
if (!box) throw new LogoError(t(LOGO_EMPTY_MESSAGE));
|
||||
const size = fitWithin(box.w, box.h, LOGO_MAX_W, LOGO_MAX_H);
|
||||
const size = fitWithin(box.w, box.h, LOGO_FILE_MAX_W, LOGO_FILE_MAX_H);
|
||||
if (size.w < LOGO_MIN || size.h < LOGO_MIN) throw new LogoError(t(LOGO_SMALL_MESSAGE));
|
||||
return { box, size };
|
||||
}
|
||||
@@ -144,6 +148,65 @@ function pixels(value: string | undefined): number | null {
|
||||
return n > 0 && Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/** Leerraum samt Byte-Order-Marke (U+FEFF zählt in `\s` mit). */
|
||||
const BLANK = /\s*/y;
|
||||
/** Der Anfang des Wurzelelements: `<svg`, dahinter Leerraum, `/` oder `>`; `<svg:svg` (mit Präfix) und `<svg-x` sind andere Elemente. */
|
||||
const ROOT = /<svg(?=[\s/>])/y;
|
||||
/** Was in einem DOCTYPE ein „>“ oder „]“ enthalten darf, ohne ihn zu beenden: Anführungszeichen, Kommentare und Anweisungen (Anfang, Ende). */
|
||||
const QUOTED: ReadonlyArray<readonly [string, string]> = [['"', '"'], ["'", "'"], ['<!--', '-->'], ['<?', '?>']];
|
||||
|
||||
/** Die Stelle hinter dem ersten `marker` ab `from`; -1, wenn er nicht mehr vorkommt. */
|
||||
function after(text: string, marker: string, from: number): number {
|
||||
const at = text.indexOf(marker, from);
|
||||
return at < 0 ? -1 : at + marker.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Ende eines DOCTYPE, der bei `start` beginnt (die Stelle hinter dem schließenden „>“); -1, wenn er nicht endet. Zeichenketten,
|
||||
* Kommentare und Anweisungen darin (auch in der eigenen DTD in `[…]`) können „>“, „]“ und „<svg“ enthalten und zählen nicht als Ende.
|
||||
*/
|
||||
function doctypeEnd(text: string, start: number): number {
|
||||
let inSubset = false;
|
||||
for (let i = start + '<!DOCTYPE'.length; i < text.length; i++) {
|
||||
const quoted = QUOTED.find(([open]) => text.startsWith(open, i));
|
||||
if (quoted) {
|
||||
const end = after(text, quoted[1], i + quoted[0].length);
|
||||
if (end < 0) return -1;
|
||||
i = end - 1;
|
||||
} else if (text[i] === '[') {
|
||||
inSubset = true;
|
||||
} else if (text[i] === ']') {
|
||||
inSubset = false;
|
||||
} else if (text[i] === '>' && !inSubset) {
|
||||
return i + 1;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wo das Wurzelelement `<svg …>` beginnt; -1, wenn der Text nicht damit anfängt. Davor dürfen nur Leerraum (auch eine Byte-Order-Marke),
|
||||
* die XML-Deklaration und andere Anweisungen (`<?…?>`), Kommentare (`<!--…-->`) und ein DOCTYPE stehen; ein `<svg` darin (etwa in einem
|
||||
* auskommentierten Beispiel) ist kein Wurzelelement. Ein Durchlauf von vorn nach hinten ohne Zurücksetzen: auch eine feindliche Datei
|
||||
* braucht nur linear viel Zeit.
|
||||
*/
|
||||
function rootStart(svg: string): number {
|
||||
let i = 0;
|
||||
for (;;) {
|
||||
BLANK.lastIndex = i;
|
||||
BLANK.test(svg);
|
||||
i = BLANK.lastIndex;
|
||||
if (svg.startsWith('<?', i)) i = after(svg, '?>', i + 2);
|
||||
else if (svg.startsWith('<!--', i)) i = after(svg, '-->', i + 4);
|
||||
else if (svg.startsWith('<!DOCTYPE', i)) i = doctypeEnd(svg, i);
|
||||
else {
|
||||
ROOT.lastIndex = i;
|
||||
return ROOT.test(svg) ? i : -1;
|
||||
}
|
||||
if (i < 0) return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Macht den Text eines SVGs rasterbar: Das Seitenverhältnis kommt aus der viewBox des Wurzelelements, wenn sie brauchbar ist, sonst
|
||||
* aus dessen Breite und Höhe in Pixeln (oder ohne Einheit), sonst ist es quadratisch (Prozent und andere Einheiten zählen nicht).
|
||||
@@ -151,16 +214,16 @@ function pixels(value: string | undefined): number | null {
|
||||
* SVG scharf auf den Canvas zeichnen (wie `withIntrinsicSize` in card/marks.ts). Eine eigene Breite und Höhe fällt dabei weg: ein
|
||||
* doppeltes Attribut machte das SVG ungültig. Fehlt eine brauchbare viewBox, gibt es aber eine Größe, wird `viewBox="0 0 <Breite> <Höhe>"`
|
||||
* ergänzt (eine unbrauchbare viewBox wird dabei ersetzt), sonst stünde der Inhalt in Originalgröße oben links im Raster. Eine
|
||||
* brauchbare viewBox bleibt immer unberührt. Nur das erste `<svg …>` zählt; ohne eines (oder ohne dessen Ende) bleibt der Text
|
||||
* unverändert und das Raster quadratisch (das Laden als Bild meldet dann den Fehler).
|
||||
* brauchbare viewBox bleibt immer unberührt. Nur das Wurzelelement zählt (siehe `rootStart`); ohne eines (oder ohne dessen Ende)
|
||||
* bleibt der Text unverändert und das Raster quadratisch (das Laden als Bild meldet dann den Fehler).
|
||||
*/
|
||||
export function sizeSvg(svg: string): SizedSvg {
|
||||
const unchanged = { svg, ...svgRasterSize(NaN) };
|
||||
const start = svg.search(/<svg\b/);
|
||||
const start = rootStart(svg);
|
||||
if (start < 0) return unchanged;
|
||||
// Das öffnende Tag endet am ersten „>“ außerhalb von Anführungszeichen. Genau ein Versuch ab dem ersten „<svg“ (`y`): so braucht
|
||||
// Das öffnende Tag endet am ersten „>“ außerhalb von Anführungszeichen. Genau ein Versuch ab dem Wurzelelement (`y`): so braucht
|
||||
// auch eine Datei voller offener „<svg x="“ nur linear viel Zeit, ein Suchmuster, das an jedem „<svg“ neu ansetzt, hinge im Tab.
|
||||
const tag = /<svg\b((?:[^>"']|"[^"]*"|'[^']*')*)>/y;
|
||||
const tag = /<svg(?=[\s/>])((?:[^>"']|"[^"]*"|'[^']*')*)>/y;
|
||||
tag.lastIndex = start;
|
||||
const hit = tag.exec(svg);
|
||||
if (!hit) return unchanged;
|
||||
@@ -189,14 +252,6 @@ export function sizeSvg(svg: string): SizedSvg {
|
||||
|
||||
// ---- Dekodieren und Zeichnen (braucht einen echten Canvas) --------------------------------------------
|
||||
|
||||
/** Das Quellbild in Originalgröße auf einem Canvas samt seinen Pixeln (RGBA). */
|
||||
interface Drawn {
|
||||
canvas: HTMLCanvasElement;
|
||||
data: Uint8ClampedArray;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
/** Ein Canvas der Größe w × h mit 2D-Kontext. Es werden Pixel gelesen, daher `willReadFrequently`. */
|
||||
function newCanvas(w: number, h: number): { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } {
|
||||
const canvas = document.createElement('canvas');
|
||||
@@ -207,11 +262,10 @@ function newCanvas(w: number, h: number): { canvas: HTMLCanvasElement; ctx: Canv
|
||||
return { canvas, ctx };
|
||||
}
|
||||
|
||||
/** Zeichnet die Quelle in Größe w × h auf einen neuen Canvas und liest dessen Pixel. */
|
||||
function draw(source: CanvasImageSource, w: number, h: number): Drawn {
|
||||
const { canvas, ctx } = newCanvas(w, h);
|
||||
ctx.drawImage(source, 0, 0, w, h);
|
||||
return { canvas, data: ctx.getImageData(0, 0, w, h).data, w, h };
|
||||
/** Gibt den Speicher eines Canvas frei, den niemand mehr braucht (manche Browser behalten ihn sonst bis zur nächsten Speicherbereinigung). */
|
||||
function releaseCanvas(canvas: HTMLCanvasElement): void {
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -228,17 +282,33 @@ async function decode(blob: Blob): Promise<CardImage> {
|
||||
}
|
||||
}
|
||||
|
||||
/** PNG, JPG oder WebP: laden und in Originalgröße zeichnen. */
|
||||
async function drawRaster(file: File): Promise<Drawn> {
|
||||
const { source, w, h } = await decode(file);
|
||||
return draw(source, w, h);
|
||||
}
|
||||
|
||||
/** SVG: Größe aus dem Wurzelelement (siehe `sizeSvg`), nur als Bild geladen (so laufen Skripte darin nie), längste Seite 2048 px. */
|
||||
async function drawSvg(file: File): Promise<Drawn> {
|
||||
async function decodeSvg(file: File): Promise<CardImage> {
|
||||
const { svg, w, h } = sizeSvg(await file.text());
|
||||
const { source } = await decode(new Blob([svg], { type: SVG_TYPE }));
|
||||
return draw(source, w, h);
|
||||
return { source, w, h };
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeichnet die Datei in Originalgröße (ein SVG in seiner Rastergröße) auf einen Quell-Canvas, schneidet auf das Sichtbare zu und
|
||||
* verkleinert auf die Zielgröße. Der Quell-Canvas (bis zu 40 Megapixel) und der Pixelpuffer, aus dem der Zuschnitt folgt, sind danach
|
||||
* schon wieder frei, noch bevor das PNG entsteht; das gilt auch, wenn etwas scheitert.
|
||||
*/
|
||||
async function renderLogo(file: File): Promise<{ canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D; size: Size }> {
|
||||
const { source, w, h } = isSvgFile(file) ? await decodeSvg(file) : await decode(file);
|
||||
const from = newCanvas(w, h);
|
||||
try {
|
||||
from.ctx.drawImage(source, 0, 0, w, h);
|
||||
// Der Pixelpuffer (4 Byte je Pixel) steht nur in diesem Aufruf: nach dem Zuschnitt hält ihn niemand mehr.
|
||||
const { box, size } = planLogo(from.ctx.getImageData(0, 0, w, h).data, w, h);
|
||||
const out = newCanvas(size.w, size.h);
|
||||
// Beim Verkleinern ist die Standardqualität (low) grob.
|
||||
out.ctx.imageSmoothingQuality = 'high';
|
||||
out.ctx.drawImage(from.canvas, box.x, box.y, box.w, box.h, 0, 0, size.w, size.h);
|
||||
return { ...out, size };
|
||||
} finally {
|
||||
releaseCanvas(from.canvas);
|
||||
}
|
||||
}
|
||||
|
||||
function toPng(canvas: HTMLCanvasElement): Promise<Blob> {
|
||||
@@ -255,17 +325,12 @@ export async function prepareLogo(file: File): Promise<PreparedLogo> {
|
||||
const check = validateLogoFile(file);
|
||||
if (!check.ok) throw new LogoError(check.message);
|
||||
try {
|
||||
const { canvas, data, w, h } = isSvgFile(file) ? await drawSvg(file) : await drawRaster(file);
|
||||
const { box, size } = planLogo(data, w, h);
|
||||
const out = newCanvas(size.w, size.h);
|
||||
// Beim Verkleinern ist die Standardqualität (low) grob.
|
||||
out.ctx.imageSmoothingQuality = 'high';
|
||||
out.ctx.drawImage(canvas, box.x, box.y, box.w, box.h, 0, 0, size.w, size.h);
|
||||
const { canvas, ctx, size } = await renderLogo(file);
|
||||
return {
|
||||
png: await toPng(out.canvas),
|
||||
png: await toPng(canvas),
|
||||
width: size.w,
|
||||
height: size.h,
|
||||
hasTransparency: hasTransparency(out.ctx.getImageData(0, 0, size.w, size.h).data),
|
||||
hasTransparency: hasTransparency(ctx.getImageData(0, 0, size.w, size.h).data),
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof LogoError) throw err;
|
||||
|
||||
@@ -32,11 +32,11 @@ const prepared = (patch: Partial<PreparedLogo> = {}): PreparedLogo => ({
|
||||
...patch,
|
||||
});
|
||||
|
||||
const form: LogoForm = { name: 'Beispielbank', displayName: 'Kenso', mono: false };
|
||||
const form: LogoForm = { name: 'Beispielbank', mono: false };
|
||||
|
||||
type Call = { name: string; args: unknown[] };
|
||||
|
||||
/** Fake-Abhängigkeiten, die jeden Aufruf in Reihenfolge aufzeichnen; die hochgeladene Datei bekommt die ID f1. */
|
||||
/** Fake-Abhängigkeiten, die jeden Aufruf in Reihenfolge aufzeichnen; die hochgeladene Datei bekommt die ID f1, das Logo der Dienst die Zeilen-ID r1. */
|
||||
function fakeDeps(overrides: Partial<UploadLogoDeps> = {}) {
|
||||
const calls: Call[] = [];
|
||||
const log = <T>(name: string, args: unknown[], result: T): T => {
|
||||
@@ -47,7 +47,7 @@ function fakeDeps(overrides: Partial<UploadLogoDeps> = {}) {
|
||||
userId: () => log('userId', [], 'u1'),
|
||||
createFile: async (file, permissions) => log('createFile', [file, permissions], { $id: 'f1' }),
|
||||
deleteFile: async (id) => log('deleteFile', [id], undefined),
|
||||
uploadLogo: async (input) => log('uploadLogo', [input], { id: 'f1' }),
|
||||
uploadLogo: async (input) => log('uploadLogo', [input], { id: 'r1' }),
|
||||
...overrides,
|
||||
};
|
||||
return {
|
||||
@@ -76,15 +76,15 @@ describe('uploadPreparedLogo', () => {
|
||||
expect(permissions).toEqual(['read("any")', 'delete("user:u1")']);
|
||||
});
|
||||
|
||||
it('schickt der API die neue Datei-ID und genau vier Felder, Name und Anzeigename getrimmt', async () => {
|
||||
it('schickt der API die neue Datei-ID und genau vier Felder: der Name getrimmt, der Anzeigename immer leer (die Zeile zeigt den Benutzernamen)', async () => {
|
||||
const fake = fakeDeps();
|
||||
await uploadPreparedLogo(prepared(), { name: ' Beispielbank ', displayName: ' Kenso\t', mono: false }, fake.deps);
|
||||
expect(fake.of('uploadLogo')[0].args[0]).toEqual({ name: 'Beispielbank', displayName: 'Kenso', mono: false, fileId: 'f1' });
|
||||
await uploadPreparedLogo(prepared(), { name: ' Beispielbank ', mono: false }, fake.deps);
|
||||
expect(fake.of('uploadLogo')[0].args[0]).toEqual({ name: 'Beispielbank', displayName: '', mono: false, fileId: 'f1' });
|
||||
});
|
||||
|
||||
it('ein leerer Anzeigename bleibt leer (der Server macht daraus „Anonym“)', async () => {
|
||||
it('ein von einem alten Aufrufer mitgegebener Anzeigename geht nicht mehr mit', async () => {
|
||||
const fake = fakeDeps();
|
||||
await uploadPreparedLogo(prepared(), { ...form, displayName: ' ' }, fake.deps);
|
||||
await uploadPreparedLogo(prepared(), { ...form, displayName: 'Kenso' } as LogoForm, fake.deps);
|
||||
expect(fake.of('uploadLogo')[0].args[0]).toMatchObject({ displayName: '' });
|
||||
});
|
||||
|
||||
@@ -100,13 +100,13 @@ describe('uploadPreparedLogo', () => {
|
||||
expect(logo.mono).toBe(sent);
|
||||
});
|
||||
|
||||
it('Erfolg: das neue Logo (ID = Datei-ID, mein Konto, ohne Herzen und Meldungen), nichts wird gelöscht', async () => {
|
||||
it('Erfolg: das neue Logo (ID des Dienstes, Datei-ID der hochgeladenen Datei, mein Konto, ohne Herzen und Meldungen), nichts wird gelöscht', async () => {
|
||||
const fake = fakeDeps();
|
||||
const logo = await uploadPreparedLogo(prepared(), { name: ' Beispielbank ', displayName: 'Kenso', mono: true }, fake.deps);
|
||||
const logo = await uploadPreparedLogo(prepared(), { name: ' Beispielbank ', mono: true }, fake.deps);
|
||||
expect(logo).toEqual({
|
||||
id: 'f1',
|
||||
id: 'r1',
|
||||
name: 'Beispielbank',
|
||||
displayName: 'Kenso',
|
||||
displayName: '',
|
||||
fileId: 'f1',
|
||||
ownerId: 'u1',
|
||||
mono: true,
|
||||
@@ -119,6 +119,16 @@ describe('uploadPreparedLogo', () => {
|
||||
expect(fake.of('deleteFile')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('die ID kommt aus der Antwort des Dienstes, die fileId aus der angelegten Datei: beide sind verschieden und werden nie vertauscht', async () => {
|
||||
const fake = fakeDeps({
|
||||
createFile: async () => ({ $id: 'datei-9' }),
|
||||
uploadLogo: async () => ({ id: 'zeile-7' }),
|
||||
});
|
||||
const logo = await uploadPreparedLogo(prepared(), form, fake.deps);
|
||||
expect(logo.id).toBe('zeile-7');
|
||||
expect(logo.fileId).toBe('datei-9');
|
||||
});
|
||||
|
||||
describe('Fehler der API: gelöscht wird nur bei eindeutiger Ablehnung', () => {
|
||||
it.each([400, 401, 403, 404])(
|
||||
'eindeutig abgelehnt (%i): die Datei dieses Versuchs wird gelöscht, der Fehler kommt an',
|
||||
@@ -239,7 +249,8 @@ describe('Standard-Abhängigkeiten (Appwrite-Bucket logo-images)', () => {
|
||||
// Wie Appwrite: die Datei hat die ID, mit der sie angelegt wurde.
|
||||
mocks.createFile.mockImplementation(async ({ fileId }: { fileId: string }) => ({ $id: fileId }));
|
||||
mocks.deleteFile.mockResolvedValue({});
|
||||
mocks.uploadLogo.mockImplementation(async ({ fileId }: { fileId: string }) => ({ id: fileId }));
|
||||
// Wie der Dienst: die Zeile des Logos bekommt eine eigene ID, nicht die der Datei.
|
||||
mocks.uploadLogo.mockImplementation(async ({ fileId }: { fileId: string }) => ({ id: `zeile-${fileId}` }));
|
||||
});
|
||||
|
||||
it('lädt mit frischer ID in den Logo-Bucket und ruft die API mit genau dieser ID', async () => {
|
||||
@@ -255,8 +266,9 @@ describe('Standard-Abhängigkeiten (Appwrite-Bucket logo-images)', () => {
|
||||
expect(fileId).toMatch(ID_PATTERN);
|
||||
expect(file.name).toBe('logo.png');
|
||||
expect(permissions).toEqual(['read("any")', 'delete("user:u1")']);
|
||||
expect(mocks.uploadLogo).toHaveBeenCalledWith({ name: 'Beispielbank', displayName: 'Kenso', mono: false, fileId });
|
||||
expect(logo.id).toBe(fileId);
|
||||
expect(mocks.uploadLogo).toHaveBeenCalledWith({ name: 'Beispielbank', displayName: '', mono: false, fileId });
|
||||
expect(logo.id).toBe(`zeile-${fileId}`);
|
||||
expect(logo.fileId).toBe(fileId);
|
||||
expect(mocks.deleteFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -11,12 +11,16 @@ import type { PreparedLogo } from './prepare-logo';
|
||||
|
||||
export interface LogoForm {
|
||||
name: string;
|
||||
/** Leer bedeutet „Anonym“ (das setzt der Dienst). */
|
||||
displayName: string;
|
||||
/** Einfarbig: nur wirksam, wenn das Logo durchsichtige Stellen hat. */
|
||||
mono: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Anzeigename, den der Client mitschickt: keiner. Seit den Benutzernamen (Spec § 5) zeigen Kachel, Infozeile und Detailseite den Namen
|
||||
* des Kontos; der Dienst nimmt das Feld weiter an (der Altbestand trägt noch Anzeigenamen).
|
||||
*/
|
||||
const NO_DISPLAY_NAME = '';
|
||||
|
||||
/** Alles, was `uploadPreparedLogo` von außen braucht (im Test ersetzbar, ohne Netz und Appwrite). */
|
||||
export interface UploadLogoDeps {
|
||||
/** Die ID des angemeldeten Nutzers; ohne Nutzer ein Fehler mit `code` 401. */
|
||||
@@ -58,13 +62,14 @@ function isLogoTaken(err: unknown): boolean {
|
||||
|
||||
/**
|
||||
* Lädt das Logo hoch: das PNG in den Logo-Bucket (`read("any")`, `delete("user:<ich>")`), dann legt die API das Logo daraus an.
|
||||
* Liefert das neue Logo, so wie die Liste es danach zeigt (die Zeilen-ID ist die Datei-ID). „Einfarbig“ gilt nur für Logos mit
|
||||
* durchsichtigen Stellen. Name und Anzeigename werden getrimmt, ihre Grenzen prüft der Dienst.
|
||||
* Liefert das neue Logo, so wie die Liste es danach zeigt: `id` ist die Zeilen-ID aus der Antwort des Dienstes (nicht die Datei-ID),
|
||||
* `fileId` die ID der hochgeladenen Datei. „Einfarbig“ gilt nur für Logos mit durchsichtigen Stellen. Der Name wird getrimmt, seine
|
||||
* Grenzen prüft der Dienst. Ohne Benutzernamen des Kontos lehnt der Dienst mit 403 `usernameRequired` ab (die Dateien werden dann aufgeräumt).
|
||||
*
|
||||
* Aufräumen (nur die eine Datei dieses Versuchs, nie etwas anderes):
|
||||
* - Scheitert der Upload, wurde die API noch nicht gerufen: es gibt kein Logo, das auf die Datei zeigen könnte.
|
||||
* - Scheitert die API eindeutig (4xx außer 408/429, auch das Limit von 30 Logos, oder schon der JWT-Abruf), wurde nichts
|
||||
* angelegt: die Datei wird gelöscht. Ausnahme `logoTaken` (409): ein anderes Logo trägt diese Datei-ID, die Datei bleibt.
|
||||
* angelegt: die Datei wird gelöscht. Ausnahme `logoTaken` (409): ein Logo trägt diese Datei schon, die Datei bleibt.
|
||||
* - Bleibt der Ausgang offen (Netzfehler, Zeitlimit, 5xx, 408, 429, Antwort ohne `id`), kann die API das Logo schon angelegt
|
||||
* haben. Dann bleibt die Datei liegen: sonst zeigte ein öffentliches Logo auf ein gelöschtes Bild.
|
||||
* Der Fehler wird in jedem Fall weitergeworfen.
|
||||
@@ -78,7 +83,6 @@ export async function uploadPreparedLogo(
|
||||
const userId = deps.userId();
|
||||
const permissions = [Permission.read(Role.any()), Permission.delete(Role.user(userId))];
|
||||
const name = form.name.trim();
|
||||
const displayName = form.displayName.trim();
|
||||
const mono = form.mono && prepared.hasTransparency;
|
||||
|
||||
const file = new File([prepared.png], 'logo.png', { type: 'image/png' });
|
||||
@@ -86,7 +90,7 @@ export async function uploadPreparedLogo(
|
||||
|
||||
let id: string;
|
||||
try {
|
||||
({ id } = await deps.uploadLogo({ name, displayName, mono, fileId }));
|
||||
({ id } = await deps.uploadLogo({ name, displayName: NO_DISPLAY_NAME, mono, fileId }));
|
||||
} catch (err) {
|
||||
if (isDefinitiveFailure(err) && !isLogoTaken(err)) await deleteQuietly(deps, fileId);
|
||||
throw err;
|
||||
@@ -95,8 +99,8 @@ export async function uploadPreparedLogo(
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
displayName,
|
||||
fileId: id,
|
||||
displayName: NO_DISPLAY_NAME,
|
||||
fileId,
|
||||
ownerId: userId,
|
||||
mono,
|
||||
likeCount: 0,
|
||||
|
||||
+339
-6
@@ -1,12 +1,21 @@
|
||||
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.
|
||||
// Vom Benutzernamen läuft das Echte (Merker in sessionStorage); ersetzt sind nur das Lesen des Profils und der Dialog.
|
||||
const mocks = vi.hoisted(() => {
|
||||
const page = () => vi.fn((..._args: unknown[]) => vi.fn()); // baut eine Seite auf und gibt deren Aufräum-Funktion zurück
|
||||
return {
|
||||
setLocale: 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>(),
|
||||
/** Die Hörer von `onLogout`: sie hören nur das Abmelden durch den Nutzer, nicht jede Änderung des Nutzers. */
|
||||
logoutListeners: new Set<() => void>(),
|
||||
loadMyUsername: vi.fn(),
|
||||
usernameDialog: vi.fn(),
|
||||
pages: { editor: page(), cards: page(), community: page(), reset: page(), home: page() },
|
||||
};
|
||||
});
|
||||
@@ -14,14 +23,20 @@ const mocks = vi.hoisted(() => {
|
||||
vi.mock('./account/appwrite', () => ({ client: { setLocale: mocks.setLocale } }));
|
||||
vi.mock('./account/auth', () => ({
|
||||
getCurrentUser: mocks.getCurrentUser,
|
||||
currentUser: () => null,
|
||||
currentUser: () => mocks.user,
|
||||
currentUserId: () => mocks.user?.$id ?? null,
|
||||
logout: vi.fn(),
|
||||
onAuthChange: (fn: (user: null) => void) => {
|
||||
onAuthChange: (fn: (user: TestUser | null) => void) => {
|
||||
mocks.authListeners.add(fn);
|
||||
return () => mocks.authListeners.delete(fn);
|
||||
},
|
||||
onLogout: (fn: () => void) => {
|
||||
mocks.logoutListeners.add(fn);
|
||||
return () => mocks.logoutListeners.delete(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/editor-page', () => ({ mountEditor: mocks.pages.editor }));
|
||||
vi.mock('./pages/cards-page', () => ({ mountCardsPage: mocks.pages.cards }));
|
||||
@@ -32,6 +47,9 @@ vi.mock('./pages/home-page', () => ({ mountHomePage: mocks.pages.home }));
|
||||
const DE_FOOTER = 'Inoffizielle Community-Seite für die App AirCard-iOS';
|
||||
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 langButton = () => document.querySelector<HTMLButtonElement>('button.lang-btn')!;
|
||||
const navTexts = () => [...document.querySelectorAll('nav.app-nav a[data-link]')].map((a) => a.textContent);
|
||||
@@ -41,6 +59,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). */
|
||||
async function start(path = '/', storedLang: string | null = 'de') {
|
||||
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();
|
||||
if (storedLang) localStorage.setItem('aircard.lang', storedLang);
|
||||
history.replaceState(null, '', path);
|
||||
@@ -55,7 +79,12 @@ async function start(path = '/', storedLang: string | null = 'de') {
|
||||
beforeEach(() => {
|
||||
mocks.setLocale.mockReset();
|
||||
mocks.getCurrentUser.mockReset().mockResolvedValue(null); // Gast: der Anmeldestatus ist „abgemeldet“
|
||||
mocks.user = null;
|
||||
mocks.authListeners.clear();
|
||||
mocks.logoutListeners.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();
|
||||
});
|
||||
|
||||
@@ -133,15 +162,49 @@ describe('Sprachwechsel', () => {
|
||||
expect(storeAfter.get().cardName).toBe('Mein Entwurf');
|
||||
});
|
||||
|
||||
it('ein geschlossenes Spenden-Banner kommt beim Sprachwechsel nicht zurück, auch wenn der Speicher gesperrt ist', async () => {
|
||||
await start('/');
|
||||
const denied = () => {
|
||||
throw new DOMException('blockiert', 'SecurityError');
|
||||
};
|
||||
vi.stubGlobal('localStorage', { getItem: denied, setItem: denied });
|
||||
document.querySelector<HTMLButtonElement>('.donate-banner-close')!.click();
|
||||
expect(document.querySelector('.donate-banner')).toBeNull();
|
||||
|
||||
langButton().click();
|
||||
|
||||
expect(document.documentElement.lang).toBe('en');
|
||||
expect(document.querySelector('.donate-banner')).toBeNull();
|
||||
});
|
||||
|
||||
it('der unberührte Standardname der Karte wechselt mit der Sprache, schon bevor die Seite neu aufgebaut wird; ein eigener Name bleibt', async () => {
|
||||
const seen: string[] = [];
|
||||
mocks.pages.editor.mockImplementation((_root: unknown, store: unknown) => {
|
||||
seen.push((store as { get(): { cardName: string } }).get().cardName);
|
||||
return vi.fn();
|
||||
});
|
||||
await start('/gestalten');
|
||||
const store = mocks.pages.editor.mock.calls[0][1] as { get(): { cardName: string }; set(patch: object): void };
|
||||
expect(store.get().cardName).toBe('Meine Karte');
|
||||
|
||||
langButton().click();
|
||||
expect(store.get().cardName).toBe('My card');
|
||||
expect(seen).toEqual(['Meine Karte', 'My card']); // die neu gebaute Seite sieht den Namen der neuen Sprache
|
||||
|
||||
store.set({ cardName: 'Mein Urlaub' });
|
||||
langButton().click(); // zurück auf Deutsch
|
||||
expect(store.get().cardName).toBe('Mein Urlaub');
|
||||
});
|
||||
|
||||
it('das Konto-Menü wird neu gebaut und zeigt sich sofort, wenn der Anmeldestatus schon bekannt ist', async () => {
|
||||
await start('/');
|
||||
await vi.waitFor(() => expect(signIn()).toBeDefined());
|
||||
expect(mocks.authListeners.size).toBe(1);
|
||||
expect(mocks.authListeners.size).toBe(AUTH_LISTENERS);
|
||||
|
||||
langButton().click();
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
@@ -153,7 +216,7 @@ describe('Sprachwechsel', () => {
|
||||
|
||||
langButton().click();
|
||||
expect(slot().childElementCount).toBe(0);
|
||||
expect(mocks.authListeners.size).toBe(1);
|
||||
expect(mocks.authListeners.size).toBe(AUTH_LISTENERS);
|
||||
|
||||
answer(null);
|
||||
await vi.waitFor(() => expect(signIn('Sign in')).toBeDefined());
|
||||
@@ -180,3 +243,273 @@ describe('Sprachwechsel', () => {
|
||||
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);
|
||||
};
|
||||
/** Der Nutzer meldet sich ab: wie in auth.ts erst der Nutzer weg (onAuthChange), dann die Abmelde-Hörer (onLogout). */
|
||||
const userLogsOut = () => {
|
||||
mocks.user = null;
|
||||
for (const fn of [...mocks.authListeners]) fn(null);
|
||||
for (const fn of [...mocks.logoutListeners]) fn();
|
||||
};
|
||||
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('Abmelden nimmt den Merker weg: ein zweites Konto ohne Namen im selben Tab wird gefragt', async () => {
|
||||
const mia: TestUser = { $id: 'u2', email: 'mia@example.com' };
|
||||
sessionAtStart();
|
||||
await start('/');
|
||||
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1));
|
||||
expect(sessionStorage.getItem(PROMPTED_KEY)).toBe('1');
|
||||
|
||||
userLogsOut();
|
||||
expect(sessionStorage.getItem(PROMPTED_KEY)).toBeNull();
|
||||
|
||||
userSignsIn(mia);
|
||||
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(2));
|
||||
expect(sessionStorage.getItem(PROMPTED_KEY)).toBe('1'); // und für dieses Konto gilt es wieder
|
||||
userSignsIn(mia);
|
||||
await settle();
|
||||
expect(mocks.usernameDialog).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('auch dasselbe Konto wird nach dem Abmelden und erneuten Anmelden (weiter ohne Namen) wieder gefragt', async () => {
|
||||
sessionAtStart();
|
||||
await start('/');
|
||||
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1));
|
||||
userLogsOut();
|
||||
userSignsIn(kenso);
|
||||
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it('nur das Abmelden räumt den Merker weg, nicht jede Änderung des Nutzers (das Anmelden beim Registrieren räumt eine alte Sitzung weg)', async () => {
|
||||
sessionAtStart();
|
||||
await start('/');
|
||||
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(1));
|
||||
mocks.user = null;
|
||||
for (const fn of [...mocks.authListeners]) fn(null); // abgelaufene Sitzung: kein onLogout
|
||||
expect(sessionStorage.getItem(PROMPTED_KEY)).toBe('1');
|
||||
});
|
||||
|
||||
it('Abmelden ohne gesetzten Merker ist harmlos', async () => {
|
||||
mocks.loadMyUsername.mockResolvedValue('Kenso');
|
||||
sessionAtStart();
|
||||
await start('/');
|
||||
await vi.waitFor(() => expect(mocks.loadMyUsername).toHaveBeenCalledTimes(1));
|
||||
expect(() => userLogsOut()).not.toThrow();
|
||||
expect(sessionStorage.getItem(PROMPTED_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('ist sessionStorage gesperrt, wirft das Abmelden nichts', 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));
|
||||
expect(() => userLogsOut()).not.toThrow();
|
||||
userSignsIn(); // der Merker im Speicher ist mit weg: gefragt wird wieder
|
||||
await vi.waitFor(() => expect(mocks.usernameDialog).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
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.logoutListeners.clear();
|
||||
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();
|
||||
mocks.logoutListeners.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);
|
||||
});
|
||||
});
|
||||
+42
-2
@@ -2,7 +2,9 @@ import '@fontsource-variable/nunito-sans';
|
||||
import './styles.css';
|
||||
import { mountAccountMenu, type AccountMenu } from './account/account-menu';
|
||||
import { client } from './account/appwrite';
|
||||
import { getCurrentUser } from './account/auth';
|
||||
import { currentUserId, getCurrentUser, onAuthChange, onLogout } from './account/auth';
|
||||
import { clearUsernamePrompted, loadMyUsername, markUsernamePrompted, usernamePrompted } from './account/profile';
|
||||
import { usernameDialog } from './account/username-dialog';
|
||||
import { DONATE_URL } from './config';
|
||||
import { currentLang, onLangChange, setLang } from './i18n/i18n';
|
||||
import { legacyRedirect, rerender, startRouter } from './pages/router';
|
||||
@@ -12,7 +14,7 @@ import { mountHomePage } from './pages/home-page';
|
||||
import { mountResetPage } from './pages/reset-page';
|
||||
import { mountEditor } from './editor/editor-page';
|
||||
import { bindEditorToAuth } from './editor/auth-reset';
|
||||
import { createStore, initialEditorState } from './editor/state';
|
||||
import { createStore, followLanguage, initialEditorState } from './editor/state';
|
||||
import { mountDonateBanner } from './shell/donate-banner';
|
||||
import { mountFooter } from './shell/footer';
|
||||
import { mountHeader } from './shell/header';
|
||||
@@ -34,6 +36,8 @@ if (legacyTarget) history.replaceState(null, '', legacyTarget);
|
||||
const editorStore = createStore(initialEditorState());
|
||||
// … und gehört zum angemeldeten Konto: beim Abmelden oder Kontowechsel wird er geleert bzw. entkoppelt.
|
||||
bindEditorToAuth(editorStore);
|
||||
// … und sein Standardname („Meine Karte“) folgt der Sprache, solange ihn niemand ändert. Der Hörer steht vor dem Neuaufbau der Seite (unten).
|
||||
followLanguage(editorStore);
|
||||
|
||||
// Der Rahmen der Seite: Spenden-Banner (ohne DONATE_URL bleibt der Platz leer), Kopfzeile mit Navigation, Suche und Knöpfen,
|
||||
// Konto-Menü im Seitenkopf und Fußzeile. Alle tragen Texte der gewählten Sprache und werden bei einem Sprachwechsel neu aufgebaut.
|
||||
@@ -62,10 +66,46 @@ function mountShell(): void {
|
||||
}
|
||||
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 Merker gilt je Anmeldung: wer sich abmeldet, nimmt ihn mit, und ein weiteres Konto ohne Namen im selben Tab wird gefragt. Nur beim
|
||||
// Abmelden, nicht bei jeder Änderung des Nutzers: das Anmelden beim Registrieren räumt auch eine alte Sitzung weg (onAuthChange(null)), und
|
||||
// die Registrierung hat den Merker da schon gesetzt (login-dialog.ts).
|
||||
onLogout(clearUsernamePrompted);
|
||||
|
||||
// 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.
|
||||
if (accountSlot) {
|
||||
getCurrentUser()
|
||||
.then((user) => {
|
||||
if (user) void checkUsername(user.$id);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error('Anmeldestatus konnte nicht geprüft werden', error);
|
||||
})
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
// „Mehrere Karten hochladen“ auf /karten: mehrere Bilder wählen, je einen Namen vergeben, alle auf einmal speichern und veröffentlichen.
|
||||
// Jede Karte wird mit der Vorlage „Nur Bild“ gespeichert (das Bild ist schon das fertige Kartendesign) und direkt veröffentlicht.
|
||||
import '../account/account.css';
|
||||
import '../community/community.css';
|
||||
import './cards.css';
|
||||
import { cardsService } from '../account/cards';
|
||||
import { DEFAULT_CONFIG, type CardConfig } from '../card/config';
|
||||
import { clampCrop } from '../card/crop';
|
||||
import { PREVIEW_QUALITY, PREVIEW_WIDTH } from '../card/geometry';
|
||||
import { exportCard, loadCardAssets } from '../card/render';
|
||||
import { autoPublish } from '../community/auto-publish';
|
||||
import { CONSENT_TEXT, publishFailureText } from '../community/publish-dialog';
|
||||
import { el } from '../editor/dom';
|
||||
import { ACCEPTED_TYPES, SIZE_MESSAGE, TYPE_MESSAGE, decodeImage, validateImageFile } from '../editor/upload';
|
||||
import { t } from '../i18n/i18n';
|
||||
|
||||
const MAX_FILES = 20;
|
||||
/** Fehler der Dateiprüfung (deutsch markiert, angezeigt mit t()). */
|
||||
const FILE_MESSAGES = new Set<string>([TYPE_MESSAGE, SIZE_MESSAGE]);
|
||||
|
||||
interface Row {
|
||||
file: File;
|
||||
url: string;
|
||||
name: HTMLInputElement;
|
||||
status: HTMLElement;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
function baseName(file: File): string {
|
||||
return file.name.replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').trim().slice(0, 60) || t('Meine Karte');
|
||||
}
|
||||
|
||||
async function saveAndPublish(file: File, name: string): Promise<void> {
|
||||
const check = validateImageFile(file);
|
||||
if (!check.ok) throw new Error(check.message);
|
||||
const image = await decodeImage(file);
|
||||
const config: CardConfig = { ...DEFAULT_CONFIG, template: 'image', crop: clampCrop(image, DEFAULT_CONFIG.crop) };
|
||||
const assets = await loadCardAssets(config, image);
|
||||
const preview = await exportCard(config, assets, { width: PREVIEW_WIDTH, type: 'image/webp', quality: PREVIEW_QUALITY });
|
||||
const card = await cardsService().save({ name, config, image: file, imageChanged: true, preview, previous: null });
|
||||
await autoPublish(card);
|
||||
}
|
||||
|
||||
/** Öffnet die Dateiauswahl und danach den Dialog. Löst auf, wenn er geschlossen ist; `changed`: mindestens eine Karte gespeichert. */
|
||||
export function bulkUpload(signal?: AbortSignal): Promise<{ changed: boolean }> {
|
||||
return new Promise((resolve) => {
|
||||
const picker = el('input', { type: 'file', accept: ACCEPTED_TYPES.join(','), multiple: true, hidden: true });
|
||||
picker.addEventListener('change', () => {
|
||||
const files = [...(picker.files ?? [])].slice(0, MAX_FILES);
|
||||
picker.remove();
|
||||
if (files.length === 0) resolve({ changed: false });
|
||||
else void openDialog(files, signal).then(resolve);
|
||||
});
|
||||
picker.addEventListener('cancel', () => {
|
||||
picker.remove();
|
||||
resolve({ changed: false });
|
||||
});
|
||||
document.body.append(picker);
|
||||
picker.click();
|
||||
});
|
||||
}
|
||||
|
||||
function openDialog(files: File[], signal?: AbortSignal): Promise<{ changed: boolean }> {
|
||||
return new Promise((resolve) => {
|
||||
let busy = false;
|
||||
let changed = false;
|
||||
const rows: Row[] = files.map((file, i) => {
|
||||
const name = el('input', {
|
||||
class: 'auth-input',
|
||||
type: 'text',
|
||||
id: `bulk-name-${i}`,
|
||||
maxlength: '60',
|
||||
autocomplete: 'off',
|
||||
'aria-label': t('Name der Karte'),
|
||||
});
|
||||
name.value = baseName(file);
|
||||
return { file, url: URL.createObjectURL(file), name, status: el('span', { class: 'bulk-status', role: 'status' }), done: false };
|
||||
});
|
||||
|
||||
const list = el(
|
||||
'ul',
|
||||
{ class: 'bulk-list' },
|
||||
...rows.map((r) =>
|
||||
el('li', { class: 'bulk-row' }, el('img', { class: 'bulk-thumb', src: r.url, alt: '' }), el('div', { class: 'bulk-fields' }, r.name, r.status)),
|
||||
),
|
||||
);
|
||||
const errorEl = el('p', { class: 'auth-msg auth-msg-error', role: 'alert' });
|
||||
const cancel = el('button', { type: 'button', class: 'auth-btn' }, t('Abbrechen'));
|
||||
const send = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, t('Alle veröffentlichen'));
|
||||
const form = el(
|
||||
'form',
|
||||
{ class: 'report-form', novalidate: true },
|
||||
el('p', { class: 'report-text' }, t('Gib jeder Karte einen Namen. Alle werden gespeichert und direkt in der Community veröffentlicht.')),
|
||||
list,
|
||||
el('p', { class: 'auth-hint' }, t(CONSENT_TEXT)),
|
||||
errorEl,
|
||||
el('div', { class: 'report-actions' }, cancel, send),
|
||||
);
|
||||
const dialog = el(
|
||||
'dialog',
|
||||
{ class: 'report-dialog publish-dialog bulk-dialog', 'aria-labelledby': 'bulk-heading' },
|
||||
el('div', { class: 'report-card' }, el('h2', { id: 'bulk-heading', class: 'report-title' }, t('Mehrere Karten hochladen')), form),
|
||||
);
|
||||
|
||||
let closed = false;
|
||||
const finish = () => {
|
||||
if (closed || busy) return;
|
||||
closed = true;
|
||||
signal?.removeEventListener('abort', finish);
|
||||
if (dialog.open) dialog.close();
|
||||
dialog.remove();
|
||||
for (const r of rows) URL.revokeObjectURL(r.url);
|
||||
resolve({ changed });
|
||||
};
|
||||
|
||||
async function run(): Promise<void> {
|
||||
const empty = rows.find((r) => !r.done && r.name.value.trim() === '');
|
||||
if (empty) {
|
||||
errorEl.textContent = t('Bitte gib jeder Karte einen Namen.');
|
||||
empty.name.focus();
|
||||
return;
|
||||
}
|
||||
errorEl.textContent = '';
|
||||
busy = true;
|
||||
send.disabled = true;
|
||||
cancel.disabled = true;
|
||||
let failed = 0;
|
||||
for (const r of rows) {
|
||||
if (r.done) continue;
|
||||
r.name.disabled = true;
|
||||
r.status.className = 'bulk-status';
|
||||
r.status.textContent = t('Wird veröffentlicht …');
|
||||
try {
|
||||
await saveAndPublish(r.file, r.name.value.trim());
|
||||
r.done = true;
|
||||
changed = true;
|
||||
r.status.className = 'bulk-status bulk-ok';
|
||||
r.status.textContent = t('Veröffentlicht');
|
||||
} catch (err) {
|
||||
failed += 1;
|
||||
r.name.disabled = false;
|
||||
r.status.className = 'bulk-status bulk-error';
|
||||
r.status.textContent = err instanceof Error && FILE_MESSAGES.has(err.message) ? t(err.message) : publishFailureText(err);
|
||||
}
|
||||
}
|
||||
busy = false;
|
||||
cancel.disabled = false;
|
||||
if (failed === 0) {
|
||||
cancel.textContent = t('Schließen');
|
||||
send.remove();
|
||||
cancel.focus();
|
||||
} else {
|
||||
send.disabled = false;
|
||||
send.textContent = t('Nochmal versuchen');
|
||||
}
|
||||
}
|
||||
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
if (!busy) void run();
|
||||
});
|
||||
cancel.addEventListener('click', finish);
|
||||
dialog.addEventListener('cancel', (event) => {
|
||||
event.preventDefault();
|
||||
finish();
|
||||
});
|
||||
signal?.addEventListener('abort', () => {
|
||||
busy = false;
|
||||
finish();
|
||||
});
|
||||
document.body.append(dialog);
|
||||
dialog.showModal();
|
||||
rows[0]?.name.focus();
|
||||
});
|
||||
}
|
||||
@@ -25,9 +25,11 @@ const mocks = vi.hoisted(() => ({
|
||||
myPublications: vi.fn(),
|
||||
getLogo: vi.fn(),
|
||||
loadLogoBlob: vi.fn(),
|
||||
requireUsername: vi.fn(),
|
||||
authListeners: new Set<(user: User | null) => void>(),
|
||||
}));
|
||||
|
||||
vi.mock('./bulk-upload', () => ({ bulkUpload: vi.fn(async () => ({ changed: false })) }));
|
||||
vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
|
||||
vi.mock('../account/auth', () => ({
|
||||
getCurrentUser: mocks.getCurrentUser,
|
||||
@@ -38,6 +40,9 @@ vi.mock('../account/auth', () => ({
|
||||
},
|
||||
}));
|
||||
vi.mock('../account/login-dialog', () => ({ requireLogin: mocks.requireLogin }));
|
||||
// Der Namensdialog ist eine Attrappe (sein eigenes Verhalten steht in account/username-dialog.test.ts); das Veröffentlichen-Dialog-Modul
|
||||
// bekommt dieselbe Attrappe für `usernameDialog` (403 `usernameRequired`, siehe publish-dialog.test.ts).
|
||||
vi.mock('../account/username-dialog', () => ({ requireUsername: mocks.requireUsername, usernameDialog: vi.fn() }));
|
||||
vi.mock('./router', () => ({ navigate: mocks.navigate }));
|
||||
vi.mock('../editor/upload', () => ({
|
||||
READ_MESSAGE: 'Das Bild konnte nicht gelesen werden.',
|
||||
@@ -110,6 +115,7 @@ beforeEach(() => {
|
||||
store = createStore(initialEditorState());
|
||||
mocks.getCurrentUser.mockResolvedValue(user);
|
||||
mocks.requireLogin.mockResolvedValue(true);
|
||||
mocks.requireUsername.mockResolvedValue(true); // das Konto hat einen Benutzernamen
|
||||
mocks.myPublications.mockResolvedValue(new Map());
|
||||
mocks.loadPreview.mockImplementation(async () => new Blob(['p'], { type: 'image/webp' }));
|
||||
unmount = null;
|
||||
@@ -1004,12 +1010,11 @@ describe('Meine Karten: Veröffentlichen', () => {
|
||||
await vi.waitFor(() => expect(labels(tileFor('a'))).toContain('Veröffentlichen'));
|
||||
};
|
||||
|
||||
it('„Veröffentlichen“ öffnet den Dialog: leere Felder, nie die E-Mail im Anzeigenamen', async () => {
|
||||
it('„Veröffentlichen“ öffnet den Dialog: leere Felder, kein Anzeigename (der Benutzername steht bei der Karte), nie die E-Mail', async () => {
|
||||
await start();
|
||||
await openPublish('a');
|
||||
expect(dlgInput('title').value).toBe('');
|
||||
expect(dlgInput('displayName').value).toBe('');
|
||||
expect(dlgInput('displayName').placeholder).toBe('Anonym');
|
||||
expect(publishDialogEl()!.querySelector('input[name="displayName"]')).toBeNull();
|
||||
expect(publishDialogEl()!.textContent).not.toContain('a@b.de');
|
||||
expect(publishDialogEl()!.textContent).toContain('„Alpha“');
|
||||
expect(mocks.publishSavedCard).not.toHaveBeenCalled();
|
||||
@@ -1024,6 +1029,68 @@ describe('Meine Karten: Veröffentlichen', () => {
|
||||
await vi.waitFor(() => expect(document.activeElement).toBe(button(tileFor('a'), 'Veröffentlichen')));
|
||||
});
|
||||
|
||||
it('vor dem Dialog wird der Benutzername verlangt (Spec § 5): erst requireUsername, dann öffnet der Veröffentlichen-Dialog', async () => {
|
||||
const order: string[] = [];
|
||||
mocks.requireUsername.mockImplementation(async () => (order.push('name'), true));
|
||||
await start();
|
||||
button(tileFor('a'), 'Veröffentlichen').click();
|
||||
await vi.waitFor(() => expect(publishDialogEl()?.open).toBe(true));
|
||||
expect(order).toEqual(['name']);
|
||||
expect(mocks.requireUsername).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('der Nutzer bricht den Namensdialog ab (false): es öffnet kein Veröffentlichen-Dialog, nichts passiert, kein Fehlertext, die Knöpfe sind wieder frei', async () => {
|
||||
mocks.requireUsername.mockResolvedValue(false);
|
||||
await start();
|
||||
button(tileFor('a'), 'Veröffentlichen').click();
|
||||
await vi.waitFor(() => expect(mocks.requireUsername).toHaveBeenCalledTimes(1));
|
||||
await vi.waitFor(() => expect(button(tileFor('a'), 'Veröffentlichen').disabled).toBe(false));
|
||||
expect(publishDialogEl()).toBeNull();
|
||||
expect(mocks.publishSavedCard).not.toHaveBeenCalled();
|
||||
expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('');
|
||||
expect(tileFor('a').hasAttribute('aria-busy')).toBe(false);
|
||||
expect(badge('a')).toBe('');
|
||||
await vi.waitFor(() => expect(document.activeElement).toBe(button(tileFor('a'), 'Veröffentlichen')));
|
||||
// ein neuer Versuch fragt noch einmal
|
||||
mocks.requireUsername.mockResolvedValue(true);
|
||||
await openPublish('a');
|
||||
expect(mocks.requireUsername).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('während der Namensdialog offen ist, sind die Knöpfe der Kachel gesperrt (ein zweiter Klick öffnet keinen zweiten)', async () => {
|
||||
mocks.requireUsername.mockReturnValue(new Promise(() => {}));
|
||||
await start();
|
||||
button(tileFor('a'), 'Veröffentlichen').click();
|
||||
await vi.waitFor(() => expect(mocks.requireUsername).toHaveBeenCalledTimes(1));
|
||||
expect(button(tileFor('a'), 'Veröffentlichen').disabled).toBe(true);
|
||||
expect(tileFor('a').getAttribute('aria-busy')).toBe('true');
|
||||
});
|
||||
|
||||
it('lässt sich der Namensdialog nicht öffnen, steht der Grund an der Kachel und es öffnet kein Veröffentlichen-Dialog', async () => {
|
||||
mocks.requireUsername.mockRejectedValue(new Error('showModal kaputt'));
|
||||
await start();
|
||||
button(tileFor('a'), 'Veröffentlichen').click();
|
||||
await vi.waitFor(() => expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('Etwas ist schiefgelaufen. Bitte nochmal versuchen.'));
|
||||
expect(publishDialogEl()).toBeNull();
|
||||
expect(button(tileFor('a'), 'Veröffentlichen').disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('der Namensdialog bekommt das Signal der Seite; wird sie währenddessen verlassen, öffnet danach kein Veröffentlichen-Dialog mehr', async () => {
|
||||
let answer!: (ok: boolean) => void;
|
||||
mocks.requireUsername.mockReturnValue(new Promise<boolean>((resolve) => (answer = resolve)));
|
||||
await start();
|
||||
button(tileFor('a'), 'Veröffentlichen').click();
|
||||
await vi.waitFor(() => expect(mocks.requireUsername).toHaveBeenCalledTimes(1));
|
||||
const signal = mocks.requireUsername.mock.calls[0][0] as AbortSignal;
|
||||
expect(signal.aborted).toBe(false);
|
||||
unmount?.();
|
||||
unmount = null;
|
||||
expect(signal.aborted).toBe(true);
|
||||
answer(true);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(publishDialogEl()).toBeNull();
|
||||
});
|
||||
|
||||
it('ohne Pflicht-Checkbox wird nichts veröffentlicht', async () => {
|
||||
await start();
|
||||
await openPublish('a');
|
||||
@@ -1037,12 +1104,12 @@ describe('Meine Karten: Veröffentlichen', () => {
|
||||
mocks.publishSavedCard.mockResolvedValue({ id: 'g9' });
|
||||
await start();
|
||||
await openPublish('a');
|
||||
fillDialog('Mein Motiv', { issuer: 'Beispielbank', tags: 'Visa, Gold', displayName: 'Kenso' });
|
||||
fillDialog('Mein Motiv', { issuer: 'Beispielbank', tags: 'Visa, Gold' });
|
||||
sendForm();
|
||||
await vi.waitFor(() => expect(badge('a')).toBe('Veröffentlicht'));
|
||||
expect(mocks.publishSavedCard).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'a', name: 'Alpha' }),
|
||||
{ title: 'Mein Motiv', issuer: 'Beispielbank', tags: 'Visa, Gold', displayName: 'Kenso' },
|
||||
{ title: 'Mein Motiv', issuer: 'Beispielbank', tags: 'Visa, Gold' },
|
||||
);
|
||||
expect(publishDialogEl()).toBeNull();
|
||||
const link = tileFor('a').querySelector<HTMLAnchorElement>('a[data-action="view"]')!;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getCurrentUser, onAuthChange } from '../account/auth';
|
||||
import { cardsService, normalizeName, type SavedCard } from '../account/cards';
|
||||
import { errorMessage, errorType } from '../account/errors';
|
||||
import { requireLogin } from '../account/login-dialog';
|
||||
import { requireUsername } from '../account/username-dialog';
|
||||
import { LOGIN_NEEDED, LoginCancelled, withLogin } from '../account/with-login';
|
||||
import { clampCrop } from '../card/crop';
|
||||
import { CARD_W } from '../card/geometry';
|
||||
@@ -22,6 +23,7 @@ import type { EditorStore } from '../editor/state';
|
||||
import { PIXELS_MESSAGE, READ_MESSAGE, decodeImage } from '../editor/upload';
|
||||
import { msg, t } from '../i18n/i18n';
|
||||
import { forgetLogo } from '../logos/logo-asset';
|
||||
import { bulkUpload } from './bulk-upload';
|
||||
import { formatDate } from './format';
|
||||
import { navigate } from './router';
|
||||
|
||||
@@ -78,7 +80,16 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
|
||||
|
||||
const heading = el('h1', { tabindex: '-1' }, t('Meine Karten'));
|
||||
const body = el('section', { class: 'cards-page', 'aria-label': t('Gespeicherte Karten') });
|
||||
root.replaceChildren(heading, body);
|
||||
const bulkButton = el('button', { type: 'button', class: 'auth-btn auth-btn-primary' }, t('Mehrere Karten hochladen'));
|
||||
bulkButton.addEventListener('click', () => void startBulk());
|
||||
root.replaceChildren(heading, el('p', { class: 'cards-bulk' }, bulkButton), body);
|
||||
|
||||
async function startBulk(): Promise<void> {
|
||||
if (!(await getCurrentUser()) && !(await requireLogin('login'))) return;
|
||||
if (disposed || !(await requireUsername(disposal.signal)) || disposed) return;
|
||||
const { changed } = await bulkUpload(disposal.signal);
|
||||
if (changed && !disposed) void load();
|
||||
}
|
||||
|
||||
function revokePreviews(keep: ReadonlySet<string> = new Set()): void {
|
||||
for (const [fileId, url] of previewUrls) {
|
||||
@@ -404,6 +415,8 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
|
||||
|
||||
function startPublish(): void {
|
||||
void run(async () => {
|
||||
// Ohne Benutzernamen geht es nicht weiter (Spec § 5): erst der Namensdialog; „Abbrechen“ heißt, es passiert nichts.
|
||||
if (!(await requireUsername(disposal.signal)) || disposed) return;
|
||||
await publishDialog({ cardName: card.name, signal: disposal.signal, submit: (form) => publishWithCheck(card, form) });
|
||||
}).then(() => focusControl('[data-action="view"], [data-action="withdraw"], [data-action="publish"]'));
|
||||
}
|
||||
|
||||
@@ -146,3 +146,14 @@
|
||||
.card-rename-input {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
/* Mehrere Karten hochladen (bulk-upload.ts) */
|
||||
.cards-bulk { margin: 0 0 16px; }
|
||||
.bulk-dialog .report-card { max-width: 560px; }
|
||||
.bulk-list { list-style: none; margin: 12px 0; padding: 0; display: grid; gap: 10px; max-height: 55vh; overflow-y: auto; }
|
||||
.bulk-row { display: flex; gap: 12px; align-items: center; }
|
||||
.bulk-thumb { width: 96px; aspect-ratio: 1536 / 969; object-fit: cover; border-radius: 8px; flex: none; background: rgba(127, 127, 127, 0.15); }
|
||||
.bulk-fields { flex: 1; min-width: 0; display: grid; gap: 4px; }
|
||||
.bulk-status { font-size: 0.85rem; opacity: 0.8; min-height: 1.1em; }
|
||||
.bulk-ok { color: #1a8f4c; opacity: 1; }
|
||||
.bulk-error { color: #d23c3c; opacity: 1; }
|
||||
@@ -41,6 +41,16 @@ const report = (n: number, reason: string): GalleryReport => ({
|
||||
createdAt: '2026-10-05T12:00:00.000Z',
|
||||
});
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
const settle = async () => {
|
||||
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||
};
|
||||
@@ -70,6 +80,12 @@ const navigate = vi.fn<(path: string) => void>();
|
||||
const saveBlobAs = vi.fn<(b: Blob, name: string) => void>();
|
||||
const withLogin = vi.fn(<T>(fn: () => Promise<T>): Promise<T> => fn());
|
||||
const getUser = vi.fn<() => Promise<User | null>>();
|
||||
/** Die Benutzernamen (eine Abfrage für die Konten der Seite) und die Hörer des eigenen Umbenennens. */
|
||||
const usernamesFor = vi.fn<CommunityDeps['usernamesFor']>();
|
||||
let nameListeners: Set<(userId: string, username: string) => void>;
|
||||
const emitName = (userId: string, username: string) => {
|
||||
for (const fn of [...nameListeners]) fn(userId, username);
|
||||
};
|
||||
|
||||
function deps(): CommunityDeps {
|
||||
return {
|
||||
@@ -81,6 +97,11 @@ function deps(): CommunityDeps {
|
||||
setVisibility,
|
||||
currentUser: () => user,
|
||||
getUser,
|
||||
usernamesFor,
|
||||
onUsernameChange: (fn) => {
|
||||
nameListeners.add(fn);
|
||||
return () => nameListeners.delete(fn);
|
||||
},
|
||||
onAuthChange: (fn) => {
|
||||
authListeners.add(fn);
|
||||
return () => authListeners.delete(fn);
|
||||
@@ -114,7 +135,7 @@ const errorText = () => q('[role="alert"]')?.textContent ?? '';
|
||||
const statusText = () => q('.detail-info [role="status"]')?.textContent ?? '';
|
||||
|
||||
beforeEach(() => {
|
||||
for (const fn of [getCard, listReports, loadImage, reportCard, withdrawCard, setVisibility, navigate, saveBlobAs, withLogin, getUser]) {
|
||||
for (const fn of [getCard, listReports, loadImage, reportCard, withdrawCard, setVisibility, navigate, saveBlobAs, withLogin, getUser, usernamesFor]) {
|
||||
fn.mockReset();
|
||||
}
|
||||
getCard.mockResolvedValue(card());
|
||||
@@ -123,8 +144,10 @@ beforeEach(() => {
|
||||
withdrawCard.mockResolvedValue(undefined);
|
||||
setVisibility.mockResolvedValue(undefined);
|
||||
getUser.mockResolvedValue(null);
|
||||
usernamesFor.mockResolvedValue(new Map()); // das Konto des Autors hat kein Profil: es gilt der alte Anzeigename
|
||||
user = null;
|
||||
authListeners = new Set();
|
||||
nameListeners = new Set();
|
||||
urlCounter = 0;
|
||||
revoked.length = 0;
|
||||
URL.revokeObjectURL = vi.fn((url: string) => {
|
||||
@@ -354,6 +377,251 @@ describe('Detailseite: Karte', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Detailseite: „von @name“', () => {
|
||||
const meta = () => q('.detail-meta')?.textContent;
|
||||
|
||||
it('fragt den Namen des Autors einmal je geladener Karte, mit seiner Konto-ID', async () => {
|
||||
await mount();
|
||||
expect(usernamesFor).toHaveBeenCalledExactlyOnceWith(['u1']);
|
||||
});
|
||||
|
||||
it('zeigt „von @name“ und das Datum; ein Profil geht dem alten Anzeigenamen vor', async () => {
|
||||
usernamesFor.mockResolvedValue(new Map([['u1', 'Kenso.K']]));
|
||||
await mount(); // die Karte trägt noch den alten Anzeigenamen „Kenso“
|
||||
expect(meta()).toBe('von @Kenso.K · 04.10.2026');
|
||||
});
|
||||
|
||||
it('ohne Profil: der alte Anzeigename (ohne @), sonst „von Anonym“', async () => {
|
||||
await mount();
|
||||
expect(meta()).toBe('von Kenso · 04.10.2026');
|
||||
unmount?.();
|
||||
getCard.mockResolvedValue(card({ displayName: '' }));
|
||||
await mount();
|
||||
expect(meta()).toBe('von Anonym · 04.10.2026');
|
||||
});
|
||||
|
||||
it('die Karte steht, ohne auf den Namen zu warten; die Zeile „von … · Datum“ erscheint, sobald der Name da ist', async () => {
|
||||
const lookup = deferred<Map<string, string>>();
|
||||
usernamesFor.mockReturnValue(lookup.promise);
|
||||
await mount();
|
||||
expect(q('h1')?.textContent).toBe('Mitternachtsblau');
|
||||
expect(q('.detail-card img')).not.toBeNull();
|
||||
expect(buttonTexts()).toEqual(['Herunterladen', 'Als Vorlage gestalten', 'Melden']);
|
||||
expect(meta()).toBe('');
|
||||
lookup.resolve(new Map([['u1', 'Kenso.K']]));
|
||||
await settle();
|
||||
expect(meta()).toBe('von @Kenso.K · 04.10.2026');
|
||||
});
|
||||
|
||||
describe('solange der Name des Autors noch gesucht wird, ist die Zeile leer (kein Ersatz, der gleich wieder wechselt)', () => {
|
||||
it('die Zeile bleibt als leeres Element stehen (die Zeilenhöhe hält die CSS-Regel für :empty), danach steht dasselbe Element voll da', async () => {
|
||||
const lookup = deferred<Map<string, string>>();
|
||||
usernamesFor.mockReturnValue(lookup.promise);
|
||||
await mount();
|
||||
const line = q('.detail-meta')!;
|
||||
expect(line.textContent).toBe('');
|
||||
expect(line.childNodes).toHaveLength(0); // sonst griffe :empty nicht
|
||||
lookup.resolve(new Map([['u1', 'Kenso.K']]));
|
||||
await settle();
|
||||
expect(q('.detail-meta')).toBe(line);
|
||||
expect(line.textContent).toBe('von @Kenso.K · 04.10.2026');
|
||||
});
|
||||
|
||||
it('kein Profil: erst jetzt der Ersatz, der alte Anzeigename (ohne @) bzw. „von Anonym“, mit dem Datum', async () => {
|
||||
const lookup = deferred<Map<string, string>>();
|
||||
usernamesFor.mockReturnValue(lookup.promise);
|
||||
await mount();
|
||||
expect(meta()).toBe('');
|
||||
lookup.resolve(new Map());
|
||||
await settle();
|
||||
expect(meta()).toBe('von Kenso · 04.10.2026');
|
||||
unmount?.();
|
||||
|
||||
const second = deferred<Map<string, string>>();
|
||||
usernamesFor.mockReturnValue(second.promise);
|
||||
getCard.mockResolvedValue(card({ displayName: '' }));
|
||||
await mount();
|
||||
expect(meta()).toBe('');
|
||||
second.resolve(new Map());
|
||||
await settle();
|
||||
expect(meta()).toBe('von Anonym · 04.10.2026');
|
||||
});
|
||||
|
||||
it('die Suche scheitert: erst jetzt der Ersatz, kein Fehlertext', async () => {
|
||||
const lookup = deferred<Map<string, string>>();
|
||||
usernamesFor.mockReturnValue(lookup.promise);
|
||||
await mount();
|
||||
expect(meta()).toBe('');
|
||||
lookup.reject(new TypeError('Failed to fetch'));
|
||||
await settle();
|
||||
expect(meta()).toBe('von Kenso · 04.10.2026');
|
||||
expect(errorText()).toBe('');
|
||||
});
|
||||
|
||||
it('ein gemeldeter Name (Umbenennen, Lesen des eigenen Profils) beendet das Warten sofort', async () => {
|
||||
const lookup = deferred<Map<string, string>>();
|
||||
usernamesFor.mockReturnValue(lookup.promise);
|
||||
await mount();
|
||||
emitName('u2', 'Fremd'); // ein anderes Konto ändert nichts
|
||||
expect(meta()).toBe('');
|
||||
emitName('u1', 'Kenso.K');
|
||||
expect(meta()).toBe('von @Kenso.K · 04.10.2026');
|
||||
lookup.resolve(new Map([['u1', 'Kenso.K']]));
|
||||
await settle();
|
||||
expect(meta()).toBe('von @Kenso.K · 04.10.2026');
|
||||
});
|
||||
|
||||
it('ein Konto, dessen Name schon feststeht (dieselbe Seite zeigt die Karte noch einmal), zeigt die Zeile sofort', async () => {
|
||||
user = owner;
|
||||
getCard.mockResolvedValue(card({ hidden: true })); // sichtbar nur für den Autor
|
||||
usernamesFor.mockResolvedValueOnce(new Map([['u1', 'Kenso.K']]));
|
||||
await mount();
|
||||
expect(meta()).toBe('von @Kenso.K · 04.10.2026');
|
||||
login(null); // abgemeldet: die Karte ist nicht mehr zu sehen
|
||||
expect(q('h1')?.textContent).toBe(UNAVAILABLE);
|
||||
login(owner); // wieder angemeldet: die Seite lädt die Karte neu
|
||||
await settle();
|
||||
expect(meta()).toBe('von @Kenso.K · 04.10.2026');
|
||||
expect(usernamesFor).toHaveBeenCalledTimes(1); // der Name steht schon: keine zweite Abfrage
|
||||
});
|
||||
|
||||
it('ein Konto, für das schon „kein Profil“ feststeht, zeigt seinen Ersatz sofort, auch während die neue Abfrage läuft', async () => {
|
||||
user = owner;
|
||||
getCard.mockResolvedValue(card({ hidden: true }));
|
||||
await mount(); // die erste Abfrage liefert nichts: kein Profil
|
||||
expect(meta()).toBe('von Kenso · 04.10.2026');
|
||||
login(null);
|
||||
usernamesFor.mockReturnValue(new Promise(() => {})); // die nächste Abfrage hängt
|
||||
login(owner);
|
||||
await settle();
|
||||
expect(meta()).toBe('von Kenso · 04.10.2026');
|
||||
});
|
||||
|
||||
it('eine Karte ohne Konto-ID hat nichts zu suchen: der Ersatz steht sofort', async () => {
|
||||
getCard.mockResolvedValue(card({ ownerId: '', displayName: 'Anna' }));
|
||||
usernamesFor.mockReturnValue(new Promise(() => {}));
|
||||
await mount();
|
||||
expect(meta()).toBe('von Anna · 04.10.2026');
|
||||
});
|
||||
|
||||
it('wird die Seite verlassen, bevor die Suche fertig ist, ändert eine spätere Antwort nichts', async () => {
|
||||
const lookup = deferred<Map<string, string>>();
|
||||
usernamesFor.mockReturnValue(lookup.promise);
|
||||
await mount();
|
||||
unmount?.();
|
||||
unmount = null;
|
||||
const html = root.innerHTML;
|
||||
lookup.resolve(new Map([['u1', 'Kenso.K']]));
|
||||
await settle();
|
||||
expect(root.innerHTML).toBe(html);
|
||||
});
|
||||
|
||||
it('Englisch: leer, dann „by @name · Datum“', async () => {
|
||||
setLang('en', { persist: false });
|
||||
const lookup = deferred<Map<string, string>>();
|
||||
usernamesFor.mockReturnValue(lookup.promise);
|
||||
await mount();
|
||||
expect(meta()).toBe('');
|
||||
lookup.resolve(new Map([['u1', 'Kenso.K']]));
|
||||
await settle();
|
||||
expect(meta()).toBe('by @Kenso.K · Oct 4, 2026');
|
||||
});
|
||||
});
|
||||
|
||||
it('schlägt die Abfrage fehl, bleibt es beim Ersatz: kein Fehlertext, die Seite geht weiter', async () => {
|
||||
usernamesFor.mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
await mount();
|
||||
expect(meta()).toBe('von Kenso · 04.10.2026');
|
||||
expect(errorText()).toBe('');
|
||||
expect(buttonTexts()).toEqual(['Herunterladen', 'Als Vorlage gestalten', 'Melden']);
|
||||
});
|
||||
|
||||
it('eine Karte ohne Konto-ID (Altbestand): keine Abfrage, der Ersatz', async () => {
|
||||
getCard.mockResolvedValue(card({ ownerId: '', displayName: '' }));
|
||||
await mount();
|
||||
expect(usernamesFor).not.toHaveBeenCalled();
|
||||
expect(meta()).toBe('von Anonym · 04.10.2026');
|
||||
});
|
||||
|
||||
it('der Name erscheint als Text, nie als HTML', async () => {
|
||||
usernamesFor.mockResolvedValue(new Map([['u1', '<b>x</b>']]));
|
||||
await mount();
|
||||
expect(meta()).toBe('von @<b>x</b> · 04.10.2026');
|
||||
expect(q('.detail-meta b')).toBeNull();
|
||||
});
|
||||
|
||||
it('eigenes Umbenennen: die Detailseite zeigt sofort den neuen Namen; ein anderes Konto ändert nichts', async () => {
|
||||
usernamesFor.mockResolvedValue(new Map([['u1', 'Alt']]));
|
||||
await mount();
|
||||
expect(meta()).toBe('von @Alt · 04.10.2026');
|
||||
emitName('u2', 'Fremd');
|
||||
expect(meta()).toBe('von @Alt · 04.10.2026');
|
||||
emitName('u1', 'Neu');
|
||||
expect(meta()).toBe('von @Neu · 04.10.2026');
|
||||
});
|
||||
|
||||
it('der Autor ohne Profil wählt einen Namen (eigene Karte): „von @name“ ersetzt den Ersatz sofort', async () => {
|
||||
await mount();
|
||||
expect(meta()).toBe('von Kenso · 04.10.2026');
|
||||
emitName('u1', 'Kenso.K');
|
||||
expect(meta()).toBe('von @Kenso.K · 04.10.2026');
|
||||
});
|
||||
|
||||
it('dieselbe Meldung noch einmal schreibt die Zeile nicht neu', async () => {
|
||||
usernamesFor.mockResolvedValue(new Map([['u1', 'Kenso']]));
|
||||
await mount();
|
||||
const line = q('.detail-meta')!;
|
||||
const text = line.firstChild;
|
||||
emitName('u1', 'Kenso');
|
||||
expect(q('.detail-meta')).toBe(line);
|
||||
expect(line.firstChild).toBe(text);
|
||||
});
|
||||
|
||||
it('die Antwort für eine inzwischen verlassene Ansicht (die Karte ist nicht mehr zu sehen) ändert nichts', async () => {
|
||||
user = owner;
|
||||
getCard.mockResolvedValue(card({ hidden: true }));
|
||||
const lookup = deferred<Map<string, string>>();
|
||||
usernamesFor.mockReturnValue(lookup.promise);
|
||||
await mount();
|
||||
expect(q('h1')?.textContent).toBe('Mitternachtsblau');
|
||||
login(null); // abgemeldet: die ausgeblendete Karte ist für niemand mehr sichtbar
|
||||
expect(q('h1')?.textContent).toBe(UNAVAILABLE);
|
||||
const html = root.innerHTML;
|
||||
lookup.resolve(new Map([['u1', 'Kenso.K']]));
|
||||
await settle();
|
||||
expect(root.innerHTML).toBe(html);
|
||||
});
|
||||
|
||||
it('Aufräumen: der Hörer des Umbenennens ist abgemeldet, eine später eintreffende Antwort ändert nichts', async () => {
|
||||
const lookup = deferred<Map<string, string>>();
|
||||
usernamesFor.mockReturnValue(lookup.promise);
|
||||
await mount();
|
||||
expect(nameListeners.size).toBe(1);
|
||||
unmount?.();
|
||||
unmount = null;
|
||||
expect(nameListeners.size).toBe(0);
|
||||
const html = root.innerHTML;
|
||||
lookup.resolve(new Map([['u1', 'Kenso.K']]));
|
||||
emitName('u1', 'Neu');
|
||||
await settle();
|
||||
expect(root.innerHTML).toBe(html);
|
||||
});
|
||||
|
||||
it('eine Karte, die nicht (mehr) verfügbar ist, fragt keinen Namen', async () => {
|
||||
getCard.mockResolvedValue(null);
|
||||
await mount();
|
||||
expect(usernamesFor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Englisch: „by @name“', async () => {
|
||||
setLang('en', { persist: false });
|
||||
usernamesFor.mockResolvedValue(new Map([['u1', 'Kenso.K']]));
|
||||
await mount();
|
||||
expect(meta()).toBe('by @Kenso.K · Oct 4, 2026');
|
||||
expect(germanLeft(root)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Detailseite: Rechte beim Ausführen', () => {
|
||||
it('Admin-Recht verloren, Knopf noch im DOM: Ausblenden sendet nichts', async () => {
|
||||
user = admin;
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
// Detailseite „/community/<id>“: große Karte, Angaben, Herunterladen, „Als Vorlage gestalten“, Melden.
|
||||
// Autor: Zurückziehen. Admin (Label `admin`): Ausblenden/Einblenden, Löschen, Meldungszahl und Gründe.
|
||||
// „von @name“ (Spec § 5): Der Benutzername des Autors kommt nach dem Laden der Karte in einer Abfrage nach. Solange er noch gesucht
|
||||
// wird, bleibt die Zeile „von … · Datum“ leer (ein Ersatz, der gleich wieder wechselt, läse sich falsch; die Zeile behält ihre Höhe,
|
||||
// siehe community.css); danach steht der Name, ohne Profil oder bei einem Fehler der alte Anzeigename bzw. „Anonym“. Ein eigenes
|
||||
// Umbenennen aktualisiert die Zeile sofort.
|
||||
// Ausgeblendete oder fehlende Karten sieht ein Besucher nur als „nicht (mehr) verfügbar“: Appwrite antwortet ihm mit 404,
|
||||
// Autor und Admin dürfen die Zeile lesen und bekommen die Seite mit dem Hinweis „Ausgeblendet“.
|
||||
// Alle Texte kommen in der Sprache der Seite (t()); ein Sprachwechsel baut die Seite neu auf (main.ts).
|
||||
import '../community/community.css';
|
||||
import { currentUser, getCurrentUser, isAdmin, onAuthChange, type User } from '../account/auth';
|
||||
import { errorMessage } from '../account/errors';
|
||||
import { byline, onUsernameChange, usernamesFor } from '../account/profile';
|
||||
import { LOGIN_NEEDED, LoginCancelled, withLogin } from '../account/with-login';
|
||||
import { ApiError, apiErrorText, reportCard, setVisibility, withdrawCard, type ReportReason } from '../community/api';
|
||||
import {
|
||||
@@ -37,6 +42,10 @@ export interface CommunityDeps {
|
||||
currentUser(): User | null;
|
||||
/** Fragt den Anmeldestatus beim Server (bei einem Direktaufruf ist er noch nicht bekannt). */
|
||||
getUser(): Promise<User | null>;
|
||||
/** Die Benutzernamen der Konten (eine Abfrage für alle); scheitert sie, fehlen die Namen (kein Fehler). */
|
||||
usernamesFor(userIds: readonly string[]): Promise<Map<string, string>>;
|
||||
/** Meldet, wenn der eigene Benutzername feststeht oder sich ändert; gibt die Abmeldung zurück. */
|
||||
onUsernameChange(fn: (userId: string, username: string) => void): () => void;
|
||||
onAuthChange(fn: (user: User | null) => void): () => void;
|
||||
navigate(path: string): void;
|
||||
saveBlobAs(blob: Blob, filename: string): void;
|
||||
@@ -53,6 +62,8 @@ export const defaultDeps: CommunityDeps = {
|
||||
setVisibility,
|
||||
currentUser,
|
||||
getUser: getCurrentUser,
|
||||
usernamesFor,
|
||||
onUsernameChange,
|
||||
onAuthChange,
|
||||
navigate,
|
||||
saveBlobAs,
|
||||
@@ -107,6 +118,10 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit
|
||||
let busy: Busy = null;
|
||||
let imageBlob: Blob | null = null;
|
||||
let imageUrl: string | null = null;
|
||||
/** Die Benutzernamen, die diese Seite kennt (Konto-ID → Name); wer fehlt, hat kein Profil oder wurde noch nicht gefragt. */
|
||||
const names = new Map<string, string>();
|
||||
/** Die Konten, deren Name nachgeschlagen ist (gefunden oder nicht, auch bei einem Fehler): ihre Zeile „von …“ steht fest. */
|
||||
const settled = new Set<string>();
|
||||
|
||||
const page = el('div', { class: 'detail-page' });
|
||||
root.replaceChildren(page);
|
||||
@@ -203,6 +218,8 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit
|
||||
// ---- Karte ----
|
||||
|
||||
const infoSlot = el('div', { class: 'detail-info' });
|
||||
/** „von @name · Datum“. Eigenes Element, damit ein nachkommender oder geänderter Name nur diese Zeile neu schreibt; leer, solange er gesucht wird. */
|
||||
const metaEl = el('p', { class: 'detail-meta' });
|
||||
const noticeSlot = el('div', { class: 'detail-notice-slot' });
|
||||
const actionsSlot = el('div', { class: 'detail-actions' });
|
||||
const adminSlot = el('div', { class: 'detail-admin-slot' });
|
||||
@@ -221,6 +238,47 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit
|
||||
return b;
|
||||
}
|
||||
|
||||
/**
|
||||
* „von @name · Datum“ der gezeigten Karte; ohne Profil des Autors steht sein alter Anzeigename bzw. „Anonym“. Solange der Name des Autors
|
||||
* noch gesucht wird, bleibt die ganze Zeile leer: so springt auch das Datum nicht, wenn der Name dazukommt.
|
||||
*/
|
||||
function renderMeta(): void {
|
||||
if (!card) return;
|
||||
if (card.ownerId !== '' && !names.has(card.ownerId) && !settled.has(card.ownerId)) {
|
||||
metaEl.textContent = '';
|
||||
return;
|
||||
}
|
||||
const date = formatDate(card.createdAt);
|
||||
const author = byline(card.ownerId, card.displayName, names);
|
||||
metaEl.textContent = date ? `${author} · ${date}` : author;
|
||||
}
|
||||
|
||||
/** Übernimmt einen Namen und zeichnet die Zeile neu, wenn er neu ist und zur gezeigten Karte gehört (dieselbe Meldung noch einmal ändert nichts). */
|
||||
function applyName(userId: string, username: string): void {
|
||||
if (username === '' || names.get(userId) === username) return;
|
||||
names.set(userId, username);
|
||||
if (card?.ownerId === userId) renderMeta();
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt den Benutzernamen des Autors (eine Abfrage). Die Anzeige braucht ihn nur als Beiwerk: schlägt die Abfrage fehl, steht danach der
|
||||
* Ersatz, ohne Fehlertext. Ein Name gehört dem Konto, nicht der Ansicht: eine späte Antwort ändert höchstens die Zeile, wenn die
|
||||
* gezeigte Karte demselben Konto gehört.
|
||||
*/
|
||||
async function loadAuthor(c: GalleryCard): Promise<void> {
|
||||
if (c.ownerId === '' || names.has(c.ownerId)) return;
|
||||
let found = new Map<string, string>();
|
||||
try {
|
||||
found = await deps.usernamesFor([c.ownerId]);
|
||||
} catch {
|
||||
// nur Beiwerk: es bleibt beim Ersatz
|
||||
}
|
||||
if (disposed) return;
|
||||
settled.add(c.ownerId);
|
||||
for (const [userId, username] of found) applyName(userId, username);
|
||||
if (card?.ownerId === c.ownerId) renderMeta(); // auch ohne Profil: jetzt steht der Ersatz
|
||||
}
|
||||
|
||||
/** Baut Hinweis, Knöpfe und Admin-Bereich aus dem Zustand neu auf; der Fokus bleibt auf dem gleichen Knopf. */
|
||||
function renderControls(): void {
|
||||
if (!card) return;
|
||||
@@ -310,12 +368,10 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit
|
||||
showMessage('', 'info');
|
||||
document.title = titleFor(route, c.title);
|
||||
|
||||
const date = formatDate(c.createdAt);
|
||||
const author = t('von {name}', { name: c.displayName !== '' ? c.displayName : t('Anonym') });
|
||||
infoSlot.replaceChildren(
|
||||
el('h1', { class: 'detail-title' }, c.title),
|
||||
...(c.issuer !== '' ? [el('p', { class: 'detail-issuer' }, c.issuer)] : []),
|
||||
el('p', { class: 'detail-meta' }, date ? `${author} · ${date}` : author),
|
||||
metaEl,
|
||||
...(c.tags.length > 0
|
||||
? [
|
||||
el(
|
||||
@@ -346,9 +402,11 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit
|
||||
el('p', { class: 'detail-back' }, el('a', { href: '/#community', 'data-link': true }, t('← Zur Galerie'))),
|
||||
el('div', { class: 'detail-layout' }, imageSlot, infoSlot),
|
||||
);
|
||||
renderMeta();
|
||||
renderControls();
|
||||
startImage(c);
|
||||
void ensureReports();
|
||||
void loadAuthor(c);
|
||||
}
|
||||
|
||||
// ---- Aktionen ----
|
||||
@@ -513,12 +571,16 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit
|
||||
void ensureReports();
|
||||
});
|
||||
|
||||
// Das eigene Umbenennen (und das Lesen des eigenen Namens nach dem Start): die Zeile „von @name“ zeigt gleich den neuen Namen.
|
||||
const stopName = deps.onUsernameChange(applyName);
|
||||
|
||||
void load();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
disposal.abort();
|
||||
stopAuth();
|
||||
stopName();
|
||||
leaveCard();
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { setLang } from '../i18n/i18n';
|
||||
import { formatDate, reportsLabel } from './format';
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('schreibt dd.mm.yyyy', () => {
|
||||
expect(formatDate(new Date(2026, 9, 4, 12).toISOString())).toBe('04.10.2026');
|
||||
@@ -19,6 +23,18 @@ describe('formatDate', () => {
|
||||
expect(formatDate('kaputt')).toBe('');
|
||||
});
|
||||
|
||||
it('der englische Formatierer wird einmal angelegt und danach wiederverwendet, nicht bei jedem Aufruf neu', async () => {
|
||||
// Eine frische Kopie des Moduls (und von i18n.ts), damit das Anlegen mitgezählt wird, wann immer es geschieht.
|
||||
vi.resetModules();
|
||||
const created = vi.spyOn(Intl, 'DateTimeFormat');
|
||||
const fresh = await import('./format');
|
||||
(await import('../i18n/i18n')).setLang('en', { persist: false });
|
||||
const iso = new Date(2026, 9, 4, 12).toISOString();
|
||||
const results = Array.from({ length: 5 }, () => fresh.formatDate(iso));
|
||||
expect(results).toEqual(Array(5).fill('Oct 4, 2026'));
|
||||
expect(created.mock.calls.length).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('folgt der Sprache bei jedem Aufruf', () => {
|
||||
const iso = new Date(2026, 9, 4, 12).toISOString();
|
||||
expect(formatDate(iso)).toBe('04.10.2026');
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
// Gemeinsame Formatierung der Seiten (Meine Karten, Community-Detailseite, Galerie-Raster, Logo-Infozeile).
|
||||
import { currentLang, t } from '../i18n/i18n';
|
||||
|
||||
// Der englische Formatierer hängt nicht von der Sprache der Seite ab (er wird nur bei Englisch benutzt): einmal angelegt, nicht je Aufruf.
|
||||
const EN_DATE = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
|
||||
/** Deutsch `dd.mm.yyyy`, Englisch `Oct 4, 2026`, jeweils in der Zeitzone des Betrachters; leer, wenn das Datum unlesbar ist. */
|
||||
export function formatDate(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
if (currentLang() === 'en') return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(date);
|
||||
if (currentLang() === 'en') return EN_DATE.format(date);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.${date.getFullYear()}`;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { setLang } from '../i18n/i18n';
|
||||
import { mountDonateBanner } from './donate-banner';
|
||||
|
||||
const KEY = 'aircard.donateBannerClosed';
|
||||
const URL_ = 'https://buymeacoffee.com/aircard';
|
||||
const TEXT = '☕ Gefällt dir AirCard-IOS? Spendier uns einen Kaffee – damit das Projekt weiterläuft.';
|
||||
|
||||
// Das Modul merkt sich im Speicher, dass das Banner geschlossen wurde (siehe unten): jeder Test bekommt darum eine frische Kopie von ihm
|
||||
// und von i18n.ts (die Sprache wird an dieser Kopie gesetzt).
|
||||
let mountDonateBanner: typeof import('./donate-banner').mountDonateBanner;
|
||||
let setLang: typeof import('../i18n/i18n').setLang;
|
||||
let slot: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ mountDonateBanner } = await import('./donate-banner'));
|
||||
({ setLang } = await import('../i18n/i18n'));
|
||||
localStorage.clear();
|
||||
document.body.innerHTML = '<div id="donate-banner"></div>';
|
||||
slot = document.getElementById('donate-banner') as HTMLElement;
|
||||
@@ -16,8 +21,26 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
/**
|
||||
* Ein Speicher, der den Zugriff verweigert (privates Fenster, gesperrte Website-Daten). `vi.spyOn(Storage.prototype, …)` greift in
|
||||
* happy-dom nicht, darum wird `localStorage` selbst ersetzt.
|
||||
*/
|
||||
function blockStorage(block: { read?: boolean; write?: boolean }): void {
|
||||
const denied = () => new DOMException('blockiert', 'SecurityError');
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: () => {
|
||||
if (block.read) throw denied();
|
||||
return null;
|
||||
},
|
||||
setItem: () => {
|
||||
if (block.write) throw denied();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('mountDonateBanner', () => {
|
||||
it('leere URL: nichts wird gerendert', () => {
|
||||
mountDonateBanner(slot, '');
|
||||
@@ -68,19 +91,39 @@ describe('mountDonateBanner', () => {
|
||||
});
|
||||
|
||||
it('localStorage wirft beim Lesen: Banner wird gezeigt', () => {
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw new DOMException('blockiert', 'SecurityError');
|
||||
});
|
||||
blockStorage({ read: true });
|
||||
expect(() => mountDonateBanner(slot, URL_)).not.toThrow();
|
||||
expect(slot.querySelectorAll('.donate-banner')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('localStorage wirft beim Schreiben: Banner verschwindet trotzdem, kein Fehler', () => {
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new DOMException('voll', 'QuotaExceededError');
|
||||
});
|
||||
blockStorage({ write: true });
|
||||
mountDonateBanner(slot, URL_);
|
||||
expect(() => slot.querySelector('button')!.click()).not.toThrow();
|
||||
expect(slot.childElementCount).toBe(0);
|
||||
});
|
||||
|
||||
it('localStorage gesperrt: das geschlossene Banner kommt beim erneuten Einhängen (Sprachwechsel) nicht zurück', () => {
|
||||
blockStorage({ read: true, write: true });
|
||||
mountDonateBanner(slot, URL_);
|
||||
slot.querySelector('button')!.click();
|
||||
expect(slot.childElementCount).toBe(0);
|
||||
mountDonateBanner(slot, URL_); // so baut main.ts den Rahmen bei einem Sprachwechsel neu auf
|
||||
expect(slot.childElementCount).toBe(0);
|
||||
});
|
||||
|
||||
it('ohne Schließen kommt das Banner beim erneuten Einhängen wieder, auch bei gesperrtem Speicher', () => {
|
||||
blockStorage({ read: true, write: true });
|
||||
mountDonateBanner(slot, URL_);
|
||||
mountDonateBanner(slot, URL_); // neu aufgebaut, ohne dass jemand es geschlossen hat
|
||||
expect(slot.querySelectorAll('.donate-banner')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('der Speicher lässt sich schreiben, aber nicht lesen: nach dem Schließen bleibt das Banner ebenfalls weg', () => {
|
||||
blockStorage({ read: true });
|
||||
mountDonateBanner(slot, URL_);
|
||||
slot.querySelector('button')!.click();
|
||||
mountDonateBanner(slot, URL_);
|
||||
expect(slot.childElementCount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -5,19 +5,24 @@ import { t } from '../i18n/i18n';
|
||||
|
||||
const CLOSED_KEY = 'aircard.donateBannerClosed';
|
||||
|
||||
// Auch im Speicher des Moduls gemerkt: bei gesperrtem localStorage bringt sonst der Neuaufbau des Rahmens (Sprachwechsel) das Banner zurück.
|
||||
let closedThisSession = false;
|
||||
|
||||
function isClosed(): boolean {
|
||||
if (closedThisSession) return true;
|
||||
try {
|
||||
return localStorage.getItem(CLOSED_KEY) !== null;
|
||||
} catch {
|
||||
return false; // Speicher gesperrt: das Banner bleibt sichtbar, bis die Seite neu geladen wird
|
||||
return false; // Speicher gesperrt: das Banner bleibt sichtbar, bis es jemand schließt
|
||||
}
|
||||
}
|
||||
|
||||
function rememberClosed(): void {
|
||||
closedThisSession = true;
|
||||
try {
|
||||
localStorage.setItem(CLOSED_KEY, '1');
|
||||
} catch {
|
||||
// gilt dann nur für diese Sitzung
|
||||
// gilt dann nur, solange die Seite offen ist
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
@@ -32,4 +32,21 @@ describe('Einzelne Quellen', () => {
|
||||
it('reportsLabel wird nur in pages/format.ts definiert', () => {
|
||||
expect(definedIn(/\bfunction\s+reportsLabel\b/)).toEqual(['pages/format.ts']);
|
||||
});
|
||||
|
||||
it('isRowMissing („die Zeile gibt es nicht“) wird nur in account/errors.ts definiert', () => {
|
||||
expect(definedIn(/\bfunction\s+isRowMissing\b/)).toEqual(['account/errors.ts']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Der Anzeigename (Spec Benutzernamen § 5) ist weg', () => {
|
||||
it('account/display-name.ts gibt es nicht mehr', () => {
|
||||
expect(existsSync(join(import.meta.dirname, 'account', 'display-name.ts'))).toBe(false);
|
||||
expect(existsSync(join(import.meta.dirname, 'account', 'display-name.test.ts'))).toBe(false);
|
||||
});
|
||||
|
||||
it('kein Quelltext liest oder schreibt ihn noch: weder das Modul noch der localStorage-Schlüssel noch seine Funktionen', () => {
|
||||
for (const pattern of [/display-name/, /aircard\.displayName/, /rememberedDisplayName/, /rememberDisplayName/, /DISPLAY_NAME_(?:KEY|MAX)/]) {
|
||||
expect(definedIn(pattern), String(pattern)).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user