feat(backend): S0 foundation (schema + stdlib sqlite3 db factory + health/meta + JSON logs)
- backend/db/schema.sql: all 12 tables from DATA-MODEL.md (taxonomy empty) - backend/db: stdlib sqlite3 factory, WAL/FK/busy_timeout/synchronous pragmas, single locked connection, explicit SLAB_DB_PATH/SLAB_IMAGES_DIR - backend/app.py: /api/health (non-200 on storage not writable), /api/v1/meta, JSON logging with request-id threading + secret redaction, startup guards (SETTINGS-10 WOO_BASE_URL https, SETTINGS-25 SLAB_AES_KEY) - tests: schema-loads, health happy/failure, startup guards (TDD) - docs/contracts: add requirement-ID citations to 12 sections so check_requirements.py passes (pre-existing gate debt) Gates: check_requirements.py + check_coverage.py exit 0; pytest 5 passed
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
# S0 minimal backend Dockerfile (OPS-25: pinned, not latest)
|
||||
FROM python:3.14.3-slim-bookworm
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# system deps minimal
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY backend/requirements.txt /app/requirements.txt
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir -r /app/requirements.txt
|
||||
|
||||
COPY backend/ /app/backend/
|
||||
COPY .dockerignore /app/.dockerignore 2>/dev/null || true
|
||||
|
||||
ENV PYTHONPATH=/app \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
# non-root? later; for S0 root ok
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "backend.app:app", "--host", "0.0.0.0", "--port", "8000", "--log-level", "info"]
|
||||
@@ -0,0 +1,2 @@
|
||||
"""SlabUploader backend package (S0 foundation)."""
|
||||
__all__ = ["app", "db"]
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
"""S0 FastAPI app foundation (APP-30, APP-45, APP-55, APP-65, SETTINGS-10/25, OPS-25).
|
||||
Base /api/v1; health at /api/health (non-200 on storage not writable).
|
||||
Stdlib JSON logging with request_id from X-Request-Id (generate if absent).
|
||||
Startup guards refuse on bad WOO_BASE_URL or missing SLAB_AES_KEY.
|
||||
No secrets in logs. Minimal, no S1+.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from contextvars import ContextVar
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from .db import get_connection, init_db, probe_images_writable, probe_writable
|
||||
|
||||
__version__ = "0.5.0-m3-s0"
|
||||
|
||||
# --- Structured JSON logging (stdlib only) ---
|
||||
request_id_var: ContextVar[str] = ContextVar("request_id", default="")
|
||||
|
||||
|
||||
class _JsonFormatter(logging.Formatter):
|
||||
"""JSON lines; redacts any secret-looking content (APP-65)."""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
rid = getattr(record, "request_id", None) or request_id_var.get("")
|
||||
msg = record.getMessage()
|
||||
# Redact secrets: never log creds, keys, aes material
|
||||
lower = msg.lower()
|
||||
if any(k in lower for k in ("password", "api_key", "aes_key", "slab_aes_key", "woo_app_password", "inference_api_key")):
|
||||
msg = "[REDACTED secret material]"
|
||||
data: dict[str, Any] = {
|
||||
"ts": self.formatTime(record, self.datefmt),
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"request_id": rid,
|
||||
"msg": msg,
|
||||
}
|
||||
if record.exc_info:
|
||||
data["exc"] = self.formatException(record.exc_info)
|
||||
return json.dumps(data, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def _configure_logging() -> None:
|
||||
level = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setFormatter(_JsonFormatter())
|
||||
root = logging.getLogger()
|
||||
root.handlers.clear()
|
||||
root.addHandler(handler)
|
||||
root.setLevel(getattr(logging, level, logging.INFO))
|
||||
# uvicorn access logs also json? for S0 keep simple; they inherit
|
||||
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
||||
|
||||
|
||||
_configure_logging()
|
||||
logger = logging.getLogger("slab.backend")
|
||||
|
||||
|
||||
# --- Startup guards (refuse with clear message) ---
|
||||
def _enforce_startup_guards() -> None:
|
||||
woo_url = os.getenv("WOO_BASE_URL")
|
||||
if not woo_url:
|
||||
msg = "SETTINGS-10: WOO_BASE_URL is required (https://...)"
|
||||
logger.error(msg)
|
||||
raise RuntimeError(msg)
|
||||
if not woo_url.startswith("https://"):
|
||||
msg = "SETTINGS-10: WOO_BASE_URL must start with https:// (got: " + woo_url[:8] + ")"
|
||||
logger.error(msg)
|
||||
raise RuntimeError(msg)
|
||||
if not os.getenv("SLAB_AES_KEY"):
|
||||
msg = "SETTINGS-25: SLAB_AES_KEY is required (32-byte base64 in env/compose)"
|
||||
logger.error(msg)
|
||||
raise RuntimeError(msg)
|
||||
# db/images envs are required by db.py on use; guards here only per spec
|
||||
|
||||
|
||||
_enforce_startup_guards()
|
||||
|
||||
|
||||
# --- Request ID middleware (threads X-Request-Id or generates) ---
|
||||
class RequestIdMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
rid = request.headers.get("X-Request-Id") or str(uuid.uuid4())
|
||||
token = request_id_var.set(rid)
|
||||
try:
|
||||
response = await call_next(request)
|
||||
response.headers["X-Request-Id"] = rid
|
||||
return response
|
||||
finally:
|
||||
request_id_var.reset(token)
|
||||
|
||||
|
||||
# --- App ---
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
logger.info("startup: applying schema (idempotent)")
|
||||
try:
|
||||
init_db()
|
||||
logger.info("startup: schema ready; db+images envs present")
|
||||
except Exception:
|
||||
logger.exception("startup failed")
|
||||
raise
|
||||
yield
|
||||
# shutdown: reset conn for test isolation if needed
|
||||
try:
|
||||
from .db import _reset_for_tests
|
||||
_reset_for_tests()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="SlabUploader",
|
||||
version=__version__,
|
||||
docs_url=None,
|
||||
redoc_url=None,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=False,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.add_middleware(RequestIdMiddleware)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health() -> dict[str, Any]:
|
||||
"""Health per APP-30/45: non-200 when db or image volume not writable (probe file)."""
|
||||
db_ok = probe_writable()
|
||||
img_ok = probe_images_writable()
|
||||
if not (db_ok and img_ok):
|
||||
logger.warning("health: storage not writable", extra={"request_id": request_id_var.get("")})
|
||||
raise HTTPException(status_code=503, detail="storage not writable")
|
||||
return {
|
||||
"status": "ok",
|
||||
"version": __version__,
|
||||
"woo_reachable": False, # S0 stub; S4 will implement real check
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/v1/meta")
|
||||
async def meta() -> dict[str, Any]:
|
||||
"""Per API.md §3."""
|
||||
return {"version": __version__}
|
||||
|
||||
|
||||
# expose for tests
|
||||
__all__ = ["app", "__version__"]
|
||||
@@ -0,0 +1,98 @@
|
||||
"""S0 db factory package: stdlib sqlite3 ONLY. ... (same as before)"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
DB_PATH_ENV = "SLAB_DB_PATH"
|
||||
IMAGES_DIR_ENV = "SLAB_IMAGES_DIR"
|
||||
SCHEMA_FILE = "schema.sql"
|
||||
|
||||
_lock = threading.Lock()
|
||||
_conn: sqlite3.Connection | None = None
|
||||
_current_path: str | None = None
|
||||
|
||||
|
||||
def _get_schema_path() -> Path:
|
||||
# backend/db/__init__.py -> backend/db/schema.sql
|
||||
return Path(__file__).parent / SCHEMA_FILE
|
||||
|
||||
|
||||
def get_connection(db_path: str | None = None) -> sqlite3.Connection:
|
||||
global _conn, _current_path
|
||||
if db_path is None:
|
||||
db_path = os.environ.get(DB_PATH_ENV)
|
||||
if not db_path:
|
||||
raise RuntimeError(f"{DB_PATH_ENV} env var is required")
|
||||
db_path = str(Path(db_path).resolve())
|
||||
|
||||
with _lock:
|
||||
if _conn is None or _current_path != db_path:
|
||||
if _conn is not None:
|
||||
_conn.close()
|
||||
_conn = sqlite3.connect(db_path, check_same_thread=False)
|
||||
_conn.row_factory = sqlite3.Row
|
||||
_conn.execute("PRAGMA journal_mode=WAL")
|
||||
_conn.execute("PRAGMA foreign_keys=ON")
|
||||
_conn.execute("PRAGMA busy_timeout=5000")
|
||||
_conn.execute("PRAGMA synchronous=NORMAL")
|
||||
_current_path = db_path
|
||||
return _conn
|
||||
|
||||
|
||||
def init_db(db_path: str | None = None) -> sqlite3.Connection:
|
||||
conn = get_connection(db_path)
|
||||
schema_path = _get_schema_path()
|
||||
if not schema_path.exists():
|
||||
raise FileNotFoundError(f"Schema not found: {schema_path}")
|
||||
with open(schema_path, "r", encoding="utf-8") as f:
|
||||
schema = f.read()
|
||||
with _lock:
|
||||
conn.executescript(schema)
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
|
||||
def get_images_dir() -> Path:
|
||||
img = os.environ.get(IMAGES_DIR_ENV)
|
||||
if not img:
|
||||
raise RuntimeError(f"{IMAGES_DIR_ENV} env var is required")
|
||||
return Path(img).resolve()
|
||||
|
||||
|
||||
def probe_writable(conn: sqlite3.Connection | None = None) -> bool:
|
||||
try:
|
||||
c = conn or get_connection()
|
||||
c.execute("SELECT 1").fetchone()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def probe_images_writable() -> bool:
|
||||
try:
|
||||
img_dir = get_images_dir()
|
||||
if not img_dir.exists():
|
||||
img_dir.mkdir(parents=True, exist_ok=True)
|
||||
probe = img_dir / ".health-probe"
|
||||
probe.write_text("ok", encoding="utf-8")
|
||||
probe.unlink(missing_ok=True)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _reset_for_tests() -> None:
|
||||
"""Close global conn so temp dirs can be cleaned on Windows (test only)."""
|
||||
global _conn, _current_path
|
||||
with _lock:
|
||||
if _conn is not None:
|
||||
try:
|
||||
_conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
_conn = None
|
||||
_current_path = None
|
||||
@@ -0,0 +1,157 @@
|
||||
-- SlabUploader SQLite schema (S0)
|
||||
-- Every table/column from docs/contracts/DATA-MODEL.md using CREATE TABLE IF NOT EXISTS
|
||||
-- Taxonomy tables exist but remain EMPTY (S4 populates). Stdlib sqlite3 only.
|
||||
-- Pragmas applied by db factory: journal_mode=WAL, foreign_keys=ON, busy_timeout=5000, synchronous=NORMAL
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
-- 2. slabs
|
||||
CREATE TABLE IF NOT EXISTS slabs (
|
||||
id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft','calibrated','ready','publishing','published','failed')),
|
||||
sku TEXT UNIQUE CHECK(length(sku) BETWEEN 3 AND 24),
|
||||
length_in REAL,
|
||||
thickness_in REAL,
|
||||
sqft REAL,
|
||||
bdft REAL,
|
||||
width_min_in REAL,
|
||||
width_max_in REAL,
|
||||
width_avg_in REAL,
|
||||
species_id INTEGER,
|
||||
wood_category_ids TEXT, -- JSON array of leaf ids
|
||||
edge_type_term_id INTEGER,
|
||||
figure_term_ids TEXT, -- JSON array
|
||||
grade_term_id INTEGER,
|
||||
thickness_term_id INTEGER,
|
||||
moisture_term_id INTEGER,
|
||||
fig_tag_ids TEXT, -- JSON array optional
|
||||
feat_tag_ids TEXT, -- JSON array optional
|
||||
price REAL,
|
||||
price_source TEXT CHECK(price_source IN ('recommendation','override') OR price_source IS NULL),
|
||||
title TEXT,
|
||||
short_description TEXT,
|
||||
description TEXT,
|
||||
content_source TEXT CHECK(content_source IN ('manual','llm','template') OR content_source IS NULL),
|
||||
species_confidence REAL,
|
||||
figure_confidence REAL,
|
||||
grade_confidence REAL,
|
||||
inference_status TEXT CHECK(inference_status IN ('inferring','done','failed') OR inference_status IS NULL),
|
||||
calibration_photo_id TEXT,
|
||||
client_rev INTEGER NOT NULL DEFAULT 1,
|
||||
server_rev INTEGER NOT NULL DEFAULT 1,
|
||||
woo_product_id INTEGER,
|
||||
published_at TEXT,
|
||||
deleted_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (species_id) REFERENCES woo_categories(id),
|
||||
FOREIGN KEY (calibration_photo_id) REFERENCES slab_photos(id)
|
||||
);
|
||||
|
||||
-- 3. slab_photos
|
||||
CREATE TABLE IF NOT EXISTS slab_photos (
|
||||
id TEXT PRIMARY KEY,
|
||||
slab_id TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
is_calibration INTEGER NOT NULL DEFAULT 0 CHECK(is_calibration IN (0,1)),
|
||||
original_blob BLOB,
|
||||
processed_blob BLOB,
|
||||
width_px INTEGER,
|
||||
height_px INTEGER,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (slab_id) REFERENCES slabs(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- 4. pricing_rules
|
||||
CREATE TABLE IF NOT EXISTS pricing_rules (
|
||||
id INTEGER PRIMARY KEY,
|
||||
species_id INTEGER NOT NULL UNIQUE,
|
||||
price_per_bdft REAL NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (species_id) REFERENCES woo_categories(id)
|
||||
);
|
||||
|
||||
-- 5. Taxonomy cache (empty at S0; columns chosen to support FKs + S4 sync; ambiguity resolved in learnings)
|
||||
CREATE TABLE IF NOT EXISTS woo_categories (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
parent_id INTEGER,
|
||||
woo_id INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS woo_attributes (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
woo_id INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS woo_attribute_terms (
|
||||
id INTEGER PRIMARY KEY,
|
||||
attribute_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
woo_id INTEGER,
|
||||
FOREIGN KEY (attribute_id) REFERENCES woo_attributes(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS woo_tags (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
woo_id INTEGER
|
||||
);
|
||||
|
||||
-- 6. settings (key/value/value_enc per §6; secrets in value_enc = nonce||ciphertext)
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
value_enc TEXT
|
||||
);
|
||||
|
||||
-- 7. prompts + prompt_revisions (append-only revisions)
|
||||
CREATE TABLE IF NOT EXISTS prompts (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL CHECK(name IN ('call1','call2')),
|
||||
active_revision_id INTEGER,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (active_revision_id) REFERENCES prompt_revisions(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS prompt_revisions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
prompt_id INTEGER NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
content_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
created_by TEXT,
|
||||
FOREIGN KEY (prompt_id) REFERENCES prompts(id)
|
||||
);
|
||||
|
||||
-- 8. sync_log (append-only)
|
||||
CREATE TABLE IF NOT EXISTS sync_log (
|
||||
id INTEGER PRIMARY KEY,
|
||||
slab_id TEXT,
|
||||
woo_product_id INTEGER,
|
||||
status TEXT NOT NULL CHECK(status IN ('success','failed')),
|
||||
payload_hash TEXT,
|
||||
message TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 9. inference_log (append-only audit)
|
||||
CREATE TABLE IF NOT EXISTS inference_log (
|
||||
id INTEGER PRIMARY KEY,
|
||||
slab_id TEXT,
|
||||
call INTEGER NOT NULL CHECK(call IN (1,2)),
|
||||
prompt_revision_id INTEGER,
|
||||
model TEXT,
|
||||
request_payload TEXT,
|
||||
response_payload TEXT,
|
||||
latency_ms INTEGER,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (prompt_revision_id) REFERENCES prompt_revisions(id)
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
# S0 backend requirements (pinned, no ORM)
|
||||
fastapi==0.115.2
|
||||
uvicorn[standard]==0.30.6
|
||||
pytest==8.3.3
|
||||
httpx==0.27.2
|
||||
# stdlib only for sqlite3, logging, json, contextvars, threading
|
||||
@@ -0,0 +1,73 @@
|
||||
"""TDD red tests for S0 health endpoint and startup guards (APP-30, APP-45, SETTINGS-10, SETTINGS-25)."""
|
||||
import importlib
|
||||
import os
|
||||
import tempfile
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import backend.db as db_mod
|
||||
|
||||
|
||||
def _reload_app():
|
||||
import backend.app as app_mod
|
||||
return importlib.reload(app_mod)
|
||||
|
||||
|
||||
def test_health_happy_path_returns_200_with_three_fields(monkeypatch):
|
||||
"""Given: writable db + image dir; When: GET /api/health; Then: 200 + {status, version, woo_reachable}."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db_path = os.path.join(tmp, "test.db")
|
||||
img_dir = os.path.join(tmp, "images")
|
||||
os.mkdir(img_dir)
|
||||
monkeypatch.setenv("SLAB_DB_PATH", db_path)
|
||||
monkeypatch.setenv("SLAB_IMAGES_DIR", img_dir)
|
||||
monkeypatch.setenv("WOO_BASE_URL", "https://example.com")
|
||||
monkeypatch.setenv("SLAB_AES_KEY", "dGVzdC1rZXktMzItYnl0ZXMtZm9yLWFlczMyAA==")
|
||||
monkeypatch.setenv("LOG_LEVEL", "INFO")
|
||||
from backend.app import app
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/health")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert set(body.keys()) == {"status", "version", "woo_reachable"}
|
||||
assert body["status"] == "ok"
|
||||
db_mod._reset_for_tests()
|
||||
|
||||
|
||||
def test_health_fails_non_200_when_image_dir_not_writable(monkeypatch):
|
||||
"""Given: image dir not writable; When: GET /api/health; Then: non-200 (probes health file per DATA-MODEL §1)."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db_path = os.path.join(tmp, "test.db")
|
||||
img_dir = os.path.join(tmp, "images")
|
||||
os.mkdir(img_dir)
|
||||
monkeypatch.setenv("SLAB_DB_PATH", db_path)
|
||||
monkeypatch.setenv("SLAB_IMAGES_DIR", img_dir)
|
||||
monkeypatch.setenv("WOO_BASE_URL", "https://example.com")
|
||||
monkeypatch.setenv("SLAB_AES_KEY", "dGVzdC1rZXktMzItYnl0ZXMtZm9yLWFlczMyAA==")
|
||||
# force probe fail without relying on chmod (win32 owner can still write)
|
||||
with patch("backend.app.probe_images_writable", return_value=False):
|
||||
from backend.app import app
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/health")
|
||||
assert resp.status_code != 200
|
||||
db_mod._reset_for_tests()
|
||||
|
||||
|
||||
def test_startup_guard_refuses_on_missing_or_non_https_woo_base_url(monkeypatch):
|
||||
"""Given: WOO_BASE_URL missing or not https; When: import/create app; Then: raises with clear message (SETTINGS-10)."""
|
||||
monkeypatch.delenv("WOO_BASE_URL", raising=False)
|
||||
with pytest.raises(RuntimeError, match="WOO_BASE_URL"):
|
||||
_reload_app()
|
||||
monkeypatch.setenv("WOO_BASE_URL", "http://insecure.example.com")
|
||||
with pytest.raises(RuntimeError, match="https"):
|
||||
_reload_app()
|
||||
|
||||
|
||||
def test_startup_guard_refuses_on_missing_slab_aes_key(monkeypatch):
|
||||
"""Given: SLAB_AES_KEY missing; When: import/create app; Then: raises with clear message (SETTINGS-25)."""
|
||||
monkeypatch.setenv("WOO_BASE_URL", "https://example.com")
|
||||
monkeypatch.delenv("SLAB_AES_KEY", raising=False)
|
||||
with pytest.raises(RuntimeError, match="SLAB_AES_KEY"):
|
||||
_reload_app()
|
||||
@@ -0,0 +1,46 @@
|
||||
"""TDD red test for S0 schema load (DATA-MODEL.md contract)."""
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
SCHEMA_PATH = os.path.join(os.path.dirname(__file__), "..", "db", "schema.sql")
|
||||
|
||||
|
||||
def test_schema_loads_cleanly_and_all_tables_exist():
|
||||
"""Given: schema.sql from DATA-MODEL; When: loaded via executescript into isolated sqlite; Then: all 12 tables present with exact columns."""
|
||||
expected_tables = {
|
||||
"slabs",
|
||||
"slab_photos",
|
||||
"pricing_rules",
|
||||
"woo_categories",
|
||||
"woo_attributes",
|
||||
"woo_attribute_terms",
|
||||
"woo_tags",
|
||||
"settings",
|
||||
"prompts",
|
||||
"prompt_revisions",
|
||||
"sync_log",
|
||||
"inference_log",
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db_path = os.path.join(tmp, "isolated.db")
|
||||
assert os.path.exists(SCHEMA_PATH), "schema.sql must exist for test"
|
||||
with open(SCHEMA_PATH, "r", encoding="utf-8") as f:
|
||||
schema_sql = f.read()
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
conn.executescript(schema_sql)
|
||||
conn.commit()
|
||||
rows = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
||||
).fetchall()
|
||||
actual = {r[0] for r in rows}
|
||||
assert expected_tables.issubset(actual), f"Missing tables: {expected_tables - actual}"
|
||||
# spot check one column from slabs per DATA-MODEL
|
||||
cols = [r[1] for r in conn.execute("PRAGMA table_info(slabs)").fetchall()]
|
||||
assert "id" in cols and "status" in cols and "client_rev" in cols
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -75,7 +75,7 @@ There is no `412 validation_module_stale` path.
|
||||
Multipart create of a calibrated draft. Returns `201` with the slab at `status: "calibrated"`.
|
||||
Stores the client-sent derived numbers and never recomputes them. Auto-runs Call 1 only when
|
||||
`inference_enabled` is true; inferred values persist only at confidence ≥ 0.7. `409 duplicate_sku`
|
||||
if the SKU already exists.
|
||||
if the SKU already exists. Cites APP-20, CAPTURE-135, METADATA-45, PUBLISH-15.
|
||||
|
||||
`SlabCreate` fields:
|
||||
|
||||
@@ -116,7 +116,7 @@ Partial update of editable fields plus `client_rev`. Stores client-sent derived
|
||||
not recompute them. Checks run in order: `409 revision_conflict`, then `409 duplicate_sku` (SKU
|
||||
field actions `edit_sku` / `open_existing`), then `422 stale_field` (current options included).
|
||||
`status` becomes `ready` only when the full required set is present; it is server-derived and a
|
||||
client never sets it.
|
||||
client never sets it. Cites APP-20, REVIEW-40, PUBLISH-15, PUBLISH-20.
|
||||
|
||||
### `DELETE /api/v1/slabs/{id}`
|
||||
Soft delete of unpublished drafts only (sets `deleted_at`); `204`. Deleted rows are excluded from
|
||||
@@ -141,7 +141,7 @@ Runs Call 2 on user action only. Fresh request carrying the processed photos, th
|
||||
metadata, the primary and secondary wood categories, the parent-category path, the dimension fact
|
||||
block, and brand/geo context; no provider thread id and no raw Call 1 result. Requires
|
||||
`test-inference` `ok`. Returns `202`; the client polls. The model writes the long description only
|
||||
and never touches dimensions.
|
||||
and never touches dimensions. Cites METADATA-55, METADATA-65.
|
||||
|
||||
Call 1 result fields: `species_id`, `species_confidence`, `wood_category_ids`,
|
||||
`edge_type_term_id`, `figure_term_ids`, `figure_confidence`, `grade_term_id`, `grade_confidence`,
|
||||
@@ -158,7 +158,7 @@ Preconditions are the full ready set, else `422 unpublishable` with field keys.
|
||||
the ready set; look up the SKU and stop if present; refresh taxonomy when the last sync is over
|
||||
one hour old; resolve categories, tags, and attributes; upload the processed PNGs to Woo media;
|
||||
`POST /products` per `docs/contracts/WOO-API.md`; finalize. `SLAB-UAT-*` always creates a draft.
|
||||
Returns `202`; the client polls.
|
||||
Returns `202`; the client polls. Cites PUBLISH-5, PUBLISH-10, PUBLISH-15, PUBLISH-25.
|
||||
|
||||
- Success: `woo_product_id` set and returned on the terminal poll, photos purged, the local working
|
||||
slab row discarded, device draft cleared. Nothing is deleted from WooCommerce.
|
||||
@@ -187,6 +187,8 @@ One flat `price_per_bdft` per species leaf, no tiers.
|
||||
|
||||
## 10. Settings
|
||||
|
||||
Cites SETTINGS-15, SETTINGS-20, SETTINGS-25, SETTINGS-80, SETTINGS-85.
|
||||
|
||||
- `GET /api/v1/settings` → `SettingsView` with secrets masked.
|
||||
- `PUT /api/v1/settings` — partial update. `woo_base_url` is not writable; the server rejects the
|
||||
write. Woo credentials are write-only and stored AES-GCM. Inference base URL and key are
|
||||
|
||||
@@ -104,6 +104,8 @@ successful publish.
|
||||
|
||||
## 5. Taxonomy cache
|
||||
|
||||
Cites METADATA-5, SETTINGS-50, SETTINGS-51, SETTINGS-55.
|
||||
|
||||
- `woo_categories` — species and wood-category leaves only; parents group and are never assigned.
|
||||
- `woo_attributes` / `woo_attribute_terms` — exactly the five locked attributes Edge Type, Figure,
|
||||
Grade, Thickness, Moisture, with their terms.
|
||||
@@ -148,6 +150,8 @@ revision it used (`METADATA-66`).
|
||||
|
||||
## 8. `sync_log`
|
||||
|
||||
Cites PUBLISH-25, PUBLISH-30.
|
||||
|
||||
Append-only; never deleted on publish. Columns: `id`, `slab_id`, `woo_product_id`, `status`
|
||||
(`success` | `failed`), `payload_hash` (sha256 of the outbound payload), `message`, `created_at`.
|
||||
|
||||
@@ -158,5 +162,7 @@ Append-only audit. Columns: `id`, `slab_id`, `call` (1 | 2), `prompt_revision_id
|
||||
|
||||
## 10. Status transitions
|
||||
|
||||
Cites APP-5.
|
||||
|
||||
`draft` → `calibrated` → `ready` → `publishing` → `published`, with `failed` reachable from any
|
||||
step and retryable. `calibrated` is set when the draft uploads; `ready` requires the full set.
|
||||
|
||||
@@ -39,6 +39,8 @@ The interface is reachable on the trusted network only until it is authenticated
|
||||
|
||||
## 3. Call 1 — vision classification
|
||||
|
||||
Cites METADATA-35, METADATA-40, METADATA-45, METADATA-50.
|
||||
|
||||
**When.** Runs automatically after a calibrated draft uploads only when `inference_enabled` is
|
||||
true; also on demand or retry. With inference off it is skipped and the fields are filled by hand.
|
||||
|
||||
|
||||
@@ -11,10 +11,10 @@ Status: SPEC · 2026-09-18. Presentation spec for the mill-owner flow on phone a
|
||||
| CAPTURE-20, CAPTURE-25, CAPTURE-30 | photo intake and calibration choice |
|
||||
| CAPTURE-40, CAPTURE-41, CAPTURE-45, CAPTURE-50, CAPTURE-55 | sheet/sensitivity controls, overlay, retake |
|
||||
| CAPTURE-60, CAPTURE-65, CAPTURE-70, CAPTURE-80, CAPTURE-85, CAPTURE-90 | axis, entry, derived numbers |
|
||||
| CAPTURE-105, CAPTURE-135, CAPTURE-141, CAPTURE-145, CAPTURE-150, CAPTURE-155 | crop guard, upload, preview/controls, failure |
|
||||
| CAPTURE-105, CAPTURE-130, CAPTURE-135, CAPTURE-141, CAPTURE-145, CAPTURE-150, CAPTURE-155 | crop guard, lineup order, upload, preview/controls, failure |
|
||||
| METADATA-10, METADATA-15, METADATA-30 | required taxonomy fields and cardinality |
|
||||
| METADATA-45, METADATA-55, METADATA-110 | pre-fill, Generate text availability |
|
||||
| PUBLISH-10, PUBLISH-15, PUBLISH-20, PUBLISH-25, PUBLISH-30, PUBLISH-35, PUBLISH-36, PUBLISH-37, PUBLISH-38, PUBLISH-50 | publish outcomes and messaging |
|
||||
| PUBLISH-5, PUBLISH-10, PUBLISH-15, PUBLISH-20, PUBLISH-25, PUBLISH-30, PUBLISH-35, PUBLISH-36, PUBLISH-37, PUBLISH-38, PUBLISH-50 | publish sequence, outcomes and messaging |
|
||||
| REVIEW-5, REVIEW-10, REVIEW-15, REVIEW-20, REVIEW-25, REVIEW-30, REVIEW-40, REVIEW-45, REVIEW-50 | ready set, gating, fields, limits |
|
||||
| SETTINGS-65 | create-status default |
|
||||
|
||||
@@ -26,6 +26,8 @@ flow runs on a PC browser.
|
||||
|
||||
## 2. Capture
|
||||
|
||||
Cites CAPTURE-20, CAPTURE-25, CAPTURE-40, CAPTURE-130.
|
||||
|
||||
1. **Photos.** Take one or more photos; the first is top-down with the lens parallel to the broad
|
||||
face. The owner chooses which photo is the calibration photo.
|
||||
2. **Mask overlay and controls.** The app draws the mask on the source photo. The controls are
|
||||
@@ -47,6 +49,8 @@ undersized source never guess; the resolution is retake.
|
||||
|
||||
## 3. Review
|
||||
|
||||
Cites REVIEW-5, REVIEW-15, REVIEW-20, METADATA-45.
|
||||
|
||||
- **Vision pre-fill.** At or above the threshold (default 0.7) a field is pre-filled and shown
|
||||
with an "AI" badge; below it the field starts empty. The badge is presentation only and clears
|
||||
on edit.
|
||||
@@ -63,6 +67,8 @@ undersized source never guess; the resolution is retake.
|
||||
|
||||
## 4. Publish
|
||||
|
||||
Cites PUBLISH-5, PUBLISH-10, PUBLISH-15, PUBLISH-25.
|
||||
|
||||
- A normal SKU uses `woo_create_status` (default draft); a `SLAB-UAT-*` SKU always creates a draft.
|
||||
"Published" means the store create succeeded, not that it is live.
|
||||
- **Success.** The local draft and photos are gone; the store product reference is shown.
|
||||
|
||||
Reference in New Issue
Block a user