● Zion Boggan
repos/Agent Time Ledger/internal/ledger/ledger_test.go
zionboggan.com ↗
243 lines · go
History for this file →
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
}