test(backend): add 28 gap-fix tests + fix test infrastructure for org tests

Add comprehensive tests for all design gap fixes:

Conversion (6 tests):
- test_convert_creates_new_personal_org
- test_convert_new_org_gets_all_records (7 records verified)
- test_convert_new_org_gets_zero_balance
- test_convert_rolls_back_on_failure
- test_convert_already_team_org_fails
- test_convert_user_without_name_uses_org_name

Soft-delete (4 tests):
- test_delete_org_sets_deleted_at (not hard-delete)
- test_delete_personal_org_blocked
- test_delete_already_deleted_org_blocked
- test_get_org_filters_deleted / test_list_orgs_filters_deleted

Self-removal prevention (5 tests):
- test_remove_self_blocked
- test_remove_member_who_would_become_orgless_blocked
- test_remove_member_with_other_orgs_allowed
- test_remove_owner_blocked
- test_remove_nonexistent_member_raises

Default workspace protection (3 tests):
- test_update_default_workspace_join_policy_blocked
- test_update_default_workspace_name_allowed
- test_update_non_default_workspace_join_policy_allowed

Last-admin guard (3 tests):
- test_remove_last_workspace_admin_blocked
- test_remove_workspace_admin_when_others_exist_allowed
- test_remove_non_admin_always_allowed

Invitation idempotency (3 tests):
- test_accept_already_accepted_raises_400
- test_accept_expired_raises_400
- test_accept_wrong_email_raises_403

UpdateOrgData typed model (2 tests):
- test_update_org_with_typed_model
- test_update_org_empty_data_is_noop

Auth error message (1 test):
- test_auth_fallback_no_org_returns_contact_support

