"""Invitation → choose password → sign-in → forgot password, with Resend mocked (respx).""" from __future__ import annotations import json import os import httpx import pytest import respx os.environ["RESEND_API_KEY"] = "re_test" os.environ["RESEND_BASE_URL"] = "https://resend.test" os.environ["APP_URL"] = "https://chat.test" os.environ["PROFESSOR_EMAILS"] = "prof@uqo.ca" os.environ["INVITED_EMAILS"] = "" from app.core.config import get_settings # noqa: E402 from app.db import init_db # noqa: E402 from app.main import app # noqa: E402 from app.services import users # noqa: E402 @pytest.fixture(scope="module", autouse=True) async def _db() -> None: get_settings.cache_clear() await init_db() @pytest.fixture async def client() -> httpx.AsyncClient: async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://t") as c: yield c async def _professor(client: httpx.AsyncClient) -> dict[str, str]: settings = get_settings() prof = await users.get_or_create_user("prof@uqo.ca", settings) await users.set_password(prof.id, "prof-secret-1") r = await client.post("/api/v1/auth/login", json={"email": "prof@uqo.ca", "password": "prof-secret-1"}) assert r.status_code == 200, r.text return {"Authorization": f"Bearer {r.json()['token']}"} def _link_from(payload: dict) -> str: return payload["html"].split('href="')[1].split('"')[0] @pytest.mark.asyncio async def test_invite_set_password_login_and_forgot(client: httpx.AsyncClient) -> None: headers = await _professor(client) with respx.mock(base_url="https://resend.test") as mock: batch = mock.post("/emails/batch").mock(return_value=httpx.Response(200, json={"data": [{"id": "1"}, {"id": "2"}]})) single = mock.post("/emails").mock(return_value=httpx.Response(200, json={"id": "3"})) # 1. professor registers two students → one batch call, both invited r = await client.post("/api/v1/professor/students", headers=headers, json={"emails": "Marie Tremblay \nlebel.paul@uqo.ca"}) assert r.status_code == 201, r.text body = r.json() assert sorted(body["created"]) == ["lebel.paul@uqo.ca", "tremblay.marie@uqo.ca"] assert sorted(body["invited"]) == ["lebel.paul@uqo.ca", "tremblay.marie@uqo.ca"] assert batch.call_count == 1 sent = json.loads(batch.calls[0].request.content) assert len(sent) == 2 and sent[0]["from"].endswith("") assert "Bonjour Marie Tremblay" in sent[0]["html"] link = _link_from(sent[0]) assert link.startswith("https://chat.test/mot-de-passe?token=") token = link.split("token=")[1] # invited_at stamped, no password yet rows = (await client.get("/api/v1/professor/students", headers=headers)).json() marie = next(u for u in rows["users"] if u["email"] == "tremblay.marie@uqo.ca") assert marie["invited_at"] and not marie["has_password"] assert rows["pending"] >= 2 and rows["mail"] is True # 2. password login refused before activation, with an explicit hint r = await client.post("/api/v1/auth/login", json={"email": "tremblay.marie@uqo.ca", "password": "whatever1"}) assert r.status_code == 401 and "pas encore de mot de passe" in r.json()["detail"] # 3. token info, then set password (too short → 400; ok → session) r = await client.get("/api/v1/auth/password-token", params={"token": token}) assert r.status_code == 200 and r.json()["first_time"] is True r = await client.post("/api/v1/auth/set-password", json={"token": token, "password": "short"}) assert r.status_code == 400 r = await client.post("/api/v1/auth/set-password", json={"token": token, "password": "Marie-2026!"}) assert r.status_code == 200, r.text assert r.json()["user"]["has_password"] is True # token single-use r = await client.post("/api/v1/auth/set-password", json={"token": token, "password": "Marie-2026!"}) assert r.status_code == 400 # 4. sign in with the password r = await client.post("/api/v1/auth/login", json={"email": "tremblay.marie@uqo.ca", "password": "Marie-2026!"}) assert r.status_code == 200 me = await client.get("/api/v1/me", headers={"Authorization": f"Bearer {r.json()['token']}"}) assert me.json()["email"] == "tremblay.marie@uqo.ca" # 5. forgot password → single e-mail with a reset link that changes the password r = await client.post("/api/v1/auth/forgot", json={"email": "tremblay.marie@uqo.ca"}) assert r.status_code == 200 and r.json()["sent"] is True and r.json()["first_time"] is False assert single.call_count == 1 reset_link = _link_from(json.loads(single.calls[0].request.content)) r = await client.post("/api/v1/auth/set-password", json={"token": reset_link.split("token=")[1], "password": "Nouveau-2026!"}) assert r.status_code == 200 assert (await client.post("/api/v1/auth/login", json={"email": "tremblay.marie@uqo.ca", "password": "Marie-2026!"})).status_code == 401 assert (await client.post("/api/v1/auth/login", json={"email": "tremblay.marie@uqo.ca", "password": "Nouveau-2026!"})).status_code == 200 # 6. unknown @uqo.ca address is refused (only the professor's list may sign in) r = await client.post("/api/v1/auth/forgot", json={"email": "inconnu@uqo.ca"}) assert r.status_code == 404 and "pas inscrite" in r.json()["detail"] r = await client.post("/api/v1/auth/forgot", json={"email": "x@gmail.com"}) assert r.status_code == 404 # 7. invite-all only targets accounts without a password never invited before r = await client.post("/api/v1/professor/students/invite-all", headers=headers, json={"only_never_invited": True}) assert r.status_code == 200 and r.json()["total"] == 0 r = await client.post("/api/v1/professor/students/invite-all", headers=headers, json={"only_never_invited": False}) assert r.status_code == 200 and "lebel.paul@uqo.ca" in r.json()["sent"] assert "tremblay.marie@uqo.ca" not in r.json()["sent"] # 8. per-student resend returns the link too paul = next(u for u in rows["users"] if u["email"] == "lebel.paul@uqo.ca") r = await client.post(f"/api/v1/professor/students/{paul['id']}/invite", headers=headers) assert r.status_code == 200 and r.json()["sent"] is True and "/mot-de-passe?token=" in r.json()["link"] @pytest.mark.asyncio async def test_batch_failure_degrades_to_unit_sends(client: httpx.AsyncClient) -> None: headers = await _professor(client) with respx.mock(base_url="https://resend.test") as mock: mock.post("/emails/batch").mock(return_value=httpx.Response(422, json={"message": "bad"})) single = mock.post("/emails").mock(side_effect=[httpx.Response(200, json={"id": "a"}), httpx.Response(403, json={"message": "no"})]) r = await client.post("/api/v1/professor/students", headers=headers, json={"emails": ["ok@uqo.ca", "ko@uqo.ca"]}) assert r.status_code == 201 assert r.json()["invited"] == ["ok@uqo.ca"] and r.json()["invite_failed"] == ["ko@uqo.ca"] assert single.call_count == 2