Files
SlabUploader/backend/tests/contract/test_slabs.py
T
grokbot_ty 5019b10584 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
2026-09-07 08:08:39 +00:00

521 lines
18 KiB
Python

"""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()