diff --git a/open_authoring_postgres_test.go b/open_authoring_postgres_test.go new file mode 100644 index 0000000..eaf8312 --- /dev/null +++ b/open_authoring_postgres_test.go @@ -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) + } + } + } + }) +}