"""Self-service integration tests. Covers profile updates, password changes, and account deletion. """ import pytest from tests.integration.client.base import ApiError from gatehouse_app.utils.constants import OrganizationRole def assert_success(response: dict, message_contains: str = "") -> dict: data = response.get("data", {}) assert response.get("success") is not False if message_contains: assert message_contains.lower() in response.get("message", "").lower() return data class TestSelfService: """Test user self-service features.""" def test_get_profile_positive(self, integration_client, create_test_user): """TEST: SELF-01 — Get own profile. WHAT: Login and GET /users/me. WHY: Profile page displays user info. EXPECTED: 200 OK with user data, has_password, totp_enabled. """ user = create_test_user(password="MyPassword123!") integration_client.auth.login(email=user["email"], password="MyPassword123!") result = integration_client.users.get_profile() data = assert_success(result) assert "user" in data assert data["user"]["email"] == user["email"] def test_update_profile_positive(self, integration_client, create_test_user): """TEST: SELF-02 — Update profile (full_name, avatar_url). WHAT: PATCH /users/me with new full_name. WHY: Users need to update their display name. EXPECTED: 200 OK, name updated. """ user = create_test_user(password="MyPassword123!") integration_client.auth.login(email=user["email"], password="MyPassword123!") result = integration_client.users.update_profile(full_name="Updated Name") data = assert_success(result) assert data["user"]["full_name"] == "Updated Name" def test_change_password_positive(self, integration_client, create_test_user): """TEST: SELF-03 — Change password with correct current password. WHAT: POST /users/me/password with current + new password. WHY: Users must be able to rotate their passwords. EXPECTED: 200 OK. Login with new password succeeds. """ user = create_test_user(password="MyPassword123!") integration_client.auth.login(email=user["email"], password="MyPassword123!") result = integration_client.users.change_password( current_password="MyPassword123!", new_password="NewPass456!", new_password_confirm="NewPass456!", ) assert_success(result) # Verify login with new password integration_client.auth.logout() login_result = integration_client.auth.login(email=user["email"], password="NewPass456!") assert_success(login_result, "login successful") def test_change_password_verify_login_positive(self, integration_client, create_test_user): """TEST: SELF-04 — Verify login with new password after change. WHAT: Change password, logout, login with new password. WHY: Ensures the password change actually persisted. EXPECTED: Login succeeds. """ user = create_test_user(password="MyPassword123!") integration_client.auth.login(email=user["email"], password="MyPassword123!") integration_client.users.change_password( current_password="MyPassword123!", new_password="NewPass456!", new_password_confirm="NewPass456!", ) integration_client.auth.logout() result = integration_client.auth.login(email=user["email"], password="NewPass456!") assert_success(result) def test_change_password_wrong_current_negative(self, integration_client, create_test_user): """TEST: SELF-05 — Change password with wrong current password. WHAT: POST /users/me/password with incorrect current password. WHY: Prevents account takeover if session is compromised. EXPECTED: 401 Unauthorized. Token must NOT be cleared. """ user = create_test_user(password="MyPassword123!") integration_client.auth.login(email=user["email"], password="MyPassword123!") with pytest.raises(ApiError) as exc_info: integration_client.users.change_password( current_password="WrongPassword!", new_password="NewPass456!", new_password_confirm="NewPass456!", ) assert exc_info.value.status_code == 401 # Token should still be valid me = integration_client.auth.me() assert_success(me) def test_change_password_mismatched_negative(self, integration_client, create_test_user): """TEST: SELF-06 — Change password with mismatched new passwords. WHAT: new_password and new_password_confirm differ. WHY: Typo protection. EXPECTED: 400 Bad Request. """ user = create_test_user(password="MyPassword123!") integration_client.auth.login(email=user["email"], password="MyPassword123!") with pytest.raises(ApiError) as exc_info: integration_client.users.change_password( current_password="MyPassword123!", new_password="NewPass456!", new_password_confirm="DifferentPass789!", ) assert exc_info.value.status_code == 400 def test_delete_account_positive(self, integration_client, create_test_user): """TEST: SELF-07 — Delete own account (no orgs with members). WHAT: Create a user with no org memberships, then DELETE /users/me. WHY: Users have the right to delete their data. EXPECTED: 200 OK. Subsequent login fails. """ user = create_test_user(password="MyPassword123!") integration_client.auth.login(email=user["email"], password="MyPassword123!") result = integration_client.users.delete_account() assert_success(result) # Token is invalidated by account deletion; do not call logout. integration_client.clear_token() with pytest.raises(ApiError) as exc_info: integration_client.auth.login(email=user["email"], password="MyPassword123!") assert exc_info.value.status_code in (400, 401) def test_delete_account_as_owner_with_members_negative(self, integration_client, create_test_user, create_test_org, create_test_membership): """TEST: SELF-09 — Reject deleting account when owner of org with members. WHAT: User is owner of an org that has other members. Attempt DELETE /users/me. WHY: Prevents orphaning organizations. EXPECTED: 409 Conflict, error about ownership transfer. """ owner = create_test_user(password="OwnerPass123!") member = create_test_user(password="MemberPass123!") org = create_test_org() create_test_membership(owner["id"], org["id"], OrganizationRole.OWNER) create_test_membership(member["id"], org["id"], OrganizationRole.MEMBER) integration_client.auth.login(email=owner["email"], password="OwnerPass123!") with pytest.raises(ApiError) as exc_info: integration_client.users.delete_account() assert exc_info.value.status_code == 409