- Do oceny admin screen (golem15.quizzes.reviews) over the open answers, with a read-only context partial, score and comment virtual fields saved through SetScore, confirm and regrade record actions, bulk confirm - open question type, rubric and max_length in the question form; grader dropdown on the quiz form from the grader registry - Review model with quiz, status and student filters
212 lines
7.9 KiB
Go
212 lines
7.9 KiB
Go
package controllers
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"html/template"
|
|
"os"
|
|
"reflect"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.golem15.com/golem15/sm-quizzes-plugin/classes"
|
|
"git.golem15.com/golem15/sm-quizzes-plugin/graders"
|
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
)
|
|
|
|
func pointsDetail(t *testing.T, err error) string {
|
|
t.Helper()
|
|
var invalid *cabana.ValidationError
|
|
if !errors.As(err, &invalid) {
|
|
t.Fatalf("err = %v, want a cabana.ValidationError", err)
|
|
}
|
|
msgs, _ := invalid.Details[fieldReviewPoints].([]string)
|
|
if len(msgs) != 1 {
|
|
t.Fatalf("details = %v, want one message on review_points", invalid.Details)
|
|
}
|
|
return msgs[0]
|
|
}
|
|
|
|
func TestReviewValues(t *testing.T) {
|
|
points, comment, err := reviewValues(map[string]any{"review_points": "1.5", "review_comment": " dobrze "})
|
|
if err != nil || points == nil || *points != 1.5 || comment == nil || *comment != "dobrze" {
|
|
t.Errorf("1.5 + comment = %v, %v, %v", points, comment, err)
|
|
}
|
|
if points, _, err := reviewValues(map[string]any{"review_points": float64(2)}); err != nil || points == nil || *points != 2 {
|
|
t.Errorf("number 2 = %v, %v", points, err)
|
|
}
|
|
if points, _, err := reviewValues(map[string]any{"review_points": 2}); err != nil || points == nil || *points != 2 {
|
|
t.Errorf("int 2 = %v, %v", points, err)
|
|
}
|
|
if points, _, err := reviewValues(map[string]any{"review_points": "1,5"}); err != nil || points == nil || *points != 1.5 {
|
|
t.Errorf("decimal comma = %v, %v", points, err)
|
|
}
|
|
if points, comment, err := reviewValues(map[string]any{"review_points": 0.0, "review_comment": " "}); err != nil || points == nil || *points != 0 || comment != nil {
|
|
t.Errorf("zero without comment = %v, %v, %v", points, comment, err)
|
|
}
|
|
for _, values := range []map[string]any{nil, {}, {"review_points": "", "review_comment": ""}, {"review_points": nil}} {
|
|
if points, comment, err := reviewValues(values); points != nil || comment != nil || err != nil {
|
|
t.Errorf("%v = %v, %v, %v, want nothing to save", values, points, comment, err)
|
|
}
|
|
}
|
|
_, _, err = reviewValues(map[string]any{"review_comment": "bez punktów"})
|
|
if got := pointsDetail(t, err); got != reviewsLang+"points_required" {
|
|
t.Errorf("comment without points = %q", got)
|
|
}
|
|
for _, bad := range []any{"abc", "NaN", "Inf", true} {
|
|
_, _, err := reviewValues(map[string]any{"review_points": bad})
|
|
if got := pointsDetail(t, err); got != reviewsLang+"points_invalid" {
|
|
t.Errorf("%v = %q", bad, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReviewErrorMapping(t *testing.T) {
|
|
if got := pointsDetail(t, reviewError(&classes.ValidationError{Problems: []string{"points"}})); got != reviewsLang+"points_invalid" {
|
|
t.Errorf("validation = %q", got)
|
|
}
|
|
for err, key := range map[error]string{
|
|
classes.ErrNoGraderScore: "no_grader_score",
|
|
classes.ErrAlreadyReviewed: "already_reviewed",
|
|
classes.ErrGraderManual: "grader_manual",
|
|
classes.ErrNotFound: "not_found",
|
|
} {
|
|
var refused *cabana.ForbiddenError
|
|
if !errors.As(reviewError(err), &refused) || refused.Message != reviewsLang+key {
|
|
t.Errorf("%v = %v, want refusal %s", err, reviewError(err), key)
|
|
}
|
|
}
|
|
other := errors.New("db down")
|
|
if got := reviewError(other); got != other {
|
|
t.Errorf("other error = %v", got)
|
|
}
|
|
if reviewError(nil) != nil {
|
|
t.Error("nil error mapped")
|
|
}
|
|
}
|
|
|
|
func TestGraderDropdown(t *testing.T) {
|
|
saved := GraderOptions
|
|
t.Cleanup(func() { GraderOptions = saved })
|
|
|
|
GraderOptions = nil
|
|
want := []pact.Option{{Value: graders.Manual, Label: "golem15.quizzes::lang.graders.manual"}}
|
|
if got := (quizzesAdmin{}).DropdownOptions("grader"); !slices.Equal(got, want) {
|
|
t.Errorf("no hook = %v, want %v", got, want)
|
|
}
|
|
|
|
GraderOptions = func() []graders.Option {
|
|
return []graders.Option{{Code: graders.Manual, Label: graders.ManualLabel}, {Code: "ai", Label: "vendor.plugin::lang.grader"}}
|
|
}
|
|
want = []pact.Option{{Value: "manual", Label: graders.ManualLabel}, {Value: "ai", Label: "vendor.plugin::lang.grader"}}
|
|
if got := (quizzesAdmin{}).DropdownOptions("grader"); !slices.Equal(got, want) {
|
|
t.Errorf("hook = %v, want %v", got, want)
|
|
}
|
|
if got := (quizzesAdmin{}).DropdownOptions("mode"); got != nil {
|
|
t.Errorf("mode = %v, want nil", got)
|
|
}
|
|
}
|
|
|
|
func TestReviewContextViewIsPlain(t *testing.T) {
|
|
typ := reflect.TypeOf(reviewContextView{})
|
|
for i := range typ.NumField() {
|
|
f := typ.Field(i)
|
|
switch f.Type.Kind() {
|
|
case reflect.String, reflect.Bool,
|
|
reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
|
|
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
|
|
reflect.Float32, reflect.Float64:
|
|
default:
|
|
t.Errorf("field %s is %s; the view model holds strings, numbers and booleans only", f.Name, f.Type)
|
|
}
|
|
if f.Type.PkgPath() != "" {
|
|
t.Errorf("field %s has the named type %s", f.Name, f.Type)
|
|
}
|
|
}
|
|
|
|
name, surname := "Anna", "Nowak"
|
|
grader, feedback := "ai", "<b>ok</b>"
|
|
gp := 1.5
|
|
v := newReviewContextView(&classes.ReviewDetail{
|
|
ReviewRow: classes.ReviewRow{Status: "graded", QuizTitle: "Q", Email: "a@example.com", Name: &name, Surname: &surname, Text: "<script>x</script>", GraderPoints: &gp},
|
|
Grader: &grader,
|
|
GraderFeedback: &feedback,
|
|
}, nil)
|
|
if v.StudentName != "Anna Nowak" || v.StatusLabel != reviewsLang+"status_graded" || !v.HasGraderPoints || v.GraderPoints != 1.5 {
|
|
t.Errorf("view = %+v", v)
|
|
}
|
|
if v.GraderLabel != "ai" {
|
|
t.Errorf("unregistered grader label = %q, want the code", v.GraderLabel)
|
|
}
|
|
if v.Text != "<script>x</script>" || v.GraderFeedback != "<b>ok</b>" {
|
|
t.Errorf("texts are copied raw for the template to escape: %+v", v)
|
|
}
|
|
}
|
|
|
|
func TestReviewsControllerShape(t *testing.T) {
|
|
c := reviewsAdmin{}
|
|
if c.ID() != "golem15.quizzes.reviews" || c.ConfigDir() != "controllers/reviews" {
|
|
t.Errorf("controller = %s %s", c.ID(), c.ConfigDir())
|
|
}
|
|
if !slices.Equal(c.RequiredPermissions(), []string{"golem15.quizzes.access_quizzes"}) {
|
|
t.Errorf("permissions = %v", c.RequiredPermissions())
|
|
}
|
|
if !slices.Equal(c.FormVirtualFields(), []string{"review_points", "review_comment"}) {
|
|
t.Errorf("virtual fields = %v", c.FormVirtualFields())
|
|
}
|
|
var refused *cabana.ForbiddenError
|
|
if err := c.FormBeforeCreate(t.Context(), nil); !errors.As(err, &refused) {
|
|
t.Errorf("create = %v, want a refusal", err)
|
|
}
|
|
if err := c.FormBeforeDelete(t.Context(), nil); !errors.As(err, &refused) {
|
|
t.Errorf("delete = %v, want a refusal", err)
|
|
}
|
|
var names []string
|
|
for _, a := range c.AdminRecordActions() {
|
|
names = append(names, a.Name)
|
|
}
|
|
if !slices.Equal(names, []string{"confirm", "regrade"}) {
|
|
t.Errorf("record actions = %v", names)
|
|
}
|
|
if bulk := c.AdminBulkActions(); len(bulk) != 1 || bulk[0].Name != "confirm" {
|
|
t.Errorf("bulk actions = %+v", bulk)
|
|
}
|
|
if _, err := c.PartialData(t.Context(), "other", nil); err == nil {
|
|
t.Error("unknown partial gave no error")
|
|
}
|
|
}
|
|
|
|
func TestReviewContextEscapes(t *testing.T) {
|
|
src, err := os.ReadFile("reviews/_review_context.htm")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(string(src), "safe") || strings.Contains(string(src), "style=") {
|
|
t.Error("the partial must not mark text as safe or use inline styles")
|
|
}
|
|
tpl, err := template.New("review_context").Funcs(template.FuncMap{"trans": func(k string) string { return k }}).Parse(string(src))
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
var out bytes.Buffer
|
|
view := reviewContextView{
|
|
Text: "<script>alert(1)</script>", GraderFeedback: "<img src=x onerror=alert(1)>",
|
|
ReviewComment: "<b>c</b>", Rubric: "<i>r</i>", Grader: "ai", GraderLabel: "ai", Reviewed: true,
|
|
StatusLabel: reviewsLang + "status_reviewed",
|
|
}
|
|
if err := tpl.Execute(&out, map[string]any{"Data": view}); err != nil {
|
|
t.Fatalf("execute: %v", err)
|
|
}
|
|
html := out.String()
|
|
for _, raw := range []string{"<script>", "<img", "<b>c</b>", "<i>r</i>"} {
|
|
if strings.Contains(html, raw) {
|
|
t.Errorf("output carries %q unescaped", raw)
|
|
}
|
|
}
|
|
if !strings.Contains(html, "<script>") {
|
|
t.Errorf("student text not escaped: %s", html)
|
|
}
|
|
}
|