proposal-system/lambdas/tests/test_pdf_generate.py
Adam Moussa 3d050bcf8e
Some checks are pending
Deploy / Deploy to AWS (push) Waiting to run
fix(lambdas): SigV4-sign internal API calls and bundle Lambda dependencies (#122)
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).
2026-06-12 17:13:08 -04:00

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