package fs import ( "errors" "os" "path/filepath" "reflect" "strings" "sync/atomic" "testing" "time" "galaxy/model/client" "galaxy/model/order" "galaxy/model/report" ) const testTimeout = time.Second type callbackResult[T any] struct { value T err error } func TestStateRoundTrip(t *testing.T) { s := newTestStorage(t) want := sampleState() exists, err := s.StateExists() if err != nil { t.Fatalf("state exists before save: %v", err) } if exists { t.Fatal("state file should not exist before save") } if err := s.SaveState(want); err != nil { t.Fatalf("save state: %v", err) } exists, err = s.StateExists() if err != nil { t.Fatalf("state exists after save: %v", err) } if !exists { t.Fatal("state file should exist after save") } got, err := s.LoadState() if err != nil { t.Fatalf("load state: %v", err) } if !reflect.DeepEqual(got, want) { t.Fatalf("loaded state mismatch\nwant: %#v\ngot: %#v", want, got) } } func TestStateRoundTripAsync(t *testing.T) { s := newTestStorage(t) want := sampleState() saveDone := make(chan error, 1) s.SaveStateAsync(want, func(err error) { saveDone <- err }) if err := waitError(t, saveDone); err != nil { t.Fatalf("save state: %v", err) } existsDone := make(chan callbackResult[bool], 1) s.StateExistsAsync(func(ok bool, err error) { existsDone <- callbackResult[bool]{value: ok, err: err} }) exists := waitResult(t, existsDone) if exists.err != nil { t.Fatalf("state exists: %v", exists.err) } if !exists.value { t.Fatal("state file should exist after save") } loadDone := make(chan callbackResult[client.State], 1) s.LoadStateAsync(func(state client.State, err error) { loadDone <- callbackResult[client.State]{value: state, err: err} }) got := waitResult(t, loadDone) if got.err != nil { t.Fatalf("load state: %v", got.err) } if !reflect.DeepEqual(got.value, want) { t.Fatalf("loaded state mismatch\nwant: %#v\ngot: %#v", want, got.value) } } func TestReportAndOrderRoundTripAsync(t *testing.T) { s := newTestStorage(t) id := client.GameID("game-1") turn := uint(7) initialReport := sampleReport(turn, "Terran") updatedReport := sampleReport(turn, "Zenith") wantOrder := sampleOrder() saveReportDone := make(chan error, 1) s.SaveReportAsync(id, turn, initialReport, func(err error) { saveReportDone <- err }) if err := waitError(t, saveReportDone); err != nil { t.Fatalf("save report: %v", err) } saveOrderDone := make(chan error, 1) s.SaveOrderAsync(id, turn, wantOrder, func(err error) { saveOrderDone <- err }) if err := waitError(t, saveOrderDone); err != nil { t.Fatalf("save order: %v", err) } saveUpdatedReportDone := make(chan error, 1) s.SaveReportAsync(id, turn, updatedReport, func(err error) { saveUpdatedReportDone <- err }) if err := waitError(t, saveUpdatedReportDone); err != nil { t.Fatalf("save updated report: %v", err) } loadReportDone := make(chan callbackResult[report.Report], 1) s.LoadReportAsync(id, turn, func(rep report.Report, err error) { loadReportDone <- callbackResult[report.Report]{value: rep, err: err} }) gotReport := waitResult(t, loadReportDone) if gotReport.err != nil { t.Fatalf("load report: %v", gotReport.err) } if !reflect.DeepEqual(gotReport.value, updatedReport) { t.Fatalf("loaded report mismatch\nwant: %#v\ngot: %#v", updatedReport, gotReport.value) } loadOrderDone := make(chan callbackResult[order.Order], 1) s.LoadOrderAsync(id, turn, func(got order.Order, err error) { loadOrderDone <- callbackResult[order.Order]{value: got, err: err} }) gotOrder := waitResult(t, loadOrderDone) if gotOrder.err != nil { t.Fatalf("load order: %v", gotOrder.err) } if !reflect.DeepEqual(gotOrder.value, wantOrder) { t.Fatalf("loaded order mismatch\nwant: %#v\ngot: %#v", wantOrder, gotOrder.value) } } func TestSaveOrderBeforeReportReturnsNotExist(t *testing.T) { s := newTestStorage(t) done := make(chan error, 1) s.SaveOrderAsync("game-2", 3, sampleOrder(), func(err error) { done <- err }) err := waitError(t, done) if !errors.Is(err, os.ErrNotExist) { t.Fatalf("save order error = %v, want os.ErrNotExist", err) } } func TestRawFileCRUDAndList(t *testing.T) { s := newTestStorage(t) if err := s.WriteFile("/nested/alpha.txt", []byte("alpha")); err != nil { t.Fatalf("write alpha: %v", err) } if err := s.WriteFile("beta.txt", []byte("beta")); err != nil { t.Fatalf("write beta: %v", err) } alphaExists, alphaPath, err := s.FileExists("nested/alpha.txt") if err != nil { t.Fatalf("file exists: %v", err) } if !alphaExists { t.Fatal("nested/alpha.txt should exist") } wantAlphaPath := filepath.Join(s.storageRoot, "nested", "alpha.txt") if alphaPath != wantAlphaPath { t.Fatalf("file path = %q, want %q", alphaPath, wantAlphaPath) } missingExists, missingPath, err := s.FileExists("missing.txt") if err != nil { t.Fatalf("missing file exists: %v", err) } if missingExists { t.Fatal("missing.txt should not exist") } if missingPath != "" { t.Fatalf("missing file path = %q, want empty string", missingPath) } alphaData, err := s.ReadFile("nested/alpha.txt") if err != nil { t.Fatalf("read alpha: %v", err) } if string(alphaData) != "alpha" { t.Fatalf("read alpha = %q, want %q", alphaData, "alpha") } if err := os.WriteFile(filepath.Join(s.storageRoot, "skip.txt"+newFileSuffix), []byte("tmp"), 0o644); err != nil { t.Fatalf("create stale .new file: %v", err) } if err := os.WriteFile(filepath.Join(s.storageRoot, "skip.txt"+oldFileSuffix), []byte("tmp"), 0o644); err != nil { t.Fatalf("create stale .old file: %v", err) } files, err := s.ListFiles() if err != nil { t.Fatalf("list files: %v", err) } wantFiles := []string{ "beta.txt", filepath.Join("nested", "alpha.txt"), } if !reflect.DeepEqual(files, wantFiles) { t.Fatalf("listed files mismatch\nwant: %#v\ngot: %#v", wantFiles, files) } if err := s.DeleteFile("beta.txt"); err != nil { t.Fatalf("delete beta: %v", err) } if err := s.DeleteFile("beta.txt"); !errors.Is(err, os.ErrNotExist) { t.Fatalf("delete missing beta error = %v, want os.ErrNotExist", err) } } func TestPathTraversalRejected(t *testing.T) { s := newTestStorage(t) for _, path := range []string{"../escape.txt", "..\\escape.txt", ""} { t.Run(path, func(t *testing.T) { err := s.WriteFile(path, []byte("blocked")) if err == nil { t.Fatalf("write %q unexpectedly succeeded", path) } }) } } func TestAtomicWriteFirstAndOverwrite(t *testing.T) { s := newTestStorage(t) target := filepath.Join("turns", "12.bin") if err := s.WriteFile(target, []byte("first")); err != nil { t.Fatalf("first write: %v", err) } assertFileContent(t, s, target, "first") assertNoTempArtifacts(t, s, target) if err := s.WriteFile(target, []byte("second")); err != nil { t.Fatalf("overwrite: %v", err) } assertFileContent(t, s, target, "second") assertNoTempArtifacts(t, s, target) } func TestAtomicWriteStaleTempCollision(t *testing.T) { t.Run("stale new file", func(t *testing.T) { s := newTestStorage(t) target := "collision-new.txt" absTarget, err := s.resolvePath(target) if err != nil { t.Fatalf("resolve target: %v", err) } if err := os.MkdirAll(filepath.Dir(absTarget), os.ModePerm); err != nil { t.Fatalf("create parent dir: %v", err) } if err := os.WriteFile(absTarget+newFileSuffix, []byte("stale"), 0o644); err != nil { t.Fatalf("write stale new file: %v", err) } err = s.WriteFile(target, []byte("payload")) if err == nil || !strings.Contains(err.Error(), "new file already exists") { t.Fatalf("write error = %v, want stale new file error", err) } }) t.Run("stale old file", func(t *testing.T) { s := newTestStorage(t) target := "collision-old.txt" absTarget, err := s.resolvePath(target) if err != nil { t.Fatalf("resolve target: %v", err) } if err := os.WriteFile(absTarget, []byte("current"), 0o644); err != nil { t.Fatalf("write target: %v", err) } if err := os.WriteFile(absTarget+oldFileSuffix, []byte("stale"), 0o644); err != nil { t.Fatalf("write stale old file: %v", err) } err = s.WriteFile(target, []byte("payload")) if err == nil || !strings.Contains(err.Error(), "old file already exists") { t.Fatalf("write error = %v, want stale old file error", err) } }) } func TestAtomicWriteRollbackOnRenameFailure(t *testing.T) { s := newTestStorage(t) target := filepath.Join("rollback", "state.txt") absTarget, err := s.resolvePath(target) if err != nil { t.Fatalf("resolve target: %v", err) } if err := s.WriteFile(target, []byte("original")); err != nil { t.Fatalf("seed target file: %v", err) } origRename := s.renameFileFn s.renameFileFn = func(oldPath, newPath string) error { if oldPath == absTarget+newFileSuffix && newPath == absTarget { return errors.New("forced rename failure") } return origRename(oldPath, newPath) } err = s.WriteFile(target, []byte("replacement")) if err == nil || !strings.Contains(err.Error(), "forced rename failure") { t.Fatalf("write error = %v, want forced rename failure", err) } assertFileContent(t, s, target, "original") assertNoTempArtifacts(t, s, target) } func TestSamePathOperationsSerialize(t *testing.T) { s := newTestStorage(t) target := "shared.txt" absTarget, err := s.resolvePath(target) if err != nil { t.Fatalf("resolve target: %v", err) } entered := make(chan struct{}) release := make(chan struct{}) origWrite := s.writeFileFn var writes atomic.Int32 s.writeFileFn = func(path string, data []byte, perm os.FileMode) error { if path == absTarget+newFileSuffix && writes.Add(1) == 1 { close(entered) <-release } return origWrite(path, data, perm) } firstDone := make(chan error, 1) go func() { firstDone <- s.WriteFile(target, []byte("one")) }() waitSignal(t, entered, "first write entered") secondDone := make(chan error, 1) go func() { secondDone <- s.WriteFile(target, []byte("two")) }() select { case err := <-secondDone: t.Fatalf("second write finished before first released: %v", err) case <-time.After(50 * time.Millisecond): } if writes.Load() != 1 { t.Fatalf("same-path write reached file hook %d times before release, want 1", writes.Load()) } close(release) if err := waitError(t, firstDone); err != nil { t.Fatalf("first write: %v", err) } if err := waitError(t, secondDone); err != nil { t.Fatalf("second write: %v", err) } } func TestDifferentPathOperationsDoNotBlockEachOther(t *testing.T) { s := newTestStorage(t) blockedTarget := "blocked.txt" absTarget, err := s.resolvePath(blockedTarget) if err != nil { t.Fatalf("resolve blocked target: %v", err) } entered := make(chan struct{}) release := make(chan struct{}) origWrite := s.writeFileFn s.writeFileFn = func(path string, data []byte, perm os.FileMode) error { if path == absTarget+newFileSuffix { close(entered) <-release } return origWrite(path, data, perm) } blockedDone := make(chan error, 1) go func() { blockedDone <- s.WriteFile(blockedTarget, []byte("blocked")) }() waitSignal(t, entered, "blocked write entered") freeDone := make(chan error, 1) go func() { freeDone <- s.WriteFile("free.txt", []byte("free")) }() select { case err := <-freeDone: if err != nil { t.Fatalf("free write: %v", err) } case <-time.After(testTimeout): t.Fatal("write for a different path should not block") } close(release) if err := waitError(t, blockedDone); err != nil { t.Fatalf("blocked write: %v", err) } } func TestSaveStateIsNonBlockingAndCallbackBased(t *testing.T) { s := newTestStorage(t) entered := make(chan struct{}) release := make(chan struct{}) origWrite := s.writeFileFn s.writeFileFn = func(path string, data []byte, perm os.FileMode) error { close(entered) <-release return origWrite(path, data, perm) } callbacks := make(chan error, 2) s.SaveStateAsync(sampleState(), func(err error) { callbacks <- err }) waitSignal(t, entered, "async save entered") select { case err := <-callbacks: t.Fatalf("callback fired before storage write completed: %v", err) default: } close(release) if err := waitError(t, callbacks); err != nil { t.Fatalf("callback error: %v", err) } select { case err := <-callbacks: t.Fatalf("callback fired more than once: %v", err) case <-time.After(50 * time.Millisecond): } } func newTestStorage(t *testing.T) *fsStorage { t.Helper() s, err := NewFS(t.TempDir()) if err != nil { t.Fatalf("new test storage: %v", err) } return s } func sampleState() client.State { return client.State{ GameState: []client.GameState{ {ID: client.GameID("game-1"), LastTurn: 12, ActiveTurn: 11}, {ID: client.GameID("game-2"), LastTurn: 4, ActiveTurn: 4}, }, ActiveGameID: client.GameID("game-2"), } } func sampleReport(turn uint, race string) report.Report { return report.Report{ Turn: turn, Width: 160, Height: 90, PlanetCount: 8, Race: race, VoteFor: "assembly", } } func sampleOrder() order.Order { return order.Order{ UpdatedAt: 1700, Commands: []order.DecodableCommand{ &order.CommandPlanetRename{ CommandMeta: order.CommandMeta{ CmdType: order.CommandTypePlanetRename, CmdID: "rename-planet", }, Number: 2, Name: "Nova Prime", }, &order.CommandRaceVote{ CommandMeta: order.CommandMeta{ CmdType: order.CommandTypeRaceVote, CmdID: "vote-race", }, Acceptor: "ZENITH", }, }, } } func assertFileContent(t *testing.T, s *fsStorage, path, want string) { t.Helper() got, err := s.ReadFile(path) if err != nil { t.Fatalf("read %q: %v", path, err) } if string(got) != want { t.Fatalf("content for %q = %q, want %q", path, got, want) } } func assertNoTempArtifacts(t *testing.T, s *fsStorage, path string) { t.Helper() absPath, err := s.resolvePath(path) if err != nil { t.Fatalf("resolve path %q: %v", path, err) } for _, tempPath := range []string{absPath + newFileSuffix, absPath + oldFileSuffix} { if _, err := os.Stat(tempPath); !errors.Is(err, os.ErrNotExist) { t.Fatalf("temp artifact %q should not exist, stat err = %v", tempPath, err) } } } func waitSignal(t *testing.T, ch <-chan struct{}, name string) { t.Helper() select { case <-ch: case <-time.After(testTimeout): t.Fatalf("timeout waiting for %s", name) } } func waitError(t *testing.T, ch <-chan error) error { t.Helper() select { case err := <-ch: return err case <-time.After(testTimeout): t.Fatal("timeout waiting for error callback") return nil } } func waitResult[T any](t *testing.T, ch <-chan callbackResult[T]) callbackResult[T] { t.Helper() select { case result := <-ch: return result case <-time.After(testTimeout): t.Fatal("timeout waiting for callback result") return callbackResult[T]{} } }