feat: serve the quiz API under /_quizzes/api/v1 behind jwt.auth
This commit is contained in:
353
api_postgres_test.go
Normal file
353
api_postgres_test.go
Normal file
@@ -0,0 +1,353 @@
|
||||
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.
|
||||
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", "answer_text", "Warszawa od 1596"} {
|
||||
if strings.Contains(string(body), leak) {
|
||||
t.Errorf("GET /quizzes/swiat leaks %q: %s", leak, body)
|
||||
}
|
||||
}
|
||||
quiz := data(raw)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
486
classes/service.go
Normal file
486
classes/service.go
Normal file
@@ -0,0 +1,486 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math/rand/v2"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-quizzes-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// Errors the service returns for the API to map onto status codes.
|
||||
var (
|
||||
// ErrNotFound is a missing quiz or an attempt the caller does not own.
|
||||
ErrNotFound = errors.New("quizzes: not found")
|
||||
// ErrCompleted is a write to a finished attempt.
|
||||
ErrCompleted = errors.New("quizzes: attempt already completed")
|
||||
// ErrTimeLimit is an answer after the quiz's time limit ran out.
|
||||
ErrTimeLimit = errors.New("quizzes: time limit exceeded")
|
||||
)
|
||||
|
||||
// FieldError is an input error on one request field (a 422).
|
||||
type FieldError struct {
|
||||
Field string
|
||||
// Key is the golem15.quizzes::lang.api.* phrase of the message.
|
||||
Key string
|
||||
}
|
||||
|
||||
func (e *FieldError) Error() string { return "quizzes: invalid " + e.Field + ": " + e.Key }
|
||||
|
||||
// Service runs the quiz API on a database. Now defaults to time.Now.
|
||||
type Service struct {
|
||||
DB *gorm.DB
|
||||
Scoring ScoringConfig
|
||||
Now func() time.Time
|
||||
// Shuffle reorders questions of a quiz with shuffle_questions; it
|
||||
// defaults to math/rand.
|
||||
Shuffle func(n int, swap func(i, j int))
|
||||
}
|
||||
|
||||
func (s *Service) now() time.Time {
|
||||
if s.Now != nil {
|
||||
return s.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func (s *Service) db(ctx context.Context) *gorm.DB { return s.DB.WithContext(ctx) }
|
||||
|
||||
// QuizSummary is one row of the quiz list with the caller's statistics.
|
||||
type QuizSummary struct {
|
||||
Quiz models.Quiz
|
||||
QuestionCount int
|
||||
BestScorePercent *float64
|
||||
AttemptsCount int
|
||||
}
|
||||
|
||||
// ListQuizzes returns the published quizzes, optionally only those whose
|
||||
// context equals quizContext, ordered by title. AttemptsCount counts the
|
||||
// user's completed attempts and BestScorePercent is their best result.
|
||||
func (s *Service) ListQuizzes(ctx context.Context, userID uint, quizContext string) ([]QuizSummary, error) {
|
||||
q := s.db(ctx).Where("is_published = ?", true)
|
||||
if quizContext != "" {
|
||||
q = q.Where("context = ?", quizContext)
|
||||
}
|
||||
var quizzes []models.Quiz
|
||||
if err := q.Order("title ASC, id ASC").Find(&quizzes).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]QuizSummary, 0, len(quizzes))
|
||||
if len(quizzes) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
ids := make([]uint, len(quizzes))
|
||||
for i, quiz := range quizzes {
|
||||
ids[i] = quiz.ID
|
||||
}
|
||||
type countRow struct {
|
||||
QuizID uint
|
||||
N int
|
||||
}
|
||||
var counts []countRow
|
||||
if err := s.db(ctx).Model(&models.Question{}).Select("quiz_id, COUNT(*) AS n").
|
||||
Where("quiz_id IN ?", ids).Group("quiz_id").Scan(&counts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
type statRow struct {
|
||||
QuizID uint
|
||||
N int
|
||||
Best *float64
|
||||
}
|
||||
var stats []statRow
|
||||
if err := s.db(ctx).Model(&models.Attempt{}).Select("quiz_id, COUNT(*) AS n, MAX(score_percent) AS best").
|
||||
Where("quiz_id IN ? AND user_id = ? AND completed_at IS NOT NULL", ids, userID).
|
||||
Group("quiz_id").Scan(&stats).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
countBy := map[uint]int{}
|
||||
for _, c := range counts {
|
||||
countBy[c.QuizID] = c.N
|
||||
}
|
||||
statBy := map[uint]statRow{}
|
||||
for _, st := range stats {
|
||||
statBy[st.QuizID] = st
|
||||
}
|
||||
for _, quiz := range quizzes {
|
||||
st := statBy[quiz.ID]
|
||||
out = append(out, QuizSummary{Quiz: quiz, QuestionCount: countBy[quiz.ID], BestScorePercent: st.Best, AttemptsCount: st.N})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// QuizDetail is a published quiz with its questions and answers.
|
||||
type QuizDetail struct {
|
||||
Quiz models.Quiz
|
||||
Questions []models.Question
|
||||
Categories []string
|
||||
}
|
||||
|
||||
// GetQuiz loads a published quiz by slug with its questions in sort order
|
||||
// (shuffled when the quiz says so) and each question's answers. A non-empty
|
||||
// category keeps only that category's questions. Categories lists every
|
||||
// category of the quiz.
|
||||
func (s *Service) GetQuiz(ctx context.Context, slug, category string) (*QuizDetail, error) {
|
||||
quiz, err := s.publishedQuiz(ctx, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
questions, err := s.questions(ctx, quiz.ID, category)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if quiz.ShuffleQuestions {
|
||||
shuffle := s.Shuffle
|
||||
if shuffle == nil {
|
||||
shuffle = rand.Shuffle
|
||||
}
|
||||
shuffle(len(questions), func(i, j int) { questions[i], questions[j] = questions[j], questions[i] })
|
||||
}
|
||||
cats, err := s.categories(ctx, quiz.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &QuizDetail{Quiz: *quiz, Questions: questions, Categories: cats}, nil
|
||||
}
|
||||
|
||||
func (s *Service) publishedQuiz(ctx context.Context, slug string) (*models.Quiz, error) {
|
||||
var quiz models.Quiz
|
||||
err := s.db(ctx).Where("slug = ? AND is_published = ?", slug, true).Take(&quiz).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &quiz, nil
|
||||
}
|
||||
|
||||
func (s *Service) questions(ctx context.Context, quizID uint, category string) ([]models.Question, error) {
|
||||
q := s.db(ctx).Where("quiz_id = ?", quizID)
|
||||
if category != "" {
|
||||
q = q.Where("category = ?", category)
|
||||
}
|
||||
var questions []models.Question
|
||||
err := q.Preload("Answers", func(db *gorm.DB) *gorm.DB { return db.Order("sort_order ASC, id ASC") }).
|
||||
Order("sort_order ASC, id ASC").Find(&questions).Error
|
||||
return questions, err
|
||||
}
|
||||
|
||||
func (s *Service) categories(ctx context.Context, quizID uint) ([]string, error) {
|
||||
cats := []string{}
|
||||
err := s.db(ctx).Model(&models.Question{}).
|
||||
Where("quiz_id = ? AND category IS NOT NULL AND category <> ''", quizID).
|
||||
Distinct("category").Order("category").Pluck("category", &cats).Error
|
||||
return cats, err
|
||||
}
|
||||
|
||||
// StartAttempt starts an attempt at a published quiz for the user. A
|
||||
// non-empty category limits the attempt to that category's questions; a
|
||||
// category without questions is a FieldError on category.
|
||||
func (s *Service) StartAttempt(ctx context.Context, userID uint, slug, category string) (*models.Attempt, *models.Quiz, error) {
|
||||
quiz, err := s.publishedQuiz(ctx, slug)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
attempt := &models.Attempt{QuizID: quiz.ID, UserID: userID, StartedAt: s.now().UTC()}
|
||||
if category != "" {
|
||||
var n int64
|
||||
if err := s.db(ctx).Model(&models.Question{}).Where("quiz_id = ? AND category = ?", quiz.ID, category).Count(&n).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if n == 0 {
|
||||
return nil, nil, &FieldError{Field: "category", Key: "unknown_category"}
|
||||
}
|
||||
attempt.Category = &category
|
||||
}
|
||||
maxPoints, err := s.maxPoints(ctx, attempt)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
attempt.MaxPoints = maxPoints
|
||||
if err := s.db(ctx).Create(attempt).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return attempt, quiz, nil
|
||||
}
|
||||
|
||||
func (s *Service) maxPoints(ctx context.Context, a *models.Attempt) (int, error) {
|
||||
q := s.db(ctx).Model(&models.Question{}).Where("quiz_id = ?", a.QuizID)
|
||||
if a.Category != nil {
|
||||
q = q.Where("category = ?", *a.Category)
|
||||
}
|
||||
var total int
|
||||
err := q.Select("COALESCE(SUM(GREATEST(points, 0)), 0)").Scan(&total).Error
|
||||
return total, err
|
||||
}
|
||||
|
||||
// ownedAttempt loads the user's attempt with its quiz. Another user's
|
||||
// attempt is ErrNotFound, never a 403.
|
||||
func (s *Service) ownedAttempt(ctx context.Context, tx *gorm.DB, userID, attemptID uint, lock bool) (*models.Attempt, error) {
|
||||
var attempt models.Attempt
|
||||
q := tx.WithContext(ctx).Preload("Quiz").Where("id = ? AND user_id = ?", attemptID, userID)
|
||||
if lock {
|
||||
q = q.Clauses(clause.Locking{Strength: "UPDATE", Table: clause.Table{Name: clause.CurrentTable}})
|
||||
}
|
||||
err := q.Take(&attempt).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) || (err == nil && attempt.Quiz == nil) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &attempt, nil
|
||||
}
|
||||
|
||||
// Deadline is when an attempt's time runs out, or nil without a limit.
|
||||
func Deadline(a *models.Attempt) *time.Time {
|
||||
if a == nil || a.Quiz == nil || a.Quiz.TimeLimitSeconds == nil || *a.Quiz.TimeLimitSeconds <= 0 {
|
||||
return nil
|
||||
}
|
||||
d := a.StartedAt.Add(time.Duration(*a.Quiz.TimeLimitSeconds) * time.Second)
|
||||
return &d
|
||||
}
|
||||
|
||||
// AnswerInput is one POST /attempts/{id}/answers body.
|
||||
type AnswerInput struct {
|
||||
QuestionID uint
|
||||
AnswerIDs []uint
|
||||
Known *bool
|
||||
}
|
||||
|
||||
// Feedback is what the user learns after answering one question.
|
||||
type Feedback struct {
|
||||
Question models.Question
|
||||
Result ScoreResult
|
||||
CorrectAnswerIDs []uint
|
||||
}
|
||||
|
||||
// Answer records or overwrites the user's answer to one question of an
|
||||
// open attempt and returns the feedback. It checks, in order: the attempt
|
||||
// belongs to the user (ErrNotFound), is not finished (ErrCompleted), is
|
||||
// within the time limit (ErrTimeLimit), the question belongs to the attempt
|
||||
// (FieldError on question_id), and the answer fits the question type
|
||||
// (FieldError on answer_ids or known).
|
||||
func (s *Service) Answer(ctx context.Context, userID, attemptID uint, in AnswerInput) (*Feedback, error) {
|
||||
var fb *Feedback
|
||||
err := lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
|
||||
attempt, err := s.ownedAttempt(ctx, tx, userID, attemptID, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if attempt.CompletedAt != nil {
|
||||
return ErrCompleted
|
||||
}
|
||||
if d := Deadline(attempt); d != nil && s.now().After(*d) {
|
||||
return ErrTimeLimit
|
||||
}
|
||||
var question models.Question
|
||||
q := tx.WithContext(ctx).Preload("Answers", func(db *gorm.DB) *gorm.DB { return db.Order("sort_order ASC, id ASC") }).
|
||||
Where("id = ? AND quiz_id = ?", in.QuestionID, attempt.QuizID)
|
||||
if attempt.Category != nil {
|
||||
q = q.Where("category = ?", *attempt.Category)
|
||||
}
|
||||
err = q.Take(&question).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return &FieldError{Field: "question_id", Key: "question_not_in_quiz"}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
row := models.AttemptAnswer{AttemptID: attempt.ID, QuestionID: question.ID}
|
||||
switch question.Type {
|
||||
case models.TypeSelfCheck:
|
||||
if in.Known == nil {
|
||||
return &FieldError{Field: "known", Key: "known_required"}
|
||||
}
|
||||
known := *in.Known
|
||||
row.SelfCheckKnown = &known
|
||||
default:
|
||||
if in.AnswerIDs == nil {
|
||||
return &FieldError{Field: "answer_ids", Key: "answer_ids_required"}
|
||||
}
|
||||
ids := uniqueIDs(in.AnswerIDs)
|
||||
for _, id := range ids {
|
||||
if !slices.ContainsFunc(question.Answers, func(a models.Answer) bool { return a.ID == id }) {
|
||||
return &FieldError{Field: "answer_ids", Key: "answer_not_in_question"}
|
||||
}
|
||||
}
|
||||
if question.Type == models.TypeSingle && len(ids) > 1 {
|
||||
return &FieldError{Field: "answer_ids", Key: "single_one_answer"}
|
||||
}
|
||||
row.AnswerIDs = lagoon.Jsonable[[]uint]{Data: ids, Valid: true}
|
||||
}
|
||||
|
||||
correct := CorrectAnswerIDs(question)
|
||||
res := Score(ScoreInput{
|
||||
Type: question.Type,
|
||||
Points: question.Points,
|
||||
CorrectIDs: correct,
|
||||
ChosenIDs: row.AnswerIDs.Data,
|
||||
Known: row.SelfCheckKnown,
|
||||
}, s.Scoring)
|
||||
row.IsCorrect, row.Points = res.IsCorrect, res.Points
|
||||
now := s.now().UTC()
|
||||
row.CreatedAt, row.UpdatedAt = now, now
|
||||
if err := tx.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "attempt_id"}, {Name: "question_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"answer_ids", "self_check_known", "is_correct", "points", "updated_at"}),
|
||||
}).Create(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
fb = &Feedback{Question: question, Result: res, CorrectAnswerIDs: correct}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fb, nil
|
||||
}
|
||||
|
||||
// CorrectAnswerIDs lists the IDs of a question's correct answers in sort
|
||||
// order; it is empty for a self_check question.
|
||||
func CorrectAnswerIDs(q models.Question) []uint {
|
||||
out := []uint{}
|
||||
for _, a := range q.Answers {
|
||||
if a.IsCorrect {
|
||||
out = append(out, a.ID)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Finish completes an open attempt: it sums the recorded answers, floors
|
||||
// the total at 0 and stores the score and completion time. It is allowed
|
||||
// after the time limit, so a late finish still keeps the answers given in
|
||||
// time.
|
||||
func (s *Service) Finish(ctx context.Context, userID, attemptID uint) (*AttemptResult, error) {
|
||||
err := lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
|
||||
attempt, err := s.ownedAttempt(ctx, tx, userID, attemptID, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if attempt.CompletedAt != nil {
|
||||
return ErrCompleted
|
||||
}
|
||||
var points []float64
|
||||
if err := tx.WithContext(ctx).Model(&models.AttemptAnswer{}).Where("attempt_id = ?", attempt.ID).
|
||||
Pluck("points", &points).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
maxPoints, err := s.maxPoints(ctx, attempt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
score, percent := Totals(points, maxPoints)
|
||||
now := s.now().UTC()
|
||||
return tx.WithContext(ctx).Model(&models.Attempt{}).Where("id = ?", attempt.ID).Updates(map[string]any{
|
||||
"completed_at": now,
|
||||
"score_points": score,
|
||||
"max_points": maxPoints,
|
||||
"score_percent": percent,
|
||||
"updated_at": now,
|
||||
}).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.Result(ctx, userID, attemptID)
|
||||
}
|
||||
|
||||
// AttemptSummary is an attempt with its quiz and answered question count.
|
||||
type AttemptSummary struct {
|
||||
Attempt models.Attempt
|
||||
AnsweredCount int
|
||||
}
|
||||
|
||||
// QuestionResult is one question of an attempt with the user's answer.
|
||||
type QuestionResult struct {
|
||||
Question models.Question
|
||||
// Answer is nil when the question was not answered.
|
||||
Answer *models.AttemptAnswer
|
||||
}
|
||||
|
||||
// AttemptResult is an attempt with every question it covers.
|
||||
type AttemptResult struct {
|
||||
AttemptSummary
|
||||
Questions []QuestionResult
|
||||
}
|
||||
|
||||
// Result loads the user's attempt with every question it covers and the
|
||||
// answers given.
|
||||
func (s *Service) Result(ctx context.Context, userID, attemptID uint) (*AttemptResult, error) {
|
||||
attempt, err := s.ownedAttempt(ctx, s.DB, userID, attemptID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
category := ""
|
||||
if attempt.Category != nil {
|
||||
category = *attempt.Category
|
||||
}
|
||||
questions, err := s.questions(ctx, attempt.QuizID, category)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var answers []models.AttemptAnswer
|
||||
if err := s.db(ctx).Where("attempt_id = ?", attempt.ID).Find(&answers).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byQuestion := make(map[uint]*models.AttemptAnswer, len(answers))
|
||||
for i := range answers {
|
||||
byQuestion[answers[i].QuestionID] = &answers[i]
|
||||
}
|
||||
res := &AttemptResult{AttemptSummary: AttemptSummary{Attempt: *attempt, AnsweredCount: len(answers)}}
|
||||
for _, q := range questions {
|
||||
res.Questions = append(res.Questions, QuestionResult{Question: q, Answer: byQuestion[q.ID]})
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// History lists the user's attempts, newest first, optionally of one quiz
|
||||
// (by slug, published or not), one page at a time.
|
||||
func (s *Service) History(ctx context.Context, userID uint, quizSlug string, page, perPage int) ([]AttemptSummary, int64, error) {
|
||||
q := s.db(ctx).Model(&models.Attempt{}).Where("golem15_quizzes_attempts.user_id = ?", userID)
|
||||
if quizSlug != "" {
|
||||
q = q.Joins("JOIN golem15_quizzes_quizzes qz ON qz.id = golem15_quizzes_attempts.quiz_id").Where("qz.slug = ?", quizSlug)
|
||||
}
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var attempts []models.Attempt
|
||||
if err := q.Preload("Quiz").Order("golem15_quizzes_attempts.started_at DESC, golem15_quizzes_attempts.id DESC").
|
||||
Offset((page - 1) * perPage).Limit(perPage).Find(&attempts).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out := make([]AttemptSummary, 0, len(attempts))
|
||||
if len(attempts) == 0 {
|
||||
return out, total, nil
|
||||
}
|
||||
ids := make([]uint, len(attempts))
|
||||
for i, a := range attempts {
|
||||
ids[i] = a.ID
|
||||
}
|
||||
type countRow struct {
|
||||
AttemptID uint
|
||||
N int
|
||||
}
|
||||
var counts []countRow
|
||||
if err := s.db(ctx).Model(&models.AttemptAnswer{}).Select("attempt_id, COUNT(*) AS n").
|
||||
Where("attempt_id IN ?", ids).Group("attempt_id").Scan(&counts).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
countBy := map[uint]int{}
|
||||
for _, c := range counts {
|
||||
countBy[c.AttemptID] = c.N
|
||||
}
|
||||
for _, a := range attempts {
|
||||
out = append(out, AttemptSummary{Attempt: a, AnsweredCount: countBy[a.ID]})
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
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)} }
|
||||
49
go.mod
49
go.mod
@@ -3,32 +3,52 @@ module git.golem15.com/golem15/sm-quizzes-plugin
|
||||
go 1.27.0
|
||||
|
||||
require (
|
||||
git.golem15.com/golem15/sm-user-plugin v0.0.0
|
||||
git.golem15.com/golem15/summercms v0.0.0
|
||||
github.com/go-gormigrate/gormigrate/v2 v2.1.7
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
github.com/testcontainers/testcontainers-go v0.44.0
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0
|
||||
gorm.io/gorm v1.31.2
|
||||
)
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/containerd/platforms v0.2.1 // indirect
|
||||
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||
github.com/disintegration/imaging v1.6.2 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/go-connections v0.7.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/ebitengine/purego v0.10.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.1.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.4 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.19.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.10.0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/klauspost/compress v1.18.6 // indirect
|
||||
github.com/knadh/koanf/maps v0.1.2 // indirect
|
||||
github.com/knadh/koanf/parsers/yaml v1.1.1 // indirect
|
||||
github.com/knadh/koanf/providers/confmap v1.0.1 // indirect
|
||||
@@ -37,20 +57,45 @@ require (
|
||||
github.com/knadh/koanf/v2 v2.3.6 // indirect
|
||||
github.com/leodido/go-urn v1.5.0 // indirect
|
||||
github.com/lib/pq v1.12.3 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
|
||||
github.com/magiconair/properties v1.8.10 // indirect
|
||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/go-archive v0.2.0 // indirect
|
||||
github.com/moby/moby/api v1.55.0 // indirect
|
||||
github.com/moby/moby/client v0.5.0 // indirect
|
||||
github.com/moby/patternmatcher v0.6.1 // indirect
|
||||
github.com/moby/sys/sequential v0.7.0 // indirect
|
||||
github.com/moby/sys/user v0.4.0 // indirect
|
||||
github.com/moby/sys/userns v0.1.0 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/riverqueue/river v0.47.0 // indirect
|
||||
github.com/riverqueue/river/riverdriver v0.47.0 // indirect
|
||||
github.com/riverqueue/river/riverdriver/riverdatabasesql v0.47.0 // indirect
|
||||
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.47.0 // indirect
|
||||
github.com/riverqueue/river/rivershared v0.47.0 // indirect
|
||||
github.com/riverqueue/river/rivertype v0.47.0 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.26.6 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/spf13/cobra v1.10.2 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
github.com/stretchr/testify v1.12.1 // indirect
|
||||
github.com/tidwall/gjson v1.19.0 // indirect
|
||||
github.com/tidwall/match v1.2.0 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/tklauser/go-sysconf v0.4.0 // indirect
|
||||
github.com/tklauser/numcpus v0.12.0 // indirect
|
||||
github.com/wneessen/go-mail v0.8.1 // indirect
|
||||
github.com/yuin/goldmark v1.8.6 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
|
||||
go.opentelemetry.io/otel v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 // indirect
|
||||
@@ -74,3 +119,5 @@ require (
|
||||
)
|
||||
|
||||
replace git.golem15.com/golem15/summercms => ../../../../summercms.go
|
||||
|
||||
replace git.golem15.com/golem15/sm-user-plugin => ../user
|
||||
|
||||
24
go.sum
24
go.sum
@@ -16,6 +16,8 @@ cloud.google.com/go/storage v1.61.3 h1:VS//ZfBuPGDvakfD9xyPW1RGF1Vy3BWUoVZXgW1KM
|
||||
cloud.google.com/go/storage v1.61.3/go.mod h1:JtqK8BBB7TWv0HVGHubtUdzYYrakOQIsMLffZ2Z/HWk=
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
@@ -83,6 +85,8 @@ github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7np
|
||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
@@ -114,6 +118,7 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
@@ -128,6 +133,8 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
@@ -182,6 +189,8 @@ github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8S
|
||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
|
||||
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
|
||||
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
|
||||
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
||||
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
||||
@@ -227,6 +236,8 @@ github.com/riverqueue/river/rivershared v0.47.0 h1:jdtFsBexCvLqTXf8wnDnGXvB/eeOt
|
||||
github.com/riverqueue/river/rivershared v0.47.0/go.mod h1:w8Pi1T+6ypyko5/hs9Mv7IIIKo4fAL9eXYnkVV/Y418=
|
||||
github.com/riverqueue/river/rivertype v0.47.0 h1:SzNavtLGR4nMT1QkrEYQ7n96OMatYsn/z3aJWhewmv0=
|
||||
github.com/riverqueue/river/rivertype v0.47.0/go.mod h1:XKkcRQR6zm8RR/JQa1Q2ywpj8uXQu21quPa4Lpw1Xhw=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs=
|
||||
github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||
@@ -239,6 +250,8 @@ github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
@@ -247,10 +260,13 @@ github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGyc
|
||||
github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE=
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 h1:8fdv/9y3JMxjQ+ULAcOG8RtgeNu5t9XF9LolSXDuTwM=
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0/go.mod h1:CFr2LncGYokw+OKjXcr8ARCKG1SaC2UEnGxFBovE86g=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
|
||||
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
|
||||
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
@@ -303,6 +319,10 @@ golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
||||
@@ -336,3 +356,7 @@ gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
|
||||
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
|
||||
|
||||
200
harness_test.go
Normal file
200
harness_test.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package quizzes_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "git.golem15.com/golem15/sm-quizzes-plugin"
|
||||
_ "git.golem15.com/golem15/sm-user-plugin"
|
||||
usermodels "git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
"github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const testSecret = "quizzes-test-only-hs256-secret-with-32-bytes"
|
||||
|
||||
// The Docker tests run against a throwaway testcontainers Postgres, never a
|
||||
// developer or shared database. Under -short the container is not started
|
||||
// and the tests skip.
|
||||
var (
|
||||
pgContainer *postgres.PostgresContainer
|
||||
pgAdmin *sql.DB
|
||||
pgDSN string
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
if !isShort() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
err := startPostgres(ctx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "quizzes: testcontainers postgres: %v\n", err)
|
||||
stopPostgres()
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
code := m.Run()
|
||||
stopPostgres()
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func isShort() bool {
|
||||
for _, a := range os.Args {
|
||||
if a == "-test.short" || a == "-test.short=true" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func startPostgres(ctx context.Context) error {
|
||||
ctr, err := postgres.Run(ctx,
|
||||
"postgres:16-alpine",
|
||||
postgres.WithDatabase("quizzes"),
|
||||
postgres.WithUsername("quizzes"),
|
||||
postgres.WithPassword("quizzes"),
|
||||
postgres.BasicWaitStrategies(),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pgContainer = ctr
|
||||
dsn, err := ctr.ConnectionString(ctx, "sslmode=disable")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
_ = db.Close()
|
||||
return err
|
||||
}
|
||||
pgAdmin, pgDSN = db, dsn
|
||||
return nil
|
||||
}
|
||||
|
||||
func stopPostgres() {
|
||||
if pgAdmin != nil {
|
||||
_ = pgAdmin.Close()
|
||||
}
|
||||
if pgContainer != nil {
|
||||
_ = testcontainers.TerminateContainer(pgContainer)
|
||||
}
|
||||
}
|
||||
|
||||
// testDatabase creates a database for one test and drops it when the test
|
||||
// ends.
|
||||
func testDatabase(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
if testing.Short() {
|
||||
t.Skip("requires testcontainers postgres")
|
||||
}
|
||||
if pgAdmin == nil {
|
||||
t.Fatal("postgres unavailable: the container was not started")
|
||||
}
|
||||
name := "quizzes_" + strings.ToLower(strings.NewReplacer("/", "_", "-", "_", " ", "_").Replace(t.Name()))
|
||||
if len(name) > 60 {
|
||||
name = name[:60]
|
||||
}
|
||||
quoted := `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
|
||||
if _, err := pgAdmin.ExecContext(t.Context(), `CREATE DATABASE `+quoted+` TEMPLATE template0 ENCODING 'UTF8'`); err != nil {
|
||||
t.Fatalf("create %s: %v", name, err)
|
||||
}
|
||||
u, err := url.Parse(pgDSN)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u.Path = "/" + name
|
||||
db, err := sql.Open("pgx", u.String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = db.Close()
|
||||
_, _ = pgAdmin.ExecContext(context.Background(), `DROP DATABASE IF EXISTS `+quoted+` WITH (FORCE)`)
|
||||
})
|
||||
return db
|
||||
}
|
||||
|
||||
// testConfig is an application config with the HTTP limits, the user JWT
|
||||
// secret and the admin secret the activated plugins need.
|
||||
func testConfig(t *testing.T) *compass.Config {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
httpYAML := "body_limits:\n default_bytes: 1048576\n upload_bytes: 10485760\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, "http.yaml"), []byte(httpYAML), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := compass.Open(compass.Options{Dir: dir, Env: "testing", Environ: []string{
|
||||
"SUMMER_GOLEM15__USER__JWT__SECRET=" + testSecret,
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cfg.Set("admin.jwt.secret", "test-only-secret-with-at-least-32-bytes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// activate boots golem15.user and golem15.quizzes the way the generated
|
||||
// main does. With gdb it publishes the database first, as tests and serve do.
|
||||
func activate(t *testing.T, sqlDB *sql.DB, gdb *gorm.DB) (*backpack.App, []party.Plugin) {
|
||||
t.Helper()
|
||||
app := backpack.New(testConfig(t))
|
||||
if gdb != nil {
|
||||
if err := lagoon.Publish(app, sqlDB, gdb); err != nil {
|
||||
t.Fatalf("lagoon.Publish: %v", err)
|
||||
}
|
||||
}
|
||||
plugins, err := party.Activate(app, []string{"golem15.user", "golem15.quizzes"})
|
||||
if err != nil {
|
||||
t.Fatalf("Activate: %v", err)
|
||||
}
|
||||
return app, plugins
|
||||
}
|
||||
|
||||
// migrated activates both plugins on a fresh, migrated database.
|
||||
func migrated(t *testing.T) (*backpack.App, []party.Plugin, *gorm.DB) {
|
||||
t.Helper()
|
||||
sqlDB := testDatabase(t)
|
||||
gdb, err := lagoon.Use(t.Context(), sqlDB)
|
||||
if err != nil {
|
||||
t.Fatalf("lagoon.Use: %v", err)
|
||||
}
|
||||
app, plugins := activate(t, sqlDB, gdb)
|
||||
if err := lagoon.Migrate(gdb, plugins); err != nil {
|
||||
t.Fatalf("lagoon.Migrate: %v", err)
|
||||
}
|
||||
return app, plugins, gdb
|
||||
}
|
||||
|
||||
// newUser inserts an active user and returns it with a bearer token.
|
||||
func newUser(t *testing.T, gdb *gorm.DB, email string) (*usermodels.User, string) {
|
||||
t.Helper()
|
||||
u := &usermodels.User{Email: email, Password: "x", IsActivated: true}
|
||||
if err := gdb.Create(u).Error; err != nil {
|
||||
t.Fatalf("create user %s: %v", email, err)
|
||||
}
|
||||
token, _, err := bouncer.Mint(testSecret, fmt.Sprint(u.ID), "http://127.0.0.1/_user/api/v1/login", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return u, token
|
||||
}
|
||||
@@ -1 +1,14 @@
|
||||
{}
|
||||
plugin:
|
||||
name: Quizzes
|
||||
description: Quizzes with single, multiple and self-check questions, attempts and scoring.
|
||||
api:
|
||||
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.
|
||||
|
||||
14
lang/pl/lang.yaml
Normal file
14
lang/pl/lang.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
plugin:
|
||||
name: Quizy
|
||||
description: Quizy z pytaniami jednokrotnego i wielokrotnego wyboru oraz fiszkami, podejściami i punktacją.
|
||||
api:
|
||||
quiz_not_found: Nie znaleziono quizu
|
||||
attempt_not_found: Nie znaleziono podejścia
|
||||
attempt_completed: To podejście zostało już zakończone.
|
||||
time_limit_exceeded: Czas na to podejście minął.
|
||||
question_not_in_quiz: To pytanie nie należy do tego podejścia.
|
||||
answer_ids_required: Wybierz odpowiedź na to pytanie.
|
||||
answer_not_in_question: Wybrane odpowiedzi nie należą do tego pytania.
|
||||
single_one_answer: Wybierz dokładnie jedną odpowiedź.
|
||||
known_required: Zaznacz, czy znasz odpowiedź.
|
||||
unknown_category: Quiz nie ma pytań w tej kategorii.
|
||||
@@ -4,12 +4,14 @@ package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Attempt is one user's run through a quiz. The score columns are set when
|
||||
// the attempt is finished.
|
||||
// Attempt is one user's run through a quiz, or through one category of its
|
||||
// questions when Category is set. The score columns are set when the attempt
|
||||
// is finished.
|
||||
type Attempt struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
QuizID uint `gorm:"column:quiz_id"`
|
||||
UserID uint `gorm:"column:user_id"`
|
||||
Category *string `gorm:"column:category"`
|
||||
StartedAt time.Time `gorm:"column:started_at"`
|
||||
CompletedAt *time.Time `gorm:"column:completed_at"`
|
||||
ScorePoints float64 `gorm:"column:score_points"`
|
||||
|
||||
25
plugin.go
25
plugin.go
@@ -32,13 +32,28 @@ var langFS embed.FS
|
||||
var mailFS embed.FS
|
||||
|
||||
// Plugin is the golem15.quizzes plugin.
|
||||
type Plugin struct{}
|
||||
type Plugin struct {
|
||||
app *backpack.App
|
||||
}
|
||||
|
||||
func (p *Plugin) ID() string { return "golem15.quizzes" }
|
||||
func (p *Plugin) Requires() []string { return nil }
|
||||
func (p *Plugin) ID() string { return "golem15.quizzes" }
|
||||
|
||||
func (p *Plugin) Register(*backpack.App) error { return nil }
|
||||
func (p *Plugin) Boot(*backpack.App) error { return nil }
|
||||
// Requires golem15.user: the API runs behind its jwt.auth middleware and
|
||||
// attempts reference its users table.
|
||||
func (p *Plugin) Requires() []string { return []string{"golem15.user"} }
|
||||
|
||||
// Register keeps the application, so route handlers and commands can reach
|
||||
// its services, such as the database, when they run.
|
||||
func (p *Plugin) Register(app *backpack.App) error {
|
||||
p.app = app
|
||||
return nil
|
||||
}
|
||||
|
||||
// Boot keeps the application.
|
||||
func (p *Plugin) Boot(app *backpack.App) error {
|
||||
p.app = app
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) ConfigFS() fs.FS { return configFS }
|
||||
func (p *Plugin) LangFS() fs.FS { return langFS }
|
||||
|
||||
99
plugin_test.go
Normal file
99
plugin_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package quizzes_test
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
quizzes "git.golem15.com/golem15/sm-quizzes-plugin"
|
||||
"git.golem15.com/golem15/sm-quizzes-plugin/controllers/api"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
"git.golem15.com/golem15/summercms/modules/towel"
|
||||
)
|
||||
|
||||
func quizzesPlugin(t *testing.T, plugins []party.Plugin) party.Plugin {
|
||||
t.Helper()
|
||||
for _, p := range plugins {
|
||||
if p.ID() == "golem15.quizzes" {
|
||||
return p
|
||||
}
|
||||
}
|
||||
t.Fatal("golem15.quizzes was not activated")
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestPluginActivates(t *testing.T) {
|
||||
app, plugins := activate(t, nil, nil)
|
||||
p := quizzesPlugin(t, plugins)
|
||||
if _, ok := p.(*quizzes.Plugin); !ok {
|
||||
t.Fatalf("activated plugin is %T", p)
|
||||
}
|
||||
if got := p.Requires(); !slices.Equal(got, []string{"golem15.user"}) {
|
||||
t.Errorf("Requires = %v, want golem15.user", got)
|
||||
}
|
||||
if got := len(p.(pact.HasModels).Models()); got != 5 {
|
||||
t.Errorf("Models = %d, want 5", got)
|
||||
}
|
||||
if len(p.(pact.HasMigrations).Migrations()) == 0 {
|
||||
t.Error("no migrations")
|
||||
}
|
||||
if app.Config.Bool("golem15.quizzes.scoring.multiple_partial") {
|
||||
t.Error("scoring.multiple_partial defaults to true, want false")
|
||||
}
|
||||
if !app.Config.Has("golem15.quizzes.scoring.penalty_wrong") {
|
||||
t.Error("scoring.penalty_wrong has no default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoutesRegistered(t *testing.T) {
|
||||
app, plugins := activate(t, nil, nil)
|
||||
router, err := surf.BuildRouter(app, plugins)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildRouter: %v", err)
|
||||
}
|
||||
want := []string{
|
||||
"GET /_quizzes/api/v1/quizzes",
|
||||
"GET /_quizzes/api/v1/quizzes/{slug}",
|
||||
"POST /_quizzes/api/v1/quizzes/{slug}/attempts",
|
||||
"GET /_quizzes/api/v1/attempts",
|
||||
"GET /_quizzes/api/v1/attempts/{id}",
|
||||
"POST /_quizzes/api/v1/attempts/{id}/answers",
|
||||
"POST /_quizzes/api/v1/attempts/{id}/finish",
|
||||
}
|
||||
var got []string
|
||||
for _, r := range router.Routes() {
|
||||
if r.PluginID != "golem15.quizzes" {
|
||||
continue
|
||||
}
|
||||
got = append(got, r.Method+" "+r.Pattern)
|
||||
if !slices.Contains(r.Middleware, "jwt.auth") {
|
||||
t.Errorf("%s %s is not behind jwt.auth: %v", r.Method, r.Pattern, r.Middleware)
|
||||
}
|
||||
}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("routes = %v\nwant %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIMessagesAreTranslated(t *testing.T) {
|
||||
// party.Activate loads the plugins' lang trees into the translator.
|
||||
app, _ := activate(t, nil, nil)
|
||||
if _, ok := app.Lookup[*phrasebook.Translator](); !ok {
|
||||
t.Fatal("no translator after activation")
|
||||
}
|
||||
h := api.New(app)
|
||||
for locale, want := range map[string]string{"pl": "Nie znaleziono podejścia", "en": "Attempt not found"} {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req = req.WithContext(towel.WithLocale(bouncer.WithUser(req.Context(), &bouncer.Principal{ID: 1}), locale))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ShowAttempt(rec, req) // no {id}: 404
|
||||
if rec.Code != 404 || !strings.Contains(rec.Body.String(), want) {
|
||||
t.Errorf("%s: %d %s, want 404 with %q", locale, rec.Code, rec.Body.String(), want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ func generatedModels() []any {
|
||||
func generatedMigrations() []*gormigrate.Migration {
|
||||
return []*gormigrate.Migration{
|
||||
updates.CreateQuizzesTables(),
|
||||
updates.AddCategoryToAttempts(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
27
routes.go
27
routes.go
@@ -1,7 +1,30 @@
|
||||
package quizzes
|
||||
|
||||
import "git.golem15.com/golem15/summercms/modules/pact"
|
||||
import (
|
||||
"git.golem15.com/golem15/sm-quizzes-plugin/controllers/api"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
)
|
||||
|
||||
var _ pact.HasRoutes = (*Plugin)(nil)
|
||||
|
||||
func (p *Plugin) Routes(pact.Router) error { return nil }
|
||||
// Routes mounts the quiz API under /_quizzes/api/v1 behind golem15.user's
|
||||
// jwt.auth middleware.
|
||||
func (p *Plugin) Routes(r pact.Router) error {
|
||||
h := api.New(p.app)
|
||||
r.Group("/_quizzes/api/v1", surf.Use("jwt.auth"), func(g pact.Router) {
|
||||
g.Get("/quizzes", h.ListQuizzes)
|
||||
g.Get("/quizzes/{slug}", h.ShowQuiz)
|
||||
g.Where("slug", `[a-z0-9-]+`)
|
||||
g.Post("/quizzes/{slug}/attempts", h.StartAttempt)
|
||||
g.Where("slug", `[a-z0-9-]+`)
|
||||
g.Get("/attempts", h.ListAttempts)
|
||||
g.Get("/attempts/{id}", h.ShowAttempt)
|
||||
g.Where("id", `[0-9]+`)
|
||||
g.Post("/attempts/{id}/answers", h.Answer)
|
||||
g.Where("id", `[0-9]+`)
|
||||
g.Post("/attempts/{id}/finish", h.Finish)
|
||||
g.Where("id", `[0-9]+`)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
22
updates/20261002120625_add_category_to_attempts.go
Normal file
22
updates/20261002120625_add_category_to_attempts.go
Normal file
@@ -0,0 +1,22 @@
|
||||
// Code generated by summer make. DO NOT EDIT.
|
||||
|
||||
package updates
|
||||
|
||||
import (
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AddCategoryToAttempts returns the 20261002120625_add_category_to_attempts gormigrate entry.
|
||||
// An attempt started with a category covers only that category's questions.
|
||||
func AddCategoryToAttempts() *gormigrate.Migration {
|
||||
return &gormigrate.Migration{
|
||||
ID: "20261002120625_add_category_to_attempts",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return tx.Exec("ALTER TABLE golem15_quizzes_attempts ADD COLUMN category VARCHAR(255) NULL").Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.Exec("ALTER TABLE golem15_quizzes_attempts DROP COLUMN IF EXISTS category").Error
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user