Add configurable document columns and allow bulk metadata editing (#14)

This commit is contained in:
2026-10-19 18:15:03 +02:00
committed by GitHub
parent 0748584b22
commit 6a47e6ad56
20 changed files with 755 additions and 85 deletions
+12
View File
@@ -24,3 +24,15 @@ Fixtures are checked-in immutable inputs under `tests/e2e/fixtures/baseline/`. T
## 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.
## Additional E2E scenarios: configurable columns and bulk operations
The E2E suite should add API-level scenarios for the bulk-edit contract using isolated local storage and disposable SQLite state. The browser column chooser and row-selection interactions require frontend/browser tests; they are not covered by API-only E2E tests.
Required scenarios:
- List records with last_modified and verify it is distinct from emission_date.
- Bulk-edit company/type and an additional field; inspect sidecars and SQLite after completion.
- Select all matching filters across pagination and prove nonmatching records remain unchanged.
- Verify validation errors and per-document partial outcomes.
- Force a destination collision and a storage failure; verify no source object is lost and sidecar/SQLite state remains recoverable.
- Retry a partially successful request using the same idempotency token and verify duplicate mutations are not introduced.
@@ -1,12 +1,15 @@
"""Tests for asynchronous, confidence-gated document suggestions."""
import asyncio
import json
import re
import unittest
from datetime import date
from types import SimpleNamespace
from unittest.mock import patch
from unittest.mock import AsyncMock, Mock, patch
from app.api import routes
from app.api.models import DocumentEditRequest
from app.ml import AmbiguousPredictionError, LocalMLPipeline
@@ -32,7 +35,7 @@ class FakeRegistry:
return SimpleNamespace(to_dict=lambda: {})
def get_key_pattern(self, name):
return "example/{company}/{period}/{document_type}"
return "example/{year}/{month}/{month_name}/{day}/{company}/{period}/{document_type}"
class FakeMLPipeline(LocalMLPipeline):
@@ -140,7 +143,10 @@ class DocumentSuggestionTests(unittest.TestCase):
self.assertEqual(suggestions["template_name"]["value"], "invoice-template")
self.assertEqual(suggestions["period_format"]["value"], "monthly")
self.assertEqual(suggestions["period"]["value"], "2026-09")
self.assertEqual(suggestions["current_key"]["value"], "files/example/Edited Company/2026-09/invoice.pdf")
self.assertEqual(
suggestions["current_key"]["value"],
"files/example/2026/09/Septembre/20/Edited Company/2026-09/invoice.pdf",
)
def test_derived_values_use_form_values_not_model_predictions(self):
class ConflictingPipeline(FakeMLPipeline):
@@ -303,6 +309,67 @@ class DocumentSuggestionTests(unittest.TestCase):
self.assertEqual(suggestions["template_name"]["value"], "invoice-template")
self.assertEqual(suggestions["period_format"]["value"], "monthly")
def test_edit_route_uses_computed_period_placeholders_in_storage_key(self):
record = SimpleNamespace(
id="document-1",
sha256="document-1",
current_key="pending/original.pdf",
original_key="pending/original.pdf",
template_name="old-template",
period_format="monthly",
emitting_company="Old Company",
document_type="receipt",
emission_date="2026-09-20",
period="September 2026",
optional_fields={},
status="pending",
confidence=None,
updated_at="2026-09-20T00:00:00+00:00",
created_at="2026-09-20T00:00:00+00:00",
extracted_text="document text",
)
metadata = json.dumps({"sha256": record.sha256, "current_key": record.current_key})
storage = Mock()
storage.get_object.return_value = metadata
storage.object_exists.side_effect = lambda key: key == record.current_key
database = Mock()
database.get_by_id.return_value = record
registry = Mock()
registry.get_template.return_value = object()
registry.get_period_format_config.return_value = SimpleNamespace(
to_dict=lambda: {"granularity": "month"}
)
registry.get_key_pattern.return_value = (
"{year}/{month}/{month_name}/{day}/{original_filename}"
)
request = SimpleNamespace(
app=SimpleNamespace(state=SimpleNamespace()),
state=SimpleNamespace(),
)
expected_key = "files/2026/09/Septembre/20/original.pdf"
with (
patch.object(routes, "_storage", return_value=storage),
patch.object(routes, "_db", return_value=database),
patch.object(routes, "_registry", return_value=registry),
patch.object(routes, "get_file", new=AsyncMock(return_value=None)),
):
asyncio.run(routes.edit_file(
request,
record.id,
DocumentEditRequest(
template_name="invoice-template",
period_format="monthly",
emitting_company="Example Company",
document_type="invoice",
emission_date=date(2026, 9, 20),
),
current_user=None,
))
self.assertEqual(record.current_key, expected_key)
storage.copy_object.assert_called_once_with(record.original_key, expected_key)
if __name__ == "__main__":
unittest.main()
@@ -5,6 +5,7 @@ import unittest
from types import SimpleNamespace
from app.pipeline import DocumentProcessor
from app.period import get_period_key_values
class EmissionDateExtractionTests(unittest.TestCase):
@@ -41,5 +42,28 @@ class EmissionDateExtractionTests(unittest.TestCase):
self.assertIsNotNone(error)
class PeriodKeyPlaceholderTests(unittest.TestCase):
def test_key_values_use_the_computed_period_and_representative_day(self):
values = get_period_key_values(
"2026-01-20",
{"granularity": "month", "offset": "previous"},
)
self.assertEqual(
values,
{"year": "2025", "month": "12", "month_name": "Décembre", "day": "20"},
)
def test_key_pattern_renders_period_placeholders(self):
processor = object.__new__(DocumentProcessor)
key = processor._format_key_pattern(
"{year}/{month}/{month_name}/{day}/document.pdf",
**get_period_key_values("2026-09-20", {"granularity": "month"}),
)
self.assertEqual(key, "2026/09/Septembre/20/document.pdf")
if __name__ == "__main__":
unittest.main()