171 lines
5.7 KiB
Python
171 lines
5.7 KiB
Python
"""
|
|
CSV importer — parse & validasi (unit, host) + import ke PostgreSQL
|
|
(integrasi — skip bila DATABASE_URL tidak diset).
|
|
"""
|
|
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from services.csv_importer import (
|
|
parse_csv,
|
|
run_import,
|
|
validate_and_plan,
|
|
)
|
|
|
|
FIXTURES = Path(__file__).parent / "fixtures"
|
|
DB_REQUIRED = os.environ.get("DATABASE_URL", "").strip()
|
|
|
|
|
|
# ── parse ──────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_parse_valid_fixture():
|
|
rows = parse_csv(FIXTURES / "tokens_valid.csv")
|
|
assert len(rows) == 3
|
|
assert rows[0]["token"] == "TOKEN_GURU_001"
|
|
assert rows[1]["nama_siswa"] == "Budi Santoso"
|
|
|
|
|
|
def test_parse_missing_file():
|
|
with pytest.raises(FileNotFoundError):
|
|
parse_csv(FIXTURES / "tidak_ada.csv")
|
|
|
|
|
|
def test_parse_empty_csv(tmp_path):
|
|
f = tmp_path / "kosong.csv"
|
|
f.write_text("", encoding="utf-8")
|
|
with pytest.raises(ValueError):
|
|
parse_csv(f)
|
|
|
|
|
|
# ── validasi & plan ────────────────────────────────────────────────
|
|
|
|
|
|
def test_plan_valid_fixture():
|
|
plan = validate_and_plan(parse_csv(FIXTURES / "tokens_valid.csv"))
|
|
assert plan.ok
|
|
assert len(plan.users) == 3
|
|
assert plan.users[0].role == "teacher"
|
|
assert plan.users[1].role == "student"
|
|
assert plan.lesson_slugs == ["hello_world", "quiz_test", "variabel"]
|
|
|
|
budi = plan.users[1]
|
|
assert budi.progress["hello_world"].state == "completed"
|
|
assert budi.progress["quiz_test"].state == "scored"
|
|
assert budi.progress["quiz_test"].score_earned == 3
|
|
assert budi.progress["quiz_test"].score_total == 4
|
|
assert budi.progress["variabel"].state == "not_started"
|
|
|
|
|
|
def test_plan_invalid_fixture_errors():
|
|
plan = validate_and_plan(parse_csv(FIXTURES / "tokens_invalid.csv"))
|
|
assert not plan.ok
|
|
joined = "\n".join(plan.errors)
|
|
# status tidak dikenal, token duplikat, nama kosong, skor earned>total, token kosong
|
|
assert "selesai" in joined
|
|
assert "Duplikat" in joined or "duplikat" in joined
|
|
assert "nama_siswa kosong" in joined
|
|
assert "4/2" in joined
|
|
assert "token kosong" in joined
|
|
|
|
|
|
def test_plan_empty_rows():
|
|
plan = validate_and_plan([])
|
|
assert not plan.ok
|
|
|
|
|
|
def test_plan_dry_run_no_write(monkeypatch):
|
|
plan = validate_and_plan(parse_csv(FIXTURES / "tokens_valid.csv"))
|
|
called = []
|
|
|
|
class FakeSession:
|
|
def commit(self):
|
|
called.append("commit")
|
|
|
|
def rollback(self):
|
|
called.append("rollback")
|
|
|
|
report = run_import(FakeSession(), plan, dry_run=True)
|
|
assert called == []
|
|
assert report.users_created == 3
|
|
# sparse model: not_started/blank TIDAK membuat progress row
|
|
# (guru 3 + budi 2 + siti 0 = 5 materialized)
|
|
assert report.progress_upserted == 5
|
|
|
|
|
|
# ── integrasi (PostgreSQL nyata) ───────────────────────────────────
|
|
|
|
|
|
@pytest.mark.skipif(not DB_REQUIRED, reason="butuh DATABASE_URL (PostgreSQL nyata)")
|
|
def test_import_to_db_and_idempotent():
|
|
from services import repositories
|
|
from services.database import SessionLocal
|
|
from services.token_hashing import hash_token
|
|
|
|
plan = validate_and_plan(parse_csv(FIXTURES / "tokens_valid.csv"))
|
|
db = SessionLocal()
|
|
try:
|
|
first = run_import(db, plan, dry_run=False)
|
|
assert first.ok, first.errors
|
|
assert first.users_created == 3
|
|
assert first.tokens_created == 3
|
|
assert first.lessons_created == 3
|
|
# sparse: 5 progress rows materialized (bukan 9 — not_started tidak di-insert)
|
|
assert first.progress_upserted == 5
|
|
|
|
# legacy '3/4' tersimpan terstruktur
|
|
user = repositories.find_user_by_raw_token(db, "TOKEN_SISWA_001")
|
|
assert user is not None and user.role == "student"
|
|
teacher = repositories.find_user_by_raw_token(db, "TOKEN_GURU_001")
|
|
assert teacher is not None and teacher.role == "teacher"
|
|
|
|
# idempotent: run kedua tidak bikin duplikat
|
|
second = run_import(db, plan, dry_run=False)
|
|
assert second.ok, second.errors
|
|
assert second.users_created == 0
|
|
assert second.tokens_created == 0
|
|
assert second.lessons_created == 0
|
|
assert second.progress_upserted == 5
|
|
|
|
from sqlalchemy import func, select
|
|
|
|
from services.models import AccessToken, StudentProgress, User
|
|
|
|
assert db.scalar(select(func.count()).select_from(User)) == 3
|
|
assert db.scalar(select(func.count()).select_from(AccessToken)) == 3
|
|
assert db.scalar(select(func.count()).select_from(StudentProgress)) == 5
|
|
# sparse model: tidak boleh ada row state='not_started'
|
|
assert (
|
|
db.scalar(
|
|
select(func.count()).select_from(StudentProgress).where(
|
|
StudentProgress.state == "not_started"
|
|
)
|
|
)
|
|
== 0
|
|
)
|
|
|
|
scored = db.scalar(
|
|
select(StudentProgress).where(
|
|
StudentProgress.user_id == user.id,
|
|
StudentProgress.state == "scored",
|
|
)
|
|
)
|
|
assert scored is not None
|
|
assert scored.score_earned == 3 and scored.score_total == 4
|
|
|
|
# hash tersimpan, bukan plaintext
|
|
digest = hash_token("TOKEN_SISWA_001")
|
|
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_SISWA_001"
|
|
))
|
|
== 0
|
|
)
|
|
finally:
|
|
db.close()
|