Files
spsays-bff/internal/service/quiz_service_test.go
T
2026-10-15 19:43:17 +05:30

303 lines
11 KiB
Go

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