framework-m 0.7.2__tar.gz → 0.8.0__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.8.0}/CHANGELOG.md +12 -1
  2. {framework_m-0.7.2 → framework_m-0.8.0}/PKG-INFO +3 -3
  3. {framework_m-0.7.2 → framework_m-0.8.0}/pyproject.toml +7 -4
  4. framework_m-0.8.0/src/framework_m/core/services/__init__.py +5 -0
  5. framework_m-0.8.0/src/framework_m/core/services/correction.py +179 -0
  6. {framework_m-0.7.2 → framework_m-0.8.0}/tests/conftest.py +1 -1
  7. framework_m-0.8.0/tests/core/test_correction_service.py +425 -0
  8. {framework_m-0.7.2 → framework_m-0.8.0}/tests/integration/test_migration_flow.py +2 -2
  9. framework_m-0.8.0/tests/integration/test_rfc_0008_audit.py +170 -0
  10. framework_m-0.8.0/tests/integration/test_rfc_0008_correction.py +228 -0
  11. framework_m-0.8.0/tests/integration/test_rfc_0008_versioning.py +101 -0
  12. {framework_m-0.7.2 → framework_m-0.8.0}/tests/integration/test_workflow_lifecycle.py +31 -19
  13. framework_m-0.7.2/docs/ports.md +0 -414
  14. {framework_m-0.7.2 → framework_m-0.8.0}/.gitignore +0 -0
  15. {framework_m-0.7.2 → framework_m-0.8.0}/LICENSE +0 -0
  16. {framework_m-0.7.2 → framework_m-0.8.0}/README.md +0 -0
  17. {framework_m-0.7.2 → framework_m-0.8.0}/alembic/README +0 -0
  18. {framework_m-0.7.2 → framework_m-0.8.0}/alembic/env.py +0 -0
  19. {framework_m-0.7.2 → framework_m-0.8.0}/alembic/script.py.mako +0 -0
  20. {framework_m-0.7.2 → framework_m-0.8.0}/alembic.ini +0 -0
  21. {framework_m-0.7.2 → framework_m-0.8.0}/src/framework_m/__init__.py +0 -0
  22. {framework_m-0.7.2 → framework_m-0.8.0}/src/framework_m/cli/__init__.py +0 -0
  23. {framework_m-0.7.2 → framework_m-0.8.0}/src/framework_m/cli/main.py +0 -0
  24. {framework_m-0.7.2 → framework_m-0.8.0}/src/framework_m/py.typed +0 -0
  25. {framework_m-0.7.2 → framework_m-0.8.0}/tests/adapters/auth/test_oauth_adapter.py +0 -0
  26. {framework_m-0.7.2 → framework_m-0.8.0}/tests/adapters/read_model/test_projector.py +0 -0
  27. {framework_m-0.7.2 → framework_m-0.8.0}/tests/architecture/fixtures/ci_dummy_index.html +0 -0
  28. {framework_m-0.7.2 → framework_m-0.8.0}/tests/architecture/test_frontend_bootstrap.py +0 -0
  29. {framework_m-0.7.2 → framework_m-0.8.0}/tests/architecture/test_package_separation.py +0 -0
  30. {framework_m-0.7.2 → framework_m-0.8.0}/tests/core/interfaces/test_read_model.py +0 -0
  31. {framework_m-0.7.2 → framework_m-0.8.0}/tests/core/interfaces/test_report_engine.py +0 -0
  32. {framework_m-0.7.2 → framework_m-0.8.0}/tests/helpers/__init__.py +0 -0
  33. {framework_m-0.7.2 → framework_m-0.8.0}/tests/integration/__init__.py +0 -0
  34. {framework_m-0.7.2 → framework_m-0.8.0}/tests/integration/test_api_endpoints.py +0 -0
  35. {framework_m-0.7.2 → framework_m-0.8.0}/tests/integration/test_app_ports.py +0 -0
  36. {framework_m-0.7.2 → framework_m-0.8.0}/tests/integration/test_child_table_integration.py +0 -0
  37. {framework_m-0.7.2 → framework_m-0.8.0}/tests/integration/test_crud_flow.py +0 -0
  38. {framework_m-0.7.2 → framework_m-0.8.0}/tests/integration/test_jobs_events_webhooks.py +0 -0
  39. {framework_m-0.7.2 → framework_m-0.8.0}/tests/integration/test_override_migration.py +0 -0
  40. {framework_m-0.7.2 → framework_m-0.8.0}/tests/integration/test_postgres_flow.py +0 -0
  41. {framework_m-0.7.2 → framework_m-0.8.0}/tests/integration/test_real_http_client.py +0 -0
  42. {framework_m-0.7.2 → framework_m-0.8.0}/tests/test_meta.py +0 -0
