julee 0.1.0__py3-none-any.whl

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 (161) hide show
  1. julee/__init__.py +3 -0
  2. julee/api/__init__.py +20 -0
  3. julee/api/app.py +180 -0
  4. julee/api/dependencies.py +257 -0
  5. julee/api/requests.py +175 -0
  6. julee/api/responses.py +43 -0
  7. julee/api/routers/__init__.py +43 -0
  8. julee/api/routers/assembly_specifications.py +212 -0
  9. julee/api/routers/documents.py +182 -0
  10. julee/api/routers/knowledge_service_configs.py +79 -0
  11. julee/api/routers/knowledge_service_queries.py +293 -0
  12. julee/api/routers/system.py +137 -0
  13. julee/api/routers/workflows.py +234 -0
  14. julee/api/services/__init__.py +20 -0
  15. julee/api/services/system_initialization.py +214 -0
  16. julee/api/tests/__init__.py +14 -0
  17. julee/api/tests/routers/__init__.py +17 -0
  18. julee/api/tests/routers/test_assembly_specifications.py +749 -0
  19. julee/api/tests/routers/test_documents.py +301 -0
  20. julee/api/tests/routers/test_knowledge_service_configs.py +234 -0
  21. julee/api/tests/routers/test_knowledge_service_queries.py +738 -0
  22. julee/api/tests/routers/test_system.py +179 -0
  23. julee/api/tests/routers/test_workflows.py +393 -0
  24. julee/api/tests/test_app.py +285 -0
  25. julee/api/tests/test_dependencies.py +245 -0
  26. julee/api/tests/test_requests.py +250 -0
  27. julee/domain/__init__.py +22 -0
  28. julee/domain/models/__init__.py +49 -0
  29. julee/domain/models/assembly/__init__.py +17 -0
  30. julee/domain/models/assembly/assembly.py +103 -0
  31. julee/domain/models/assembly/tests/__init__.py +0 -0
  32. julee/domain/models/assembly/tests/factories.py +37 -0
  33. julee/domain/models/assembly/tests/test_assembly.py +430 -0
  34. julee/domain/models/assembly_specification/__init__.py +24 -0
  35. julee/domain/models/assembly_specification/assembly_specification.py +172 -0
  36. julee/domain/models/assembly_specification/knowledge_service_query.py +123 -0
  37. julee/domain/models/assembly_specification/tests/__init__.py +0 -0
  38. julee/domain/models/assembly_specification/tests/factories.py +78 -0
  39. julee/domain/models/assembly_specification/tests/test_assembly_specification.py +490 -0
  40. julee/domain/models/assembly_specification/tests/test_knowledge_service_query.py +310 -0
  41. julee/domain/models/custom_fields/__init__.py +0 -0
  42. julee/domain/models/custom_fields/content_stream.py +68 -0
  43. julee/domain/models/custom_fields/tests/__init__.py +0 -0
  44. julee/domain/models/custom_fields/tests/test_custom_fields.py +53 -0
  45. julee/domain/models/document/__init__.py +17 -0
  46. julee/domain/models/document/document.py +150 -0
  47. julee/domain/models/document/tests/__init__.py +0 -0
  48. julee/domain/models/document/tests/factories.py +76 -0
  49. julee/domain/models/document/tests/test_document.py +297 -0
  50. julee/domain/models/knowledge_service_config/__init__.py +17 -0
  51. julee/domain/models/knowledge_service_config/knowledge_service_config.py +86 -0
  52. julee/domain/models/policy/__init__.py +15 -0
  53. julee/domain/models/policy/document_policy_validation.py +220 -0
  54. julee/domain/models/policy/policy.py +203 -0
  55. julee/domain/models/policy/tests/__init__.py +0 -0
  56. julee/domain/models/policy/tests/factories.py +47 -0
  57. julee/domain/models/policy/tests/test_document_policy_validation.py +420 -0
  58. julee/domain/models/policy/tests/test_policy.py +546 -0
  59. julee/domain/repositories/__init__.py +27 -0
  60. julee/domain/repositories/assembly.py +45 -0
  61. julee/domain/repositories/assembly_specification.py +52 -0
  62. julee/domain/repositories/base.py +146 -0
  63. julee/domain/repositories/document.py +49 -0
  64. julee/domain/repositories/document_policy_validation.py +52 -0
  65. julee/domain/repositories/knowledge_service_config.py +54 -0
  66. julee/domain/repositories/knowledge_service_query.py +44 -0
  67. julee/domain/repositories/policy.py +49 -0
  68. julee/domain/use_cases/__init__.py +17 -0
  69. julee/domain/use_cases/decorators.py +107 -0
  70. julee/domain/use_cases/extract_assemble_data.py +649 -0
  71. julee/domain/use_cases/initialize_system_data.py +842 -0
  72. julee/domain/use_cases/tests/__init__.py +7 -0
  73. julee/domain/use_cases/tests/test_extract_assemble_data.py +548 -0
  74. julee/domain/use_cases/tests/test_initialize_system_data.py +455 -0
  75. julee/domain/use_cases/tests/test_validate_document.py +1228 -0
  76. julee/domain/use_cases/validate_document.py +736 -0
  77. julee/fixtures/assembly_specifications.yaml +70 -0
  78. julee/fixtures/documents.yaml +178 -0
  79. julee/fixtures/knowledge_service_configs.yaml +37 -0
  80. julee/fixtures/knowledge_service_queries.yaml +27 -0
  81. julee/repositories/__init__.py +17 -0
  82. julee/repositories/memory/__init__.py +31 -0
  83. julee/repositories/memory/assembly.py +84 -0
  84. julee/repositories/memory/assembly_specification.py +125 -0
  85. julee/repositories/memory/base.py +227 -0
  86. julee/repositories/memory/document.py +149 -0
  87. julee/repositories/memory/document_policy_validation.py +104 -0
  88. julee/repositories/memory/knowledge_service_config.py +123 -0
  89. julee/repositories/memory/knowledge_service_query.py +120 -0
  90. julee/repositories/memory/policy.py +87 -0
  91. julee/repositories/memory/tests/__init__.py +0 -0
  92. julee/repositories/memory/tests/test_document.py +212 -0
  93. julee/repositories/memory/tests/test_document_policy_validation.py +161 -0
  94. julee/repositories/memory/tests/test_policy.py +443 -0
  95. julee/repositories/minio/__init__.py +31 -0
  96. julee/repositories/minio/assembly.py +103 -0
  97. julee/repositories/minio/assembly_specification.py +170 -0
  98. julee/repositories/minio/client.py +570 -0
  99. julee/repositories/minio/document.py +530 -0
  100. julee/repositories/minio/document_policy_validation.py +120 -0
  101. julee/repositories/minio/knowledge_service_config.py +187 -0
  102. julee/repositories/minio/knowledge_service_query.py +211 -0
  103. julee/repositories/minio/policy.py +106 -0
  104. julee/repositories/minio/tests/__init__.py +0 -0
  105. julee/repositories/minio/tests/fake_client.py +213 -0
  106. julee/repositories/minio/tests/test_assembly.py +374 -0
  107. julee/repositories/minio/tests/test_assembly_specification.py +391 -0
  108. julee/repositories/minio/tests/test_client_protocol.py +57 -0
  109. julee/repositories/minio/tests/test_document.py +591 -0
  110. julee/repositories/minio/tests/test_document_policy_validation.py +192 -0
  111. julee/repositories/minio/tests/test_knowledge_service_config.py +374 -0
  112. julee/repositories/minio/tests/test_knowledge_service_query.py +438 -0
  113. julee/repositories/minio/tests/test_policy.py +559 -0
  114. julee/repositories/temporal/__init__.py +38 -0
  115. julee/repositories/temporal/activities.py +114 -0
  116. julee/repositories/temporal/activity_names.py +34 -0
  117. julee/repositories/temporal/proxies.py +159 -0
  118. julee/services/__init__.py +18 -0
  119. julee/services/knowledge_service/__init__.py +48 -0
  120. julee/services/knowledge_service/anthropic/__init__.py +12 -0
  121. julee/services/knowledge_service/anthropic/knowledge_service.py +331 -0
  122. julee/services/knowledge_service/anthropic/tests/test_knowledge_service.py +318 -0
  123. julee/services/knowledge_service/factory.py +138 -0
  124. julee/services/knowledge_service/knowledge_service.py +160 -0
  125. julee/services/knowledge_service/memory/__init__.py +13 -0
  126. julee/services/knowledge_service/memory/knowledge_service.py +278 -0
  127. julee/services/knowledge_service/memory/test_knowledge_service.py +345 -0
  128. julee/services/knowledge_service/test_factory.py +112 -0
  129. julee/services/temporal/__init__.py +38 -0
  130. julee/services/temporal/activities.py +86 -0
  131. julee/services/temporal/activity_names.py +22 -0
  132. julee/services/temporal/proxies.py +41 -0
  133. julee/util/__init__.py +0 -0
  134. julee/util/domain.py +119 -0
  135. julee/util/repos/__init__.py +0 -0
  136. julee/util/repos/minio/__init__.py +0 -0
  137. julee/util/repos/minio/file_storage.py +213 -0
  138. julee/util/repos/temporal/__init__.py +11 -0
  139. julee/util/repos/temporal/client_proxies/file_storage.py +68 -0
  140. julee/util/repos/temporal/data_converter.py +123 -0
  141. julee/util/repos/temporal/minio_file_storage.py +12 -0
  142. julee/util/repos/temporal/proxies/__init__.py +0 -0
  143. julee/util/repos/temporal/proxies/file_storage.py +58 -0
  144. julee/util/repositories.py +55 -0
  145. julee/util/temporal/__init__.py +22 -0
  146. julee/util/temporal/activities.py +123 -0
  147. julee/util/temporal/decorators.py +473 -0
  148. julee/util/tests/__init__.py +1 -0
  149. julee/util/tests/test_decorators.py +770 -0
  150. julee/util/validation/__init__.py +29 -0
  151. julee/util/validation/repository.py +100 -0
  152. julee/util/validation/type_guards.py +369 -0
  153. julee/worker.py +211 -0
  154. julee/workflows/__init__.py +26 -0
  155. julee/workflows/extract_assemble.py +215 -0
  156. julee/workflows/validate_document.py +228 -0
  157. julee-0.1.0.dist-info/METADATA +195 -0
  158. julee-0.1.0.dist-info/RECORD +161 -0
  159. julee-0.1.0.dist-info/WHEEL +5 -0
  160. julee-0.1.0.dist-info/licenses/LICENSE +674 -0
  161. julee-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,213 @@
