194 lines
6.8 KiB
Go
194 lines
6.8 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/sm-quizzes-plugin/classes"
|
|
"git.golem15.com/golem15/sm-quizzes-plugin/models"
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
)
|
|
|
|
func call(t *testing.T, h http.HandlerFunc, method, target, body string, user uint, pathValues map[string]string) (int, map[string]any) {
|
|
t.Helper()
|
|
var req *http.Request
|
|
if body != "" {
|
|
req = httptest.NewRequest(method, target, strings.NewReader(body))
|
|
} else {
|
|
req = httptest.NewRequest(method, target, nil)
|
|
}
|
|
for k, v := range pathValues {
|
|
req.SetPathValue(k, v)
|
|
}
|
|
if user != 0 {
|
|
req = req.WithContext(bouncer.WithUser(req.Context(), &bouncer.Principal{ID: user}))
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
h(rec, req)
|
|
var out map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
|
t.Fatalf("%s %s: %v\n%s", method, target, err, rec.Body.String())
|
|
}
|
|
return rec.Code, out
|
|
}
|
|
|
|
func TestHandlersRequireAUser(t *testing.T) {
|
|
h := New(backpack.New(nil))
|
|
for name, fn := range map[string]http.HandlerFunc{
|
|
"list": h.ListQuizzes, "show": h.ShowQuiz, "start": h.StartAttempt, "answer": h.Answer,
|
|
"finish": h.Finish, "attempt": h.ShowAttempt, "history": h.ListAttempts,
|
|
} {
|
|
code, out := call(t, fn, "POST", "/", "{}", 0, map[string]string{"id": "1", "slug": "x"})
|
|
if code != http.StatusUnauthorized || out["error"] != true {
|
|
t.Errorf("%s without a user = %d %v, want 401", name, code, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHandlersWithoutDatabase(t *testing.T) {
|
|
h := New(backpack.New(nil))
|
|
for name, fn := range map[string]http.HandlerFunc{
|
|
"list": h.ListQuizzes, "show": h.ShowQuiz, "start": h.StartAttempt, "finish": h.Finish,
|
|
"attempt": h.ShowAttempt, "history": h.ListAttempts,
|
|
} {
|
|
code, _ := call(t, fn, "POST", "/", "", 7, map[string]string{"id": "1", "slug": "x"})
|
|
if code != http.StatusServiceUnavailable {
|
|
t.Errorf("%s without a database = %d, want 503", name, code)
|
|
}
|
|
}
|
|
code, _ := call(t, h.Answer, "POST", "/", `{"question_id":1,"known":true}`, 7, map[string]string{"id": "1"})
|
|
if code != http.StatusServiceUnavailable {
|
|
t.Errorf("valid answer without a database = %d, want 503", code)
|
|
}
|
|
}
|
|
|
|
func TestAnswerValidation(t *testing.T) {
|
|
h := New(backpack.New(nil))
|
|
cases := []struct {
|
|
body string
|
|
fields []string
|
|
}{
|
|
{``, []string{"question_id"}},
|
|
{`[1,2]`, []string{"question_id"}},
|
|
{`{"question_id":0}`, []string{"question_id"}},
|
|
{`{"question_id":1.5}`, []string{"question_id"}},
|
|
{`{"question_id":"abc","answer_ids":7,"known":"perhaps"}`, []string{"question_id", "answer_ids", "known"}},
|
|
{`{"question_id":1,"answer_ids":[1,-2]}`, []string{"answer_ids.1"}},
|
|
}
|
|
for _, tc := range cases {
|
|
code, out := call(t, h.Answer, "POST", "/", tc.body, 7, map[string]string{"id": "1"})
|
|
errs, _ := out["errors"].(map[string]any)
|
|
if code != http.StatusUnprocessableEntity || len(errs) != len(tc.fields) {
|
|
t.Errorf("%s = %d %v, want 422 on %v", tc.body, code, out, tc.fields)
|
|
continue
|
|
}
|
|
for _, f := range tc.fields {
|
|
if errs[f] == nil {
|
|
t.Errorf("%s: no error on %s: %v", tc.body, f, errs)
|
|
}
|
|
}
|
|
if msg, _ := out["error"].(string); msg == "" {
|
|
t.Errorf("%s: no top-level error message: %v", tc.body, out)
|
|
}
|
|
}
|
|
code, out := call(t, h.StartAttempt, "POST", "/", `{"category":5}`, 7, map[string]string{"slug": "x"})
|
|
if code != http.StatusUnprocessableEntity || out["errors"].(map[string]any)["category"] == nil {
|
|
t.Errorf("start with a numeric category = %d %v", code, out)
|
|
}
|
|
}
|
|
|
|
func TestErrorKeysInRuleOrder(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
writeValidation(rec, map[string][]string{"known": {"k"}, "question_id": {"q"}, "zzz": {"z"}}, []string{"question_id", "known"})
|
|
if got := rec.Body.String(); got != `{"error":"q","errors":{"question_id":["q"],"known":["k"],"zzz":["z"]}}` {
|
|
t.Errorf("body = %s", got)
|
|
}
|
|
}
|
|
|
|
func TestWriteErrorMapping(t *testing.T) {
|
|
h := New(nil)
|
|
cases := []struct {
|
|
err error
|
|
slug string
|
|
code int
|
|
msg string
|
|
}{
|
|
{classes.ErrNotFound, "quiz", 404, "Quiz not found"},
|
|
{classes.ErrNotFound, "", 404, "Attempt not found"},
|
|
{classes.ErrCompleted, "", 409, apiMessages["attempt_completed"]},
|
|
{classes.ErrTimeLimit, "", 409, apiMessages["time_limit_exceeded"]},
|
|
{&classes.FieldError{Field: "question_id", Key: "question_not_in_quiz"}, "", 422, apiMessages["question_not_in_quiz"]},
|
|
}
|
|
for _, tc := range cases {
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
if tc.slug != "" {
|
|
req.SetPathValue("slug", tc.slug)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
h.writeError(rec, req, tc.err)
|
|
var out map[string]any
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
|
if rec.Code != tc.code || out["error"] != tc.msg {
|
|
t.Errorf("%v = %d %v, want %d %q", tc.err, rec.Code, out, tc.code, tc.msg)
|
|
}
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
h.writeError(rec, httptest.NewRequest("GET", "/", nil), http.ErrBodyNotAllowed)
|
|
if rec.Code != 500 {
|
|
t.Errorf("unknown error = %d, want 500", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestResultHidesSolutionsOfUnansweredQuestions(t *testing.T) {
|
|
expl := "why"
|
|
q := models.Question{ID: 1, Type: models.TypeSingle, Points: 1, Explanation: &expl,
|
|
Answers: []models.Answer{{ID: 10, Text: "a", IsCorrect: true}, {ID: 11, Text: "b"}}}
|
|
open := &classes.AttemptResult{
|
|
AttemptSummary: classes.AttemptSummary{Attempt: models.Attempt{ID: 5, StartedAt: time.Now(), Quiz: &models.Quiz{Slug: "s", PassingScore: 50}}},
|
|
Questions: []classes.QuestionResult{{Question: q}},
|
|
}
|
|
out := result(open)
|
|
row := out.Questions[0]
|
|
if row.Answered || row.CorrectAnswerIDs != nil || row.Explanation != nil || row.Answers[0].IsCorrect != nil {
|
|
t.Errorf("unanswered question in an open attempt reveals the solution: %+v", row)
|
|
}
|
|
if out.Passed != nil || out.CompletedAt != nil {
|
|
t.Errorf("open attempt has a result: %+v", out.attemptJSON)
|
|
}
|
|
|
|
done := time.Now()
|
|
pct := 40.0
|
|
open.Attempt.CompletedAt = &done
|
|
open.Attempt.ScorePercent = &pct
|
|
out = result(open)
|
|
row = out.Questions[0]
|
|
if row.CorrectAnswerIDs == nil || row.Explanation == nil || row.Answers[0].IsCorrect == nil || !*row.Answers[0].IsCorrect {
|
|
t.Errorf("completed attempt hides the solution: %+v", row)
|
|
}
|
|
if out.Passed == nil || *out.Passed {
|
|
t.Errorf("40%% of a 50%% quiz passed = %v", out.Passed)
|
|
}
|
|
b, _ := json.Marshal(out)
|
|
if !strings.Contains(string(b), `"answer_ids":[]`) || !strings.Contains(string(b), `"quiz_slug":"s"`) {
|
|
t.Errorf("json = %s", b)
|
|
}
|
|
}
|
|
|
|
func TestToBoolAndToInt(t *testing.T) {
|
|
for v, want := range map[any]bool{true: true, false: false, "1": true, "0": false, "true": true, json.Number("1"): true, json.Number("0"): false, 1.0: true} {
|
|
if toBool(v) != want {
|
|
t.Errorf("toBool(%v) != %v", v, want)
|
|
}
|
|
}
|
|
for v, want := range map[any]int64{json.Number("12"): 12, 3.0: 3, 4: 4, int64(5): 5, "6": 6, nil: 0} {
|
|
if toInt(v) != want {
|
|
t.Errorf("toInt(%v) != %v", v, want)
|
|
}
|
|
}
|
|
}
|