mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 19:23:15 +00:00
236 lines
9.7 KiB
Python
236 lines
9.7 KiB
Python
|
|
"""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
|