Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ python -m app.dump # generate ./dump/v1/... static tree

`TECHAPI_DATA_DIR=/path/to/TechAPI/data` overrides the data location.

For tablets, watches, and PDAs, `battery_mah` and `weight_g` are optional:
seed records may omit them or use `null` when no source value is known, and API
detail responses and static dumps preserve unknown values as `null`.
Smartphones continue to require numeric values for both fields.

### Docker Compose (Postgres)

```bash
Expand Down
4 changes: 2 additions & 2 deletions app/models/mobile_device.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,11 @@ class MobileDeviceFields(SQLModel):
display: dict[str, Any] = Field(default_factory=dict, sa_type=JSON)
cameras: list[dict[str, Any]] = Field(default_factory=list, sa_type=JSON)

battery_mah: int
battery_mah: int | None = None
charging_wired_w: float | None = None
charging_wireless_w: float | None = None

weight_g: float
weight_g: float | None = None
dimensions: dict[str, Any] = Field(default_factory=dict, sa_type=JSON)
ip_rating: str | None = None

Expand Down
4 changes: 2 additions & 2 deletions app/schemas/mobile_device.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ class MobileDeviceRead(BaseModel):
variant: dict[str, Any]
display: dict[str, Any]
cameras: list[dict[str, Any]]
battery_mah: int
battery_mah: int | None = None
charging_wired_w: float | None = None
charging_wireless_w: float | None = None
weight_g: float
weight_g: float | None = None
dimensions: dict[str, Any]
ip_rating: str | None = None
os: str
Expand Down
2 changes: 0 additions & 2 deletions app/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,6 @@
"brand",
"release_date",
"ram_gb",
"battery_mah",
"weight_g",
"os",
"source_urls",
"verified",
Expand Down
126 changes: 126 additions & 0 deletions tests/integration/test_mobile_device_nullable_specs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""Unknown mobile specs survive validation, seeding, API responses, and dumps."""

from __future__ import annotations

import json
from collections.abc import Iterator
from pathlib import Path

import pytest
from fastapi.testclient import TestClient
from sqlmodel import Session, SQLModel, create_engine

from app import validate
from app.database import get_session
from app.dump import generate
from app.main import app
from app.schemas.mobile_device import MobileDeviceRead
from app.seed import seed


@pytest.mark.parametrize(
"category,resource", [("tablet", "tablets"), ("watch", "watches"), ("pda", "pdas")]
)
@pytest.mark.parametrize(
"specs",
[
{},
{"battery_mah": None, "weight_g": None},
{"battery_mah": None, "weight_g": 150.0},
{"battery_mah": 1500, "weight_g": None},
{"battery_mah": 1500, "weight_g": 150.0},
],
ids=["omitted", "null", "unknown-battery", "unknown-weight", "known"],
)
def test_mobile_specs_round_trip(
client: TestClient,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
category: str,
resource: str,
specs: dict[str, int | float | None],
) -> None:
data_dir = tmp_path / "data"
brand_path = data_dir / "brand" / "us" / "example.json"
brand_path.parent.mkdir(parents=True)
brand_path.write_text(
json.dumps(
{
"slug": "example",
"name": "Example",
"country": "US",
"categories": ["smartphone-oem"],
"source_urls": ["https://example.com"],
}
),
encoding="utf-8",
)
device_path = data_dir / category / "example" / "2020" / "device" / "device-base.json"
device_path.parent.mkdir(parents=True)
device_path.write_text(
json.dumps(
{
"slug": "device-base",
"base_model_slug": "device",
"name": "Example device",
"brand": "example",
"release_date": "2020-01-01",
"ram_gb": 1,
"os": "Example OS",
"source_urls": ["https://example.com"],
"verified": False,
**specs,
}
),
encoding="utf-8",
)
monkeypatch.setattr(validate, "DATA_DIR", data_dir)
assert validate.validate() == []

test_engine = create_engine(
f"sqlite:///{tmp_path / 'nullable.db'}", connect_args={"check_same_thread": False}
)
SQLModel.metadata.create_all(test_engine)
try:
with Session(test_engine) as session:
assert seed(session, data_dir)[resource] == 1

def session_override() -> Iterator[Session]:
with Session(test_engine) as session:
yield session

monkeypatch.setitem(app.dependency_overrides, get_session, session_override)
response = client.get(f"/v1/{resource}/device-base")
assert response.status_code == 200
detail = response.json()
for field in ("battery_mah", "weight_g"):
assert detail[field] == specs.get(field)

# The response schema also defaults omitted values to None.
without_specs = {k: v for k, v in detail.items() if k not in ("battery_mah", "weight_g")}
read = MobileDeviceRead.model_validate(without_specs)
assert read.battery_mah is None
assert read.weight_g is None

output_dir = tmp_path / "dump"
assert generate(client, output_dir, [resource]) == {resource: 1}
dumped = json.loads(
(output_dir / "v1" / resource / "device-base" / "index.json").read_text("utf-8")
)
assert dumped == detail
finally:
test_engine.dispose()


def test_mobile_specs_openapi_contract(client: TestClient) -> None:
schemas = client.get("/openapi.json").json()["components"]["schemas"]
mobile = schemas["MobileDeviceRead"]
phone = schemas["SmartphoneRead"]
for field, numeric_type in (("battery_mah", "integer"), ("weight_g", "number")):
assert field not in mobile["required"]
assert {variant["type"] for variant in mobile["properties"][field]["anyOf"]} == {
numeric_type,
"null",
}
assert field in phone["required"]
assert phone["properties"][field]["type"] == numeric_type
17 changes: 17 additions & 0 deletions tests/unit/test_data_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

import pytest

from app import validate


Expand All @@ -27,6 +29,21 @@ def test_range_checker_flags_out_of_range() -> None:
assert errors


@pytest.mark.parametrize("field,lo,hi", [("battery_mah", 50, 20000), ("weight_g", 10, 2000)])
def test_mobile_spec_ranges_allow_null_but_reject_invalid_values(
field: str,
lo: float,
hi: float,
) -> None:
errors: list[str] = []
validate._check_range("x.json", field, None, lo, hi, errors)
assert errors == []
for value in (0, hi + 1, "unknown"):
errors = []
validate._check_range("x.json", field, value, lo, hi, errors)
assert errors


def test_date_checker_requires_iso_format() -> None:
errors: list[str] = []
validate._check_date("x.json", "Jan 1 2025", errors)
Expand Down
Loading