proposal-system/lambdas/tests/test_library_ingest.py
Adam Moussa 5d84399a0d
Some checks are pending
Deploy / Deploy to AWS (push) Waiting to run
feat: pricing library — curated priced items feed the RAG corpus (#127)
Adds a managed pricing library so admins can seed/curate reference priced items
directly, instead of the corpus being populated only by ingesting Sent proposals.
v1 PR5.

API:
- PricingLibraryItem entity + migration; /api/pricing-library CRUD (admin), with
  GET {id} reachable by internal Lambda callers (admins role via internal key).
- Create/update publish an ADDITIVE library-ingest SQS job {pricingLibraryItemId},
  wrapped so a publish failure never rolls back the save.

Lambda (library-ingest):
- Additive event-shape branch: pricingLibraryItemId -> fetch item, format markdown,
  upload to pricing-library/{category}/{id}.md, trigger KB sync. The existing
  proposalId path is byte-for-byte unchanged. Explicit error when neither id present;
  warns when both present.

Web:
- Pricing Library management page (/admin/pricing-library): list / create / edit / delete.

GPT-4.1 cross-review on the event-shape change: no BLOCK (neither/both-id handling
applied). Verified: api 159 tests; web tsc + 26 tests; lambdas ruff + 37 pytest.
2026-06-18 12:49:47 -04:00

235 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