Files
learning/internal/handler/test.go
Ilya 47a76bef7c
All checks were successful
CI / test (push) Successful in 14s
Build and Deploy / build-and-deploy (push) Successful in 27s
feat(tests): UpdateQuestion (full-replace) + ReorderQuestions
UpdateQuestion (PUT /tests/{id}/questions/{questionId}):
- транзакционно: UPDATE test_questions SET ... + DELETE test_answers +
  INSERT новых ответов. question_id стабилен — test_attempt_answers
  (FK CASCADE) остаются. Снимок ответа в payload — для будущего
  показа истории, в MVP не реализовано;
- семантика full-replace: проще на клиенте (один POST вместо
  per-answer патчей), атомарно на сервере.

ReorderQuestions (POST /tests/{id}/questions/reorder):
- батч-апдейт position через UNNEST(uuid[], int[]) — один запрос
  вместо N UPDATE'ов; идемпотентно;
- /reorder регистрируется ДО /{questionId} чтобы chi не заматчил
  его как questionId="reorder".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 23:18:05 +03:00

367 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"learning-service/internal/model"
"learning-service/internal/repository"
)
type TestHandler struct {
repo *repository.TestRepository
}
func NewTestHandler(repo *repository.TestRepository) *TestHandler {
return &TestHandler{repo: repo}
}
// List — GET /tests. Параметры:
// ?mine=true — только мои (owner_user_id = X-User-Id);
// без mine — published тесты (для прохождения).
//
// MVP: ещё не подключён access_grants-фильтр; до этого момента «published»
// = «всем видно». Следующая итерация: handler через AccessRepository
// получит visibleIDs и передаст в repo.List.
func (h *TestHandler) List(w http.ResponseWriter, r *http.Request) {
uid, ok := userIDFromHeader(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
mine := r.URL.Query().Get("mine") == "true"
var ownerFilter *uuid.UUID
if mine {
ownerFilter = &uid
}
tests, err := h.repo.List(r.Context(), ownerFilter, !mine, nil)
if err != nil {
writeRepoError(w, r, err, "list tests")
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": tests})
}
func (h *TestHandler) Get(w http.ResponseWriter, r *http.Request) {
id, err := parseUUID(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
t, err := h.repo.Get(r.Context(), id)
if err != nil {
writeRepoError(w, r, err, "get test")
return
}
writeJSON(w, http.StatusOK, t)
}
func (h *TestHandler) Create(w http.ResponseWriter, r *http.Request) {
uid, ok := userIDFromHeader(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
var req model.CreateTestRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid body")
return
}
if strings.TrimSpace(req.Title) == "" {
writeError(w, http.StatusBadRequest, "title is required")
return
}
t, err := h.repo.Create(r.Context(), uid, req)
if err != nil {
writeRepoError(w, r, err, "create test")
return
}
writeJSON(w, http.StatusCreated, t)
}
// Update / Delete: пока без access-проверки (только owner). Когда подключим
// access_grants с can_manage, разрешим co-owner'ам тоже редактировать.
func (h *TestHandler) Update(w http.ResponseWriter, r *http.Request) {
uid, ok := userIDFromHeader(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
id, err := parseUUID(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
existing, err := h.repo.Get(r.Context(), id)
if err != nil {
writeRepoError(w, r, err, "get test for update")
return
}
if existing.OwnerUserID != uid {
writeError(w, http.StatusForbidden, "only owner can edit")
return
}
var req model.UpdateTestRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid body")
return
}
t, err := h.repo.Update(r.Context(), id, req)
if err != nil {
writeRepoError(w, r, err, "update test")
return
}
writeJSON(w, http.StatusOK, t)
}
func (h *TestHandler) Delete(w http.ResponseWriter, r *http.Request) {
uid, ok := userIDFromHeader(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
id, err := parseUUID(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
existing, err := h.repo.Get(r.Context(), id)
if err != nil {
writeRepoError(w, r, err, "get test for delete")
return
}
if existing.OwnerUserID != uid {
writeError(w, http.StatusForbidden, "only owner can delete")
return
}
if err := h.repo.Delete(r.Context(), id); err != nil {
writeRepoError(w, r, err, "delete test")
return
}
w.WriteHeader(http.StatusNoContent)
}
// ListQuestions — вопросы теста с ответами. Если запросивший не владелец —
// is_correct поле обнуляется в ответе (показывать правильные ответы до
// сабмита нельзя — иначе тест теряет смысл).
func (h *TestHandler) ListQuestions(w http.ResponseWriter, r *http.Request) {
uid, ok := userIDFromHeader(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
testID, err := parseUUID(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
t, err := h.repo.Get(r.Context(), testID)
if err != nil {
writeRepoError(w, r, err, "get test")
return
}
qs, err := h.repo.ListQuestions(r.Context(), testID)
if err != nil {
writeRepoError(w, r, err, "list questions")
return
}
// Не-владельцу скрываем is_correct, чтобы он не подсмотрел через
// DevTools правильные ответы до сабмита.
if t.OwnerUserID != uid {
for i := range qs {
for j := range qs[i].Answers {
qs[i].Answers[j].IsCorrect = false
}
// Объяснение тоже не светим — оно потенциально содержит
// разбор правильного ответа.
qs[i].Explanation = ""
}
}
writeJSON(w, http.StatusOK, map[string]any{"items": qs})
}
func (h *TestHandler) CreateQuestion(w http.ResponseWriter, r *http.Request) {
uid, ok := userIDFromHeader(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
testID, err := parseUUID(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
t, err := h.repo.Get(r.Context(), testID)
if err != nil {
writeRepoError(w, r, err, "get test")
return
}
if t.OwnerUserID != uid {
writeError(w, http.StatusForbidden, "only owner can add questions")
return
}
var req model.CreateQuestionRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid body")
return
}
if strings.TrimSpace(req.Text) == "" {
writeError(w, http.StatusBadRequest, "text is required")
return
}
if req.Kind != "single" && req.Kind != "multi" && req.Kind != "text" {
writeError(w, http.StatusBadRequest, "kind must be single|multi|text")
return
}
if req.Points <= 0 {
req.Points = 1
}
q, err := h.repo.CreateQuestion(r.Context(), testID, req)
if err != nil {
writeRepoError(w, r, err, "create question")
return
}
writeJSON(w, http.StatusCreated, q)
}
// UpdateQuestion — PUT /tests/{id}/questions/{questionId}. Full-replace
// семантика (см. repository.UpdateQuestion): метаданные обновляются,
// ответы пересоздаются. question_id стабилен.
func (h *TestHandler) UpdateQuestion(w http.ResponseWriter, r *http.Request) {
uid, ok := userIDFromHeader(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
testID, err := parseUUID(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid test id")
return
}
qID, err := parseUUID(chi.URLParam(r, "questionId"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid question id")
return
}
t, err := h.repo.Get(r.Context(), testID)
if err != nil {
writeRepoError(w, r, err, "get test")
return
}
if t.OwnerUserID != uid {
writeError(w, http.StatusForbidden, "only owner can edit questions")
return
}
var req model.CreateQuestionRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid body")
return
}
if strings.TrimSpace(req.Text) == "" {
writeError(w, http.StatusBadRequest, "text is required")
return
}
if req.Kind != "single" && req.Kind != "multi" && req.Kind != "text" {
writeError(w, http.StatusBadRequest, "kind must be single|multi|text")
return
}
if req.Points <= 0 {
req.Points = 1
}
q, err := h.repo.UpdateQuestion(r.Context(), qID, req)
if err != nil {
writeRepoError(w, r, err, "update question")
return
}
writeJSON(w, http.StatusOK, q)
}
// ReorderQuestions — POST /tests/{id}/questions/reorder. Body:
// {"items": [{"id": "uuid", "position": 0}, ...]}
func (h *TestHandler) ReorderQuestions(w http.ResponseWriter, r *http.Request) {
uid, ok := userIDFromHeader(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
testID, err := parseUUID(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid test id")
return
}
t, err := h.repo.Get(r.Context(), testID)
if err != nil {
writeRepoError(w, r, err, "get test")
return
}
if t.OwnerUserID != uid {
writeError(w, http.StatusForbidden, "only owner can reorder")
return
}
var req struct {
Items []struct {
ID string `json:"id"`
Position int `json:"position"`
} `json:"items"`
}
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid body")
return
}
items := make([]struct {
ID uuid.UUID
Position int
}, 0, len(req.Items))
for _, it := range req.Items {
id, err := parseUUID(it.ID)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid item id")
return
}
items = append(items, struct {
ID uuid.UUID
Position int
}{ID: id, Position: it.Position})
}
if err := h.repo.ReorderQuestions(r.Context(), testID, items); err != nil {
writeRepoError(w, r, err, "reorder questions")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *TestHandler) DeleteQuestion(w http.ResponseWriter, r *http.Request) {
uid, ok := userIDFromHeader(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
testID, err := parseUUID(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
qID, err := parseUUID(chi.URLParam(r, "questionId"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid question id")
return
}
t, err := h.repo.Get(r.Context(), testID)
if err != nil {
writeRepoError(w, r, err, "get test")
return
}
if t.OwnerUserID != uid {
writeError(w, http.StatusForbidden, "only owner can delete questions")
return
}
if err := h.repo.DeleteQuestion(r.Context(), qID); err != nil {
writeRepoError(w, r, err, "delete question")
return
}
w.WriteHeader(http.StatusNoContent)
}