This commit is contained in:
2026-10-01 16:42:39 +07:00
parent 5638349623
commit ee15d149bd
31 changed files with 37 additions and 88 deletions
@@ -39,7 +39,7 @@ func TestAnswerAccessPostgres(t *testing.T) {
if err != nil {
t.Fatal(err)
}
// Rejected requests must not reach scenario or answer storage.
// Отклонённые запросы не должны доходить до хранилищ сценариев и ответов.
service := NewAnswerService(games_repo.NewGamesRepo(pool), teams_repo.NewTeamsRepo(pool), nil, nil, nil)
for _, tc := range []struct {
status string
@@ -6,7 +6,6 @@ import (
"evening_detective_server/internal/repos/game_reviews_repo"
)
// Review is the application model returned by game review use cases.
type Review struct {
ID int
GameID int
@@ -22,7 +22,6 @@ var (
ErrInvalidReview = errors.New("Оценка должна быть от 1 до 5, текст обязателен")
)
// GameReviewService handles game review use cases and reminder emails.
type GameReviewService struct {
reviews *game_reviews_repo.Repo
games *games_repo.GamesRepo
@@ -40,7 +40,7 @@ func TestServicesLifecyclePostgres(t *testing.T) {
}
config, err := pgxpool.ParseConfig(dsn)
check(err)
config.MaxConns = 1 // Temporary tables stay on the same connection.
config.MaxConns = 1 // Временные таблицы должны оставаться в одном соединении.
pool, err := pgxpool.NewWithConfig(ctx, config)
check(err)
defer pool.Close()
+1 -10
View File
@@ -1,4 +1,4 @@
// Package mcp_service собирает MCP-сервер (Model Context Protocol) для
// Пакет mcp_service собирает MCP-сервер (Model Context Protocol) для
// игры «Вечерний детектив»: инструменты MCP вызывают игровые сервисы
// напрямую (через интерфейс GamePlayer), без HTTP-прослойки. MCP-сервер
// монтируется в основной процесс как HTTP-эндпоинт /mcp (streamable HTTP)
@@ -49,13 +49,10 @@ type MCPService struct {
answers AnswerSubmitter
}
// NewMCPService создаёт сервис поверх игровых сервисов.
func NewMCPService(game GamePlayer, answers AnswerSubmitter) *MCPService {
return &MCPService{game: game, answers: answers}
}
// Server собирает и возвращает MCP-сервер с зарегистрированными
// инструментами.
func (s *MCPService) Server() *server.MCPServer {
srv := server.NewMCPServer(
"evening-detective-mcp",
@@ -124,7 +121,6 @@ func (s *MCPService) Server() *server.MCPServer {
return srv
}
// connectArgs — результат разбора ссылки на игру.
type connectArgs struct {
teamID int64
password string
@@ -168,21 +164,17 @@ func parseConnectURL(rawURL string) (connectArgs, error) {
return connectArgs{teamID: teamID, password: password}, nil
}
// gameResponse — компактное описание игры для ответа инструментов
// (game_model.Game JSON-тегов не имеет).
type gameResponse struct {
ID int `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
}
// storyResponse — JSON-ответ инструментов: история команды + инфо об игре.
type storyResponse struct {
Story *storytelling.Story `json:"story"`
Game *gameResponse `json:"game"`
}
// storyJSON сериализует историю команды и игру в JSON.
func storyJSON(story *storytelling.Story, game *game_model.Game) (string, error) {
resp := storyResponse{
Story: story,
@@ -284,7 +276,6 @@ func (s *MCPService) handleMakeMove(ctx context.Context, req mcp.CallToolRequest
return mcp.NewToolResultText(jsonBody), nil
}
// submitAnswersArgs — разобранные аргументы инструмента submit_answers.
type submitAnswersArgs struct {
teamID int64
password string
@@ -82,7 +82,7 @@ func defaultGame() *game_model.Game {
}
}
// mcpFixture поднимает in-process MCP-клиент над сервисом с моком
// mcpFixture поднимает встроенный MCP-клиент над сервисом с моком
// GamePlayer. Каждый кейс создаёт СВЕЖИЙ сервис.
type mcpFixture struct {
client *client.Client
@@ -99,7 +99,7 @@ func TestParticipationPostgres(t *testing.T) {
}
}
state(1, "pending")
state(2, "pending") // Existing teams are not changed by the migration.
state(2, "pending") // Существующие команды миграция не изменяет.
// Model registration output explicitly: direct SQL inserts do not auto-confirm teams.
exec(`UPDATE teams SET captain_email='captain@example.org',participants_count=5 WHERE id=1;
INSERT INTO teams(game_id,created_at,captain_email) SELECT 1,start_at-INTERVAL '24 hours','boundary@example.org' FROM games WHERE id=1;
@@ -113,7 +113,7 @@ func TestParticipationPostgres(t *testing.T) {
t.Fatal(err)
}
state(5, "confirmed")
// Independent workers must claim each due team just once.
// Независимые рабочие процессы должны захватывать каждую подходящую команду только один раз.
var wg sync.WaitGroup
ids := make(chan int, 8)
errs := make(chan error, 8)
@@ -158,7 +158,7 @@ func TestParticipationPostgres(t *testing.T) {
t.Fatalf("failed send not recorded: %+v %v", team, err)
}
sender.err = nil
// Expired lease is recovered after a worker restart.
// Истёкшая аренда восстанавливается после перезапуска рабочего процесса.
exec(`UPDATE teams SET participation_next_attempt_at=NOW()-INTERVAL '1 minute' WHERE id IN (1,3)`)
if err := service.sendParticipationEmails(ctx, sender, "secret", time.UTC); err != nil {
t.Fatal(err)
@@ -209,7 +209,7 @@ func TestParticipationPostgres(t *testing.T) {
if _, err := service.AnswerParticipation(ctx, token, p.Status); err != nil {
t.Fatalf("same answer not idempotent: %v", err)
}
// Cancellation excludes teams from shared active listings and password-based gameplay.
// Отмена исключает команды из общих активных списков и игры по паролю.
token3 := participationToken("secret", 3)
if _, err := service.AnswerParticipation(ctx, token3, "cancelled"); err != nil {
t.Fatal(err)
@@ -35,8 +35,10 @@ func (s *ParticipationService) AnswerParticipation(ctx context.Context, token, a
return s.teamsRepo.AnswerParticipation(ctx, token, answer)
}
// Stable, purpose-separated tokens keep links valid when an ambiguous SMTP failure is retried.
// Only the hash is stored in the database; the signing secret must stay stable across workers/restarts.
// Стабильные токены для разных целей сохраняют работоспособность ссылок при
// повторе после неоднозначной ошибки SMTP.
// В базе хранится только хеш; секрет подписи должен быть постоянным между
// рабочими процессами и перезапусками.
func participationToken(secret string, id int) string {
mac := hmac.New(sha256.New, []byte(secret))
fmt.Fprintf(mac, "team-participation:v1:%d", id)
@@ -102,7 +104,7 @@ func (s *ParticipationService) sendParticipationEmails(ctx context.Context, send
err = sender.Send(jobCtx, participationEmail(p, s.appBaseURL, token, location))
}
cancel()
// Persist even when sending consumed the job's deadline.
// Сохраняем результат даже если отправка исчерпала время задачи.
saveCtx, saveCancel := context.WithTimeout(ctx, 10*time.Second)
saveErr := s.teamsRepo.FinishParticipationEmail(saveCtx, p.TeamID, p.Attempts, err)
saveCancel()
@@ -47,7 +47,8 @@ func TestChangeUserPassword(t *testing.T) {
r.byID[u.ID] = u
r.users[u.Email] = u
}
// Nil JWT, token repository and mail sender ensure this operation does not use them.
// Пустые JWT, репозиторий токенов и отправитель писем подтверждают,
// что операция их не использует.
s := &UsersService{usersRepo: r}
err = s.ChangeUserPassword(context.Background(), tc.actor, tc.target, tc.password)
if !errors.Is(err, tc.want) {
@@ -89,7 +90,7 @@ func TestLoginAfterPasswordChangePreservesEmailCase(t *testing.T) {
if err := svc.ChangeUserPassword(ctx, 1, 2, "new password 123"); err != nil {
t.Fatal(err)
}
// This error can only occur after email lookup and bcrypt verification succeed.
// Эта ошибка возможна только после успешного поиска email и проверки bcrypt.
_, _, err := svc.Login(ctx, " User@Example.COM ", "new password 123")
if err == nil || err.Error() != "Не получилось получить доступ" {
t.Fatalf("lookup/password verification failed: %v", err)
@@ -40,7 +40,7 @@ func TestEmailThemeAndContent(t *testing.T) {
}
}
if n.Type == html.ElementNode && n.Data == "a" {
protected = true // The button has its own paired background and text colors.
protected = true // У кнопки собственные согласованные цвета фона и текста.
}
if paper && n.Type == html.TextNode && strings.TrimSpace(n.Data) != "" {
if !protected {