From e54d6c586a592b43585e71610574ee741ae3ce57 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 6 May 2026 15:07:29 -0400 Subject: [PATCH] feat(invites): wire EmailService.send_account_invite_email into create handler Co-Authored-By: Claude Opus 4.7 --- backend/app/api/endpoints/accounts.py | 20 ++++++- .../tests/test_account_invite_extensions.py | 54 +++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_account_invite_extensions.py diff --git a/backend/app/api/endpoints/accounts.py b/backend/app/api/endpoints/accounts.py index b7910ca2..f8dc3744 100644 --- a/backend/app/api/endpoints/accounts.py +++ b/backend/app/api/endpoints/accounts.py @@ -260,7 +260,7 @@ async def create_invite( db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(require_account_owner)] ): - """Create an invite to join this account (owner only).""" + """Create an invite to join this account (owner only). Sends invite email.""" code = secrets.token_urlsafe(16) expires_at = None @@ -276,6 +276,24 @@ async def create_invite( expires_at=expires_at, ) db.add(invite) + await db.flush() + + # Lookup account name for email + account_result = await db.execute( + select(Account).where(Account.id == current_user.account_id) + ) + account = account_result.scalar_one() + + # Send invite email — non-blocking on failure (function returns False on error) + email_sent = await EmailService.send_account_invite_email( + to_email=invite.email, + code=code, + account_name=account.name, + role=invite.role, + ) + if email_sent: + invite.email_sent_at = datetime.now(timezone.utc) + await db.commit() await db.refresh(invite) return invite diff --git a/backend/tests/test_account_invite_extensions.py b/backend/tests/test_account_invite_extensions.py new file mode 100644 index 00000000..994903bb --- /dev/null +++ b/backend/tests/test_account_invite_extensions.py @@ -0,0 +1,54 @@ +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