DocDoeAI / tests /test_hardening_sync.py
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
3bcdb36
Raw
History Blame Contribute Delete
6.98 kB
"""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