Compare commits
16
Commits
ec0b51a949
...
13b46da2e0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13b46da2e0 | ||
|
|
6e5fe3e199 | ||
|
|
691b28df19 | ||
|
|
e0f11f905e | ||
|
|
0e4759a05a | ||
|
|
02b01c9deb | ||
|
|
8167a18857 | ||
|
|
29c9b2efaf | ||
|
|
76c3c3aea2 | ||
|
|
1fd08d6373 | ||
|
|
0bcdfe258d | ||
|
|
af05e5f1f1 | ||
|
|
7cb1381566 | ||
|
|
6134fd26d7 | ||
|
|
738358fcac | ||
|
|
6d152c11b3 |
No files matched your search
@@ -39,7 +39,7 @@ Alle Antworten sind JSON; Fehler haben die Form `{"error": "<deutscher Text>", "
|
||||
| `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, displayName?, mono?, fileId}` | Nutzer-JWT | `201 {id}` (Zeilen-ID des Logos, ein Hash aus Konto und Datei, nicht die 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/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 |
|
||||
@@ -70,17 +70,17 @@ Jede Fehlerantwort trägt neben dem deutschen Text einen `code`: den Schlüssel
|
||||
- **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 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. **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`) → 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).
|
||||
- **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)`; 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
|
||||
|
||||
|
||||
+92
-60
@@ -8,7 +8,7 @@
|
||||
* 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
|
||||
* 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 +17,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.
|
||||
*/
|
||||
|
||||
@@ -76,6 +79,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,17 +214,18 @@ 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 }) {
|
||||
@@ -221,66 +236,79 @@ function createLogoRoutes({ appwrite, salt, say, withLock, rateLimit, requireUse
|
||||
|
||||
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 +381,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 +430,4 @@ function createLogoRoutes({ appwrite, salt, say, withLock, rateLimit, requireUse
|
||||
];
|
||||
}
|
||||
|
||||
module.exports = { createLogoRoutes, logoLikeId, logoReportId, validateLogoUpload };
|
||||
module.exports = { createLogoRoutes, logoRowId, logoLikeId, logoReportId, validateLogoUpload };
|
||||
+343
-53
@@ -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,
|
||||
@@ -115,10 +116,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 +136,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 +145,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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,15 +160,26 @@ 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');
|
||||
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 +199,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 +264,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 +293,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 +301,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);
|
||||
});
|
||||
@@ -392,18 +425,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 +455,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 +542,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 +591,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 +613,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 +633,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 +647,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,34 +667,35 @@ 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 () => {
|
||||
@@ -687,6 +771,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 +970,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 +979,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 +1113,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 +1126,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 +1150,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 +1190,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 +1202,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 +1226,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 +1325,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 +1342,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 +1363,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 +1385,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 +1393,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 +1505,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)}…`);
|
||||
|
||||
@@ -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'`.
|
||||
|
||||
@@ -31,6 +31,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 settle = async () => {
|
||||
for (let i = 0; i < 30; i++) await Promise.resolve();
|
||||
};
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
@@ -215,6 +219,55 @@ 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('displayName')).toEqual(['publish-displayName-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({ displayName: 'x'.repeat(31) });
|
||||
send();
|
||||
expect(describedBy('displayName')).toEqual(['publish-displayName-hint', 'publish-error']);
|
||||
expect(describedBy('tags')).toEqual(['publish-tags-hint']);
|
||||
|
||||
fill({ consent: false });
|
||||
send();
|
||||
expect(describedBy('consent')).toEqual(['publish-error']);
|
||||
});
|
||||
|
||||
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 };
|
||||
expect(validatePublishForm(ok)).toBeNull();
|
||||
|
||||
@@ -5,7 +5,7 @@ import './community.css';
|
||||
import { rememberDisplayName, rememberedDisplayName } from '../account/display-name';
|
||||
import { errorMessage } from '../account/errors';
|
||||
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';
|
||||
@@ -24,6 +24,8 @@ const ISSUER_MAX = 40;
|
||||
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).
|
||||
@@ -160,7 +162,7 @@ export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcom
|
||||
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 +207,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 {
|
||||
@@ -233,7 +235,7 @@ 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;
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -85,7 +85,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();
|
||||
@@ -1207,6 +1207,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 +1350,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 +1446,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 +1619,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();
|
||||
|
||||
@@ -80,6 +80,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,11 +206,16 @@ 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();
|
||||
}
|
||||
@@ -231,6 +247,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,
|
||||
@@ -450,6 +469,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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -70,6 +70,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();
|
||||
@@ -533,6 +536,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));
|
||||
};
|
||||
|
||||
@@ -597,6 +601,75 @@ describe('Logo-Dialog: Prüfung vor dem Senden', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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('displayName')).toEqual(['logo-upload-displayName-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({ displayName: 'x'.repeat(31) });
|
||||
send();
|
||||
expect(describedBy('displayName')).toEqual(['logo-upload-displayName-hint', 'logo-upload-error']);
|
||||
expect(describedBy('name')).toEqual([]);
|
||||
|
||||
fill({ consent: false });
|
||||
send();
|
||||
expect(describedBy('consent')).toEqual(['logo-upload-error']);
|
||||
expect(describedBy('displayName')).toEqual(['logo-upload-displayName-hint']);
|
||||
});
|
||||
|
||||
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({ displayName: 'x'.repeat(31) });
|
||||
send();
|
||||
expect(describedBy('displayName')).toEqual(['logo-upload-displayName-hint', 'logo-upload-error']);
|
||||
fill({ displayName: 'Kenso' });
|
||||
send();
|
||||
expect(describedBy('displayName')).toEqual(['logo-upload-displayName-hint']);
|
||||
expect(input('displayName').hasAttribute('aria-invalid')).toBe(false);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const p = prepared();
|
||||
|
||||
@@ -10,7 +10,7 @@ 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 { el, setFieldInvalid } from '../editor/dom';
|
||||
import { msg, t } from '../i18n/i18n';
|
||||
import type { CommunityLogo } from './logos';
|
||||
import {
|
||||
@@ -34,6 +34,8 @@ export const LOGO_CONSENT_TEXT = msg(
|
||||
|
||||
/** Grenze wie der Dienst (deploy/api/logos.js): Länge in UTF-16-Einheiten, nach dem Trimmen. Der Anzeigename: wie beim Veröffentlichen. */
|
||||
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 = [
|
||||
@@ -177,7 +179,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', {
|
||||
@@ -227,7 +229,7 @@ export function logoUploadDialog(opts: LogoDialogOptions = {}): Promise<Communit
|
||||
),
|
||||
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'));
|
||||
@@ -303,8 +305,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 +323,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);
|
||||
@@ -359,7 +362,7 @@ export function logoUploadDialog(opts: LogoDialogOptions = {}): Promise<Communit
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -673,6 +673,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 +761,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();
|
||||
|
||||
+28
-12
@@ -4,7 +4,8 @@
|
||||
// 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';
|
||||
@@ -78,7 +79,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;
|
||||
@@ -103,7 +104,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 +138,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);
|
||||
}
|
||||
@@ -174,17 +179,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 +250,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 };
|
||||
|
||||
@@ -80,6 +80,7 @@ const fx = {
|
||||
confirm: vi.fn<LogoPickerDeps['confirm']>(),
|
||||
reportDialog: vi.fn<LogoPickerDeps['reportDialog']>(),
|
||||
onSelect: vi.fn<(id: string | null) => void>(),
|
||||
onLogoChanged: vi.fn<(id: string) => void>(),
|
||||
};
|
||||
|
||||
const deps: LogoPickerDeps = {
|
||||
@@ -184,7 +185,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,6 +236,22 @@ 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']);
|
||||
@@ -461,6 +478,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 +787,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 +805,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 +819,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 +1127,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('');
|
||||
@@ -1229,7 +1375,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 +1510,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 +1752,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 +1912,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();
|
||||
});
|
||||
});
|
||||
@@ -99,7 +99,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 +120,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). */
|
||||
@@ -152,6 +165,7 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
||||
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 +183,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,
|
||||
),
|
||||
@@ -219,7 +236,7 @@ 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 selectButton = el(
|
||||
'button',
|
||||
{ type: 'button', class: 'tile logo-tile', 'aria-pressed': 'false', title: logo.name },
|
||||
art,
|
||||
@@ -229,8 +246,8 @@ export function createLogoPicker(handlers: { onSelect(id: string | null): void }
|
||||
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, 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 +270,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 +282,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 +319,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 +335,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 +366,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 = '';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -36,7 +36,7 @@ const form: LogoForm = { name: 'Beispielbank', displayName: 'Kenso', 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 {
|
||||
@@ -100,11 +100,11 @@ 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);
|
||||
expect(logo).toEqual({
|
||||
id: 'f1',
|
||||
id: 'r1',
|
||||
name: 'Beispielbank',
|
||||
displayName: 'Kenso',
|
||||
fileId: 'f1',
|
||||
@@ -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 () => {
|
||||
@@ -256,7 +267,8 @@ describe('Standard-Abhängigkeiten (Appwrite-Bucket logo-images)', () => {
|
||||
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(logo.id).toBe(`zeile-${fileId}`);
|
||||
expect(logo.fileId).toBe(fileId);
|
||||
expect(mocks.deleteFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -58,13 +58,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. Name und Anzeigename werden
|
||||
* getrimmt, ihre Grenzen prüft der Dienst.
|
||||
*
|
||||
* 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.
|
||||
@@ -96,7 +97,7 @@ export async function uploadPreparedLogo(
|
||||
id,
|
||||
name,
|
||||
displayName,
|
||||
fileId: id,
|
||||
fileId,
|
||||
ownerId: userId,
|
||||
mono,
|
||||
likeCount: 0,
|
||||
|
||||
@@ -133,6 +133,40 @@ 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());
|
||||
|
||||
+3
-1
@@ -12,7 +12,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 +34,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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user