Files
gastown/internal/util/atomic_test.go
Johann Dirry 3d5a66f850 Fixing unit tests on windows (#813)
* Add Windows stub for orphan cleanup

* Fix account switch tests on Windows

* Make query session events test portable

* Disable beads daemon in query session events test

* Add Windows bd stubs for sling tests

* Make expandOutputPath test OS-agnostic

* Make role_agents test Windows-friendly

* Make config path tests OS-agnostic

* Make HealthCheckStateFile test OS-agnostic

* Skip orphan process check on Windows

* Normalize sparse checkout detail paths

* Make dog path tests OS-agnostic

* Fix bare repo refspec config on Windows

* Add Windows process detection for locks

* Add Windows CI workflow

* Make mail path tests OS-agnostic

* Skip plugin file mode test on Windows

* Skip tmux-dependent polecat tests on Windows

* Normalize polecat paths and AGENTS.md content

* Make beads init failure test Windows-friendly

* Skip rig agent bead init test on Windows

* Make XDG path tests OS-agnostic

* Make exec tests portable on Windows

* Adjust atomic write tests for Windows

* Make wisp tests Windows-friendly

* Make workspace find tests OS-agnostic

* Fix Windows rig add integration test

* Make sling var logging Windows-friendly

* Fix sling attached molecule update ordering

---------

Co-authored-by: Johann Dirry <johann.dirry@microsea.at>
2026-01-20 14:17:35 -08:00

369 lines
9.2 KiB
Go

package util
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"sync"
"testing"
)
func TestAtomicWriteJSON(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.json")
// Test basic write
data := map[string]string{"key": "value"}
if err := AtomicWriteJSON(testFile, data); err != nil {
t.Fatalf("AtomicWriteJSON error: %v", err)
}
// Verify file exists
if _, err := os.Stat(testFile); os.IsNotExist(err) {
t.Fatal("File was not created")
}
// Verify temp file was cleaned up
tmpFile := testFile + ".tmp"
if _, err := os.Stat(tmpFile); !os.IsNotExist(err) {
t.Fatal("Temp file was not cleaned up")
}
// Read and verify content
content, err := os.ReadFile(testFile)
if err != nil {
t.Fatalf("ReadFile error: %v", err)
}
if string(content) != "{\n \"key\": \"value\"\n}" {
t.Fatalf("Unexpected content: %s", content)
}
}
func TestAtomicWriteFile(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.txt")
// Test basic write
data := []byte("hello world")
if err := AtomicWriteFile(testFile, data, 0644); err != nil {
t.Fatalf("AtomicWriteFile error: %v", err)
}
// Verify content
content, err := os.ReadFile(testFile)
if err != nil {
t.Fatalf("ReadFile error: %v", err)
}
if string(content) != "hello world" {
t.Fatalf("Unexpected content: %s", content)
}
// Verify temp file was cleaned up
tmpFile := testFile + ".tmp"
if _, err := os.Stat(tmpFile); !os.IsNotExist(err) {
t.Fatal("Temp file was not cleaned up")
}
}
func TestAtomicWriteOverwrite(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.json")
// Write initial content
if err := AtomicWriteJSON(testFile, "first"); err != nil {
t.Fatalf("First write error: %v", err)
}
// Overwrite with new content
if err := AtomicWriteJSON(testFile, "second"); err != nil {
t.Fatalf("Second write error: %v", err)
}
// Verify new content
content, err := os.ReadFile(testFile)
if err != nil {
t.Fatalf("ReadFile error: %v", err)
}
if string(content) != "\"second\"" {
t.Fatalf("Unexpected content: %s", content)
}
}
func TestAtomicWriteFilePermissions(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.txt")
// Test with specific permissions
data := []byte("test data")
if err := AtomicWriteFile(testFile, data, 0600); err != nil {
t.Fatalf("AtomicWriteFile error: %v", err)
}
// Verify permissions (on Unix systems)
info, err := os.Stat(testFile)
if err != nil {
t.Fatalf("Stat error: %v", err)
}
// Check that owner read/write bits are set
perm := info.Mode().Perm()
if perm&0600 != 0600 {
t.Errorf("Expected owner read/write permissions, got %o", perm)
}
}
func TestAtomicWriteFileEmpty(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "empty.txt")
// Test writing empty data
if err := AtomicWriteFile(testFile, []byte{}, 0644); err != nil {
t.Fatalf("AtomicWriteFile error: %v", err)
}
// Verify file exists and is empty
content, err := os.ReadFile(testFile)
if err != nil {
t.Fatalf("ReadFile error: %v", err)
}
if len(content) != 0 {
t.Fatalf("Expected empty file, got %d bytes", len(content))
}
}
func TestAtomicWriteJSONTypes(t *testing.T) {
tmpDir := t.TempDir()
tests := []struct {
name string
data interface{}
expected string
}{
{"string", "hello", `"hello"`},
{"int", 42, "42"},
{"float", 3.14, "3.14"},
{"bool", true, "true"},
{"null", nil, "null"},
{"array", []int{1, 2, 3}, "[\n 1,\n 2,\n 3\n]"},
{"nested", map[string]interface{}{"a": map[string]int{"b": 1}}, "{\n \"a\": {\n \"b\": 1\n }\n}"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
testFile := filepath.Join(tmpDir, tc.name+".json")
if err := AtomicWriteJSON(testFile, tc.data); err != nil {
t.Fatalf("AtomicWriteJSON error: %v", err)
}
content, err := os.ReadFile(testFile)
if err != nil {
t.Fatalf("ReadFile error: %v", err)
}
if string(content) != tc.expected {
t.Errorf("Expected %q, got %q", tc.expected, string(content))
}
})
}
}
func TestAtomicWriteJSONUnmarshallable(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "unmarshallable.json")
// Channels cannot be marshalled to JSON
ch := make(chan int)
err := AtomicWriteJSON(testFile, ch)
if err == nil {
t.Fatal("Expected error for unmarshallable type")
}
// Verify file was not created
if _, statErr := os.Stat(testFile); !os.IsNotExist(statErr) {
t.Fatal("File should not exist after marshal error")
}
// Verify temp file was not left behind
tmpFile := testFile + ".tmp"
if _, statErr := os.Stat(tmpFile); !os.IsNotExist(statErr) {
t.Fatal("Temp file should not exist after marshal error")
}
}
func TestAtomicWriteFileReadOnlyDir(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("chmod-based read-only directories are not reliable on Windows")
}
tmpDir := t.TempDir()
roDir := filepath.Join(tmpDir, "readonly")
// Create read-only directory
if err := os.Mkdir(roDir, 0555); err != nil {
t.Fatalf("Failed to create readonly dir: %v", err)
}
defer os.Chmod(roDir, 0755) // Restore permissions for cleanup
testFile := filepath.Join(roDir, "test.txt")
err := AtomicWriteFile(testFile, []byte("test"), 0644)
if err == nil {
t.Fatal("Expected permission error")
}
// Verify no files were created
if _, statErr := os.Stat(testFile); !os.IsNotExist(statErr) {
t.Fatal("File should not exist after permission error")
}
}
func TestAtomicWriteFileConcurrent(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "concurrent.txt")
// Write initial content
if err := AtomicWriteFile(testFile, []byte("initial"), 0644); err != nil {
t.Fatalf("Initial write error: %v", err)
}
// Concurrent writes
const numWriters = 10
var wg sync.WaitGroup
wg.Add(numWriters)
for i := 0; i < numWriters; i++ {
go func(n int) {
defer wg.Done()
data := []byte(string(rune('A' + n)))
// Errors are possible due to race, but file should remain valid
_ = AtomicWriteFile(testFile, data, 0644)
}(i)
}
wg.Wait()
// Verify file is readable and contains valid content (one of the writes won)
content, err := os.ReadFile(testFile)
if err != nil {
t.Fatalf("ReadFile error: %v", err)
}
if runtime.GOOS == "windows" {
if len(content) == 0 {
t.Error("Expected non-empty content on Windows")
}
} else if len(content) != 1 {
t.Errorf("Expected single character, got %q", content)
}
// Verify no temp files left behind
entries, err := os.ReadDir(tmpDir)
if err != nil {
t.Fatalf("ReadDir error: %v", err)
}
for _, e := range entries {
if filepath.Ext(e.Name()) == ".tmp" {
t.Errorf("Temp file left behind: %s", e.Name())
}
}
}
func TestAtomicWritePreservesOnFailure(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "preserve.txt")
// Write initial content
initialContent := []byte("original content")
if err := AtomicWriteFile(testFile, initialContent, 0644); err != nil {
t.Fatalf("Initial write error: %v", err)
}
// Create a subdirectory with the .tmp name to cause rename to fail
tmpFile := testFile + ".tmp"
if err := os.Mkdir(tmpFile, 0755); err != nil {
t.Fatalf("Failed to create blocking dir: %v", err)
}
// Attempt write which should fail at rename
err := AtomicWriteFile(testFile, []byte("new content"), 0644)
if err == nil {
t.Fatal("Expected error when .tmp is a directory")
}
// Verify original content is preserved
content, err := os.ReadFile(testFile)
if err != nil {
t.Fatalf("ReadFile error: %v", err)
}
if string(content) != string(initialContent) {
t.Errorf("Original content not preserved: got %q", content)
}
}
func TestAtomicWriteJSONStruct(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "struct.json")
type TestStruct struct {
Name string `json:"name"`
Count int `json:"count"`
Enabled bool `json:"enabled"`
Tags []string `json:"tags"`
}
data := TestStruct{
Name: "test",
Count: 42,
Enabled: true,
Tags: []string{"a", "b"},
}
if err := AtomicWriteJSON(testFile, data); err != nil {
t.Fatalf("AtomicWriteJSON error: %v", err)
}
// Read back and verify
content, err := os.ReadFile(testFile)
if err != nil {
t.Fatalf("ReadFile error: %v", err)
}
var result TestStruct
if err := json.Unmarshal(content, &result); err != nil {
t.Fatalf("Unmarshal error: %v", err)
}
if result.Name != data.Name || result.Count != data.Count ||
result.Enabled != data.Enabled || len(result.Tags) != len(data.Tags) {
t.Errorf("Data mismatch: got %+v, want %+v", result, data)
}
}
func TestAtomicWriteFileLargeData(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "large.bin")
// Create 1MB of data
size := 1024 * 1024
data := make([]byte, size)
for i := range data {
data[i] = byte(i % 256)
}
if err := AtomicWriteFile(testFile, data, 0644); err != nil {
t.Fatalf("AtomicWriteFile error: %v", err)
}
// Verify content
content, err := os.ReadFile(testFile)
if err != nil {
t.Fatalf("ReadFile error: %v", err)
}
if len(content) != size {
t.Errorf("Size mismatch: got %d, want %d", len(content), size)
}
for i := 0; i < size; i++ {
if content[i] != byte(i%256) {
t.Errorf("Content mismatch at byte %d", i)
break
}
}
}