framework-m 0.7.1__tar.gz → 0.7.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {framework_m-0.7.1 → framework_m-0.7.3}/CHANGELOG.md +1 -1
- {framework_m-0.7.1 → framework_m-0.7.3}/PKG-INFO +3 -3
- {framework_m-0.7.1 → framework_m-0.7.3}/pyproject.toml +4 -4
- framework_m-0.7.3/src/framework_m/core/services/__init__.py +5 -0
- framework_m-0.7.3/src/framework_m/core/services/correction.py +179 -0
- framework_m-0.7.3/tests/core/test_correction_service.py +328 -0
- framework_m-0.7.3/tests/integration/test_rfc_0008_audit.py +170 -0
- framework_m-0.7.3/tests/integration/test_rfc_0008_correction.py +228 -0
- framework_m-0.7.3/tests/integration/test_rfc_0008_versioning.py +101 -0
- {framework_m-0.7.1 → framework_m-0.7.3}/.gitignore +0 -0
- {framework_m-0.7.1 → framework_m-0.7.3}/LICENSE +0 -0
- {framework_m-0.7.1 → framework_m-0.7.3}/README.md +0 -0
- {framework_m-0.7.1 → framework_m-0.7.3}/alembic/README +0 -0
- {framework_m-0.7.1 → framework_m-0.7.3}/alembic/env.py +0 -0
- {framework_m-0.7.1 → framework_m-0.7.3}/alembic/script.py.mako +0 -0
- {framework_m-0.7.1 → framework_m-0.7.3}/alembic.ini +0 -0
- {framework_m-0.7.1 → framework_m-0.7.3}/docs/ports.md +0 -0
- {framework_m-0.7.1 → framework_m-0.7.3}/src/framework_m/__init__.py +0 -0
- {framework_m-0.7.1 → framework_m-0.7.3}/src/framework_m/cli/__init__.py +0 -0
- {framework_m-0.7.1 → framework_m-0.7.3}/src/framework_m/cli/main.py +0 -0
- {framework_m-0.7.1 → framework_m-0.7.3}/src/framework_m/py.typed +0 -0
- {framework_m-0.7.1 → framework_m-0.7.3}/tests/adapters/auth/test_oauth_adapter.py +0 -0
- {framework_m-0.7.1 → framework_m-0.7.3}/tests/adapters/read_model/test_projector.py +0 -0
- {framework_m-0.7.1 → framework_m-0.7.3}/tests/architecture/fixtures/ci_dummy_index.html +0 -0
- {framework_m-0.7.1 → framework_m-0.7.3}/tests/architecture/test_frontend_bootstrap.py +0 -0
- {framework_m-0.7.1 → framework_m-0.7.3}/tests/architecture/test_package_separation.py +0 -0
- {framework_m-0.7.1 → framework_m-0.7.3}/tests/conftest.py +0 -0
- {framework_m-0.7.1 → framework_m-0.7.3}/tests/core/interfaces/test_read_model.py +0 -0
- {framework_m-0.7.1 → framework_m-0.7.3}/tests/core/interfaces/test_report_engine.py +0 -0
- {framework_m-0.7.1 → framework_m-0.7.3}/tests/helpers/__init__.py +0 -0
- {framework_m-0.7.1 → framework_m-0.7.3}/tests/integration/__init__.py +0 -0
- {framework_m-0.7.1 → framework_m-0.7.3}/tests/integration/test_api_endpoints.py +0 -0
- {framework_m-0.7.1 → framework_m-0.7.3}/tests/integration/test_app_ports.py +0 -0
- {framework_m-0.7.1 → framework_m-0.7.3}/tests/integration/test_child_table_integration.py +0 -0
- {framework_m-0.7.1 → framework_m-0.7.3}/tests/integration/test_crud_flow.py +0 -0
- {framework_m-0.7.1 → framework_m-0.7.3}/tests/integration/test_jobs_events_webhooks.py +0 -0
- {framework_m-0.7.1 → framework_m-0.7.3}/tests/integration/test_migration_flow.py +0 -0
- {framework_m-0.7.1 → framework_m-0.7.3}/tests/integration/test_override_migration.py +0 -0
- {framework_m-0.7.1 → framework_m-0.7.3}/tests/integration/test_postgres_flow.py +0 -0
- {framework_m-0.7.1 → framework_m-0.7.3}/tests/integration/test_real_http_client.py +0 -0
- {framework_m-0.7.1 → framework_m-0.7.3}/tests/integration/test_workflow_lifecycle.py +0 -0
- {framework_m-0.7.1 → framework_m-0.7.3}/tests/test_meta.py +0 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
**framework-m** v0.7.1: internal dependency bump due to **framework-m-studio** update## framework-m v0.7.0
|
|
1
|
+
**framework-m** v0.7.3: internal dependency bump due to **framework-m-core** update**framework-m** v0.7.2: internal dependency bump due to **framework-m-core** update**framework-m** v0.7.1: internal dependency bump due to **framework-m-studio** update## framework-m v0.7.0
|
|
2
2
|
|
|
3
3
|
### Features
|
|
4
4
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: framework-m
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.3
|
|
4
4
|
Summary: A modular, metadata-driven business application framework
|
|
5
5
|
Project-URL: Homepage, https://gitlab.com/castlecraft/framework-m
|
|
6
6
|
Project-URL: Documentation, https://gitlab.com/castlecraft/framework-m#readme
|
|
@@ -18,8 +18,8 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.13
|
|
19
19
|
Classifier: Typing :: Typed
|
|
20
20
|
Requires-Python: >=3.12
|
|
21
|
-
Requires-Dist: framework-m-core>=0.
|
|
22
|
-
Requires-Dist: framework-m-standard>=0.
|
|
21
|
+
Requires-Dist: framework-m-core>=0.13.0
|
|
22
|
+
Requires-Dist: framework-m-standard>=0.10.0
|
|
23
23
|
Description-Content-Type: text/markdown
|
|
24
24
|
|
|
25
25
|
# Framework M
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "framework-m"
|
|
3
|
-
version = "0.7.
|
|
3
|
+
version = "0.7.3"
|
|
4
4
|
description = "A modular, metadata-driven business application framework"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { text = "Apache-2.0" }
|
|
@@ -21,8 +21,8 @@ classifiers = [
|
|
|
21
21
|
]
|
|
22
22
|
|
|
23
23
|
dependencies = [
|
|
24
|
-
"framework-m-core>=0.
|
|
25
|
-
"framework-m-standard>=0.
|
|
24
|
+
"framework-m-core>=0.13.0",
|
|
25
|
+
"framework-m-standard>=0.10.0",
|
|
26
26
|
]
|
|
27
27
|
|
|
28
28
|
[tool.uv.sources]
|
|
@@ -126,7 +126,7 @@ select = [
|
|
|
126
126
|
"PTH", # flake8-use-pathlib
|
|
127
127
|
"RUF", # ruff-specific rules
|
|
128
128
|
]
|
|
129
|
-
ignore = ["E501"
|
|
129
|
+
ignore = ["E501"] # Line length is handled by formatter, but we allow it for long docstrings/comments
|
|
130
130
|
|
|
131
131
|
[tool.ruff.format]
|
|
132
132
|
quote-style = "double"
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Correction service implementing amend, restore, and in-place edit flows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from framework_m_core.domain.base_controller import BaseController
|
|
9
|
+
from framework_m_core.domain.base_doctype import BaseDocType
|
|
10
|
+
from framework_m_core.domain.mixins import DocStatus
|
|
11
|
+
from framework_m_core.interfaces.audit import AuditLogProtocol
|
|
12
|
+
from framework_m_core.interfaces.auth_context import UserContext
|
|
13
|
+
from framework_m_core.interfaces.repository import RepositoryProtocol
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CorrectionService:
|
|
17
|
+
"""Service for RFC-0008 correction workflows on submitted documents."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
repository: RepositoryProtocol[BaseDocType],
|
|
22
|
+
audit: AuditLogProtocol,
|
|
23
|
+
) -> None:
|
|
24
|
+
self._repository = repository
|
|
25
|
+
self._audit = audit
|
|
26
|
+
|
|
27
|
+
async def amend(
|
|
28
|
+
self,
|
|
29
|
+
doc: BaseDocType,
|
|
30
|
+
reason: str,
|
|
31
|
+
user: UserContext,
|
|
32
|
+
) -> BaseDocType:
|
|
33
|
+
"""Cancel submitted doc and create a new draft copy with incremented name."""
|
|
34
|
+
self._ensure_submitted(doc)
|
|
35
|
+
|
|
36
|
+
cancelled = doc.model_copy(deep=True)
|
|
37
|
+
if hasattr(cancelled, "docstatus"):
|
|
38
|
+
cancelled.docstatus = DocStatus.CANCELLED
|
|
39
|
+
await self._repository.save(cancelled)
|
|
40
|
+
|
|
41
|
+
amended = self._clone_with_updates(
|
|
42
|
+
source=doc,
|
|
43
|
+
new_name=self._next_amended_name(doc.name),
|
|
44
|
+
docstatus=DocStatus.DRAFT,
|
|
45
|
+
drop_id=True,
|
|
46
|
+
)
|
|
47
|
+
saved_amended = await self._repository.save(amended)
|
|
48
|
+
|
|
49
|
+
await self._audit.log(
|
|
50
|
+
user_id=user.id,
|
|
51
|
+
action="amend",
|
|
52
|
+
doctype=type(doc).__name__,
|
|
53
|
+
document_id=self._document_ref(doc),
|
|
54
|
+
changes=None,
|
|
55
|
+
metadata={"reason": reason},
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
return saved_amended
|
|
59
|
+
|
|
60
|
+
async def restore(
|
|
61
|
+
self,
|
|
62
|
+
doc: BaseDocType,
|
|
63
|
+
snapshot_data: dict[str, Any],
|
|
64
|
+
reason: str,
|
|
65
|
+
user: UserContext,
|
|
66
|
+
) -> BaseDocType:
|
|
67
|
+
"""Cancel submitted doc and recreate a submitted document from snapshot data."""
|
|
68
|
+
self._ensure_submitted(doc)
|
|
69
|
+
if not snapshot_data:
|
|
70
|
+
raise ValueError("snapshot_data cannot be empty")
|
|
71
|
+
|
|
72
|
+
cancelled = doc.model_copy(deep=True)
|
|
73
|
+
if hasattr(cancelled, "docstatus"):
|
|
74
|
+
cancelled.docstatus = DocStatus.CANCELLED
|
|
75
|
+
await self._repository.save(cancelled)
|
|
76
|
+
|
|
77
|
+
restored_data = dict(snapshot_data)
|
|
78
|
+
restored_data.pop("id", None)
|
|
79
|
+
restored_data["name"] = self._next_amended_name(doc.name)
|
|
80
|
+
restored_data["docstatus"] = DocStatus.SUBMITTED
|
|
81
|
+
|
|
82
|
+
restored_doc = type(doc).model_validate(restored_data)
|
|
83
|
+
saved_restored = await self._repository.save(restored_doc)
|
|
84
|
+
|
|
85
|
+
await self._audit.log(
|
|
86
|
+
user_id=user.id,
|
|
87
|
+
action="restore",
|
|
88
|
+
doctype=type(doc).__name__,
|
|
89
|
+
document_id=self._document_ref(doc),
|
|
90
|
+
changes=None,
|
|
91
|
+
metadata={"reason": reason},
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
return saved_restored
|
|
95
|
+
|
|
96
|
+
async def in_place_edit(
|
|
97
|
+
self,
|
|
98
|
+
doc: BaseDocType,
|
|
99
|
+
reason: str,
|
|
100
|
+
user: UserContext,
|
|
101
|
+
) -> BaseDocType:
|
|
102
|
+
"""Keep same document identity, snapshot history, then reset to draft."""
|
|
103
|
+
if not getattr(type(doc), "_versioning_enabled", False):
|
|
104
|
+
raise ValueError("in_place_edit requires VersionedMixin")
|
|
105
|
+
|
|
106
|
+
self._ensure_submitted(doc)
|
|
107
|
+
|
|
108
|
+
history_snapshot = doc.model_copy(deep=True)
|
|
109
|
+
await self._repository.save(history_snapshot)
|
|
110
|
+
|
|
111
|
+
editable = doc.model_copy(deep=True)
|
|
112
|
+
if hasattr(editable, "docstatus"):
|
|
113
|
+
editable.docstatus = DocStatus.DRAFT
|
|
114
|
+
|
|
115
|
+
controller: BaseController[BaseDocType] = BaseController(editable)
|
|
116
|
+
await controller._validate_submitted_changes(editable, doc)
|
|
117
|
+
|
|
118
|
+
saved_editable = await self._repository.save(editable)
|
|
119
|
+
|
|
120
|
+
await self._audit.log(
|
|
121
|
+
user_id=user.id,
|
|
122
|
+
action="in_place_edit",
|
|
123
|
+
doctype=type(doc).__name__,
|
|
124
|
+
document_id=self._document_ref(doc),
|
|
125
|
+
changes=None,
|
|
126
|
+
metadata={"reason": reason},
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return saved_editable
|
|
130
|
+
|
|
131
|
+
def _next_amended_name(self, name: str | None) -> str:
|
|
132
|
+
"""Return incremented amended name: INV-001 -> INV-001-1, INV-001-1 -> INV-001-2."""
|
|
133
|
+
base_name = name or "DOC"
|
|
134
|
+
# Only treat trailing numeric segment as amendment counter when
|
|
135
|
+
# a prior amendment segment already exists (at least 2 hyphens).
|
|
136
|
+
if base_name.count("-") < 2:
|
|
137
|
+
return f"{base_name}-1"
|
|
138
|
+
|
|
139
|
+
match = re.match(r"^(.*)-(\d+)$", base_name)
|
|
140
|
+
if match is None:
|
|
141
|
+
return f"{base_name}-1"
|
|
142
|
+
|
|
143
|
+
prefix = match.group(1)
|
|
144
|
+
number = int(match.group(2))
|
|
145
|
+
return f"{prefix}-{number + 1}"
|
|
146
|
+
|
|
147
|
+
def _ensure_submitted(self, doc: BaseDocType) -> None:
|
|
148
|
+
"""Validate that the document is in submitted state."""
|
|
149
|
+
if not hasattr(doc, "docstatus"):
|
|
150
|
+
raise ValueError("Only submitted documents can be corrected")
|
|
151
|
+
|
|
152
|
+
if doc.docstatus != DocStatus.SUBMITTED:
|
|
153
|
+
raise ValueError("Only submitted documents can be corrected")
|
|
154
|
+
|
|
155
|
+
def _clone_with_updates(
|
|
156
|
+
self,
|
|
157
|
+
source: BaseDocType,
|
|
158
|
+
new_name: str,
|
|
159
|
+
docstatus: DocStatus,
|
|
160
|
+
*,
|
|
161
|
+
drop_id: bool,
|
|
162
|
+
) -> BaseDocType:
|
|
163
|
+
"""Clone a document and apply correction-flow identity/status updates."""
|
|
164
|
+
data = source.model_dump(mode="python")
|
|
165
|
+
if drop_id:
|
|
166
|
+
data.pop("id", None)
|
|
167
|
+
data["name"] = new_name
|
|
168
|
+
if "docstatus" in type(source).model_fields:
|
|
169
|
+
data["docstatus"] = docstatus
|
|
170
|
+
return type(source).model_validate(data)
|
|
171
|
+
|
|
172
|
+
def _document_ref(self, doc: BaseDocType) -> str:
|
|
173
|
+
"""Return preferred audit document reference (name first, then id)."""
|
|
174
|
+
if doc.name:
|
|
175
|
+
return str(doc.name)
|
|
176
|
+
return str(doc.id)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
__all__ = ["CorrectionService"]
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Tests for CorrectionService (RFC-0008 Step 8.1)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
from unittest.mock import AsyncMock
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from framework_m_core.domain.base_doctype import BaseDocType, Field
|
|
10
|
+
from framework_m_core.domain.mixins import DocStatus, SubmittableMixin, VersionedMixin
|
|
11
|
+
from framework_m_core.interfaces.audit import AuditLogProtocol
|
|
12
|
+
from framework_m_core.interfaces.auth_context import UserContext
|
|
13
|
+
from framework_m_core.interfaces.repository import RepositoryProtocol
|
|
14
|
+
|
|
15
|
+
from framework_m.core.services.correction import CorrectionService
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Invoice(BaseDocType, SubmittableMixin):
|
|
19
|
+
"""Test invoice doctype for correction-flow tests."""
|
|
20
|
+
|
|
21
|
+
customer: str = Field(description="Customer")
|
|
22
|
+
amount: float = Field(default=0.0, description="Amount")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class VersionedInvoice(Invoice, VersionedMixin):
|
|
26
|
+
"""Versioned invoice doctype for in-place correction tests."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture
|
|
30
|
+
def mock_repository() -> AsyncMock:
|
|
31
|
+
"""Repository mock implementing save semantics."""
|
|
32
|
+
|
|
33
|
+
def _save(entity: BaseDocType, version: int | None = None) -> BaseDocType:
|
|
34
|
+
_ = version
|
|
35
|
+
return entity
|
|
36
|
+
|
|
37
|
+
repo = AsyncMock(spec=RepositoryProtocol[BaseDocType])
|
|
38
|
+
repo.save = AsyncMock(side_effect=_save)
|
|
39
|
+
return repo
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.fixture
|
|
43
|
+
def mock_audit_adapter() -> AsyncMock:
|
|
44
|
+
"""Audit adapter mock for correction-flow logging."""
|
|
45
|
+
audit = AsyncMock(spec=AuditLogProtocol)
|
|
46
|
+
audit.log = AsyncMock(return_value="audit-entry-001")
|
|
47
|
+
return audit
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@pytest.fixture
|
|
51
|
+
def service(
|
|
52
|
+
mock_repository: AsyncMock,
|
|
53
|
+
mock_audit_adapter: AsyncMock,
|
|
54
|
+
) -> CorrectionService:
|
|
55
|
+
"""CorrectionService under test."""
|
|
56
|
+
return CorrectionService(
|
|
57
|
+
repository=mock_repository,
|
|
58
|
+
audit=mock_audit_adapter,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@pytest.fixture
|
|
63
|
+
def user_context() -> UserContext:
|
|
64
|
+
"""User context fixture for audit metadata."""
|
|
65
|
+
return UserContext(
|
|
66
|
+
id="user-001",
|
|
67
|
+
email="user-001@example.com",
|
|
68
|
+
name="Test User",
|
|
69
|
+
roles=["Employee"],
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@pytest.fixture
|
|
74
|
+
def submitted_invoice() -> Invoice:
|
|
75
|
+
"""Submitted invoice fixture for amend/restore tests."""
|
|
76
|
+
return Invoice(
|
|
77
|
+
name="INV-001",
|
|
78
|
+
customer="Acme",
|
|
79
|
+
amount=100.0,
|
|
80
|
+
docstatus=DocStatus.SUBMITTED,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@pytest.fixture
|
|
85
|
+
def versioned_submitted_invoice() -> VersionedInvoice:
|
|
86
|
+
"""Submitted versioned invoice fixture for in-place edit tests."""
|
|
87
|
+
return VersionedInvoice(
|
|
88
|
+
name="INV-001",
|
|
89
|
+
customer="Acme",
|
|
90
|
+
amount=100.0,
|
|
91
|
+
docstatus=DocStatus.SUBMITTED,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class TestCorrectionServiceAmend:
|
|
96
|
+
"""Amend flow tests."""
|
|
97
|
+
|
|
98
|
+
@pytest.mark.asyncio
|
|
99
|
+
async def test_amend_cancels_original_and_creates_new_draft(
|
|
100
|
+
self,
|
|
101
|
+
service: CorrectionService,
|
|
102
|
+
mock_repository: AsyncMock,
|
|
103
|
+
mock_audit_adapter: AsyncMock,
|
|
104
|
+
submitted_invoice: Invoice,
|
|
105
|
+
user_context: UserContext,
|
|
106
|
+
) -> None:
|
|
107
|
+
result = await service.amend(
|
|
108
|
+
doc=submitted_invoice,
|
|
109
|
+
reason="Typo fix",
|
|
110
|
+
user=user_context,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
assert result.docstatus == DocStatus.DRAFT
|
|
114
|
+
assert result.name == "INV-001-1"
|
|
115
|
+
assert result.id != submitted_invoice.id
|
|
116
|
+
|
|
117
|
+
assert mock_repository.save.await_count == 2
|
|
118
|
+
|
|
119
|
+
cancelled_doc = mock_repository.save.await_args_list[0].args[0]
|
|
120
|
+
amended_doc = mock_repository.save.await_args_list[1].args[0]
|
|
121
|
+
|
|
122
|
+
assert cancelled_doc.docstatus == DocStatus.CANCELLED
|
|
123
|
+
assert cancelled_doc.name == "INV-001"
|
|
124
|
+
|
|
125
|
+
assert amended_doc.docstatus == DocStatus.DRAFT
|
|
126
|
+
assert amended_doc.name == "INV-001-1"
|
|
127
|
+
|
|
128
|
+
mock_audit_adapter.log.assert_awaited_once_with(
|
|
129
|
+
user_id=user_context.id,
|
|
130
|
+
action="amend",
|
|
131
|
+
doctype="Invoice",
|
|
132
|
+
document_id="INV-001",
|
|
133
|
+
changes=None,
|
|
134
|
+
metadata={"reason": "Typo fix"},
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def test_amend_name_increments_correctly(self, service: CorrectionService) -> None:
|
|
138
|
+
assert service._next_amended_name("INV-001") == "INV-001-1"
|
|
139
|
+
assert service._next_amended_name("INV-001-1") == "INV-001-2"
|
|
140
|
+
|
|
141
|
+
@pytest.mark.asyncio
|
|
142
|
+
async def test_amend_raises_if_not_submitted(
|
|
143
|
+
self,
|
|
144
|
+
service: CorrectionService,
|
|
145
|
+
mock_repository: AsyncMock,
|
|
146
|
+
submitted_invoice: Invoice,
|
|
147
|
+
user_context: UserContext,
|
|
148
|
+
) -> None:
|
|
149
|
+
draft = submitted_invoice.model_copy(deep=True)
|
|
150
|
+
draft.docstatus = DocStatus.DRAFT
|
|
151
|
+
|
|
152
|
+
with pytest.raises(ValueError):
|
|
153
|
+
await service.amend(
|
|
154
|
+
doc=draft,
|
|
155
|
+
reason="Need correction",
|
|
156
|
+
user=user_context,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
mock_repository.save.assert_not_awaited()
|
|
160
|
+
|
|
161
|
+
@pytest.mark.asyncio
|
|
162
|
+
async def test_amend_audit_failure_propagates(
|
|
163
|
+
self,
|
|
164
|
+
service: CorrectionService,
|
|
165
|
+
mock_audit_adapter: AsyncMock,
|
|
166
|
+
submitted_invoice: Invoice,
|
|
167
|
+
user_context: UserContext,
|
|
168
|
+
) -> None:
|
|
169
|
+
mock_audit_adapter.log.side_effect = RuntimeError("audit write failed")
|
|
170
|
+
|
|
171
|
+
with pytest.raises(RuntimeError, match="audit write failed"):
|
|
172
|
+
await service.amend(
|
|
173
|
+
doc=submitted_invoice,
|
|
174
|
+
reason="Typo fix",
|
|
175
|
+
user=user_context,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class TestCorrectionServiceRestore:
|
|
180
|
+
"""Restore flow tests."""
|
|
181
|
+
|
|
182
|
+
@pytest.mark.asyncio
|
|
183
|
+
async def test_restore_cancels_original_and_creates_snapshot_doc(
|
|
184
|
+
self,
|
|
185
|
+
service: CorrectionService,
|
|
186
|
+
mock_repository: AsyncMock,
|
|
187
|
+
mock_audit_adapter: AsyncMock,
|
|
188
|
+
submitted_invoice: Invoice,
|
|
189
|
+
user_context: UserContext,
|
|
190
|
+
) -> None:
|
|
191
|
+
snapshot_data: dict[str, Any] = {
|
|
192
|
+
"customer": "Acme Restored",
|
|
193
|
+
"amount": 120.0,
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
result = await service.restore(
|
|
197
|
+
doc=submitted_invoice,
|
|
198
|
+
snapshot_data=snapshot_data,
|
|
199
|
+
reason="Rollback to prior snapshot",
|
|
200
|
+
user=user_context,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
assert result.name == "INV-001-1"
|
|
204
|
+
assert result.docstatus == DocStatus.SUBMITTED
|
|
205
|
+
|
|
206
|
+
assert mock_repository.save.await_count == 2
|
|
207
|
+
|
|
208
|
+
cancelled_doc = mock_repository.save.await_args_list[0].args[0]
|
|
209
|
+
restored_doc = mock_repository.save.await_args_list[1].args[0]
|
|
210
|
+
|
|
211
|
+
assert cancelled_doc.docstatus == DocStatus.CANCELLED
|
|
212
|
+
assert restored_doc.docstatus == DocStatus.SUBMITTED
|
|
213
|
+
assert restored_doc.customer == "Acme Restored"
|
|
214
|
+
|
|
215
|
+
mock_audit_adapter.log.assert_awaited_once_with(
|
|
216
|
+
user_id=user_context.id,
|
|
217
|
+
action="restore",
|
|
218
|
+
doctype="Invoice",
|
|
219
|
+
document_id="INV-001",
|
|
220
|
+
changes=None,
|
|
221
|
+
metadata={"reason": "Rollback to prior snapshot"},
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
@pytest.mark.asyncio
|
|
225
|
+
async def test_restore_raises_if_not_submitted(
|
|
226
|
+
self,
|
|
227
|
+
service: CorrectionService,
|
|
228
|
+
submitted_invoice: Invoice,
|
|
229
|
+
user_context: UserContext,
|
|
230
|
+
) -> None:
|
|
231
|
+
draft = submitted_invoice.model_copy(deep=True)
|
|
232
|
+
draft.docstatus = DocStatus.DRAFT
|
|
233
|
+
|
|
234
|
+
with pytest.raises(ValueError):
|
|
235
|
+
await service.restore(
|
|
236
|
+
doc=draft,
|
|
237
|
+
snapshot_data={"customer": "Acme"},
|
|
238
|
+
reason="Invalid state",
|
|
239
|
+
user=user_context,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
@pytest.mark.asyncio
|
|
243
|
+
async def test_restore_raises_if_snapshot_empty(
|
|
244
|
+
self,
|
|
245
|
+
service: CorrectionService,
|
|
246
|
+
submitted_invoice: Invoice,
|
|
247
|
+
user_context: UserContext,
|
|
248
|
+
) -> None:
|
|
249
|
+
with pytest.raises(ValueError, match="snapshot_data cannot be empty"):
|
|
250
|
+
await service.restore(
|
|
251
|
+
doc=submitted_invoice,
|
|
252
|
+
snapshot_data={},
|
|
253
|
+
reason="Missing snapshot",
|
|
254
|
+
user=user_context,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class TestCorrectionServiceInPlaceEdit:
|
|
259
|
+
"""In-place edit flow tests."""
|
|
260
|
+
|
|
261
|
+
@pytest.mark.asyncio
|
|
262
|
+
async def test_in_place_edit_copies_to_history_and_resets_to_draft(
|
|
263
|
+
self,
|
|
264
|
+
service: CorrectionService,
|
|
265
|
+
mock_repository: AsyncMock,
|
|
266
|
+
mock_audit_adapter: AsyncMock,
|
|
267
|
+
versioned_submitted_invoice: VersionedInvoice,
|
|
268
|
+
user_context: UserContext,
|
|
269
|
+
) -> None:
|
|
270
|
+
result = await service.in_place_edit(
|
|
271
|
+
doc=versioned_submitted_invoice,
|
|
272
|
+
reason="Operational correction",
|
|
273
|
+
user=user_context,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
assert result.id == versioned_submitted_invoice.id
|
|
277
|
+
assert result.name == versioned_submitted_invoice.name
|
|
278
|
+
assert result.docstatus == DocStatus.DRAFT
|
|
279
|
+
|
|
280
|
+
assert mock_repository.save.await_count == 2
|
|
281
|
+
|
|
282
|
+
history_snapshot_doc = mock_repository.save.await_args_list[0].args[0]
|
|
283
|
+
editable_doc = mock_repository.save.await_args_list[1].args[0]
|
|
284
|
+
|
|
285
|
+
assert history_snapshot_doc.docstatus == DocStatus.SUBMITTED
|
|
286
|
+
assert editable_doc.docstatus == DocStatus.DRAFT
|
|
287
|
+
assert editable_doc.id == versioned_submitted_invoice.id
|
|
288
|
+
assert editable_doc.name == versioned_submitted_invoice.name
|
|
289
|
+
|
|
290
|
+
mock_audit_adapter.log.assert_awaited_once_with(
|
|
291
|
+
user_id=user_context.id,
|
|
292
|
+
action="in_place_edit",
|
|
293
|
+
doctype="VersionedInvoice",
|
|
294
|
+
document_id="INV-001",
|
|
295
|
+
changes=None,
|
|
296
|
+
metadata={"reason": "Operational correction"},
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
@pytest.mark.asyncio
|
|
300
|
+
async def test_in_place_edit_raises_if_not_versioned(
|
|
301
|
+
self,
|
|
302
|
+
service: CorrectionService,
|
|
303
|
+
submitted_invoice: Invoice,
|
|
304
|
+
user_context: UserContext,
|
|
305
|
+
) -> None:
|
|
306
|
+
with pytest.raises(ValueError, match="in_place_edit requires VersionedMixin"):
|
|
307
|
+
await service.in_place_edit(
|
|
308
|
+
doc=submitted_invoice,
|
|
309
|
+
reason="Invalid flow",
|
|
310
|
+
user=user_context,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
@pytest.mark.asyncio
|
|
314
|
+
async def test_in_place_edit_raises_if_not_submitted(
|
|
315
|
+
self,
|
|
316
|
+
service: CorrectionService,
|
|
317
|
+
versioned_submitted_invoice: VersionedInvoice,
|
|
318
|
+
user_context: UserContext,
|
|
319
|
+
) -> None:
|
|
320
|
+
draft = versioned_submitted_invoice.model_copy(deep=True)
|
|
321
|
+
draft.docstatus = DocStatus.DRAFT
|
|
322
|
+
|
|
323
|
+
with pytest.raises(ValueError):
|
|
324
|
+
await service.in_place_edit(
|
|
325
|
+
doc=draft,
|
|
326
|
+
reason="Invalid state",
|
|
327
|
+
user=user_context,
|
|
328
|
+
)
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""RFC-0008 audit integration tests (Phase 12.1)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncGenerator
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
from framework_m_core.doctypes.activity_log import ActivityLog
|
|
11
|
+
from framework_m_core.domain.base_controller import BaseController
|
|
12
|
+
from framework_m_core.domain.base_doctype import BaseDocType, Field
|
|
13
|
+
from framework_m_core.domain.mixins import AuditMixin, DocStatus, SubmittableMixin
|
|
14
|
+
from framework_m_core.exceptions import ImmutableDocumentError
|
|
15
|
+
from framework_m_core.interfaces.audit import AuditEntry, AuditLogProtocol
|
|
16
|
+
from framework_m_standard.adapters.audit.database_audit import DatabaseAuditAdapter
|
|
17
|
+
from framework_m_standard.adapters.db.generic_repository import GenericRepository
|
|
18
|
+
from framework_m_standard.adapters.db.schema_mapper import SchemaMapper
|
|
19
|
+
from sqlalchemy import MetaData
|
|
20
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AuditedInvoice(BaseDocType, AuditMixin, SubmittableMixin):
|
|
24
|
+
"""Audited + submittable DocType for integration tests."""
|
|
25
|
+
|
|
26
|
+
title: str = Field(description="Invoice title")
|
|
27
|
+
total: float = Field(default=0.0, description="Invoice total")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AuditedInvoiceController(BaseController[AuditedInvoice]):
|
|
31
|
+
"""Controller that supports test-time audit adapter injection."""
|
|
32
|
+
|
|
33
|
+
audit_adapter: AuditLogProtocol | None = None
|
|
34
|
+
|
|
35
|
+
def __init__(self, doc: AuditedInvoice) -> None:
|
|
36
|
+
super().__init__(doc)
|
|
37
|
+
self._audit_adapter = type(self).audit_adapter
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _SessionBoundActivityRepository:
|
|
41
|
+
"""Expose RepositoryProtocol-style methods backed by a bound DB session."""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
repo: GenericRepository[ActivityLog],
|
|
46
|
+
session: AsyncSession,
|
|
47
|
+
) -> None:
|
|
48
|
+
self._repo = repo
|
|
49
|
+
self._session = session
|
|
50
|
+
|
|
51
|
+
async def get(
|
|
52
|
+
self,
|
|
53
|
+
id: Any,
|
|
54
|
+
as_of: datetime | None = None,
|
|
55
|
+
) -> ActivityLog | None:
|
|
56
|
+
return await self._repo.get(self._session, id, as_of=as_of)
|
|
57
|
+
|
|
58
|
+
async def save(
|
|
59
|
+
self,
|
|
60
|
+
entity: ActivityLog,
|
|
61
|
+
version: int | None = None,
|
|
62
|
+
) -> ActivityLog:
|
|
63
|
+
return await self._repo.save(self._session, entity, version=version)
|
|
64
|
+
|
|
65
|
+
async def list(
|
|
66
|
+
self,
|
|
67
|
+
filters: list[Any] | None = None,
|
|
68
|
+
order_by: list[Any] | None = None,
|
|
69
|
+
limit: int = 20,
|
|
70
|
+
offset: int = 0,
|
|
71
|
+
as_of: datetime | None = None,
|
|
72
|
+
) -> Any:
|
|
73
|
+
return await self._repo.list_entities(
|
|
74
|
+
self._session,
|
|
75
|
+
filters=filters,
|
|
76
|
+
order_by=order_by,
|
|
77
|
+
limit=limit,
|
|
78
|
+
offset=offset,
|
|
79
|
+
as_of=as_of,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@pytest.fixture
|
|
84
|
+
async def audit_stack(
|
|
85
|
+
db_session: AsyncSession,
|
|
86
|
+
clean_tables: MetaData,
|
|
87
|
+
) -> AsyncGenerator[
|
|
88
|
+
tuple[GenericRepository[AuditedInvoice], DatabaseAuditAdapter], None
|
|
89
|
+
]:
|
|
90
|
+
"""Create repositories and a database-backed audit adapter."""
|
|
91
|
+
mapper = SchemaMapper()
|
|
92
|
+
audited_table = mapper.create_table(AuditedInvoice, clean_tables)
|
|
93
|
+
activity_table = mapper.create_table(ActivityLog, clean_tables)
|
|
94
|
+
|
|
95
|
+
conn = await db_session.connection()
|
|
96
|
+
await conn.run_sync(clean_tables.create_all)
|
|
97
|
+
|
|
98
|
+
invoice_repo = GenericRepository(
|
|
99
|
+
model=AuditedInvoice,
|
|
100
|
+
table=audited_table,
|
|
101
|
+
controller_class=AuditedInvoiceController,
|
|
102
|
+
)
|
|
103
|
+
activity_repo = GenericRepository(model=ActivityLog, table=activity_table)
|
|
104
|
+
audit_repo_adapter = _SessionBoundActivityRepository(activity_repo, db_session)
|
|
105
|
+
audit_adapter = DatabaseAuditAdapter(repository=audit_repo_adapter)
|
|
106
|
+
|
|
107
|
+
AuditedInvoiceController.audit_adapter = audit_adapter
|
|
108
|
+
yield invoice_repo, audit_adapter
|
|
109
|
+
AuditedInvoiceController.audit_adapter = None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def _entries_for_document(
|
|
113
|
+
audit: DatabaseAuditAdapter,
|
|
114
|
+
document_id: str,
|
|
115
|
+
) -> list[AuditEntry]:
|
|
116
|
+
"""Query all audit entries for an AuditedInvoice by document reference."""
|
|
117
|
+
return await audit.query(
|
|
118
|
+
filters={"doctype": "AuditedInvoice", "document_id": document_id},
|
|
119
|
+
limit=100,
|
|
120
|
+
offset=0,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@pytest.mark.asyncio
|
|
125
|
+
async def test_rfc_0008_audit_flow_end_to_end(
|
|
126
|
+
audit_stack: tuple[GenericRepository[AuditedInvoice], DatabaseAuditAdapter],
|
|
127
|
+
db_session: AsyncSession,
|
|
128
|
+
) -> None:
|
|
129
|
+
"""Insert/update/submit should audit, while submitted edits are blocked."""
|
|
130
|
+
repo, audit = audit_stack
|
|
131
|
+
|
|
132
|
+
invoice = AuditedInvoice(name="INV-001", title="Initial", total=10.0)
|
|
133
|
+
|
|
134
|
+
# Create -> create audit entry
|
|
135
|
+
await repo.save(db_session, invoice)
|
|
136
|
+
entries_after_create = await _entries_for_document(audit, "INV-001")
|
|
137
|
+
assert any(entry.action == "create" for entry in entries_after_create)
|
|
138
|
+
|
|
139
|
+
# Update -> update audit entry with diff
|
|
140
|
+
invoice.total = 25.0
|
|
141
|
+
await repo.save(db_session, invoice)
|
|
142
|
+
entries_after_update = await _entries_for_document(audit, "INV-001")
|
|
143
|
+
update_entries = [
|
|
144
|
+
entry for entry in entries_after_update if entry.action == "update"
|
|
145
|
+
]
|
|
146
|
+
assert len(update_entries) >= 1
|
|
147
|
+
assert update_entries[0].changes == {"total": {"old": 10.0, "new": 25.0}}
|
|
148
|
+
|
|
149
|
+
# Submit -> submit audit entry
|
|
150
|
+
invoice.docstatus = DocStatus.SUBMITTED
|
|
151
|
+
await repo.save(db_session, invoice)
|
|
152
|
+
entries_after_submit = await _entries_for_document(audit, "INV-001")
|
|
153
|
+
assert any(entry.action == "submit" for entry in entries_after_submit)
|
|
154
|
+
|
|
155
|
+
# Direct edit on submitted doc -> immutable error and no extra update entry
|
|
156
|
+
update_count_before_illegal = len(
|
|
157
|
+
[entry for entry in entries_after_submit if entry.action == "update"]
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
illegal_edit = invoice.model_copy(deep=True)
|
|
161
|
+
illegal_edit.total = 99.0
|
|
162
|
+
|
|
163
|
+
with pytest.raises(ImmutableDocumentError):
|
|
164
|
+
await repo.save(db_session, illegal_edit)
|
|
165
|
+
|
|
166
|
+
entries_after_illegal = await _entries_for_document(audit, "INV-001")
|
|
167
|
+
update_count_after_illegal = len(
|
|
168
|
+
[entry for entry in entries_after_illegal if entry.action == "update"]
|
|
169
|
+
)
|
|
170
|
+
assert update_count_after_illegal == update_count_before_illegal
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""RFC-0008 correction integration tests (Phase 12.3)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from framework_m_core.doctypes.activity_log import ActivityLog
|
|
10
|
+
from framework_m_core.domain.base_doctype import BaseDocType, Field
|
|
11
|
+
from framework_m_core.domain.mixins import DocStatus, SubmittableMixin, VersionedMixin
|
|
12
|
+
from framework_m_core.interfaces.auth_context import UserContext
|
|
13
|
+
from framework_m_standard.adapters.audit.database_audit import DatabaseAuditAdapter
|
|
14
|
+
from framework_m_standard.adapters.db.generic_repository import GenericRepository
|
|
15
|
+
from framework_m_standard.adapters.db.schema_mapper import SchemaMapper
|
|
16
|
+
from sqlalchemy import MetaData, func, select
|
|
17
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
18
|
+
|
|
19
|
+
from framework_m.core.services.correction import CorrectionService
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CorrectableInvoice(BaseDocType, VersionedMixin, SubmittableMixin):
|
|
23
|
+
"""Submittable + versioned DocType for correction-flow integration."""
|
|
24
|
+
|
|
25
|
+
customer: str = Field(description="Customer")
|
|
26
|
+
amount: float = Field(default=0.0, description="Amount")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _SessionBoundRepositoryAdapter:
|
|
30
|
+
"""Expose RepositoryProtocol-style methods backed by a bound DB session."""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
repo: GenericRepository[Any],
|
|
35
|
+
session: AsyncSession,
|
|
36
|
+
) -> None:
|
|
37
|
+
self._repo = repo
|
|
38
|
+
self._session = session
|
|
39
|
+
|
|
40
|
+
async def get(
|
|
41
|
+
self,
|
|
42
|
+
id: Any,
|
|
43
|
+
as_of: datetime | None = None,
|
|
44
|
+
) -> Any | None:
|
|
45
|
+
return await self._repo.get(self._session, id, as_of=as_of)
|
|
46
|
+
|
|
47
|
+
async def save(
|
|
48
|
+
self,
|
|
49
|
+
entity: Any,
|
|
50
|
+
version: int | None = None,
|
|
51
|
+
) -> Any:
|
|
52
|
+
return await self._repo.save(self._session, entity, version=version)
|
|
53
|
+
|
|
54
|
+
async def list(
|
|
55
|
+
self,
|
|
56
|
+
filters: list[Any] | None = None,
|
|
57
|
+
order_by: list[Any] | None = None,
|
|
58
|
+
limit: int = 20,
|
|
59
|
+
offset: int = 0,
|
|
60
|
+
as_of: datetime | None = None,
|
|
61
|
+
) -> Any:
|
|
62
|
+
return await self._repo.list_entities(
|
|
63
|
+
self._session,
|
|
64
|
+
filters=filters,
|
|
65
|
+
order_by=order_by,
|
|
66
|
+
limit=limit,
|
|
67
|
+
offset=offset,
|
|
68
|
+
as_of=as_of,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@pytest.fixture
|
|
73
|
+
def test_user() -> UserContext:
|
|
74
|
+
"""User context used by correction service audit logging."""
|
|
75
|
+
return UserContext(
|
|
76
|
+
id="user-001",
|
|
77
|
+
email="user-001@example.com",
|
|
78
|
+
roles=["Employee"],
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@pytest.fixture
|
|
83
|
+
async def correction_stack(
|
|
84
|
+
db_session: AsyncSession,
|
|
85
|
+
clean_tables: MetaData,
|
|
86
|
+
) -> tuple[
|
|
87
|
+
GenericRepository[CorrectableInvoice],
|
|
88
|
+
object,
|
|
89
|
+
DatabaseAuditAdapter,
|
|
90
|
+
CorrectionService,
|
|
91
|
+
]:
|
|
92
|
+
"""Create repository/audit/correction service wired to real SQLite tables."""
|
|
93
|
+
mapper = SchemaMapper()
|
|
94
|
+
tables = mapper.create_versioned_tables(
|
|
95
|
+
CorrectableInvoice,
|
|
96
|
+
clean_tables,
|
|
97
|
+
{"compliance": {"versioning_enabled": True}},
|
|
98
|
+
)
|
|
99
|
+
invoice_table, history_table = tables[0], tables[1]
|
|
100
|
+
activity_table = mapper.create_table(ActivityLog, clean_tables)
|
|
101
|
+
|
|
102
|
+
conn = await db_session.connection()
|
|
103
|
+
await conn.run_sync(clean_tables.create_all)
|
|
104
|
+
|
|
105
|
+
invoice_repo = GenericRepository(CorrectableInvoice, invoice_table)
|
|
106
|
+
activity_repo = GenericRepository(ActivityLog, activity_table)
|
|
107
|
+
|
|
108
|
+
invoice_repo_adapter = _SessionBoundRepositoryAdapter(invoice_repo, db_session)
|
|
109
|
+
activity_repo_adapter = _SessionBoundRepositoryAdapter(activity_repo, db_session)
|
|
110
|
+
|
|
111
|
+
audit_adapter = DatabaseAuditAdapter(repository=activity_repo_adapter)
|
|
112
|
+
correction_service = CorrectionService(
|
|
113
|
+
repository=invoice_repo_adapter,
|
|
114
|
+
audit=audit_adapter,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
return invoice_repo, history_table, audit_adapter, correction_service
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def _create_submitted_invoice(
|
|
121
|
+
repo: GenericRepository[CorrectableInvoice],
|
|
122
|
+
session: AsyncSession,
|
|
123
|
+
name: str,
|
|
124
|
+
) -> CorrectableInvoice:
|
|
125
|
+
"""Persist a submitted invoice record for correction tests."""
|
|
126
|
+
invoice = CorrectableInvoice(
|
|
127
|
+
name=name,
|
|
128
|
+
customer="Acme Corp",
|
|
129
|
+
amount=100.0,
|
|
130
|
+
)
|
|
131
|
+
await repo.save(session, invoice)
|
|
132
|
+
|
|
133
|
+
invoice.docstatus = DocStatus.SUBMITTED
|
|
134
|
+
await repo.save(session, invoice)
|
|
135
|
+
|
|
136
|
+
return invoice
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@pytest.mark.asyncio
|
|
140
|
+
async def test_rfc_0008_correction_amend_flow(
|
|
141
|
+
correction_stack: tuple[
|
|
142
|
+
GenericRepository[CorrectableInvoice],
|
|
143
|
+
object,
|
|
144
|
+
DatabaseAuditAdapter,
|
|
145
|
+
CorrectionService,
|
|
146
|
+
],
|
|
147
|
+
db_session: AsyncSession,
|
|
148
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
149
|
+
test_user: UserContext,
|
|
150
|
+
) -> None:
|
|
151
|
+
"""amend() should cancel the original and create a new draft with new name."""
|
|
152
|
+
monkeypatch.setenv("FRAMEWORK_M_STRONGSYNC_VERSIONING", "true")
|
|
153
|
+
|
|
154
|
+
repo, _history_table, audit, correction_service = correction_stack
|
|
155
|
+
original = await _create_submitted_invoice(repo, db_session, "INV-100")
|
|
156
|
+
|
|
157
|
+
amended = await correction_service.amend(
|
|
158
|
+
doc=original,
|
|
159
|
+
reason="Typo fix",
|
|
160
|
+
user=test_user,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
persisted_original = await repo.get(db_session, original.id)
|
|
164
|
+
assert persisted_original is not None
|
|
165
|
+
assert persisted_original.docstatus == DocStatus.CANCELLED
|
|
166
|
+
|
|
167
|
+
assert amended.id != original.id
|
|
168
|
+
assert amended.name == "INV-100-1"
|
|
169
|
+
assert amended.docstatus == DocStatus.DRAFT
|
|
170
|
+
|
|
171
|
+
amended_lookup = await repo.get_by_name(db_session, "INV-100-1")
|
|
172
|
+
assert amended_lookup is not None
|
|
173
|
+
assert amended_lookup.docstatus == DocStatus.DRAFT
|
|
174
|
+
|
|
175
|
+
entries = await audit.query(
|
|
176
|
+
filters={"doctype": "CorrectableInvoice", "document_id": "INV-100"},
|
|
177
|
+
limit=100,
|
|
178
|
+
offset=0,
|
|
179
|
+
)
|
|
180
|
+
assert any(entry.action == "amend" for entry in entries)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@pytest.mark.asyncio
|
|
184
|
+
async def test_rfc_0008_correction_in_place_edit_flow(
|
|
185
|
+
correction_stack: tuple[
|
|
186
|
+
GenericRepository[CorrectableInvoice],
|
|
187
|
+
object,
|
|
188
|
+
DatabaseAuditAdapter,
|
|
189
|
+
CorrectionService,
|
|
190
|
+
],
|
|
191
|
+
db_session: AsyncSession,
|
|
192
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
193
|
+
test_user: UserContext,
|
|
194
|
+
) -> None:
|
|
195
|
+
"""in_place_edit() should keep identity, reset docstatus, and record audit/history."""
|
|
196
|
+
monkeypatch.setenv("FRAMEWORK_M_STRONGSYNC_VERSIONING", "true")
|
|
197
|
+
|
|
198
|
+
repo, history_table, audit, correction_service = correction_stack
|
|
199
|
+
original = await _create_submitted_invoice(repo, db_session, "INV-200")
|
|
200
|
+
|
|
201
|
+
edited = await correction_service.in_place_edit(
|
|
202
|
+
doc=original,
|
|
203
|
+
reason="Operational correction",
|
|
204
|
+
user=test_user,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
assert edited.id == original.id
|
|
208
|
+
assert edited.name == original.name
|
|
209
|
+
assert edited.docstatus == DocStatus.DRAFT
|
|
210
|
+
|
|
211
|
+
persisted = await repo.get(db_session, original.id)
|
|
212
|
+
assert persisted is not None
|
|
213
|
+
assert persisted.docstatus == DocStatus.DRAFT
|
|
214
|
+
|
|
215
|
+
history_count = await db_session.scalar(
|
|
216
|
+
select(func.count())
|
|
217
|
+
.select_from(history_table)
|
|
218
|
+
.where(history_table.c.id == original.id)
|
|
219
|
+
)
|
|
220
|
+
assert history_count is not None
|
|
221
|
+
assert history_count >= 3
|
|
222
|
+
|
|
223
|
+
entries = await audit.query(
|
|
224
|
+
filters={"doctype": "CorrectableInvoice", "document_id": "INV-200"},
|
|
225
|
+
limit=100,
|
|
226
|
+
offset=0,
|
|
227
|
+
)
|
|
228
|
+
assert any(entry.action == "in_place_edit" for entry in entries)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""RFC-0008 versioning integration tests (Phase 12.2)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from framework_m_core.domain.base_doctype import BaseDocType, Field
|
|
10
|
+
from framework_m_core.domain.mixins import VersionedMixin
|
|
11
|
+
from framework_m_core.exceptions import TemporalQueryNotSupportedError
|
|
12
|
+
from framework_m_standard.adapters.db.generic_repository import GenericRepository
|
|
13
|
+
from framework_m_standard.adapters.db.schema_mapper import SchemaMapper
|
|
14
|
+
from sqlalchemy import MetaData, func, select
|
|
15
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class VersionedInvoice(BaseDocType, VersionedMixin):
|
|
19
|
+
"""Versioned DocType for temporal integration checks."""
|
|
20
|
+
|
|
21
|
+
title: str = Field(description="Title")
|
|
22
|
+
total: float = Field(default=0.0, description="Amount")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture
|
|
26
|
+
async def versioned_repo(
|
|
27
|
+
db_session: AsyncSession,
|
|
28
|
+
clean_tables: MetaData,
|
|
29
|
+
) -> tuple[GenericRepository[VersionedInvoice], object]:
|
|
30
|
+
"""Create a versioned repository with provisioned history table."""
|
|
31
|
+
mapper = SchemaMapper()
|
|
32
|
+
tables = mapper.create_versioned_tables(
|
|
33
|
+
VersionedInvoice,
|
|
34
|
+
clean_tables,
|
|
35
|
+
{"compliance": {"versioning_enabled": True}},
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
main_table, history_table = tables[0], tables[1]
|
|
39
|
+
conn = await db_session.connection()
|
|
40
|
+
await conn.run_sync(clean_tables.create_all)
|
|
41
|
+
|
|
42
|
+
return GenericRepository(VersionedInvoice, main_table), history_table
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@pytest.mark.asyncio
|
|
46
|
+
async def test_rfc_0008_versioning_history_and_temporal_query(
|
|
47
|
+
versioned_repo: tuple[GenericRepository[VersionedInvoice], object],
|
|
48
|
+
db_session: AsyncSession,
|
|
49
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Versioned saves should create history and support as_of lookup."""
|
|
52
|
+
monkeypatch.setenv("FRAMEWORK_M_STRONGSYNC_VERSIONING", "true")
|
|
53
|
+
|
|
54
|
+
repo, history_table = versioned_repo
|
|
55
|
+
|
|
56
|
+
invoice = VersionedInvoice(name="VINV-001", title="v1", total=10.0)
|
|
57
|
+
await repo.save(db_session, invoice)
|
|
58
|
+
|
|
59
|
+
between = datetime.now(UTC)
|
|
60
|
+
await asyncio.sleep(0.01)
|
|
61
|
+
|
|
62
|
+
invoice.total = 20.0
|
|
63
|
+
await repo.save(db_session, invoice)
|
|
64
|
+
|
|
65
|
+
history_count = await db_session.scalar(
|
|
66
|
+
select(func.count())
|
|
67
|
+
.select_from(history_table)
|
|
68
|
+
.where(history_table.c.id == invoice.id)
|
|
69
|
+
)
|
|
70
|
+
assert history_count == 2
|
|
71
|
+
|
|
72
|
+
version_rows = await db_session.execute(
|
|
73
|
+
select(history_table)
|
|
74
|
+
.where(history_table.c.id == invoice.id)
|
|
75
|
+
.order_by(history_table.c._version_no.asc())
|
|
76
|
+
)
|
|
77
|
+
version_numbers = [row._mapping["_version_no"] for row in version_rows.fetchall()]
|
|
78
|
+
assert version_numbers == [1, 2]
|
|
79
|
+
|
|
80
|
+
as_of_doc = await repo.get(db_session, invoice.id, as_of=between)
|
|
81
|
+
assert as_of_doc is not None
|
|
82
|
+
assert as_of_doc.total == pytest.approx(10.0)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@pytest.mark.asyncio
|
|
86
|
+
async def test_rfc_0008_versioning_disabled_rejects_temporal_query(
|
|
87
|
+
versioned_repo: tuple[GenericRepository[VersionedInvoice], object],
|
|
88
|
+
db_session: AsyncSession,
|
|
89
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
90
|
+
) -> None:
|
|
91
|
+
"""as_of must fail fast when versioning is disabled."""
|
|
92
|
+
repo, _history_table = versioned_repo
|
|
93
|
+
|
|
94
|
+
invoice = VersionedInvoice(name="VINV-002", title="v1", total=33.0)
|
|
95
|
+
|
|
96
|
+
monkeypatch.setenv("FRAMEWORK_M_STRONGSYNC_VERSIONING", "true")
|
|
97
|
+
await repo.save(db_session, invoice)
|
|
98
|
+
|
|
99
|
+
monkeypatch.setenv("FRAMEWORK_M_STRONGSYNC_VERSIONING", "false")
|
|
100
|
+
with pytest.raises(TemporalQueryNotSupportedError):
|
|
101
|
+
await repo.get(db_session, invoice.id, as_of=datetime.now(UTC))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|