Files
sm-quizzes-plugin/api_postgres_test.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)
}
}
}