diff --git a/backend/app/api/endpoints/accounts.py b/backend/app/api/endpoints/accounts.py index c4c37488..85dde423 100644 --- a/backend/app/api/endpoints/accounts.py +++ b/backend/app/api/endpoints/accounts.py @@ -395,6 +395,8 @@ async def create_invites_bulk( invite.email_sent_at = datetime.now(timezone.utc) created.append(invite) + except HTTPException as exc: + failed.append({"email": invite_data.email, "error": exc.detail}) except Exception as e: failed.append({"email": invite_data.email, "error": str(e)}) diff --git a/backend/app/api/endpoints/oauth.py b/backend/app/api/endpoints/oauth.py index d4ed3962..5ce3ef3f 100644 --- a/backend/app/api/endpoints/oauth.py +++ b/backend/app/api/endpoints/oauth.py @@ -3,7 +3,7 @@ import string from datetime import datetime, timezone from typing import Annotated -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -131,7 +131,7 @@ async def _sign_in_or_register( seat_result = await check_seat_available(acct, sub, invite_record.role, db) if not seat_result.available: raise HTTPException( - status_code=402, + status_code=status.HTTP_402_PAYMENT_REQUIRED, detail={ "code": "seat_limit_exceeded", "role": seat_result.role, diff --git a/backend/tests/test_invite_seat_enforcement.py b/backend/tests/test_invite_seat_enforcement.py index ddd11218..11609bfa 100644 --- a/backend/tests/test_invite_seat_enforcement.py +++ b/backend/tests/test_invite_seat_enforcement.py @@ -175,6 +175,39 @@ async def test_invite_unlimited_seat_limit_always_succeeds(client: AsyncClient, assert resp.status_code == 201, resp.text +@pytest.mark.asyncio +async def test_bulk_invite_per_row_402_preserves_structured_detail(client: AsyncClient, test_db: AsyncSession): + """Bulk invite returns 200 overall; rows that hit the seat limit appear in the + `failed` list with structured detail (not a stringified repr).""" + owner = await _register(client, email="owner_bulk@example.com") + account_id = uuid.UUID(owner["account_id"]) + headers = await _login(client, email="owner_bulk@example.com") + + # seat_limit=1, already 1 engineer → next engineer invite fails + await _set_sub(test_db, account_id, seat_limit=1) + await _add_member(test_db, account_id, role="engineer") + + resp = await client.post( + "/api/v1/accounts/me/invites/bulk", + json={"invites": [ + {"email": "viewer-ok@example.com", "role": "viewer"}, + {"email": "eng-blocked@example.com", "role": "engineer"}, + ]}, + headers=headers, + ) + assert resp.status_code in (200, 201), resp.text + body = resp.json() + assert len(body["created"]) == 1 + assert body["created"][0]["email"] == "viewer-ok@example.com" + assert len(body["failed"]) == 1 + failed_row = body["failed"][0] + assert failed_row["email"] == "eng-blocked@example.com" + # Structured detail preserved (dict, not repr string) + assert isinstance(failed_row["error"], dict) + assert failed_row["error"]["code"] == "seat_limit_exceeded" + assert failed_row["error"]["role"] == "engineer" + + @pytest.mark.asyncio async def test_invite_grandfathered_account_blocks_new_invites(client: AsyncClient, test_db: AsyncSession): """Grandfathering: existing over-seated account keeps existing users but