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) } } }