| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "encoding/json" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "testing" |
| 9 | |
| 10 | "reasonix/internal/fileutil" |
| 11 | ) |
| 12 | |
| 13 | func TestHeartbeatRunHistoryLegacySidecarReadable(t *testing.T) { |
| 14 | isolateDesktopUserDirs(t) |
| 15 | engine := &HeartbeatEngine{} |
| 16 | if err := os.MkdirAll(filepath.Dir(engine.configPath()), 0o755); err != nil { |
| 17 | t.Fatal(err) |
| 18 | } |
| 19 | config := []byte(`{"schemaVersion":2,"revision":7,"tasks":[{"id":"t1","title":"task"}]}`) |
| 20 | legacySidecar := []byte(`{"runs":{"t1":[{"at":100,"topicId":"legacy"}]}}`) |
| 21 | if err := os.WriteFile(engine.configPath(), config, 0o644); err != nil { |
| 22 | t.Fatal(err) |
| 23 | } |
| 24 | if err := os.WriteFile(engine.runHistoryPath(), legacySidecar, 0o644); err != nil { |
| 25 | t.Fatal(err) |
| 26 | } |
| 27 | got := engine.ReloadTasks() |
| 28 | if len(got) != 1 || len(got[0].RunHistory) != 1 || got[0].RunHistory[0].TopicID != "legacy" { |
| 29 | t.Fatalf("legacy sidecar was not loaded: %+v", got) |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | func TestHeartbeatRunHistorySidecarSurvivesOlderFullTableSave(t *testing.T) { |
| 34 | isolateDesktopUserDirs(t) |
| 35 | engine := &HeartbeatEngine{} |
| 36 | if err := engine.ReplaceTasks([]HeartbeatTask{{ |
| 37 | ID: "t1", |
| 38 | Title: "task", |
| 39 | RunHistory: []HeartbeatRun{{At: 100, TopicID: "a"}, {At: 200, TopicID: "b"}}, |
| 40 | }}); err != nil { |
| 41 | t.Fatalf("seed: %v", err) |
| 42 | } |
| 43 | legacySave := `{"schemaVersion":1,"tasks":[{"id":"t1","title":"task"}]}` |
| 44 | if err := os.WriteFile(engine.configPath(), []byte(legacySave), 0o644); err != nil { |
| 45 | t.Fatal(err) |
| 46 | } |
| 47 | reloaded := &HeartbeatEngine{} |
| 48 | snap, err := reloaded.readConfigSnapshot() |
| 49 | if err != nil { |
| 50 | t.Fatalf("read snapshot: %v", err) |
| 51 | } |
| 52 | if len(snap.cfg.Tasks) != 1 || len(snap.cfg.Tasks[0].RunHistory) != 2 { |
| 53 | t.Fatalf("sidecar did not survive older full-table save: %+v", snap.cfg.Tasks) |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | func TestHeartbeatRunHistorySidecarTrimmedOnSave(t *testing.T) { |
| 58 | isolateDesktopUserDirs(t) |
| 59 | engine := &HeartbeatEngine{} |
| 60 | if err := engine.ReplaceTasks([]HeartbeatTask{{ |
| 61 | ID: "t1", |
| 62 | Title: "task", |
| 63 | RunHistory: []HeartbeatRun{{At: 100, TopicID: "a"}}, |
| 64 | }}); err != nil { |
| 65 | t.Fatalf("seed: %v", err) |
| 66 | } |
| 67 | data, err := os.ReadFile(engine.configPath()) |
| 68 | if err != nil { |
| 69 | t.Fatal(err) |
| 70 | } |
| 71 | var cfg heartbeatConfig |
| 72 | if err := json.Unmarshal(data, &cfg); err != nil { |
| 73 | t.Fatal(err) |
| 74 | } |
| 75 | if len(cfg.Tasks) != 1 || cfg.Tasks[0].RunHistory != nil { |
| 76 | t.Fatalf("main config retained sidecar-owned history: %+v", cfg.Tasks) |
| 77 | } |
| 78 | raw, err := os.ReadFile(engine.runHistoryPath()) |
| 79 | if err != nil { |
| 80 | t.Fatal(err) |
| 81 | } |
| 82 | var previousReader struct { |
| 83 | Runs map[string][]HeartbeatRun `json:"runs"` |
| 84 | } |
| 85 | if err := json.Unmarshal(raw, &previousReader); err != nil || len(previousReader.Runs["t1"]) != 1 { |
| 86 | t.Fatalf("previous sidecar reader lost top-level runs: runs=%+v err=%v", previousReader.Runs, err) |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | func TestHeartbeatRunHistorySidecarClearsAfterLastTaskDeletion(t *testing.T) { |
| 91 | isolateDesktopUserDirs(t) |
| 92 | engine := &HeartbeatEngine{} |
| 93 | if err := engine.ReplaceTasks([]HeartbeatTask{{ |
| 94 | ID: "reused", |
| 95 | Title: "old task", |
| 96 | RunHistory: []HeartbeatRun{{At: 100, TopicID: "old-topic"}}, |
| 97 | }}); err != nil { |
| 98 | t.Fatalf("seed: %v", err) |
| 99 | } |
| 100 | if err := engine.ReplaceTasks([]HeartbeatTask{}); err != nil { |
| 101 | t.Fatalf("delete last task: %v", err) |
| 102 | } |
| 103 | raw, err := os.ReadFile(engine.runHistoryPath()) |
| 104 | if err != nil { |
| 105 | t.Fatalf("read empty sidecar: %v", err) |
| 106 | } |
| 107 | var sidecar heartbeatRunHistorySidecar |
| 108 | if err := json.Unmarshal(raw, &sidecar); err != nil { |
| 109 | t.Fatalf("decode empty sidecar: %v", err) |
| 110 | } |
| 111 | if len(sidecar.Runs) != 0 { |
| 112 | t.Fatalf("sidecar runs=%v, want empty", sidecar.Runs) |
| 113 | } |
| 114 | if err := engine.ReplaceTasks([]HeartbeatTask{{ID: "reused", Title: "new task"}}); err != nil { |
| 115 | t.Fatalf("reuse task ID: %v", err) |
| 116 | } |
| 117 | got := engine.ListTasks() |
| 118 | if len(got) != 1 || len(got[0].RunHistory) != 0 { |
| 119 | t.Fatalf("deleted task history resurrected after ID reuse: %+v", got) |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | func TestHeartbeatRunHistorySidecarSurvivesCrashBeforeConfigCommit(t *testing.T) { |
| 124 | isolateDesktopUserDirs(t) |
| 125 | engine := &HeartbeatEngine{} |
| 126 | if err := engine.ReplaceTasks([]HeartbeatTask{{ |
| 127 | ID: "reused", |
| 128 | Title: "old task", |
| 129 | RunHistory: []HeartbeatRun{{At: 100, TopicID: "old-topic"}}, |
| 130 | }}); err != nil { |
| 131 | t.Fatalf("seed: %v", err) |
| 132 | } |
| 133 | mainBefore, err := os.ReadFile(engine.configPath()) |
| 134 | if err != nil { |
| 135 | t.Fatal(err) |
| 136 | } |
| 137 | |
| 138 | previousCrashPoint := fileutil.CrashPoint |
| 139 | defer func() { fileutil.CrashPoint = previousCrashPoint }() |
| 140 | const crashMarker = "simulated process crash before config commit" |
| 141 | crashed := false |
| 142 | func() { |
| 143 | defer func() { |
| 144 | if recovered := recover(); recovered != nil { |
| 145 | if recovered != crashMarker { |
| 146 | panic(recovered) |
| 147 | } |
| 148 | crashed = true |
| 149 | } |
| 150 | }() |
| 151 | fileutil.CrashPoint = func(op, path string) { |
| 152 | if op == "atomic-write" && path == engine.configPath() { |
| 153 | panic(crashMarker) |
| 154 | } |
| 155 | } |
| 156 | _ = engine.ReplaceTasks([]HeartbeatTask{}) |
| 157 | }() |
| 158 | fileutil.CrashPoint = previousCrashPoint |
| 159 | if !crashed { |
| 160 | t.Fatal("delete did not reach the injected crash point") |
| 161 | } |
| 162 | mainAfter, err := os.ReadFile(engine.configPath()) |
| 163 | if err != nil { |
| 164 | t.Fatal(err) |
| 165 | } |
| 166 | if !bytes.Equal(mainAfter, mainBefore) { |
| 167 | t.Fatal("main config changed before its commit point") |
| 168 | } |
| 169 | |
| 170 | rawSidecar, err := os.ReadFile(engine.runHistoryPath()) |
| 171 | if err != nil { |
| 172 | t.Fatal(err) |
| 173 | } |
| 174 | var staged heartbeatRunHistorySidecar |
| 175 | if err := json.Unmarshal(rawSidecar, &staged); err != nil { |
| 176 | t.Fatal(err) |
| 177 | } |
| 178 | if staged.Revision != 2 || len(staged.Runs) != 0 || staged.Previous == nil || staged.Previous.Revision != 1 { |
| 179 | t.Fatalf("staged sidecar = %+v, want empty revision 2 with revision 1 fallback", staged) |
| 180 | } |
| 181 | |
| 182 | restarted := &HeartbeatEngine{} |
| 183 | restored := restarted.ReloadTasks() |
| 184 | if len(restored) != 1 || restored[0].ID != "reused" || len(restored[0].RunHistory) != 1 || restored[0].RunHistory[0].TopicID != "old-topic" { |
| 185 | t.Fatalf("restart loaded inconsistent config/sidecar pair: %+v", restored) |
| 186 | } |
| 187 | if err := restarted.ReplaceTasks([]HeartbeatTask{}); err != nil { |
| 188 | t.Fatalf("retry delete after restart: %v", err) |
| 189 | } |
| 190 | if err := restarted.ReplaceTasks([]HeartbeatTask{{ID: "reused", Title: "new task"}}); err != nil { |
| 191 | t.Fatalf("reuse task ID: %v", err) |
| 192 | } |
| 193 | got := restarted.ReloadTasks() |
| 194 | if len(got) != 1 || len(got[0].RunHistory) != 0 { |
| 195 | t.Fatalf("deleted task history resurrected after committed delete and ID reuse: %+v", got) |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | func TestHeartbeatRunHistorySidecarForwardProtection(t *testing.T) { |
| 200 | isolateDesktopUserDirs(t) |
| 201 | engine := &HeartbeatEngine{} |
| 202 | if err := engine.ReplaceTasks([]HeartbeatTask{{ID: "t1", Title: "task"}}); err != nil { |
| 203 | t.Fatalf("seed: %v", err) |
| 204 | } |
| 205 | future := []byte(`{"schemaVersion":99,"revision":1,"runs":{"t1":[{"at":100,"topicId":"future"}]}}`) |
| 206 | if err := os.WriteFile(engine.runHistoryPath(), future, 0o644); err != nil { |
| 207 | t.Fatal(err) |
| 208 | } |
| 209 | if _, err := engine.readConfigSnapshot(); err == nil { |
| 210 | t.Fatal("read should reject a future run-history sidecar schemaVersion") |
| 211 | } |
| 212 | // A running older process may still have the task in memory. Manual runs |
| 213 | // must revalidate the persisted schemas before touching the app runtime. |
| 214 | engine.TriggerNow("t1") |
| 215 | restarted := newHeartbeatEngine(nil) |
| 216 | restarted.Start() |
| 217 | t.Cleanup(restarted.Stop) |
| 218 | if tasks := restarted.ListTasks(); len(tasks) != 0 { |
| 219 | t.Fatalf("startup loaded tasks beside a future sidecar schema: %+v", tasks) |
| 220 | } |
| 221 | if err := engine.ReplaceTasks([]HeartbeatTask{{ID: "t1", Title: "edited"}}); err == nil { |
| 222 | t.Fatal("write should reject a future run-history sidecar schemaVersion") |
| 223 | } |
| 224 | got, err := os.ReadFile(engine.runHistoryPath()) |
| 225 | if err != nil { |
| 226 | t.Fatal(err) |
| 227 | } |
| 228 | if !bytes.Equal(got, future) { |
| 229 | t.Fatalf("future run-history sidecar was overwritten: %s", got) |
| 230 | } |
| 231 | } |
| 232 |