language-learning-app/tests/test_packs.py

421 lines
14 KiB
Python
Raw Normal View History

"""
End-to-end tests for the Word Bank Pack feature.
Admin endpoints require a user whose email matches ADMIN_USER_EMAILS (admin@test.com).
User endpoints require any authenticated user.
"""
import uuid
import httpx
import pytest
ADMIN_EMAIL = "admin@test.com"
ADMIN_PASSWORD = "adminpassword123"
USER_EMAIL = "packuser@example.com"
USER_PASSWORD = "userpassword123"
# ── Auth helpers ──────────────────────────────────────────────────────────────
def _register_and_login(client: httpx.Client, email: str, password: str) -> str:
"""Return a Bearer token for the given credentials, registering first if needed."""
client.post("/auth/register", json={"email": email, "password": password})
resp = client.post("/auth/login", json={"email": email, "password": password})
return resp.json()["access_token"]
@pytest.fixture
def admin_client(client: httpx.Client) -> httpx.Client:
token = _register_and_login(client, ADMIN_EMAIL, ADMIN_PASSWORD)
client.headers["Authorization"] = f"Bearer {token}"
return client
@pytest.fixture
def user_client(client: httpx.Client) -> httpx.Client:
token = _register_and_login(client, USER_EMAIL, USER_PASSWORD)
client.headers["Authorization"] = f"Bearer {token}"
return client
@pytest.fixture
def unauthed_client(client: httpx.Client) -> httpx.Client:
return client
# ── Admin: create / list / update / publish ───────────────────────────────────
def test_admin_creates_pack(admin_client: httpx.Client):
resp = admin_client.post(
"/api/admin/packs",
json={
"name": "Food & Drink",
"name_target": "La Nourriture et les Boissons",
"description": "Common food and drink vocabulary.",
"description_target": "Vocabulaire courant de nourriture et de boissons.",
"source_lang": "en",
"target_lang": "fr",
"proficiencies": ["A1", "A2"],
},
)
assert resp.status_code == 201
body = resp.json()
assert body["name"] == "Food & Drink"
assert body["is_published"] is False
assert body["proficiencies"] == ["A1", "A2"]
assert "id" in body
def test_non_admin_cannot_create_pack(user_client: httpx.Client):
resp = user_client.post(
"/api/admin/packs",
json={
"name": "Sneaky Pack",
"name_target": "Pack Sournois",
"description": "d",
"description_target": "d",
"source_lang": "en",
"target_lang": "fr",
"proficiencies": [],
},
)
assert resp.status_code == 403
def test_admin_lists_packs_including_unpublished(admin_client: httpx.Client):
admin_client.post(
"/api/admin/packs",
json={
"name": f"Draft Pack {uuid.uuid4()}",
"name_target": "Paquet Brouillon",
"description": "d",
"description_target": "d",
"source_lang": "en",
"target_lang": "fr",
"proficiencies": [],
},
)
resp = admin_client.get("/api/admin/packs")
assert resp.status_code == 200
packs = resp.json()
assert isinstance(packs, list)
assert len(packs) >= 1
def test_admin_updates_pack(admin_client: httpx.Client):
create_resp = admin_client.post(
"/api/admin/packs",
json={
"name": "Original Name",
"name_target": "Nom Original",
"description": "d",
"description_target": "d",
"source_lang": "en",
"target_lang": "fr",
"proficiencies": ["A1"],
},
)
pack_id = create_resp.json()["id"]
resp = admin_client.patch(
f"/api/admin/packs/{pack_id}",
json={"name": "Updated Name", "proficiencies": ["A1", "A2"]},
)
assert resp.status_code == 200
assert resp.json()["name"] == "Updated Name"
assert resp.json()["proficiencies"] == ["A1", "A2"]
def test_admin_publishes_pack(admin_client: httpx.Client):
create_resp = admin_client.post(
"/api/admin/packs",
json={
"name": "Soon Published",
"name_target": "Bientôt Publié",
"description": "d",
"description_target": "d",
"source_lang": "en",
"target_lang": "fr",
"proficiencies": [],
},
)
pack_id = create_resp.json()["id"]
resp = admin_client.post(f"/api/admin/packs/{pack_id}/publish")
assert resp.status_code == 200
assert resp.json()["is_published"] is True
# ── Admin: entries and flashcard templates ────────────────────────────────────
def _create_published_pack(admin_client: httpx.Client) -> str:
resp = admin_client.post(
"/api/admin/packs",
json={
"name": f"Test Pack {uuid.uuid4()}",
"name_target": "Paquet Test",
"description": "d",
"description_target": "d",
"source_lang": "en",
"target_lang": "fr",
"proficiencies": ["A1"],
},
)
pack_id = resp.json()["id"]
admin_client.post(f"/api/admin/packs/{pack_id}/publish")
return pack_id
def test_admin_adds_entry_to_pack(admin_client: httpx.Client):
pack_id = _create_published_pack(admin_client)
resp = admin_client.post(
f"/api/admin/packs/{pack_id}/entries",
json={"surface_text": "bonjour"},
)
assert resp.status_code == 201
assert resp.json()["surface_text"] == "bonjour"
assert resp.json()["pack_id"] == pack_id
def test_admin_adds_flashcard_template_to_entry(admin_client: httpx.Client):
pack_id = _create_published_pack(admin_client)
entry_resp = admin_client.post(
f"/api/admin/packs/{pack_id}/entries",
json={"surface_text": "aller"},
)
entry_id = entry_resp.json()["id"]
resp = admin_client.post(
f"/api/admin/packs/{pack_id}/entries/{entry_id}/flashcards",
json={
"prompt_text": "aller",
"answer_text": "to go",
"prompt_context_text": "il veut [aller] au cinéma",
"answer_context_text": "he wants [to go] to the cinema",
},
)
assert resp.status_code == 201
body = resp.json()
assert body["prompt_context_text"] == "il veut [aller] au cinéma"
def test_admin_get_pack_detail_includes_entries_and_templates(admin_client: httpx.Client):
pack_id = _create_published_pack(admin_client)
entry_resp = admin_client.post(
f"/api/admin/packs/{pack_id}/entries",
json={"surface_text": "maison"},
)
entry_id = entry_resp.json()["id"]
admin_client.post(
f"/api/admin/packs/{pack_id}/entries/{entry_id}/flashcards",
json={
"prompt_text": "house",
"answer_text": "maison",
},
)
resp = admin_client.get(f"/api/admin/packs/{pack_id}")
assert resp.status_code == 200
body = resp.json()
assert len(body["entries"]) == 1
assert body["entries"][0]["surface_text"] == "maison"
assert len(body["entries"][0]["flashcard_templates"]) == 1
def test_admin_removes_entry_from_pack(admin_client: httpx.Client):
pack_id = _create_published_pack(admin_client)
entry_resp = admin_client.post(
f"/api/admin/packs/{pack_id}/entries",
json={"surface_text": "chat"},
)
entry_id = entry_resp.json()["id"]
del_resp = admin_client.delete(f"/api/admin/packs/{pack_id}/entries/{entry_id}")
assert del_resp.status_code == 204
detail = admin_client.get(f"/api/admin/packs/{pack_id}")
assert all(e["id"] != entry_id for e in detail.json()["entries"])
# ── User: browse published packs ──────────────────────────────────────────────
def test_user_only_sees_published_packs(admin_client: httpx.Client, user_client: httpx.Client):
# Create and leave unpublished
admin_client.post(
"/api/admin/packs",
json={
"name": f"Hidden {uuid.uuid4()}",
"name_target": "Caché",
"description": "d",
"description_target": "d",
"source_lang": "en",
"target_lang": "fr",
"proficiencies": [],
},
)
# Create and publish
create_resp = admin_client.post(
"/api/admin/packs",
json={
"name": f"Visible {uuid.uuid4()}",
"name_target": "Visible",
"description": "d",
"description_target": "d",
"source_lang": "en",
"target_lang": "fr",
"proficiencies": [],
},
)
visible_id = create_resp.json()["id"]
admin_client.post(f"/api/admin/packs/{visible_id}/publish")
resp = user_client.get("/api/packs", params={"source_lang": "en", "target_lang": "fr"})
assert resp.status_code == 200
ids = [p["id"] for p in resp.json()]
assert visible_id in ids
def test_user_cannot_see_unpublished_pack_by_id(
admin_client: httpx.Client, user_client: httpx.Client
):
create_resp = admin_client.post(
"/api/admin/packs",
json={
"name": "Secret Draft",
"name_target": "Brouillon Secret",
"description": "d",
"description_target": "d",
"source_lang": "en",
"target_lang": "fr",
"proficiencies": [],
},
)
pack_id = create_resp.json()["id"]
resp = user_client.get(f"/api/packs/{pack_id}")
assert resp.status_code == 404
def test_user_sees_surface_texts_in_pack_detail(
admin_client: httpx.Client, user_client: httpx.Client
):
pack_id = _create_published_pack(admin_client)
admin_client.post(
f"/api/admin/packs/{pack_id}/entries", json={"surface_text": "chat"}
)
admin_client.post(
f"/api/admin/packs/{pack_id}/entries", json={"surface_text": "chien"}
)
resp = user_client.get(f"/api/packs/{pack_id}")
assert resp.status_code == 200
body = resp.json()
assert body["entry_count"] == 2
assert set(body["surface_texts"]) == {"chat", "chien"}
# ── User: add pack to bank ────────────────────────────────────────────────────
def _setup_fresh_user(client: httpx.Client) -> None:
"""Register and log in as a fresh user (sets Authorization header on client)."""
email = f"packtest-{uuid.uuid4()}@example.com"
client.post("/auth/register", json={"email": email, "password": "password123"})
token_resp = client.post("/auth/login", json={"email": email, "password": "password123"})
client.headers["Authorization"] = f"Bearer {token_resp.json()['access_token']}"
def test_add_pack_to_bank_creates_bank_entries(
admin_client: httpx.Client, client: httpx.Client
):
pack_id = _create_published_pack(admin_client)
admin_client.post(f"/api/admin/packs/{pack_id}/entries", json={"surface_text": "bonjour"})
admin_client.post(f"/api/admin/packs/{pack_id}/entries", json={"surface_text": "merci"})
_setup_fresh_user(client)
resp = client.post(
f"/api/packs/{pack_id}/add-to-bank",
json={"source_lang": "en", "target_lang": "fr"},
)
assert resp.status_code == 201
body = resp.json()
assert set(body["added"]) == {"bonjour", "merci"}
def test_add_unpublished_pack_to_bank_returns_404(
admin_client: httpx.Client, client: httpx.Client
):
create_resp = admin_client.post(
"/api/admin/packs",
json={
"name": "Draft Only",
"name_target": "Brouillon Seulement",
"description": "d",
"description_target": "d",
"source_lang": "en",
"target_lang": "fr",
"proficiencies": [],
},
)
pack_id = create_resp.json()["id"]
_setup_fresh_user(client)
resp = client.post(
f"/api/packs/{pack_id}/add-to-bank",
json={"source_lang": "en", "target_lang": "fr"},
)
assert resp.status_code == 404
def test_add_pack_duplicate_plain_card_returns_409(
admin_client: httpx.Client, client: httpx.Client
):
"""Adding a pack whose plain-card entry the user already has returns 409."""
pack_id = _create_published_pack(admin_client)
admin_client.post(f"/api/admin/packs/{pack_id}/entries", json={"surface_text": "maison"})
_setup_fresh_user(client)
# Add the pack once — succeeds
client.post(
f"/api/packs/{pack_id}/add-to-bank",
json={"source_lang": "en", "target_lang": "fr"},
)
# Add it again — same plain card, should 409
resp = client.post(
f"/api/packs/{pack_id}/add-to-bank",
json={"source_lang": "en", "target_lang": "fr"},
)
assert resp.status_code == 409
assert "maison" in resp.json()["detail"]
# ── BFF: pack selection screen ────────────────────────────────────────────────
def test_bff_packs_shows_already_added_flag(
admin_client: httpx.Client, client: httpx.Client
):
pack_id = _create_published_pack(admin_client)
admin_client.post(f"/api/admin/packs/{pack_id}/entries", json={"surface_text": "eau"})
_setup_fresh_user(client)
# Before adding
resp = client.get("/bff/packs", params={"source_lang": "en", "target_lang": "fr"})
assert resp.status_code == 200
pack_item = next((p for p in resp.json() if p["id"] == pack_id), None)
assert pack_item is not None
assert pack_item["already_added"] is False
# Add the pack
client.post(
f"/api/packs/{pack_id}/add-to-bank",
json={"source_lang": "en", "target_lang": "fr"},
)
# After adding
resp = client.get("/bff/packs", params={"source_lang": "en", "target_lang": "fr"})
pack_item = next((p for p in resp.json() if p["id"] == pack_id), None)
assert pack_item["already_added"] is True