diff --git a/classes/service.go b/classes/service.go index 4c1921a..30ee963 100644 --- a/classes/service.go +++ b/classes/service.go @@ -286,6 +286,9 @@ func (s *Service) Start(ctx context.Context, userID uint, slug, category string) func (s *Service) startTest(ctx context.Context, userID uint, quiz *models.Quiz) (*Started, error) { var st *Started + // limited refuses the start after the commit, so the expired attempts + // closed on the way stay closed (an error would roll the closes back). + var limited bool err := lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { tx = tx.WithContext(ctx) if err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtext(?))`, AttemptLockKey(quiz.ID, userID)).Error; err != nil { @@ -327,7 +330,8 @@ func (s *Service) startTest(ctx context.Context, userID uint, quiz *models.Quiz) return nil } if left := AttemptsLeft(quiz, int(used)); left != nil && *left == 0 { - return ErrAttemptLimit + limited = true + return nil } attempt := &models.Attempt{QuizID: quiz.ID, UserID: userID, StartedAt: now.UTC()} maxPoints, err := s.maxPoints(ctx, tx, attempt) @@ -344,6 +348,9 @@ func (s *Service) startTest(ctx context.Context, userID uint, quiz *models.Quiz) if err != nil { return nil, err } + if limited { + return nil, ErrAttemptLimit + } return st, nil } diff --git a/test_mode_postgres_test.go b/test_mode_postgres_test.go index 668fb5f..b71f5dd 100644 --- a/test_mode_postgres_test.go +++ b/test_mode_postgres_test.go @@ -286,6 +286,38 @@ VALUES ('Stary', 'stary', true, 50, false, NOW(), NOW())`).Error) } }) + t.Run("the last expired attempt is closed at the limit", func(t *testing.T) { + f := seedQuiz(t, gdb, "test-wygasly-limit", test(func(q *models.Quiz) { + q.TimeLimitSeconds = intPtr(60) + q.MaxAttempts = intPtr(1) + })) + u, _ := newUser(t, gdb, "lazy-limit@example.com") + t0 := time.Now().UTC().Truncate(time.Second) + clock := t0 + svc := &classes.Service{DB: gdb, Now: func() time.Time { return clock }, Grace: 5 * time.Second} + st, err := svc.Start(t.Context(), u.ID, "test-wygasly-limit", "") + must(t, err) + clock = t0.Add(10 * time.Second) + _, err = svc.Answer(t.Context(), u.ID, st.Attempt.ID, classes.AnswerInput{QuestionID: f.single.ID, AnswerIDs: []uint{f.singleRight.ID}}) + must(t, err) + + clock = t0.Add(70 * time.Second) + if _, err := svc.Start(t.Context(), u.ID, "test-wygasly-limit", ""); !errors.Is(err, classes.ErrAttemptLimit) { + t.Errorf("start after the last attempt expired = %v, want ErrAttemptLimit", err) + } + var old models.Attempt + must(t, gdb.Take(&old, st.Attempt.ID).Error) + if old.CompletedAt == nil || !old.CompletedAt.Equal(t0.Add(60*time.Second)) { + t.Errorf("expired attempt completed_at = %v, want %v (closed despite the limit)", old.CompletedAt, t0.Add(60*time.Second)) + } + if old.ScorePoints != 1 || old.MaxPoints != 4 { + t.Errorf("expired attempt score = %v / %d, want 1 / 4", old.ScorePoints, old.MaxPoints) + } + if n := countAttempts(t, f.quiz.ID, u.ID); n != 1 { + t.Errorf("attempts after the refused start = %d, want 1", n) + } + }) + t.Run("a shuffled test keeps its order", func(t *testing.T) { c := &client{t: t, h: h} seedQuiz(t, gdb, "test-losowy", test(func(q *models.Quiz) { q.ShuffleQuestions = true }))