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:
@@ -62,9 +62,10 @@ func (h *Handlers) ListQuizzes(w http.ResponseWriter, r *http.Request) {
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": out})
|
||||
}
|
||||
|
||||
// ShowQuiz answers GET /quizzes/{slug}?category=.
|
||||
// ShowQuiz answers GET /quizzes/{slug}?category=. A test ignores the
|
||||
// category and carries attempts_left.
|
||||
func (h *Handlers) ShowQuiz(w http.ResponseWriter, r *http.Request) {
|
||||
_, svc, ok := h.begin(w, r)
|
||||
userID, svc, ok := h.begin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -73,11 +74,21 @@ func (h *Handlers) ShowQuiz(w http.ResponseWriter, r *http.Request) {
|
||||
h.writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": quizDetail(detail)})
|
||||
out := quizDetail(detail)
|
||||
if classes.IsTest(&detail.Quiz) {
|
||||
used, err := svc.AttemptsUsed(r.Context(), detail.Quiz.ID, userID)
|
||||
if err != nil {
|
||||
wire.WriteOpaque500(w)
|
||||
return
|
||||
}
|
||||
out.AttemptsLeft = classes.AttemptsLeft(&detail.Quiz, used)
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": out})
|
||||
}
|
||||
|
||||
// StartAttempt answers POST /quizzes/{slug}/attempts with an optional
|
||||
// {"category": "..."} body.
|
||||
// {"category": "..."} body: 201 with a new attempt, or 200 when an open
|
||||
// test attempt is resumed.
|
||||
func (h *Handlers) StartAttempt(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := h.user(w, r)
|
||||
if !ok {
|
||||
@@ -92,13 +103,16 @@ func (h *Handlers) StartAttempt(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
category, _ := input["category"].(string)
|
||||
a, quiz, err := svc.StartAttempt(r.Context(), userID, r.PathValue("slug"), strings.TrimSpace(category))
|
||||
st, err := svc.Start(r.Context(), userID, r.PathValue("slug"), strings.TrimSpace(category))
|
||||
if err != nil {
|
||||
h.writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
a.Quiz = quiz
|
||||
wire.WriteJSON(w, http.StatusCreated, map[string]any{"data": attempt(classes.AttemptSummary{Attempt: *a})})
|
||||
status := http.StatusCreated
|
||||
if st.Resumed {
|
||||
status = http.StatusOK
|
||||
}
|
||||
wire.WriteJSON(w, status, map[string]any{"data": start(st)})
|
||||
}
|
||||
|
||||
// Answer answers POST /attempts/{id}/answers with
|
||||
@@ -240,10 +254,12 @@ func (h *Handlers) service(w http.ResponseWriter) (*classes.Service, bool) {
|
||||
return h.Service(gdb), true
|
||||
}
|
||||
var scoring classes.ScoringConfig
|
||||
grace := classes.DefaultTestGrace
|
||||
if h.App != nil {
|
||||
scoring = classes.ScoringConfigFrom(h.App.Config)
|
||||
grace = classes.TestGraceFrom(h.App.Config)
|
||||
}
|
||||
return &classes.Service{DB: gdb, Scoring: scoring}, true
|
||||
return &classes.Service{DB: gdb, Scoring: scoring, Grace: grace}, true
|
||||
}
|
||||
|
||||
// input decodes a JSON body (an empty body is {}) and validates it. It
|
||||
@@ -317,10 +333,32 @@ var apiMessages = map[string]string{
|
||||
"known_required": "The known field is required for this question.",
|
||||
"unknown_category": "The quiz has no questions in this category.",
|
||||
"validation_failed": "The given data was invalid.",
|
||||
"attempt_limit_reached": "All attempts at this test have been used.",
|
||||
"answer_locked": "An answer in this test cannot be changed.",
|
||||
"category_not_allowed": "A test covers the whole quiz; a category cannot be chosen.",
|
||||
}
|
||||
|
||||
// writeError maps a service error onto the API's status codes.
|
||||
// conflicts are the service errors answered with 409 and the code (also
|
||||
// the lang.api key of the message) the body carries.
|
||||
var conflicts = []struct {
|
||||
err error
|
||||
code string
|
||||
}{
|
||||
{classes.ErrCompleted, "attempt_completed"},
|
||||
{classes.ErrTimeLimit, "time_limit_exceeded"},
|
||||
{classes.ErrAttemptLimit, "attempt_limit_reached"},
|
||||
{classes.ErrAnswerLocked, "answer_locked"},
|
||||
}
|
||||
|
||||
// writeError maps a service error onto the API's status codes. A 409 body
|
||||
// is {"error": message, "code": key}.
|
||||
func (h *Handlers) writeError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
for _, c := range conflicts {
|
||||
if errors.Is(err, c.err) {
|
||||
wire.WriteJSON(w, http.StatusConflict, map[string]any{"error": h.message(r.Context(), c.code), "code": c.code})
|
||||
return
|
||||
}
|
||||
}
|
||||
var fe *classes.FieldError
|
||||
switch {
|
||||
case errors.As(err, &fe):
|
||||
@@ -332,10 +370,6 @@ func (h *Handlers) writeError(w http.ResponseWriter, r *http.Request, err error)
|
||||
key = "quiz_not_found"
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusNotFound, map[string]any{"error": h.message(r.Context(), key)})
|
||||
case errors.Is(err, classes.ErrCompleted):
|
||||
wire.WriteJSON(w, http.StatusConflict, map[string]any{"error": h.message(r.Context(), "attempt_completed")})
|
||||
case errors.Is(err, classes.ErrTimeLimit):
|
||||
wire.WriteJSON(w, http.StatusConflict, map[string]any{"error": h.message(r.Context(), "time_limit_exceeded")})
|
||||
default:
|
||||
wire.WriteOpaque500(w)
|
||||
}
|
||||
|
||||
@@ -113,16 +113,21 @@ func TestErrorKeysInRuleOrder(t *testing.T) {
|
||||
func TestWriteErrorMapping(t *testing.T) {
|
||||
h := New(nil)
|
||||
cases := []struct {
|
||||
err error
|
||||
slug string
|
||||
code int
|
||||
msg string
|
||||
err error
|
||||
slug string
|
||||
code int
|
||||
msg string
|
||||
errCode string
|
||||
errField string
|
||||
}{
|
||||
{classes.ErrNotFound, "quiz", 404, "Quiz not found"},
|
||||
{classes.ErrNotFound, "", 404, "Attempt not found"},
|
||||
{classes.ErrCompleted, "", 409, apiMessages["attempt_completed"]},
|
||||
{classes.ErrTimeLimit, "", 409, apiMessages["time_limit_exceeded"]},
|
||||
{&classes.FieldError{Field: "question_id", Key: "question_not_in_quiz"}, "", 422, apiMessages["question_not_in_quiz"]},
|
||||
{classes.ErrNotFound, "quiz", 404, "Quiz not found", "", ""},
|
||||
{classes.ErrNotFound, "", 404, "Attempt not found", "", ""},
|
||||
{classes.ErrCompleted, "", 409, apiMessages["attempt_completed"], "attempt_completed", ""},
|
||||
{classes.ErrTimeLimit, "", 409, apiMessages["time_limit_exceeded"], "time_limit_exceeded", ""},
|
||||
{classes.ErrAttemptLimit, "quiz", 409, apiMessages["attempt_limit_reached"], "attempt_limit_reached", ""},
|
||||
{classes.ErrAnswerLocked, "", 409, apiMessages["answer_locked"], "answer_locked", ""},
|
||||
{&classes.FieldError{Field: "question_id", Key: "question_not_in_quiz"}, "", 422, apiMessages["question_not_in_quiz"], "", "question_id"},
|
||||
{&classes.FieldError{Field: "category", Key: "category_not_allowed"}, "quiz", 422, apiMessages["category_not_allowed"], "", "category"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
@@ -133,9 +138,17 @@ func TestWriteErrorMapping(t *testing.T) {
|
||||
h.writeError(rec, req, tc.err)
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
if rec.Code != tc.code || out["error"] != tc.msg {
|
||||
if rec.Code != tc.code || out["error"] != tc.msg || tc.msg == "" {
|
||||
t.Errorf("%v = %d %v, want %d %q", tc.err, rec.Code, out, tc.code, tc.msg)
|
||||
}
|
||||
if tc.errCode != "" && out["code"] != tc.errCode {
|
||||
t.Errorf("%v code = %v, want %q", tc.err, out["code"], tc.errCode)
|
||||
}
|
||||
if tc.errField != "" {
|
||||
if errs, _ := out["errors"].(map[string]any); errs[tc.errField] == nil {
|
||||
t.Errorf("%v has no error on %s: %v", tc.err, tc.errField, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.writeError(rec, httptest.NewRequest("GET", "/", nil), http.ErrBodyNotAllowed)
|
||||
|
||||
@@ -28,11 +28,14 @@ type quizSummaryJSON struct {
|
||||
Mode string `json:"mode"`
|
||||
Navigation string `json:"navigation"`
|
||||
MaxAttempts *int `json:"max_attempts"`
|
||||
// AttemptsLeft is null for a learning quiz or a test without a limit.
|
||||
AttemptsLeft *int `json:"attempts_left"`
|
||||
QuestionTypes map[string]int `json:"question_types"`
|
||||
}
|
||||
|
||||
func quizSummary(s classes.QuizSummary) quizSummaryJSON {
|
||||
q := s.Quiz
|
||||
return quizSummaryJSON{
|
||||
out := quizSummaryJSON{
|
||||
ID: q.ID,
|
||||
Slug: q.Slug,
|
||||
Title: q.Title,
|
||||
@@ -48,7 +51,13 @@ func quizSummary(s classes.QuizSummary) quizSummaryJSON {
|
||||
Mode: quizMode(&q),
|
||||
Navigation: quizNavigation(&q),
|
||||
MaxAttempts: q.MaxAttempts,
|
||||
AttemptsLeft: classes.AttemptsLeft(&q, s.AttemptsUsed),
|
||||
QuestionTypes: make(map[string]int, len(models.QuestionTypes)),
|
||||
}
|
||||
for _, typ := range models.QuestionTypes {
|
||||
out.QuestionTypes[typ] = s.QuestionTypes[typ]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// quizMode is the quiz's mode as the API reports it: test, or learning for
|
||||
@@ -88,6 +97,8 @@ type quizJSON struct {
|
||||
Mode string `json:"mode"`
|
||||
Navigation string `json:"navigation"`
|
||||
MaxAttempts *int `json:"max_attempts"`
|
||||
// AttemptsLeft is set by ShowQuiz for a test with a limit.
|
||||
AttemptsLeft *int `json:"attempts_left"`
|
||||
}
|
||||
|
||||
// questionJSON is a question as the player sees it before answering: no
|
||||
@@ -177,8 +188,14 @@ type attemptJSON struct {
|
||||
// QuizMode and QuizNavigation tell the player which flow to run.
|
||||
QuizMode string `json:"quiz_mode"`
|
||||
QuizNavigation string `json:"quiz_navigation"`
|
||||
// SecondsLeft is the time left of an open attempt with a limit, counted
|
||||
// by the server.
|
||||
SecondsLeft *int `json:"seconds_left"`
|
||||
}
|
||||
|
||||
// timeNow is the clock of seconds_left; tests pin it.
|
||||
var timeNow = time.Now
|
||||
|
||||
func attempt(s classes.AttemptSummary) attemptJSON {
|
||||
a := s.Attempt
|
||||
out := attemptJSON{
|
||||
@@ -200,6 +217,7 @@ func attempt(s classes.AttemptSummary) attemptJSON {
|
||||
t := wireTime(*d)
|
||||
out.ExpiresAt = &t
|
||||
}
|
||||
out.SecondsLeft = classes.SecondsLeft(&a, timeNow())
|
||||
if a.CompletedAt != nil {
|
||||
t := wireTime(*a.CompletedAt)
|
||||
out.CompletedAt = &t
|
||||
@@ -209,6 +227,25 @@ func attempt(s classes.AttemptSummary) attemptJSON {
|
||||
return out
|
||||
}
|
||||
|
||||
// startJSON is POST /quizzes/{slug}/attempts: the attempt, whether an open
|
||||
// test attempt was resumed, and the attempts left after this one (null for
|
||||
// learning or without a limit).
|
||||
type startJSON struct {
|
||||
attemptJSON
|
||||
Resumed bool `json:"resumed"`
|
||||
AttemptsLeft *int `json:"attempts_left"`
|
||||
}
|
||||
|
||||
func start(st *classes.Started) startJSON {
|
||||
a := *st.Attempt
|
||||
a.Quiz = st.Quiz
|
||||
return startJSON{
|
||||
attemptJSON: attempt(classes.AttemptSummary{Attempt: a, AnsweredCount: st.AnsweredCount}),
|
||||
Resumed: st.Resumed,
|
||||
AttemptsLeft: classes.AttemptsLeft(st.Quiz, st.AttemptsUsed),
|
||||
}
|
||||
}
|
||||
|
||||
// resultJSON is GET /attempts/{id} and POST /attempts/{id}/finish.
|
||||
// SolutionsWithheld is true for an open test attempt (classes.Withheld).
|
||||
type resultJSON struct {
|
||||
|
||||
@@ -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