package quizzes_test import ( "fmt" "net/http" "os" "strings" "testing" "time" "git.golem15.com/golem15/sm-quizzes-plugin/models" "git.golem15.com/golem15/summercms/modules/surf" ) // TestManageAPIAgainstDatabase covers the owner manage routes the MCP quiz // tools bind (D-13..D-16): access, quiz CRUD, publish, show with // solutions, stats with the lazy close of expired test attempts, delete // with confirm and the import route. Skipped under -short. func TestManageAPIAgainstDatabase(t *testing.T) { 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" editor, _ := newUser(t, gdb, "ala@example.com") stranger, _ := newUser(t, gdb, "ola@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 (?, ?)`, editor.ID, groupID).Error) token := apiToken(t, gdb, editor.ID, "read", "write") readOnly := apiToken(t, gdb, editor.ID, "read") t.Run("access", func(t *testing.T) { for name, tc := range map[string]struct { method, path, token string want int }{ "no token": {"GET", prefix, "", http.StatusUnauthorized}, "read token on a write": {"POST", prefix, readOnly, http.StatusForbidden}, "user without permission": {"GET", prefix, apiToken(t, gdb, stranger.ID, "read", "write"), http.StatusForbidden}, } { if code, _ := c.do(tc.method, tc.path, tc.token, `{"title":"X"}`); code != tc.want { t.Errorf("%s: %s %s = %d, want %d", name, tc.method, tc.path, code, tc.want) } } if code, _ := c.do("GET", prefix, readOnly, ""); code != http.StatusOK { t.Errorf("read token on a read = %d, want 200", code) } }) t.Run("create, update, publish and list", func(t *testing.T) { out := data(c.ok(http.StatusCreated, "POST", prefix, token, `{"title":"Pojęcia bezpieczeństwa","context":"BM2","mode":"test","max_attempts":2}`)) if out["slug"] != "pojecia-bezpieczenstwa" || out["is_published"] != false || out["mode"] != "test" || out["max_attempts"] != float64(2) || out["passing_score"] != float64(50) { t.Fatalf("created = %v", out) } if code, _ := c.do("POST", prefix, token, `{"title":"Inny","slug":"pojecia-bezpieczenstwa"}`); code != http.StatusConflict { t.Errorf("taken slug = %d, want 409", code) } out = data(c.ok(http.StatusOK, "PATCH", prefix+"/pojecia-bezpieczenstwa", token, `{"max_attempts":0,"description":"Opis"}`)) if out["max_attempts"] != nil || out["description"] != "Opis" || out["mode"] != "test" { t.Errorf("update clearing max_attempts = %v", out) } out = data(c.ok(http.StatusOK, "PATCH", prefix+"/pojecia-bezpieczenstwa", token, `{"description":""}`)) if out["description"] != nil { t.Errorf("empty description = %v, want null", out["description"]) } if code, _ := c.do("PATCH", prefix+"/pojecia-bezpieczenstwa", token, `{"is_published":true}`); code != http.StatusUnprocessableEntity { t.Errorf("update with is_published = %d, want 422", code) } for range 2 { if out := data(c.ok(http.StatusOK, "POST", prefix+"/pojecia-bezpieczenstwa/publish", token, "")); out["is_published"] != true { t.Errorf("publish = %v", out) } } data(c.ok(http.StatusOK, "POST", prefix+"/pojecia-bezpieczenstwa/unpublish", token, "")) c.ok(http.StatusCreated, "POST", prefix, token, `{"title":"Drugi"}`) rows := list(c.ok(http.StatusOK, "GET", prefix, token, "")) if len(rows) != 2 || rows[0].(map[string]any)["context"] != "BM2" || rows[0].(map[string]any)["is_published"] != false { t.Errorf("list = %v, want two unpublished quizzes, BM2 first", rows) } if rows := list(c.ok(http.StatusOK, "GET", prefix+"?context=BM2", token, "")); len(rows) != 1 { t.Errorf("context filter = %v", rows) } if code, _ := c.do("GET", prefix+"/nie-ma", token, ""); code != http.StatusNotFound { t.Errorf("missing quiz = %d, want 404", code) } }) f := seedQuiz(t, gdb, "statystyka", func(q *models.Quiz) { q.PassingScore = 60 }) t.Run("show includes the solutions", func(t *testing.T) { out := data(c.ok(http.StatusOK, "GET", prefix+"/statystyka", token, "")) qs, _ := out["questions"].([]any) if len(qs) != 3 || out["question_count"] != float64(3) { t.Fatalf("show = %v", out) } first := qs[0].(map[string]any) if first["explanation"] != "Warszawa od 1596." { t.Errorf("question explanation = %v", first["explanation"]) } answers := first["answers"].([]any) if answers[0].(map[string]any)["is_correct"] != true || answers[1].(map[string]any)["explanation"] != "Dawna stolica." { t.Errorf("answers = %v", answers) } if qs[2].(map[string]any)["answer_text"] != "Zdolność społeczeństwa do..." { t.Errorf("self_check answer_text = %v", qs[2]) } }) t.Run("stats", func(t *testing.T) { finish := func(email string, answers ...string) { t.Helper() _, jwt := newUser(t, gdb, email) att := data(c.ok(http.StatusCreated, "POST", apiPrefix+"/quizzes/statystyka/attempts", jwt, "")) path := fmt.Sprintf("%s/attempts/%d", apiPrefix, int(att["id"].(float64))) for _, body := range answers { c.ok(http.StatusOK, "POST", path+"/answers", jwt, body) } c.ok(http.StatusOK, "POST", path+"/finish", jwt, "") } right := fmt.Sprintf(`{"question_id":%d,"answer_ids":[%d]}`, f.single.ID, f.singleRight.ID) wrong := fmt.Sprintf(`{"question_id":%d,"answer_ids":[%d]}`, f.single.ID, f.singleNo.ID) multiWrong := fmt.Sprintf(`{"question_id":%d,"answer_ids":[%d,%d]}`, f.multi.ID, f.multiA.ID, f.multiC.ID) known := fmt.Sprintf(`{"question_id":%d,"known":true}`, f.selfQ.ID) finish("s1@example.com", right, multiWrong, known) finish("s2@example.com", wrong) finish("s3@example.com", right) out := data(c.ok(http.StatusOK, "GET", prefix+"/statystyka/stats", token, "")) students := out["students"].([]any) if len(students) != 3 { t.Fatalf("students = %v", students) } for _, s := range students { s := s.(map[string]any) if s["attempts"] != float64(1) || s["best_score_percent"] == nil || s["last_completed_at"] == nil { t.Errorf("student row = %v", s) } } qs := out["questions"].([]any) single := qs[0].(map[string]any) if single["answered"] != float64(3) || single["correct"] != float64(2) || single["correct_percent"] != 66.7 { t.Errorf("single question = %v", single) } if w, _ := single["most_chosen_wrong"].(map[string]any); w == nil || w["answer_id"] != float64(f.singleNo.ID) || w["count"] != float64(1) { t.Errorf("single most_chosen_wrong = %v", single["most_chosen_wrong"]) } if w, _ := qs[1].(map[string]any)["most_chosen_wrong"].(map[string]any); w == nil || w["answer_id"] != float64(f.multiC.ID) { t.Errorf("multiple most_chosen_wrong = %v", qs[1]) } if self := qs[2].(map[string]any); self["most_chosen_wrong"] != nil || self["correct"] != float64(1) { t.Errorf("self_check stats = %v", self) } }) t.Run("stats closes an expired test attempt", func(t *testing.T) { tq := seedQuiz(t, gdb, "test-wygasly", func(q *models.Quiz) { q.Mode = models.ModeTest; q.TimeLimitSeconds = intPtr(60) }) student, _ := newUser(t, gdb, "late@example.com") started := time.Now().UTC().Add(-10 * time.Minute).Truncate(time.Second) attempt := models.Attempt{QuizID: tq.quiz.ID, UserID: student.ID, StartedAt: started, MaxPoints: 4} must(t, gdb.Create(&attempt).Error) out := data(c.ok(http.StatusOK, "GET", prefix+"/test-wygasly/stats", token, "")) var closed models.Attempt must(t, gdb.Take(&closed, attempt.ID).Error) if closed.CompletedAt == nil || !closed.CompletedAt.Equal(started.Add(time.Minute)) { t.Errorf("expired attempt completed_at = %v, want the deadline %v", closed.CompletedAt, started.Add(time.Minute)) } if students := out["students"].([]any); len(students) != 1 || students[0].(map[string]any)["passed"] != false { t.Errorf("students after lazy close = %v", students) } }) t.Run("delete", func(t *testing.T) { code, body := c.do("DELETE", prefix+"/statystyka?confirm=inny", token, "") if code != http.StatusUnprocessableEntity || !strings.Contains(fmt.Sprint(body["message"]), "confirm") { t.Errorf("wrong confirm = %d %v", code, body) } out := data(c.ok(http.StatusOK, "DELETE", prefix+"/statystyka?confirm=statystyka", token, "")) if out["slug"] != "statystyka" || out["questions"] != float64(3) || out["attempts"] != float64(3) { t.Errorf("delete = %v", out) } var n int64 must(t, gdb.Model(&models.Attempt{}).Where("quiz_id = ?", f.quiz.ID).Count(&n).Error) if n != 0 { t.Errorf("attempts after delete = %d", n) } if code, _ := c.do("GET", prefix+"/statystyka", token, ""); code != http.StatusNotFound { t.Errorf("deleted quiz = %d, want 404", code) } }) t.Run("questions", func(t *testing.T) { const qp = prefix + "/pytania/questions" c.ok(http.StatusCreated, "POST", prefix, token, `{"title":"Pytania"}`) single := data(c.ok(http.StatusCreated, "POST", qp, token, `{"text":"Stolica?","type":"single","points":2,"answers":[{"text":"Warszawa","is_correct":true},{"text":"Kraków"},{"text":"Gdańsk"}]}`)) card := data(c.ok(http.StatusCreated, "POST", qp, token, `{"text":"Odporność?","type":"self_check","answer_text":"Zdolność"}`)) if single["sort_order"] != float64(1) || card["sort_order"] != float64(2) || len(single["answers"].([]any)) != 3 { t.Fatalf("created questions = %v %v", single, card) } if code, _ := c.do("POST", qp, token, `{"text":"Zła","type":"single","answers":[{"text":"a"},{"text":"b"}]}`); code != http.StatusUnprocessableEntity { t.Errorf("single without a correct answer = %d, want 422", code) } singleID := uint(single["id"].(float64)) cardID := uint(card["id"].(float64)) ans := single["answers"].([]any) warszawa := uint(ans[0].(map[string]any)["id"].(float64)) krakow := uint(ans[1].(map[string]any)["id"].(float64)) gdansk := uint(ans[2].(map[string]any)["id"].(float64)) // A student finishes an attempt with the right answer. c.ok(http.StatusOK, "POST", prefix+"/pytania/publish", token, "") _, jwt := newUser(t, gdb, "student@example.com") att := data(c.ok(http.StatusCreated, "POST", apiPrefix+"/quizzes/pytania/attempts", jwt, "")) attPath := fmt.Sprintf("%s/attempts/%d", apiPrefix, int(att["id"].(float64))) c.ok(http.StatusOK, "POST", attPath+"/answers", jwt, fmt.Sprintf(`{"question_id":%d,"answer_ids":[%d]}`, singleID, warszawa)) c.ok(http.StatusOK, "POST", attPath+"/answers", jwt, fmt.Sprintf(`{"question_id":%d,"known":true}`, cardID)) c.ok(http.StatusOK, "POST", attPath+"/finish", jwt, "") var before models.Attempt must(t, gdb.Take(&before, int(att["id"].(float64))).Error) // Kraków becomes the correct answer: Warszawa is kept (same id), // Gdańsk is deleted, Poznań is created. body := fmt.Sprintf(`{"text":"Stolica Małopolski?","answers":[{"id":%d,"text":"Kraków","is_correct":true},{"id":%d,"text":"Warszawa"},{"text":"Poznań"}]}`, krakow, warszawa) upd := data(c.ok(http.StatusOK, "PATCH", fmt.Sprintf("%s/%d", qp, singleID), token, body)) got := upd["answers"].([]any) if len(got) != 3 || got[0].(map[string]any)["id"] != float64(krakow) || got[1].(map[string]any)["id"] != float64(warszawa) || got[2].(map[string]any)["id"] == float64(gdansk) { t.Errorf("updated answers = %v", got) } var n int64 must(t, gdb.Model(&models.Answer{}).Where("id = ?", gdansk).Count(&n).Error) if n != 0 { t.Errorf("Gdańsk was not deleted") } var after models.Attempt must(t, gdb.Take(&after, before.ID).Error) if after.ScorePoints != before.ScorePoints || *after.ScorePercent != *before.ScorePercent { t.Errorf("attempt score changed by an edit: %v -> %v", before.ScorePoints, after.ScorePoints) } foreign := fmt.Sprintf(`{"answers":[{"id":%d,"text":"x","is_correct":true},{"text":"y"}]}`, gdansk) if code, _ := c.do("PATCH", fmt.Sprintf("%s/%d", qp, singleID), token, foreign); code != http.StatusUnprocessableEntity { t.Errorf("foreign answer id = %d, want 422", code) } if code, _ := c.do("PATCH", fmt.Sprintf("%s/%d", qp, 999999), token, `{"text":"x"}`); code != http.StatusNotFound { t.Errorf("missing question = %d, want 404", code) } // Reorder refuses a partial list and sets the order. if code, _ := c.do("PUT", qp+"/order", token, fmt.Sprintf(`{"question_ids":[%d]}`, cardID)); code != http.StatusUnprocessableEntity { t.Errorf("partial reorder = %d, want 422", code) } out := data(c.ok(http.StatusOK, "PUT", qp+"/order", token, fmt.Sprintf(`{"question_ids":[%d,%d]}`, cardID, singleID))) qs := out["questions"].([]any) if qs[0].(map[string]any)["id"] != float64(cardID) || qs[0].(map[string]any)["sort_order"] != float64(1) { t.Errorf("reordered = %v", qs) } // Deleting a question removes the attempt answers given to it; the // attempt keeps its score. del := data(c.ok(http.StatusOK, "DELETE", fmt.Sprintf("%s/%d", qp, cardID), token, "")) if del["quiz_slug"] != "pytania" { t.Errorf("delete question = %v", del) } must(t, gdb.Model(&models.AttemptAnswer{}).Where("question_id = ?", cardID).Count(&n).Error) if n != 0 { t.Errorf("attempt answers of the deleted question = %d", n) } must(t, gdb.Take(&after, before.ID).Error) if after.ScorePoints != before.ScorePoints { t.Errorf("attempt score changed by a delete: %v -> %v", before.ScorePoints, after.ScorePoints) } }) t.Run("import still works", func(t *testing.T) { raw, err := os.ReadFile("testdata/quiz.json") must(t, err) c.ok(http.StatusOK, "POST", prefix+"/import", token, string(raw)) if out := data(c.ok(http.StatusOK, "POST", prefix+"/import?replace=true", token, string(raw))); out["created"] != false { t.Errorf("import with replace=true = %v", out) } }) }