package quizzes_test import ( "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "testing" "time" "git.golem15.com/golem15/sm-quizzes-plugin/models" "git.golem15.com/golem15/summercms/modules/surf" "gorm.io/gorm" ) func strPtr(s string) *string { return &s } func intPtr(n int) *int { return &n } type fixture struct { quiz models.Quiz single, multi, selfQ models.Question singleRight, singleNo models.Answer multiA, multiB, multiC models.Answer } // seedQuiz writes a published quiz with a single, a multiple and a // self_check question. func seedQuiz(t *testing.T, gdb *gorm.DB, slug string, mutate func(*models.Quiz)) *fixture { t.Helper() f := &fixture{} f.quiz = models.Quiz{Title: "Świat i ład " + slug, Slug: slug, Description: strPtr("Opis"), Context: strPtr("acme.shop:course:12"), IsPublished: true, PassingScore: 50, RoundSize: intPtr(5)} if mutate != nil { mutate(&f.quiz) } must(t, gdb.Create(&f.quiz).Error) f.single = models.Question{QuizID: f.quiz.ID, Text: "Stolica Polski?", Type: models.TypeSingle, Category: strPtr("Geografia"), Explanation: strPtr("Warszawa od 1596."), SortOrder: 1, Points: 1} f.multi = models.Question{QuizID: f.quiz.ID, Text: "Członkowie NATO?", Type: models.TypeMultiple, Category: strPtr("Geografia"), SortOrder: 2, Points: 2} f.selfQ = models.Question{QuizID: f.quiz.ID, Text: "Czym jest odporność społeczna?", Type: models.TypeSelfCheck, Category: strPtr("Teoria"), AnswerText: strPtr("Zdolność społeczeństwa do..."), SortOrder: 3, Points: 1} for _, q := range []*models.Question{&f.single, &f.multi, &f.selfQ} { must(t, gdb.Create(q).Error) } f.singleRight = models.Answer{QuestionID: f.single.ID, Text: "Warszawa", IsCorrect: true, SortOrder: 1} f.singleNo = models.Answer{QuestionID: f.single.ID, Text: "Kraków", Explanation: strPtr("Dawna stolica."), SortOrder: 2} f.multiA = models.Answer{QuestionID: f.multi.ID, Text: "Polska", IsCorrect: true, SortOrder: 1} f.multiB = models.Answer{QuestionID: f.multi.ID, Text: "Szwecja", IsCorrect: true, SortOrder: 2} f.multiC = models.Answer{QuestionID: f.multi.ID, Text: "Szwajcaria", SortOrder: 3} for _, a := range []*models.Answer{&f.singleRight, &f.singleNo, &f.multiA, &f.multiB, &f.multiC} { must(t, gdb.Create(a).Error) } return f } func must(t *testing.T, err error) { t.Helper() if err != nil { t.Fatal(err) } } type client struct { t *testing.T h http.Handler } func (c *client) do(method, path, token, body string) (int, map[string]any) { c.t.Helper() var req *http.Request if body != "" { req = httptest.NewRequest(method, path, strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") } else { req = httptest.NewRequest(method, path, nil) } if token != "" { req.Header.Set("Authorization", "Bearer "+token) } rec := httptest.NewRecorder() c.h.ServeHTTP(rec, req) var out map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { c.t.Fatalf("%s %s: %d %v\n%s", method, path, rec.Code, err, rec.Body.String()) } return rec.Code, out } func (c *client) ok(want int, method, path, token, body string) map[string]any { c.t.Helper() code, out := c.do(method, path, token, body) if code != want { c.t.Fatalf("%s %s = %d, want %d: %v", method, path, code, want, out) } return out } func data(m map[string]any) map[string]any { d, _ := m["data"].(map[string]any); return d } func list(m map[string]any) []any { d, _ := m["data"].([]any); return d } const apiPrefix = "/_quizzes/api/v1" func TestQuizAPIFlow(t *testing.T) { app, plugins, gdb := migrated(t) h, err := surf.Assemble(app, plugins) must(t, err) c := &client{t: t, h: h} f := seedQuiz(t, gdb, "swiat", nil) seedQuiz(t, gdb, "szkic", func(q *models.Quiz) { q.IsPublished = false; q.Context = strPtr("other") }) other := seedQuiz(t, gdb, "inny", func(q *models.Quiz) { q.Context = strPtr("other") }) _, token := newUser(t, gdb, "ala@example.com") _, stranger := newUser(t, gdb, "ola@example.com") // No token: the jwt.auth guard answers. if code, _ := c.do("GET", apiPrefix+"/quizzes", "", ""); code != http.StatusUnauthorized { t.Fatalf("GET /quizzes without a token = %d, want 401", code) } // The list holds published quizzes only, filtered by context. rows := list(c.ok(200, "GET", apiPrefix+"/quizzes", token, "")) if len(rows) != 2 { t.Fatalf("GET /quizzes = %v, want the two published quizzes", rows) } rows = list(c.ok(200, "GET", apiPrefix+"/quizzes?context=acme.shop:course:12", token, "")) if len(rows) != 1 { t.Fatalf("GET /quizzes?context= = %v, want one", rows) } row := rows[0].(map[string]any) for key, want := range map[string]any{ "slug": "swiat", "question_count": float64(3), "passing_score": float64(50), "round_size": float64(5), "best_score_percent": nil, "attempts_count": float64(0), "time_limit_seconds": nil, "context": "acme.shop:course:12", } { if row[key] != want { t.Errorf("quiz row %s = %v, want %v", key, row[key], want) } } // The quiz payload never reveals a solution; only self_check flashcards carry answer_text. code, raw := c.do("GET", apiPrefix+"/quizzes/swiat", token, "") if code != 200 { t.Fatalf("GET /quizzes/swiat = %d", code) } body, _ := json.Marshal(raw) for _, leak := range []string{"is_correct", "explanation", "Warszawa od 1596"} { if strings.Contains(string(body), leak) { t.Errorf("GET /quizzes/swiat leaks %q: %s", leak, body) } } quiz := data(raw) for _, qi := range quiz["questions"].([]any) { qm := qi.(map[string]any) text, has := qm["answer_text"] if (qm["type"] == "self_check") != has { t.Errorf("question %v (%v): answer_text present = %v", qm["id"], qm["type"], has) } if has && text != "Zdolność społeczeństwa do..." { t.Errorf("self_check answer_text = %v", text) } } if qs := quiz["questions"].([]any); len(qs) != 3 || qs[0].(map[string]any)["id"] != float64(f.single.ID) { t.Errorf("questions = %v, want three in sort order", qs) } if cats := fmt.Sprint(quiz["categories"]); cats != "[Geografia Teoria]" { t.Errorf("categories = %s", cats) } quiz = data(c.ok(200, "GET", apiPrefix+"/quizzes/swiat?category=Teoria", token, "")) if qs := quiz["questions"].([]any); len(qs) != 1 || qs[0].(map[string]any)["category"] != "Teoria" { t.Errorf("?category=Teoria questions = %v", qs) } c.ok(404, "GET", apiPrefix+"/quizzes/szkic", token, "") c.ok(404, "GET", apiPrefix+"/quizzes/brak", token, "") // Start an attempt. att := data(c.ok(201, "POST", apiPrefix+"/quizzes/swiat/attempts", token, "")) id := int(att["id"].(float64)) if att["max_points"] != float64(4) || att["completed_at"] != nil || att["passed"] != nil || att["expires_at"] != nil { t.Errorf("new attempt = %v", att) } path := fmt.Sprintf("%s/attempts/%d", apiPrefix, id) // Answer, overwrite, answer again. fb := data(c.ok(200, "POST", path+"/answers", token, fmt.Sprintf(`{"question_id":%d,"answer_ids":[%d]}`, f.single.ID, f.singleNo.ID))) if fb["is_correct"] != false || fb["points"] != float64(0) || fb["explanation"] != "Warszawa od 1596." || fmt.Sprint(fb["correct_answer_ids"]) != fmt.Sprintf("[%d]", f.singleRight.ID) { t.Errorf("wrong single feedback = %v", fb) } fb = data(c.ok(200, "POST", path+"/answers", token, fmt.Sprintf(`{"question_id":%d,"answer_ids":[%d]}`, f.single.ID, f.singleRight.ID))) if fb["is_correct"] != true || fb["points"] != float64(1) { t.Errorf("right single feedback = %v", fb) } fb = data(c.ok(200, "POST", path+"/answers", token, fmt.Sprintf(`{"question_id":%d,"answer_ids":[%d]}`, f.multi.ID, f.multiA.ID))) if fb["is_correct"] != false || fb["points"] != float64(0) { t.Errorf("partial multiple feedback without partial credit = %v", fb) } fb = data(c.ok(200, "POST", path+"/answers", token, fmt.Sprintf(`{"question_id":%d,"known":true}`, f.selfQ.ID))) if fb["is_correct"] != true || fb["answer_text"] != "Zdolność społeczeństwa do..." || fmt.Sprint(fb["correct_answer_ids"]) != "[]" { t.Errorf("self_check feedback = %v", fb) } var n int64 must(t, gdb.Model(&models.AttemptAnswer{}).Where("attempt_id = ?", id).Count(&n).Error) if n != 3 { t.Errorf("attempt answers = %d, want 3 (answering again overwrites)", n) } // Rejections. for _, tc := range []struct { body string field string }{ {`{}`, "question_id"}, {`not json`, "question_id"}, {`{"question_id":"x"}`, "question_id"}, {`{"question_id":1,"answer_ids":"x"}`, "answer_ids"}, {`{"question_id":1,"answer_ids":["a"]}`, "answer_ids.0"}, {`{"question_id":1,"known":"maybe"}`, "known"}, {fmt.Sprintf(`{"question_id":%d,"answer_ids":[]}`, other.single.ID), "question_id"}, {fmt.Sprintf(`{"question_id":%d,"answer_ids":[%d]}`, f.single.ID, f.multiA.ID), "answer_ids"}, {fmt.Sprintf(`{"question_id":%d,"answer_ids":[%d,%d]}`, f.single.ID, f.singleRight.ID, f.singleNo.ID), "answer_ids"}, {fmt.Sprintf(`{"question_id":%d}`, f.single.ID), "answer_ids"}, {fmt.Sprintf(`{"question_id":%d}`, f.selfQ.ID), "known"}, } { code, out := c.do("POST", path+"/answers", token, tc.body) errs, _ := out["errors"].(map[string]any) if code != 422 || errs[tc.field] == nil || out["error"] == "" { t.Errorf("POST answers %s = %d %v, want 422 on %s", tc.body, code, out, tc.field) } } c.ok(404, "POST", path+"/answers", stranger, fmt.Sprintf(`{"question_id":%d,"known":true}`, f.selfQ.ID)) c.ok(404, "GET", path, stranger, "") c.ok(404, "POST", path+"/finish", stranger, "") c.ok(404, "GET", apiPrefix+"/attempts/999999", token, "") // An open attempt reveals the solution of answered questions only. res := data(c.ok(200, "GET", path, token, "")) if res["answered_count"] != float64(3) || res["completed_at"] != nil { t.Errorf("open result = %v", res) } // Finish. res = data(c.ok(200, "POST", path+"/finish", token, "")) if res["score_points"] != float64(2) || res["max_points"] != float64(4) || res["score_percent"] != float64(50) || res["passed"] != true { t.Errorf("finished result = %v", res) } qs := res["questions"].([]any) if len(qs) != 3 { t.Fatalf("result questions = %v", qs) } first := qs[0].(map[string]any) if first["answered"] != true || first["is_correct"] != true || first["points_earned"] != float64(1) || fmt.Sprint(first["answer_ids"]) != fmt.Sprintf("[%d]", f.singleRight.ID) { t.Errorf("first result question = %v", first) } if ans := first["answers"].([]any)[1].(map[string]any); ans["is_correct"] != false || ans["explanation"] != "Dawna stolica." { t.Errorf("result answer = %v", ans) } c.ok(409, "POST", path+"/finish", token, "") c.ok(409, "POST", path+"/answers", token, fmt.Sprintf(`{"question_id":%d,"known":true}`, f.selfQ.ID)) if got := data(c.ok(200, "GET", path, token, "")); got["score_percent"] != float64(50) { t.Errorf("GET finished attempt = %v", got) } // A second, unfinished attempt; history is newest first. att2 := data(c.ok(201, "POST", apiPrefix+"/quizzes/swiat/attempts", token, `{"category":"Teoria"}`)) if att2["max_points"] != float64(1) || att2["category"] != "Teoria" { t.Errorf("category attempt = %v", att2) } path2 := fmt.Sprintf("%s/attempts/%d", apiPrefix, int(att2["id"].(float64))) code, out := c.do("POST", path2+"/answers", token, fmt.Sprintf(`{"question_id":%d,"answer_ids":[%d]}`, f.single.ID, f.singleRight.ID)) if code != 422 { t.Errorf("answer outside the attempt's category = %d %v, want 422", code, out) } if got := data(c.ok(200, "GET", path2, token, "")); len(got["questions"].([]any)) != 1 { t.Errorf("category attempt result = %v", got) } c.ok(422, "POST", apiPrefix+"/quizzes/swiat/attempts", token, `{"category":"Brak"}`) c.ok(404, "POST", apiPrefix+"/quizzes/szkic/attempts", token, "") hist := c.ok(200, "GET", apiPrefix+"/attempts?quiz=swiat", token, "") items := list(hist) if len(items) != 2 || items[0].(map[string]any)["id"] != att2["id"] || items[1].(map[string]any)["id"] != float64(id) { t.Errorf("history = %v, want newest first", items) } if meta := hist["meta"].(map[string]any); meta["total"] != float64(2) { t.Errorf("history meta = %v", meta) } if items := list(c.ok(200, "GET", apiPrefix+"/attempts?quiz=inny", token, "")); len(items) != 0 { t.Errorf("history of another quiz = %v", items) } if items := list(c.ok(200, "GET", apiPrefix+"/attempts", stranger, "")); len(items) != 0 { t.Errorf("stranger history = %v", items) } // The list now carries the caller's statistics (completed attempts only). row = list(c.ok(200, "GET", apiPrefix+"/quizzes?context=acme.shop:course:12", token, ""))[0].(map[string]any) if row["best_score_percent"] != float64(50) || row["attempts_count"] != float64(1) { t.Errorf("quiz row after an attempt = %v", row) } row = list(c.ok(200, "GET", apiPrefix+"/quizzes?context=acme.shop:course:12", stranger, ""))[0].(map[string]any) if row["best_score_percent"] != nil || row["attempts_count"] != float64(0) { t.Errorf("stranger quiz row = %v", row) } } func TestQuizAPITimeLimitAndShuffle(t *testing.T) { app, plugins, gdb := migrated(t) h, err := surf.Assemble(app, plugins) must(t, err) c := &client{t: t, h: h} f := seedQuiz(t, gdb, "na-czas", func(q *models.Quiz) { q.TimeLimitSeconds = intPtr(60); q.ShuffleQuestions = true }) _, token := newUser(t, gdb, "ala@example.com") att := data(c.ok(201, "POST", apiPrefix+"/quizzes/na-czas/attempts", token, "")) if att["expires_at"] == nil { t.Errorf("timed attempt has no expires_at: %v", att) } id := int(att["id"].(float64)) path := fmt.Sprintf("%s/attempts/%d", apiPrefix, id) c.ok(200, "POST", path+"/answers", token, fmt.Sprintf(`{"question_id":%d,"answer_ids":[%d]}`, f.single.ID, f.singleRight.ID)) must(t, gdb.Model(&models.Attempt{}).Where("id = ?", id).Update("started_at", time.Now().Add(-2*time.Minute)).Error) c.ok(409, "POST", path+"/answers", token, fmt.Sprintf(`{"question_id":%d,"known":true}`, f.selfQ.ID)) // A late finish still counts the answers given in time. res := data(c.ok(200, "POST", path+"/finish", token, "")) if res["score_points"] != float64(1) || res["passed"] != false { t.Errorf("late finish = %v", res) } // Shuffled quizzes still return every question once. seen := map[float64]bool{} for _, q := range data(c.ok(200, "GET", apiPrefix+"/quizzes/na-czas", token, ""))["questions"].([]any) { seen[q.(map[string]any)["id"].(float64)] = true } if len(seen) != 3 { t.Errorf("shuffled questions = %v", seen) } } func TestCascadeDeletes(t *testing.T) { _, _, gdb := migrated(t) f := seedQuiz(t, gdb, "kaskada", nil) u, _ := newUser(t, gdb, "ala@example.com") a := models.Attempt{QuizID: f.quiz.ID, UserID: u.ID, StartedAt: time.Now()} must(t, gdb.Create(&a).Error) must(t, gdb.Create(&models.AttemptAnswer{AttemptID: a.ID, QuestionID: f.single.ID}).Error) must(t, gdb.Delete(&models.Question{}, f.single.ID).Error) count := func(model any) int64 { var n int64 must(t, gdb.Model(model).Count(&n).Error) return n } if n := count(&models.AttemptAnswer{}); n != 0 { t.Errorf("attempt answers after deleting the question = %d", n) } if n := count(&models.Answer{}); n != 3 { t.Errorf("answers after deleting a question = %d, want 3", n) } must(t, gdb.Delete(&models.Quiz{}, f.quiz.ID).Error) for _, m := range []any{&models.Question{}, &models.Answer{}, &models.Attempt{}} { if n := count(m); n != 0 { t.Errorf("%T rows after deleting the quiz = %d", m, n) } } }