- quizzes_create_quiz, update_quiz, delete_quiz (confirm repeats the slug), publish, unpublish, import_quiz (the existing import route) and quiz_stats on new manage routes - stats close expired open test attempts first, then report per student over completed whole-quiz attempts and per question over every completed attempt - input is checked before the database; TestManageAPIAgainstDatabase is written and skipped under -short
132 lines
4.5 KiB
Go
132 lines
4.5 KiB
Go
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)
|
|
}
|
|
migrations := p.(pact.HasMigrations).Migrations()
|
|
if len(migrations) == 0 {
|
|
t.Fatal("no migrations")
|
|
}
|
|
if got := migrations[len(migrations)-1].ID; got != "20261109120000_add_test_mode_to_quizzes" {
|
|
t.Errorf("last migration = %s, want 20261109120000_add_test_mode_to_quizzes", got)
|
|
}
|
|
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",
|
|
"POST /api/v1/quizzes/import",
|
|
"GET /api/v1/quizzes",
|
|
"GET /api/v1/quizzes/{slug}",
|
|
"POST /api/v1/quizzes",
|
|
"PATCH /api/v1/quizzes/{slug}",
|
|
"DELETE /api/v1/quizzes/{slug}",
|
|
"POST /api/v1/quizzes/{slug}/publish",
|
|
"POST /api/v1/quizzes/{slug}/unpublish",
|
|
"GET /api/v1/quizzes/{slug}/stats",
|
|
}
|
|
var got []string
|
|
for _, r := range router.Routes() {
|
|
if r.PluginID != "golem15.quizzes" {
|
|
continue
|
|
}
|
|
got = append(got, r.Method+" "+r.Pattern)
|
|
if r.Pattern == "/api/v1/quizzes/import" {
|
|
for _, mw := range []string{"user.api_token", "user.scope:write", "user.permission:golem15.quizzes.access_quizzes"} {
|
|
if !slices.Contains(r.Middleware, mw) {
|
|
t.Errorf("%s %s is not behind %s: %v", r.Method, r.Pattern, mw, r.Middleware)
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
if r.Pattern == "/api/v1/quizzes" || strings.HasPrefix(r.Pattern, "/api/v1/quizzes/") {
|
|
for _, mw := range []string{"user.api_token", "throttle:golem15.quizzes.manage", "user.permission:golem15.quizzes.access_quizzes"} {
|
|
if !slices.Contains(r.Middleware, mw) {
|
|
t.Errorf("%s %s is not behind %s: %v", r.Method, r.Pattern, mw, r.Middleware)
|
|
}
|
|
}
|
|
if !slices.Contains(r.Middleware, "user.scope:read") && !slices.Contains(r.Middleware, "user.scope:write") {
|
|
t.Errorf("%s %s has no token scope: %v", r.Method, r.Pattern, r.Middleware)
|
|
}
|
|
continue
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
}
|