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