SLICE-3: storefront — create + INV-4 guard, Create/Admin screens, full entry routing (SD-0001 §7.2) #9
@@ -0,0 +1,19 @@
|
|||||||
|
"""storefronts domain — storefront entity, membership, the one-storefront guard.
|
||||||
|
|
||||||
|
Owns INV-4 (one storefront per account is a service-layer rule) and INV-5 (tenant rows
|
||||||
|
carry storefront_id). Knows nothing about identity (that is the accounts domain). Imported
|
||||||
|
via this package surface only (§6.2).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .errors import AlreadyOwnsStorefront, StorefrontsError
|
||||||
|
from .models import Storefront
|
||||||
|
from .service import create_storefront, storefront_for
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Storefront",
|
||||||
|
"StorefrontsError",
|
||||||
|
"AlreadyOwnsStorefront",
|
||||||
|
"create_storefront",
|
||||||
|
"storefront_for",
|
||||||
|
]
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"""storefronts domain errors."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class StorefrontsError(Exception):
|
||||||
|
"""Base for storefronts-domain errors."""
|
||||||
|
|
||||||
|
|
||||||
|
class AlreadyOwnsStorefront(StorefrontsError):
|
||||||
|
"""INV-4: the account already has its one storefront (PUC-7)."""
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"""Storefront record — the §6.3 entity as the domain returns it."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Storefront:
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""storefronts — the storefront + membership service (SD-0001 §6.5).
|
||||||
|
|
||||||
|
Owns the storefront entity, the account<->storefront membership (INV-5), the entry-routing
|
||||||
|
answer (storefront_for), and INV-4's one-storefront guard — the single deletable check that
|
||||||
|
makes one-per-account an MVP rule, not a schema law. Never mints identity: the BFF passes
|
||||||
|
account_id and email down.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
|
||||||
|
from .errors import AlreadyOwnsStorefront
|
||||||
|
from .models import Storefront
|
||||||
|
|
||||||
|
|
||||||
|
def _default_name(email: str) -> str:
|
||||||
|
"""§6.3: a blank name stores a generated default — never NULL (corpus 14.01.0026)."""
|
||||||
|
return f"{email.split('@', 1)[0]}'s storefront"
|
||||||
|
|
||||||
|
|
||||||
|
def storefront_for(conn: psycopg.Connection, account_id: int) -> Storefront | None:
|
||||||
|
"""The entry-routing answer (§6.5): which storefront, if any, this account has."""
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT s.id, s.name FROM storefront s"
|
||||||
|
" JOIN storefront_membership m ON m.storefront_id = s.id"
|
||||||
|
" WHERE m.account_id = %s ORDER BY m.created_at LIMIT 1",
|
||||||
|
(account_id,),
|
||||||
|
).fetchone()
|
||||||
|
return Storefront(id=row[0], name=row[1]) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def create_storefront(
|
||||||
|
conn: psycopg.Connection, account_id: int, email: str, name: str | None
|
||||||
|
) -> Storefront:
|
||||||
|
"""Create the account's one storefront + owner membership (PUC-4; INV-4, INV-5).
|
||||||
|
|
||||||
|
Guard + insert run as one atomic unit: a transaction-scoped advisory lock keyed by
|
||||||
|
account_id serializes concurrent creates for the same account, so the second of two
|
||||||
|
racing requests sees the first's committed membership and is refused (§6.5).
|
||||||
|
"""
|
||||||
|
conn.execute("SELECT pg_advisory_xact_lock(%s)", (account_id,))
|
||||||
|
existing = storefront_for(conn, account_id)
|
||||||
|
if existing is not None:
|
||||||
|
conn.rollback() # release the advisory lock; nothing was written
|
||||||
|
raise AlreadyOwnsStorefront()
|
||||||
|
final_name = (name or "").strip() or _default_name(email)
|
||||||
|
sf_id = conn.execute(
|
||||||
|
"INSERT INTO storefront (name) VALUES (%s) RETURNING id", (final_name,)
|
||||||
|
).fetchone()[0]
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO storefront_membership (account_id, storefront_id, role)"
|
||||||
|
" VALUES (%s, %s, 'owner')",
|
||||||
|
(account_id, sf_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return Storefront(id=sf_id, name=final_name)
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""SLICE-3 storefront creation — service + endpoint scenario tests (SD-0001 §6.5 PUC-4/7)."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import create_app
|
||||||
|
from app.domains import storefronts
|
||||||
|
from app.platform import db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def migrated_conn(fresh_db_url):
|
||||||
|
with psycopg.connect(fresh_db_url) as conn:
|
||||||
|
db.migrate(conn)
|
||||||
|
yield conn
|
||||||
|
|
||||||
|
|
||||||
|
def _account_id(conn, email="merchant@example.com"):
|
||||||
|
return conn.execute(
|
||||||
|
"INSERT INTO account (email) VALUES (%s) RETURNING id", (email,)
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_storefront_with_name(migrated_conn):
|
||||||
|
acct = _account_id(migrated_conn)
|
||||||
|
sf = storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", "Ben's Bets")
|
||||||
|
assert sf.name == "Ben's Bets"
|
||||||
|
assert storefronts.storefront_for(migrated_conn, acct) == sf
|
||||||
|
|
||||||
|
|
||||||
|
def test_14_01_0026_blank_name_gets_generated_default(migrated_conn):
|
||||||
|
acct = _account_id(migrated_conn)
|
||||||
|
sf = storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", None)
|
||||||
|
assert sf.name == "merchant's storefront"
|
||||||
|
|
||||||
|
|
||||||
|
def test_whitespace_name_treated_as_blank(migrated_conn):
|
||||||
|
acct = _account_id(migrated_conn)
|
||||||
|
sf = storefronts.create_storefront(migrated_conn, acct, "ben@bensbets.com", " ")
|
||||||
|
assert sf.name == "ben's storefront"
|
||||||
|
|
||||||
|
|
||||||
|
def test_second_create_refused_inv_4(migrated_conn):
|
||||||
|
acct = _account_id(migrated_conn)
|
||||||
|
storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", "First")
|
||||||
|
with pytest.raises(storefronts.AlreadyOwnsStorefront):
|
||||||
|
storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", "Second")
|
||||||
|
|
||||||
|
|
||||||
|
def test_membership_row_is_owner(migrated_conn):
|
||||||
|
acct = _account_id(migrated_conn)
|
||||||
|
sf = storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", None)
|
||||||
|
role = migrated_conn.execute(
|
||||||
|
"SELECT role FROM storefront_membership WHERE account_id = %s AND storefront_id = %s",
|
||||||
|
(acct, sf.id),
|
||||||
|
).fetchone()[0]
|
||||||
|
assert role == "owner"
|
||||||
|
|
||||||
|
|
||||||
|
def test_storefront_for_none_when_absent(migrated_conn):
|
||||||
|
acct = _account_id(migrated_conn)
|
||||||
|
assert storefronts.storefront_for(migrated_conn, acct) is None
|
||||||
Reference in New Issue
Block a user