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.
- {framework_m-0.7.2 → framework_m-0.8.0}/CHANGELOG.md +12 -1
- {framework_m-0.7.2 → framework_m-0.8.0}/PKG-INFO +3 -3
- {framework_m-0.7.2 → framework_m-0.8.0}/pyproject.toml +7 -4
- framework_m-0.8.0/src/framework_m/core/services/__init__.py +5 -0
- framework_m-0.8.0/src/framework_m/core/services/correction.py +179 -0
- {framework_m-0.7.2 → framework_m-0.8.0}/tests/conftest.py +1 -1
- framework_m-0.8.0/tests/core/test_correction_service.py +425 -0
- {framework_m-0.7.2 → framework_m-0.8.0}/tests/integration/test_migration_flow.py +2 -2
- framework_m-0.8.0/tests/integration/test_rfc_0008_audit.py +170 -0
- framework_m-0.8.0/tests/integration/test_rfc_0008_correction.py +228 -0
- framework_m-0.8.0/tests/integration/test_rfc_0008_versioning.py +101 -0
- {framework_m-0.7.2 → framework_m-0.8.0}/tests/integration/test_workflow_lifecycle.py +31 -19
- framework_m-0.7.2/docs/ports.md +0 -414
- {framework_m-0.7.2 → framework_m-0.8.0}/.gitignore +0 -0
- {framework_m-0.7.2 → framework_m-0.8.0}/LICENSE +0 -0
- {framework_m-0.7.2 → framework_m-0.8.0}/README.md +0 -0
- {framework_m-0.7.2 → framework_m-0.8.0}/alembic/README +0 -0
- {framework_m-0.7.2 → framework_m-0.8.0}/alembic/env.py +0 -0
- {framework_m-0.7.2 → framework_m-0.8.0}/alembic/script.py.mako +0 -0
- {framework_m-0.7.2 → framework_m-0.8.0}/alembic.ini +0 -0
- {framework_m-0.7.2 → framework_m-0.8.0}/src/framework_m/__init__.py +0 -0
- {framework_m-0.7.2 → framework_m-0.8.0}/src/framework_m/cli/__init__.py +0 -0
- {framework_m-0.7.2 → framework_m-0.8.0}/src/framework_m/cli/main.py +0 -0
- {framework_m-0.7.2 → framework_m-0.8.0}/src/framework_m/py.typed +0 -0
- {framework_m-0.7.2 → framework_m-0.8.0}/tests/adapters/auth/test_oauth_adapter.py +0 -0
- {framework_m-0.7.2 → framework_m-0.8.0}/tests/adapters/read_model/test_projector.py +0 -0
- {framework_m-0.7.2 → framework_m-0.8.0}/tests/architecture/fixtures/ci_dummy_index.html +0 -0
- {framework_m-0.7.2 → framework_m-0.8.0}/tests/architecture/test_frontend_bootstrap.py +0 -0
- {framework_m-0.7.2 → framework_m-0.8.0}/tests/architecture/test_package_separation.py +0 -0
- {framework_m-0.7.2 → framework_m-0.8.0}/tests/core/interfaces/test_read_model.py +0 -0
- {framework_m-0.7.2 → framework_m-0.8.0}/tests/core/interfaces/test_report_engine.py +0 -0
- {framework_m-0.7.2 → framework_m-0.8.0}/tests/helpers/__init__.py +0 -0
- {framework_m-0.7.2 → framework_m-0.8.0}/tests/integration/__init__.py +0 -0
- {framework_m-0.7.2 → framework_m-0.8.0}/tests/integration/test_api_endpoints.py +0 -0
- {framework_m-0.7.2 → framework_m-0.8.0}/tests/integration/test_app_ports.py +0 -0
- {framework_m-0.7.2 → framework_m-0.8.0}/tests/integration/test_child_table_integration.py +0 -0
- {framework_m-0.7.2 → framework_m-0.8.0}/tests/integration/test_crud_flow.py +0 -0
- {framework_m-0.7.2 → framework_m-0.8.0}/tests/integration/test_jobs_events_webhooks.py +0 -0
- {framework_m-0.7.2 → framework_m-0.8.0}/tests/integration/test_override_migration.py +0 -0
- {framework_m-0.7.2 → framework_m-0.8.0}/tests/integration/test_postgres_flow.py +0 -0
- {framework_m-0.7.2 → framework_m-0.8.0}/tests/integration/test_real_http_client.py +0 -0
- {framework_m-0.7.2 → framework_m-0.8.0}/tests/test_meta.py +0 -0
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
|
|
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.
|
|
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.
|
|
22
|
-
Requires-Dist: framework-m-standard>=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.
|
|
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.
|
|
25
|
-
"framework-m-standard>=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,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
|
+
)
|