feat: enforce attempt limits, time limits and one-way tests
- Service.Start resumes an open test attempt, closes expired ones at their deadline and refuses a start past max_attempts under an advisory lock; StartAttempt stays as a wrapper - answers to a test are accepted until the deadline plus golem15.quizzes.test.grace_seconds; one_way answers are final - a test covers the whole quiz and keeps a stable per-attempt order - 409 bodies carry a code; list and attempt rows carry attempts_left, question_types and seconds_left; import accepts the test keys
This commit is contained in:
@@ -198,3 +198,46 @@ func TestTestQuizDetailHidesQuestions(t *testing.T) {
|
||||
t.Errorf("learning detail questions = %d, want 2", len(qs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummaryAndAttemptFields(t *testing.T) {
|
||||
two := 2
|
||||
test := models.Quiz{ID: 3, Slug: "t", Mode: models.ModeTest, MaxAttempts: &two}
|
||||
m, _ := decode(t, quizSummary(classes.QuizSummary{Quiz: test, QuestionCount: 3, AttemptsUsed: 1, QuestionTypes: map[string]int{models.TypeSingle: 3}}))
|
||||
if m["attempts_left"] != float64(1) || m["mode"] != models.ModeTest || m["max_attempts"] != float64(2) {
|
||||
t.Errorf("test summary = %v", m)
|
||||
}
|
||||
types, _ := m["question_types"].(map[string]any)
|
||||
if len(types) != 3 || types["single"] != float64(3) || types["multiple"] != float64(0) || types["self_check"] != float64(0) {
|
||||
t.Errorf("question_types = %v, want all three keys", m["question_types"])
|
||||
}
|
||||
m, _ = decode(t, quizSummary(classes.QuizSummary{Quiz: models.Quiz{ID: 4, Slug: "l"}, AttemptsUsed: 9}))
|
||||
if v, ok := m["attempts_left"]; !ok || v != nil {
|
||||
t.Errorf("learning attempts_left = %v (present %v), want null", v, ok)
|
||||
}
|
||||
if types, _ := m["question_types"].(map[string]any); len(types) != 3 {
|
||||
t.Errorf("learning question_types = %v, want all three keys", m["question_types"])
|
||||
}
|
||||
if m["mode"] != models.ModeLearning || m["navigation"] != models.NavigationFree {
|
||||
t.Errorf("zero-value quiz mode / navigation = %v / %v", m["mode"], m["navigation"])
|
||||
}
|
||||
|
||||
begin := time.Date(2026, 11, 9, 10, 0, 0, 0, time.UTC)
|
||||
defer func(orig func() time.Time) { timeNow = orig }(timeNow)
|
||||
timeNow = func() time.Time { return begin.Add(20 * time.Second) }
|
||||
limit := 60
|
||||
timed := &models.Quiz{Slug: "t", Mode: models.ModeTest, Navigation: models.NavigationOneWay, TimeLimitSeconds: &limit}
|
||||
m, _ = decode(t, attempt(classes.AttemptSummary{Attempt: models.Attempt{ID: 1, StartedAt: begin, Quiz: timed}}))
|
||||
if m["seconds_left"] != float64(40) || m["quiz_mode"] != models.ModeTest || m["quiz_navigation"] != models.NavigationOneWay {
|
||||
t.Errorf("open timed attempt = %v", m)
|
||||
}
|
||||
done := begin.Add(30 * time.Second)
|
||||
m, _ = decode(t, attempt(classes.AttemptSummary{Attempt: models.Attempt{ID: 1, StartedAt: begin, CompletedAt: &done, Quiz: timed}}))
|
||||
if v, ok := m["seconds_left"]; !ok || v != nil {
|
||||
t.Errorf("completed seconds_left = %v (present %v), want null", v, ok)
|
||||
}
|
||||
|
||||
m, _ = decode(t, start(&classes.Started{Attempt: &models.Attempt{ID: 2, StartedAt: begin}, Quiz: &test, Resumed: true, AttemptsUsed: 2, AnsweredCount: 1}))
|
||||
if m["resumed"] != true || m["attempts_left"] != float64(0) || m["answered_count"] != float64(1) || m["quiz_slug"] != "t" {
|
||||
t.Errorf("start json = %v", m)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user