elemes/services/tests/test_student_roundtrip.py

542 lines
20 KiB
Python

"""
Unit test parser/formatter round-trip CSV siswa (murni, tanpa database).
Mengunci:
- schema tunggal export/import (BOM, delimiter ';', kolom token kosong);
- validasi all-or-nothing (duplicate UUID/token, status, ukuran, dst.);
- token TIDAK pernah bocor ke error maupun repr row.
"""
from pathlib import Path
import pytest
from services.progress_status import (
ParsedProgress,
format_progress_status,
parse_progress_status,
)
from services.student_roundtrip import (
MAX_FILE_BYTES,
MAX_ROWS,
RoundTripImportError,
StudentRoundTripRow,
is_canonical_uuid,
mask_student_id,
parse_roundtrip_csv,
serialize_export_csv,
)
FIXTURES = Path(__file__).parent / "fixtures"
ACTIVE = ["hello_world", "variabel"]
UUID_1 = "7eab651c-5eb1-4eb8-8fd2-17fd77aec6df"
UUID_2 = "22222222-2222-4222-8222-222222222222"
# ── helper format status ───────────────────────────────────────────
def test_parse_progress_status_contract():
assert parse_progress_status("") == ("not_started", None, None)
assert parse_progress_status("not_started") == ("not_started", None, None)
assert parse_progress_status("completed") == ("completed", None, None)
assert parse_progress_status("3/4") == ("scored", 3, 4)
assert parse_progress_status("0/10") == ("scored", 0, 10)
def test_parse_progress_status_invalid():
for bad in ["selesai", "4/2", "abc", "1/0", "-1/5", "2/1/3", "a/b"]:
with pytest.raises(ValueError):
parse_progress_status(bad)
def test_format_progress_status_contract():
assert format_progress_status(None) == ""
assert format_progress_status(ParsedProgress("completed")) == "completed"
assert format_progress_status(ParsedProgress("scored", 3, 4)) == "3/4"
assert format_progress_status(("not_started", None, None)) == "not_started"
# ── serializer export ──────────────────────────────────────────────
def _sample_export_rows():
return [
StudentRoundTripRow(
line=0,
student_id=UUID_1,
raw_token="",
display_name="Nama Siswa 1",
progress={
"hello_world": ParsedProgress("completed"),
"variabel": ParsedProgress("scored", 3, 4),
},
),
StudentRoundTripRow(
line=0,
student_id=None,
raw_token="",
display_name="Siswa Baru",
progress={"variabel": ParsedProgress("completed")},
),
]
def test_export_serializer_bom_delimiter_and_header():
data = serialize_export_csv(_sample_export_rows(), ["hello_world", "variabel"])
text = data.decode("utf-8-sig")
assert not text.startswith("\ufeff") # BOM sudah dikonsumsi decode utf-8-sig
assert data.startswith("\ufeff".encode("utf-8")) # raw bytes membawa BOM
lines = text.strip().splitlines()
assert lines[0] == "student_id;token;nama_siswa;hello_world;variabel"
assert lines[1].startswith(f"{UUID_1};;Nama Siswa 1;completed;3/4")
# row 2: UUID kosong, token kosong, hello_world blank → ";;Siswa Baru;;completed"
assert lines[2] == ";;Siswa Baru;;completed"
def test_export_always_has_empty_token_column():
data = serialize_export_csv(_sample_export_rows(), ["hello_world"])
text = data.decode("utf-8-sig")
for line in text.strip().splitlines()[1:]:
cols = line.split(";")
assert cols[1] == "" # token selalu kosong tanpa pengecualian
def test_export_excludes_internal_fields():
text = serialize_export_csv(_sample_export_rows(), ["hello_world"]).decode("utf-8-sig")
assert "completed_count" not in text
assert "token_hash" not in text
assert "digest" not in text
assert "role" not in text
assert "teacher" not in text
# ── parser: valid ──────────────────────────────────────────────────
def test_parse_valid_fixture():
content = (FIXTURES / "student_roundtrip_valid.csv").read_bytes()
rows = parse_roundtrip_csv(content, ACTIVE)
assert len(rows) == 2
r1 = rows[0]
assert r1.student_id == UUID_1
assert r1.raw_token == "TOKEN_12345678"
assert r1.display_name == "Nama Siswa 1"
assert r1.progress["hello_world"].state == "completed"
assert r1.progress["variabel"] == ParsedProgress("scored", 3, 4)
r2 = rows[1]
assert r2.student_id is None # UUID kosong = siswa baru
# not_started tidak disimpan → sparse; variabel completed dipertahankan
assert "hello_world" not in r2.progress
assert r2.progress["variabel"].state == "completed"
def test_parse_subset_of_lesson_columns():
content = (
"student_id;token;nama_siswa;hello_world\n"
";TOKEN_12345678;Siswa Baru;completed\n"
)
rows = parse_roundtrip_csv(content, ["hello_world", "variabel"])
assert len(rows) == 1
# lesson yang tidak ada kolomnya → not_started (tidak di-progress)
assert "variabel" not in rows[0].progress
def test_parse_header_only_file_is_valid_empty():
content = "student_id;token;nama_siswa;hello_world\n"
rows = parse_roundtrip_csv(content, ACTIVE)
assert rows == []
def test_parse_ignores_empty_rows():
content = (
"student_id;token;nama_siswa;hello_world\n"
"\n"
";TOKEN_12345678;Siswa Baru;completed\n"
"\n"
)
rows = parse_roundtrip_csv(content, ACTIVE)
assert len(rows) == 1
# ── parser: token ──────────────────────────────────────────────────
def test_parse_rejects_empty_token():
bad = "student_id;token;nama_siswa;hello_world\n;;Nama;completed\n"
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv(bad, ACTIVE)
assert any("token wajib diisi" in e for e in exc.value.errors)
def test_parse_rejects_whitespace_and_control_chars_in_token():
cases = [
"student_id;token;nama_siswa;hello_world\n; TOKEN_1234;A;completed\n",
"student_id;token;nama_siswa;hello_world\n;TOKEN_12\n34;A;completed\n",
"student_id;token;nama_siswa;hello_world\n;abc;A;completed\n", # < 8 karakter
]
for content in cases:
with pytest.raises(RoundTripImportError):
parse_roundtrip_csv(content, ACTIVE)
def test_parse_rejects_token_too_long():
long_token = "T" * 129
content = f"student_id;token;nama_siswa;hello_world\n;{long_token};A;completed\n"
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv(content, ACTIVE)
assert any("128" in e for e in exc.value.errors)
def test_parse_rejects_duplicate_token_in_file():
content = (
"student_id;token;nama_siswa;hello_world\n"
f";TOKEN_12345678;A;completed\n"
f";TOKEN_12345678;B;completed\n"
)
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv(content, ACTIVE)
assert any("duplikat" in e.lower() for e in exc.value.errors)
# ── parser: token kosong untuk baris existing (hasil export) ───────
def test_parse_accepts_existing_student_id_with_empty_token():
# Baris hasil export: student_id terisi + token kosong → diterima
# (siswa existing dipulihkan/di-update; token lama dipertahankan).
content = (
"student_id;token;nama_siswa;hello_world\n"
f"{UUID_1};;Nama Siswa Lama;completed\n"
)
rows = parse_roundtrip_csv(content, ACTIVE)
assert len(rows) == 1
r = rows[0]
assert r.student_id == UUID_1
assert r.raw_token == ""
assert r.display_name == "Nama Siswa Lama"
assert r.progress["hello_world"].state == "completed"
def test_parse_accepts_export_snapshot_with_empty_tokens():
# CSV aktual berisi dua baris student_id + token kosong (hasil export
# PostgreSQL) dapat diparse murni; token kosong tidak dianggap duplikat.
content = (
"student_id;token;nama_siswa;hello_world\n"
f"{UUID_1};;Nama Siswa Lama;completed\n"
f"{UUID_2};;Siswa Kedua;not_started\n"
)
rows = parse_roundtrip_csv(content, ACTIVE)
assert len(rows) == 2
assert [r.student_id for r in rows] == [UUID_1, UUID_2]
assert all(r.raw_token == "" for r in rows)
assert "hello_world" in rows[0].progress
assert "hello_world" not in rows[1].progress # not_started → sparse
def test_parse_accepts_whitespace_only_token_with_student_id():
# Token whitespace-only pada baris existing diperlakukan sebagai token
# kosong yang sah.
content = (
"student_id;token;nama_siswa;hello_world\n"
f"{UUID_1}; ;Nama Siswa Lama;completed\n"
)
rows = parse_roundtrip_csv(content, ACTIVE)
assert len(rows) == 1
assert rows[0].raw_token == ""
def test_parse_rejects_whitespace_around_nonempty_token_even_with_student_id():
# Spasi di sekitar token non-kosong tetap ditolak walau student_id terisi.
secret = "TOKEN_RAHASIA_UNIK_001"
content = (
"student_id;token;nama_siswa;hello_world\n"
f"{UUID_1}; {secret};Nama Siswa Lama;completed\n"
)
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv(content, ACTIVE)
assert any("spasi" in e for e in exc.value.errors)
assert secret not in str(exc.value) # token tidak bocor ke pesan error
def test_parse_existing_row_with_empty_token_never_leaks_repr():
row = parse_roundtrip_csv(
f"student_id;token;nama_siswa;hello_world\n{UUID_1};;Nama;completed\n",
ACTIVE,
)[0]
assert row.raw_token == ""
assert "token" not in repr(row).lower() or "raw_token" not in repr(row)
# ── parser: student_id / UUID ──────────────────────────────────────
def test_is_canonical_uuid():
assert is_canonical_uuid(UUID_1)
assert not is_canonical_uuid("")
assert not is_canonical_uuid("bukan-uuid")
assert not is_canonical_uuid(UUID_1.upper()) # huruf besar tidak canonical
def test_parse_rejects_invalid_and_duplicate_uuid():
bad_uuid = "student_id;token;nama_siswa;hello_world\nNOT_A_UUID;TOKEN_12345678;A;completed\n"
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv(bad_uuid, ACTIVE)
assert any("UUID tidak valid" in e for e in exc.value.errors)
dup = (
"student_id;token;nama_siswa;hello_world\n"
f"{UUID_1};TOKEN_12345678;A;completed\n"
f"{UUID_1};TOKEN_87654321;B;completed\n"
)
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv(dup, ACTIVE)
assert any("UUID duplikat" in e for e in exc.value.errors)
def test_parse_invalid_uuid_with_empty_token_reports_only_uuid_error():
# UUID invalid + token kosong: error token "untuk siswa baru" TIDAK boleh
# muncul (baris ini jelas bukan siswa baru — error UUID-nya sudah cukup).
content = "student_id;token;nama_siswa;hello_world\nNOT_A_UUID;;A;completed\n"
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv(content, ACTIVE)
assert any("UUID tidak valid" in e for e in exc.value.errors)
assert not any("token wajib diisi" in e for e in exc.value.errors)
# ── parser: header & format file ───────────────────────────────────
def test_parse_rejects_missing_required_column():
content = "token;nama_siswa;hello_world\nTOKEN_12345678;A;completed\n"
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv(content, ACTIVE)
assert any("student_id" in e for e in exc.value.errors)
def test_parse_rejects_duplicate_header():
content = "student_id;token;token;nama_siswa;hello_world\n;;T;A;completed\n"
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv(content, ACTIVE)
assert any("Header duplikat" in e for e in exc.value.errors)
def test_parse_rejects_unknown_lesson_column():
content = "student_id;token;nama_siswa;hello_world;tidak_ada\n;TOKEN_12345678;A;completed;completed\n"
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv(content, ACTIVE)
assert any("tidak_ada" in e for e in exc.value.errors)
def test_parse_rejects_inactive_lesson_column():
# lesson tidak ada di daftar aktif → ditolak (bukan diam-diam di-ignore)
content = "student_id;token;nama_siswa;hello_world\n;TOKEN_12345678;A;completed\n"
with pytest.raises(RoundTripImportError):
parse_roundtrip_csv(content, ["variabel"])
def test_parse_rejects_unsupported_tab_delimiter():
# Delimiter tab tidak didukung; seluruh header menjadi satu field pada
# semua kandidat → error delimiter yang jelas, BUKAN seluruh header
# ditampilkan sebagai satu unknown lesson.
content = "student_id\ttoken\tnama_siswa\thello_world\n\tTOKEN_12345678\tA\tcompleted\n"
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv(content, ACTIVE)
joined = "; ".join(exc.value.errors)
assert "titik koma (;)" in joined or "koma (,)" in joined
assert "Kolom lesson tidak dikenal" not in joined
# ── parser: delimiter koma (`,`) ───────────────────────────────────
def test_parse_accepts_comma_delimiter_for_new_student():
# Reproduksi file lampiran pengguna: header koma + row siswa baru.
content = (
"student_id,token,nama_siswa,hello_world,variabel\r\n"
",LocustBot48,Locust Bot 48,completed,\r\n"
)
rows = parse_roundtrip_csv(content, ACTIVE)
assert len(rows) == 1
assert rows[0].student_id is None
assert rows[0].raw_token == "LocustBot48"
assert rows[0].display_name == "Locust Bot 48"
assert rows[0].progress["hello_world"].state == "completed"
def test_parse_accepts_comma_delimiter_with_bom():
content = (
"\ufeffstudent_id,token,nama_siswa,hello_world\r\n"
",TOKEN_12345678,Siswa Baru,completed\r\n"
)
rows = parse_roundtrip_csv(content, ACTIVE)
assert len(rows) == 1
assert rows[0].raw_token == "TOKEN_12345678"
assert rows[0].display_name == "Siswa Baru"
def test_parse_accepts_comma_delimiter_existing_student_empty_token():
# Baris hasil export (student_id terisi + token kosong) juga valid
# dengan delimiter koma.
content = (
"student_id,token,nama_siswa,hello_world\r\n"
f"{UUID_1},,Nama Siswa Lama,completed\r\n"
)
rows = parse_roundtrip_csv(content, ACTIVE)
assert len(rows) == 1
assert rows[0].student_id == UUID_1
assert rows[0].raw_token == ""
def test_parse_comma_csv_rejects_duplicate_header():
content = "student_id,token,token,nama_siswa,hello_world\n,,T,A,completed\n"
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv(content, ACTIVE)
assert any("Header duplikat" in e for e in exc.value.errors)
def test_parse_comma_csv_rejects_unknown_lesson_column():
content = "student_id,token,nama_siswa,hello_world,tidak_ada\n,TOKEN_12345678,A,completed,completed\n"
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv(content, ACTIVE)
assert any("tidak_ada" in e for e in exc.value.errors)
def test_parse_comma_csv_errors_never_contain_token_value():
secret = "TOKEN_RAHASIA_UNIK_001"
content = (
"student_id,token,nama_siswa,hello_world\n"
f",{secret},A,completed\n"
f",{secret},B,completed\n" # duplikat → error yang menyebut token
)
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv(content, ACTIVE)
assert secret not in str(exc.value)
for error in exc.value.errors:
assert secret not in error
# ── parser: quoted field (delimiter di dalam nilai, bukan di-split naif) ──
def test_parse_quoted_comma_in_name_comma_csv():
# Koma di dalam quoted field harus tetap satu nilai, bukan split manual.
content = (
"student_id,token,nama_siswa,hello_world\r\n"
',TOKEN_12345678,"Doe, Budi",completed\r\n'
)
rows = parse_roundtrip_csv(content, ACTIVE)
assert len(rows) == 1
assert rows[0].display_name == "Doe, Budi"
def test_parse_quoted_semicolon_in_name_semicolon_csv():
# Titik koma di dalam quoted field harus tetap satu nilai pada
# delimiter canonical.
content = (
"student_id;token;nama_siswa;hello_world\r\n"
';TOKEN_12345678;"Budi; Santoso";completed\r\n'
)
rows = parse_roundtrip_csv(content, ACTIVE)
assert len(rows) == 1
assert rows[0].display_name == "Budi; Santoso"
def test_parse_mixed_delimiter_reports_schema_errors_not_whole_header():
# Header campuran ; dan , terpecah menjadi beberapa field tapi tidak
# memenuhi schema pada delimiter manapun → error kolom wajib yang
# jelas, dan seluruh raw header TIDAK tampil sebagai satu unknown
# lesson (yang menyesatkan).
content = "student_id;token,nama_siswa;hello_world\n;TOKEN_12345678,A,completed\n"
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv(content, ACTIVE)
joined = "; ".join(exc.value.errors)
assert "Kolom wajib hilang" in joined
assert "student_id;token,nama_siswa;hello_world" not in joined
def test_parse_rejects_csv_layer_error():
# Field melebihi batas field csv (131072) → csv.Error saat parsing
content = (
"student_id;token;nama_siswa;hello_world\n"
";TOKEN_12345678;" + "x" * 200000 + ";completed\n"
)
with pytest.raises(RoundTripImportError):
parse_roundtrip_csv(content, ACTIVE)
def test_parse_rejects_empty_file():
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv("", ACTIVE)
assert any("kosong" in e or "header" in e.lower() for e in exc.value.errors)
def test_parse_rejects_invalid_progress_status():
content = "student_id;token;nama_siswa;hello_world;variabel\n;TOKEN_12345678;A;selesai;4/2\n"
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv(content, ACTIVE)
joined = "; ".join(exc.value.errors)
assert "selesai" in joined
assert "4/2" in joined
def test_parse_rejects_name_too_long():
name = "N" * 256
content = f"student_id;token;nama_siswa;hello_world\n;TOKEN_12345678;{name};completed\n"
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv(content, ACTIVE)
assert any("255" in e for e in exc.value.errors)
def test_parse_rejects_too_many_rows():
header = "student_id;token;nama_siswa;hello_world\n"
body = "".join(f";TOKEN_{i:08d};Siswa {i};completed\n" for i in range(MAX_ROWS + 1))
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv(header + body, ACTIVE)
assert any(f"batas {MAX_ROWS}" in e for e in exc.value.errors)
def test_parse_rejects_file_too_big():
big = b"a" * (MAX_FILE_BYTES + 1)
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv(big, ACTIVE)
assert any("5 MiB" in e for e in exc.value.errors)
# ── keamanan: token tidak bocor ────────────────────────────────────
def test_errors_never_contain_token_value():
secret = "TOKEN_RAHASIA_UNIK_001"
content = (
"student_id;token;nama_siswa;hello_world\n"
f";{secret};A;completed\n"
f";{secret};B;completed\n" # duplikat → error yang menyebut token
)
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv(content, ACTIVE)
assert secret not in str(exc.value)
for error in exc.value.errors:
assert secret not in error
def test_row_repr_never_contains_token():
row = StudentRoundTripRow(
line=2,
student_id=UUID_1,
raw_token="TOKEN_RAHASIA_UNIK_001",
display_name="Budi",
progress={},
)
assert "TOKEN_RAHASIA_UNIK_001" not in repr(row)
def test_mask_student_id():
assert mask_student_id(UUID_1) == "7eab651c…"
assert mask_student_id(None) == ""
assert mask_student_id("") == ""