303 lines
11 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|