"""Tests for library-ingest Lambda handler. PR5: Covers both the existing proposal ingestion path and the new pricing library item ingestion path. """ import importlib import sys import os from unittest.mock import patch from helpers import make_sqs_event # Import library-ingest app under a unique module name (the directory name # contains a hyphen so normal import won't work). _library_ingest_dir = os.path.join(os.path.dirname(__file__), "..", "library-ingest") _spec = importlib.util.spec_from_file_location( "library_ingest_app", os.path.join(_library_ingest_dir, "app.py") ) library_ingest_app = importlib.util.module_from_spec(_spec) sys.modules["library_ingest_app"] = library_ingest_app _spec.loader.exec_module(library_ingest_app) # --------------------------------------------------------------------------- # Sample data # --------------------------------------------------------------------------- SAMPLE_PRICING_ITEM = { "id": "item-abc-123", "title": "Standard HVAC Filter Replacement", "serviceCategory": "HVAC", "unit": "each", "unitPrice": 85.50, "description": "Replace standard 20x25 HVAC filter with MERV-13 rated filter.", "keywords": ["hvac", "filter", "replacement", "merv-13"], "source": "PricingLibrary", } # --------------------------------------------------------------------------- # Handler routing tests # --------------------------------------------------------------------------- class TestHandlerRouting: """Verify the handler correctly routes pricing-item vs proposal messages.""" @patch.object(library_ingest_app, "process_pricing_item_ingestion") def test_handler_routes_pricing_item_message(self, mock_process): """PR5: pricingLibraryItemId in payload routes to pricing item path.""" event = make_sqs_event({"payload": {"pricingLibraryItemId": "item-abc-123"}}) result = library_ingest_app.handler(event, None) mock_process.assert_called_once_with("item-abc-123") assert result["batchItemFailures"] == [] @patch.object(library_ingest_app, "process_ingestion") def test_handler_routes_proposal_message(self, mock_process): """PR5: proposalId in payload still routes to existing proposal path.""" event = make_sqs_event({"payload": {"proposalId": "prop-xyz-789"}}) result = library_ingest_app.handler(event, None) mock_process.assert_called_once_with("prop-xyz-789") assert result["batchItemFailures"] == [] @patch.object(library_ingest_app, "process_pricing_item_ingestion") def test_handler_pricing_item_failure_goes_to_batch_failures(self, mock_process): """PR5: Pricing item processing failure adds messageId to batchItemFailures.""" mock_process.side_effect = RuntimeError("API unreachable") event = make_sqs_event({"payload": {"pricingLibraryItemId": "item-fail"}}) result = library_ingest_app.handler(event, None) assert len(result["batchItemFailures"]) == 1 assert result["batchItemFailures"][0]["itemIdentifier"] == "msg-0" @patch.object(library_ingest_app, "process_pricing_item_ingestion") @patch.object(library_ingest_app, "process_ingestion") def test_handler_mixed_batch(self, mock_proposal, mock_pricing): """PR5: A batch with both message types routes each correctly.""" event = make_sqs_event( {"payload": {"proposalId": "prop-1"}}, {"payload": {"pricingLibraryItemId": "item-2"}}, ) result = library_ingest_app.handler(event, None) mock_proposal.assert_called_once_with("prop-1") mock_pricing.assert_called_once_with("item-2") assert result["batchItemFailures"] == [] # --------------------------------------------------------------------------- # Pricing item ingestion flow # --------------------------------------------------------------------------- class TestProcessPricingItemIngestion: """Test the full pricing item ingestion pipeline.""" @patch.object(library_ingest_app, "trigger_kb_sync") @patch.object(library_ingest_app, "upload_pricing_item_to_library") @patch.object(library_ingest_app, "fetch_pricing_item") def test_full_flow(self, mock_fetch, mock_upload, mock_sync): """PR5: Happy path fetches item, formats, uploads, and triggers sync.""" mock_fetch.return_value = SAMPLE_PRICING_ITEM mock_upload.return_value = "pricing-library/hvac/item-abc-123.md" library_ingest_app.process_pricing_item_ingestion("item-abc-123") mock_fetch.assert_called_once_with("item-abc-123") mock_upload.assert_called_once() mock_sync.assert_called_once() @patch.object(library_ingest_app, "trigger_kb_sync") @patch.object(library_ingest_app, "upload_pricing_item_to_library") @patch.object(library_ingest_app, "fetch_pricing_item") def test_skips_when_item_not_found(self, mock_fetch, mock_upload, mock_sync): """PR5: Missing item logs warning and returns without failing the batch.""" mock_fetch.return_value = None library_ingest_app.process_pricing_item_ingestion("nonexistent") mock_upload.assert_not_called() mock_sync.assert_not_called() @patch.object(library_ingest_app, "trigger_kb_sync") @patch.object(library_ingest_app, "upload_pricing_item_to_library") @patch.object(library_ingest_app, "fetch_pricing_item") def test_skips_sync_when_upload_fails(self, mock_fetch, mock_upload, mock_sync): """PR5: No KB sync when S3 upload returns None.""" mock_fetch.return_value = SAMPLE_PRICING_ITEM mock_upload.return_value = None library_ingest_app.process_pricing_item_ingestion("item-abc-123") mock_upload.assert_called_once() mock_sync.assert_not_called() # --------------------------------------------------------------------------- # format_pricing_item_document # --------------------------------------------------------------------------- class TestFormatPricingItemDocument: """Test markdown formatting for pricing items.""" def test_format_includes_all_fields(self): """PR5: All pricing item fields appear in the formatted document.""" doc = library_ingest_app.format_pricing_item_document(SAMPLE_PRICING_ITEM) assert "# Pricing Item: Standard HVAC Filter Replacement" in doc assert "**Service Category:** HVAC" in doc assert "**Unit:** each" in doc assert "**Unit Price:** $85.50" in doc assert "**Source:** PricingLibrary" in doc assert "Replace standard 20x25 HVAC filter" in doc assert "hvac, filter, replacement, merv-13" in doc def test_format_handles_missing_unit_price(self): """PR5: None unitPrice renders as dash.""" item = {**SAMPLE_PRICING_ITEM, "unitPrice": None} doc = library_ingest_app.format_pricing_item_document(item) assert "**Unit Price:** -" in doc def test_format_handles_empty_keywords(self): """PR5: Empty keywords list produces empty keywords section.""" item = {**SAMPLE_PRICING_ITEM, "keywords": []} doc = library_ingest_app.format_pricing_item_document(item) # Keywords section header exists, value is empty string assert "## Keywords" in doc def test_format_handles_string_keywords(self): """PR5: String keywords (non-list) are rendered as-is.""" item = {**SAMPLE_PRICING_ITEM, "keywords": "hvac, filters"} doc = library_ingest_app.format_pricing_item_document(item) assert "hvac, filters" in doc # --------------------------------------------------------------------------- # upload_pricing_item_to_library # --------------------------------------------------------------------------- class TestUploadPricingItemToLibrary: """Test S3 upload for pricing items.""" @patch.object(library_ingest_app, "s3") def test_upload_uses_pricing_library_prefix(self, mock_s3): """PR5: S3 key uses pricing-library/ prefix, not proposals/.""" original_bucket = library_ingest_app.LIBRARY_BUCKET library_ingest_app.LIBRARY_BUCKET = "test-library-bucket" try: result = library_ingest_app.upload_pricing_item_to_library( SAMPLE_PRICING_ITEM, "# Test doc" ) assert result == "pricing-library/hvac/item-abc-123.md" mock_s3.put_object.assert_called_once() call_kwargs = mock_s3.put_object.call_args[1] assert call_kwargs["Key"] == "pricing-library/hvac/item-abc-123.md" assert call_kwargs["Bucket"] == "test-library-bucket" assert call_kwargs["ContentType"] == "text/markdown" finally: library_ingest_app.LIBRARY_BUCKET = original_bucket def test_upload_returns_none_when_no_bucket(self): """PR5: Returns None when LIBRARY_BUCKET is not configured.""" original_bucket = library_ingest_app.LIBRARY_BUCKET library_ingest_app.LIBRARY_BUCKET = "" try: result = library_ingest_app.upload_pricing_item_to_library( SAMPLE_PRICING_ITEM, "# Test doc" ) assert result is None finally: library_ingest_app.LIBRARY_BUCKET = original_bucket @patch.object(library_ingest_app, "s3") def test_upload_lowercases_category(self, mock_s3): """PR5: Category in S3 key is lowercased.""" original_bucket = library_ingest_app.LIBRARY_BUCKET library_ingest_app.LIBRARY_BUCKET = "test-bucket" item = {**SAMPLE_PRICING_ITEM, "serviceCategory": "Plumbing"} try: result = library_ingest_app.upload_pricing_item_to_library( item, "# Test doc" ) assert result is not None assert "pricing-library/plumbing/" in result finally: library_ingest_app.LIBRARY_BUCKET = original_bucket