feat(backend): invite system + user management
- InviteToken model with unique token for each invite
- POST /users/invite - create invite by email with role (admin/mecanic)
- POST /auth/accept-invite - accept invite, set password, return JWT
- GET /users - list all users in tenant
- DELETE /users/{id} - deactivate user (cannot deactivate owner)
- Alembic migration for invites table
- 25 passing tests (auth + sync + orders + pdf + portal + invoices + users)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
111
backend/tests/test_users.py
Normal file
111
backend/tests/test_users.py
Normal file
@@ -0,0 +1,111 @@
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invite_and_accept_flow(client, auth_headers):
|
||||
# Invite a new user
|
||||
r = await client.post(
|
||||
"/api/users/invite",
|
||||
headers=auth_headers,
|
||||
json={"email": "mecanic@service.ro", "rol": "mecanic"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert "token" in data
|
||||
assert data["email"] == "mecanic@service.ro"
|
||||
invite_token = data["token"]
|
||||
|
||||
# Accept invite (creates user + returns JWT)
|
||||
r = await client.post(
|
||||
"/api/auth/accept-invite",
|
||||
json={"token": invite_token, "password": "mecanic123"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert "access_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
# New user can access /me
|
||||
new_headers = {"Authorization": f"Bearer {data['access_token']}"}
|
||||
r = await client.get("/api/auth/me", headers=new_headers)
|
||||
assert r.status_code == 200
|
||||
me = r.json()
|
||||
assert me["email"] == "mecanic@service.ro"
|
||||
assert me["rol"] == "mecanic"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_users(client, auth_headers):
|
||||
r = await client.get("/api/users", headers=auth_headers)
|
||||
assert r.status_code == 200
|
||||
users = r.json()
|
||||
assert len(users) >= 1
|
||||
assert users[0]["rol"] == "owner"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deactivate_user(client, auth_headers):
|
||||
# Invite and accept a user first
|
||||
r = await client.post(
|
||||
"/api/users/invite",
|
||||
headers=auth_headers,
|
||||
json={"email": "delete@service.ro", "rol": "mecanic"},
|
||||
)
|
||||
invite_token = r.json()["token"]
|
||||
await client.post(
|
||||
"/api/auth/accept-invite",
|
||||
json={"token": invite_token, "password": "pass123"},
|
||||
)
|
||||
|
||||
# List users to get the new user's id
|
||||
r = await client.get("/api/users", headers=auth_headers)
|
||||
users = r.json()
|
||||
mecanic = next(u for u in users if u["email"] == "delete@service.ro")
|
||||
|
||||
# Deactivate
|
||||
r = await client.delete(
|
||||
f"/api/users/{mecanic['id']}", headers=auth_headers
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["ok"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cannot_deactivate_owner(client, auth_headers):
|
||||
r = await client.get("/api/users", headers=auth_headers)
|
||||
users = r.json()
|
||||
owner = next(u for u in users if u["rol"] == "owner")
|
||||
|
||||
r = await client.delete(
|
||||
f"/api/users/{owner['id']}", headers=auth_headers
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_invite_token(client):
|
||||
r = await client.post(
|
||||
"/api/auth/accept-invite",
|
||||
json={"token": "invalid-token", "password": "pass123"},
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_invite(client, auth_headers):
|
||||
# Invite same email twice - first should succeed, second may fail if user exists
|
||||
await client.post(
|
||||
"/api/users/invite",
|
||||
headers=auth_headers,
|
||||
json={"email": "dup@service.ro", "rol": "mecanic"},
|
||||
)
|
||||
# Second invite for same email is allowed (user not created yet)
|
||||
r = await client.post(
|
||||
"/api/users/invite",
|
||||
headers=auth_headers,
|
||||
json={"email": "dup@service.ro", "rol": "admin"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
Reference in New Issue
Block a user