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) } } } }) }