SLICE-3: storefront — create + INV-4 guard, Create/Admin screens, full entry routing (SD-0001 §7.2) #9
+32
-10
@@ -3,8 +3,8 @@
|
|||||||
SLICE-1 mounted /healthz; SLICE-2 adds the /api/auth/* identity endpoints (§6.4). The BFF
|
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
|
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 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
|
the configured mailer (INV-8) at startup. SLICE-3 adds POST /api/storefronts and feeds the
|
||||||
storefronts domain (SLICE-3) replaces the _storefront_for seam.
|
_storefront_for seam from the storefronts domain.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ from fastapi import Depends, FastAPI, Response
|
|||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from pydantic import BaseModel
|
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 config, db
|
||||||
from app.platform import mailer as mailer_mod
|
from app.platform import mailer as mailer_mod
|
||||||
from app.platform.deps import SESSION_COOKIE, get_conn, get_mailer, get_session
|
from app.platform.deps import SESSION_COOKIE, get_conn, get_mailer, get_session
|
||||||
@@ -35,18 +35,19 @@ class VerifyBody(BaseModel):
|
|||||||
code: str
|
code: str
|
||||||
|
|
||||||
|
|
||||||
|
class CreateStorefrontBody(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def _error(status: int, code: str, message: str, **extra: Any) -> JSONResponse:
|
def _error(status: int, code: str, message: str, **extra: Any) -> JSONResponse:
|
||||||
"""The shared §6.4 error envelope: {"error": {"code", "message", ...}}."""
|
"""The shared §6.4 error envelope: {"error": {"code", "message", ...}}."""
|
||||||
return JSONResponse(status_code=status, content={"error": {"code": code, "message": message, **extra}})
|
return JSONResponse(status_code=status, content={"error": {"code": code, "message": message, **extra}})
|
||||||
|
|
||||||
|
|
||||||
def _storefront_for(conn: psycopg.Connection, account: accounts.Account) -> dict | None:
|
def _storefront_for(conn: psycopg.Connection, account: accounts.Account) -> dict | None:
|
||||||
"""The entry-routing answer: which storefront, if any, this account has (§6.5).
|
"""The entry-routing answer: which storefront, if any, this account has (§6.5)."""
|
||||||
|
sf = storefronts.storefront_for(conn, account.id)
|
||||||
Hard-wired null in SLICE-2 (identity only). SLICE-3 replaces this with a storefronts
|
return {"id": sf.id, "name": sf.name} if sf else None
|
||||||
domain call; the verify/me responses already carry the field.
|
|
||||||
"""
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_app_logging() -> None:
|
def _ensure_app_logging() -> None:
|
||||||
@@ -93,7 +94,7 @@ def create_app(database_url: str | None = None) -> FastAPI:
|
|||||||
finally:
|
finally:
|
||||||
app.state.pool.close()
|
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")
|
@app.get("/healthz")
|
||||||
def healthz(response: Response, conn: psycopg.Connection = Depends(get_conn)):
|
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="/")
|
resp.delete_cookie(SESSION_COOKIE, path="/")
|
||||||
return resp
|
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
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""SLICE-3 storefront creation — service + endpoint scenario tests (SD-0001 §6.5 PUC-4/7)."""
|
"""SLICE-3 storefront creation — service + endpoint scenario tests (SD-0001 §6.5 PUC-4/7)."""
|
||||||
import re
|
import re
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
import psycopg
|
import psycopg
|
||||||
import pytest
|
import pytest
|
||||||
@@ -62,3 +63,92 @@ def test_membership_row_is_owner(migrated_conn):
|
|||||||
def test_storefront_for_none_when_absent(migrated_conn):
|
def test_storefront_for_none_when_absent(migrated_conn):
|
||||||
acct = _account_id(migrated_conn)
|
acct = _account_id(migrated_conn)
|
||||||
assert storefronts.storefront_for(migrated_conn, acct) is None
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user