"""R4: email case-normalisation at the auth API boundary. Storage/lookup (`SQLiteUserRepository.get_by_email`) is case-sensitive, so without normalisation `Foo@x.se` and `foo@x.se` would register as two distinct accounts. `RegisterRequest`/`LoginRequest` (`app/models/auth.py`) now lowercase `email` via a field validator — these tests drive the real app over HTTP (same technique as `tests/integration/test_user_journey.py`) to prove that boundary actually normalises, rather than only unit-testing the validator in isolation. """ from __future__ import annotations import aiosqlite import pytest import pytest_asyncio from httpx import ASGITransport, AsyncClient from app.dependencies import get_connection from app.main import app from app.rate_limit import limiter _PASSWORD = "correct horse battery staple" @pytest.fixture(autouse=True) def _reset_rate_limiter() -> None: """`/api/auth/register` and `/api/auth/login` are limited to 10/minute, keyed on client IP (`app/rate_limit.py`). In the full suite, every test file that POSTs to either endpoint shares that one in-memory bucket for 127.0.0.1 (see `tests/integration/test_user_journey.py`'s comment on the same issue) — by the time this file runs, other files may have already spent it. Reset the limiter's storage before each test here rather than disabling enforcement, so these tests still exercise the real middleware stack; this is exactly `Limiter.reset()`'s documented purpose. """ limiter.reset() @pytest_asyncio.fixture async def client(connection: aiosqlite.Connection) -> AsyncClient: """Real app over HTTP, with `get_connection` swapped for the in-memory test connection — see `tests/integration/test_user_journey.py`'s fixture of the same name.""" app.dependency_overrides[get_connection] = lambda: connection async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: yield ac app.dependency_overrides.pop(get_connection, None) async def test_register_lowercases_email(client: AsyncClient) -> None: resp = await client.post( "/api/auth/register", json={"email": "Mixed.Case@Example.COM", "password": _PASSWORD, "display_name": "A"}, ) assert resp.status_code == 201, resp.text async def test_login_with_different_case_resolves_to_same_account(client: AsyncClient) -> None: register_resp = await client.post( "/api/auth/register", json={"email": "Case.Test@Example.com", "password": _PASSWORD, "display_name": "A"}, ) assert register_resp.status_code == 201, register_resp.text login_resp = await client.post( "/api/auth/login", json={"email": "CASE.TEST@EXAMPLE.COM", "password": _PASSWORD}, ) assert login_resp.status_code == 200, login_resp.text assert "access_token" in login_resp.json() async def test_registering_same_address_in_different_case_is_rejected_as_duplicate( client: AsyncClient, ) -> None: first = await client.post( "/api/auth/register", json={"email": "dupe@example.com", "password": _PASSWORD, "display_name": "First"}, ) assert first.status_code == 201, first.text second = await client.post( "/api/auth/register", json={"email": "DUPE@EXAMPLE.COM", "password": _PASSWORD, "display_name": "Second"}, ) assert second.status_code == 409, second.text async def test_context_includes_take_pl_on_red_days_default_false(client: AsyncClient) -> None: """H12 step 1: `/auth/context` must carry the same red-days flag `GET /workspace/:id` does, so the frontend can drop that second cold-load call. A freshly registered workspace defaults to `False`.""" register_resp = await client.post( "/api/auth/register", json={"email": "red-days@example.com", "password": _PASSWORD, "display_name": "A"}, ) assert register_resp.status_code == 201, register_resp.text headers = {"Authorization": f"Bearer {register_resp.json()['access_token']}"} ctx_resp = await client.get("/api/auth/context", headers=headers) assert ctx_resp.status_code == 200, ctx_resp.text assert ctx_resp.json()["take_pl_on_red_days"] is False async def test_context_reflects_take_pl_on_red_days_after_workspace_update( client: AsyncClient, ) -> None: """Once the workspace's flag is flipped via `PUT /workspace/:id`, the context endpoint must reflect that new value, not a stale default — this is what `Settings.tsx`'s red-days toggle relies on after it saves.""" register_resp = await client.post( "/api/auth/register", json={"email": "red-days-2@example.com", "password": _PASSWORD, "display_name": "A"}, ) assert register_resp.status_code == 201, register_resp.text headers = {"Authorization": f"Bearer {register_resp.json()['access_token']}"} ctx_resp = await client.get("/api/auth/context", headers=headers) assert ctx_resp.status_code == 200, ctx_resp.text workspace_id = ctx_resp.json()["workspace_id"] update_resp = await client.put( f"/api/workspace/{workspace_id}", headers=headers, json={"take_pl_on_red_days": True}, ) assert update_resp.status_code == 200, update_resp.text ctx_resp_2 = await client.get("/api/auth/context", headers=headers) assert ctx_resp_2.status_code == 200, ctx_resp_2.text assert ctx_resp_2.json()["take_pl_on_red_days"] is True