fix(l1): T8 review fixes — oauth status const + bulk-invite structured error
- oauth.py: use status.HTTP_402_PAYMENT_REQUIRED constant (was raw 402) - accounts.py bulk-invite: catch HTTPException separately to preserve structured detail dict in failed-row error (was stringified repr, unparseable by clients) - Add bulk-invite per-row 402 test verifying structured error preserved T8 code review identified these as Important issues. Functional change is the bulk-invite fix; clients can now parse seat-limit errors from bulk responses. 13/13 seat-enforcement tests pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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)})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user