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>
169 lines
6.2 KiB
Python
169 lines
6.2 KiB
Python
"""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
|