1
+ """
2
+ Fake Minio client for state-based testing.
3
+
4
+ This module provides a FakeMinioClient that implements the same interface as
5
+ the real Minio client but stores objects in memory for testing. This enables
6
+ state-based testing where you can verify actual storage state rather than
7
+ just mocking method calls.
8
+ """
9
+
10
+ from typing import Dict, Any, Optional, Callable, BinaryIO, Union, List
11
+ from functools import wraps
12
+ from unittest.mock import Mock
13
+ from datetime import datetime, timezone
14
+ from minio.error import S3Error
15
+ from minio.datatypes import Object
16
+ from minio.api import ObjectWriteResult
17
+ from urllib3.response import BaseHTTPResponse
18
+ from urllib3 import HTTPHeaderDict
19
+
20
+ from ..client import MinioClient
21
+
22
+
23
+ def requires_bucket(func: Callable) -> Callable:
24
+ """Decorator to check if bucket exists before method execution."""
25
+
26
+ @wraps(func)
27
+ def wrapper(self: Any, bucket_name: str, *args: Any, **kwargs: Any) -> Any:
28
+ if bucket_name not in self._buckets:
29
+ raise S3Error(
30
+ code="NoSuchBucket",
31
+ message="Bucket does not exist",
32
+ resource=bucket_name,
33
+ request_id="req123",
34
+ host_id="host123",
35
+ response=Mock(),
36
+ )
37
+ return func(self, bucket_name, *args, **kwargs)
38
+
39
+ return wrapper
40
+
41
+
42
+ def requires_object(func: Callable) -> Callable:
43
+ """Decorator to check if object exists before method execution."""
44
+
45
+ @wraps(func)
46
+ def wrapper(
47
+ self: Any,
48
+ bucket_name: str,
49
+ object_name: str,
50
+ *args: Any,
51
+ **kwargs: Any,
52
+ ) -> Any:
53
+ if bucket_name not in self._objects:
54
+ raise S3Error(
55
+ code="NoSuchBucket",
56
+ message="Bucket does not exist",
57
+ resource=bucket_name,
58
+ request_id="req123",
59
+ host_id="host123",
60
+ response=Mock(),
61
+ )
62
+ if object_name not in self._objects[bucket_name]:
63
+ raise S3Error(
64
+ code="NoSuchKey",
65
+ message="Object not found",
66
+ resource=object_name,
67
+ request_id="req123",
68
+ host_id="host123",
69
+ response=Mock(),
70
+ )
71
+ return func(self, bucket_name, object_name, *args, **kwargs)
72
+
73
+ return wrapper
74
+
75
+
76
+ class FakeMinioClient(MinioClient):
77
+ """
78
+ Fake Minio client that stores objects in memory for testing.
79
+
80
+ This client implements the MinioClient protocol and stores all data in
81
+ memory, allowing for fast state-based testing without requiring a real
82
+ MinIO server.
83
+ """
84
+
85
+ def __init__(self) -> None:
86
+ self._buckets: Dict[str, Dict[str, Any]] = {}
87
+ self._objects: Dict[str, Dict[str, Dict[str, Any]]] = {}
88
+
89
+ def bucket_exists(self, bucket_name: str) -> bool:
90
+ """Check if a bucket exists."""
91
+ return bucket_name in self._buckets
92
+
93
+ def make_bucket(self, bucket_name: str) -> None:
94
+ """Create a bucket."""
95
+ if bucket_name in self._buckets:
96
+ raise S3Error(
97
+ code="BucketAlreadyExists",
98
+ message="Bucket already exists",
99
+ resource=bucket_name,
100
+ request_id="req123",
101
+ host_id="host123",
102
+ response=Mock(),
103
+ )
104
+ self._buckets[bucket_name] = {}
105
+ self._objects[bucket_name] = {}
106
+
107
+ @requires_bucket
108
+ def put_object(
109
+ self,
110
+ bucket_name: str,
111
+ object_name: str,
112
+ data: BinaryIO,
113
+ length: int,
114
+ content_type: str = "application/octet-stream",
115
+ metadata: Optional[Dict[str, Union[str, List[str], tuple[str]]]] = None,
116
+ ) -> ObjectWriteResult:
117
+ """Store an object in the bucket."""
118
+
119
+ # Read the data from stream
120
+ if hasattr(data, "read"):
121
+ if hasattr(data, "seek"):
122
+ data.seek(0) # Ensure we're at the beginning
123
+ content = data.read()
124
+ if hasattr(data, "seek"):
125
+ data.seek(0) # Reset for potential re-use
126
+ else:
127
+ content = data if isinstance(data, bytes) else str(data).encode("utf-8")
128
+
129
+ self._objects[bucket_name][object_name] = {
130
+ "data": content,
131
+ "metadata": metadata or {},
132
+ "content_type": content_type,
133
+ "size": len(content),
134
+ }
135
+
136
+ # Return a proper ObjectWriteResult
137
+ return ObjectWriteResult(
138
+ bucket_name=bucket_name,
139
+ object_name=object_name,
140
+ version_id=None,
141
+ etag="fake-etag",
142
+ http_headers=HTTPHeaderDict(),
143
+ last_modified=datetime.now(timezone.utc),
144
+ location=f"/{bucket_name}/{object_name}",
145
+ )
146
+
147
+ @requires_object
148
+ def get_object(self, bucket_name: str, object_name: str) -> BaseHTTPResponse:
149
+ """Retrieve an object from the bucket."""
150
+
151
+ obj_info = self._objects[bucket_name][object_name]
152
+ # Create a mock BaseHTTPResponse with the data
153
+ mock_response = Mock(spec=BaseHTTPResponse)
154
+ mock_response.read = Mock(return_value=obj_info["data"])
155
+ mock_response.close = Mock()
156
+ mock_response.release_conn = Mock()
157
+ return mock_response
158
+
159
+ @requires_object
160
+ def stat_object(self, bucket_name: str, object_name: str) -> Object:
161
+ """Get object metadata without retrieving the object data."""
162
+
163
+ obj_info = self._objects[bucket_name][object_name]
164
+ # Create a real Minio Object
165
+ return Object(
166
+ bucket_name=bucket_name,
167
+ object_name=object_name,
168
+ last_modified=datetime.now(timezone.utc),
169
+ etag="fake-etag",
170
+ size=obj_info["size"],
171
+ content_type=obj_info["content_type"],
172
+ metadata=obj_info["metadata"],
173
+ )
174
+
175
+ def list_objects(self, bucket_name: str, prefix: str = "") -> list:
176
+ """List objects in a bucket with optional prefix filter."""
177
+ if bucket_name not in self._objects:
178
+ return []
179
+
180
+ objects = []
181
+ for object_name, obj_info in self._objects[bucket_name].items():
182
+ if object_name.startswith(prefix):
183
+ # Create a simple object info structure
184
+ obj = Mock()
185
+ obj.object_name = object_name
186
+ obj.size = obj_info["size"]
187
+ objects.append(obj)
188
+
189
+ return objects
190
+
191
+ @requires_object
192
+ def remove_object(self, bucket_name: str, object_name: str) -> None:
193
+ """Remove an object from the bucket."""
194
+
195
+ del self._objects[bucket_name][object_name]
196
+
197
+ # Inspection methods for testing
198
+ def get_stored_objects(self, bucket_name: str) -> Dict[str, Dict[str, Any]]:
199
+ """Get all stored objects in a bucket (for testing purposes)."""
200
+ return self._objects.get(bucket_name, {}).copy()
201
+
202
+ def get_object_count(self, bucket_name: str) -> int:
203
+ """Get the number of objects in a bucket (for testing purposes)."""
204
+ return len(self._objects.get(bucket_name, {}))
205
+
206
+ def get_total_object_count(self) -> int:
207
+ """Get total number of objects across all buckets (for testing)."""
208
+ return sum(len(objects) for objects in self._objects.values())
209
+
210
+ def clear_all_data(self) -> None:
211
+ """Clear all buckets and objects (for testing purposes)."""
212
+ self._buckets.clear()
213
+ self._objects.clear()
@@ -0,0 +1,374 @@
1
+ """
2
+ Tests for MinioAssemblyRepository implementation.
3
+
4
+ This module provides comprehensive tests for the Minio-based assembly
5
+ repository implementation, using the fake client to avoid external
6
+ dependencies during testing.
7
+ """
8
+
9
+ import pytest
10
+ from datetime import datetime, timezone
11
+
12
+ from julee.domain.models.assembly import Assembly, AssemblyStatus
13
+ from julee.repositories.minio.assembly import MinioAssemblyRepository
14
+ from .fake_client import FakeMinioClient
15
+
16
+
17
+ @pytest.fixture
18
+ def fake_client() -> FakeMinioClient:
19
+ """Create a fresh fake Minio client for each test."""
20
+ return FakeMinioClient()
21
+
22
+
23
+ @pytest.fixture
24
+ def assembly_repo(fake_client: FakeMinioClient) -> MinioAssemblyRepository:
25
+ """Create assembly repository with fake client."""
26
+ return MinioAssemblyRepository(fake_client)
27
+
28
+
29
+ @pytest.fixture
30
+ def sample_assembly() -> Assembly:
31
+ """Create a sample assembly for testing."""
32
+ return Assembly(
33
+ assembly_id="test-assembly-123",
34
+ assembly_specification_id="spec-456",
35
+ input_document_id="input-doc-789",
36
+ workflow_id="test-workflow-123",
37
+ status=AssemblyStatus.PENDING,
38
+ assembled_document_id=None,
39
+ created_at=datetime.now(timezone.utc),
40
+ updated_at=datetime.now(timezone.utc),
41
+ )
42
+
43
+
44
+ class TestMinioAssemblyRepositoryBasicOperations:
45
+ """Test basic CRUD operations on assemblies."""
46
+
47
+ @pytest.mark.asyncio
48
+ async def test_save_and_get_assembly(
49
+ self,
50
+ assembly_repo: MinioAssemblyRepository,
51
+ sample_assembly: Assembly,
52
+ ) -> None:
53
+ """Test saving and retrieving an assembly."""
54
+ # Save assembly
55
+ await assembly_repo.save(sample_assembly)
56
+
57
+ # Retrieve assembly
58
+ retrieved = await assembly_repo.get(sample_assembly.assembly_id)
59
+
60
+ assert retrieved is not None
61
+ assert retrieved.assembly_id == sample_assembly.assembly_id
62
+ assert (
63
+ retrieved.assembly_specification_id
64
+ == sample_assembly.assembly_specification_id
65
+ )
66
+ assert retrieved.input_document_id == sample_assembly.input_document_id
67
+ assert retrieved.status == sample_assembly.status
68
+ assert retrieved.assembled_document_id is None
69
+
70
+ @pytest.mark.asyncio
71
+ async def test_get_nonexistent_assembly(
72
+ self, assembly_repo: MinioAssemblyRepository
73
+ ) -> None:
74
+ """Test retrieving a non-existent assembly returns None."""
75
+ result = await assembly_repo.get("nonexistent-assembly")
76
+ assert result is None
77
+
78
+ @pytest.mark.asyncio
79
+ async def test_generate_id(self, assembly_repo: MinioAssemblyRepository) -> None:
80
+ """Test generating unique assembly IDs."""
81
+ id1 = await assembly_repo.generate_id()
82
+ id2 = await assembly_repo.generate_id()
83
+
84
+ assert isinstance(id1, str)
85
+ assert isinstance(id2, str)
86
+ assert id1 != id2
87
+ assert len(id1) > 0
88
+ assert len(id2) > 0
89
+
90
+
91
+ class TestMinioAssemblyRepositoryDirectCompletion:
92
+ """Test assembly completion using direct field assignment."""
93
+
94
+ @pytest.mark.asyncio
95
+ async def test_complete_assembly_with_direct_assignment(
96
+ self,
97
+ assembly_repo: MinioAssemblyRepository,
98
+ sample_assembly: Assembly,
99
+ ) -> None:
100
+ """Test completing assembly using direct field assignment + save."""
101
+ # Save initial assembly
102
+ await assembly_repo.save(sample_assembly)
103
+
104
+ # Complete assembly using direct assignment (new approach)
105
+ sample_assembly.assembled_document_id = "output-doc-123"
106
+ sample_assembly.status = AssemblyStatus.COMPLETED
107
+ await assembly_repo.save(sample_assembly)
108
+
109
+ # Verify completion by retrieving again
110
+ retrieved = await assembly_repo.get(sample_assembly.assembly_id)
111
+ assert retrieved is not None
112
+ assert retrieved.assembled_document_id == "output-doc-123"
113
+ assert retrieved.status == AssemblyStatus.COMPLETED
114
+
115
+ @pytest.mark.asyncio
116
+ async def test_assembly_completion_idempotency(
117
+ self,
118
+ assembly_repo: MinioAssemblyRepository,
119
+ sample_assembly: Assembly,
120
+ ) -> None:
121
+ """Test that direct assignment approach maintains idempotency."""
122
+ # Save initial assembly
123
+ await assembly_repo.save(sample_assembly)
124
+
125
+ # Complete assembly first time
126
+ sample_assembly.assembled_document_id = "output-doc-456"
127
+ sample_assembly.status = AssemblyStatus.COMPLETED
128
+ await assembly_repo.save(sample_assembly)
129
+
130
+ first_updated_at = sample_assembly.updated_at
131
+
132
+ # "Complete" same assembly again with same values (idempotent)
133
+ sample_assembly.assembled_document_id = "output-doc-456"
134
+ sample_assembly.status = AssemblyStatus.COMPLETED
135
+ await assembly_repo.save(sample_assembly)
136
+
137
+ # Verify state and that updated_at changed (save updates timestamp)
138
+ retrieved = await assembly_repo.get(sample_assembly.assembly_id)
139
+ assert retrieved is not None
140
+ assert retrieved.assembled_document_id == "output-doc-456"
141
+ assert retrieved.status == AssemblyStatus.COMPLETED
142
+ assert retrieved.updated_at is not None
143
+ assert first_updated_at is not None
144
+ assert retrieved.updated_at > first_updated_at
145
+
146
+
147
+ class TestMinioAssemblyRepositoryStatusUpdates:
148
+ """Test assembly status management."""
149
+
150
+ @pytest.mark.asyncio
151
+ async def test_update_assembly_status(
152
+ self,
153
+ assembly_repo: MinioAssemblyRepository,
154
+ sample_assembly: Assembly,
155
+ ) -> None:
156
+ """Test updating assembly status."""
157
+ # Save initial assembly
158
+ await assembly_repo.save(sample_assembly)
159
+
160
+ # Update status
161
+ sample_assembly.status = AssemblyStatus.IN_PROGRESS
162
+ await assembly_repo.save(sample_assembly)
163
+
164
+ # Verify update
165
+ retrieved = await assembly_repo.get(sample_assembly.assembly_id)
166
+ assert retrieved is not None
167
+ assert retrieved.status == AssemblyStatus.IN_PROGRESS
168
+
169
+ # Update to completed
170
+ sample_assembly.status = AssemblyStatus.COMPLETED
171
+ await assembly_repo.save(sample_assembly)
172
+
173
+ # Verify final state
174
+ retrieved = await assembly_repo.get(sample_assembly.assembly_id)
175
+ assert retrieved is not None
176
+ assert retrieved.status == AssemblyStatus.COMPLETED
177
+
178
+ @pytest.mark.asyncio
179
+ async def test_save_updates_timestamp(
180
+ self,
181
+ assembly_repo: MinioAssemblyRepository,
182
+ sample_assembly: Assembly,
183
+ ) -> None:
184
+ """Test that save operations update the updated_at timestamp."""
185
+ original_updated_at = sample_assembly.updated_at
186
+
187
+ # Save assembly
188
+ await assembly_repo.save(sample_assembly)
189
+
190
+ # Retrieve and check timestamp was updated
191
+ retrieved = await assembly_repo.get(sample_assembly.assembly_id)
192
+ assert retrieved is not None
193
+ assert retrieved.updated_at is not None
194
+ assert original_updated_at is not None
195
+ assert retrieved.updated_at > original_updated_at
196
+
197
+ @pytest.mark.asyncio
198
+ async def test_save_preserves_assembled_document_id(
199
+ self,
200
+ assembly_repo: MinioAssemblyRepository,
201
+ sample_assembly: Assembly,
202
+ ) -> None:
203
+ """Test that save operations preserve assembled_document_id."""
204
+ # Set assembled document first
205
+ sample_assembly.assembled_document_id = "test-doc-123"
206
+ sample_assembly.status = AssemblyStatus.COMPLETED
207
+ await assembly_repo.save(sample_assembly)
208
+
209
+ # Update other fields and save again
210
+ sample_assembly.status = AssemblyStatus.FAILED
211
+ await assembly_repo.save(sample_assembly)
212
+
213
+ # Verify assembled_document_id is preserved
214
+ retrieved = await assembly_repo.get(sample_assembly.assembly_id)
215
+ assert retrieved is not None
216
+ assert retrieved.assembled_document_id == "test-doc-123"
217
+ assert retrieved.status == AssemblyStatus.FAILED
218
+
219
+
220
+ class TestMinioAssemblyRepositoryEdgeCases:
221
+ """Test edge cases and error conditions."""
222
+
223
+ @pytest.mark.asyncio
224
+ async def test_assembly_with_no_assembled_document(
225
+ self,
226
+ assembly_repo: MinioAssemblyRepository,
227
+ sample_assembly: Assembly,
228
+ ) -> None:
229
+ """Test handling assembly with no assembled document."""
230
+ await assembly_repo.save(sample_assembly)
231
+
232
+ retrieved = await assembly_repo.get(sample_assembly.assembly_id)
233
+ assert retrieved is not None
234
+ assert retrieved.assembled_document_id is None
235
+
236
+ @pytest.mark.asyncio
237
+ async def test_complex_workflow_scenario(
238
+ self,
239
+ assembly_repo: MinioAssemblyRepository,
240
+ sample_assembly: Assembly,
241
+ ) -> None:
242
+ """Test complex scenario with multiple operations."""
243
+ # Save initial assembly
244
+ await assembly_repo.save(sample_assembly)
245
+
246
+ # Start processing
247
+ sample_assembly.status = AssemblyStatus.IN_PROGRESS
248
+ await assembly_repo.save(sample_assembly)
249
+
250
+ # Set assembled document using direct assignment
251
+ sample_assembly.assembled_document_id = "final-output-doc"
252
+ sample_assembly.status = AssemblyStatus.COMPLETED
253
+ await assembly_repo.save(sample_assembly)
254
+
255
+ # Verify final state
256
+ updated_assembly = await assembly_repo.get(sample_assembly.assembly_id)
257
+ assert updated_assembly is not None
258
+
259
+ # Verify final state
260
+ assert updated_assembly.assembled_document_id == "final-output-doc"
261
+ assert updated_assembly.status == AssemblyStatus.COMPLETED
262
+
263
+ # Double-check by retrieving fresh
264
+ retrieved = await assembly_repo.get(sample_assembly.assembly_id)
265
+ assert retrieved is not None
266
+ assert retrieved.assembled_document_id == "final-output-doc"
267
+ assert retrieved.status == AssemblyStatus.COMPLETED
268
+
269
+ @pytest.mark.asyncio
270
+ async def test_assembly_failure_scenario(
271
+ self,
272
+ assembly_repo: MinioAssemblyRepository,
273
+ sample_assembly: Assembly,
274
+ ) -> None:
275
+ """Test assembly that fails during processing."""
276
+ # Save initial assembly
277
+ await assembly_repo.save(sample_assembly)
278
+
279
+ # Start processing
280
+ sample_assembly.status = AssemblyStatus.IN_PROGRESS
281
+ await assembly_repo.save(sample_assembly)
282
+
283
+ # Mark as failed (without setting assembled document)
284
+ sample_assembly.status = AssemblyStatus.FAILED
285
+ await assembly_repo.save(sample_assembly)
286
+
287
+ # Verify final state - no assembled document
288
+ retrieved = await assembly_repo.get(sample_assembly.assembly_id)
289
+ assert retrieved is not None
290
+ assert retrieved.assembled_document_id is None
291
+ assert retrieved.status == AssemblyStatus.FAILED
292
+
293
+
294
+ class TestMinioAssemblyRepositoryRoundtrip:
295
+ """Test full round-trip scenarios."""
296
+
297
+ @pytest.mark.asyncio
298
+ async def test_full_assembly_lifecycle_success(
299
+ self, assembly_repo: MinioAssemblyRepository
300
+ ) -> None:
301
+ """Test complete successful assembly lifecycle from creation to
302
+ completion."""
303
+ # Generate new assembly
304
+ assembly_id = await assembly_repo.generate_id()
305
+
306
+ # Create and save initial assembly
307
+ assembly = Assembly(
308
+ assembly_id=assembly_id,
309
+ assembly_specification_id="spec-test",
310
+ input_document_id="input-test",
311
+ workflow_id="test-workflow-success",
312
+ status=AssemblyStatus.PENDING,
313
+ assembled_document_id=None,
314
+ created_at=datetime.now(timezone.utc),
315
+ updated_at=datetime.now(timezone.utc),
316
+ )
317
+ await assembly_repo.save(assembly)
318
+
319
+ # Start processing
320
+ assembly.status = AssemblyStatus.IN_PROGRESS
321
+ await assembly_repo.save(assembly)
322
+
323
+ # Complete assembly with output document
324
+ assembly.assembled_document_id = "final-output-document"
325
+ assembly.status = AssemblyStatus.COMPLETED
326
+ await assembly_repo.save(assembly)
327
+
328
+ final_assembly = await assembly_repo.get(assembly_id)
329
+ assert final_assembly is not None
330
+
331
+ # Final verification
332
+ assert final_assembly.status == AssemblyStatus.COMPLETED
333
+ assert final_assembly.assembled_document_id == "final-output-document"
334
+
335
+ # Verify persistence
336
+ retrieved = await assembly_repo.get(assembly_id)
337
+ assert retrieved is not None
338
+ assert retrieved.status == AssemblyStatus.COMPLETED
339
+ assert retrieved.assembled_document_id == "final-output-document"
340
+
341
+ @pytest.mark.asyncio
342
+ async def test_full_assembly_lifecycle_failure(
343
+ self, assembly_repo: MinioAssemblyRepository
344
+ ) -> None:
345
+ """Test complete failed assembly lifecycle."""
346
+ # Generate new assembly
347
+ assembly_id = await assembly_repo.generate_id()
348
+
349
+ # Create and save initial assembly
350
+ assembly = Assembly(
351
+ assembly_id=assembly_id,
352
+ assembly_specification_id="spec-test",
353
+ input_document_id="input-test",
354
+ workflow_id="test-workflow-failure",
355
+ status=AssemblyStatus.PENDING,
356
+ assembled_document_id=None,
357
+ created_at=datetime.now(timezone.utc),
358
+ updated_at=datetime.now(timezone.utc),
359
+ )
360
+ await assembly_repo.save(assembly)
361
+
362
+ # Start processing
363
+ assembly.status = AssemblyStatus.IN_PROGRESS
364
+ await assembly_repo.save(assembly)
365
+
366
+ # Fail assembly (without setting output document)
367
+ assembly.status = AssemblyStatus.FAILED
368
+ await assembly_repo.save(assembly)
369
+
370
+ # Final verification
371
+ retrieved = await assembly_repo.get(assembly_id)
372
+ assert retrieved is not None
373
+ assert retrieved.status == AssemblyStatus.FAILED
374
+ assert retrieved.assembled_document_id is None