From 306b5c1e5db5ed0bbceb39b9903863817705cf24 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 10 Jun 2026 22:36:50 -0700 Subject: [PATCH] =?UTF-8?q?feat(slice-3):=20POST=20/api/storefronts=20+=20?= =?UTF-8?q?storefronts-fed=20entry=20routing=20(=C2=A76.4/=C2=A76.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- backend/app/main.py | 42 ++++++++--- backend/tests/test_storefronts_create.py | 90 ++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 10 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index 3f89071..84a43aa 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 diff --git a/backend/tests/test_storefronts_create.py b/backend/tests/test_storefronts_create.py index e0a2be2..1160a69 100644 --- a/backend/tests/test_storefronts_create.py +++ b/backend/tests/test_storefronts_create.py @@ -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"