test: cover open question authoring against Postgres
This commit is contained in:
156
open_authoring_postgres_test.go
Normal file
156
open_authoring_postgres_test.go
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
package quizzes_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-quizzes-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/surf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestOpenAuthoringAgainstDatabase covers authoring open questions over the
|
||||||
|
// owner routes the MCP quiz tools bind (D-01, D-04, D-05, D-10): an import
|
||||||
|
// with an open question and a grader code no plugin registers, the owner
|
||||||
|
// and the student views of it (the rubric only in the owner view), the
|
||||||
|
// open-question rules of the question routes, the grader check of the quiz
|
||||||
|
// routes and the open-answer stats. Skipped under -short; it runs in the
|
||||||
|
// verification phase.
|
||||||
|
func TestOpenAuthoringAgainstDatabase(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Postgres test; runs in the verification phase")
|
||||||
|
}
|
||||||
|
app, plugins, gdb := migrated(t)
|
||||||
|
h, err := surf.Assemble(app, plugins)
|
||||||
|
must(t, err)
|
||||||
|
c := &client{t: t, h: h}
|
||||||
|
const prefix = "/api/v1/quizzes"
|
||||||
|
|
||||||
|
owner, _ := newUser(t, gdb, "owner@example.com")
|
||||||
|
var groupID uint
|
||||||
|
must(t, gdb.Raw(`INSERT INTO user_groups (name, code, permissions, created_at, updated_at)
|
||||||
|
VALUES ('Editors', 'editors', '{"golem15.quizzes.access_quizzes":1}', NOW(), NOW()) RETURNING id`).Scan(&groupID).Error)
|
||||||
|
must(t, gdb.Exec(`INSERT INTO users_groups (user_id, user_group_id) VALUES (?, ?)`, owner.ID, groupID).Error)
|
||||||
|
token := apiToken(t, gdb, owner.ID, "read", "write")
|
||||||
|
_, student := newUser(t, gdb, "student@example.com")
|
||||||
|
|
||||||
|
doc := `{"title":"Eseje","slug":"eseje","is_published":true,"grader":"ai","questions":[
|
||||||
|
{"text":"Wyjaśnij odstraszanie.","type":"open","points":2,"answer_text":"MODEL","rubric":"R-RUBRIC","max_length":300},
|
||||||
|
{"text":"Stolica?","type":"single","answers":[{"text":"Warszawa","is_correct":true},{"text":"Kraków"}]}]}`
|
||||||
|
|
||||||
|
var openID uint
|
||||||
|
t.Run("import and read back", func(t *testing.T) {
|
||||||
|
out := data(c.ok(http.StatusOK, "POST", prefix+"/import", token, doc))
|
||||||
|
if out["slug"] != "eseje" || out["questions"] != float64(2) {
|
||||||
|
t.Fatalf("import = %v", out)
|
||||||
|
}
|
||||||
|
show := data(c.ok(http.StatusOK, "GET", prefix+"/eseje", token, ""))
|
||||||
|
if show["grader"] != "ai" {
|
||||||
|
t.Errorf("owner grader = %v, want ai (kept although no plugin registers it)", show["grader"])
|
||||||
|
}
|
||||||
|
qs := show["questions"].([]any)
|
||||||
|
open := qs[0].(map[string]any)
|
||||||
|
openID = uint(open["id"].(float64))
|
||||||
|
if open["type"] != "open" || open["rubric"] != "R-RUBRIC" || open["max_length"] != float64(300) || open["answer_text"] != "MODEL" {
|
||||||
|
t.Errorf("owner open question = %v", open)
|
||||||
|
}
|
||||||
|
if single := qs[1].(map[string]any); single["rubric"] != nil || single["max_length"] != nil {
|
||||||
|
t.Errorf("owner single question = %v, want null rubric and max_length", single)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, body := c.do("GET", apiPrefix+"/quizzes/eseje", student, "")
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("student quiz = %d %v", code, body)
|
||||||
|
}
|
||||||
|
if raw := fmt.Sprint(body); strings.Contains(raw, "R-RUBRIC") || strings.Contains(raw, "rubric") {
|
||||||
|
t.Errorf("student quiz carries the rubric: %v", body)
|
||||||
|
}
|
||||||
|
sq := data(body)["questions"].([]any)[0].(map[string]any)
|
||||||
|
if sq["max_length"] != float64(300) {
|
||||||
|
t.Errorf("student open question = %v, want max_length 300", sq)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("question rules", func(t *testing.T) {
|
||||||
|
code, body := c.do("POST", prefix+"/eseje/questions", token, `{"text":"Q","type":"open","answers":[{"text":"a","is_correct":true}]}`)
|
||||||
|
if msg, _ := body["message"].(string); code != http.StatusUnprocessableEntity || !strings.Contains(msg, "an open question has no answers") {
|
||||||
|
t.Errorf("open with answers = %d %v, want 422", code, body)
|
||||||
|
}
|
||||||
|
code, body = c.do("POST", prefix+"/eseje/questions", token, `{"text":"Q","type":"single","rubric":"R","answers":[{"text":"a","is_correct":true},{"text":"b"}]}`)
|
||||||
|
if msg, _ := body["message"].(string); code != http.StatusUnprocessableEntity || !strings.Contains(msg, "rubric applies to open questions only") {
|
||||||
|
t.Errorf("single with rubric = %d %v, want 422", code, body)
|
||||||
|
}
|
||||||
|
out := data(c.ok(http.StatusOK, "PATCH", fmt.Sprintf("%s/eseje/questions/%d", prefix, openID), token, `{"max_length":0}`))
|
||||||
|
if out["max_length"] != nil || out["rubric"] != "R-RUBRIC" {
|
||||||
|
t.Errorf("max_length 0 = %v, want it cleared and the rubric kept", out)
|
||||||
|
}
|
||||||
|
out = data(c.ok(http.StatusOK, "PATCH", fmt.Sprintf("%s/eseje/questions/%d", prefix, openID), token, `{"rubric":"","max_length":500}`))
|
||||||
|
if out["rubric"] != nil || out["max_length"] != float64(500) {
|
||||||
|
t.Errorf("rubric cleared, max_length 500 = %v", out)
|
||||||
|
}
|
||||||
|
var q models.Question
|
||||||
|
must(t, gdb.Where("id = ?", openID).Take(&q).Error)
|
||||||
|
if q.Rubric != nil || q.MaxLength == nil || *q.MaxLength != 500 {
|
||||||
|
t.Errorf("stored question = %+v", q)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("quiz grader", func(t *testing.T) {
|
||||||
|
code, body := c.do("POST", prefix, token, `{"title":"Z","grader":"zzz"}`)
|
||||||
|
if msg, _ := body["message"].(string); code != http.StatusUnprocessableEntity || !strings.Contains(msg, "manual") {
|
||||||
|
t.Errorf("unknown grader = %d %v, want 422 naming manual", code, body)
|
||||||
|
}
|
||||||
|
code, body = c.do("PATCH", prefix+"/eseje", token, `{"grader":"zzz"}`)
|
||||||
|
if code != http.StatusUnprocessableEntity {
|
||||||
|
t.Errorf("update to an unknown grader = %d %v, want 422", code, body)
|
||||||
|
}
|
||||||
|
out := data(c.ok(http.StatusOK, "PATCH", prefix+"/eseje", token, `{"grader":"manual"}`))
|
||||||
|
if out["grader"] != "manual" {
|
||||||
|
t.Errorf("grader manual = %v", out)
|
||||||
|
}
|
||||||
|
out = data(c.ok(http.StatusCreated, "POST", prefix, token, `{"title":"Domyślny"}`))
|
||||||
|
if out["grader"] != "manual" {
|
||||||
|
t.Errorf("default grader = %v, want manual", out["grader"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("stats count open answers", func(t *testing.T) {
|
||||||
|
finished := func(email, text string) uint {
|
||||||
|
t.Helper()
|
||||||
|
_, jwt := newUser(t, gdb, email)
|
||||||
|
att := data(c.ok(http.StatusCreated, "POST", apiPrefix+"/quizzes/eseje/attempts", jwt, ""))
|
||||||
|
id := uint(att["id"].(float64))
|
||||||
|
c.ok(http.StatusOK, "POST", fmt.Sprintf("%s/attempts/%d/answers", apiPrefix, id), jwt,
|
||||||
|
fmt.Sprintf(`{"question_id":%d,"text":%q}`, openID, text))
|
||||||
|
c.ok(http.StatusOK, "POST", fmt.Sprintf("%s/attempts/%d/finish", apiPrefix, id), jwt, "")
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
graded := finished("s1@example.com", "Odstraszanie to groźba odwetu.")
|
||||||
|
finished("s2@example.com", "Nie wiem.")
|
||||||
|
now := time.Now().UTC()
|
||||||
|
must(t, gdb.Model(&models.AttemptAnswer{}).Where("attempt_id = ? AND question_id = ?", graded, openID).Updates(map[string]any{
|
||||||
|
"grading_status": models.GradingGraded, "grader": "ai", "grader_points": 1.5, "points": 1.5,
|
||||||
|
"graded_at": now, "updated_at": now,
|
||||||
|
}).Error)
|
||||||
|
|
||||||
|
out := data(c.ok(http.StatusOK, "GET", prefix+"/eseje/stats", token, ""))
|
||||||
|
if out["unreviewed"] != float64(2) {
|
||||||
|
t.Errorf("total unreviewed = %v, want 2", out["unreviewed"])
|
||||||
|
}
|
||||||
|
for _, row := range out["questions"].([]any) {
|
||||||
|
q := row.(map[string]any)
|
||||||
|
switch uint(q["id"].(float64)) {
|
||||||
|
case openID:
|
||||||
|
if q["unreviewed"] != float64(2) || q["average_points"] != 1.5 || q["most_chosen_wrong"] != nil || q["answered"] != float64(2) {
|
||||||
|
t.Errorf("open stats = %v, want unreviewed 2 and average_points 1.5 (the pending answer is not averaged)", q)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if q["unreviewed"] != float64(0) || q["average_points"] != nil {
|
||||||
|
t.Errorf("single stats = %v, want no open counts", q)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user