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:
@@ -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)
|
||||
Reference in New Issue
Block a user