diff --git a/api_postgres_test.go b/api_postgres_test.go new file mode 100644 index 0000000..b062147 --- /dev/null +++ b/api_postgres_test.go @@ -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) + } + } +} diff --git a/classes/service.go b/classes/service.go new file mode 100644 index 0000000..82803bb --- /dev/null +++ b/classes/service.go @@ -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 +} diff --git a/controllers/api/handlers.go b/controllers/api/handlers.go new file mode 100644 index 0000000..49fac7c --- /dev/null +++ b/controllers/api/handlers.go @@ -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., 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": , "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 +} diff --git a/controllers/api/handlers_test.go b/controllers/api/handlers_test.go new file mode 100644 index 0000000..478d8bb --- /dev/null +++ b/controllers/api/handlers_test.go @@ -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) + } + } +} diff --git a/controllers/api/json.go b/controllers/api/json.go new file mode 100644 index 0000000..d7e7ed9 --- /dev/null +++ b/controllers/api/json.go @@ -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)} } diff --git a/go.mod b/go.mod index 422f929..64e7217 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index d406917..ecad56f 100644 --- a/go.sum +++ b/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= diff --git a/harness_test.go b/harness_test.go new file mode 100644 index 0000000..f8bc64d --- /dev/null +++ b/harness_test.go @@ -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 +} diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index 0967ef4..646ae29 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -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. diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml new file mode 100644 index 0000000..37c1428 --- /dev/null +++ b/lang/pl/lang.yaml @@ -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. diff --git a/models/attempt.go b/models/attempt.go index f39853d..3560e57 100644 --- a/models/attempt.go +++ b/models/attempt.go @@ -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"` diff --git a/plugin.go b/plugin.go index 934ca3a..cbf4fbc 100644 --- a/plugin.go +++ b/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 } diff --git a/plugin_test.go b/plugin_test.go new file mode 100644 index 0000000..b72d903 --- /dev/null +++ b/plugin_test.go @@ -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) + } + } +} diff --git a/registry.gen.go b/registry.gen.go index b6a3557..c0cc1c8 100644 --- a/registry.gen.go +++ b/registry.gen.go @@ -23,6 +23,7 @@ func generatedModels() []any { func generatedMigrations() []*gormigrate.Migration { return []*gormigrate.Migration{ updates.CreateQuizzesTables(), + updates.AddCategoryToAttempts(), } } diff --git a/routes.go b/routes.go index 862e018..cbe9afa 100644 --- a/routes.go +++ b/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 +} diff --git a/updates/20261002120625_add_category_to_attempts.go b/updates/20261002120625_add_category_to_attempts.go new file mode 100644 index 0000000..048108a --- /dev/null +++ b/updates/20261002120625_add_category_to_attempts.go @@ -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 + }, + } +}