5019b10584
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
521 lines
18 KiB
Python
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()
|