feat: list, show and confirm open answers over MCP
- classes/review.go: review queue, detail and Confirm, shared by MCP and the admin screen; AnswerReviewed and AttemptReviewed after commit - /api/v1/quiz-reviews routes behind the quizzes permission - quizzes_review_list, quizzes_review_show, quizzes_review_confirm
This commit is contained in:
175
controllers/api/review_json.go
Normal file
175
controllers/api/review_json.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package api
|
||||
|
||||
// The owner shapes of the review routes. They carry student emails, the
|
||||
// model answer, the rubric and grading errors, and are never used by a
|
||||
// student route; the student shapes live in json.go. Built field by field,
|
||||
// so a new column never leaks.
|
||||
|
||||
import (
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.golem15.com/golem15/sm-quizzes-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-quizzes-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/wire"
|
||||
)
|
||||
|
||||
type reviewQuizShape struct {
|
||||
ID uint `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
|
||||
type reviewQuestionShape struct {
|
||||
ID uint `json:"id"`
|
||||
Text string `json:"text"`
|
||||
Points int `json:"points"`
|
||||
}
|
||||
|
||||
type reviewDetailQuestionShape struct {
|
||||
reviewQuestionShape
|
||||
AnswerText *string `json:"answer_text"`
|
||||
Rubric *string `json:"rubric"`
|
||||
MaxLength int `json:"max_length"`
|
||||
}
|
||||
|
||||
type reviewAttemptShape struct {
|
||||
ID uint `json:"id"`
|
||||
Completed bool `json:"completed"`
|
||||
CompletedAt *wire.Time `json:"completed_at"`
|
||||
}
|
||||
|
||||
type reviewStudentShape struct {
|
||||
UserID uint `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Name *string `json:"name"`
|
||||
Surname *string `json:"surname"`
|
||||
}
|
||||
|
||||
type reviewerShape struct {
|
||||
UserID *uint `json:"user_id"`
|
||||
AdminID *uint `json:"admin_id"`
|
||||
}
|
||||
|
||||
// reviewRowShape is one row of GET /api/v1/quiz-reviews.
|
||||
type reviewRowShape struct {
|
||||
AnswerID uint `json:"answer_id"`
|
||||
Status string `json:"status"`
|
||||
Quiz reviewQuizShape `json:"quiz"`
|
||||
Question reviewQuestionShape `json:"question"`
|
||||
Attempt reviewAttemptShape `json:"attempt"`
|
||||
Student reviewStudentShape `json:"student"`
|
||||
Text string `json:"text"`
|
||||
TextTruncated bool `json:"text_truncated"`
|
||||
GraderPoints *float64 `json:"grader_points"`
|
||||
Points float64 `json:"points"`
|
||||
GradedAt *wire.Time `json:"graded_at"`
|
||||
ReviewedAt *wire.Time `json:"reviewed_at"`
|
||||
UpdatedAt wire.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// reviewDetailShape is GET /api/v1/quiz-reviews/{id} and every review
|
||||
// write: the row keys with the whole text, plus the solution and the
|
||||
// grading and review record.
|
||||
type reviewDetailShape struct {
|
||||
AnswerID uint `json:"answer_id"`
|
||||
Status string `json:"status"`
|
||||
Quiz reviewQuizShape `json:"quiz"`
|
||||
Question reviewDetailQuestionShape `json:"question"`
|
||||
Attempt reviewAttemptShape `json:"attempt"`
|
||||
Student reviewStudentShape `json:"student"`
|
||||
Text string `json:"text"`
|
||||
GraderPoints *float64 `json:"grader_points"`
|
||||
Points float64 `json:"points"`
|
||||
GradedAt *wire.Time `json:"graded_at"`
|
||||
ReviewedAt *wire.Time `json:"reviewed_at"`
|
||||
UpdatedAt wire.Time `json:"updated_at"`
|
||||
Grader *string `json:"grader"`
|
||||
GraderFeedback *string `json:"grader_feedback"`
|
||||
GradingError *string `json:"grading_error"`
|
||||
GradingAttempts int `json:"grading_attempts"`
|
||||
IsCorrect bool `json:"is_correct"`
|
||||
ReviewComment *string `json:"review_comment"`
|
||||
ReviewedBy reviewerShape `json:"reviewed_by"`
|
||||
}
|
||||
|
||||
func wireTimePtr(t *time.Time) *wire.Time {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
w := wireTime(*t)
|
||||
return &w
|
||||
}
|
||||
|
||||
func reviewQuiz(r classes.ReviewRow) reviewQuizShape {
|
||||
return reviewQuizShape{ID: r.QuizID, Slug: r.QuizSlug, Title: r.QuizTitle, Mode: quizMode(&models.Quiz{Mode: r.QuizMode})}
|
||||
}
|
||||
|
||||
func reviewAttempt(r classes.ReviewRow) reviewAttemptShape {
|
||||
return reviewAttemptShape{ID: r.AttemptID, Completed: r.AttemptCompletedAt != nil, CompletedAt: wireTimePtr(r.AttemptCompletedAt)}
|
||||
}
|
||||
|
||||
func reviewStudent(r classes.ReviewRow) reviewStudentShape {
|
||||
return reviewStudentShape{UserID: r.UserID, Email: r.Email, Name: r.Name, Surname: r.Surname}
|
||||
}
|
||||
|
||||
// excerpt keeps the first n runes of s and reports whether it cut.
|
||||
func excerpt(s string, n int) (string, bool) {
|
||||
if utf8.RuneCountInString(s) <= n {
|
||||
return s, false
|
||||
}
|
||||
return string([]rune(s)[:n]), true
|
||||
}
|
||||
|
||||
// reviewRowJSON is one queue row; the text is cut to the first
|
||||
// classes.ReviewExcerptRunes runes with text_truncated set.
|
||||
func reviewRowJSON(r classes.ReviewRow) reviewRowShape {
|
||||
text, cut := excerpt(r.Text, classes.ReviewExcerptRunes)
|
||||
return reviewRowShape{
|
||||
AnswerID: r.AnswerID,
|
||||
Status: r.Status,
|
||||
Quiz: reviewQuiz(r),
|
||||
Question: reviewQuestionShape{ID: r.QuestionID, Text: r.QuestionText, Points: r.QuestionPoints},
|
||||
Attempt: reviewAttempt(r),
|
||||
Student: reviewStudent(r),
|
||||
Text: text,
|
||||
TextTruncated: cut,
|
||||
GraderPoints: r.GraderPoints,
|
||||
Points: r.Points,
|
||||
GradedAt: wireTimePtr(r.GradedAt),
|
||||
ReviewedAt: wireTimePtr(r.ReviewedAt),
|
||||
UpdatedAt: wireTime(r.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
// reviewDetailJSON is one answer for review, owner view.
|
||||
func reviewDetailJSON(d classes.ReviewDetail) reviewDetailShape {
|
||||
r := d.ReviewRow
|
||||
return reviewDetailShape{
|
||||
AnswerID: r.AnswerID,
|
||||
Status: r.Status,
|
||||
Quiz: reviewQuiz(r),
|
||||
Question: reviewDetailQuestionShape{
|
||||
reviewQuestionShape: reviewQuestionShape{ID: r.QuestionID, Text: r.QuestionText, Points: r.QuestionPoints},
|
||||
AnswerText: d.AnswerText,
|
||||
Rubric: d.Rubric,
|
||||
MaxLength: d.MaxLength,
|
||||
},
|
||||
Attempt: reviewAttempt(r),
|
||||
Student: reviewStudent(r),
|
||||
Text: r.Text,
|
||||
GraderPoints: r.GraderPoints,
|
||||
Points: r.Points,
|
||||
GradedAt: wireTimePtr(r.GradedAt),
|
||||
ReviewedAt: wireTimePtr(r.ReviewedAt),
|
||||
UpdatedAt: wireTime(r.UpdatedAt),
|
||||
Grader: d.Grader,
|
||||
GraderFeedback: d.GraderFeedback,
|
||||
GradingError: d.GradingError,
|
||||
GradingAttempts: d.GradingAttempts,
|
||||
IsCorrect: d.IsCorrect,
|
||||
ReviewComment: d.ReviewComment,
|
||||
ReviewedBy: reviewerShape{UserID: d.ReviewedByUserID, AdminID: d.ReviewedByAdminID},
|
||||
}
|
||||
}
|
||||
145
controllers/api/reviews.go
Normal file
145
controllers/api/reviews.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package api
|
||||
|
||||
// The owner review routes under /api/v1/quiz-reviews, behind user.api_token,
|
||||
// the golem15.quizzes.manage throttle, a token scope and the access_quizzes
|
||||
// permission (D-13). The quizzes_review_* MCP tools bind them. Answers use
|
||||
// the owner shapes of review_json.go.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/sm-quizzes-plugin/classes"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/wire"
|
||||
)
|
||||
|
||||
// reviewConflict answers a review action the answer's state refuses: 409
|
||||
// with a stable code for clients to branch on.
|
||||
func reviewConflict(w http.ResponseWriter, message, code string) {
|
||||
wire.WriteJSON(w, http.StatusConflict, map[string]any{"error": true, "message": message, "code": code})
|
||||
}
|
||||
|
||||
// writeReview answers {data: data}, or maps err: the review conflicts are
|
||||
// 409s with a code, ErrNotFound a 404, everything else as writeManaged.
|
||||
func writeReview(w http.ResponseWriter, status int, data any, err error) {
|
||||
switch {
|
||||
case errors.Is(err, classes.ErrNoGraderScore):
|
||||
reviewConflict(w, "The answer has no grader score to confirm; set the score instead.", "no_grader_score")
|
||||
case errors.Is(err, classes.ErrNotFound):
|
||||
wire.WriteJSON(w, http.StatusNotFound, map[string]any{"error": true, "message": "Open answer, attempt or quiz not found."})
|
||||
default:
|
||||
writeManaged(w, status, data, err)
|
||||
}
|
||||
}
|
||||
|
||||
// queryID reads an optional id query value: empty is 0, anything but a
|
||||
// positive integer is a problem.
|
||||
func queryID(q url.Values, name string) (uint, []string) {
|
||||
raw := strings.TrimSpace(q.Get(name))
|
||||
if raw == "" {
|
||||
return 0, nil
|
||||
}
|
||||
id, err := strconv.ParseUint(raw, 10, 32)
|
||||
if err != nil || id == 0 {
|
||||
return 0, []string{name + " must be a positive integer"}
|
||||
}
|
||||
return uint(id), nil
|
||||
}
|
||||
|
||||
// reviewID reads the {id} path value, the answer id: anything but a
|
||||
// positive integer is the 404 of a missing answer.
|
||||
func reviewID(w http.ResponseWriter, r *http.Request) (uint, bool) {
|
||||
id, err := strconv.ParseUint(r.PathValue("id"), 10, 32)
|
||||
if err != nil || id == 0 {
|
||||
writeReview(w, 0, nil, classes.ErrNotFound)
|
||||
return 0, false
|
||||
}
|
||||
return uint(id), true
|
||||
}
|
||||
|
||||
// reviewer is the API token's user, who signs a review made over MCP.
|
||||
func (h *Handlers) reviewer(w http.ResponseWriter, r *http.Request) (classes.Reviewer, bool) {
|
||||
id, ok := h.user(w, r)
|
||||
return classes.Reviewer{UserID: id}, ok
|
||||
}
|
||||
|
||||
// ReviewList answers GET /api/v1/quiz-reviews?quiz=&status=&user_id=
|
||||
// &attempt_id=&page=&per_page=: the open answers, oldest change first,
|
||||
// paginated. status defaults to open (pending, graded and failed).
|
||||
func (h *Handlers) ReviewList(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
f := classes.ReviewFilter{QuizSlug: strings.TrimSpace(q.Get("quiz")), Status: strings.TrimSpace(q.Get("status"))}
|
||||
_, problems := classes.CheckReviewStatus(f.Status)
|
||||
var p []string
|
||||
f.UserID, p = queryID(q, "user_id")
|
||||
problems = append(problems, p...)
|
||||
f.AttemptID, p = queryID(q, "attempt_id")
|
||||
problems = append(problems, p...)
|
||||
if refuse(w, problems) {
|
||||
return
|
||||
}
|
||||
svc, ok := h.service(w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
page := queryInt(r, "page", 1, 1, 100000)
|
||||
perPage := queryInt(r, "per_page", 20, 1, 100)
|
||||
rows, total, err := svc.ReviewQueue(r.Context(), f, page, perPage)
|
||||
if err != nil {
|
||||
writeReview(w, 0, nil, err)
|
||||
return
|
||||
}
|
||||
out := make([]reviewRowShape, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, reviewRowJSON(row))
|
||||
}
|
||||
wire.WriteJSON(w, http.StatusOK, lagoon.Paginate(out, page, perPage, total))
|
||||
}
|
||||
|
||||
// ReviewShow answers GET /api/v1/quiz-reviews/{id}: one open answer with
|
||||
// its question, model answer, rubric, grading and review.
|
||||
func (h *Handlers) ReviewShow(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := reviewID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
svc, ok := h.service(w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
d, err := svc.ReviewItem(r.Context(), id)
|
||||
answerReview(w, d, err)
|
||||
}
|
||||
|
||||
// ReviewConfirm answers POST /api/v1/quiz-reviews/{id}/confirm: the
|
||||
// grader's score becomes final; 409 no_grader_score for a pending or
|
||||
// failed answer.
|
||||
func (h *Handlers) ReviewConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := reviewID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
by, ok := h.reviewer(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
svc, ok := h.service(w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
d, err := svc.Confirm(r.Context(), id, by)
|
||||
answerReview(w, d, err)
|
||||
}
|
||||
|
||||
// answerReview answers one reviewed answer, or maps err.
|
||||
func answerReview(w http.ResponseWriter, d *classes.ReviewDetail, err error) {
|
||||
if err != nil {
|
||||
writeReview(w, 0, nil, err)
|
||||
return
|
||||
}
|
||||
writeReview(w, http.StatusOK, reviewDetailJSON(*d), nil)
|
||||
}
|
||||
169
controllers/api/reviews_test.go
Normal file
169
controllers/api/reviews_test.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.golem15.com/golem15/sm-quizzes-plugin/classes"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
)
|
||||
|
||||
func reviewRowFixture(text string) classes.ReviewRow {
|
||||
at := time.Date(2026, 11, 9, 12, 0, 0, 0, time.UTC)
|
||||
gp := 1.5
|
||||
name := "Anna"
|
||||
return classes.ReviewRow{
|
||||
AnswerID: 41, Status: "graded",
|
||||
QuizID: 3, QuizSlug: "pojecia", QuizTitle: "Pojęcia", QuizMode: "",
|
||||
QuestionID: 7, QuestionText: "Czym jest odstraszanie?", QuestionPoints: 2,
|
||||
AttemptID: 9, AttemptCompletedAt: &at,
|
||||
UserID: 5, Email: "anna@example.test", Name: &name,
|
||||
Text: text, GraderPoints: &gp, Points: 1.5, GradedAt: &at, UpdatedAt: at,
|
||||
}
|
||||
}
|
||||
|
||||
func marshalMap(t *testing.T, v any) (string, map[string]any) {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw), out
|
||||
}
|
||||
|
||||
// TestReviewJSON: a queue row cuts the text to 300 runes and says so,
|
||||
// keeps a short text whole, and names its keys in snake_case; the detail
|
||||
// carries the whole text with the model answer, rubric and grading error.
|
||||
func TestReviewJSON(t *testing.T) {
|
||||
long := strings.Repeat("ż", 301)
|
||||
_, out := marshalMap(t, reviewRowJSON(reviewRowFixture(long)))
|
||||
if got := out["text"].(string); utf8.RuneCountInString(got) != 300 || out["text_truncated"] != true {
|
||||
t.Errorf("long text = %d runes, truncated %v; want 300, true", utf8.RuneCountInString(got), out["text_truncated"])
|
||||
}
|
||||
|
||||
short := strings.Repeat("a", 120)
|
||||
raw, out := marshalMap(t, reviewRowJSON(reviewRowFixture(short)))
|
||||
if out["text"] != short || out["text_truncated"] != false {
|
||||
t.Errorf("short text = %v, truncated %v; want whole, false", out["text"], out["text_truncated"])
|
||||
}
|
||||
for _, key := range []string{"answer_id", "status", "quiz", "question", "attempt", "student", "text", "text_truncated", "grader_points", "points", "graded_at", "reviewed_at", "updated_at"} {
|
||||
if _, ok := out[key]; !ok {
|
||||
t.Errorf("row lacks %q: %s", key, raw)
|
||||
}
|
||||
}
|
||||
if q := out["quiz"].(map[string]any); q["slug"] != "pojecia" || q["mode"] != "learning" {
|
||||
t.Errorf("quiz = %v, want slug pojecia and mode learning", q)
|
||||
}
|
||||
if a := out["attempt"].(map[string]any); a["id"] != float64(9) || a["completed"] != true {
|
||||
t.Errorf("attempt = %v, want id 9 completed", a)
|
||||
}
|
||||
if s := out["student"].(map[string]any); s["user_id"] != float64(5) || s["email"] != "anna@example.test" || s["name"] != "Anna" || s["surname"] != nil {
|
||||
t.Errorf("student = %v", s)
|
||||
}
|
||||
for _, leak := range []string{"rubric", "answer_text", "grading_error"} {
|
||||
if strings.Contains(raw, `"`+leak+`"`) {
|
||||
t.Errorf("row carries %q: %s", leak, raw)
|
||||
}
|
||||
}
|
||||
|
||||
model, rubric, failure := "model", "R-RUBRIC", "grader timed out"
|
||||
d := classes.ReviewDetail{
|
||||
ReviewRow: reviewRowFixture(long), AnswerText: &model, Rubric: &rubric, MaxLength: 2000,
|
||||
GradingError: &failure, GradingAttempts: 3,
|
||||
}
|
||||
raw, out = marshalMap(t, reviewDetailJSON(d))
|
||||
if got := out["text"].(string); got != long {
|
||||
t.Errorf("detail text has %d runes, want the whole 301", utf8.RuneCountInString(got))
|
||||
}
|
||||
q := out["question"].(map[string]any)
|
||||
if q["answer_text"] != "model" || q["rubric"] != "R-RUBRIC" || q["max_length"] != float64(2000) || q["points"] != float64(2) {
|
||||
t.Errorf("detail question = %v", q)
|
||||
}
|
||||
for _, key := range []string{"grader", "grader_feedback", "grading_error", "grading_attempts", "is_correct", "review_comment", "reviewed_by", "reviewed_at"} {
|
||||
if _, ok := out[key]; !ok {
|
||||
t.Errorf("detail lacks %q: %s", key, raw)
|
||||
}
|
||||
}
|
||||
if out["grading_error"] != failure || out["grading_attempts"] != float64(3) {
|
||||
t.Errorf("detail grading = %v / %v", out["grading_error"], out["grading_attempts"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestReviewErrorMapping: the review conflicts are 409s with a code, a
|
||||
// missing answer a 404, invalid input a 422, other errors an opaque 500.
|
||||
func TestReviewErrorMapping(t *testing.T) {
|
||||
for name, tc := range map[string]struct {
|
||||
err error
|
||||
code int
|
||||
key string
|
||||
}{
|
||||
"no grader score": {classes.ErrNoGraderScore, http.StatusConflict, "no_grader_score"},
|
||||
"not found": {classes.ErrNotFound, http.StatusNotFound, ""},
|
||||
"validation": {&classes.ValidationError{Problems: []string{"status must be one of"}}, http.StatusUnprocessableEntity, ""},
|
||||
"other": {errors.New("boom"), http.StatusInternalServerError, ""},
|
||||
} {
|
||||
rec := httptest.NewRecorder()
|
||||
writeReview(rec, 0, nil, tc.err)
|
||||
if rec.Code != tc.code {
|
||||
t.Errorf("%s = %d, want %d: %s", name, rec.Code, tc.code, rec.Body.String())
|
||||
continue
|
||||
}
|
||||
if tc.key != "" {
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
if out["code"] != tc.key || out["error"] != true || out["message"] == "" {
|
||||
t.Errorf("%s body = %v, want code %s", name, out, tc.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
writeReview(rec, http.StatusOK, map[string]any{"x": 1}, nil)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"data"`) {
|
||||
t.Errorf("success = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestReviewHandlersBeforeDatabase: bad filters are a 422 and a bad id a
|
||||
// 404 before the database; valid requests without one are a 503.
|
||||
func TestReviewHandlersBeforeDatabase(t *testing.T) {
|
||||
h := New(backpack.New(nil))
|
||||
for target, want := range map[string]string{
|
||||
"/?status=done": "status must be one of",
|
||||
"/?user_id=abc": "user_id must be a positive integer",
|
||||
"/?attempt_id=0": "attempt_id must be a positive integer",
|
||||
"/?attempt_id=-3": "attempt_id must be a positive integer",
|
||||
"/?user_id=99999999999": "user_id must be a positive integer",
|
||||
} {
|
||||
code, out := call(t, h.ReviewList, "GET", target, "", 7, nil)
|
||||
if msg, _ := out["message"].(string); code != http.StatusUnprocessableEntity || !strings.Contains(msg, want) {
|
||||
t.Errorf("list %s = %d %v, want 422 with %q", target, code, out, want)
|
||||
}
|
||||
}
|
||||
for _, id := range []string{"", "0", "abc"} {
|
||||
for name, fn := range map[string]http.HandlerFunc{"show": h.ReviewShow, "confirm": h.ReviewConfirm} {
|
||||
code, _ := call(t, fn, "POST", "/", "", 7, map[string]string{"id": id})
|
||||
if code != http.StatusNotFound {
|
||||
t.Errorf("%s id %q = %d, want 404", name, id, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
for name, fn := range map[string]http.HandlerFunc{"list": h.ReviewList, "show": h.ReviewShow, "confirm": h.ReviewConfirm} {
|
||||
code, _ := call(t, fn, "GET", "/?status=graded", "", 7, map[string]string{"id": "3"})
|
||||
if code != http.StatusServiceUnavailable {
|
||||
t.Errorf("%s without a database = %d, want 503", name, code)
|
||||
}
|
||||
}
|
||||
if code, _ := call(t, h.ReviewConfirm, "POST", "/", "", 0, map[string]string{"id": "3"}); code != http.StatusUnauthorized {
|
||||
t.Errorf("confirm without a user = %d, want 401", code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user