- Demo accounts: super_admin-only provisioning into isolated DEMO_ORG_ID tenant, 30-day UTC trial on first login, revocable, one-time credential delivery via optional SES/webhook (never persisted). Adds boto3 dependency. - Analytics/report/export/privacy: shared bounded scan budget across users/groups/ sessions, tenant-consistent session/user/group joins, scalar-only CSV export (no nested persisted-value stringification). - Ownership/tenant isolation: canonical owner-tenant predicate for list/read/chat; client sees is_owned only, never owner_user_id. - Lifecycle/races: status transition validation, analyzing is an in-progress gate (no duplicate reanalysis), structured-ready publication, stale-variant revalidation. - Auth/setup/consent/JWT/OAuth/config: fail-closed consent, bounded JWT lifetime, provider-subject atomic OAuth identity, repeated-secret rejection, strict Persona trait validation. - Chat/session/privacy: pre-seller opener redaction, corrupt-session recovery, role-aware completed-chat dashboard routing. - Frontend: Training→product→personas→practice flow, demo/role/demo guards, is_owned-based ownership display, 320×568 and 500×768 responsive E2E. - 8 independent exact-five-key review scopes passed; backend 509, frontend 26, production build 1775 modules, isolated E2E 15.
249 lines
8.1 KiB
Python
249 lines
8.1 KiB
Python
"""Private group lookup/create race regression tests."""
|
|
from __future__ import annotations
|
|
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from pathlib import Path
|
|
from threading import Barrier
|
|
|
|
import pytest
|
|
|
|
from app.services.groups import GroupStore
|
|
|
|
|
|
def test_private_group_lookup_and_create_is_atomic(tmp_path: Path):
|
|
store = GroupStore(tmp_path)
|
|
barrier = Barrier(8)
|
|
|
|
def get_group():
|
|
barrier.wait(timeout=5)
|
|
return store.get_or_create_private_group(
|
|
org_id="org-1",
|
|
owner_user_id="user-1",
|
|
owner_name="Trainee",
|
|
)
|
|
|
|
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
groups = list(pool.map(lambda _: get_group(), range(8)))
|
|
|
|
assert len({group["id"] for group in groups}) == 1
|
|
assert len(store.list_for_org("org-1")) == 1
|
|
assert groups[0]["owner_user_id"] == "user-1"
|
|
assert groups[0]["status"] == "draft"
|
|
assert groups[0]["personas"] == []
|
|
|
|
|
|
def test_private_group_lookup_revalidates_candidate_after_snapshot(
|
|
tmp_path: Path, monkeypatch
|
|
):
|
|
store = GroupStore(tmp_path)
|
|
existing = store.get_or_create_private_group(
|
|
org_id="org-1", owner_user_id="user-1", owner_name="Trainee"
|
|
)
|
|
original_list = store.list_for_org
|
|
transferred = False
|
|
|
|
def list_then_transfer(*, org_id):
|
|
nonlocal transferred
|
|
rows = original_list(org_id=org_id)
|
|
if rows and not transferred:
|
|
transferred = True
|
|
store.groups.update(rows[0]["id"], owner_user_id="user-2")
|
|
return rows
|
|
|
|
monkeypatch.setattr(store, "list_for_org", list_then_transfer)
|
|
|
|
selected = store.get_or_create_private_group(
|
|
org_id="org-1", owner_user_id="user-1", owner_name="Trainee"
|
|
)
|
|
|
|
assert selected["id"] != existing["id"]
|
|
assert selected["owner_user_id"] == "user-1"
|
|
|
|
|
|
@pytest.mark.parametrize("operation", ["get_or_create", "append"])
|
|
def test_private_group_lookup_fails_closed_on_malformed_candidate_id(
|
|
tmp_path: Path, monkeypatch, operation: str
|
|
):
|
|
store = GroupStore(tmp_path)
|
|
calls = 0
|
|
|
|
def malformed_rows(*, org_id):
|
|
nonlocal calls
|
|
calls += 1
|
|
if calls > 1:
|
|
raise AssertionError("malformed private-group candidates must not retry forever")
|
|
return [{"id": 0, "org_id": org_id, "owner_user_id": "user-1"}]
|
|
|
|
monkeypatch.setattr(store, "list_for_org", malformed_rows)
|
|
|
|
with pytest.raises(ValueError, match="private group state"):
|
|
if operation == "get_or_create":
|
|
store.get_or_create_private_group(
|
|
org_id="org-1", owner_user_id="user-1", owner_name="Trainee"
|
|
)
|
|
else:
|
|
store.append_private_persona(
|
|
org_id="org-1",
|
|
owner_user_id="user-1",
|
|
persona={"id": "variant", "name": "Variant"},
|
|
)
|
|
|
|
assert calls == 1
|
|
|
|
|
|
def test_private_persona_append_revalidates_target_state(
|
|
tmp_path: Path, monkeypatch
|
|
):
|
|
store = GroupStore(tmp_path)
|
|
group = store.append_private_persona(
|
|
org_id="org-1",
|
|
owner_user_id="user-1",
|
|
persona={"id": "initial", "name": "Initial"},
|
|
)
|
|
original_record_lock = store.record_lock
|
|
transitioned = False
|
|
|
|
def lock_after_transition(gid):
|
|
nonlocal transitioned
|
|
if gid == group["id"] and not transitioned:
|
|
transitioned = True
|
|
store.groups.update(gid, status="failed")
|
|
return original_record_lock(gid)
|
|
|
|
monkeypatch.setattr(store, "record_lock", lock_after_transition)
|
|
|
|
with pytest.raises(ValueError, match="not ready"):
|
|
store.append_private_persona(
|
|
org_id="org-1",
|
|
owner_user_id="user-1",
|
|
persona={"id": "variant", "name": "Variant"},
|
|
)
|
|
|
|
|
|
def test_private_groups_are_scoped_by_org_and_owner(tmp_path: Path):
|
|
store = GroupStore(tmp_path)
|
|
|
|
first = store.get_or_create_private_group(org_id="org-1", owner_user_id="user-1")
|
|
same_owner = store.get_or_create_private_group(org_id="org-1", owner_user_id="user-1")
|
|
other_org = store.get_or_create_private_group(org_id="org-2", owner_user_id="user-1")
|
|
other_owner = store.get_or_create_private_group(org_id="org-1", owner_user_id="user-2")
|
|
|
|
assert same_owner["id"] == first["id"]
|
|
assert other_org["id"] != first["id"]
|
|
assert other_owner["id"] != first["id"]
|
|
assert len(store.list_for_org("org-1")) == 2
|
|
assert len(store.list_for_org("org-2")) == 1
|
|
|
|
|
|
def test_private_persona_append_publishes_ready_group_with_persona(tmp_path: Path):
|
|
store = GroupStore(tmp_path)
|
|
|
|
group = store.append_private_persona(
|
|
org_id="org-1",
|
|
owner_user_id="user-1",
|
|
owner_name="Trainee",
|
|
persona={
|
|
"id": "variant-1",
|
|
"name": "Variant",
|
|
"channel": "facebook",
|
|
"initiation_mode": "customer",
|
|
},
|
|
)
|
|
|
|
assert group["status"] == "ready"
|
|
assert [persona["id"] for persona in group["personas"]] == ["variant-1"]
|
|
persisted = store.get(group["id"])
|
|
assert persisted["status"] == "ready"
|
|
assert [persona["id"] for persona in persisted["personas"]] == ["variant-1"]
|
|
|
|
|
|
def test_private_persona_append_can_publish_the_initial_draft(tmp_path: Path):
|
|
store = GroupStore(tmp_path)
|
|
draft = store.get_or_create_private_group(
|
|
org_id="org-1", owner_user_id="user-1", owner_name="Trainee"
|
|
)
|
|
|
|
published = store.append_private_persona(
|
|
org_id="org-1",
|
|
owner_user_id="user-1",
|
|
persona={"id": "first", "name": "First variant"},
|
|
)
|
|
|
|
assert published["id"] == draft["id"]
|
|
assert published["status"] == "ready"
|
|
assert [persona["id"] for persona in published["personas"]] == ["first"]
|
|
|
|
|
|
def test_private_group_cannot_publish_ready_without_a_persona(tmp_path: Path):
|
|
store = GroupStore(tmp_path)
|
|
draft = store.get_or_create_private_group(
|
|
org_id="org-1", owner_user_id="user-1", owner_name="Trainee"
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="persona"):
|
|
store.update(draft["id"], status="ready")
|
|
|
|
|
|
def test_private_group_rejects_truthy_non_list_personas_when_ready(tmp_path: Path):
|
|
store = GroupStore(tmp_path)
|
|
|
|
with pytest.raises(ValueError, match="persona"):
|
|
store.create(
|
|
org_id="org-1",
|
|
creator_id="user-1",
|
|
owner_user_id="user-1",
|
|
title="Malformed ready group",
|
|
status="ready",
|
|
personas={"id": "not-a-list"},
|
|
)
|
|
|
|
|
|
def test_private_group_rejects_malformed_persona_traits_when_ready(tmp_path: Path):
|
|
store = GroupStore(tmp_path)
|
|
|
|
with pytest.raises(ValueError, match="persona traits"):
|
|
store.create(
|
|
org_id="org-1",
|
|
creator_id="user-1",
|
|
owner_user_id="user-1",
|
|
title="Malformed ready persona",
|
|
status="ready",
|
|
personas=[{"id": "p-1", "name": "Bad", "channel": "internal"}],
|
|
)
|
|
|
|
|
|
def test_locked_persona_appends_preserve_all_concurrent_variants(tmp_path: Path):
|
|
store = GroupStore(tmp_path)
|
|
group = store.get_or_create_private_group(org_id="org-1", owner_user_id="user-1")
|
|
barrier = Barrier(8)
|
|
|
|
def append_one(index: int):
|
|
barrier.wait(timeout=5)
|
|
with store.record_lock(group["id"]):
|
|
current = store.get(group["id"])
|
|
store.set_personas(
|
|
group["id"],
|
|
current.get("personas", []) + [{"id": f"variant-{index}", "name": str(index)}],
|
|
)
|
|
|
|
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
list(pool.map(append_one, range(8)))
|
|
|
|
personas = store.get(group["id"])["personas"]
|
|
assert {persona["id"] for persona in personas} == {f"variant-{i}" for i in range(8)}
|
|
|
|
|
|
def test_record_lock_is_reentrant_for_nested_service_writes(tmp_path: Path):
|
|
store = GroupStore(tmp_path)
|
|
group = store.create(
|
|
org_id="org-1",
|
|
creator_id="user-1",
|
|
title="Nested",
|
|
sales_kit={"productName": "Test product"},
|
|
report={"summary": "Test report"},
|
|
)
|
|
with store.record_lock(group["id"]):
|
|
with store.record_lock(group["id"]):
|
|
store.set_personas(group["id"], [{"id": "persona-1", "name": "Nested"}])
|
|
assert store.get(group["id"])["personas"][0]["id"] == "persona-1"
|