|
@@ -240,7 +240,7 @@
class="w-full px-4 py-2 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
>
-
+
diff --git a/app/modules/tenancy/tests/integration/test_store_team_members_api.py b/app/modules/tenancy/tests/integration/test_store_team_members_api.py
new file mode 100644
index 00000000..b09b593a
--- /dev/null
+++ b/app/modules/tenancy/tests/integration/test_store_team_members_api.py
@@ -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)
diff --git a/app/modules/tenancy/tests/unit/test_store_team_service.py b/app/modules/tenancy/tests/unit/test_store_team_service.py
index a493eb95..55523761 100644
--- a/app/modules/tenancy/tests/unit/test_store_team_service.py
+++ b/app/modules/tenancy/tests/unit/test_store_team_service.py
@@ -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."""
|