SPB Git forge

spb/uqo-chat

Public
14commits 1branches 0releases
1.4 MBsize
maindefault branch
17 days agolast push
Python 64.6% TypeScript 33.7% CSS 0.8%
7.3 KB · 140 lines python
Raw Blame History
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