diff --git a/backend/app/domains/storefronts/__init__.py b/backend/app/domains/storefronts/__init__.py new file mode 100644 index 0000000..d2db7f1 --- /dev/null +++ b/backend/app/domains/storefronts/__init__.py @@ -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", +] diff --git a/backend/app/domains/storefronts/errors.py b/backend/app/domains/storefronts/errors.py new file mode 100644 index 0000000..699f1a7 --- /dev/null +++ b/backend/app/domains/storefronts/errors.py @@ -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).""" diff --git a/backend/app/domains/storefronts/models.py b/backend/app/domains/storefronts/models.py new file mode 100644 index 0000000..4bedb2f --- /dev/null +++ b/backend/app/domains/storefronts/models.py @@ -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 diff --git a/backend/app/domains/storefronts/service.py b/backend/app/domains/storefronts/service.py new file mode 100644 index 0000000..4249dc2 --- /dev/null +++ b/backend/app/domains/storefronts/service.py @@ -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) diff --git a/backend/tests/test_storefronts_create.py b/backend/tests/test_storefronts_create.py new file mode 100644 index 0000000..e0a2be2 --- /dev/null +++ b/backend/tests/test_storefronts_create.py @@ -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