feat: quiz and rbac
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user