Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 72d101415f | |||
| ae7d0ac1d9 | |||
| 79ec249bc3 |
|
|
@ -197,7 +197,7 @@ Phasen sind **sequentiell** wo „Abhängigkeit“ steht; Teile können parallel
|
||||||
|
|
||||||
**Erledigt:** Phase A — [`ACTIVITY_SCALAR_KANON_TABLE.md`](./ACTIVITY_SCALAR_KANON_TABLE.md).
|
**Erledigt:** Phase A — [`ACTIVITY_SCALAR_KANON_TABLE.md`](./ACTIVITY_SCALAR_KANON_TABLE.md).
|
||||||
|
|
||||||
**Aktuell:** Phase B fortsetzen (weitere Consumer prüfen: Export, Import-Vorschau, ggf. zukünftige Chart-Metriken aus EAV), dann **Phase C** (Schreibpfad), dann **Phase D** (Composite-MVP).
|
**Aktuell:** Phase B abgeschlossen (Consumer-Audit 2026-04-16). **Phase C** Schreibpfad entschlackt (Sync abgestellt, Orchestrator als SSoT; Review 2026-04-16 + Regression `test_activity_insert_sql.py`). Nächster Schritt: **Phase D** (Composite-MVP).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,10 +46,12 @@ Dieses Dokument ist **normativ für Agenten**, die ein neues Import-Zielmodul an
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Bekannte Einschränkungen (Follow-up in Gitea)
|
## 4. Validierung & Dry-Run (Stand 2026-07-23, Gitea #71)
|
||||||
|
|
||||||
- Admin **„Format prüfen“** kann `import_row_processing` derzeit weglassen; volle Parität mit dem gespeicherten Template erst beim Speichern / echten Import.
|
- Admin **„Format prüfen“** sendet dieselbe `import_row_processing`-Spec wie Speichern (`AdminCsvTemplateEditorPage` → `POST /api/admin/csv-templates/validate`).
|
||||||
- Nutzer-Mappings (Copy aus Systemvorlage) laufen nicht automatisch durch **`validate_csv_template`** – Tracking: **Gitea #71** (http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/71).
|
- **Profil-Mappings:** `POST /api/csv/mappings/{id}/copy` und `POST /api/csv/import` prüfen vor dem Schreiben mit **`validate_csv_template`** (HTTP 422 bei Fehlern).
|
||||||
|
- **Diagnose:** `GET /api/csv/mappings/{id}/validate` — Strukturprüfung ohne Import.
|
||||||
|
- **Nutzer-UI:** `UniversalCsvImportPage` zeigt `error_details` mit Zeile, `code` und `hint` (`CsvImportErrorDetails`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
10
.claude/docs/working/gitea-close-2026-07-23/comment-25.md
Normal file
10
.claude/docs/working/gitea-close-2026-07-23/comment-25.md
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
**Automatischer Audit (Code + Playwright, 2026-07-23)**
|
||||||
|
|
||||||
|
Ziele-System ist im Repo umgesetzt:
|
||||||
|
- Backend: `backend/routers/goals.py`, Focus Areas, Goal Types, Progress
|
||||||
|
- Frontend: `frontend/src/pages/GoalsPage.jsx`, Nav `/goals` in `config/appNav.js`
|
||||||
|
- Spec: `docs/issues/issue-50-phase-0a-goal-system.md`
|
||||||
|
|
||||||
|
Playwright auf dev.mitai.jinkendo.de: `/goals` rendert ohne Fehler.
|
||||||
|
|
||||||
|
Verbleibende KI-Goal-Erweiterungen ggf. als neues Issue.
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
**Automatischer Audit (Code, 2026-07-23)**
|
||||||
|
|
||||||
|
Deprecated Tabelle `subscriptions` ist aus dem aktiven Schema entfernt:
|
||||||
|
- Kein Treffer in `backend/schema.sql`, `backend/migrations/`, Backend-Python
|
||||||
|
- Membership nutzt `access_grants`, `tier_limits`, etc. (Migration v9c)
|
||||||
|
|
||||||
|
Doku-Hinweis deprecated: `.claude/docs/technical/DATABASE.md`
|
||||||
|
|
||||||
|
Optional Prod-Check: `\dt subscriptions` — sollte leer/nicht vorhanden sein.
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
**Automatischer Audit (Code, 2026-07-23)**
|
||||||
|
|
||||||
|
Bug behoben: JSONB-Insert für `abilities`/`profile` nutzt `psycopg2.extras.Json()`.
|
||||||
|
|
||||||
|
- Fix-Commit: `2977050` — wrap abilities dict with Json() for JSONB insert
|
||||||
|
- Aktuell: `backend/routers/admin_training_types.py` (`create_training_type`, Zeilen 111–112)
|
||||||
|
|
||||||
|
Admin-UI: `frontend/src/pages/AdminTrainingTypesPage.jsx`
|
||||||
23
.claude/docs/working/gitea-close-2026-07-23/comment-37.md
Normal file
23
.claude/docs/working/gitea-close-2026-07-23/comment-37.md
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
**Umsetzung 2026-07-23 (nach Architektur-Abgleich)**
|
||||||
|
|
||||||
|
## Architektur-Check vor Implementierung
|
||||||
|
|
||||||
|
- **Phase C (Schreibpfad):** laut `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` bereits erledigt (Sync abgestellt, Orchestrator als SSoT, keine Aufrufer von `sync_column_backed_session_metrics`). Keine Doppel-Implementierung.
|
||||||
|
- **Feature-ID:** Issue-Vorgabe `activity_entries` (nicht separates `activity_import`) — konsistent mit `create_activity` und Universal-CSV-Modul-Check in `csv_import.py`.
|
||||||
|
- **Legacy-Endpoint bleibt:** Frontend nutzt weiterhin `POST /api/activity/import-csv` (`ActivityPage` Apple-Health-Panel); Universal-Pfad ist parallel (ARCH §8.2).
|
||||||
|
|
||||||
|
## Änderungen
|
||||||
|
|
||||||
|
**Backend** (`routers/activity.py`):
|
||||||
|
- `check_feature_access(pid, "activity_entries")` vor Legacy-Import
|
||||||
|
- HTTP 403 bei Limit (wie `nutrition/import-csv`)
|
||||||
|
- `increment_feature_usage` pro neu eingefügter Zeile (`inserted`)
|
||||||
|
|
||||||
|
**Frontend** (`ActivityPage.jsx` ImportPanel):
|
||||||
|
- `UsageBadge` am Import-Titel
|
||||||
|
- Drop-Zone deaktiviert bei Limit
|
||||||
|
- Usage-Reload nach Import
|
||||||
|
|
||||||
|
**Tests:** `tests/test_activity_import_feature_enforcement.py` (403 + Increment)
|
||||||
|
|
||||||
|
Regression INSERT-SQL: `tests/test_activity_insert_sql.py` (grün)
|
||||||
22
.claude/docs/working/gitea-close-2026-07-23/comment-38.md
Normal file
22
.claude/docs/working/gitea-close-2026-07-23/comment-38.md
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
**Umsetzung 2026-07-23 (UI-Teil; Backend war bereits erledigt)**
|
||||||
|
|
||||||
|
## Ist-Zustand vorher
|
||||||
|
|
||||||
|
- Backend: `POST /api/nutrition/import-csv` mit `check_feature_access('nutrition_entries')` und HTTP 403 — **bereits implementiert**
|
||||||
|
- Frontend: FDDB-Import ohne Usage-Anzeige, Drop-Zone blieb bei Limit klickbar
|
||||||
|
|
||||||
|
## Änderungen
|
||||||
|
|
||||||
|
**Frontend** (`NutritionPage.jsx`, ImportPanel):
|
||||||
|
- `getFeatureUsage()` → `nutrition_entries`
|
||||||
|
- `UsageBadge` am Import-Titel
|
||||||
|
- Drop-Zone und Paste-Import deaktiviert bei Limit
|
||||||
|
- Usage-Reload nach erfolgreichem Import
|
||||||
|
|
||||||
|
Analog zu Activity #37 (geschlossen 2026-07-23).
|
||||||
|
|
||||||
|
**Playwright:** `tests/issue-audit.spec.js` — Badge am FDDB-Panel
|
||||||
|
|
||||||
|
## Hinweis
|
||||||
|
|
||||||
|
Universal-CSV (`/api/csv-import/import`) prüft zusätzlich `data_import` — Legacy FDDB-Pfad folgt dem Nutrition-Muster (nur `nutrition_entries`), konsistent mit Architektur §8.2.
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
**Automatischer Audit (Code + Playwright, 2026-07-23)**
|
||||||
|
|
||||||
|
Logout-Button im Mobile-Header neben Avatar implementiert:
|
||||||
|
- `frontend/src/App.jsx` — Button mit `title="Abmelden"`, Icon `LogOut`
|
||||||
|
- Zusätzlich Desktop: `frontend/src/components/DesktopSidebar.jsx`
|
||||||
|
|
||||||
|
Playwright auf dev.mitai.jinkendo.de: Button sichtbar und klickbar.
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
**Duplikat — geschlossen im Rahmen Issue-Audit 2026-07-23**
|
||||||
|
|
||||||
|
Inhalt identisch mit **#42** (Enhanced Debug/Prompt Analysis UI).
|
||||||
|
|
||||||
|
Teilumsetzung existiert bereits (`Analysis.jsx` Experten-Modus, `WorkflowDebugPanel`). Weiterverfolgung unter #42.
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
**Duplikat — geschlossen im Rahmen Issue-Audit 2026-07-23**
|
||||||
|
|
||||||
|
Inhalt identisch mit **#55** (Placeholder Registry: UNRESOLVED & TO_VERIFY Metadaten).
|
||||||
|
|
||||||
|
Bitte weiterverfolgen unter #55.
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
**Duplikat — geschlossen im Rahmen Issue-Audit 2026-07-23**
|
||||||
|
|
||||||
|
Inhalt identisch mit **#56** (Body Cluster — Restarbeiten & Metadaten-Verifizierung).
|
||||||
|
|
||||||
|
Bitte weiterverfolgen unter #56.
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
**Duplikat — geschlossen im Rahmen Issue-Audit 2026-07-23**
|
||||||
|
|
||||||
|
Inhalt identisch mit **#56** (Body Cluster — Restarbeiten & Metadaten-Verifizierung).
|
||||||
|
|
||||||
|
Bitte weiterverfolgen unter #56.
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
**Automatischer Audit (Code + Playwright, 2026-07-23)**
|
||||||
|
|
||||||
|
Nutzer-konfigurierbares Dashboard umgesetzt:
|
||||||
|
- Migration `039_dashboard_layout.sql` — `profiles.dashboard_layout`
|
||||||
|
- API: `backend/routers/app_dashboard.py` — GET/PUT/reset `/api/app/dashboard-layout`
|
||||||
|
- Frontend: `DashboardConfigurePage.jsx`, Widget-Registry `registerDashboardWidgets.js`
|
||||||
|
- Tests: `test_dashboard_layout_schema.py`, `test_widget_catalog.py`
|
||||||
|
|
||||||
|
Playwright: `/settings/dashboard-layout` erreichbar, Widget/Layout-UI sichtbar.
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
**Automatischer Audit (Code, 2026-07-23)**
|
||||||
|
|
||||||
|
Feature-Gate-Zuordnung aus Admin/DB implementiert:
|
||||||
|
- Migration `041_widget_feature_requirements.sql`
|
||||||
|
- Logik: `backend/widget_feature_requirements_db.py`, `dashboard_widget_entitlements.py`
|
||||||
|
- Admin-UI: `AdminWidgetFeatureAssignmentsPage.jsx`, Route `/admin/widget-features`
|
||||||
|
- Changelog: `backend/version.py` (Admin Widgets × Features)
|
||||||
|
|
||||||
|
Hardcodierter Katalog wird durch DB-Overrides ergänzt (Hybrid-Modell).
|
||||||
20
.claude/docs/working/gitea-close-2026-07-23/comment-71.md
Normal file
20
.claude/docs/working/gitea-close-2026-07-23/comment-71.md
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
**Implementierung abgeschlossen (2026-07-24)**
|
||||||
|
|
||||||
|
### 1. Admin „Format prüfen“ inkl. `import_row_processing`
|
||||||
|
- `AdminCsvTemplateEditorPage`: `resolveEditorImportRowProcessing()` — dieselbe Spec wie beim Speichern
|
||||||
|
- Dry-Run/Format-Check nutzt konsistente Vorlage inkl. Aggregation (`group_by` / `aggregates`)
|
||||||
|
|
||||||
|
### 2. Profil-Mappings validieren
|
||||||
|
- `csv_import.py`: `_validate_mapping_config` / `_ensure_mapping_valid` bei **Copy** und **Import**
|
||||||
|
- Neuer Endpoint: `GET /api/csv/mappings/{id}/validate`
|
||||||
|
- `api.js`: `validateCsvMapping()`; `formatFastApiDetail` für verschachtelte Validierungsfehler
|
||||||
|
|
||||||
|
### 3. Nutzer-UI: strukturierte Fehler
|
||||||
|
- Neue Komponente `CsvImportErrorDetails.jsx` (Zeile, code, hint)
|
||||||
|
- `UniversalCsvImportPage` zeigt `error_details` statt JSON-Dump
|
||||||
|
|
||||||
|
### Tests & Doku
|
||||||
|
- `backend/tests/test_csv_mapping_validation.py` — pytest grün
|
||||||
|
- Agent-Guide: `.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` §4 aktualisiert
|
||||||
|
|
||||||
|
**Betroffene Dateien:** `backend/routers/csv_import.py`, `frontend/src/pages/AdminCsvTemplateEditorPage.jsx`, `frontend/src/pages/UniversalCsvImportPage.jsx`, `frontend/src/components/CsvImportErrorDetails.jsx`, `frontend/src/utils/api.js`
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -58,6 +58,9 @@ coverage/
|
||||||
# Temp
|
# Temp
|
||||||
tmp/
|
tmp/
|
||||||
*.tmp
|
*.tmp
|
||||||
|
test-results/
|
||||||
|
screenshots/
|
||||||
|
tests/.auth/
|
||||||
|
|
||||||
# Claude: nur ausgewählte Bereiche versionieren (siehe .claude/rules/DOCUMENTATION.md)
|
# Claude: nur ausgewählte Bereiche versionieren (siehe .claude/rules/DOCUMENTATION.md)
|
||||||
.claude/**
|
.claude/**
|
||||||
|
|
|
||||||
|
|
@ -617,6 +617,24 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional
|
||||||
Persistenz läuft über activity_persistence_orchestrator — gleiche Schicht wie Universal-CSV.
|
Persistenz läuft über activity_persistence_orchestrator — gleiche Schicht wie Universal-CSV.
|
||||||
"""
|
"""
|
||||||
pid = get_pid(x_profile_id)
|
pid = get_pid(x_profile_id)
|
||||||
|
|
||||||
|
# Feature-Enforcement (wie manueller Create + nutrition/import-csv): activity_entries
|
||||||
|
access = check_feature_access(pid, "activity_entries")
|
||||||
|
log_feature_usage(pid, "activity_entries", access, "import_csv")
|
||||||
|
if not access["allowed"]:
|
||||||
|
logger.warning(
|
||||||
|
f"[FEATURE-LIMIT] User {pid} blocked: "
|
||||||
|
f"activity_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})"
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail=(
|
||||||
|
f"Limit erreicht: Du hast das Kontingent für Aktivitätseinträge überschritten "
|
||||||
|
f"({access['used']}/{access['limit']}). "
|
||||||
|
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
raw = await file.read()
|
raw = await file.read()
|
||||||
try: text = raw.decode('utf-8')
|
try: text = raw.decode('utf-8')
|
||||||
except: text = raw.decode('latin-1')
|
except: text = raw.decode('latin-1')
|
||||||
|
|
@ -729,4 +747,8 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Import row failed: {e}")
|
logger.warning(f"Import row failed: {e}")
|
||||||
skipped+=1
|
skipped+=1
|
||||||
|
|
||||||
|
for _ in range(inserted):
|
||||||
|
increment_feature_usage(pid, "activity_entries")
|
||||||
|
|
||||||
return {"inserted":inserted,"skipped":skipped,"message":f"{inserted} Trainings importiert"}
|
return {"inserted":inserted,"skipped":skipped,"message":f"{inserted} Trainings importiert"}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ from csv_parser.type_converter import build_row_after_mapping, diagnose_row_mapp
|
||||||
from csv_parser.field_units import source_unit_choices_for_field
|
from csv_parser.field_units import source_unit_choices_for_field
|
||||||
from csv_parser.import_errors import enrich_row_error
|
from csv_parser.import_errors import enrich_row_error
|
||||||
from csv_parser.module_registry import get_module_definition, list_modules, validate_field_mappings
|
from csv_parser.module_registry import get_module_definition, list_modules, validate_field_mappings
|
||||||
|
from csv_parser.template_validator import validate_csv_template
|
||||||
from data_layer.activity_persistence_orchestrator import merge_activity_csv_module_fields
|
from data_layer.activity_persistence_orchestrator import merge_activity_csv_module_fields
|
||||||
from csv_parser.sleep_apple_import import detect_apple_sleep_csv_format
|
from csv_parser.sleep_apple_import import detect_apple_sleep_csv_format
|
||||||
|
|
||||||
|
|
@ -50,6 +51,38 @@ def _load_import_limits() -> dict[str, int]:
|
||||||
return get_csv_import_limits(r2d(row) if row else None)
|
return get_csv_import_limits(r2d(row) if row else None)
|
||||||
|
|
||||||
|
|
||||||
|
def _mapping_column_signature(m: dict) -> list[str] | None:
|
||||||
|
sig = m.get("column_signature")
|
||||||
|
if not sig:
|
||||||
|
return None
|
||||||
|
return list(sig)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_mapping_config(cur, m: dict) -> dict:
|
||||||
|
"""Strukturelle Vorlagen-Prüfung (wie Admin validate / Create)."""
|
||||||
|
return validate_csv_template(
|
||||||
|
m["module"],
|
||||||
|
m.get("field_mappings") or {},
|
||||||
|
m.get("type_conversions"),
|
||||||
|
m.get("import_row_processing"),
|
||||||
|
_mapping_column_signature(m),
|
||||||
|
cur=cur,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_mapping_valid(cur, m: dict) -> dict:
|
||||||
|
report = _validate_mapping_config(cur, m)
|
||||||
|
if not report.get("valid"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail={
|
||||||
|
"message": "CSV-Vorlage ist strukturell ungültig.",
|
||||||
|
"validation": report,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
def _mapping_to_summary(m: dict) -> dict:
|
def _mapping_to_summary(m: dict) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": m["id"],
|
"id": m["id"],
|
||||||
|
|
@ -184,6 +217,16 @@ def copy_csv_mapping(
|
||||||
n += 1
|
n += 1
|
||||||
name = f"{base_name} {n}"
|
name = f"{base_name} {n}"
|
||||||
|
|
||||||
|
validation = _validate_mapping_config(cur, src)
|
||||||
|
if not validation.get("valid"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail={
|
||||||
|
"message": "Quell-Vorlage ist ungültig — Kopie abgebrochen.",
|
||||||
|
"validation": validation,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO csv_field_mappings (
|
INSERT INTO csv_field_mappings (
|
||||||
|
|
@ -213,7 +256,21 @@ def copy_csv_mapping(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
new_id = cur.fetchone()["id"]
|
new_id = cur.fetchone()["id"]
|
||||||
return {"new_mapping_id": new_id, "mapping_name": name}
|
return {"new_mapping_id": new_id, "mapping_name": name, "validation": validation}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mappings/{mapping_id}/validate")
|
||||||
|
def validate_csv_mapping(
|
||||||
|
mapping_id: int,
|
||||||
|
session: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""Strukturprüfung einer gespeicherten Vorlage (System oder eigenes Profil-Mapping)."""
|
||||||
|
pid = str(session["profile_id"])
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
m = _fetch_mapping_row(cur, mapping_id, pid)
|
||||||
|
report = _validate_mapping_config(cur, m)
|
||||||
|
return {"mapping_id": mapping_id, "mapping_name": m.get("mapping_name"), **report}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/analyze")
|
@router.post("/analyze")
|
||||||
|
|
@ -538,6 +595,7 @@ async def csv_import_execute(
|
||||||
)
|
)
|
||||||
|
|
||||||
_check_module_feature_access(pid, exec_module)
|
_check_module_feature_access(pid, exec_module)
|
||||||
|
_ensure_mapping_valid(cur, m)
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
110
backend/tests/test_activity_import_feature_enforcement.py
Normal file
110
backend/tests/test_activity_import_feature_enforcement.py
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
"""Feature-Enforcement für Legacy Activity CSV-Import (#37)."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from routers.activity import import_activity_csv
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_activity_csv_blocks_when_limit_reached():
|
||||||
|
upload = AsyncMock()
|
||||||
|
upload.read = AsyncMock(return_value=b"Workout Type,Start\n")
|
||||||
|
|
||||||
|
with patch("routers.activity.get_pid", return_value="profile-1"), patch(
|
||||||
|
"routers.activity.check_feature_access",
|
||||||
|
return_value={
|
||||||
|
"allowed": False,
|
||||||
|
"reason": "limit_exceeded",
|
||||||
|
"used": 30,
|
||||||
|
"limit": 30,
|
||||||
|
},
|
||||||
|
), patch("routers.activity.log_feature_usage"):
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
asyncio.run(
|
||||||
|
import_activity_csv(
|
||||||
|
file=upload,
|
||||||
|
x_profile_id=None,
|
||||||
|
session={"profile_id": "profile-1"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_activity_csv_increments_usage_per_insert(monkeypatch):
|
||||||
|
upload = AsyncMock()
|
||||||
|
upload.read = AsyncMock(
|
||||||
|
return_value=(
|
||||||
|
"Workout Type,Start,Duration,Aktive Energie (kJ),Ruheeinträge (kJ),"
|
||||||
|
"Durchschn. Herzfrequenz (count/min),Max. Herzfrequenz (count/min),Distanz (km),End\n"
|
||||||
|
"Running,2026-07-22 10:00:00 +0200,0:45:00,1000,500,140,160,5.0,\n"
|
||||||
|
).encode("utf-8")
|
||||||
|
)
|
||||||
|
|
||||||
|
increments = []
|
||||||
|
|
||||||
|
class FakeCursor:
|
||||||
|
def execute(self, *args, **kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fetchone(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
class FakeConn:
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
return False
|
||||||
|
|
||||||
|
fake_cur = FakeCursor()
|
||||||
|
|
||||||
|
monkeypatch.setattr("routers.activity.get_pid", lambda _h: "profile-1")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"routers.activity.check_feature_access",
|
||||||
|
lambda *_a, **_k: {"allowed": True, "reason": "ok", "used": 0, "limit": 100},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("routers.activity.log_feature_usage", lambda *_a, **_k: None)
|
||||||
|
monkeypatch.setattr("routers.activity.get_db", lambda: FakeConn())
|
||||||
|
monkeypatch.setattr("routers.activity.get_cursor", lambda _c: fake_cur)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"routers.activity.normalize_activity_start",
|
||||||
|
lambda _s: ("2026-07-22", "10:00:00"),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"routers.activity.get_training_type_for_activity",
|
||||||
|
lambda *_a, **_k: (1, "cardio", None),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"routers.activity.find_activity_duplicate_id",
|
||||||
|
lambda *_a, **_k: None,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"routers.activity.new_activity_id",
|
||||||
|
lambda: "new-id",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"routers.activity.insert_activity_csv_minimal",
|
||||||
|
lambda *_a, **_k: None,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"routers.activity.run_activity_post_write_hooks_import",
|
||||||
|
lambda *_a, **_k: None,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"routers.activity.increment_feature_usage",
|
||||||
|
lambda _pid, feature: increments.append(feature),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
import_activity_csv(
|
||||||
|
file=upload,
|
||||||
|
x_profile_id=None,
|
||||||
|
session={"profile_id": "profile-1"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["inserted"] == 1
|
||||||
|
assert increments == ["activity_entries"]
|
||||||
26
backend/tests/test_csv_mapping_validation.py
Normal file
26
backend/tests/test_csv_mapping_validation.py
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
"""Tests für CSV-Vorlagen-Validierung (#71)."""
|
||||||
|
|
||||||
|
from csv_parser.template_validator import validate_csv_template
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_csv_template_rejects_invalid_row_processing():
|
||||||
|
report = validate_csv_template(
|
||||||
|
"nutrition",
|
||||||
|
{"Datum": "date", "kcal": "kcal"},
|
||||||
|
None,
|
||||||
|
{"group_by": ["date"], "aggregates": {"kcal": "not_an_op"}},
|
||||||
|
["Datum", "kcal"],
|
||||||
|
)
|
||||||
|
assert report["valid"] is False
|
||||||
|
assert any(e.get("code") == "invalid_import_row_processing" for e in report["errors"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_csv_template_accepts_nutrition_aggregation():
|
||||||
|
report = validate_csv_template(
|
||||||
|
"nutrition",
|
||||||
|
{"Datum": "date", "kcal": "kcal", "protein": "protein_g"},
|
||||||
|
None,
|
||||||
|
{"group_by": ["date"], "aggregates": {"kcal": "sum", "protein_g": "sum"}},
|
||||||
|
["Datum", "kcal", "protein"],
|
||||||
|
)
|
||||||
|
assert report["valid"] is True
|
||||||
|
|
@ -7,8 +7,8 @@ Semantic Versioning: MAJOR.MINOR.PATCH
|
||||||
- PATCH: Bugfix, kleine Änderung, Refactor
|
- PATCH: Bugfix, kleine Änderung, Refactor
|
||||||
"""
|
"""
|
||||||
|
|
||||||
APP_VERSION = "0.9t"
|
APP_VERSION = "0.9u"
|
||||||
BUILD_DATE = "2026-04-20"
|
BUILD_DATE = "2026-07-24"
|
||||||
DB_SCHEMA_VERSION = "20260409c" # 048/049 vitals_baseline.source csv + SAVEPOINT Import
|
DB_SCHEMA_VERSION = "20260409c" # 048/049 vitals_baseline.source csv + SAVEPOINT Import
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
MODULE_VERSIONS = {
|
||||||
|
|
@ -19,8 +19,8 @@ MODULE_VERSIONS = {
|
||||||
"weight": "1.0.3",
|
"weight": "1.0.3",
|
||||||
"circumference": "1.0.1",
|
"circumference": "1.0.1",
|
||||||
"caliper": "1.0.1",
|
"caliper": "1.0.1",
|
||||||
"activity": "1.2.0", # GET /activity: optional days= window + limit
|
"activity": "1.2.1", # Legacy CSV import: activity_entries feature enforcement
|
||||||
"nutrition": "1.0.2",
|
"nutrition": "1.0.3", # FDDB import UI: UsageBadge + blocked import zone
|
||||||
"photos": "1.0.0",
|
"photos": "1.0.0",
|
||||||
"insights": "1.3.0",
|
"insights": "1.3.0",
|
||||||
"prompts": "1.1.0",
|
"prompts": "1.1.0",
|
||||||
|
|
@ -31,11 +31,20 @@ MODULE_VERSIONS = {
|
||||||
"membership": "2.1.0",
|
"membership": "2.1.0",
|
||||||
"workflow": "0.7.0", # Part 3: Inline Prompts (reference + inline mode)
|
"workflow": "0.7.0", # Part 3: Inline Prompts (reference + inline mode)
|
||||||
"app_dashboard": "1.17.1", # history_overview_viz: Bereichs-Kacheln einzeln per show_section_*
|
"app_dashboard": "1.17.1", # history_overview_viz: Bereichs-Kacheln einzeln per show_section_*
|
||||||
"csv_import": "0.3.2", # Import-Fehler: enrich_row_error / freundlichere 500-Hinweise
|
"csv_import": "0.4.0", # Mapping validation on copy/import; validate endpoint; error_details UI
|
||||||
"admin_csv_templates": "0.3.0", # POST /validate + Speichern nur bei valid (422 + warnings in Response)
|
"admin_csv_templates": "0.3.1", # Format check includes import_row_processing parity
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
{
|
||||||
|
"version": "0.9u",
|
||||||
|
"date": "2026-07-24",
|
||||||
|
"changes": [
|
||||||
|
"Gitea #37: Activity legacy CSV import — activity_entries feature enforcement + UsageBadge UI",
|
||||||
|
"Gitea #38: Nutrition FDDB import — UsageBadge + deaktivierte Import-Zone bei Limit",
|
||||||
|
"Gitea #71: Universal CSV — Dry-Run/Format-Check Parität import_row_processing; Mapping-Validierung bei Copy/Import; strukturierte error_details in Nutzer-UI",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.9t",
|
"version": "0.9t",
|
||||||
"date": "2026-04-20",
|
"date": "2026-04-20",
|
||||||
|
|
|
||||||
76
frontend/src/components/CsvImportErrorDetails.jsx
Normal file
76
frontend/src/components/CsvImportErrorDetails.jsx
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
/**
|
||||||
|
* Strukturierte Zeilenfehler aus Universal-CSV-Import (error_details mit hint/code).
|
||||||
|
*/
|
||||||
|
export default function CsvImportErrorDetails({ errors, title, defaultOpen = true, maxVisible = 20 }) {
|
||||||
|
if (!errors?.length) return null
|
||||||
|
|
||||||
|
const shown = errors.slice(0, maxVisible)
|
||||||
|
const rest = errors.length - shown.length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details
|
||||||
|
className="card"
|
||||||
|
style={{ marginBottom: 16, padding: 16, cursor: 'pointer' }}
|
||||||
|
open={defaultOpen}
|
||||||
|
>
|
||||||
|
<summary style={{ fontWeight: 600, color: 'var(--text1)' }}>
|
||||||
|
{title || `Zeilenfehler (${errors.length})`}
|
||||||
|
</summary>
|
||||||
|
<ul
|
||||||
|
style={{
|
||||||
|
margin: '12px 0 0 0',
|
||||||
|
padding: 0,
|
||||||
|
listStyle: 'none',
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{shown.map((err, i) => {
|
||||||
|
const row = err.row ?? err.row_index
|
||||||
|
const message = err.error ?? err.message ?? (typeof err === 'string' ? err : JSON.stringify(err))
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
padding: '10px 12px',
|
||||||
|
marginBottom: 8,
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
borderRadius: 8,
|
||||||
|
borderLeft: '3px solid var(--danger)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ color: 'var(--text1)' }}>
|
||||||
|
{row != null && (
|
||||||
|
<strong style={{ marginRight: 6 }}>Zeile {row}:</strong>
|
||||||
|
)}
|
||||||
|
{message}
|
||||||
|
{err.code && (
|
||||||
|
<code
|
||||||
|
style={{
|
||||||
|
marginLeft: 8,
|
||||||
|
fontSize: 11,
|
||||||
|
color: 'var(--text3)',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
padding: '1px 6px',
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{err.code}
|
||||||
|
</code>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{err.hint && (
|
||||||
|
<div style={{ marginTop: 6, fontSize: 12, color: 'var(--text2)' }}>{err.hint}</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
{rest > 0 && (
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '8px 0 0' }}>
|
||||||
|
… und {rest} weitere Fehler (Details in der Import-Historie).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</details>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -364,13 +364,15 @@ function SessionMetricsFields({ schema, values, setValues, metrics }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Import Panel ──────────────────────────────────────────────────────────────
|
// ── Import Panel ──────────────────────────────────────────────────────────────
|
||||||
function ImportPanel({ onImported }) {
|
function ImportPanel({ onImported, usage = null }) {
|
||||||
const fileRef = useRef()
|
const fileRef = useRef()
|
||||||
const [status, setStatus] = useState(null)
|
const [status, setStatus] = useState(null)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [dragging, setDragging] = useState(false)
|
const [dragging, setDragging] = useState(false)
|
||||||
|
const atLimit = usage && !usage.allowed
|
||||||
|
|
||||||
const runImport = async (file) => {
|
const runImport = async (file) => {
|
||||||
|
if (atLimit) return
|
||||||
setStatus('loading'); setError(null)
|
setStatus('loading'); setError(null)
|
||||||
try {
|
try {
|
||||||
const result = await api.importActivityCsv(file)
|
const result = await api.importActivityCsv(file)
|
||||||
|
|
@ -382,24 +384,29 @@ function ImportPanel({ onImported }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
<div className="card-title">📥 Apple Health Import</div>
|
<div className="card-title badge-container-right">
|
||||||
|
<span>📥 Apple Health Import</span>
|
||||||
|
{usage && <UsageBadge {...usage} />}
|
||||||
|
</div>
|
||||||
<p style={{fontSize:13,color:'var(--text2)',marginBottom:10,lineHeight:1.6}}>
|
<p style={{fontSize:13,color:'var(--text2)',marginBottom:10,lineHeight:1.6}}>
|
||||||
<strong>Health Auto Export App</strong> → Workouts exportieren → CSV → hier hochladen.<br/>
|
<strong>Health Auto Export App</strong> → Workouts exportieren → CSV → hier hochladen.<br/>
|
||||||
Nur die <em>Workouts-…csv</em> Datei wird benötigt (nicht die Detaildateien).
|
Nur die <em>Workouts-…csv</em> Datei wird benötigt (nicht die Detaildateien).
|
||||||
</p>
|
</p>
|
||||||
<input ref={fileRef} type="file" accept=".csv" style={{display:'none'}}
|
<input ref={fileRef} type="file" accept=".csv" style={{display:'none'}}
|
||||||
|
disabled={atLimit}
|
||||||
onChange={e=>{ const f=e.target.files[0]; if(f) runImport(f); e.target.value='' }}/>
|
onChange={e=>{ const f=e.target.files[0]; if(f) runImport(f); e.target.value='' }}/>
|
||||||
<div
|
<div
|
||||||
onDragOver={e=>{e.preventDefault();setDragging(true)}}
|
onDragOver={e=>{ if(!atLimit){ e.preventDefault(); setDragging(true) }}}
|
||||||
onDragLeave={()=>setDragging(false)}
|
onDragLeave={()=>setDragging(false)}
|
||||||
onDrop={e=>{e.preventDefault();setDragging(false);const f=e.dataTransfer.files[0];if(f)runImport(f)}}
|
onDrop={e=>{ e.preventDefault(); setDragging(false); if(atLimit) return; const f=e.dataTransfer.files[0]; if(f) runImport(f) }}
|
||||||
onClick={()=>fileRef.current.click()}
|
onClick={()=>{ if(!atLimit) fileRef.current?.click() }}
|
||||||
|
title={atLimit ? `Limit erreicht (${usage.used}/${usage.limit})` : ''}
|
||||||
style={{border:`2px dashed ${dragging?'var(--accent)':'var(--border2)'}`,borderRadius:10,
|
style={{border:`2px dashed ${dragging?'var(--accent)':'var(--border2)'}`,borderRadius:10,
|
||||||
padding:'20px 16px',textAlign:'center',background:dragging?'var(--accent-light)':'var(--surface2)',
|
padding:'20px 16px',textAlign:'center',background:dragging?'var(--accent-light)':'var(--surface2)',
|
||||||
cursor:'pointer',transition:'all 0.15s'}}>
|
cursor:atLimit?'not-allowed':'pointer',opacity:atLimit?0.65:1,transition:'all 0.15s'}}>
|
||||||
<Upload size={24} style={{color:dragging?'var(--accent)':'var(--text3)',marginBottom:6}}/>
|
<Upload size={24} style={{color:dragging?'var(--accent)':'var(--text3)',marginBottom:6}}/>
|
||||||
<div style={{fontSize:13,color:dragging?'var(--accent-dark)':'var(--text2)'}}>
|
<div style={{fontSize:13,color:dragging?'var(--accent-dark)':'var(--text2)'}}>
|
||||||
{dragging?'Datei loslassen…':'CSV hierher ziehen oder tippen'}
|
{atLimit ? '🔒 Limit erreicht — Import nicht möglich' : dragging ? 'Datei loslassen…' : 'CSV hierher ziehen oder tippen'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{status==='loading' && (
|
{status==='loading' && (
|
||||||
|
|
@ -945,7 +952,12 @@ export default function ActivityPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab==='import' && <ImportPanel onImported={load}/>}
|
{tab==='import' && (
|
||||||
|
<ImportPanel
|
||||||
|
usage={activityUsage}
|
||||||
|
onImported={() => { load(); loadUsage() }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{tab==='categorize' && (
|
{tab==='categorize' && (
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,68 @@ function buildImportRowProcessingSimple(modFields, fm, groupBy, mode, multiRowPo
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Gleiche Logik wie beim Speichern — für Formatprüfung und Create/Update. */
|
||||||
|
function resolveEditorImportRowProcessing({
|
||||||
|
aggregateSleepImport,
|
||||||
|
rowAggUseCustom,
|
||||||
|
rowAggIrregular,
|
||||||
|
rowAggJsonText,
|
||||||
|
rowAggGroupBy,
|
||||||
|
rowAggMode,
|
||||||
|
rowAggMultiRowPolicy,
|
||||||
|
rowAggDedupeIdentical,
|
||||||
|
modFields,
|
||||||
|
fieldMappings,
|
||||||
|
assignedTargets,
|
||||||
|
}) {
|
||||||
|
if (aggregateSleepImport || !rowAggUseCustom) {
|
||||||
|
return { import_row_processing: null, error: null }
|
||||||
|
}
|
||||||
|
if (rowAggIrregular) {
|
||||||
|
try {
|
||||||
|
const import_row_processing = JSON.parse(rowAggJsonText || '{}')
|
||||||
|
if (!import_row_processing || typeof import_row_processing !== 'object') {
|
||||||
|
return { import_row_processing: null, error: 'Zeilenaggregation: ungültiges JSON.' }
|
||||||
|
}
|
||||||
|
const gb = import_row_processing.group_by
|
||||||
|
if (!Array.isArray(gb) || !gb.length) {
|
||||||
|
return {
|
||||||
|
import_row_processing: null,
|
||||||
|
error: 'Zeilenaggregation (JSON): „group_by“ muss eine nicht-leere Liste sein.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { import_row_processing, error: null }
|
||||||
|
} catch {
|
||||||
|
return { import_row_processing: null, error: 'Zeilenaggregation: ungültiges JSON.' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!rowAggGroupBy.length) {
|
||||||
|
return { import_row_processing: null, error: 'Zeilenaggregation: mindestens ein Schlüsselfeld auswählen.' }
|
||||||
|
}
|
||||||
|
if (!rowAggMode) {
|
||||||
|
return { import_row_processing: null, error: 'Zeilenaggregation: eine Funktion wählen (Summe, Mittelwert, …).' }
|
||||||
|
}
|
||||||
|
for (const g of rowAggGroupBy) {
|
||||||
|
if (!assignedTargets.has(g)) {
|
||||||
|
return {
|
||||||
|
import_row_processing: null,
|
||||||
|
error: `Zeilenaggregation: Schlüsselfeld „${g}“ muss einer CSV-Spalte zugeordnet sein.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
import_row_processing: buildImportRowProcessingSimple(
|
||||||
|
modFields,
|
||||||
|
fieldMappings,
|
||||||
|
rowAggGroupBy,
|
||||||
|
rowAggMode,
|
||||||
|
rowAggMultiRowPolicy,
|
||||||
|
rowAggDedupeIdentical,
|
||||||
|
),
|
||||||
|
error: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Erlaubt Eingaben wie 1,03 oder 1.03 während des Tippens; Finale normalisiert bei Blur/Speichern. */
|
/** Erlaubt Eingaben wie 1,03 oder 1.03 während des Tippens; Finale normalisiert bei Blur/Speichern. */
|
||||||
function normalizeDecimalInputString(raw) {
|
function normalizeDecimalInputString(raw) {
|
||||||
let s = String(raw).trim().replace(/\s/g, '')
|
let s = String(raw).trim().replace(/\s/g, '')
|
||||||
|
|
@ -621,11 +683,28 @@ export default function AdminCsvTemplateEditorPage() {
|
||||||
}
|
}
|
||||||
setValidating(true)
|
setValidating(true)
|
||||||
try {
|
try {
|
||||||
|
const { import_row_processing, error: irpError } = resolveEditorImportRowProcessing({
|
||||||
|
aggregateSleepImport,
|
||||||
|
rowAggUseCustom,
|
||||||
|
rowAggIrregular,
|
||||||
|
rowAggJsonText,
|
||||||
|
rowAggGroupBy,
|
||||||
|
rowAggMode,
|
||||||
|
rowAggMultiRowPolicy,
|
||||||
|
rowAggDedupeIdentical,
|
||||||
|
modFields: modMeta?.fields,
|
||||||
|
fieldMappings,
|
||||||
|
assignedTargets,
|
||||||
|
})
|
||||||
|
if (irpError) {
|
||||||
|
setError(irpError)
|
||||||
|
return
|
||||||
|
}
|
||||||
const r = await api.adminValidateCsvTemplate({
|
const r = await api.adminValidateCsvTemplate({
|
||||||
module,
|
module,
|
||||||
field_mappings: fieldMappings,
|
field_mappings: fieldMappings,
|
||||||
type_conversions: tc,
|
type_conversions: tc,
|
||||||
import_row_processing: null,
|
import_row_processing,
|
||||||
column_signature: columnSignature.length ? columnSignature : null,
|
column_signature: columnSignature.length ? columnSignature : null,
|
||||||
})
|
})
|
||||||
setValidationReport(r)
|
setValidationReport(r)
|
||||||
|
|
@ -692,44 +771,25 @@ export default function AdminCsvTemplateEditorPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
let import_row_processing = null
|
let import_row_processing = null
|
||||||
if (!aggregateSleepImport && rowAggUseCustom) {
|
{
|
||||||
if (rowAggIrregular) {
|
const resolved = resolveEditorImportRowProcessing({
|
||||||
try {
|
aggregateSleepImport,
|
||||||
import_row_processing = JSON.parse(rowAggJsonText || '{}')
|
rowAggUseCustom,
|
||||||
if (!import_row_processing || typeof import_row_processing !== 'object') throw new Error('bad')
|
rowAggIrregular,
|
||||||
} catch {
|
rowAggJsonText,
|
||||||
setError('Zeilenaggregation: ungültiges JSON.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const gb = import_row_processing.group_by
|
|
||||||
if (!Array.isArray(gb) || !gb.length) {
|
|
||||||
setError('Zeilenaggregation (JSON): „group_by“ muss eine nicht-leere Liste sein.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (!rowAggGroupBy.length) {
|
|
||||||
setError('Zeilenaggregation: mindestens ein Schlüsselfeld auswählen.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!rowAggMode) {
|
|
||||||
setError('Zeilenaggregation: eine Funktion wählen (Summe, Mittelwert, …).')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for (const g of rowAggGroupBy) {
|
|
||||||
if (!assignedTargets.has(g)) {
|
|
||||||
setError(`Zeilenaggregation: Schlüsselfeld „${g}“ muss einer CSV-Spalte zugeordnet sein.`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
import_row_processing = buildImportRowProcessingSimple(
|
|
||||||
modMeta?.fields,
|
|
||||||
fieldMappings,
|
|
||||||
rowAggGroupBy,
|
rowAggGroupBy,
|
||||||
rowAggMode,
|
rowAggMode,
|
||||||
rowAggMultiRowPolicy,
|
rowAggMultiRowPolicy,
|
||||||
rowAggDedupeIdentical,
|
rowAggDedupeIdentical,
|
||||||
)
|
modFields: modMeta?.fields,
|
||||||
|
fieldMappings,
|
||||||
|
assignedTargets,
|
||||||
|
})
|
||||||
|
if (resolved.error) {
|
||||||
|
setError(resolved.error)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
import_row_processing = resolved.import_row_processing
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
|
|
@ -1494,12 +1554,13 @@ export default function AdminCsvTemplateEditorPage() {
|
||||||
{validationReport.valid ? <span style={{ color: 'var(--accent)' }}>— speicherfähig</span> : <span style={{ color: 'var(--danger)' }}>— Fehler beheben</span>}
|
{validationReport.valid ? <span style={{ color: 'var(--accent)' }}>— speicherfähig</span> : <span style={{ color: 'var(--danger)' }}>— Fehler beheben</span>}
|
||||||
</div>
|
</div>
|
||||||
<p style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 10 }}>
|
<p style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 10 }}>
|
||||||
Ohne Zeilenaggregations-JSON; vollständige Prüfung inkl. Aggregation beim Speichern. Warnungen blockieren nicht.
|
Inkl. Zeilenaggregation (wie beim Speichern). Warnungen blockieren nicht.
|
||||||
</p>
|
</p>
|
||||||
{validationReport.errors?.length ? (
|
{validationReport.errors?.length ? (
|
||||||
<ul style={{ margin: '0 0 12px 1rem', color: 'var(--danger)', fontSize: 14 }}>
|
<ul style={{ margin: '0 0 12px 1rem', color: 'var(--danger)', fontSize: 14 }}>
|
||||||
{validationReport.errors.map((e, i) => (
|
{validationReport.errors.map((e, i) => (
|
||||||
<li key={`e-${i}`}>
|
<li key={`e-${i}`}>
|
||||||
|
{e.code ? <code style={{ fontSize: 11, marginRight: 6 }}>{e.code}</code> : null}
|
||||||
{e.message}
|
{e.message}
|
||||||
{e.hint ? <span style={{ display: 'block', fontSize: 12, color: 'var(--text2)', marginTop: 4 }}>{e.hint}</span> : null}
|
{e.hint ? <span style={{ display: 'block', fontSize: 12, color: 'var(--text2)', marginTop: 4 }}>{e.hint}</span> : null}
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -1510,6 +1571,7 @@ export default function AdminCsvTemplateEditorPage() {
|
||||||
<ul style={{ margin: '0 0 0 1rem', color: 'var(--text2)', fontSize: 13 }}>
|
<ul style={{ margin: '0 0 0 1rem', color: 'var(--text2)', fontSize: 13 }}>
|
||||||
{validationReport.warnings.map((w, i) => (
|
{validationReport.warnings.map((w, i) => (
|
||||||
<li key={`w-${i}`}>
|
<li key={`w-${i}`}>
|
||||||
|
{w.code ? <code style={{ fontSize: 11, marginRight: 6 }}>{w.code}</code> : null}
|
||||||
{w.message}
|
{w.message}
|
||||||
{w.hint ? <span style={{ display: 'block', fontSize: 12, color: 'var(--text3)', marginTop: 4 }}>{w.hint}</span> : null}
|
{w.hint ? <span style={{ display: 'block', fontSize: 12, color: 'var(--text3)', marginTop: 4 }}>{w.hint}</span> : null}
|
||||||
</li>
|
</li>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { Upload, CheckCircle, TrendingUp, Info } from 'lucide-react'
|
import { Upload, CheckCircle, TrendingUp, Info } from 'lucide-react'
|
||||||
|
import UsageBadge from '../components/UsageBadge'
|
||||||
import {
|
import {
|
||||||
LineChart, Line, BarChart, Bar, XAxis, YAxis, Tooltip,
|
LineChart, Line, BarChart, Bar, XAxis, YAxis, Tooltip,
|
||||||
ResponsiveContainer, CartesianGrid, Legend, ReferenceLine, ScatterChart, Scatter
|
ResponsiveContainer, CartesianGrid, Legend, ReferenceLine, ScatterChart, Scatter
|
||||||
|
|
@ -433,15 +434,17 @@ function ImportHistory() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Import Panel ──────────────────────────────────────────────────────────────
|
// ── Import Panel ──────────────────────────────────────────────────────────────
|
||||||
function ImportPanel({ onImported }) {
|
function ImportPanel({ onImported, usage = null }) {
|
||||||
const fileRef = useRef()
|
const fileRef = useRef()
|
||||||
const [status, setStatus] = useState(null)
|
const [status, setStatus] = useState(null)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [dragging,setDragging]= useState(false)
|
const [dragging,setDragging]= useState(false)
|
||||||
const [tab, setTab] = useState('file') // 'file' | 'paste'
|
const [tab, setTab] = useState('file') // 'file' | 'paste'
|
||||||
const [pasteText, setPasteText] = useState('')
|
const [pasteText, setPasteText] = useState('')
|
||||||
|
const atLimit = usage && !usage.allowed
|
||||||
|
|
||||||
const runImport = async (file) => {
|
const runImport = async (file) => {
|
||||||
|
if (atLimit) return
|
||||||
setStatus('loading'); setError(null)
|
setStatus('loading'); setError(null)
|
||||||
try {
|
try {
|
||||||
const result = await nutritionApi.importCsv(file)
|
const result = await nutritionApi.importCsv(file)
|
||||||
|
|
@ -468,7 +471,7 @@ function ImportPanel({ onImported }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePasteImport = async () => {
|
const handlePasteImport = async () => {
|
||||||
if (!pasteText.trim()) return
|
if (!pasteText.trim() || atLimit) return
|
||||||
const blob = new Blob([pasteText], { type: 'text/csv' })
|
const blob = new Blob([pasteText], { type: 'text/csv' })
|
||||||
const file = new File([blob], 'paste.csv', { type: 'text/csv' })
|
const file = new File([blob], 'paste.csv', { type: 'text/csv' })
|
||||||
await runImport(file)
|
await runImport(file)
|
||||||
|
|
@ -476,7 +479,10 @@ function ImportPanel({ onImported }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
<div className="card-title">📥 FDDB CSV Import</div>
|
<div className="card-title badge-container-right">
|
||||||
|
<span>📥 FDDB CSV Import</span>
|
||||||
|
{usage && <UsageBadge {...usage} />}
|
||||||
|
</div>
|
||||||
<p style={{fontSize:13,color:'var(--text2)',marginBottom:10,lineHeight:1.6}}>
|
<p style={{fontSize:13,color:'var(--text2)',marginBottom:10,lineHeight:1.6}}>
|
||||||
In FDDB: <strong>Mein Tagebuch → Exportieren → CSV</strong> — dann hier importieren.
|
In FDDB: <strong>Mein Tagebuch → Exportieren → CSV</strong> — dann hier importieren.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -498,23 +504,26 @@ function ImportPanel({ onImported }) {
|
||||||
<>
|
<>
|
||||||
{/* Drag & Drop Zone */}
|
{/* Drag & Drop Zone */}
|
||||||
<div
|
<div
|
||||||
onDragOver={e=>{e.preventDefault();setDragging(true)}}
|
onDragOver={e=>{ if(!atLimit){ e.preventDefault(); setDragging(true) }}}
|
||||||
onDragLeave={()=>setDragging(false)}
|
onDragLeave={()=>setDragging(false)}
|
||||||
onDrop={handleDrop}
|
onDrop={e=>{ e.preventDefault(); setDragging(false); if(atLimit) return; handleDrop(e) }}
|
||||||
onClick={()=>fileRef.current.click()}
|
onClick={()=>{ if(!atLimit) fileRef.current?.click() }}
|
||||||
|
title={atLimit ? `Limit erreicht (${usage.used}/${usage.limit})` : ''}
|
||||||
style={{
|
style={{
|
||||||
border:`2px dashed ${dragging?'var(--accent)':'var(--border2)'}`,
|
border:`2px dashed ${dragging?'var(--accent)':'var(--border2)'}`,
|
||||||
borderRadius:10, padding:'24px 16px', textAlign:'center',
|
borderRadius:10, padding:'24px 16px', textAlign:'center',
|
||||||
background: dragging?'var(--accent-light)':'var(--surface2)',
|
background: dragging?'var(--accent-light)':'var(--surface2)',
|
||||||
cursor:'pointer', transition:'all 0.15s',
|
cursor:atLimit?'not-allowed':'pointer', opacity:atLimit?0.65:1, transition:'all 0.15s',
|
||||||
}}>
|
}}>
|
||||||
<Upload size={28} style={{color:dragging?'var(--accent)':'var(--text3)',marginBottom:8}}/>
|
<Upload size={28} style={{color:dragging?'var(--accent)':'var(--text3)',marginBottom:8}}/>
|
||||||
<div style={{fontSize:14,fontWeight:500,color:dragging?'var(--accent-dark)':'var(--text2)'}}>
|
<div style={{fontSize:14,fontWeight:500,color:dragging?'var(--accent-dark)':'var(--text2)'}}>
|
||||||
{dragging ? 'Datei loslassen…' : 'CSV hierher ziehen oder tippen zum Auswählen'}
|
{atLimit
|
||||||
|
? '🔒 Limit erreicht — Import nicht möglich'
|
||||||
|
: dragging ? 'Datei loslassen…' : 'CSV hierher ziehen oder tippen zum Auswählen'}
|
||||||
</div>
|
</div>
|
||||||
<div style={{fontSize:11,color:'var(--text3)',marginTop:4}}>.csv Dateien</div>
|
<div style={{fontSize:11,color:'var(--text3)',marginTop:4}}>.csv Dateien</div>
|
||||||
</div>
|
</div>
|
||||||
<input ref={fileRef} type="file" accept=".csv" style={{display:'none'}} onChange={handleFile}/>
|
<input ref={fileRef} type="file" accept=".csv" style={{display:'none'}} disabled={atLimit} onChange={handleFile}/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -523,14 +532,20 @@ function ImportPanel({ onImported }) {
|
||||||
<textarea
|
<textarea
|
||||||
style={{width:'100%',minHeight:120,padding:10,fontFamily:'monospace',fontSize:11,
|
style={{width:'100%',minHeight:120,padding:10,fontFamily:'monospace',fontSize:11,
|
||||||
background:'var(--surface2)',border:'1.5px solid var(--border2)',borderRadius:8,
|
background:'var(--surface2)',border:'1.5px solid var(--border2)',borderRadius:8,
|
||||||
color:'var(--text1)',resize:'vertical',boxSizing:'border-box'}}
|
color:'var(--text1)',resize:'vertical',boxSizing:'border-box',
|
||||||
|
opacity:atLimit?0.65:1}}
|
||||||
placeholder="datum_tag_monat_jahr_stunde_minute;bezeichnung; 13.03.2026 21:54;50 g Hähnchen;..."
|
placeholder="datum_tag_monat_jahr_stunde_minute;bezeichnung; 13.03.2026 21:54;50 g Hähnchen;..."
|
||||||
value={pasteText}
|
value={pasteText}
|
||||||
onChange={e=>setPasteText(e.target.value)}
|
onChange={e=>setPasteText(e.target.value)}
|
||||||
|
disabled={atLimit}
|
||||||
/>
|
/>
|
||||||
<button className="btn btn-primary btn-full" style={{marginTop:8}}
|
<button className="btn btn-primary btn-full" style={{marginTop:8}}
|
||||||
onClick={handlePasteImport} disabled={status==='loading'||!pasteText.trim()}>
|
onClick={handlePasteImport}
|
||||||
{status==='loading'
|
disabled={status==='loading'||!pasteText.trim()||atLimit}
|
||||||
|
title={atLimit ? `Limit erreicht (${usage.used}/${usage.limit})` : ''}>
|
||||||
|
{atLimit
|
||||||
|
? '🔒 Limit erreicht'
|
||||||
|
: status==='loading'
|
||||||
? <><div className="spinner" style={{width:14,height:14}}/> Importiere…</>
|
? <><div className="spinner" style={{width:14,height:14}}/> Importiere…</>
|
||||||
: <><Upload size={15}/> CSV-Text importieren</>}
|
: <><Upload size={15}/> CSV-Text importieren</>}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -781,6 +796,14 @@ export default function NutritionPage() {
|
||||||
const [loading, setLoad] = useState(true)
|
const [loading, setLoad] = useState(true)
|
||||||
const [hasData, setHasData]= useState(false)
|
const [hasData, setHasData]= useState(false)
|
||||||
const [importHistoryKey, setImportHistoryKey] = useState(Date.now()) // BUG-004 fix
|
const [importHistoryKey, setImportHistoryKey] = useState(Date.now()) // BUG-004 fix
|
||||||
|
const [nutritionUsage, setNutritionUsage] = useState(null)
|
||||||
|
|
||||||
|
const loadUsage = () => {
|
||||||
|
nutritionApi.getFeatureUsage().then(features => {
|
||||||
|
const nutritionFeature = features.find(f => f.feature_id === 'nutrition_entries')
|
||||||
|
setNutritionUsage(nutritionFeature ?? null)
|
||||||
|
}).catch(err => console.error('Failed to load usage:', err))
|
||||||
|
}
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoad(true)
|
setLoad(true)
|
||||||
|
|
@ -800,7 +823,7 @@ export default function NutritionPage() {
|
||||||
finally { setLoad(false) }
|
finally { setLoad(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => { load() }, [])
|
useEffect(() => { load(); loadUsage() }, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="capture-page">
|
<div className="capture-page">
|
||||||
|
|
@ -822,7 +845,10 @@ export default function NutritionPage() {
|
||||||
{/* Import Panel + History */}
|
{/* Import Panel + History */}
|
||||||
{inputTab==='import' && (
|
{inputTab==='import' && (
|
||||||
<>
|
<>
|
||||||
<ImportPanel onImported={() => { load(); setImportHistoryKey(Date.now()) }}/>
|
<ImportPanel
|
||||||
|
usage={nutritionUsage}
|
||||||
|
onImported={() => { load(); loadUsage(); setImportHistoryKey(Date.now()) }}
|
||||||
|
/>
|
||||||
<ImportHistory key={importHistoryKey}/>
|
<ImportHistory key={importHistoryKey}/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'
|
||||||
import { ArrowLeft, FileSpreadsheet, Loader2, Upload } from 'lucide-react'
|
import { ArrowLeft, FileSpreadsheet, Loader2, Upload } from 'lucide-react'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
import { csvPreviewTdStyle } from '../utils/csvPreviewCells'
|
import { csvPreviewTdStyle } from '../utils/csvPreviewCells'
|
||||||
|
import CsvImportErrorDetails from '../components/CsvImportErrorDetails'
|
||||||
|
|
||||||
/** Ziele, die der Universal-Executor bereits schreiben kann (ohne manuelle Modul-Wahl). */
|
/** Ziele, die der Universal-Executor bereits schreiben kann (ohne manuelle Modul-Wahl). */
|
||||||
const EXECUTOR_READY = new Set([
|
const EXECUTOR_READY = new Set([
|
||||||
|
|
@ -288,29 +289,11 @@ export default function UniversalCsvImportPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{lastImport?.error_details?.length > 0 && (
|
{lastImport?.error_details?.length > 0 && (
|
||||||
<details
|
<CsvImportErrorDetails
|
||||||
className="card"
|
errors={lastImport.error_details}
|
||||||
style={{ marginBottom: 16, padding: 16, cursor: 'pointer' }}
|
title={`Zeilenfehler vom letzten Import (${lastImport.error_details.length})`}
|
||||||
open
|
defaultOpen
|
||||||
>
|
/>
|
||||||
<summary style={{ fontWeight: 600, color: 'var(--text1)' }}>
|
|
||||||
Zeilenfehler vom letzten Import ({lastImport.error_details.length}) — zum Kopieren aufklappen
|
|
||||||
</summary>
|
|
||||||
<pre
|
|
||||||
style={{
|
|
||||||
marginTop: 12,
|
|
||||||
fontSize: 12,
|
|
||||||
overflow: 'auto',
|
|
||||||
maxHeight: 320,
|
|
||||||
background: 'var(--surface2)',
|
|
||||||
padding: 12,
|
|
||||||
borderRadius: 8,
|
|
||||||
color: 'var(--text1)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{JSON.stringify(lastImport.error_details, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</details>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="card" style={{ marginBottom: 16, padding: 16 }}>
|
<div className="card" style={{ marginBottom: 16, padding: 16 }}>
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,13 @@ export function formatFastApiDetail(detail, fallback = '') {
|
||||||
return parts.length ? parts.join(' · ') : fallback || 'Validierungsfehler'
|
return parts.length ? parts.join(' · ') : fallback || 'Validierungsfehler'
|
||||||
}
|
}
|
||||||
if (typeof detail === 'object') {
|
if (typeof detail === 'object') {
|
||||||
|
if (detail.validation?.errors?.length) {
|
||||||
|
const parts = detail.validation.errors
|
||||||
|
.map((e) => (e && typeof e === 'object' ? e.message || e.msg || '' : String(e)))
|
||||||
|
.filter(Boolean)
|
||||||
|
const prefix = detail.message || 'Vorlage ungültig'
|
||||||
|
return parts.length ? `${prefix}: ${parts.join(' · ')}` : prefix
|
||||||
|
}
|
||||||
if (Array.isArray(detail.errors) && detail.errors.length > 0) {
|
if (Array.isArray(detail.errors) && detail.errors.length > 0) {
|
||||||
const parts = detail.errors
|
const parts = detail.errors
|
||||||
.map((e) => {
|
.map((e) => {
|
||||||
|
|
@ -713,6 +720,7 @@ export const api = {
|
||||||
req(module ? `/csv/mappings?module=${encodeURIComponent(module)}` : '/csv/mappings'),
|
req(module ? `/csv/mappings?module=${encodeURIComponent(module)}` : '/csv/mappings'),
|
||||||
copyCsvMapping: (mappingId, body = null) =>
|
copyCsvMapping: (mappingId, body = null) =>
|
||||||
req(`/csv/mappings/${mappingId}/copy`, body ? json(body) : { method: 'POST' }),
|
req(`/csv/mappings/${mappingId}/copy`, body ? json(body) : { method: 'POST' }),
|
||||||
|
validateCsvMapping: (mappingId) => req(`/csv/mappings/${mappingId}/validate`),
|
||||||
/** Universal-CSV (Issue #21): Zielmodul steckt in der Vorlage; nur file + mapping_id */
|
/** Universal-CSV (Issue #21): Zielmodul steckt in der Vorlage; nur file + mapping_id */
|
||||||
/** Import-Diagnose: keine Datenbank-Schreibung, erste Zeilen + Mapping-Auflösung */
|
/** Import-Diagnose: keine Datenbank-Schreibung, erste Zeilen + Mapping-Auflösung */
|
||||||
diagnoseUniversalCsv: async (file, mappingId, module = null) => {
|
diagnoseUniversalCsv: async (file, mappingId, module = null) => {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
module.exports = {
|
module.exports = {
|
||||||
testDir: './tests',
|
testDir: './tests',
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
|
workers: 1,
|
||||||
|
globalSetup: require.resolve('./tests/global-setup.js'),
|
||||||
use: {
|
use: {
|
||||||
channel: 'chrome',
|
channel: 'chrome',
|
||||||
headless: true,
|
headless: true,
|
||||||
viewport: { width: 390, height: 844 },
|
viewport: { width: 390, height: 844 },
|
||||||
screenshot: 'only-on-failure',
|
screenshot: 'only-on-failure',
|
||||||
baseURL: 'https://dev.mitai.jinkendo.de',
|
baseURL: 'https://dev.mitai.jinkendo.de',
|
||||||
|
storageState: './tests/.auth/user.json',
|
||||||
},
|
},
|
||||||
reporter: 'list',
|
reporter: 'list',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,61 +1,53 @@
|
||||||
const { test, expect } = require('@playwright/test');
|
const { test, expect } = require('@playwright/test');
|
||||||
|
const { loginViaForm, clickBottomNav } = require('./helpers');
|
||||||
|
|
||||||
const TEST_EMAIL = process.env.TEST_EMAIL || 'lars@stommer.com';
|
test.describe('Login-Flow', () => {
|
||||||
const TEST_PASSWORD = process.env.TEST_PASSWORD || '5112';
|
test.use({ storageState: { cookies: [], origins: [] } });
|
||||||
|
|
||||||
async function login(page) {
|
|
||||||
await page.goto('/');
|
|
||||||
await page.waitForLoadState('networkidle');
|
|
||||||
await page.fill('input[type="email"]', TEST_EMAIL);
|
|
||||||
await page.fill('input[type="password"]', TEST_PASSWORD);
|
|
||||||
await page.click('button:has-text("Anmelden")');
|
|
||||||
await page.waitForLoadState('networkidle');
|
|
||||||
}
|
|
||||||
|
|
||||||
test('1. Login funktioniert', async ({ page }) => {
|
test('1. Login funktioniert', async ({ page }) => {
|
||||||
await page.goto('/');
|
await loginViaForm(page);
|
||||||
await page.fill('input[type="email"]', TEST_EMAIL);
|
|
||||||
await page.fill('input[type="password"]', TEST_PASSWORD);
|
|
||||||
await page.click('button:has-text("Anmelden")');
|
|
||||||
await page.waitForLoadState('networkidle');
|
|
||||||
const loginButton = page.locator('button:has-text("Anmelden")');
|
|
||||||
await expect(loginButton).toHaveCount(0, { timeout: 10000 });
|
|
||||||
await page.screenshot({ path: 'screenshots/01-nach-login.png' });
|
await page.screenshot({ path: 'screenshots/01-nach-login.png' });
|
||||||
console.log('Login erfolgreich');
|
console.log('Login erfolgreich');
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('2. Dashboard laedt ohne Fehler', async ({ page }) => {
|
test('2. Dashboard laedt ohne Fehler', async ({ page }) => {
|
||||||
await login(page);
|
await page.goto('/');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
|
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
|
||||||
await page.screenshot({ path: 'screenshots/02-dashboard.png' });
|
await page.screenshot({ path: 'screenshots/02-dashboard.png' });
|
||||||
console.log('Dashboard OK');
|
console.log('Dashboard OK');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('3. Erfassung erreichbar', async ({ page }) => {
|
test('3. Erfassen erreichbar', async ({ page }) => {
|
||||||
await login(page);
|
await page.goto('/');
|
||||||
await page.click('text=Erfassung');
|
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('networkidle');
|
||||||
|
await clickBottomNav(page, 'Erfassen');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await expect(page).toHaveURL(/\/capture/);
|
||||||
await page.screenshot({ path: 'screenshots/03-erfassung.png' });
|
await page.screenshot({ path: 'screenshots/03-erfassung.png' });
|
||||||
console.log('Erfassung OK');
|
console.log('Erfassen OK');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('4. Analyse erreichbar', async ({ page }) => {
|
test('4. Analyse erreichbar', async ({ page }) => {
|
||||||
await login(page);
|
await page.goto('/');
|
||||||
await page.click('text=Analyse');
|
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('networkidle');
|
||||||
|
await clickBottomNav(page, 'Analyse');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await expect(page).toHaveURL(/\/analysis/);
|
||||||
await page.screenshot({ path: 'screenshots/04-analyse.png' });
|
await page.screenshot({ path: 'screenshots/04-analyse.png' });
|
||||||
console.log('Analyse OK');
|
console.log('Analyse OK');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('5. Keine kritischen Console-Fehler', async ({ page }) => {
|
test('5. Keine kritischen Console-Fehler', async ({ page }) => {
|
||||||
const errors = [];
|
const errors = [];
|
||||||
page.on('console', msg => {
|
page.on('console', (msg) => {
|
||||||
if (msg.type() === 'error') errors.push(msg.text());
|
if (msg.type() === 'error') errors.push(msg.text());
|
||||||
});
|
});
|
||||||
await login(page);
|
await page.goto('/');
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('networkidle');
|
||||||
const kritisch = errors.filter(e =>
|
const kritisch = errors.filter(
|
||||||
!e.includes('favicon') && !e.includes('sourceMap') && !e.includes('404')
|
(e) => !e.includes('favicon') && !e.includes('sourceMap') && !e.includes('404')
|
||||||
);
|
);
|
||||||
if (kritisch.length > 0) {
|
if (kritisch.length > 0) {
|
||||||
console.log('Console-Fehler:', kritisch.join(', '));
|
console.log('Console-Fehler:', kritisch.join(', '));
|
||||||
|
|
|
||||||
64
tests/global-setup.js
Normal file
64
tests/global-setup.js
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
const { chromium } = require('@playwright/test');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const TEST_EMAIL = process.env.TEST_EMAIL || 'lars@stommer.com';
|
||||||
|
const TEST_PASSWORD = process.env.TEST_PASSWORD || '5112';
|
||||||
|
const AUTH_FILE = path.join(__dirname, '.auth', 'user.json');
|
||||||
|
|
||||||
|
async function tryLogin(page, baseURL) {
|
||||||
|
await page.goto(`${baseURL}/`);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
const loginBtn = page.locator('button:has-text("Anmelden")');
|
||||||
|
if ((await loginBtn.count()) === 0) {
|
||||||
|
return { ok: true, status: 200 };
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.fill('input[type="email"]', TEST_EMAIL);
|
||||||
|
await page.fill('input[type="password"]', TEST_PASSWORD);
|
||||||
|
|
||||||
|
const responsePromise = page
|
||||||
|
.waitForResponse(
|
||||||
|
(r) => r.url().includes('/api/auth/login') && r.request().method() === 'POST',
|
||||||
|
{ timeout: 15000 }
|
||||||
|
)
|
||||||
|
.catch(() => null);
|
||||||
|
|
||||||
|
await page.click('button:has-text("Anmelden")');
|
||||||
|
const response = await responsePromise;
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
const loginVisible = await page.locator('button:has-text("Anmelden")').count();
|
||||||
|
return { ok: loginVisible === 0, status: response?.status() ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = async (config) => {
|
||||||
|
fs.mkdirSync(path.dirname(AUTH_FILE), { recursive: true });
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const baseURL = config.projects?.[0]?.use?.baseURL || 'https://dev.mitai.jinkendo.de';
|
||||||
|
|
||||||
|
const waits = [0, 15000, 30000, 45000];
|
||||||
|
let lastStatus = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < waits.length; i++) {
|
||||||
|
if (waits[i] > 0) {
|
||||||
|
console.log(`Global setup: warte ${waits[i] / 1000}s (Login-Rate-Limit 5/min)…`);
|
||||||
|
await page.waitForTimeout(waits[i]);
|
||||||
|
}
|
||||||
|
const result = await tryLogin(page, baseURL);
|
||||||
|
lastStatus = result.status;
|
||||||
|
if (result.ok) {
|
||||||
|
await page.context().storageState({ path: AUTH_FILE });
|
||||||
|
await browser.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
throw new Error(
|
||||||
|
`Global setup: Login fehlgeschlagen (letzter HTTP-Status: ${lastStatus ?? 'unbekannt'})`
|
||||||
|
);
|
||||||
|
};
|
||||||
23
tests/helpers.js
Normal file
23
tests/helpers.js
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
const { expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
const TEST_EMAIL = process.env.TEST_EMAIL || 'lars@stommer.com';
|
||||||
|
const TEST_PASSWORD = process.env.TEST_PASSWORD || '5112';
|
||||||
|
|
||||||
|
/** Nutzt storageState aus global-setup; nur für Tests nötig, die explizit Login prüfen. */
|
||||||
|
async function loginViaForm(page) {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.fill('input[type="email"]', TEST_EMAIL);
|
||||||
|
await page.fill('input[type="password"]', TEST_PASSWORD);
|
||||||
|
await page.click('button:has-text("Anmelden")');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await expect(page.locator('button:has-text("Anmelden")')).toHaveCount(0, { timeout: 15000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickBottomNav(page, label) {
|
||||||
|
const link = page.locator('nav.bottom-nav a.nav-item').filter({ hasText: label });
|
||||||
|
await expect(link).toBeVisible({ timeout: 10000 });
|
||||||
|
await link.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { TEST_EMAIL, TEST_PASSWORD, loginViaForm, clickBottomNav };
|
||||||
51
tests/issue-audit.spec.js
Normal file
51
tests/issue-audit.spec.js
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
/**
|
||||||
|
* Gitea Issue-Audit — UI-Verifikation gegen dev.mitai.jinkendo.de
|
||||||
|
* Einmaliger Audit-Lauf; keine dauerhafte CI-Pflicht.
|
||||||
|
*/
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
test.describe('Issue-Audit UI', () => {
|
||||||
|
test('#40 Logout-Button im Mobile-Header sichtbar', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
const logoutBtn = page.locator('header.app-header--mobile button[title="Abmelden"]');
|
||||||
|
await expect(logoutBtn).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('#25 Goals-Seite erreichbar und rendert Inhalt', async ({ page }) => {
|
||||||
|
await page.goto('/goals');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await expect(page.locator('body')).not.toContainText('404', { timeout: 5000 });
|
||||||
|
const hasGoalsUi =
|
||||||
|
(await page.getByText(/Ziel/i).count()) > 0 ||
|
||||||
|
(await page.locator('.card').count()) > 0;
|
||||||
|
expect(hasGoalsUi).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('#65 Dashboard-Layout-Konfiguration erreichbar', async ({ page }) => {
|
||||||
|
await page.goto('/settings/dashboard-layout');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
const body = await page.locator('body').innerText();
|
||||||
|
expect(body).toMatch(/Widget|Übersicht|Layout|Dashboard/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('#30 Desktop-Sidebar bei breitem Viewport', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.setViewportSize({ width: 1280, height: 800 });
|
||||||
|
await expect(page.locator('aside.desktop-sidebar')).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('#38 Nutrition CSV Import — FDDB-Panel mit Usage-Integration', async ({ page }) => {
|
||||||
|
await page.goto('/nutrition');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.getByRole('button', { name: /Import/i }).click();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
const importCard = page.locator('.card').filter({ hasText: 'FDDB CSV Import' });
|
||||||
|
await expect(importCard).toBeVisible();
|
||||||
|
const titleRow = importCard.locator('.card-title.badge-container-right');
|
||||||
|
if (await titleRow.count()) {
|
||||||
|
await expect(titleRow).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user