mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 18:13:14 +00:00
- Add web-test job (vitest) and python-test job (pytest) to CI workflow - dotnet reusable workflow already runs tests by default - Update InternalApiKeyMiddleware tests for API-C1/API-H1 fixes: invalid key now returns 401 (not pass-through), valid key on disallowed path returns 403 - Fix suggestions test: include status field for LAM-H4 idempotency guard - Total: 108 tests (77 .NET, 12 web, 19 Python) all passing
218 lines
8.8 KiB
Python
218 lines
8.8 KiB
Python
"""Tests for suggestions Lambda handler.
|
|
|
|
QA-C6 (partial): Verifies SQS batch processing, idempotency guard
|
|
(skips when AI items exist), and error handling.
|
|
"""
|
|
|
|
import importlib
|
|
import json
|
|
import sys
|
|
import os
|
|
from unittest.mock import MagicMock, patch, call
|
|
|
|
import pytest
|
|
|
|
from helpers import make_sqs_event
|
|
|
|
# Import suggestions app under a unique module name to avoid collision with
|
|
# pdf-generate/app.py (both are named 'app').
|
|
_suggestions_dir = os.path.join(os.path.dirname(__file__), "..", "suggestions")
|
|
_spec = importlib.util.spec_from_file_location("suggestions_app", os.path.join(_suggestions_dir, "app.py"))
|
|
suggestions_app = importlib.util.module_from_spec(_spec)
|
|
sys.modules["suggestions_app"] = suggestions_app
|
|
_spec.loader.exec_module(suggestions_app)
|
|
|
|
|
|
class TestSuggestionsHandler:
|
|
"""Test the SQS handler entry point for suggestions Lambda."""
|
|
|
|
@patch.object(suggestions_app, "process_suggestion")
|
|
def test_handler_processes_sqs_record(self, mock_process):
|
|
"""QA-C6: Handler extracts proposalId and trigger from SQS body."""
|
|
event = make_sqs_event({"payload": {"proposalId": "abc-123", "trigger": "generate"}})
|
|
result = suggestions_app.handler(event, None)
|
|
|
|
mock_process.assert_called_once_with("abc-123", "generate")
|
|
assert result["batchItemFailures"] == []
|
|
|
|
@patch.object(suggestions_app, "process_suggestion")
|
|
def test_handler_defaults_trigger_to_generate(self, mock_process):
|
|
"""QA-C6: Handler defaults trigger to 'generate' when not specified."""
|
|
event = make_sqs_event({"payload": {"proposalId": "abc-123"}})
|
|
result = suggestions_app.handler(event, None)
|
|
|
|
mock_process.assert_called_once_with("abc-123", "generate")
|
|
assert result["batchItemFailures"] == []
|
|
|
|
@patch.object(suggestions_app, "process_suggestion")
|
|
def test_handler_returns_batch_failures_on_error(self, mock_process):
|
|
"""QA-C6: Handler returns failed message IDs for partial batch failure."""
|
|
mock_process.side_effect = [None, RuntimeError("Bedrock timeout")]
|
|
|
|
event = make_sqs_event(
|
|
{"payload": {"proposalId": "id-1", "trigger": "generate"}},
|
|
{"payload": {"proposalId": "id-2", "trigger": "generate"}},
|
|
)
|
|
result = suggestions_app.handler(event, None)
|
|
|
|
assert len(result["batchItemFailures"]) == 1
|
|
assert result["batchItemFailures"][0]["itemIdentifier"] == "msg-1"
|
|
|
|
@patch.object(suggestions_app, "process_suggestion")
|
|
def test_handler_handles_malformed_body(self, mock_process):
|
|
"""QA-C6: Handler reports failure for records with invalid JSON."""
|
|
event = {
|
|
"Records": [
|
|
{
|
|
"messageId": "msg-bad",
|
|
"body": "{invalid json",
|
|
"receiptHandle": "handle-0",
|
|
}
|
|
]
|
|
}
|
|
result = suggestions_app.handler(event, None)
|
|
|
|
assert len(result["batchItemFailures"]) == 1
|
|
mock_process.assert_not_called()
|
|
|
|
|
|
class TestProcessSuggestion:
|
|
"""Test the core suggestion generation logic."""
|
|
|
|
@patch.object(suggestions_app, "store_similar_references")
|
|
@patch.object(suggestions_app, "post_line_items")
|
|
@patch.object(suggestions_app, "generate_line_items")
|
|
@patch.object(suggestions_app, "retrieve_similar")
|
|
@patch.object(suggestions_app, "fetch_line_items")
|
|
@patch.object(suggestions_app, "fetch_proposal")
|
|
def test_process_suggestion_full_flow(
|
|
self,
|
|
mock_fetch_proposal,
|
|
mock_fetch_items,
|
|
mock_retrieve,
|
|
mock_generate,
|
|
mock_post,
|
|
mock_store_refs,
|
|
):
|
|
"""QA-C6: Full suggestion flow fetches proposal, retrieves similar, generates, posts."""
|
|
mock_fetch_proposal.return_value = {
|
|
"id": "abc",
|
|
"scopeOfWork": "Replace HVAC system",
|
|
"serviceCategory": "HVAC",
|
|
"priority": "Standard",
|
|
"status": "InReview",
|
|
}
|
|
mock_fetch_items.return_value = []
|
|
mock_retrieve.return_value = [{"content": "similar doc", "score": 0.9, "metadata": {}, "sourceUri": ""}]
|
|
mock_generate.return_value = [
|
|
{"description": "Ductwork", "quantity": 100, "unit": "linear ft", "unitPrice": 15.0, "totalPrice": 1500.0, "pricingMode": "UnitPrice"},
|
|
]
|
|
|
|
suggestions_app.process_suggestion("abc", "generate")
|
|
|
|
mock_fetch_proposal.assert_called_once_with("abc")
|
|
mock_fetch_items.assert_called_once_with("abc")
|
|
mock_retrieve.assert_called_once()
|
|
mock_generate.assert_called_once()
|
|
mock_post.assert_called_once()
|
|
|
|
@patch.object(suggestions_app, "fetch_proposal")
|
|
def test_process_suggestion_skips_when_proposal_not_found(self, mock_fetch):
|
|
"""QA-C6: Skips processing when proposal is not found (returns early)."""
|
|
mock_fetch.return_value = None
|
|
|
|
# Should not raise - just logs and returns
|
|
suggestions_app.process_suggestion("nonexistent", "generate")
|
|
|
|
@patch.object(suggestions_app, "_retry_request")
|
|
def test_post_line_items_preserves_non_ai_items(self, mock_request):
|
|
"""QA-C6: Existing non-AI items are preserved when new suggestions are generated."""
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_request.return_value = mock_response
|
|
|
|
existing_items = [
|
|
{"id": "manual-1", "description": "Manual item", "quantity": 1, "unit": "each",
|
|
"totalPrice": 500, "pricingMode": "TotalPrice", "source": "Manual"},
|
|
{"id": "ai-1", "description": "Old AI item", "quantity": 1, "unit": "each",
|
|
"totalPrice": 200, "pricingMode": "TotalPrice", "source": "AI"},
|
|
]
|
|
new_items = [
|
|
{"description": "New AI item", "quantity": 2, "unit": "hours",
|
|
"unitPrice": 100, "totalPrice": 200, "pricingMode": "UnitPrice"},
|
|
]
|
|
|
|
suggestions_app.post_line_items("abc", new_items, existing_items)
|
|
|
|
# Verify the API was called
|
|
mock_request.assert_called_once()
|
|
call_kwargs = mock_request.call_args
|
|
payload = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json")
|
|
|
|
# Should have 2 items: 1 preserved Manual + 1 new AI (old AI items are replaced)
|
|
assert len(payload["lineItems"]) == 2
|
|
sources = [li["source"] for li in payload["lineItems"]]
|
|
assert "Manual" in sources
|
|
assert "AI" in sources
|
|
# The manual item should be first (preserved), AI item second (new)
|
|
assert payload["lineItems"][0]["source"] == "Manual"
|
|
assert payload["lineItems"][0]["description"] == "Manual item"
|
|
assert payload["lineItems"][1]["source"] == "AI"
|
|
assert payload["lineItems"][1]["description"] == "New AI item"
|
|
|
|
@patch.object(suggestions_app, "post_line_items")
|
|
@patch.object(suggestions_app, "generate_line_items")
|
|
@patch.object(suggestions_app, "retrieve_similar")
|
|
@patch.object(suggestions_app, "fetch_line_items")
|
|
@patch.object(suggestions_app, "fetch_proposal")
|
|
def test_process_suggestion_skips_when_no_items_and_no_suggestions(
|
|
self,
|
|
mock_fetch_proposal,
|
|
mock_fetch_items,
|
|
mock_retrieve,
|
|
mock_generate,
|
|
mock_post,
|
|
):
|
|
"""QA-C6: Skips status update when no existing items and no suggestions generated."""
|
|
mock_fetch_proposal.return_value = {
|
|
"id": "abc",
|
|
"scopeOfWork": "Vague scope",
|
|
"serviceCategory": "General",
|
|
"priority": "Standard",
|
|
}
|
|
mock_fetch_items.return_value = []
|
|
mock_retrieve.return_value = []
|
|
mock_generate.return_value = []
|
|
|
|
suggestions_app.process_suggestion("abc", "generate")
|
|
|
|
mock_post.assert_not_called()
|
|
|
|
|
|
class TestRetrySafety:
|
|
"""Test retry and API communication helpers."""
|
|
|
|
def test_get_api_key_caches_result(self):
|
|
"""QA-C6: API key is fetched once and cached for subsequent calls."""
|
|
# Reset cached key
|
|
suggestions_app._cached_api_key = None
|
|
|
|
mock_secrets = MagicMock()
|
|
mock_secrets.get_secret_value.return_value = {"SecretString": "test-key"}
|
|
|
|
original_client = suggestions_app.secrets_client
|
|
original_arn = suggestions_app.INTERNAL_API_KEY_SECRET_ARN
|
|
suggestions_app.secrets_client = mock_secrets
|
|
suggestions_app.INTERNAL_API_KEY_SECRET_ARN = "arn:aws:secretsmanager:us-east-1:123:secret:key"
|
|
try:
|
|
key1 = suggestions_app._get_api_key()
|
|
key2 = suggestions_app._get_api_key()
|
|
|
|
assert key1 == "test-key"
|
|
assert key2 == "test-key"
|
|
# Should only call secrets manager once (cached)
|
|
mock_secrets.get_secret_value.assert_called_once()
|
|
finally:
|
|
suggestions_app.INTERNAL_API_KEY_SECRET_ARN = original_arn
|
|
suggestions_app.secrets_client = original_client
|
|
suggestions_app._cached_api_key = None
|