generated from VLADIMIR/template
up
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user