@@ -1,4 +1,15 @@
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.8.0
2
+
3
+ ### Features
4
+
5
+ - pagination, tabbed layout and dynamic fields in desk (86b5e66)
6
+
7
+ ### Bug Fixes
8
+
9
+ - clarify use aiosqlite for db url in test (981c6c4)
10
+ - typecheck error (1e06770)
11
+
12
+ **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
13
 
3
14
  ### Features
4
15
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: framework-m
3
- Version: 0.7.2
3
+ Version: 0.8.0
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.14.0
22
+ Requires-Dist: framework-m-standard>=0.11.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.8.0"
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.14.0",
25
+ "framework-m-standard>=0.11.0",
26
26
  ]
27
27
 
28
28
  [tool.uv.sources]
@@ -83,8 +83,11 @@ filterwarnings = [
83
83
  ]
84
84
 
85
85
  [tool.coverage.run]
86
- source = ["src/framework_m"]
87
86
  branch = true
87
+ omit = [
88
+ "*/tests/*",
89
+ "*conftest.py",
90
+ ]
88
91
 
89
92
  [tool.coverage.report]
90
93
  exclude_lines = [
@@ -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"]
@@ -101,7 +101,7 @@ def configured_container() -> Container:
101
101
  container = Container()
102
102
  container.config.from_dict(
103
103
  {
104
- "database_url": "sqlite:///:memory:",
104
+ "database_url": "sqlite+aiosqlite:///:memory:",
105
105
  "debug": True,
106
106
  "app_name": "TestApp",
107
107
  }
@@ -0,0 +1,425 @@
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, patch
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
+ from pydantic import ConfigDict
15
+
16
+ from framework_m.core.services.correction import CorrectionService
17
+
18
+
19
+ class Invoice(BaseDocType, SubmittableMixin):
20
+ """Test invoice doctype for correction-flow tests."""
21
+
22
+ customer: str = Field(description="Customer")
23
+ amount: float = Field(default=0.0, description="Amount")
24
+
25
+
26
+ class VersionedInvoice(Invoice, VersionedMixin):
27
+ """Versioned invoice doctype for in-place correction tests."""
28
+
29
+
30
+ class PlainDoc(BaseDocType):
31
+ """Plain doc without SubmittableMixin for edge cases."""
32
+
33
+ title: str = Field(default="Test")
34
+ model_config = ConfigDict(extra="ignore")
35
+
36
+
37
+ class PlainVersionedDoc(PlainDoc, VersionedMixin):
38
+ """Versioned plain doc for in-place edge cases."""
39
+
40
+
41
+ @pytest.fixture
42
+ def mock_repository() -> AsyncMock:
43
+ """Repository mock implementing save semantics."""
44
+
45
+ def _save(entity: BaseDocType, version: int | None = None) -> BaseDocType:
46
+ _ = version
47
+ return entity
48
+
49
+ repo = AsyncMock(spec=RepositoryProtocol[BaseDocType])
50
+ repo.save = AsyncMock(side_effect=_save)
51
+ return repo
52
+
53
+
54
+ @pytest.fixture
55
+ def mock_audit_adapter() -> AsyncMock:
56
+ """Audit adapter mock for correction-flow logging."""
57
+ audit = AsyncMock(spec=AuditLogProtocol)
58
+ audit.log = AsyncMock(return_value="audit-entry-001")
59
+ return audit
60
+
61
+
62
+ @pytest.fixture
63
+ def service(
64
+ mock_repository: AsyncMock,
65
+ mock_audit_adapter: AsyncMock,
66
+ ) -> CorrectionService:
67
+ """CorrectionService under test."""
68
+ return CorrectionService(
69
+ repository=mock_repository,
70
+ audit=mock_audit_adapter,
71
+ )
72
+
73
+
74
+ @pytest.fixture
75
+ def user_context() -> UserContext:
76
+ """User context fixture for audit metadata."""
77
+ return UserContext(
78
+ id="user-001",
79
+ email="user-001@example.com",
80
+ name="Test User",
81
+ roles=["Employee"],
82
+ )
83
+
84
+
85
+ @pytest.fixture
86
+ def submitted_invoice() -> Invoice:
87
+ """Submitted invoice fixture for amend/restore tests."""
88
+ return Invoice(
89
+ name="INV-001",
90
+ customer="Acme",
91
+ amount=100.0,
92
+ docstatus=DocStatus.SUBMITTED,
93
+ )
94
+
95
+
96
+ @pytest.fixture
97
+ def versioned_submitted_invoice() -> VersionedInvoice:
98
+ """Submitted versioned invoice fixture for in-place edit tests."""
99
+ return VersionedInvoice(
100
+ name="INV-001",
101
+ customer="Acme",
102
+ amount=100.0,
103
+ docstatus=DocStatus.SUBMITTED,
104
+ )
105
+
106
+
107
+ class TestCorrectionServiceAmend:
108
+ """Amend flow tests."""
109
+
110
+ @pytest.mark.asyncio
111
+ async def test_amend_cancels_original_and_creates_new_draft(
112
+ self,
113
+ service: CorrectionService,
114
+ mock_repository: AsyncMock,
115
+ mock_audit_adapter: AsyncMock,
116
+ submitted_invoice: Invoice,
117
+ user_context: UserContext,
118
+ ) -> None:
119
+ result = await service.amend(
120
+ doc=submitted_invoice,
121
+ reason="Typo fix",
122
+ user=user_context,
123
+ )
124
+
125
+ assert result.docstatus == DocStatus.DRAFT
126
+ assert result.name == "INV-001-1"
127
+ assert result.id != submitted_invoice.id
128
+
129
+ assert mock_repository.save.await_count == 2
130
+
131
+ cancelled_doc = mock_repository.save.await_args_list[0].args[0]
132
+ amended_doc = mock_repository.save.await_args_list[1].args[0]
133
+
134
+ assert cancelled_doc.docstatus == DocStatus.CANCELLED
135
+ assert cancelled_doc.name == "INV-001"
136
+
137
+ assert amended_doc.docstatus == DocStatus.DRAFT
138
+ assert amended_doc.name == "INV-001-1"
139
+
140
+ mock_audit_adapter.log.assert_awaited_once_with(
141
+ user_id=user_context.id,
142
+ action="amend",
143
+ doctype="Invoice",
144
+ document_id="INV-001",
145
+ changes=None,
146
+ metadata={"reason": "Typo fix"},
147
+ )
148
+
149
+ def test_amend_name_increments_correctly(self, service: CorrectionService) -> None:
150
+ assert service._next_amended_name("INV-001") == "INV-001-1"
151
+ assert service._next_amended_name("INV-001-1") == "INV-001-2"
152
+
153
+ @pytest.mark.asyncio
154
+ async def test_amend_raises_if_not_submitted(
155
+ self,
156
+ service: CorrectionService,
157
+ mock_repository: AsyncMock,
158
+ submitted_invoice: Invoice,
159
+ user_context: UserContext,
160
+ ) -> None:
161
+ draft = submitted_invoice.model_copy(deep=True)
162
+ draft.docstatus = DocStatus.DRAFT
163
+
164
+ with pytest.raises(ValueError):
165
+ await service.amend(
166
+ doc=draft,
167
+ reason="Need correction",
168
+ user=user_context,
169
+ )
170
+
171
+ mock_repository.save.assert_not_awaited()
172
+
173
+ @pytest.mark.asyncio
174
+ async def test_amend_audit_failure_propagates(
175
+ self,
176
+ service: CorrectionService,
177
+ mock_audit_adapter: AsyncMock,
178
+ submitted_invoice: Invoice,
179
+ user_context: UserContext,
180
+ ) -> None:
181
+ mock_audit_adapter.log.side_effect = RuntimeError("audit write failed")
182
+
183
+ with pytest.raises(RuntimeError, match="audit write failed"):
184
+ await service.amend(
185
+ doc=submitted_invoice,
186
+ reason="Typo fix",
187
+ user=user_context,
188
+ )
189
+
190
+
191
+ class TestCorrectionServiceRestore:
192
+ """Restore flow tests."""
193
+
194
+ @pytest.mark.asyncio
195
+ async def test_restore_cancels_original_and_creates_snapshot_doc(
196
+ self,
197
+ service: CorrectionService,
198
+ mock_repository: AsyncMock,
199
+ mock_audit_adapter: AsyncMock,
200
+ submitted_invoice: Invoice,
201
+ user_context: UserContext,
202
+ ) -> None:
203
+ snapshot_data: dict[str, Any] = {
204
+ "customer": "Acme Restored",
205
+ "amount": 120.0,
206
+ }
207
+
208
+ result = await service.restore(
209
+ doc=submitted_invoice,
210
+ snapshot_data=snapshot_data,
211
+ reason="Rollback to prior snapshot",
212
+ user=user_context,
213
+ )
214
+
215
+ assert result.name == "INV-001-1"
216
+ assert result.docstatus == DocStatus.SUBMITTED
217
+
218
+ assert mock_repository.save.await_count == 2
219
+
220
+ cancelled_doc = mock_repository.save.await_args_list[0].args[0]
221
+ restored_doc = mock_repository.save.await_args_list[1].args[0]
222
+
223
+ assert cancelled_doc.docstatus == DocStatus.CANCELLED
224
+ assert restored_doc.docstatus == DocStatus.SUBMITTED
225
+ assert restored_doc.customer == "Acme Restored"
226
+
227
+ mock_audit_adapter.log.assert_awaited_once_with(
228
+ user_id=user_context.id,
229
+ action="restore",
230
+ doctype="Invoice",
231
+ document_id="INV-001",
232
+ changes=None,
233
+ metadata={"reason": "Rollback to prior snapshot"},
234
+ )
235
+
236
+ @pytest.mark.asyncio
237
+ async def test_restore_raises_if_not_submitted(
238
+ self,
239
+ service: CorrectionService,
240
+ submitted_invoice: Invoice,
241
+ user_context: UserContext,
242
+ ) -> None:
243
+ draft = submitted_invoice.model_copy(deep=True)
244
+ draft.docstatus = DocStatus.DRAFT
245
+
246
+ with pytest.raises(ValueError):
247
+ await service.restore(
248
+ doc=draft,
249
+ snapshot_data={"customer": "Acme"},
250
+ reason="Invalid state",
251
+ user=user_context,
252
+ )
253
+
254
+
255
+ class TestCorrectionServiceEdgeCases:
256
+ """Coverage tests for defensive checks and edge cases."""
257
+
258
+ def test_next_amended_name_unmatchable_pattern(
259
+ self, service: CorrectionService
260
+ ) -> None:
261
+ """Cover branch where regex match is None."""
262
+ assert service._next_amended_name("INV-001-A") == "INV-001-A-1"
263
+
264
+ def test_ensure_submitted_without_docstatus(
265
+ self, service: CorrectionService
266
+ ) -> None:
267
+ """Cover branch where doc has no docstatus attribute."""
268
+ doc = PlainDoc(name="PLAIN-1")
269
+ with pytest.raises(
270
+ ValueError, match="Only submitted documents can be corrected"
271
+ ):
272
+ service._ensure_submitted(doc)
273
+
274
+ def test_clone_with_updates_keep_id_and_no_docstatus(
275
+ self, service: CorrectionService
276
+ ) -> None:
277
+ """Cover branches for drop_id=False and missing docstatus field."""
278
+ doc = PlainDoc(name="PLAIN-1")
279
+ cloned = service._clone_with_updates(
280
+ source=doc,
281
+ new_name="PLAIN-2",
282
+ docstatus=DocStatus.DRAFT,
283
+ drop_id=False,
284
+ )
285
+ assert cloned.id == doc.id
286
+ assert cloned.name == "PLAIN-2"
287
+ assert not hasattr(cloned, "docstatus")
288
+
289
+ def test_document_ref_without_name(self, service: CorrectionService) -> None:
290
+ """Cover branch where doc.name is None/empty."""
291
+ doc = PlainDoc(name="")
292
+ assert service._document_ref(doc) == str(doc.id)
293
+
294
+ @pytest.mark.asyncio
295
+ @patch.object(CorrectionService, "_ensure_submitted")
296
+ async def test_amend_without_copied_docstatus(
297
+ self,
298
+ mock_ensure: Any,
299
+ service: CorrectionService,
300
+ mock_repository: AsyncMock,
301
+ user_context: UserContext,
302
+ ) -> None:
303
+ """Cover hasattr(cancelled, 'docstatus') == False branch."""
304
+ doc = PlainDoc(name="PLAIN-1")
305
+ await service.amend(doc, "Test", user_context)
306
+ cancelled_doc = mock_repository.save.await_args_list[0].args[0]
307
+ assert not hasattr(cancelled_doc, "docstatus")
308
+
309
+ @pytest.mark.asyncio
310
+ @patch.object(CorrectionService, "_ensure_submitted")
311
+ async def test_restore_without_copied_docstatus(
312
+ self,
313
+ mock_ensure: Any,
314
+ service: CorrectionService,
315
+ mock_repository: AsyncMock,
316
+ user_context: UserContext,
317
+ ) -> None:
318
+ """Cover hasattr(cancelled, 'docstatus') == False branch."""
319
+ doc = PlainDoc(name="PLAIN-1")
320
+ await service.restore(doc, {"title": "Restored"}, "Test", user_context)
321
+ cancelled_doc = mock_repository.save.await_args_list[0].args[0]
322
+ assert not hasattr(cancelled_doc, "docstatus")
323
+
324
+ @pytest.mark.asyncio
325
+ @patch.object(CorrectionService, "_ensure_submitted")
326
+ async def test_in_place_edit_without_copied_docstatus(
327
+ self,
328
+ mock_ensure: Any,
329
+ service: CorrectionService,
330
+ mock_repository: AsyncMock,
331
+ user_context: UserContext,
332
+ ) -> None:
333
+ """Cover hasattr(editable, 'docstatus') == False branch."""
334
+ doc = PlainVersionedDoc(name="PLAIN-1")
335
+ await service.in_place_edit(doc, "Test", user_context)
336
+ editable_doc = mock_repository.save.await_args_list[1].args[0]
337
+ assert not hasattr(editable_doc, "docstatus")
338
+
339
+ @pytest.mark.asyncio
340
+ async def test_restore_raises_if_snapshot_empty(
341
+ self,
342
+ service: CorrectionService,
343
+ submitted_invoice: Invoice,
344
+ user_context: UserContext,
345
+ ) -> None:
346
+ with pytest.raises(ValueError, match="snapshot_data cannot be empty"):
347
+ await service.restore(
348
+ doc=submitted_invoice,
349
+ snapshot_data={},
350
+ reason="Missing snapshot",
351
+ user=user_context,
352
+ )
353
+
354
+
355
+ class TestCorrectionServiceInPlaceEdit:
356
+ """In-place edit flow tests."""
357
+
358
+ @pytest.mark.asyncio
359
+ async def test_in_place_edit_copies_to_history_and_resets_to_draft(
360
+ self,
361
+ service: CorrectionService,
362
+ mock_repository: AsyncMock,
363
+ mock_audit_adapter: AsyncMock,
364
+ versioned_submitted_invoice: VersionedInvoice,
365
+ user_context: UserContext,
366
+ ) -> None:
367
+ result = await service.in_place_edit(
368
+ doc=versioned_submitted_invoice,
369
+ reason="Operational correction",
370
+ user=user_context,
371
+ )
372
+
373
+ assert result.id == versioned_submitted_invoice.id
374
+ assert result.name == versioned_submitted_invoice.name
375
+ assert result.docstatus == DocStatus.DRAFT
376
+
377
+ assert mock_repository.save.await_count == 2
378
+
379
+ history_snapshot_doc = mock_repository.save.await_args_list[0].args[0]
380
+ editable_doc = mock_repository.save.await_args_list[1].args[0]
381
+
382
+ assert history_snapshot_doc.docstatus == DocStatus.SUBMITTED
383
+ assert editable_doc.docstatus == DocStatus.DRAFT
384
+ assert editable_doc.id == versioned_submitted_invoice.id
385
+ assert editable_doc.name == versioned_submitted_invoice.name
386
+
387
+ mock_audit_adapter.log.assert_awaited_once_with(
388
+ user_id=user_context.id,
389
+ action="in_place_edit",
390
+ doctype="VersionedInvoice",
391
+ document_id="INV-001",
392
+ changes=None,
393
+ metadata={"reason": "Operational correction"},
394
+ )
395
+
396
+ @pytest.mark.asyncio
397
+ async def test_in_place_edit_raises_if_not_versioned(
398
+ self,
399
+ service: CorrectionService,
400
+ submitted_invoice: Invoice,
401
+ user_context: UserContext,
402
+ ) -> None:
403
+ with pytest.raises(ValueError, match="in_place_edit requires VersionedMixin"):
404
+ await service.in_place_edit(
405
+ doc=submitted_invoice,
406
+ reason="Invalid flow",
407
+ user=user_context,
408
+ )
409
+
410
+ @pytest.mark.asyncio
411
+ async def test_in_place_edit_raises_if_not_submitted(
412
+ self,
413
+ service: CorrectionService,
414
+ versioned_submitted_invoice: VersionedInvoice,
415
+ user_context: UserContext,
416
+ ) -> None:
417
+ draft = versioned_submitted_invoice.model_copy(deep=True)
418
+ draft.docstatus = DocStatus.DRAFT
419
+
420
+ with pytest.raises(ValueError):
421
+ await service.in_place_edit(
422
+ doc=draft,
423
+ reason="Invalid state",
424
+ user=user_context,
425
+ )