Files
galaxy-game/pkg/storage/fs/fs_test.go
T
2026-05-09 10:36:44 +02:00

617 lines
16 KiB
Go

package fs
import (
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"sync/atomic"
"testing"
"time"
gerr "galaxy/error"
"galaxy/model/client"
"galaxy/model/order"
"galaxy/model/report"
"github.com/google/uuid"
)
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.UserGamesOrder], 1)
s.LoadOrderAsync(id, turn, func(got order.UserGamesOrder, err error) {
loadOrderDone <- callbackResult[order.UserGamesOrder]{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")
}
wantMissingPath := filepath.Join(s.storageRoot, "missing.txt")
if wantMissingPath != missingPath {
t.Fatalf("missing file path = %q, want %q", missingPath, wantMissingPath)
}
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)
}
if !gerr.IsStorage(err) {
t.Fatalf("write %q error = %v, want storage classified error", path, err)
}
})
}
}
func TestDeleteFileClassifiesAndPreservesNotExist(t *testing.T) {
t.Parallel()
s := newTestStorage(t)
err := s.DeleteFile("missing.txt")
if !gerr.IsStorage(err) {
t.Fatalf("DeleteFile() error = %v, want storage classified error", err)
}
if !errors.Is(err, os.ErrNotExist) {
t.Fatalf("DeleteFile() error = %v, want os.ErrNotExist", err)
}
}
func TestLoadStateClassifiesDecodeErrors(t *testing.T) {
t.Parallel()
s := newTestStorage(t)
if err := os.WriteFile(filepath.Join(s.storageRoot, stateFileName), []byte("{"), 0o644); err != nil {
t.Fatalf("seed invalid state file: %v", err)
}
_, err := s.LoadState()
if err == nil {
t.Fatal("LoadState() error = nil, want non-nil")
}
if !gerr.IsStorage(err) {
t.Fatalf("LoadState() error = %v, want storage classified error", err)
}
}
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: new(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.UserGamesOrder {
return order.UserGamesOrder{
GameID: uuid.New(),
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]{}
}
}