elemes/services/tests/test_repositories.py

667 lines
24 KiB
Python

"""
Integration test repository round-trip siswa (butuh PostgreSQL — skip bila
DATABASE_URL tidak diset). Mengunci:
- export: student-only, deterministic, token kosong, tanpa metadata internal;
- import: create (siswa baru) + restore/update (siswa existing), all-or-nothing,
digest-only, sparse progress, race-safe;
- bulk delete: atomic, cascade, teacher terlindungi, UUID/token dapat dipakai
ulang untuk re-create.
"""
import os
import uuid as uuid_lib
from pathlib import Path
import pytest
from sqlalchemy import func, select
from services import repositories as repo
from services.database import SessionLocal
from services.models import AccessToken, StudentProgress, User
from services.progress_status import ParsedProgress
from services.student_roundtrip import (
RoundTripImportError,
StudentRoundTripRow,
parse_roundtrip_csv,
)
from services.token_hashing import hash_token
from services.tests.conftest import STUDENT2_TOKEN, STUDENT_TOKEN, TEACHER_TOKEN
DB_REQUIRED = os.environ.get("DATABASE_URL", "").strip()
pytestmark = pytest.mark.skipif(not DB_REQUIRED, reason="butuh DATABASE_URL (PostgreSQL nyata)")
FIXTURES = Path(__file__).parent / "fixtures"
UUID_1 = "7eab651c-5eb1-4eb8-8fd2-17fd77aec6df"
# ── helper ─────────────────────────────────────────────────────────
def _seed_lesson(db, slug="hello_world", order=0):
return repo.upsert_lesson(
db, slug=slug, title=slug.replace("_", " ").title(), order_index=order
)
def _seed_student(db, token, name="Budi Santoso", role="student"):
user = repo.create_user(db, display_name=name, role=role)
repo.create_access_token(db, user_id=user.id, raw_token=token)
return user
def _make_row(
student_id,
token,
name,
progress=None,
line=2,
):
return StudentRoundTripRow(
line=line,
student_id=student_id,
raw_token=token,
display_name=name,
progress=progress or {},
)
def _counts(db):
return {
"users": db.scalar(select(func.count()).select_from(User)) or 0,
"tokens": db.scalar(select(func.count()).select_from(AccessToken)) or 0,
"progress": db.scalar(select(func.count()).select_from(StudentProgress)) or 0,
}
# ── export: list_students_for_export ───────────────────────────────
def test_list_students_for_export_student_only():
db = SessionLocal()
try:
_seed_student(db, TEACHER_TOKEN, "Pak Guru", role="teacher")
s1 = _seed_student(db, STUDENT_TOKEN, "Budi Santoso")
s2 = _seed_student(db, STUDENT2_TOKEN, "Siti Aminah")
users = repo.list_students_for_export(db)
assert {u.id for u in users} == {s1.id, s2.id} # teacher tidak ikut
finally:
db.close()
def test_export_selection_rejects_invalid_ids():
db = SessionLocal()
try:
s = _seed_student(db, STUDENT_TOKEN, "Budi")
teacher = _seed_student(db, TEACHER_TOKEN, "Pak Guru", role="teacher")
with pytest.raises(ValueError):
repo.list_students_for_export(db, [str(uuid_lib.uuid4())]) # unknown
with pytest.raises(ValueError):
repo.list_students_for_export(db, [teacher.id]) # teacher ID
with pytest.raises(ValueError):
repo.list_students_for_export(db, [s.id, s.id]) # duplikat
with pytest.raises(ValueError):
repo.list_students_for_export(db, ["bukan-uuid"]) # malformed
with pytest.raises(ValueError):
repo.list_students_for_export(db, [s.id] * 1001) # melebihi 1000
finally:
db.close()
def test_export_selection_subset_and_order():
db = SessionLocal()
try:
_seed_lesson(db, "hello_world", 0)
s1 = _seed_student(db, STUDENT_TOKEN, "Budi")
s2 = _seed_student(db, STUDENT2_TOKEN, "Siti")
selected = repo.list_students_for_export(db, [s2.id])
assert [u.id for u in selected] == [s2.id]
all_users = repo.list_students_for_export(db)
assert [u.id for u in all_users] == sorted([s1.id, s2.id]) # deterministic by (created_at, id)
finally:
db.close()
# ── export: serialize ──────────────────────────────────────────────
def test_export_csv_content_and_no_leak():
db = SessionLocal()
try:
lesson = _seed_lesson(db, "hello_world", 0)
lesson2 = _seed_lesson(db, "variabel", 1)
teacher = _seed_student(db, TEACHER_TOKEN, "Pak Guru", role="teacher")
s1 = _seed_student(db, STUDENT_TOKEN, "Budi Santoso")
repo.set_progress(db, user_id=s1.id, lesson_id=lesson.id, state="completed")
repo.set_progress(db, user_id=s1.id, lesson_id=lesson2.id, state="scored", score_earned=3, score_total=4)
csv_bytes = repo.export_students_csv(db)
text = csv_bytes.decode("utf-8-sig")
assert csv_bytes.startswith("\ufeff".encode("utf-8"))
lines = text.strip().splitlines()
assert lines[0] == "student_id;token;nama_siswa;hello_world;variabel"
assert len(lines) == 2 # hanya 1 siswa (teacher tidak ikut)
cols = lines[1].split(";")
assert cols[0] == s1.id
assert cols[1] == "" # token selalu kosong
assert cols[2] == "Budi Santoso"
assert cols[3] == "completed"
assert cols[4] == "3/4"
# tanpa metadata internal / hash / teacher / completed_count
for forbidden in ("completed_count", "token_hash", "digest", teacher.id, TEACHER_TOKEN):
assert forbidden not in text
finally:
db.close()
def test_export_empty_db_header_only():
db = SessionLocal()
try:
csv_bytes = repo.export_students_csv(db)
text = csv_bytes.decode("utf-8-sig")
assert text.strip() == "student_id;token;nama_siswa"
finally:
db.close()
def test_export_does_not_modify_database():
db = SessionLocal()
try:
_seed_student(db, STUDENT_TOKEN, "Budi")
before = _counts(db)
repo.export_students_csv(db)
assert _counts(db) == before
finally:
db.close()
# ── import: create-user & transaksi ────────────────────────────────
def test_create_user_with_explicit_id():
db = SessionLocal()
try:
user = repo.create_user(db, display_name="X", role="student", user_id=UUID_1)
assert user.id == UUID_1
assert db.get(User, UUID_1) is not None
finally:
db.close()
def test_import_generates_canonical_uuids_for_new_students():
db = SessionLocal()
try:
_seed_lesson(db, "hello_world", 0)
rows = [
_make_row(None, "TOKEN_12345678", "Siswa Baru"),
_make_row(None, "TOKEN_87654321", "Siswa Baru 2"),
]
result = repo.run_student_import(db, rows)
assert result["students_created"] == 2
first = repo.find_user_by_raw_token(db, "TOKEN_12345678")
assert first is not None
assert str(uuid_lib.UUID(first.id)) == first.id # canonical UUID server-side
assert first.role == "student"
finally:
db.close()
def test_import_all_users_are_students():
db = SessionLocal()
try:
rows = [_make_row(None, f"TOKEN_AKHIR_{i:04d}", f"Siswa {i}") for i in range(3)]
repo.run_student_import(db, rows)
users = db.scalars(select(User)).all()
assert len(users) == 3
assert all(u.role == "student" for u in users)
finally:
db.close()
def test_import_stores_digest_not_plaintext():
db = SessionLocal()
try:
rows = [_make_row(None, "TOKEN_12345678", "Budi")]
repo.run_student_import(db, rows)
digest = hash_token("TOKEN_12345678")
assert db.scalar(select(AccessToken.id).where(AccessToken.token_hash == digest)) is not None
assert db.scalar(select(func.count()).select_from(AccessToken).where(AccessToken.token_hash == "TOKEN_12345678")) == 0
finally:
db.close()
def test_import_sparse_progress_and_counts():
db = SessionLocal()
try:
hello = _seed_lesson(db, "hello_world", 0)
var = _seed_lesson(db, "variabel", 1)
rows = [
_make_row(
None,
"TOKEN_12345678",
"Budi",
progress={
"hello_world": ParsedProgress("completed"),
"variabel": ParsedProgress("scored", 3, 4),
},
),
_make_row(None, "TOKEN_87654321", "Siti"), # tanpa progress
]
result = repo.run_student_import(db, rows)
assert result["progress_created"] == 2
user = repo.find_user_by_raw_token(db, "TOKEN_12345678")
assert db.scalar(select(func.count()).select_from(StudentProgress).where(StudentProgress.user_id == user.id)) == 2
scored = db.scalar(
select(StudentProgress).where(StudentProgress.user_id == user.id, StudentProgress.lesson_id == var.id)
)
assert scored.state == "scored" and scored.score_earned == 3 and scored.score_total == 4
# sparse: Siti tidak punya row progress sama sekali
siti = repo.find_user_by_raw_token(db, "TOKEN_87654321")
assert db.scalar(select(func.count()).select_from(StudentProgress).where(StudentProgress.user_id == siti.id)) == 0
_ = hello # dipakai di assertion count di atas
finally:
db.close()
# ── import: conflict & all-or-nothing ──────────────────────────────
def test_import_rejects_new_token_for_existing_student():
db = SessionLocal()
try:
existing = _seed_student(db, STUDENT_TOKEN, "Budi")
rows = [_make_row(existing.id, "TOKEN_99999999", "Budi Baru")]
with pytest.raises(RoundTripImportError) as exc:
repo.run_student_import(db, rows)
assert any("tidak boleh menerima token baru" in e for e in exc.value.errors)
assert _counts(db)["users"] == 1 # zero write
finally:
db.close()
def test_import_fails_if_teacher_id_exists():
db = SessionLocal()
try:
teacher = _seed_student(db, TEACHER_TOKEN, "Pak Guru", role="teacher")
rows = [_make_row(teacher.id, "TOKEN_99999999", "Hacker")]
with pytest.raises(RoundTripImportError):
repo.run_student_import(db, rows)
assert _counts(db)["users"] == 1
finally:
db.close()
def test_import_fails_if_token_exists_including_revoked():
db = SessionLocal()
try:
existing = _seed_student(db, STUDENT_TOKEN, "Budi")
rows = [_make_row(None, STUDENT_TOKEN, "Peniru")] # token sama, UUID berbeda
with pytest.raises(RoundTripImportError) as exc:
repo.run_student_import(db, rows)
assert any("token sudah terdaftar" in e for e in exc.value.errors)
# token revoked pun tetap menabrak (guard terhadap reuse token)
repo.revoke_token(db, user_id=existing.id, raw_token=STUDENT_TOKEN)
db.commit()
with pytest.raises(RoundTripImportError):
repo.run_student_import(db, rows)
finally:
db.close()
def test_import_mixed_new_and_existing_zero_write():
db = SessionLocal()
try:
existing = _seed_student(db, STUDENT_TOKEN, "Budi")
before = _counts(db)
rows = [
_make_row(None, "TOKEN_11111111", "Siswa Baru 1"),
_make_row(existing.id, "TOKEN_22222222", "Siswa Baru 2"), # menabrak
]
with pytest.raises(RoundTripImportError):
repo.run_student_import(db, rows)
assert _counts(db) == before # tidak ada satupun yang dibuat
finally:
db.close()
def test_import_same_file_twice_fails_second_time():
db = SessionLocal()
try:
_seed_lesson(db, "hello_world", 0)
rows = [_make_row(None, "TOKEN_12345678", "Budi")]
first = repo.run_student_import(db, rows)
assert first["students_created"] == 1
# upload ulang file yang sama → seluruh import ditolak
with pytest.raises(RoundTripImportError):
repo.run_student_import(db, rows)
assert _counts(db)["users"] == 1
finally:
db.close()
def test_import_rollback_on_mid_exception():
db = SessionLocal()
try:
rows = [
_make_row(None, "TOKEN_12345678", "Budi"),
_make_row(None, "TOKEN_87654321", "Siti", progress={"tidak_ada_di_db": ParsedProgress("completed")}),
]
with pytest.raises(ValueError):
repo.run_student_import(db, rows)
# rollback penuh — user pertama pun tidak tersimpan
assert _counts(db)["users"] == 0
assert _counts(db)["tokens"] == 0
finally:
db.close()
def test_import_race_preview_apply_conflict():
db = SessionLocal()
try:
rows = [_make_row(None, "TOKEN_12345678", "Budi")]
# preview sukses (tidak ada conflict)
assert repo.preview_student_import(db, rows)["conflicts"] == []
# antara preview & apply, siswa lain dibuat dengan token sama
other = SessionLocal()
try:
repo.run_student_import(other, rows)
finally:
other.close()
# apply harus memvalidasi ulang → gagal, zero write
with pytest.raises(RoundTripImportError):
repo.run_student_import(db, rows)
assert _counts(db)["users"] == 1
finally:
db.close()
def test_preview_student_import_summary():
db = SessionLocal()
try:
rows = [
_make_row(None, "TOKEN_12345678", "Budi", progress={"a": ParsedProgress("completed")}),
_make_row(None, "TOKEN_87654321", "Siti"),
]
summary = repo.preview_student_import(db, rows)
assert summary == {
"rows": 2,
"students_to_create": 2,
"students_to_update": 0,
"progress_to_create": 1,
"progress_to_restore": 0,
"conflicts": [],
}
finally:
db.close()
# ── import: restore/update siswa existing (token kosong) ──────────
def test_import_existing_with_empty_token_restores_without_new_token():
db = SessionLocal()
try:
hello = _seed_lesson(db, "hello_world", 0)
existing = _seed_student(db, STUDENT_TOKEN, "Budi Santoso")
repo.set_progress(db, user_id=existing.id, lesson_id=hello.id, state="completed")
db.commit()
before = _counts(db)
# hasil export: student_id terisi + token kosong → restore/update
rows = [
_make_row(
existing.id,
"",
"Budi Update",
progress={"hello_world": ParsedProgress("scored", 3, 4)},
)
]
result = repo.run_student_import(db, rows)
assert result == {
"students_created": 0,
"students_updated": 1,
"progress_created": 0,
"progress_restored": 1,
"user_ids": [existing.id],
}
# TIDAK ada user/token/progress baru — hanya update nama + restore progress
assert _counts(db) == before
user = db.get(User, existing.id)
assert user is not None and user.display_name == "Budi Update"
# token lama tetap valid dan tidak dibuat ulang/ditimpa
assert repo.find_user_by_raw_token(db, STUDENT_TOKEN) is not None
assert (
db.scalar(
select(func.count())
.select_from(AccessToken)
.where(AccessToken.user_id == existing.id)
)
== 1
)
restored = db.scalar(
select(StudentProgress).where(
StudentProgress.user_id == existing.id, StudentProgress.lesson_id == hello.id
)
)
assert restored.state == "scored"
assert restored.score_earned == 3 and restored.score_total == 4
finally:
db.close()
def test_import_unknown_student_id_with_empty_token_rejected():
db = SessionLocal()
try:
rows = [_make_row(str(uuid_lib.uuid4()), "", "Siswa Hantu")]
with pytest.raises(RoundTripImportError) as exc:
repo.run_student_import(db, rows)
assert any("tidak dikenal" in e for e in exc.value.errors)
assert _counts(db)["users"] == 0 # zero write
finally:
db.close()
def test_import_teacher_with_empty_token_rejected():
db = SessionLocal()
try:
teacher = _seed_student(db, TEACHER_TOKEN, "Pak Guru", role="teacher")
rows = [_make_row(teacher.id, "", "Hacker")]
with pytest.raises(RoundTripImportError) as exc:
repo.run_student_import(db, rows)
assert any("teacher" in e for e in exc.value.errors)
assert _counts(db)["users"] == 1 # zero write
finally:
db.close()
def test_import_mixed_existing_restore_and_new_student_atomic():
db = SessionLocal()
try:
hello = _seed_lesson(db, "hello_world", 0)
existing = _seed_student(db, STUDENT_TOKEN, "Budi")
rows = [
_make_row(
existing.id,
"",
"Budi Update",
progress={"hello_world": ParsedProgress("completed")},
),
_make_row(None, "TOKEN_BARU_12345678", "Siswa Baru"),
]
result = repo.run_student_import(db, rows)
assert result["students_created"] == 1
assert result["students_updated"] == 1
# satu transaksi: keduanya tersimpan bersama
assert db.get(User, existing.id).display_name == "Budi Update"
baru = repo.find_user_by_raw_token(db, "TOKEN_BARU_12345678")
assert baru is not None and baru.role == "student"
assert repo.find_user_by_raw_token(db, STUDENT_TOKEN) is not None # token lama utuh
finally:
db.close()
def test_preview_reports_existing_as_update_restore():
db = SessionLocal()
try:
existing = _seed_student(db, STUDENT_TOKEN, "Budi")
rows = [
_make_row(existing.id, "", "Budi", progress={"a": ParsedProgress("completed")}),
_make_row(None, "TOKEN_12345678", "Baru", progress={"b": ParsedProgress("completed")}),
]
summary = repo.preview_student_import(db, rows)
assert summary == {
"rows": 2,
"students_to_create": 1,
"students_to_update": 1,
"progress_to_create": 1,
"progress_to_restore": 1,
"conflicts": [],
}
finally:
db.close()
# ── bulk delete ────────────────────────────────────────────────────
def test_delete_students_cascades():
db = SessionLocal()
try:
lesson = _seed_lesson(db, "hello_world", 0)
s1 = _seed_student(db, STUDENT_TOKEN, "Budi")
s2 = _seed_student(db, STUDENT2_TOKEN, "Siti")
repo.set_progress(db, user_id=s1.id, lesson_id=lesson.id, state="completed")
deleted = repo.delete_students(db, [s1.id, s2.id])
assert set(deleted) == {s1.id, s2.id}
db.commit()
assert db.get(User, s1.id) is None
assert db.get(User, s2.id) is None
# cascade: token & progress ikut terhapus
assert db.scalar(select(func.count()).select_from(AccessToken)) == 0
assert db.scalar(select(func.count()).select_from(StudentProgress)) == 0
# lesson tetap ada
assert db.get(lesson.__class__, lesson.id) is not None
finally:
db.close()
def test_delete_rejects_invalid_and_zero_delete():
db = SessionLocal()
try:
s = _seed_student(db, STUDENT_TOKEN, "Budi")
teacher = _seed_student(db, TEACHER_TOKEN, "Pak Guru", role="teacher")
db.commit() # seeding aman dari rollback percobaan delete di bawah
for bad in (
[str(uuid_lib.uuid4())], # unknown
[teacher.id], # teacher
[s.id, s.id], # duplikat
["malformed"], # non-UUID
[], # kosong
[s.id] * 1001,
):
with pytest.raises(ValueError):
repo.delete_students(db, bad)
db.rollback()
assert db.get(User, s.id) is not None # tidak ada yang terhapus
assert db.get(User, teacher.id) is not None
finally:
db.close()
# ── round-trip: export → delete → import ───────────────────────────
def test_roundtrip_export_restore_delete_recreate():
db = SessionLocal()
try:
hello = _seed_lesson(db, "hello_world", 0)
var = _seed_lesson(db, "variabel", 1)
s1 = _seed_student(db, STUDENT_TOKEN, "Budi Santoso")
s2 = _seed_student(db, STUDENT2_TOKEN, "Siti Aminah")
repo.set_progress(db, user_id=s1.id, lesson_id=hello.id, state="completed")
repo.set_progress(db, user_id=s1.id, lesson_id=var.id, state="scored", score_earned=3, score_total=4)
# 1) export selection → verifikasi token kosong & progress benar
csv_bytes = repo.export_students_csv(db, [s1.id])
active_slugs = ["hello_world", "variabel"]
text = csv_bytes.decode("utf-8-sig")
lines = text.strip().splitlines()
assert lines[0] == "student_id;token;nama_siswa;hello_world;variabel"
cols = lines[1].split(";")
assert cols[0] == s1.id and cols[1] == "" # token kosong
assert cols[2] == "Budi Santoso" and cols[3] == "completed" and cols[4] == "3/4"
# 2) export mentah (token kosong) KINI dapat langsung diimport sebagai
# restore/update — tanpa membuat user/token baru.
rows_export = parse_roundtrip_csv(csv_bytes, active_slugs)
assert len(rows_export) == 1
assert rows_export[0].student_id == s1.id
assert rows_export[0].raw_token == ""
restore_result = repo.run_student_import(db, rows_export)
assert restore_result["students_updated"] == 1
assert restore_result["students_created"] == 0
assert repo.find_user_by_raw_token(db, STUDENT_TOKEN) is not None
# 3) mengisi token pada baris existing → ditolak (siswa existing tidak
# boleh menerima token baru)
filled = text.replace(
f"{s1.id};;Budi Santoso", f"{s1.id};TOKEN_BARU_12345678;Budi Santoso"
)
rows = parse_roundtrip_csv(filled, active_slugs)
assert len(rows) == 1
assert rows[0].student_id == s1.id
assert rows[0].raw_token == "TOKEN_BARU_12345678"
with pytest.raises(RoundTripImportError) as exc:
repo.run_student_import(db, rows)
assert any("tidak boleh menerima token baru" in e for e in exc.value.errors)
# 4) delete → UUID lama tidak dikenal; import ulang dengan UUID lama
# DITOLAK (tidak ada create-with-ID untuk UUID tak dikenal)
repo.delete_students(db, [s1.id])
db.commit()
with pytest.raises(RoundTripImportError) as exc:
repo.run_student_import(db, rows)
assert any("tidak dikenal" in e for e in exc.value.errors)
# 5) siswa baru dibuat dengan student_id KOSONG → UUID baru dari server
rows_new = parse_roundtrip_csv(
text.replace(f"{s1.id};;Budi Santoso", ";TOKEN_BARU_12345678;Budi Santoso"),
active_slugs,
)
assert rows_new[0].student_id is None
result = repo.run_student_import(db, rows_new)
assert result["students_created"] == 1
recreated = repo.find_user_by_raw_token(db, "TOKEN_BARU_12345678")
assert recreated is not None
assert recreated.id != s1.id # UUID baru, bukan UUID lama
assert recreated.display_name == "Budi Santoso"
progress = {
p.lesson_id: p for p in repo.list_progress_for_user(db, user_id=recreated.id)
}
assert progress[hello.id].state == "completed"
assert progress[var.id].state == "scored"
assert progress[var.id].score_earned == 3 and progress[var.id].score_total == 4
# token lama sudah tidak berlaku
assert repo.find_user_by_raw_token(db, STUDENT_TOKEN) is None
# 6) upload file yang sama lagi → ditolak
with pytest.raises(RoundTripImportError):
repo.run_student_import(db, rows_new)
finally:
db.close()