package api import ( "encoding/json" "strings" "testing" "time" "git.golem15.com/golem15/sm-quizzes-plugin/classes" "git.golem15.com/golem15/sm-quizzes-plugin/models" "git.golem15.com/golem15/summercms/modules/lagoon" ) // fixtureQuestion is a question of type typ with every solution field set, // so a serializer that forgets to drop one shows it. func fixtureQuestion(id uint, typ string) models.Question { expl, model, why := "question explanation", "model answer", "answer explanation" q := models.Question{ID: id, Text: "Q", Type: typ, Points: 2, Explanation: &expl, AnswerText: &model} if typ != models.TypeSelfCheck { q.Answers = []models.Answer{ {ID: id*10 + 1, QuestionID: id, Text: "right", IsCorrect: true, Explanation: &why}, {ID: id*10 + 2, QuestionID: id, Text: "wrong", Explanation: &why}, } } return q } func decode(t *testing.T, v any) (map[string]any, string) { t.Helper() b, err := json.Marshal(v) if err != nil { t.Fatal(err) } var out map[string]any if err := json.Unmarshal(b, &out); err != nil { t.Fatal(err) } return out, string(b) } // noSolution fails when any solution key of m (a feedback or a result // question) holds a value. func noSolution(t *testing.T, where string, m map[string]any, keys ...string) { t.Helper() for _, k := range keys { v, ok := m[k] if !ok { t.Errorf("%s: key %s missing", where, k) continue } if v != nil { t.Errorf("%s: %s = %v, want null", where, k, v) } } answers, _ := m["answers"].([]any) for i, raw := range answers { a := raw.(map[string]any) if a["is_correct"] != nil || a["explanation"] != nil { t.Errorf("%s: answers[%d] = %v, want no is_correct or explanation", where, i, a) } if a["id"] == nil || a["text"] == nil { t.Errorf("%s: answers[%d] lost its id or text: %v", where, i, a) } } } func noCorrectness(t *testing.T, where, raw string) { t.Helper() if strings.Contains(raw, `"is_correct":true`) || strings.Contains(raw, `"is_correct":false`) { t.Errorf("%s reveals correctness: %s", where, raw) } } func TestWithheldSerializers(t *testing.T) { for _, typ := range models.QuestionTypes { t.Run(typ, func(t *testing.T) { answered, unanswered := fixtureQuestion(1, typ), fixtureQuestion(2, typ) test := &models.Quiz{Slug: "t", PassingScore: 50, Mode: models.ModeTest} fb := &classes.Feedback{ Question: answered, Result: classes.ScoreResult{IsCorrect: true, Points: 2}, CorrectAnswerIDs: classes.CorrectAnswerIDs(answered), Withheld: true, } m, raw := decode(t, feedback(fb)) if m["withheld"] != true { t.Errorf("feedback withheld = %v, want true", m["withheld"]) } noSolution(t, "feedback", m, "is_correct", "points", "correct_answer_ids", "explanation", "answer_text") noCorrectness(t, "feedback", raw) if typ != models.TypeSelfCheck && len(m["answers"].([]any)) != 2 { t.Errorf("feedback answers = %v, want both answers", m["answers"]) } known := true row := &models.AttemptAnswer{AttemptID: 5, QuestionID: 1, IsCorrect: true, Points: 2} if typ == models.TypeSelfCheck { row.SelfCheckKnown = &known } else { row.AnswerIDs = lagoon.Jsonable[[]uint]{Data: []uint{11}, Valid: true} } res := &classes.AttemptResult{ AttemptSummary: classes.AttemptSummary{Attempt: models.Attempt{ID: 5, QuizID: 3, StartedAt: time.Now(), Quiz: test}, AnsweredCount: 1}, Questions: []classes.QuestionResult{{Question: answered, Answer: row}, {Question: unanswered}}, } m, raw = decode(t, result(res)) if m["solutions_withheld"] != true || m["quiz_mode"] != models.ModeTest { t.Errorf("result solutions_withheld = %v quiz_mode = %v", m["solutions_withheld"], m["quiz_mode"]) } noCorrectness(t, "result", raw) questions := m["questions"].([]any) if len(questions) != 2 { t.Fatalf("result questions = %d, want 2", len(questions)) } for i, raw := range questions { q := raw.(map[string]any) noSolution(t, "result question "+string(rune('0'+i)), q, "is_correct", "points_earned", "correct_answer_ids", "explanation", "answer_text") } first := questions[0].(map[string]any) if first["answered"] != true { t.Errorf("answered question lost answered: %v", first) } if typ == models.TypeSelfCheck { if first["known"] != true { t.Errorf("self_check lost the student's known: %v", first) } } else if ids, _ := first["answer_ids"].([]any); len(ids) != 1 || ids[0] != float64(11) { t.Errorf("answer_ids = %v, want the student's [11]", first["answer_ids"]) } done := time.Now() res.Attempt.CompletedAt = &done m, _ = decode(t, result(res)) if m["solutions_withheld"] != false { t.Errorf("completed test still withheld") } for _, raw := range m["questions"].([]any) { q := raw.(map[string]any) if q["is_correct"] == nil || q["points_earned"] == nil || q["explanation"] == nil || q["correct_answer_ids"] == nil { t.Errorf("completed test hides a solution: %v", q) } } }) } } func TestLearningFeedbackUnchanged(t *testing.T) { q := fixtureQuestion(1, models.TypeSingle) fb := &classes.Feedback{Question: q, Result: classes.ScoreResult{IsCorrect: false, Points: 0}, CorrectAnswerIDs: classes.CorrectAnswerIDs(q)} m, raw := decode(t, feedback(fb)) if m["withheld"] != false { t.Errorf("withheld = %v, want false", m["withheld"]) } if m["is_correct"] != false || m["points"] != float64(0) { t.Errorf("is_correct / points = %v / %v, want false / 0", m["is_correct"], m["points"]) } if ids, ok := m["correct_answer_ids"].([]any); !ok || len(ids) != 1 { t.Errorf("correct_answer_ids = %v, want [11]", m["correct_answer_ids"]) } if !strings.Contains(raw, `"is_correct":true`) || m["explanation"] == nil || m["answer_text"] == nil { t.Errorf("learning feedback lost a solution: %s", raw) } res := &classes.AttemptResult{ AttemptSummary: classes.AttemptSummary{Attempt: models.Attempt{ID: 5, StartedAt: time.Now(), Quiz: &models.Quiz{Slug: "s", PassingScore: 50}}}, Questions: []classes.QuestionResult{{Question: q}}, } m, _ = decode(t, result(res)) row := m["questions"].([]any)[0].(map[string]any) if m["solutions_withheld"] != false || m["quiz_mode"] != models.ModeLearning || m["quiz_navigation"] != models.NavigationFree { t.Errorf("learning result flags = %v %v %v", m["solutions_withheld"], m["quiz_mode"], m["quiz_navigation"]) } if row["is_correct"] != false || row["points_earned"] != float64(0) { t.Errorf("unanswered learning question = %v / %v, want false / 0", row["is_correct"], row["points_earned"]) } } func TestTestQuizDetailHidesQuestions(t *testing.T) { d := &classes.QuizDetail{ Quiz: models.Quiz{Slug: "t", Mode: models.ModeTest, Navigation: models.NavigationOneWay}, Questions: []models.Question{fixtureQuestion(1, models.TypeSelfCheck), fixtureQuestion(2, models.TypeSingle)}, } m, raw := decode(t, quizDetail(d)) if qs, ok := m["questions"].([]any); !ok || len(qs) != 0 { t.Errorf("test detail questions = %v, want []", m["questions"]) } if m["question_count"] != float64(2) || m["mode"] != models.ModeTest || m["navigation"] != models.NavigationOneWay { t.Errorf("test detail = %s", raw) } if strings.Contains(raw, "model answer") { t.Errorf("test detail leaks answer_text: %s", raw) } d.Quiz.Mode = models.ModeLearning m, _ = decode(t, quizDetail(d)) if qs, _ := m["questions"].([]any); len(qs) != 2 { t.Errorf("learning detail questions = %d, want 2", len(qs)) } }