add registration

This commit is contained in:
2026-09-13 01:42:22 +07:00
parent 0723644d3f
commit c12f81a7c4
20 changed files with 517 additions and 33 deletions
+5 -1
View File
@@ -19,6 +19,10 @@ func mapGame(
endedAt = timestamppb.New(*o.EndedAt)
}
teams := make([]*proto.Team, 0, len(o.Teams))
for _, team := range o.Teams {
teams = append(teams, mapTeamDetails(team))
}
return &proto.Game{
Id: int32(o.ID),
Name: o.Name,
@@ -27,7 +31,7 @@ func mapGame(
Status: o.Status,
Format: o.Format,
Scenario: mapScenario(o.Scenario),
Teams: mapTeams(o.Teams),
Teams: teams,
StartedAt: startedAt,
EndedAt: endedAt,
}
+7
View File
@@ -18,6 +18,13 @@ func mapTeam(
}
}
func mapTeamDetails(o *game_service.Team) *proto.Team {
team := mapTeam(o)
team.CaptainEmail = o.CaptainEmail
team.ParticipantsCount = o.ParticipantsCount
return team
}
func mapTeams(
o []*game_service.Team,
) []*proto.Team {
+65
View File
@@ -0,0 +1,65 @@
package app
import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"time"
"evening_detective_server/internal/repos/teams_repo"
"evening_detective_server/internal/services/game_service"
)
// TeamRegistrationHandler принимает webhook Яндекс Форм отдельно от пользовательского JWT.
func TeamRegistrationHandler(token string, register func(context.Context, int, string, game_service.Registration) (int, error)) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
fail := func(code int, message string) {
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(map[string]string{"error": message})
}
if token == "" {
fail(http.StatusServiceUnavailable, "Интеграция регистрации не настроена")
return
}
if subtle.ConstantTimeCompare([]byte(r.Header.Get("Authorization")), []byte("Bearer "+token)) != 1 {
fail(http.StatusUnauthorized, "Неверный токен интеграции")
return
}
gameID, err := strconv.ParseInt(r.PathValue("id"), 10, 32)
deliveryID := r.Header.Get("X-Delivery-Id")
if err != nil || gameID <= 0 || deliveryID == "" || len(deliveryID) > 255 {
fail(http.StatusBadRequest, "Требуются ID игры и заголовок X-Delivery-Id (до 255 байт)")
return
}
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10))
decoder.DisallowUnknownFields()
var registration game_service.Registration
if err := decoder.Decode(&registration); err != nil {
fail(http.StatusBadRequest, "Некорректный JSON регистрации")
return
}
if err := decoder.Decode(new(any)); err != io.EOF {
fail(http.StatusBadRequest, "Ожидается один JSON-объект")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 4*time.Second)
defer cancel()
id, err := register(ctx, int(gameID), deliveryID, registration)
switch {
case errors.Is(err, game_service.ErrInvalidRegistration):
fail(http.StatusBadRequest, err.Error())
case errors.Is(err, teams_repo.ErrRegistrationClosed):
fail(http.StatusConflict, err.Error())
case err != nil:
fail(http.StatusInternalServerError, "Не удалось зарегистрировать команду")
default:
_ = json.NewEncoder(w).Encode(map[string]int{"id": id})
}
})
}
+77
View File
@@ -0,0 +1,77 @@
package app
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"evening_detective_server/internal/repos/teams_repo"
"evening_detective_server/internal/services/game_service"
)
func TestTeamRegistrationHandler(t *testing.T) {
valid := `{"name":"Команда","email":"captain@example.org","participantsCount":5}`
for _, tc := range []struct {
name, token, auth, gameID, deliveryID, body string
serviceErr error
want int
called bool
}{
{"success", "secret", "Bearer secret", "42", "delivery", valid, nil, 200, true},
{"disabled", "", "Bearer secret", "42", "delivery", valid, nil, 503, false},
{"no token", "secret", "", "42", "delivery", valid, nil, 401, false},
{"wrong token", "secret", "Bearer wrong", "42", "delivery", valid, nil, 401, false},
{"bad game", "secret", "Bearer secret", "0", "delivery", valid, nil, 400, false},
{"no delivery", "secret", "Bearer secret", "42", "", valid, nil, 400, false},
{"malformed", "secret", "Bearer secret", "42", "delivery", `{`, nil, 400, false},
{"extra object", "secret", "Bearer secret", "42", "delivery", valid + ` {}`, nil, 400, false},
{"unknown field", "secret", "Bearer secret", "42", "delivery", `{"extra":1}`, nil, 400, false},
{"too large", "secret", "Bearer secret", "42", "delivery", `{"name":"` + strings.Repeat("a", 17000) + `"}`, nil, 400, false},
{"invalid fields", "secret", "Bearer secret", "42", "delivery", valid, game_service.ErrInvalidRegistration, 400, true},
{"closed", "secret", "Bearer secret", "42", "delivery", valid, teams_repo.ErrRegistrationClosed, 409, true},
{"internal", "secret", "Bearer secret", "42", "delivery", valid, errors.New("private database detail"), 500, true},
} {
t.Run(tc.name, func(t *testing.T) {
called := false
mux := http.NewServeMux()
mux.Handle("POST /api/games/{id}/registrations", TeamRegistrationHandler(tc.token, func(ctx context.Context, gameID int, deliveryID string, registration game_service.Registration) (int, error) {
called = true
if gameID != 42 || deliveryID != "delivery" || registration.Name != "Команда" || registration.Email != "captain@example.org" || registration.ParticipantsCount != 5 {
t.Fatal("incorrect registration passed to service")
}
if _, ok := ctx.Deadline(); !ok {
t.Fatal("missing deadline")
}
return 7, tc.serviceErr
}))
req := httptest.NewRequest(http.MethodPost, "/api/games/"+tc.gameID+"/registrations", strings.NewReader(tc.body))
req.Header.Set("Authorization", tc.auth)
req.Header.Set("X-Delivery-Id", tc.deliveryID)
res := httptest.NewRecorder()
mux.ServeHTTP(res, req)
if res.Code != tc.want || called != tc.called {
t.Fatalf("status=%d called=%v body=%s", res.Code, called, res.Body.String())
}
if tc.want == 200 && res.Body.String() != "{\"id\":7}\n" {
t.Fatal(res.Body.String())
}
if strings.Contains(res.Body.String(), "private database detail") {
t.Fatal("private error exposed")
}
})
}
}
func TestCaptainContactOnlyInGameDetails(t *testing.T) {
team := &game_service.Team{ID: 1, CaptainEmail: "captain@example.org", ParticipantsCount: 5}
details := mapGame(&game_service.Game{Teams: []*game_service.Team{team}})
if details.Teams[0].CaptainEmail != team.CaptainEmail || details.Teams[0].ParticipantsCount != 5 {
t.Fatal("registration details missing from organizer view")
}
if mapTeam(team).CaptainEmail != "" {
t.Fatal("captain email exposed in shared team/results mapping")
}
}