diff --git a/app/crm/api.py b/app/crm/api.py index 38089d149..dae9d762b 100644 --- a/app/crm/api.py +++ b/app/crm/api.py @@ -41,6 +41,9 @@ router.include_router( outreach_api.customer_router, prefix="/customers", tags=["Workflows"] ) +router.include_router( + outreach_api.segment_router, prefix="/segments", tags=["Segments"] +) router.include_router( connectivity_api.router, prefix="/connectors", tags=["Connectors"] ) diff --git a/app/crm/outreach/api.py b/app/crm/outreach/api.py index d954324dc..3566a68f8 100644 --- a/app/crm/outreach/api.py +++ b/app/crm/outreach/api.py @@ -24,7 +24,7 @@ from app.api.security.breeze_buddy.rbac_token import get_current_user_with_rbac from app.crm.auth import merchant_scope -from app.crm.outreach import analytics, plans, runs, versions +from app.crm.outreach import analytics, plans, runs, segments, versions from app.crm.outreach.schemas import ( CustomerRun, EnrollmentRun, @@ -33,6 +33,8 @@ RunCall, RunPage, RunStep, + Segment, + SegmentPage, VersionMigration, Workflow, WorkflowCallSummary, @@ -48,6 +50,9 @@ # The customer-facing door (/customers/{id}/runs) mounts under a different # prefix than the plans' router — the record module's two-router precedent. customer_router = APIRouter() +# The audiences door (/segments, canon T15) — outreach owns segments beside +# the plans they will one day feed through a broadcast; same precedent. +segment_router = APIRouter() class WorkflowCreate(BaseModel): @@ -55,6 +60,12 @@ class WorkflowCreate(BaseModel): definition: Dict[str, Any] +class SegmentCreate(BaseModel): + # Phase 1 creates static lists only: a name and nothing else. A + # dynamic segment's definition joins this body with its compiler. + name: str = Field(min_length=1, max_length=200) + + class WorkflowStatusChange(BaseModel): status: Literal["live", "paused", "archived"] @@ -470,3 +481,50 @@ async def customer_runs_route( ), ) -> List[CustomerRun]: return await runs.customer_runs(merchant_id, customer_id, limit) + + +@segment_router.post("", response_model=Segment, status_code=status.HTTP_201_CREATED) +async def create_segment_route( + body: SegmentCreate, + merchant_id: str = Depends( + merchant_scope("create a segment", "crm.segments.create") + ), + current_user: UserInfo = Depends(get_current_user_with_rbac), +) -> Segment: + """A new, empty static list. 409 when the merchant already has a segment + by this name; 422 when the name is blank once trimmed.""" + try: + return await segments.create_static_list( + merchant_id, body.name, current_user.email + ) + except segments.SegmentNameTaken as e: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"a segment named {e} already exists", + ) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e) + ) + + +@segment_router.get("", response_model=SegmentPage) +async def list_segments_route( + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), + merchant_id: str = Depends(merchant_scope("list segments", "crm.segments.list")), +) -> SegmentPage: + return await segments.list_segments(merchant_id, limit, offset) + + +@segment_router.get("/{segment_id}", response_model=Segment) +async def get_segment_route( + segment_id: UUID, + merchant_id: str = Depends(merchant_scope("read a segment", "crm.segments.get")), +) -> Segment: + segment = await segments.get_segment(merchant_id, str(segment_id)) + if segment is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Segment not found" + ) + return segment diff --git a/app/crm/outreach/db/accessors/segment.py b/app/crm/outreach/db/accessors/segment.py new file mode 100644 index 000000000..847370604 --- /dev/null +++ b/app/crm/outreach/db/accessors/segment.py @@ -0,0 +1,48 @@ +"""Mechanical DB access for crm_segment (T15) — one table, one file (module +rules §1 at scale). Every function self-scopes one statement; no decisions. +""" + +from typing import List, Optional + +from app.crm.outreach.db.decoders.segment import decode_segment +from app.crm.outreach.db.queries.segment import ( + count_segments_query, + get_segment_query, + insert_static_list_query, + list_segments_query, +) +from app.crm.outreach.schemas import Segment +from app.crm.shared.db import crm_connection + + +async def insert_static_list( + merchant_id: str, name: str, created_by: Optional[str] +) -> Segment: + """Raises the db door's UniqueViolation when the merchant already has a + segment with this name — the caller says what that means.""" + query, values = insert_static_list_query(merchant_id, name, created_by) + async with crm_connection() as conn: + row = await conn.fetchrow(query, *values) + assert row is not None # INSERT ... RETURNING always yields the row + return decode_segment(row) + + +async def get_segment(merchant_id: str, segment_id: str) -> Optional[Segment]: + query, values = get_segment_query(merchant_id, segment_id) + async with crm_connection() as conn: + row = await conn.fetchrow(query, *values) + return decode_segment(row) if row else None + + +async def list_segments(merchant_id: str, limit: int, offset: int) -> List[Segment]: + query, values = list_segments_query(merchant_id, limit, offset) + async with crm_connection() as conn: + rows = await conn.fetch(query, *values) + return [decode_segment(row) for row in rows] + + +async def count_segments(merchant_id: str) -> int: + query, values = count_segments_query(merchant_id) + async with crm_connection() as conn: + row = await conn.fetchrow(query, *values) + return int(row["total"]) if row else 0 diff --git a/app/crm/outreach/db/decoders/segment.py b/app/crm/outreach/db/decoders/segment.py new file mode 100644 index 000000000..176855c00 --- /dev/null +++ b/app/crm/outreach/db/decoders/segment.py @@ -0,0 +1,27 @@ +"""row -> schema translation for crm_segment (T15) — one table, one file +(module rules §1 at scale). DB-side translation only — never imported +outside db/. +""" + +from typing import Any, Mapping + +from app.crm.outreach.schemas import Segment +from app.crm.shared.decode import jsonb_value + + +def decode_segment(row: Mapping[str, Any]) -> Segment: + definition = jsonb_value(row["definition"]) + return Segment( + id=row["id"], + merchant_id=row["merchant_id"], + name=row["name"], + # The kind is a predicate over definition, never a stored column + # (law 10): NULL = a static list whose members are rows. + kind="static" if definition is None else "dynamic", + definition=definition, + member_count=row["member_count"], + computed_at=row["computed_at"], + created_by=row["created_by"], + created_at=row["created_at"], + updated_at=row["updated_at"], + ) diff --git a/app/crm/outreach/db/queries/segment.py b/app/crm/outreach/db/queries/segment.py new file mode 100644 index 000000000..c3451c998 --- /dev/null +++ b/app/crm/outreach/db/queries/segment.py @@ -0,0 +1,64 @@ +"""SQL builders for crm_segment (T15, canon/05-audiences.md) — one table, one +file (module rules §1 at scale). $1 placeholders only — every value +parameterized; merchant_id is $1 on every read and write (tenancy law). +""" + +from typing import Any, List, Optional, Tuple + +from app.crm.outreach.db.queries.tables import SEGMENT_TABLE + +_SEGMENT_COLUMNS = """ + id, merchant_id, name, definition, member_count, computed_at, + created_by, created_at, updated_at +""" + + +def insert_static_list_query( + merchant_id: str, name: str, created_by: Optional[str] +) -> Tuple[str, List[Any]]: + """A static list is born empty, and its count is exact from the first + moment: zero members, stamped now (T15 col 7 — exact forever for a + list). ``definition`` stays NULL — that NULL is what makes it a list. + No ON CONFLICT: a second list with the same name is the unique index's + refusal to surface, never a merge.""" + query = f""" + INSERT INTO {SEGMENT_TABLE} + (merchant_id, name, member_count, computed_at, created_by) + VALUES ($1, $2, 0, now(), $3) + RETURNING {_SEGMENT_COLUMNS} + """ + return query, [merchant_id, name, created_by] + + +def get_segment_query(merchant_id: str, segment_id: str) -> Tuple[str, List[Any]]: + query = f""" + SELECT {_SEGMENT_COLUMNS} + FROM {SEGMENT_TABLE} + WHERE merchant_id = $1 AND id = $2 + """ + return query, [merchant_id, segment_id] + + +def list_segments_query( + merchant_id: str, limit: int, offset: int +) -> Tuple[str, List[Any]]: + """Newest first — the order crm_segment_merchant_created_ix carries.""" + query = f""" + SELECT {_SEGMENT_COLUMNS} + FROM {SEGMENT_TABLE} + WHERE merchant_id = $1 + ORDER BY created_at DESC, id DESC + LIMIT $2 OFFSET $3 + """ + return query, [merchant_id, limit, offset] + + +def count_segments_query(merchant_id: str) -> Tuple[str, List[Any]]: + """The merchant's segment count, for the list's pager (in the body — + the RunPage ruling).""" + query = f""" + SELECT count(*)::int AS total + FROM {SEGMENT_TABLE} + WHERE merchant_id = $1 + """ + return query, [merchant_id] diff --git a/app/crm/outreach/db/queries/tables.py b/app/crm/outreach/db/queries/tables.py index 64d63fd3a..6eaf68436 100644 --- a/app/crm/outreach/db/queries/tables.py +++ b/app/crm/outreach/db/queries/tables.py @@ -8,3 +8,4 @@ ENROLLMENT_TABLE = "crm_workflow_enrollment" VERSION_TABLE = "crm_workflow_version" STEP_TABLE = "crm_workflow_step" +SEGMENT_TABLE = "crm_segment" diff --git a/app/crm/outreach/schemas.py b/app/crm/outreach/schemas.py index 327336cb9..fdd7486ac 100644 --- a/app/crm/outreach/schemas.py +++ b/app/crm/outreach/schemas.py @@ -1032,3 +1032,34 @@ class CustomerRun(EnrollmentRun): each row says which plan it belongs to (rollout phase 09).""" workflow_name: str + + +# --- segments (canon T15, canon/05-audiences.md) --- + + +class Segment(BaseModel): + """A segment: a static list (``definition`` None — its members are + stored rows) or a dynamic segment (``definition`` set — the stored + question, computed at read time). ``kind`` is derived from + ``definition`` on read, never stored (law 10). ``member_count`` is + display-only and always travels with ``computed_at``, so a count says + how old it is (T15 cols 7-8); nothing ever decides on it.""" + + id: UUID + merchant_id: str + name: str + kind: Literal["static", "dynamic"] + definition: Optional[Dict[str, Any]] = None + member_count: Optional[int] = None + computed_at: Optional[datetime] = None + created_by: Optional[str] = None + created_at: datetime + updated_at: datetime + + +class SegmentPage(BaseModel): + """One page of a merchant's segments and how many there are in all — + the total in the body, typed (the RunPage ruling).""" + + items: List[Segment] + total: int diff --git a/app/crm/outreach/segments.py b/app/crm/outreach/segments.py new file mode 100644 index 000000000..66b5c65e0 --- /dev/null +++ b/app/crm/outreach/segments.py @@ -0,0 +1,67 @@ +"""Segments (canon T15, canon/05-audiences.md) — phase 1: a static list's +row, created and read. + +A segment is a stored question; membership is computed at read time and +never stored. A static list is the one exception — an imported set IS its +members (phase 2 adds them; phase 4 compiles dynamic definitions). Until +then every segment this file creates is a static list. + +What this file keeps: +- **A segment is never a permission list** (modules/05-outreach.md:233). + Nothing here sends, consents or suppresses; a broadcast still asks the + gate about every member. +- **The name is unique per merchant** (T15 col 3). The unique index referees + two racing creates; this file only names the refusal. +- **Counts carry their stamp** (T15 col 8): a new list's count is exact — + zero members, stamped at birth. +""" + +from typing import Optional + +from app.crm.outreach.db import UniqueViolation +from app.crm.outreach.db.accessors import segment as segment_accessor +from app.crm.outreach.schemas import Segment, SegmentPage + + +class SegmentNameTaken(Exception): + """The merchant already has a segment with this name (T15 col 3).""" + + +def segment_name(raw: str) -> str: + """The name as stored: surrounding whitespace dropped, so "Diwali VIPs" + and "Diwali VIPs " are one name to the unique index, not two lists a + merchant cannot tell apart. Pure. Raises ValueError when nothing is + left — a blank name is not a name. The 085 CHECK refuses a blank or + untrimmed name too, so a writer that skips this cannot split a name.""" + name = (raw or "").strip() + if not name: + raise ValueError("a segment needs a name") + return name + + +async def create_static_list( + merchant_id: str, name: str, created_by: Optional[str] +) -> Segment: + """A new, empty static list. Raises SegmentNameTaken when the merchant + already has a segment by this name, ValueError on a blank name.""" + stored = segment_name(name) + try: + return await segment_accessor.insert_static_list( + merchant_id, stored, created_by + ) + except UniqueViolation as e: + raise SegmentNameTaken(stored) from e + + +async def get_segment(merchant_id: str, segment_id: str) -> Optional[Segment]: + """One segment, or None when this merchant has no such segment — another + merchant's id answers None too, never their row.""" + return await segment_accessor.get_segment(merchant_id, segment_id) + + +async def list_segments(merchant_id: str, limit: int, offset: int) -> SegmentPage: + """One page of the merchant's segments, newest first, and the total.""" + return SegmentPage( + items=await segment_accessor.list_segments(merchant_id, limit, offset), + total=await segment_accessor.count_segments(merchant_id), + ) diff --git a/app/database/migrations/085_create_crm_segment.sql b/app/database/migrations/085_create_crm_segment.sql new file mode 100644 index 000000000..1c3ed9053 --- /dev/null +++ b/app/database/migrations/085_create_crm_segment.sql @@ -0,0 +1,53 @@ +-- 085: outreach — crm_segment (canon T15, canon/05-audiences.md). Segments +-- phase 1: the segment row itself. Static-list members (crm_segment_member, +-- T21) arrive in phase 2 with the writer that fills them; the definition +-- compiler (dynamic segments) in phase 4. +-- +-- A segment is a stored question; membership is computed at read time, +-- never stored. A static list is the one exception: an imported set IS its +-- members (canon/05-audiences.md:3-4). One row here is EITHER a dynamic +-- segment (definition set) OR a static list (definition NULL) — the kind is +-- a predicate over definition, never a stored column (law 10). +-- +-- member_count + computed_at are the honesty pair (T15 cols 7-8): a static +-- list's count is exact, stamped whenever its members change; a dynamic +-- segment's will be a display-only cache. Nothing ever decides on it. + +CREATE TABLE crm_segment ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + merchant_id text NOT NULL, + -- Unique per merchant (index below); a blank name is not a name, and + -- a stored name never starts or ends with whitespace — so the plain + -- unique index already treats "Diwali VIPs" and "Diwali VIPs " as one + -- name, for every writer, not only the ones that trim first. + name text NOT NULL CHECK (name <> '' AND name !~ '^\s|\s$'), + -- The stored question — dynamic segments only; NULL for a static list. + -- Always a JSON object when set: the compiler reads keys, never a bare + -- array or scalar. + definition jsonb CHECK (definition IS NULL OR jsonb_typeof(definition) = 'object'), + member_count integer CHECK (member_count >= 0), + computed_at timestamptz, + -- Who built the thing that will one day address 8,400 people. + created_by text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +-- T15 col 3: the name is unique per merchant — merchant_id first (tenancy law). +CREATE UNIQUE INDEX crm_segment_merchant_name_uq + ON crm_segment (merchant_id, name); + +-- The pin target for tenant-pinned FKs into this table (the 049/058/073 +-- precedent): crm_broadcast.segment_id (T17 col 6) will reference +-- (merchant_id, id), so a broadcast can never freeze another merchant's +-- audience. +CREATE UNIQUE INDEX crm_segment_tenant_pin_ux + ON crm_segment (merchant_id, id); + +-- The list read's cursor (newest first per merchant), carried by the index. +CREATE INDEX crm_segment_merchant_created_ix + ON crm_segment (merchant_id, created_at DESC, id DESC); + +CREATE TRIGGER crm_segment_touch + BEFORE UPDATE ON crm_segment + FOR EACH ROW EXECUTE FUNCTION crm_touch_updated_at(); diff --git a/docs/crm/migrations.md b/docs/crm/migrations.md index 1d3cf2cf3..925b34799 100644 --- a/docs/crm/migrations.md +++ b/docs/crm/migrations.md @@ -77,7 +77,10 @@ Rules the template encodes: CHECK; `crm_workflow_step` (073, canon T26) is outreach's as well — the append-only trail behind the token, inserted by the statement that moves the run and never updated, read by no one on the hot path, cascade-deleted - with its run). + with its run; `crm_segment` (085, canon T15) is outreach's too — one row + per segment, a static list when `definition` is NULL, the kind derived on + read and never stored; its members (T21) ship with the writer that fills + them). - Append-only tables (crm_consent_event) additionally REVOKE UPDATE, DELETE and add the refusal trigger in the same migration. - Partitioned tables (crm_event_raw, crm_message, crm_decision_log) diff --git a/scripts/check_crm_boundaries.py b/scripts/check_crm_boundaries.py index 5daf47114..c30ddf8e7 100644 --- a/scripts/check_crm_boundaries.py +++ b/scripts/check_crm_boundaries.py @@ -69,6 +69,7 @@ "crm_workflow_enrollment": "outreach", "crm_workflow_version": "outreach", "crm_workflow_step": "outreach", + "crm_segment": "outreach", "crm_connector_installation": "connectivity", "crm_channel_binding": "connectivity", "crm_channel_template": "connectivity", diff --git a/tests/crm/test_segments.py b/tests/crm/test_segments.py new file mode 100644 index 000000000..d0412460a --- /dev/null +++ b/tests/crm/test_segments.py @@ -0,0 +1,373 @@ +"""Segments phase 1 (canon T15, canon/05-audiences.md): the segment row, +created and read. + +What is proven here: +- the table has its owner, and 085 carries the canon's self-defense: the + name unique per merchant with merchant_id first, a blank name refused, a + definition only ever a JSON object, and the tenant pin a broadcast will FK; +- every builder puts merchant_id at $1 and parameterizes every value; a new + list is born with an exact count (0, stamped now) and no definition; +- the name is trimmed before it reaches the unique index, a blank one never + reaches the database, and the index's refusal becomes SegmentNameTaken; +- the kind is derived from definition on read; +- every /segments route declares the tenancy door, and the routes answer + 201 / 409 / 422 / 404 / 403 as documented. +""" + +from datetime import datetime, timezone +from pathlib import Path +from types import SimpleNamespace +from typing import Any, Dict, List, Optional, Tuple +from uuid import UUID, uuid4 + +import pytest +from fastapi import FastAPI +from fastapi.routing import APIRoute +from fastapi.testclient import TestClient + +import app.crm.outreach.segments as segments +from app.api.security.breeze_buddy.rbac_token import get_current_user_with_rbac +from app.crm import api as crm_root_api +from app.crm.auth import MERCHANT_SCOPE_MARK +from app.crm.outreach import api as outreach_api +from app.crm.outreach.db import UniqueViolation +from app.crm.outreach.db.decoders.segment import decode_segment +from app.crm.outreach.db.queries.segment import ( + count_segments_query, + get_segment_query, + insert_static_list_query, + list_segments_query, +) +from app.crm.outreach.schemas import Segment, SegmentPage +from scripts.check_crm_boundaries import TABLE_OWNERS + +NOW = datetime(2026, 10, 13, 10, 0, tzinfo=timezone.utc) +MIGRATION = Path("app/database/migrations/085_create_crm_segment.sql") + + +def _segment(name: str = "Diwali VIPs", **overrides: Any) -> Segment: + row: Dict[str, Any] = { + "id": uuid4(), + "merchant_id": "m1", + "name": name, + "kind": "static", + "definition": None, + "member_count": 0, + "computed_at": NOW, + "created_by": "ops@x", + "created_at": NOW, + "updated_at": NOW, + } + row.update(overrides) + return Segment(**row) + + +class FakeSegmentAccessor: + """Records every insert by field name; answers reads from a dict.""" + + def __init__(self, existing: Optional[List[Segment]] = None) -> None: + self.rows: List[Segment] = list(existing or []) + self.inserts: List[Dict[str, Any]] = [] + + async def insert_static_list( + self, merchant_id: str, name: str, created_by: Optional[str] + ) -> Segment: + self.inserts.append( + {"merchant_id": merchant_id, "name": name, "created_by": created_by} + ) + if any(r.merchant_id == merchant_id and r.name == name for r in self.rows): + raise UniqueViolation("crm_segment_merchant_name_uq") + segment = _segment(name, merchant_id=merchant_id, created_by=created_by) + self.rows.append(segment) + return segment + + async def get_segment(self, merchant_id: str, segment_id: str) -> Optional[Segment]: + return next( + ( + r + for r in self.rows + if r.merchant_id == merchant_id and str(r.id) == segment_id + ), + None, + ) + + async def list_segments( + self, merchant_id: str, limit: int, offset: int + ) -> List[Segment]: + mine = [r for r in self.rows if r.merchant_id == merchant_id] + return mine[offset : offset + limit] + + async def count_segments(self, merchant_id: str) -> int: + return sum(1 for r in self.rows if r.merchant_id == merchant_id) + + +@pytest.fixture +def accessor(monkeypatch: pytest.MonkeyPatch) -> FakeSegmentAccessor: + fake = FakeSegmentAccessor() + monkeypatch.setattr(segments, "segment_accessor", fake) + return fake + + +# --- the table --- + + +def test_the_segment_table_has_an_owner() -> None: + assert TABLE_OWNERS["crm_segment"] == "outreach" + + +def test_085_keeps_the_canon_self_defense() -> None: + sql = MIGRATION.read_text() + # T15 col 3: unique per merchant, merchant_id first (tenancy law). + assert "ON crm_segment (merchant_id, name)" in sql + assert "CREATE UNIQUE INDEX crm_segment_merchant_name_uq" in sql + # Blank or untrimmed names are refused by the table itself, so the plain + # unique index is whitespace-insensitive for every writer. + assert "CHECK (name <> '' AND name !~ '^\\s|\\s$')" in sql + # A definition is always an object when set; NULL means a static list. + assert "jsonb_typeof(definition) = 'object'" in sql + assert ( + "definition jsonb CHECK" in sql and "definition jsonb NOT NULL" not in sql + ) + # The pin target crm_broadcast (T17) will reference. + assert "ON crm_segment (merchant_id, id)" in sql + # The kind is derived, never stored (law 10). + assert "kind" not in sql.split("CREATE TABLE crm_segment (")[1].split(");")[0] + + +# --- the builders --- + + +@pytest.mark.parametrize( + ("built", "merchant_first"), + [ + (insert_static_list_query("m1", "Diwali VIPs", "ops@x"), True), + (get_segment_query("m1", "seg-1"), True), + (list_segments_query("m1", 50, 0), True), + (count_segments_query("m1"), True), + ], +) +def test_every_builder_puts_the_merchant_first_and_parameterizes( + built: Tuple[str, List[Any]], merchant_first: bool +) -> None: + query, params = built + assert params[0] == "m1" + assert "merchant_id = $1" in query or "VALUES ($1" in query + for value in params: + if isinstance(value, str): + assert value not in query, f"{value!r} reached the SQL text" + + +def test_a_new_list_is_born_with_an_exact_count_and_no_definition() -> None: + query, params = insert_static_list_query("m1", "Diwali VIPs", "ops@x") + assert "VALUES ($1, $2, 0, now(), $3)" in query + assert "definition" not in query.split("RETURNING")[0] + assert params == ["m1", "Diwali VIPs", "ops@x"] + + +def test_the_list_read_is_newest_first_and_paged() -> None: + query, params = list_segments_query("m1", 25, 50) + assert "ORDER BY created_at DESC, id DESC" in query + assert "LIMIT $2 OFFSET $3" in query + assert params == ["m1", 25, 50] + + +# --- the name --- + + +@pytest.mark.parametrize( + ("raw", "stored"), + [ + ("Diwali VIPs", "Diwali VIPs"), + (" Diwali VIPs ", "Diwali VIPs"), + ("Diwali VIPs", "Diwali VIPs"), # inner spacing is the author's + ], +) +def test_the_name_is_trimmed_before_it_reaches_the_index(raw: str, stored: str) -> None: + assert segments.segment_name(raw) == stored + + +@pytest.mark.parametrize("raw", ["", " ", "\t\n"]) +def test_a_blank_name_is_refused(raw: str) -> None: + with pytest.raises(ValueError): + segments.segment_name(raw) + + +# --- create and read --- + + +async def test_create_stores_the_trimmed_name_and_the_author( + accessor: FakeSegmentAccessor, +) -> None: + segment = await segments.create_static_list("m1", " Diwali VIPs ", "ops@x") + assert accessor.inserts == [ + {"merchant_id": "m1", "name": "Diwali VIPs", "created_by": "ops@x"} + ] + assert segment.kind == "static" and segment.member_count == 0 + + +async def test_a_blank_name_never_reaches_the_database( + accessor: FakeSegmentAccessor, +) -> None: + with pytest.raises(ValueError): + await segments.create_static_list("m1", " ", "ops@x") + assert accessor.inserts == [] + + +async def test_a_second_list_with_the_same_name_is_refused( + accessor: FakeSegmentAccessor, +) -> None: + await segments.create_static_list("m1", "Diwali VIPs", "ops@x") + with pytest.raises(segments.SegmentNameTaken): + await segments.create_static_list("m1", "Diwali VIPs ", "ops@y") + + +async def test_two_merchants_may_use_the_same_name( + accessor: FakeSegmentAccessor, +) -> None: + await segments.create_static_list("m1", "Diwali VIPs", "ops@x") + other = await segments.create_static_list("m2", "Diwali VIPs", "ops@y") + assert other.merchant_id == "m2" + + +async def test_another_merchants_segment_reads_as_none( + accessor: FakeSegmentAccessor, +) -> None: + mine = await segments.create_static_list("m1", "Diwali VIPs", "ops@x") + assert await segments.get_segment("m2", str(mine.id)) is None + assert await segments.get_segment("m1", str(mine.id)) == mine + + +async def test_the_list_carries_its_total_in_the_body( + accessor: FakeSegmentAccessor, +) -> None: + for name in ("A", "B", "C"): + await segments.create_static_list("m1", name, None) + page = await segments.list_segments("m1", limit=2, offset=0) + assert isinstance(page, SegmentPage) + assert len(page.items) == 2 and page.total == 3 + + +# --- the decoder --- + + +def _row(definition: Any) -> Dict[str, Any]: + return { + "id": uuid4(), + "merchant_id": "m1", + "name": "s", + "definition": definition, + "member_count": None, + "computed_at": None, + "created_by": None, + "created_at": NOW, + "updated_at": NOW, + } + + +@pytest.mark.parametrize( + ("definition", "kind"), + [ + (None, "static"), + ({"all": []}, "dynamic"), + ('{"all": []}', "dynamic"), # jsonb arriving as text decodes too + ], +) +def test_the_kind_is_derived_from_the_definition(definition: Any, kind: str) -> None: + assert decode_segment(_row(definition)).kind == kind + + +# --- the routes --- + + +def _client(merchants: Tuple[str, ...] = ("m1",)) -> TestClient: + app = FastAPI() + app.include_router(outreach_api.segment_router, prefix="/segments") + app.dependency_overrides[get_current_user_with_rbac] = lambda: SimpleNamespace( + role="user", + username="u", + merchant_ids=list(merchants), + reseller_ids=[], + email="ops@x", + ) + return TestClient(app) + + +def test_every_segment_route_declares_the_tenancy_door() -> None: + routes = [r for r in outreach_api.segment_router.routes if isinstance(r, APIRoute)] + assert len(routes) == 3, "the router lost or gained routes — update this walk" + missing = [ + r.path + for r in routes + if not any( + getattr(d.call, MERCHANT_SCOPE_MARK, False) + for d in r.dependant.dependencies + ) + ] + assert missing == [] + + +def test_the_root_router_mounts_segments() -> None: + paths = {getattr(r, "path", "") for r in crm_root_api.router.routes} + assert {"/segments", "/segments/{segment_id}"} <= paths + + +def test_create_answers_201_with_the_new_list(accessor: FakeSegmentAccessor) -> None: + response = _client().post( + "/segments", json={"merchant_id": "m1", "name": " Diwali VIPs "} + ) + assert response.status_code == 201 + body = response.json() + assert body["name"] == "Diwali VIPs" and body["kind"] == "static" + assert body["member_count"] == 0 and body["created_by"] == "ops@x" + + +def test_create_answers_409_on_a_taken_name(accessor: FakeSegmentAccessor) -> None: + client = _client() + client.post("/segments", json={"merchant_id": "m1", "name": "Diwali VIPs"}) + again = client.post("/segments", json={"merchant_id": "m1", "name": "Diwali VIPs"}) + assert again.status_code == 409 + + +def test_create_answers_422_on_a_name_blank_once_trimmed( + accessor: FakeSegmentAccessor, +) -> None: + response = _client().post("/segments", json={"merchant_id": "m1", "name": " "}) + assert response.status_code == 422 + assert accessor.inserts == [] + + +def test_create_refuses_a_merchant_the_caller_cannot_reach( + accessor: FakeSegmentAccessor, +) -> None: + response = _client(merchants=("m1",)).post( + "/segments", json={"merchant_id": "m2", "name": "Diwali VIPs"} + ) + assert response.status_code == 403 + assert accessor.inserts == [] + + +def test_get_answers_404_for_an_unknown_or_foreign_segment( + accessor: FakeSegmentAccessor, +) -> None: + client = _client(merchants=("m1", "m2")) + created = client.post( + "/segments", json={"merchant_id": "m2", "name": "Diwali VIPs"} + ).json() + assert client.get(f"/segments/{uuid4()}?merchant_id=m1").status_code == 404 + assert client.get(f"/segments/{created['id']}?merchant_id=m1").status_code == 404 + mine = client.get(f"/segments/{created['id']}?merchant_id=m2") + assert mine.status_code == 200 and UUID(mine.json()["id"]) + + +def test_get_answers_422_for_an_id_that_is_not_a_uuid( + accessor: FakeSegmentAccessor, +) -> None: + assert _client().get("/segments/not-a-uuid?merchant_id=m1").status_code == 422 + + +def test_the_list_route_pages_and_totals(accessor: FakeSegmentAccessor) -> None: + client = _client() + for name in ("A", "B", "C"): + client.post("/segments", json={"merchant_id": "m1", "name": name}) + page = client.get("/segments?merchant_id=m1&limit=2").json() + assert len(page["items"]) == 2 and page["total"] == 3