Files
lifetrack/apps/api/app/tests/test_imports.py
T
MeeJayandClaude Opus 5 93f0689c1e Initial import: LifeTrack v1 (santé, vape, finances)
Tracker de vie auto-hébergé : suivi poids/calories/sport avec planning de
pesées, sevrage tabac (vape) avec modèle de coût DIY et économies, et
finances personnelles avec import de relevés bancaires.

Architecture : FastAPI + SQLAlchemy 2.0 + PostgreSQL 16, React 18 + TS +
Vite + Tailwind + ECharts, déploiement Docker Compose. Modules
auto-découverts des deux côtés (pkgutil / import.meta.glob) et framework
de connecteurs à deux voies (importeurs de fichiers + ingestion JSON)
pour brancher de nouvelles sources sans toucher au noyau.

Validé : 292 tests pytest, tsc + vite build, contrat API/web vérifié
contre le schéma OpenAPI, et déploiement Docker réel sur PostgreSQL 16
(28 tables, SPA servie par nginx, wizard de premier démarrage).

Documentation : README.md, docs/GUIDE.md, CONVENTIONS.md, et les
documents de conception et de recherche dans docs/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 10:48:57 +02:00

369 lines
13 KiB
Python

"""Contract tests for the imports/ingest framework.
They use a self-contained sample domain (model + importer + ingest handler
defined below) so they do not depend on any business module.
"""
from collections.abc import Iterator
from io import BytesIO
from fastapi.testclient import TestClient
from sqlalchemy import ForeignKey, String, UniqueConstraint, select
from sqlalchemy.orm import Mapped, Session, mapped_column
from app.core.database import Base
from app.core.importing.base import (
BaseImporter,
ImporterParseError,
NormalizedRecord,
RowError,
UpsertOutcome,
)
from app.core.importing.hashing import content_hash
from app.core.importing.registry import IMPORTER_REGISTRY, register_importer
from app.core.ingest.base import BaseIngestHandler, IngestRecord
from app.core.ingest.registry import INGEST_REGISTRY, register_ingest_handler
from app.core.mixins import SourceMixin, TimestampMixin
from app.modules.auth import service as auth_service
from app.modules.auth.schemas import DeviceKeyCreate
class SampleItem(TimestampMixin, SourceMixin, Base):
__tablename__ = "test_sample_items"
__table_args__ = (
UniqueConstraint(
"user_id", "source", "external_id", name="uq_test_sample_items_external"
),
UniqueConstraint("user_id", "content_hash", name="uq_test_sample_items_hash"),
)
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
import_run_id: Mapped[int | None] = mapped_column(
ForeignKey("import_runs.id", ondelete="CASCADE"), default=None
)
label: Mapped[str] = mapped_column(String(100))
value: Mapped[float] = mapped_column()
def _upsert_sample(
db: Session,
user_id: int,
record: NormalizedRecord,
source: str,
import_run_id: int | None,
) -> UpsertOutcome:
if record.data["value"] < 0:
raise RowError("Valeur négative interdite.")
if record.external_id is not None:
digest = None
existing = db.scalar(
select(SampleItem).where(
SampleItem.user_id == user_id,
SampleItem.source == source,
SampleItem.external_id == record.external_id,
)
)
else:
digest = content_hash(record)
existing = db.scalar(
select(SampleItem).where(
SampleItem.user_id == user_id,
SampleItem.content_hash == digest,
)
)
if existing is not None:
return UpsertOutcome.DUPLICATE
db.add(
SampleItem(
user_id=user_id,
source=source,
external_id=record.external_id,
content_hash=digest,
import_run_id=import_run_id,
label=record.data["label"],
value=record.data["value"],
)
)
db.flush()
return UpsertOutcome.INSERTED
if "test_sample_csv" not in IMPORTER_REGISTRY:
@register_importer
class SampleCsvImporter(BaseImporter):
id = "test_sample_csv"
label = "Échantillon de test (CSV)"
domain = "sample"
accepted_extensions = (".csv",)
@classmethod
def sniff(cls, filename: str, head: bytes) -> bool:
return filename.lower().endswith(".csv") and head.startswith(b"label;value")
def parse(self, data: bytes, filename: str) -> Iterator[NormalizedRecord]:
lines = [ln for ln in data.decode("utf-8").splitlines() if ln.strip()]
if not lines or not lines[0].startswith("label;value"):
raise ImporterParseError("En-têtes introuvables dans le fichier.")
for line in lines[1:]:
label, raw_value, external_id = line.split(";")
yield NormalizedRecord(
kind="sample",
data={"label": label, "value": float(raw_value.replace(",", "."))},
external_id=external_id or None,
dedupe_fields=("label", "value"),
)
def upsert(
self, db: Session, user_id: int, record: NormalizedRecord
) -> UpsertOutcome:
return _upsert_sample(db, user_id, record, self.id, self.import_run_id)
if "sample" not in INGEST_REGISTRY:
@register_ingest_handler
class SampleIngestHandler(BaseIngestHandler):
domain = "sample"
record_types = ("sample",)
def apply(
self, db: Session, user_id: int, record: IngestRecord
) -> UpsertOutcome:
normalized = NormalizedRecord(
kind=record.type,
data=record.data,
external_id=record.external_id,
dedupe_fields=("label", "value"),
)
return _upsert_sample(db, user_id, normalized, record.source, None)
CSV_OK = "label;value;ext\nCafé;1,5;a1\nThé;2;a2\nJus;3;\n"
def _upload(client: TestClient, headers: dict[str, str], content: str, source: str):
return client.post(
"/api/imports",
headers=headers,
files={"file": ("sample.csv", BytesIO(content.encode("utf-8")), "text/csv")},
data={"source": source},
)
def test_sources_endpoint_lists_importers(
client: TestClient, user, auth_headers
) -> None:
res = client.get("/api/imports/sources", headers=auth_headers)
assert res.status_code == 200
by_id = {src["id"]: src for src in res.json()}
assert by_id["test_sample_csv"]["label"] == "Échantillon de test (CSV)"
assert by_id["test_sample_csv"]["domain"] == "sample"
assert by_id["test_sample_csv"]["accepted_extensions"] == [".csv"]
def test_import_then_reimport_is_idempotent(
client: TestClient, db: Session, user, auth_headers
) -> None:
res = _upload(client, auth_headers, CSV_OK, "test_sample_csv")
assert res.status_code == 201
run = res.json()
assert run["status"] == "completed"
assert run["rows_total"] == 3
assert run["rows_inserted"] == 3
assert run["rows_duplicates"] == 0
# Same file again: everything deduped (external_id + content_hash paths).
res = _upload(client, auth_headers, CSV_OK, "test_sample_csv")
assert res.status_code == 201
rerun = res.json()
assert rerun["rows_inserted"] == 0
assert rerun["rows_duplicates"] == 3
assert db.scalar(select(SampleItem.id).limit(1)) is not None
rows = db.scalars(select(SampleItem)).all()
assert len(rows) == 3
assert all(row.import_run_id == run["id"] for row in rows)
def test_auto_sniff_detects_and_rejects(client: TestClient, user, auth_headers) -> None:
res = _upload(client, auth_headers, CSV_OK, "auto")
assert res.status_code == 201
assert res.json()["importer_id"] == "test_sample_csv"
res = _upload(client, auth_headers, "something;else\n1;2\n", "auto")
assert res.status_code == 422
assert (
res.json()["error"]["message"]
== "Format non reconnu, choisissez un profil de source."
)
def test_unknown_source_rejected(client: TestClient, user, auth_headers) -> None:
res = _upload(client, auth_headers, CSV_OK, "nope_csv")
assert res.status_code == 422
assert res.json()["error"]["code"] == "validation_error"
def test_row_errors_do_not_stop_the_run(client: TestClient, user, auth_headers) -> None:
csv = "label;value;ext\nCafé;1,5;b1\nMauvais;-4;b2\nThé;2;b3\n"
res = _upload(client, auth_headers, csv, "test_sample_csv")
assert res.status_code == 201
run = res.json()
assert run["status"] == "completed"
assert run["rows_total"] == 3
assert run["rows_inserted"] == 2
assert run["rows_errors"] == 1
assert run["error_details"][0]["row"] == 2
assert "négative" in run["error_details"][0]["message"]
def test_fatal_parse_error_marks_run_failed(
client: TestClient, db: Session, user, auth_headers
) -> None:
res = _upload(client, auth_headers, "garbage without headers\n", "test_sample_csv")
assert res.status_code == 201
run = res.json()
assert run["status"] == "failed"
assert run["error_details"]
# No partial domain rows are kept for a failed run.
assert db.scalars(select(SampleItem)).all() == []
def test_history_pagination_and_filters(client: TestClient, user, auth_headers) -> None:
_upload(client, auth_headers, CSV_OK, "test_sample_csv")
_upload(client, auth_headers, CSV_OK, "test_sample_csv")
res = client.get("/api/imports", headers=auth_headers)
assert res.status_code == 200
page = res.json()
assert set(page.keys()) == {"items", "total", "page", "page_size"}
assert page["total"] == 2
res = client.get("/api/imports?domain=finance", headers=auth_headers)
assert res.json()["total"] == 0
res = client.get("/api/imports?sort=bogus", headers=auth_headers)
assert res.status_code == 422
run_id = page["items"][0]["id"]
res = client.get(f"/api/imports/{run_id}", headers=auth_headers)
assert res.status_code == 200
assert res.json()["id"] == run_id
def test_rollback_deletes_run_and_cascades(
client: TestClient, db: Session, user, auth_headers
) -> None:
run_id = _upload(client, auth_headers, CSV_OK, "test_sample_csv").json()["id"]
assert len(db.scalars(select(SampleItem)).all()) == 3
res = client.delete(f"/api/imports/{run_id}", headers=auth_headers)
assert res.status_code == 204
db.expire_all()
assert db.scalars(select(SampleItem)).all() == []
res = client.get(f"/api/imports/{run_id}", headers=auth_headers)
assert res.status_code == 404
assert res.json()["error"]["code"] == "not_found"
def test_upload_too_large_rejected(
client: TestClient, user, auth_headers, monkeypatch
) -> None:
from app.core.config import get_settings
monkeypatch.setattr(get_settings(), "max_upload_bytes", 10)
res = _upload(client, auth_headers, CSV_OK, "test_sample_csv")
assert res.status_code == 413
assert res.json()["error"]["code"] == "payload_too_large"
def test_ingest_with_device_key_and_dedup(client: TestClient, user, device_key) -> None:
payload = {
"source": "android_bridge",
"records": [
{"type": "sample", "external_id": "x1", "data": {"label": "A", "value": 1}},
{"type": "sample", "data": {"label": "B", "value": 2}},
],
}
res = client.post("/api/ingest/sample", headers=device_key.headers, json=payload)
assert res.status_code == 200
body = res.json()
assert body == {
"domain": "sample",
"received": 2,
"inserted": 2,
"updated": 0,
"duplicates": 0,
"errors": [],
}
res = client.post("/api/ingest/sample", headers=device_key.headers, json=payload)
assert res.json()["duplicates"] == 2
def test_ingest_works_with_jwt_too(client: TestClient, user, auth_headers) -> None:
payload = {"records": [{"type": "sample", "data": {"label": "J", "value": 9}}]}
res = client.post("/api/ingest/sample", headers=auth_headers, json=payload)
assert res.status_code == 200
assert res.json()["inserted"] == 1
def test_ingest_auth_and_scope_rules(
client: TestClient, db: Session, user, device_key
) -> None:
payload = {"records": [{"type": "sample", "data": {"label": "S", "value": 4}}]}
# No credentials at all -> 401.
res = client.post("/api/ingest/sample", json=payload)
assert res.status_code == 401
# Key scoped to another domain -> 403.
_, other_plaintext = auth_service.create_device_key(
db, user.id, DeviceKeyCreate(name="Autre", scopes=["ingest:health"])
)
res = client.post(
"/api/ingest/sample", headers={"X-API-Key": other_plaintext}, json=payload
)
assert res.status_code == 403
# Revoked wildcard key -> 401.
auth_service.revoke_device_key(db, user.id, device_key.id)
res = client.post("/api/ingest/sample", headers=device_key.headers, json=payload)
assert res.status_code == 401
def test_ingest_unknown_domain_and_bad_type(
client: TestClient, user, device_key
) -> None:
payload = {"records": [{"type": "sample", "data": {"label": "D", "value": 1}}]}
res = client.post("/api/ingest/nope", headers=device_key.headers, json=payload)
assert res.status_code == 404
payload = {
"records": [
{"type": "sample", "data": {"label": "OK", "value": 1}},
{"type": "unknown_type", "data": {}},
{"type": "sample", "data": {"label": "KO", "value": -1}},
]
}
res = client.post("/api/ingest/sample", headers=device_key.headers, json=payload)
assert res.status_code == 200
body = res.json()
assert body["received"] == 3
assert body["inserted"] == 1
assert {err["index"] for err in body["errors"]} == {1, 2}
def test_ingest_record_limit(client: TestClient, user, device_key) -> None:
records = [
{"type": "sample", "data": {"label": f"r{i}", "value": i}} for i in range(1001)
]
res = client.post(
"/api/ingest/sample", headers=device_key.headers, json={"records": records}
)
assert res.status_code == 422
assert res.json()["error"]["code"] == "validation_error"