From 37150344a19a842b1f45c682e8ebe66c564b5571 Mon Sep 17 00:00:00 2001 From: Seungpyo1007 Date: Tue, 29 Sep 2026 05:24:55 +0900 Subject: [PATCH] feat(models): allow null battery_mah/weight_g on mobile devices --- README.md | 5 + app/models/mobile_device.py | 4 +- app/schemas/mobile_device.py | 4 +- app/validate.py | 2 - .../test_mobile_device_nullable_specs.py | 126 ++++++++++++++++++ tests/unit/test_data_validation.py | 17 +++ 6 files changed, 152 insertions(+), 6 deletions(-) create mode 100644 tests/integration/test_mobile_device_nullable_specs.py diff --git a/README.md b/README.md index 5b7609a..60e8ab5 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/models/mobile_device.py b/app/models/mobile_device.py index eda61ea..908633c 100644 --- a/app/models/mobile_device.py +++ b/app/models/mobile_device.py @@ -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 diff --git a/app/schemas/mobile_device.py b/app/schemas/mobile_device.py index 1aac205..3a80304 100644 --- a/app/schemas/mobile_device.py +++ b/app/schemas/mobile_device.py @@ -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 diff --git a/app/validate.py b/app/validate.py index d6d2cf1..2f6edee 100644 --- a/app/validate.py +++ b/app/validate.py @@ -56,8 +56,6 @@ "brand", "release_date", "ram_gb", - "battery_mah", - "weight_g", "os", "source_urls", "verified", diff --git a/tests/integration/test_mobile_device_nullable_specs.py b/tests/integration/test_mobile_device_nullable_specs.py new file mode 100644 index 0000000..bf40f7c --- /dev/null +++ b/tests/integration/test_mobile_device_nullable_specs.py @@ -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 diff --git a/tests/unit/test_data_validation.py b/tests/unit/test_data_validation.py index 9134814..bf4455e 100644 --- a/tests/unit/test_data_validation.py +++ b/tests/unit/test_data_validation.py @@ -2,6 +2,8 @@ from __future__ import annotations +import pytest + from app import validate @@ -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)