diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-26 23:37:59 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-26 23:37:59 +0200 |
| commit | d4bdc94f5b29a9baa8517acd2d363383e1e3ee53 (patch) | |
| tree | 42ff2f918d9ce5be39d7acf1e4cad72af4defa79 /internal/askcli/task_alias_cache_test.go | |
| parent | eb978df1bcb6ccca3d9d0233ea53b9a2186ae18b (diff) | |
Implement ask alias cache foundation for d5a99b1b-13f3-4b73-8222-71f012c60bc9
Diffstat (limited to 'internal/askcli/task_alias_cache_test.go')
| -rw-r--r-- | internal/askcli/task_alias_cache_test.go | 265 |
1 files changed, 265 insertions, 0 deletions
diff --git a/internal/askcli/task_alias_cache_test.go b/internal/askcli/task_alias_cache_test.go new file mode 100644 index 0000000..c9fffd6 --- /dev/null +++ b/internal/askcli/task_alias_cache_test.go @@ -0,0 +1,265 @@ +package askcli + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +func TestEncodeTaskAliasID(t *testing.T) { + t.Parallel() + + tests := []struct { + id uint64 + want string + }{ + {id: 0, want: "0"}, + {id: 9, want: "9"}, + {id: 10, want: "a"}, + {id: 35, want: "z"}, + {id: 36, want: "00"}, + {id: 37, want: "01"}, + {id: 71, want: "0z"}, + {id: 72, want: "10"}, + {id: 1331, want: "zz"}, + {id: 1332, want: "000"}, + {id: 1333, want: "001"}, + } + + for _, tc := range tests { + if got := encodeTaskAliasID(tc.id); got != tc.want { + t.Fatalf("encodeTaskAliasID(%d) = %q, want %q", tc.id, got, tc.want) + } + } +} + +func TestDecodeTaskAliasID(t *testing.T) { + t.Parallel() + + tests := []struct { + alias string + want uint64 + ok bool + }{ + {alias: "0", want: 0, ok: true}, + {alias: "z", want: 35, ok: true}, + {alias: "00", want: 36, ok: true}, + {alias: "01", want: 37, ok: true}, + {alias: "zz", want: 1331, ok: true}, + {alias: "000", want: 1332, ok: true}, + {alias: "", ok: false}, + {alias: "A", ok: false}, + {alias: "-", ok: false}, + } + + for _, tc := range tests { + got, ok := decodeTaskAliasID(tc.alias) + if ok != tc.ok { + t.Fatalf("decodeTaskAliasID(%q) ok = %v, want %v", tc.alias, ok, tc.ok) + } + if ok && got != tc.want { + t.Fatalf("decodeTaskAliasID(%q) = %d, want %d", tc.alias, got, tc.want) + } + } +} + +func TestEnsureTaskAliases_PersistsAliasesAndTracksAccess(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CACHE_HOME", dir) + + oldNow := nowTaskAliasCache + oldRoot := taskAliasCacheRoot + nowTaskAliasCache = func() time.Time { return time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) } + taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil } + defer func() { + nowTaskAliasCache = oldNow + taskAliasCacheRoot = oldRoot + }() + + tasks := []TaskExport{{UUID: "uuid-1"}, {UUID: "uuid-2"}} + aliases, err := ensureTaskAliases(tasks) + if err != nil { + t.Fatalf("ensureTaskAliases returned error: %v", err) + } + if aliases["uuid-1"] != "0" || aliases["uuid-2"] != "1" { + t.Fatalf("aliases = %#v, want sequential aliases", aliases) + } + + path, err := taskAliasCachePath() + if err != nil { + t.Fatalf("taskAliasCachePath: %v", err) + } + cache := readTaskAliasCacheForTest(t, path) + if cache.NextID != 2 { + t.Fatalf("NextID = %d, want 2", cache.NextID) + } + if len(cache.Entries) != 2 { + t.Fatalf("len(Entries) = %d, want 2", len(cache.Entries)) + } + + nowTaskAliasCache = func() time.Time { return time.Date(2026, 3, 27, 12, 0, 0, 0, time.UTC) } + aliases, err = ensureTaskAliases([]TaskExport{{UUID: "uuid-2"}, {UUID: "uuid-3"}}) + if err != nil { + t.Fatalf("ensureTaskAliases second call returned error: %v", err) + } + if aliases["uuid-2"] != "1" || aliases["uuid-3"] != "2" { + t.Fatalf("second aliases = %#v, want existing+new aliases", aliases) + } + + cache = readTaskAliasCacheForTest(t, path) + if cache.NextID != 3 { + t.Fatalf("NextID after second call = %d, want 3", cache.NextID) + } + entry := findTaskAliasEntry(t, cache, "uuid-2") + if got := entry.LastAccessedAt; !got.Equal(nowTaskAliasCache()) { + t.Fatalf("LastAccessedAt = %s, want %s", got, nowTaskAliasCache()) + } +} + +func TestEnsureTaskAliases_PrunesExpiredEntriesWithoutReusingIDs(t *testing.T) { + dir := t.TempDir() + + oldNow := nowTaskAliasCache + oldRoot := taskAliasCacheRoot + nowTaskAliasCache = func() time.Time { return time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) } + taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil } + defer func() { + nowTaskAliasCache = oldNow + taskAliasCacheRoot = oldRoot + }() + + path, err := taskAliasCachePath() + if err != nil { + t.Fatalf("taskAliasCachePath: %v", err) + } + + cache := taskAliasCache{ + NextID: 37, + Entries: []taskAliasCacheEntry{ + { + UUID: "expired", + Alias: "z", + CreatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + LastAccessedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + }, + { + UUID: "fresh", + Alias: "00", + CreatedAt: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC), + LastAccessedAt: time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC), + }, + }, + } + if err := cache.save(path); err != nil { + t.Fatalf("save seed cache: %v", err) + } + + aliases, err := ensureTaskAliases([]TaskExport{{UUID: "fresh"}, {UUID: "new-task"}}) + if err != nil { + t.Fatalf("ensureTaskAliases returned error: %v", err) + } + if aliases["fresh"] != "00" { + t.Fatalf("fresh alias = %q, want 00", aliases["fresh"]) + } + if aliases["new-task"] != "01" { + t.Fatalf("new-task alias = %q, want 01", aliases["new-task"]) + } + + cache = readTaskAliasCacheForTest(t, path) + if cache.NextID != 38 { + t.Fatalf("NextID = %d, want 38", cache.NextID) + } + if len(cache.Entries) != 2 { + t.Fatalf("len(Entries) = %d, want 2 after prune", len(cache.Entries)) + } + if hasTaskAliasEntry(cache, "expired") { + t.Fatalf("expired entry should have been pruned") + } +} + +func TestEnsureTaskAliases_InvalidCacheReturnsError(t *testing.T) { + dir := t.TempDir() + + oldRoot := taskAliasCacheRoot + taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil } + defer func() { taskAliasCacheRoot = oldRoot }() + + path, err := taskAliasCachePath() + if err != nil { + t.Fatalf("taskAliasCachePath: %v", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(path, []byte("{not-json"), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + if _, err := ensureTaskAliases([]TaskExport{{UUID: "uuid-1"}}); err == nil { + t.Fatal("expected error for invalid cache file") + } +} + +func TestEnsureTaskAliases_RejectsNextIDReuse(t *testing.T) { + dir := t.TempDir() + + oldRoot := taskAliasCacheRoot + taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil } + defer func() { taskAliasCacheRoot = oldRoot }() + + path, err := taskAliasCachePath() + if err != nil { + t.Fatalf("taskAliasCachePath: %v", err) + } + + cache := taskAliasCache{ + NextID: 36, + Entries: []taskAliasCacheEntry{ + {UUID: "uuid-1", Alias: "00", CreatedAt: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)}, + }, + } + if err := cache.save(path); err != nil { + t.Fatalf("save seed cache: %v", err) + } + + if _, err := ensureTaskAliases([]TaskExport{{UUID: "uuid-2"}}); err == nil { + t.Fatal("expected error when next_id would reuse an alias") + } +} + +func readTaskAliasCacheForTest(t *testing.T, path string) taskAliasCache { + t.Helper() + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%s): %v", path, err) + } + var cache taskAliasCache + if err := json.Unmarshal(data, &cache); err != nil { + t.Fatalf("Unmarshal(%s): %v", path, err) + } + return cache +} + +func findTaskAliasEntry(t *testing.T, cache taskAliasCache, uuid string) taskAliasCacheEntry { + t.Helper() + + for _, entry := range cache.Entries { + if entry.UUID == uuid { + return entry + } + } + t.Fatalf("missing alias entry for %q", uuid) + return taskAliasCacheEntry{} +} + +func hasTaskAliasEntry(cache taskAliasCache, uuid string) bool { + for _, entry := range cache.Entries { + if entry.UUID == uuid { + return true + } + } + return false +} |
