feat(#27): Gate B [07/10]: FastAPI health endpoint + app factory (H-1)

- App factory pattern (FUND-8): create_app() produces fresh app per test
- Logging config (structured, no secrets)
- Config validation: SLAB_AES_KEY required, WOO_BASE_URL HTTPS-only
- GET /api/health returns 200 {status: ok, version, woo_reachable: false}
- SQLite + alembic skeleton: full DATA-MODEL schema, initial migration
- API contract tests via httpx TestClient: 5 cases all green
  - test_returns_200, test_returns_ok_status, test_woo_reachable_false
  - test_response_shape, test_with_test_client_lifespan (FUND-9/10)
This commit is contained in:
grokbot_ty
2026-09-07 07:53:36 +00:00
committed by grokbot_ty
parent d5d8c49e04
commit e5514fe3bf
29 changed files with 915 additions and 0 deletions
View File
+65
View File
@@ -0,0 +1,65 @@
"""Alembic environment configuration.
Sets up sys.path so that app packages are importable when running
alembic from the backend directory.
Alembic uses sync connections for migrations even with an async app.
"""
import os
import sys
# Ensure the backend root is on the path so app.* is importable
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
# Import models so that Base.metadata includes all tables
from app.db.models import Base
config = context.config
fileConfig(config.config_file_name)
def run_migrations_online() -> None:
"""Run migrations in 'online' mode using sync engine."""
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=Base.metadata,
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
connectable.dispose()
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode."""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=Base.metadata,
render_as_batch=True,
literal_binds=True,
dialect_opts={"paramstyle": "batch"},
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
@@ -0,0 +1,178 @@
"""initial schema.
Revision ID: initial
Revises:
Create Date: 2026-09-07
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "initial"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# slabs
op.create_table(
"slabs",
sa.Column("id", sa.Text(), nullable=False, primary_key=True),
sa.Column("sku", sa.String(length=24), nullable=False),
sa.Column("status", sa.Text(), nullable=False, server_default="draft"),
sa.Column("length_in", sa.Float(), nullable=False),
sa.Column("thickness_in", sa.Float(), nullable=False),
sa.Column("sqft", sa.Float(), nullable=True),
sa.Column("bdft", sa.Float(), nullable=True),
sa.Column("width_min_in", sa.Float(), nullable=True),
sa.Column("width_max_in", sa.Float(), nullable=True),
sa.Column("width_avg_in", sa.Float(), nullable=True),
sa.Column("species_id", sa.Integer(), nullable=True),
sa.Column("wood_category_ids", sa.JSON(), nullable=True),
sa.Column("edge_type_term_id", sa.Integer(), nullable=True),
sa.Column("figure_term_ids", sa.JSON(), nullable=True),
sa.Column("grade_term_ids", sa.JSON(), nullable=True),
sa.Column("thickness_term_id", sa.Integer(), nullable=True),
sa.Column("moisture_term_id", sa.Integer(), nullable=True),
sa.Column("fig_tag_ids", sa.JSON(), nullable=True),
sa.Column("feat_tag_ids", sa.JSON(), nullable=True),
sa.Column("price_per_bdft", sa.Float(), nullable=True),
sa.Column("price", sa.Float(), nullable=False),
sa.Column("price_source", sa.Text(), nullable=True),
sa.Column("title", sa.Text(), nullable=True),
sa.Column("short_title", sa.Text(), nullable=True),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("content_source", sa.Text(), nullable=True),
sa.Column("client_rev", sa.Integer(), nullable=False, server_default="1"),
sa.Column("server_rev", sa.Integer(), nullable=False, server_default="1"),
sa.Column("created_at", sa.Text(), nullable=False, server_default=sa.text("datetime('now')")),
sa.Column("updated_at", sa.Text(), nullable=False, server_default=sa.text("datetime('now')")),
sa.Column("inference_thread_id", sa.Text(), nullable=True),
sa.CheckConstraint("length_in >= 0"),
sa.CheckConstraint("thickness_in >= 0"),
sa.CheckConstraint("price >= 0"),
sa.UniqueConstraint("sku"),
)
op.create_index("idx_slabs_status", "slabs", ["status"])
op.create_index("idx_slabs_updated_at", "slabs", ["updated_at"])
# slab_photos
op.create_table(
"slab_photos",
sa.Column("id", sa.Text(), nullable=False, primary_key=True),
sa.Column("slab_id", sa.Text(), nullable=False),
sa.Column("kind", sa.Text(), nullable=False),
sa.Column("role", sa.Text(), nullable=True),
sa.Column("seq", sa.Integer(), nullable=False, server_default="0"),
sa.Column("original_path", sa.Text(), nullable=True),
sa.Column("processed_path", sa.Text(), nullable=True),
sa.Column("created_at", sa.Text(), server_default=sa.text("datetime('now')")),
sa.ForeignKeyConstraint(["slab_id"], ["slabs.id"], ondelete="CASCADE"),
)
# pricing_rules
op.create_table(
"pricing_rules",
sa.Column("id", sa.Text(), nullable=False, primary_key=True),
sa.Column("species", sa.Text(), nullable=False),
sa.Column("price_per_bdft", sa.Float(), nullable=False),
sa.Column("created_at", sa.Text(), server_default=sa.text("datetime('now')")),
sa.Column("updated_at", sa.Text(), server_default=sa.text("datetime('now')")),
sa.UniqueConstraint("species"),
)
# woo_categories
op.create_table(
"woo_categories",
sa.Column("woo_id", sa.Integer(), nullable=False, primary_key=True),
sa.Column("name", sa.Text(), nullable=True),
sa.Column("slug", sa.Text(), nullable=True),
sa.Column("parent_id", sa.Integer(), nullable=True),
sa.Column("synced_at", sa.Text(), nullable=True),
sa.UniqueConstraint("woo_id"),
)
# woo_attributes
op.create_table(
"woo_attributes",
sa.Column("woo_id", sa.Integer(), nullable=False, primary_key=True),
sa.Column("name", sa.Text(), nullable=True),
sa.Column("slug", sa.Text(), nullable=True),
sa.Column("type", sa.Text(), server_default="select"),
sa.Column("synced_at", sa.Text(), nullable=True),
sa.UniqueConstraint("woo_id"),
)
# woo_attribute_terms
op.create_table(
"woo_attribute_terms",
sa.Column("woo_id", sa.Integer(), nullable=False, primary_key=True),
sa.Column("attribute_id", sa.Integer(), nullable=False),
sa.Column("name", sa.Text(), nullable=True),
sa.Column("slug", sa.Text(), nullable=True),
sa.Column("synced_at", sa.Text(), nullable=True),
sa.ForeignKeyConstraint(["attribute_id"], ["woo_attributes.woo_id"]),
sa.UniqueConstraint("woo_id"),
)
# woo_tags
op.create_table(
"woo_tags",
sa.Column("woo_id", sa.Integer(), nullable=False, primary_key=True),
sa.Column("name", sa.Text(), nullable=True),
sa.Column("slug", sa.Text(), nullable=True),
sa.Column("synced_at", sa.Text(), nullable=True),
sa.UniqueConstraint("woo_id"),
)
# settings
op.create_table(
"settings",
sa.Column("key", sa.Text(), nullable=False, primary_key=True),
sa.Column("value_enc", sa.LargeBinary(), nullable=True),
sa.Column("updated_at", sa.Text(), server_default=sa.text("datetime('now')")),
)
# sync_log
op.create_table(
"sync_log",
sa.Column("id", sa.Integer(), nullable=False, autoincrement=True, primary_key=True),
sa.Column("slab_id", sa.Text(), nullable=True),
sa.Column("sku", sa.Text(), nullable=True),
sa.Column("woo_product_id", sa.Integer(), nullable=True),
sa.Column("status", sa.Text(), nullable=True),
sa.Column("http_status", sa.Integer(), nullable=True),
sa.Column("error", sa.Text(), nullable=True),
sa.Column("detail", sa.Text(), nullable=True),
sa.Column("payload_hash", sa.Text(), nullable=True),
sa.Column("created_at", sa.Text(), server_default=sa.text("datetime('now')")),
)
# inference_log
op.create_table(
"inference_log",
sa.Column("id", sa.Integer(), nullable=False, autoincrement=True, primary_key=True),
sa.Column("slab_id", sa.Text(), nullable=True),
sa.Column("call", sa.Text(), nullable=True),
sa.Column("request_payload", sa.Text(), nullable=True),
sa.Column("response_payload", sa.Text(), nullable=True),
sa.Column("created_at", sa.Text(), server_default=sa.text("datetime('now')")),
)
def downgrade() -> None:
op.drop_table("inference_log")
op.drop_table("sync_log")
op.drop_table("settings")
op.drop_table("woo_tags")
op.drop_table("woo_attribute_terms")
op.drop_table("woo_attributes")
op.drop_table("woo_categories")
op.drop_table("pricing_rules")
op.drop_table("slab_photos")
op.drop_index("idx_slabs_updated_at", table_name="slabs")
op.drop_index("idx_slabs_status", table_name="slabs")
op.drop_table("slabs")