package main import ( "encoding/json" "os" "path/filepath" "testing" ) // config.json is the only record of WHERE the database is. Losing it moves an // operator's whole station back to an empty default, and it has happened twice. // These are the two ways it was lost. // A half-written file must never be publishable. os.WriteFile truncates and // then fills, so a process that stops in between leaves an empty pointer; the // rename cannot. func TestWriteBootstrapIsAtomicAndKeepsABackup(t *testing.T) { dir := t.TempDir() // Absolute and OUTSIDE the application folder, so portablePath stores them // verbatim — the round trip is what is under test, not the re-rooting. first := filepath.Join(t.TempDir(), "first", "one.db") second := filepath.Join(t.TempDir(), "second", "two.db") if err := writeBootstrap(dir, dbPointer{DBPath: first}); err != nil { t.Fatal(err) } if err := writeBootstrap(dir, dbPointer{DBPath: second}); err != nil { t.Fatal(err) } // No temporary left lying around to be mistaken for the real thing. if _, err := os.Stat(dbPointerPath(dir) + ".tmp"); err == nil { t.Error("the temporary file was left behind") } // The previous contents are still there. var prev dbPointer b, err := os.ReadFile(dbPointerPath(dir) + ".bak") if err != nil { t.Fatalf("no backup was kept: %v", err) } if err := json.Unmarshal(b, &prev); err != nil { t.Fatalf("the backup does not parse: %v", err) } if prev.DBPath != first { t.Errorf("the backup holds %q, want the previous pointer", prev.DBPath) } if got := readBootstrap(dir); got.DBPath != second { t.Errorf("read back %q, want the current pointer", got.DBPath) } } // A pointer that EXISTS and cannot be read is not the same thing as no pointer. // Treating it as one is what opened an empty database and presented an operator // with a program that had forgotten them. func TestReadBootstrapRecoversFromABrokenPointer(t *testing.T) { dir := t.TempDir() mine := filepath.Join(t.TempDir(), "mine", "station.db") if err := writeBootstrap(dir, dbPointer{DBPath: mine}); err != nil { t.Fatal(err) } // Two writes, so there is a backup of the good one to fall back to. if err := writeBootstrap(dir, dbPointer{DBPath: mine}); err != nil { t.Fatal(err) } // Now truncate it, exactly as an interrupted write would. if err := os.WriteFile(dbPointerPath(dir), nil, 0o644); err != nil { t.Fatal(err) } got := readBootstrap(dir) if got.DBPath != mine { t.Fatalf("read %q — the database the operator chose was lost", got.DBPath) } // And it is put back, so the next launch does not have to recover again. if again := readBootstrap(dir); again.DBPath != mine { t.Errorf("the restored pointer did not stick: %q", again.DBPath) } } // With nothing to restore from, the broken file is KEPT. It is evidence, and it // may still be readable by hand. func TestReadBootstrapKeepsAnUnrecoverablePointer(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(dbPointerPath(dir), []byte("{oops"), 0o644); err != nil { t.Fatal(err) } if got := readBootstrap(dir); got.DBPath != "" { t.Errorf("invented a path out of a broken file: %q", got.DBPath) } if _, err := os.Stat(dbPointerPath(dir) + ".broken"); err != nil { t.Errorf("the broken pointer was not kept: %v", err) } } // The warning that turns a silent loss into a sentence: a new empty database // about to be created in a folder that already holds a full one. func TestOtherDatabasesInSpotsTheFullOneNextDoor(t *testing.T) { dir := t.TempDir() full := filepath.Join(dir, "opslog.db") if err := os.WriteFile(full, []byte("not empty"), 0o644); err != nil { t.Fatal(err) } chosen := filepath.Join(dir, "settings.db") if got := otherDatabasesIn(dir, chosen); len(got) != 1 || got[0] != "opslog.db" { t.Errorf("got %v, want the full database next door", got) } // A zero-byte file is not a lost configuration. if err := os.WriteFile(full, nil, 0o644); err != nil { t.Fatal(err) } if got := otherDatabasesIn(dir, chosen); len(got) != 0 { t.Errorf("an empty file was reported as a database: %v", got) } // And the one being opened is never reported against itself. if err := os.WriteFile(chosen, []byte("in use"), 0o644); err != nil { t.Fatal(err) } if got := otherDatabasesIn(dir, chosen); len(got) != 0 { t.Errorf("the chosen database was reported as another: %v", got) } }