generated from VLADIMIR/template
add
This commit is contained in:
@@ -32,24 +32,6 @@ func mapApplication(o *repos.Application, domain string) *storytelling.Applicati
|
||||
return app
|
||||
}
|
||||
|
||||
// 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
|
||||
if fileType == "" {
|
||||
fileType = file_storage.FileType(image)
|
||||
}
|
||||
return &repos.Application{
|
||||
Name: app.Name,
|
||||
Image: image,
|
||||
FileType: fileType,
|
||||
}
|
||||
}
|
||||
|
||||
// prefixDomain — идемпотентная префиксация ссылки доменом хранилища
|
||||
// (пустые ссылки и внешние http/https URL не трогаются).
|
||||
func prefixDomain(ref, domain string) string {
|
||||
|
||||
@@ -3,72 +3,11 @@ package game_service
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"evening_detective_server/internal/modules/storytelling"
|
||||
"evening_detective_server/internal/repos"
|
||||
)
|
||||
|
||||
const testDomain = "http://storage.test/api/files/"
|
||||
|
||||
func TestNewApplication(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
app *storytelling.Application
|
||||
image string // ожидаемое хранимое имя (без домена)
|
||||
ftype string // ожидаемый file_type
|
||||
}{
|
||||
{
|
||||
name: "доменный префикс снимается, file_type сохраняется",
|
||||
app: &storytelling.Application{
|
||||
Name: "Улика",
|
||||
Image: testDomain + "clue.pdf",
|
||||
FileType: "pdf",
|
||||
},
|
||||
image: "clue.pdf",
|
||||
ftype: "pdf",
|
||||
},
|
||||
{
|
||||
name: "относительное имя не трогается",
|
||||
app: &storytelling.Application{
|
||||
Name: "Улика",
|
||||
Image: "clue.png",
|
||||
FileType: "image",
|
||||
},
|
||||
image: "clue.png",
|
||||
ftype: "image",
|
||||
},
|
||||
{
|
||||
name: "пустой file_type деривируется из расширения",
|
||||
app: &storytelling.Application{
|
||||
Name: "Улика",
|
||||
Image: testDomain + "clue.ogg",
|
||||
},
|
||||
image: "clue.ogg",
|
||||
ftype: "audio",
|
||||
},
|
||||
{
|
||||
name: "внешний URL снимается как префикс только при совпадении домена",
|
||||
app: &storytelling.Application{
|
||||
Name: "Улика",
|
||||
Image: "https://example.com/clue.pdf",
|
||||
},
|
||||
image: "https://example.com/clue.pdf",
|
||||
ftype: "pdf",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := newApplication(c.app, testDomain)
|
||||
if got.Name != c.app.Name {
|
||||
t.Errorf("%s: Name = %q, want %q", c.name, got.Name, c.app.Name)
|
||||
}
|
||||
if got.Image != c.image {
|
||||
t.Errorf("%s: Image = %q, want %q", c.name, got.Image, c.image)
|
||||
}
|
||||
if got.FileType != c.ftype {
|
||||
t.Errorf("%s: FileType = %q, want %q", c.name, got.FileType, c.ftype)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapApplications(t *testing.T) {
|
||||
o := []*repos.Application{
|
||||
{Name: "Улика", Image: "clue.pdf", FileType: ""},
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
package game_service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"evening_detective_server/internal/modules/storytelling"
|
||||
"evening_detective_server/internal/repos/games_repo"
|
||||
"evening_detective_server/internal/repos/teams_repo"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestFinishedTeamStoryPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("set TEST_DATABASE_URL to run PostgreSQL integration check")
|
||||
}
|
||||
ctx := context.Background()
|
||||
config, err := pgxpool.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config.MaxConns = 1
|
||||
pool, err := pgxpool.NewWithConfig(ctx, config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
_, err = pool.Exec(ctx, `
|
||||
CREATE TEMP TABLE games (id INT, name TEXT, description TEXT, start_at TIMESTAMP,
|
||||
format TEXT, status TEXT, scenario_id INT, started_at TIMESTAMP, ended_at TIMESTAMP);
|
||||
CREATE TEMP TABLE teams (id INT, name TEXT, captain_email TEXT, participants_count INT,
|
||||
status TEXT, password TEXT, game_id INT);
|
||||
INSERT INTO games VALUES (1, 'Game', '', NOW(), 'online', 'finish', 1, NULL, NULL);
|
||||
INSERT INTO teams VALUES (1, 'Team', '', 1, 'register', 'secret', 1);
|
||||
ALTER TABLE teams ADD COLUMN participation_status TEXT DEFAULT 'pending', ADD COLUMN participation_source TEXT DEFAULT '',
|
||||
ADD COLUMN participation_email_error TEXT DEFAULT '', ADD COLUMN participation_sent_at TIMESTAMPTZ;`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// No action/scenario repositories: finished games must not load either.
|
||||
s := &GameService{gamesRepo: games_repo.NewGamesRepo(pool), teamsRepo: teams_repo.NewTeamsRepo(pool)}
|
||||
for _, format := range []string{"online", "offline"} {
|
||||
if _, err := pool.Exec(ctx, `UPDATE games SET format = $1`, format); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
story, game, actions, err := s.GetTeamActions(ctx, 1, "secret")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(story, &storytelling.Story{}) || len(actions) != 0 || game.Status != "finish" {
|
||||
t.Fatalf("finished game exposed content or lost status: story=%+v game=%+v actions=%+v", story, game, actions)
|
||||
}
|
||||
}
|
||||
if _, _, _, err := s.GetTeamActions(ctx, 1, "wrong"); !errors.Is(err, teams_repo.ErrTeamNotFound) {
|
||||
t.Fatalf("password check bypassed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,7 @@ package game_service
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"evening_detective_server/internal/modules/cleaner"
|
||||
"evening_detective_server/internal/modules/game_model"
|
||||
"evening_detective_server/internal/modules/storytelling"
|
||||
"evening_detective_server/internal/repos/actions_repo"
|
||||
"evening_detective_server/internal/repos/applications_repo"
|
||||
"evening_detective_server/internal/repos/games_repo"
|
||||
@@ -21,10 +19,8 @@ type GameService struct {
|
||||
scenariosRepo *scenarios_repo.ScenariosRepo
|
||||
scenarioService *scenarios_service.ScenarioService
|
||||
domain string
|
||||
storyteller storytelling.IStory
|
||||
actionsRepo *actions_repo.ActionsRepo
|
||||
applicationsRepo *applications_repo.ApplicationsRepo
|
||||
cleaner cleaner.ICleaner
|
||||
}
|
||||
|
||||
func NewGameService(
|
||||
@@ -33,10 +29,8 @@ func NewGameService(
|
||||
scenariosRepo *scenarios_repo.ScenariosRepo,
|
||||
scenarioService *scenarios_service.ScenarioService,
|
||||
domain string,
|
||||
storyteller storytelling.IStory,
|
||||
actionsRepo *actions_repo.ActionsRepo,
|
||||
applicationsRepo *applications_repo.ApplicationsRepo,
|
||||
cleaner cleaner.ICleaner,
|
||||
) *GameService {
|
||||
return &GameService{
|
||||
gamesRepo: gamesRepo,
|
||||
@@ -44,10 +38,8 @@ func NewGameService(
|
||||
scenariosRepo: scenariosRepo,
|
||||
scenarioService: scenarioService,
|
||||
domain: domain,
|
||||
storyteller: storyteller,
|
||||
actionsRepo: actionsRepo,
|
||||
applicationsRepo: applicationsRepo,
|
||||
cleaner: cleaner,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,136 +188,3 @@ func (s *GameService) FinishGame(ctx context.Context, id int) error {
|
||||
func (s *GameService) ResetGame(ctx context.Context, id int) error {
|
||||
return s.gamesRepo.ResetGame(ctx, id)
|
||||
}
|
||||
|
||||
func (s *GameService) GiveTeamApplications(ctx context.Context, teamId int, name string) error {
|
||||
return s.applicationsRepo.UpdateApplicationStatus(ctx, teamId, name, "done")
|
||||
}
|
||||
|
||||
func (s *GameService) GetTeamActions(ctx context.Context, teamId int, password string) (*storytelling.Story, *game_model.Game, []actions_repo.Action, error) {
|
||||
team, err := s.teamsRepo.GetTeamByID(ctx, teamId)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
if team.Password != password || team.ParticipationStatus == "cancelled" {
|
||||
return nil, nil, nil, teams_repo.ErrTeamNotFound
|
||||
}
|
||||
|
||||
game, err := s.gamesRepo.GetGameByID(ctx, team.GameId)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if game.Status == "finish" {
|
||||
return &storytelling.Story{}, game_model.MapGame(game, s.domain), nil, nil
|
||||
}
|
||||
|
||||
actions, err := s.actionsRepo.GetActionsByTeamID(ctx, teamId)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
scenario, err := s.scenarioService.GetFullScenarioByID(ctx, game.ScenarioId)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
codes := make([]string, len(actions))
|
||||
for i, action := range actions {
|
||||
codes[i] = action.Code
|
||||
}
|
||||
story := s.storyteller.GetStory(scenario.Story, codes)
|
||||
filterTeamStory(story, game.Format)
|
||||
return story, game_model.MapGame(game, s.domain), actions, nil
|
||||
}
|
||||
|
||||
func (s *GameService) AddTeamAction(ctx context.Context, teamId int, password string, actionCode string, viaMCP bool) error {
|
||||
team, err := s.teamsRepo.GetTeamByID(ctx, teamId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if team.Password != password || team.ParticipationStatus == "cancelled" {
|
||||
return teams_repo.ErrTeamNotFound
|
||||
}
|
||||
|
||||
_, err = s.actionsRepo.AddAction(ctx, teamId, actionCode, viaMCP)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if team.ParticipationStatus == "pending" {
|
||||
if err := s.teamsRepo.ConfirmParticipationByFirstAction(ctx, teamId); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
game, err := s.gamesRepo.GetGameByID(ctx, team.GameId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
actionDetails, err := s.actionsRepo.GetActionsByTeamID(ctx, teamId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
scenario, err := s.scenarioService.GetFullScenarioByID(ctx, game.ScenarioId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
actions := make([]string, len(actionDetails))
|
||||
for i, action := range actionDetails {
|
||||
actions[i] = action.Code
|
||||
}
|
||||
teamStory := s.storyteller.GetStory(scenario.Story, actions)
|
||||
|
||||
lastPlace := teamStory.Places[len(teamStory.Places)-1]
|
||||
|
||||
for _, application := range lastPlace.Applications {
|
||||
app := newApplication(application, s.domain)
|
||||
app.Name = s.cleaner.ClearText(app.Name)
|
||||
if _, err := s.applicationsRepo.AddApplication(ctx, teamId, app.Name, app.Image, app.FileType); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *GameService) DeleteLastTeamAction(ctx context.Context, teamId int) error {
|
||||
team, err := s.teamsRepo.GetTeamByID(ctx, teamId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
game, err := s.gamesRepo.GetGameByID(ctx, team.GameId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
actionDetails, err := s.actionsRepo.GetActionsByTeamID(ctx, teamId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
scenario, err := s.scenarioService.GetFullScenarioByID(ctx, game.ScenarioId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
actions := make([]string, len(actionDetails))
|
||||
for i, action := range actionDetails {
|
||||
actions[i] = action.Code
|
||||
}
|
||||
teamStory := s.storyteller.GetStory(scenario.Story, actions)
|
||||
|
||||
lastPlace := teamStory.Places[len(teamStory.Places)-1]
|
||||
|
||||
for _, application := range lastPlace.Applications {
|
||||
if err := s.applicationsRepo.DeleteApplicationByTeamIdAndName(ctx, teamId, application.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return s.actionsRepo.DeleteLastActionByTeamId(ctx, teamId)
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
package game_service
|
||||
|
||||
import "evening_detective_server/internal/modules/storytelling"
|
||||
|
||||
// filterTeamStory применяется только к истории команды, общей для сайта и MCP.
|
||||
func filterTeamStory(story *storytelling.Story, format string) {
|
||||
if format != "offline" {
|
||||
return
|
||||
}
|
||||
story.Introduction = nil
|
||||
story.Questions = nil
|
||||
for _, place := range story.Places {
|
||||
for _, application := range place.Applications {
|
||||
application.Image = ""
|
||||
application.FileType = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package game_service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"evening_detective_server/internal/modules/cleaner"
|
||||
"evening_detective_server/internal/modules/storytelling"
|
||||
)
|
||||
|
||||
func TestTeamStoryFormat(t *testing.T) {
|
||||
scenario := &storytelling.Story{
|
||||
Introduction: &storytelling.Introduction{Text: "Введение", Audio: "intro.mp3"},
|
||||
Questions: []*storytelling.Question{{Code: "q", Text: "Вопрос"}},
|
||||
Places: []*storytelling.Place{{Code: "p", Text: "Точка", Image: "place.jpg",
|
||||
Applications: []*storytelling.Application{
|
||||
{Name: "Фото", Image: "clue.jpg", FileType: "image"},
|
||||
{Name: "Документ", Image: "clue.pdf", FileType: "pdf"},
|
||||
{Name: "Запись", Image: "clue.mp3", FileType: "audio"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
for _, format := range []string{"offline", "online"} {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
story := storytelling.NewStory(cleaner.NewCleaner()).GetStory(scenario, []string{"p"})
|
||||
filterTeamStory(story, format)
|
||||
if len(story.Places) != 1 || story.Places[0].Text != "Точка" || story.Places[0].Image != "place.jpg" {
|
||||
t.Fatal("place content changed")
|
||||
}
|
||||
if format == "offline" {
|
||||
if story.Introduction != nil || len(story.Questions) != 0 {
|
||||
t.Fatal("offline story exposes introduction or questions")
|
||||
}
|
||||
} else if story.Introduction == nil || len(story.Questions) != 1 {
|
||||
t.Fatal("online story lost introduction or questions")
|
||||
}
|
||||
if len(story.Places[0].Applications) != 3 {
|
||||
t.Fatal("clue names missing")
|
||||
}
|
||||
for i, application := range story.Places[0].Applications {
|
||||
original := scenario.Places[0].Applications[i]
|
||||
if application.Name != original.Name || original.Image == "" || original.FileType == "" {
|
||||
t.Fatal("clue name or original scenario changed")
|
||||
}
|
||||
if format == "offline" && (application.Image != "" || application.FileType != "") {
|
||||
t.Fatal("offline story exposes a clue file")
|
||||
}
|
||||
if format == "online" && application.Image != original.Image {
|
||||
t.Fatal("online story lost a clue file")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user