"""Tests for the /sync/progress endpoints. Verifies: - POST /sync/progress returns 200 with standard envelope - POST with empty events array returns 200 - GET /sync/progress returns persisted, account-scoped activity - repeated client event IDs are idempotent - Response shape matches {success, data, meta} """ from __future__ import annotations import sys from pathlib import Path import pytest BACKEND_DIR = Path(__file__).resolve().parents[1] if str(BACKEND_DIR) not in sys.path: sys.path.insert(0, str(BACKEND_DIR)) def test_post_sync_progress_returns_ok(client): resp = client.post( "/sync/progress", json={ "events": [ { "event_id": "activity-emi-1", "event_type": "study_session", "title": "Electromagnetic Induction study session", "topic": "Electromagnetic Induction", "subject": "Physics", "chapter": "Chapter 6", "score": 0.85, "duration_seconds": 1200, } ] }, ) assert resp.status_code == 200 body = resp.json() assert body["success"] is True assert "data" in body assert body["data"]["synced"] is True assert body["data"]["events_received"] == 1 assert body["data"]["events_stored"] == 1 assert "meta" in body assert "timestamp" in body["meta"] def test_post_sync_progress_empty_events(client): resp = client.post("/sync/progress", json={"events": []}) assert resp.status_code == 200 body = resp.json() assert body["success"] is True assert body["data"]["events_received"] == 0 assert "No events" in body["meta"].get("message", "") def test_post_sync_progress_multiple_events(client): events = [ {"event_type": "quiz_complete", "topic": "Photosynthesis", "score": 0.9}, {"event_type": "flashcard_review", "topic": "Photosynthesis"}, {"event_type": "study_session", "duration_seconds": 600}, ] resp = client.post("/sync/progress", json={"events": events}) assert resp.status_code == 200 assert resp.json()["data"]["events_received"] == 3 def test_post_sync_progress_invalid_score_rejected(client): """Score outside [0, 1] should fail validation.""" resp = client.post( "/sync/progress", json={"events": [{"event_type": "quiz_complete", "score": 1.5}]}, ) assert resp.status_code == 422 def test_post_sync_no_events_field_defaults_to_empty(client): """Missing 'events' field should default to empty list (not error).""" resp = client.post("/sync/progress", json={}) assert resp.status_code == 200 assert resp.json()["data"]["events_received"] == 0 def test_get_sync_progress_empty_state(client): resp = client.get("/sync/progress") assert resp.status_code == 200 body = resp.json() assert body["success"] is True assert "data" in body data = body["data"] assert "total_sessions" in data assert "total_events" in data assert data["total_sessions"] == 0 assert data["recent_events"] == [] def test_sync_response_has_meta_timestamp(client): resp = client.post("/sync/progress", json={"events": []}) meta = resp.json().get("meta", {}) assert "timestamp" in meta # Should be ISO-8601 format ts = meta["timestamp"] assert "T" in ts or ts.endswith("Z") def test_sync_user_id_in_response(client): """Response should include user_id (dev user when auth is disabled).""" resp = client.post( "/sync/progress", json={"events": [{"event_type": "study_session"}]}, ) data = resp.json()["data"] assert "user_id" in data assert data["user_id"] is not None def test_progress_event_persists_and_duplicate_retry_is_ignored(client): event = { "event_id": "act-duplicate-safe", "event_type": "lesson_completed", "title": "Sound Waves: reflection", "subject": "Physics", "chapter_id": "physics-sound-waves", "mission_id": "reflection", "occurred_at": "2026-07-29T08:00:00Z", } first = client.post("/sync/progress", json={"events": [event]}) second = client.post("/sync/progress", json={"events": [event]}) assert first.status_code == 200 assert first.json()["data"]["events_stored"] == 1 assert second.status_code == 200 assert second.json()["data"]["events_stored"] == 0 assert second.json()["data"]["duplicates"] == 1 summary = client.get("/sync/progress") assert summary.status_code == 200 data = summary.json()["data"] assert data["total_events"] == 1 assert data["by_type"] == {"lesson_completed": 1} assert data["recent_events"][0] == { "id": "act-duplicate-safe", "kind": "lesson_completed", "title": "Sound Waves: reflection", "subject": "Physics", "chapterId": "physics-sound-waves", "missionId": "reflection", "detail": None, "at": "2026-07-29T08:00:00", "provenance": None, "sample": False, } def _signup(client, email: str) -> str: response = client.post( "/auth/signup", json={"name": "Progress Student", "email": email, "password": "Pass123!beta"}, ) assert response.status_code == 201, response.text return response.json()["access_token"] def test_progress_summary_is_owner_scoped(auth_client): token_a = _signup(auth_client, "progress-a@example.test") token_b = _signup(auth_client, "progress-b@example.test") auth_a = {"Authorization": f"Bearer {token_a}"} auth_b = {"Authorization": f"Bearer {token_b}"} stored = auth_client.post( "/sync/progress", headers=auth_a, json={ "events": [ { "event_id": "same-device-event", "event_type": "note_saved", "title": "Physics formula notes", } ] }, ) assert stored.status_code == 200 assert stored.json()["data"]["events_stored"] == 1 summary_a = auth_client.get("/sync/progress", headers=auth_a).json()["data"] summary_b = auth_client.get("/sync/progress", headers=auth_b).json()["data"] assert summary_a["total_events"] == 1 assert summary_a["recent_events"][0]["title"] == "Physics formula notes" assert summary_b["total_events"] == 0 assert summary_b["recent_events"] == [] def test_sync_rejects_unknown_event_type_and_oversized_batch(client): unknown = client.post( "/sync/progress", json={"events": [{"event_type": "invented_progress"}]}, ) assert unknown.status_code == 422 oversized = client.post( "/sync/progress", json={ "events": [ {"event_type": "study_session"} for _ in range(101) ] }, ) assert oversized.status_code == 422