| """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 |
| |
| 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 |
|
|