generated from VLADIMIR/template
fix
This commit is contained in:
@@ -6,28 +6,22 @@ import (
|
||||
"evening_detective_server/internal/services/game_model"
|
||||
)
|
||||
|
||||
// Answer — ответ команды на вопрос дела (для публичного API команды).
|
||||
type Answer struct {
|
||||
QuestionCode string
|
||||
Text string
|
||||
}
|
||||
|
||||
// AnswerScore — ответ команды с баллом организатора (для панели оценки).
|
||||
type AnswerScore struct {
|
||||
QuestionCode string
|
||||
Text string
|
||||
// Score — балл; nil — ещё не оценено.
|
||||
Score *int
|
||||
Score *int
|
||||
}
|
||||
|
||||
// TeamAnswers — ответы одной команды игры.
|
||||
type TeamAnswers struct {
|
||||
Team *game_model.Team
|
||||
Answers []AnswerScore
|
||||
}
|
||||
|
||||
// GameAnswers — полная картина для оценки организатором: вопросы сценария
|
||||
// (с правильными ответами) и ответы всех команд с баллами.
|
||||
type GameAnswers struct {
|
||||
Questions []*storytelling.Question
|
||||
Teams []TeamAnswers
|
||||
|
||||
@@ -7,31 +7,18 @@ import (
|
||||
"evening_detective_server/internal/modules/storytelling"
|
||||
)
|
||||
|
||||
// TeamResult — строка итоговой таблицы: команда, баллы по вопросам (в порядке
|
||||
// сценария), сумма баллов и место.
|
||||
type TeamResult struct {
|
||||
Team *game_model.Team
|
||||
Answers []AnswerScore
|
||||
// TotalScore — сумма баллов по всем вопросам сценария; неоценённые
|
||||
// вопросы считаются как 0.
|
||||
Team *game_model.Team
|
||||
Answers []AnswerScore
|
||||
TotalScore int
|
||||
// Place — место в итоговой таблице. Команды с одинаковой суммой баллов
|
||||
// и одинаковым числом ходов занимают одно место (следующее место
|
||||
// пропускается); проставляется функцией rankTeams.
|
||||
Place int
|
||||
Place int
|
||||
}
|
||||
|
||||
// GameResults — итоговая таблица игры: вопросы сценария (шапка таблицы) и
|
||||
// результаты команд, отсортированные по сумме баллов.
|
||||
type GameResults struct {
|
||||
Questions []*storytelling.Question
|
||||
Teams []TeamResult
|
||||
}
|
||||
|
||||
// buildTeamResults собирает строки итоговой таблицы по командам: баллы в
|
||||
// порядке вопросов сценария (неотвеченный/неоценённый вопрос — с пустым
|
||||
// текстом и Score=nil), сумма баллов (nil считается как 0). Команды в
|
||||
// результате — в порядке переданного списка; сортировку и места делает
|
||||
// rankTeams.
|
||||
func buildTeamResults(
|
||||
questions []*storytelling.Question,
|
||||
@@ -63,12 +50,6 @@ func buildTeamResults(
|
||||
return res
|
||||
}
|
||||
|
||||
// rankTeams сортирует строки итоговой таблицы по сумме баллов (убывание);
|
||||
// при равенстве суммы выше команда с меньшим числом ходов. Команды с
|
||||
// одинаковой суммой баллов И одинаковым числом ходов занимают одно место
|
||||
// (следующее место пропускается — схема 1,2,2,4 по ключу (сумма, ходы)).
|
||||
// id команды влияет только на порядок показа внутри группы (меньший id
|
||||
// первым), не на место. Входной срез не изменяется.
|
||||
func rankTeams(rows []TeamResult) []TeamResult {
|
||||
sorted := make([]TeamResult, len(rows))
|
||||
copy(sorted, rows)
|
||||
|
||||
@@ -17,7 +17,6 @@ func teamResult(id, actions, total int) TeamResult {
|
||||
}
|
||||
|
||||
func TestRankTeamsSortsByTotalThenActions(t *testing.T) {
|
||||
// У всех команд разный ключ (сумма, ходы) — места идут подряд 1..N.
|
||||
rows := []TeamResult{
|
||||
teamResult(3, 5, 10),
|
||||
teamResult(1, 3, 10),
|
||||
@@ -36,14 +35,12 @@ func TestRankTeamsSortsByTotalThenActions(t *testing.T) {
|
||||
t.Fatalf("rankTeams[%d].Place = %d, want %d", i, got[i].Place, wantPlaces[i])
|
||||
}
|
||||
}
|
||||
// Команды 1 и 3 имеют одинаковую сумму (10), но разные ходы — разные места.
|
||||
if got[1].Place == got[2].Place {
|
||||
t.Fatalf("равные суммы при разных ходах должны давать разные места: places = %d, %d", got[1].Place, got[2].Place)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRankTeamsSharesPlaceOnEqualTotalAndActions(t *testing.T) {
|
||||
// Одинаковая сумма И одинаковое число ходов — одно место у всех трёх.
|
||||
rows := []TeamResult{
|
||||
teamResult(5, 4, 9),
|
||||
teamResult(2, 4, 9),
|
||||
@@ -51,7 +48,6 @@ func TestRankTeamsSharesPlaceOnEqualTotalAndActions(t *testing.T) {
|
||||
}
|
||||
got := rankTeams(rows)
|
||||
|
||||
// Внутри группы порядок показа — по id asc (детерминированный).
|
||||
wantIDs := []int{2, 5, 7}
|
||||
for i, id := range wantIDs {
|
||||
if got[i].Team.ID != id {
|
||||
@@ -64,7 +60,6 @@ func TestRankTeamsSharesPlaceOnEqualTotalAndActions(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRankTeamsSkipsPlaceAfterSharedTie(t *testing.T) {
|
||||
// Две команды делят 1-е место, следующая получает 3-е (схема 1,1,3).
|
||||
rows := []TeamResult{
|
||||
teamResult(3, 2, 7),
|
||||
teamResult(1, 5, 10),
|
||||
@@ -116,7 +111,7 @@ func TestBuildTeamResultsScenarioOrderAndNilScores(t *testing.T) {
|
||||
"q3": {QuestionCode: "q3", Text: "ответ 3", Score: intPtr(-1)},
|
||||
},
|
||||
2: {
|
||||
"q2": {QuestionCode: "q2", Text: "ответ 2"}, // балл не выставлен
|
||||
"q2": {QuestionCode: "q2", Text: "ответ 2"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -126,7 +121,6 @@ func TestBuildTeamResultsScenarioOrderAndNilScores(t *testing.T) {
|
||||
t.Fatalf("len(buildTeamResults) = %d, want 2", len(got))
|
||||
}
|
||||
|
||||
// Команда 1: q1(5), q2 не отвечен, q3(-1) → сумма 4.
|
||||
first := got[0]
|
||||
if first.Team.ID != 1 || first.TotalScore != 4 {
|
||||
t.Fatalf("команда 1: Team.ID = %d, TotalScore = %d; want ID 1, Total 4", first.Team.ID, first.TotalScore)
|
||||
@@ -143,7 +137,6 @@ func TestBuildTeamResultsScenarioOrderAndNilScores(t *testing.T) {
|
||||
t.Fatalf("команда 1: неотвеченный q2 = %+v, want Score=nil и Text=\"\"", first.Answers[1])
|
||||
}
|
||||
|
||||
// Команда 2: все не оценены → сумма 0.
|
||||
second := got[1]
|
||||
if second.Team.ID != 2 || second.TotalScore != 0 {
|
||||
t.Fatalf("команда 2: Team.ID = %d, TotalScore = %d; want ID 2, Total 0", second.Team.ID, second.TotalScore)
|
||||
|
||||
@@ -40,24 +40,15 @@ func NewAnswerService(
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrTeamNotFound — команда не найдена или пароль неверен.
|
||||
ErrTeamNotFound = errors.New("Команда не найдена")
|
||||
// ErrGameNotFound — игра не найдена.
|
||||
ErrGameNotFound = errors.New("Игра не найдена")
|
||||
// ErrGameFinished — после завершения игры ответы команд больше нельзя
|
||||
// изменять: организатор оценивает их в панели игры.
|
||||
ErrGameFinished = errors.New("Игра завершена, ответы больше нельзя изменять")
|
||||
// ErrGameNotStarted — игра ещё не начата, отвечать на вопросы нельзя.
|
||||
ErrGameNotStarted = errors.New("Игра не начата, отвечать на вопросы нельзя")
|
||||
// ErrQuestionNotFound — вопрос с таким кодом отсутствует в сценарии игры.
|
||||
ErrTeamNotFound = errors.New("Команда не найдена")
|
||||
ErrGameNotFound = errors.New("Игра не найдена")
|
||||
ErrGameFinished = errors.New("Игра завершена, ответы больше нельзя изменять")
|
||||
ErrGameNotStarted = errors.New("Игра не начата, отвечать на вопросы нельзя")
|
||||
ErrQuestionNotFound = errors.New("Вопрос не найден")
|
||||
// ErrGameNotFinished — итоги игры доступны только после её завершения.
|
||||
ErrGameNotFinished = errors.New("Итоги доступны после завершения игры")
|
||||
// ErrOfflineAnswers — ответы для offline-игры недоступны через сайт.
|
||||
ErrOfflineAnswers = errors.New("В offline-игре ответы через сайт недоступны")
|
||||
ErrGameNotFinished = errors.New("Итоги доступны после завершения игры")
|
||||
ErrOfflineAnswers = errors.New("В offline-игре ответы через сайт недоступны")
|
||||
)
|
||||
|
||||
// getTeamAndGame проверяет команду по паролю и возвращает её вместе с игрой.
|
||||
func (s *AnswerService) getTeamAndGame(ctx context.Context, teamID int, password string) (*repos.Team, *repos.Game, error) {
|
||||
team, err := s.teamsRepo.GetTeamByID(ctx, teamID)
|
||||
if err != nil {
|
||||
@@ -79,7 +70,6 @@ func (s *AnswerService) getTeamAndGame(ctx context.Context, teamID int, password
|
||||
return team, game, nil
|
||||
}
|
||||
|
||||
// getScenarioQuestionsByCode возвращает вопросы сценария игры с индексацией по коду.
|
||||
func (s *AnswerService) getScenarioQuestionsByCode(ctx context.Context, scenarioID int) ([]*storytelling.Question, map[string]*storytelling.Question, error) {
|
||||
scenario, err := s.scenarioService.GetFullScenarioByID(ctx, scenarioID)
|
||||
if err != nil {
|
||||
@@ -92,9 +82,6 @@ func (s *AnswerService) getScenarioQuestionsByCode(ctx context.Context, scenario
|
||||
return scenario.Story.Questions, byCode, nil
|
||||
}
|
||||
|
||||
// GetTeamAnswers возвращает ответы команды на вопросы дела (без баллов:
|
||||
// баллы видны только организаторам). Ответы на удалённые из сценария
|
||||
// вопросы не возвращаются.
|
||||
func (s *AnswerService) GetTeamAnswers(ctx context.Context, teamID int, password string) ([]Answer, error) {
|
||||
_, game, err := s.getTeamAndGame(ctx, teamID, password)
|
||||
if err != nil {
|
||||
@@ -124,10 +111,6 @@ func (s *AnswerService) GetTeamAnswers(ctx context.Context, teamID int, password
|
||||
return answers, nil
|
||||
}
|
||||
|
||||
// SubmitTeamAnswers сохраняет ответы команды на вопросы дела (upsert).
|
||||
// Доступно только пока игра идёт (статусы run/pause): после завершения
|
||||
// ответы фиксируются для оценки организатором. Переданные коды должны
|
||||
// существовать в сценарии игры; пустая строка очищает ответ.
|
||||
func (s *AnswerService) SubmitTeamAnswers(ctx context.Context, teamID int, password string, answers []Answer) error {
|
||||
_, game, err := s.getTeamAndGame(ctx, teamID, password)
|
||||
if err != nil {
|
||||
@@ -138,7 +121,6 @@ func (s *AnswerService) SubmitTeamAnswers(ctx context.Context, teamID int, passw
|
||||
}
|
||||
switch game.Status {
|
||||
case "run", "pause":
|
||||
// игра идёт — можно отвечать
|
||||
default:
|
||||
if game.Status == "finish" {
|
||||
return ErrGameFinished
|
||||
@@ -162,12 +144,6 @@ func (s *AnswerService) SubmitTeamAnswers(ctx context.Context, teamID int, passw
|
||||
return s.answersRepo.UpsertAnswers(ctx, teamID, rows)
|
||||
}
|
||||
|
||||
// GetGameAnswers возвращает полную картину для оценки организатором:
|
||||
// вопросы сценария с правильными ответами и ответы всех команд с баллами.
|
||||
// Команда может не дать ответ на вопрос — такой вопрос попадает в список
|
||||
// ответов команды с пустым текстом и без балла (организатор видит его и
|
||||
// может выставить балл, в т.ч. штрафной). Ответы на удалённые из сценария
|
||||
// вопросы не возвращаются (как в GetTeamAnswers).
|
||||
func (s *AnswerService) GetGameAnswers(ctx context.Context, gameID int) (*GameAnswers, error) {
|
||||
game, err := s.gamesRepo.GetGameByID(ctx, gameID)
|
||||
if err != nil {
|
||||
@@ -211,8 +187,6 @@ func (s *AnswerService) GetGameAnswers(ctx context.Context, gameID int) (*GameAn
|
||||
teamAnswers := make([]TeamAnswers, 0, len(teams))
|
||||
for _, team := range teams {
|
||||
byCode := answerByTeamAndCode[team.ID]
|
||||
// Все вопросы сценария в порядке сценария; неотвеченные — с пустым
|
||||
// текстом и без балла, чтобы организатор видел пропуски.
|
||||
answers := make([]AnswerScore, 0, len(questions))
|
||||
for _, question := range questions {
|
||||
if answer, ok := byCode[question.Code]; ok {
|
||||
@@ -234,10 +208,6 @@ func (s *AnswerService) GetGameAnswers(ctx context.Context, gameID int) (*GameAn
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetTeamAnswerScore выставляет балл команде за ответ на вопрос дела;
|
||||
// score == nil сбрасывает балл. Балл может быть любым целым числом
|
||||
// (в т.ч. отрицательным — штрафные баллы). Вопрос должен существовать
|
||||
// в сценарии игры.
|
||||
func (s *AnswerService) SetTeamAnswerScore(ctx context.Context, teamID int, questionCode string, score *int) error {
|
||||
team, err := s.teamsRepo.GetTeamByID(ctx, teamID)
|
||||
if err != nil {
|
||||
@@ -257,11 +227,6 @@ func (s *AnswerService) SetTeamAnswerScore(ctx context.Context, teamID int, ques
|
||||
return s.answersRepo.SetAnswerScore(ctx, teamID, questionCode, score)
|
||||
}
|
||||
|
||||
// GetGameResults возвращает итоговую таблицу игры: вопросы сценария (шапка
|
||||
// таблицы, без правильных ответов) и команды, отсортированные по сумме
|
||||
// баллов, с баллами по каждому вопросу (в порядке сценария), числом ходов и
|
||||
// местом. Доступно только после завершения игры (статус finish); неоценённые
|
||||
// вопросы в сумме считаются как 0.
|
||||
func (s *AnswerService) GetGameResults(ctx context.Context, gameID int) (*GameResults, error) {
|
||||
game, err := s.gamesRepo.GetGameByID(ctx, gameID)
|
||||
if err != nil {
|
||||
|
||||
@@ -39,7 +39,6 @@ func TestAnswerAccessPostgres(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Отклонённые запросы не должны доходить до хранилищ сценариев и ответов.
|
||||
service := NewAnswerService(games_repo.NewGamesRepo(pool), teams_repo.NewTeamsRepo(pool), nil, nil, nil)
|
||||
for _, tc := range []struct {
|
||||
status string
|
||||
|
||||
@@ -13,20 +13,15 @@ import (
|
||||
"evening_detective_server/internal/modules/string_tools"
|
||||
)
|
||||
|
||||
// Ошибки валидации загружаемого файла. Маппятся в gRPC-статусы в app-слое:
|
||||
// ErrFileTooLarge → ResourceExhausted, остальные → InvalidArgument.
|
||||
var (
|
||||
ErrEmptyName = errors.New("пустое имя файла")
|
||||
ErrEmptyData = errors.New("пустые данные файла")
|
||||
ErrInvalidExtension = errors.New("недопустимое расширение файла (разрешены pdf, jpg, jpeg, png, gif, webp, mp3, ogg, wav, mp4, webm)")
|
||||
ErrInvalidContentType = errors.New("содержимое файла не соответствует разрешённым типам (pdf, изображения, аудио или видео)")
|
||||
ErrFileTooLarge = errors.New("файл слишком большой")
|
||||
// ErrFileNotFound — файл отсутствует в хранилище.
|
||||
ErrFileNotFound = errors.New("файл не найден")
|
||||
ErrFileNotFound = errors.New("файл не найден")
|
||||
)
|
||||
|
||||
// allowedExtensions — расширения, разрешённые для загрузки: PDF, изображения
|
||||
// и аудио/видео (введение и закрывашка загружаются тем же эндпоинтом).
|
||||
var allowedExtensions = map[string]struct{}{
|
||||
".pdf": {},
|
||||
".jpg": {},
|
||||
@@ -58,12 +53,6 @@ func NewFileService(
|
||||
}
|
||||
}
|
||||
|
||||
// UploadFile сохраняет файл в хранилище под уникальным именем и возвращает
|
||||
// сохранённое имя и категорию файла (image|pdf|audio|video).
|
||||
//
|
||||
// Порядок проверок: имя/данные → расширение → base → размер → содержимое.
|
||||
// Проверка размера — defense-in-depth: фактический лимит запроса
|
||||
// устанавливается на HTTP-слое (MaxBytesReader) и gRPC-лимитами.
|
||||
func (s *FileService) UploadFile(
|
||||
ctx context.Context,
|
||||
filename string,
|
||||
@@ -86,8 +75,6 @@ func (s *FileService) UploadFile(
|
||||
return "", "", ErrEmptyName
|
||||
}
|
||||
|
||||
// Транслитерация может обнулить base (например, «!!!.pdf»): хранимое
|
||||
// имя без base недопустимо — проверяем до размера и содержимого.
|
||||
transliterated := string_tools.Transliterate(base)
|
||||
if transliterated == "" {
|
||||
return "", "", ErrEmptyName
|
||||
@@ -97,8 +84,6 @@ func (s *FileService) UploadFile(
|
||||
return "", "", fmt.Errorf("%w (лимит %d МБ)", ErrFileTooLarge, s.maxFileSize>>20)
|
||||
}
|
||||
|
||||
// Категория по содержимому (сырой sniff): text/* и octet-stream не
|
||||
// проходят вообще, расширение должно соответствовать категории.
|
||||
sniffed := http.DetectContentType(data)
|
||||
if ext == ".mp3" && isMP3(data) {
|
||||
sniffed = "audio/mpeg"
|
||||
@@ -124,16 +109,12 @@ func (s *FileService) UploadFile(
|
||||
return storedName, fileType, nil
|
||||
}
|
||||
|
||||
// isMP3 распознаёт MP3 без необязательного ID3-тега по заголовку MPEG-фрейма.
|
||||
func isMP3(data []byte) bool {
|
||||
return len(data) >= 4 && data[0] == 0xff && data[1]&0xe0 == 0xe0 &&
|
||||
data[1]&0x18 != 0x08 && data[1]&0x06 != 0 &&
|
||||
data[2]&0xf0 != 0 && data[2]&0xf0 != 0xf0 && data[2]&0x0c != 0x0c
|
||||
}
|
||||
|
||||
// contentTypeCategory возвращает категорию по sniffed-типу:
|
||||
// application/pdf → pdf; image/* → image; audio/* и application/ogg → audio;
|
||||
// всё остальное (text/*, application/octet-stream и пр.) → "".
|
||||
func contentTypeCategory(sniffed string) string {
|
||||
switch {
|
||||
case sniffed == "application/pdf":
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"evening_detective_server/internal/modules/file_storage"
|
||||
)
|
||||
|
||||
// fakeStorage — in-memory реализация IFileStorage для тестов.
|
||||
type fakeStorage struct {
|
||||
files map[string]*file_storage.File
|
||||
}
|
||||
@@ -87,7 +86,6 @@ func TestUploadFileValid(t *testing.T) {
|
||||
if !strings.Contains(name, "_") {
|
||||
t.Errorf("%s: имя %q не содержит уникальный hex-суффикс", c.filename, name)
|
||||
}
|
||||
// Сохранённый MIME соответствует содержимому (sniffed-тип).
|
||||
stored, err := svc.fileStorage.Get(context.Background(), name)
|
||||
if err != nil {
|
||||
t.Fatalf("Get(%q): %v", name, err)
|
||||
@@ -144,11 +142,9 @@ func TestUploadFileValidation(t *testing.T) {
|
||||
func TestUploadFileTooLarge(t *testing.T) {
|
||||
svc := newService(1024)
|
||||
|
||||
// ровно лимит — проходит
|
||||
if _, _, err := svc.UploadFile(context.Background(), "clue.pdf", bytes1024()); err != nil {
|
||||
t.Errorf("ровно лимит: err = %v, want nil", err)
|
||||
}
|
||||
// больше лимита — ErrFileTooLarge
|
||||
_, _, err := svc.UploadFile(context.Background(), "clue.pdf", bytes1025())
|
||||
if !errors.Is(err, ErrFileTooLarge) {
|
||||
t.Errorf("больше лимита: err = %v, want ErrFileTooLarge", err)
|
||||
|
||||
@@ -9,9 +9,6 @@ import (
|
||||
"evening_detective_server/internal/repos"
|
||||
)
|
||||
|
||||
// mapApplications — улики команды для ответа организатору (GetFullGame):
|
||||
// имена файлов префиксуются доменом идемпотентно, file_type деривируется
|
||||
// из расширения при пустом значении (легаси-строки таблицы).
|
||||
func mapApplications(o []*repos.Application, domain string) []*storytelling.Application {
|
||||
res := make([]*storytelling.Application, 0, len(o))
|
||||
for _, item := range o {
|
||||
@@ -33,8 +30,6 @@ func mapApplication(o *repos.Application, domain string) *storytelling.Applicati
|
||||
return app
|
||||
}
|
||||
|
||||
// prefixDomain — идемпотентная префиксация ссылки доменом хранилища
|
||||
// (пустые ссылки и внешние http/https URL не трогаются).
|
||||
func prefixDomain(ref, domain string) string {
|
||||
if ref == "" || strings.HasPrefix(ref, domain) {
|
||||
return ref
|
||||
|
||||
@@ -19,7 +19,6 @@ func TestMapApplications(t *testing.T) {
|
||||
t.Fatalf("len = %d, want 2", len(got))
|
||||
}
|
||||
|
||||
// Относительное имя префиксуется доменом, file_type деривируется.
|
||||
if got[0].Image != testDomain+"clue.pdf" {
|
||||
t.Errorf("Image[0] = %q, want %q", got[0].Image, testDomain+"clue.pdf")
|
||||
}
|
||||
@@ -27,7 +26,6 @@ func TestMapApplications(t *testing.T) {
|
||||
t.Errorf("FileType[0] = %q, want pdf", got[0].FileType)
|
||||
}
|
||||
|
||||
// Внешний URL не префиксуется повторно.
|
||||
if got[1].Image != "https://external.example.com/pic.jpg" {
|
||||
t.Errorf("Image[1] = %q, want внешний URL без изменений", got[1].Image)
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ func TestServicesLifecyclePostgres(t *testing.T) {
|
||||
}
|
||||
config, err := pgxpool.ParseConfig(dsn)
|
||||
check(err)
|
||||
config.MaxConns = 1 // Временные таблицы должны оставаться в одном соединении.
|
||||
config.MaxConns = 1
|
||||
pool, err := pgxpool.NewWithConfig(ctx, config)
|
||||
check(err)
|
||||
defer pool.Close()
|
||||
|
||||
@@ -25,9 +25,7 @@ type GameService struct {
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrGameNotFound — игра не найдена.
|
||||
ErrGameNotFound = errors.New("Игра не найдена")
|
||||
// ErrInvalidGameFormat — формат игры не поддерживается.
|
||||
ErrGameNotFound = errors.New("Игра не найдена")
|
||||
ErrInvalidGameFormat = errors.New("Формат игры должен быть online или offline")
|
||||
)
|
||||
|
||||
|
||||
@@ -9,11 +9,6 @@ import (
|
||||
"evening_detective_server/internal/repos"
|
||||
)
|
||||
|
||||
// newApplication — улика для сохранения команде (AddTeamAction): имена
|
||||
// приходят из истории уже с доменным префиксом (mapStory), в БД хранятся
|
||||
// относительные пути — префикс снимается идемпотентно; file_type
|
||||
// деривируется при пустом значении. Name не меняется (очистка — в
|
||||
// вызывающем коде, cleaner'ом).
|
||||
func newApplication(app *storytelling.Application, domain string) *repos.Application {
|
||||
image := strings.TrimPrefix(app.Image, domain)
|
||||
fileType := app.FileType
|
||||
|
||||
@@ -12,8 +12,8 @@ func TestNewApplication(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
app *storytelling.Application
|
||||
image string // ожидаемое хранимое имя (без домена)
|
||||
ftype string // ожидаемый file_type
|
||||
image string
|
||||
ftype string
|
||||
}{
|
||||
{
|
||||
name: "доменный префикс снимается, file_type сохраняется",
|
||||
|
||||
@@ -2,7 +2,6 @@ package gameplay_service
|
||||
|
||||
import "evening_detective_server/internal/modules/storytelling"
|
||||
|
||||
// filterTeamStory применяется только к истории команды, общей для сайта и MCP.
|
||||
func filterTeamStory(story *storytelling.Story, format string) {
|
||||
if format != "offline" {
|
||||
return
|
||||
|
||||
@@ -15,10 +15,6 @@ import (
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
// Интеграционный тест HTTP-«клея»: MCP-сервер монтируется как
|
||||
// streamable HTTP (эндпоинт /api/mcp) и вызывается через HTTP-клиент mcp-go.
|
||||
// Проверяются initialize, tools/list (точное множество) и tools/call
|
||||
// connect — как это будет делать внешний MCP-клиент.
|
||||
func TestStreamableHTTPEndToEnd(t *testing.T) {
|
||||
svc := NewMCPService(&fakeGamePlayer{story: defaultStory(), game: defaultGame()}, &fakeAnswerSubmitter{})
|
||||
|
||||
@@ -29,8 +25,6 @@ func TestStreamableHTTPEndToEnd(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
// ВАЖНО: клиент POST-ит строго на переданный baseURL без добавления
|
||||
// пути — эндпоинт /api/mcp передаём явно.
|
||||
mcpClient, err := client.NewStreamableHttpClient(srv.URL + "/api/mcp")
|
||||
if err != nil {
|
||||
t.Fatalf("NewStreamableHttpClient() error = %v", err)
|
||||
@@ -77,8 +71,6 @@ func TestStreamableHTTPEndToEnd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка, что структуры моков сериализуемы (защита от изменения типов
|
||||
// storytelling/game_service без обновления моков).
|
||||
func TestMockTypesUsable(_ *testing.T) {
|
||||
_ = defaultStory()
|
||||
_ = defaultGame()
|
||||
|
||||
@@ -19,27 +19,17 @@ import (
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
// defaultCallTimeout — таймаут на выполнение одного инструмента:
|
||||
// защита от зависших запросов к БД (10s — как у прежнего HTTP-прокси).
|
||||
const defaultCallTimeout = 10 * time.Second
|
||||
|
||||
// GamePlayer — граница доступа к игровым сервисам, реализуемая
|
||||
// *gameplay_service.GameplayService; интерфейс позволяет тестировать сервис
|
||||
// без БД.
|
||||
type GamePlayer interface {
|
||||
GetTeamActions(ctx context.Context, teamID int, password string) (*storytelling.Story, *game_model.Game, []actions_repo.Action, error)
|
||||
AddTeamAction(ctx context.Context, teamID int, password, actionCode string, viaMCP bool) error
|
||||
}
|
||||
|
||||
// AnswerSubmitter сохраняет ответы команды независимо от прохождения истории.
|
||||
type AnswerSubmitter interface {
|
||||
SubmitTeamAnswers(ctx context.Context, teamID int, password string, answers []answer_service.Answer) error
|
||||
}
|
||||
|
||||
// MCPService собирает MCP-сервер с игровыми инструментами. Сервис не
|
||||
// хранит состояние между вызовами: каждый вызов получает id команды и
|
||||
// пароль явно — либо из ссылки на игру через инструмент connect, либо
|
||||
// напрямую.
|
||||
type MCPService struct {
|
||||
game GamePlayer
|
||||
answers AnswerSubmitter
|
||||
@@ -124,13 +114,6 @@ type connectArgs struct {
|
||||
password string
|
||||
}
|
||||
|
||||
// parseConnectURL разбирает ссылку на игру вида /team-story/{id}?password=...
|
||||
// и возвращает id команды и пароль.
|
||||
//
|
||||
// Ссылка используется ТОЛЬКО как носитель id и пароля: MCP-сервер не
|
||||
// выполняет HTTP-запросов по ней, поэтому схема и хост не проверяются
|
||||
// (протокол-относительная ссылка //host/team-story/{id}?password=...
|
||||
// принимается, «mailto:...» отклоняется на проверке пути).
|
||||
func parseConnectURL(rawURL string) (connectArgs, error) {
|
||||
if strings.TrimSpace(rawURL) == "" {
|
||||
return connectArgs{}, fmt.Errorf("требуется url")
|
||||
@@ -280,10 +263,6 @@ type submitAnswersArgs struct {
|
||||
answers []answer_service.Answer
|
||||
}
|
||||
|
||||
// parseSubmitAnswersArgs разбирает аргументы инструмента submit_answers:
|
||||
// answers — массив объектов {questionCode, answer} (JSON-массив приходит
|
||||
// как []any с map[string]any элементами). Ключ questionCode совпадает с
|
||||
// REST JSON (camelCase) — единый формат для клиентов.
|
||||
func parseSubmitAnswersArgs(args map[string]any) (submitAnswersArgs, error) {
|
||||
teamID, ok := argInt64(args["team_id"])
|
||||
if !ok {
|
||||
@@ -345,8 +324,6 @@ func (s *MCPService) handleSubmitAnswers(ctx context.Context, req mcp.CallToolRe
|
||||
return mcp.NewToolResultText(jsonBody), nil
|
||||
}
|
||||
|
||||
// argInt64 достаёт int64 из аргумента инструмента: числа приходят как
|
||||
// float64 (JSON), строки — как string.
|
||||
func argInt64(v any) (int64, bool) {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
|
||||
@@ -16,8 +16,6 @@ import (
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
)
|
||||
|
||||
// fakeGamePlayer — мок GamePlayer с предзаданными историей, игрой и
|
||||
// ошибками (реализует интерфейс GamePlayer, БД не нужна).
|
||||
type fakeGamePlayer struct {
|
||||
story *storytelling.Story
|
||||
game *game_model.Game
|
||||
@@ -82,8 +80,6 @@ func defaultGame() *game_model.Game {
|
||||
}
|
||||
}
|
||||
|
||||
// mcpFixture поднимает встроенный MCP-клиент над сервисом с моком
|
||||
// GamePlayer. Каждый кейс создаёт СВЕЖИЙ сервис.
|
||||
type mcpFixture struct {
|
||||
client *client.Client
|
||||
answers *fakeAnswerSubmitter
|
||||
@@ -118,7 +114,6 @@ func newMCPFixture(t *testing.T, player *fakeGamePlayer) *mcpFixture {
|
||||
return &mcpFixture{client: mcpClient, answers: answers}
|
||||
}
|
||||
|
||||
// call вызывает инструмент с аргументами и возвращает текст результата.
|
||||
func (f *mcpFixture) call(t *testing.T, name string, args map[string]any) string {
|
||||
t.Helper()
|
||||
request := mcp.CallToolRequest{}
|
||||
@@ -141,7 +136,6 @@ func (f *mcpFixture) call(t *testing.T, name string, args map[string]any) string
|
||||
return text.String()
|
||||
}
|
||||
|
||||
// toolNames возвращает имена инструментов сервера.
|
||||
func (f *mcpFixture) toolNames(t *testing.T) []string {
|
||||
t.Helper()
|
||||
toolsResult, err := f.client.ListTools(context.Background(), mcp.ListToolsRequest{})
|
||||
@@ -199,8 +193,6 @@ func TestConnectRelativeURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnectProtocolRelativeURL: протокол-относительная ссылка принимается
|
||||
// (запросы по ней не выполняются — из ссылки берутся только id и пароль).
|
||||
func TestConnectProtocolRelativeURL(t *testing.T) {
|
||||
f := newMCPFixture(t, &fakeGamePlayer{story: defaultStory(), game: defaultGame()})
|
||||
|
||||
@@ -313,7 +305,7 @@ func TestMakeMove(t *testing.T) {
|
||||
f := newMCPFixture(t, player)
|
||||
|
||||
got := f.call(t, "make_move", map[string]any{
|
||||
"team_id": "10", // строка тоже принимается
|
||||
"team_id": "10",
|
||||
"password": "team-pass-1",
|
||||
"code": "hall",
|
||||
})
|
||||
@@ -408,7 +400,6 @@ func TestParseConnectURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewMCPService — конструктор не требует БД и возвращает сервис.
|
||||
func TestNewMCPService(t *testing.T) {
|
||||
svc := NewMCPService(&fakeGamePlayer{}, &fakeAnswerSubmitter{})
|
||||
if svc == nil {
|
||||
@@ -419,7 +410,6 @@ func TestNewMCPService(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultCallTimeout — защита от случайного изменения таймаута.
|
||||
func TestDefaultCallTimeout(t *testing.T) {
|
||||
if defaultCallTimeout != 10*time.Second {
|
||||
t.Fatalf("defaultCallTimeout = %v; want 10s", defaultCallTimeout)
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Participation — данные участия, доступные транспортному и email-сценариям.
|
||||
type Participation struct {
|
||||
TeamID int
|
||||
TeamName string
|
||||
|
||||
@@ -99,7 +99,7 @@ func TestParticipationPostgres(t *testing.T) {
|
||||
}
|
||||
}
|
||||
state(1, "pending")
|
||||
state(2, "pending") // Существующие команды миграция не изменяет.
|
||||
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,6 @@ func TestParticipationPostgres(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state(5, "confirmed")
|
||||
// Независимые рабочие процессы должны захватывать каждую подходящую команду только один раз.
|
||||
var wg sync.WaitGroup
|
||||
ids := make(chan int, 8)
|
||||
errs := make(chan error, 8)
|
||||
@@ -158,7 +157,6 @@ func TestParticipationPostgres(t *testing.T) {
|
||||
t.Fatalf("failed send not recorded: %+v %v", team, err)
|
||||
}
|
||||
sender.err = nil
|
||||
// Истёкшая аренда восстанавливается после перезапуска рабочего процесса.
|
||||
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 +207,6 @@ func TestParticipationPostgres(t *testing.T) {
|
||||
if _, err := service.AnswerParticipation(ctx, token, p.Status); err != nil {
|
||||
t.Fatalf("same answer not idempotent: %v", err)
|
||||
}
|
||||
// Отмена исключает команды из общих активных списков и игры по паролю.
|
||||
token3 := participationToken("secret", 3)
|
||||
if _, err := service.AnswerParticipation(ctx, token3, "cancelled"); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -58,10 +58,6 @@ func mapParticipationError(err error) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Стабильные токены для разных целей сохраняют работоспособность ссылок при
|
||||
// повторе после неоднозначной ошибки SMTP.
|
||||
// В базе хранится только хеш; секрет подписи должен быть постоянным между
|
||||
// рабочими процессами и перезапусками.
|
||||
func participationToken(secret string, id int) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = fmt.Fprintf(mac, "team-participation:v1:%d", id)
|
||||
@@ -129,7 +125,6 @@ func (s *ParticipationService) sendParticipationEmails(ctx context.Context, send
|
||||
err = sender.Send(jobCtx, participationEmail(p, s.appBaseURL, token, location))
|
||||
}
|
||||
cancel()
|
||||
// Сохраняем результат даже если отправка исчерпала время задачи.
|
||||
saveCtx, saveCancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
saveErr := s.teamsRepo.FinishParticipationEmail(saveCtx, p.TeamID, p.Attempts, err)
|
||||
saveCancel()
|
||||
|
||||
@@ -91,9 +91,6 @@ func mapStory(o string, domain string) (*storytelling.Story, error) {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// prefixDomain — идемпотентная префиксация ссылки доменом хранилища:
|
||||
// пустые ссылки и внешние http/https URL не трогаются, повторный префикс
|
||||
// исключён. Единое правило для картинок точек, аудио введения и улик.
|
||||
func prefixDomain(ref, domain string) string {
|
||||
if ref == "" || strings.HasPrefix(ref, domain) {
|
||||
return ref
|
||||
|
||||
@@ -7,9 +7,6 @@ import (
|
||||
"evening_detective_server/internal/modules/storytelling"
|
||||
)
|
||||
|
||||
// TestMapStoryPrefixesEvidence — улики получают полный URL (домен + имя),
|
||||
// как картинки точек и аудио введения; префиксация идемпотентна, внешние
|
||||
// URL не трогаются; file_type деривируется при пустом значении.
|
||||
func TestMapStoryPrefixesEvidence(t *testing.T) {
|
||||
storyJSON := `{
|
||||
"introduction": {"text": "Вступление", "audio": "intro.mp3"},
|
||||
@@ -38,7 +35,6 @@ func TestMapStoryPrefixesEvidence(t *testing.T) {
|
||||
t.Errorf("place.Image = %q, want %q", place.Image, testDomain+"place.png")
|
||||
}
|
||||
|
||||
// Улика из хранилища: полный URL + деривированный file_type.
|
||||
app := place.Applications[0]
|
||||
if app.Image != testDomain+"contract.pdf" {
|
||||
t.Errorf("application.Image = %q, want %q", app.Image, testDomain+"contract.pdf")
|
||||
@@ -47,7 +43,6 @@ func TestMapStoryPrefixesEvidence(t *testing.T) {
|
||||
t.Errorf("application.FileType = %q, want pdf", app.FileType)
|
||||
}
|
||||
|
||||
// Внешний URL не префиксуется, file_type сохраняется.
|
||||
external := place.Applications[1]
|
||||
if external.Image != "https://external.example.com/pic.jpg" {
|
||||
t.Errorf("external.Image = %q, want без изменений", external.Image)
|
||||
@@ -56,7 +51,6 @@ func TestMapStoryPrefixesEvidence(t *testing.T) {
|
||||
t.Errorf("external.FileType = %q, want image", external.FileType)
|
||||
}
|
||||
|
||||
// Идемпотентность: повторный прогон не даёт двойного префикса.
|
||||
jsonOut, err := convertStory(story)
|
||||
if err != nil {
|
||||
t.Fatalf("convertStory: %v", err)
|
||||
@@ -70,8 +64,6 @@ func TestMapStoryPrefixesEvidence(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeFileType — невалидный file_type заменяется деривацией из
|
||||
// расширения файла, известные значения сохраняются.
|
||||
func TestNormalizeFileType(t *testing.T) {
|
||||
cases := []struct {
|
||||
fileType string
|
||||
@@ -92,8 +84,6 @@ func TestNormalizeFileType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeStoryBanana — файл-улика с невалидным file_type проходит
|
||||
// normalizeStory и получает деривированный тип.
|
||||
func TestNormalizeStoryBanana(t *testing.T) {
|
||||
story := &storytelling.Story{
|
||||
Places: []*storytelling.Place{
|
||||
@@ -121,14 +111,11 @@ func TestNormalizeStoryBanana(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateStoryTrimsEvidenceDomain — сохранение истории снимает доменный
|
||||
// префикс с улик (в БД хранятся относительные имена).
|
||||
func TestUpdateStoryTrimsEvidenceDomain(t *testing.T) {
|
||||
repo := newFakeScenariosRepo()
|
||||
storage := newFakeStorage()
|
||||
svc := newTestService(repo, storage)
|
||||
|
||||
// Сценарий с историей, где улика уже с доменным префиксом.
|
||||
scenario := seedScenario(t, repo, storage)
|
||||
scenario.Scenario = `{"places":[{"code":"p1","name":"Место","text":"","image":"place.png","applications":[{"name":"Договор","image":"http://storage.test/api/files/contract.pdf","file_type":"pdf"}]}]}`
|
||||
|
||||
|
||||
@@ -14,8 +14,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// IScenariosRepository — граница доступа к данным сценариев, реализуемая
|
||||
// *scenarios_repo.ScenariosRepo (интерфейс — для тестов без БД).
|
||||
type IScenariosRepository interface {
|
||||
AddScenario(ctx context.Context, name string, authorID int) (int, error)
|
||||
GetScenariosByAuthorID(ctx context.Context, authorID int) ([]*repos.Scenario, error)
|
||||
@@ -130,32 +128,20 @@ func (s *ScenarioService) UpdateScenarioByID(
|
||||
return s.scenariosRepo.UpdateScenarioByID(ctx, id, name, description, image)
|
||||
}
|
||||
|
||||
// ErrScenarioPublished — опубликованный сценарий нельзя снять с публикации
|
||||
// или удалить: при публикации исключительное право отчуждается Оператору
|
||||
// (п. 7.2 Пользовательского соглашения, ст. 1234 ГК РФ), а право на отзыв
|
||||
// действует только до обнародования (ст. 1269 ГК РФ).
|
||||
var ErrScenarioPublished = errors.New("Опубликованный сценарий нельзя снять с публикации или удалить: исключительное право отчуждено Оператору")
|
||||
|
||||
// ErrScenarioNotOwner — сценарий принадлежит другому пользователю.
|
||||
var ErrScenarioNotOwner = errors.New("Сценарий принадлежит другому пользователю")
|
||||
|
||||
// ErrScenarioNotFound — сценарий не найден или уже удалён.
|
||||
var ErrScenarioNotFound = errors.New("Сценарий не найден")
|
||||
|
||||
// ErrInvalidArchive — архив не соответствует формату сценария.
|
||||
var ErrInvalidArchive = errors.New("Некорректный архив сценария")
|
||||
|
||||
// ErrInvalidScenarioData — данные истории сценария не проходят валидацию.
|
||||
var ErrInvalidScenarioData = errors.New("Некорректные данные сценария")
|
||||
|
||||
// MaxArchiveSize возвращает максимальный размер загружаемого архива.
|
||||
func MaxArchiveSize() int {
|
||||
return scenario_archive.MaxArchiveSize()
|
||||
}
|
||||
|
||||
// getScenarioForChange проверяет права актора на изменение сценария:
|
||||
// не-админ должен быть автором сценария, сценарий не должен быть удалён.
|
||||
// Возвращает сценарий, чтобы вызывающий код не делал повторное чтение.
|
||||
func (s *ScenarioService) getScenarioForChange(
|
||||
ctx context.Context,
|
||||
id int,
|
||||
@@ -198,15 +184,11 @@ func (s *ScenarioService) UpdateScenarioStatusByID(
|
||||
return err
|
||||
}
|
||||
if !isAdmin && scenario.Status == "public" {
|
||||
// Повторная публикация уже опубликованного — идемпотентный успех
|
||||
// (published_at не двигается: CASE WHEN published_at IS NULL).
|
||||
if status == "public" {
|
||||
return nil
|
||||
}
|
||||
return ErrScenarioPublished
|
||||
}
|
||||
// Guard в SQL защищает от гонки «чтение статуса → запись»: не-админ не
|
||||
// сможет перевести в draft сценарий, опубликованный между чтением и UPDATE.
|
||||
updated, err := s.scenariosRepo.UpdateScenarioStatusByID(ctx, id, status, isAdmin)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -227,8 +209,6 @@ func (s *ScenarioService) DeleteScenarioByID(
|
||||
if _, err := s.getScenarioForChange(ctx, id, actorID, isAdmin); err != nil {
|
||||
return err
|
||||
}
|
||||
// Guard в SQL защищает от гонки «чтение статуса → запись»: не-админ не
|
||||
// сможет удалить сценарий, опубликованный между чтением и UPDATE.
|
||||
updated, err := s.scenariosRepo.DeleteScenarioByID(ctx, id, isAdmin)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -310,7 +290,6 @@ func (s *ScenarioService) DeleteScenarioPlace(
|
||||
return s.updateStory(ctx, id, story)
|
||||
}
|
||||
|
||||
// UpdateScenarioBackground устанавливает фон; пустое значение снимает его с дела.
|
||||
func (s *ScenarioService) UpdateScenarioBackground(ctx context.Context, id int, background string, actorID int, isAdmin bool) error {
|
||||
if _, err := s.getScenarioForChange(ctx, id, actorID, isAdmin); err != nil {
|
||||
return err
|
||||
@@ -326,7 +305,6 @@ func (s *ScenarioService) UpdateScenarioBackground(ctx context.Context, id int,
|
||||
return s.updateStory(ctx, id, story)
|
||||
}
|
||||
|
||||
// UpdateScenarioIntro сохраняет введение сценария (текст и аудио).
|
||||
func (s *ScenarioService) UpdateScenarioIntro(
|
||||
ctx context.Context,
|
||||
id int,
|
||||
@@ -358,7 +336,6 @@ func (s *ScenarioService) UpdateScenarioConclusion(ctx context.Context, id int,
|
||||
return s.updateStory(ctx, id, story)
|
||||
}
|
||||
|
||||
// AddScenarioQuestion добавляет вопрос дела в конец списка вопросов сценария.
|
||||
func (s *ScenarioService) AddScenarioQuestion(
|
||||
ctx context.Context,
|
||||
id int,
|
||||
@@ -377,8 +354,6 @@ func (s *ScenarioService) AddScenarioQuestion(
|
||||
return s.updateStory(ctx, id, story)
|
||||
}
|
||||
|
||||
// UpdateScenarioQuestion обновляет вопрос дела по его коду. Смена кода
|
||||
// оставляет строки team_answers старого кода в БД
|
||||
func (s *ScenarioService) UpdateScenarioQuestion(
|
||||
ctx context.Context,
|
||||
id int,
|
||||
@@ -403,7 +378,6 @@ func (s *ScenarioService) UpdateScenarioQuestion(
|
||||
return s.updateStory(ctx, id, story)
|
||||
}
|
||||
|
||||
// DeleteScenarioQuestion удаляет вопрос дела по его коду.
|
||||
func (s *ScenarioService) DeleteScenarioQuestion(
|
||||
ctx context.Context,
|
||||
id int,
|
||||
@@ -435,7 +409,6 @@ func (s *ScenarioService) getStory(ctx context.Context, id int) (*storytelling.S
|
||||
return mapStory(storyString, s.domain)
|
||||
}
|
||||
|
||||
// DownloadArchive собирает ZIP-архив сценария
|
||||
func (s *ScenarioService) DownloadArchive(
|
||||
ctx context.Context,
|
||||
id int,
|
||||
@@ -472,9 +445,6 @@ func (s *ScenarioService) DownloadArchive(
|
||||
return data, scenario.Name, nil
|
||||
}
|
||||
|
||||
// UploadArchive импортирует сценарий из ZIP-архива: изображения сохраняются
|
||||
// под новыми случайными именами (не перетирают чужие файлы), ссылки
|
||||
// переписываются. Новый сценарий всегда draft — статус из архива игнорируется.
|
||||
func (s *ScenarioService) UploadArchive(
|
||||
ctx context.Context,
|
||||
data []byte,
|
||||
@@ -486,7 +456,6 @@ func (s *ScenarioService) UploadArchive(
|
||||
}
|
||||
scenario := bundle.Scenario
|
||||
|
||||
// При ошибке удаляем загруженные файлы (best-effort) — без orphan-объектов.
|
||||
uploaded := map[string]string{}
|
||||
ok := false
|
||||
defer func() {
|
||||
@@ -499,7 +468,6 @@ func (s *ScenarioService) UploadArchive(
|
||||
}()
|
||||
|
||||
rewrite := func(ref string) (string, error) {
|
||||
// Берём только ссылки images/... из архива; остальное — как есть.
|
||||
archiveRef := strings.TrimPrefix(ref, s.domain)
|
||||
if !strings.HasPrefix(archiveRef, "images/") {
|
||||
return ref, nil
|
||||
@@ -570,7 +538,6 @@ func (s *ScenarioService) UploadArchive(
|
||||
return 0, err
|
||||
}
|
||||
if err := s.scenariosRepo.UpdateScenarioByID(ctx, id, scenario.Name, scenario.Description, image); err != nil {
|
||||
// Зачищаем строку сценария (best-effort), как и файлы.
|
||||
_, _ = s.scenariosRepo.DeleteScenarioByID(ctx, id, true)
|
||||
return 0, err
|
||||
}
|
||||
@@ -604,9 +571,6 @@ func (s *ScenarioService) updateStory(ctx context.Context, id int, story *storyt
|
||||
return s.scenariosRepo.UpdateStoryByScenarioID(ctx, id, storyString)
|
||||
}
|
||||
|
||||
// normalizeStory проверяет уникальность кодов точек, отбрасывает пустые коды,
|
||||
// нормализует file_type улик и возвращает JSON истории.
|
||||
// Общая для редактирования и импорта.
|
||||
func normalizeStory(story *storytelling.Story) (string, error) {
|
||||
if story.Conclusion != nil && story.Conclusion.Video != "" && file_storage.FileType(story.Conclusion.Video) != "video" {
|
||||
return "", errors.New("видео закрывашки должно быть в формате MP4 или WebM")
|
||||
@@ -625,16 +589,12 @@ func normalizeStory(story *storytelling.Story) (string, error) {
|
||||
continue
|
||||
}
|
||||
for _, application := range place.Applications {
|
||||
// file_type принимает только известные значения; невалидные
|
||||
// (включая значения из импортированных архивов) заменяются
|
||||
// деривацией из расширения файла.
|
||||
application.FileType = normalizeFileType(application.FileType, application.Image)
|
||||
}
|
||||
cleanPlaces = append(cleanPlaces, place)
|
||||
}
|
||||
story.Places = cleanPlaces
|
||||
|
||||
// Коды вопросов уникальны, пустые коды отбрасываются (как у точек).
|
||||
questionCodes := map[string]struct{}{}
|
||||
for _, question := range story.Questions {
|
||||
questionCodes[question.Code] = struct{}{}
|
||||
@@ -654,10 +614,6 @@ func normalizeStory(story *storytelling.Story) (string, error) {
|
||||
return convertStory(story)
|
||||
}
|
||||
|
||||
// normalizeFileType приводит file_type к одному из {pdf, image, audio, ""}.
|
||||
// Пустое/невалидное значение при сохранении заменяется деривацией из
|
||||
// расширения файла; пустое значение в legacy-строках деривируется при
|
||||
// чтении (mapStory).
|
||||
func normalizeFileType(fileType, image string) string {
|
||||
switch fileType {
|
||||
case "pdf", "image", "audio":
|
||||
|
||||
@@ -92,7 +92,6 @@ func TestConclusionLifecycle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// fakeStorage — in-memory реализация IFileStorage.
|
||||
type fakeStorage struct {
|
||||
mu sync.Mutex
|
||||
files map[string]*file_storage.File
|
||||
@@ -148,7 +147,6 @@ func (s *fakeStorage) names() []string {
|
||||
return res
|
||||
}
|
||||
|
||||
// fakeScenariosRepo — минимальная in-memory реализация IScenariosRepository.
|
||||
type fakeScenariosRepo struct {
|
||||
mu sync.Mutex
|
||||
byID map[int]*repos.Scenario
|
||||
@@ -316,7 +314,6 @@ func TestPublishDraftScenario(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// seedScenario кладёт в repo сценарий с историей и изображениями в storage.
|
||||
func seedScenario(t *testing.T, repo *fakeScenariosRepo, storage *fakeStorage) *repos.Scenario {
|
||||
t.Helper()
|
||||
description := "Детективная история"
|
||||
@@ -345,14 +342,12 @@ func TestUploadArchive(t *testing.T) {
|
||||
srcStorage := newFakeStorage()
|
||||
seedScenario(t, repo, srcStorage)
|
||||
|
||||
// Собираем архив как при скачивании (Pack через storage).
|
||||
svc := newTestService(repo, srcStorage)
|
||||
archive, _, err := svc.DownloadArchive(context.Background(), 1, 7, false)
|
||||
if err != nil {
|
||||
t.Fatalf("DownloadArchive: %v", err)
|
||||
}
|
||||
|
||||
// Импортируем в «чистые» repo и storage.
|
||||
importRepo := newFakeScenariosRepo()
|
||||
importStorage := newFakeStorage()
|
||||
importSvc := newTestService(importRepo, importStorage)
|
||||
@@ -382,7 +377,6 @@ func TestUploadArchive(t *testing.T) {
|
||||
t.Errorf("Author = %+v, want id 42", saved.Author)
|
||||
}
|
||||
|
||||
// Ссылки переписаны на новые имена без префикса images/.
|
||||
if saved.Image == nil || strings.HasPrefix(*saved.Image, "images/") || *saved.Image == "cover.png" {
|
||||
t.Errorf("Image = %v, want новое случайное имя без images/", saved.Image)
|
||||
}
|
||||
@@ -400,12 +394,10 @@ func TestUploadArchive(t *testing.T) {
|
||||
if len(club.Applications) != 1 || strings.HasPrefix(club.Applications[0].Image, "images/") {
|
||||
t.Errorf("application image = %+v, want новое имя", club.Applications)
|
||||
}
|
||||
// Одна и та же картинка в двух точках — одно новое имя.
|
||||
if story.Places[1].Image != club.Image {
|
||||
t.Errorf("дублирующаяся картинка переписана по-разному: %q и %q", story.Places[1].Image, club.Image)
|
||||
}
|
||||
|
||||
// Файлы загружены в storage под новыми именами с правильным содержимым.
|
||||
files := importStorage.names()
|
||||
if len(files) != 3 {
|
||||
t.Fatalf("storage files = %v, want 3", files)
|
||||
@@ -446,8 +438,6 @@ func TestUploadArchiveWithIntro(t *testing.T) {
|
||||
_ = srcStorage.Put(context.Background(), &file_storage.File{Name: "club.png", Data: []byte("club-bytes")})
|
||||
_ = srcStorage.Put(context.Background(), &file_storage.File{Name: "intro.mp3", Data: []byte("intro-audio-bytes")})
|
||||
|
||||
// Экспорт (Pack кладёт аудио введения в архив), затем импорт в «чистые»
|
||||
// repo и storage: ссылки должны быть переписаны на новые имена.
|
||||
svc := newTestService(repo, srcStorage)
|
||||
archive, _, err := svc.DownloadArchive(context.Background(), 1, 7, false)
|
||||
if err != nil {
|
||||
@@ -478,7 +468,6 @@ func TestUploadArchiveWithIntro(t *testing.T) {
|
||||
t.Errorf("Introduction.Audio = %q, want новое случайное имя без images/", story.Introduction.Audio)
|
||||
}
|
||||
|
||||
// Аудио введения загружено в storage под новым именем.
|
||||
files := importStorage.names()
|
||||
found := false
|
||||
for _, name := range files {
|
||||
@@ -500,7 +489,6 @@ func TestUploadArchiveMissingImage(t *testing.T) {
|
||||
storage := newFakeStorage()
|
||||
svc := newTestService(repo, storage)
|
||||
|
||||
// Архив ссылается на images/missing.png, файла в архиве нет.
|
||||
archive := buildRawArchive(t, map[string][]byte{
|
||||
scenario_archive.FileName: mustJSON(t, scenario_archive.ScenarioJSON{
|
||||
Version: scenario_archive.Version,
|
||||
@@ -564,7 +552,6 @@ func TestUploadArchiveCleanupOnRepoError(t *testing.T) {
|
||||
if _, err := svc.UploadArchive(context.Background(), archive, 1); err == nil {
|
||||
t.Fatal("UploadArchive должен вернуть ошибку репозитория")
|
||||
}
|
||||
// Загруженные файлы удалены (best-effort), orphan-объектов не осталось.
|
||||
if len(storage.names()) != 0 {
|
||||
t.Errorf("после ошибки БД storage должен быть пуст: %v", storage.names())
|
||||
}
|
||||
@@ -589,7 +576,6 @@ func TestUploadArchiveDeletesRowOnPartialFailure(t *testing.T) {
|
||||
if _, err := svc.UploadArchive(context.Background(), archive, 1); err == nil {
|
||||
t.Fatal("UploadArchive должен вернуть ошибку репозитория")
|
||||
}
|
||||
// Строка помечена удалённой (best-effort, soft delete как в проде), файлы зачищены.
|
||||
if got := repo.get(1); got == nil || !got.IsDeleted {
|
||||
t.Errorf("строка сценария должна быть удалена после частичного сбоя, осталась: %+v", got)
|
||||
}
|
||||
@@ -638,7 +624,6 @@ func TestDownloadArchiveNotOwner(t *testing.T) {
|
||||
if _, _, err := svc.DownloadArchive(context.Background(), 1, 99, false); !errors.Is(err, ErrScenarioNotOwner) {
|
||||
t.Errorf("err = %v, want ErrScenarioNotOwner", err)
|
||||
}
|
||||
// Админ может скачать чужой сценарий.
|
||||
if _, _, err := svc.DownloadArchive(context.Background(), 1, 99, true); err != nil {
|
||||
t.Errorf("админ: err = %v", err)
|
||||
}
|
||||
@@ -672,7 +657,6 @@ func TestUpdateScenarioIntro(t *testing.T) {
|
||||
seedScenario(t, repo, storage)
|
||||
svc := newTestService(repo, storage)
|
||||
|
||||
// Установка введения автором.
|
||||
err := svc.UpdateScenarioIntro(context.Background(), 1, &storytelling.Introduction{
|
||||
Text: "Введение",
|
||||
Audio: "intro.mp3",
|
||||
@@ -681,7 +665,6 @@ func TestUpdateScenarioIntro(t *testing.T) {
|
||||
t.Fatalf("UpdateScenarioIntro: %v", err)
|
||||
}
|
||||
|
||||
// В хранилище — голое имя аудио, без домена.
|
||||
parsed := &storytelling.Story{}
|
||||
if err := json.Unmarshal([]byte(repo.get(1).Scenario), parsed); err != nil {
|
||||
t.Fatalf("json.Unmarshal: %v", err)
|
||||
@@ -696,7 +679,6 @@ func TestUpdateScenarioIntro(t *testing.T) {
|
||||
t.Errorf("Introduction.Audio = %q, want %q (без домена)", parsed.Introduction.Audio, "intro.mp3")
|
||||
}
|
||||
|
||||
// Наружу — аудио с доменом.
|
||||
full, err := svc.GetFullScenarioByID(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetFullScenarioByID: %v", err)
|
||||
@@ -708,7 +690,6 @@ func TestUpdateScenarioIntro(t *testing.T) {
|
||||
t.Errorf("Introduction.Audio = %q, want %q", full.Story.Introduction.Audio, testDomain+"intro.mp3")
|
||||
}
|
||||
|
||||
// Редактирование точки не должно затрагивать введение и голое имя аудио.
|
||||
err = svc.UpdateScenarioPlace(context.Background(), 1, "club", &storytelling.Place{
|
||||
Code: "club",
|
||||
Name: "Клуб",
|
||||
@@ -725,7 +706,6 @@ func TestUpdateScenarioIntro(t *testing.T) {
|
||||
t.Errorf("введение повреждено после UpdateScenarioPlace: %+v", parsed.Introduction)
|
||||
}
|
||||
|
||||
// Очистка введения (nil) — поле исчезает из истории.
|
||||
err = svc.UpdateScenarioIntro(context.Background(), 1, nil, 7, false)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateScenarioIntro(nil): %v", err)
|
||||
@@ -738,14 +718,12 @@ func TestUpdateScenarioIntro(t *testing.T) {
|
||||
t.Errorf("Introduction = %+v, want nil после очистки", parsed.Introduction)
|
||||
}
|
||||
|
||||
// Чужой автор не может менять введение.
|
||||
err = svc.UpdateScenarioIntro(context.Background(), 1, &storytelling.Introduction{Text: "x"}, 99, false)
|
||||
if !errors.Is(err, ErrScenarioNotOwner) {
|
||||
t.Errorf("err = %v, want ErrScenarioNotOwner", err)
|
||||
}
|
||||
}
|
||||
|
||||
// buildRawArchive собирает zip из произвольных записей (для тестов импорта).
|
||||
func buildRawArchive(t *testing.T, entries map[string][]byte) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
@@ -780,7 +758,6 @@ func TestScenarioQuestionsCRUD(t *testing.T) {
|
||||
seedScenario(t, repo, storage)
|
||||
svc := newTestService(repo, storage)
|
||||
|
||||
// Добавление вопроса автором.
|
||||
err := svc.AddScenarioQuestion(context.Background(), 1, &storytelling.Question{
|
||||
Code: "q1",
|
||||
Text: "Кто убийца?",
|
||||
@@ -805,7 +782,6 @@ func TestScenarioQuestionsCRUD(t *testing.T) {
|
||||
if len(full.Story.Questions) != 2 {
|
||||
t.Fatalf("len(Questions) = %d, want 2", len(full.Story.Questions))
|
||||
}
|
||||
// Вопросы добавляются в конец списка: q1 добавлен первым.
|
||||
if full.Story.Questions[0].Code != "q1" || full.Story.Questions[0].Answer != "Дворецкий" {
|
||||
t.Errorf("Questions[0] = %+v, want q1 с правильным ответом", full.Story.Questions[0])
|
||||
}
|
||||
@@ -813,7 +789,6 @@ func TestScenarioQuestionsCRUD(t *testing.T) {
|
||||
t.Errorf("Questions[1] = %+v, want q2 с правильным ответом", full.Story.Questions[1])
|
||||
}
|
||||
|
||||
// Обновление вопроса по коду.
|
||||
err = svc.UpdateScenarioQuestion(context.Background(), 1, "q1", &storytelling.Question{
|
||||
Code: "q1",
|
||||
Text: "Кто убийца на самом деле?",
|
||||
@@ -839,7 +814,6 @@ func TestScenarioQuestionsCRUD(t *testing.T) {
|
||||
t.Error("вопрос q1 не найден после обновления")
|
||||
}
|
||||
|
||||
// Удаление вопроса.
|
||||
err = svc.DeleteScenarioQuestion(context.Background(), 1, "q2", 7, false)
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteScenarioQuestion: %v", err)
|
||||
@@ -852,7 +826,6 @@ func TestScenarioQuestionsCRUD(t *testing.T) {
|
||||
t.Errorf("Questions = %+v, want только q1", full.Story.Questions)
|
||||
}
|
||||
|
||||
// Чужой автор не может менять вопросы.
|
||||
err = svc.AddScenarioQuestion(context.Background(), 1, &storytelling.Question{Code: "q3"}, 99, false)
|
||||
if !errors.Is(err, ErrScenarioNotOwner) {
|
||||
t.Errorf("AddScenarioQuestion чужой автор: err = %v, want ErrScenarioNotOwner", err)
|
||||
@@ -862,7 +835,6 @@ func TestScenarioQuestionsCRUD(t *testing.T) {
|
||||
t.Errorf("DeleteScenarioQuestion чужой автор: err = %v, want ErrScenarioNotOwner", err)
|
||||
}
|
||||
|
||||
// Админ может менять вопросы чужого сценария.
|
||||
err = svc.AddScenarioQuestion(context.Background(), 1, &storytelling.Question{
|
||||
Code: "q3", Text: "Вопрос админа",
|
||||
}, 1, true)
|
||||
@@ -877,7 +849,6 @@ func TestNormalizeStoryQuestionCodes(t *testing.T) {
|
||||
seedScenario(t, repo, storage)
|
||||
svc := newTestService(repo, storage)
|
||||
|
||||
// Дубликат кода вопроса — ошибка.
|
||||
err := svc.AddScenarioQuestion(context.Background(), 1, &storytelling.Question{Code: "q1"}, 7, false)
|
||||
if err != nil {
|
||||
t.Fatalf("AddScenarioQuestion q1: %v", err)
|
||||
@@ -887,7 +858,6 @@ func TestNormalizeStoryQuestionCodes(t *testing.T) {
|
||||
t.Errorf("AddScenarioQuestion с дублем кода: err = %v, want ошибку о дубле", err)
|
||||
}
|
||||
|
||||
// Пустой код — вопрос отбрасывается при сохранении.
|
||||
err = svc.AddScenarioQuestion(context.Background(), 1, &storytelling.Question{Code: "", Text: "без кода"}, 7, false)
|
||||
if err != nil {
|
||||
t.Fatalf("AddScenarioQuestion с пустым кодом: %v", err)
|
||||
|
||||
@@ -11,14 +11,10 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrInvalidCaptainEmail — почта капитана команды некорректна.
|
||||
ErrInvalidCaptainEmail = errors.New("Укажите корректную почту капитана")
|
||||
// ErrTeamNotFound — команда не найдена.
|
||||
ErrTeamNotFound = errors.New("Команда не найдена")
|
||||
// ErrInvalidRegistration — входные данные регистрации команды некорректны.
|
||||
ErrTeamNotFound = errors.New("Команда не найдена")
|
||||
ErrInvalidRegistration = errors.New("Укажите название команды (до 255 символов), почту капитана и положительное количество участников")
|
||||
// ErrRegistrationClosed — игра не найдена или регистрация в неё закрыта.
|
||||
ErrRegistrationClosed = errors.New("Игра не найдена или регистрация уже закрыта")
|
||||
ErrRegistrationClosed = errors.New("Игра не найдена или регистрация уже закрыта")
|
||||
)
|
||||
|
||||
// Registration contains data required to register a team.
|
||||
|
||||
@@ -12,10 +12,8 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrGameNotFound — игра для генерации QR-кодов не найдена.
|
||||
ErrGameNotFound = errors.New("Игра не найдена")
|
||||
// ErrNoCards — для игры нет активных команд, которым нужны QR-коды.
|
||||
ErrNoCards = errors.New("нет карточек")
|
||||
ErrNoCards = errors.New("нет карточек")
|
||||
)
|
||||
|
||||
func teamQRData(baseURL string, game *repos.Game, teams []*repos.Team) ([]team_qr_pdf.Card, string, error) {
|
||||
|
||||
@@ -48,8 +48,6 @@ func TestChangeUserPassword(t *testing.T) {
|
||||
r.byID[u.ID] = u
|
||||
r.users[u.Email] = u
|
||||
}
|
||||
// Пустые JWT, репозиторий токенов и отправитель писем подтверждают,
|
||||
// что операция их не использует.
|
||||
s := &UsersService{usersRepo: r}
|
||||
err = s.ChangeUserPassword(context.Background(), tc.actor, tc.target, tc.password)
|
||||
if !errors.Is(err, tc.want) {
|
||||
@@ -91,7 +89,6 @@ func TestLoginAfterPasswordChangePreservesEmailCase(t *testing.T) {
|
||||
if err := svc.ChangeUserPassword(ctx, 1, 2, "new password 123"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Эта ошибка возможна только после успешного поиска 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)
|
||||
|
||||
@@ -6,17 +6,11 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Письма содержат текстовую и HTML-версию; sender собирает multipart/alternative.
|
||||
|
||||
const (
|
||||
// welcomeSubject — тема письма о регистрации: конкретика без КАПСА и
|
||||
// кликбейта, до 50 символов (видно на мобильных целиком).
|
||||
welcomeSubject = "Твой доступ к «Вечернему детективу»"
|
||||
// resetSubject — тема письма о сбросе пароля.
|
||||
resetSubject = "Новый пароль для «Вечернего детектива»"
|
||||
resetSubject = "Новый пароль для «Вечернего детектива»"
|
||||
)
|
||||
|
||||
// welcomePlain возвращает текстовую версию письма о регистрации.
|
||||
func welcomePlain(username, email, password, loginURL string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(greeting(username))
|
||||
@@ -39,7 +33,6 @@ func welcomePlain(username, email, password, loginURL string) string {
|
||||
return sanitizeLine(b.String())
|
||||
}
|
||||
|
||||
// welcomeHTML возвращает HTML-версию письма о регистрации.
|
||||
func welcomeHTML(username, email, password, loginURL string) string {
|
||||
var content strings.Builder
|
||||
content.WriteString("<p class=\"email-text\" style=\"color:#211a14;\">Привет, <strong>")
|
||||
@@ -54,7 +47,6 @@ func welcomeHTML(username, email, password, loginURL string) string {
|
||||
return wrapHTML(content.String(), loginURL)
|
||||
}
|
||||
|
||||
// resetPlain возвращает текстовую версию письма о сбросе пароля.
|
||||
func resetPlain(username, email, password, loginURL string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(greeting(username))
|
||||
@@ -77,7 +69,6 @@ func resetPlain(username, email, password, loginURL string) string {
|
||||
return sanitizeLine(b.String())
|
||||
}
|
||||
|
||||
// resetHTML возвращает HTML-версию письма о сбросе пароля.
|
||||
func resetHTML(username, email, password, loginURL string) string {
|
||||
var content strings.Builder
|
||||
content.WriteString("<p class=\"email-text\" style=\"color:#211a14;\">Привет, <strong>")
|
||||
@@ -93,8 +84,6 @@ func resetHTML(username, email, password, loginURL string) string {
|
||||
return wrapHTML(content.String(), loginURL)
|
||||
}
|
||||
|
||||
// greeting возвращает приветствие с именем пользователя; при пустом имени —
|
||||
// без обращения, чтобы не было «Привет, !».
|
||||
func greeting(username string) string {
|
||||
username = sanitizeLine(strings.TrimSpace(username))
|
||||
if username == "" {
|
||||
@@ -103,8 +92,6 @@ func greeting(username string) string {
|
||||
return fmt.Sprintf("Привет, %s!", username)
|
||||
}
|
||||
|
||||
// credentialsBlockHTML — блок «Логин / Пароль» в табличной вёрстке,
|
||||
// совместимой с Gmail.
|
||||
func credentialsBlockHTML(email, password string) string {
|
||||
return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:24px 0; border-top:1px dashed #888888; border-bottom:1px dashed #888888; table-layout:fixed;">
|
||||
<tr><td class="email-text" style="padding:16px 0 4px; font-family:Arial,Helvetica,sans-serif; font-size:12px; color:#211a14;">Логин</td></tr>
|
||||
@@ -114,7 +101,6 @@ func credentialsBlockHTML(email, password string) string {
|
||||
</table>`
|
||||
}
|
||||
|
||||
// buttonHTML — кнопка входа с inline-стилями и табличным фоном.
|
||||
func buttonHTML(link, label string) string {
|
||||
return `<table role="presentation" cellpadding="0" cellspacing="0" style="margin:20px 0;">
|
||||
<tr>
|
||||
@@ -125,8 +111,6 @@ func buttonHTML(link, label string) string {
|
||||
</table>`
|
||||
}
|
||||
|
||||
// wrapHTML — светлая палитра по умолчанию, тёмная через media query.
|
||||
// Клиенты без поддержки CSS темы могут перекрашивать обычные цвета сами.
|
||||
func wrapHTML(content, baseURL string) string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
@@ -181,7 +165,6 @@ func wrapHTML(content, baseURL string) string {
|
||||
</html>`
|
||||
}
|
||||
|
||||
// Шрифт находится в public/email фронтенда.
|
||||
func emailAssetURL(baseURL, filename string) string {
|
||||
if baseURL == "" {
|
||||
return ""
|
||||
@@ -189,15 +172,10 @@ func emailAssetURL(baseURL, filename string) string {
|
||||
return html.EscapeString(strings.TrimRight(baseURL, "/") + "/email/" + filename)
|
||||
}
|
||||
|
||||
// sanitizeLine убирает переносы строк из пользовательского ввода,
|
||||
// вставляемого в тело письма (защита от инъекции строк в DATA-фазу SMTP).
|
||||
func sanitizeLine(s string) string {
|
||||
return strings.NewReplacer("\r", "", "\n", "").Replace(s)
|
||||
}
|
||||
|
||||
// loginURL нормализует адрес фронтенда для ссылки в письме: срезает
|
||||
// завершающий слэш и допускает только http(s). Пустое или некорректное
|
||||
// значение — ссылка в письма не добавляется.
|
||||
func loginURL(baseURL string) string {
|
||||
baseURL = strings.TrimSpace(baseURL)
|
||||
baseURL = strings.TrimRight(baseURL, "/")
|
||||
|
||||
@@ -40,7 +40,7 @@ func TestEmailThemeAndContent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
if n.Type == html.ElementNode && n.Data == "a" {
|
||||
protected = true // У кнопки собственные согласованные цвета фона и текста.
|
||||
protected = true
|
||||
}
|
||||
if paper && n.Type == html.TextNode && strings.TrimSpace(n.Data) != "" {
|
||||
if !protected {
|
||||
|
||||
@@ -22,19 +22,12 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Версии документов, акцепт которых фиксируется. Должны совпадать с
|
||||
// редакциями, опубликованными по постоянным URL (/api/terms и /api/privacy,
|
||||
// rpc GetTerms/GetPrivacy, текст встраивается из пакета docs): при изменении
|
||||
// текста документа версия ОБЯЗАНА увеличиваться, иначе повторный акцепт
|
||||
// не будет зафиксирован как согласие с новой редакцией
|
||||
// (UNIQUE (user_id, agreement_type, version)).
|
||||
const (
|
||||
termsVersion = "1.0"
|
||||
privacyVersion = "1.0"
|
||||
)
|
||||
|
||||
// usersRepository — граница доступа к данным пользователей, реализуемая
|
||||
// *users_repo.UsersRepo; интерфейс позволяет тестировать сервис без БД.
|
||||
type usersRepository interface {
|
||||
AddUserWithAgreements(
|
||||
ctx context.Context,
|
||||
@@ -60,9 +53,7 @@ type UsersService struct {
|
||||
emailSender email_sender.IEmailSender
|
||||
processorJWT processor_jwt.IProcessorJWT
|
||||
refreshTokensRepo *refresh_tokens_repo.RefreshTokensRepo
|
||||
// appBaseURL — публичный адрес фронтенда для ссылки «Войти в игру» в
|
||||
// письмах; пустое значение — ссылка не добавляется.
|
||||
appBaseURL string
|
||||
appBaseURL string
|
||||
}
|
||||
|
||||
// NewUsersService creates a users service.
|
||||
@@ -85,7 +76,6 @@ func NewUsersService(
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrTermsNotAccepted — регистрация невозможна без акцепта соглашений.
|
||||
ErrTermsNotAccepted = errors.New("Необходимо принять Пользовательское соглашение и Политику конфиденциальности")
|
||||
// ErrInvalidEmail indicates that an email address is invalid.
|
||||
ErrInvalidEmail = errors.New("Некорректный email")
|
||||
@@ -126,8 +116,6 @@ func (s *UsersService) AddUser(
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Создание пользователя и фиксация акцептов — в одной транзакции:
|
||||
// без записей о согласиях пользователь не существует.
|
||||
id, err := s.usersRepo.AddUserWithAgreements(
|
||||
ctx,
|
||||
username,
|
||||
@@ -153,12 +141,6 @@ func (s *UsersService) AddUser(
|
||||
return err
|
||||
}
|
||||
|
||||
// Консистентность ответа и состояния БД: если письмо с паролем не
|
||||
// ушло, пользователь не должен оставаться в системе «полусозданным»
|
||||
// (клиент получит ошибку и повторит регистрацию). Компенсация —
|
||||
// удаление созданного пользователя. Выполняется вне отменённого
|
||||
// контекста: письмо могло упасть именно из-за истечения таймаута ctx,
|
||||
// и тогда тот же ctx не дал бы выполнить удаление.
|
||||
err = s.emailSender.Send(ctx, email_sender.Message{
|
||||
To: email,
|
||||
Subject: welcomeSubject,
|
||||
@@ -175,9 +157,6 @@ func (s *UsersService) AddUser(
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteAccount удаляет учётную запись и все персональные данные пользователя
|
||||
// (ст. 21 152-ФЗ). Требует подтверждения паролем: обладатель украденного
|
||||
// access-токена не должен мочь удалить чужой аккаунт.
|
||||
func (s *UsersService) DeleteAccount(
|
||||
ctx context.Context,
|
||||
userID int,
|
||||
@@ -210,8 +189,6 @@ func (s *UsersService) RefreshPassword(
|
||||
return err
|
||||
}
|
||||
|
||||
// Анти-enumeration: ответ одинаков для существующего и несуществующего
|
||||
// адреса, письмо отправляется только реальному пользователю.
|
||||
user, err := s.usersRepo.GetUserByEmail(ctx, email)
|
||||
if err != nil {
|
||||
if errors.Is(err, users_repo.ErrUserNotFound) {
|
||||
@@ -229,9 +206,6 @@ func (s *UsersService) RefreshPassword(
|
||||
return err
|
||||
}
|
||||
|
||||
// Письмо отправляется ДО смены пароля: при сбое отправки пароль
|
||||
// остаётся прежним и аккаунт не блокируется (иначе старый пароль уже
|
||||
// не работает, а новый пользователь так и не узнал).
|
||||
err = s.emailSender.Send(ctx, email_sender.Message{
|
||||
To: user.Email,
|
||||
Subject: resetSubject,
|
||||
@@ -245,7 +219,6 @@ func (s *UsersService) RefreshPassword(
|
||||
return s.usersRepo.UpdateUserPassword(ctx, user.Email, string(hashedPassword))
|
||||
}
|
||||
|
||||
// validateEmail проверяет, что строка является одиночным email-адресом.
|
||||
func validateEmail(email string) error {
|
||||
if email == "" {
|
||||
return ErrInvalidEmail
|
||||
@@ -254,7 +227,6 @@ func validateEmail(email string) error {
|
||||
if err != nil {
|
||||
return ErrInvalidEmail
|
||||
}
|
||||
// Отклоняем форму "Имя <a@b.c>" и всё, что не является чистым адресом.
|
||||
if !strings.EqualFold(addr.Address, email) {
|
||||
return ErrInvalidEmail
|
||||
}
|
||||
@@ -267,7 +239,6 @@ func (s *UsersService) Login(
|
||||
email string,
|
||||
password string,
|
||||
) (string, string, error) {
|
||||
// Сохраняем регистр email, как при регистрации; убираем только внешние пробелы.
|
||||
user, err := s.usersRepo.GetUserByEmail(
|
||||
ctx,
|
||||
strings.TrimSpace(email),
|
||||
@@ -418,11 +389,9 @@ var (
|
||||
ErrPasswordChangeForbidden = errors.New("Изменять чужой пароль может только администратор")
|
||||
// ErrInvalidPasswordChange indicates that the password change request is invalid.
|
||||
ErrInvalidPasswordChange = errors.New("Укажите чужой положительный ID и пароль от 8 до 16 символов")
|
||||
// ErrUserNotFound — целевой пользователь не найден.
|
||||
ErrUserNotFound = errors.New("Пользователь не найден")
|
||||
ErrUserNotFound = errors.New("Пользователь не найден")
|
||||
)
|
||||
|
||||
// ChangeUserPassword сохраняет только хеш; действующие сеансы не изменяются.
|
||||
// ChangeUserPassword changes a user's password subject to authorization checks.
|
||||
func (s *UsersService) ChangeUserPassword(ctx context.Context, actorID, userID int, password string) error {
|
||||
actor, err := s.usersRepo.GetUserByID(ctx, actorID)
|
||||
|
||||
@@ -36,8 +36,6 @@ func (f *fakeSender) sent() []email_sender.Message {
|
||||
return append([]email_sender.Message(nil), f.messages...)
|
||||
}
|
||||
|
||||
// fakeRepo — минимальная in-memory реализация usersRepository для тестов
|
||||
// сервиса без БД.
|
||||
type fakeRepo struct {
|
||||
mu sync.Mutex
|
||||
users map[string]*repos.User
|
||||
@@ -149,8 +147,6 @@ func newTestService(repo usersRepository, sender email_sender.IEmailSender) *Use
|
||||
}
|
||||
|
||||
func newTestServiceWithBaseURL(repo usersRepository, sender email_sender.IEmailSender, baseURL string) *UsersService {
|
||||
// Пул создаётся лениво (pgxpool.New не контактирует с БД) и в этих
|
||||
// тестах не используется.
|
||||
pool, err := pgxpool.New(context.Background(), "postgres://postgres:postgres@localhost:5432/none")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
@@ -175,7 +171,6 @@ func TestAddUserSuccess(t *testing.T) {
|
||||
t.Fatalf("AddUser: %v", err)
|
||||
}
|
||||
|
||||
// Убираем внешние пробелы, сохраняя исходный регистр email.
|
||||
if _, ok := repo.users["User@Example.COM"]; !ok {
|
||||
t.Errorf("пользователь не создан с исходным регистром email: %+v", repo.users)
|
||||
}
|
||||
@@ -196,8 +191,6 @@ func TestAddUserSuccess(t *testing.T) {
|
||||
if msgs[0].HTML == "" {
|
||||
t.Error("у письма нет HTML-версии — Gmail хуже рендерит такие письма")
|
||||
}
|
||||
// APP_BASE_URL в тесте пуст — кнопка «Войти в игру» не добавляется,
|
||||
// но каркас письма (шапка сервиса) обязан присутствовать.
|
||||
if !strings.Contains(msgs[0].HTML, "ВЕЧЕРНИЙ ДЕТЕКТИВ") {
|
||||
t.Error("HTML-версия письма не содержит шапку сервиса")
|
||||
}
|
||||
@@ -220,7 +213,6 @@ func TestAddUserWithBaseURLAddsLoginLink(t *testing.T) {
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("ожидалось 1 письмо, отправлено %d", len(msgs))
|
||||
}
|
||||
// Завершающий слэш срезается, ссылка есть и в plain, и в HTML-версии.
|
||||
if !strings.Contains(msgs[0].Body, "https://evening-detective.crabs-games.art") {
|
||||
t.Errorf("plain-версия не содержит ссылку на вход:\n%s", msgs[0].Body)
|
||||
}
|
||||
@@ -250,7 +242,6 @@ func TestAddUserSendFailureCompensates(t *testing.T) {
|
||||
t.Fatal("ожидалась ошибка при сбое отправки письма")
|
||||
}
|
||||
|
||||
// Компенсация: клиенту ошибка, пользователя в БД нет.
|
||||
if repo.userCount() != 0 {
|
||||
t.Errorf("пользователь остался после ошибки отправки: %+v", repo.users)
|
||||
}
|
||||
@@ -288,7 +279,6 @@ func TestRefreshPasswordSuccess(t *testing.T) {
|
||||
sender := &fakeSender{}
|
||||
svc := newTestService(repo, sender)
|
||||
|
||||
// Внешние пробелы убираются, регистр остаётся исходным.
|
||||
err := svc.RefreshPassword(context.Background(), " User@Example.COM ")
|
||||
if err != nil {
|
||||
t.Fatalf("RefreshPassword: %v", err)
|
||||
@@ -325,7 +315,6 @@ func TestRefreshPasswordUnknownEmail(t *testing.T) {
|
||||
sender := &fakeSender{}
|
||||
svc := newTestService(repo, sender)
|
||||
|
||||
// Анти-enumeration: для несуществующего адреса — успех и без письма.
|
||||
if err := svc.RefreshPassword(context.Background(), "nobody@example.com"); err != nil {
|
||||
t.Fatalf("для несуществующего email ожидался успех, получено %v", err)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package users_service
|
||||
|
||||
import "time"
|
||||
|
||||
// User — пользователь, доступный прикладным сценариям и выдаче наружу.
|
||||
// User describes a user exposed by the service layer.
|
||||
type User struct {
|
||||
ID int
|
||||
|
||||
Reference in New Issue
Block a user