Files
sm-quizzes-plugin/test_mode_postgres_test.go
2026-11-09 14:10:25 +01:00

309 lines
13 KiB
Go

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)
}
})
}