package main import ( "bytes" "context" "errors" "os" "path/filepath" "testing" "time" tea "github.com/charmbracelet/bubbletea" ) type switchService struct { prs []PullRequest details PRDetails err error } type countingCachedService struct { liveDetailsCalls int cachedDetailsCalls int } func (s *countingCachedService) ListPullRequests(context.Context, string, string, int, bool) ([]PullRequest, error) { return nil, nil } func (s *countingCachedService) GetPullRequest(context.Context, string, string, int) (PRDetails, error) { return PRDetails{}, nil } func (s *countingCachedService) LivePullRequests(context.Context, string, string, int, bool) ([]PullRequest, error) { return nil, nil } func (s *countingCachedService) LivePullRequest(context.Context, string, string, int) (PRDetails, error) { s.liveDetailsCalls++ return PRDetails{}, nil } func (s *countingCachedService) CachedPullRequests(string, string, int, bool) ([]PullRequest, error) { return nil, nil } func (s *countingCachedService) CachedPullRequest(string, string, int) (PRDetails, error) { s.cachedDetailsCalls++ return PRDetails{}, nil } func (s *switchService) ListPullRequests(context.Context, string, string, int, bool) ([]PullRequest, error) { return s.prs, s.err } func (s *switchService) GetPullRequest(context.Context, string, string, int) (PRDetails, error) { return s.details, s.err } func TestCachedServiceFallsBackToRecentReadData(t *testing.T) { remote := &switchService{ prs: []PullRequest{{ID: "pr", Owner: "o", Repository: "r", Number: 1}}, details: PRDetails{PullRequest: PullRequest{ID: "pr", Owner: "o", Repository: "r", Number: 1}}, } service := NewCachedGitHubService(remote, t.TempDir(), time.Hour) if _, err := service.ListPullRequests(context.Background(), "o", "r", 50, false); err != nil { t.Fatal(err) } if _, err := service.GetPullRequest(context.Background(), "o", "r", 1); err != nil { t.Fatal(err) } remote.err = errors.New("offline") prs, err := service.ListPullRequests(context.Background(), "o", "r", 50, false) if err != nil || len(prs) != 1 || !prs[0].FromCache || prs[0].CachedAt.IsZero() { t.Fatalf("cached PR list = %#v, error = %v", prs, err) } details, err := service.GetPullRequest(context.Background(), "o", "r", 1) if err != nil || !details.FromCache || details.CachedAt.IsZero() { t.Fatalf("cached details = %#v, error = %v", details, err) } } func TestReadStateSurvivesRestartWithUnreadComment(t *testing.T) { path := filepath.Join(t.TempDir(), "state.json") settings := defaultAppSettings() settings.ReadState = loadReadState(path) m := NewAppWithSettings(nil, "o", "r", false, 50, time.Second, settings) initial := PRDetails{ PullRequest: PullRequest{ID: "pr"}, Threads: []ReviewThread{{ID: "thread", Comments: []ReviewComment{{ID: "old"}}}}, } m.trackThreadUpdates(initial) updated := initial updated.Threads[0].Comments = append(updated.Threads[0].Comments, ReviewComment{ID: "new"}) m.trackThreadUpdates(updated) if !m.unreadThreads["thread"] { t.Fatal("new comment was not unread before restart") } settings.ReadState = loadReadState(path) restarted := NewAppWithSettings(nil, "o", "r", false, 50, time.Second, settings) restarted.trackThreadUpdates(updated) if !restarted.unreadThreads["thread"] { t.Fatal("unread comment was lost across restart") } } func TestCachedPickerSnapshotStaysVisibleWhileLiveRefreshContinues(t *testing.T) { m := NewApp(nil, "", "", false, 50, time.Second) m.loading = true cachedAt := time.Now().Add(-time.Hour) updated, _ := m.Update(prsLoadedMsg{ cached: true, prs: []PullRequest{{ ID: "cached", Number: 1, FromCache: true, CachedAt: cachedAt, }}, }) m = updated.(App) if len(m.prs) != 1 || !m.prs[0].FromCache || !m.loading { t.Fatalf("cached snapshot was not shown during refresh: %#v", m) } updated, _ = m.Update(prsLoadedMsg{prs: []PullRequest{{ID: "live", Number: 2}}}) m = updated.(App) if len(m.prs) != 1 || m.prs[0].ID != "live" || m.loading { t.Fatalf("live response did not replace cached snapshot: %#v", m) } } func TestRoutineRefreshDoesNotReplayCachedDetails(t *testing.T) { service := &countingCachedService{} m := NewApp(service, "o", "r", false, 50, time.Second) pr := PullRequest{Owner: "o", Repository: "r", Number: 1} if msg := m.loadDetails(pr, false)(); msg == nil { t.Fatal("live refresh returned no message") } if service.liveDetailsCalls != 1 || service.cachedDetailsCalls != 0 { t.Fatalf("routine refresh calls: live=%d cached=%d", service.liveDetailsCalls, service.cachedDetailsCalls) } msg := m.loadDetails(pr, true)() batch, ok := msg.(tea.BatchMsg) if !ok { t.Fatalf("initial load command returned %T, want tea.BatchMsg", msg) } for _, command := range batch { _ = command() } if service.liveDetailsCalls != 2 || service.cachedDetailsCalls != 1 { t.Fatalf("initial load calls: live=%d cached=%d", service.liveDetailsCalls, service.cachedDetailsCalls) } } func TestCacheDoesNotRewriteUnchangedContent(t *testing.T) { remote := &switchService{details: PRDetails{ PullRequest: PullRequest{ID: "pr", Owner: "o", Repository: "r", Number: 1, Title: "same"}, }} service := NewCachedGitHubService(remote, t.TempDir(), time.Hour) if _, err := service.LivePullRequest(context.Background(), "o", "r", 1); err != nil { t.Fatal(err) } path := service.file(service.pullRequestKey("o", "r", 1)) before, err := os.ReadFile(path) if err != nil { t.Fatal(err) } time.Sleep(2 * time.Millisecond) if _, err := service.LivePullRequest(context.Background(), "o", "r", 1); err != nil { t.Fatal(err) } after, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if !bytes.Equal(before, after) { t.Fatal("unchanged cache content was rewritten") } remote.details.Title = "changed" if _, err := service.LivePullRequest(context.Background(), "o", "r", 1); err != nil { t.Fatal(err) } changed, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if bytes.Equal(after, changed) { t.Fatal("changed cache content was not persisted") } }