This commit is contained in:
2026-10-17 16:00:21 +07:00
parent ee0478ab16
commit 300ff484df
21 changed files with 1643 additions and 870 deletions
+14
View File
@@ -51,6 +51,20 @@ func (s *server) GetGameExport(ctx context.Context, req *proto.GetGameExportReq)
return &proto.GameExportRsp{Export: mapGameExport(result)}, nil
}
func (s *server) ResetGameLocalMode(ctx context.Context, req *proto.ResetGameLocalModeReq) (*proto.ResetGameLocalModeRsp, error) {
claims, err := claimsFromContext(ctx)
if err != nil {
return nil, err
}
if !canExportGame(claims) {
return nil, status.Error(codes.PermissionDenied, "permission denied")
}
if err := s.gameExportService.ResetLocalMode(ctx, int(req.GetID()), req.GetExportID()); err != nil {
return nil, gameExportErrorStatus(err)
}
return &proto.ResetGameLocalModeRsp{}, nil
}
// GameExportDownloadHandler authenticates with the existing JWT processor and streams private ZIPs.
func GameExportDownloadHandler(jwt processor_jwt.IProcessorJWT, exports *game_export_service.GameExportService) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+4 -2
View File
@@ -19,10 +19,12 @@ func gameExportErrorStatus(err error) error {
return status.Error(codes.FailedPrecondition, game_export_service.ErrVenueMode.Error())
case errors.Is(err, game_export_service.ErrNotReady):
return status.Error(codes.FailedPrecondition, game_export_service.ErrNotReady.Error())
case errors.Is(err, game_export_service.ErrNotExportable):
return status.Error(codes.FailedPrecondition, game_export_service.ErrNotExportable.Error())
case errors.Is(err, game_export_service.ErrExportChanged):
return status.Error(codes.Aborted, game_export_service.ErrExportChanged.Error())
case errors.Is(err, game_export_service.ErrInvalidGameID):
return status.Error(codes.InvalidArgument, game_export_service.ErrInvalidGameID.Error())
case errors.Is(err, game_export_service.ErrInvalidExportID):
return status.Error(codes.InvalidArgument, game_export_service.ErrInvalidExportID.Error())
default:
log.Printf("game export API: %v", err)
return status.Error(codes.Internal, "Не удалось выполнить операцию экспорта игры")
+6 -1
View File
@@ -45,6 +45,10 @@ func TestExportRolesAndVenueMode(t *testing.T) {
if status.Code(err) != tc.code {
t.Fatalf("status access: %v", err)
}
_, err = server.ResetGameLocalMode(ctx, &proto.ResetGameLocalModeReq{ID: 1, ExportID: "export"})
if status.Code(err) != tc.code {
t.Fatalf("reset access: %v", err)
}
request := httptest.NewRequest(http.MethodGet, "/api/games/1/export/download", nil)
request.SetPathValue("id", "1")
if tc.role != "" {
@@ -71,7 +75,8 @@ func TestExportErrorsAreSafe(t *testing.T) {
}{
{"game missing", game_export_service.ErrGameNotFound, codes.NotFound},
{"export missing", game_export_service.ErrExportNotFound, codes.NotFound},
{"progress", game_export_service.ErrNotExportable, codes.FailedPrecondition},
{"changed export", game_export_service.ErrExportChanged, codes.Aborted},
{"invalid export", game_export_service.ErrInvalidExportID, codes.InvalidArgument},
{"not ready", game_export_service.ErrNotReady, codes.FailedPrecondition},
{"venue", game_export_service.ErrVenueMode, codes.FailedPrecondition},
{"invalid game", game_export_service.ErrInvalidGameID, codes.InvalidArgument},
@@ -104,3 +104,27 @@ func (f exportFixture) start(t *testing.T, id string) *Attempt {
requireNoError(t, err)
return attempt
}
// releaseMigration exercises transactional schema changes without querying application data.
func (f exportFixture) releaseMigration(t *testing.T, down bool) error {
t.Helper()
raw, err := os.ReadFile("../../../migrations/20261017120000_release_game_exports.sql")
requireNoError(t, err)
parts := strings.Split(string(raw), "-- +goose Down")
sql := parts[0]
if down {
sql = parts[1]
}
tx, err := f.pool.Begin(context.Background())
if err != nil {
return err
}
defer func() { _ = tx.Rollback(context.Background()) }()
if _, err := tx.Exec(
context.Background(),
sql,
); err != nil {
return err
}
return tx.Commit(context.Background())
}
+84 -51
View File
@@ -13,7 +13,7 @@ import (
var (
ErrGameNotFound = errors.New("игра не найдена")
ErrNotExportable = errors.New("экспорт доступен только для игры в черновике без дат проведения и прогресса")
ErrExportChanged = errors.New("экспорт игры изменился; обновите страницу и повторите действие")
ErrExportNotFound = errors.New("экспорт игры ещё не запущен")
ErrOwnershipLost = errors.New("владение экспортом потеряно")
)
@@ -45,79 +45,43 @@ func NewGameExportsRepo(pool *pgxpool.Pool) *GameExportsRepo {
}
}
// Start locks the game before inspecting progress, then saves all package data in one SQL snapshot.
// Start serializes replacements on the game and saves a fresh, consistent package snapshot.
func (r *GameExportsRepo) Start(ctx context.Context, gameID, userID int, id, source string) (*Attempt, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback(ctx) }()
var status string
var started, ended *time.Time
var lockedID int
err = tx.QueryRow(
ctx,
`SELECT
status,
started_at,
ended_at
id
FROM games
WHERE id = $1
FOR UPDATE`,
gameID,
).Scan(&status, &started, &ended)
).Scan(&lockedID)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrGameNotFound
}
if err != nil {
return nil, err
}
existing, err := readAttempt(ctx, tx, gameID, true, true)
if err == nil {
return existing, tx.Commit(ctx)
}
if !errors.Is(err, ErrExportNotFound) {
return nil, err
}
if status != "draft" || started != nil || ended != nil {
return nil, ErrNotExportable
}
var progress bool
err = tx.QueryRow(
// Retain ready packages for late results; fence unfinished workers before replacing them.
_, err = tx.Exec(
ctx,
`SELECT
EXISTS (
SELECT
1
FROM teams t
WHERE t.game_id = $1 AND (
EXISTS (
SELECT
1
FROM actions a
WHERE a.team_id = t.id
)
OR EXISTS (
SELECT
1
FROM applications a
WHERE a.team_id = t.id
)
OR EXISTS (
SELECT
1
FROM team_answers a
WHERE a.team_id = t.id
)
)
)`,
`UPDATE game_exports
SET released_at = clock_timestamp(),
state = CASE WHEN state = 'preparing' THEN 'failed' ELSE state END,
failure = CASE WHEN state = 'preparing' THEN 'Подготовка заменена новым пакетом.' ELSE failure END,
owner_id = NULL, lease_until = NULL
WHERE game_id = $1 AND released_at IS NULL AND state IN ('preparing', 'ready')`,
gameID,
).Scan(&progress)
)
if err != nil {
return nil, err
}
if progress {
return nil, ErrNotExportable
}
snapshot, err := captureSnapshot(ctx, tx, gameID)
if err != nil {
return nil, err
@@ -147,6 +111,75 @@ func (r *GameExportsRepo) Get(ctx context.Context, gameID int) (*Attempt, error)
return readAttempt(ctx, r.pool, gameID, false, false)
}
// ResetLocalMode releases only the expected latest export, preserving ready archives for late results.
func (r *GameExportsRepo) ResetLocalMode(ctx context.Context, gameID int, exportID string) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
var lockedID int
err = tx.QueryRow(
ctx,
`SELECT
id
FROM games
WHERE id = $1
FOR UPDATE`,
gameID,
).Scan(&lockedID)
if errors.Is(err, pgx.ErrNoRows) {
return ErrGameNotFound
}
if err != nil {
return err
}
var latestID, state string
var releasedAt *time.Time
err = tx.QueryRow(
ctx,
`SELECT
id,
state,
released_at
FROM game_exports
WHERE game_id = $1
ORDER BY created_at DESC, id
LIMIT 1
FOR UPDATE`,
gameID,
).Scan(&latestID, &state, &releasedAt)
if errors.Is(err, pgx.ErrNoRows) {
return ErrExportNotFound
}
if err != nil {
return err
}
if latestID != exportID {
return ErrExportChanged
}
if releasedAt != nil {
return tx.Commit(ctx)
}
if state != "ready" && state != "preparing" {
return ErrExportChanged
}
_, err = tx.Exec(
ctx,
`UPDATE game_exports
SET released_at = clock_timestamp(),
state = CASE WHEN state = 'preparing' THEN 'failed' ELSE state END,
failure = CASE WHEN state = 'preparing' THEN 'Подготовка отменена при сбросе локального режима.' ELSE failure END,
owner_id = NULL, lease_until = NULL
WHERE id = $1`,
exportID,
)
if err != nil {
return err
}
return tx.Commit(ctx)
}
// IRowReader is implemented by both the pool and a transaction.
type IRowReader interface {
QueryRow(context.Context, string, ...any) pgx.Row
@@ -169,7 +202,7 @@ func readAttempt(ctx context.Context, reader IRowReader, gameID int, active, wit
object_key,
created_at
FROM game_exports
WHERE game_id = $1 AND (NOT $2 OR state IN ('preparing', 'ready'))
WHERE game_id = $1 AND (NOT $2 OR (state IN ('preparing', 'ready') AND released_at IS NULL))
ORDER BY created_at DESC, id
LIMIT 1`,
gameID,
+21 -11
View File
@@ -108,9 +108,9 @@ func TestExportSnapshotAndEditableGame(t *testing.T) {
requireNoError(t, mapRepo.Update(ctx, game_map_repo.Item{GameID: f.gameID, Kind: "team", ItemID: f.teamID, X: 10}))
_, err = reviews.Moderate(ctx, review.ID, "approved")
requireNoError(t, err)
same, err := f.exports.Start(ctx, f.gameID, f.userID, "not-a-new-attempt", "test")
same, err := f.exports.Get(ctx, f.gameID)
requireNoError(t, err)
if same.ID != attempt.ID || !bytes.Equal(same.Snapshot, attempt.Snapshot) {
if same.ID != attempt.ID {
t.Fatal("online edits changed an existing export snapshot")
}
requireNoError(t, f.scenarios.UpdateScenarioByID(ctx, f.scenarioID, "Edited shared story", "", ""))
@@ -163,12 +163,9 @@ func TestExportConcurrentStartAndTeamWrites(t *testing.T) {
if changeErr != nil {
t.Fatal(changeErr)
}
var id string
ids := map[string]bool{}
for attempt := range attempts {
if id != "" && id != attempt.ID {
t.Fatal("more than one active export")
}
id = attempt.ID
ids[attempt.ID] = true
var snapshot struct {
Teams []struct {
Name string `json:"name"`
@@ -179,11 +176,26 @@ func TestExportConcurrentStartAndTeamWrites(t *testing.T) {
t.Fatalf("unexpected team snapshot: %s", snapshot.Teams[0].Name)
}
}
if len(ids) != 8 {
t.Fatalf("expected fresh attempts, got %d", len(ids))
}
current, err := f.exports.Get(ctx, f.gameID)
requireNoError(t, err)
claimed, err := f.exports.Claim(ctx, "worker")
requireNoError(t, err)
if claimed.ID != current.ID {
t.Fatal("worker claimed an obsolete attempt")
}
_, err = f.exports.Claim(ctx, "other-worker")
if !errors.Is(err, pgx.ErrNoRows) {
t.Fatalf("more than one active export: %v", err)
}
})
}
}
func TestExportRejectsProgressAndRunningGames(t *testing.T) {
func TestExportAllowsProgressAndRunningGames(t *testing.T) {
for _, tc := range []struct {
name string
mutate func(exportFixture) error
@@ -205,9 +217,7 @@ func TestExportRejectsProgressAndRunningGames(t *testing.T) {
f := newExportFixture(t)
requireNoError(t, tc.mutate(f))
_, err := f.exports.Start(context.Background(), f.gameID, f.userID, "attempt", "https://game.example")
if !errors.Is(err, ErrNotExportable) {
t.Fatalf("export allowed: %v", err)
}
requireNoError(t, err)
requireNoError(t, f.teams.UpdateTeam(context.Background(), f.teamID, "Still writable"))
})
}
@@ -0,0 +1,150 @@
package game_exports_repo
import (
"context"
"errors"
"sync"
"testing"
)
func TestResetLocalModePreservesReadyPackageAndGame(t *testing.T) {
for _, name := range []string{"ResetLocalModePreservesReadyPackageAndGame"} {
t.Run(name, func(t *testing.T) {
f := newExportFixture(t)
ctx := context.Background()
attempt := f.start(t, "ready-attempt")
_, err := f.exports.Claim(ctx, "worker")
requireNoError(t, err)
requireNoError(t, f.exports.Complete(ctx, attempt.ID, "worker", "package", "digest", 123))
before, err := f.games.GetGameByID(ctx, f.gameID)
requireNoError(t, err)
if before.Export == nil {
t.Fatal("missing local mode before reset")
}
requireNoError(t, f.exports.ResetLocalMode(ctx, f.gameID, attempt.ID))
requireNoError(t, f.exports.ResetLocalMode(ctx, f.gameID, attempt.ID))
after, err := f.games.GetGameByID(ctx, f.gameID)
requireNoError(t, err)
if after.Export != nil || after.Status != before.Status || after.Name != before.Name {
t.Fatal("reset retained local mode or changed the game")
}
games, err := f.games.GetGames(ctx)
requireNoError(t, err)
for _, game := range games {
if game.ID == f.gameID && game.Export != nil {
t.Fatal("game list retained local mode")
}
}
saved, err := f.exports.Get(ctx, f.gameID)
requireNoError(t, err)
if saved.ID != attempt.ID || saved.State != "ready" || saved.PackageID != "package" {
t.Fatal("reset damaged the package needed for late results")
}
next := f.start(t, "new-attempt")
if err := f.exports.ResetLocalMode(ctx, f.gameID, attempt.ID); !errors.Is(err, ErrExportChanged) {
t.Fatalf("stale confirmation accepted: %v", err)
}
current, err := f.exports.Get(ctx, f.gameID)
requireNoError(t, err)
if current.ID != next.ID || current.State != "preparing" {
t.Fatal("stale confirmation changed new export")
}
})
}
}
func TestResetAndReplacementFencePreparingWorker(t *testing.T) {
for _, replace := range []bool{false, true} {
t.Run(map[bool]string{false: "reset", true: "replace"}[replace], func(t *testing.T) {
f := newExportFixture(t)
ctx := context.Background()
attempt := f.start(t, "old")
_, err := f.exports.Claim(ctx, "worker")
requireNoError(t, err)
if replace {
f.start(t, "new")
} else {
requireNoError(t, f.exports.ResetLocalMode(ctx, f.gameID, attempt.ID))
}
if err := f.exports.Complete(ctx, attempt.ID, "worker", "package", "digest", 123); !errors.Is(err, ErrOwnershipLost) {
t.Fatalf("cancelled worker published: %v", err)
}
if err := f.exports.Heartbeat(ctx, attempt.ID, "worker"); !errors.Is(err, ErrOwnershipLost) {
t.Fatalf("cancelled worker renewed lease: %v", err)
}
keys, err := f.exports.CleanupKeys(ctx)
requireNoError(t, err)
if len(keys) != 1 || keys[0] != attempt.ObjectKey {
t.Fatalf("cancelled object cleanup lost: %v", keys)
}
})
}
}
func TestResetConcurrentReplacement(t *testing.T) {
for _, name := range []string{"ResetConcurrentReplacement"} {
t.Run(name, func(t *testing.T) {
f := newExportFixture(t)
ctx := context.Background()
attempt := f.start(t, "old")
var wg sync.WaitGroup
var resetErr, startErr error
wg.Add(2)
go func() { defer wg.Done(); resetErr = f.exports.ResetLocalMode(ctx, f.gameID, attempt.ID) }()
go func() { defer wg.Done(); _, startErr = f.exports.Start(ctx, f.gameID, f.userID, "new", "source") }()
wg.Wait()
requireNoError(t, startErr)
if resetErr != nil && !errors.Is(resetErr, ErrExportChanged) {
t.Fatal(resetErr)
}
game, err := f.games.GetGameByID(ctx, f.gameID)
requireNoError(t, err)
if game.Export == nil || game.Export.ID != "new" {
t.Fatal("concurrent reset released the new export")
}
})
}
}
func TestResetMissingGameOrExport(t *testing.T) {
for _, name := range []string{"ResetMissingGameOrExport"} {
t.Run(name, func(t *testing.T) {
f := newExportFixture(t)
ctx := context.Background()
if err := f.exports.ResetLocalMode(ctx, f.gameID+1, "missing"); !errors.Is(err, ErrGameNotFound) {
t.Fatal(err)
}
if err := f.exports.ResetLocalMode(ctx, f.gameID, "missing"); !errors.Is(err, ErrExportNotFound) {
t.Fatal(err)
}
})
}
}
func TestReleaseMigrationRollback(t *testing.T) {
for _, name := range []string{"ReleaseMigrationRollback"} {
t.Run(name, func(t *testing.T) {
f := newExportFixture(t)
ctx := context.Background()
requireNoError(t, f.releaseMigration(t, true))
requireNoError(t, f.releaseMigration(t, false))
attempt := f.start(t, "first")
_, err := f.exports.Claim(ctx, "worker")
requireNoError(t, err)
requireNoError(t, f.exports.Complete(ctx, attempt.ID, "worker", "package-1", "digest", 1))
second := f.start(t, "second")
_, err = f.exports.Claim(ctx, "worker")
requireNoError(t, err)
requireNoError(t, f.exports.Complete(ctx, second.ID, "worker", "package-2", "digest", 1))
// Rollback must refuse incompatible history without removing the current export or schema.
if err := f.releaseMigration(t, true); err == nil {
t.Fatal("rollback allowed incompatible ready history")
}
current, err := f.exports.Get(ctx, f.gameID)
requireNoError(t, err)
if current.ID != second.ID || current.State != "ready" {
t.Fatal("rollback damaged export history")
}
})
}
}
+2 -2
View File
@@ -78,7 +78,7 @@ func (r *GamesRepo) GetGameByID(
games.started_at,
games.ended_at,
(SELECT
CASE WHEN EXISTS (
CASE WHEN e.released_at IS NOT NULL OR EXISTS (
SELECT 1
FROM game_returns r
WHERE r.export_id = e.id
@@ -152,7 +152,7 @@ func (r *GamesRepo) GetGames(
games.format,
games.status,
(SELECT
CASE WHEN EXISTS (
CASE WHEN e.released_at IS NOT NULL OR EXISTS (
SELECT 1
FROM game_returns r
WHERE r.export_id = e.id
@@ -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,