generated from VLADIMIR/template
up
This commit is contained in:
@@ -29,7 +29,7 @@ func TestAnswerAccessPostgres(t *testing.T) {
|
||||
defer pool.Close()
|
||||
_, err = pool.Exec(ctx, `
|
||||
CREATE TEMP TABLE game_returns(export_id TEXT);
|
||||
CREATE TEMP TABLE game_exports(id TEXT, game_id INT, state TEXT, package_id TEXT, size BIGINT, sha256 TEXT, failure TEXT, created_at TIMESTAMPTZ);
|
||||
CREATE TEMP TABLE game_exports(id TEXT, game_id INT, state TEXT, package_id TEXT, size BIGINT, sha256 TEXT, failure TEXT, created_at TIMESTAMPTZ, released_at TIMESTAMPTZ);
|
||||
CREATE TEMP TABLE games (id INT, name TEXT, description TEXT, location TEXT, start_at TIMESTAMP,
|
||||
format TEXT, status TEXT, scenario_id INT, started_at TIMESTAMP, ended_at TIMESTAMP);
|
||||
CREATE TEMP TABLE teams (id UUID, name TEXT, captain_email TEXT, participants_count INT,
|
||||
|
||||
@@ -218,16 +218,22 @@ func TestExportBuildsSavedSnapshotAcrossWorkerRestart(t *testing.T) {
|
||||
}
|
||||
first := verifyPackage(t, f, restartedWorker)
|
||||
verifyExportedGameReads(t, f, restartedWorker, original.ID)
|
||||
repeated, err := restartedWorker.Start(context.Background(), f.gameID, f.userID)
|
||||
check(t, err)
|
||||
if repeated.ID != original.ID || repeated.State != "ready" {
|
||||
t.Fatal("ready export not idempotent")
|
||||
}
|
||||
_, second := download(t, f.service(storage), f.gameID)
|
||||
if !bytes.Equal(first, second) {
|
||||
t.Fatal("download changed after restart")
|
||||
}
|
||||
assertAbortedDownload(t, f, restartedWorker, first)
|
||||
repeated, err := restartedWorker.Start(context.Background(), f.gameID, f.userID)
|
||||
check(t, err)
|
||||
if repeated.ID == original.ID || repeated.State != "preparing" {
|
||||
t.Fatal("new preparation reused old export")
|
||||
}
|
||||
_, err = restartedWorker.ProcessNext(context.Background())
|
||||
check(t, err)
|
||||
_, changed := download(t, restartedWorker, f.gameID)
|
||||
if bytes.Equal(first, changed) {
|
||||
t.Fatal("new preparation reused old bytes")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
package game_export_service_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"evening_detective_server/internal/repos"
|
||||
"evening_detective_server/internal/repos/actions_repo"
|
||||
"evening_detective_server/internal/repos/game_imports_repo"
|
||||
"evening_detective_server/internal/repos/game_screen_plans_repo"
|
||||
"evening_detective_server/internal/repos/team_answers_repo"
|
||||
"evening_detective_server/internal/services/game_export_service"
|
||||
"evening_detective_server/internal/services/game_import_service"
|
||||
)
|
||||
|
||||
func TestResetLocalModeValidation(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
venue bool
|
||||
gameID int
|
||||
exportID string
|
||||
want error
|
||||
}{
|
||||
{"venue", true, 1, "export", game_export_service.ErrVenueMode},
|
||||
{"invalid game", false, 0, "export", game_export_service.ErrInvalidGameID},
|
||||
{"empty export", false, 1, " ", game_export_service.ErrInvalidExportID},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
service := game_export_service.NewGameExportService(nil, nil, nil, nil, "", tc.venue)
|
||||
if err := service.ResetLocalMode(context.Background(), tc.gameID, tc.exportID); !errors.Is(err, tc.want) {
|
||||
t.Fatalf("unexpected validation: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportAnyStatusProducesFreshSetup(t *testing.T) {
|
||||
for _, format := range []string{"online", "offline"} {
|
||||
for _, state := range []string{"draft", "run", "pause", "finish"} {
|
||||
t.Run(format+"/"+state, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newServiceFixture(t)
|
||||
check(t, f.games.UpdateGame(ctx, f.gameID, "Test game", "Description", nil, time.Now(), int32(f.scenarioID), format))
|
||||
if state != "draft" {
|
||||
check(t, f.games.StartGame(ctx, f.gameID))
|
||||
if state == "pause" {
|
||||
check(t, f.games.PauseGame(ctx, f.gameID))
|
||||
}
|
||||
if state == "finish" {
|
||||
check(t, f.games.FinishGame(ctx, f.gameID))
|
||||
}
|
||||
}
|
||||
_, err := actions_repo.NewActionsRepo(f.pool).AddAction(ctx, f.teamID, "p", false)
|
||||
check(t, err)
|
||||
check(t, team_answers_repo.NewTeamAnswersRepo(f.pool).UpsertAnswers(ctx, f.teamID, []repos.TeamAnswer{{QuestionCode: "q", Text: "Saved answer"}}))
|
||||
_, err = game_screen_plans_repo.NewGameScreenPlansRepo(f.pool).Save(ctx, f.gameID,
|
||||
[]game_screen_plans_repo.Step{{Mode: 1}, {Mode: 2}}, 1)
|
||||
check(t, err)
|
||||
before, err := f.games.GetGameByID(ctx, f.gameID)
|
||||
check(t, err)
|
||||
storage := &memoryStorage{objects: map[string][]byte{}}
|
||||
exporter := f.service(storage)
|
||||
_, err = exporter.Start(ctx, f.gameID, f.userID)
|
||||
check(t, err)
|
||||
_, err = exporter.ProcessNext(ctx)
|
||||
check(t, err)
|
||||
_, raw := download(t, exporter, f.gameID)
|
||||
bundle, err := f.codec.Unpack(ctx, bytes.NewReader(raw), int64(len(raw)), t.TempDir())
|
||||
check(t, err)
|
||||
defer func() { _ = os.RemoveAll(bundle.Dir) }()
|
||||
game := bundle.Data.Game
|
||||
if game.Status != "draft" || game.StartedAt != nil || game.EndedAt != nil || game.Format != format ||
|
||||
bundle.Data.Teams[0].Status != "register" || bundle.Data.ScreenPlan.CurrentStep != 0 {
|
||||
t.Fatalf("package retained source progress: %+v", bundle.Data)
|
||||
}
|
||||
after, err := f.games.GetGameByID(ctx, f.gameID)
|
||||
check(t, err)
|
||||
before.Export, after.Export = nil, nil
|
||||
if !reflect.DeepEqual(before, after) {
|
||||
t.Fatal("export mutated the source game")
|
||||
}
|
||||
actions, err := actions_repo.NewActionsRepo(f.pool).GetActionsByTeamID(ctx, f.teamID)
|
||||
check(t, err)
|
||||
answers, err := team_answers_repo.NewTeamAnswersRepo(f.pool).GetAnswersByTeamID(ctx, f.teamID)
|
||||
check(t, err)
|
||||
if len(actions) != 1 || len(answers) != 1 {
|
||||
t.Fatal("export removed source progress")
|
||||
}
|
||||
// Import into an independent venue schema using the real importer.
|
||||
venue := newServiceFixture(t)
|
||||
check(t, venue.teams.DeleteTeam(ctx, venue.teamID))
|
||||
check(t, venue.games.DeleteGameByID(ctx, venue.gameID))
|
||||
importer := game_import_service.NewGameImportService(game_imports_repo.NewGameImportsRepo(venue.pool), venue.codec,
|
||||
&memoryStorage{objects: map[string][]byte{}}, true)
|
||||
imported, err := importer.Import(ctx, bytes.NewReader(raw), venue.userID)
|
||||
check(t, err)
|
||||
local, err := venue.games.GetGameByID(ctx, imported.GameID)
|
||||
check(t, err)
|
||||
if local.Status != "draft" || local.StartedAt != nil || local.EndedAt != nil {
|
||||
t.Fatalf("import did not start fresh: %+v", local)
|
||||
}
|
||||
actions, err = actions_repo.NewActionsRepo(venue.pool).GetActionsByTeamID(ctx, f.teamID)
|
||||
check(t, err)
|
||||
answers, err = team_answers_repo.NewTeamAnswersRepo(venue.pool).GetAnswersByTeamID(ctx, f.teamID)
|
||||
check(t, err)
|
||||
if len(actions) != 0 || len(answers) != 0 {
|
||||
t.Fatal("import carried source progress")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResultsFromFreshPackageOfFinishedGame(t *testing.T) {
|
||||
for _, format := range []string{"online", "offline"} {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newReturnFixture(t, format)
|
||||
check(t, f.games.StartGame(ctx, f.gameID))
|
||||
check(t, f.games.FinishGame(ctx, f.gameID))
|
||||
exporter := f.service(f.storage)
|
||||
_, err := exporter.Start(ctx, f.gameID, f.userID)
|
||||
check(t, err)
|
||||
_, err = exporter.ProcessNext(ctx)
|
||||
check(t, err)
|
||||
export, _ := download(t, exporter, f.gameID)
|
||||
f.manifest.SourcePackageID = export.PackageID
|
||||
f.manifest.SourceSHA256 = export.SHA256
|
||||
check(t, exporter.ResetLocalMode(ctx, f.gameID, export.ID))
|
||||
_, err = f.apply(f.pack(t))
|
||||
check(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLateResultsAfterResetAndNewExport(t *testing.T) {
|
||||
for _, format := range []string{"online", "offline"} {
|
||||
for _, next := range []string{"none", "preparing", "ready"} {
|
||||
t.Run(format+"/"+next, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newReturnFixture(t, format)
|
||||
raw := f.pack(t)
|
||||
exporter := f.service(f.storage)
|
||||
export, err := exporter.Get(ctx, f.gameID)
|
||||
check(t, err)
|
||||
check(t, exporter.ResetLocalMode(ctx, f.gameID, export.ID))
|
||||
if next != "none" {
|
||||
_, err = exporter.Start(ctx, f.gameID, f.userID)
|
||||
check(t, err)
|
||||
if next == "ready" {
|
||||
_, err = exporter.ProcessNext(ctx)
|
||||
check(t, err)
|
||||
}
|
||||
}
|
||||
check(t, exporter.Maintain(ctx))
|
||||
_, err = f.apply(raw)
|
||||
check(t, err)
|
||||
game, err := f.games.GetGameByID(ctx, f.gameID)
|
||||
check(t, err)
|
||||
if game.Status != "finish" || (next == "none" && game.Export != nil) {
|
||||
t.Fatal("late results were lost or restored released local mode")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetConcurrentResults(t *testing.T) {
|
||||
for _, name := range []string{"ResetConcurrentResults"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
f := newReturnFixture(t, "online")
|
||||
ctx := context.Background()
|
||||
exporter := f.service(f.storage)
|
||||
export, err := exporter.Get(ctx, f.gameID)
|
||||
check(t, err)
|
||||
raw := f.pack(t)
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, 2)
|
||||
wg.Add(2)
|
||||
go func() { defer wg.Done(); errs <- exporter.ResetLocalMode(ctx, f.gameID, export.ID) }()
|
||||
go func() { defer wg.Done(); _, err := f.apply(raw); errs <- err }()
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
check(t, err)
|
||||
}
|
||||
game, err := f.games.GetGameByID(ctx, f.gameID)
|
||||
check(t, err)
|
||||
if game.Export != nil || game.Status != "finish" {
|
||||
t.Fatalf("reset and return conflict: %+v", game)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package game_export_service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"evening_detective_server/internal/repos"
|
||||
"evening_detective_server/internal/repos/game_exports_repo"
|
||||
)
|
||||
@@ -12,12 +13,13 @@ type GameExport = repos.GameExport
|
||||
var (
|
||||
ErrGameNotFound = game_exports_repo.ErrGameNotFound
|
||||
ErrExportNotFound = game_exports_repo.ErrExportNotFound
|
||||
ErrNotExportable = game_exports_repo.ErrNotExportable
|
||||
ErrExportChanged = game_exports_repo.ErrExportChanged
|
||||
)
|
||||
|
||||
// IGameExportsRepo is the worker's persistence boundary, including publication fencing.
|
||||
type IGameExportsRepo interface {
|
||||
Start(context.Context, int, int, string, string) (*game_exports_repo.Attempt, error)
|
||||
ResetLocalMode(context.Context, int, string) error
|
||||
Get(context.Context, int) (*game_exports_repo.Attempt, error)
|
||||
Claim(context.Context, string) (*game_exports_repo.Attempt, error)
|
||||
Heartbeat(context.Context, string, string) error
|
||||
|
||||
@@ -24,9 +24,10 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrVenueMode = errors.New("экспорт игры доступен только на онлайн-сервере")
|
||||
ErrNotReady = errors.New("пакет игры ещё не готов")
|
||||
ErrInvalidGameID = errors.New("некорректный идентификатор игры")
|
||||
ErrVenueMode = errors.New("экспорт игры доступен только на онлайн-сервере")
|
||||
ErrNotReady = errors.New("пакет игры ещё не готов")
|
||||
ErrInvalidGameID = errors.New("некорректный идентификатор игры")
|
||||
ErrInvalidExportID = errors.New("не указан идентификатор экспорта")
|
||||
)
|
||||
|
||||
type GameExportService struct {
|
||||
@@ -82,6 +83,19 @@ func (s *GameExportService) Get(ctx context.Context, gameID int) (*repos.GameExp
|
||||
return &attempt.GameExport, nil
|
||||
}
|
||||
|
||||
func (s *GameExportService) ResetLocalMode(ctx context.Context, gameID int, exportID string) error {
|
||||
if s.venue {
|
||||
return ErrVenueMode
|
||||
}
|
||||
if gameID <= 0 {
|
||||
return ErrInvalidGameID
|
||||
}
|
||||
if strings.TrimSpace(exportID) == "" {
|
||||
return ErrInvalidExportID
|
||||
}
|
||||
return s.repo.ResetLocalMode(ctx, gameID, exportID)
|
||||
}
|
||||
|
||||
// OpenDownload always opens the same stored bytes and never changes game or export state.
|
||||
func (s *GameExportService) OpenDownload(ctx context.Context, gameID int) (*repos.GameExport, io.ReadCloser, error) {
|
||||
if s.venue {
|
||||
@@ -225,6 +239,16 @@ func (s *GameExportService) prepare(ctx context.Context, attempt *game_exports_r
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Export setup for a fresh game without mutating the recorded source snapshot.
|
||||
snapshot.Data.Game.Status = "draft"
|
||||
snapshot.Data.Game.StartedAt = nil
|
||||
snapshot.Data.Game.EndedAt = nil
|
||||
for i := range snapshot.Data.Teams {
|
||||
snapshot.Data.Teams[i].Status = "register"
|
||||
}
|
||||
if snapshot.Data.ScreenPlan != nil {
|
||||
snapshot.Data.ScreenPlan.CurrentStep = 0
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "game-export-"+attempt.ID+"-")
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -47,7 +47,7 @@ func TestServicesLifecyclePostgres(t *testing.T) {
|
||||
_, err = pool.Exec(ctx, `
|
||||
CREATE TEMP TABLE users(id INT, username TEXT);
|
||||
CREATE TEMP TABLE game_returns(export_id TEXT);
|
||||
CREATE TEMP TABLE game_exports(id TEXT, game_id INT, state TEXT, package_id TEXT, size BIGINT, sha256 TEXT, failure TEXT, created_at TIMESTAMPTZ);
|
||||
CREATE TEMP TABLE game_exports(id TEXT, game_id INT, state TEXT, package_id TEXT, size BIGINT, sha256 TEXT, failure TEXT, created_at TIMESTAMPTZ, released_at TIMESTAMPTZ);
|
||||
CREATE TEMP TABLE scenarios(id INT PRIMARY KEY, playlist_id INT, name TEXT, description TEXT, image TEXT, scenario TEXT,
|
||||
author_id INT, status TEXT DEFAULT 'draft', updated_at TIMESTAMP DEFAULT NOW(), created_at TIMESTAMP DEFAULT NOW(), published_at TIMESTAMP, is_deleted BOOLEAN DEFAULT FALSE);
|
||||
CREATE TEMP TABLE games(id SERIAL PRIMARY KEY, name TEXT, description TEXT, location TEXT, start_at TIMESTAMP, scenario_id INT,
|
||||
|
||||
@@ -32,7 +32,7 @@ func TestFinishedTeamStoryPostgres(t *testing.T) {
|
||||
defer pool.Close()
|
||||
_, err = pool.Exec(ctx, `
|
||||
CREATE TEMP TABLE game_returns(export_id TEXT);
|
||||
CREATE TEMP TABLE game_exports(id TEXT, game_id INT, state TEXT, package_id TEXT, size BIGINT, sha256 TEXT, failure TEXT, created_at TIMESTAMPTZ);
|
||||
CREATE TEMP TABLE game_exports(id TEXT, game_id INT, state TEXT, package_id TEXT, size BIGINT, sha256 TEXT, failure TEXT, created_at TIMESTAMPTZ, released_at TIMESTAMPTZ);
|
||||
CREATE TEMP TABLE games (id INT, name TEXT, description TEXT, location TEXT, start_at TIMESTAMP,
|
||||
format TEXT, status TEXT, scenario_id INT, started_at TIMESTAMP, ended_at TIMESTAMP);
|
||||
CREATE TEMP TABLE teams (id UUID, name TEXT, captain_email TEXT, participants_count INT,
|
||||
|
||||
Reference in New Issue
Block a user