Python 64.6%
TypeScript 33.7%
CSS 0.8%
1"""Invitation → choose password → sign-in → forgot password, with Resend mocked (respx)."""23from __future__ import annotations45import json6import os78import httpx9import pytest10import respx1112os.environ["RESEND_API_KEY"] = "re_test"13os.environ["RESEND_BASE_URL"] = "https://resend.test"14os.environ["APP_URL"] = "https://chat.test"15os.environ["PROFESSOR_EMAILS"] = "prof@uqo.ca"16os.environ["INVITED_EMAILS"] = ""1718from app.core.config import get_settings # noqa: E40219from app.db import init_db # noqa: E40220from app.main import app # noqa: E40221from app.services import users # noqa: E402222324@pytest.fixture(scope="module", autouse=True)25async def _db() -> None:26 get_settings.cache_clear()27 await init_db()282930@pytest.fixture31async def client() -> httpx.AsyncClient:32 async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://t") as c:33 yield c343536async def _professor(client: httpx.AsyncClient) -> dict[str, str]:37 settings = get_settings()38 prof = await users.get_or_create_user("prof@uqo.ca", settings)39 await users.set_password(prof.id, "prof-secret-1")40 r = await client.post("/api/v1/auth/login", json={"email": "prof@uqo.ca", "password": "prof-secret-1"})41 assert r.status_code == 200, r.text42 return {"Authorization": f"Bearer {r.json()['token']}"}434445def _link_from(payload: dict) -> str:46 return payload["html"].split('href="')[1].split('"')[0]474849@pytest.mark.asyncio50async def test_invite_set_password_login_and_forgot(client: httpx.AsyncClient) -> None:51 headers = await _professor(client)52 with respx.mock(base_url="https://resend.test") as mock:53 batch = mock.post("/emails/batch").mock(return_value=httpx.Response(200, json={"data": [{"id": "1"}, {"id": "2"}]}))54 single = mock.post("/emails").mock(return_value=httpx.Response(200, json={"id": "3"}))5556 # 1. professor registers two students → one batch call, both invited57 r = await client.post("/api/v1/professor/students", headers=headers,58 json={"emails": "Marie Tremblay <tremblay.marie@uqo.ca>\nlebel.paul@uqo.ca"})59 assert r.status_code == 201, r.text60 body = r.json()61 assert sorted(body["created"]) == ["lebel.paul@uqo.ca", "tremblay.marie@uqo.ca"]62 assert sorted(body["invited"]) == ["lebel.paul@uqo.ca", "tremblay.marie@uqo.ca"]63 assert batch.call_count == 164 sent = json.loads(batch.calls[0].request.content)65 assert len(sent) == 2 and sent[0]["from"].endswith("<no-reply@uqo-chat.app>")66 assert "Bonjour Marie Tremblay" in sent[0]["html"]67 link = _link_from(sent[0])68 assert link.startswith("https://chat.test/mot-de-passe?token=")69 token = link.split("token=")[1]7071 # invited_at stamped, no password yet72 rows = (await client.get("/api/v1/professor/students", headers=headers)).json()73 marie = next(u for u in rows["users"] if u["email"] == "tremblay.marie@uqo.ca")74 assert marie["invited_at"] and not marie["has_password"]75 assert rows["pending"] >= 2 and rows["mail"] is True7677 # 2. password login refused before activation, with an explicit hint78 r = await client.post("/api/v1/auth/login", json={"email": "tremblay.marie@uqo.ca", "password": "whatever1"})79 assert r.status_code == 401 and "pas encore de mot de passe" in r.json()["detail"]8081 # 3. token info, then set password (too short → 400; ok → session)82 r = await client.get("/api/v1/auth/password-token", params={"token": token})83 assert r.status_code == 200 and r.json()["first_time"] is True84 r = await client.post("/api/v1/auth/set-password", json={"token": token, "password": "short"})85 assert r.status_code == 40086 r = await client.post("/api/v1/auth/set-password", json={"token": token, "password": "Marie-2026!"})87 assert r.status_code == 200, r.text88 assert r.json()["user"]["has_password"] is True89 # token single-use90 r = await client.post("/api/v1/auth/set-password", json={"token": token, "password": "Marie-2026!"})91 assert r.status_code == 4009293 # 4. sign in with the password94 r = await client.post("/api/v1/auth/login", json={"email": "tremblay.marie@uqo.ca", "password": "Marie-2026!"})95 assert r.status_code == 20096 me = await client.get("/api/v1/me", headers={"Authorization": f"Bearer {r.json()['token']}"})97 assert me.json()["email"] == "tremblay.marie@uqo.ca"9899 # 5. forgot password → single e-mail with a reset link that changes the password100 r = await client.post("/api/v1/auth/forgot", json={"email": "tremblay.marie@uqo.ca"})101 assert r.status_code == 200 and r.json()["sent"] is True and r.json()["first_time"] is False102 assert single.call_count == 1103 reset_link = _link_from(json.loads(single.calls[0].request.content))104 r = await client.post("/api/v1/auth/set-password", json={"token": reset_link.split("token=")[1], "password": "Nouveau-2026!"})105 assert r.status_code == 200106 assert (await client.post("/api/v1/auth/login", json={"email": "tremblay.marie@uqo.ca", "password": "Marie-2026!"})).status_code == 401107 assert (await client.post("/api/v1/auth/login", json={"email": "tremblay.marie@uqo.ca", "password": "Nouveau-2026!"})).status_code == 200108109 # 6. unknown @uqo.ca address is refused (only the professor's list may sign in)110 r = await client.post("/api/v1/auth/forgot", json={"email": "inconnu@uqo.ca"})111 assert r.status_code == 404 and "pas inscrite" in r.json()["detail"]112 r = await client.post("/api/v1/auth/forgot", json={"email": "x@gmail.com"})113 assert r.status_code == 404114115 # 7. invite-all only targets accounts without a password never invited before116 r = await client.post("/api/v1/professor/students/invite-all", headers=headers, json={"only_never_invited": True})117 assert r.status_code == 200 and r.json()["total"] == 0118 r = await client.post("/api/v1/professor/students/invite-all", headers=headers, json={"only_never_invited": False})119 assert r.status_code == 200 and "lebel.paul@uqo.ca" in r.json()["sent"]120 assert "tremblay.marie@uqo.ca" not in r.json()["sent"]121122 # 8. per-student resend returns the link too123 paul = next(u for u in rows["users"] if u["email"] == "lebel.paul@uqo.ca")124 r = await client.post(f"/api/v1/professor/students/{paul['id']}/invite", headers=headers)125 assert r.status_code == 200 and r.json()["sent"] is True and "/mot-de-passe?token=" in r.json()["link"]126127128@pytest.mark.asyncio129async def test_batch_failure_degrades_to_unit_sends(client: httpx.AsyncClient) -> None:130 headers = await _professor(client)131 with respx.mock(base_url="https://resend.test") as mock:132 mock.post("/emails/batch").mock(return_value=httpx.Response(422, json={"message": "bad"}))133 single = mock.post("/emails").mock(side_effect=[httpx.Response(200, json={"id": "a"}),134 httpx.Response(403, json={"message": "no"})])135 r = await client.post("/api/v1/professor/students", headers=headers,136 json={"emails": ["ok@uqo.ca", "ko@uqo.ca"]})137 assert r.status_code == 201138 assert r.json()["invited"] == ["ok@uqo.ca"] and r.json()["invite_failed"] == ["ko@uqo.ca"]139 assert single.call_count == 2140