364 lines
15 KiB
Go
364 lines
15 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|