test: cover test mode against Postgres
This commit is contained in:
308
test_mode_postgres_test.go
Normal file
308
test_mode_postgres_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user