elemes/services/tests/test_csv_importer.py

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()