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>
200 lines
6.7 KiB
Python
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"]
|