proposal-system/lambdas/tests/test_pdf_generate.py
Adam Moussa d21b1c5edb test: bootstrap test infrastructure with critical coverage (QA-C1 through QA-C6)
QA-C1: Create xUnit test project, add to solution, wire dependencies
- api/tests/ProposalSystem.Tests with xUnit + FluentAssertions + NSubstitute
- InMemory EF Core provider for isolated DB tests

QA-C2: Proposal state machine transition tests (16 tests)
- Valid: InReview->Approved, Approved->Sent, Sent->Revised
- Invalid: InReview->Sent, Approved->Revised, Draft->Approved, etc.
- Edge cases: idempotency, missing line items, revision line item copying
- Audit and job publisher verification

QA-C3: Authorization attribute tests (16 tests)
- Controller-level [Authorize] on all controllers except AuthController
- Role requirements: admins/sysadmins on admin actions
- Dispatcher exclusion from admin/sysadmin routes
- SysAdmin-only user management enforcement

QA-C4: InternalApiKeyMiddleware tests (8 tests)
- Valid key sets claims and calls next
- Invalid key passes through to JWT (no 401/403)
- Missing key/empty config disables middleware
- Documents API-C1 vulnerability (key works on any path)

QA-C5: ProtectedRoute and RoleGuard tests (12 tests, vitest)
- ProtectedRoute: renders children when authenticated, redirects when not
- RoleGuard: role-based access, dispatcher/admin/sysadmin enforcement
- authSlice: setUser, logout, expired token handling

QA-C6: Lambda SQS handler tests (19 tests, pytest)
- pdf-generate: batch processing, failure reporting, malformed body
- suggestions: batch processing, proposal-not-found skip, AI item preservation
- API key caching, retry helpers

Total: 107 tests (76 .NET + 12 web + 19 Python), all passing.
2026-05-27 18:18:44 -04:00

151 lines
5.9 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 json
import sys
import os
from unittest.mock import MagicMock, patch
import pytest
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