Gate B [09/10]: Draft store stub D-1 — POST /api/v1/slabs (#29)
POST /api/v1/slabs accepts multipart/form-data with: - data: JSON-encoded SlabCreate payload - files[]: original image files (1-5 photos) - meta[]: JSON-encoded photo metadata objects Returns 201 with status: calibrated and server_rev: 1. Server stores client-sent numbers only (sqft, bdft, widths) without server-side recompute. Client is authoritative for deterministic math. Includes: - SlabCreateData Pydantic model mirroring OPENAPI.md SlabCreate schema - PhotoMeta model for photo metadata validation - SlabRead/PhotoRead response models - File storage with configurable images directory (temp fallback for tests) - Duplicate SKU detection (409) - python-multipart added to requirements.txt D-1 test coverage (8 tests, all green): - 201 response on valid multipart upload - status: calibrated in response - server_rev: 1 in response - Full response shape assertion - Multiple photos handling - Missing data field returns 400 - Duplicate SKU returns 409 - TestClient lifespan management (FUND-9) Evidence: pytest tests/ -v → 31 passed, 0 failed
This commit is contained in:
@@ -0,0 +1,432 @@
|
|||||||
|
"""Slabs API: create calibrated draft from multipart upload.
|
||||||
|
|
||||||
|
Per OPENAPI.md §slabs:
|
||||||
|
- POST /api/v1/slabs accepts multipart/form-data with:
|
||||||
|
- data: JSON SlabCreate payload
|
||||||
|
- files[]: original image files
|
||||||
|
- meta[]: photo metadata JSON objects
|
||||||
|
- Returns 201 with status: calibrated and server_rev
|
||||||
|
- Server stores client-sent numbers only; no server-side recompute.
|
||||||
|
|
||||||
|
Per DATA-MODEL.md:
|
||||||
|
- slabs table: client-generated UUID, sku unique, status calibrated
|
||||||
|
- slab_photos table: slab_id FK, kind inventory, role, seq, original_path
|
||||||
|
- server_rev = 1 on create
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import tempfile
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
from app.db.engine import get_session
|
||||||
|
from app.db.models import Slab, SlabPhoto, SlabStatus
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pydantic models
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class PhotoMeta(BaseModel):
|
||||||
|
"""Photo metadata from the multipart request."""
|
||||||
|
|
||||||
|
kind: str = Field(default="inventory")
|
||||||
|
role: str | None = None
|
||||||
|
seq: int = 0
|
||||||
|
|
||||||
|
@field_validator("kind")
|
||||||
|
@classmethod
|
||||||
|
def validate_kind(cls, v: str) -> str:
|
||||||
|
if v != "inventory":
|
||||||
|
raise ValueError("kind must be 'inventory'")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("role")
|
||||||
|
@classmethod
|
||||||
|
def validate_role(cls, v: str | None) -> str | None:
|
||||||
|
if v is not None and v not in ("topdown", "extra"):
|
||||||
|
raise ValueError("role must be 'topdown' or 'extra'")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class SlabCreateData(BaseModel):
|
||||||
|
"""Client-sent slab data (the `data` multipart field).
|
||||||
|
|
||||||
|
Mirrors the OPENAPI.md SlabCreate schema. All numeric fields are
|
||||||
|
client-computed; the server stores them as-is without recomputation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str = Field(description="Client-generated UUID")
|
||||||
|
sku: str = Field(min_length=3, max_length=24, pattern=r"^[A-Z0-9-]+$")
|
||||||
|
length_in: float = Field(ge=0)
|
||||||
|
thickness_in: float = Field(ge=0)
|
||||||
|
sqft: float | None = Field(default=None, ge=0)
|
||||||
|
bdft: float | None = Field(default=None, ge=0)
|
||||||
|
width_min_in: float | None = Field(default=None, ge=0)
|
||||||
|
width_max_in: float | None = Field(default=None, ge=0)
|
||||||
|
width_avg_in: float | None = Field(default=None, ge=0)
|
||||||
|
species_id: int | None = None
|
||||||
|
wood_category_ids: list[int] | None = None
|
||||||
|
edge_type_term_id: int | None = None
|
||||||
|
figure_term_ids: list[int] | None = None
|
||||||
|
grade_term_ids: list[int] | None = None
|
||||||
|
thickness_term_id: int | None = None
|
||||||
|
moisture_term_id: int | None = None
|
||||||
|
fig_tag_ids: list[int] | None = None
|
||||||
|
feat_tag_ids: list[int] | None = None
|
||||||
|
price_per_bdft: float | None = Field(default=None, ge=0)
|
||||||
|
price: float = Field(ge=0)
|
||||||
|
price_source: str | None = Field(default=None, pattern=r"^(recommendation|override)$")
|
||||||
|
title: str | None = None
|
||||||
|
short_title: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
content_source: str | None = Field(default=None, pattern=r"^(manual|llm)$")
|
||||||
|
client_rev: int = Field(default=1, ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class PhotoRead(BaseModel):
|
||||||
|
"""Photo read model returned in slab responses."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
kind: str = "inventory"
|
||||||
|
role: str | None = None
|
||||||
|
seq: int = 0
|
||||||
|
original_url: str
|
||||||
|
processed_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SlabRead(BaseModel):
|
||||||
|
"""Slab read model — all fields from create plus server state."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
sku: str
|
||||||
|
status: str
|
||||||
|
length_in: float
|
||||||
|
thickness_in: float
|
||||||
|
sqft: float | None = None
|
||||||
|
bdft: float | None = None
|
||||||
|
width_min_in: float | None = None
|
||||||
|
width_max_in: float | None = None
|
||||||
|
width_avg_in: float | None = None
|
||||||
|
species_id: int | None = None
|
||||||
|
wood_category_ids: list[int] | None = None
|
||||||
|
edge_type_term_id: int | None = None
|
||||||
|
figure_term_ids: list[int] | None = None
|
||||||
|
grade_term_ids: list[int] | None = None
|
||||||
|
thickness_term_id: int | None = None
|
||||||
|
moisture_term_id: int | None = None
|
||||||
|
fig_tag_ids: list[int] | None = None
|
||||||
|
feat_tag_ids: list[int] | None = None
|
||||||
|
price_per_bdft: float | None = None
|
||||||
|
price: float
|
||||||
|
price_source: str | None = None
|
||||||
|
title: str | None = None
|
||||||
|
short_title: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
content_source: str | None = None
|
||||||
|
client_rev: int
|
||||||
|
server_rev: int = 1
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
inference_thread_id: str | None = None
|
||||||
|
species_confidence: float | None = None
|
||||||
|
woo_product_id: int | None = None
|
||||||
|
published_at: str | None = None
|
||||||
|
photos: list[PhotoRead] = []
|
||||||
|
inference_status: str | None = None
|
||||||
|
inference_error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_IMAGES_DIR = Path("/data/images")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_images_dir() -> Path:
|
||||||
|
"""Return the images storage directory, creating it if needed.
|
||||||
|
|
||||||
|
For tests, falls back to a temp directory.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
return _IMAGES_DIR
|
||||||
|
except (PermissionError, OSError):
|
||||||
|
# Fallback: use temp directory (e.g., in tests)
|
||||||
|
return Path(tempfile.mkdtemp(prefix="slab_"))
|
||||||
|
|
||||||
|
|
||||||
|
def _save_photo_file(photo_id: str, file_bytes: bytes, filename: str) -> str:
|
||||||
|
"""Save an uploaded photo file to disk and return the relative path.
|
||||||
|
|
||||||
|
Stores as images/{photo_id}/{filename}. In test environments,
|
||||||
|
uses a temp directory.
|
||||||
|
"""
|
||||||
|
base_dir = _get_images_dir()
|
||||||
|
photo_dir = base_dir / photo_id
|
||||||
|
photo_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
file_path = photo_dir / filename
|
||||||
|
file_path.write_bytes(file_bytes)
|
||||||
|
return str(f"images/{photo_id}/{filename}")
|
||||||
|
|
||||||
|
|
||||||
|
def _slab_read_from_model(slab: Slab, photos: list[SlabPhoto] | None = None) -> SlabRead:
|
||||||
|
"""Convert a Slab ORM model to SlabRead response."""
|
||||||
|
photos_read = []
|
||||||
|
if photos:
|
||||||
|
for p in photos:
|
||||||
|
original_url = (
|
||||||
|
f"/api/v1/slabs/{slab.id}/photos/{p.id}/original"
|
||||||
|
if p.original_path
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
photos_read.append(
|
||||||
|
PhotoRead(
|
||||||
|
id=p.id,
|
||||||
|
kind=p.kind,
|
||||||
|
role=p.role,
|
||||||
|
seq=p.seq,
|
||||||
|
original_url=original_url or "",
|
||||||
|
processed_url=p.processed_path,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return SlabRead(
|
||||||
|
id=slab.id,
|
||||||
|
sku=slab.sku,
|
||||||
|
status=slab.status.value,
|
||||||
|
length_in=slab.length_in,
|
||||||
|
thickness_in=slab.thickness_in,
|
||||||
|
sqft=slab.sqft,
|
||||||
|
bdft=slab.bdft,
|
||||||
|
width_min_in=slab.width_min_in,
|
||||||
|
width_max_in=slab.width_max_in,
|
||||||
|
width_avg_in=slab.width_avg_in,
|
||||||
|
species_id=slab.species_id,
|
||||||
|
wood_category_ids=slab.wood_category_ids,
|
||||||
|
edge_type_term_id=slab.edge_type_term_id,
|
||||||
|
figure_term_ids=slab.figure_term_ids,
|
||||||
|
grade_term_ids=slab.grade_term_ids,
|
||||||
|
thickness_term_id=slab.thickness_term_id,
|
||||||
|
moisture_term_id=slab.moisture_term_id,
|
||||||
|
fig_tag_ids=slab.fig_tag_ids,
|
||||||
|
feat_tag_ids=slab.feat_tag_ids,
|
||||||
|
price_per_bdft=slab.price_per_bdft,
|
||||||
|
price=slab.price,
|
||||||
|
price_source=slab.price_source,
|
||||||
|
title=slab.title,
|
||||||
|
short_title=slab.short_title,
|
||||||
|
description=slab.description,
|
||||||
|
content_source=slab.content_source,
|
||||||
|
client_rev=slab.client_rev,
|
||||||
|
server_rev=slab.server_rev,
|
||||||
|
created_at=slab.created_at,
|
||||||
|
updated_at=slab.updated_at,
|
||||||
|
inference_thread_id=slab.inference_thread_id,
|
||||||
|
photos=photos_read,
|
||||||
|
inference_status=slab.inference_thread_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Endpoints
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/slabs", status_code=201)
|
||||||
|
async def create_slab(request: Request) -> JSONResponse:
|
||||||
|
"""Create a calibrated draft from a multipart upload.
|
||||||
|
|
||||||
|
The request body must be multipart/form-data with these fields:
|
||||||
|
- `data` — JSON-encoded SlabCreate payload
|
||||||
|
- `files[]` — original image files (1–5 photos)
|
||||||
|
- `meta[]` — JSON-encoded photo metadata objects
|
||||||
|
|
||||||
|
Returns 201 with the created slab (status: calibrated, server_rev: 1).
|
||||||
|
Server stores client-sent numbers only; no server-side recompute.
|
||||||
|
|
||||||
|
Per AGENTS.md §3: client is authoritative for deterministic math;
|
||||||
|
server stores client-sent numbers.
|
||||||
|
"""
|
||||||
|
# Parse multipart form data
|
||||||
|
form = await request.form()
|
||||||
|
|
||||||
|
# --- Extract and validate `data` field ---
|
||||||
|
data_json = form.get("data")
|
||||||
|
if not data_json:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail={
|
||||||
|
"code": "validation",
|
||||||
|
"message": "Missing 'data' field in multipart upload.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
create_data = SlabCreateData.model_validate_json(data_json)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail={
|
||||||
|
"code": "validation",
|
||||||
|
"message": f"Invalid data JSON: {e}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Extract `files[]` and `meta[]` ---
|
||||||
|
files: list[request.file_uploads] = form.getlist("files[]")
|
||||||
|
metas: list[str] = form.getlist("meta[]")
|
||||||
|
|
||||||
|
# Require at least one photo
|
||||||
|
if not files:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail={
|
||||||
|
"code": "validation",
|
||||||
|
"message": "At least one photo file is required.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pair files with metadata; extras get default meta
|
||||||
|
if len(metas) < len(files):
|
||||||
|
# Pad with default meta for files without explicit metadata
|
||||||
|
default_meta = PhotoMeta(kind="inventory", role=None, seq=0)
|
||||||
|
metas = list(metas) + [default_meta.model_dump_json()] * (len(files) - len(metas))
|
||||||
|
|
||||||
|
if len(files) != len(metas):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail={
|
||||||
|
"code": "validation",
|
||||||
|
"message": "files[] and meta[] counts must match.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Validate meta objects and resolve photo files ---
|
||||||
|
photo_metas = []
|
||||||
|
for i, meta_json in enumerate(metas):
|
||||||
|
try:
|
||||||
|
meta = PhotoMeta.model_validate_json(meta_json)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail={
|
||||||
|
"code": "validation",
|
||||||
|
"message": f"Invalid meta[{i}] JSON: {e}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
photo_metas.append(meta)
|
||||||
|
|
||||||
|
# --- Database: create slab and photos ---
|
||||||
|
session = await get_session()
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check for duplicate SKU
|
||||||
|
existing = await session.execute(
|
||||||
|
text("SELECT id FROM slabs WHERE sku = :sku"), {"sku": create_data.sku}
|
||||||
|
)
|
||||||
|
if existing.fetchone():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail={
|
||||||
|
"code": "duplicate_sku",
|
||||||
|
"message": f"SKU '{create_data.sku}' already exists.",
|
||||||
|
"actions": ["edit_sku", "open_existing"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create slab record — status starts as calibrated
|
||||||
|
slab = Slab(
|
||||||
|
id=create_data.id,
|
||||||
|
sku=create_data.sku,
|
||||||
|
status=SlabStatus.CALIBRATED,
|
||||||
|
length_in=create_data.length_in,
|
||||||
|
thickness_in=create_data.thickness_in,
|
||||||
|
sqft=create_data.sqft,
|
||||||
|
bdft=create_data.bdft,
|
||||||
|
width_min_in=create_data.width_min_in,
|
||||||
|
width_max_in=create_data.width_max_in,
|
||||||
|
width_avg_in=create_data.width_avg_in,
|
||||||
|
species_id=create_data.species_id,
|
||||||
|
wood_category_ids=create_data.wood_category_ids,
|
||||||
|
edge_type_term_id=create_data.edge_type_term_id,
|
||||||
|
figure_term_ids=create_data.figure_term_ids,
|
||||||
|
grade_term_ids=create_data.grade_term_ids,
|
||||||
|
thickness_term_id=create_data.thickness_term_id,
|
||||||
|
moisture_term_id=create_data.moisture_term_id,
|
||||||
|
fig_tag_ids=create_data.fig_tag_ids,
|
||||||
|
feat_tag_ids=create_data.feat_tag_ids,
|
||||||
|
price_per_bdft=create_data.price_per_bdft,
|
||||||
|
price=create_data.price,
|
||||||
|
price_source=create_data.price_source,
|
||||||
|
title=create_data.title,
|
||||||
|
short_title=create_data.short_title,
|
||||||
|
description=create_data.description,
|
||||||
|
content_source=create_data.content_source,
|
||||||
|
client_rev=create_data.client_rev,
|
||||||
|
server_rev=1,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
inference_thread_id=None,
|
||||||
|
)
|
||||||
|
session.add(slab)
|
||||||
|
|
||||||
|
# Create photo records and store files
|
||||||
|
photos = []
|
||||||
|
for file_upload, meta in zip(files, photo_metas):
|
||||||
|
file_bytes = await file_upload.read()
|
||||||
|
filename = file_upload.filename or f"photo_{meta.seq}.jpg"
|
||||||
|
|
||||||
|
photo_id = str(uuid.uuid4())
|
||||||
|
original_path = _save_photo_file(photo_id, file_bytes, filename)
|
||||||
|
|
||||||
|
photo = SlabPhoto(
|
||||||
|
id=photo_id,
|
||||||
|
slab_id=slab.id,
|
||||||
|
kind=meta.kind,
|
||||||
|
role=meta.role,
|
||||||
|
seq=meta.seq,
|
||||||
|
original_path=original_path,
|
||||||
|
processed_path=None,
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
session.add(photo)
|
||||||
|
photos.append(photo)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# Build response
|
||||||
|
result = _slab_read_from_model(slab, photos)
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=201,
|
||||||
|
content=result.model_dump(mode="json"),
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
await session.rollback()
|
||||||
|
logger.exception("Failed to create slab")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail={
|
||||||
|
"code": "internal",
|
||||||
|
"message": f"Failed to create slab: {exc}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
@@ -7,6 +7,7 @@ from fastapi import FastAPI
|
|||||||
|
|
||||||
from app.api import health as health_router
|
from app.api import health as health_router
|
||||||
from app.api import settings as settings_router
|
from app.api import settings as settings_router
|
||||||
|
from app.api import slabs as slabs_router
|
||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
from app.db.engine import close_db_connection, init_db
|
from app.db.engine import close_db_connection, init_db
|
||||||
from app.logging_config import configure_logging
|
from app.logging_config import configure_logging
|
||||||
@@ -50,5 +51,6 @@ def create_app(app_settings: Settings | None = None) -> FastAPI:
|
|||||||
|
|
||||||
app.include_router(health_router.router)
|
app.include_router(health_router.router)
|
||||||
app.include_router(settings_router.router)
|
app.include_router(settings_router.router)
|
||||||
|
app.include_router(slabs_router.router)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ pydantic>=2.9,<3
|
|||||||
pydantic-settings>=2.7,<3
|
pydantic-settings>=2.7,<3
|
||||||
python-dotenv>=1.0,<2
|
python-dotenv>=1.0,<2
|
||||||
cryptography>=43.0,<45
|
cryptography>=43.0,<45
|
||||||
|
python-multipart>=0.0.30,<1
|
||||||
|
|||||||
@@ -0,0 +1,520 @@
|
|||||||
|
"""D-1: Draft store stub — POST /api/v1/slabs contract test.
|
||||||
|
|
||||||
|
Tests via httpx TestClient. Each test gets a fresh app instance (FUND-8).
|
||||||
|
|
||||||
|
D-1: POST /api/v1/slabs accepts multipart with data JSON + files + metadata;
|
||||||
|
returns 201 with status: calibrated and server_rev.
|
||||||
|
|
||||||
|
FUND-8: App factory creates fresh instance per test.
|
||||||
|
FUND-9: TestClient context manager manages lifespan.
|
||||||
|
FUND-10: dependency_overrides cleared after every test.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
from app.main import create_app
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateSlab:
|
||||||
|
"""D-1: POST /api/v1/slabs multipart contract."""
|
||||||
|
|
||||||
|
def test_returns_201(self) -> None:
|
||||||
|
"""D-1: POST /api/v1/slabs returns 201 on valid multipart upload."""
|
||||||
|
settings = Settings(
|
||||||
|
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||||
|
woo_base_url="https://test.example.com",
|
||||||
|
)
|
||||||
|
app = create_app(settings)
|
||||||
|
|
||||||
|
slab_id = str(uuid.uuid4())
|
||||||
|
data_json = json.dumps({
|
||||||
|
"id": slab_id,
|
||||||
|
"sku": "BW-0042",
|
||||||
|
"length_in": 96.0,
|
||||||
|
"thickness_in": 1.5,
|
||||||
|
"sqft": 0.64,
|
||||||
|
"bdft": 0.96,
|
||||||
|
"width_min_in": 14.0,
|
||||||
|
"width_max_in": 22.0,
|
||||||
|
"width_avg_in": 18.2,
|
||||||
|
"species_id": 42,
|
||||||
|
"wood_category_ids": [15, 23],
|
||||||
|
"edge_type_term_id": 8,
|
||||||
|
"figure_term_ids": [3],
|
||||||
|
"grade_term_ids": [7],
|
||||||
|
"thickness_term_id": 12,
|
||||||
|
"moisture_term_id": 5,
|
||||||
|
"fig_tag_ids": [99],
|
||||||
|
"feat_tag_ids": [42],
|
||||||
|
"price_per_bdft": 12.50,
|
||||||
|
"price": 12.00,
|
||||||
|
"price_source": "recommendation",
|
||||||
|
"title": None,
|
||||||
|
"short_title": None,
|
||||||
|
"description": None,
|
||||||
|
"content_source": None,
|
||||||
|
"client_rev": 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/slabs",
|
||||||
|
files=[
|
||||||
|
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
|
||||||
|
],
|
||||||
|
data={
|
||||||
|
"data": data_json,
|
||||||
|
"meta[]": json.dumps({
|
||||||
|
"kind": "inventory",
|
||||||
|
"role": "topdown",
|
||||||
|
"seq": 1,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
def test_returns_status_calibrated(self) -> None:
|
||||||
|
"""D-1: Response contains status: calibrated."""
|
||||||
|
settings = Settings(
|
||||||
|
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||||
|
woo_base_url="https://test.example.com",
|
||||||
|
)
|
||||||
|
app = create_app(settings)
|
||||||
|
|
||||||
|
slab_id = str(uuid.uuid4())
|
||||||
|
data_json = json.dumps({
|
||||||
|
"id": slab_id,
|
||||||
|
"sku": "BW-0099",
|
||||||
|
"length_in": 96.0,
|
||||||
|
"thickness_in": 1.5,
|
||||||
|
"sqft": 0.64,
|
||||||
|
"bdft": 0.96,
|
||||||
|
"width_min_in": 14.0,
|
||||||
|
"width_max_in": 22.0,
|
||||||
|
"width_avg_in": 18.2,
|
||||||
|
"species_id": 42,
|
||||||
|
"wood_category_ids": [15, 23],
|
||||||
|
"edge_type_term_id": 8,
|
||||||
|
"figure_term_ids": [3],
|
||||||
|
"grade_term_ids": [7],
|
||||||
|
"thickness_term_id": 12,
|
||||||
|
"moisture_term_id": 5,
|
||||||
|
"fig_tag_ids": [99],
|
||||||
|
"feat_tag_ids": [42],
|
||||||
|
"price_per_bdft": 12.50,
|
||||||
|
"price": 12.00,
|
||||||
|
"price_source": "recommendation",
|
||||||
|
"title": None,
|
||||||
|
"short_title": None,
|
||||||
|
"description": None,
|
||||||
|
"content_source": None,
|
||||||
|
"client_rev": 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/slabs",
|
||||||
|
files=[
|
||||||
|
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
|
||||||
|
],
|
||||||
|
data={
|
||||||
|
"data": data_json,
|
||||||
|
"meta[]": json.dumps({
|
||||||
|
"kind": "inventory",
|
||||||
|
"role": "topdown",
|
||||||
|
"seq": 1,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
body = response.json()
|
||||||
|
assert body["status"] == "calibrated"
|
||||||
|
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
def test_returns_server_rev(self) -> None:
|
||||||
|
"""D-1: Response contains server_rev."""
|
||||||
|
settings = Settings(
|
||||||
|
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||||
|
woo_base_url="https://test.example.com",
|
||||||
|
)
|
||||||
|
app = create_app(settings)
|
||||||
|
|
||||||
|
slab_id = str(uuid.uuid4())
|
||||||
|
data_json = json.dumps({
|
||||||
|
"id": slab_id,
|
||||||
|
"sku": "BW-0077",
|
||||||
|
"length_in": 96.0,
|
||||||
|
"thickness_in": 1.5,
|
||||||
|
"sqft": 0.64,
|
||||||
|
"bdft": 0.96,
|
||||||
|
"width_min_in": 14.0,
|
||||||
|
"width_max_in": 22.0,
|
||||||
|
"width_avg_in": 18.2,
|
||||||
|
"species_id": 42,
|
||||||
|
"wood_category_ids": [15, 23],
|
||||||
|
"edge_type_term_id": 8,
|
||||||
|
"figure_term_ids": [3],
|
||||||
|
"grade_term_ids": [7],
|
||||||
|
"thickness_term_id": 12,
|
||||||
|
"moisture_term_id": 5,
|
||||||
|
"fig_tag_ids": [99],
|
||||||
|
"feat_tag_ids": [42],
|
||||||
|
"price_per_bdft": 12.50,
|
||||||
|
"price": 12.00,
|
||||||
|
"price_source": "recommendation",
|
||||||
|
"title": None,
|
||||||
|
"short_title": None,
|
||||||
|
"description": None,
|
||||||
|
"content_source": None,
|
||||||
|
"client_rev": 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/slabs",
|
||||||
|
files=[
|
||||||
|
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
|
||||||
|
],
|
||||||
|
data={
|
||||||
|
"data": data_json,
|
||||||
|
"meta[]": json.dumps({
|
||||||
|
"kind": "inventory",
|
||||||
|
"role": "topdown",
|
||||||
|
"seq": 1,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
body = response.json()
|
||||||
|
assert "server_rev" in body
|
||||||
|
assert body["server_rev"] == 1
|
||||||
|
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
def test_response_shape(self) -> None:
|
||||||
|
"""D-1: Response contains required fields."""
|
||||||
|
settings = Settings(
|
||||||
|
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||||
|
woo_base_url="https://test.example.com",
|
||||||
|
)
|
||||||
|
app = create_app(settings)
|
||||||
|
|
||||||
|
slab_id = str(uuid.uuid4())
|
||||||
|
data_json = json.dumps({
|
||||||
|
"id": slab_id,
|
||||||
|
"sku": "BW-0033",
|
||||||
|
"length_in": 96.0,
|
||||||
|
"thickness_in": 1.5,
|
||||||
|
"sqft": 0.64,
|
||||||
|
"bdft": 0.96,
|
||||||
|
"width_min_in": 14.0,
|
||||||
|
"width_max_in": 22.0,
|
||||||
|
"width_avg_in": 18.2,
|
||||||
|
"species_id": 42,
|
||||||
|
"wood_category_ids": [15, 23],
|
||||||
|
"edge_type_term_id": 8,
|
||||||
|
"figure_term_ids": [3],
|
||||||
|
"grade_term_ids": [7],
|
||||||
|
"thickness_term_id": 12,
|
||||||
|
"moisture_term_id": 5,
|
||||||
|
"fig_tag_ids": [99],
|
||||||
|
"feat_tag_ids": [42],
|
||||||
|
"price_per_bdft": 12.50,
|
||||||
|
"price": 12.00,
|
||||||
|
"price_source": "recommendation",
|
||||||
|
"title": None,
|
||||||
|
"short_title": None,
|
||||||
|
"description": None,
|
||||||
|
"content_source": None,
|
||||||
|
"client_rev": 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/slabs",
|
||||||
|
files=[
|
||||||
|
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
|
||||||
|
],
|
||||||
|
data={
|
||||||
|
"data": data_json,
|
||||||
|
"meta[]": json.dumps({
|
||||||
|
"kind": "inventory",
|
||||||
|
"role": "topdown",
|
||||||
|
"seq": 1,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
body = response.json()
|
||||||
|
|
||||||
|
# Required fields per OPENAPI.md Slab read model
|
||||||
|
required_fields = [
|
||||||
|
"id", "sku", "status", "length_in", "thickness_in",
|
||||||
|
"sqft", "bdft", "width_min_in", "width_max_in", "width_avg_in",
|
||||||
|
"species_id", "wood_category_ids", "edge_type_term_id",
|
||||||
|
"figure_term_ids", "grade_term_ids", "thickness_term_id",
|
||||||
|
"moisture_term_id", "fig_tag_ids", "feat_tag_ids",
|
||||||
|
"price_per_bdft", "price", "price_source", "title",
|
||||||
|
"short_title", "description", "content_source",
|
||||||
|
"client_rev", "server_rev", "created_at", "updated_at",
|
||||||
|
"photos", "inference_status",
|
||||||
|
]
|
||||||
|
for field in required_fields:
|
||||||
|
assert field in body, f"Missing field: {field}"
|
||||||
|
|
||||||
|
# Verify stored values match sent values (server stores as-is)
|
||||||
|
assert body["id"] == slab_id
|
||||||
|
assert body["sku"] == "BW-0033"
|
||||||
|
assert body["sqft"] == 0.64
|
||||||
|
assert body["bdft"] == 0.96
|
||||||
|
assert body["width_min_in"] == 14.0
|
||||||
|
assert body["width_max_in"] == 22.0
|
||||||
|
assert body["width_avg_in"] == 18.2
|
||||||
|
assert body["price"] == 12.00
|
||||||
|
assert body["price_source"] == "recommendation"
|
||||||
|
assert body["species_id"] == 42
|
||||||
|
assert body["wood_category_ids"] == [15, 23]
|
||||||
|
assert body["edge_type_term_id"] == 8
|
||||||
|
assert body["figure_term_ids"] == [3]
|
||||||
|
assert body["grade_term_ids"] == [7]
|
||||||
|
assert body["thickness_term_id"] == 12
|
||||||
|
assert body["moisture_term_id"] == 5
|
||||||
|
assert body["fig_tag_ids"] == [99]
|
||||||
|
assert body["feat_tag_ids"] == [42]
|
||||||
|
assert body["price_per_bdft"] == 12.50
|
||||||
|
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
def test_multiple_photos(self) -> None:
|
||||||
|
"""D-1: Multiple files[] and meta[] pairs are accepted."""
|
||||||
|
settings = Settings(
|
||||||
|
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||||
|
woo_base_url="https://test.example.com",
|
||||||
|
)
|
||||||
|
app = create_app(settings)
|
||||||
|
|
||||||
|
slab_id = str(uuid.uuid4())
|
||||||
|
data_json = json.dumps({
|
||||||
|
"id": slab_id,
|
||||||
|
"sku": "BW-0055",
|
||||||
|
"length_in": 96.0,
|
||||||
|
"thickness_in": 1.5,
|
||||||
|
"sqft": 0.64,
|
||||||
|
"bdft": 0.96,
|
||||||
|
"width_min_in": 14.0,
|
||||||
|
"width_max_in": 22.0,
|
||||||
|
"width_avg_in": 18.2,
|
||||||
|
"species_id": 42,
|
||||||
|
"wood_category_ids": [15, 23],
|
||||||
|
"edge_type_term_id": 8,
|
||||||
|
"figure_term_ids": [3],
|
||||||
|
"grade_term_ids": [7],
|
||||||
|
"thickness_term_id": 12,
|
||||||
|
"moisture_term_id": 5,
|
||||||
|
"fig_tag_ids": [99],
|
||||||
|
"feat_tag_ids": [42],
|
||||||
|
"price_per_bdft": 12.50,
|
||||||
|
"price": 12.00,
|
||||||
|
"price_source": "recommendation",
|
||||||
|
"title": None,
|
||||||
|
"short_title": None,
|
||||||
|
"description": None,
|
||||||
|
"content_source": None,
|
||||||
|
"client_rev": 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/slabs",
|
||||||
|
files=[
|
||||||
|
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
|
||||||
|
("files[]", ("extra1.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
|
||||||
|
],
|
||||||
|
data={
|
||||||
|
"data": data_json,
|
||||||
|
"meta[]": json.dumps({
|
||||||
|
"kind": "inventory",
|
||||||
|
"role": "topdown",
|
||||||
|
"seq": 1,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# Note: httpx TestClient with multiple files[] + single meta[]
|
||||||
|
# will pair them. The meta[] for the second file defaults.
|
||||||
|
assert response.status_code == 201
|
||||||
|
body = response.json()
|
||||||
|
assert len(body["photos"]) >= 2
|
||||||
|
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
def test_missing_data_field_returns_400(self) -> None:
|
||||||
|
"""D-1: Missing data field returns 400."""
|
||||||
|
settings = Settings(
|
||||||
|
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||||
|
woo_base_url="https://test.example.com",
|
||||||
|
)
|
||||||
|
app = create_app(settings)
|
||||||
|
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/slabs",
|
||||||
|
files=[
|
||||||
|
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
|
||||||
|
],
|
||||||
|
data={
|
||||||
|
"meta[]": json.dumps({
|
||||||
|
"kind": "inventory",
|
||||||
|
"role": "topdown",
|
||||||
|
"seq": 1,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
body = response.json()
|
||||||
|
assert body["detail"]["code"] == "validation"
|
||||||
|
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
def test_duplicate_sku_returns_409(self) -> None:
|
||||||
|
"""D-1: Duplicate SKU returns 409."""
|
||||||
|
settings = Settings(
|
||||||
|
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||||
|
woo_base_url="https://test.example.com",
|
||||||
|
)
|
||||||
|
app = create_app(settings)
|
||||||
|
|
||||||
|
slab_id = str(uuid.uuid4())
|
||||||
|
sku = "DUPLICATE-TEST"
|
||||||
|
data_json = json.dumps({
|
||||||
|
"id": slab_id,
|
||||||
|
"sku": sku,
|
||||||
|
"length_in": 96.0,
|
||||||
|
"thickness_in": 1.5,
|
||||||
|
"sqft": 0.64,
|
||||||
|
"bdft": 0.96,
|
||||||
|
"width_min_in": 14.0,
|
||||||
|
"width_max_in": 22.0,
|
||||||
|
"width_avg_in": 18.2,
|
||||||
|
"species_id": 42,
|
||||||
|
"wood_category_ids": [15, 23],
|
||||||
|
"edge_type_term_id": 8,
|
||||||
|
"figure_term_ids": [3],
|
||||||
|
"grade_term_ids": [7],
|
||||||
|
"thickness_term_id": 12,
|
||||||
|
"moisture_term_id": 5,
|
||||||
|
"fig_tag_ids": [99],
|
||||||
|
"feat_tag_ids": [42],
|
||||||
|
"price_per_bdft": 12.50,
|
||||||
|
"price": 12.00,
|
||||||
|
"price_source": "recommendation",
|
||||||
|
"title": None,
|
||||||
|
"short_title": None,
|
||||||
|
"description": None,
|
||||||
|
"content_source": None,
|
||||||
|
"client_rev": 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
with TestClient(app) as client:
|
||||||
|
# Create first slab
|
||||||
|
response1 = client.post(
|
||||||
|
"/api/v1/slabs",
|
||||||
|
files=[
|
||||||
|
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
|
||||||
|
],
|
||||||
|
data={
|
||||||
|
"data": data_json,
|
||||||
|
"meta[]": json.dumps({
|
||||||
|
"kind": "inventory",
|
||||||
|
"role": "topdown",
|
||||||
|
"seq": 1,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response1.status_code == 201
|
||||||
|
|
||||||
|
# Try to create second slab with same SKU
|
||||||
|
response2 = client.post(
|
||||||
|
"/api/v1/slabs",
|
||||||
|
files=[
|
||||||
|
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
|
||||||
|
],
|
||||||
|
data={
|
||||||
|
"data": data_json,
|
||||||
|
"meta[]": json.dumps({
|
||||||
|
"kind": "inventory",
|
||||||
|
"role": "topdown",
|
||||||
|
"seq": 1,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response2.status_code == 409
|
||||||
|
body = response2.json()
|
||||||
|
assert body["detail"]["code"] == "duplicate_sku"
|
||||||
|
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
def test_with_test_client_lifespan(self) -> None:
|
||||||
|
"""FUND-9: TestClient with app manages lifespan correctly for slabs."""
|
||||||
|
settings = Settings(
|
||||||
|
aes_key="dGVzdC1rZXktZm9yLW1hbGxlb3AtdGVzdGluZy1vbmx5LXNob3VsZC1iZS11c2Vk",
|
||||||
|
woo_base_url="https://test.example.com",
|
||||||
|
)
|
||||||
|
app = create_app(settings)
|
||||||
|
|
||||||
|
slab_id = str(uuid.uuid4())
|
||||||
|
data_json = json.dumps({
|
||||||
|
"id": slab_id,
|
||||||
|
"sku": "BW-0011",
|
||||||
|
"length_in": 96.0,
|
||||||
|
"thickness_in": 1.5,
|
||||||
|
"sqft": 0.64,
|
||||||
|
"bdft": 0.96,
|
||||||
|
"width_min_in": 14.0,
|
||||||
|
"width_max_in": 22.0,
|
||||||
|
"width_avg_in": 18.2,
|
||||||
|
"species_id": 42,
|
||||||
|
"wood_category_ids": [15, 23],
|
||||||
|
"edge_type_term_id": 8,
|
||||||
|
"figure_term_ids": [3],
|
||||||
|
"grade_term_ids": [7],
|
||||||
|
"thickness_term_id": 12,
|
||||||
|
"moisture_term_id": 5,
|
||||||
|
"fig_tag_ids": [99],
|
||||||
|
"feat_tag_ids": [42],
|
||||||
|
"price_per_bdft": 12.50,
|
||||||
|
"price": 12.00,
|
||||||
|
"price_source": "recommendation",
|
||||||
|
"title": None,
|
||||||
|
"short_title": None,
|
||||||
|
"description": None,
|
||||||
|
"content_source": None,
|
||||||
|
"client_rev": 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/slabs",
|
||||||
|
files=[
|
||||||
|
("files[]", ("topdown.jpg", b"\xff\xd8\xff\xe0", "image/jpeg")),
|
||||||
|
],
|
||||||
|
data={
|
||||||
|
"data": data_json,
|
||||||
|
"meta[]": json.dumps({
|
||||||
|
"kind": "inventory",
|
||||||
|
"role": "topdown",
|
||||||
|
"seq": 1,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
assert response.json()["status"] == "calibrated"
|
||||||
|
|
||||||
|
app.dependency_overrides.clear()
|
||||||
Reference in New Issue
Block a user