golib/database/csvdb/file_append_test.go

230 lines
6.5 KiB
Go

package csvdb
import (
"context"
"os"
"testing"
"github.com/therootcompany/golib/database/rowsync"
)
func insertPlan[T any](key string, rows ...T) rowsync.Plan[T] {
plan := rowsync.Plan[T]{Mode: rowsync.UpdateMode}
for i := range rows {
row := rows[i]
plan.Actions = append(plan.Actions, rowsync.Action[T]{Kind: rowsync.InsertAction, Key: key, After: &row})
plan.Final = append(plan.Final, row)
}
return plan
}
func TestFileAppendFastPathPreservesPrefix(t *testing.T) {
dir := t.TempDir()
root, err := os.OpenRoot(dir)
if err != nil {
t.Fatal(err)
}
defer root.Close()
store := &Store[testRuleRow]{Root: root, Name: "rules.tsv", Format: "tsv"}
ctx := context.Background()
if err := store.WriteAll(ctx, []testRuleRow{{Email: "a@example.test"}}); err != nil {
t.Fatal(err)
}
before, _ := os.ReadFile(dir + "/rules.tsv")
plan := insertPlan[testRuleRow]("b@example.test", testRuleRow{Email: "b@example.test"})
if err := store.Update(ctx, plan); err != nil {
t.Fatal(err)
}
after, _ := os.ReadFile(dir + "/rules.tsv")
if string(after[:len(before)]) != string(before) {
t.Fatal("append fast path changed existing bytes")
}
rows, err := store.Fetch(ctx)
if err != nil || len(rows) != 2 {
t.Fatalf("rows=%d err=%v", len(rows), err)
}
}
func TestFileAppendHeaderMismatchFallsBackToRewrite(t *testing.T) {
dir := t.TempDir()
root, err := os.OpenRoot(dir)
if err != nil {
t.Fatal(err)
}
defer root.Close()
store := &Store[testRuleRow]{Root: root, Name: "rules.tsv", Format: "tsv"}
ctx := context.Background()
// Create a file with a manually-written header that has fewer columns
// than the struct produces. This simulates the production bug: a
// template-derived TSV whose header doesn't match the encoder output.
if err := root.WriteFile("rules.tsv", []byte("email\nold@example.test\n"), 0600); err != nil {
t.Fatal(err)
}
// Insert-only plan: Update must detect the header mismatch and fall
// back to a full atomic rewrite instead of appending mismatched rows.
newRow := testRuleRow{Email: "new@example.test"}
plan := insertPlan[testRuleRow]("new@example.test", newRow)
plan.Final = []testRuleRow{
{Email: "old@example.test"},
newRow,
}
if err := store.Update(ctx, plan); err != nil {
t.Fatalf("Update with header mismatch: %v", err)
}
rows, err := store.Fetch(ctx)
if err != nil {
t.Fatalf("Fetch after rewrite: %v", err)
}
if len(rows) != 2 {
t.Fatalf("got %d rows, want 2", len(rows))
}
}
func TestFileUpdateFallsBackToRewrite(t *testing.T) {
dir := t.TempDir()
root, err := os.OpenRoot(dir)
if err != nil {
t.Fatal(err)
}
defer root.Close()
store := &Store[testRuleRow]{Root: root, Name: "rules.tsv", Format: "tsv"}
ctx := context.Background()
initial := []testRuleRow{{Email: "a@example.test"}}
if err := store.WriteAll(ctx, initial); err != nil {
t.Fatal(err)
}
changed := testRuleRow{Email: "changed@example.test"}
plan := rowsync.Plan[testRuleRow]{
Mode: rowsync.SyncMode,
Final: []testRuleRow{changed},
Actions: []rowsync.Action[testRuleRow]{{
Kind: rowsync.UpdateAction,
Key: "changed@example.test",
Before: &initial[0],
After: &changed,
}},
}
if err := store.Update(ctx, plan); err != nil {
t.Fatal(err)
}
rows, err := store.Fetch(ctx)
if err != nil || len(rows) != 1 || rows[0].Email != "changed@example.test" {
t.Fatalf("rows=%#v err=%v", rows, err)
}
}
func TestFileUpdateRequiresFinalState(t *testing.T) {
dir := t.TempDir()
root, err := os.OpenRoot(dir)
if err != nil {
t.Fatal(err)
}
defer root.Close()
store := &Store[testRuleRow]{Root: root, Name: "rules.tsv", Format: "tsv"}
ctx := context.Background()
if err := store.WriteAll(ctx, []testRuleRow{{Email: "a@example.test"}}); err != nil {
t.Fatal(err)
}
before, _ := os.ReadFile(dir + "/rules.tsv")
row := testRuleRow{Email: "a@example.test"}
plan := rowsync.Plan[testRuleRow]{
Actions: []rowsync.Action[testRuleRow]{{Kind: rowsync.DeleteAction, Key: "a@example.test", Before: &row}},
}
if err := store.Update(ctx, plan); err == nil {
t.Fatal("Update accepted a plan without a final state")
}
after, _ := os.ReadFile(dir + "/rules.tsv")
if string(before) != string(after) {
t.Fatal("failed Update modified the file")
}
}
func TestFileFailedWritePreservesPreviousFile(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("running as root; permission bits do not apply")
}
dir := t.TempDir()
root, err := os.OpenRoot(dir)
if err != nil {
t.Fatal(err)
}
defer root.Close()
store := &Store[testRuleRow]{Root: root, Name: "rules.tsv", Format: "tsv"}
ctx := context.Background()
original := []testRuleRow{{Email: "a@example.test"}}
if err := store.WriteAll(ctx, original); err != nil {
t.Fatal(err)
}
if err := os.Chmod(dir, 0555); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chmod(dir, 0755) }()
row := testRuleRow{Email: "changed@example.test"}
plan := rowsync.Plan[testRuleRow]{
Mode: rowsync.SyncMode,
Final: []testRuleRow{row},
Actions: []rowsync.Action[testRuleRow]{{
Kind: rowsync.UpdateAction,
Key: "changed@example.test",
Before: &original[0],
After: &row,
}},
}
if err := store.Update(ctx, plan); err == nil {
t.Fatal("Update succeeded on a read-only directory")
}
rows, err := store.Fetch(ctx)
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].Email != "a@example.test" {
t.Fatalf("previous file not preserved: %#v", rows)
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
if len(entries) != 1 || entries[0].Name() != "rules.tsv" {
t.Fatalf("leftover temp files: %v", entries)
}
}
func TestFileZeroActionPlanLeavesFileUnchanged(t *testing.T) {
dir := t.TempDir()
root, err := os.OpenRoot(dir)
if err != nil {
t.Fatal(err)
}
defer root.Close()
store := &Store[testRuleRow]{Root: root, Name: "rules.tsv", Format: "tsv"}
ctx := context.Background()
if err := store.WriteAll(ctx, []testRuleRow{{Email: "a@example.test"}}); err != nil {
t.Fatal(err)
}
before, err := os.ReadFile(dir + "/rules.tsv")
if err != nil {
t.Fatal(err)
}
statBefore, err := os.Stat(dir + "/rules.tsv")
if err != nil {
t.Fatal(err)
}
if err := store.Update(ctx, rowsync.Plan[testRuleRow]{Mode: rowsync.UpdateMode, Final: []testRuleRow{{Email: "a@example.test"}}}); err != nil {
t.Fatal(err)
}
after, err := os.ReadFile(dir + "/rules.tsv")
if err != nil {
t.Fatal(err)
}
statAfter, err := os.Stat(dir + "/rules.tsv")
if err != nil {
t.Fatal(err)
}
if string(before) != string(after) || !statBefore.ModTime().Equal(statAfter.ModTime()) {
t.Fatal("zero-action plan rewrote the file")
}
}