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"`.
|
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
|
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`
|
`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:
|
`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
|
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).
|
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
|
`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}`
|
### `DELETE /api/v1/slabs/{id}`
|
||||||
Soft delete of unpublished drafts only (sets `deleted_at`); `204`. Deleted rows are excluded from
|
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
|
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
|
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
|
`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`,
|
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`,
|
`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
|
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;
|
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.
|
`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
|
- 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.
|
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
|
## 10. Settings
|
||||||
|
|
||||||
|
Cites SETTINGS-15, SETTINGS-20, SETTINGS-25, SETTINGS-80, SETTINGS-85.
|
||||||
|
|
||||||
- `GET /api/v1/settings` → `SettingsView` with secrets masked.
|
- `GET /api/v1/settings` → `SettingsView` with secrets masked.
|
||||||
- `PUT /api/v1/settings` — partial update. `woo_base_url` is not writable; the server rejects the
|
- `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
|
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
|
## 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_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,
|
- `woo_attributes` / `woo_attribute_terms` — exactly the five locked attributes Edge Type, Figure,
|
||||||
Grade, Thickness, Moisture, with their terms.
|
Grade, Thickness, Moisture, with their terms.
|
||||||
@@ -148,6 +150,8 @@ revision it used (`METADATA-66`).
|
|||||||
|
|
||||||
## 8. `sync_log`
|
## 8. `sync_log`
|
||||||
|
|
||||||
|
Cites PUBLISH-25, PUBLISH-30.
|
||||||
|
|
||||||
Append-only; never deleted on publish. Columns: `id`, `slab_id`, `woo_product_id`, `status`
|
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`.
|
(`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
|
## 10. Status transitions
|
||||||
|
|
||||||
|
Cites APP-5.
|
||||||
|
|
||||||
`draft` → `calibrated` → `ready` → `publishing` → `published`, with `failed` reachable from any
|
`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.
|
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
|
## 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
|
**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.
|
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-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-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-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-10, METADATA-15, METADATA-30 | required taxonomy fields and cardinality |
|
||||||
| METADATA-45, METADATA-55, METADATA-110 | pre-fill, Generate text availability |
|
| 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 |
|
| 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 |
|
| SETTINGS-65 | create-status default |
|
||||||
|
|
||||||
@@ -26,6 +26,8 @@ flow runs on a PC browser.
|
|||||||
|
|
||||||
## 2. Capture
|
## 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
|
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.
|
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
|
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
|
## 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
|
- **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
|
with an "AI" badge; below it the field starts empty. The badge is presentation only and clears
|
||||||
on edit.
|
on edit.
|
||||||
@@ -63,6 +67,8 @@ undersized source never guess; the resolution is retake.
|
|||||||
|
|
||||||
## 4. Publish
|
## 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.
|
- 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.
|
"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.
|
- **Success.** The local draft and photos are gone; the store product reference is shown.
|
||||||
|
|||||||
Reference in New Issue
Block a user