Compare commits
7
Commits
d64c14eae1
...
e7acd79225
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7acd79225 | ||
|
|
9c4e80ec9a | ||
|
|
144181f2c9 | ||
|
|
436ea4f4a5 | ||
|
|
0c51689dfd | ||
|
|
09de06e797 | ||
|
|
09832c2cf4 |
No files matched your search
@@ -1,15 +1,90 @@
|
||||
# AirCard-IOS
|
||||
|
||||
Inoffizielles Hobbyprojekt: AirCard-IOS ist ein Editor, mit dem man eigene Kartenmotive für https://aircard-ios.org gestalten kann. Keine Verbindung zu Apple, Visa, Mastercard, UnionPay, American Express, JCB oder Discover.
|
||||
Inoffizielles Hobbyprojekt, mit dem man eigene Kartenmotive für Apple Wallet unter https://aircard-ios.org gestalten kann. Bilder hochladen, Vorlage und Netzwerk-Logo wählen, herunterladen – oder mit Konto speichern. Keine Verbindung zu Apple, Visa, Mastercard, UnionPay, American Express, JCB oder Discover.
|
||||
|
||||
Die Website liegt in `web/` (statisches Vite + TypeScript, ohne UI-Framework). Die Design-Spec steht in `docs/superpowers/specs/2026-10-04-aircard-website-design.md`.
|
||||
## Aufbau
|
||||
|
||||
## Entwicklung
|
||||
**`web/`** – Vite + TypeScript ohne Framework. Frontend-Module in `src/`:
|
||||
- `card/` – Geometrie, Zuschnitt, Canvas-Rendering (keine DOM-Abhängigkeiten, einzeln testbar)
|
||||
- `editor/` – UI (Vorlagen, Vorschau, Download, Speichern)
|
||||
- `account/` – Appwrite-Client, Auth, Kartenverwaltung
|
||||
- `pages/` – Routen (`/`, `/karten`, `/passwort-neu` via History-API)
|
||||
|
||||
**`deploy/`** – Webhook-Router (Port 8087), Build-Skript (`deploy.sh`), nginx-Config, Traefik-Route für Appwrite.
|
||||
|
||||
**`docs/`** – Spec, Plan, Appwrite-Konfiguration.
|
||||
|
||||
## Lokal entwickeln
|
||||
|
||||
```sh
|
||||
cd web
|
||||
npm install
|
||||
npm run dev # Entwicklungsserver
|
||||
npm test # Tests (Vitest)
|
||||
npm run build # Typprüfung + Produktions-Build nach web/dist
|
||||
npm ci
|
||||
npm run dev # Entwicklungsserver http://localhost:5173
|
||||
```
|
||||
|
||||
Login und Speichern brauchen die Appwrite-API. Ohne Einstellung gilt als Endpoint `${location.origin}/v1` (der Entwicklungsserver hat keinen Proxy dafür, es würde also der Vite-Server selbst gefragt). Für die lokale Entwicklung deshalb in `web/.env.local` (steht über `.env*` in `.gitignore`) den Endpoint setzen:
|
||||
|
||||
```sh
|
||||
VITE_APPWRITE_ENDPOINT=https://appwrite.webklar.com/v1
|
||||
```
|
||||
|
||||
Die Projekt-ID ist als Standard im Code hinterlegt (`6ac2130a00289343d6d5`, `VITE_APPWRITE_PROJECT_ID` überschreibt sie). Damit Appwrite den lokalen Host akzeptiert, muss die Web-Plattform `localhost` in der Appwrite-Console eingetragen sein (Projekt → Overview → Integrations → Platforms). Der Produktions-Build bekommt `VITE_APPWRITE_ENDPOINT=https://aircard-ios.org/v1` aus der Container-Umgebung (`deploy/compose-snippet.yml`).
|
||||
|
||||
## Tests
|
||||
|
||||
Im Frontend (`web/`):
|
||||
|
||||
```sh
|
||||
cd web
|
||||
npx vitest run # Unit-Tests (card/*, editor/*, account/*, pages/*)
|
||||
npx tsc --noEmit # TypeScript-Prüfung
|
||||
```
|
||||
|
||||
Im Repo-Wurzelverzeichnis:
|
||||
|
||||
```sh
|
||||
node --test deploy/webhook-server.test.js # Webhook-Server (deploy.sh wird dort durch einen Stub ersetzt)
|
||||
node --test deploy/deploy.test.js # deploy.sh (echtes Skript, lokaler git-Remote, npm-Stub)
|
||||
node --test web/scripts/appwrite-setup.test.mjs # Appwrite-Schema-Skript
|
||||
```
|
||||
|
||||
## Deploy
|
||||
|
||||
**Automatisch:** Push auf `main` → Gitea-Webhook → Container `webklar-aircard-build` führt `npm ci` und `vite build` aus → Ergebnis nach `out/` → nginx `webklar-aircard` liefert.
|
||||
|
||||
**Manuell:**
|
||||
```sh
|
||||
docker exec webklar-aircard-build sh /deploy.sh
|
||||
```
|
||||
|
||||
Starten nur mit:
|
||||
```sh
|
||||
docker compose up -d aircard-build aircard
|
||||
```
|
||||
|
||||
Die Appwrite-API wird über `/home/webklar/apps/traefik/dynamic/aircard.yml` geroutet (Vorlage: `deploy/traefik/aircard.yml`): Requests auf `aircard-ios.org/v1` mit Header `X-Appwrite-Project: 6ac2130a00289343d6d5` gehen an den Appwrite-Container. Ohne diesen Header antwortet der nginx der Seite mit 404.
|
||||
|
||||
## Appwrite
|
||||
|
||||
Projekt „AirCard“ auf `appwrite.webklar.com`:
|
||||
- Projekt-ID (öffentlich, von Appwrite generiert): `6ac2130a00289343d6d5`. Sie steht in `deploy/traefik/aircard.yml`, in `/home/webklar/apps/.env` (`AIRCARD_APPWRITE_PROJECT_ID`) und als Standard in `web/src/account/appwrite.ts`.
|
||||
- Datenbank: `aircard`, Tabelle: `cards`, Bucket: `card-images` (das Skript unten legt sie an und prüft sie)
|
||||
- Console-Einstellungen laut `docs/appwrite-console.md` (Soll-Stand, nicht maschinell geprüft; `--check` prüft nur das Schema): Auth mit E-Mail + Passwort, Sitzungsdauer 30 Tage, max. 5 Sitzungen pro Nutzer, Nutzerlimit 2000, Web-Plattformen `aircard-ios.org` und `localhost`. Schritte 3 und 4 dort (Auth-Härtung, Dienste abschalten) sind optional und am Live-Projekt unbestätigt.
|
||||
|
||||
**Schema prüfen:**
|
||||
```sh
|
||||
node --env-file=/home/webklar/apps/.env web/scripts/appwrite-setup.mjs --check
|
||||
```
|
||||
|
||||
**Schema erstmalig anlegen:**
|
||||
```sh
|
||||
node --env-file=/home/webklar/apps/.env web/scripts/appwrite-setup.mjs
|
||||
```
|
||||
|
||||
Umgebungsvariablen (in `/home/webklar/apps/.env`):
|
||||
- `AIRCARD_APPWRITE_PROJECT_ID` – Projekt-ID (idempotent im Skript lesbar)
|
||||
- `AIRCARD_APPWRITE_API_KEY` – API-Key `aircard-provision` (nur für Setup)
|
||||
|
||||
## Hinweis
|
||||
|
||||
Inoffizielles Hobbyprojekt. Alle Marken gehören ihren Inhabern.
|
||||
@@ -53,6 +53,8 @@ docker exec webklar-aircard-build sh /deploy.sh
|
||||
|
||||
Ohne `DEPLOY_TRIGGER` baut das Skript immer neu, auch wenn sich der Commit nicht geändert hat.
|
||||
|
||||
Es läuft immer nur ein Deployment: Ist schon eines aktiv (Sperrverzeichnis `build/.git/aircard-deploy.lock`), schreibt das Skript „Deployment läuft bereits“ und endet mit Exit-Code 0. Eine verwaiste Sperre (Prozess weg oder älter als 60 Minuten) übernimmt der nächste Lauf selbst. `npm ci` und der Build brechen nach 15 Minuten ab (`timeout 900`).
|
||||
|
||||
## Gitea-Webhook
|
||||
|
||||
Repo `knso/AirCardIOS` → Einstellungen → Webhooks → Gitea:
|
||||
|
||||
+59
-6
@@ -13,17 +13,32 @@
|
||||
# DEPLOY_TRIGGER (leer = manual)
|
||||
#
|
||||
# Fehlerzeilen beginnen mit "ERROR:"; der letzte Satz eines erfolgreichen Builds ist
|
||||
# "Deployment abgeschlossen".
|
||||
# "Deployment abgeschlossen". Läuft schon ein Deployment (Sperrverzeichnis in .git), meldet das
|
||||
# Skript "läuft bereits" und endet mit 0. npm ci und der Build sind auf 15 Minuten begrenzt (timeout).
|
||||
set -e
|
||||
|
||||
# Jeder Abbruch soll eine ERROR-Zeile hinterlassen, auch wenn set -e ihn auslöst.
|
||||
# Die eigene Sperre (LOCK_HELD) wird bei jedem Ende freigegeben, eine fremde nie.
|
||||
ERROR_PRINTED=""
|
||||
LOCK_HELD=""
|
||||
LOCK_DIR=""
|
||||
fail() {
|
||||
echo "ERROR: $*"
|
||||
ERROR_PRINTED=1
|
||||
exit 1
|
||||
}
|
||||
trap 'status=$?; if [ "${status}" -ne 0 ] && [ -z "${ERROR_PRINTED}" ]; then echo "ERROR: Deployment unerwartet abgebrochen (Exit-Code ${status})"; fi' EXIT
|
||||
cleanup() {
|
||||
status=$?
|
||||
if [ "${status}" -ne 0 ] && [ -z "${ERROR_PRINTED}" ]; then
|
||||
echo "ERROR: Deployment unerwartet abgebrochen (Exit-Code ${status})"
|
||||
fi
|
||||
if [ -n "${LOCK_HELD}" ] && [ -n "${LOCK_DIR}" ]; then
|
||||
rm -rf "${LOCK_DIR}"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
# Ein Signal (z. B. Zeitlimit des Webhook-Servers) soll ebenfalls über den EXIT-Trap aufräumen.
|
||||
trap 'exit 143' HUP INT TERM
|
||||
|
||||
REPO_DIR="${REPO_DIR:-/app}"
|
||||
OUT_DIR="${OUT_DIR:-/out}"
|
||||
@@ -78,6 +93,33 @@ fi
|
||||
|
||||
cd "${REPO_DIR}" || fail "Verzeichnis ${REPO_DIR} nicht erreichbar"
|
||||
|
||||
# Nur ein Deployment gleichzeitig (Webhook, Polling und der manuelle Aufruf teilen sich REPO_DIR und OUT_DIR).
|
||||
# mkdir ist atomar. Eine verwaiste Sperre (Prozess weg oder über 60 Minuten alt) wird übernommen.
|
||||
LOCK_DIR="${REPO_DIR}/.git/aircard-deploy.lock"
|
||||
acquire_lock() {
|
||||
mkdir "${LOCK_DIR}" 2>/dev/null || return 1
|
||||
echo "$$" >"${LOCK_DIR}/pid"
|
||||
LOCK_HELD=1
|
||||
}
|
||||
if ! acquire_lock; then
|
||||
_lock_pid="$(cat "${LOCK_DIR}/pid" 2>/dev/null || true)"
|
||||
_lock_stale=""
|
||||
if [ -n "${_lock_pid}" ]; then
|
||||
kill -0 "${_lock_pid}" 2>/dev/null || _lock_stale=1
|
||||
fi
|
||||
if [ -n "$(find "${LOCK_DIR}" -maxdepth 0 -mmin +60 2>/dev/null)" ]; then
|
||||
_lock_stale=1
|
||||
fi
|
||||
if [ -n "${_lock_stale}" ]; then
|
||||
echo "Verwaiste Sperre gefunden, übernehme sie."
|
||||
rm -rf "${LOCK_DIR}"
|
||||
acquire_lock || { echo "Deployment läuft bereits, dieser Lauf wird übersprungen."; exit 0; }
|
||||
else
|
||||
echo "Deployment läuft bereits, dieser Lauf wird übersprungen."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Aktualisiere Repository..."
|
||||
# Remote-URL bei jedem Lauf neu setzen, damit ein getauschtes Token greift.
|
||||
git remote set-url origin "${REPO_URL}" || fail "git remote set-url fehlgeschlagen"
|
||||
@@ -111,18 +153,29 @@ echo "${CURRENT_COMMIT}" >"${MARKER}" || fail "Marker ${MARKER} nicht schreibbar
|
||||
[ -d "${REPO_DIR}/${APP_SUBDIR}" ] || fail "Ordner ${APP_SUBDIR} fehlt im Repository"
|
||||
cd "${REPO_DIR}/${APP_SUBDIR}"
|
||||
|
||||
# Zugangsdaten nicht an npm und die Build-Skripte weiterreichen.
|
||||
unset GITEA_TOKEN REPO_URL
|
||||
# Zugangsdaten nicht an npm und die Build-Skripte weiterreichen (auch das Webhook-Geheimnis steht im
|
||||
# Container und käme sonst beim manuellen Aufruf `docker exec ... sh /deploy.sh` bei npm an).
|
||||
unset GITEA_TOKEN REPO_URL AIRCARD_WEBHOOK_TOKEN
|
||||
|
||||
# Ein hängender Build soll nicht ewig laufen (und alle späteren Deployments sperren): 15 Minuten.
|
||||
# `timeout` gibt es in busybox (node:22-alpine); fehlt es, läuft der Build ohne Limit.
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
LIMIT="timeout 900"
|
||||
else
|
||||
LIMIT=""
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Installiere Abhängigkeiten..."
|
||||
npm ci --no-audit --no-fund || fail "npm ci fehlgeschlagen"
|
||||
# shellcheck disable=SC2086 # LIMIT besteht absichtlich aus zwei Wörtern
|
||||
${LIMIT} npm ci --no-audit --no-fund || fail "npm ci fehlgeschlagen (oder das Zeitlimit von 15 Minuten ist erreicht)"
|
||||
|
||||
echo ""
|
||||
echo "Baue die Seite..."
|
||||
# VITE_*-Variablen kommen direkt aus der Container-Umgebung (compose).
|
||||
rm -rf dist
|
||||
npm run build || fail "Build fehlgeschlagen"
|
||||
# shellcheck disable=SC2086
|
||||
${LIMIT} npm run build || fail "Build fehlgeschlagen (oder das Zeitlimit von 15 Minuten ist erreicht)"
|
||||
[ -f dist/index.html ] || fail "dist/index.html fehlt nach dem Build"
|
||||
|
||||
echo ""
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
'use strict';
|
||||
|
||||
// Tests für deploy/deploy.sh (das echte Skript, mit einem lokalen git-Remote und einem npm-Stub).
|
||||
// Aufruf: node --test deploy/deploy.test.js
|
||||
// Voraussetzung: git und sh auf dem Host. Der Stub-npm legt dist/index.html an und schreibt seine Umgebung weg.
|
||||
|
||||
const { test, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawn, spawnSync } = require('node:child_process');
|
||||
|
||||
const DEPLOY_SH = path.join(__dirname, 'deploy.sh');
|
||||
// Standard: das sh des Hosts. Zum Gegenprüfen mit busybox (wie im node:alpine-Container): DEPLOY_TEST_SH="busybox sh".
|
||||
const [SHELL_CMD, ...SHELL_ARGS] = (process.env.DEPLOY_TEST_SH || 'sh').split(' ');
|
||||
const GIT_ENV = {
|
||||
GIT_AUTHOR_NAME: 't',
|
||||
GIT_AUTHOR_EMAIL: 't@example.invalid',
|
||||
GIT_COMMITTER_NAME: 't',
|
||||
GIT_COMMITTER_EMAIL: 't@example.invalid',
|
||||
};
|
||||
|
||||
const tmpDirs = [];
|
||||
const children = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const child of children.splice(0)) {
|
||||
// Prozessgruppe beenden: auch der wartende npm-Stub unter dem Skript.
|
||||
if (child.exitCode === null && child.signalCode === null) process.kill(-child.pid, 'SIGKILL');
|
||||
}
|
||||
for (const dir of tmpDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
async function waitFor(condition, what, timeoutMs = 5000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (!condition()) {
|
||||
if (Date.now() > deadline) throw new Error(`Zeitüberschreitung beim Warten auf: ${what}`);
|
||||
await pause(20);
|
||||
}
|
||||
}
|
||||
|
||||
function git(args, cwd) {
|
||||
const res = spawnSync('git', args, { cwd, env: { ...process.env, ...GIT_ENV }, encoding: 'utf8' });
|
||||
assert.equal(res.status, 0, `git ${args.join(' ')}: ${res.stderr}`);
|
||||
return res.stdout;
|
||||
}
|
||||
|
||||
/** Ein Arbeitsverzeichnis mit Remote (file://), Stub-npm und optional Stub-timeout. */
|
||||
function setup({ withTimeoutStub = false } = {}) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'aircard-deploy-test-'));
|
||||
tmpDirs.push(dir);
|
||||
|
||||
const bare = path.join(dir, 'remote', 'u', 'r.git');
|
||||
fs.mkdirSync(bare, { recursive: true });
|
||||
git(['init', '--bare', '--quiet', '-b', 'main', bare]);
|
||||
const work = path.join(dir, 'seed');
|
||||
git(['clone', '--quiet', bare, work]);
|
||||
fs.mkdirSync(path.join(work, 'web'));
|
||||
fs.writeFileSync(path.join(work, 'web', 'package.json'), '{}\n');
|
||||
git(['add', '-A'], work);
|
||||
git(['commit', '--quiet', '-m', 'seed'], work);
|
||||
git(['push', '--quiet', 'origin', 'HEAD:main'], work);
|
||||
|
||||
const bin = path.join(dir, 'bin');
|
||||
fs.mkdirSync(bin);
|
||||
const npmLog = path.join(dir, 'npm.log');
|
||||
const release = path.join(dir, 'release');
|
||||
fs.writeFileSync(
|
||||
path.join(bin, 'npm'),
|
||||
[
|
||||
'#!/bin/sh',
|
||||
`echo "npm $* | WEBHOOK=\${AIRCARD_WEBHOOK_TOKEN-unset} GITEA=\${GITEA_TOKEN-unset} VITE=\${VITE_TEST_VAR-unset}" >> "${npmLog}"`,
|
||||
// Blockiert, bis die Testdatei "release" existiert (für den Test mit zwei gleichzeitigen Läufen).
|
||||
`if [ -n "\${STUB_NPM_BLOCK:-}" ]; then i=0; while [ ! -f "${release}" ] && [ "$i" -lt 400 ]; do sleep 0.05; i=$((i+1)); done; fi`,
|
||||
'if [ -n "${STUB_NPM_FAIL:-}" ]; then echo "npm kaputt" >&2; exit 1; fi',
|
||||
'if [ "$1" = run ]; then mkdir -p dist && echo "<html>ok</html>" > dist/index.html; fi',
|
||||
'exit 0',
|
||||
'',
|
||||
].join('\n'),
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
const timeoutLog = path.join(dir, 'timeout.log');
|
||||
if (withTimeoutStub) {
|
||||
fs.writeFileSync(
|
||||
path.join(bin, 'timeout'),
|
||||
['#!/bin/sh', `echo "timeout $*" >> "${timeoutLog}"`, 'shift', 'exec "$@"', ''].join('\n'),
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
}
|
||||
|
||||
const repoDir = path.join(dir, 'app');
|
||||
const outDir = path.join(dir, 'out');
|
||||
fs.mkdirSync(repoDir);
|
||||
fs.mkdirSync(outDir);
|
||||
const env = {
|
||||
...process.env,
|
||||
...GIT_ENV,
|
||||
PATH: `${bin}:${process.env.PATH}`,
|
||||
GITEA_URL: `file://${path.join(dir, 'remote')}`,
|
||||
GITEA_USER: 'u',
|
||||
GITEA_REPO: 'r',
|
||||
GITEA_TOKEN: '',
|
||||
REPO_DIR: repoDir,
|
||||
OUT_DIR: outDir,
|
||||
DEPLOY_BRANCH: 'main',
|
||||
DEPLOY_TRIGGER: 'webhook',
|
||||
AIRCARD_WEBHOOK_TOKEN: 'geheim-webhook',
|
||||
VITE_TEST_VAR: 'bleibt',
|
||||
};
|
||||
const lockDir = path.join(repoDir, '.git', 'aircard-deploy.lock');
|
||||
const npmCalls = () => (fs.existsSync(npmLog) ? fs.readFileSync(npmLog, 'utf8').trim().split('\n') : []);
|
||||
const timeoutCalls = () => (fs.existsSync(timeoutLog) ? fs.readFileSync(timeoutLog, 'utf8').trim().split('\n') : []);
|
||||
return { dir, env, repoDir, outDir, lockDir, release, npmCalls, timeoutCalls };
|
||||
}
|
||||
|
||||
function runSync(ctx, extraEnv = {}) {
|
||||
const res = spawnSync(SHELL_CMD, [...SHELL_ARGS, DEPLOY_SH], { env: { ...ctx.env, ...extraEnv }, encoding: 'utf8', timeout: 60000 });
|
||||
return { status: res.status, out: `${res.stdout}${res.stderr}` };
|
||||
}
|
||||
|
||||
function runAsync(ctx, extraEnv = {}) {
|
||||
const child = spawn(SHELL_CMD, [...SHELL_ARGS, DEPLOY_SH], {
|
||||
env: { ...ctx.env, ...extraEnv },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: true,
|
||||
});
|
||||
children.push(child);
|
||||
const result = { out: '', status: null, done: null };
|
||||
child.stdout.on('data', (d) => (result.out += d));
|
||||
child.stderr.on('data', (d) => (result.out += d));
|
||||
result.done = new Promise((resolve) =>
|
||||
child.once('exit', (code) => {
|
||||
result.status = code;
|
||||
resolve(result);
|
||||
}),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
test('ein Lauf baut, kopiert nach out/ und gibt das Webhook-Geheimnis nicht an npm weiter', () => {
|
||||
const ctx = setup();
|
||||
const res = runSync(ctx);
|
||||
assert.equal(res.status, 0, res.out);
|
||||
assert.match(res.out, /Deployment abgeschlossen/);
|
||||
assert.equal(fs.readFileSync(path.join(ctx.outDir, 'index.html'), 'utf8').trim(), '<html>ok</html>');
|
||||
const calls = ctx.npmCalls();
|
||||
assert.equal(calls.length, 2, calls.join('\n'));
|
||||
for (const call of calls) {
|
||||
assert.match(call, /WEBHOOK=unset/, `AIRCARD_WEBHOOK_TOKEN darf npm nicht erreichen: ${call}`);
|
||||
assert.match(call, /GITEA=unset/);
|
||||
assert.match(call, /VITE=bleibt/, 'andere Variablen (VITE_*) bleiben erhalten');
|
||||
}
|
||||
assert.ok(!res.out.includes('geheim-webhook'));
|
||||
});
|
||||
|
||||
test('das Sperrverzeichnis wird nach einem erfolgreichen Lauf entfernt', () => {
|
||||
const ctx = setup();
|
||||
assert.equal(runSync(ctx).status, 0);
|
||||
assert.equal(fs.existsSync(ctx.lockDir), false);
|
||||
});
|
||||
|
||||
test('das Sperrverzeichnis wird auch nach einem fehlgeschlagenen Build entfernt', () => {
|
||||
const ctx = setup();
|
||||
const res = runSync(ctx, { STUB_NPM_FAIL: '1' });
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.out, /ERROR: /);
|
||||
assert.equal(fs.existsSync(ctx.lockDir), false);
|
||||
});
|
||||
|
||||
test('ein zweiter gleichzeitiger Lauf meldet „läuft bereits“, beendet mit 0 und baut nicht', async () => {
|
||||
const ctx = setup();
|
||||
const first = runAsync(ctx, { STUB_NPM_BLOCK: '1' });
|
||||
await waitFor(() => ctx.npmCalls().length >= 1, 'npm des ersten Laufs');
|
||||
assert.equal(fs.existsSync(ctx.lockDir), true);
|
||||
|
||||
const second = runSync(ctx);
|
||||
assert.equal(second.status, 0, second.out);
|
||||
assert.match(second.out, /läuft bereits/);
|
||||
assert.doesNotMatch(second.out, /Deployment abgeschlossen/);
|
||||
assert.equal(ctx.npmCalls().length, 1, 'der zweite Lauf darf npm nicht starten');
|
||||
assert.equal(fs.existsSync(ctx.lockDir), true, 'der zweite Lauf darf die Sperre des ersten nicht freigeben');
|
||||
|
||||
fs.writeFileSync(ctx.release, '');
|
||||
await first.done;
|
||||
assert.equal(first.status, 0, first.out);
|
||||
assert.match(first.out, /Deployment abgeschlossen/);
|
||||
assert.equal(fs.existsSync(ctx.lockDir), false);
|
||||
});
|
||||
|
||||
test('eine verwaiste Sperre (Prozess gibt es nicht mehr) wird übernommen', () => {
|
||||
const ctx = setup();
|
||||
// Erster Lauf legt das Repo an.
|
||||
assert.equal(runSync(ctx).status, 0);
|
||||
fs.mkdirSync(ctx.lockDir);
|
||||
fs.writeFileSync(path.join(ctx.lockDir, 'pid'), '2147483646\n');
|
||||
const res = runSync(ctx);
|
||||
assert.equal(res.status, 0, res.out);
|
||||
assert.match(res.out, /Deployment abgeschlossen/);
|
||||
assert.equal(fs.existsSync(ctx.lockDir), false);
|
||||
});
|
||||
|
||||
test('eine sehr alte Sperre ohne lebenden Besitzer-Prozess-Nachweis gilt als verwaist', () => {
|
||||
const ctx = setup();
|
||||
assert.equal(runSync(ctx).status, 0);
|
||||
fs.mkdirSync(ctx.lockDir);
|
||||
// Kein pid-Eintrag, aber alt genug (über 60 Minuten).
|
||||
const old = new Date(Date.now() - 2 * 60 * 60 * 1000);
|
||||
fs.utimesSync(ctx.lockDir, old, old);
|
||||
const res = runSync(ctx);
|
||||
assert.equal(res.status, 0, res.out);
|
||||
assert.match(res.out, /Deployment abgeschlossen/);
|
||||
});
|
||||
|
||||
const busyboxApplets = SHELL_CMD === 'busybox' && 'busybox löst timeout als eigenes Applet auf, der Stub wird nicht gerufen';
|
||||
test('npm ci und npm run build laufen unter timeout 900, wenn timeout verfügbar ist', { skip: busyboxApplets }, () => {
|
||||
const ctx = setup({ withTimeoutStub: true });
|
||||
const res = runSync(ctx);
|
||||
assert.equal(res.status, 0, res.out);
|
||||
const calls = ctx.timeoutCalls();
|
||||
assert.equal(calls.length, 2, calls.join('\n'));
|
||||
assert.match(calls[0], /^timeout 900 npm ci /);
|
||||
assert.match(calls[1], /^timeout 900 npm run build/);
|
||||
});
|
||||
|
||||
test('ein Build-Fehler unter timeout bleibt ein ERROR mit Exit-Code 1', () => {
|
||||
const ctx = setup({ withTimeoutStub: true });
|
||||
const res = runSync(ctx, { STUB_NPM_FAIL: '1' });
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.out, /ERROR: npm ci fehlgeschlagen/);
|
||||
});
|
||||
@@ -7,6 +7,7 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
server_tokens off;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
@@ -15,6 +16,8 @@ server {
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
# TLS endet bei Traefik; der Header läuft durch und gilt für den Browser (kein includeSubDomains).
|
||||
add_header Strict-Transport-Security "max-age=31536000" always;
|
||||
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
@@ -39,8 +42,10 @@ server {
|
||||
}
|
||||
|
||||
# Die Appwrite-API liegt nur über Traefik (Header X-Appwrite-Project) auf /v1.
|
||||
# Ohne diesen Header darf nginx hier nie die App (index.html) ausliefern.
|
||||
location ^~ /v1/ {
|
||||
# Ohne diesen Header darf nginx hier nie die App (index.html) ausliefern, auch nicht für genau /v1.
|
||||
# Regex-Block: steht vor den anderen Regex-Blöcken (die erste passende Regel gewinnt, /v1/x.png
|
||||
# soll also 404 liefern); /assets/ bleibt durch das ^~ oben unberührt.
|
||||
location ~ ^/v1(/|$) {
|
||||
return 404;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,16 +4,20 @@ Klickliste für Kenso. Dauer: etwa 5 Minuten. Console: <https://appwrite.webklar
|
||||
|
||||
Alles passiert in der bestehenden Organisation. Das Schema (Datenbank, Tabelle, Bucket) legt danach das Skript `web/scripts/appwrite-setup.mjs` an – dafür musst du nichts anklicken.
|
||||
|
||||
**Hinweis:** Das Projekt wird mit der automatisch generierten ID `6ac2130a00289343d6d5` angelegt und dort in `/home/webklar/apps/.env` unter `AIRCARD_APPWRITE_PROJECT_ID` gespeichert. Das Skript liest diese ID aus der Umgebungsvariablen; der Wert muss beim Aufruf verfügbar sein.
|
||||
**Hinweis:** Das Projekt wurde mit der automatisch generierten ID `6ac2130a00289343d6d5` angelegt (nicht mit einer selbst gewählten ID). Diese ID steht in `/home/webklar/apps/.env` unter `AIRCARD_APPWRITE_PROJECT_ID`; das Skript liest sie von dort, der Wert muss beim Aufruf verfügbar sein.
|
||||
|
||||
**Stand der Einstellungen:** Schritt 2 (Web-Plattform `aircard-ios.org`) ist Voraussetzung, ohne sie lehnt Appwrite die Anfragen der Seite ab. Die Schritte 3 und 4 sind empfohlene Härtung. Ob sie in der Console umgesetzt sind, prüft kein Skript (`--check` deckt nur Schema, Tabelle und Bucket ab). Solange du sie nicht selbst gesetzt hast, läuft das Live-Projekt mit den Appwrite-Standards für Auth und Dienste. Das Schema und die Zeilen-/Dateirechte sind davon unabhängig.
|
||||
|
||||
## 1. Projekt anlegen
|
||||
|
||||
1. In der Console anmelden und die bestehende Organisation öffnen.
|
||||
2. **Create project** klicken.
|
||||
3. Name: `AirCard`.
|
||||
4. Beim Feld **Project ID** auf das Stift-Symbol (bzw. „Edit“) klicken und `aircard` eintragen. Die ID lässt sich später nicht mehr ändern; sie muss genau `aircard` heißen.
|
||||
4. Das Feld **Project ID** unverändert lassen: Appwrite erzeugt die ID selbst. Nach dem Anlegen steht sie unter **Settings** (und in der Adresszeile der Console). Sie ist öffentlich, steckt im JavaScript der Seite.
|
||||
5. **Create** klicken.
|
||||
|
||||
Bei einem Neuaufbau bekommt das neue Projekt eine neue ID. Dann müssen drei Stellen dazu passen: die Traefik-Regel (`deploy/traefik/aircard.yml`, dort steht die ID im `Header(...)`), `AIRCARD_APPWRITE_PROJECT_ID` in `/home/webklar/apps/.env` und der Standardwert in `web/src/account/appwrite.ts`.
|
||||
|
||||
## 2. Web-Plattformen eintragen
|
||||
|
||||
Projekt `AirCard` → **Overview** → **Integrations** → **Platforms** → **Add platform** → **Web**.
|
||||
@@ -21,9 +25,9 @@ Projekt `AirCard` → **Overview** → **Integrations** → **Platforms** → **
|
||||
1. Name `AirCard Website`, Hostname `aircard-ios.org` → **Create**.
|
||||
2. Noch einmal **Add platform** → **Web**: Name `Entwicklung`, Hostname `localhost` → **Create**.
|
||||
|
||||
## 3. Auth-Einstellungen
|
||||
## 3. Auth-Einstellungen (empfohlene Härtung, keine Voraussetzung für den Betrieb)
|
||||
|
||||
Linke Leiste → **Auth** → **Settings**.
|
||||
Linke Leiste → **Auth** → **Settings**. Registrieren und Anmelden funktionieren auch mit den Standardwerten; die folgenden Einstellungen begrenzen nur, was Besucher mit dem Projekt anstellen können.
|
||||
|
||||
1. Bei **Auth methods** nur **Email/Password** eingeschaltet lassen. Ausschalten: **Magic URL**, **Email OTP**, **Phone**, **Anonymous**, **JWT** (steht dort auch **Invites**: ebenfalls aus).
|
||||
2. **Session duration**: `30` Tage → **Update**.
|
||||
@@ -33,11 +37,11 @@ Linke Leiste → **Auth** → **Settings**.
|
||||
|
||||
Keine E-Mail-Bestätigung nötig, Mindestlänge 8 Zeichen ist Standard – dafür ist nichts einzustellen. Die „Passwort vergessen“-Mail geht über den globalen Server-SMTP (Absender WEBklar), auch dafür ist nichts einzustellen.
|
||||
|
||||
## 4. Nicht benötigte Dienste abschalten
|
||||
## 4. Nicht benötigte Dienste abschalten (optional)
|
||||
|
||||
Linke Leiste → **Settings** → Reiter **Services**.
|
||||
Linke Leiste → **Settings** → Reiter **Services**. Das ist optionale Härtung: Die Website braucht diese Dienste nicht, schadet aber auch nicht, wenn sie an bleiben. Solange du das nicht gemacht hast, sind sie am Live-Projekt an (z. B. antwortet GraphQL).
|
||||
|
||||
1. Ausschalten: **Functions**, **Sites**, **Messaging**, **Teams**, **GraphQL**.
|
||||
1. Ausschalten, soweit die Console es dort anbietet: **Functions**, **Sites**, **Messaging**, **Teams**, **GraphQL**.
|
||||
2. Alles andere (Account, Avatars, Databases, Locale, Storage …) bleibt an.
|
||||
|
||||
## 5. API-Key `aircard-provision` anlegen
|
||||
@@ -59,14 +63,15 @@ Dieser Key gehört nur dem Setup-Skript. Die Website selbst braucht ihn nicht.
|
||||
Der Key gehört in die Datei `/home/webklar/apps/.env`, nirgendwo sonst.
|
||||
|
||||
1. Auf dem Server die Datei öffnen, z. B. `nano /home/webklar/apps/.env`.
|
||||
2. Ans Ende springen und eine neue Zeile anfügen, ohne Anführungszeichen und ohne Leerzeichen um das `=`:
|
||||
2. Ans Ende springen und neue Zeilen anfügen, ohne Anführungszeichen und ohne Leerzeichen um das `=` (die Projekt-ID ist die in Schritt 1 notierte, siehe Hinweis oben):
|
||||
|
||||
```
|
||||
AIRCARD_APPWRITE_PROJECT_ID=<Projekt-ID>
|
||||
AIRCARD_APPWRITE_API_KEY=<hier den kopierten Secret-Wert einfügen>
|
||||
```
|
||||
|
||||
3. Speichern (in `nano`: Strg+O, Enter, Strg+X). Andere Zeilen nicht verändern.
|
||||
4. Den Key **nicht im Chat teilen**, nicht in Screenshots zeigen und nirgends einchecken. Claude liest ihn nie selbst, das Skript bekommt ihn über `node --env-file=/home/webklar/apps/.env`.
|
||||
4. Den Key möglichst nicht im Chat teilen (wer ihn dort einfügt, gibt ihn in den Verlauf), nicht in Screenshots zeigen und nirgends einchecken. Das Skript bekommt ihn über `node --env-file=/home/webklar/apps/.env`, dafür muss ihn niemand sonst sehen.
|
||||
|
||||
## 7. Bescheid geben
|
||||
|
||||
|
||||
@@ -147,6 +147,9 @@ const newInput = (patch: Partial<SaveInput> = {}): SaveInput => ({
|
||||
const service = (fake: ReturnType<typeof fakeApi>, userId: () => string = () => 'u1') =>
|
||||
createCardsService(fake.api, userId);
|
||||
|
||||
/** Der Server hat die Anfrage eindeutig abgelehnt (4xx): es wurde nichts geschrieben. */
|
||||
const rejected = (code = 400) => new AppwriteException('Abgelehnt', code, 'document_invalid_structure');
|
||||
|
||||
describe('normalizeName', () => {
|
||||
it('trimmt und fasst Leerraum zusammen', () => {
|
||||
expect(normalizeName(' Mein Urlaub ')).toBe('Mein Urlaub');
|
||||
@@ -240,14 +243,60 @@ describe('save: neue Karte', () => {
|
||||
expect((fake.methods('createFile')[1].args[0] as File).name).toBe('preview.png');
|
||||
});
|
||||
|
||||
it('scheitert createRow, werden beide neuen Dateien gelöscht und der Fehler kommt an', async () => {
|
||||
it('lehnt der Server createRow eindeutig ab (4xx), werden beide neuen Dateien gelöscht und der Fehler kommt an', async () => {
|
||||
const fake = fakeApi();
|
||||
const boom = new Error('createRow');
|
||||
const boom = rejected(400);
|
||||
fake.failOnce.set('createRow', boom);
|
||||
await expect(service(fake).save(newInput())).rejects.toBe(boom);
|
||||
expect(fake.methods('deleteFile').map((c) => c.args[0]).sort()).toEqual(['file1', 'file2']);
|
||||
});
|
||||
|
||||
it('auch 401 (Sitzung weg) und andere 4xx gelten als eindeutig abgelehnt', async () => {
|
||||
for (const code of [401, 403, 404, 409, 413]) {
|
||||
const fake = fakeApi();
|
||||
fake.failOnce.set('createRow', rejected(code));
|
||||
await expect(service(fake).save(newInput())).rejects.toThrow();
|
||||
expect(fake.methods('deleteFile').map((c) => c.args[0]).sort()).toEqual(['file1', 'file2']);
|
||||
}
|
||||
});
|
||||
|
||||
describe('unklarer Ausgang beim Schreiben der Zeile: Uploads bleiben liegen', () => {
|
||||
const ambiguous: [string, () => unknown][] = [
|
||||
['Netzfehler (TypeError)', () => new TypeError('Failed to fetch')],
|
||||
['503', () => new AppwriteException('Server', 503, 'general_server_error')],
|
||||
['500', () => new AppwriteException('Server', 500, 'general_server_error')],
|
||||
['429 (zu viele Anfragen)', () => new AppwriteException('Rate', 429, 'general_rate_limit_exceeded')],
|
||||
['408 (Zeitüberschreitung)', () => new AppwriteException('Timeout', 408, 'general_timeout')],
|
||||
['ein Fehler ohne Statuscode', () => new Error('unbekannt')],
|
||||
];
|
||||
|
||||
for (const [label, make] of ambiguous) {
|
||||
it(`neue Karte, createRow: ${label}`, async () => {
|
||||
const fake = fakeApi();
|
||||
const boom = make();
|
||||
fake.failOnce.set('createRow', boom);
|
||||
await expect(service(fake).save(newInput())).rejects.toBe(boom);
|
||||
expect(fake.methods('deleteFile')).toHaveLength(0);
|
||||
});
|
||||
}
|
||||
|
||||
it('bestehende Karte, updateRow: Netzfehler löscht weder neue noch alte Dateien', async () => {
|
||||
const fake = fakeApi([{ $id: 'row1', $updatedAt: NOW, name: 'Alt', config: serializeConfig(config()), imageFileId: 'oldImage', previewFileId: 'oldPreview' }]);
|
||||
const boom = new TypeError('Failed to fetch');
|
||||
fake.failOnce.set('updateRow', boom);
|
||||
await expect(service(fake).save(newInput({ imageChanged: true, previous: previous() }))).rejects.toBe(boom);
|
||||
expect(fake.methods('deleteFile')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Zeile verschwunden, dann Netzfehler beim Anlegen: nichts wird gelöscht', async () => {
|
||||
const fake = fakeApi();
|
||||
fake.failOnce.set('updateRow', new AppwriteException('Row not found', 404, 'row_not_found'));
|
||||
fake.failOnce.set('createRow', new TypeError('Failed to fetch'));
|
||||
await expect(service(fake).save(newInput({ imageChanged: false, previous: previous() }))).rejects.toThrow(TypeError);
|
||||
expect(fake.methods('deleteFile')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('scheitert die Vorschau, wird das Bild gelöscht und keine Zeile angelegt', async () => {
|
||||
const fake = fakeApi();
|
||||
const boom = new Error('preview');
|
||||
@@ -259,7 +308,7 @@ describe('save: neue Karte', () => {
|
||||
|
||||
it('Fehler beim Aufräumen überdecken den Ursprungsfehler nicht', async () => {
|
||||
const fake = fakeApi();
|
||||
const boom = new Error('createRow');
|
||||
const boom = rejected(400);
|
||||
fake.failOnce.set('createRow', boom);
|
||||
const original = fake.api.deleteFile;
|
||||
fake.api.deleteFile = async (id) => {
|
||||
@@ -309,10 +358,13 @@ describe('save: bestehende Karte', () => {
|
||||
const [file] = fake.methods('createFile');
|
||||
expect((file.args[0] as File).name).toBe('preview.webp');
|
||||
expect(file.args[1]).toEqual(OWNER);
|
||||
// Ohne Bildwechsel steht kein imageFileId im Update: ein veralteter Schnappschuss darf die Zeile nicht auf eine
|
||||
// inzwischen gelöschte Bilddatei zurücksetzen.
|
||||
expect(fake.methods('updateRow')[0].args).toEqual([
|
||||
'row1',
|
||||
{ name: 'Neu', config: serializeConfig(c), imageFileId: 'oldImage', previewFileId: 'file1' },
|
||||
{ name: 'Neu', config: serializeConfig(c), previewFileId: 'file1' },
|
||||
]);
|
||||
expect(fake.methods('updateRow')[0].args[1]).not.toHaveProperty('imageFileId');
|
||||
expect(fake.methods('deleteFile')[0].args).toEqual(['oldPreview']);
|
||||
expect(saved.id).toBe('row1');
|
||||
expect(saved.imageFileId).toBe('oldImage');
|
||||
@@ -320,6 +372,18 @@ describe('save: bestehende Karte', () => {
|
||||
expect(saved.config.network).toBe('jcb');
|
||||
});
|
||||
|
||||
it('veralteter Schnappschuss (Bild in anderem Tab ersetzt): das Update schickt kein altes imageFileId, die Zeile behält das neue Bild', async () => {
|
||||
// Der Server kennt schon das neue Bild; der Editor hält noch den Stand von vorher.
|
||||
const fake = fakeApi([{ ...existing(), imageFileId: 'serverImage', previewFileId: 'serverPreview' }]);
|
||||
const saved = await service(fake).save(
|
||||
newInput({ imageChanged: false, previous: previous({ imageFileId: 'deletedImage', previewFileId: 'stalePreview' }) }),
|
||||
);
|
||||
expect(fake.methods('updateRow')[0].args[1]).not.toHaveProperty('imageFileId');
|
||||
expect(saved.imageFileId).toBe('serverImage');
|
||||
// Gelöscht wird nur die Vorschau des Schnappschusses, nie die Bilddatei.
|
||||
expect(fake.methods('deleteFile').map((c) => c.args[0])).toEqual(['stalePreview']);
|
||||
});
|
||||
|
||||
it('mit Bildwechsel: zwei neue Dateien, nach dem Update werden beide alten gelöscht', async () => {
|
||||
const fake = fakeApi([existing()]);
|
||||
const saved = await service(fake).save(newInput({ imageChanged: true, previous: previous() }));
|
||||
@@ -331,9 +395,9 @@ describe('save: bestehende Karte', () => {
|
||||
expect(saved.imageFileId).toBe('file1');
|
||||
});
|
||||
|
||||
it('scheitert updateRow, werden nur die neuen Dateien gelöscht', async () => {
|
||||
it('lehnt der Server updateRow ab, werden nur die neuen Dateien gelöscht', async () => {
|
||||
const fake = fakeApi([existing()]);
|
||||
const boom = new Error('updateRow');
|
||||
const boom = rejected(400);
|
||||
fake.failOnce.set('updateRow', boom);
|
||||
await expect(service(fake).save(newInput({ imageChanged: true, previous: previous() }))).rejects.toBe(boom);
|
||||
expect(fake.methods('deleteFile').map((c) => c.args[0]).sort()).toEqual(['file1', 'file2']);
|
||||
@@ -341,7 +405,7 @@ describe('save: bestehende Karte', () => {
|
||||
|
||||
it('scheitert updateRow ohne Bildwechsel, wird nur die neue Vorschau gelöscht', async () => {
|
||||
const fake = fakeApi([existing()]);
|
||||
fake.failOnce.set('updateRow', new Error('updateRow'));
|
||||
fake.failOnce.set('updateRow', rejected(400));
|
||||
await expect(service(fake).save(newInput({ imageChanged: false, previous: previous() }))).rejects.toThrow();
|
||||
expect(fake.methods('deleteFile').map((c) => c.args[0])).toEqual(['file1']);
|
||||
});
|
||||
@@ -401,7 +465,7 @@ describe('save: bestehende Karte', () => {
|
||||
|
||||
it('scheitert auch das Anlegen, werden alle neuen Dateien gelöscht, die alten bleiben, der Fehler kommt an', async () => {
|
||||
const fake = fakeApi([existing()]);
|
||||
const boom = new Error('createRow');
|
||||
const boom = rejected(400);
|
||||
fake.failOnce.set('updateRow', gone());
|
||||
fake.failOnce.set('createRow', boom);
|
||||
await expect(service(fake).save(newInput({ imageChanged: false, previous: previous() }))).rejects.toBe(boom);
|
||||
|
||||
@@ -87,6 +87,15 @@ async function deleteQuietly(api: CardsApi, fileIds: string[]): Promise<void> {
|
||||
await Promise.allSettled(fileIds.map((id) => api.deleteFile(id)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Server hat eine Anfrage eindeutig abgelehnt (4xx außer Zeitüberschreitung 408 und Drosselung 429): es wurde nichts
|
||||
* geschrieben. Netzfehler, 5xx und alles ohne Statuscode lassen offen, ob die Zeile doch angekommen ist.
|
||||
*/
|
||||
function isDefinitiveFailure(err: unknown): boolean {
|
||||
const code = typeof err === 'object' && err !== null ? (err as { code?: unknown }).code : undefined;
|
||||
return typeof code === 'number' && code >= 400 && code < 500 && code !== 408 && code !== 429;
|
||||
}
|
||||
|
||||
export function createCardsService(api: CardsApi, userId: () => string) {
|
||||
async function save(input: SaveInput): Promise<SavedCard> {
|
||||
// Ohne Nutzer wirft das hier, bevor etwas hochgeladen wird.
|
||||
@@ -109,25 +118,36 @@ export function createCardsService(api: CardsApi, userId: () => string) {
|
||||
let row: CardRow;
|
||||
// Die Zeile gab es nicht mehr (anderer Tab, anderes Konto): dann wurde eine neue Karte angelegt, die alten Dateien sind Müll.
|
||||
let replacedMissingCard = false;
|
||||
// Scheitert ein Upload, hängt noch keine Zeile an den Dateien. Beim Schreiben der Zeile ist der Ausgang bei
|
||||
// Netzfehler/5xx dagegen offen (sie kann angekommen sein): dann bleiben die Uploads lieber liegen, als dass
|
||||
// eine Zeile auf gelöschte Dateien zeigt.
|
||||
let writingRow = false;
|
||||
try {
|
||||
const imageFileId = imageFile ? await upload(imageFile) : (prev as SavedCardRef).imageFileId;
|
||||
const newImageId = imageFile ? await upload(imageFile) : null;
|
||||
const previewFileId = await upload(previewFile);
|
||||
const data: CardRowData = { name, config, imageFileId, previewFileId };
|
||||
writingRow = true;
|
||||
if (!prev) {
|
||||
row = await api.createRow(data, permissions);
|
||||
// Ohne vorherige Karte wird immer ein Bild hochgeladen (uploadImage).
|
||||
row = await api.createRow({ name, config, imageFileId: newImageId as string, previewFileId }, permissions);
|
||||
} else {
|
||||
// Ohne Bildwechsel steht kein imageFileId im Update: der Schnappschuss des Editors kann veraltet sein
|
||||
// (Bild in einem anderen Tab ersetzt) und würde die Zeile auf eine gelöschte Datei zurücksetzen.
|
||||
const update: Partial<CardRowData> = { name, config, previewFileId };
|
||||
if (newImageId) update.imageFileId = newImageId;
|
||||
try {
|
||||
row = await api.updateRow(prev.id, data);
|
||||
row = await api.updateRow(prev.id, update);
|
||||
} catch (err) {
|
||||
if (errorType(err) !== 'row_not_found') throw err;
|
||||
replacedMissingCard = true;
|
||||
// Ohne Bildwechsel zeigt `data` auf die Datei der verschwundenen Karte; die neue Karte bekommt ein eigenes Bild.
|
||||
const ownImageId = imageFile ? imageFileId : await upload(toUploadFile(input.image, 'image'));
|
||||
row = await api.createRow({ ...data, imageFileId: ownImageId }, permissions);
|
||||
// Ohne Bildwechsel gibt es noch keine Bilddatei der neuen Karte; die der verschwundenen gehört ihr nicht.
|
||||
writingRow = false;
|
||||
const ownImageId = newImageId ?? (await upload(toUploadFile(input.image, 'image')));
|
||||
writingRow = true;
|
||||
row = await api.createRow({ name, config, imageFileId: ownImageId, previewFileId }, permissions);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
await deleteQuietly(api, uploaded);
|
||||
if (!writingRow || isDefinitiveFailure(err)) await deleteQuietly(api, uploaded);
|
||||
throw err;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,11 @@ import { LoginCancelled, withLogin } from './with-login';
|
||||
|
||||
const unauthorized = () => Object.assign(new Error('Unauthorized'), { code: 401, type: 'user_unauthorized' });
|
||||
|
||||
// Appwrite antwortet Gästen auf Lesezugriffe mit 404, nicht mit 401 (Zeilen- und Dateirechte).
|
||||
const rowNotFound = () => Object.assign(new Error('Row not found'), { code: 404, type: 'row_not_found' });
|
||||
const fileNotFound = () => Object.assign(new Error('File not found'), { code: 404, type: 'storage_file_not_found' });
|
||||
const user = { $id: 'u1', email: 'a@b.de' };
|
||||
|
||||
describe('withLogin', () => {
|
||||
it('gibt das Ergebnis zurück, ohne den Login-Dialog zu öffnen', async () => {
|
||||
const fn = vi.fn().mockResolvedValue('ok');
|
||||
@@ -76,4 +81,78 @@ describe('withLogin', () => {
|
||||
await expect(withLogin(fn, requireLogin, refreshUser)).resolves.toBe('ok');
|
||||
expect(requireLogin).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
describe('404 für Gäste (row_not_found / storage_file_not_found)', () => {
|
||||
it.each([
|
||||
['row_not_found', rowNotFound],
|
||||
['storage_file_not_found', fileNotFound],
|
||||
])('%s ohne Sitzung: wie eine abgelaufene Sitzung behandeln (anmelden, einmal wiederholen)', async (_name, make) => {
|
||||
const order: string[] = [];
|
||||
const fn = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(async () => {
|
||||
order.push('fn1');
|
||||
throw make();
|
||||
})
|
||||
.mockImplementationOnce(async () => {
|
||||
order.push('fn2');
|
||||
return 'ergebnis';
|
||||
});
|
||||
const refreshUser = vi.fn(async () => {
|
||||
order.push('refresh');
|
||||
return null;
|
||||
});
|
||||
const requireLogin = vi.fn(async () => {
|
||||
order.push('login');
|
||||
return true;
|
||||
});
|
||||
await expect(withLogin(fn, requireLogin, refreshUser)).resolves.toBe('ergebnis');
|
||||
expect(order).toEqual(['fn1', 'refresh', 'login', 'fn2']);
|
||||
expect(refreshUser).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('ist der Nutzer noch angemeldet, ist es ein echtes „nicht gefunden“ und wird durchgereicht', async () => {
|
||||
const notFound = rowNotFound();
|
||||
const fn = vi.fn().mockRejectedValue(notFound);
|
||||
const requireLogin = vi.fn();
|
||||
const refreshUser = vi.fn().mockResolvedValue(user);
|
||||
await expect(withLogin(fn, requireLogin, refreshUser)).rejects.toBe(notFound);
|
||||
expect(fn).toHaveBeenCalledOnce();
|
||||
expect(requireLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('scheitert die Nutzerprüfung (Netz), wird das ursprüngliche 404 durchgereicht', async () => {
|
||||
const notFound = fileNotFound();
|
||||
const fn = vi.fn().mockRejectedValue(notFound);
|
||||
const requireLogin = vi.fn();
|
||||
const refreshUser = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
await expect(withLogin(fn, requireLogin, refreshUser)).rejects.toBe(notFound);
|
||||
expect(requireLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Abbruch im Dialog: LoginCancelled, fn wird nicht erneut aufgerufen', async () => {
|
||||
const fn = vi.fn().mockRejectedValue(rowNotFound());
|
||||
const requireLogin = vi.fn().mockResolvedValue(false);
|
||||
const err = await withLogin(fn, requireLogin, vi.fn().mockResolvedValue(null)).catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(LoginCancelled);
|
||||
expect(fn).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('ein zweites 404 nach der Anmeldung wird durchgereicht (kein dritter Versuch)', async () => {
|
||||
const second = rowNotFound();
|
||||
const fn = vi.fn().mockRejectedValueOnce(rowNotFound()).mockRejectedValueOnce(second);
|
||||
const requireLogin = vi.fn().mockResolvedValue(true);
|
||||
await expect(withLogin(fn, requireLogin, vi.fn().mockResolvedValue(null))).rejects.toBe(second);
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
expect(requireLogin).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('ein anderes 404 (z. B. falscher Pfad) löst keinen Dialog aus', async () => {
|
||||
const other = Object.assign(new Error('Route not found'), { code: 404, type: 'general_route_not_found' });
|
||||
const fn = vi.fn().mockRejectedValue(other);
|
||||
const refreshUser = vi.fn();
|
||||
await expect(withLogin(fn, vi.fn(), refreshUser)).rejects.toBe(other);
|
||||
expect(refreshUser).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
// Führt eine Kartenanfrage aus und meldet bei abgelaufener Sitzung (401) einmal neu an.
|
||||
// Führt eine Kartenanfrage aus und meldet bei abgelaufener Sitzung (401, oder 404 für Gäste) einmal neu an.
|
||||
import { getCurrentUser } from './auth';
|
||||
import { isUnauthorized } from './errors';
|
||||
import { errorType, isUnauthorized } from './errors';
|
||||
import { requireLogin } from './login-dialog';
|
||||
|
||||
/** Der Nutzer hat den Login-Dialog geschlossen, ohne sich anzumelden. */
|
||||
@@ -11,9 +11,20 @@ export class LoginCancelled extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Appwrite beantwortet Lesezugriffe von Gästen auf Zeilen und Dateien mit Zeilen-/Dateirechten nicht mit 401,
|
||||
* sondern mit 404 (die Daten sind für sie „nicht vorhanden“).
|
||||
*/
|
||||
function isMissingForGuest(err: unknown): boolean {
|
||||
const type = errorType(err);
|
||||
return type === 'row_not_found' || type === 'storage_file_not_found';
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruft `fn` auf. Antwortet der Server mit 401, wird der Anmeldestatus neu geholt (damit Konto-Menü und
|
||||
* Nutzer-ID nicht veraltet sind), einmal der Login-Dialog gezeigt und `fn` danach genau einmal wiederholt.
|
||||
* Dasselbe gilt für „Zeile/Datei nicht gefunden“, wenn die Prüfung ergibt, dass gar keine Sitzung mehr besteht;
|
||||
* ist der Nutzer noch angemeldet (oder die Prüfung scheitert), ist es ein echtes „nicht gefunden“ und geht durch.
|
||||
* Schließt der Nutzer den Dialog, wirft das `LoginCancelled`; jeder andere Fehler geht unverändert durch.
|
||||
*/
|
||||
export async function withLogin<T>(
|
||||
@@ -24,12 +35,23 @@ export async function withLogin<T>(
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
if (!isUnauthorized(err)) throw err;
|
||||
}
|
||||
try {
|
||||
await refreshUser();
|
||||
} catch {
|
||||
// Ein Netzfehler hier darf die Anmeldung nicht verhindern; der Login-Dialog setzt den Nutzer ohnehin neu.
|
||||
if (isUnauthorized(err)) {
|
||||
try {
|
||||
await refreshUser();
|
||||
} catch {
|
||||
// Ein Netzfehler hier darf die Anmeldung nicht verhindern; der Login-Dialog setzt den Nutzer ohnehin neu.
|
||||
}
|
||||
} else if (isMissingForGuest(err)) {
|
||||
let user: unknown;
|
||||
try {
|
||||
user = await refreshUser();
|
||||
} catch {
|
||||
throw err; // ohne Gewissheit über die Sitzung bleibt es beim ursprünglichen Fehler
|
||||
}
|
||||
if (user) throw err;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
if (!(await requireLoginFn())) throw new LoginCancelled();
|
||||
return fn();
|
||||
|
||||
@@ -1 +1 @@
|
||||
<svg viewBox="172 360 680 730" xmlns="http://www.w3.org/2000/svg"><defs><mask id="aircards-symbol-waves" width="680" height="430" x="172" y="660" maskUnits="userSpaceOnUse"><path fill="#fff" d="M172 660h680v430H172z"/><g fill="none" stroke="#000" stroke-linecap="round" stroke-width="32"><path d="M591.1 843.9a44 44 0 0 1 0 62.2m34-96.2a92 92 0 0 1 0 130.2"/><path d="M659 776a140 140 0 0 1 0 198"/></g></mask></defs><g fill="currentColor"><path fill-opacity=".45" d="M252 360h520a80 80 0 0 1 80 80v150a80 80 0 0 0-80-80H252a80 80 0 0 0-80 80V440a80 80 0 0 1 80-80"/><path fill-opacity=".7" d="M252 510h520a80 80 0 0 1 80 80v150a80 80 0 0 0-80-80H252a80 80 0 0 0-80 80V590a80 80 0 0 1 80-80"/><rect width="680" height="430" x="172" y="660" mask="url(#aircards-symbol-waves)" rx="80"/></g></svg>
|
||||
<svg viewBox="172 360 680 730" xmlns="http://www.w3.org/2000/svg"><defs><mask id="aircards-symbol-waves"><path fill="#fff" d="M100 600h1000v600H100z"/><g fill="none" stroke="#000" stroke-linecap="round" stroke-width="32"><path d="M591.1 843.9a44 44 0 0 1 0 62.2m34-96.2a92 92 0 0 1 0 130.2"/><path d="M659 776a140 140 0 0 1 0 198"/></g></mask></defs><g fill="currentColor"><path fill-opacity=".45" d="M252 360h520a80 80 0 0 1 80 80v150a80 80 0 0 0-80-80H252a80 80 0 0 0-80 80V440a80 80 0 0 1 80-80"/><path fill-opacity=".7" d="M252 510h520a80 80 0 0 1 80 80v150a80 80 0 0 0-80-80H252a80 80 0 0 0-80 80V590a80 80 0 0 1 80-80"/><rect width="680" height="430" x="172" y="660" mask="url(#aircards-symbol-waves)" rx="80"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 794 B After Width: | Height: | Size: 727 B |
@@ -175,6 +175,21 @@ describe('AirCard-IOS-Marken', () => {
|
||||
expect(symbol).not.toMatch(/fill-opacity="1"/);
|
||||
});
|
||||
|
||||
it('Symbol: die Maske hat keine eigene Region und ihr weißes Feld ist größer als die vordere Karte (kein Haarstrich an der Naht)', () => {
|
||||
const tag = /<mask\b[^>]*>/.exec(symbol)?.[0] ?? '';
|
||||
// Ohne x/y/width/height gilt die Standardregion (-10 % / 120 % der Karte); eine Region exakt auf der Kartenkante
|
||||
// würde deren Kantenglättung doppelt anwenden und bei Vergrößerung einen hellen Strich lassen.
|
||||
expect(tag).not.toMatch(/\s(?:x|y|width|height|maskUnits)=/);
|
||||
const white = /<mask\b[^>]*>\s*<path\b[^>]*fill="#fff"[^>]*\bd="M(-?[\d.]+)[ ,](-?[\d.]+)h([\d.]+)v([\d.]+)H/.exec(symbol);
|
||||
expect(white, 'weißes Rechteck am Anfang der Maske').not.toBeNull();
|
||||
const [x, y, w, h] = white!.slice(1).map(Number);
|
||||
// Die vordere Karte: x 172–852, y 660–1090.
|
||||
expect(x).toBeLessThan(172);
|
||||
expect(y).toBeLessThan(660);
|
||||
expect(x + w).toBeGreaterThan(852);
|
||||
expect(y + h).toBeGreaterThan(1090);
|
||||
});
|
||||
|
||||
it('Symbol: enge viewBox um die drei Karten (680 x 730)', () => {
|
||||
expect(svgAspect(symbol)).toBeCloseTo(680 / 730, 3);
|
||||
});
|
||||
|
||||
@@ -93,6 +93,7 @@ const fileInput = () => root.querySelector<HTMLInputElement>('input[type="file"]
|
||||
const errorText = () => root.querySelector('.msg-error')!.textContent;
|
||||
const warningText = () => root.querySelector('.msg-warning')!.textContent;
|
||||
const noticeText = () => root.querySelector('.msg-notice')!.textContent;
|
||||
const discardDialogEl = () => document.querySelector<HTMLDialogElement>('dialog.confirm-dialog');
|
||||
|
||||
/** Der Editor mit einem gewählten Bild, bereit zum Speichern. */
|
||||
function withImage(blob = png(), img = image()): { blob: Blob; img: CardImage } {
|
||||
@@ -365,9 +366,9 @@ describe('Karte öffnen (/?karte=<id>)', () => {
|
||||
expect(mocks.decodeImage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Karte nicht gefunden: Meldung und leerer Editor', async () => {
|
||||
it('Karte nicht gefunden (angemeldet): Meldung, ein leerer Editor wird zurückgesetzt', async () => {
|
||||
open('gibtsnicht');
|
||||
store.set({ image: image(), imageBlob: png(), cardName: 'Altlast' });
|
||||
store.set({ config: { ...store.get().config, template: 'minimal' }, cardName: 'Altlast' });
|
||||
mocks.get.mockRejectedValue(Object.assign(new Error('nope'), { code: 404, type: 'row_not_found' }));
|
||||
mount();
|
||||
await vi.waitFor(() =>
|
||||
@@ -375,28 +376,230 @@ describe('Karte öffnen (/?karte=<id>)', () => {
|
||||
);
|
||||
expect(store.get()).toEqual(initialEditorState());
|
||||
expect(noticeText()).toBe('');
|
||||
expect(mocks.requireLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Anmeldung abgebrochen: Hinweis, Editor unverändert', async () => {
|
||||
open('k1');
|
||||
mocks.get.mockRejectedValue(Object.assign(new Error('Unauthorized'), { code: 401 }));
|
||||
mocks.requireLogin.mockResolvedValue(false);
|
||||
it('Karte nicht gefunden (angemeldet) bei gefülltem Editor: nur die Meldung, der Editor bleibt', async () => {
|
||||
open('gibtsnicht');
|
||||
const { blob, img } = withImage();
|
||||
store.set({ cardName: 'Entwurf' });
|
||||
const before = store.get();
|
||||
mocks.get.mockRejectedValue(Object.assign(new Error('nope'), { code: 404, type: 'row_not_found' }));
|
||||
mount();
|
||||
await vi.waitFor(() => expect(warningText()).toBe('Melde dich an, um diese Karte zu öffnen.'));
|
||||
expect(store.get().saved).toBeNull();
|
||||
await vi.waitFor(() =>
|
||||
expect(errorText()).toBe('Diese Karte gibt es nicht (mehr) oder sie gehört nicht zu deinem Konto.'),
|
||||
);
|
||||
expect(store.get()).toBe(before);
|
||||
expect(store.get().imageBlob).toBe(blob);
|
||||
expect(store.get().image).toBe(img);
|
||||
expect(store.get().cardName).toBe('Entwurf');
|
||||
expect(noticeText()).toBe('');
|
||||
expect(discardDialogEl()).toBeNull();
|
||||
});
|
||||
|
||||
it('Sitzung abgelaufen, Anmeldung klappt: Karte wird danach geöffnet', async () => {
|
||||
open('k1');
|
||||
mocks.get
|
||||
.mockRejectedValueOnce(Object.assign(new Error('Unauthorized'), { code: 401 }))
|
||||
.mockResolvedValueOnce(savedCard());
|
||||
mocks.loadImage.mockResolvedValue(png());
|
||||
mocks.decodeImage.mockResolvedValue(image());
|
||||
it('Karte nicht gefunden bei gefülltem Editor mit gespeicherter Karte: auch hier bleibt alles', async () => {
|
||||
open('gibtsnicht');
|
||||
withImage();
|
||||
store.set({
|
||||
saved: { id: 'k9', name: 'Neun', imageFileId: 'i', previewFileId: 'p', updatedAt: 'x' },
|
||||
imageChanged: false,
|
||||
});
|
||||
const before = store.get();
|
||||
mocks.get.mockRejectedValue(Object.assign(new Error('nope'), { code: 404, type: 'row_not_found' }));
|
||||
mount();
|
||||
await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1'));
|
||||
expect(mocks.requireLogin).toHaveBeenCalledTimes(1);
|
||||
await vi.waitFor(() => expect(errorText()).not.toBe(''));
|
||||
expect(store.get()).toBe(before);
|
||||
});
|
||||
|
||||
describe('ohne Sitzung (Appwrite antwortet Gästen mit 404, nicht 401)', () => {
|
||||
it('Gast: zuerst anmelden, dann erst laden; danach wird die Karte geöffnet', async () => {
|
||||
open('k1');
|
||||
const order: string[] = [];
|
||||
mocks.getCurrentUser.mockImplementation(async () => {
|
||||
order.push('user');
|
||||
return null;
|
||||
});
|
||||
mocks.requireLogin.mockImplementation(async () => {
|
||||
order.push('login');
|
||||
return true;
|
||||
});
|
||||
mocks.get.mockImplementation(async () => {
|
||||
order.push('get');
|
||||
return savedCard();
|
||||
});
|
||||
mocks.loadImage.mockResolvedValue(png());
|
||||
mocks.decodeImage.mockResolvedValue(image());
|
||||
mount();
|
||||
await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1'));
|
||||
expect(order).toEqual(['user', 'login', 'get']);
|
||||
expect(mocks.requireLogin).toHaveBeenCalledWith('login');
|
||||
expect(errorText()).toBe('');
|
||||
});
|
||||
|
||||
it('Gast bricht den Dialog ab: Hinweis, nichts wird geladen, der Editor bleibt unberührt', async () => {
|
||||
open('k1');
|
||||
const { blob } = withImage();
|
||||
const before = store.get();
|
||||
mocks.getCurrentUser.mockResolvedValue(null);
|
||||
mocks.requireLogin.mockResolvedValue(false);
|
||||
mount();
|
||||
await vi.waitFor(() => expect(warningText()).toBe('Melde dich an, um diese Karte zu öffnen.'));
|
||||
expect(mocks.get).not.toHaveBeenCalled();
|
||||
expect(store.get()).toBe(before);
|
||||
expect(store.get().imageBlob).toBe(blob);
|
||||
expect(noticeText()).toBe('');
|
||||
expect(errorText()).toBe('');
|
||||
});
|
||||
|
||||
it('Sitzung läuft mitten im Laden ab (404 row_not_found, Nutzerprüfung ergibt null): anmelden und einmal wiederholen', async () => {
|
||||
open('k1');
|
||||
mocks.getCurrentUser.mockResolvedValueOnce(user).mockResolvedValue(null);
|
||||
mocks.get
|
||||
.mockRejectedValueOnce(Object.assign(new Error('Row not found'), { code: 404, type: 'row_not_found' }))
|
||||
.mockResolvedValueOnce(savedCard());
|
||||
mocks.loadImage.mockResolvedValue(png());
|
||||
mocks.decodeImage.mockResolvedValue(image());
|
||||
mount();
|
||||
await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1'));
|
||||
expect(mocks.requireLogin).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('Bilddatei 404 ohne Sitzung: anmelden, das Bild wird danach geladen', async () => {
|
||||
open('k1');
|
||||
mocks.get.mockResolvedValue(savedCard());
|
||||
mocks.getCurrentUser.mockResolvedValueOnce(user).mockResolvedValue(null);
|
||||
mocks.loadImage
|
||||
.mockRejectedValueOnce(Object.assign(new Error('File not found'), { code: 404, type: 'storage_file_not_found' }))
|
||||
.mockResolvedValueOnce(png());
|
||||
mocks.decodeImage.mockResolvedValue(image());
|
||||
mount();
|
||||
await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1'));
|
||||
expect(mocks.requireLogin).toHaveBeenCalledTimes(1);
|
||||
expect(store.get().image).not.toBeNull();
|
||||
expect(warningText()).toBe('');
|
||||
});
|
||||
|
||||
it('angemeldet und Bilddatei wirklich weg: die Karte öffnet ohne Bild, kein Dialog', async () => {
|
||||
open('k1');
|
||||
mocks.get.mockResolvedValue(savedCard());
|
||||
mocks.loadImage.mockRejectedValue(Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' }));
|
||||
mount();
|
||||
await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1'));
|
||||
expect(mocks.requireLogin).not.toHaveBeenCalled();
|
||||
expect(warningText()).toBe('Das Bild dieser Karte fehlt.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ungespeicherte Änderungen im Editor', () => {
|
||||
const dialogButton = (label: string) =>
|
||||
[...discardDialogEl()!.querySelectorAll('button')].find((b) => b.textContent === label) as HTMLButtonElement;
|
||||
const loadable = () => {
|
||||
mocks.get.mockResolvedValue(savedCard());
|
||||
mocks.loadImage.mockResolvedValue(png());
|
||||
mocks.decodeImage.mockResolvedValue(image(1200, 800));
|
||||
};
|
||||
|
||||
it('ein neues, nie gespeichertes Bild: Rückfrage; „Verwerfen“ übernimmt die geöffnete Karte', async () => {
|
||||
open('k1');
|
||||
loadable();
|
||||
withImage();
|
||||
mount();
|
||||
await vi.waitFor(() => expect(discardDialogEl()?.open).toBe(true));
|
||||
expect(discardDialogEl()!.querySelector('h2')!.textContent).toBe('Ungespeicherte Änderungen verwerfen?');
|
||||
// Solange gefragt wird, bleibt der Entwurf.
|
||||
expect(store.get().saved).toBeNull();
|
||||
expect(store.get().imageChanged).toBe(true);
|
||||
dialogButton('Verwerfen').click();
|
||||
await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1'));
|
||||
expect(store.get().imageChanged).toBe(false);
|
||||
expect(store.get().cardName).toBe('Urlaub');
|
||||
expect(discardDialogEl()).toBeNull();
|
||||
expect(noticeText()).toBe('');
|
||||
});
|
||||
|
||||
it('„Abbrechen“: Entwurf bleibt, ?karte= verschwindet aus der Adresse, die Karte wird nicht übernommen', async () => {
|
||||
open('k1');
|
||||
loadable();
|
||||
const { blob, img } = withImage();
|
||||
const before = store.get();
|
||||
mount();
|
||||
await vi.waitFor(() => expect(discardDialogEl()?.open).toBe(true));
|
||||
dialogButton('Abbrechen').click();
|
||||
await vi.waitFor(() => expect(discardDialogEl()).toBeNull());
|
||||
await vi.waitFor(() => expect(noticeText()).toBe(''));
|
||||
expect(store.get()).toBe(before);
|
||||
expect(store.get().imageBlob).toBe(blob);
|
||||
expect(store.get().image).toBe(img);
|
||||
expect(location.pathname + location.search).toBe('/');
|
||||
});
|
||||
|
||||
it('Esc im Dialog verwirft nichts', async () => {
|
||||
open('k1');
|
||||
loadable();
|
||||
withImage();
|
||||
const before = store.get();
|
||||
mount();
|
||||
await vi.waitFor(() => expect(discardDialogEl()?.open).toBe(true));
|
||||
discardDialogEl()!.dispatchEvent(new Event('close'));
|
||||
await vi.waitFor(() => expect(discardDialogEl()).toBeNull());
|
||||
await vi.waitFor(() => expect(location.search).toBe(''));
|
||||
expect(store.get()).toBe(before);
|
||||
});
|
||||
|
||||
it('geänderte Konfiguration einer anderen gespeicherten Karte: ebenfalls Rückfrage', async () => {
|
||||
open('k1');
|
||||
loadable();
|
||||
withImage();
|
||||
const config = store.get().config;
|
||||
store.set({
|
||||
saved: { id: 'k7', name: 'Sieben', imageFileId: 'i', previewFileId: 'p', updatedAt: 'x' },
|
||||
savedConfig: config,
|
||||
imageChanged: false,
|
||||
config: { ...config, template: 'minimal' },
|
||||
});
|
||||
mount();
|
||||
await vi.waitFor(() => expect(discardDialogEl()?.open).toBe(true));
|
||||
dialogButton('Verwerfen').click();
|
||||
await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1'));
|
||||
});
|
||||
|
||||
it('eine andere gespeicherte Karte ohne Änderungen wird ohne Rückfrage ersetzt', async () => {
|
||||
open('k1');
|
||||
loadable();
|
||||
withImage();
|
||||
const config = store.get().config;
|
||||
store.set({
|
||||
saved: { id: 'k7', name: 'Sieben', imageFileId: 'i', previewFileId: 'p', updatedAt: 'x' },
|
||||
savedConfig: config,
|
||||
imageChanged: false,
|
||||
});
|
||||
mount();
|
||||
await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1'));
|
||||
expect(discardDialogEl()).toBeNull();
|
||||
});
|
||||
|
||||
it('ein leerer Editor wird ohne Rückfrage befüllt', async () => {
|
||||
open('k1');
|
||||
loadable();
|
||||
mount();
|
||||
await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1'));
|
||||
expect(discardDialogEl()).toBeNull();
|
||||
});
|
||||
|
||||
it('verlässt der Nutzer die Seite, während gefragt wird, schließt der Dialog und nichts wird übernommen', async () => {
|
||||
open('k1');
|
||||
loadable();
|
||||
withImage();
|
||||
const before = store.get();
|
||||
mount();
|
||||
await vi.waitFor(() => expect(discardDialogEl()?.open).toBe(true));
|
||||
unmount?.();
|
||||
unmount = null;
|
||||
await vi.waitFor(() => expect(discardDialogEl()).toBeNull());
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(store.get()).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
it('anderer Fehler (Netz): Meldung, Editor unverändert', async () => {
|
||||
@@ -417,6 +620,16 @@ describe('Karte öffnen (/?karte=<id>)', () => {
|
||||
expect(store.get().saved).toBeNull();
|
||||
});
|
||||
|
||||
it('ein Bild über 40 Megapixel meldet die Größen-Meldung, ohne die Karte zu übernehmen', async () => {
|
||||
open('k1');
|
||||
mocks.get.mockResolvedValue(savedCard());
|
||||
mocks.loadImage.mockResolvedValue(png());
|
||||
mocks.decodeImage.mockRejectedValue(new Error('Das Bild ist zu groß (max. 40 Megapixel).'));
|
||||
mount();
|
||||
await vi.waitFor(() => expect(errorText()).toBe('Das Bild ist zu groß (max. 40 Megapixel).'));
|
||||
expect(store.get().saved).toBeNull();
|
||||
});
|
||||
|
||||
it('verlässt der Nutzer die Seite während des Ladens, wird nichts mehr übernommen', async () => {
|
||||
open('k1');
|
||||
let finish!: (c: SavedCard) => void;
|
||||
|
||||
@@ -18,7 +18,7 @@ import { attachFileDrop, createPreview } from './preview';
|
||||
import { createSaveAction, type SaveStatus } from './save';
|
||||
import { initialEditorState, setImage, type EditorState, type EditorStore, type SavedCardRef } from './state';
|
||||
import { createTemplatesPanel } from './templates-panel';
|
||||
import { READ_MESSAGE, decodeImage, validateImageFile } from './upload';
|
||||
import { PIXELS_MESSAGE, READ_MESSAGE, decodeImage, validateImageFile } from './upload';
|
||||
|
||||
const LOGO_ERROR = 'Die Logos konnten nicht geladen werden.';
|
||||
const EXPORT_ERROR = 'Das PNG konnte nicht erzeugt werden.';
|
||||
@@ -51,8 +51,14 @@ function hasUnsavedChanges(s: EditorState): boolean {
|
||||
return s.savedConfig !== null && serializeConfig(s.config) !== serializeConfig(s.savedConfig);
|
||||
}
|
||||
|
||||
/** Hält der Editor ein Bild mit Änderungen, die ein Öffnen einer anderen Karte verlieren würde? */
|
||||
function hasUnsavedWork(s: EditorState): boolean {
|
||||
return s.image !== null && hasUnsavedChanges(s);
|
||||
}
|
||||
|
||||
export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
|
||||
let disposed = false;
|
||||
const disposal = new AbortController(); // schließt eine offene Rückfrage beim Verlassen der Seite
|
||||
|
||||
const patchConfig = (patch: Partial<CardConfig>) => store.set({ config: { ...store.get().config, ...patch } });
|
||||
|
||||
@@ -213,6 +219,12 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
|
||||
preview.setWarning(null);
|
||||
preview.setNotice(LOADING_CARD);
|
||||
try {
|
||||
// Appwrite beantwortet Gäste mit 404 statt 401: ohne Sitzung zuerst anmelden, statt „nicht gefunden“ zu melden.
|
||||
if (!(await getCurrentUser())) {
|
||||
if (stale()) return;
|
||||
if (!(await requireLogin('login'))) throw new LoginCancelled();
|
||||
if (stale()) return;
|
||||
}
|
||||
const card = await withLogin(() => service.get(id));
|
||||
if (stale()) return;
|
||||
|
||||
@@ -229,6 +241,16 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
|
||||
const image: CardImage | null = blob ? await decodeImage(blob) : null;
|
||||
if (stale()) return;
|
||||
|
||||
// Die geladene Karte würde einen ungespeicherten Entwurf ersetzen: erst fragen (wie bei „Neue Karte“).
|
||||
if (hasUnsavedWork(store.get())) {
|
||||
const discard = await askToDiscard();
|
||||
if (stale()) return;
|
||||
if (!discard) {
|
||||
dropCardParam();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const config = image ? { ...card.config, crop: clampCrop(image, card.config.crop) } : card.config;
|
||||
store.set({
|
||||
config,
|
||||
@@ -245,35 +267,45 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
|
||||
if (err instanceof LoginCancelled) {
|
||||
preview.setWarning(LOGIN_TO_OPEN);
|
||||
} else if (errorType(err) === 'row_not_found') {
|
||||
// Die Karte gibt es nicht (für dieses Konto): kein halb übernommener Zustand, sondern ein leerer Editor.
|
||||
store.set(initialEditorState());
|
||||
// Die Karte gibt es nicht (für dieses Konto). Ein leerer Editor wird zurückgesetzt; hält er Arbeit, bleibt sie.
|
||||
const s = store.get();
|
||||
if (s.image === null && s.saved === null) store.set(initialEditorState());
|
||||
preview.setError(errorMessage(err, 'load'));
|
||||
} else {
|
||||
preview.setError(err instanceof Error && err.message === READ_MESSAGE ? READ_MESSAGE : errorMessage(err, 'load'));
|
||||
const imageProblem = err instanceof Error && (err.message === READ_MESSAGE || err.message === PIXELS_MESSAGE);
|
||||
preview.setError(imageProblem ? err.message : errorMessage(err, 'load'));
|
||||
}
|
||||
} finally {
|
||||
if (!stale()) preview.setNotice(null);
|
||||
}
|
||||
}
|
||||
|
||||
// „Neue Karte“: löst den Editor von der gespeicherten Karte, damit ein anderes Bild sie nicht überschreibt.
|
||||
// Rückfrage vor dem Verwerfen von Änderungen („Neue Karte“, Öffnen einer anderen Karte).
|
||||
let askingToDiscard = false;
|
||||
async function startNewCard(): Promise<void> {
|
||||
if (askingToDiscard || store.get().saving || !store.get().saved) return;
|
||||
if (hasUnsavedChanges(store.get())) {
|
||||
askingToDiscard = true;
|
||||
let discard = false;
|
||||
try {
|
||||
discard = await confirmDialog({
|
||||
async function askToDiscard(): Promise<boolean> {
|
||||
askingToDiscard = true;
|
||||
try {
|
||||
return await confirmDialog(
|
||||
{
|
||||
title: 'Ungespeicherte Änderungen verwerfen?',
|
||||
text: 'Alles, was du seit dem letzten Speichern geändert hast, geht verloren.',
|
||||
confirmLabel: 'Verwerfen',
|
||||
});
|
||||
} catch {
|
||||
// Dialog nicht darstellbar: dann lieber nichts verwerfen.
|
||||
} finally {
|
||||
askingToDiscard = false;
|
||||
}
|
||||
},
|
||||
disposal.signal,
|
||||
);
|
||||
} catch {
|
||||
// Dialog nicht darstellbar: dann lieber nichts verwerfen.
|
||||
return false;
|
||||
} finally {
|
||||
askingToDiscard = false;
|
||||
}
|
||||
}
|
||||
|
||||
// „Neue Karte“: löst den Editor von der gespeicherten Karte, damit ein anderes Bild sie nicht überschreibt.
|
||||
async function startNewCard(): Promise<void> {
|
||||
if (askingToDiscard || store.get().saving || !store.get().saved) return;
|
||||
if (hasUnsavedChanges(store.get())) {
|
||||
const discard = await askToDiscard();
|
||||
// Währenddessen kann gespeichert oder die Seite verlassen worden sein.
|
||||
if (!discard || disposed || store.get().saving) return;
|
||||
}
|
||||
@@ -371,6 +403,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
disposal.abort();
|
||||
clearTimeout(flashTimer);
|
||||
offLogout();
|
||||
unsubscribe();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Editor: drei Spalten (Vorlagen | Vorschau | Optionen), unter 900 px einspaltig. */
|
||||
/* Editor: drei Spalten (Vorlagen | Vorschau | Optionen), unter 1024 px einspaltig. */
|
||||
|
||||
.editor [hidden] {
|
||||
display: none !important;
|
||||
@@ -30,7 +30,8 @@
|
||||
--card-radius: 3.711% / 5.882%; /* 57 px von 1536 x 969, nur in der Vorschau */
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 300px 1fr 400px;
|
||||
/* Die Seitenspalten geben nach, damit die Vorschau in der Mitte ab 1024 px mindestens 360 px breit bleibt. */
|
||||
grid-template-columns: minmax(210px, 280px) minmax(470px, 1fr) minmax(250px, 360px);
|
||||
align-items: stretch;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 24px;
|
||||
@@ -494,9 +495,29 @@
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Unter 900 px: Vorschau, Vorlagen als waagerechte Reihe, Optionen */
|
||||
/* Ab 1024 px: schmale Optionsspalte → zwei statt drei Netzwerk-Kacheln pro Reihe, damit Beschriftungen nicht mitten im Wort brechen */
|
||||
|
||||
@media (max-width: 899px) {
|
||||
@media (min-width: 1024px) {
|
||||
.mark-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(92px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
/* 1024 bis 1199 px: drei Spalten, aber die Mitte bekommt kompaktere Knöpfe, damit sie in einer Reihe bleiben */
|
||||
|
||||
@media (min-width: 1024px) and (max-width: 1199px) {
|
||||
.editor-center {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.editor-center .btn {
|
||||
padding: 0 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Unter 1024 px: Vorschau, Vorlagen als waagerechte Reihe, Optionen */
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.editor {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Tastatursteuerung der Kartenvorschau (Verschieben, Zoom).
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createPreview, type PreviewHandlers } from './preview';
|
||||
|
||||
function setup() {
|
||||
const handlers = {
|
||||
onFile: vi.fn(),
|
||||
onPan: vi.fn(),
|
||||
onZoom: vi.fn(),
|
||||
onDownload: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
onNewCard: vi.fn(),
|
||||
} satisfies PreviewHandlers;
|
||||
const preview = createPreview(handlers);
|
||||
preview.sync({ hasImage: true, zoom: 1, saving: false, saved: false, boundName: null, exporting: false });
|
||||
const press = (key: string, init: KeyboardEventInit = {}) => {
|
||||
const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init });
|
||||
preview.canvas.dispatchEvent(event);
|
||||
return event;
|
||||
};
|
||||
return { handlers, press };
|
||||
}
|
||||
|
||||
describe('Vorschau: Tastatur', () => {
|
||||
it('Pfeiltasten verschieben, Umschalt vergrößert den Schritt', () => {
|
||||
const { handlers, press } = setup();
|
||||
expect(press('ArrowLeft').defaultPrevented).toBe(true);
|
||||
expect(handlers.onPan).toHaveBeenLastCalledWith(-24, 0);
|
||||
press('ArrowDown', { shiftKey: true });
|
||||
expect(handlers.onPan).toHaveBeenLastCalledWith(0, 96);
|
||||
});
|
||||
|
||||
it('+, = und - zoomen um 0,1', () => {
|
||||
const { handlers, press } = setup();
|
||||
press('+');
|
||||
expect(handlers.onZoom).toHaveBeenLastCalledWith(expect.closeTo(1.1, 5));
|
||||
press('=');
|
||||
expect(handlers.onZoom).toHaveBeenLastCalledWith(expect.closeTo(1.1, 5));
|
||||
press('-');
|
||||
expect(handlers.onZoom).toHaveBeenLastCalledWith(expect.closeTo(0.9, 5));
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Strg', { ctrlKey: true }],
|
||||
['Cmd', { metaKey: true }],
|
||||
['Alt', { altKey: true }],
|
||||
])('%s + Taste gehört dem Browser (Browser-Zoom, Verlauf): nichts wird gesteuert oder verschluckt', (_name, mod) => {
|
||||
const { handlers, press } = setup();
|
||||
for (const key of ['+', '=', '-', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']) {
|
||||
const event = press(key, mod);
|
||||
expect(event.defaultPrevented, `${key} mit ${_name}`).toBe(false);
|
||||
}
|
||||
expect(handlers.onPan).not.toHaveBeenCalled();
|
||||
expect(handlers.onZoom).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -151,6 +151,8 @@ export function createPreview(handlers: PreviewHandlers): Preview {
|
||||
canvas.addEventListener('pointercancel', endPan);
|
||||
|
||||
canvas.addEventListener('keydown', (e) => {
|
||||
// Mit Strg/Cmd/Alt gehören die Tasten dem Browser (Seitenzoom Strg + und Strg -, Verlauf Alt + Pfeil).
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
const step = e.shiftKey ? KEY_STEP_BIG : KEY_STEP;
|
||||
const moves: Record<string, [number, number]> = {
|
||||
ArrowLeft: [-step, 0],
|
||||
|
||||
@@ -4,6 +4,7 @@ import { decodeImage, validateImageFile } from './upload';
|
||||
const TYPE_MESSAGE = 'Bitte ein PNG-, JPG- oder WebP-Bild wählen.';
|
||||
const SIZE_MESSAGE = 'Das Bild ist größer als 5 MiB.';
|
||||
const READ_MESSAGE = 'Das Bild konnte nicht gelesen werden.';
|
||||
const PIXELS_MESSAGE = 'Das Bild ist zu groß (max. 40 Megapixel).';
|
||||
|
||||
describe('validateImageFile', () => {
|
||||
it('nimmt ein kleines PNG an', () => {
|
||||
@@ -55,6 +56,7 @@ describe('decodeImage', () => {
|
||||
}
|
||||
let behaviour: Behaviour;
|
||||
let created: FakeImage[];
|
||||
let decodeCalls: number;
|
||||
let createObjectURL: ReturnType<typeof vi.fn>;
|
||||
let revokeObjectURL: ReturnType<typeof vi.fn>;
|
||||
|
||||
@@ -69,8 +71,10 @@ describe('decodeImage', () => {
|
||||
constructor() {
|
||||
created.push(this);
|
||||
if (behaviour.decode !== 'missing') {
|
||||
this.decode = () =>
|
||||
behaviour.decode === 'ok' ? this.#settle() : Promise.reject(new DOMException('kaputt', 'EncodingError'));
|
||||
this.decode = () => {
|
||||
decodeCalls += 1;
|
||||
return behaviour.decode === 'ok' ? this.#settle() : Promise.reject(new DOMException('kaputt', 'EncodingError'));
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +105,7 @@ describe('decodeImage', () => {
|
||||
beforeEach(() => {
|
||||
behaviour = { decode: 'ok', load: 'ok', width: 4000, height: 3000 };
|
||||
created = [];
|
||||
decodeCalls = 0;
|
||||
createObjectURL = vi.fn(() => 'blob:test-url');
|
||||
revokeObjectURL = vi.fn();
|
||||
vi.stubGlobal('Image', FakeImage);
|
||||
@@ -152,4 +157,34 @@ describe('decodeImage', () => {
|
||||
behaviour = { ...behaviour, decode: 'missing', load: 'fail' };
|
||||
await expect(decodeImage(new Blob(['x']))).rejects.toThrow(READ_MESSAGE);
|
||||
});
|
||||
|
||||
describe('Pixel-Obergrenze (40 Megapixel)', () => {
|
||||
it('nimmt genau 40 000 000 Pixel an', async () => {
|
||||
behaviour = { ...behaviour, width: 8000, height: 5000 };
|
||||
const image = await decodeImage(new Blob(['x']));
|
||||
expect(image.w * image.h).toBe(40_000_000);
|
||||
});
|
||||
|
||||
it('lehnt 40 000 005 Pixel mit der Größen-Meldung ab', async () => {
|
||||
behaviour = { ...behaviour, width: 8001, height: 5000 };
|
||||
await expect(decodeImage(new Blob(['x']))).rejects.toThrow(PIXELS_MESSAGE);
|
||||
});
|
||||
|
||||
it('lehnt ein extrem großes Bild ab, ohne es zu dekodieren, und gibt die URL frei', async () => {
|
||||
behaviour = { ...behaviour, width: 20000, height: 20000 };
|
||||
await expect(decodeImage(new Blob(['x']))).rejects.toThrow(PIXELS_MESSAGE);
|
||||
expect(decodeCalls).toBe(0);
|
||||
expect(revokeObjectURL).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('prüft auch ohne decode() (nur load)', async () => {
|
||||
behaviour = { ...behaviour, decode: 'missing', width: 9000, height: 9000 };
|
||||
await expect(decodeImage(new Blob(['x']))).rejects.toThrow(PIXELS_MESSAGE);
|
||||
});
|
||||
|
||||
it('ein normales Bild wird weiterhin dekodiert', async () => {
|
||||
await decodeImage(new Blob(['x']));
|
||||
expect(decodeCalls).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,10 +3,13 @@ import type { CardImage } from '../card/render';
|
||||
|
||||
export const ACCEPTED_TYPES = ['image/png', 'image/jpeg', 'image/webp'] as const;
|
||||
export const MAX_IMAGE_BYTES = 5_242_880;
|
||||
/** Obergrenze für Breite × Höhe: ein kleines, stark komprimiertes PNG kann sonst Gigabytes an Pixeln enthalten. */
|
||||
export const MAX_IMAGE_PIXELS = 40_000_000;
|
||||
|
||||
export const TYPE_MESSAGE = 'Bitte ein PNG-, JPG- oder WebP-Bild wählen.';
|
||||
export const SIZE_MESSAGE = 'Das Bild ist größer als 5 MiB.';
|
||||
export const READ_MESSAGE = 'Das Bild konnte nicht gelesen werden.';
|
||||
export const PIXELS_MESSAGE = 'Das Bild ist zu groß (max. 40 Megapixel).';
|
||||
|
||||
export type FileCheck = { ok: true } | { ok: false; message: string };
|
||||
|
||||
@@ -43,10 +46,15 @@ export async function decodeImage(blob: Blob): Promise<CardImage> {
|
||||
// Wird nur bei Bedarf abgewartet; ohne das hier meldet der Browser eine unbehandelte Ablehnung.
|
||||
loaded.catch(() => undefined);
|
||||
img.src = url;
|
||||
// Erst auf load warten: dann sind die Maße bekannt, das Bild aber noch nicht dekodiert (decode() belegt den Speicher).
|
||||
await loaded;
|
||||
if (!img.naturalWidth || !img.naturalHeight) throw new Error(READ_MESSAGE);
|
||||
if (img.naturalWidth * img.naturalHeight > MAX_IMAGE_PIXELS) throw new Error(PIXELS_MESSAGE);
|
||||
await whenDecoded(img, loaded);
|
||||
if (!img.naturalWidth || !img.naturalHeight) throw new Error(READ_MESSAGE);
|
||||
return { source: img, w: img.naturalWidth, h: img.naturalHeight };
|
||||
} catch {
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message === PIXELS_MESSAGE) throw err;
|
||||
throw new Error(READ_MESSAGE);
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
@@ -31,7 +31,11 @@ vi.mock('../account/auth', () => ({
|
||||
}));
|
||||
vi.mock('../account/login-dialog', () => ({ requireLogin: mocks.requireLogin }));
|
||||
vi.mock('./router', () => ({ navigate: mocks.navigate }));
|
||||
vi.mock('../editor/upload', () => ({ READ_MESSAGE: 'Das Bild konnte nicht gelesen werden.', decodeImage: mocks.decodeImage }));
|
||||
vi.mock('../editor/upload', () => ({
|
||||
READ_MESSAGE: 'Das Bild konnte nicht gelesen werden.',
|
||||
PIXELS_MESSAGE: 'Das Bild ist zu groß (max. 40 Megapixel).',
|
||||
decodeImage: mocks.decodeImage,
|
||||
}));
|
||||
vi.mock('../card/render', () => ({ loadCardAssets: mocks.loadCardAssets, exportCard: mocks.exportCard }));
|
||||
vi.mock('../editor/download', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../editor/download')>()),
|
||||
@@ -267,6 +271,19 @@ describe('Meine Karten: Öffnen und Herunterladen', () => {
|
||||
expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('');
|
||||
});
|
||||
|
||||
it('Herunterladen eines über 40 Megapixel großen Bildes zeigt die Größen-Meldung an der Kachel', async () => {
|
||||
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
|
||||
mocks.loadImage.mockResolvedValue(new Blob(['i'], { type: 'image/png' }));
|
||||
mocks.decodeImage.mockRejectedValue(new Error('Das Bild ist zu groß (max. 40 Megapixel).'));
|
||||
mount();
|
||||
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
|
||||
button(tileFor('a'), 'Herunterladen').click();
|
||||
await vi.waitFor(() =>
|
||||
expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('Das Bild ist zu groß (max. 40 Megapixel).'),
|
||||
);
|
||||
expect(mocks.saveBlobAs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Herunterladen ohne Bilddatei zeigt die Meldung an der Kachel', async () => {
|
||||
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
|
||||
mocks.loadImage.mockRejectedValue(Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' }));
|
||||
@@ -279,6 +296,75 @@ describe('Meine Karten: Öffnen und Herunterladen', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Meine Karten: Sitzung weg, Appwrite antwortet mit 404 statt 401', () => {
|
||||
const fileGone = () => Object.assign(new Error('File not found'), { code: 404, type: 'storage_file_not_found' });
|
||||
const rowGone = () => Object.assign(new Error('Row not found'), { code: 404, type: 'row_not_found' });
|
||||
/** Beim Laden der Seite ist der Nutzer noch da; danach (Nutzerprüfung nach dem 404) nicht mehr. */
|
||||
const sessionEndsAfterLoad = () => mocks.getCurrentUser.mockResolvedValueOnce(user).mockResolvedValue(null);
|
||||
|
||||
it('Vorschau: erst anmelden, dann wird die Vorschau geladen (statt „Keine Vorschau“)', async () => {
|
||||
sessionEndsAfterLoad();
|
||||
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
|
||||
mocks.loadPreview.mockReset();
|
||||
mocks.loadPreview
|
||||
.mockRejectedValueOnce(fileGone())
|
||||
.mockImplementation(async () => new Blob(['p'], { type: 'image/webp' }));
|
||||
mount();
|
||||
await vi.waitFor(() => expect(tileFor('a').querySelector('img')).not.toBeNull());
|
||||
expect(mocks.requireLogin).toHaveBeenCalledOnce();
|
||||
expect(mocks.loadPreview).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('Herunterladen: erst anmelden, dann wird das Bild geladen (statt „Das Bild dieser Karte fehlt.“)', async () => {
|
||||
const png = new Blob(['png'], { type: 'image/png' });
|
||||
const image = { source: {}, w: 3000, h: 2000 };
|
||||
sessionEndsAfterLoad();
|
||||
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
|
||||
mocks.loadImage.mockRejectedValueOnce(fileGone()).mockResolvedValueOnce(new Blob(['i'], { type: 'image/jpeg' }));
|
||||
mocks.decodeImage.mockResolvedValue(image);
|
||||
mocks.loadCardAssets.mockResolvedValue({ image, network: null, brand: null });
|
||||
mocks.exportCard.mockResolvedValue(png);
|
||||
mount();
|
||||
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
|
||||
button(tileFor('a'), 'Herunterladen').click();
|
||||
await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalledWith(png, 'aircard-alpha.png'));
|
||||
expect(mocks.requireLogin).toHaveBeenCalledOnce();
|
||||
expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('');
|
||||
});
|
||||
|
||||
it('Löschen: die Karte gilt nicht als „schon gelöscht“, sondern wird nach dem Anmelden wirklich gelöscht', async () => {
|
||||
sessionEndsAfterLoad();
|
||||
mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]);
|
||||
mocks.remove.mockRejectedValueOnce(rowGone()).mockResolvedValueOnce(undefined);
|
||||
mount();
|
||||
await vi.waitFor(() => expect(tiles()).toHaveLength(2));
|
||||
await vi.waitFor(() => expect(button(tileFor('b'), 'Löschen').disabled).toBe(false));
|
||||
button(tileFor('b'), 'Löschen').click();
|
||||
await vi.waitFor(() => expect(dialog()?.open).toBe(true));
|
||||
button(dialog()!, 'Löschen').click();
|
||||
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
|
||||
expect(mocks.requireLogin).toHaveBeenCalledOnce();
|
||||
expect(mocks.remove).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('Löschen, Anmeldung abgebrochen: die Karte bleibt, Hinweis an der Kachel', async () => {
|
||||
sessionEndsAfterLoad();
|
||||
mocks.requireLogin.mockResolvedValue(false);
|
||||
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
|
||||
mocks.remove.mockRejectedValue(rowGone());
|
||||
mount();
|
||||
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
|
||||
await vi.waitFor(() => expect(button(tileFor('a'), 'Löschen').disabled).toBe(false));
|
||||
button(tileFor('a'), 'Löschen').click();
|
||||
await vi.waitFor(() => expect(dialog()?.open).toBe(true));
|
||||
button(dialog()!, 'Löschen').click();
|
||||
await vi.waitFor(() =>
|
||||
expect(tileFor('a').querySelector('.card-msg')!.textContent).toBe('Bitte melde dich an, um fortzufahren.'),
|
||||
);
|
||||
expect(tiles()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Meine Karten: Umbenennen', () => {
|
||||
const startRename = async () => {
|
||||
mocks.list.mockResolvedValue([card('a', 'Alpha'), card('b', 'Beta')]);
|
||||
@@ -301,6 +387,13 @@ describe('Meine Karten: Umbenennen', () => {
|
||||
expect(tileFor('b').querySelector('input')).toBeNull();
|
||||
});
|
||||
|
||||
it('der vorhandene Name ist beim Öffnen komplett markiert (Tippen ersetzt ihn, statt davor einzufügen)', async () => {
|
||||
const input = await startRename();
|
||||
expect(document.activeElement).toBe(input);
|
||||
expect(input.selectionStart).toBe(0);
|
||||
expect(input.selectionEnd).toBe('Beta'.length);
|
||||
});
|
||||
|
||||
it('Esc bricht ab, ohne etwas zu speichern', async () => {
|
||||
const input = await startRename();
|
||||
input.value = 'Anders';
|
||||
|
||||
@@ -13,7 +13,7 @@ import { confirmDialog } from '../editor/confirm-dialog';
|
||||
import { el } from '../editor/dom';
|
||||
import { downloadFileName, pngSizeWarning, saveBlobAs } from '../editor/download';
|
||||
import type { EditorStore } from '../editor/state';
|
||||
import { READ_MESSAGE, decodeImage } from '../editor/upload';
|
||||
import { PIXELS_MESSAGE, READ_MESSAGE, decodeImage } from '../editor/upload';
|
||||
import { navigate } from './router';
|
||||
|
||||
const LOADING = 'Karten werden geladen …';
|
||||
@@ -328,7 +328,7 @@ export function mountCardsPage(root: HTMLElement, store: EditorStore): () => voi
|
||||
|
||||
function failureText(err: unknown): string {
|
||||
if (err instanceof LoginCancelled) return LOGIN_NEEDED;
|
||||
if (err instanceof Error && err.message === READ_MESSAGE) return READ_MESSAGE;
|
||||
if (err instanceof Error && (err.message === READ_MESSAGE || err.message === PIXELS_MESSAGE)) return err.message;
|
||||
return errorMessage(err, 'load');
|
||||
}
|
||||
|
||||
|
||||
@@ -125,6 +125,53 @@ describe('startRouter', () => {
|
||||
expect(location.pathname).toBe('/');
|
||||
});
|
||||
|
||||
describe('Scrollposition und Titel', () => {
|
||||
it('navigate springt nach dem Rendern an den Seitenanfang', () => {
|
||||
const order: string[] = [];
|
||||
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => order.push('scroll'));
|
||||
try {
|
||||
startRouter(() => {
|
||||
order.push('render');
|
||||
});
|
||||
order.length = 0;
|
||||
navigate('/karten');
|
||||
expect(scrollTo).toHaveBeenCalledTimes(1);
|
||||
expect(scrollTo).toHaveBeenCalledWith(0, 0);
|
||||
expect(order).toEqual(['render', 'scroll']);
|
||||
} finally {
|
||||
scrollTo.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('beim Start und bei popstate wird nicht gescrollt (das übernimmt der Browser)', () => {
|
||||
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
||||
try {
|
||||
startRouter(vi.fn());
|
||||
history.pushState(null, '', '/karten');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
scrollTo.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('setzt den Dokumenttitel je Route', () => {
|
||||
vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
||||
try {
|
||||
startRouter(vi.fn());
|
||||
expect(document.title).toBe('AirCard-IOS – Kartenmotiv gestalten');
|
||||
navigate('/karten');
|
||||
expect(document.title).toBe('Meine Karten – AirCard-IOS');
|
||||
navigate('/passwort-neu');
|
||||
expect(document.title).toBe('Passwort neu setzen – AirCard-IOS');
|
||||
navigate('/');
|
||||
expect(document.title).toBe('AirCard-IOS – Kartenmotiv gestalten');
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('rendert bei popstate die dann aktuelle Route', () => {
|
||||
const render = vi.fn();
|
||||
startRouter(render);
|
||||
|
||||
+11
-1
@@ -9,6 +9,12 @@ export function matchRoute(pathname: string): RouteId {
|
||||
return 'editor';
|
||||
}
|
||||
|
||||
const TITLES: Record<RouteId, string> = {
|
||||
editor: 'AirCard-IOS – Kartenmotiv gestalten',
|
||||
cards: 'Meine Karten – AirCard-IOS',
|
||||
reset: 'Passwort neu setzen – AirCard-IOS',
|
||||
};
|
||||
|
||||
// Beendet den laufenden Router (Seiten-Cleanup, Listener); null, solange keiner läuft.
|
||||
let stopCurrent: (() => void) | null = null;
|
||||
let renderCurrent: (() => void) | null = null;
|
||||
@@ -20,6 +26,8 @@ export function navigate(path: string): void {
|
||||
history.pushState(null, '', target);
|
||||
}
|
||||
renderCurrent?.();
|
||||
// Eine neue Seite beginnt oben (pushState setzt die Scrollposition nicht zurück); bei popstate bleibt es dem Browser überlassen.
|
||||
if (renderCurrent) window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
export function startRouter(render: Render): void {
|
||||
@@ -33,7 +41,9 @@ export function startRouter(render: Render): void {
|
||||
const renderPage = () => {
|
||||
cleanup?.();
|
||||
cleanup = undefined;
|
||||
cleanup = render(matchRoute(location.pathname), root);
|
||||
const route = matchRoute(location.pathname);
|
||||
document.title = TITLES[route];
|
||||
cleanup = render(route, root);
|
||||
};
|
||||
|
||||
const onClick = (event: MouseEvent) => {
|
||||
|
||||
Vendored
+1
-1
@@ -3,7 +3,7 @@
|
||||
interface ImportMetaEnv {
|
||||
/** Appwrite-Endpoint; ohne Angabe `${location.origin}/v1` (Produktion: https://aircard-ios.org/v1). */
|
||||
readonly VITE_APPWRITE_ENDPOINT?: string;
|
||||
/** Appwrite-Projekt-ID; ohne Angabe `aircard`. */
|
||||
/** Appwrite-Projekt-ID; ohne Angabe die generierte ID des Projekts „AirCard“ (`6ac2130a00289343d6d5`). */
|
||||
readonly VITE_APPWRITE_PROJECT_ID?: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user