diff --git a/docs/superpowers/plans/2026-05-17-examora-module-batches.md b/docs/superpowers/plans/2026-05-17-examora-module-batches.md index 5f87786..8cd5b47 100644 --- a/docs/superpowers/plans/2026-05-17-examora-module-batches.md +++ b/docs/superpowers/plans/2026-05-17-examora-module-batches.md @@ -177,7 +177,7 @@ pnpm --dir apps/admin lint - Modify: `internal/library/paper.go` - Modify: `packages/types/src/index.ts` -- [ ] Add backend dependency tests. +- [x] Add backend dependency tests. Cases: @@ -187,7 +187,7 @@ Published question used by a published paper cannot be unpublished. Programming question cannot publish without at least one sample test case and one hidden test case. ``` -- [ ] Implement dependency-safe question status actions. +- [x] Implement dependency-safe question status actions. Expected behavior: @@ -196,7 +196,7 @@ Admin receives clear error messages when status changes are blocked. Batch status actions report per-item success and failure. ``` -- [ ] Improve paper preview and readiness display. +- [x] Improve paper preview and readiness display. Expected behavior: @@ -205,7 +205,7 @@ Paper detail shows section order, question count, total score, unpublished count Save bar stays compact and does not resize the layout unexpectedly. ``` -- [ ] Verify Batch 2. +- [x] Verify Batch 2. Run: diff --git a/internal/api/library_test.go b/internal/api/library_test.go index 56f222a..7445a34 100644 --- a/internal/api/library_test.go +++ b/internal/api/library_test.go @@ -232,6 +232,64 @@ func TestBatchPatchQuestionStatusEndpointReturnsPartialFailures(t *testing.T) { require.Equal(t, library.QuestionStatusPublished, published.Status) } +func TestBatchPatchQuestionStatusEndpointReportsReferencedPublishedPaperFailure(t *testing.T) { + router, service := newLibraryAPIRouter(t) + ctx := t.Context() + + referenced, err := service.CreateQuestion(ctx, library.SaveQuestionCommand{ + Type: library.QuestionTypeTrueFalse, + Title: "Referenced", + Content: map[string]any{"text": "Go is compiled."}, + Answer: map[string]any{"correct": true}, + Status: library.QuestionStatusPublished, + }) + require.NoError(t, err) + loose, err := service.CreateQuestion(ctx, library.SaveQuestionCommand{ + Type: library.QuestionTypeTrueFalse, + Title: "Loose", + Content: map[string]any{"text": "Go has maps."}, + Answer: map[string]any{"correct": true}, + Status: library.QuestionStatusPublished, + }) + require.NoError(t, err) + paper, err := service.CreatePaper(ctx, library.SavePaperCommand{ + Title: "Published paper", + Status: library.PaperStatusDraft, + }) + require.NoError(t, err) + _, err = service.AddPaperQuestion(ctx, paper.ID, library.AddPaperQuestionCommand{ + QuestionID: referenced.ID, + Score: 10, + }) + require.NoError(t, err) + _, err = service.UpdatePaper(ctx, paper.ID, library.SavePaperCommand{ + Title: paper.Title, + Status: library.PaperStatusPublished, + }) + require.NoError(t, err) + + bodyBytes, err := json.Marshal(map[string]any{ + "ids": []uint64{referenced.ID, loose.ID}, + "status": library.QuestionStatusDraft, + }) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPatch, "/api/v1/questions/batch/status", bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + require.Equal(t, http.StatusOK, resp.Code) + var body struct { + Data library.BatchResult `json:"data"` + } + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &body)) + require.Equal(t, 1, body.Data.SuccessCount) + require.Equal(t, 1, body.Data.FailedCount) + require.Len(t, body.Data.Failures, 1) + require.Equal(t, referenced.ID, body.Data.Failures[0].ID) + require.Contains(t, body.Data.Failures[0].Reason, library.ErrQuestionReferenced.Error()) +} + func TestBatchDeleteQuestionsEndpointProtectsReferencedQuestions(t *testing.T) { router, service := newLibraryAPIRouter(t) ctx := t.Context() diff --git a/internal/library/question.go b/internal/library/question.go index a5ce036..9a7d4ec 100644 --- a/internal/library/question.go +++ b/internal/library/question.go @@ -150,15 +150,19 @@ func (s *Service) CreateQuestion(ctx context.Context, cmd SaveQuestionCommand) ( } func (s *Service) UpdateQuestion(ctx context.Context, id uint64, cmd SaveQuestionCommand) (*Question, error) { - if _, err := s.store.GetQuestion(ctx, id); err != nil { + existing, err := s.store.GetQuestion(ctx, id) + if err != nil { return nil, err } if err := normalizeAndValidateQuestionCommand(&cmd); err != nil { return nil, err } + if err := s.validateQuestionStatusTransition(ctx, existing, cmd.Status); err != nil { + return nil, err + } q := fromQuestionCommand(cmd) q.ID = id - err := s.withTx(ctx, func(ctx context.Context) error { + err = s.withTx(ctx, func(ctx context.Context) error { if err := s.store.UpdateQuestion(ctx, q); err != nil { return err } @@ -195,6 +199,9 @@ func (s *Service) PatchQuestionStatus(ctx context.Context, id uint64, status str return nil, err } } + if err := s.validateQuestionStatusTransition(ctx, q, status); err != nil { + return nil, err + } q.Status = status if err := s.store.UpdateQuestion(ctx, q); err != nil { return nil, err @@ -202,6 +209,20 @@ func (s *Service) PatchQuestionStatus(ctx context.Context, id uint64, status str return q, nil } +func (s *Service) validateQuestionStatusTransition(ctx context.Context, q *Question, nextStatus string) error { + if q.Status != QuestionStatusPublished || nextStatus != QuestionStatusDraft { + return nil + } + count, err := s.store.CountPublishedPaperQuestions(ctx, q.ID) + if err != nil { + return err + } + if count > 0 { + return ErrQuestionReferenced + } + return nil +} + func (s *Service) DeleteQuestion(ctx context.Context, id uint64) error { if _, err := s.store.GetQuestion(ctx, id); err != nil { return err @@ -319,7 +340,7 @@ func normalizeAndValidateQuestionCommand(cmd *SaveQuestionCommand) error { return err } if cmd.Type == QuestionTypeProgramming { - if err := validateProgrammingFields(cmd.Language, cmd.TestCases); err != nil { + if err := validateProgrammingFields(cmd.Language, cmd.TestCases, cmd.Status == QuestionStatusPublished); err != nil { return err } } else if len(cmd.TestCases) > 0 { @@ -345,7 +366,7 @@ func ValidateQuestionForPublish(q *Question, tcs []TestCase) error { return err } if q.Type == QuestionTypeProgramming { - return validateProgrammingFields(q.Language, tcs) + return validateProgrammingFields(q.Language, tcs, true) } return nil } @@ -473,15 +494,19 @@ func validateChoiceOptions(content map[string]any) (map[string]struct{}, error) return keys, nil } -func validateProgrammingFields(language *string, testCases []TestCase) error { +func validateProgrammingFields(language *string, testCases []TestCase, requirePublishReady bool) error { if language == nil || strings.TrimSpace(*language) == "" { return fmt.Errorf("%w: programming language is required", ErrInvalidQuestion) } if len(testCases) == 0 { return fmt.Errorf("%w: programming question requires at least one test case", ErrInvalidQuestion) } + hasSample := false + hasHidden := false for i := range testCases { normalizeTestCase(&testCases[i], i) + hasSample = hasSample || testCases[i].IsSample + hasHidden = hasHidden || testCases[i].IsHidden if testCases[i].TimeLimitMS < 1 { return fmt.Errorf("%w: test case time limit must be positive", ErrInvalidQuestion) } @@ -492,6 +517,12 @@ func validateProgrammingFields(language *string, testCases []TestCase) error { return fmt.Errorf("%w: test case expected output is required", ErrInvalidQuestion) } } + if requirePublishReady && !hasSample { + return fmt.Errorf("%w: programming question requires at least one sample test case", ErrInvalidQuestion) + } + if requirePublishReady && !hasHidden { + return fmt.Errorf("%w: programming question requires at least one hidden test case", ErrInvalidQuestion) + } return nil } diff --git a/internal/library/question_test.go b/internal/library/question_test.go index e195b7a..37e1bd1 100644 --- a/internal/library/question_test.go +++ b/internal/library/question_test.go @@ -138,15 +138,18 @@ func TestProgrammingQuestionReplacesTestCases(t *testing.T) { TimeLimitMS: 1500, TestCases: []library.TestCase{ {Input: "5 8", ExpectedOutput: "13", IsSample: true}, + {Input: "10 20", ExpectedOutput: "30", IsHidden: true}, }, }) require.NoError(t, err) cases, err = service.ListTestCases(ctx, question.ID, true) require.NoError(t, err) - require.Len(t, cases, 1) + require.Len(t, cases, 2) require.Equal(t, "5 8", cases[0].Input) require.Equal(t, "13", cases[0].ExpectedOutput) + require.Equal(t, "10 20", cases[1].Input) + require.Equal(t, "30", cases[1].ExpectedOutput) } func TestCreateQuestionValidatesStructuredQuestionPayloads(t *testing.T) { @@ -267,6 +270,13 @@ func TestPatchQuestionStatusPublishedValidatesExistingQuestionCompleteness(t *te _, err = service.AddTestCase(ctx, question.ID, library.SaveTestCaseCommand{ Input: "", ExpectedOutput: "hello", + IsSample: true, + }) + require.NoError(t, err) + _, err = service.AddTestCase(ctx, question.ID, library.SaveTestCaseCommand{ + Input: "hidden", + ExpectedOutput: "secret", + IsHidden: true, }) require.NoError(t, err) @@ -275,6 +285,162 @@ func TestPatchQuestionStatusPublishedValidatesExistingQuestionCompleteness(t *te require.Equal(t, library.QuestionStatusPublished, published.Status) } +func TestPublishProgrammingQuestionRequiresSampleAndHiddenTestCases(t *testing.T) { + service, _ := newLibraryService(t) + ctx := context.Background() + + tests := []struct { + name string + testCase library.SaveTestCaseCommand + wantError string + }{ + { + name: "sample only", + testCase: library.SaveTestCaseCommand{ + Input: "", + ExpectedOutput: "hello", + IsSample: true, + }, + wantError: "hidden", + }, + { + name: "hidden only", + testCase: library.SaveTestCaseCommand{ + Input: "hidden", + ExpectedOutput: "secret", + IsHidden: true, + }, + wantError: "sample", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + question, err := service.CreateQuestion(ctx, library.SaveQuestionCommand{ + Type: library.QuestionTypeProgramming, + Title: "Print hello", + Content: map[string]any{"text": "Print hello."}, + Language: stringPtr("GO"), + Status: library.QuestionStatusDraft, + TestCases: []library.TestCase{ + { + Input: tt.testCase.Input, + ExpectedOutput: tt.testCase.ExpectedOutput, + IsSample: tt.testCase.IsSample, + IsHidden: tt.testCase.IsHidden, + }, + }, + }) + require.NoError(t, err) + + _, err = service.PatchQuestionStatus(ctx, question.ID, library.QuestionStatusPublished) + require.ErrorIs(t, err, library.ErrInvalidQuestion) + require.Contains(t, err.Error(), tt.wantError) + }) + } +} + +func TestPatchQuestionStatusDraftRejectsPublishedPaperReference(t *testing.T) { + service, _ := newLibraryService(t) + ctx := context.Background() + + question, err := service.CreateQuestion(ctx, library.SaveQuestionCommand{ + Type: library.QuestionTypeTrueFalse, + Title: "Go is compiled", + Content: map[string]any{"text": "Go is compiled."}, + Answer: map[string]any{"correct": true}, + Status: library.QuestionStatusPublished, + }) + require.NoError(t, err) + paper, err := service.CreatePaper(ctx, library.SavePaperCommand{ + Title: "Published paper", + Status: library.PaperStatusDraft, + }) + require.NoError(t, err) + _, err = service.AddPaperQuestion(ctx, paper.ID, library.AddPaperQuestionCommand{ + QuestionID: question.ID, + Score: 10, + SortOrder: 1, + }) + require.NoError(t, err) + _, err = service.UpdatePaper(ctx, paper.ID, library.SavePaperCommand{ + Title: paper.Title, + Status: library.PaperStatusPublished, + }) + require.NoError(t, err) + + _, err = service.PatchQuestionStatus(ctx, question.ID, library.QuestionStatusDraft) + require.ErrorIs(t, err, library.ErrQuestionReferenced) +} + +func TestUpdateQuestionDraftRejectsPublishedPaperReference(t *testing.T) { + service, _ := newLibraryService(t) + ctx := context.Background() + + question, err := service.CreateQuestion(ctx, library.SaveQuestionCommand{ + Type: library.QuestionTypeTrueFalse, + Title: "Go is compiled", + Content: map[string]any{"text": "Go is compiled."}, + Answer: map[string]any{"correct": true}, + Status: library.QuestionStatusPublished, + }) + require.NoError(t, err) + paper, err := service.CreatePaper(ctx, library.SavePaperCommand{ + Title: "Published paper", + Status: library.PaperStatusDraft, + }) + require.NoError(t, err) + _, err = service.AddPaperQuestion(ctx, paper.ID, library.AddPaperQuestionCommand{ + QuestionID: question.ID, + Score: 10, + SortOrder: 1, + }) + require.NoError(t, err) + _, err = service.UpdatePaper(ctx, paper.ID, library.SavePaperCommand{ + Title: paper.Title, + Status: library.PaperStatusPublished, + }) + require.NoError(t, err) + + _, err = service.UpdateQuestion(ctx, question.ID, library.SaveQuestionCommand{ + Type: library.QuestionTypeTrueFalse, + Title: "Go is still compiled", + Content: map[string]any{"text": "Go is still compiled."}, + Answer: map[string]any{"correct": true}, + Status: library.QuestionStatusDraft, + }) + require.ErrorIs(t, err, library.ErrQuestionReferenced) +} + +func TestPatchQuestionStatusDraftAllowsDraftPaperReference(t *testing.T) { + service, _ := newLibraryService(t) + ctx := context.Background() + + question, err := service.CreateQuestion(ctx, library.SaveQuestionCommand{ + Type: library.QuestionTypeTrueFalse, + Title: "Go is compiled", + Content: map[string]any{"text": "Go is compiled."}, + Answer: map[string]any{"correct": true}, + Status: library.QuestionStatusPublished, + }) + require.NoError(t, err) + paper, err := service.CreatePaper(ctx, library.SavePaperCommand{ + Title: "Draft paper", + Status: library.PaperStatusDraft, + }) + require.NoError(t, err) + _, err = service.AddPaperQuestion(ctx, paper.ID, library.AddPaperQuestionCommand{ + QuestionID: question.ID, + Score: 10, + SortOrder: 1, + }) + require.NoError(t, err) + + updated, err := service.PatchQuestionStatus(ctx, question.ID, library.QuestionStatusDraft) + require.NoError(t, err) + require.Equal(t, library.QuestionStatusDraft, updated.Status) +} + func TestAddPaperQuestionValidatesPublishedQuestionScoreAndDuplicates(t *testing.T) { service, _ := newLibraryService(t) ctx := context.Background() diff --git a/internal/library/store.go b/internal/library/store.go index 3b07d8f..b426d13 100644 --- a/internal/library/store.go +++ b/internal/library/store.go @@ -16,6 +16,7 @@ type QuestionStore interface { DeleteQuestion(ctx context.Context, id uint64) error QuestionExists(ctx context.Context, id uint64) (bool, error) CountPaperQuestions(ctx context.Context, questionID uint64) (int64, error) + CountPublishedPaperQuestions(ctx context.Context, questionID uint64) (int64, error) AddTestCase(ctx context.Context, tc *TestCase) error ListTestCases(ctx context.Context, questionID uint64, includeHidden bool) ([]TestCase, error) DeleteTestCasesByQuestionID(ctx context.Context, questionID uint64) error diff --git a/internal/library/store/question.go b/internal/library/store/question.go index 8423ef0..28b3902 100644 --- a/internal/library/store/question.go +++ b/internal/library/store/question.go @@ -143,6 +143,16 @@ func (s *Store) CountPaperQuestions(ctx context.Context, questionID uint64) (int return count, err } +func (s *Store) CountPublishedPaperQuestions(ctx context.Context, questionID uint64) (int64, error) { + var count int64 + err := s.db(ctx). + Table("paper_questions AS pq"). + Joins("JOIN papers AS p ON p.id = pq.paper_id"). + Where("pq.question_id = ? AND p.status = ?", questionID, library.PaperStatusPublished). + Count(&count).Error + return count, err +} + func (s *Store) DeleteTestCasesByQuestionID(ctx context.Context, questionID uint64) error { return s.db(ctx).Where("question_id = ?", questionID).Delete(&database.TestCaseModel{}).Error }