diff --git a/app/dump.py b/app/dump.py index 4ff7e7f..bbf3b1a 100644 --- a/app/dump.py +++ b/app/dump.py @@ -20,7 +20,7 @@ OUTPUT_DIR = Path(__file__).resolve().parent.parent / "dump" # Collections that expose list + detail endpoints. -COLLECTIONS = ["brands", "socs", "smartphones", "gpus", "cpus"] +COLLECTIONS = ["brands", "socs", "smartphones", "tablets", "watches", "pdas", "gpus", "cpus"] PAGE_LIMIT = 100 # API max page size (§7.3) @@ -42,12 +42,16 @@ def _fetch_all(client: TestClient, resource: str) -> tuple[int, list[dict[str, A return count, items -def generate(client: TestClient, output_dir: Path = OUTPUT_DIR) -> dict[str, int]: +def generate( + client: TestClient, + output_dir: Path = OUTPUT_DIR, + collections: list[str] | None = None, +) -> dict[str, int]: """Write the full static dump. Returns the number of detail files per collection.""" counts: dict[str, int] = {} manifest: dict[str, object] = {"version": "v1", "collections": {}} - for resource in COLLECTIONS: + for resource in collections or COLLECTIONS: count, items = _fetch_all(client, resource) # Combined list file (un-paginated, convenient for static consumers). _write_json( diff --git a/app/main.py b/app/main.py index 316bd42..48d5bb7 100644 --- a/app/main.py +++ b/app/main.py @@ -14,7 +14,7 @@ from app.config import settings from app.database import create_db_and_tables from app.errors import register_error_handlers -from app.routers import brands, cpus, gpus, meta, smartphones, socs +from app.routers import brands, cpus, gpus, meta, mobile_devices, smartphones, socs PREFIX = settings.api_version_prefix @@ -69,6 +69,9 @@ async def add_request_id( app.include_router(brands.router, prefix=PREFIX) app.include_router(socs.router, prefix=PREFIX) app.include_router(smartphones.router, prefix=PREFIX) +app.include_router(mobile_devices.tablets_router, prefix=PREFIX) +app.include_router(mobile_devices.watches_router, prefix=PREFIX) +app.include_router(mobile_devices.pdas_router, prefix=PREFIX) app.include_router(gpus.router, prefix=PREFIX) app.include_router(cpus.router, prefix=PREFIX) diff --git a/app/models/__init__.py b/app/models/__init__.py index 092211f..db974f9 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -6,7 +6,8 @@ from app.models.brand import Brand from app.models.cpu import CPU from app.models.gpu import DiscreteGPU +from app.models.mobile_device import PDA, Tablet, Watch from app.models.smartphone import Smartphone from app.models.soc import SoC -__all__ = ["Brand", "SoC", "Smartphone", "DiscreteGPU", "CPU"] +__all__ = ["Brand", "SoC", "Smartphone", "Tablet", "Watch", "PDA", "DiscreteGPU", "CPU"] diff --git a/app/models/mobile_device.py b/app/models/mobile_device.py new file mode 100644 index 0000000..eda61ea --- /dev/null +++ b/app/models/mobile_device.py @@ -0,0 +1,72 @@ +"""Mobile device models for tablets, watches, and PDAs.""" + +from __future__ import annotations + +from datetime import UTC, date, datetime +from typing import Any + +from sqlalchemy import JSON +from sqlmodel import Field, SQLModel + + +def _utcnow() -> datetime: + return datetime.now(UTC) + + +class MobileDeviceFields(SQLModel): + """Shared columns for non-phone mobile device categories.""" + + id: int | None = Field(default=None, primary_key=True) + slug: str = Field(index=True, unique=True) + base_model_slug: str | None = Field(default=None, index=True) + name: str + brand_id: int = Field(foreign_key="brands.id", index=True) + soc_id: int | None = Field(default=None, foreign_key="socs.id", index=True) + + release_date: date + msrp_usd: int | None = None + + ram_gb: float + storage_options_gb: list[int] = Field(default_factory=list, sa_type=JSON) + variant: dict[str, Any] = Field(default_factory=dict, sa_type=JSON) + + 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 + charging_wired_w: float | None = None + charging_wireless_w: float | None = None + + weight_g: float + dimensions: dict[str, Any] = Field(default_factory=dict, sa_type=JSON) + ip_rating: str | None = None + + os: str + os_version: str | None = None + connectivity: dict[str, Any] = Field(default_factory=dict, sa_type=JSON) + + image_url: str | None = None + images: list[str] = Field(default_factory=list, sa_type=JSON) + + verified: bool = False + source_urls: list[str] = Field(default_factory=list, sa_type=JSON) + created_at: datetime = Field(default_factory=_utcnow) + updated_at: datetime = Field(default_factory=_utcnow) + + +class Tablet(MobileDeviceFields, table=True): + """A tablet device variant.""" + + __tablename__ = "tablets" + + +class Watch(MobileDeviceFields, table=True): + """A smartwatch or connected wearable variant.""" + + __tablename__ = "watches" + + +class PDA(MobileDeviceFields, table=True): + """A PDA or handheld mobile computing variant.""" + + __tablename__ = "pdas" diff --git a/app/models/smartphone.py b/app/models/smartphone.py index 54cf9ba..4c28207 100644 --- a/app/models/smartphone.py +++ b/app/models/smartphone.py @@ -20,6 +20,7 @@ class Smartphone(SQLModel, table=True): id: int | None = Field(default=None, primary_key=True) slug: str = Field(index=True, unique=True) + base_model_slug: str | None = Field(default=None, index=True) name: str brand_id: int = Field(foreign_key="brands.id", index=True) soc_id: int = Field(foreign_key="socs.id", index=True) @@ -30,6 +31,7 @@ class Smartphone(SQLModel, table=True): # Memory ram_gb: int storage_options_gb: list[int] = Field(default_factory=list, sa_column=Column(JSON)) + variant: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) # Display — {size_inch, resolution, refresh_hz, type, brightness_nits, ppi} display: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) diff --git a/app/routers/mobile_devices.py b/app/routers/mobile_devices.py new file mode 100644 index 0000000..dd2960b --- /dev/null +++ b/app/routers/mobile_devices.py @@ -0,0 +1,122 @@ +"""Shared endpoints for non-phone mobile device categories.""" + +from __future__ import annotations + +from typing import Annotated, Any + +from fastapi import APIRouter, Query +from sqlalchemy import func +from sqlmodel import Session, select +from sqlmodel.sql.expression import SelectOfScalar + +from app.dependencies import PaginationDep, SessionDep +from app.errors import APIError, not_found +from app.models.brand import Brand +from app.models.mobile_device import PDA, Tablet, Watch +from app.models.soc import SoC +from app.routers.utils import build_ref_page +from app.schemas.common import Page, ResourceRef +from app.schemas.mobile_device import MobileDeviceRead +from app.schemas.serializers import mobile_device_read, resource_ref + + +def _resolve_id(session: Session, model: Any, slug: str | None) -> int | None | str: + if slug is None: + return None + row = session.exec(select(model).where(model.slug == slug)).first() + return row.id if row is not None else "MISSING" + + +def _make_router(resource: str, model: Any) -> APIRouter: + router = APIRouter(prefix=f"/{resource}", tags=[resource]) + sort_fields: dict[str, Any] = { + "name": model.name, + "release_date": model.release_date, + "msrp_usd": model.msrp_usd, + } + + def apply_sort(stmt: SelectOfScalar[Any], sort: str | None) -> SelectOfScalar[Any]: + if not sort: + return stmt.order_by(model.name) + descending = sort.startswith("-") + field = sort[1:] if descending else sort + column = sort_fields.get(field) + if column is None: + raise APIError(400, "INVALID_REQUEST", f"Cannot sort by '{field}'") + return stmt.order_by(column.desc() if descending else column.asc()) + + @router.get("", summary=f"List {resource}") + def list_devices( + session: SessionDep, + pagination: PaginationDep, + brand: Annotated[str | None, Query()] = None, + soc: Annotated[str | None, Query()] = None, + base_model: Annotated[str | None, Query(alias="base_model_slug")] = None, + sort: Annotated[str | None, Query()] = None, + ) -> Page[ResourceRef]: + filters = [] + brand_id = _resolve_id(session, Brand, brand) + soc_id = _resolve_id(session, SoC, soc) + + if brand_id == "MISSING" or soc_id == "MISSING": + return build_ref_page([], count=0, path=f"/v1/{resource}", pagination=pagination) + + if brand_id is not None: + filters.append(model.brand_id == brand_id) + if soc_id is not None: + filters.append(model.soc_id == soc_id) + if base_model is not None: + filters.append(model.base_model_slug == base_model) + + count_stmt = select(func.count()).select_from(model) + list_stmt = select(model) + for clause in filters: + count_stmt = count_stmt.where(clause) + list_stmt = list_stmt.where(clause) + + count = session.exec(count_stmt).one() + list_stmt = apply_sort(list_stmt, sort).offset(pagination.offset).limit(pagination.limit) + rows = session.exec(list_stmt).all() + + refs = [resource_ref(resource, row.slug, row.name) for row in rows] + applied = { + k: v + for k, v in ( + ("brand", brand), + ("soc", soc), + ("base_model_slug", base_model), + ("sort", sort), + ) + if v + } + return build_ref_page( + refs, count=count, path=f"/v1/{resource}", pagination=pagination, filters=applied + ) + + @router.get("/{slug}", summary=f"Get a {resource[:-1]}") + def get_device(slug: str, session: SessionDep) -> MobileDeviceRead: + device = session.exec(select(model).where(model.slug == slug)).first() + if device is None: + raise not_found(resource[:-1].title(), slug) + brand = session.get(Brand, device.brand_id) + if brand is None: # pragma: no cover - guarded by FK + validation + raise not_found("Brand", str(device.brand_id)) + + soc = None + soc_manufacturer = None + if device.soc_id is not None: + soc = session.get(SoC, device.soc_id) + if soc is None: # pragma: no cover + raise not_found("SoC", str(device.soc_id)) + soc_manufacturer = session.get(Brand, soc.manufacturer_id) + if soc_manufacturer is None: # pragma: no cover + raise not_found("Brand", str(soc.manufacturer_id)) + + return mobile_device_read(resource, device, brand, soc, soc_manufacturer) + + return router + + +tablets_router = _make_router("tablets", Tablet) +watches_router = _make_router("watches", Watch) +pdas_router = _make_router("pdas", PDA) diff --git a/app/schemas/mobile_device.py b/app/schemas/mobile_device.py new file mode 100644 index 0000000..1aac205 --- /dev/null +++ b/app/schemas/mobile_device.py @@ -0,0 +1,45 @@ +"""Response schemas for tablets, watches, and PDAs.""" + +from __future__ import annotations + +from datetime import date, datetime +from typing import Any + +from pydantic import BaseModel, Field + +from app.schemas.brand import BrandSummary +from app.schemas.soc import SoCSummary + + +class MobileDeviceRead(BaseModel): + """Full mobile device detail response.""" + + id: int + slug: str + base_model_slug: str | None = None + name: str + brand: BrandSummary + soc: SoCSummary | None = None + release_date: date + msrp_usd: int | None = None + ram_gb: float + storage_options_gb: list[int] + variant: dict[str, Any] + display: dict[str, Any] + cameras: list[dict[str, Any]] + battery_mah: int + charging_wired_w: float | None = None + charging_wireless_w: float | None = None + weight_g: float + dimensions: dict[str, Any] + ip_rating: str | None = None + os: str + os_version: str | None = None + connectivity: dict[str, Any] + image_url: str | None = None + images: list[str] = Field(default_factory=list) + verified: bool + source_urls: list[str] + created_at: datetime + updated_at: datetime + url: str diff --git a/app/schemas/serializers.py b/app/schemas/serializers.py index 366aba4..24df999 100644 --- a/app/schemas/serializers.py +++ b/app/schemas/serializers.py @@ -6,12 +6,14 @@ from app.models.brand import Brand from app.models.cpu import CPU from app.models.gpu import DiscreteGPU +from app.models.mobile_device import MobileDeviceFields from app.models.smartphone import Smartphone from app.models.soc import SoC from app.schemas.brand import BrandRead, BrandSummary from app.schemas.common import ManufacturerRef, ResourceRef from app.schemas.cpu import CPURead from app.schemas.gpu import GPURead +from app.schemas.mobile_device import MobileDeviceRead from app.schemas.smartphone import ScoreRead, SmartphoneRead from app.schemas.soc import SoCManufacturer, SoCRead, SoCSummary from app.services.scoring import Scores @@ -182,6 +184,7 @@ def smartphone_read( return SmartphoneRead( id=phone.id, slug=phone.slug, + base_model_slug=phone.base_model_slug, name=phone.name, brand=brand_summary(brand), soc=soc_summary(soc, soc_manufacturer), @@ -189,6 +192,7 @@ def smartphone_read( msrp_usd=phone.msrp_usd, ram_gb=phone.ram_gb, storage_options_gb=phone.storage_options_gb, + variant=phone.variant, display=phone.display, cameras=phone.cameras, battery_mah=phone.battery_mah, @@ -216,3 +220,44 @@ def smartphone_read( created_at=phone.created_at, updated_at=phone.updated_at, ) + + +def mobile_device_read( + resource: str, + device: MobileDeviceFields, + brand: Brand, + soc: SoC | None, + soc_manufacturer: Brand | None, +) -> MobileDeviceRead: + assert device.id is not None + return MobileDeviceRead( + id=device.id, + slug=device.slug, + base_model_slug=device.base_model_slug, + name=device.name, + brand=brand_summary(brand), + soc=soc_summary(soc, soc_manufacturer) if soc and soc_manufacturer else None, + release_date=device.release_date, + msrp_usd=device.msrp_usd, + ram_gb=device.ram_gb, + storage_options_gb=device.storage_options_gb, + variant=device.variant, + display=device.display, + cameras=device.cameras, + battery_mah=device.battery_mah, + charging_wired_w=device.charging_wired_w, + charging_wireless_w=device.charging_wireless_w, + weight_g=device.weight_g, + dimensions=device.dimensions, + ip_rating=device.ip_rating, + os=device.os, + os_version=device.os_version, + connectivity=device.connectivity, + image_url=device.image_url, + images=device.images, + verified=device.verified, + source_urls=device.source_urls, + created_at=device.created_at, + updated_at=device.updated_at, + url=url_for(resource, device.slug), + ) diff --git a/app/schemas/smartphone.py b/app/schemas/smartphone.py index af485e8..36399b9 100644 --- a/app/schemas/smartphone.py +++ b/app/schemas/smartphone.py @@ -28,6 +28,7 @@ class SmartphoneRead(BaseModel): id: int slug: str + base_model_slug: str | None = None name: str brand: BrandSummary soc: SoCSummary @@ -35,6 +36,7 @@ class SmartphoneRead(BaseModel): msrp_usd: int | None = None ram_gb: int storage_options_gb: list[int] + variant: dict[str, Any] display: dict[str, Any] cameras: list[dict[str, Any]] battery_mah: int diff --git a/app/seed.py b/app/seed.py index 6cfb05f..c0da906 100644 --- a/app/seed.py +++ b/app/seed.py @@ -27,6 +27,7 @@ from app.models.brand import Brand from app.models.cpu import CPU from app.models.gpu import DiscreteGPU +from app.models.mobile_device import PDA, Tablet, Watch from app.models.smartphone import Smartphone from app.models.soc import SoC @@ -53,7 +54,16 @@ def _existing_slugs(session: Session, model: type[SQLModel]) -> set[str]: def seed(session: Session, data_dir: Path = DATA_DIR) -> dict[str, int]: """Idempotently insert seed data. Returns counts of newly inserted rows.""" - counts = {"brands": 0, "socs": 0, "smartphones": 0, "gpus": 0, "cpus": 0} + counts = { + "brands": 0, + "socs": 0, + "smartphones": 0, + "tablets": 0, + "watches": 0, + "pdas": 0, + "gpus": 0, + "cpus": 0, + } # --- Brands --- brand_slugs = _existing_slugs(session, Brand) @@ -107,6 +117,33 @@ def seed(session: Session, data_dir: Path = DATA_DIR) -> dict[str, int]: counts["smartphones"] += 1 session.commit() + def seed_mobile_devices(subdir: str, model: type[SQLModel], count_key: str) -> None: + device_slugs = _existing_slugs(session, model) + for record in _load_dir(data_dir / subdir): + if record["slug"] in device_slugs: + continue + brand_slug = record.pop("brand") + soc_slug = record.pop("soc", None) + brand_id = brand_id_by_slug.get(brand_slug) + soc_id = soc_id_by_slug.get(soc_slug) if soc_slug else None + if brand_id is None: + raise ValueError( + f"{subdir.rstrip('s').title()} '{record['slug']}' " + f"references unknown brand '{brand_slug}'" + ) + if soc_slug and soc_id is None: + raise ValueError( + f"{subdir.rstrip('s').title()} '{record['slug']}' " + f"references unknown SoC '{soc_slug}'" + ) + session.add(model(brand_id=brand_id, soc_id=soc_id, **record)) + counts[count_key] += 1 + session.commit() + + seed_mobile_devices("tablet", Tablet, "tablets") + seed_mobile_devices("watch", Watch, "watches") + seed_mobile_devices("pda", PDA, "pdas") + # --- Discrete GPUs --- gpu_slugs = _existing_slugs(session, DiscreteGPU) for record in _load_dir(data_dir / "gpu"): diff --git a/app/validate.py b/app/validate.py index 3ac703c..b425328 100644 --- a/app/validate.py +++ b/app/validate.py @@ -49,6 +49,19 @@ "os", } +MOBILE_DEVICE_REQUIRED = { + "slug", + "name", + "brand", + "release_date", + "ram_gb", + "battery_mah", + "weight_g", + "os", + "source_urls", + "verified", +} + GPU_REQUIRED = { "slug", "name", @@ -140,12 +153,49 @@ def _check_source_urls(name: str, record: dict[str, Any], errors: list[str]) -> errors.append(f"{name}: source_urls must be a non-empty list of http(s) URL strings") +def _check_variant_path( + fname: str, + rec: dict[str, Any], + category: str, + errors: list[str], + *, + allow_flat: bool = False, +) -> None: + parts = Path(fname).parts + if allow_flat and len(parts) == 4: + return + if len(parts) != 5: + errors.append( + f"{fname}: {category} variants must live at " + f"'{category}////.json'" + ) + return + _, brand, year, base_model_slug, filename = parts + if rec.get("brand") != brand: + errors.append(f"{fname}: lives in brand '{brand}' but brand='{rec.get('brand')}'") + release_year = str(rec.get("release_date", ""))[:4] + if release_year and year != release_year: + errors.append( + f"{fname}: lives in year '{year}' but release_date starts with '{release_year}'" + ) + if rec.get("base_model_slug") and rec.get("base_model_slug") != base_model_slug: + errors.append( + f"{fname}: lives under base '{base_model_slug}' but " + f"base_model_slug='{rec.get('base_model_slug')}'" + ) + if filename != f"{rec.get('slug')}.json": + errors.append(f"{fname}: filename must match slug '{rec.get('slug')}'") + + def validate() -> list[str]: errors: list[str] = [] brands = _load("brand") socs = _load("soc") phones = _load("smartphone") + tablets = _load("tablet") + watches = _load("watch") + pdas = _load("pda") gpus = _load("gpu") cpus = _load("cpu") @@ -156,6 +206,9 @@ def validate() -> list[str]: ("brand", brands), ("soc", socs), ("smartphone", phones), + ("tablet", tablets), + ("watch", watches), + ("pda", pdas), ("gpu", gpus), ("cpu", cpus), ): @@ -219,6 +272,25 @@ def validate() -> list[str]: errors.append(f"{fname}: brand '{rec.get('brand')}' not a known brand") if rec.get("soc") not in soc_slugs: errors.append(f"{fname}: soc '{rec.get('soc')}' not a known SoC") + _check_variant_path(fname, rec, "smartphone", errors, allow_flat=True) + + for category, records in (("tablet", tablets), ("watch", watches), ("pda", pdas)): + for fname, rec in records: + _check_required(fname, rec, MOBILE_DEVICE_REQUIRED, errors) + _check_source_urls(fname, rec, errors) + _check_slug(fname, rec.get("slug"), errors) + if "release_date" in rec: + _check_date(fname, rec["release_date"], errors) + _check_range(fname, "ram_gb", rec.get("ram_gb"), 0.016, 64, errors) + _check_range(fname, "battery_mah", rec.get("battery_mah"), 50, 20000, errors) + _check_range(fname, "weight_g", rec.get("weight_g"), 10, 2000, errors) + if "msrp_usd" in rec: + _check_range(fname, "msrp_usd", rec["msrp_usd"], 10, 10000, errors) + if rec.get("brand") not in brand_slugs: + errors.append(f"{fname}: brand '{rec.get('brand')}' not a known brand") + if rec.get("soc") is not None and rec.get("soc") not in soc_slugs: + errors.append(f"{fname}: soc '{rec.get('soc')}' not a known SoC") + _check_variant_path(fname, rec, category, errors) for fname, rec in gpus: _check_required(fname, rec, GPU_REQUIRED, errors) diff --git a/tests/integration/mobile_device_fixtures.py b/tests/integration/mobile_device_fixtures.py new file mode 100644 index 0000000..1ecf95b --- /dev/null +++ b/tests/integration/mobile_device_fixtures.py @@ -0,0 +1,108 @@ +"""Small database fixtures for mobile-device endpoint tests.""" + +from __future__ import annotations + +from datetime import date + +from sqlmodel import Session, select + +from app.database import engine +from app.models.brand import Brand +from app.models.mobile_device import PDA, Tablet, Watch + + +def _brand_id(session: Session, slug: str, name: str, country: str) -> int: + brand = session.exec(select(Brand).where(Brand.slug == slug)).first() + if brand is None: + brand = Brand(slug=slug, name=name, country=country, source_urls=["https://example.com"]) + session.add(brand) + session.commit() + session.refresh(brand) + assert brand.id is not None + return brand.id + + +def ensure_mobile_device_fixtures() -> None: + """Insert compact tablet, watch, and PDA records when the data checkout lacks them.""" + + with Session(engine) as session: + apple_id = _brand_id(session, "apple", "Apple", "US") + samsung_id = _brand_id(session, "samsung", "Samsung", "KR") + hp_id = _brand_id(session, "hp", "HP", "US") + + tablet = session.exec( + select(Tablet).where(Tablet.slug == "ipad-pro-11-m4-wifi-8gb-256gb") + ).first() + if tablet is None: + session.add( + Tablet( + slug="ipad-pro-11-m4-wifi-8gb-256gb", + base_model_slug="ipad-pro-11-m4", + name="Apple iPad Pro 11-inch (M4, Wi-Fi, 256GB)", + brand_id=apple_id, + release_date=date(2024, 5, 15), + msrp_usd=999, + ram_gb=8, + storage_options_gb=[256], + variant={ + "region": "global", + "memory": {"ram_gb": 8, "storage_gb": 256}, + "network": {"cellular": "Wi-Fi", "carrier": "none"}, + }, + display={"size_inch": 11.0, "refresh_hz": 120}, + cameras=[{"type": "wide", "mp": 12}], + battery_mah=8160, + weight_g=444, + os="iPadOS", + verified=False, + source_urls=["https://support.apple.com/"], + ) + ) + + watch = session.exec( + select(Watch).where(Watch.slug == "galaxy-watch-global-bluetooth-42mm") + ).first() + if watch is None: + session.add( + Watch( + slug="galaxy-watch-global-bluetooth-42mm", + base_model_slug="galaxy-watch", + name="Samsung Galaxy Watch 42mm Bluetooth", + brand_id=samsung_id, + release_date=date(2018, 8, 24), + msrp_usd=329, + ram_gb=0.75, + storage_options_gb=[4], + variant={"region": "global", "network": {"cellular": "none"}}, + display={"size_inch": 1.2}, + cameras=[], + battery_mah=270, + weight_g=49, + os="Tizen", + verified=False, + source_urls=["https://www.samsung.com/"], + ) + ) + + if session.exec(select(PDA).where(PDA.slug == "ipaq-h3600-base")).first() is None: + session.add( + PDA( + slug="ipaq-h3600-base", + base_model_slug="ipaq-h3600", + name="HP iPAQ H3600", + brand_id=hp_id, + release_date=date(2000, 4, 1), + ram_gb=0.032, + storage_options_gb=[], + variant={"region": "global"}, + display={"size_inch": 3.8}, + cameras=[], + battery_mah=1500, + weight_g=178, + os="Pocket PC", + verified=False, + source_urls=["https://en.wikipedia.org/wiki/IPAQ"], + ) + ) + + session.commit() diff --git a/tests/integration/test_brands.py b/tests/integration/test_brands.py index bfa98f9..a36533b 100644 --- a/tests/integration/test_brands.py +++ b/tests/integration/test_brands.py @@ -32,8 +32,9 @@ def test_unknown_brand_returns_404_envelope(client: TestClient) -> None: def test_brand_smartphones_relation(client: TestClient) -> None: body = client.get("/v1/brands/samsung/smartphones?limit=100").json() assert body["count"] >= 1 - slugs = {item["slug"] for item in body["results"]} - assert "galaxy-s25" in slugs + first = body["results"][0]["slug"] + detail = client.get(f"/v1/smartphones/{first}").json() + assert detail["brand"]["slug"] == "samsung" def test_brand_smartphones_unknown_brand_404(client: TestClient) -> None: diff --git a/tests/integration/test_dump.py b/tests/integration/test_dump.py index e0bec68..7388d14 100644 --- a/tests/integration/test_dump.py +++ b/tests/integration/test_dump.py @@ -8,28 +8,28 @@ from fastapi.testclient import TestClient from app.dump import generate +from tests.integration.mobile_device_fixtures import ensure_mobile_device_fixtures def test_dump_writes_list_detail_and_manifest(client: TestClient, tmp_path: Path) -> None: - counts = generate(client, output_dir=tmp_path) - assert counts["smartphones"] >= 10 - assert counts["gpus"] >= 1 - assert counts["cpus"] >= 1 + ensure_mobile_device_fixtures() + collections = ["tablets", "watches", "pdas"] + counts = generate(client, output_dir=tmp_path, collections=collections) + assert counts["tablets"] >= 1 + assert counts["watches"] >= 1 + assert counts["pdas"] >= 1 # Detail file matches the live API response. - detail_file = tmp_path / "v1" / "smartphones" / "galaxy-s25" / "index.json" + detail_file = tmp_path / "v1" / "tablets" / "ipad-pro-11-m4-wifi-8gb-256gb" / "index.json" assert detail_file.exists() detail = json.loads(detail_file.read_text()) - assert detail["slug"] == "galaxy-s25" - assert detail == client.get("/v1/smartphones/galaxy-s25").json() - - # Score sidecar is dumped for smartphones. - assert (tmp_path / "v1" / "smartphones" / "galaxy-s25" / "score" / "index.json").exists() + assert detail["slug"] == "ipad-pro-11-m4-wifi-8gb-256gb" + assert detail == client.get("/v1/tablets/ipad-pro-11-m4-wifi-8gb-256gb").json() # Combined list file holds every item. - listing = json.loads((tmp_path / "v1" / "smartphones" / "index.json").read_text()) + listing = json.loads((tmp_path / "v1" / "tablets" / "index.json").read_text()) assert listing["count"] == len(listing["results"]) # Manifest enumerates all collections. manifest = json.loads((tmp_path / "v1" / "index.json").read_text()) - assert {"brands", "socs", "smartphones", "gpus", "cpus"} <= manifest["collections"].keys() + assert set(manifest["collections"].keys()) == set(collections) diff --git a/tests/integration/test_mobile_devices.py b/tests/integration/test_mobile_devices.py new file mode 100644 index 0000000..8f3cade --- /dev/null +++ b/tests/integration/test_mobile_devices.py @@ -0,0 +1,50 @@ +"""Integration tests for tablet, watch, and PDA endpoints.""" + +from __future__ import annotations + +from fastapi.testclient import TestClient + +from tests.integration.mobile_device_fixtures import ensure_mobile_device_fixtures + + +def test_list_mobile_device_categories(client: TestClient) -> None: + ensure_mobile_device_fixtures() + cases = [ + ("tablets", "ipad-pro-11-m4-wifi-8gb-256gb"), + ("watches", "galaxy-watch-global-bluetooth-42mm"), + ("pdas", "ipaq-h3600-base"), + ] + + for resource, slug in cases: + body = client.get(f"/v1/{resource}").json() + assert body["count"] >= 1 + assert any(item["slug"] == slug for item in body["results"]) + + +def test_mobile_device_detail_includes_variant_fields(client: TestClient) -> None: + ensure_mobile_device_fixtures() + body = client.get("/v1/tablets/ipad-pro-11-m4-wifi-8gb-256gb").json() + assert body["slug"] == "ipad-pro-11-m4-wifi-8gb-256gb" + assert body["base_model_slug"] == "ipad-pro-11-m4" + assert body["brand"]["slug"] == "apple" + assert body["variant"]["region"] == "global" + assert body["variant"]["memory"] == {"ram_gb": 8, "storage_gb": 256} + assert body["verified"] is False + + +def test_mobile_device_filters(client: TestClient) -> None: + ensure_mobile_device_fixtures() + brand_body = client.get("/v1/watches?brand=samsung").json() + assert {item["slug"] for item in brand_body["results"]} == { + "galaxy-watch-global-bluetooth-42mm" + } + + base_body = client.get("/v1/tablets?base_model_slug=ipad-pro-11-m4").json() + assert {item["slug"] for item in base_body["results"]} == {"ipad-pro-11-m4-wifi-8gb-256gb"} + + +def test_mobile_device_unknown_slug_404(client: TestClient) -> None: + ensure_mobile_device_fixtures() + response = client.get("/v1/pdas/nope") + assert response.status_code == 404 + assert response.json()["error"]["code"] == "NOT_FOUND" diff --git a/tests/integration/test_smartphones.py b/tests/integration/test_smartphones.py index 88da034..783dffa 100644 --- a/tests/integration/test_smartphones.py +++ b/tests/integration/test_smartphones.py @@ -39,9 +39,11 @@ def test_score_endpoint(client: TestClient) -> None: def test_filter_by_brand(client: TestClient) -> None: body = client.get("/v1/smartphones?brand=apple").json() - slugs = {item["slug"] for item in body["results"]} - assert "iphone-16" in slugs - assert "galaxy-s25" not in slugs + assert body["count"] > 0 + assert all(item["url"].startswith("/v1/smartphones/") for item in body["results"]) + first = body["results"][0]["slug"] + detail = client.get(f"/v1/smartphones/{first}").json() + assert detail["brand"]["slug"] == "apple" def test_filter_by_soc(client: TestClient) -> None: