first commit - MVE Incrementables Parser microservice with FastAPI, JWT, Celery, Redis

This commit is contained in:
Ernesto Herrera
2026-03-02 21:31:50 -07:00
commit 068d859f42
27 changed files with 2337 additions and 0 deletions

0
tests/__init__.py Normal file
View File

57
tests/conftest.py Normal file
View File

@@ -0,0 +1,57 @@
"""Pytest configuration and fixtures."""
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.core.config import Settings, get_settings
import bcrypt
# Test settings
TEST_PASSWORD = "test_password_123"
TEST_PASSWORD_HASH = bcrypt.hashpw(TEST_PASSWORD.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
def get_test_settings():
"""Override settings for testing."""
return Settings(
auth_username="testuser",
auth_password_hash=TEST_PASSWORD_HASH,
jwt_secret="test-secret-key-for-testing-only",
jwt_expires_minutes=60,
max_file_mb=10,
log_level="DEBUG",
service_name="mve-incrementables-parser",
service_version="1.0.0"
)
@pytest.fixture
def test_settings():
"""Provide test settings."""
return get_test_settings()
@pytest.fixture
def client(test_settings):
"""Create a test client with overridden settings."""
app.dependency_overrides[get_settings] = lambda: test_settings
with TestClient(app) as test_client:
yield test_client
app.dependency_overrides.clear()
@pytest.fixture
def auth_token(client):
"""Get authentication token for testing."""
response = client.post(
"/auth/login",
json={"username": "testuser", "password": TEST_PASSWORD}
)
assert response.status_code == 200
return response.json()["access_token"]
@pytest.fixture
def auth_headers(auth_token):
"""Get authorization headers for testing."""
return {"Authorization": f"Bearer {auth_token}"}

235
tests/test_api.py Normal file
View File

@@ -0,0 +1,235 @@
"""Tests for API endpoints."""
import pytest
from io import BytesIO
class TestHealthEndpoint:
"""Test health check endpoint."""
def test_health_check(self, client):
"""Test health endpoint returns correct status."""
response = client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
assert data["service"] == "mve-incrementables-parser"
assert data["version"] == "1.0.0"
class TestAuthEndpoint:
"""Test authentication endpoints."""
def test_login_success(self, client):
"""Test successful login."""
response = client.post(
"/auth/login",
json={"username": "testuser", "password": "test_password_123"}
)
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
assert data["expires_in"] == 3600
def test_login_invalid_username(self, client):
"""Test login with invalid username."""
response = client.post(
"/auth/login",
json={"username": "wronguser", "password": "test_password_123"}
)
assert response.status_code == 401
data = response.json()
assert data["detail"] == "Invalid credentials"
def test_login_invalid_password(self, client):
"""Test login with invalid password."""
response = client.post(
"/auth/login",
json={"username": "testuser", "password": "wrongpassword"}
)
assert response.status_code == 401
data = response.json()
assert data["detail"] == "Invalid credentials"
def test_login_missing_fields(self, client):
"""Test login with missing fields."""
response = client.post(
"/auth/login",
json={"username": "testuser"}
)
assert response.status_code == 422 # Validation error
class TestParseEndpoint:
"""Test incrementables parse endpoint."""
def create_mock_pdf(self, content: str) -> BytesIO:
"""Create a simple mock PDF file for testing."""
# This is a minimal PDF structure
pdf_content = f"""%PDF-1.4
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R >>
endobj
4 0 obj
<< /Length {len(content)} >>
stream
{content}
endstream
endobj
xref
0 5
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000214 00000 n
trailer
<< /Size 5 /Root 1 0 R >>
startxref
{300 + len(content)}
%%EOF"""
return BytesIO(pdf_content.encode())
def test_parse_requires_auth(self, client):
"""Test parse endpoint requires authentication."""
files = {"file": ("test.pdf", BytesIO(b"fake pdf"), "application/pdf")}
response = client.post("/v1/incrementables/parse", files=files)
assert response.status_code == 403 # Forbidden without auth
def test_parse_invalid_file_type(self, client, auth_headers):
"""Test parse endpoint rejects non-PDF files."""
files = {"file": ("test.txt", BytesIO(b"not a pdf"), "text/plain")}
response = client.post(
"/v1/incrementables/parse",
files=files,
headers=auth_headers
)
assert response.status_code == 400
assert "PDF" in response.json()["detail"]
def test_parse_invalid_file_extension(self, client, auth_headers):
"""Test parse endpoint rejects files without .pdf extension."""
files = {"file": ("test.txt", BytesIO(b"content"), "application/pdf")}
response = client.post(
"/v1/incrementables/parse",
files=files,
headers=auth_headers
)
assert response.status_code == 400
assert "PDF" in response.json()["detail"]
def test_parse_with_correlation_id(self, client, auth_headers):
"""Test parse endpoint accepts correlation ID."""
pdf_content = self.create_mock_pdf("test content")
files = {"file": ("test.pdf", pdf_content, "application/pdf")}
headers = {**auth_headers, "X-Correlation-Id": "test-correlation-123"}
response = client.post(
"/v1/incrementables/parse",
files=files,
headers=headers
)
# Will fail at extraction/parsing stage, but should accept the correlation ID
# Either 422 (parsing failed) or 500 (extraction failed)
assert response.status_code in [422, 500]
def test_parse_with_document_ref(self, client, auth_headers):
"""Test parse endpoint accepts document reference."""
pdf_content = self.create_mock_pdf("test content")
files = {"file": ("test.pdf", pdf_content, "application/pdf")}
data = {"document_ref": "REF-12345"}
response = client.post(
"/v1/incrementables/parse",
files=files,
data=data,
headers=auth_headers
)
# Will fail at extraction/parsing stage
assert response.status_code in [422, 500]
def test_parse_generates_correlation_id(self, client, auth_headers):
"""Test parse endpoint generates correlation ID if not provided."""
pdf_content = self.create_mock_pdf("test content")
files = {"file": ("test.pdf", pdf_content, "application/pdf")}
response = client.post(
"/v1/incrementables/parse",
files=files,
headers=auth_headers
)
# Even on error, should have correlation_id in some responses
# This test verifies the endpoint is reachable
assert response.status_code in [422, 500]
class TestAuthIntegration:
"""Test authentication integration."""
def test_expired_token_rejected(self, client, test_settings):
"""Test that expired tokens are rejected."""
from app.core.security import create_access_token
from datetime import timedelta
# Create an already-expired token
expired_token = create_access_token(
data={"sub": "testuser"},
expires_delta=timedelta(seconds=-1)
)
headers = {"Authorization": f"Bearer {expired_token}"}
pdf_content = BytesIO(b"fake pdf")
files = {"file": ("test.pdf", pdf_content, "application/pdf")}
response = client.post(
"/v1/incrementables/parse",
files=files,
headers=headers
)
assert response.status_code == 401
def test_invalid_token_format(self, client):
"""Test that invalid token format is rejected."""
headers = {"Authorization": "Bearer invalid_token_xyz"}
pdf_content = BytesIO(b"fake pdf")
files = {"file": ("test.pdf", pdf_content, "application/pdf")}
response = client.post(
"/v1/incrementables/parse",
files=files,
headers=headers
)
assert response.status_code == 401
def test_missing_bearer_prefix(self, client, auth_token):
"""Test that token without Bearer prefix is rejected."""
headers = {"Authorization": auth_token}
pdf_content = BytesIO(b"fake pdf")
files = {"file": ("test.pdf", pdf_content, "application/pdf")}
response = client.post(
"/v1/incrementables/parse",
files=files,
headers=headers
)
assert response.status_code == 403

201
tests/test_parser.py Normal file
View File

@@ -0,0 +1,201 @@
"""Tests for the incrementables parser service."""
import pytest
from app.services.parser import IncrementablesParser, ParsingError, parse_incrementables
# Sample text fixtures
SAMPLE_TEXT_COMPLETE = """
CARTA INSTRUCTIVO DE EMBARQUE No. 228718
AJUSTE DE INCREMENTABLES EN:
Fletes: $1,591.20 USD
Seguros: $250.50 USD
Almacenaje/Consolidación: $100.00 USD
Regalías: $75.00 USD
Total: $2,016.70 USD
"""
SAMPLE_TEXT_EMPTY_FIELDS = """
AJUSTE DE INCREMENTABLES EN:
Fletes: $1,591.20 USD
Seguros: USD
Almacenaje/Consolidación: $0.00 USD
Regalías: USD
Observaciones: Los seguros y regalías están vacíos
"""
SAMPLE_TEXT_NO_ANCHOR = """
Este es un documento sin la sección de incrementables.
Solo tiene texto normal sin el ancla esperada.
"""
SAMPLE_TEXT_VARIATIONS = """
INCREMENTABLES EN:
Fletes: $2,500.00 USD
Seguros: USD
Almacenaje Consolidacion: $150.75 USD
Regalias: $300.00 USD
"""
class TestIncrementablesParser:
"""Test cases for IncrementablesParser class."""
def test_parse_complete_data(self):
"""Test parsing with all fields present."""
parser = IncrementablesParser(SAMPLE_TEXT_COMPLETE)
result = parser.parse()
assert result["currency"] == "USD"
assert result["fletes"] == 1591.20
assert result["seguros"] == 250.50
assert result["almacenaje_consolidacion"] == 100.00
assert result["regalias"] == 75.00
assert len(result["anchors_found"]) > 0
def test_parse_empty_fields(self):
"""Test parsing with empty seguros and regalias."""
parser = IncrementablesParser(SAMPLE_TEXT_EMPTY_FIELDS)
result = parser.parse()
assert result["currency"] == "USD"
assert result["fletes"] == 1591.20
assert result["seguros"] is None
assert result["almacenaje_consolidacion"] == 0.00
assert result["regalias"] is None
def test_parse_no_anchor(self):
"""Test parsing fails when anchor not found."""
parser = IncrementablesParser(SAMPLE_TEXT_NO_ANCHOR)
with pytest.raises(ParsingError) as exc_info:
parser.parse()
assert "not found" in str(exc_info.value).lower()
def test_parse_variations(self):
"""Test parsing with text variations."""
parser = IncrementablesParser(SAMPLE_TEXT_VARIATIONS)
result = parser.parse()
assert result["currency"] == "USD"
assert result["fletes"] == 2500.00
assert result["seguros"] is None
assert result["almacenaje_consolidacion"] == 150.75
assert result["regalias"] == 300.00
def test_parse_amount_with_commas(self):
"""Test amount parsing with comma separators."""
parser = IncrementablesParser(SAMPLE_TEXT_COMPLETE)
amount = parser._parse_amount("1,591.20")
assert amount == 1591.20
def test_parse_amount_with_dollar(self):
"""Test amount parsing with dollar sign."""
parser = IncrementablesParser(SAMPLE_TEXT_COMPLETE)
amount = parser._parse_amount("$1,591.20")
assert amount == 1591.20
def test_parse_amount_empty(self):
"""Test amount parsing with empty string."""
parser = IncrementablesParser(SAMPLE_TEXT_COMPLETE)
amount = parser._parse_amount("")
assert amount is None
def test_parse_amount_none(self):
"""Test amount parsing with None."""
parser = IncrementablesParser(SAMPLE_TEXT_COMPLETE)
amount = parser._parse_amount(None)
assert amount is None
def test_extract_currency_default(self):
"""Test currency extraction defaults to USD."""
parser = IncrementablesParser("Text without currency")
currency = parser._extract_currency("No currency here")
assert currency == "USD"
assert len(parser.warnings) > 0
assert "currency" in parser.warnings[0].lower()
def test_find_incrementables_section(self):
"""Test finding incrementables section."""
parser = IncrementablesParser(SAMPLE_TEXT_COMPLETE)
section = parser._find_incrementables_section()
assert section is not None
assert "Fletes" in section
assert len(parser.anchors_found) > 0
def test_parse_incrementables_function():
"""Test the main parse_incrementables function."""
result = parse_incrementables(SAMPLE_TEXT_COMPLETE)
assert result["currency"] == "USD"
assert result["fletes"] == 1591.20
assert "anchors_found" in result
assert "warnings" in result
def test_parse_incrementables_raises_error():
"""Test parse_incrementables raises error on invalid input."""
with pytest.raises(ParsingError):
parse_incrementables(SAMPLE_TEXT_NO_ANCHOR)
class TestParserEdgeCases:
"""Test edge cases and error conditions."""
def test_missing_fletes(self):
"""Test that missing fletes raises error."""
text = """
AJUSTE DE INCREMENTABLES EN:
Seguros: $100.00 USD
Almacenaje: $50.00 USD
"""
parser = IncrementablesParser(text)
with pytest.raises(ParsingError) as exc_info:
parser.parse()
assert "fletes" in str(exc_info.value).lower()
def test_missing_almacenaje(self):
"""Test that missing almacenaje raises error."""
text = """
AJUSTE DE INCREMENTABLES EN:
Fletes: $1000.00 USD
Seguros: $100.00 USD
"""
parser = IncrementablesParser(text)
with pytest.raises(ParsingError) as exc_info:
parser.parse()
assert "almacenaje" in str(exc_info.value).lower()
def test_case_insensitive_anchor(self):
"""Test anchor detection is case insensitive."""
text_lower = "ajuste de incrementables en:\nFletes: $100.00 USD\nAlmacenaje: $50.00 USD"
parser = IncrementablesParser(text_lower)
section = parser._find_incrementables_section()
assert section is not None
def test_accent_variations_regalias(self):
"""Test that both 'regalías' and 'regalias' work."""
text_with_accent = """
AJUSTE DE INCREMENTABLES EN:
Fletes: $100.00 USD
Almacenaje: $50.00 USD
Regalías: $25.00 USD
"""
parser = IncrementablesParser(text_with_accent)
result = parser.parse()
assert result["regalias"] == 25.00