diff --git a/test_mode_postgres_test.go b/test_mode_postgres_test.go new file mode 100644 index 0000000..668fb5f --- /dev/null +++ b/test_mode_postgres_test.go @@ -0,0 +1,308 @@ +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) + } + }) +}