feat: serve the quiz API under /_quizzes/api/v1 behind jwt.auth

This commit is contained in:
Jakub Zych
2026-10-02 14:13:30 +02:00
parent 4d4da5dc14
commit effe640549
16 changed files with 2232 additions and 11 deletions

353
api_postgres_test.go Normal file
View 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
View 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
View 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
}

View 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
View 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
View File

@@ -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
View File

@@ -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
View 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
}

View File

@@ -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
View 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.

View File

@@ -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"`

View File

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

View File

@@ -23,6 +23,7 @@ func generatedModels() []any {
func generatedMigrations() []*gormigrate.Migration {
return []*gormigrate.Migration{
updates.CreateQuizzesTables(),
updates.AddCategoryToAttempts(),
}
}

View File

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

View 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
},
}
}