2026-08-08 11:57:08 +07:00
|
|
|
"""Metadata model: nama tabel, constraint, relasi — tanpa butuh DB hidup."""
|
|
|
|
|
|
|
|
|
|
from sqlalchemy import ForeignKeyConstraint
|
|
|
|
|
|
|
|
|
|
from services import models as _models # noqa: F401 (mendaftarkan metadata)
|
|
|
|
|
from services.database import Base
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _table(name):
|
|
|
|
|
return Base.metadata.tables[name]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _fks(table):
|
|
|
|
|
return [c for c in table.constraints if isinstance(c, ForeignKeyConstraint)]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _fk_to(table, column_name):
|
|
|
|
|
for fk in _fks(table):
|
|
|
|
|
if column_name in fk.column_keys:
|
|
|
|
|
return fk
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_users_table_shape():
|
|
|
|
|
cols = _table("users").columns
|
|
|
|
|
assert {"id", "display_name", "role", "is_active"}.issubset(cols.keys())
|
|
|
|
|
assert cols["role"].type.length == 10
|
|
|
|
|
assert _table("users").primary_key.columns.keys() == ["id"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_users_role_check_constraint():
|
|
|
|
|
checks = [c.name for c in _table("users").constraints if c.name == "ck_users_role"]
|
|
|
|
|
assert checks
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_access_tokens_hash_unique_and_indexed():
|
|
|
|
|
table = _table("access_tokens")
|
|
|
|
|
assert any(c.name == "uq_access_tokens_token_hash" for c in table.constraints)
|
|
|
|
|
assert {"ix_access_tokens_token_hash", "ix_access_tokens_user_id"}.issubset(
|
|
|
|
|
{i.name for i in table.indexes}
|
|
|
|
|
)
|
|
|
|
|
fk = _fk_to(table, "user_id")
|
|
|
|
|
assert fk is not None
|
|
|
|
|
assert fk.ondelete == "CASCADE"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_lessons_slug_unique():
|
|
|
|
|
table = _table("lessons")
|
|
|
|
|
assert any(c.name == "uq_lessons_slug" for c in table.constraints)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_student_progress_unique_and_state_check():
|
|
|
|
|
table = _table("student_progress")
|
|
|
|
|
assert any(c.name == "uq_student_progress_user_lesson" for c in table.constraints)
|
|
|
|
|
assert any(c.name == "ck_student_progress_state" for c in table.constraints)
|
|
|
|
|
cols = table.columns
|
|
|
|
|
assert cols["state"].default.arg == "not_started"
|
|
|
|
|
assert cols["score_earned"].nullable
|
|
|
|
|
assert cols["score_total"].nullable
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_cascade_delete_user_removes_tokens_and_progress():
|
|
|
|
|
tokens_fk = _fk_to(_table("access_tokens"), "user_id")
|
|
|
|
|
progress_fk = _fk_to(_table("student_progress"), "user_id")
|
|
|
|
|
assert tokens_fk is not None and tokens_fk.ondelete == "CASCADE"
|
|
|
|
|
assert progress_fk is not None and progress_fk.ondelete == "CASCADE"
|
feat(quiz): strict focus-loss anti-cheat + audit attempt
Kebijakan strict: kehilangan fokus saat kuis aktif langsung mengakhiri kuis
dengan penalti, satu attempt per kuis tercatat audit, pembahasan disembunyikan
saat focus_lost, badge pelanggaran muncul di laporan guru.
Backend:
- tabel quiz_attempts (migration 0002) + endpoint POST /quiz-attempts/submit
idempotent dengan unique constraint (satu attempt per token+lesson, 409
untuk duplikat), bounded termination_reason
- models/repositories/storage: QuizAttempt + record/query pelanggaran untuk
laporan guru (progress)
Frontend:
- quiz-integrity.ts: policy hub (isFocusLossEvent, terminateQuiz,
shouldShowQuizReview) + attempt_id via crypto.randomUUID()
- lesson.svelte.ts: finalisasi idempotent; sendBeacon untuk exit path yang
bisa suspend (focus_lost/page_unload), fetch keepalive untuk reason lain
- QuizTab.svelte: summary menyembunyikan pembahasan saat focus_lost
- progress/+page.svelte: badge pelanggaran focus_lost di laporan guru
- APP_VERSION 26 -> 27
Tests & docs:
- 204 backend test pass (unit host + integrasi container, DB elemes_test)
- 107 frontend test pass, svelte-check 0 error
- docs/12-quiz-integrity.md (baru), docs/07-quiz-authoring.md + README FAQ
anti-cheat diperbarui
2026-08-11 20:55:39 +07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_quiz_attempts_table_shape():
|
|
|
|
|
table = _table("quiz_attempts")
|
|
|
|
|
cols = table.columns
|
|
|
|
|
assert {
|
|
|
|
|
"id",
|
|
|
|
|
"user_id",
|
|
|
|
|
"lesson_id",
|
|
|
|
|
"status",
|
|
|
|
|
"termination_reason",
|
|
|
|
|
"score_earned",
|
|
|
|
|
"score_total",
|
|
|
|
|
"started_at",
|
|
|
|
|
"finished_at",
|
|
|
|
|
"visibility_event_count",
|
|
|
|
|
}.issubset(cols.keys())
|
|
|
|
|
assert table.primary_key.columns.keys() == ["id"]
|
|
|
|
|
assert cols["termination_reason"].nullable
|
|
|
|
|
assert cols["user_agent"].nullable
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_quiz_attempts_constraints():
|
|
|
|
|
table = _table("quiz_attempts")
|
|
|
|
|
names = {c.name for c in table.constraints}
|
|
|
|
|
assert "uq_quiz_attempts_user_lesson" in names
|
|
|
|
|
assert "ck_quiz_attempts_status" in names
|
|
|
|
|
assert "ck_quiz_attempts_reason" in names
|
|
|
|
|
assert "ck_quiz_attempts_status_reason" in names
|
|
|
|
|
assert {"ix_quiz_attempts_user_id", "ix_quiz_attempts_finished_at"}.issubset(
|
|
|
|
|
{i.name for i in table.indexes}
|
|
|
|
|
)
|
|
|
|
|
user_fk = _fk_to(table, "user_id")
|
|
|
|
|
lesson_fk = _fk_to(table, "lesson_id")
|
|
|
|
|
assert user_fk is not None and user_fk.ondelete == "CASCADE"
|
|
|
|
|
assert lesson_fk is not None and lesson_fk.ondelete == "CASCADE"
|