package service import ( "context" "errors" "testing" "github.com/prabhupadasays/spsays-bff/internal/model" "github.com/prabhupadasays/spsays-bff/internal/repository" ) func q(prompt string, correct int, opts ...string) repository.QuestionInput { out := repository.QuestionInput{Prompt: prompt} for i, o := range opts { out.Options = append(out.Options, repository.OptionInput{Body: o, IsCorrect: i == correct}) } return out } func withExplanation(x repository.QuestionInput, e string) repository.QuestionInput { x.Explanation = e return x } func newQuizSvc(t *testing.T) (*QuizService, *repository.Postgres) { t.Helper() _, _, pg := testChatService(t) // Stand-alone quizzes never consult content, so no Mongo is needed. return NewQuizService(repository.NewQuizRepo(pg), nil), pg } func cleanupQuiz(t *testing.T, pg *repository.Postgres, id string) { t.Cleanup(func() { _, _ = pg.Pool.Exec(context.Background(), `DELETE FROM quizzes WHERE id = $1`, id) }) } func TestQuiz_Validation(t *testing.T) { svc, pg := newQuizSvc(t) ctx := context.Background() admin := chatUser(t, pg, "Admin") cases := map[string]struct { d QuizDraft want error }{ "no title": {QuizDraft{Questions: []repository.QuestionInput{q("p", 0, "a", "b")}}, ErrQuizTitle}, "no questions": {QuizDraft{Title: "T"}, ErrQuizNoQuestions}, "one option": {QuizDraft{Title: "T", Questions: []repository.QuestionInput{q("p", 0, "a")}}, ErrQuizOptionCount}, "six options": {QuizDraft{Title: "T", Questions: []repository.QuestionInput{q("p", 0, "a", "b", "c", "d", "e", "f")}}, ErrQuizOptionCount}, "no correct": {QuizDraft{Title: "T", Questions: []repository.QuestionInput{q("p", -1, "a", "b")}}, ErrQuizOneCorrect}, "blank prompt": {QuizDraft{Title: "T", Questions: []repository.QuestionInput{q(" ", 0, "a", "b")}}, ErrQuizPrompt}, "bad target url": {QuizDraft{Title: "T", TargetURL: "/letters/x", Questions: []repository.QuestionInput{q("p", 0, "a", "b")}}, ErrQuizTarget}, } for name, c := range cases { if _, err := svc.Create(ctx, admin, c.d); !errors.Is(err, c.want) { t.Errorf("%s: err = %v, want %v", name, err, c.want) } } two := q("p", 1, "a", "b") two.Options[0].IsCorrect = true // two correct if _, err := svc.Create(ctx, admin, QuizDraft{Title: "T", Questions: []repository.QuestionInput{two}}); !errors.Is(err, ErrQuizOneCorrect) { t.Errorf("two correct: %v", err) } // …which is exactly what a multi question allows, but it needs one. two.Multi = true if quiz, err := svc.Create(ctx, admin, QuizDraft{Title: "T", Questions: []repository.QuestionInput{two}}); err != nil { t.Errorf("multi with two correct: %v", err) } else { cleanupQuiz(t, pg, quiz.ID) } none := q("p", -1, "a", "b") none.Multi = true if _, err := svc.Create(ctx, admin, QuizDraft{Title: "T", Questions: []repository.QuestionInput{none}}); !errors.Is(err, ErrQuizNoCorrect) { t.Errorf("multi with none correct: %v, want ErrQuizNoCorrect", err) } } // A multi question is scored correct only on the exact set of correct // options: a subset or an extra wrong pick both miss. func TestQuiz_MultiChoice(t *testing.T) { svc, pg := newQuizSvc(t) ctx := context.Background() admin := chatUser(t, pg, "Admin") multi := func(prompt string) repository.QuestionInput { x := q(prompt, 0, "right A", "right B", "wrong") x.Options[1].IsCorrect = true x.Multi = true return x } quiz, err := svc.Create(ctx, admin, QuizDraft{ Title: "Select all", Questions: []repository.QuestionInput{multi("exact"), multi("subset"), multi("extra"), q("single", 0, "a", "b")}, }) if err != nil { t.Fatal(err) } cleanupQuiz(t, pg, quiz.ID) if _, err := svc.SetPublished(ctx, quiz.ID, true); err != nil { t.Fatal(err) } full, _ := svc.GetAdmin(ctx, quiz.ID) if !full.Questions[0].Multi || full.Questions[3].Multi { t.Fatal("multi flag didn't round-trip") } opt := func(qi, oi int) string { return full.Questions[qi].Options[oi].ID } user := chatUser(t, pg, "U") play, err := svc.Start(ctx, user, quiz.ID) if err != nil { t.Fatal(err) } if !play.Question.Multi { t.Error("the player isn't told the question takes several answers") } // Nothing chosen is refused. if _, err := svc.Answer(ctx, user, quiz.ID, play.Question.ID, []string{}); !errors.Is(err, repository.ErrQuizChoiceCount) { t.Errorf("empty answer: %v, want ErrQuizChoiceCount", err) } steps := [][]string{ {opt(0, 1), opt(0, 0), opt(0, 0)}, // exact set, any order, duplicate ignored {opt(1, 0)}, // subset {opt(2, 0), opt(2, 1), opt(2, 2)}, // every option, one wrong } for _, ids := range steps { if play, err = svc.Answer(ctx, user, quiz.ID, play.Question.ID, ids); err != nil { t.Fatal(err) } } // A single-answer question still takes exactly one. if _, err := svc.Answer(ctx, user, quiz.ID, play.Question.ID, []string{opt(3, 0), opt(3, 1)}); !errors.Is(err, repository.ErrQuizChoiceCount) { t.Errorf("two picks on a single question: %v, want ErrQuizChoiceCount", err) } if _, err := svc.Answer(ctx, user, quiz.ID, play.Question.ID, []string{opt(3, 0)}); err != nil { t.Fatal(err) } res, err := svc.Result(ctx, user, quiz.ID) if err != nil { t.Fatal(err) } got := []bool{} for _, it := range res.Items { got = append(got, it.IsCorrect) } if res.Score != 2 || !got[0] || got[1] || got[2] || !got[3] { t.Errorf("score %d, per question %v; want 2, [true false false true]", res.Score, got) } if n := len(res.Items[0].ChosenOptionIDs); n != 2 { t.Errorf("exact answer stored %d options, want 2 (duplicate dropped)", n) } stats, err := svc.Responses(ctx, quiz.ID) if err != nil { t.Fatal(err) } // Every option of the "extra" question was picked once. for _, o := range stats[2].Options { if o.Picks != 1 { t.Errorf("option %q picks = %d, want 1", o.Body, o.Picks) } } } func TestQuiz_TakingFlow(t *testing.T) { svc, pg := newQuizSvc(t) ctx := context.Background() admin, user, other := chatUser(t, pg, "Admin"), chatUser(t, pg, "U"), chatUser(t, pg, "O") quiz, err := svc.Create(ctx, admin, QuizDraft{ Title: "Bg basics", Questions: []repository.QuestionInput{ withExplanation(q("Who spoke the *Gītā*?", 0, "Kṛṣṇa", "Arjuna", "Sañjaya"), "Kṛṣṇa, to Arjuna."), q("Where?", 1, "Mathurā", "Kurukṣetra"), }, }) if err != nil { t.Fatal(err) } cleanupQuiz(t, pg, quiz.ID) // Drafts aren't reachable. if _, err := svc.Start(ctx, user, quiz.ID); !errors.Is(err, ErrQuizUnpublished) { t.Fatalf("starting a draft: %v", err) } if _, err := svc.SetPublished(ctx, quiz.ID, true); err != nil { t.Fatal(err) } play, err := svc.Start(ctx, user, quiz.ID) if err != nil { t.Fatal(err) } if play.Question == nil || play.Total != 2 || play.Answered != 0 { t.Fatalf("first step = %+v", play) } // The correct answer never reaches someone mid-attempt — nor the // explanation, which discusses it. if play.Question.Explanation != "" { t.Error("explanation leaked to a user mid-attempt") } for _, o := range play.Question.Options { if o.IsCorrect != nil { t.Fatal("is_correct leaked to a user mid-attempt") } } // Nor can they read a result yet. if _, err := svc.Result(ctx, user, quiz.ID); !errors.Is(err, repository.ErrQuizNotFinished) { t.Errorf("result mid-attempt: %v, want ErrQuizNotFinished", err) } card, _ := svc.Card(ctx, user, quiz.ID) if card.My.State != model.QuizInProgress { t.Errorf("state after start = %s, want in_progress", card.My.State) } // Answering out of order (a question not yet served) is refused. full, _ := svc.GetAdmin(ctx, quiz.ID) second := full.Questions[1] if _, err := svc.Answer(ctx, user, quiz.ID, second.ID, []string{second.Options[0].ID}); !errors.Is(err, repository.ErrQuizNotCurrent) { t.Errorf("answering ahead: %v, want ErrQuizNotCurrent", err) } // An option from another question is refused. if _, err := svc.Answer(ctx, user, quiz.ID, play.Question.ID, []string{second.Options[0].ID}); !errors.Is(err, repository.ErrQuizBadOption) { t.Errorf("foreign option: %v, want ErrQuizBadOption", err) } // Q1 right, Q2 wrong. play, err = svc.Answer(ctx, user, quiz.ID, play.Question.ID, []string{full.Questions[0].Options[0].ID}) if err != nil { t.Fatal(err) } // Can't change an answer. if _, err := svc.Answer(ctx, user, quiz.ID, full.Questions[0].ID, []string{full.Questions[0].Options[1].ID}); !errors.Is(err, repository.ErrQuizAlreadyAnswer) { t.Errorf("re-answering: %v, want ErrQuizAlreadyAnswer", err) } play, err = svc.Answer(ctx, user, quiz.ID, play.Question.ID, []string{second.Options[0].ID}) if err != nil { t.Fatal(err) } if !play.Done { t.Fatalf("after the last answer: %+v", play) } res, err := svc.Result(ctx, user, quiz.ID) if err != nil { t.Fatal(err) } if res.Score != 1 || res.Total != 2 || res.CompletedAt == nil { t.Errorf("result = score %d/%d completed %v", res.Score, res.Total, res.CompletedAt) } for _, it := range res.Items { if it.ResponseMs == nil { t.Error("a response time wasn't recorded") } if len(it.ChosenOptionIDs) != 1 { t.Error("the chosen option wasn't recorded") } } if !res.Items[0].IsCorrect || res.Items[1].IsCorrect { t.Error("per-question correctness is wrong") } // Only your own result: someone who never took it has none. if _, err := svc.Result(ctx, other, quiz.ID); !errors.Is(err, repository.ErrQuizNotStarted) { t.Errorf("another user's result: %v, want ErrQuizNotStarted", err) } // Admin sees the participant and the per-question picks. parts, err := svc.Participants(ctx, quiz.ID) if err != nil || len(parts) != 1 || parts[0].Score != 1 || parts[0].Answered != 2 { t.Errorf("participants = %+v, %v", parts, err) } stats, err := svc.Responses(ctx, quiz.ID) if err != nil || len(stats) != 2 || stats[0].Correct != 1 || stats[1].Correct != 0 { t.Errorf("responses = %+v, %v", stats, err) } // Taken, so questions are frozen — but the title can still change. if _, err := svc.Update(ctx, quiz.ID, QuizDraft{Title: "x", Questions: []repository.QuestionInput{q("p", 0, "a", "b")}}); !errors.Is(err, ErrQuizLocked) { t.Errorf("editing questions after attempts: %v, want ErrQuizLocked", err) } if upd, err := svc.Update(ctx, quiz.ID, QuizDraft{Title: "Bhagavad-gītā basics"}); err != nil || len(upd.Questions) != 2 { t.Errorf("retitling a taken quiz: %v (questions %d)", err, len(upd.Questions)) } } func TestNormalizeContentPath(t *testing.T) { for in, want := range map[string]string{ "https://prabhupadasays.org/books/bg/2/?x=1#v3": "/books/bg/2", "/transcriptions/720720sb-london": "/transcriptions/720720sb-london", "books/bg/2": "/books/bg/2", "": "", "/": "", } { if got := NormalizeContentPath(in); got != want { t.Errorf("NormalizeContentPath(%q) = %q, want %q", in, got, want) } } }