| 1 | package ledger |
| 2 | |
| 3 | import ( |
| 4 | "bufio" |
| 5 | "encoding/json" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "testing" |
| 9 | "time" |
| 10 | |
| 11 | "github.com/zionboggan/agent-time-ledger/internal/state" |
| 12 | ) |
| 13 | |
| 14 | func TestNowResponseIsRFC3339(t *testing.T) { |
| 15 | service := testService(t, time.Date(2026, 6, 19, 6, 30, 0, 0, time.UTC)) |
| 16 | response, err := service.NowResponse() |
| 17 | if err != nil { |
| 18 | t.Fatalf("NowResponse returned error: %v", err) |
| 19 | } |
| 20 | if _, err := time.Parse(time.RFC3339Nano, response.Timestamp); err != nil { |
| 21 | t.Fatalf("timestamp %q is not RFC3339: %v", response.Timestamp, err) |
| 22 | } |
| 23 | if _, err := time.Parse(time.RFC3339Nano, response.UTCTimestamp); err != nil { |
| 24 | t.Fatalf("utc timestamp %q is not RFC3339: %v", response.UTCTimestamp, err) |
| 25 | } |
| 26 | if response.Confidence != "host_clock" { |
| 27 | t.Fatalf("confidence = %q, want host_clock", response.Confidence) |
| 28 | } |
| 29 | } |
| 30 | |
| 31 | func TestNowResponseTimezone(t *testing.T) { |
| 32 | service := testService(t, time.Date(2026, 6, 19, 6, 30, 0, 0, time.UTC)) |
| 33 | response, err := service.NowResponseIn("America/Chicago") |
| 34 | if err != nil { |
| 35 | t.Fatalf("NowResponseIn returned error: %v", err) |
| 36 | } |
| 37 | if response.Timezone != "America/Chicago" { |
| 38 | t.Fatalf("timezone = %q, want America/Chicago", response.Timezone) |
| 39 | } |
| 40 | if response.UTCOffset != "-05:00" { |
| 41 | t.Fatalf("utc offset = %q, want -05:00", response.UTCOffset) |
| 42 | } |
| 43 | if response.Timestamp != "2026-06-19T01:30:00-05:00" { |
| 44 | t.Fatalf("timestamp = %q, want Central daylight timestamp", response.Timestamp) |
| 45 | } |
| 46 | if response.UTCTimestamp != "2026-06-19T06:30:00Z" { |
| 47 | t.Fatalf("utc timestamp = %q, want 2026-06-19T06:30:00Z", response.UTCTimestamp) |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | func TestStaleCheckFreshBeforeTTL(t *testing.T) { |
| 52 | now := time.Date(2026, 6, 19, 6, 30, 0, 0, time.UTC) |
| 53 | service := testService(t, now) |
| 54 | response, err := service.StaleFromTimestamp(now.Add(-14*time.Minute), 15*time.Minute) |
| 55 | if err != nil { |
| 56 | t.Fatalf("StaleFromTimestamp returned error: %v", err) |
| 57 | } |
| 58 | if response.Stale { |
| 59 | t.Fatal("expected fresh before TTL") |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | func TestStaleCheckStaleAfterTTL(t *testing.T) { |
| 64 | now := time.Date(2026, 6, 19, 6, 30, 0, 0, time.UTC) |
| 65 | service := testService(t, now) |
| 66 | response, err := service.StaleFromTimestamp(now.Add(-16*time.Minute), 15*time.Minute) |
| 67 | if err != nil { |
| 68 | t.Fatalf("StaleFromTimestamp returned error: %v", err) |
| 69 | } |
| 70 | if !response.Stale { |
| 71 | t.Fatal("expected stale after TTL") |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | func TestMissingMarkError(t *testing.T) { |
| 76 | service := testService(t, time.Date(2026, 6, 19, 6, 30, 0, 0, time.UTC)) |
| 77 | if _, err := service.MarkElapsed("missing"); err == nil { |
| 78 | t.Fatal("expected missing mark error") |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | func TestDeleteMissingMarkError(t *testing.T) { |
| 83 | service := testService(t, time.Date(2026, 6, 19, 6, 30, 0, 0, time.UTC)) |
| 84 | if err := service.DeleteMark("missing"); err == nil { |
| 85 | t.Fatal("expected missing mark error") |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | func TestSessionStatusNoActiveSession(t *testing.T) { |
| 90 | service := testService(t, time.Date(2026, 6, 19, 6, 30, 0, 0, time.UTC)) |
| 91 | status, err := service.SessionStatus() |
| 92 | if err != nil { |
| 93 | t.Fatalf("SessionStatus returned error: %v", err) |
| 94 | } |
| 95 | if status.Active { |
| 96 | t.Fatal("expected no active session") |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | func TestStatePersistence(t *testing.T) { |
| 101 | dir := t.TempDir() |
| 102 | start := time.Date(2026, 6, 19, 6, 30, 0, 0, time.UTC) |
| 103 | first := NewService(state.NewStore(dir)) |
| 104 | first.Now = func() time.Time { return start } |
| 105 | if _, err := first.StartSession("build"); err != nil { |
| 106 | t.Fatalf("StartSession returned error: %v", err) |
| 107 | } |
| 108 | if _, err := first.StartMark("compile"); err != nil { |
| 109 | t.Fatalf("StartMark returned error: %v", err) |
| 110 | } |
| 111 | |
| 112 | second := NewService(state.NewStore(dir)) |
| 113 | second.Now = func() time.Time { return start.Add(2 * time.Hour) } |
| 114 | status, err := second.SessionStatus() |
| 115 | if err != nil { |
| 116 | t.Fatalf("SessionStatus returned error: %v", err) |
| 117 | } |
| 118 | if !status.Active || status.Name != "build" { |
| 119 | t.Fatalf("unexpected persisted status: %+v", status) |
| 120 | } |
| 121 | if status.ElapsedSeconds != 7200 { |
| 122 | t.Fatalf("elapsed = %f, want 7200", status.ElapsedSeconds) |
| 123 | } |
| 124 | mark, err := second.MarkElapsed("compile") |
| 125 | if err != nil { |
| 126 | t.Fatalf("MarkElapsed returned error: %v", err) |
| 127 | } |
| 128 | if mark.ElapsedSeconds != 7200 { |
| 129 | t.Fatalf("mark elapsed = %f, want 7200", mark.ElapsedSeconds) |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | func TestListMarksEmpty(t *testing.T) { |
| 134 | service := testService(t, time.Date(2026, 6, 19, 6, 30, 0, 0, time.UTC)) |
| 135 | marks, err := service.ListMarks() |
| 136 | if err != nil { |
| 137 | t.Fatalf("ListMarks returned error: %v", err) |
| 138 | } |
| 139 | if len(marks) != 0 { |
| 140 | t.Fatalf("expected 0 marks, got %d", len(marks)) |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | func TestListMarksReturnsAll(t *testing.T) { |
| 145 | now := time.Date(2026, 6, 19, 6, 30, 0, 0, time.UTC) |
| 146 | service := testService(t, now) |
| 147 | if _, err := service.StartMark("build"); err != nil { |
| 148 | t.Fatalf("StartMark(build) returned error: %v", err) |
| 149 | } |
| 150 | if _, err := service.StartMark("test"); err != nil { |
| 151 | t.Fatalf("StartMark(test) returned error: %v", err) |
| 152 | } |
| 153 | marks, err := service.ListMarks() |
| 154 | if err != nil { |
| 155 | t.Fatalf("ListMarks returned error: %v", err) |
| 156 | } |
| 157 | if len(marks) != 2 { |
| 158 | t.Fatalf("expected 2 marks, got %d", len(marks)) |
| 159 | } |
| 160 | names := map[string]struct{}{} |
| 161 | for _, m := range marks { |
| 162 | names[m.Name] = struct{}{} |
| 163 | } |
| 164 | if _, ok := names["build"]; !ok { |
| 165 | t.Fatal("mark 'build' not in ListMarks result") |
| 166 | } |
| 167 | if _, ok := names["test"]; !ok { |
| 168 | t.Fatal("mark 'test' not in ListMarks result") |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | func TestListMarksExcludesDeleted(t *testing.T) { |
| 173 | now := time.Date(2026, 6, 19, 6, 30, 0, 0, time.UTC) |
| 174 | service := testService(t, now) |
| 175 | if _, err := service.StartMark("build"); err != nil { |
| 176 | t.Fatalf("StartMark returned error: %v", err) |
| 177 | } |
| 178 | if _, err := service.StartMark("deploy"); err != nil { |
| 179 | t.Fatalf("StartMark returned error: %v", err) |
| 180 | } |
| 181 | if err := service.DeleteMark("deploy"); err != nil { |
| 182 | t.Fatalf("DeleteMark returned error: %v", err) |
| 183 | } |
| 184 | marks, err := service.ListMarks() |
| 185 | if err != nil { |
| 186 | t.Fatalf("ListMarks returned error: %v", err) |
| 187 | } |
| 188 | if len(marks) != 1 || marks[0].Name != "build" { |
| 189 | t.Fatalf("expected [build], got %v", marks) |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | func TestJSONLEventValidity(t *testing.T) { |
| 194 | dir := t.TempDir() |
| 195 | service := NewService(state.NewStore(dir)) |
| 196 | service.Now = func() time.Time { |
| 197 | return time.Date(2026, 6, 19, 6, 30, 0, 0, time.UTC) |
| 198 | } |
| 199 | if _, err := service.StartSession("build"); err != nil { |
| 200 | t.Fatalf("StartSession returned error: %v", err) |
| 201 | } |
| 202 | if _, err := service.StartMark("compile"); err != nil { |
| 203 | t.Fatalf("StartMark returned error: %v", err) |
| 204 | } |
| 205 | if err := service.DeleteMark("compile"); err != nil { |
| 206 | t.Fatalf("DeleteMark returned error: %v", err) |
| 207 | } |
| 208 | if _, err := service.StaleFromTimestamp(service.Now().Add(-2*time.Hour), time.Hour); err != nil { |
| 209 | t.Fatalf("StaleFromTimestamp returned error: %v", err) |
| 210 | } |
| 211 | |
| 212 | file, err := os.Open(filepath.Join(dir, state.EventsFile)) |
| 213 | if err != nil { |
| 214 | t.Fatalf("open events file: %v", err) |
| 215 | } |
| 216 | defer file.Close() |
| 217 | |
| 218 | count := 0 |
| 219 | scanner := bufio.NewScanner(file) |
| 220 | for scanner.Scan() { |
| 221 | count++ |
| 222 | var event state.Event |
| 223 | if err := json.Unmarshal(scanner.Bytes(), &event); err != nil { |
| 224 | t.Fatalf("event line %d is invalid JSON: %v", count, err) |
| 225 | } |
| 226 | if event.Type == "" || event.Timestamp.IsZero() { |
| 227 | t.Fatalf("event line %d missing type or timestamp: %+v", count, event) |
| 228 | } |
| 229 | } |
| 230 | if err := scanner.Err(); err != nil { |
| 231 | t.Fatalf("scan events: %v", err) |
| 232 | } |
| 233 | if count != 4 { |
| 234 | t.Fatalf("event count = %d, want 4", count) |
| 235 | } |
| 236 | } |
| 237 | |
| 238 | func testService(t *testing.T, now time.Time) *Service { |
| 239 | t.Helper() |
| 240 | service := NewService(state.NewStore(t.TempDir())) |
| 241 | service.Now = func() time.Time { return now } |
| 242 | return service |
| 243 | } |