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", "ok" gp := 1.5 v := newReviewContextView(&classes.ReviewDetail{ ReviewRow: classes.ReviewRow{Status: "graded", QuizTitle: "Q", Email: "a@example.com", Name: &name, Surname: &surname, Text: "", 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 != "" || v.GraderFeedback != "ok" { 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: "", GraderFeedback: "", ReviewComment: "c", Rubric: "r", 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{"