unique quiz constraint

This commit is contained in:
Krispire Admin
2026-10-16 15:23:06 +05:30
parent b1f78b1655
commit eb02d08f14
6 changed files with 128 additions and 6 deletions
+25
View File
@@ -199,6 +199,9 @@ func (s *QuizService) Create(ctx context.Context, actorID string, d QuizDraft) (
if err != nil {
return nil, err
}
if err := s.ensureTargetFree(ctx, in, ""); err != nil {
return nil, err
}
id, err := s.quizzes.Create(ctx, in, actorID)
if err != nil {
return nil, err
@@ -223,12 +226,34 @@ func (s *QuizService) Update(ctx context.Context, id string, d QuizDraft) (*mode
if err != nil {
return nil, err
}
if err := s.ensureTargetFree(ctx, in, id); err != nil {
return nil, err
}
if err := s.quizzes.Update(ctx, id, in, !locked); err != nil {
return nil, err
}
return s.GetAdmin(ctx, id)
}
// ensureTargetFree refuses a second quiz on the same chapter, lecture or
// conversation, naming the one already there so the admin can edit it
// instead. Stand-alone quizzes are never limited. The unique index backs
// this up against a race between two saves.
func (s *QuizService) ensureTargetFree(ctx context.Context, in repository.QuizInput, selfID string) error {
if in.TargetPath == nil {
return nil
}
_, title, err := s.quizzes.QuizOnTarget(ctx, *in.TargetPath, selfID)
if err != nil {
return err
}
if title != "" {
return fmt.Errorf("%w — %q is already linked to %s. Edit that quiz, or delete it first",
repository.ErrQuizTargetTaken, title, *in.TargetPath)
}
return nil
}
func (s *QuizService) SetPublished(ctx context.Context, id string, published bool) (*model.Quiz, error) {
if published {
q, err := s.quizzes.Get(ctx, id, false)
+64
View File
@@ -3,8 +3,11 @@ package service
import (
"context"
"errors"
"strings"
"testing"
"github.com/google/uuid"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
)
@@ -318,3 +321,64 @@ func TestNormalizeContentPath(t *testing.T) {
}
}
}
// A chapter, lecture or conversation carries at most one live quiz. The
// service names the quiz already there; the unique index catches a race;
// deleting a quiz frees its link; stand-alone quizzes are unlimited.
func TestQuiz_OnePerTarget(t *testing.T) {
svc, pg := newQuizSvc(t)
ctx := context.Background()
admin := chatUser(t, pg, "Admin")
repo := repository.NewQuizRepo(pg)
path := "/books/zz-test/" + uuid.NewString()[:8]
in := repository.QuizInput{
Title: "First", TargetKind: model.QuizChapter, TargetPath: &path,
Questions: []repository.QuestionInput{q("p", 0, "a", "b")},
}
first, err := repo.Create(ctx, in, admin)
if err != nil {
t.Fatal(err)
}
cleanupQuiz(t, pg, first)
// The friendly pre-check names the quiz that holds the link…
err = svc.ensureTargetFree(ctx, in, "")
if !errors.Is(err, repository.ErrQuizTargetTaken) || !strings.Contains(err.Error(), `"First"`) {
t.Errorf("second quiz on a link: %v, want ErrQuizTargetTaken naming \"First\"", err)
}
// …but a quiz re-saving its own link is fine.
if err := svc.ensureTargetFree(ctx, in, first); err != nil {
t.Errorf("re-saving the same quiz: %v", err)
}
// The index refuses it even if the check is skipped (two saves racing).
in.Title = "Second"
if _, err := repo.Create(ctx, in, admin); !errors.Is(err, repository.ErrQuizTargetTaken) {
t.Errorf("create past the index: %v, want ErrQuizTargetTaken", err)
}
// Moving another quiz onto the link is refused too.
other := repository.QuizInput{Title: "Other", TargetKind: model.QuizStandalone,
Questions: []repository.QuestionInput{q("p", 0, "a", "b")}}
otherID, err := repo.Create(ctx, other, admin)
if err != nil {
t.Fatal(err)
}
cleanupQuiz(t, pg, otherID)
other.TargetKind, other.TargetPath = model.QuizChapter, &path
if err := repo.Update(ctx, otherID, other, false); !errors.Is(err, repository.ErrQuizTargetTaken) {
t.Errorf("moving a quiz onto a taken link: %v, want ErrQuizTargetTaken", err)
}
// Stand-alone quizzes are unlimited.
if solo, err := svc.Create(ctx, admin, QuizDraft{Title: "Solo", Questions: []repository.QuestionInput{q("p", 0, "a", "b")}}); err != nil {
t.Errorf("a second stand-alone quiz: %v", err)
} else {
cleanupQuiz(t, pg, solo.ID)
}
// Deleting the first frees the link.
if err := svc.Delete(ctx, first); err != nil {
t.Fatal(err)
}
if err := repo.Update(ctx, otherID, other, false); err != nil {
t.Errorf("taking a freed link: %v", err)
}
}