Boost Azure Demo
This commit is contained in:
506
Backend/internal/handlers/api/questions/handler.go
Normal file
506
Backend/internal/handlers/api/questions/handler.go
Normal file
@@ -0,0 +1,506 @@
|
||||
// Path: Backend/internal/handlers/api/questions/handler.go
|
||||
|
||||
package questions
|
||||
|
||||
import (
|
||||
"boostai-backend/internal/handlers/api/shared"
|
||||
"boostai-backend/internal/http/params"
|
||||
"boostai-backend/internal/http/respond"
|
||||
authmw "boostai-backend/internal/middleware"
|
||||
"boostai-backend/internal/questiongen"
|
||||
"boostai-backend/internal/sqlc"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
queries *sqlc.Queries
|
||||
generator *questiongen.Service
|
||||
}
|
||||
|
||||
type QuestionResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
AuthorTeacherID int64 `json:"author_teacher_id"`
|
||||
Title string `json:"title"`
|
||||
Prompt string `json:"prompt"`
|
||||
Topic *string `json:"topic,omitempty"`
|
||||
Subject *string `json:"subject,omitempty"`
|
||||
Difficulty *string `json:"difficulty,omitempty"`
|
||||
Source *string `json:"source,omitempty"`
|
||||
CorrectAnswer *string `json:"correct_answer,omitempty"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type TagResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||
}
|
||||
|
||||
type createQuestionRequest struct {
|
||||
AuthorTeacherID int64 `json:"author_teacher_id"`
|
||||
Title string `json:"title"`
|
||||
Prompt string `json:"prompt"`
|
||||
Topic *string `json:"topic"`
|
||||
Subject *string `json:"subject"`
|
||||
Difficulty *string `json:"difficulty"`
|
||||
Source *string `json:"source"`
|
||||
CorrectAnswer *string `json:"correct_answer"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type createTagRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type attachTagToQuestionRequest struct {
|
||||
TagID int64 `json:"tag_id"`
|
||||
}
|
||||
|
||||
type generateQuestionsRequest struct {
|
||||
Topic string `json:"topic"`
|
||||
Difficulty string `json:"difficulty"`
|
||||
Count int `json:"count"`
|
||||
Seed *int64 `json:"seed"`
|
||||
Status *string `json:"status"`
|
||||
Source *string `json:"source"`
|
||||
}
|
||||
|
||||
type GeneratedQuestionResponse struct {
|
||||
Question QuestionResponse `json:"question"`
|
||||
Tags []string `json:"tags"`
|
||||
WorkedSolution []string `json:"worked_solution"`
|
||||
}
|
||||
|
||||
type GenerateQuestionsResponse struct {
|
||||
Seed int64 `json:"seed"`
|
||||
Data []GeneratedQuestionResponse `json:"data"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
func NewHandler(queries *sqlc.Queries, generator *questiongen.Service) *Handler {
|
||||
return &Handler{queries: queries, generator: generator}
|
||||
}
|
||||
|
||||
func (h *Handler) ListQuestionsByTeacher(c *fiber.Ctx) error {
|
||||
teacherID, err := params.Int64PathParam(c, "teacherId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := shared.WithTimeout()
|
||||
defer cancel()
|
||||
|
||||
questions, err := h.queries.ListQuestionsByTeacher(ctx, teacherID)
|
||||
if err != nil {
|
||||
return respond.DatabaseError(c, err)
|
||||
}
|
||||
|
||||
items := make([]QuestionResponse, 0, len(questions))
|
||||
for _, question := range questions {
|
||||
items = append(items, mapQuestion(question))
|
||||
}
|
||||
|
||||
return c.JSON(shared.ListResponse[QuestionResponse]{Data: items})
|
||||
}
|
||||
|
||||
func (h *Handler) GetQuestionByID(c *fiber.Ctx) error {
|
||||
questionID, err := params.Int64PathParam(c, "questionId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := shared.WithTimeout()
|
||||
defer cancel()
|
||||
|
||||
question, err := h.queries.GetQuestionByID(ctx, questionID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return respond.Error(c, fiber.StatusNotFound, "not_found", "Question not found")
|
||||
}
|
||||
return respond.DatabaseError(c, err)
|
||||
}
|
||||
|
||||
return c.JSON(mapQuestion(question))
|
||||
}
|
||||
|
||||
func (h *Handler) ListTags(c *fiber.Ctx) error {
|
||||
ctx, cancel := shared.WithTimeout()
|
||||
defer cancel()
|
||||
|
||||
tags, err := h.queries.ListTags(ctx)
|
||||
if err != nil {
|
||||
return respond.DatabaseError(c, err)
|
||||
}
|
||||
|
||||
items := make([]TagResponse, 0, len(tags))
|
||||
for _, tag := range tags {
|
||||
items = append(items, mapTag(tag))
|
||||
}
|
||||
|
||||
return c.JSON(shared.ListResponse[TagResponse]{Data: items})
|
||||
}
|
||||
|
||||
func (h *Handler) CreateQuestion(c *fiber.Ctx) error {
|
||||
var req createQuestionRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body")
|
||||
}
|
||||
|
||||
teacherID := authmw.CurrentUserID(c)
|
||||
if teacherID == 0 || strings.TrimSpace(req.Title) == "" || strings.TrimSpace(req.Prompt) == "" || strings.TrimSpace(req.Status) == "" {
|
||||
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "teacher authentication, title, prompt, and status are required")
|
||||
}
|
||||
|
||||
ctx, cancel := shared.WithTimeout()
|
||||
defer cancel()
|
||||
|
||||
topic, subject, err := parseQuestionTopic(req.Topic, req.Subject)
|
||||
if err != nil {
|
||||
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", err.Error())
|
||||
}
|
||||
|
||||
difficulty, err := parseQuestionDifficulty(req.Difficulty)
|
||||
if err != nil {
|
||||
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", err.Error())
|
||||
}
|
||||
|
||||
question, err := h.queries.CreateQuestion(ctx, sqlc.CreateQuestionParams{
|
||||
AuthorTeacherID: teacherID,
|
||||
Title: strings.TrimSpace(req.Title),
|
||||
Prompt: strings.TrimSpace(req.Prompt),
|
||||
Topic: topic,
|
||||
Subject: shared.NullableText(subject),
|
||||
Difficulty: difficulty,
|
||||
Source: shared.NullableText(req.Source),
|
||||
CorrectAnswer: shared.NullableText(req.CorrectAnswer),
|
||||
Status: sqlc.QuestionStatus(strings.TrimSpace(req.Status)),
|
||||
})
|
||||
if err != nil {
|
||||
return respond.DatabaseError(c, err)
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(mapQuestion(question))
|
||||
}
|
||||
|
||||
func (h *Handler) GenerateQuestions(c *fiber.Ctx) error {
|
||||
if h.generator == nil {
|
||||
return respond.Error(c, fiber.StatusServiceUnavailable, "generator_unavailable", "Question generator is not available")
|
||||
}
|
||||
|
||||
var req generateQuestionsRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body")
|
||||
}
|
||||
|
||||
teacherID := authmw.CurrentUserID(c)
|
||||
if teacherID == 0 {
|
||||
return respond.Error(c, fiber.StatusUnauthorized, "unauthorized", "teacher authentication is required")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.Topic) == "" || strings.TrimSpace(req.Difficulty) == "" {
|
||||
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "topic and difficulty are required")
|
||||
}
|
||||
|
||||
if req.Count < 1 || req.Count > 25 {
|
||||
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "count must be between 1 and 25")
|
||||
}
|
||||
|
||||
topic, subject, err := parseQuestionTopic(&req.Topic, nil)
|
||||
if err != nil {
|
||||
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", err.Error())
|
||||
}
|
||||
if !topic.Valid {
|
||||
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "topic is required")
|
||||
}
|
||||
|
||||
difficulty, err := parseQuestionDifficulty(&req.Difficulty)
|
||||
if err != nil {
|
||||
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", err.Error())
|
||||
}
|
||||
if !difficulty.Valid {
|
||||
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "difficulty is required")
|
||||
}
|
||||
|
||||
status := sqlc.QuestionStatusDraft
|
||||
if req.Status != nil && strings.TrimSpace(*req.Status) != "" {
|
||||
normalizedStatus := sqlc.QuestionStatus(strings.ToLower(strings.TrimSpace(*req.Status)))
|
||||
switch normalizedStatus {
|
||||
case sqlc.QuestionStatusDraft, sqlc.QuestionStatusPublished, sqlc.QuestionStatusArchived:
|
||||
status = normalizedStatus
|
||||
default:
|
||||
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "status must be draft, published, or archived")
|
||||
}
|
||||
}
|
||||
|
||||
seed := int64(0)
|
||||
if req.Seed != nil {
|
||||
seed = *req.Seed
|
||||
}
|
||||
|
||||
generated, usedSeed, err := h.generator.Generate(questiongen.GenerateParams{
|
||||
Topic: topic.QuestionTopic,
|
||||
Difficulty: difficulty.QuestionDifficulty,
|
||||
Count: req.Count,
|
||||
Seed: seed,
|
||||
})
|
||||
if err != nil {
|
||||
return respond.Error(c, fiber.StatusBadRequest, "generation_failed", err.Error())
|
||||
}
|
||||
|
||||
ctx, cancel := shared.WithTimeout()
|
||||
defer cancel()
|
||||
|
||||
source := shared.NullableText(req.Source)
|
||||
if !source.Valid {
|
||||
defaultSource := "rng_generated"
|
||||
source = shared.NullableText(&defaultSource)
|
||||
}
|
||||
|
||||
responses := make([]GeneratedQuestionResponse, 0, len(generated))
|
||||
for index, item := range generated {
|
||||
title := strings.TrimSpace(item.Title)
|
||||
if title == "" {
|
||||
title = fmt.Sprintf("%s %s %d", questionTopicLabel(topic.QuestionTopic), strings.Title(string(difficulty.QuestionDifficulty)), index+1)
|
||||
}
|
||||
|
||||
question, err := h.queries.CreateQuestion(ctx, sqlc.CreateQuestionParams{
|
||||
AuthorTeacherID: teacherID,
|
||||
Title: title,
|
||||
Prompt: strings.TrimSpace(item.Prompt),
|
||||
Topic: topic,
|
||||
Subject: shared.NullableText(subject),
|
||||
Difficulty: difficulty,
|
||||
Source: source,
|
||||
CorrectAnswer: shared.NullableText(stringPointer(item.CorrectAnswer)),
|
||||
Status: status,
|
||||
})
|
||||
if err != nil {
|
||||
return respond.DatabaseError(c, err)
|
||||
}
|
||||
|
||||
for _, tagName := range item.Tags {
|
||||
tag, err := h.queries.CreateTag(ctx, tagName)
|
||||
if err != nil {
|
||||
return respond.DatabaseError(c, err)
|
||||
}
|
||||
if err := h.queries.AttachTagToQuestion(ctx, sqlc.AttachTagToQuestionParams{QuestionID: question.ID, TagID: tag.ID}); err != nil {
|
||||
return respond.DatabaseError(c, err)
|
||||
}
|
||||
}
|
||||
|
||||
responses = append(responses, GeneratedQuestionResponse{
|
||||
Question: mapQuestion(question),
|
||||
Tags: item.Tags,
|
||||
WorkedSolution: item.WorkedSolution,
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(GenerateQuestionsResponse{
|
||||
Seed: usedSeed,
|
||||
Data: responses,
|
||||
Count: len(responses),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) CreateTag(c *fiber.Ctx) error {
|
||||
var req createTagRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "name is required")
|
||||
}
|
||||
|
||||
ctx, cancel := shared.WithTimeout()
|
||||
defer cancel()
|
||||
|
||||
tag, err := h.queries.CreateTag(ctx, strings.TrimSpace(req.Name))
|
||||
if err != nil {
|
||||
return respond.DatabaseError(c, err)
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(mapTag(tag))
|
||||
}
|
||||
|
||||
func (h *Handler) AttachTagToQuestion(c *fiber.Ctx) error {
|
||||
questionID, err := params.Int64PathParam(c, "questionId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var req attachTagToQuestionRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "Unable to parse request body")
|
||||
}
|
||||
|
||||
if req.TagID == 0 {
|
||||
return respond.Error(c, fiber.StatusBadRequest, "invalid_request", "tag_id is required")
|
||||
}
|
||||
|
||||
ctx, cancel := shared.WithTimeout()
|
||||
defer cancel()
|
||||
|
||||
err = h.queries.AttachTagToQuestion(ctx, sqlc.AttachTagToQuestionParams{
|
||||
QuestionID: questionID,
|
||||
TagID: req.TagID,
|
||||
})
|
||||
if err != nil {
|
||||
return respond.DatabaseError(c, err)
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
|
||||
"status": "ok",
|
||||
"question_id": questionID,
|
||||
"tag_id": req.TagID,
|
||||
})
|
||||
}
|
||||
|
||||
func mapQuestion(question sqlc.Question) QuestionResponse {
|
||||
return QuestionResponse{
|
||||
ID: question.ID,
|
||||
AuthorTeacherID: question.AuthorTeacherID,
|
||||
Title: question.Title,
|
||||
Prompt: question.Prompt,
|
||||
Topic: questionTopicPointer(question.Topic),
|
||||
Subject: shared.TextPointer(question.Subject),
|
||||
Difficulty: questionDifficultyPointer(question.Difficulty),
|
||||
Source: shared.TextPointer(question.Source),
|
||||
CorrectAnswer: shared.TextPointer(question.CorrectAnswer),
|
||||
Status: string(question.Status),
|
||||
CreatedAt: shared.TimePointer(question.CreatedAt),
|
||||
UpdatedAt: shared.TimePointer(question.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func mapTag(tag sqlc.Tag) TagResponse {
|
||||
return TagResponse{
|
||||
ID: tag.ID,
|
||||
Name: tag.Name,
|
||||
CreatedAt: shared.TimePointer(tag.CreatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func parseQuestionTopic(rawTopic, rawSubject *string) (sqlc.NullQuestionTopic, *string, error) {
|
||||
topicValue := strings.TrimSpace(firstNonEmpty(rawTopic, rawSubject))
|
||||
if topicValue == "" {
|
||||
return sqlc.NullQuestionTopic{}, rawSubject, nil
|
||||
}
|
||||
|
||||
normalizedTopic, ok := normalizeQuestionTopic(topicValue)
|
||||
if !ok {
|
||||
return sqlc.NullQuestionTopic{}, nil, errors.New("topic must match the supported seeded subjects")
|
||||
}
|
||||
|
||||
subjectLabel := questionTopicLabel(sqlc.QuestionTopic(normalizedTopic))
|
||||
return sqlc.NullQuestionTopic{QuestionTopic: sqlc.QuestionTopic(normalizedTopic), Valid: true}, &subjectLabel, nil
|
||||
}
|
||||
|
||||
func parseQuestionDifficulty(rawDifficulty *string) (sqlc.NullQuestionDifficulty, error) {
|
||||
value := strings.TrimSpace(firstNonEmpty(rawDifficulty))
|
||||
if value == "" {
|
||||
return sqlc.NullQuestionDifficulty{}, nil
|
||||
}
|
||||
|
||||
switch strings.ToLower(value) {
|
||||
case "easy":
|
||||
return sqlc.NullQuestionDifficulty{QuestionDifficulty: sqlc.QuestionDifficultyEasy, Valid: true}, nil
|
||||
case "medium":
|
||||
return sqlc.NullQuestionDifficulty{QuestionDifficulty: sqlc.QuestionDifficultyMedium, Valid: true}, nil
|
||||
case "hard":
|
||||
return sqlc.NullQuestionDifficulty{QuestionDifficulty: sqlc.QuestionDifficultyHard, Valid: true}, nil
|
||||
default:
|
||||
return sqlc.NullQuestionDifficulty{}, errors.New("difficulty must be easy, medium, or hard")
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeQuestionTopic(value string) (string, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "place value", "place_value":
|
||||
return string(sqlc.QuestionTopicPlaceValue), true
|
||||
case "arithmetic":
|
||||
return string(sqlc.QuestionTopicArithmetic), true
|
||||
case "negative numbers", "negative_numbers":
|
||||
return string(sqlc.QuestionTopicNegativeNumbers), true
|
||||
case "bidmas":
|
||||
return string(sqlc.QuestionTopicBidmas), true
|
||||
case "fractions":
|
||||
return string(sqlc.QuestionTopicFractions), true
|
||||
case "algebra":
|
||||
return string(sqlc.QuestionTopicAlgebra), true
|
||||
case "geometry":
|
||||
return string(sqlc.QuestionTopicGeometry), true
|
||||
case "data":
|
||||
return string(sqlc.QuestionTopicData), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func questionTopicLabel(topic sqlc.QuestionTopic) string {
|
||||
switch topic {
|
||||
case sqlc.QuestionTopicPlaceValue:
|
||||
return "Place Value"
|
||||
case sqlc.QuestionTopicArithmetic:
|
||||
return "Arithmetic"
|
||||
case sqlc.QuestionTopicNegativeNumbers:
|
||||
return "Negative Numbers"
|
||||
case sqlc.QuestionTopicBidmas:
|
||||
return "BIDMAS"
|
||||
case sqlc.QuestionTopicFractions:
|
||||
return "Fractions"
|
||||
case sqlc.QuestionTopicAlgebra:
|
||||
return "Algebra"
|
||||
case sqlc.QuestionTopicGeometry:
|
||||
return "Geometry"
|
||||
case sqlc.QuestionTopicData:
|
||||
return "Data"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func questionTopicPointer(topic sqlc.NullQuestionTopic) *string {
|
||||
if !topic.Valid {
|
||||
return nil
|
||||
}
|
||||
label := string(topic.QuestionTopic)
|
||||
return &label
|
||||
}
|
||||
|
||||
func questionDifficultyPointer(difficulty sqlc.NullQuestionDifficulty) *string {
|
||||
if !difficulty.Valid {
|
||||
return nil
|
||||
}
|
||||
value := string(difficulty.QuestionDifficulty)
|
||||
return &value
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...*string) string {
|
||||
for _, value := range values {
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
trimmed := strings.TrimSpace(*value)
|
||||
if trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func stringPointer(value string) *string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
return &trimmed
|
||||
}
|
||||
140
Backend/internal/handlers/api/questions/handler_test.go
Normal file
140
Backend/internal/handlers/api/questions/handler_test.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package questions
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"boostai-backend/internal/http/respond"
|
||||
"boostai-backend/internal/questiongen"
|
||||
"boostai-backend/internal/sqlc"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func TestGenerateQuestionsReturnsGeneratorUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := NewHandler(nil, nil)
|
||||
status, body := performGenerateRequest(t, handler, map[string]any{
|
||||
"topic": "fractions",
|
||||
"difficulty": "easy",
|
||||
"count": 1,
|
||||
}, true)
|
||||
|
||||
if status != fiber.StatusServiceUnavailable {
|
||||
t.Fatalf("expected status %d, got %d", fiber.StatusServiceUnavailable, status)
|
||||
}
|
||||
if body.Error != "generator_unavailable" {
|
||||
t.Fatalf("expected generator_unavailable error, got %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateQuestionsRequiresTeacherAuthentication(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := NewHandler(nil, questiongen.NewService())
|
||||
status, body := performGenerateRequest(t, handler, map[string]any{
|
||||
"topic": "fractions",
|
||||
"difficulty": "easy",
|
||||
"count": 1,
|
||||
}, false)
|
||||
|
||||
if status != fiber.StatusUnauthorized {
|
||||
t.Fatalf("expected status %d, got %d", fiber.StatusUnauthorized, status)
|
||||
}
|
||||
if body.Error != "unauthorized" {
|
||||
t.Fatalf("expected unauthorized error, got %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateQuestionsRejectsZeroCount(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := NewHandler(nil, questiongen.NewService())
|
||||
status, body := performGenerateRequest(t, handler, map[string]any{
|
||||
"topic": "fractions",
|
||||
"difficulty": "easy",
|
||||
"count": 0,
|
||||
}, true)
|
||||
|
||||
if status != fiber.StatusBadRequest {
|
||||
t.Fatalf("expected status %d, got %d", fiber.StatusBadRequest, status)
|
||||
}
|
||||
if body.Message != "count must be between 1 and 25" {
|
||||
t.Fatalf("expected count validation message, got %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateQuestionsRejectsInvalidStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := NewHandler(nil, questiongen.NewService())
|
||||
status, body := performGenerateRequest(t, handler, map[string]any{
|
||||
"topic": "fractions",
|
||||
"difficulty": "easy",
|
||||
"count": 1,
|
||||
"status": "invalid",
|
||||
}, true)
|
||||
|
||||
if status != fiber.StatusBadRequest {
|
||||
t.Fatalf("expected status %d, got %d", fiber.StatusBadRequest, status)
|
||||
}
|
||||
if body.Message != "status must be draft, published, or archived" {
|
||||
t.Fatalf("expected invalid status message, got %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateQuestionsRejectsInvalidTopic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := NewHandler(nil, questiongen.NewService())
|
||||
status, body := performGenerateRequest(t, handler, map[string]any{
|
||||
"topic": "not_a_topic",
|
||||
"difficulty": "easy",
|
||||
"count": 1,
|
||||
}, true)
|
||||
|
||||
if status != fiber.StatusBadRequest {
|
||||
t.Fatalf("expected status %d, got %d", fiber.StatusBadRequest, status)
|
||||
}
|
||||
if body.Error != "invalid_request" {
|
||||
t.Fatalf("expected invalid_request error, got %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func performGenerateRequest(t *testing.T, handler *Handler, payload map[string]any, authenticated bool) (int, respond.ErrorBody) {
|
||||
t.Helper()
|
||||
|
||||
app := fiber.New()
|
||||
app.Post("/questions/generate", func(c *fiber.Ctx) error {
|
||||
if authenticated {
|
||||
c.Locals("auth.user_id", int64(42))
|
||||
c.Locals("auth.role", sqlc.UserRoleTeacher)
|
||||
}
|
||||
return handler.GenerateQuestions(c)
|
||||
})
|
||||
|
||||
bodyBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal payload: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/questions/generate", bytes.NewReader(bodyBytes))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test returned error: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var errorBody respond.ErrorBody
|
||||
if err := json.NewDecoder(resp.Body).Decode(&errorBody); err != nil {
|
||||
t.Fatalf("decode error response: %v", err)
|
||||
}
|
||||
|
||||
return resp.StatusCode, errorBody
|
||||
}
|
||||
17
Backend/internal/handlers/api/questions/routes.go
Normal file
17
Backend/internal/handlers/api/questions/routes.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package questions
|
||||
|
||||
import (
|
||||
authmw "boostai-backend/internal/middleware"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func RegisterRoutes(app fiber.Router, auth *authmw.AuthMiddleware, h *Handler) {
|
||||
app.Get("/teachers/:teacherId/questions", auth.RequireTeacherSelf("teacherId"), h.ListQuestionsByTeacher)
|
||||
app.Get("/questions/:questionId", h.GetQuestionByID)
|
||||
app.Get("/tags", h.ListTags)
|
||||
app.Post("/questions", auth.RequireTeacher(), h.CreateQuestion)
|
||||
app.Post("/questions/generate", auth.RequireTeacher(), h.GenerateQuestions)
|
||||
app.Post("/tags", auth.RequireTeacher(), h.CreateTag)
|
||||
app.Post("/questions/:questionId/tags", auth.RequireTeacher(), h.AttachTagToQuestion)
|
||||
}
|
||||
Reference in New Issue
Block a user