package quizzes_test import ( "errors" "fmt" "os" "slices" "sync" "testing" "time" "git.golem15.com/golem15/sm-quizzes-plugin/classes" "git.golem15.com/golem15/sm-quizzes-plugin/models" "git.golem15.com/golem15/summercms/modules/surf" ) // TestTestModeAgainstDatabase covers test mode end to end: the migration's // defaults, withheld solutions over HTTP, the attempt limit and resume // under the advisory lock, the one-way lock, the grace and the lazy close // (through a classes.Service with an injected clock) and the stable order. func TestTestModeAgainstDatabase(t *testing.T) { app, plugins, gdb := migrated(t) h, err := surf.Assemble(app, plugins) must(t, err) test := func(mutate func(*models.Quiz)) func(*models.Quiz) { return func(q *models.Quiz) { q.Mode = models.ModeTest if mutate != nil { mutate(q) } } } start := func(c *client, slug, token string, want int) map[string]any { c.t.Helper() return data(c.ok(want, "POST", apiPrefix+"/quizzes/"+slug+"/attempts", token, "")) } attemptPath := func(att map[string]any) string { return fmt.Sprintf("%s/attempts/%d", apiPrefix, int(att["id"].(float64))) } countAttempts := func(t *testing.T, quizID, userID uint) int64 { t.Helper() var n int64 must(t, gdb.Model(&models.Attempt{}).Where("quiz_id = ? AND user_id = ?", quizID, userID).Count(&n).Error) return n } t.Run("existing quizzes stay learning", func(t *testing.T) { must(t, gdb.Exec(`INSERT INTO golem15_quizzes_quizzes (title, slug, is_published, passing_score, shuffle_questions, created_at, updated_at) VALUES ('Stary', 'stary', true, 50, false, NOW(), NOW())`).Error) var q models.Quiz must(t, gdb.Where("slug = ?", "stary").Take(&q).Error) if q.Mode != models.ModeLearning || q.Navigation != models.NavigationFree || q.MaxAttempts != nil { t.Errorf("pre-existing quiz = %q %q %v, want learning free NULL", q.Mode, q.Navigation, q.MaxAttempts) } f := seedQuiz(t, gdb, "zero-value", nil) if f.quiz.Mode != models.ModeLearning || f.quiz.Navigation != models.NavigationFree { t.Errorf("zero-value quiz = %q %q", f.quiz.Mode, f.quiz.Navigation) } }) t.Run("an imported sample is learning", func(t *testing.T) { raw, err := os.ReadFile("testdata/quiz.json") must(t, err) doc, err := classes.ParseImport(raw) must(t, err) res, err := classes.Import(t.Context(), gdb, doc, false) must(t, err) if res.Quiz.Mode != models.ModeLearning || res.Quiz.Navigation != models.NavigationFree || res.Quiz.MaxAttempts != nil { t.Errorf("imported quiz = %q %q %v", res.Quiz.Mode, res.Quiz.Navigation, res.Quiz.MaxAttempts) } }) t.Run("solutions are withheld until finish", func(t *testing.T) { c := &client{t: t, h: h} f := seedQuiz(t, gdb, "test-ukryty", test(nil)) _, token := newUser(t, gdb, "withheld@example.com") detail := data(c.ok(200, "GET", apiPrefix+"/quizzes/test-ukryty", token, "")) if qs, _ := detail["questions"].([]any); len(qs) != 0 || detail["question_count"] != float64(3) || detail["mode"] != models.ModeTest { t.Errorf("test detail = %v, want questions [] and question_count 3", detail) } for _, row := range list(c.ok(200, "GET", apiPrefix+"/quizzes", token, "")) { r := row.(map[string]any) if r["slug"] == "test-ukryty" { if types, _ := r["question_types"].(map[string]any); types["single"] != float64(1) || types["multiple"] != float64(1) || types["self_check"] != float64(1) { t.Errorf("question_types = %v", r["question_types"]) } } } att := start(c, "test-ukryty", token, 201) if att["resumed"] != false || att["quiz_mode"] != models.ModeTest { t.Errorf("start = %v", att) } path := attemptPath(att) for _, body := range []string{ fmt.Sprintf(`{"question_id":%d,"answer_ids":[%d]}`, f.single.ID, f.singleRight.ID), fmt.Sprintf(`{"question_id":%d,"answer_ids":[%d,%d]}`, f.multi.ID, f.multiA.ID, f.multiB.ID), fmt.Sprintf(`{"question_id":%d,"known":true}`, f.selfQ.ID), } { fb := data(c.ok(200, "POST", path+"/answers", token, body)) if fb["withheld"] != true { t.Errorf("feedback withheld = %v", fb["withheld"]) } for _, k := range []string{"is_correct", "points", "correct_answer_ids", "explanation", "answer_text"} { if fb[k] != nil { t.Errorf("feedback %s = %v, want null", k, fb[k]) } } for _, a := range fb["answers"].([]any) { if am := a.(map[string]any); am["is_correct"] != nil || am["explanation"] != nil { t.Errorf("feedback answer = %v", am) } } } res := data(c.ok(200, "GET", path, token, "")) if res["solutions_withheld"] != true { t.Errorf("open result solutions_withheld = %v", res["solutions_withheld"]) } for _, q := range res["questions"].([]any) { qm := q.(map[string]any) for _, k := range []string{"is_correct", "points_earned", "correct_answer_ids", "explanation", "answer_text"} { if qm[k] != nil { t.Errorf("open result question %v: %s = %v, want null", qm["id"], k, qm[k]) } } if qm["answered"] != true { t.Errorf("open result question %v lost answered", qm["id"]) } } done := data(c.ok(200, "POST", path+"/finish", token, "")) if done["solutions_withheld"] != false || done["passed"] != true { t.Errorf("finish = %v", done) } for _, q := range done["questions"].([]any) { qm := q.(map[string]any) if qm["is_correct"] == nil || qm["points_earned"] == nil { t.Errorf("finished question %v hides its result", qm["id"]) } if qm["id"] == float64(f.single.ID) && (qm["correct_answer_ids"] == nil || qm["explanation"] == nil) { t.Errorf("finished single question hides its solution: %v", qm) } } }) t.Run("a test refuses a category", func(t *testing.T) { c := &client{t: t, h: h} seedQuiz(t, gdb, "test-kategoria", test(nil)) _, token := newUser(t, gdb, "category@example.com") out := c.ok(422, "POST", apiPrefix+"/quizzes/test-kategoria/attempts", token, `{"category":"Geografia"}`) if errs, _ := out["errors"].(map[string]any); errs["category"] == nil { t.Errorf("category start = %v, want errors.category", out) } }) t.Run("attempt limit and resume", func(t *testing.T) { c := &client{t: t, h: h} f := seedQuiz(t, gdb, "test-limit", test(func(q *models.Quiz) { q.MaxAttempts = intPtr(1) })) u, token := newUser(t, gdb, "limit@example.com") att := start(c, "test-limit", token, 201) if att["attempts_left"] != float64(0) { t.Errorf("attempts_left after the only start = %v", att["attempts_left"]) } again := start(c, "test-limit", token, 200) if again["resumed"] != true || again["id"] != att["id"] || countAttempts(t, f.quiz.ID, u.ID) != 1 { t.Errorf("second start = %v, want the open attempt resumed", again) } c.ok(200, "POST", attemptPath(att)+"/finish", token, "") out := c.ok(409, "POST", apiPrefix+"/quizzes/test-limit/attempts", token, "") if out["code"] != "attempt_limit_reached" { t.Errorf("start past the limit = %v", out) } }) t.Run("concurrent starts share one attempt", func(t *testing.T) { f := seedQuiz(t, gdb, "test-wyscig", test(func(q *models.Quiz) { q.MaxAttempts = intPtr(1) })) u, _ := newUser(t, gdb, "race@example.com") svc := &classes.Service{DB: gdb, Grace: 5 * time.Second} var wg sync.WaitGroup ids := make([]uint, 2) errs := make([]error, 2) for i := range 2 { wg.Go(func() { st, err := svc.Start(t.Context(), u.ID, "test-wyscig", "") errs[i] = err if err == nil { ids[i] = st.Attempt.ID } }) } wg.Wait() for _, err := range errs { must(t, err) } if ids[0] != ids[1] || countAttempts(t, f.quiz.ID, u.ID) != 1 { t.Errorf("concurrent starts = %v with %d rows, want one attempt", ids, countAttempts(t, f.quiz.ID, u.ID)) } }) t.Run("one_way answers are final", func(t *testing.T) { c := &client{t: t, h: h} f := seedQuiz(t, gdb, "test-jednokierunkowy", test(func(q *models.Quiz) { q.Navigation = models.NavigationOneWay })) _, token := newUser(t, gdb, "oneway@example.com") path := attemptPath(start(c, "test-jednokierunkowy", token, 201)) c.ok(200, "POST", path+"/answers", token, fmt.Sprintf(`{"question_id":%d,"answer_ids":[%d]}`, f.single.ID, f.singleRight.ID)) out := c.ok(409, "POST", path+"/answers", token, fmt.Sprintf(`{"question_id":%d,"answer_ids":[%d]}`, f.single.ID, f.singleNo.ID)) if out["code"] != "answer_locked" { t.Errorf("changed one_way answer = %v", out) } var row models.AttemptAnswer must(t, gdb.Where("question_id = ?", f.single.ID).Take(&row).Error) if !slices.Equal(row.AnswerIDs.Data, []uint{f.singleRight.ID}) { t.Errorf("stored answer = %v, want the first one", row.AnswerIDs.Data) } }) t.Run("free answers are overwritten", func(t *testing.T) { c := &client{t: t, h: h} f := seedQuiz(t, gdb, "test-swobodny", test(nil)) _, token := newUser(t, gdb, "free@example.com") path := attemptPath(start(c, "test-swobodny", token, 201)) c.ok(200, "POST", path+"/answers", token, fmt.Sprintf(`{"question_id":%d,"answer_ids":[%d]}`, f.single.ID, f.singleRight.ID)) c.ok(200, "POST", path+"/answers", token, fmt.Sprintf(`{"question_id":%d,"answer_ids":[%d]}`, f.single.ID, f.singleNo.ID)) var row models.AttemptAnswer must(t, gdb.Where("question_id = ?", f.single.ID).Take(&row).Error) if !slices.Equal(row.AnswerIDs.Data, []uint{f.singleNo.ID}) { t.Errorf("stored answer = %v, want the second one", row.AnswerIDs.Data) } }) t.Run("grace applies to tests only", func(t *testing.T) { ft := seedQuiz(t, gdb, "test-laska", test(func(q *models.Quiz) { q.TimeLimitSeconds = intPtr(60) })) fl := seedQuiz(t, gdb, "nauka-czas", func(q *models.Quiz) { q.TimeLimitSeconds = intPtr(60) }) u, _ := newUser(t, gdb, "grace@example.com") t0 := time.Now().UTC().Truncate(time.Second) clock := t0 svc := &classes.Service{DB: gdb, Now: func() time.Time { return clock }, Grace: 5 * time.Second} st, err := svc.Start(t.Context(), u.ID, "test-laska", "") must(t, err) clock = t0.Add(62 * time.Second) if _, err := svc.Answer(t.Context(), u.ID, st.Attempt.ID, classes.AnswerInput{QuestionID: ft.single.ID, AnswerIDs: []uint{ft.singleRight.ID}}); err != nil { t.Errorf("test answer at +62s = %v, want accepted inside the grace", err) } clock = t0.Add(66 * time.Second) if _, err := svc.Answer(t.Context(), u.ID, st.Attempt.ID, classes.AnswerInput{QuestionID: ft.selfQ.ID, Known: new(true)}); !errors.Is(err, classes.ErrTimeLimit) { t.Errorf("test answer at +66s = %v, want ErrTimeLimit", err) } clock = t0 lst, err := svc.Start(t.Context(), u.ID, "nauka-czas", "") must(t, err) clock = t0.Add(61 * time.Second) if _, err := svc.Answer(t.Context(), u.ID, lst.Attempt.ID, classes.AnswerInput{QuestionID: fl.selfQ.ID, Known: new(true)}); !errors.Is(err, classes.ErrTimeLimit) { t.Errorf("learning answer at +61s = %v, want ErrTimeLimit", err) } }) t.Run("an expired test is closed at its deadline", func(t *testing.T) { f := seedQuiz(t, gdb, "test-wygasly", test(func(q *models.Quiz) { q.TimeLimitSeconds = intPtr(60) })) u, _ := newUser(t, gdb, "lazy@example.com") t0 := time.Now().UTC().Truncate(time.Second) clock := t0 svc := &classes.Service{DB: gdb, Now: func() time.Time { return clock }, Grace: 5 * time.Second} st, err := svc.Start(t.Context(), u.ID, "test-wygasly", "") must(t, err) clock = t0.Add(10 * time.Second) _, err = svc.Answer(t.Context(), u.ID, st.Attempt.ID, classes.AnswerInput{QuestionID: f.single.ID, AnswerIDs: []uint{f.singleRight.ID}}) must(t, err) clock = t0.Add(70 * time.Second) next, err := svc.Start(t.Context(), u.ID, "test-wygasly", "") must(t, err) if next.Resumed || next.Attempt.ID == st.Attempt.ID { t.Errorf("start after expiry = %+v, want a new attempt", next) } var old models.Attempt must(t, gdb.Take(&old, st.Attempt.ID).Error) if old.CompletedAt == nil || !old.CompletedAt.Equal(t0.Add(60*time.Second)) { t.Errorf("expired attempt completed_at = %v, want %v", old.CompletedAt, t0.Add(60*time.Second)) } if old.ScorePoints != 1 || old.MaxPoints != 4 { t.Errorf("expired attempt score = %v / %d, want 1 / 4", old.ScorePoints, old.MaxPoints) } }) t.Run("a shuffled test keeps its order", func(t *testing.T) { c := &client{t: t, h: h} seedQuiz(t, gdb, "test-losowy", test(func(q *models.Quiz) { q.ShuffleQuestions = true })) _, token := newUser(t, gdb, "shuffle@example.com") path := attemptPath(start(c, "test-losowy", token, 201)) order := func(res map[string]any) []float64 { var ids []float64 for _, q := range res["questions"].([]any) { ids = append(ids, q.(map[string]any)["id"].(float64)) } return ids } first := order(data(c.ok(200, "GET", path, token, ""))) second := order(data(c.ok(200, "GET", path, token, ""))) finished := order(data(c.ok(200, "POST", path+"/finish", token, ""))) if len(first) != 3 || !slices.Equal(first, second) || !slices.Equal(first, finished) { t.Errorf("orders = %v %v %v, want one stable order", first, second, finished) } }) }