mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 08:53:15 +00:00
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.
151 lines
5.9 KiB
Python
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
|