fix(tenancy): fix team CRUD bugs + add member integration tests

Store team page:
- Fix undefined user_id (API returns `id`, JS used `user_id`)
- Fix wrong URL path in updateMember (remove redundant storeCode)
- Fix update_member_role route passing wrong kwarg (new_role_id → new_role_name)
- Add update_member() service method for role_id + is_active updates
- Add :selected binding for role pre-selection in edit modal

Merchant team page:
- Add missing db.commit() on invite, update, and remove endpoints
- Fix forward-reference string type annotation on MerchantTeamInvite
- Add :selected binding for role pre-selection in edit modal

Shared fixes:
- Replace removed subscription_service.check_team_limit with usage_service
- Replace removed subscription_service.get_current_tier in email service
- Fix email config bool settings crashing on .lower() (value_type=boolean)

Tests: 15 new integration tests for store team member API endpoints.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-28 21:06:21 +01:00
parent 0455e63a2e
commit 332960de30
10 changed files with 541 additions and 22 deletions

View File

@@ -0,0 +1,434 @@
# app/modules/tenancy/tests/integration/test_store_team_members_api.py
"""
Integration tests for store team member CRUD API endpoints.
Tests the member management endpoints at:
/api/v1/store/team/members
/api/v1/store/team/invite
Authentication: Overrides get_current_store_from_cookie_or_header to return
a UserContext with the correct token_store_id. The test user is the merchant
owner, so all permission checks pass (owner bypass).
"""
import uuid
import pytest
from app.api.deps import get_current_store_from_cookie_or_header
from app.modules.tenancy.models import Merchant, Role, Store, StoreUser, User
from app.modules.tenancy.schemas.auth import UserContext
from main import app
# ============================================================================
# Fixtures
# ============================================================================
BASE = "/api/v1/store/team"
@pytest.fixture
def member_owner(db):
"""Create a store owner user for member tests."""
from middleware.auth import AuthManager
auth = AuthManager()
uid = uuid.uuid4().hex[:8]
user = User(
email=f"memberowner_{uid}@test.com",
username=f"memberowner_{uid}",
hashed_password=auth.hash_password("memberpass123"),
role="merchant_owner",
is_active=True,
first_name="Owner",
last_name="User",
)
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture
def member_merchant(db, member_owner):
"""Create a merchant owned by member_owner."""
merchant = Merchant(
name="Member Test Merchant",
owner_user_id=member_owner.id,
contact_email=member_owner.email,
is_active=True,
is_verified=True,
)
db.add(merchant)
db.commit()
db.refresh(merchant)
return merchant
@pytest.fixture
def member_store(db, member_merchant):
"""Create a store for member tests."""
uid = uuid.uuid4().hex[:8]
store = Store(
merchant_id=member_merchant.id,
store_code=f"MEMTEST_{uid.upper()}",
subdomain=f"memtest{uid}",
name=f"Member Test Store {uid}",
is_active=True,
is_verified=True,
)
db.add(store)
db.commit()
db.refresh(store)
return store
@pytest.fixture
def member_store_user(db, member_store, member_owner):
"""Create a StoreUser association for the owner."""
store_user = StoreUser(
store_id=member_store.id,
user_id=member_owner.id,
is_active=True,
)
db.add(store_user)
db.commit()
db.refresh(store_user)
return store_user
@pytest.fixture
def member_auth(member_owner, member_store, member_store_user):
"""Override auth dependency to simulate authenticated store owner.
Overrides get_current_store_from_cookie_or_header so that both
require_store_owner and require_store_permission(...) inner functions
receive the correct UserContext. The owner bypass ensures all
permission checks pass.
"""
user_context = UserContext(
id=member_owner.id,
email=member_owner.email,
username=member_owner.username,
role="merchant_owner",
is_active=True,
token_store_id=member_store.id,
)
def _override():
return user_context
app.dependency_overrides[get_current_store_from_cookie_or_header] = _override
yield {"Authorization": "Bearer fake-token"}
app.dependency_overrides.pop(get_current_store_from_cookie_or_header, None)
@pytest.fixture
def staff_role(db, member_store):
"""Create a 'staff' role for the store."""
role = Role(
store_id=member_store.id,
name="staff",
permissions=["orders.view", "products.view"],
)
db.add(role)
db.commit()
db.refresh(role)
return role
@pytest.fixture
def manager_role(db, member_store):
"""Create a 'manager' role for the store."""
role = Role(
store_id=member_store.id,
name="manager",
permissions=["orders.view", "orders.edit", "products.view", "products.edit", "team.view"],
)
db.add(role)
db.commit()
db.refresh(role)
return role
@pytest.fixture
def team_member_user(db):
"""Create another user to serve as a team member."""
from middleware.auth import AuthManager
auth = AuthManager()
uid = uuid.uuid4().hex[:8]
user = User(
email=f"teammember_{uid}@test.com",
username=f"teammember_{uid}",
hashed_password=auth.hash_password("memberpass123"),
role="store_member",
is_active=True,
first_name="Team",
last_name="Member",
)
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture
def team_member(db, member_store, team_member_user, staff_role):
"""Create a StoreUser for team_member_user with staff role."""
store_user = StoreUser(
store_id=member_store.id,
user_id=team_member_user.id,
role_id=staff_role.id,
is_active=True,
)
db.add(store_user)
db.commit()
db.refresh(store_user)
return store_user
# ============================================================================
# GET /team/members
# ============================================================================
@pytest.mark.integration
@pytest.mark.tenancy
class TestListMembers:
"""Tests for GET /api/v1/store/team/members."""
def test_list_members_returns_owner_and_member(
self, client, member_auth, team_member, member_owner, team_member_user
):
"""GET /members returns both owner and team member."""
response = client.get(f"{BASE}/members", headers=member_auth)
assert response.status_code == 200
data = response.json()
member_ids = {m["id"] for m in data["members"]}
assert member_owner.id in member_ids
assert team_member_user.id in member_ids
def test_list_members_response_shape(self, client, member_auth, team_member):
"""Each member in the response has expected fields."""
response = client.get(f"{BASE}/members", headers=member_auth)
assert response.status_code == 200
data = response.json()
assert "members" in data
assert "total" in data
assert "active_count" in data
assert "pending_invitations" in data
member = data["members"][0]
assert "id" in member
assert "email" in member
assert "username" in member
assert "first_name" in member
assert "last_name" in member
assert "full_name" in member
assert "role_name" in member
assert "role_id" in member
assert "permissions" in member
assert "is_active" in member
assert "is_owner" in member
assert "invitation_pending" in member
def test_list_members_stats(
self, client, member_auth, team_member, member_owner
):
"""GET /members returns correct statistics."""
response = client.get(f"{BASE}/members", headers=member_auth)
assert response.status_code == 200
data = response.json()
assert data["total"] >= 2
assert data["active_count"] >= 2
assert data["pending_invitations"] >= 0
# ============================================================================
# GET /team/members/{user_id}
# ============================================================================
@pytest.mark.integration
@pytest.mark.tenancy
class TestGetMember:
"""Tests for GET /api/v1/store/team/members/{user_id}."""
def test_get_member_success(
self, client, member_auth, team_member, team_member_user
):
"""GET /members/{user_id} returns the specific member."""
response = client.get(
f"{BASE}/members/{team_member_user.id}", headers=member_auth
)
assert response.status_code == 200
data = response.json()
assert data["id"] == team_member_user.id
assert data["email"] == team_member_user.email
assert data["role_name"] == "staff"
def test_get_nonexistent_member(self, client, member_auth):
"""GET /members/{user_id} returns 404 for non-existent user."""
response = client.get(f"{BASE}/members/99999", headers=member_auth)
assert response.status_code == 404
# ============================================================================
# PUT /team/members/{user_id}
# ============================================================================
@pytest.mark.integration
@pytest.mark.tenancy
class TestUpdateMember:
"""Tests for PUT /api/v1/store/team/members/{user_id}."""
def test_update_member_role_success(
self, client, member_auth, team_member, team_member_user, manager_role
):
"""PUT /members/{user_id} updates the member's role."""
response = client.put(
f"{BASE}/members/{team_member_user.id}",
headers=member_auth,
json={"role_id": manager_role.id},
)
assert response.status_code == 200
data = response.json()
assert data["role_name"] == "manager"
assert data["role_id"] == manager_role.id
def test_update_member_active_status(
self, client, member_auth, team_member, team_member_user
):
"""PUT /members/{user_id} can deactivate a member."""
response = client.put(
f"{BASE}/members/{team_member_user.id}",
headers=member_auth,
json={"is_active": False},
)
assert response.status_code == 200
data = response.json()
assert data["is_active"] is False
def test_update_owner_rejected(
self, client, member_auth, member_owner, staff_role
):
"""PUT /members/{user_id} rejects changing owner's role."""
response = client.put(
f"{BASE}/members/{member_owner.id}",
headers=member_auth,
json={"role_id": staff_role.id},
)
assert response.status_code in (400, 422)
def test_update_nonexistent_member(self, client, member_auth, staff_role):
"""PUT /members/{user_id} returns 404 for non-existent user."""
response = client.put(
f"{BASE}/members/99999",
headers=member_auth,
json={"role_id": staff_role.id},
)
assert response.status_code == 404
def test_update_with_invalid_role_id(
self, client, member_auth, team_member, team_member_user
):
"""PUT /members/{user_id} returns 422 for non-existent role."""
response = client.put(
f"{BASE}/members/{team_member_user.id}",
headers=member_auth,
json={"role_id": 99999},
)
assert response.status_code == 422
# ============================================================================
# DELETE /team/members/{user_id}
# ============================================================================
@pytest.mark.integration
@pytest.mark.tenancy
class TestRemoveMember:
"""Tests for DELETE /api/v1/store/team/members/{user_id}."""
def test_remove_member_success(
self, client, member_auth, team_member, team_member_user, db
):
"""DELETE /members/{user_id} removes a team member."""
response = client.delete(
f"{BASE}/members/{team_member_user.id}", headers=member_auth
)
assert response.status_code == 200
data = response.json()
assert data["user_id"] == team_member_user.id
# Verify member is soft-deleted (deleted_at set, record hidden from normal queries)
db.expire_all()
store_user = (
db.query(StoreUser)
.execution_options(include_deleted=True)
.filter(StoreUser.user_id == team_member_user.id)
.first()
)
assert store_user is not None
assert store_user.deleted_at is not None
def test_remove_owner_rejected(self, client, member_auth, member_owner):
"""DELETE /members/{user_id} rejects removing the owner."""
response = client.delete(
f"{BASE}/members/{member_owner.id}", headers=member_auth
)
assert response.status_code == 400
def test_remove_nonexistent_member(self, client, member_auth):
"""DELETE /members/{user_id} returns 404 for non-existent user."""
response = client.delete(
f"{BASE}/members/99999", headers=member_auth
)
assert response.status_code == 404
# ============================================================================
# POST /team/invite
# ============================================================================
@pytest.mark.integration
@pytest.mark.tenancy
class TestInviteMember:
"""Tests for POST /api/v1/store/team/invite."""
def test_invite_member_success(self, client, member_auth, staff_role):
"""POST /invite creates an invitation for a new email."""
uid = uuid.uuid4().hex[:8]
response = client.post(
f"{BASE}/invite",
headers=member_auth,
json={
"email": f"newinvite_{uid}@test.com",
"role_name": "staff",
"first_name": "New",
"last_name": "Invitee",
},
)
assert response.status_code == 200
data = response.json()
assert data["email"] == f"newinvite_{uid}@test.com"
assert data["invitation_sent"] is True
assert data["role"] == "staff"
def test_invite_duplicate_email(
self, client, member_auth, team_member, team_member_user, staff_role
):
"""POST /invite with existing member email returns error or reactivation."""
response = client.post(
f"{BASE}/invite",
headers=member_auth,
json={
"email": team_member_user.email,
"role_name": "staff",
},
)
# May succeed as reactivation or fail as duplicate
assert response.status_code in (200, 400, 409, 422)

View File

@@ -243,17 +243,29 @@ class TestStoreTeamServiceRemove:
"""Test suite for removing team members."""
def test_remove_team_member_success(self, db, team_store, team_member):
"""Test removing a team member."""
"""Test removing a team member (soft delete)."""
result = store_team_service.remove_team_member(
db=db,
store=team_store,
user_id=team_member.user_id,
)
db.commit()
db.refresh(team_member)
# Verify soft-deleted (hidden from normal queries, visible with include_deleted)
from app.modules.tenancy.models import StoreUser
hidden = db.query(StoreUser).filter(StoreUser.id == team_member.id).first()
assert hidden is None # Filtered out by soft-delete
visible = (
db.query(StoreUser)
.execution_options(include_deleted=True)
.filter(StoreUser.id == team_member.id)
.first()
)
assert visible is not None
assert visible.deleted_at is not None
assert result is True
assert team_member.is_active is False
def test_remove_owner_raises_error(self, db, team_store, store_owner):
"""Test removing owner raises exception."""