|
9 | 9 | from hindsight_api.extensions import ( |
10 | 10 | ApiKeyTenantExtension, |
11 | 11 | AuthenticationError, |
| 12 | + # Consolidation operation |
| 13 | + ConsolidateContext, |
| 14 | + ConsolidateResult, |
| 15 | + CreateBankContext, |
12 | 16 | Extension, |
13 | 17 | HttpExtension, |
14 | 18 | OperationValidationError, |
|
25 | 29 | TenantExtension, |
26 | 30 | ValidationResult, |
27 | 31 | load_extension, |
28 | | - # Consolidation operation |
29 | | - ConsolidateContext, |
30 | | - ConsolidateResult, |
31 | 32 | ) |
32 | 33 |
|
33 | 34 |
|
@@ -169,6 +170,30 @@ async def on_consolidate_complete(self, result: ConsolidateResult) -> None: |
169 | 170 | self.post_consolidate_calls.append(result) |
170 | 171 |
|
171 | 172 |
|
| 173 | +class CreateBankRejectingValidator(OperationValidatorExtension): |
| 174 | + """Validator that records create-bank checks and can reject them.""" |
| 175 | + |
| 176 | + def __init__(self, *, reject: bool = True): |
| 177 | + super().__init__({}) |
| 178 | + self.reject = reject |
| 179 | + self.create_bank_calls: list[CreateBankContext] = [] |
| 180 | + |
| 181 | + async def validate_retain(self, ctx: RetainContext) -> ValidationResult: |
| 182 | + return ValidationResult.accept() |
| 183 | + |
| 184 | + async def validate_recall(self, ctx: RecallContext) -> ValidationResult: |
| 185 | + return ValidationResult.accept() |
| 186 | + |
| 187 | + async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult: |
| 188 | + return ValidationResult.accept() |
| 189 | + |
| 190 | + async def validate_create_bank(self, ctx: CreateBankContext) -> ValidationResult: |
| 191 | + self.create_bank_calls.append(ctx) |
| 192 | + if self.reject: |
| 193 | + return ValidationResult.reject("bank creation not allowed", status_code=402) |
| 194 | + return ValidationResult.accept() |
| 195 | + |
| 196 | + |
172 | 197 | class TestMemoryEngineValidation: |
173 | 198 | """Tests for validation integration with MemoryEngine. |
174 | 199 |
|
@@ -264,6 +289,109 @@ async def test_reflect_validation(self, memory_with_validator): |
264 | 289 |
|
265 | 290 | assert "limit exceeded" in str(exc_info.value).lower() |
266 | 291 |
|
| 292 | + @pytest.mark.asyncio |
| 293 | + async def test_retain_validates_create_bank_for_missing_bank(self, memory): |
| 294 | + """Retain may lazily create a bank, so missing-bank retain validates create_bank.""" |
| 295 | + bank_id = "test-retain-create-bank-validation" |
| 296 | + ctx = RequestContext() |
| 297 | + validator = CreateBankRejectingValidator() |
| 298 | + memory._operation_validator = validator |
| 299 | + |
| 300 | + with pytest.raises(OperationValidationError) as exc_info: |
| 301 | + await memory.retain_batch_async( |
| 302 | + bank_id=bank_id, |
| 303 | + contents=[{"content": "Should not create a bank"}], |
| 304 | + request_context=ctx, |
| 305 | + ) |
| 306 | + |
| 307 | + assert "bank creation not allowed" in str(exc_info.value) |
| 308 | + assert len(validator.create_bank_calls) == 1 |
| 309 | + assert validator.create_bank_calls[0].bank_id == bank_id |
| 310 | + assert await memory.get_bank_profile(bank_id, request_context=ctx, create_if_missing=False) is None |
| 311 | + |
| 312 | + @pytest.mark.asyncio |
| 313 | + async def test_async_retain_validates_create_bank_for_missing_bank(self, memory): |
| 314 | + """Async retain validates create_bank before creating its parent operation.""" |
| 315 | + bank_id = "test-async-retain-create-bank-validation" |
| 316 | + ctx = RequestContext() |
| 317 | + validator = CreateBankRejectingValidator() |
| 318 | + memory._operation_validator = validator |
| 319 | + |
| 320 | + with pytest.raises(OperationValidationError) as exc_info: |
| 321 | + await memory.submit_async_retain( |
| 322 | + bank_id=bank_id, |
| 323 | + contents=[{"content": "Should not create a bank"}], |
| 324 | + request_context=ctx, |
| 325 | + ) |
| 326 | + |
| 327 | + assert "bank creation not allowed" in str(exc_info.value) |
| 328 | + assert len(validator.create_bank_calls) == 1 |
| 329 | + assert validator.create_bank_calls[0].bank_id == bank_id |
| 330 | + assert await memory.get_bank_profile(bank_id, request_context=ctx, create_if_missing=False) is None |
| 331 | + |
| 332 | + @pytest.mark.asyncio |
| 333 | + async def test_create_bank_validation_skips_existing_bank(self, memory): |
| 334 | + """Existing banks do not require create_bank validation.""" |
| 335 | + bank_id = "test-create-bank-existing" |
| 336 | + ctx = RequestContext() |
| 337 | + await memory.get_bank_profile(bank_id, request_context=ctx) |
| 338 | + |
| 339 | + validator = CreateBankRejectingValidator() |
| 340 | + memory._operation_validator = validator |
| 341 | + |
| 342 | + created = await memory._ensure_bank_exists(bank_id, ctx) |
| 343 | + |
| 344 | + assert created is False |
| 345 | + assert validator.create_bank_calls == [] |
| 346 | + |
| 347 | + @pytest.mark.asyncio |
| 348 | + async def test_get_bank_profile_validates_create_bank_for_missing_bank(self, memory): |
| 349 | + """The default auto-create profile path validates create_bank.""" |
| 350 | + bank_id = "test-profile-create-bank-validation" |
| 351 | + ctx = RequestContext() |
| 352 | + validator = CreateBankRejectingValidator() |
| 353 | + memory._operation_validator = validator |
| 354 | + |
| 355 | + with pytest.raises(OperationValidationError) as exc_info: |
| 356 | + await memory.get_bank_profile(bank_id, request_context=ctx) |
| 357 | + |
| 358 | + assert "bank creation not allowed" in str(exc_info.value) |
| 359 | + assert len(validator.create_bank_calls) == 1 |
| 360 | + assert validator.create_bank_calls[0].bank_id == bank_id |
| 361 | + assert await memory.get_bank_profile(bank_id, request_context=ctx, create_if_missing=False) is None |
| 362 | + |
| 363 | + @pytest.mark.asyncio |
| 364 | + async def test_http_create_or_update_validates_create_bank(self, api_client, memory): |
| 365 | + bank_id = "test-http-put-create-bank-validation" |
| 366 | + validator = CreateBankRejectingValidator() |
| 367 | + memory._operation_validator = validator |
| 368 | + |
| 369 | + resp = await api_client.put( |
| 370 | + f"/v1/default/banks/{bank_id}", |
| 371 | + json={"name": "Blocked Bank"}, |
| 372 | + ) |
| 373 | + |
| 374 | + assert resp.status_code == 402 |
| 375 | + assert resp.json()["detail"] == "bank creation not allowed" |
| 376 | + assert len(validator.create_bank_calls) == 1 |
| 377 | + assert validator.create_bank_calls[0].bank_id == bank_id |
| 378 | + |
| 379 | + @pytest.mark.asyncio |
| 380 | + async def test_http_template_import_validates_create_bank(self, api_client, memory): |
| 381 | + bank_id = "test-http-import-create-bank-validation" |
| 382 | + validator = CreateBankRejectingValidator() |
| 383 | + memory._operation_validator = validator |
| 384 | + |
| 385 | + resp = await api_client.post( |
| 386 | + f"/v1/default/banks/{bank_id}/import", |
| 387 | + json={"version": "1"}, |
| 388 | + ) |
| 389 | + |
| 390 | + assert resp.status_code == 402 |
| 391 | + assert resp.json()["detail"] == "bank creation not allowed" |
| 392 | + assert len(validator.create_bank_calls) == 1 |
| 393 | + assert validator.create_bank_calls[0].bank_id == bank_id |
| 394 | + |
267 | 395 |
|
268 | 396 | @pytest.fixture |
269 | 397 | def memory_with_validator(memory): |
@@ -356,6 +484,7 @@ async def test_retain_post_hook_receives_all_parameters_and_result(self, memory_ |
356 | 484 | async def test_recall_pre_hook_receives_all_parameters(self, memory_with_tracking_validator): |
357 | 485 | """Pre-recall hook receives all user-provided parameters.""" |
358 | 486 | from datetime import datetime, timezone |
| 487 | + |
359 | 488 | from hindsight_api.engine.memory_engine import Budget |
360 | 489 |
|
361 | 490 | memory, validator = memory_with_tracking_validator |
|
0 commit comments