181 lines
5.8 KiB
Python
181 lines
5.8 KiB
Python
import pytest
|
|
from unittest.mock import AsyncMock, patch
|
|
from sqlalchemy import select
|
|
from app.models.account_invite import AccountInvite
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_invite_sends_email_and_stamps_email_sent_at(
|
|
client, test_db, test_user, auth_headers
|
|
):
|
|
"""Regression: today's create_invite does NOT send email. After this task, it MUST."""
|
|
with patch(
|
|
"app.core.email.EmailService.send_account_invite_email",
|
|
new_callable=AsyncMock, return_value=True,
|
|
) as mock_send:
|
|
response = await client.post(
|
|
"/api/v1/accounts/me/invites",
|
|
json={"email": "teammate@example.com", "role": "engineer"},
|
|
headers=auth_headers,
|
|
)
|
|
assert response.status_code == 201, response.json()
|
|
mock_send.assert_called_once()
|
|
kwargs = mock_send.call_args.kwargs
|
|
assert kwargs["to_email"] == "teammate@example.com"
|
|
assert kwargs["role"] == "engineer"
|
|
assert kwargs["code"]
|
|
|
|
invite = (await test_db.execute(
|
|
select(AccountInvite).where(AccountInvite.email == "teammate@example.com")
|
|
)).scalar_one()
|
|
assert invite.email_sent_at is not None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_invite_email_failure_still_creates_row(
|
|
client, test_db, test_user, auth_headers
|
|
):
|
|
"""When EmailService returns False, the invite row is still created but
|
|
email_sent_at remains NULL."""
|
|
with patch(
|
|
"app.core.email.EmailService.send_account_invite_email",
|
|
new_callable=AsyncMock, return_value=False,
|
|
):
|
|
response = await client.post(
|
|
"/api/v1/accounts/me/invites",
|
|
json={"email": "fail-mail@example.com", "role": "engineer"},
|
|
headers=auth_headers,
|
|
)
|
|
assert response.status_code == 201
|
|
|
|
invite = (await test_db.execute(
|
|
select(AccountInvite).where(AccountInvite.email == "fail-mail@example.com")
|
|
)).scalar_one()
|
|
assert invite.email_sent_at is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bulk_invite_creates_n_rows_and_sends_n_emails(
|
|
client, test_db, test_user, auth_headers
|
|
):
|
|
with patch(
|
|
"app.core.email.EmailService.send_account_invite_email",
|
|
new_callable=AsyncMock, return_value=True,
|
|
) as mock_send:
|
|
response = await client.post(
|
|
"/api/v1/accounts/me/invites/bulk",
|
|
json={"invites": [
|
|
{"email": "a@example.com", "role": "engineer"},
|
|
{"email": "b@example.com", "role": "engineer"},
|
|
{"email": "c@example.com", "role": "viewer"},
|
|
]},
|
|
headers=auth_headers,
|
|
)
|
|
assert response.status_code == 201, response.json()
|
|
body = response.json()
|
|
assert len(body["created"]) == 3
|
|
assert body["failed"] == []
|
|
assert mock_send.call_count == 3
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_revoke_invite_sets_revoked_at(client, test_db, test_user, auth_headers):
|
|
import uuid
|
|
from datetime import datetime, timezone, timedelta
|
|
from app.models.account_invite import AccountInvite
|
|
|
|
invited_by_id = uuid.UUID(test_user["user_data"]["id"])
|
|
account_id = uuid.UUID(test_user["user_data"]["account_id"])
|
|
|
|
invite = AccountInvite(
|
|
account_id=account_id,
|
|
invited_by_id=invited_by_id,
|
|
email="revoked@example.com",
|
|
code="REVOKEME01",
|
|
role="engineer",
|
|
expires_at=datetime.now(timezone.utc) + timedelta(days=7),
|
|
)
|
|
test_db.add(invite)
|
|
await test_db.commit()
|
|
invite_id = invite.id
|
|
|
|
response = await client.delete(
|
|
f"/api/v1/accounts/me/invites/{invite_id}",
|
|
headers=auth_headers,
|
|
)
|
|
assert response.status_code == 204
|
|
|
|
await test_db.refresh(invite)
|
|
assert invite.revoked_at is not None
|
|
assert invite.is_valid is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_revoke_invite_idempotent(client, test_db, test_user, auth_headers):
|
|
import uuid
|
|
from datetime import datetime, timezone, timedelta
|
|
from app.models.account_invite import AccountInvite
|
|
|
|
invited_by_id = uuid.UUID(test_user["user_data"]["id"])
|
|
account_id = uuid.UUID(test_user["user_data"]["account_id"])
|
|
|
|
invite = AccountInvite(
|
|
account_id=account_id,
|
|
invited_by_id=invited_by_id,
|
|
email="revoked2@example.com",
|
|
code="REVOKEME02",
|
|
role="engineer",
|
|
revoked_at=datetime.now(timezone.utc),
|
|
expires_at=datetime.now(timezone.utc) + timedelta(days=7),
|
|
)
|
|
test_db.add(invite)
|
|
await test_db.commit()
|
|
invite_id = invite.id
|
|
|
|
response = await client.delete(
|
|
f"/api/v1/accounts/me/invites/{invite_id}",
|
|
headers=auth_headers,
|
|
)
|
|
assert response.status_code == 204
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_revoke_invite_404_when_not_found(client, test_user, auth_headers):
|
|
import uuid
|
|
response = await client.delete(
|
|
f"/api/v1/accounts/me/invites/{uuid.uuid4()}",
|
|
headers=auth_headers,
|
|
)
|
|
assert response.status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_revoke_used_invite_returns_400(
|
|
client, test_db, test_user, auth_headers
|
|
):
|
|
import uuid
|
|
from datetime import datetime, timezone, timedelta
|
|
from app.models.account_invite import AccountInvite
|
|
|
|
invited_by_id = uuid.UUID(test_user["user_data"]["id"])
|
|
account_id = uuid.UUID(test_user["user_data"]["account_id"])
|
|
|
|
invite = AccountInvite(
|
|
account_id=account_id,
|
|
invited_by_id=invited_by_id,
|
|
email="used@example.com",
|
|
code="USEDCODE01",
|
|
role="engineer",
|
|
accepted_by_id=invited_by_id, # mark as used
|
|
expires_at=datetime.now(timezone.utc) + timedelta(days=7),
|
|
)
|
|
test_db.add(invite)
|
|
await test_db.commit()
|
|
invite_id = invite.id
|
|
|
|
response = await client.delete(
|
|
f"/api/v1/accounts/me/invites/{invite_id}",
|
|
headers=auth_headers,
|
|
)
|
|
assert response.status_code == 400
|