diff --git a/automatic-pdf-renamer/API.md b/automatic-pdf-renamer/API.md index 6b2316e..b2f6781 100644 --- a/automatic-pdf-renamer/API.md +++ b/automatic-pdf-renamer/API.md @@ -64,7 +64,6 @@ This convention applies to HTTP error responses from every endpoint. Successful | GET | `/files` | Browse, filter, sort, and search documents | | GET | `/files/{sha256}` | Document detail and metadata | | GET | `/files/{sha256}/pdf` | Stream the current PDF for the embedded viewer | -| GET | `/files/{sha256}/text` | Retrieve complete extracted text | | POST | `/files/{sha256}/preview` | Validate proposed fields and calculate the target key without mutation | | POST | `/files/{sha256}/validate` | Validate metadata and apply the final move/rename if required | | GET | `/consistency` | List known storage/index anomalies | @@ -226,7 +225,7 @@ The response must not claim ML confidence for a rules-only decision. `confidence ### GET `/files/{sha256}` -Returns the latest sidecar-backed record, including fields needed by the detail page. SQLite may be used to locate the record, but authoritative metadata must be reconciled from the sidecar where needed. +Returns the complete document detail in a single request, including metadata and the full extracted text from the PDF. Example response: @@ -242,6 +241,7 @@ Example response: "emitting_company": "EDF", "document_type": "invoice", "optional_fields": {"invoice_number": null, "amount": 125.5}, + "extracted_text": "Complete text from page 1...\\n\\nComplete text from page 2...", "target_key_preview": null, "confidence": null, "events": [ @@ -268,17 +268,6 @@ Requirements: - Return `404` if the object is genuinely missing and create/retain a consistency anomaly for diagnosis. - Never return the extracted text in place of the PDF bytes. -### GET `/files/{sha256}/text` - -Returns the complete extracted text stored in the sidecar/replica, including all pages. Useful for a separate text panel or troubleshooting; the UI may instead use the detail response if the payload size is acceptable. - -```json -{ - "sha256": "0123456789abcdef...", - "text": "Complete text from page 1...\n\nComplete text from page 2..." -} -``` - ## 8. Preview and validation All mutation endpoints must validate the requested state, recompute the period from `emission_date` and the applicable template period rules, calculate the destination key from the template's key pattern, and prevent an invalid document from being renamed. Filename parsing must never be used as a source for the period. diff --git a/automatic-pdf-renamer/tests.md b/automatic-pdf-renamer/tests.md new file mode 100644 index 0000000..0b9b8a7 --- /dev/null +++ b/automatic-pdf-renamer/tests.md @@ -0,0 +1,168 @@ +# End-to-End Testing Guide + +## Purpose + +The end-to-end (E2E) test harness is intended to verify the Automatic PDF Renamer as a running service, through its HTTP API and its persisted state. It should catch regressions that unit tests can miss, such as a successful API response that leaves a stale sidecar, an unexpected file move, or a SQLite index that no longer matches the authoritative metadata. + +The harness is designed to test the backend without depending on the web dashboard or a real S3 service. It uses a local filesystem-backed test storage area and disposable test data. + +> **Implementation status:** A first Docker-based pytest harness is implemented under `tests/e2e/`. It reuses the existing filesystem storage backend and includes representative rebuild, read-only, missing-document, and PDF-streaming scenarios. The suite now also covers representative Epic 4 dashboard workflows: list filtering/search, template options, metadata correction and safe PDF movement, plus configuration retrieval and valid/invalid updates. + +## Test design at a glance + +Each test run should follow this sequence: + +1. Create a baseline fixture directory containing sample PDF files and their `.meta.json` sidecars. +2. Keep that baseline inside a unique temporary workspace, never in a developer or production data directory. +3. Start a fresh instance of the application configured to use that workspace as its local storage root and a new SQLite database. +4. Call the API's consistency rebuild endpoint and wait for its asynchronous job to finish. This initializes the database from the sidecars rather than from pre-populated database state. +5. Execute the test's ordered list of HTTP actions. +6. After each action, check the HTTP response and assert the expected database and filesystem state. +7. Stop the application and remove the temporary workspace, including the database and any job state. + +A test must not depend on state left behind by another test. Each test should be reproducible when run alone or as part of the full suite. + +## Fixture directory + +The fixture directory is the known starting point for each test. It should contain realistic directory layouts and sidecars, but no SQLite database that the test relies on. + +For example, the fixture layout could look like this: + +```text +tests/ + fixtures/ + baseline/ + inbox/ + sample-a.pdf + sample-a.pdf.json + pending/ + sample-b.pdf + sample-b.pdf.json + files/ + sample-c.pdf + sample-c.pdf.json +``` + +The exact sidecar filename convention and directory layout must match the application's storage implementation. The names above are illustrative, not a declaration of the current format. + +PDF fixtures may be minimal or empty when a scenario only needs to test file identity, moves, sidecar updates, or indexing. Scenarios that test PDF parsing or text extraction must use valid PDF fixtures with known contents. Sidecars should be small, explicit, and deterministic, with stable metadata and extracted text. + +The E2E harness keeps its baseline PDFs and `.meta.json` sidecars as checked-in files under `tests/e2e/fixtures/baseline/`, then copies them into each temporary workspace. Keep those source fixtures unchanged during a test; do not point the service at a developer data directory or a production storage endpoint. + +## Local storage and service isolation + +The test instance should use a filesystem-backed storage implementation, or a test adapter that exposes the same storage operations as the production adapter. It must not require S3 credentials, a live Garage endpoint, or network access to an object-storage service. + +The local storage adapter should preserve the production semantics relevant to tests, including: + +- Listing and reading managed PDF objects. +- Reading and writing authoritative sidecars. +- Copying or moving a PDF safely when a document is renamed. +- Deleting a source only after the destination is safely written. +- Reporting missing objects and sidecars for consistency checks. + +The test harness should configure a fresh SQLite database for each isolated run. SQLite is a rebuildable index; sidecars remain the source of truth for document metadata and extracted text. Rebuilding the index must not rewrite or delete fixture PDFs or sidecars merely to make them match the database. + +The service should run with background polling and other non-deterministic workers disabled unless a test explicitly covers them. Tests should use a configurable local base URL and a bounded timeout for API calls and asynchronous jobs. + +## Test cases as ordered API actions + +A test scenario should be a readable sequence of actions. Each action should define: + +- An HTTP method, path, and optional query parameters or request body. +- The expected HTTP status and, where useful, response fields. +- Assertions to run after the action against the local filesystem and SQLite database. + +Actions run in order. Assertions run after each action so the test can identify the first operation that caused state to diverge. + +A scenario might conceptually look like this: + +```yaml +name: "Preview does not mutate a document" +setup: + fixture: "baseline" +actions: + - request: + method: GET + path: "/api/v1/files/" + expect: + status: 200 + assert: + sidecar_unchanged: true + pdf_locations_unchanged: true + database_matches_sidecar: true + + - request: + method: POST + path: "/api/v1/files//preview" + json: + template_name: "invoice" + emission_date: "2026-09-20" + emitting_company: "Example Company" + document_type: "invoice" + optional_fields: {} + expect: + status: 200 + json: + valid: true + assert: + sidecar_unchanged: true + pdf_locations_unchanged: true + database_matches_sidecar: true +``` + +This is an illustrative format, not a currently supported test-file schema. The eventual harness may use YAML, JSON, or ordinary Python test functions. It should prioritize readability and useful failure messages over introducing a large custom test language. + +## What to assert + +Every action should check the relevant layers rather than relying only on the HTTP response. + +### HTTP API + +- Status code and response content type. +- Required response fields and important values. +- Error messages for invalid requests. +- Job completion and job result for asynchronous operations. + +### Filesystem + +- Expected PDFs and sidecars exist. +- Renames and moves affect the correct paths. +- PDF bytes are preserved by a metadata-only rename or move. +- Sidecars contain the expected metadata, extracted text, and event history. +- Preview and rejected validation requests do not mutate files. +- No duplicate, orphaned, or unexpectedly deleted files are introduced. + +### SQLite database + +- Indexed rows reflect the authoritative sidecars after synchronization. +- Searchable fields and extracted text are indexed as expected. +- Rebuilding the database from sidecars reproduces the expected index. +- Database state is not treated as authoritative when it disagrees with a sidecar. + +Tests should avoid asserting implementation details that are irrelevant to the contract. Where possible, compare normalized records and meaningful fields rather than timestamps or generated job IDs that naturally vary between runs. + +## Recommended initial scenarios + +Start with a small set of high-value scenarios: + +1. **Fresh rebuild:** the database is populated from fixture sidecars, and the filesystem is unchanged. +2. **Read-only requests:** list and detail requests return expected metadata and do not mutate files. +3. **Preview:** a valid preview returns the computed destination without changing the PDF, sidecar, or database. +4. **Invalid validation:** invalid fields return the documented error and leave all persisted state unchanged. +5. **Successful validation:** a valid update safely moves or renames the PDF, updates the sidecar, and synchronizes SQLite. +6. **Idempotency:** repeating a supported operation does not create duplicate records or lose the document. +7. **Consistency anomalies:** missing PDFs, missing sidecars, or database drift are detected and reported. +8. **Rebuild after drift:** after deliberately changing or removing database state, rebuilding restores the index from sidecars without modifying the PDFs or sidecars. +9. **Collision handling:** a rename to an occupied destination follows the documented collision policy without overwriting another document. +10. **Interrupted operations:** simulated failures do not delete the only recoverable copy of a document. + +Not every scenario needs to be implemented in the first iteration. The initial goal is a reliable isolation mechanism, a deterministic local storage adapter, and representative end-to-end scenarios. The Epic 4 tests currently exercise the implemented HTTP endpoints and persisted sidecar/SQLite/filesystem state; they do not automate browser interactions in the React dashboard. + +## Running and troubleshooting + +Once implemented, the repository should provide a single documented command to run the E2E suite. The command should report which action and which state assertion failed, preserve or print the temporary workspace location on failure when useful, and clean up temporary data after successful runs. + +The harness should never point at a developer's normal data directory or a production storage endpoint. It should fail early if its configuration appears to target a non-test database or external object storage. + +Until the harness is implemented, this document is a design guide rather than a runnable command reference. See [API.md](API.md) for the API contract and [arch.md](arch.md) for the architecture overview. diff --git a/automatic-pdf-renamer/tests/e2e/README.md b/automatic-pdf-renamer/tests/e2e/README.md new file mode 100644 index 0000000..d44482c --- /dev/null +++ b/automatic-pdf-renamer/tests/e2e/README.md @@ -0,0 +1,26 @@ +# Docker-based end-to-end tests + +The E2E suite starts the real API in a disposable Docker container using the existing `FileStorage` backend. It does not start the dashboard, connect to S3/Garage, or enable background workers. + +## Requirements + +- Docker Engine with the `docker` CLI +- Python 3.11+ +- `pytest` (install with `python -m pip install -r tests/e2e/requirements.txt`) + +Run from `automatic-pdf-renamer/`: + +```sh +python -m pip install -r tests/e2e/requirements.txt +pytest -q tests/e2e +``` + +The suite builds the application image once per pytest session. Each test creates a baseline fixture tree (PDFs plus `.meta.json` sidecars) in its own temporary workspace, starts a new container with a new SQLite database, rebuilds SQLite from sidecars, and executes ordered HTTP actions. After each action, assertions can inspect the API, the host-mounted filesystem, and SQLite. + +Set `E2E_DOCKER_IMAGE` to override the temporary image tag. Set `E2E_STARTUP_TIMEOUT` to adjust the bounded startup wait. The suite uses a dynamically allocated localhost port and removes each container and temporary workspace when finished. + +Fixtures are checked-in immutable inputs under `tests/e2e/fixtures/baseline/`. The baseline includes an empty PDF and a valid one-page PDF, each with its matching `.meta.json` sidecar. Each test copies these files into its own temporary workspace; tests do not generate PDF or sidecar contents in Python. + +## Safety + +The suite always overrides storage and SQLite paths with paths inside its temporary workspace, and sets `APP_STORAGE_BACKEND=file`. It does not accept a production base URL or reuse a developer database. Do not modify the fixture source during a run. diff --git a/automatic-pdf-renamer/tests/e2e/conftest.py b/automatic-pdf-renamer/tests/e2e/conftest.py new file mode 100644 index 0000000..37bfb4b --- /dev/null +++ b/automatic-pdf-renamer/tests/e2e/conftest.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import json +import os +import subprocess +import shutil +import time +import uuid +from pathlib import Path +from typing import Any, Iterator + +import pytest + +APP_DIR = Path(__file__).resolve().parents[2] + +def _run(command: list[str], *, timeout: int = 120) -> str: + result = subprocess.run( + command, + cwd=APP_DIR, + check=True, + capture_output=True, + text=True, + timeout=timeout, + ) + return result.stdout.strip() + + +def _write_baseline(workspace: Path) -> None: + """Copy checked-in PDF and sidecar fixtures into an isolated test workspace.""" + storage = workspace / "storage" + fixture_dir = Path(__file__).resolve().parent / "fixtures" / "baseline" + if not fixture_dir.is_dir(): + raise FileNotFoundError(f"E2E fixture directory does not exist: {fixture_dir}") + shutil.copytree(fixture_dir, storage) + + +@pytest.fixture(scope="session") +def docker_image() -> Iterator[str]: + image = os.environ.get("E2E_DOCKER_IMAGE", f"automatic-pdf-renamer-e2e:{uuid.uuid4().hex[:10]}") + _run(["docker", "build", "-t", image, "-f", "Dockerfile", "."], timeout=600) + try: + yield image + finally: + if not os.environ.get("E2E_DOCKER_IMAGE"): + subprocess.run(["docker", "image", "rm", "-f", image], capture_output=True, text=True) + + +@pytest.fixture +def e2e_service(tmp_path: Path, docker_image: str) -> Iterator[dict[str, Any]]: + workspace = tmp_path / "workspace" + _write_baseline(workspace) + db_path = workspace / "renamer.db" + name = f"pdf-renamer-e2e-{uuid.uuid4().hex[:12]}" + _run( + [ + "docker", "run", "-d", "--rm", "--name", name, + "-p", "127.0.0.1::8000", + "-e", "APP_STORAGE_BACKEND=file", + "-e", "LOCAL_STORAGE_DIR=/e2e/storage", + "-e", "SQLITE_PATH=/e2e/renamer.db", + "-e", "DEBUG=false", + "-v", f"{workspace.resolve()}:/e2e", + docker_image, + ], + timeout=60, + ) + try: + port = _run(["docker", "port", name, "8000/tcp"]).splitlines()[0].rsplit(":", 1)[1] + base_url = f"http://127.0.0.1:{port}" + deadline = time.monotonic() + float(os.environ.get("E2E_STARTUP_TIMEOUT", "45")) + import urllib.error + import urllib.request + + while time.monotonic() < deadline: + try: + with urllib.request.urlopen(f"{base_url}/health", timeout=2) as response: + if response.status == 200: + break + except (OSError, urllib.error.URLError): + time.sleep(0.25) + else: + logs = subprocess.run(["docker", "logs", name], capture_output=True, text=True).stdout + raise RuntimeError(f"Application did not become healthy. Container logs:\n{logs}") + + yield {"base_url": base_url, "workspace": workspace, "storage": workspace / "storage", "db_path": db_path} + finally: + subprocess.run(["docker", "stop", name], capture_output=True, text=True, timeout=30) + + +def request_json( + base_url: str, + method: str, + path: str, + body: dict[str, Any] | None = None, +) -> tuple[int, Any, dict[str, str]]: + import urllib.error + import urllib.request + + payload = None if body is None else json.dumps(body).encode("utf-8") + request = urllib.request.Request( + base_url + path, + data=payload, + method=method, + headers={"Content-Type": "application/json"} if payload is not None else {}, + ) + try: + with urllib.request.urlopen(request, timeout=10) as response: + raw = response.read() + try: + content: Any = json.loads(raw) if raw else None + except json.JSONDecodeError: + content = raw.decode("utf-8", errors="replace") + return response.status, content, dict(response.headers.items()) + except urllib.error.HTTPError as error: + raw = error.read() + try: + content = json.loads(raw) if raw else None + except json.JSONDecodeError: + content = raw.decode("utf-8", errors="replace") + return error.code, content, dict(error.headers.items()) diff --git a/automatic-pdf-renamer/tests/e2e/fixtures/baseline/inbox/empty.meta.json b/automatic-pdf-renamer/tests/e2e/fixtures/baseline/inbox/empty.meta.json new file mode 100644 index 0000000..3c5920b --- /dev/null +++ b/automatic-pdf-renamer/tests/e2e/fixtures/baseline/inbox/empty.meta.json @@ -0,0 +1,19 @@ +{ + "schema_version": "1.0", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "original_key": "inbox/empty.pdf", + "current_key": "inbox/empty.pdf", + "status": "not processed", + "template_name": "default", + "period_format": "monthly", + "confidence": 0, + "emission_date": "", + "period": "", + "emitting_company": "", + "document_type": "", + "optional_fields": {}, + "extracted_text": "", + "created_at": "2026-01-01T00:00:00+00:00", + "updated_at": "2026-01-01T00:00:00+00:00", + "events": [] +} diff --git a/automatic-pdf-renamer/tests/e2e/fixtures/baseline/inbox/empty.pdf b/automatic-pdf-renamer/tests/e2e/fixtures/baseline/inbox/empty.pdf new file mode 100644 index 0000000..e69de29 diff --git a/automatic-pdf-renamer/tests/e2e/fixtures/baseline/pending/valid.meta.json b/automatic-pdf-renamer/tests/e2e/fixtures/baseline/pending/valid.meta.json new file mode 100644 index 0000000..46b53c9 --- /dev/null +++ b/automatic-pdf-renamer/tests/e2e/fixtures/baseline/pending/valid.meta.json @@ -0,0 +1,19 @@ +{ + "schema_version": "1.0", + "sha256": "b6c81b0f56ce2237b8d121f1066ed699fc32cd18a587e337cb7273d63e862fa4", + "original_key": "pending/valid.pdf", + "current_key": "pending/valid.pdf", + "status": "processed", + "template_name": "default", + "period_format": "monthly", + "confidence": 1, + "emission_date": "2026-09-20", + "period": "September 2026", + "emitting_company": "EDF", + "document_type": "facture", + "optional_fields": {}, + "extracted_text": "E2E sample document 2026-09-20", + "created_at": "2026-01-01T00:00:00+00:00", + "updated_at": "2026-01-01T00:00:00+00:00", + "events": [] +} diff --git a/automatic-pdf-renamer/tests/e2e/fixtures/baseline/pending/valid.pdf b/automatic-pdf-renamer/tests/e2e/fixtures/baseline/pending/valid.pdf new file mode 100644 index 0000000..cc3c759 --- /dev/null +++ b/automatic-pdf-renamer/tests/e2e/fixtures/baseline/pending/valid.pdf @@ -0,0 +1,32 @@ +%PDF-1.4 +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >> +endobj +4 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +5 0 obj +<< /Length 61 >> +stream +BT /F1 12 Tf 72 720 Td (E2E sample document 2026-09-20) Tj ET +endstream +endobj +xref +0 6 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000115 00000 n +0000000241 00000 n +0000000311 00000 n +trailer +<< /Size 6 /Root 1 0 R >> +startxref +422 +%%EOF diff --git a/automatic-pdf-renamer/tests/e2e/requirements.txt b/automatic-pdf-renamer/tests/e2e/requirements.txt new file mode 100644 index 0000000..5293116 --- /dev/null +++ b/automatic-pdf-renamer/tests/e2e/requirements.txt @@ -0,0 +1 @@ +pytest>=8.0,<9.0 diff --git a/automatic-pdf-renamer/tests/e2e/test_scenarios.py b/automatic-pdf-renamer/tests/e2e/test_scenarios.py new file mode 100644 index 0000000..6b0a2ef --- /dev/null +++ b/automatic-pdf-renamer/tests/e2e/test_scenarios.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +import json +import sqlite3 +from pathlib import Path +from typing import Any + +from conftest import request_json + + +def _sidecar(storage: Path, key: str) -> dict[str, Any]: + return json.loads((storage / key).with_suffix(".meta.json").read_text(encoding="utf-8")) + + +def _database_rows(db_path: Path) -> list[dict[str, Any]]: + with sqlite3.connect(db_path) as connection: + connection.row_factory = sqlite3.Row + return [dict(row) for row in connection.execute("SELECT * FROM files ORDER BY sha256")] + + +def _filesystem_snapshot(storage: Path) -> dict[str, bytes]: + return { + str(path.relative_to(storage)): path.read_bytes() + for path in storage.rglob("*") if path.is_file() + } + + +def _assert_baseline(service: dict[str, Any], *, expected_count: int) -> None: + rows = _database_rows(service["db_path"]) + assert len(rows) == expected_count + assert {row["sha256"] for row in rows} == ({"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "b6c81b0f56ce2237b8d121f1066ed699fc32cd18a587e337cb7273d63e862fa4"} if expected_count else set()) + assert _sidecar(service["storage"], "inbox/empty.pdf")["sha256"] == "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + assert _sidecar(service["storage"], "pending/valid.pdf")["sha256"] == "b6c81b0f56ce2237b8d121f1066ed699fc32cd18a587e337cb7273d63e862fa4" + + +def test_rebuild_and_read_only_requests(e2e_service: dict[str, Any]) -> None: + """Run an ordered action list and check persisted state after each action.""" + service = e2e_service + base = service["base_url"] + storage = service["storage"] + original_files = _filesystem_snapshot(storage) + + # Action 1: rebuild SQLite from authoritative sidecars. + status, result, _ = request_json(base, "POST", "/consistency/rebuild") + assert status == 200, result + assert result["status"] == "completed" + assert result["count"] == 2 + _assert_baseline(service, expected_count=2) + assert _filesystem_snapshot(storage) == original_files + + # Action 2: list documents. + status, items, headers = request_json(base, "GET", "/files") + assert status == 200, items + assert len(items) == 2 + assert {key.lower(): value for key, value in headers.items()}.get("x-total-count") == "2" + _assert_baseline(service, expected_count=2) + assert _filesystem_snapshot(storage) == original_files + + # Action 3: read document detail, including extracted text. + status, detail, _ = request_json(base, "GET", f"/files/b6c81b0f56ce2237b8d121f1066ed699fc32cd18a587e337cb7273d63e862fa4") + assert status == 200, detail + assert detail["current_key"] == "pending/valid.pdf" + assert detail["extracted_text"] == "E2E sample document 2026-09-20" + _assert_baseline(service, expected_count=2) + assert _filesystem_snapshot(storage) == original_files + + +def test_missing_document_returns_404_without_mutation(e2e_service: dict[str, Any]) -> None: + service = e2e_service + request_status, result, _ = request_json(service["base_url"], "POST", "/consistency/rebuild") + assert request_status == 200, result + before = _filesystem_snapshot(service["storage"]) + + status, body, _ = request_json(service["base_url"], "GET", f"/files/{'f' * 64}") + assert status == 404, body + _assert_baseline(service, expected_count=2) + assert _filesystem_snapshot(service["storage"]) == before + + +def test_pdf_endpoint_streams_original_bytes(e2e_service: dict[str, Any]) -> None: + import urllib.request + + service = e2e_service + request_status, result, _ = request_json(service["base_url"], "POST", "/consistency/rebuild") + assert request_status == 200, result + original_pdf = (service["storage"] / "pending/valid.pdf").read_bytes() + before = _filesystem_snapshot(service["storage"]) + + with urllib.request.urlopen( + service["base_url"] + f"/files/b6c81b0f56ce2237b8d121f1066ed699fc32cd18a587e337cb7273d63e862fa4/pdf", timeout=10 + ) as response: + assert response.status == 200 + assert response.headers.get_content_type() == "application/pdf" + assert response.read() == original_pdf + + _assert_baseline(service, expected_count=2) + assert _filesystem_snapshot(service["storage"]) == before + +def test_file_list_filters_and_search(e2e_service: dict[str, Any]) -> None: + service = e2e_service + base = service["base_url"] + status_code, rebuilt, _ = request_json(base, "POST", "/consistency/rebuild") + assert status_code == 200, rebuilt + + status_code, processed, headers = request_json(base, "GET", "/files?status=processed") + assert status_code == 200, processed + assert len(processed) == 1 + assert processed[0]["id"] == "b6c81b0f56ce2237b8d121f1066ed699fc32cd18a587e337cb7273d63e862fa4" + assert {key.lower(): value for key, value in headers.items()}.get("x-total-count") == "1" + + status_code, by_company, _ = request_json(base, "GET", "/files?emitting_company=EDF") + assert status_code == 200, by_company + assert len(by_company) == 1 + assert by_company[0]["emitting_company"] == "EDF" + + status_code, by_text, _ = request_json(base, "GET", "/files?q=E2E%20sample%20document") + assert status_code == 200, by_text + assert any(item["id"] == "b6c81b0f56ce2237b8d121f1066ed699fc32cd18a587e337cb7273d63e862fa4" for item in by_text) + + +def test_document_edit_updates_metadata_and_moves_pdf_safely(e2e_service: dict[str, Any]) -> None: + service = e2e_service + base = service["base_url"] + file_id = "b6c81b0f56ce2237b8d121f1066ed699fc32cd18a587e337cb7273d63e862fa4" + status_code, rebuilt, _ = request_json(base, "POST", "/consistency/rebuild") + assert status_code == 200, rebuilt + + source_pdf = service["storage"] / "pending/valid.pdf" + original_bytes = source_pdf.read_bytes() + payload = { + "template_name": "default", + "period_format": "monthly", + "emitting_company": "EDF", + "document_type": "facture", + "emission_date": "2026-09-20", + "optional_fields": {"customer_number": "CUST-12345678"}, + } + status_code, detail, _ = request_json(base, "POST", f"/files/{file_id}/edit", payload) + assert status_code == 200, detail + assert detail["status"] == "validated" + assert detail["emission_date"] == "2026-09-20" + assert detail["period"] == "Septembre 2026" + assert detail["current_key"].startswith("files/EDF/Septembre 2026/facture/2026-09-20_valid.pdf") + assert detail["optional_fields"] == {"customer_number": "CUST-12345678"} + + destination = service["storage"] / detail["current_key"] + assert destination.read_bytes() == original_bytes + assert not source_pdf.exists() + assert not (service["storage"] / "pending/valid.meta.json").exists() + + rows = _database_rows(service["db_path"]) + matching = [row for row in rows if row["sha256"] == file_id] + assert len(matching) == 1 + assert matching[0]["status"] == "validated" + assert matching[0]["current_key"] == detail["current_key"] + assert matching[0]["optional_fields"] == json.dumps({"customer_number": "CUST-12345678"}, separators=(",", ":")) or json.loads(matching[0]["optional_fields"]) == {"customer_number": "CUST-12345678"} + + sidecar_path = service["storage"] / f"{detail['current_key']}.meta.json" + assert sidecar_path.exists() + sidecar = json.loads(sidecar_path.read_text(encoding="utf-8")) + assert sidecar["status"] == "validated" + assert sidecar["optional_fields"] == {"customer_number": "CUST-12345678"} + assert any(event.get("type") == "corrected" for event in sidecar.get("events", [])) + + +def test_configuration_can_be_read_and_invalid_update_is_rejected(e2e_service: dict[str, Any]) -> None: + base = e2e_service["base_url"] + status_code, original, _ = request_json(base, "GET", "/api/config") + assert status_code == 200, original + assert isinstance(original.get("content"), str) + assert '"templates"' in original["content"] + + status_code, error, _ = request_json(base, "PUT", "/api/config", {"content": '{"templates":'}) + assert status_code == 422, error + + status_code, after, _ = request_json(base, "GET", "/api/config") + assert status_code == 200, after + assert after["content"] == original["content"] + + +def test_configuration_valid_update_reloads_without_changing_content(e2e_service: dict[str, Any]) -> None: + base = e2e_service["base_url"] + status_code, original, _ = request_json(base, "GET", "/api/config") + assert status_code == 200, original + + status_code, saved, _ = request_json(base, "PUT", "/api/config", {"content": original["content"]}) + assert status_code == 200, saved + assert saved == {"saved": True, "reloaded": True} + + status_code, current, _ = request_json(base, "GET", "/api/config") + assert status_code == 200, current + assert current["content"] == original["content"] + + +def test_template_options_are_available_for_dashboard(e2e_service: dict[str, Any]) -> None: + base = e2e_service["base_url"] + status_code, templates, _ = request_json(base, "GET", "/templates") + assert status_code == 200, templates + assert {template["name"] for template in templates} >= {"default", "edf_invoice", "bank_statement"} + + status_code, options, _ = request_json(base, "GET", "/api/document-options") + assert status_code == 200, options + assert "templates" in options + assert "period_formats" in options + assert "companies" in options + assert "document_types" in options +