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
|
}
|