framework-m 0.7.2__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.
Files changed (42) hide show
  1. {framework_m-0.7.2 → framework_m-0.7.3}/CHANGELOG.md +1 -1
  2. {framework_m-0.7.2 → framework_m-0.7.3}/PKG-INFO +3 -3
  3. {framework_m-0.7.2 → framework_m-0.7.3}/pyproject.toml +3 -3
  4. framework_m-0.7.3/src/framework_m/core/services/__init__.py +5 -0
  5. framework_m-0.7.3/src/framework_m/core/services/correction.py +179 -0
  6. framework_m-0.7.3/tests/core/test_correction_service.py +328 -0
  7. framework_m-0.7.3/tests/integration/test_rfc_0008_audit.py +170 -0
  8. framework_m-0.7.3/tests/integration/test_rfc_0008_correction.py +228 -0
  9. framework_m-0.7.3/tests/integration/test_rfc_0008_versioning.py +101 -0
  10. {framework_m-0.7.2 → framework_m-0.7.3}/.gitignore +0 -0
  11. {framework_m-0.7.2 → framework_m-0.7.3}/LICENSE +0 -0
  12. {framework_m-0.7.2 → framework_m-0.7.3}/README.md +0 -0
  13. {framework_m-0.7.2 → framework_m-0.7.3}/alembic/README +0 -0
  14. {framework_m-0.7.2 → framework_m-0.7.3}/alembic/env.py +0 -0
  15. {framework_m-0.7.2 → framework_m-0.7.3}/alembic/script.py.mako +0 -0
  16. {framework_m-0.7.2 → framework_m-0.7.3}/alembic.ini +0 -0
  17. {framework_m-0.7.2 → framework_m-0.7.3}/docs/ports.md +0 -0
  18. {framework_m-0.7.2 → framework_m-0.7.3}/src/framework_m/__init__.py +0 -0
  19. {framework_m-0.7.2 → framework_m-0.7.3}/src/framework_m/cli/__init__.py +0 -0
  20. {framework_m-0.7.2 → framework_m-0.7.3}/src/framework_m/cli/main.py +0 -0
  21. {framework_m-0.7.2 → framework_m-0.7.3}/src/framework_m/py.typed +0 -0
  22. {framework_m-0.7.2 → framework_m-0.7.3}/tests/adapters/auth/test_oauth_adapter.py +0 -0
  23. {framework_m-0.7.2 → framework_m-0.7.3}/tests/adapters/read_model/test_projector.py +0 -0
  24. {framework_m-0.7.2 → framework_m-0.7.3}/tests/architecture/fixtures/ci_dummy_index.html +0 -0
  25. {framework_m-0.7.2 → framework_m-0.7.3}/tests/architecture/test_frontend_bootstrap.py +0 -0
  26. {framework_m-0.7.2 → framework_m-0.7.3}/tests/architecture/test_package_separation.py +0 -0
  27. {framework_m-0.7.2 → framework_m-0.7.3}/tests/conftest.py +0 -0
  28. {framework_m-0.7.2 → framework_m-0.7.3}/tests/core/interfaces/test_read_model.py +0 -0
  29. {framework_m-0.7.2 → framework_m-0.7.3}/tests/core/interfaces/test_report_engine.py +0 -0
  30. {framework_m-0.7.2 → framework_m-0.7.3}/tests/helpers/__init__.py +0 -0
  31. {framework_m-0.7.2 → framework_m-0.7.3}/tests/integration/__init__.py +0 -0
  32. {framework_m-0.7.2 → framework_m-0.7.3}/tests/integration/test_api_endpoints.py +0 -0
  33. {framework_m-0.7.2 → framework_m-0.7.3}/tests/integration/test_app_ports.py +0 -0
  34. {framework_m-0.7.2 → framework_m-0.7.3}/tests/integration/test_child_table_integration.py +0 -0
  35. {framework_m-0.7.2 → framework_m-0.7.3}/tests/integration/test_crud_flow.py +0 -0
  36. {framework_m-0.7.2 → framework_m-0.7.3}/tests/integration/test_jobs_events_webhooks.py +0 -0
  37. {framework_m-0.7.2 → framework_m-0.7.3}/tests/integration/test_migration_flow.py +0 -0
  38. {framework_m-0.7.2 → framework_m-0.7.3}/tests/integration/test_override_migration.py +0 -0
  39. {framework_m-0.7.2 → framework_m-0.7.3}/tests/integration/test_postgres_flow.py +0 -0
  40. {framework_m-0.7.2 → framework_m-0.7.3}/tests/integration/test_real_http_client.py +0 -0
  41. {framework_m-0.7.2 → framework_m-0.7.3}/tests/integration/test_workflow_lifecycle.py +0 -0
  42. {framework_m-0.7.2 → framework_m-0.7.3}/tests/test_meta.py +0 -0
@@ -1,4 +1,4 @@
1
- **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
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.2
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.12.0
22
- Requires-Dist: framework-m-standard>=0.9.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.2"
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.12.0",
25
- "framework-m-standard>=0.9.0",
24
+ "framework-m-core>=0.13.0",
25
+ "framework-m-standard>=0.10.0",
26
26
  ]
27
27
 
28
28
  [tool.uv.sources]
@@ -0,0 +1,5 @@
1
+ """Service-layer components for Framework M."""
2
+
3
+ from framework_m.core.services.correction import CorrectionService
4
+
5
+ __all__ = ["CorrectionService"]
@@ -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