1package store23import (4 "encoding/json"5 "errors"6 "io/fs"7 "os"8 "path/filepath"9 "testing"10 "time"11)1213type doc struct {14 N int `json:"n"`15}1617func save(t *testing.T, path string, n int) {18 t.Helper()19 raw, _ := json.Marshal(doc{N: n})20 if err := WriteFile(path, raw, 0o644); err != nil {21 t.Fatal(err)22 }23}2425func load(path string) (doc, error) {26 var d doc27 err := ReadFile(path, func(raw []byte) error {28 var x doc29 if err := json.Unmarshal(raw, &x); err != nil {30 return err31 }32 d = x33 return nil34 })35 return d, err36}3738func read(t *testing.T, path string) string {39 t.Helper()40 raw, err := os.ReadFile(path)41 if err != nil {42 t.Fatal(err)43 }44 return string(raw)45}4647// clock sets the day for daily backups.48func clock(t *testing.T, at time.Time) {49 t.Helper()50 now = func() time.Time { return at }51 t.Cleanup(func() { now = time.Now })52}5354// TestWriteKeepsBak: each save keeps the file it replaces as .bak.55func TestWriteKeepsBak(t *testing.T) {56 clock(t, time.Date(2026, 10, 12, 18, 0, 0, 0, time.UTC))57 path := filepath.Join(t.TempDir(), "dvr.json")58 save(t, path, 1)59 if _, err := os.Stat(path + ".bak"); !errors.Is(err, fs.ErrNotExist) {60 t.Errorf("a first save left a .bak: %v", err)61 }62 save(t, path, 2)63 save(t, path, 3)64 if got := read(t, path); got != `{"n":3}` {65 t.Errorf("file: %s", got)66 }67 if got := read(t, path+".bak"); got != `{"n":2}` {68 t.Errorf(".bak: %s", got)69 }70 // The day's backup is the file before the day's first save.71 if got := read(t, filepath.Join(filepath.Dir(path), "backups", "dvr.json.2026-10-12")); got != `{"n":1}` {72 t.Errorf("daily backup: %s", got)73 }74 // Saving the same again changes nothing.75 save(t, path, 3)76 if got := read(t, path+".bak"); got != `{"n":2}` {77 t.Errorf(".bak after an unchanged save: %s", got)78 }79 if fi, err := os.Stat(path); err != nil || fi.Mode().Perm() != 0o644 {80 t.Errorf("mode: %v, %v", fi.Mode(), err)81 }82}8384// TestDailyBackupsKeepAWeek: one backup a day, the last BackupDays.85func TestDailyBackupsKeepAWeek(t *testing.T) {86 path := filepath.Join(t.TempDir(), "signal.json")87 day := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC)88 for i := range 10 {89 clock(t, day.AddDate(0, 0, i))90 save(t, path, 2*i)91 save(t, path, 2*i+1)92 }93 got := backups(path)94 if len(got) != BackupDays {95 t.Fatalf("%d backups: %v", len(got), got)96 }97 if filepath.Base(got[0]) != "signal.json.2026-10-10" || filepath.Base(got[BackupDays-1]) != "signal.json.2026-10-04" {98 t.Errorf("backups: %v", got)99 }100 // The 10th's is the file as the 9th left it.101 if s := read(t, got[0]); s != `{"n":17}` {102 t.Errorf("newest backup: %s", s)103 }104}105106// TestCorruptFallsBack: an unreadable file is set aside and its .bak read107// and put back; with no good .bak, the newest good daily backup.108func TestCorruptFallsBack(t *testing.T) {109 clock(t, time.Date(2026, 10, 12, 18, 0, 0, 0, time.UTC))110 path := filepath.Join(t.TempDir(), "config.json")111 save(t, path, 1)112 save(t, path, 2)113 if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil {114 t.Fatal(err)115 }116 if d, err := load(path); err != nil || d.N != 1 {117 t.Fatalf("from .bak: %+v, %v", d, err)118 }119 if got := read(t, path); got != `{"n":1}` {120 t.Errorf("not put back: %s", got)121 }122 aside, _ := filepath.Glob(path + ".corrupt-*")123 if len(aside) != 1 || read(t, aside[0]) != "{not json" {124 t.Errorf("set aside: %v", aside)125 }126127 // An empty file and a bad .bak: the daily backup.128 clock(t, time.Date(2026, 10, 13, 18, 0, 0, 0, time.UTC))129 save(t, path, 5) // backs up {"n":1} for the 13th130 for _, p := range []string{path, path + ".bak"} {131 if err := os.WriteFile(p, nil, 0o644); err != nil {132 t.Fatal(err)133 }134 }135 if d, err := load(path); err != nil || d.N != 1 {136 t.Fatalf("from the daily backup: %+v, %v", d, err)137 }138139 // Nothing readable at all.140 if err := os.RemoveAll(filepath.Join(filepath.Dir(path), "backups")); err != nil {141 t.Fatal(err)142 }143 for _, p := range []string{path, path + ".bak"} {144 if err := os.WriteFile(p, []byte("[]"), 0o644); err != nil {145 t.Fatal(err)146 }147 }148 if _, err := load(path); !errors.Is(err, ErrCorrupt) {149 t.Errorf("nothing readable: %v", err)150 }151}152153// TestMissingIsFresh: no file is a fresh start, not a corrupt one.154func TestMissingIsFresh(t *testing.T) {155 path := filepath.Join(t.TempDir(), "dvr.json")156 if _, err := load(path); !errors.Is(err, fs.ErrNotExist) {157 t.Errorf("missing: %v", err)158 }159 if m, _ := filepath.Glob(path + "*"); len(m) != 0 {160 t.Errorf("files made: %v", m)161 }162}