Files
motovaultpro/ocr/tests/test_vin_decode.py
Eric Gullickson a75f7b5583 feat: add VIN decode endpoint to OCR Python service (refs #224)
Add POST /decode/vin endpoint using Gemini 2.5 Flash for VIN string
decoding. Returns structured vehicle data (year, make, model, trim,
body/drive/fuel type, engine, transmission) with confidence score.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 21:40:10 -06:00

200 lines
6.7 KiB
Python

"""Tests for the VIN decode endpoint (POST /decode/vin).
Covers: valid VIN returns 200 with correct response shape,
invalid VIN format returns 400, Gemini unavailable returns 503,
and Gemini processing error returns 422.
All GeminiEngine calls are mocked.
"""
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from app.engines.gemini_engine import (
GeminiProcessingError,
GeminiUnavailableError,
VinDecodeResult,
)
from app.main import app
client = TestClient(app)
# A valid 17-character VIN (no I, O, Q)
_VALID_VIN = "1HGBH41JXMN109186"
_FULL_RESULT = VinDecodeResult(
year=2021,
make="Honda",
model="Civic",
trim_level="EX",
body_type="Sedan",
drive_type="FWD",
fuel_type="Gasoline",
engine="2.0L I4",
transmission="CVT",
confidence=0.95,
)
# --- Valid VIN ---
class TestDecodeVinSuccess:
"""Verify successful VIN decode returns 200 with correct response shape."""
@patch("app.routers.decode._gemini_engine")
def test_valid_vin_returns_200(self, mock_engine):
"""Normal: Valid VIN returns 200 with all vehicle fields populated."""
mock_engine.decode_vin.return_value = _FULL_RESULT
response = client.post("/decode/vin", json={"vin": _VALID_VIN})
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["vin"] == _VALID_VIN
assert data["year"] == 2021
assert data["make"] == "Honda"
assert data["model"] == "Civic"
assert data["trimLevel"] == "EX"
assert data["bodyType"] == "Sedan"
assert data["driveType"] == "FWD"
assert data["fuelType"] == "Gasoline"
assert data["engine"] == "2.0L I4"
assert data["transmission"] == "CVT"
assert data["confidence"] == 0.95
assert "processingTimeMs" in data
assert data["error"] is None
@patch("app.routers.decode._gemini_engine")
def test_vin_uppercased_before_decode(self, mock_engine):
"""VIN submitted in lowercase is normalised to uppercase before decoding."""
mock_engine.decode_vin.return_value = _FULL_RESULT
response = client.post("/decode/vin", json={"vin": _VALID_VIN.lower()})
assert response.status_code == 200
data = response.json()
assert data["vin"] == _VALID_VIN
mock_engine.decode_vin.assert_called_once_with(_VALID_VIN)
@patch("app.routers.decode._gemini_engine")
def test_nullable_fields_allowed(self, mock_engine):
"""Edge: VIN decode with only confidence set returns valid response."""
mock_engine.decode_vin.return_value = VinDecodeResult(confidence=0.3)
response = client.post("/decode/vin", json={"vin": _VALID_VIN})
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["year"] is None
assert data["make"] is None
assert data["confidence"] == 0.3
# --- Invalid VIN format ---
class TestDecodeVinValidation:
"""Verify invalid VIN formats return 400."""
def test_too_short_vin_returns_400(self):
"""VIN shorter than 17 characters is rejected."""
response = client.post("/decode/vin", json={"vin": "1HGBH41JXM"})
assert response.status_code == 400
assert "Invalid VIN format" in response.json()["detail"]
def test_too_long_vin_returns_400(self):
"""VIN longer than 17 characters is rejected."""
response = client.post("/decode/vin", json={"vin": "1HGBH41JXMN109186X"})
assert response.status_code == 400
def test_vin_with_letter_i_returns_400(self):
"""VIN containing the letter I (invalid character) is rejected."""
# Replace position 0 with I to create invalid VIN
invalid_vin = "IHGBH41JXMN109186"
response = client.post("/decode/vin", json={"vin": invalid_vin})
assert response.status_code == 400
assert "Invalid VIN format" in response.json()["detail"]
def test_vin_with_letter_o_returns_400(self):
"""VIN containing the letter O (invalid character) is rejected."""
invalid_vin = "OHGBH41JXMN109186"
response = client.post("/decode/vin", json={"vin": invalid_vin})
assert response.status_code == 400
def test_vin_with_letter_q_returns_400(self):
"""VIN containing the letter Q (invalid character) is rejected."""
invalid_vin = "QHGBH41JXMN109186"
response = client.post("/decode/vin", json={"vin": invalid_vin})
assert response.status_code == 400
def test_empty_vin_returns_400(self):
"""Empty VIN string is rejected."""
response = client.post("/decode/vin", json={"vin": ""})
assert response.status_code == 400
def test_vin_with_special_chars_returns_400(self):
"""VIN containing special characters is rejected."""
response = client.post("/decode/vin", json={"vin": "1HGBH41J-MN109186"})
assert response.status_code == 400
# --- Gemini unavailable ---
class TestDecodeVinGeminiUnavailable:
"""Verify Gemini service unavailability returns 503."""
@patch("app.routers.decode._gemini_engine")
def test_gemini_unavailable_returns_503(self, mock_engine):
"""When Gemini cannot be initialized, endpoint returns 503."""
mock_engine.decode_vin.side_effect = GeminiUnavailableError(
"Google credential config not found"
)
response = client.post("/decode/vin", json={"vin": _VALID_VIN})
assert response.status_code == 503
assert "Google credential config not found" in response.json()["detail"]
# --- Gemini processing error ---
class TestDecodeVinGeminiProcessingError:
"""Verify Gemini processing failures return 422."""
@patch("app.routers.decode._gemini_engine")
def test_gemini_processing_error_returns_422(self, mock_engine):
"""When Gemini returns invalid output, endpoint returns 422."""
mock_engine.decode_vin.side_effect = GeminiProcessingError(
"Gemini returned invalid JSON for VIN decode: ..."
)
response = client.post("/decode/vin", json={"vin": _VALID_VIN})
assert response.status_code == 422
assert "Gemini returned invalid JSON" in response.json()["detail"]
@patch("app.routers.decode._gemini_engine")
def test_gemini_api_failure_returns_422(self, mock_engine):
"""When Gemini API call fails at runtime, endpoint returns 422."""
mock_engine.decode_vin.side_effect = GeminiProcessingError(
"Gemini VIN decode failed: API quota exceeded"
)
response = client.post("/decode/vin", json={"vin": _VALID_VIN})
assert response.status_code == 422
assert "Gemini VIN decode failed" in response.json()["detail"]