mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 04:13:13 +00:00
Some checks are pending
Deploy / Deploy to AWS (push) Waiting to run
The .NET API Lambda Function URL uses authType=AWS_IAM, but the four workload Lambdas (suggestions, pdf-extract, pdf-generate, library-ingest) sent unsigned requests with only X-Internal-Api-Key -> every internal call 403s. They also used bare fromAsset() with no pip bundling -> ImportError at cold start. Both made the SQS->Lambda->API pipeline non-functional when deployed (v1 pre-flight). - Add _sign_request_headers (botocore SigV4Auth, service "lambda"); serialize the JSON body once and send via httpx content= so the signed payload hash matches the bytes sent; preserve X-Internal-Api-Key for the app-layer check. Sign per retry attempt to avoid SigV4 timestamp expiry on slow retries. - Add CDK pip bundling (--platform manylinux2014_aarch64 --only-binary=:all:) to all four Lambdas so ARM64 wheels (reportlab, Pillow, pdfplumber) ship. - Converge _retry_request across all four (fixes possibly-undefined return in pdf-extract/pdf-generate). - Add SigV4 signing regression tests. Verified: ruff clean, infra tsc clean, aarch64 wheels resolve for all four, 23 pytest pass. GPT-4.1 cross-family review: no BLOCK (FIX + NIT applied).
151 lines
5.8 KiB
Python
151 lines
5.8 KiB
Python
"""Tests for pdf-generate Lambda handler.
|
|
|
|
QA-C6 (partial): Verifies SQS batch processing, error handling, and
|
|
batch failure reporting for the PDF generation pipeline.
|
|
"""
|
|
|
|
import importlib
|
|
import sys
|
|
import os
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
|
|
from helpers import make_sqs_event
|
|
|
|
# Import pdf-generate app under a unique module name to avoid collision with
|
|
# suggestions/app.py (both are named 'app').
|
|
_pdf_gen_dir = os.path.join(os.path.dirname(__file__), "..", "pdf-generate")
|
|
_spec = importlib.util.spec_from_file_location(
|
|
"pdf_generate_app", os.path.join(_pdf_gen_dir, "app.py")
|
|
)
|
|
pdf_generate_app = importlib.util.module_from_spec(_spec)
|
|
sys.modules["pdf_generate_app"] = pdf_generate_app
|
|
_spec.loader.exec_module(pdf_generate_app)
|
|
|
|
|
|
class TestPdfGenerateHandler:
|
|
"""Test the SQS handler entry point for pdf-generate Lambda."""
|
|
|
|
@patch.object(pdf_generate_app, "generate_pdf")
|
|
def test_handler_processes_sqs_record_successfully(self, mock_generate_pdf):
|
|
"""QA-C6: Handler extracts proposalId from SQS body and calls generate_pdf."""
|
|
mock_generate_pdf.return_value = None
|
|
|
|
event = make_sqs_event({"payload": {"proposalId": "abc-123"}})
|
|
result = pdf_generate_app.handler(event, None)
|
|
|
|
mock_generate_pdf.assert_called_once_with("abc-123")
|
|
assert result["batchItemFailures"] == []
|
|
|
|
@patch.object(pdf_generate_app, "generate_pdf")
|
|
def test_handler_processes_multiple_records(self, mock_generate_pdf):
|
|
"""QA-C6: Handler processes all records in an SQS batch."""
|
|
mock_generate_pdf.return_value = None
|
|
|
|
event = make_sqs_event(
|
|
{"payload": {"proposalId": "id-1"}},
|
|
{"payload": {"proposalId": "id-2"}},
|
|
{"payload": {"proposalId": "id-3"}},
|
|
)
|
|
result = pdf_generate_app.handler(event, None)
|
|
|
|
assert mock_generate_pdf.call_count == 3
|
|
assert result["batchItemFailures"] == []
|
|
|
|
@patch.object(pdf_generate_app, "generate_pdf")
|
|
def test_handler_returns_batch_failures_on_error(self, mock_generate_pdf):
|
|
"""QA-C6: Handler returns failed message IDs for partial batch failure."""
|
|
mock_generate_pdf.side_effect = [None, Exception("PDF generation failed"), None]
|
|
|
|
event = make_sqs_event(
|
|
{"payload": {"proposalId": "id-1"}},
|
|
{"payload": {"proposalId": "id-2"}},
|
|
{"payload": {"proposalId": "id-3"}},
|
|
)
|
|
result = pdf_generate_app.handler(event, None)
|
|
|
|
assert len(result["batchItemFailures"]) == 1
|
|
assert result["batchItemFailures"][0]["itemIdentifier"] == "msg-1"
|
|
|
|
@patch.object(pdf_generate_app, "generate_pdf")
|
|
def test_handler_handles_malformed_body(self, mock_generate_pdf):
|
|
"""QA-C6: Handler reports failure for records with invalid JSON body."""
|
|
event = {
|
|
"Records": [
|
|
{
|
|
"messageId": "msg-bad",
|
|
"body": "not-valid-json",
|
|
"receiptHandle": "handle-0",
|
|
}
|
|
]
|
|
}
|
|
result = pdf_generate_app.handler(event, None)
|
|
|
|
assert len(result["batchItemFailures"]) == 1
|
|
assert result["batchItemFailures"][0]["itemIdentifier"] == "msg-bad"
|
|
mock_generate_pdf.assert_not_called()
|
|
|
|
@patch.object(pdf_generate_app, "generate_pdf")
|
|
def test_handler_handles_missing_proposal_id(self, mock_generate_pdf):
|
|
"""QA-C6: Handler reports failure when proposalId is missing from payload."""
|
|
event = make_sqs_event({"payload": {}})
|
|
result = pdf_generate_app.handler(event, None)
|
|
|
|
assert len(result["batchItemFailures"]) == 1
|
|
mock_generate_pdf.assert_not_called()
|
|
|
|
@patch.object(pdf_generate_app, "generate_pdf")
|
|
def test_handler_handles_empty_records(self, mock_generate_pdf):
|
|
"""QA-C6: Handler handles empty Records array gracefully."""
|
|
result = pdf_generate_app.handler({"Records": []}, None)
|
|
|
|
assert result["batchItemFailures"] == []
|
|
mock_generate_pdf.assert_not_called()
|
|
|
|
@patch.object(pdf_generate_app, "generate_pdf")
|
|
def test_handler_handles_body_without_payload_wrapper(self, mock_generate_pdf):
|
|
"""QA-C6: Handler supports body with proposalId at top level (no payload wrapper)."""
|
|
mock_generate_pdf.return_value = None
|
|
|
|
event = make_sqs_event({"proposalId": "direct-id"})
|
|
result = pdf_generate_app.handler(event, None)
|
|
|
|
mock_generate_pdf.assert_called_once_with("direct-id")
|
|
assert result["batchItemFailures"] == []
|
|
|
|
|
|
class TestPdfGenerateHelpers:
|
|
"""Test helper functions in the pdf-generate Lambda."""
|
|
|
|
@patch.object(pdf_generate_app, "_retry_request")
|
|
def test_fetch_proposal_returns_dict_on_200(self, mock_retry):
|
|
"""QA-C6: fetch_proposal returns proposal dict on 200 response."""
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = {"id": "abc", "proposalNumber": "P-001"}
|
|
mock_retry.return_value = mock_response
|
|
|
|
result = pdf_generate_app.fetch_proposal("abc")
|
|
|
|
assert result is not None
|
|
assert result["proposalNumber"] == "P-001"
|
|
|
|
@patch.object(pdf_generate_app, "_retry_request")
|
|
def test_fetch_proposal_returns_none_on_404(self, mock_retry):
|
|
"""QA-C6: fetch_proposal returns None when API returns 404."""
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 404
|
|
mock_retry.return_value = mock_response
|
|
|
|
result = pdf_generate_app.fetch_proposal("nonexistent")
|
|
|
|
assert result is None
|
|
|
|
@patch.object(pdf_generate_app, "_retry_request")
|
|
def test_fetch_proposal_returns_none_on_exception(self, mock_retry):
|
|
"""QA-C6: fetch_proposal returns None on network error."""
|
|
mock_retry.side_effect = Exception("Connection refused")
|
|
|
|
result = pdf_generate_app.fetch_proposal("abc")
|
|
|
|
assert result is None
|