package history import ( "encoding/json" "fmt" "os" "path/filepath" "testing" "time" ) func TestAddDoesNotCapAtFiftyEntries(t *testing.T) { store := &Store{ retentionDays: 0, path: filepath.Join(t.TempDir(), "history.json"), } for i := 0; i < 60; i++ { store.Add("history entry") } entries := store.GetAll() if len(entries) != 60 { t.Fatalf("expected 60 entries, got %d", len(entries)) } fd := readHistoryFile(t, store.path) if len(fd.Entries) != 60 { t.Fatalf("expected 60 persisted entries, got %d", len(fd.Entries)) } } func TestRetentionPrunesMemoryAndFile(t *testing.T) { now := time.Now() store := &Store{ retentionDays: 7, path: filepath.Join(t.TempDir(), "history.json"), entries: []Entry{ {Text: "new entry", Timestamp: now.UnixMilli()}, {Text: "old entry", Timestamp: now.AddDate(0, 0, -8).UnixMilli()}, }, } entries := store.GetAll() if len(entries) != 1 { t.Fatalf("expected 1 retained entry, got %d", len(entries)) } if entries[0].Text != "new entry" { t.Fatalf("expected new entry to be retained, got %q", entries[0].Text) } fd := readHistoryFile(t, store.path) if len(fd.Entries) != 1 { t.Fatalf("expected 1 persisted entry, got %d", len(fd.Entries)) } if fd.Entries[0].Text != "new entry" { t.Fatalf("expected persisted new entry, got %q", fd.Entries[0].Text) } } func TestSetRetentionDaysRejectsUnsupportedValues(t *testing.T) { store := &Store{ retentionDays: 30, path: filepath.Join(t.TempDir(), "history.json"), } if err := store.SetRetentionDays(365); err == nil { t.Fatal("expected unsupported retention value to fail") } if got := store.GetRetentionDays(); got != 30 { t.Fatalf("expected retention to remain 30, got %d", got) } } func TestLoadNormalizesUnsupportedRetention(t *testing.T) { path := filepath.Join(t.TempDir(), "history.json") data, err := json.Marshal(fileData{ RetentionDays: intPtr(365), Entries: []Entry{{Text: "entry", Timestamp: time.Now().UnixMilli()}}, }) if err != nil { t.Fatal(err) } if err := os.WriteFile(path, data, 0644); err != nil { t.Fatal(err) } store := &Store{ retentionDays: 30, path: path, } store.load() if got := store.GetRetentionDays(); got != 30 { t.Fatalf("expected invalid loaded retention to normalize to 30, got %d", got) } } func TestGetUnexportedFiltersByScope(t *testing.T) { loc := time.FixedZone("UTC+8", 8*60*60) now := time.Date(2026, 5, 30, 15, 0, 0, 0, loc) store := &Store{ retentionDays: 0, path: filepath.Join(t.TempDir(), "history.json"), entries: []Entry{ {Text: "today", Timestamp: now.Add(-time.Hour).UnixMilli()}, {Text: "exported", Timestamp: now.Add(-2 * time.Hour).UnixMilli(), ExportedAt: now.UnixMilli()}, {Text: "yesterday", Timestamp: now.AddDate(0, 0, -1).UnixMilli()}, {Text: "old", Timestamp: now.AddDate(0, 0, -8).UnixMilli()}, }, } today := store.GetUnexported(ExportScopeToday, now, loc) if len(today) != 1 || today[0].Text != "today" { t.Fatalf("expected only today's unexported entry, got %+v", today) } last7Days := store.GetUnexported(ExportScopeLast7Days, now, loc) if len(last7Days) != 2 || last7Days[0].Text != "today" || last7Days[1].Text != "yesterday" { t.Fatalf("expected today and yesterday, got %+v", last7Days) } all := store.GetUnexported(ExportScopeAll, now, loc) if len(all) != 3 || all[0].Text != "today" || all[1].Text != "yesterday" || all[2].Text != "old" { t.Fatalf("expected all unexported entries, got %+v", all) } } func TestGetPageClampsAndReturnsCurrentSlice(t *testing.T) { store := &Store{ retentionDays: 0, path: filepath.Join(t.TempDir(), "history.json"), } for i := 0; i < 12; i++ { store.entries = append(store.entries, Entry{ Text: fmt.Sprintf("entry %02d", i), Timestamp: int64(1000 - i), }) } page := store.GetPage(2, 5) if page.Total != 12 || page.TotalPages != 3 || page.Page != 2 || page.PageSize != 5 { t.Fatalf("unexpected page metadata: %+v", page) } if len(page.Entries) != 5 || page.Entries[0].Text != "entry 05" || page.Entries[4].Text != "entry 09" { t.Fatalf("unexpected page entries: %+v", page.Entries) } last := store.GetPage(99, 5) if last.Page != 3 || len(last.Entries) != 2 || last.Entries[0].Text != "entry 10" { t.Fatalf("expected clamped last page, got %+v", last) } defaultSize := store.GetPage(1, 0) if defaultSize.PageSize != defaultPageSize { t.Fatalf("expected default page size %d, got %d", defaultPageSize, defaultSize.PageSize) } } func TestGetPageHandlesEmptyHistory(t *testing.T) { store := &Store{ retentionDays: 0, path: filepath.Join(t.TempDir(), "history.json"), } page := store.GetPage(4, 100) if page.Total != 0 || page.TotalPages != 0 || page.Page != 1 || len(page.Entries) != 0 { t.Fatalf("unexpected empty page: %+v", page) } } func TestCountsSummarizesFullHistory(t *testing.T) { loc := time.FixedZone("UTC+8", 8*60*60) now := time.Date(2026, 5, 30, 15, 0, 0, 0, loc) store := &Store{ retentionDays: 0, path: filepath.Join(t.TempDir(), "history.json"), entries: []Entry{ {Text: "today", Timestamp: now.Add(-time.Hour).UnixMilli()}, {Text: "exported", Timestamp: now.Add(-2 * time.Hour).UnixMilli(), ExportedAt: now.UnixMilli()}, {Text: "yesterday", Timestamp: now.AddDate(0, 0, -1).UnixMilli()}, {Text: "old", Timestamp: now.AddDate(0, 0, -8).UnixMilli()}, {Text: "corrupt exportedAt", Timestamp: now.Add(-3 * time.Hour).UnixMilli(), ExportedAt: -1}, }, } counts := store.Counts(now, loc) if counts.TotalCount != 5 { t.Fatalf("expected total 5, got %+v", counts) } if counts.ExportedCount != 1 { t.Fatalf("expected exported 1, got %+v", counts) } if counts.UnexportedAllCount != 4 || counts.UnexportedLast7DaysCount != 3 || counts.UnexportedTodayCount != 2 { t.Fatalf("unexpected unexported counts: %+v", counts) } } func TestMarkExportedPersistsExportedAt(t *testing.T) { now := time.Date(2026, 5, 30, 15, 0, 0, 0, time.UTC) exportedAt := now.Add(time.Minute).UnixMilli() store := &Store{ retentionDays: 0, path: filepath.Join(t.TempDir(), "history.json"), entries: []Entry{ {Text: "one", Timestamp: now.UnixMilli()}, {Text: "two", Timestamp: now.Add(-time.Minute).UnixMilli()}, }, } updated, err := store.MarkExported([]int64{store.entries[0].Timestamp}, exportedAt) if err != nil { t.Fatal(err) } if updated != 1 { t.Fatalf("expected 1 updated entry, got %d", updated) } if store.entries[0].ExportedAt != exportedAt { t.Fatalf("expected exportedAt %d, got %d", exportedAt, store.entries[0].ExportedAt) } if store.entries[1].ExportedAt != 0 { t.Fatalf("expected unmatched entry to remain unexported, got %d", store.entries[1].ExportedAt) } fd := readHistoryFile(t, store.path) if len(fd.Entries) != 2 || fd.Entries[0].ExportedAt != exportedAt { t.Fatalf("expected exportedAt to persist, got %+v", fd.Entries) } } func TestMarkExportedRollsBackOnSaveFailure(t *testing.T) { now := time.Date(2026, 5, 30, 15, 0, 0, 0, time.UTC) path := filepath.Join(t.TempDir(), "history.json") if err := os.Mkdir(path, 0755); err != nil { t.Fatal(err) } store := &Store{ retentionDays: 0, path: path, entries: []Entry{ {Text: "one", Timestamp: now.UnixMilli()}, }, } updated, err := store.MarkExported([]int64{store.entries[0].Timestamp}, now.UnixMilli()) if err == nil { t.Fatal("expected save failure") } if updated != 0 { t.Fatalf("expected 0 updated entries after rollback, got %d", updated) } if store.entries[0].ExportedAt != 0 { t.Fatalf("expected exportedAt rollback, got %d", store.entries[0].ExportedAt) } } func TestMarkExportedReplacesNonPositiveExportedAt(t *testing.T) { now := time.Date(2026, 5, 30, 15, 0, 0, 0, time.UTC) exportedAt := now.Add(time.Minute).UnixMilli() store := &Store{ retentionDays: 0, path: filepath.Join(t.TempDir(), "history.json"), entries: []Entry{ {Text: "negative", Timestamp: now.UnixMilli(), ExportedAt: -1}, }, } updated, err := store.MarkExported([]int64{store.entries[0].Timestamp}, exportedAt) if err != nil { t.Fatal(err) } if updated != 1 { t.Fatalf("expected 1 updated entry, got %d", updated) } if store.entries[0].ExportedAt != exportedAt { t.Fatalf("expected exportedAt to be replaced, got %d", store.entries[0].ExportedAt) } } func TestClearExportedRemovesOnlyPositiveExportedAt(t *testing.T) { now := time.Date(2026, 5, 30, 15, 0, 0, 0, time.UTC) store := &Store{ retentionDays: 0, path: filepath.Join(t.TempDir(), "history.json"), entries: []Entry{ {Text: "exported", Timestamp: now.UnixMilli(), ExportedAt: now.UnixMilli()}, {Text: "unexported", Timestamp: now.Add(-time.Minute).UnixMilli()}, {Text: "zero", Timestamp: now.Add(-2 * time.Minute).UnixMilli(), ExportedAt: 0}, {Text: "negative", Timestamp: now.Add(-3 * time.Minute).UnixMilli(), ExportedAt: -1}, }, } result, err := store.ClearExported() if err != nil { t.Fatal(err) } if result.DeletedCount != 1 || result.RemainingCount != 3 { t.Fatalf("unexpected result: %+v", result) } if len(store.entries) != 3 { t.Fatalf("expected 3 remaining entries, got %d", len(store.entries)) } for _, entry := range store.entries { if entry.Text == "exported" { t.Fatalf("exported entry was not removed: %+v", store.entries) } } fd := readHistoryFile(t, store.path) if len(fd.Entries) != 3 { t.Fatalf("expected persisted 3 entries, got %+v", fd.Entries) } } func TestClearExportedRollsBackOnSaveFailure(t *testing.T) { now := time.Date(2026, 5, 30, 15, 0, 0, 0, time.UTC) path := filepath.Join(t.TempDir(), "history.json") if err := os.Mkdir(path, 0755); err != nil { t.Fatal(err) } store := &Store{ retentionDays: 0, path: path, entries: []Entry{ {Text: "exported", Timestamp: now.UnixMilli(), ExportedAt: now.UnixMilli()}, {Text: "unexported", Timestamp: now.Add(-time.Minute).UnixMilli()}, }, } result, err := store.ClearExported() if err == nil { t.Fatal("expected save failure") } if result.DeletedCount != 0 || result.RemainingCount != 0 { t.Fatalf("expected empty result after rollback, got %+v", result) } if len(store.entries) != 2 || store.entries[0].Text != "exported" { t.Fatalf("expected entries rollback, got %+v", store.entries) } } func TestLoadOldHistoryWithoutExportedAtDefaultsToUnexported(t *testing.T) { path := filepath.Join(t.TempDir(), "history.json") data := []byte(`{"retentionDays":0,"entries":[{"text":"old format","timestamp":1780134620883}]}`) if err := os.WriteFile(path, data, 0644); err != nil { t.Fatal(err) } store := &Store{ retentionDays: 30, path: path, } store.load() entries := store.GetAll() if len(entries) != 1 { t.Fatalf("expected 1 entry, got %d", len(entries)) } if entries[0].ExportedAt != 0 { t.Fatalf("expected old entry to load as unexported, got exportedAt=%d", entries[0].ExportedAt) } } func readHistoryFile(t *testing.T, path string) fileData { t.Helper() data, err := os.ReadFile(path) if err != nil { t.Fatal(err) } var fd fileData if err := json.Unmarshal(data, &fd); err != nil { t.Fatal(err) } return fd } func intPtr(v int) *int { return &v }