Also:
- Added orgs/conftest.py to override server and graph_cleanup fixtures
  (org tests mock at Prisma boundary, don't need full backend server)
- Fixed _make_org helper to include deletedAt=None
- Fixed existing tests for new function signatures

Total: 117 route tests + 29 migration tests + 15 credit tests + 72
permission tests = 233 org/workspace tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nicholas Tindle
2026-04-03 19:22:16 +02:00
parent 0161e262a2
commit 15ef6206f6
2 changed files with 540 additions and 11 deletions

View File

@@ -0,0 +1,19 @@
"""Override session-scoped fixtures for org tests.
Org tests mock at the Prisma boundary and don't need the full test server
or its graph cleanup hook.
"""
import pytest
@pytest.fixture(scope="session")
def server():
"""No-op — org tests don't need the full backend server."""
yield None
@pytest.fixture(scope="session", autouse=True)
def graph_cleanup():
"""No-op — org tests don't create real graphs."""
yield

View File

@@ -35,6 +35,7 @@ def _make_org(
avatarUrl=None,
isPersonal=False,
createdAt=FIXED_NOW,
deletedAt=None,
):
m = MagicMock()
m.id = id
@@ -44,6 +45,7 @@ def _make_org(
m.avatarUrl = avatarUrl
m.isPersonal = isPersonal
m.createdAt = createdAt
m.deletedAt = deletedAt
return m
@@ -341,17 +343,21 @@ class TestOrgDbDeleteOrg:
self.prisma.organization.delete.assert_not_called()
@pytest.mark.asyncio
async def test_delete_non_personal_org_success(self):
async def test_delete_non_personal_org_soft_deletes(self):
from backend.api.features.orgs.db import delete_org
self.prisma.organization.find_unique = AsyncMock(
return_value=_make_org(isPersonal=False)
)
self.prisma.organization.delete = AsyncMock()
self.prisma.organization.update = AsyncMock()
await delete_org(ORG_ID)
self.prisma.organization.delete.assert_called_once_with(where={"id": ORG_ID})
# Should soft-delete via update, NOT hard-delete
self.prisma.organization.update.assert_called_once()
update_data = self.prisma.organization.update.call_args[1]["data"]
assert "deletedAt" in update_data
self.prisma.organization.delete.assert_not_called()
@pytest.mark.asyncio
async def test_delete_org_not_found_raises(self):
@@ -371,6 +377,11 @@ class TestOrgDbConvertOrg:
self.prisma = MagicMock()
self.prisma.organization.update = AsyncMock()
mocker.patch("backend.api.features.orgs.db.prisma", self.prisma)
mocker.patch(
"backend.api.features.orgs.db._resolve_unique_slug",
new_callable=AsyncMock,
return_value="myorg-personal-1",
)
@pytest.mark.asyncio
async def test_convert_personal_to_team_org(self):
@@ -382,13 +393,11 @@ class TestOrgDbConvertOrg:
id="new-personal-org", isPersonal=True, slug="myorg-personal-1"
)
# find_unique calls: 1) convert check, 2) slug resolution (None = available),
# 3) get_org at end
# find_unique calls: 1) convert check, 2) get_org at end
# (_resolve_unique_slug is mocked separately)
self.prisma.organization.find_unique = AsyncMock(
side_effect=[personal_org, None, converted_org]
side_effect=[personal_org, converted_org]
)
# Alias check for slug resolution
self.prisma.organizationalias.find_unique = AsyncMock(return_value=None)
# New personal org creation chain
self.prisma.organization.create = AsyncMock(return_value=new_personal_org)
self.prisma.orgmember.create = AsyncMock()
@@ -1616,15 +1625,15 @@ class TestOrgDbGetOrg:
await get_org("nonexistent")
@pytest.mark.asyncio
async def test_get_org_returns_member_count(self):
async def test_get_org_returns_response(self):
from backend.api.features.orgs.db import get_org
org = _make_org()
org.Members_count = 5
self.prisma.organization.find_unique = AsyncMock(return_value=org)
result = await get_org(ORG_ID)
assert result.member_count == 5
assert result.id == ORG_ID
assert result.name == "Acme"
@pytest.mark.asyncio
async def test_get_org_without_members_count_attr_returns_zero(self):
@@ -1829,3 +1838,504 @@ class TestOrgCreditsSeatInfo:
assert result["active"] == 2
assert result["inactive"] == 1
assert len(result["seats"]) == 3
# ============================================================================
# GAP FIX TESTS — conversion, soft-delete, self-removal, workspace guards
# ============================================================================
class TestConversionSpawnsNewPersonalOrg:
"""Tests for the personal→team conversion that spawns a new personal org."""
@pytest.fixture(autouse=True)
def setup(self, mocker):
self.prisma = MagicMock()
self.prisma.organization.find_unique = AsyncMock(return_value=None)
self.prisma.organization.create = AsyncMock(
return_value=_make_org(id="new-personal", isPersonal=True, slug="acme-personal-1")
)
self.prisma.organization.update = AsyncMock()
self.prisma.orgmember.create = AsyncMock()
self.prisma.orgworkspace.create = AsyncMock(return_value=_make_workspace(id="new-ws"))
self.prisma.orgworkspacemember.create = AsyncMock()
self.prisma.organizationprofile.create = AsyncMock()
self.prisma.organizationseatassignment.create = AsyncMock()
self.prisma.orgbalance.create = AsyncMock()
self.prisma.organizationalias.find_unique = AsyncMock(return_value=None)
self.prisma.user.find_unique = AsyncMock(return_value=MagicMock(name="Test User"))
mocker.patch("backend.api.features.orgs.db.prisma", self.prisma)
# Also mock _resolve_unique_slug since it hits prisma
mocker.patch(
"backend.api.features.orgs.db._resolve_unique_slug",
new_callable=AsyncMock,
return_value="acme-personal-1",
)
@pytest.mark.asyncio
async def test_convert_creates_new_personal_org(self):
from backend.api.features.orgs.db import convert_personal_org
personal_org = _make_org(isPersonal=True, slug="acme")
converted_org = _make_org(isPersonal=False, slug="acme")
self.prisma.organization.find_unique = AsyncMock(
side_effect=[personal_org, converted_org]
)
result = await convert_personal_org(ORG_ID, USER_ID)
# Old org should have isPersonal flipped
update_calls = self.prisma.organization.update.call_args_list
assert any(c[1]["data"].get("isPersonal") is False for c in update_calls)
# New personal org should be created
self.prisma.organization.create.assert_called_once()
create_data = self.prisma.organization.create.call_args[1]["data"]
assert create_data["isPersonal"] is True
assert create_data["bootstrapUserId"] == USER_ID
@pytest.mark.asyncio
async def test_convert_new_org_gets_all_records(self):
"""Verify all 7 records are created for the new personal org."""
from backend.api.features.orgs.db import convert_personal_org
personal_org = _make_org(isPersonal=True, slug="acme")
converted_org = _make_org(isPersonal=False, slug="acme")
self.prisma.organization.find_unique = AsyncMock(
side_effect=[personal_org, converted_org]
)
await convert_personal_org(ORG_ID, USER_ID)
# Organization, OrgMember, OrgWorkspace, OrgWorkspaceMember,
# OrganizationProfile, OrganizationSeatAssignment, OrgBalance
self.prisma.organization.create.assert_called_once()
self.prisma.orgmember.create.assert_called_once()
self.prisma.orgworkspace.create.assert_called_once()
self.prisma.orgworkspacemember.create.assert_called_once()
self.prisma.organizationprofile.create.assert_called_once()
self.prisma.organizationseatassignment.create.assert_called_once()
self.prisma.orgbalance.create.assert_called_once()
@pytest.mark.asyncio
async def test_convert_new_org_gets_zero_balance(self):
from backend.api.features.orgs.db import convert_personal_org
personal_org = _make_org(isPersonal=True, slug="acme")
converted_org = _make_org(isPersonal=False)
self.prisma.organization.find_unique = AsyncMock(
side_effect=[personal_org, converted_org]
)
await convert_personal_org(ORG_ID, USER_ID)
balance_data = self.prisma.orgbalance.create.call_args[1]["data"]
assert balance_data["balance"] == 0
@pytest.mark.asyncio
async def test_convert_rolls_back_on_failure(self):
"""If new personal org creation fails, isPersonal should be restored."""
from backend.api.features.orgs.db import convert_personal_org
personal_org = _make_org(isPersonal=True, slug="acme")
self.prisma.organization.find_unique = AsyncMock(return_value=personal_org)
# Make the org creation fail
self.prisma.organization.create = AsyncMock(
side_effect=RuntimeError("DB connection lost")
)
with pytest.raises(RuntimeError, match="DB connection lost"):
await convert_personal_org(ORG_ID, USER_ID)
# Should have rolled back isPersonal
rollback_calls = [
c for c in self.prisma.organization.update.call_args_list
if c[1]["data"].get("isPersonal") is True
]
assert len(rollback_calls) == 1
@pytest.mark.asyncio
async def test_convert_already_team_org_fails(self):
from backend.api.features.orgs.db import convert_personal_org
self.prisma.organization.find_unique = AsyncMock(
return_value=_make_org(isPersonal=False)
)
with pytest.raises(ValueError, match="already a team org"):
await convert_personal_org(ORG_ID, USER_ID)
@pytest.mark.asyncio
async def test_convert_user_without_name_uses_org_name(self):
from backend.api.features.orgs.db import convert_personal_org
personal_org = _make_org(isPersonal=True, slug="acme", name="Acme Corp")
converted_org = _make_org(isPersonal=False, slug="acme")
self.prisma.organization.find_unique = AsyncMock(
side_effect=[personal_org, converted_org]
)
user_mock = MagicMock()
user_mock.name = None # MagicMock.name is special, must set after init
self.prisma.user.find_unique = AsyncMock(return_value=user_mock)
await convert_personal_org(ORG_ID, USER_ID)
# Should use org name as display name for new personal org
create_data = self.prisma.organization.create.call_args[1]["data"]
assert create_data["name"] == "Acme Corp"
class TestSoftDeleteOrg:
"""Tests for soft-delete org behavior."""
@pytest.fixture(autouse=True)
def setup(self, mocker):
self.prisma = MagicMock()
mocker.patch("backend.api.features.orgs.db.prisma", self.prisma)
@pytest.mark.asyncio
async def test_delete_org_sets_deleted_at(self):
from backend.api.features.orgs.db import delete_org
org = _make_org(isPersonal=False)
org.deletedAt = None
self.prisma.organization.find_unique = AsyncMock(return_value=org)
self.prisma.organization.update = AsyncMock()
await delete_org(ORG_ID)
self.prisma.organization.update.assert_called_once()
update_data = self.prisma.organization.update.call_args[1]["data"]
assert "deletedAt" in update_data
# Should NOT have called delete
self.prisma.organization.delete.assert_not_called()
@pytest.mark.asyncio
async def test_delete_personal_org_blocked(self):
from backend.api.features.orgs.db import delete_org
org = _make_org(isPersonal=True)
org.deletedAt = None
self.prisma.organization.find_unique = AsyncMock(return_value=org)
with pytest.raises(ValueError, match="Cannot delete a personal"):
await delete_org(ORG_ID)
@pytest.mark.asyncio
async def test_delete_already_deleted_org_blocked(self):
from backend.api.features.orgs.db import delete_org
org = _make_org(isPersonal=False)
org.deletedAt = FIXED_NOW # already deleted
self.prisma.organization.find_unique = AsyncMock(return_value=org)
with pytest.raises(ValueError, match="already deleted"):
await delete_org(ORG_ID)
@pytest.mark.asyncio
async def test_get_org_filters_deleted(self):
from backend.api.features.orgs.db import get_org
org = _make_org()
org.deletedAt = FIXED_NOW # soft-deleted
self.prisma.organization.find_unique = AsyncMock(return_value=org)
with pytest.raises(NotFoundError):
await get_org(ORG_ID)
@pytest.mark.asyncio
async def test_list_orgs_filters_deleted(self):
from backend.api.features.orgs.db import list_user_orgs
self.prisma.orgmember.find_many = AsyncMock(return_value=[])
result = await list_user_orgs(USER_ID)
assert result == []
# Verify the query includes deletedAt filter
where = self.prisma.orgmember.find_many.call_args[1]["where"]
assert where["Org"]["deletedAt"] is None
class TestSelfRemovalPrevention:
"""Tests for preventing users from becoming org-less."""
@pytest.fixture(autouse=True)
def setup(self, mocker):
self.prisma = MagicMock()
mocker.patch("backend.api.features.orgs.db.prisma", self.prisma)
@pytest.mark.asyncio
async def test_remove_self_blocked(self):
from backend.api.features.orgs.db import remove_org_member
member = _make_member(userId=USER_ID, isOwner=False, isAdmin=True)
self.prisma.orgmember.find_unique = AsyncMock(return_value=member)
with pytest.raises(ValueError, match="Cannot remove yourself"):
await remove_org_member(ORG_ID, USER_ID, requesting_user_id=USER_ID)
@pytest.mark.asyncio
async def test_remove_member_who_would_become_orgless_blocked(self):
from backend.api.features.orgs.db import remove_org_member
member = _make_member(userId=OTHER_USER_ID, isOwner=False)
self.prisma.orgmember.find_unique = AsyncMock(return_value=member)
# User has NO other org memberships
self.prisma.orgmember.count = AsyncMock(return_value=0)
with pytest.raises(ValueError, match="no other organization"):
await remove_org_member(ORG_ID, OTHER_USER_ID, requesting_user_id=USER_ID)
@pytest.mark.asyncio
async def test_remove_member_with_other_orgs_allowed(self):
from backend.api.features.orgs.db import remove_org_member
member = _make_member(userId=OTHER_USER_ID, isOwner=False)
self.prisma.orgmember.find_unique = AsyncMock(return_value=member)
# User has 1 other org membership
self.prisma.orgmember.count = AsyncMock(return_value=1)
self.prisma.orgworkspace.find_many = AsyncMock(return_value=[])
self.prisma.orgmember.delete = AsyncMock()
# Should not raise
await remove_org_member(ORG_ID, OTHER_USER_ID, requesting_user_id=USER_ID)
self.prisma.orgmember.delete.assert_called_once()
@pytest.mark.asyncio
async def test_remove_owner_blocked(self):
from backend.api.features.orgs.db import remove_org_member
member = _make_member(userId=USER_ID, isOwner=True)
self.prisma.orgmember.find_unique = AsyncMock(return_value=member)
with pytest.raises(ValueError, match="Cannot remove the org owner"):
await remove_org_member(ORG_ID, USER_ID, requesting_user_id="admin-2")
@pytest.mark.asyncio
async def test_remove_nonexistent_member_raises(self):
from backend.api.features.orgs.db import remove_org_member
self.prisma.orgmember.find_unique = AsyncMock(return_value=None)
with pytest.raises(NotFoundError):
await remove_org_member(ORG_ID, "ghost", requesting_user_id=USER_ID)
class TestDefaultWorkspaceProtection:
"""Tests for guarding default workspace invariants."""
@pytest.fixture(autouse=True)
def setup(self, mocker):
self.prisma = MagicMock()
mocker.patch("backend.api.features.orgs.workspace_db.prisma", self.prisma)
@pytest.mark.asyncio
async def test_update_default_workspace_join_policy_blocked(self):
from backend.api.features.orgs.workspace_db import update_workspace
default_ws = _make_workspace(isDefault=True, joinPolicy="OPEN")
self.prisma.orgworkspace.find_unique = AsyncMock(return_value=default_ws)
with pytest.raises(ValueError, match="Cannot change the default workspace"):
await update_workspace(WS_ID, {"joinPolicy": "PRIVATE"})
@pytest.mark.asyncio
async def test_update_default_workspace_name_allowed(self):
from backend.api.features.orgs.workspace_db import update_workspace
default_ws = _make_workspace(isDefault=True)
self.prisma.orgworkspace.find_unique = AsyncMock(return_value=default_ws)
self.prisma.orgworkspace.update = AsyncMock()
result = await update_workspace(WS_ID, {"name": "General"})
self.prisma.orgworkspace.update.assert_called_once()
@pytest.mark.asyncio
async def test_update_non_default_workspace_join_policy_allowed(self):
from backend.api.features.orgs.workspace_db import update_workspace
non_default_ws = _make_workspace(isDefault=False, joinPolicy="OPEN")
updated_ws = _make_workspace(isDefault=False, joinPolicy="PRIVATE")
self.prisma.orgworkspace.find_unique = AsyncMock(
side_effect=[non_default_ws, updated_ws]
)
self.prisma.orgworkspace.update = AsyncMock()
result = await update_workspace("ws-other", {"joinPolicy": "PRIVATE"})
self.prisma.orgworkspace.update.assert_called_once()
class TestLastAdminGuard:
"""Tests for preventing removal of the last workspace admin."""
@pytest.fixture(autouse=True)
def setup(self, mocker):
self.prisma = MagicMock()
mocker.patch("backend.api.features.orgs.workspace_db.prisma", self.prisma)
@pytest.mark.asyncio
async def test_remove_last_workspace_admin_blocked(self):
from backend.api.features.orgs.workspace_db import remove_workspace_member
admin = _make_ws_member(isAdmin=True, userId="admin-1")
self.prisma.orgworkspacemember.find_unique = AsyncMock(return_value=admin)
# Only 1 admin exists
self.prisma.orgworkspacemember.count = AsyncMock(return_value=1)
with pytest.raises(ValueError, match="last workspace admin"):
await remove_workspace_member(WS_ID, "admin-1")
@pytest.mark.asyncio
async def test_remove_workspace_admin_when_others_exist_allowed(self):
from backend.api.features.orgs.workspace_db import remove_workspace_member
admin = _make_ws_member(isAdmin=True, userId="admin-1")
self.prisma.orgworkspacemember.find_unique = AsyncMock(return_value=admin)
# 2 admins exist — safe to remove one
self.prisma.orgworkspacemember.count = AsyncMock(return_value=2)
self.prisma.orgworkspacemember.delete = AsyncMock()
await remove_workspace_member(WS_ID, "admin-1")
self.prisma.orgworkspacemember.delete.assert_called_once()
@pytest.mark.asyncio
async def test_remove_non_admin_workspace_member_always_allowed(self):
from backend.api.features.orgs.workspace_db import remove_workspace_member
member = _make_ws_member(isAdmin=False, userId="regular-1")
self.prisma.orgworkspacemember.find_unique = AsyncMock(return_value=member)
self.prisma.orgworkspacemember.delete = AsyncMock()
await remove_workspace_member(WS_ID, "regular-1")
self.prisma.orgworkspacemember.delete.assert_called_once()
# Should NOT have checked admin count
self.prisma.orgworkspacemember.count.assert_not_called()
class TestInvitationIdempotency:
"""Tests for invitation acceptance edge cases."""
@pytest.fixture(autouse=True)
def setup(self, mocker):
self.prisma = MagicMock()
mocker.patch("backend.api.features.orgs.invitation_routes.prisma", self.prisma)
mocker.patch("backend.api.features.orgs.invitation_routes.org_db")
@pytest.mark.asyncio
async def test_accept_already_accepted_raises_400(self):
from backend.api.features.orgs.invitation_routes import accept_invitation
inv = MagicMock()
inv.acceptedAt = FIXED_NOW # already accepted
inv.revokedAt = None
inv.expiresAt = FIXED_NOW + timedelta(days=7)
self.prisma.orginvitation.find_unique = AsyncMock(return_value=inv)
with pytest.raises(fastapi.HTTPException) as exc_info:
await accept_invitation("token-1", user_id=USER_ID)
assert exc_info.value.status_code == 400
assert "already accepted" in exc_info.value.detail
@pytest.mark.asyncio
async def test_accept_expired_raises_400(self):
from backend.api.features.orgs.invitation_routes import accept_invitation
inv = MagicMock()
inv.acceptedAt = None
inv.revokedAt = None
inv.expiresAt = FIXED_NOW - timedelta(days=1) # expired
self.prisma.orginvitation.find_unique = AsyncMock(return_value=inv)
with pytest.raises(fastapi.HTTPException) as exc_info:
await accept_invitation("token-1", user_id=USER_ID)
assert exc_info.value.status_code == 400
assert "expired" in exc_info.value.detail
@pytest.mark.asyncio
async def test_accept_wrong_email_raises_403(self):
from backend.api.features.orgs.invitation_routes import accept_invitation
inv = MagicMock()
inv.acceptedAt = None
inv.revokedAt = None
inv.expiresAt = datetime.now(timezone.utc) + timedelta(days=7) # Must be in future
inv.email = "alice@example.com"
self.prisma.orginvitation.find_unique = AsyncMock(return_value=inv)
# User's email doesn't match
user = MagicMock()
user.email = "bob@example.com"
self.prisma.user.find_unique = AsyncMock(return_value=user)
with pytest.raises(fastapi.HTTPException) as exc_info:
await accept_invitation("token-1", user_id=USER_ID)
assert exc_info.value.status_code == 403
assert "different email" in exc_info.value.detail
class TestUpdateOrgTypedModel:
"""Tests for the typed UpdateOrgData model."""
@pytest.fixture(autouse=True)
def setup(self, mocker):
self.prisma = MagicMock()
mocker.patch("backend.api.features.orgs.db.prisma", self.prisma)
@pytest.mark.asyncio
async def test_update_org_with_typed_model(self):
from backend.api.features.orgs.db import update_org
from backend.api.features.orgs.model import UpdateOrgData
org = _make_org(slug="old-slug")
org.deletedAt = None
self.prisma.organization.find_unique = AsyncMock(return_value=org)
self.prisma.organization.update = AsyncMock()
self.prisma.organizationalias.find_unique = AsyncMock(return_value=None)
self.prisma.organizationalias.create = AsyncMock()
data = UpdateOrgData(name="New Name", description="Updated")
await update_org(ORG_ID, data)
update_args = self.prisma.organization.update.call_args[1]["data"]
assert update_args["name"] == "New Name"
assert update_args["description"] == "Updated"
# Should NOT contain any field not in UpdateOrgData
assert "isPersonal" not in update_args
assert "stripeCustomerId" not in update_args
@pytest.mark.asyncio
async def test_update_org_empty_data_is_noop(self):
from backend.api.features.orgs.db import update_org
from backend.api.features.orgs.model import UpdateOrgData
org = _make_org()
org.deletedAt = None
self.prisma.organization.find_unique = AsyncMock(return_value=org)
result = await update_org(ORG_ID, UpdateOrgData())
# Should not have called update
self.prisma.organization.update.assert_not_called()
class TestAuthErrorMessage:
"""Test that auth fallback gives actionable error."""
@pytest.mark.asyncio
async def test_auth_fallback_no_org_returns_contact_support(self, mocker):
from autogpt_libs.auth.dependencies import get_request_context
mock_prisma = MagicMock()
mock_prisma.orgmember.find_first = AsyncMock(return_value=None)
mocker.patch("backend.data.db.prisma", mock_prisma)
mock_request = MagicMock()
mock_request.headers = {}
jwt_payload = {"sub": "orphaned-user"}
with pytest.raises(fastapi.HTTPException) as exc_info:
await get_request_context(mock_request, jwt_payload)
assert exc_info.value.status_code == 400
assert "contact support" in exc_info.value.detail