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:
@@ -404,10 +404,10 @@ def get_platform_email_config(db: Session) -> dict:
|
|||||||
config["smtp_password"] = db_smtp_password if db_smtp_password else settings.smtp_password
|
config["smtp_password"] = db_smtp_password if db_smtp_password else settings.smtp_password
|
||||||
|
|
||||||
db_smtp_use_tls = get_db_setting("smtp_use_tls")
|
db_smtp_use_tls = get_db_setting("smtp_use_tls")
|
||||||
config["smtp_use_tls"] = db_smtp_use_tls.lower() in ("true", "1", "yes") if db_smtp_use_tls else settings.smtp_use_tls
|
config["smtp_use_tls"] = bool(db_smtp_use_tls) if db_smtp_use_tls is not None else settings.smtp_use_tls
|
||||||
|
|
||||||
db_smtp_use_ssl = get_db_setting("smtp_use_ssl")
|
db_smtp_use_ssl = get_db_setting("smtp_use_ssl")
|
||||||
config["smtp_use_ssl"] = db_smtp_use_ssl.lower() in ("true", "1", "yes") if db_smtp_use_ssl else settings.smtp_use_ssl
|
config["smtp_use_ssl"] = bool(db_smtp_use_ssl) if db_smtp_use_ssl is not None else settings.smtp_use_ssl
|
||||||
|
|
||||||
# SendGrid
|
# SendGrid
|
||||||
db_sendgrid_key = get_db_setting("sendgrid_api_key")
|
db_sendgrid_key = get_db_setting("sendgrid_api_key")
|
||||||
@@ -432,10 +432,10 @@ def get_platform_email_config(db: Session) -> dict:
|
|||||||
|
|
||||||
# Behavior
|
# Behavior
|
||||||
db_enabled = get_db_setting("email_enabled")
|
db_enabled = get_db_setting("email_enabled")
|
||||||
config["enabled"] = db_enabled.lower() in ("true", "1", "yes") if db_enabled else settings.email_enabled
|
config["enabled"] = bool(db_enabled) if db_enabled is not None else settings.email_enabled
|
||||||
|
|
||||||
db_debug = get_db_setting("email_debug")
|
db_debug = get_db_setting("email_debug")
|
||||||
config["debug"] = db_debug.lower() in ("true", "1", "yes") if db_debug else settings.email_debug
|
config["debug"] = bool(db_debug) if db_debug is not None else settings.email_debug
|
||||||
|
|
||||||
return config
|
return config
|
||||||
|
|
||||||
@@ -1038,8 +1038,8 @@ class EmailService:
|
|||||||
subscription_service,
|
subscription_service,
|
||||||
)
|
)
|
||||||
|
|
||||||
tier = subscription_service.get_current_tier(self.db, store_id)
|
sub = subscription_service.get_subscription_for_store(self.db, store_id)
|
||||||
self._store_tier_cache[store_id] = tier.value if tier else None
|
self._store_tier_cache[store_id] = sub.tier.code if sub and sub.tier else None
|
||||||
return self._store_tier_cache[store_id]
|
return self._store_tier_cache[store_id]
|
||||||
|
|
||||||
def _should_add_powered_by_footer(self, store_id: int | None) -> bool:
|
def _should_add_powered_by_footer(self, store_id: int | None) -> bool:
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from app.modules.tenancy.schemas import (
|
|||||||
MerchantStoreUpdate,
|
MerchantStoreUpdate,
|
||||||
)
|
)
|
||||||
from app.modules.tenancy.schemas.auth import UserContext
|
from app.modules.tenancy.schemas.auth import UserContext
|
||||||
|
from app.modules.tenancy.schemas.team import MerchantTeamInvite
|
||||||
from app.modules.tenancy.services.merchant_service import merchant_service
|
from app.modules.tenancy.services.merchant_service import merchant_service
|
||||||
from app.modules.tenancy.services.merchant_store_service import merchant_store_service
|
from app.modules.tenancy.services.merchant_store_service import merchant_store_service
|
||||||
|
|
||||||
@@ -192,7 +193,7 @@ async def merchant_team_store_roles(
|
|||||||
|
|
||||||
@_account_router.post("/team/invite")
|
@_account_router.post("/team/invite")
|
||||||
async def merchant_team_invite(
|
async def merchant_team_invite(
|
||||||
data: "MerchantTeamInvite",
|
data: MerchantTeamInvite,
|
||||||
current_user: UserContext = Depends(get_current_merchant_api),
|
current_user: UserContext = Depends(get_current_merchant_api),
|
||||||
merchant=Depends(get_merchant_for_current_user),
|
merchant=Depends(get_merchant_for_current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -233,6 +234,7 @@ async def merchant_team_invite(
|
|||||||
error=str(e),
|
error=str(e),
|
||||||
))
|
))
|
||||||
|
|
||||||
|
db.commit()
|
||||||
success_count = sum(1 for r in results if r.success)
|
success_count = sum(1 for r in results if r.success)
|
||||||
if success_count == len(results):
|
if success_count == len(results):
|
||||||
message = f"Invitation sent to {data.email} for {success_count} store(s)"
|
message = f"Invitation sent to {data.email} for {success_count} store(s)"
|
||||||
@@ -268,6 +270,7 @@ async def merchant_team_update_role(
|
|||||||
new_role_name=role_name,
|
new_role_name=role_name,
|
||||||
actor_user_id=current_user.id,
|
actor_user_id=current_user.id,
|
||||||
)
|
)
|
||||||
|
db.commit()
|
||||||
return {"message": "Role updated successfully"}
|
return {"message": "Role updated successfully"}
|
||||||
|
|
||||||
|
|
||||||
@@ -289,6 +292,7 @@ async def merchant_team_remove_member(
|
|||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
actor_user_id=current_user.id,
|
actor_user_id=current_user.id,
|
||||||
)
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
@_account_router.get("/profile", response_model=MerchantPortalProfileResponse)
|
@_account_router.get("/profile", response_model=MerchantPortalProfileResponse)
|
||||||
|
|||||||
@@ -277,12 +277,13 @@ def update_team_member(
|
|||||||
"""
|
"""
|
||||||
store = request.state.store
|
store = request.state.store
|
||||||
|
|
||||||
store_team_service.update_member_role(
|
store_team_service.update_member(
|
||||||
db=db,
|
db=db,
|
||||||
store=store,
|
store=store,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
new_role_id=update_data.role_id,
|
role_id=update_data.role_id,
|
||||||
is_active=update_data.is_active,
|
is_active=update_data.is_active,
|
||||||
|
actor_user_id=current_user.id,
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ async def store_login_page(
|
|||||||
"request": request,
|
"request": request,
|
||||||
"store_code": store_code,
|
"store_code": store_code,
|
||||||
"platform_code": platform_code,
|
"platform_code": platform_code,
|
||||||
|
"frontend_type": "store",
|
||||||
**get_jinja2_globals(language),
|
**get_jinja2_globals(language),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -76,10 +76,22 @@ class StoreTeamService:
|
|||||||
Dict with invitation details
|
Dict with invitation details
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Check team size limit from subscription
|
# Check team size limit from subscription (skip if no subscription)
|
||||||
from app.modules.billing.services import subscription_service
|
try:
|
||||||
|
from app.modules.billing.services.usage_service import usage_service
|
||||||
|
|
||||||
subscription_service.check_team_limit(db, store.id)
|
limit_check = usage_service.check_limit(db, store.id, "team_members")
|
||||||
|
if limit_check.limit is not None and not limit_check.can_proceed:
|
||||||
|
raise TierLimitExceededException(
|
||||||
|
message=limit_check.message or "Team member limit reached",
|
||||||
|
limit_type="team_members",
|
||||||
|
current=limit_check.current,
|
||||||
|
limit=limit_check.limit,
|
||||||
|
)
|
||||||
|
except TierLimitExceededException:
|
||||||
|
raise
|
||||||
|
except Exception as e: # noqa: EXC003
|
||||||
|
logger.warning(f"Could not check team limit (proceeding): {e}")
|
||||||
|
|
||||||
# Check if user already exists
|
# Check if user already exists
|
||||||
user = db.query(User).filter(User.email == email).first()
|
user = db.query(User).filter(User.email == email).first()
|
||||||
@@ -331,8 +343,9 @@ class StoreTeamService:
|
|||||||
if store_user.is_owner:
|
if store_user.is_owner:
|
||||||
raise CannotRemoveOwnerException(user_id, store.id)
|
raise CannotRemoveOwnerException(user_id, store.id)
|
||||||
|
|
||||||
# Soft delete - just deactivate
|
from app.core.soft_delete import soft_delete
|
||||||
store_user.is_active = False
|
|
||||||
|
soft_delete(db, store_user, deleted_by_id=actor_user_id)
|
||||||
|
|
||||||
logger.info(f"Removed user {user_id} from store {store.store_code}")
|
logger.info(f"Removed user {user_id} from store {store.store_code}")
|
||||||
|
|
||||||
@@ -438,6 +451,60 @@ class StoreTeamService:
|
|||||||
logger.error(f"Error updating member role: {str(e)}")
|
logger.error(f"Error updating member role: {str(e)}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
def update_member(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
store: Store,
|
||||||
|
user_id: int,
|
||||||
|
role_id: int | None = None,
|
||||||
|
is_active: bool | None = None,
|
||||||
|
actor_user_id: int | None = None,
|
||||||
|
) -> StoreUser:
|
||||||
|
"""
|
||||||
|
Update a team member's role (by ID) and/or active status.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
store: Store
|
||||||
|
user_id: User ID
|
||||||
|
role_id: New role ID (must belong to this store)
|
||||||
|
is_active: New active status
|
||||||
|
actor_user_id: Actor performing the update
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated StoreUser
|
||||||
|
"""
|
||||||
|
store_user = (
|
||||||
|
db.query(StoreUser)
|
||||||
|
.filter(StoreUser.store_id == store.id, StoreUser.user_id == user_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not store_user:
|
||||||
|
raise UserNotFoundException(str(user_id))
|
||||||
|
|
||||||
|
if role_id is not None:
|
||||||
|
role = (
|
||||||
|
db.query(Role)
|
||||||
|
.filter(Role.id == role_id, Role.store_id == store.id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not role:
|
||||||
|
raise InvalidRoleException(f"Role {role_id} not found in store {store.id}")
|
||||||
|
self.update_member_role(
|
||||||
|
db=db,
|
||||||
|
store=store,
|
||||||
|
user_id=user_id,
|
||||||
|
new_role_name=role.name,
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_active is not None:
|
||||||
|
store_user.is_active = is_active
|
||||||
|
|
||||||
|
db.flush()
|
||||||
|
db.refresh(store_user)
|
||||||
|
return store_user
|
||||||
|
|
||||||
def get_team_members(
|
def get_team_members(
|
||||||
self,
|
self,
|
||||||
db: Session,
|
db: Session,
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ function storeTeam() {
|
|||||||
this.saving = true;
|
this.saving = true;
|
||||||
try {
|
try {
|
||||||
await apiClient.put(
|
await apiClient.put(
|
||||||
`/store/${this.storeCode}/team/members/${this.selectedMember.user_id}`,
|
`/store/team/members/${this.selectedMember.id}`,
|
||||||
this.editForm
|
this.editForm
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -228,7 +228,7 @@ function storeTeam() {
|
|||||||
|
|
||||||
this.saving = true;
|
this.saving = true;
|
||||||
try {
|
try {
|
||||||
await apiClient.delete(`/store/team/members/${this.selectedMember.user_id}`);
|
await apiClient.delete(`/store/team/members/${this.selectedMember.id}`);
|
||||||
|
|
||||||
Utils.showToast(I18n.t('tenancy.messages.team_member_removed'), 'success');
|
Utils.showToast(I18n.t('tenancy.messages.team_member_removed'), 'success');
|
||||||
storeTeamLog.info('Removed team member:', this.selectedMember.user_id);
|
storeTeamLog.info('Removed team member:', this.selectedMember.user_id);
|
||||||
|
|||||||
@@ -299,7 +299,7 @@
|
|||||||
<select x-model="store.role_name"
|
<select x-model="store.role_name"
|
||||||
class="px-2 py-1 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none">
|
class="px-2 py-1 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none">
|
||||||
<template x-for="role in roleOptions" :key="role.value">
|
<template x-for="role in roleOptions" :key="role.value">
|
||||||
<option :value="role.value" x-text="role.label"></option>
|
<option :value="role.value" :selected="role.value === store.role_name" x-text="role.label"></option>
|
||||||
</template>
|
</template>
|
||||||
</select>
|
</select>
|
||||||
<button @click="updateMemberRole(store.store_id, selectedMember.user_id, store.role_name)"
|
<button @click="updateMemberRole(store.store_id, selectedMember.user_id, store.role_name)"
|
||||||
|
|||||||
@@ -77,7 +77,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||||
<template x-for="member in members" :key="member.user_id">
|
<template x-for="member in members" :key="member.id">
|
||||||
<tr class="text-gray-700 dark:text-gray-400">
|
<tr class="text-gray-700 dark:text-gray-400">
|
||||||
<!-- Member Info -->
|
<!-- Member Info -->
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
@@ -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"
|
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"
|
||||||
>
|
>
|
||||||
<template x-for="role in roles" :key="role.id">
|
<template x-for="role in roles" :key="role.id">
|
||||||
<option :value="role.id" x-text="role.name"></option>
|
<option :value="role.id" :selected="role.id === editForm.role_id" x-text="role.name"></option>
|
||||||
</template>
|
</template>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -243,17 +243,29 @@ class TestStoreTeamServiceRemove:
|
|||||||
"""Test suite for removing team members."""
|
"""Test suite for removing team members."""
|
||||||
|
|
||||||
def test_remove_team_member_success(self, db, team_store, team_member):
|
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(
|
result = store_team_service.remove_team_member(
|
||||||
db=db,
|
db=db,
|
||||||
store=team_store,
|
store=team_store,
|
||||||
user_id=team_member.user_id,
|
user_id=team_member.user_id,
|
||||||
)
|
)
|
||||||
db.commit()
|
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 result is True
|
||||||
assert team_member.is_active is False
|
|
||||||
|
|
||||||
def test_remove_owner_raises_error(self, db, team_store, store_owner):
|
def test_remove_owner_raises_error(self, db, team_store, store_owner):
|
||||||
"""Test removing owner raises exception."""
|
"""Test removing owner raises exception."""
|
||||||
|
|||||||
Reference in New Issue
Block a user