feat: serve the quiz API under /_quizzes/api/v1 behind jwt.auth
This commit is contained in:
459
controllers/api/handlers.go
Normal file
459
controllers/api/handlers.go
Normal file
@@ -0,0 +1,459 @@
|
||||
// Package api holds the golem15.quizzes JSON API handlers, mounted under
|
||||
// /_quizzes/api/v1 behind the jwt.auth middleware.
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/sm-quizzes-plugin/classes"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
"git.golem15.com/golem15/summercms/modules/wire"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Handlers serves the quiz API. It resolves the database and the scoring
|
||||
// config from the application on every request.
|
||||
type Handlers struct {
|
||||
App *backpack.App
|
||||
// Service, when set, replaces the per-request service (tests).
|
||||
Service func(*gorm.DB) *classes.Service
|
||||
}
|
||||
|
||||
// New returns the handlers for app.
|
||||
func New(app *backpack.App) *Handlers { return &Handlers{App: app} }
|
||||
|
||||
var (
|
||||
startAttemptRules = []lagoon.RequestRule{
|
||||
{Field: "category", Rules: lagoon.ParseRules("nullable|string|max:255")},
|
||||
}
|
||||
answerRules = []lagoon.RequestRule{
|
||||
{Field: "question_id", Rules: lagoon.ParseRules("required|integer|min:1")},
|
||||
{Field: "answer_ids", Rules: lagoon.ParseRules("nullable|array")},
|
||||
{Field: "answer_ids.*", Rules: lagoon.ParseRules("integer|min:1")},
|
||||
{Field: "known", Rules: lagoon.ParseRules("nullable|boolean")},
|
||||
}
|
||||
)
|
||||
|
||||
// ListQuizzes answers GET /quizzes?context=.
|
||||
func (h *Handlers) ListQuizzes(w http.ResponseWriter, r *http.Request) {
|
||||
userID, svc, ok := h.begin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := svc.ListQuizzes(r.Context(), userID, strings.TrimSpace(r.URL.Query().Get("context")))
|
||||
if err != nil {
|
||||
wire.WriteOpaque500(w)
|
||||
return
|
||||
}
|
||||
out := make([]quizSummaryJSON, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, quizSummary(row))
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": out})
|
||||
}
|
||||
|
||||
// ShowQuiz answers GET /quizzes/{slug}?category=.
|
||||
func (h *Handlers) ShowQuiz(w http.ResponseWriter, r *http.Request) {
|
||||
_, svc, ok := h.begin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
detail, err := svc.GetQuiz(r.Context(), r.PathValue("slug"), strings.TrimSpace(r.URL.Query().Get("category")))
|
||||
if err != nil {
|
||||
h.writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": quizDetail(detail)})
|
||||
}
|
||||
|
||||
// StartAttempt answers POST /quizzes/{slug}/attempts with an optional
|
||||
// {"category": "..."} body.
|
||||
func (h *Handlers) StartAttempt(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := h.user(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
input, ok := h.input(w, r, startAttemptRules)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
svc, ok := h.service(w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
category, _ := input["category"].(string)
|
||||
a, quiz, err := svc.StartAttempt(r.Context(), userID, r.PathValue("slug"), strings.TrimSpace(category))
|
||||
if err != nil {
|
||||
h.writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
a.Quiz = quiz
|
||||
wire.WriteJSON(w, http.StatusCreated, map[string]any{"data": attempt(classes.AttemptSummary{Attempt: *a})})
|
||||
}
|
||||
|
||||
// Answer answers POST /attempts/{id}/answers with
|
||||
// {"question_id": 1, "answer_ids": [2], "known": true}.
|
||||
func (h *Handlers) Answer(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := h.user(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
attemptID, ok := h.pathID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
input, ok := h.input(w, r, answerRules)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
svc, ok := h.service(w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
in := classes.AnswerInput{QuestionID: uint(toInt(input["question_id"]))}
|
||||
if raw, present := input["answer_ids"]; present && raw != nil {
|
||||
ids := []uint{}
|
||||
list, _ := raw.([]any) // the array rule already refused anything else
|
||||
for _, v := range list {
|
||||
ids = append(ids, uint(toInt(v)))
|
||||
}
|
||||
in.AnswerIDs = ids
|
||||
}
|
||||
if raw, present := input["known"]; present && raw != nil {
|
||||
known := toBool(raw)
|
||||
in.Known = &known
|
||||
}
|
||||
fb, err := svc.Answer(r.Context(), userID, attemptID, in)
|
||||
if err != nil {
|
||||
h.writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": feedback(fb)})
|
||||
}
|
||||
|
||||
// Finish answers POST /attempts/{id}/finish.
|
||||
func (h *Handlers) Finish(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := h.user(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
attemptID, ok := h.pathID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
svc, ok := h.service(w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
res, err := svc.Finish(r.Context(), userID, attemptID)
|
||||
if err != nil {
|
||||
h.writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": result(res)})
|
||||
}
|
||||
|
||||
// ShowAttempt answers GET /attempts/{id}.
|
||||
func (h *Handlers) ShowAttempt(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := h.user(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
attemptID, ok := h.pathID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
svc, ok := h.service(w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
res, err := svc.Result(r.Context(), userID, attemptID)
|
||||
if err != nil {
|
||||
h.writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": result(res)})
|
||||
}
|
||||
|
||||
// ListAttempts answers GET /attempts?quiz={slug}&page=&per_page=.
|
||||
func (h *Handlers) ListAttempts(w http.ResponseWriter, r *http.Request) {
|
||||
userID, svc, ok := h.begin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
page := queryInt(r, "page", 1, 1, 100000)
|
||||
perPage := queryInt(r, "per_page", 20, 1, 100)
|
||||
rows, total, err := svc.History(r.Context(), userID, strings.TrimSpace(r.URL.Query().Get("quiz")), page, perPage)
|
||||
if err != nil {
|
||||
wire.WriteOpaque500(w)
|
||||
return
|
||||
}
|
||||
out := make([]attemptJSON, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, attempt(row))
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusOK, lagoon.Paginate(out, page, perPage, total))
|
||||
}
|
||||
|
||||
// begin is user then service, for handlers without a body.
|
||||
func (h *Handlers) begin(w http.ResponseWriter, r *http.Request) (uint, *classes.Service, bool) {
|
||||
userID, ok := h.user(w, r)
|
||||
if !ok {
|
||||
return 0, nil, false
|
||||
}
|
||||
svc, ok := h.service(w)
|
||||
return userID, svc, ok
|
||||
}
|
||||
|
||||
// user reads the signed-in user that jwt.auth stored on the request. The
|
||||
// guard already answers 401 without a token; this covers a route mounted
|
||||
// without it.
|
||||
func (h *Handlers) user(w http.ResponseWriter, r *http.Request) (uint, bool) {
|
||||
p, ok := bouncer.User(r.Context())
|
||||
if !ok || p == nil || p.ID == 0 {
|
||||
wire.WriteJSON(w, http.StatusUnauthorized, map[string]any{"error": true, "message": "Unauthenticated"})
|
||||
return 0, false
|
||||
}
|
||||
return p.ID, true
|
||||
}
|
||||
|
||||
func (h *Handlers) service(w http.ResponseWriter) (*classes.Service, bool) {
|
||||
var gdb *gorm.DB
|
||||
if h.App != nil {
|
||||
gdb, _ = h.App.Lookup[*gorm.DB]()
|
||||
}
|
||||
if gdb == nil {
|
||||
wire.WriteJSON(w, http.StatusServiceUnavailable, map[string]any{"error": "Database unavailable"})
|
||||
return nil, false
|
||||
}
|
||||
if h.Service != nil {
|
||||
return h.Service(gdb), true
|
||||
}
|
||||
var scoring classes.ScoringConfig
|
||||
if h.App != nil {
|
||||
scoring = classes.ScoringConfigFrom(h.App.Config)
|
||||
}
|
||||
return &classes.Service{DB: gdb, Scoring: scoring}, true
|
||||
}
|
||||
|
||||
// input decodes a JSON body (an empty body is {}) and validates it. It
|
||||
// writes 413 for a body over the route limit and 422 for invalid input.
|
||||
func (h *Handlers) input(w http.ResponseWriter, r *http.Request, rules []lagoon.RequestRule) (map[string]any, bool) {
|
||||
input := map[string]any{}
|
||||
if r.Body != nil {
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.UseNumber()
|
||||
var decoded any
|
||||
err := dec.Decode(&decoded)
|
||||
var tooLarge *http.MaxBytesError
|
||||
switch {
|
||||
case errors.As(err, &tooLarge):
|
||||
wire.WriteJSON(w, http.StatusRequestEntityTooLarge, map[string]any{"error": "Payload too large"})
|
||||
return nil, false
|
||||
case err == nil:
|
||||
if m, ok := decoded.(map[string]any); ok {
|
||||
input = m
|
||||
}
|
||||
case errors.Is(err, io.EOF):
|
||||
default:
|
||||
// A malformed body validates as an empty one.
|
||||
}
|
||||
}
|
||||
errs, err := lagoon.ValidateRequest(r.Context(), nil, input, rules, h.translator())
|
||||
if err != nil {
|
||||
wire.WriteOpaque500(w)
|
||||
return nil, false
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
writeValidation(w, errs, lagoon.ErrorKeys(errs, rules))
|
||||
return nil, false
|
||||
}
|
||||
return input, true
|
||||
}
|
||||
|
||||
func (h *Handlers) translator() *phrasebook.Translator {
|
||||
if h.App == nil {
|
||||
return nil
|
||||
}
|
||||
tr, _ := h.App.Lookup[*phrasebook.Translator]()
|
||||
return tr
|
||||
}
|
||||
|
||||
// message translates golem15.quizzes::lang.api.<key>, falling back to the
|
||||
// English text when no translator or line is available.
|
||||
func (h *Handlers) message(ctx context.Context, key string) string {
|
||||
fallback := apiMessages[key]
|
||||
tr := h.translator()
|
||||
if tr == nil {
|
||||
return fallback
|
||||
}
|
||||
full := "golem15.quizzes::lang.api." + key
|
||||
s := tr.Get(ctx, full, nil)
|
||||
if s == "" || s == full {
|
||||
return fallback
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
var apiMessages = map[string]string{
|
||||
"quiz_not_found": "Quiz not found",
|
||||
"attempt_not_found": "Attempt not found",
|
||||
"attempt_completed": "The attempt is already completed.",
|
||||
"time_limit_exceeded": "The time limit of this attempt has been exceeded.",
|
||||
"question_not_in_quiz": "The question does not belong to this attempt.",
|
||||
"answer_ids_required": "The answer ids field is required for this question.",
|
||||
"answer_not_in_question": "The chosen answers do not belong to this question.",
|
||||
"single_one_answer": "Choose exactly one answer for this question.",
|
||||
"known_required": "The known field is required for this question.",
|
||||
"unknown_category": "The quiz has no questions in this category.",
|
||||
"validation_failed": "The given data was invalid.",
|
||||
}
|
||||
|
||||
// writeError maps a service error onto the API's status codes.
|
||||
func (h *Handlers) writeError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
var fe *classes.FieldError
|
||||
switch {
|
||||
case errors.As(err, &fe):
|
||||
msg := h.message(r.Context(), fe.Key)
|
||||
writeValidation(w, map[string][]string{fe.Field: {msg}}, []string{fe.Field})
|
||||
case errors.Is(err, classes.ErrNotFound):
|
||||
key := "attempt_not_found"
|
||||
if r.PathValue("slug") != "" {
|
||||
key = "quiz_not_found"
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusNotFound, map[string]any{"error": h.message(r.Context(), key)})
|
||||
case errors.Is(err, classes.ErrCompleted):
|
||||
wire.WriteJSON(w, http.StatusConflict, map[string]any{"error": h.message(r.Context(), "attempt_completed")})
|
||||
case errors.Is(err, classes.ErrTimeLimit):
|
||||
wire.WriteJSON(w, http.StatusConflict, map[string]any{"error": h.message(r.Context(), "time_limit_exceeded")})
|
||||
default:
|
||||
wire.WriteOpaque500(w)
|
||||
}
|
||||
}
|
||||
|
||||
// writeValidation writes the 422 body of the golem15.user API and Laravel:
|
||||
// {"error": <first message>, "errors": {field: [messages]}}, with the
|
||||
// fields in rule order.
|
||||
func writeValidation(w http.ResponseWriter, errs map[string][]string, order []string) {
|
||||
first := ""
|
||||
for _, k := range order {
|
||||
if msgs := errs[k]; len(msgs) > 0 {
|
||||
first = msgs[0]
|
||||
break
|
||||
}
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{
|
||||
"error": first,
|
||||
"errors": orderedErrors{errs: errs, order: order},
|
||||
})
|
||||
}
|
||||
|
||||
// orderedErrors marshals the errors object with its keys in rule order.
|
||||
type orderedErrors struct {
|
||||
errs map[string][]string
|
||||
order []string
|
||||
}
|
||||
|
||||
func (o orderedErrors) MarshalJSON() ([]byte, error) {
|
||||
var b strings.Builder
|
||||
b.WriteByte('{')
|
||||
seen := map[string]bool{}
|
||||
write := func(k string) error {
|
||||
msgs, ok := o.errs[k]
|
||||
if !ok || seen[k] {
|
||||
return nil
|
||||
}
|
||||
seen[k] = true
|
||||
if b.Len() > 1 {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
key, err := json.Marshal(k)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
val, err := json.Marshal(msgs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.Write(key)
|
||||
b.WriteByte(':')
|
||||
b.Write(val)
|
||||
return nil
|
||||
}
|
||||
for _, k := range o.order {
|
||||
if err := write(k); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for k := range o.errs {
|
||||
if err := write(k); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
b.WriteByte('}')
|
||||
return []byte(b.String()), nil
|
||||
}
|
||||
|
||||
func (h *Handlers) pathID(w http.ResponseWriter, r *http.Request) (uint, bool) {
|
||||
id, ok := surf.IntParam(r, "id")
|
||||
if !ok || id <= 0 {
|
||||
wire.WriteJSON(w, http.StatusNotFound, map[string]any{"error": h.message(r.Context(), "attempt_not_found")})
|
||||
return 0, false
|
||||
}
|
||||
return uint(id), true
|
||||
}
|
||||
|
||||
func queryInt(r *http.Request, name string, def, lo, hi int) int {
|
||||
n, err := strconv.Atoi(r.URL.Query().Get(name))
|
||||
if err != nil {
|
||||
n = def
|
||||
}
|
||||
return min(max(n, lo), hi)
|
||||
}
|
||||
|
||||
func toInt(v any) int64 {
|
||||
switch n := v.(type) {
|
||||
case json.Number:
|
||||
i, err := n.Int64()
|
||||
if err == nil {
|
||||
return i
|
||||
}
|
||||
f, _ := n.Float64()
|
||||
return int64(f)
|
||||
case float64:
|
||||
return int64(n)
|
||||
case int:
|
||||
return int64(n)
|
||||
case int64:
|
||||
return n
|
||||
case string:
|
||||
i, _ := strconv.ParseInt(n, 10, 64)
|
||||
return i
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// toBool follows Laravel's boolean rule: true, false, 1, 0, "1", "0".
|
||||
func toBool(v any) bool {
|
||||
switch b := v.(type) {
|
||||
case bool:
|
||||
return b
|
||||
case json.Number:
|
||||
return b.String() == "1"
|
||||
case string:
|
||||
return b == "1" || b == "true"
|
||||
case float64:
|
||||
return b == 1
|
||||
}
|
||||
return false
|
||||
}
|
||||
193
controllers/api/handlers_test.go
Normal file
193
controllers/api/handlers_test.go
Normal file
@@ -0,0 +1,193 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
270
controllers/api/json.go
Normal file
270
controllers/api/json.go
Normal file
@@ -0,0 +1,270 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-quizzes-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-quizzes-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/wire"
|
||||
)
|
||||
|
||||
// The response shapes are built field by field, so a column added to a
|
||||
// model never leaks into the API.
|
||||
|
||||
// quizSummaryJSON is one row of GET /quizzes.
|
||||
type quizSummaryJSON struct {
|
||||
ID uint `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Context *string `json:"context"`
|
||||
QuestionCount int `json:"question_count"`
|
||||
TimeLimitSeconds *int `json:"time_limit_seconds"`
|
||||
PassingScore int `json:"passing_score"`
|
||||
RoundSize *int `json:"round_size"`
|
||||
ShuffleQuestions bool `json:"shuffle_questions"`
|
||||
BestScorePercent *float64 `json:"best_score_percent"`
|
||||
AttemptsCount int `json:"attempts_count"`
|
||||
}
|
||||
|
||||
func quizSummary(s classes.QuizSummary) quizSummaryJSON {
|
||||
q := s.Quiz
|
||||
return quizSummaryJSON{
|
||||
ID: q.ID,
|
||||
Slug: q.Slug,
|
||||
Title: q.Title,
|
||||
Description: q.Description,
|
||||
Context: q.Context,
|
||||
QuestionCount: s.QuestionCount,
|
||||
TimeLimitSeconds: q.TimeLimitSeconds,
|
||||
PassingScore: q.PassingScore,
|
||||
RoundSize: q.RoundSize,
|
||||
ShuffleQuestions: q.ShuffleQuestions,
|
||||
BestScorePercent: s.BestScorePercent,
|
||||
AttemptsCount: s.AttemptsCount,
|
||||
}
|
||||
}
|
||||
|
||||
// quizJSON is GET /quizzes/{slug}: the quiz without any solution.
|
||||
type quizJSON struct {
|
||||
ID uint `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Context *string `json:"context"`
|
||||
TimeLimitSeconds *int `json:"time_limit_seconds"`
|
||||
PassingScore int `json:"passing_score"`
|
||||
RoundSize *int `json:"round_size"`
|
||||
ShuffleQuestions bool `json:"shuffle_questions"`
|
||||
QuestionCount int `json:"question_count"`
|
||||
Categories []string `json:"categories"`
|
||||
Questions []questionJSON `json:"questions"`
|
||||
}
|
||||
|
||||
// questionJSON is a question as the player sees it before answering: no
|
||||
// is_correct, explanation or answer_text.
|
||||
type questionJSON struct {
|
||||
ID uint `json:"id"`
|
||||
Text string `json:"text"`
|
||||
Type string `json:"type"`
|
||||
Category *string `json:"category"`
|
||||
Points int `json:"points"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
Answers []answerJSON `json:"answers"`
|
||||
}
|
||||
|
||||
type answerJSON struct {
|
||||
ID uint `json:"id"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
func quizDetail(d *classes.QuizDetail) quizJSON {
|
||||
q := d.Quiz
|
||||
out := quizJSON{
|
||||
ID: q.ID,
|
||||
Slug: q.Slug,
|
||||
Title: q.Title,
|
||||
Description: q.Description,
|
||||
Context: q.Context,
|
||||
TimeLimitSeconds: q.TimeLimitSeconds,
|
||||
PassingScore: q.PassingScore,
|
||||
RoundSize: q.RoundSize,
|
||||
ShuffleQuestions: q.ShuffleQuestions,
|
||||
QuestionCount: len(d.Questions),
|
||||
Categories: wire.Slice(d.Categories),
|
||||
Questions: make([]questionJSON, 0, len(d.Questions)),
|
||||
}
|
||||
for _, qu := range d.Questions {
|
||||
out.Questions = append(out.Questions, question(qu))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func question(q models.Question) questionJSON {
|
||||
out := questionJSON{
|
||||
ID: q.ID,
|
||||
Text: q.Text,
|
||||
Type: q.Type,
|
||||
Category: q.Category,
|
||||
Points: q.Points,
|
||||
SortOrder: q.SortOrder,
|
||||
Answers: make([]answerJSON, 0, len(q.Answers)),
|
||||
}
|
||||
for _, a := range q.Answers {
|
||||
out.Answers = append(out.Answers, answerJSON{ID: a.ID, Text: a.Text})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// attemptJSON is an attempt without its questions: POST
|
||||
// /quizzes/{slug}/attempts and each row of GET /attempts.
|
||||
type attemptJSON struct {
|
||||
ID uint `json:"id"`
|
||||
QuizID uint `json:"quiz_id"`
|
||||
QuizSlug string `json:"quiz_slug"`
|
||||
QuizTitle string `json:"quiz_title"`
|
||||
Category *string `json:"category"`
|
||||
StartedAt wire.Time `json:"started_at"`
|
||||
ExpiresAt *wire.Time `json:"expires_at"`
|
||||
CompletedAt *wire.Time `json:"completed_at"`
|
||||
AnsweredCount int `json:"answered_count"`
|
||||
ScorePoints float64 `json:"score_points"`
|
||||
MaxPoints int `json:"max_points"`
|
||||
ScorePercent *float64 `json:"score_percent"`
|
||||
PassingScore int `json:"passing_score"`
|
||||
Passed *bool `json:"passed"`
|
||||
}
|
||||
|
||||
func attempt(s classes.AttemptSummary) attemptJSON {
|
||||
a := s.Attempt
|
||||
out := attemptJSON{
|
||||
ID: a.ID,
|
||||
QuizID: a.QuizID,
|
||||
Category: a.Category,
|
||||
StartedAt: wireTime(a.StartedAt),
|
||||
AnsweredCount: s.AnsweredCount,
|
||||
ScorePoints: a.ScorePoints,
|
||||
MaxPoints: a.MaxPoints,
|
||||
ScorePercent: a.ScorePercent,
|
||||
}
|
||||
if a.Quiz != nil {
|
||||
out.QuizSlug, out.QuizTitle, out.PassingScore = a.Quiz.Slug, a.Quiz.Title, a.Quiz.PassingScore
|
||||
}
|
||||
if d := classes.Deadline(&a); d != nil {
|
||||
t := wireTime(*d)
|
||||
out.ExpiresAt = &t
|
||||
}
|
||||
if a.CompletedAt != nil {
|
||||
t := wireTime(*a.CompletedAt)
|
||||
out.CompletedAt = &t
|
||||
passed := a.ScorePercent != nil && *a.ScorePercent >= float64(out.PassingScore)
|
||||
out.Passed = &passed
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// resultJSON is GET /attempts/{id} and POST /attempts/{id}/finish.
|
||||
type resultJSON struct {
|
||||
attemptJSON
|
||||
Questions []questionResultJSON `json:"questions"`
|
||||
}
|
||||
|
||||
// questionResultJSON is one question of a result. The solution fields
|
||||
// (is_correct of the answers, correct_answer_ids, explanation, answer_text)
|
||||
// are revealed for an answered question and for every question once the
|
||||
// attempt is completed; otherwise they are null.
|
||||
type questionResultJSON struct {
|
||||
ID uint `json:"id"`
|
||||
Text string `json:"text"`
|
||||
Type string `json:"type"`
|
||||
Category *string `json:"category"`
|
||||
Points int `json:"points"`
|
||||
Answered bool `json:"answered"`
|
||||
AnswerIDs []uint `json:"answer_ids"`
|
||||
Known *bool `json:"known"`
|
||||
IsCorrect bool `json:"is_correct"`
|
||||
PointsEarned float64 `json:"points_earned"`
|
||||
CorrectAnswerIDs []uint `json:"correct_answer_ids"`
|
||||
Explanation *string `json:"explanation"`
|
||||
AnswerText *string `json:"answer_text"`
|
||||
Answers []resultAnswerJSON `json:"answers"`
|
||||
}
|
||||
|
||||
type resultAnswerJSON struct {
|
||||
ID uint `json:"id"`
|
||||
Text string `json:"text"`
|
||||
IsCorrect *bool `json:"is_correct"`
|
||||
Explanation *string `json:"explanation"`
|
||||
}
|
||||
|
||||
func result(r *classes.AttemptResult) resultJSON {
|
||||
out := resultJSON{attemptJSON: attempt(r.AttemptSummary), Questions: make([]questionResultJSON, 0, len(r.Questions))}
|
||||
completed := r.Attempt.CompletedAt != nil
|
||||
for _, qr := range r.Questions {
|
||||
q := qr.Question
|
||||
row := questionResultJSON{
|
||||
ID: q.ID,
|
||||
Text: q.Text,
|
||||
Type: q.Type,
|
||||
Category: q.Category,
|
||||
Points: q.Points,
|
||||
AnswerIDs: []uint{},
|
||||
Answers: make([]resultAnswerJSON, 0, len(q.Answers)),
|
||||
}
|
||||
if a := qr.Answer; a != nil {
|
||||
row.Answered = true
|
||||
row.AnswerIDs = wire.Slice(a.AnswerIDs.Data)
|
||||
row.Known = a.SelfCheckKnown
|
||||
row.IsCorrect = a.IsCorrect
|
||||
row.PointsEarned = a.Points
|
||||
}
|
||||
reveal := completed || row.Answered
|
||||
if reveal {
|
||||
row.CorrectAnswerIDs = classes.CorrectAnswerIDs(q)
|
||||
row.Explanation = q.Explanation
|
||||
row.AnswerText = q.AnswerText
|
||||
}
|
||||
for _, ans := range q.Answers {
|
||||
ra := resultAnswerJSON{ID: ans.ID, Text: ans.Text}
|
||||
if reveal {
|
||||
correct := ans.IsCorrect
|
||||
ra.IsCorrect = &correct
|
||||
ra.Explanation = ans.Explanation
|
||||
}
|
||||
row.Answers = append(row.Answers, ra)
|
||||
}
|
||||
out.Questions = append(out.Questions, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// feedbackJSON is POST /attempts/{id}/answers.
|
||||
type feedbackJSON struct {
|
||||
QuestionID uint `json:"question_id"`
|
||||
IsCorrect bool `json:"is_correct"`
|
||||
Points float64 `json:"points"`
|
||||
CorrectAnswerIDs []uint `json:"correct_answer_ids"`
|
||||
Explanation *string `json:"explanation"`
|
||||
AnswerText *string `json:"answer_text"`
|
||||
Answers []resultAnswerJSON `json:"answers"`
|
||||
}
|
||||
|
||||
func feedback(fb *classes.Feedback) feedbackJSON {
|
||||
q := fb.Question
|
||||
out := feedbackJSON{
|
||||
QuestionID: q.ID,
|
||||
IsCorrect: fb.Result.IsCorrect,
|
||||
Points: fb.Result.Points,
|
||||
CorrectAnswerIDs: wire.Slice(fb.CorrectAnswerIDs),
|
||||
Explanation: q.Explanation,
|
||||
AnswerText: q.AnswerText,
|
||||
Answers: make([]resultAnswerJSON, 0, len(q.Answers)),
|
||||
}
|
||||
for _, a := range q.Answers {
|
||||
correct := a.IsCorrect
|
||||
out.Answers = append(out.Answers, resultAnswerJSON{ID: a.ID, Text: a.Text, IsCorrect: &correct, Explanation: a.Explanation})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func wireTime(t time.Time) wire.Time { return wire.Time{Time: t.UTC().Truncate(time.Second)} }
|
||||
Reference in New Issue
Block a user