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>
This commit is contained in:
199
ocr/tests/test_vin_decode.py
Normal file
199
ocr/tests/test_vin_decode.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""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"]
|
||||
Reference in New Issue
Block a user