Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions docs/superpowers/plans/2026-05-17-examora-module-batches.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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:

Expand All @@ -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:

Expand All @@ -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:

Expand Down
58 changes: 58 additions & 0 deletions internal/api/library_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
41 changes: 36 additions & 5 deletions internal/library/question.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -195,13 +199,30 @@ 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
}
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
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -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)
}
Expand All @@ -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
}

Expand Down
Loading
Loading