This commit is contained in:
2026-10-11 13:09:34 +07:00
parent 26c65062e9
commit 74a45da402
18 changed files with 2294 additions and 82 deletions
@@ -28,6 +28,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 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);
@@ -0,0 +1,508 @@
package game_export_service_test
import (
"archive/zip"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"os"
"reflect"
"sync"
"testing"
"time"
"evening_detective_server/internal/repos"
"evening_detective_server/internal/repos/game_map_repo"
"evening_detective_server/internal/repos/game_returns_repo"
"evening_detective_server/internal/repos/team_answers_repo"
"evening_detective_server/internal/repos/tickets_repo"
results "evening_detective_server/internal/services/game_results_package_service"
)
type returnFixture struct {
serviceFixture
storage *memoryStorage
data results.Data
manifest results.Manifest
}
func newReturnFixture(t *testing.T, format string) returnFixture {
t.Helper()
ctx := context.Background()
f := newServiceFixture(t)
check(t, f.games.UpdateGame(ctx, f.gameID, "Test game", "Description", nil, time.Now().Add(time.Hour), int32(f.scenarioID), format))
check(t, f.scenarios.UpdateStoryByScenarioID(ctx, f.scenarioID, `{"places":[{"code":"p","name":"Place","text":"Text","applications":[{"name":"Clue","image":"original.png"}]}],"questions":[{"code":"q","text":"Who?","answer":"Butler"},{"code":"q2","text":"Why?","answer":"Money"}]}`))
_, err := f.teams.AddTeam(ctx, f.userID, f.gameID, "Removed", "removed-qr")
check(t, err)
hash := sha256.Sum256([]byte("removed ticket"))
_, err = tickets_repo.New(f.pool).CreateBatch(ctx, f.gameID, [][]byte{hash[:]})
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)
info, raw := download(t, exporter, f.gameID)
original, err := f.codec.Unpack(ctx, bytes.NewReader(raw), int64(len(raw)), t.TempDir())
check(t, err)
defer func() { _ = os.RemoveAll(original.Dir) }()
story, _ := json.Marshal(original.Data.Scenario.Story)
storyHash := sha256.Sum256(story)
now := time.Now().UTC().Truncate(time.Microsecond)
end := now.Add(time.Hour)
sourceTeam := original.Data.Teams[0]
team := results.Team{Team: sourceTeam, SourceID: sourceTeam.ID}
team.ParticipationStatus = "confirmed"
local := f.userID
newTeam := team
newTeam.ID = original.Data.Teams[len(original.Data.Teams)-1].ID + 10
newTeam.SourceID = 0
newTeam.CreatorID = nil
newTeam.LocalCreatorID = &local
newTeam.Name = "Venue team"
newTeam.Password = "venue-printed-qr"
sourceTicket := original.Data.Tickets[0]
ticket := results.Ticket{Ticket: sourceTicket, SourceID: sourceTicket.ID, LocalUsedBy: &local}
ticket.Status = "used"
ticket.UsedAt = &now
newTicket := ticket
newTicket.ID = original.Data.Tickets[len(original.Data.Tickets)-1].ID + 10
newTicket.SourceID = 0
newHash := sha256.Sum256([]byte("venue ticket"))
newTicket.TokenHash = newHash[:]
material := results.Material{Name: "Clue", Image: original.Data.Scenario.Story.Places[0].Applications[0].Image, FileType: "image"}
score := 5
return returnFixture{
serviceFixture: f,
storage: storage,
manifest: results.Manifest{SourceServer: "https://game.example", GameID: f.gameID, SourcePackageID: info.PackageID, SourceSHA256: info.SHA256},
data: results.Data{
Source: results.Source{ScenarioID: f.scenarioID, ScenarioSHA256: hex.EncodeToString(storyHash[:]), Teams: original.Data.Teams, Tickets: original.Data.Tickets, Materials: []results.Material{material}, QuestionCodes: []string{"q", "q2"}},
Game: results.Game{ID: f.gameID, Status: "finish", Format: format, StartedAt: &now, EndedAt: &end},
Teams: []results.Team{team, newTeam}, Tickets: []results.Ticket{ticket, newTicket},
DeletedTeamIDs: []int{original.Data.Teams[1].ID}, DeletedTicketIDs: []int{original.Data.Tickets[1].ID},
Actions: []results.Action{{ID: 70, TeamID: team.ID, Code: "p", CreatedAt: now}, {ID: 71, TeamID: team.ID, Code: "p", ViaMCP: true, CreatedAt: now}},
Applications: []results.Application{{ID: 80, TeamID: team.ID, Material: material, Status: "done", CreatedAt: now}},
Answers: []results.Answer{{TeamID: team.ID, QuestionCode: "q", Text: "Butler", Score: &score, CreatedAt: now, UpdatedAt: now}, {TeamID: team.ID, QuestionCode: "q2", Text: "Money", CreatedAt: now, UpdatedAt: now}},
},
}
}
func (f returnFixture) pack(t *testing.T) []byte {
t.Helper()
var output bytes.Buffer
check(t, results.Pack(context.Background(), f.manifest, &f.data, &output))
return output.Bytes()
}
func (f returnFixture) apply(raw []byte) (*game_returns_repo.Return, error) {
service := results.NewGameResultsApplyService(game_returns_repo.NewGameReturnsRepo(f.pool), f.storage, "prod", "https://game.example")
r, err := service.Apply(context.Background(), bytes.NewReader(raw), int64(len(raw)), f.userID)
if err != nil {
return nil, fmt.Errorf("%w (cause: %v)", err, errors.Unwrap(err))
}
return r, nil
}
func TestReturnAtomicLifecycle(t *testing.T) {
for _, format := range []string{"online", "offline"} {
t.Run(format, func(t *testing.T) {
ctx := context.Background()
f := newReturnFixture(t, format)
mapRepo := game_map_repo.NewGameMapRepo(f.pool)
check(t, mapRepo.Ensure(ctx, f.gameID, "team", f.data.DeletedTeamIDs[0]))
check(t, mapRepo.Ensure(ctx, f.gameID, "team", f.teamID))
check(t, f.games.UpdateGame(ctx, f.gameID, "Online title", "Online description", nil, time.Now().Add(72*time.Hour), int32(f.scenarioID), format))
token := hex.EncodeToString(bytes.Repeat([]byte{9}, 32))
check(t, f.teams.SetParticipationToken(ctx, f.teamID, token))
raw := f.pack(t)
first, err := f.apply(raw)
check(t, err)
participation, err := f.teams.GetParticipation(ctx, token)
check(t, err)
if participation.TeamID != f.teamID {
t.Fatal("mail token lost")
}
game, err := f.games.GetGameByID(ctx, f.gameID)
check(t, err)
if game.Export != nil || game.Status != "finish" || game.Name != "Online title" || game.Description != "Online description" {
t.Fatal("online presentation/status not preserved")
}
games, err := f.games.GetGames(ctx)
check(t, err)
if games[0].Export != nil {
t.Fatal("list export visible")
}
team, err := f.teams.GetTeamByID(ctx, f.data.Teams[1].ID)
check(t, err)
if team.Password != "venue-printed-qr" {
t.Fatal("venue QR changed")
}
_, err = f.teams.GetTeamByID(ctx, f.data.DeletedTeamIDs[0])
if err == nil {
t.Fatal("deleted team remains")
}
items, err := mapRepo.List(ctx, f.gameID)
check(t, err)
if len(items) != 1 || items[0].ItemID != f.teamID {
t.Fatal("map relation orphaned")
}
ticket, err := tickets_repo.New(f.pool).GetByHash(ctx, f.data.Tickets[0].TokenHash)
check(t, err)
if ticket.ID != f.data.Tickets[0].ID || ticket.Status != "used" || ticket.UsedBy != nil {
t.Fatal("ticket identity or local actor wrong")
}
newTicket, err := tickets_repo.New(f.pool).GetByHash(ctx, f.data.Tickets[1].TokenHash)
check(t, err)
if newTicket.ID == f.data.Tickets[1].ID {
t.Fatal("venue ticket ID reused")
}
added, err := f.teams.AddTeam(ctx, f.userID, f.gameID, "Sequence check", "qr")
check(t, err)
if added <= f.data.Teams[1].ID {
t.Fatal("sequence not advanced")
}
check(t, f.teams.DeleteTeam(ctx, added))
again, err := f.apply(raw)
check(t, err)
if !reflect.DeepEqual(first, again) {
t.Fatal("repeat changed result")
}
zero := 0
f.data.Answers[0].Score = &zero
corrected := f.pack(t)
second, err := f.apply(corrected)
check(t, err)
if !second.PreparedAt.After(first.PreparedAt) {
t.Fatal("package order")
}
answers := team_answers_repo.NewTeamAnswersRepo(f.pool)
stored, err := answers.GetAnswersByTeamID(ctx, f.teamID)
check(t, err)
if len(stored) != 2 || stored[0].Score == nil || *stored[0].Score != 0 || stored[1].Score != nil {
t.Fatal("scores recalculated or null lost")
}
old, err := f.apply(raw)
check(t, err)
if !old.AppliedAt.Equal(first.AppliedAt) {
t.Fatal("old repeat changed")
}
later := f.pack(t)
check(t, answers.SetAnswerScore(ctx, f.teamID, "q", nil))
_, err = f.apply(later)
check(t, err)
stored, err = answers.GetAnswersByTeamID(ctx, f.teamID)
check(t, err)
if stored[0].Score == nil || *stored[0].Score != 0 {
t.Fatal("online grading was not overwritten")
}
// The same ID with changed archive bytes conflicts before any source access.
modified := append(bytes.Clone(raw), []byte("extra")...)
_, err = f.apply(modified)
if !errors.Is(err, results.ErrReturnConflict) {
t.Fatalf("changed bytes: %v", err)
}
// Registry survives game deletion and supports old repeats.
teams, err := f.teams.GetTeamsByGameID(ctx, f.gameID)
check(t, err)
for _, team := range teams {
check(t, f.teams.DeleteTeam(ctx, team.ID))
}
check(t, f.games.DeleteGameByID(ctx, f.gameID))
_, err = f.apply(raw)
check(t, err)
_, err = f.apply(f.pack(t))
if !errors.Is(err, results.ErrSourceMismatch) {
t.Fatal(err)
}
})
}
}
func TestReturnRollbackOnCollision(t *testing.T) {
for _, kind := range []string{"team ID", "ticket hash", "source teams", "source story", "original ZIP", "source materials", "source questions", "source tickets", "format", "team overflow"} {
t.Run(kind, func(t *testing.T) {
ctx := context.Background()
f := newReturnFixture(t, "online")
switch kind {
case "team ID":
foreign, err := f.games.AddGame(ctx, "Other", "", nil, time.Now(), int32(f.scenarioID), "online")
check(t, err)
id, err := f.teams.AddTeam(ctx, f.userID, foreign, "Other team", "qr")
check(t, err)
f.data.Teams[1].ID = id
case "ticket hash":
foreign, err := f.games.AddGame(ctx, "Other", "", nil, time.Now(), int32(f.scenarioID), "online")
check(t, err)
_, err = tickets_repo.New(f.pool).CreateBatch(ctx, foreign, [][]byte{f.data.Tickets[1].TokenHash})
check(t, err)
case "source teams":
f.data.Source.Teams[0].Name = "Forged source"
f.data.Teams[0].Name = "Forged source"
case "source story":
f.data.Source.ScenarioSHA256 = hex.EncodeToString(make([]byte, 32))
case "format":
f.data.Game.Format = "offline"
case "team overflow":
f.data.Teams[1].ID = 2147483647
case "source materials":
f.data.Source.Materials[0].Name = "Forged clue"
f.data.Applications[0].Name = "Forged clue"
case "source questions":
f.data.Source.QuestionCodes[1] = "forged"
f.data.Answers[1].QuestionCode = "forged"
case "source tickets":
f.data.Source.Tickets[0].TokenHash = bytes.Repeat([]byte{11}, 32)
f.data.Tickets[0].TokenHash = f.data.Source.Tickets[0].TokenHash
case "original ZIP":
for key := range f.storage.objects {
f.storage.objects[key] = []byte("corrupt")
}
}
raw := f.pack(t)
_, err := f.apply(raw)
expected := results.ErrReturnConflict
if kind == "source teams" || kind == "source story" || kind == "original ZIP" || kind == "source materials" || kind == "source questions" || kind == "source tickets" || kind == "format" {
expected = results.ErrSourceMismatch
}
if !errors.Is(err, expected) {
t.Fatalf("expected %v got %v", expected, err)
}
game, err := f.games.GetGameByID(ctx, f.gameID)
check(t, err)
if game.Status != "draft" || game.Export == nil {
t.Fatal("partial application")
}
originalHash := sha256.Sum256([]byte("ticket"))
ticket, err := tickets_repo.New(f.pool).GetByHash(ctx, originalHash[:])
check(t, err)
if ticket.Status != "active" {
t.Fatal("ticket partially applied")
}
_, err = game_returns_repo.NewGameReturnsRepo(f.pool).FindReturn(ctx, resultID(t, raw))
if err == nil {
t.Fatal("failed return recorded")
}
})
}
}
func resultID(t *testing.T, raw []byte) string {
t.Helper()
b, err := results.Unpack(context.Background(), bytes.NewReader(raw), int64(len(raw)))
check(t, err)
return b.Manifest.PackageID
}
func TestReturnConcurrentRepeat(t *testing.T) {
for _, name := range []string{"concurrent same package"} {
t.Run(name, func(t *testing.T) {
f := newReturnFixture(t, "online")
raw := f.pack(t)
var workers sync.WaitGroup
failures := make(chan error, 2)
times := make(chan time.Time, 2)
for i := 0; i < 2; i++ {
workers.Add(1)
go func() {
defer workers.Done()
r, err := f.apply(raw)
if err != nil {
failures <- err
} else {
times <- r.AppliedAt
}
}()
}
workers.Wait()
close(failures)
close(times)
for err := range failures {
t.Fatal(err)
}
var first time.Time
for timestamp := range times {
if first.IsZero() {
first = timestamp
} else if !first.Equal(timestamp) {
t.Fatal("repeat applied twice")
}
}
})
}
}
// rewriteManifest keeps valid result bytes while testing ordering and identity checks.
func rewriteManifest(t *testing.T, raw []byte, change func(*results.Manifest)) []byte {
t.Helper()
reader, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
check(t, err)
var output bytes.Buffer
writer := zip.NewWriter(&output)
for _, entry := range reader.File {
stream, err := entry.Open()
check(t, err)
var content bytes.Buffer
_, err = content.ReadFrom(stream)
check(t, err)
check(t, stream.Close())
data := content.Bytes()
if entry.Name == "manifest.json" {
var manifest results.Manifest
check(t, json.Unmarshal(data, &manifest))
change(&manifest)
data, err = json.Marshal(manifest)
check(t, err)
}
file, err := writer.Create(entry.Name)
check(t, err)
_, err = file.Write(data)
check(t, err)
}
check(t, writer.Close())
return output.Bytes()
}
func TestReturnPackageOrdering(t *testing.T) {
for _, kind := range []string{"equal", "older", "other server", "wrong export hash", "wrong export ID", "one ns later"} {
t.Run(kind, func(t *testing.T) {
f := newReturnFixture(t, "online")
raw := f.pack(t)
first, err := f.apply(raw)
check(t, err)
newer := f.pack(t)
modified := rewriteManifest(t, newer, func(m *results.Manifest) {
switch kind {
case "one ns later":
m.CreatedAt = first.PreparedAt.Add(time.Nanosecond)
case "equal":
m.CreatedAt = first.PreparedAt
case "older":
m.CreatedAt = first.PreparedAt.Add(-time.Hour)
case "other server":
m.SourceServer = "https://other.example"
case "wrong export hash":
m.SourceSHA256 = hex.EncodeToString(make([]byte, 32))
case "wrong export ID":
m.SourcePackageID = hex.EncodeToString(make([]byte, 16))
}
})
_, err = f.apply(modified)
expected := results.ErrSourceMismatch
if kind == "one ns later" {
expected = nil
}
if kind == "equal" || kind == "older" {
expected = results.ErrReturnConflict
}
if !errors.Is(err, expected) {
t.Fatal(err)
}
})
}
}
func TestReturnCreatesNoMailJobs(t *testing.T) {
for _, name := range []string{"returned game has no reminder or participation jobs"} {
t.Run(name, func(t *testing.T) {
ctx := context.Background()
f := newReturnFixture(t, "online")
start := time.Now().Add(-24 * time.Hour).UTC()
end := start.Add(time.Hour)
f.data.Game.StartedAt = &start
f.data.Game.EndedAt = &end
for i := range f.data.Teams {
f.data.Teams[i].CaptainEmail = "captain@test.invalid"
}
_, err := f.apply(f.pack(t))
check(t, err)
_, err = f.teams.ClaimReviewReminder(ctx)
if !errors.Is(err, pgx.ErrNoRows) {
t.Fatalf("review mail created: %v", err)
}
_, err = f.teams.ClaimParticipationEmail(ctx)
if !errors.Is(err, pgx.ErrNoRows) {
t.Fatalf("participation mail created: %v", err)
}
})
}
}
func TestReturnOverwritesOnlineEditsAndIsolatesScenario(t *testing.T) {
for _, kind := range []string{"shared scenario edited", "different scenario selected"} {
t.Run(kind, func(t *testing.T) {
ctx := context.Background()
f := newReturnFixture(t, "online")
selected := f.scenarioID
if kind == "different scenario selected" {
var err error
selected, err = f.scenarios.AddScenario(ctx, "Online scenario", f.userID)
check(t, err)
}
changedStory := `{"places":[],"questions":[]}`
check(t, f.scenarios.UpdateStoryByScenarioID(ctx, selected, changedStory))
other, err := f.games.AddGame(ctx, "Other game", "", nil, time.Now(), int32(selected), "offline")
check(t, err)
check(t, f.games.UpdateGame(ctx, f.gameID, "Online title", "Online description", nil, time.Now(), int32(selected), "offline"))
check(t, f.teams.UpdateTeam(ctx, f.teamID, "Online team name"))
extraTeam, err := f.teams.AddTeam(ctx, f.userID, f.gameID, "Online extra team", "online-qr")
check(t, err)
err = team_answers_repo.NewTeamAnswersRepo(f.pool).UpsertAnswers(ctx, f.teamID, []repos.TeamAnswer{{QuestionCode: "q", Text: "Online answer"}})
check(t, err)
raw := f.pack(t)
first, err := f.apply(raw)
check(t, err)
game, err := f.games.GetGameByID(ctx, f.gameID)
check(t, err)
if game.ScenarioID == selected || game.Format != "online" || game.Name != "Online title" || game.Description != "Online description" {
t.Fatal("scenario/format/presentation not restored")
}
story, err := f.scenarios.GetStoryByScenarioID(ctx, int(game.ScenarioID))
check(t, err)
if !bytes.Contains([]byte(story), []byte("Butler")) || !bytes.Contains([]byte(story), []byte("original.png")) || bytes.Contains([]byte(story), []byte("package://")) {
t.Fatal("original questions/material links not restored")
}
otherGame, err := f.games.GetGameByID(ctx, other)
check(t, err)
otherStory, err := f.scenarios.GetStoryByScenarioID(ctx, int(otherGame.ScenarioID))
check(t, err)
var expected, actual any
check(t, json.Unmarshal([]byte(changedStory), &expected))
check(t, json.Unmarshal([]byte(otherStory), &actual))
if !reflect.DeepEqual(expected, actual) {
t.Fatal("shared scenario changed")
}
team, err := f.teams.GetTeamByID(ctx, f.teamID)
check(t, err)
if team.Name != f.data.Teams[0].Name {
t.Fatal("online team edit remains")
}
if _, err := f.teams.GetTeamByID(ctx, extraTeam); err == nil {
t.Fatal("extra online team remains")
}
stored, err := team_answers_repo.NewTeamAnswersRepo(f.pool).GetAnswersByTeamID(ctx, f.teamID)
check(t, err)
if len(stored) != 2 || stored[0].Text != "Butler" {
t.Fatal("online answers not overwritten")
}
_, err = f.apply(f.pack(t))
check(t, err)
updated, err := f.games.GetGameByID(ctx, f.gameID)
check(t, err)
if updated.ScenarioID != game.ScenarioID {
t.Fatal("unchanged scenario cloned again")
}
var mappings struct {
Scenario int `json:"scenario"`
}
check(t, json.Unmarshal(first.Mappings, &mappings))
if mappings.Scenario != int(game.ScenarioID) {
t.Fatal("scenario mapping missing")
}
})
}
}
@@ -0,0 +1,309 @@
package game_results_package_service
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"os"
"evening_detective_server/internal/modules/file_storage"
"evening_detective_server/internal/repos/game_returns_repo"
"evening_detective_server/internal/services/game_package_service"
"github.com/jackc/pgx/v5"
)
var (
ErrReturnNotAvailable = errors.New("возврат результатов доступен только на онлайн-сервере")
ErrSourceMismatch = errors.New("пакет не соответствует исходному экспорту")
ErrReturnConflict = game_returns_repo.ErrConflict
ErrReturnRetry = game_returns_repo.ErrRetry
ErrReturnFailed = errors.New("не удалось применить результаты")
)
// GameResultsApplyService applies private result archives; transport is added separately.
type GameResultsApplyService struct {
repo *game_returns_repo.GameReturnsRepo
storage file_storage.IStreamStorage
mode string
source string
}
func NewGameResultsApplyService(repo *game_returns_repo.GameReturnsRepo, storage file_storage.IStreamStorage, mode, source string) *GameResultsApplyService {
return &GameResultsApplyService{
repo: repo,
storage: storage,
mode: mode,
source: source,
}
}
// Apply preserves recorded repeats, including older packages and deleted games.
func (s *GameResultsApplyService) Apply(ctx context.Context, input io.ReaderAt, size int64, initiatorID int) (*game_returns_repo.Return, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if s.mode != "prod" || s.repo == nil || s.storage == nil {
return nil, ErrReturnNotAvailable
}
bundle, digest, err := readResultInput(ctx, input, size)
if err != nil {
return nil, safeReturnError(ctx, err)
}
result, err := s.apply(ctx, bundle, digest, initiatorID)
if err == nil || errors.Is(err, ErrSourceMismatch) || errors.Is(err, ErrReturnConflict) || errors.Is(err, ErrReturnRetry) || errors.Is(err, ErrInvalidPackage) {
return result, err
}
if ctx.Err() != nil {
return nil, ctx.Err()
}
return nil, &returnFailure{cause: err}
}
type returnFailure struct{ cause error }
func (e *returnFailure) Error() string { return ErrReturnFailed.Error() }
func (e *returnFailure) Unwrap() error { return e.cause }
func (e *returnFailure) Is(target error) bool { return target == ErrReturnFailed }
func (s *GameResultsApplyService) apply(ctx context.Context, bundle *Bundle, digest string, initiatorID int) (*game_returns_repo.Return, error) {
saved, err := s.repo.FindReturn(ctx, bundle.Manifest.PackageID)
if err == nil {
return repeatReturn(saved, digest)
}
if !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
export, err := s.repo.ReadExport(ctx, bundle.Manifest.SourcePackageID)
if errors.Is(err, game_returns_repo.ErrSource) {
return nil, ErrSourceMismatch
}
if err != nil {
return nil, err
}
m := bundle.Manifest
if m.SourceServer != s.source || m.SourceServer != export.SourceServer || m.SourceSHA256 != export.SHA256 || m.GameID != export.GameID {
return nil, ErrSourceMismatch
}
var snapshot game_package_service.Data
if err := json.Unmarshal(export.Snapshot, &snapshot); err != nil {
return nil, err
}
original, err := s.readOriginal(ctx, export.ObjectKey, export.Size, export.SHA256)
if err != nil {
return nil, err
}
defer func() { _ = os.RemoveAll(original.Dir) }()
if original.Manifest.PackageID != m.SourcePackageID || original.Manifest.SourceServer != m.SourceServer || original.Manifest.GameID != m.GameID {
return nil, ErrSourceMismatch
}
source, err := sourceFromOriginal(&original.Data)
if err != nil {
return nil, ErrSourceMismatch
}
if original.Data.Game.Format != bundle.Data.Game.Format || !sameJSON(source, bundle.Data.Source) {
return nil, ErrSourceMismatch
}
materials, err := originalMaterials(&original.Data, &snapshot)
if err != nil {
return nil, err
}
return s.repo.Transaction(ctx, func(tx pgx.Tx) (*game_returns_repo.Return, error) {
saved, err := game_returns_repo.ReadReturn(ctx, tx, m.PackageID)
if err == nil {
return repeatReturn(saved, digest)
}
if !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
if err := game_returns_repo.Lock(ctx, tx, m.GameID, export.ID); err != nil {
if errors.Is(err, game_returns_repo.ErrSource) {
return nil, ErrSourceMismatch
}
return nil, err
}
latest, err := game_returns_repo.ReadLatest(ctx, tx, export.ID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
if err == nil && !m.CreatedAt.After(latest.PreparedAt) {
return nil, ErrReturnConflict
}
current, err := game_returns_repo.Capture(ctx, tx, m.GameID)
if err != nil {
return nil, err
}
mappings := newMappings()
if latest != nil {
if err := json.Unmarshal(latest.Mappings, &mappings); err != nil {
return nil, err
}
}
if err := allocateReturnIDs(ctx, tx, &bundle.Data, &mappings); err != nil {
return nil, err
}
data, err := mapReturnData(&bundle.Data, current, materials, &mappings)
if err != nil {
return nil, err
}
raw, err := json.Marshal(data)
if err != nil {
return nil, err
}
if err := game_returns_repo.Write(ctx, tx, m.GameID, raw); err != nil {
return nil, err
}
story, err := json.Marshal(snapshot.Scenario.Story)
if err != nil {
return nil, err
}
mappings.Scenario = current.ScenarioID
if !sameRawJSON(current.Story, story) {
mappings.Scenario, err = game_returns_repo.RestoreStory(ctx, tx, m.GameID, story)
if err != nil {
return nil, err
}
}
ids, err := json.Marshal(mappings)
if err != nil {
return nil, err
}
actors, err := json.Marshal(localActors(&bundle.Data))
if err != nil {
return nil, err
}
result := &game_returns_repo.Return{
PackageID: m.PackageID,
SHA256: digest,
ExportID: export.ID,
GameID: m.GameID,
PreparedAt: m.CreatedAt.UTC(),
Mappings: ids,
LocalActors: actors,
InitiatorID: initiatorID,
}
if err := game_returns_repo.Save(ctx, tx, result); err != nil {
return nil, err
}
return game_returns_repo.ReadReturn(ctx, tx, m.PackageID)
})
}
func repeatReturn(saved *game_returns_repo.Return, digest string) (*game_returns_repo.Return, error) {
if saved.SHA256 != digest {
return nil, ErrReturnConflict
}
return saved, nil
}
func (s *GameResultsApplyService) readOriginal(ctx context.Context, key string, size int64, digest string) (*game_package_service.Bundle, error) {
if size <= 0 || size > game_package_service.MaxArchiveSize {
return nil, ErrSourceMismatch
}
body, err := s.storage.Open(ctx, key)
if err != nil {
return nil, err
}
defer func() { _ = body.Close() }()
file, err := os.CreateTemp("", "game-return-*.zip")
if err != nil {
return nil, err
}
defer func() { _ = file.Close(); _ = os.Remove(file.Name()) }()
hash := sha256.New()
n, err := io.Copy(io.MultiWriter(file, hash), io.LimitReader(&contextReader{ctx: ctx, reader: body}, size+1))
if err != nil {
return nil, err
}
if n != size || hex.EncodeToString(hash.Sum(nil)) != digest {
return nil, ErrSourceMismatch
}
original, err := game_package_service.NewGamePackageService("").Unpack(ctx, file, size, "")
if err != nil {
if ctx.Err() != nil {
return nil, ctx.Err()
}
return nil, ErrSourceMismatch
}
return original, nil
}
func reserveID(ctx context.Context, tx pgx.Tx, kind string, localID int, ids map[int]int) (int, error) {
if id := ids[localID]; id != 0 {
return id, nil
}
id, err := game_returns_repo.Allocate(ctx, tx, kind)
if err != nil {
return 0, err
}
ids[localID] = id
return id, nil
}
func allocateReturnIDs(ctx context.Context, tx pgx.Tx, data *Data, ids *idMappings) error {
for _, ticket := range data.Tickets {
if ticket.SourceID != 0 {
ids.Tickets[ticket.ID] = ticket.SourceID
continue
}
if _, err := reserveID(ctx, tx, "tickets", ticket.ID, ids.Tickets); err != nil {
return err
}
}
// Fresh progress IDs preserve the package order even when earlier rows reappear.
ids.Actions = map[int]int{}
ids.Applications = map[int]int{}
for _, action := range data.Actions {
if _, err := reserveID(ctx, tx, "actions", action.ID, ids.Actions); err != nil {
return err
}
}
for _, application := range data.Applications {
if _, err := reserveID(ctx, tx, "applications", application.ID, ids.Applications); err != nil {
return err
}
}
return nil
}
func safeReturnError(ctx context.Context, err error) error {
if ctx.Err() != nil {
return ctx.Err()
}
for _, sentinel := range []error{ErrInvalidPackage, ErrUnsupportedVersion, ErrLimitExceeded} {
if errors.Is(err, sentinel) {
return sentinel
}
}
return &returnFailure{cause: err}
}
// Stage once so the validated payload and idempotency digest always describe the same bytes.
func readResultInput(ctx context.Context, input io.ReaderAt, size int64) (*Bundle, string, error) {
if input == nil || size <= 0 {
return nil, "", ErrInvalidPackage
}
if size > game_package_service.MaxArchiveSize {
return nil, "", ErrLimitExceeded
}
file, err := os.CreateTemp("", "game-return-results-*.zip")
if err != nil {
return nil, "", err
}
defer func() { _ = file.Close(); _ = os.Remove(file.Name()) }()
hash := sha256.New()
n, err := io.Copy(io.MultiWriter(file, hash), &contextReader{ctx: ctx, reader: io.NewSectionReader(input, 0, size)})
if err != nil {
return nil, "", err
}
if n != size {
return nil, "", ErrInvalidPackage
}
bundle, err := Unpack(ctx, file, size)
if err != nil {
return nil, "", err
}
return bundle, hex.EncodeToString(hash.Sum(nil)), nil
}
@@ -0,0 +1,105 @@
package game_results_package_service
import (
"encoding/json"
"evening_detective_server/internal/repos/game_results_repo"
)
type idMappings struct {
Scenario int `json:"scenario"`
Teams map[int]int `json:"teams"`
Tickets map[int]int `json:"tickets"`
Actions map[int]int `json:"actions"`
Applications map[int]int `json:"applications"`
}
func newMappings() idMappings {
return idMappings{
Teams: map[int]int{},
Tickets: map[int]int{},
Actions: map[int]int{},
Applications: map[int]int{},
}
}
type actors struct {
Creators map[int]int `json:"creators"`
TicketUsers map[int]int `json:"ticket_users"`
}
func localActors(data *Data) actors {
result := actors{
Creators: map[int]int{},
TicketUsers: map[int]int{},
}
for _, team := range data.Teams {
if team.LocalCreatorID != nil {
result.Creators[team.ID] = *team.LocalCreatorID
}
}
for _, ticket := range data.Tickets {
if ticket.LocalUsedBy != nil {
result.TicketUsers[ticket.ID] = *ticket.LocalUsedBy
}
}
return result
}
// mapReturnData keeps credentials and online identities; local actors remain only in the registry.
func mapReturnData(input *Data, current *game_results_repo.Snapshot, materials map[Material]Material, ids *idMappings) (*Data, error) {
raw, err := json.Marshal(input)
if err != nil {
return nil, err
}
var data, online Data
if err := json.Unmarshal(raw, &data); err != nil {
return nil, err
}
if err := json.Unmarshal(current.Data, &online); err != nil {
return nil, err
}
for i := range data.Teams {
team := &data.Teams[i]
ids.Teams[team.ID] = team.ID
team.LocalCreatorID = nil
for _, existing := range online.Teams {
if existing.ID == team.ID {
team.CreatorID = existing.CreatorID
team.RegistrationDeliveryID = existing.RegistrationDeliveryID
break
}
}
}
for i := range data.Tickets {
ticket := &data.Tickets[i]
localID := ticket.ID
if ticket.SourceID != 0 {
ticket.ID = ticket.SourceID
} else {
ticket.ID = ids.Tickets[localID]
}
ids.Tickets[localID] = ticket.ID
ticket.LocalUsedBy = nil
for _, existing := range online.Tickets {
if existing.ID == ticket.ID && ticket.Status == "used" {
ticket.UsedBy = existing.UsedBy
break
}
}
}
for i := range data.Actions {
action := &data.Actions[i]
action.ID = ids.Actions[action.ID]
}
for i := range data.Applications {
application := &data.Applications[i]
material, ok := materials[application.Material]
if !ok {
return nil, ErrSourceMismatch
}
application.Material = material
application.ID = ids.Applications[application.ID]
}
return &data, nil
}
@@ -0,0 +1,249 @@
package game_results_package_service
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"os"
"strings"
"testing"
"evening_detective_server/internal/repos/game_results_repo"
"evening_detective_server/internal/repos/game_returns_repo"
"evening_detective_server/internal/services/game_package_service"
)
func marshalTest(t *testing.T, value any) []byte {
t.Helper()
raw, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
return raw
}
func TestReturnMappingPreservesOnlineActorsAndMaterial(t *testing.T) {
for _, name := range []string{"actors and nullable scores"} {
t.Run(name, func(t *testing.T) {
input := sampleData("online")
source := input.Source.Materials[0]
onlineMaterial := source
onlineMaterial.Image = "original.pdf"
online := *input
online.Teams = append([]Team(nil), input.Teams...)
owner := 44
online.Teams[0].CreatorID = &owner
online.Tickets = append([]Ticket(nil), input.Tickets...)
online.Tickets[0].UsedBy = &owner
current := &game_results_repo.Snapshot{Data: marshalTest(t, online)}
ids := newMappings()
ids.Tickets[31] = 300
ids.Actions[1] = 100
ids.Actions[2] = 101
ids.Applications[1] = 200
// Assign every application regardless of the sample's local ID.
for _, a := range input.Applications {
ids.Applications[a.ID] = 200
}
mapped, err := mapReturnData(input, current, map[Material]Material{source: onlineMaterial}, &ids)
if err != nil {
t.Fatal(err)
}
if *mapped.Teams[0].CreatorID != owner || mapped.Teams[1].CreatorID != nil || mapped.Teams[1].LocalCreatorID != nil || mapped.Teams[1].ID != input.Teams[1].ID || mapped.Teams[1].Password != input.Teams[1].Password {
t.Fatal("team identity lost")
}
if *mapped.Tickets[0].UsedBy != owner || mapped.Tickets[1].UsedBy != nil || mapped.Tickets[1].LocalUsedBy != nil || mapped.Tickets[1].ID != 300 {
t.Fatal("ticket actors mixed")
}
if mapped.Applications[0].Image != "original.pdf" || mapped.Answers[0].Score == nil || *mapped.Answers[0].Score != 0 || mapped.Answers[1].Score != nil {
t.Fatal("material or nullable score lost")
}
if input.Applications[0].Image != source.Image {
t.Fatal("input mutated")
}
actorData := localActors(input)
if len(actorData.Creators) != 1 || len(actorData.TicketUsers) != 2 {
t.Fatal("local actors missing from registry")
}
})
}
}
func TestRepeatReturnRejectsChangedBytes(t *testing.T) {
for _, tc := range []struct {
name, digest string
conflict bool
}{{"same bytes", "same", false}, {"different bytes", "different", true}} {
t.Run(tc.name, func(t *testing.T) {
saved := &game_returns_repo.Return{SHA256: "same"}
result, err := repeatReturn(saved, tc.digest)
if errors.Is(err, ErrReturnConflict) != tc.conflict || (!tc.conflict && result != saved) {
t.Fatal(err)
}
})
}
}
func TestApplyModeAndSafeErrors(t *testing.T) {
for _, mode := range []string{"venue", "", "unknown"} {
t.Run(mode, func(t *testing.T) {
_, err := NewGameResultsApplyService(nil, nil, mode, "").Apply(context.Background(), nil, 0, 0)
if !errors.Is(err, ErrReturnNotAvailable) {
t.Fatal(err)
}
})
}
for _, name := range []string{"internal cause hidden"} {
t.Run(name, func(t *testing.T) {
cause := errors.New("SQL private password token")
err := &returnFailure{cause: cause}
if !errors.Is(err, ErrReturnFailed) || !errors.Is(err, cause) || strings.Contains(err.Error(), "SQL") {
t.Fatal(err)
}
})
}
}
func TestSafeReturnErrorHidesInternalDetails(t *testing.T) {
for _, tc := range []struct {
name string
cause error
expected error
}{
{"invalid reader", errors.Join(ErrInvalidPackage, errors.New("private path and password")), ErrInvalidPackage},
{"unknown reader", errors.New("private path and password"), ErrReturnFailed},
{"unsupported", ErrUnsupportedVersion, ErrUnsupportedVersion},
{"limit", ErrLimitExceeded, ErrLimitExceeded},
} {
t.Run(tc.name, func(t *testing.T) {
err := safeReturnError(context.Background(), tc.cause)
if !errors.Is(err, tc.expected) || strings.Contains(err.Error(), "private") {
t.Fatal(err)
}
})
}
for _, name := range []string{"cancelled"} {
t.Run(name, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := safeReturnError(ctx, errors.New("private")); !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
})
}
}
func TestOriginalMaterialsRestoresSavedURLs(t *testing.T) {
for _, link := range []string{"saved.pdf", "https://example.invalid/clue.pdf"} {
t.Run(link, func(t *testing.T) {
saved := storedSample(t)
var portable, online game_package_service.Data
if err := json.Unmarshal(saved.OriginalData, &portable); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(saved.OriginalData, &online); err != nil {
t.Fatal(err)
}
online.Scenario.Story.Places[0].Applications[0].Image = link
materials, err := originalMaterials(&portable, &online)
if err != nil {
t.Fatal(err)
}
source, err := sourceFromOriginal(&portable)
if err != nil {
t.Fatal(err)
}
restored := materials[source.Materials[0]]
if restored.Image != link || restored.FileType != "pdf" {
t.Fatal("saved URL/type lost")
}
})
}
}
type inspectedResultReader struct {
reader io.ReaderAt
inspect func()
fail error
}
func (r *inspectedResultReader) ReadAt(raw []byte, offset int64) (int, error) {
if r.inspect != nil {
r.inspect()
r.inspect = nil
}
if r.fail != nil {
return 0, r.fail
}
return r.reader.ReadAt(raw, offset)
}
func TestResultStagingIsPrivateAndAlwaysRemoved(t *testing.T) {
readerFailure := errors.New("private reader failure")
for _, tc := range []struct {
name string
malformed, cancelled bool
failure, expected error
}{
{"success", false, false, nil, nil},
{"invalid ZIP", true, false, nil, ErrInvalidPackage},
{"reader failure", false, false, readerFailure, readerFailure},
{"cancelled", false, true, nil, context.Canceled},
} {
t.Run(tc.name, func(t *testing.T) {
directory := t.TempDir()
t.Setenv("TMPDIR", directory)
var output bytes.Buffer
if err := Pack(context.Background(), sampleManifest(), sampleData("online"), &output); err != nil {
t.Fatal(err)
}
raw := output.Bytes()
if tc.malformed {
raw = []byte("invalid ZIP")
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if tc.cancelled {
cancel()
}
reader := &inspectedResultReader{
reader: bytes.NewReader(raw),
fail: tc.failure,
inspect: func() {
files, err := os.ReadDir(directory)
if err != nil {
t.Fatal(err)
}
if len(files) != 1 {
t.Fatal("staging file missing")
}
info, err := files[0].Info()
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0600 {
t.Fatal("staging permissions too broad")
}
},
}
bundle, digest, err := readResultInput(ctx, reader, int64(len(raw)))
if !errors.Is(err, tc.expected) {
t.Fatal(err)
}
files, readErr := os.ReadDir(directory)
if readErr != nil || len(files) != 0 {
t.Fatal("staging file leaked")
}
if err == nil {
hash := sha256.Sum256(raw)
if bundle.Data.Game.ID != 1 || digest != hex.EncodeToString(hash[:]) {
t.Fatal("staged payload/digest mismatch")
}
}
})
}
}
@@ -0,0 +1,73 @@
package game_results_package_service
import (
"encoding/json"
"reflect"
"evening_detective_server/internal/modules/cleaner"
"evening_detective_server/internal/modules/file_storage"
"evening_detective_server/internal/services/game_package_service"
)
func sameJSON(a, b any) bool {
left, err := json.Marshal(a)
if err != nil {
return false
}
right, err := json.Marshal(b)
if err != nil {
return false
}
var x, y any
if json.Unmarshal(left, &x) != nil || json.Unmarshal(right, &y) != nil {
return false
}
return reflect.DeepEqual(x, y)
}
func sameRawJSON(a, b []byte) bool {
var x, y any
if json.Unmarshal(a, &x) != nil || json.Unmarshal(b, &y) != nil {
return false
}
return reflect.DeepEqual(x, y)
}
// originalMaterials pairs portable evidence with the immutable online export snapshot.
func originalMaterials(portable, online *game_package_service.Data) (map[Material]Material, error) {
result := map[Material]Material{}
if portable.Scenario.Story == nil || online.Scenario.Story == nil || len(portable.Scenario.Story.Places) != len(online.Scenario.Story.Places) {
return nil, ErrSourceMismatch
}
clean := cleaner.NewCleaner()
for i, place := range portable.Scenario.Story.Places {
other := online.Scenario.Story.Places[i]
if place == nil || other == nil || len(place.Applications) != len(other.Applications) {
return nil, ErrSourceMismatch
}
for j, application := range place.Applications {
original := other.Applications[j]
if application == nil || original == nil {
return nil, ErrSourceMismatch
}
from := Material{
Name: clean.ClearText(application.Name),
Image: application.Image,
FileType: application.FileType,
}
if from.FileType == "" {
from.FileType = file_storage.FileType(from.Image)
}
to := Material{
Name: clean.ClearText(original.Name),
Image: original.Image,
FileType: from.FileType,
}
if previous, ok := result[from]; ok && previous != to {
return nil, ErrSourceMismatch
}
result[from] = to
}
}
return result, nil
}
@@ -0,0 +1,143 @@
package game_results_package_service
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strconv"
"testing"
"time"
"evening_detective_server/internal/modules/file_storage"
"evening_detective_server/internal/repos/game_returns_repo"
"evening_detective_server/internal/repos/games_repo"
"evening_detective_server/internal/repos/team_answers_repo"
"evening_detective_server/internal/services/game_package_service"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// TestProdFlowReturns applies the actual venue ZIP to the isolated prod DB and private RustFS.
func TestProdFlowReturns(t *testing.T) {
for _, name := range []string{"real result archive and restart repeats"} {
t.Run(name, func(t *testing.T) {
idText := os.Getenv("ED029_RETURN_GAME_ID")
if idText == "" {
t.Skip("run python3 tests/venue/flow.py --apply")
}
gameID, err := strconv.Atoi(idText)
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, os.Getenv("TEST_DATABASE_URL"))
if err != nil {
t.Fatal(err)
}
defer pool.Close()
storage, err := file_storage.NewPrivateRustFSStorage(ctx, os.Getenv("ED029_RETURN_S3"), "test", "test-only-password", "test-game-exports")
if err != nil {
t.Fatal(err)
}
repo := game_returns_repo.NewGameReturnsRepo(pool)
service := NewGameResultsApplyService(repo, storage, "prod", os.Getenv("ED029_RETURN_SOURCE"))
directory := os.Getenv("ED029_RESULTS_DIR")
raw, err := os.ReadFile(filepath.Join(directory, "results-"+idText+".zip"))
if err != nil {
t.Fatal(err)
}
bundle, err := Unpack(ctx, bytes.NewReader(raw), int64(len(raw)))
if err != nil {
t.Fatal(err)
}
previous, err := repo.FindReturn(ctx, bundle.Manifest.PackageID)
if errors.Is(err, pgx.ErrNoRows) {
export, err := repo.ReadExport(ctx, bundle.Manifest.SourcePackageID)
if err != nil {
t.Fatal(err)
}
var original game_package_service.Data
if err := json.Unmarshal(export.Snapshot, &original); err != nil {
t.Fatal(err)
}
games := games_repo.NewGamesRepo(pool)
if err := games.UpdateGame(ctx, gameID, "TEST Online title after export", "TEST Online description", nil, time.Now().Add(72*time.Hour), int32(original.Game.ScenarioID), original.Game.Format); err != nil {
t.Fatal(err)
}
} else if err != nil {
t.Fatal(err)
}
applied, err := service.Apply(ctx, bytes.NewReader(raw), int64(len(raw)), 0)
if err != nil {
t.Fatal(err)
}
if previous != nil && !previous.AppliedAt.Equal(applied.AppliedAt) {
t.Fatal("restart changed repeat")
}
correctedName := filepath.Join(directory, "corrected-"+idText+".zip")
corrected, err := os.ReadFile(correctedName)
if errors.Is(err, os.ErrNotExist) {
zero := 0
found := false
for i := range bundle.Data.Answers {
if bundle.Data.Answers[i].Score != nil && *bundle.Data.Answers[i].Score == 5 {
bundle.Data.Answers[i].Score = &zero
found = true
break
}
}
if !found {
t.Fatal("scored venue answer missing")
}
var output bytes.Buffer
if err := Pack(ctx, bundle.Manifest, &bundle.Data, &output); err != nil {
t.Fatal(err)
}
corrected = output.Bytes()
if err := os.WriteFile(correctedName, corrected, 0600); err != nil {
t.Fatal(err)
}
} else if err != nil {
t.Fatal(err)
}
newer, err := service.Apply(ctx, bytes.NewReader(corrected), int64(len(corrected)), 0)
if err != nil {
t.Fatal(err)
}
if !newer.PreparedAt.After(applied.PreparedAt) {
t.Fatal("correction did not follow original")
}
repeated, err := service.Apply(ctx, bytes.NewReader(raw), int64(len(raw)), 0)
if err != nil {
t.Fatal(err)
}
if !repeated.AppliedAt.Equal(applied.AppliedAt) {
t.Fatal("old package reapplied")
}
games := games_repo.NewGamesRepo(pool)
game, err := games.GetGameByID(ctx, gameID)
if err != nil {
t.Fatal(err)
}
if game.Status != "finish" || game.Export != nil || game.Name != "TEST Online title after export" {
t.Fatal("status/export/presentation wrong")
}
answers, err := team_answers_repo.NewTeamAnswersRepo(pool).GetAnswersByGameID(ctx, gameID)
if err != nil {
t.Fatal(err)
}
zeroFound := false
for _, answer := range answers {
if answer.Score != nil && *answer.Score == 0 {
zeroFound = true
}
}
if !zeroFound {
t.Fatal("corrected zero score lost")
}
})
}
}
@@ -31,46 +31,12 @@ func prepareData(snapshot *game_results_repo.Snapshot) (*Data, error) {
if snapshot.ScenarioID != snapshot.ImportedScenarioID || original.Game.ID != data.Game.ID || original.Game.Format != data.Game.Format || original.Scenario.Story == nil {
return nil, ErrScenarioChanged
}
sourceStory, err := json.Marshal(original.Scenario.Story)
source, err := sourceFromOriginal(&original)
if err != nil {
return nil, err
}
data.Source = Source{
ScenarioID: original.Scenario.ID,
ScenarioSHA256: digest(sourceStory),
Teams: original.Teams,
Tickets: original.Tickets,
Materials: []Material{},
QuestionCodes: []string{},
}
textCleaner := cleaner.NewCleaner()
for _, place := range original.Scenario.Story.Places {
if place == nil {
return nil, ErrScenarioChanged
}
for _, material := range place.Applications {
if material == nil {
return nil, ErrScenarioChanged
}
portable := Material{
Name: textCleaner.ClearText(material.Name),
Image: material.Image,
FileType: material.FileType,
}
if portable.FileType == "" {
portable.FileType = file_storage.FileType(portable.Image)
}
if !containsMaterial(data.Source.Materials, portable) {
data.Source.Materials = append(data.Source.Materials, portable)
}
}
}
for _, question := range original.Scenario.Story.Questions {
if question == nil {
return nil, ErrScenarioChanged
}
data.Source.QuestionCodes = append(data.Source.QuestionCodes, question.Code)
}
data.Source = source
names := map[string]string{}
for _, file := range manifest.Files {
if file.Path != "game.json" {
@@ -163,3 +129,47 @@ func containsMaterial(materials []Material, candidate Material) bool {
}
return false
}
func sourceFromOriginal(original *game_package_service.Data) (Source, error) {
sourceStory, err := json.Marshal(original.Scenario.Story)
if err != nil {
return Source{}, err
}
source := Source{
ScenarioID: original.Scenario.ID,
ScenarioSHA256: digest(sourceStory),
Teams: original.Teams,
Tickets: original.Tickets,
Materials: []Material{},
QuestionCodes: []string{},
}
textCleaner := cleaner.NewCleaner()
for _, place := range original.Scenario.Story.Places {
if place == nil {
return Source{}, ErrScenarioChanged
}
for _, material := range place.Applications {
if material == nil {
return Source{}, ErrScenarioChanged
}
portable := Material{
Name: textCleaner.ClearText(material.Name),
Image: material.Image,
FileType: material.FileType,
}
if portable.FileType == "" {
portable.FileType = file_storage.FileType(portable.Image)
}
if !containsMaterial(source.Materials, portable) {
source.Materials = append(source.Materials, portable)
}
}
}
for _, question := range original.Scenario.Story.Questions {
if question == nil {
return Source{}, ErrScenarioChanged
}
source.QuestionCodes = append(source.QuestionCodes, question.Code)
}
return source, nil
}
@@ -46,6 +46,7 @@ func TestServicesLifecyclePostgres(t *testing.T) {
defer pool.Close()
_, 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 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);
@@ -30,6 +30,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 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);