feat(slice-3): POST /api/storefronts + storefronts-fed entry routing (§6.4/§6.5)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 22:36:50 -07:00
parent e11fb032b1
commit 306b5c1e5d
2 changed files with 122 additions and 10 deletions
+32 -10
View File
@@ -3,8 +3,8 @@
SLICE-1 mounted /healthz; SLICE-2 adds the /api/auth/* identity endpoints (§6.4). The BFF
translates HTTP <-> domain calls and owns no business logic (INV-6): every rule lives in
the accounts domain. create_app() opens the pool, self-migrates (INV-1, INV-7), and builds
the configured mailer (INV-8) at startup. storefront is hard-wired null this slice; the
storefronts domain (SLICE-3) replaces the _storefront_for seam.
the configured mailer (INV-8) at startup. SLICE-3 adds POST /api/storefronts and feeds the
_storefront_for seam from the storefronts domain.
"""
from __future__ import annotations
@@ -18,7 +18,7 @@ from fastapi import Depends, FastAPI, Response
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from app.domains import accounts
from app.domains import accounts, storefronts
from app.platform import config, db
from app.platform import mailer as mailer_mod
from app.platform.deps import SESSION_COOKIE, get_conn, get_mailer, get_session
@@ -35,18 +35,19 @@ class VerifyBody(BaseModel):
code: str
class CreateStorefrontBody(BaseModel):
name: str | None = None
def _error(status: int, code: str, message: str, **extra: Any) -> JSONResponse:
"""The shared §6.4 error envelope: {"error": {"code", "message", ...}}."""
return JSONResponse(status_code=status, content={"error": {"code": code, "message": message, **extra}})
def _storefront_for(conn: psycopg.Connection, account: accounts.Account) -> dict | None:
"""The entry-routing answer: which storefront, if any, this account has (§6.5).
Hard-wired null in SLICE-2 (identity only). SLICE-3 replaces this with a storefronts
domain call; the verify/me responses already carry the field.
"""
return None
"""The entry-routing answer: which storefront, if any, this account has (§6.5)."""
sf = storefronts.storefront_for(conn, account.id)
return {"id": sf.id, "name": sf.name} if sf else None
def _ensure_app_logging() -> None:
@@ -93,7 +94,7 @@ def create_app(database_url: str | None = None) -> FastAPI:
finally:
app.state.pool.close()
app = FastAPI(title="ecomm", version="0.2", lifespan=lifespan)
app = FastAPI(title="ecomm", version="0.3", lifespan=lifespan)
@app.get("/healthz")
def healthz(response: Response, conn: psycopg.Connection = Depends(get_conn)):
@@ -170,6 +171,27 @@ def create_app(database_url: str | None = None) -> FastAPI:
resp.delete_cookie(SESSION_COOKIE, path="/")
return resp
@app.post("/api/storefronts")
def create_storefront(
body: CreateStorefrontBody,
conn: psycopg.Connection = Depends(get_conn),
sess: dict | None = Depends(get_session),
):
"""Create the account's one storefront (§6.4; PUC-4, INV-4/PUC-7 on refusal)."""
if sess is None:
return _error(401, "unauthenticated", "You are not signed in.")
account = accounts.get_account(conn, sess["account_id"])
if account is None:
return _error(401, "unauthenticated", "You are not signed in.")
try:
sf = storefronts.create_storefront(conn, account.id, account.email, body.name)
except storefronts.AlreadyOwnsStorefront:
return _error(
409, "already_owns_storefront",
"Your account already has its storefront — ecomm is one storefront per account today.",
)
return JSONResponse(status_code=201, content={"id": sf.id, "name": sf.name})
return app
+90
View File
@@ -1,5 +1,6 @@
"""SLICE-3 storefront creation — service + endpoint scenario tests (SD-0001 §6.5 PUC-4/7)."""
import re
from contextlib import contextmanager
import psycopg
import pytest
@@ -62,3 +63,92 @@ def test_membership_row_is_owner(migrated_conn):
def test_storefront_for_none_when_absent(migrated_conn):
acct = _account_id(migrated_conn)
assert storefronts.storefront_for(migrated_conn, acct) is None
# ── endpoint scenario tests (§6.4 POST /api/storefronts; corpus 14.01.*) ──────────
@contextmanager
def _signed_in_client(fresh_db_url, email="merchant@example.com"):
with TestClient(create_app(database_url=fresh_db_url)) as client:
client.post("/api/auth/request-code", json={"email": email})
code = re.search(r"\b(\d{6})\b", client.app.state.mailer.outbox[-1].body).group(1)
client.post("/api/auth/verify", json={"email": email, "code": code})
yield client
def test_14_01_0013_create_storefront_with_name(fresh_db_url):
with _signed_in_client(fresh_db_url) as client:
resp = client.post("/api/storefronts", json={"name": "Ben's Bets"})
assert resp.status_code == 201
assert resp.json()["name"] == "Ben's Bets"
assert isinstance(resp.json()["id"], int)
def test_14_01_0015_0016_nothing_but_a_name_is_asked(fresh_db_url):
# No payment method, plan, or commitment: the request body needs nothing at all and
# the response carries only the storefront — no plan/trial/billing fields.
with _signed_in_client(fresh_db_url) as client:
resp = client.post("/api/storefronts", json={})
assert resp.status_code == 201
assert set(resp.json().keys()) == {"id", "name"}
def test_14_01_0025_create_lands_on_admin(fresh_db_url):
# After create, the entry-routing answer carries the storefront -> admin (PUC-6).
with _signed_in_client(fresh_db_url) as client:
created = client.post("/api/storefronts", json={"name": "Ben's Bets"}).json()
me = client.get("/api/auth/me").json()
assert me["storefront"] == created
def test_puc_05_returning_without_storefront_routes_to_create(fresh_db_url):
with _signed_in_client(fresh_db_url) as client:
me = client.get("/api/auth/me").json()
assert me["storefront"] is None
def test_14_01_0027_returning_with_storefront_straight_to_admin(fresh_db_url):
# PUC-6: a returning merchant's verify response already carries the storefront.
from datetime import datetime, timedelta, timezone
email = "returning@example.com"
with _signed_in_client(fresh_db_url, email) as client:
client.post("/api/storefronts", json={"name": "Ben's Bets"})
# fresh login: backdate the consumed code to clear the 60s resend cooldown
with psycopg.connect(fresh_db_url) as c:
c.execute(
"UPDATE auth_code SET created_at = %s WHERE email = %s",
(datetime.now(timezone.utc) - timedelta(minutes=2), email),
)
c.commit()
client.post("/api/auth/request-code", json={"email": email})
code = re.search(r"\b(\d{6})\b", client.app.state.mailer.outbox[-1].body).group(1)
resp = client.post("/api/auth/verify", json={"email": email, "code": code})
assert resp.status_code == 200
assert resp.json()["storefront"]["name"] == "Ben's Bets"
assert resp.json()["created"] is False
def test_puc_07_second_create_refused_409(fresh_db_url):
with _signed_in_client(fresh_db_url) as client:
client.post("/api/storefronts", json={"name": "First"})
resp = client.post("/api/storefronts", json={"name": "Second"})
assert resp.status_code == 409
assert resp.json()["error"]["code"] == "already_owns_storefront"
def test_puc_08_admin_shell_answer(fresh_db_url):
# The shell renders from /me alone: storefront name + signed-in email (§6.5 PUC-8).
with _signed_in_client(fresh_db_url) as client:
client.post("/api/storefronts", json={"name": "Ben's Bets"})
me = client.get("/api/auth/me").json()
assert me["account"]["email"] == "merchant@example.com"
assert me["storefront"]["name"] == "Ben's Bets"
def test_create_storefront_requires_session(fresh_db_url):
with TestClient(create_app(database_url=fresh_db_url)) as client:
resp = client.post("/api/storefronts", json={"name": "Nope"})
assert resp.status_code == 401
assert resp.json()["error"]["code"] == "unauthenticated"