first commit - MVE Incrementables Parser microservice with FastAPI, JWT, Celery, Redis
This commit is contained in:
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
57
tests/conftest.py
Normal file
57
tests/conftest.py
Normal 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
235
tests/test_api.py
Normal 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
201
tests/test_parser.py
Normal 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
|
||||
Reference in New Issue
Block a user