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)
|
invite.email_sent_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
created.append(invite)
|
created.append(invite)
|
||||||
|
except HTTPException as exc:
|
||||||
|
failed.append({"email": invite_data.email, "error": exc.detail})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
failed.append({"email": invite_data.email, "error": str(e)})
|
failed.append({"email": invite_data.email, "error": str(e)})
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import string
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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)
|
seat_result = await check_seat_available(acct, sub, invite_record.role, db)
|
||||||
if not seat_result.available:
|
if not seat_result.available:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=402,
|
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||||
detail={
|
detail={
|
||||||
"code": "seat_limit_exceeded",
|
"code": "seat_limit_exceeded",
|
||||||
"role": seat_result.role,
|
"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
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_invite_grandfathered_account_blocks_new_invites(client: AsyncClient, test_db: AsyncSession):
|
async def test_invite_grandfathered_account_blocks_new_invites(client: AsyncClient, test_db: AsyncSession):
|
||||||
"""Grandfathering: existing over-seated account keeps existing users but
|
"""Grandfathering: existing over-seated account keeps existing users but
|
||||||
|
|||||||
Reference in New Issue
Block a user