feat: add tree forking, custom step tracking, and session sharing
Implement three foundational schema features from the design doc: - Tree forking with lineage tracking (migration 022): parent_tree_id, root_tree_id, fork_depth columns with self-referential FKs and composite analytics index - Custom step enhancement: CustomStepSchema with source tracking (ad-hoc, step-library, forked-tree) for backward-compatible JSONB - Session sharing (migration 023): session_shares and session_share_views tables with account-scoped visibility, cryptographic tokens, view tracking, and allow_public_shares account policy Includes 21 new integration tests (9 forking, 12 sharing), SaaS consultant-recommended denormalizations, rate limiting on public share access, and test fixture fix for invite code requirement. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,8 @@ from app.main import app
|
||||
from app.core.database import Base, get_db
|
||||
from app.core.config import settings
|
||||
|
||||
# Disable invite code requirement for tests
|
||||
settings.REQUIRE_INVITE_CODE = False
|
||||
|
||||
# Test database URL (separate from production)
|
||||
TEST_DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost:5432/patherly_test"
|
||||
|
||||
239
backend/tests/test_session_sharing.py
Normal file
239
backend/tests/test_session_sharing.py
Normal file
@@ -0,0 +1,239 @@
|
||||
"""Tests for session sharing (create, access, revoke)."""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
class TestSessionSharing:
|
||||
"""Test session share creation, access control, and revocation."""
|
||||
|
||||
async def _create_session(self, client, auth_headers, tree_id):
|
||||
"""Helper: start a session for a tree."""
|
||||
response = await client.post(
|
||||
"/api/v1/sessions",
|
||||
json={"tree_id": tree_id},
|
||||
headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 201
|
||||
return response.json()
|
||||
|
||||
async def test_create_public_share(self, client: AsyncClient, auth_headers, test_tree):
|
||||
"""Create a public share link."""
|
||||
session = await self._create_session(client, auth_headers, test_tree["id"])
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/sessions/{session['id']}/shares",
|
||||
json={"visibility": "public", "share_name": "Customer link"},
|
||||
headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
share = response.json()
|
||||
assert share["visibility"] == "public"
|
||||
assert share["share_name"] == "Customer link"
|
||||
assert share["is_active"] is True
|
||||
assert share["view_count"] == 0
|
||||
assert len(share["share_token"]) > 0
|
||||
|
||||
async def test_create_account_share(self, client: AsyncClient, auth_headers, test_tree):
|
||||
"""Create an account-only share link."""
|
||||
session = await self._create_session(client, auth_headers, test_tree["id"])
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/sessions/{session['id']}/shares",
|
||||
json={"visibility": "account"},
|
||||
headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 201
|
||||
assert response.json()["visibility"] == "account"
|
||||
|
||||
async def test_access_public_share(self, client: AsyncClient, auth_headers, test_tree):
|
||||
"""Access a public share without authentication."""
|
||||
session = await self._create_session(client, auth_headers, test_tree["id"])
|
||||
|
||||
# Create share
|
||||
share_resp = await client.post(
|
||||
f"/api/v1/sessions/{session['id']}/shares",
|
||||
json={"visibility": "public"},
|
||||
headers=auth_headers
|
||||
)
|
||||
share_token = share_resp.json()["share_token"]
|
||||
|
||||
# Access without auth
|
||||
response = await client.get(f"/api/v1/share/{share_token}")
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert data["session_id"] == session["id"]
|
||||
assert data["visibility"] == "public"
|
||||
assert data["path_taken"] is not None
|
||||
|
||||
async def test_access_revoked_share_returns_404(self, client: AsyncClient, auth_headers, test_tree):
|
||||
"""Accessing a revoked share returns 404."""
|
||||
session = await self._create_session(client, auth_headers, test_tree["id"])
|
||||
|
||||
# Create and revoke share
|
||||
share_resp = await client.post(
|
||||
f"/api/v1/sessions/{session['id']}/shares",
|
||||
json={"visibility": "public"},
|
||||
headers=auth_headers
|
||||
)
|
||||
share = share_resp.json()
|
||||
|
||||
await client.delete(
|
||||
f"/api/v1/shares/{share['id']}",
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
# Try to access revoked share
|
||||
response = await client.get(f"/api/v1/share/{share['share_token']}")
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_access_nonexistent_share_returns_404(self, client: AsyncClient):
|
||||
"""Accessing a nonexistent share token returns 404."""
|
||||
response = await client.get("/api/v1/share/nonexistent-token-12345")
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_list_my_shares(self, client: AsyncClient, auth_headers, test_tree):
|
||||
"""List shares created by current user."""
|
||||
session = await self._create_session(client, auth_headers, test_tree["id"])
|
||||
|
||||
# Create two shares
|
||||
await client.post(
|
||||
f"/api/v1/sessions/{session['id']}/shares",
|
||||
json={"visibility": "public", "share_name": "Link 1"},
|
||||
headers=auth_headers
|
||||
)
|
||||
await client.post(
|
||||
f"/api/v1/sessions/{session['id']}/shares",
|
||||
json={"visibility": "account", "share_name": "Link 2"},
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
response = await client.get(
|
||||
"/api/v1/shares/my-shares",
|
||||
headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
shares = response.json()
|
||||
assert len(shares) == 2
|
||||
|
||||
async def test_revoke_share(self, client: AsyncClient, auth_headers, test_tree):
|
||||
"""Revoke a share link (soft delete)."""
|
||||
session = await self._create_session(client, auth_headers, test_tree["id"])
|
||||
|
||||
share_resp = await client.post(
|
||||
f"/api/v1/sessions/{session['id']}/shares",
|
||||
json={"visibility": "public"},
|
||||
headers=auth_headers
|
||||
)
|
||||
share = share_resp.json()
|
||||
|
||||
# Revoke
|
||||
response = await client.delete(
|
||||
f"/api/v1/shares/{share['id']}",
|
||||
headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
# Verify it's gone from my-shares
|
||||
list_resp = await client.get(
|
||||
"/api/v1/shares/my-shares",
|
||||
headers=auth_headers
|
||||
)
|
||||
shares = list_resp.json()
|
||||
assert len(shares) == 0
|
||||
|
||||
async def test_multiple_shares_per_session(self, client: AsyncClient, auth_headers, test_tree):
|
||||
"""Multiple shares for the same session work independently."""
|
||||
session = await self._create_session(client, auth_headers, test_tree["id"])
|
||||
|
||||
# Create public + account shares
|
||||
resp1 = await client.post(
|
||||
f"/api/v1/sessions/{session['id']}/shares",
|
||||
json={"visibility": "public", "share_name": "For customer"},
|
||||
headers=auth_headers
|
||||
)
|
||||
resp2 = await client.post(
|
||||
f"/api/v1/sessions/{session['id']}/shares",
|
||||
json={"visibility": "account", "share_name": "For team"},
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
assert resp1.status_code == 201
|
||||
assert resp2.status_code == 201
|
||||
|
||||
# Both tokens are different
|
||||
assert resp1.json()["share_token"] != resp2.json()["share_token"]
|
||||
|
||||
# Both accessible
|
||||
access1 = await client.get(f"/api/v1/share/{resp1.json()['share_token']}")
|
||||
assert access1.status_code == 200
|
||||
|
||||
async def test_share_view_count_increments(self, client: AsyncClient, auth_headers, test_tree):
|
||||
"""View count increments on each access."""
|
||||
session = await self._create_session(client, auth_headers, test_tree["id"])
|
||||
|
||||
share_resp = await client.post(
|
||||
f"/api/v1/sessions/{session['id']}/shares",
|
||||
json={"visibility": "public"},
|
||||
headers=auth_headers
|
||||
)
|
||||
token = share_resp.json()["share_token"]
|
||||
|
||||
# Access three times
|
||||
await client.get(f"/api/v1/share/{token}")
|
||||
await client.get(f"/api/v1/share/{token}")
|
||||
await client.get(f"/api/v1/share/{token}")
|
||||
|
||||
# Check view count via my-shares
|
||||
list_resp = await client.get(
|
||||
"/api/v1/shares/my-shares",
|
||||
headers=auth_headers
|
||||
)
|
||||
shares = list_resp.json()
|
||||
assert shares[0]["view_count"] == 3
|
||||
|
||||
async def test_share_requires_session_ownership(self, client: AsyncClient, auth_headers, test_tree, test_db):
|
||||
"""Non-owner cannot create a share for someone else's session."""
|
||||
session = await self._create_session(client, auth_headers, test_tree["id"])
|
||||
|
||||
# Register a different user
|
||||
await client.post("/api/v1/auth/register", json={
|
||||
"email": "other@example.com",
|
||||
"password": "OtherPassword123!",
|
||||
"name": "Other User"
|
||||
})
|
||||
login_resp = await client.post("/api/v1/auth/login/json", json={
|
||||
"email": "other@example.com",
|
||||
"password": "OtherPassword123!"
|
||||
})
|
||||
other_headers = {"Authorization": f"Bearer {login_resp.json()['access_token']}"}
|
||||
|
||||
# Try to share other user's session
|
||||
response = await client.post(
|
||||
f"/api/v1/sessions/{session['id']}/shares",
|
||||
json={"visibility": "public"},
|
||||
headers=other_headers
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
async def test_share_nonexistent_session(self, client: AsyncClient, auth_headers):
|
||||
"""Creating a share for nonexistent session returns 404."""
|
||||
response = await client.post(
|
||||
"/api/v1/sessions/00000000-0000-0000-0000-000000000000/shares",
|
||||
json={"visibility": "public"},
|
||||
headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_create_share_requires_auth(self, client: AsyncClient, test_tree):
|
||||
"""Creating a share without auth returns 401."""
|
||||
response = await client.post(
|
||||
"/api/v1/sessions/00000000-0000-0000-0000-000000000000/shares",
|
||||
json={"visibility": "public"}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
168
backend/tests/test_tree_forking.py
Normal file
168
backend/tests/test_tree_forking.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""Tests for tree forking and lineage tracking."""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
class TestTreeForking:
|
||||
"""Test tree fork creation, lineage, and update detection."""
|
||||
|
||||
async def test_fork_tree(self, client: AsyncClient, auth_headers, test_tree):
|
||||
"""Fork a tree and verify fork metadata."""
|
||||
response = await client.post(
|
||||
f"/api/v1/trees/{test_tree['id']}/fork",
|
||||
json={"fork_reason": "Customizing for our network"},
|
||||
headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
fork = response.json()
|
||||
assert fork["name"] == f"Fork of {test_tree['name']}"
|
||||
assert fork["tree_structure"] == test_tree["tree_structure"]
|
||||
assert fork["is_public"] is False
|
||||
assert fork["version"] == 1
|
||||
|
||||
# Verify fork_info
|
||||
assert fork["fork_info"] is not None
|
||||
assert fork["fork_info"]["parent_tree_id"] == test_tree["id"]
|
||||
assert fork["fork_info"]["root_tree_id"] == test_tree["id"]
|
||||
assert fork["fork_info"]["fork_reason"] == "Customizing for our network"
|
||||
assert fork["fork_info"]["fork_depth"] == 1
|
||||
assert fork["fork_info"]["has_parent_updates"] is False
|
||||
|
||||
async def test_fork_with_custom_name(self, client: AsyncClient, auth_headers, test_tree):
|
||||
"""Fork a tree with a custom name."""
|
||||
response = await client.post(
|
||||
f"/api/v1/trees/{test_tree['id']}/fork",
|
||||
json={"name": "My Custom Fork", "fork_reason": "Testing"},
|
||||
headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 201
|
||||
assert response.json()["name"] == "My Custom Fork"
|
||||
|
||||
async def test_fork_of_fork_lineage(self, client: AsyncClient, auth_headers, test_tree):
|
||||
"""Fork a fork and verify lineage tracking (root_tree_id, fork_depth)."""
|
||||
# Create first fork
|
||||
resp1 = await client.post(
|
||||
f"/api/v1/trees/{test_tree['id']}/fork",
|
||||
json={"fork_reason": "Junior's customization"},
|
||||
headers=auth_headers
|
||||
)
|
||||
assert resp1.status_code == 201
|
||||
fork1 = resp1.json()
|
||||
|
||||
# Fork the fork
|
||||
resp2 = await client.post(
|
||||
f"/api/v1/trees/{fork1['id']}/fork",
|
||||
json={"fork_reason": "Senior's refinement"},
|
||||
headers=auth_headers
|
||||
)
|
||||
assert resp2.status_code == 201
|
||||
fork2 = resp2.json()
|
||||
|
||||
# Verify lineage
|
||||
assert fork2["fork_info"]["parent_tree_id"] == fork1["id"]
|
||||
assert fork2["fork_info"]["root_tree_id"] == test_tree["id"] # Points to original
|
||||
assert fork2["fork_info"]["fork_depth"] == 2
|
||||
assert fork2["fork_info"]["fork_reason"] == "Senior's refinement"
|
||||
|
||||
async def test_fork_nonexistent_tree(self, client: AsyncClient, auth_headers):
|
||||
"""Fork a nonexistent tree returns 404."""
|
||||
response = await client.post(
|
||||
"/api/v1/trees/00000000-0000-0000-0000-000000000000/fork",
|
||||
json={"fork_reason": "test"},
|
||||
headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_list_forks(self, client: AsyncClient, auth_headers, test_tree):
|
||||
"""List forks of a tree."""
|
||||
# Create two forks
|
||||
await client.post(
|
||||
f"/api/v1/trees/{test_tree['id']}/fork",
|
||||
json={"fork_reason": "Fork 1"},
|
||||
headers=auth_headers
|
||||
)
|
||||
await client.post(
|
||||
f"/api/v1/trees/{test_tree['id']}/fork",
|
||||
json={"fork_reason": "Fork 2"},
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
response = await client.get(
|
||||
f"/api/v1/trees/{test_tree['id']}/forks",
|
||||
headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
forks = response.json()
|
||||
assert len(forks) == 2
|
||||
|
||||
async def test_lineage_chain(self, client: AsyncClient, auth_headers, test_tree):
|
||||
"""Get lineage from fork back to root."""
|
||||
# Create chain: root → fork1 → fork2
|
||||
resp1 = await client.post(
|
||||
f"/api/v1/trees/{test_tree['id']}/fork",
|
||||
json={"fork_reason": "Level 1"},
|
||||
headers=auth_headers
|
||||
)
|
||||
fork1 = resp1.json()
|
||||
|
||||
resp2 = await client.post(
|
||||
f"/api/v1/trees/{fork1['id']}/fork",
|
||||
json={"fork_reason": "Level 2"},
|
||||
headers=auth_headers
|
||||
)
|
||||
fork2 = resp2.json()
|
||||
|
||||
# Get lineage from fork2
|
||||
response = await client.get(
|
||||
f"/api/v1/trees/{fork2['id']}/lineage",
|
||||
headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
lineage = response.json()
|
||||
|
||||
# Should be [fork2, fork1, root]
|
||||
assert len(lineage) == 3
|
||||
assert lineage[0]["id"] == fork2["id"]
|
||||
assert lineage[1]["id"] == fork1["id"]
|
||||
assert lineage[2]["id"] == test_tree["id"]
|
||||
|
||||
async def test_lineage_of_root_tree(self, client: AsyncClient, auth_headers, test_tree):
|
||||
"""Root tree lineage is just itself."""
|
||||
response = await client.get(
|
||||
f"/api/v1/trees/{test_tree['id']}/lineage",
|
||||
headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
lineage = response.json()
|
||||
assert len(lineage) == 1
|
||||
assert lineage[0]["id"] == test_tree["id"]
|
||||
|
||||
async def test_fork_preserves_tree_structure(self, client: AsyncClient, auth_headers, test_tree):
|
||||
"""Fork copies the complete tree_structure JSONB."""
|
||||
response = await client.post(
|
||||
f"/api/v1/trees/{test_tree['id']}/fork",
|
||||
json={"fork_reason": "Copy check"},
|
||||
headers=auth_headers
|
||||
)
|
||||
fork = response.json()
|
||||
|
||||
# Get full fork detail
|
||||
detail = await client.get(
|
||||
f"/api/v1/trees/{fork['id']}",
|
||||
headers=auth_headers
|
||||
)
|
||||
assert detail.status_code == 200
|
||||
assert detail.json()["tree_structure"] == test_tree["tree_structure"]
|
||||
|
||||
async def test_fork_requires_auth(self, client: AsyncClient, test_tree):
|
||||
"""Fork without auth returns 401."""
|
||||
response = await client.post(
|
||||
f"/api/v1/trees/{test_tree['id']}/fork",
|
||||
json={"fork_reason": "No auth"}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
Reference in New Issue
Block a user