package server import ( "context" "errors" "net/http" "testing" "time" "github.com/Autumn-27/artex/selfupdate" ) // releaseCache is the layer that protects the GitHub quota: the unauthenticated // API allows only 60 requests/hour/IP, and the top-bar "new version available" // indicator queries once per full page load. Once the cache stops working, a // user opening a few tabs would burn through the quota, and real update checks // would then fail to get through. func newTestCache(fetch func(context.Context, *http.Client) (*selfupdate.Release, error)) *releaseCache { return &releaseCache{fetch: fetch} } func TestReleaseCacheServesFromCache(t *testing.T) { calls := 0 c := newTestCache(func(context.Context, *http.Client) (*selfupdate.Release, error) { calls++ return &selfupdate.Release{TagName: "v0.3.8"}, nil }) for range 5 { rel, err := c.get(t.Context(), nil, false) if err != nil { t.Fatalf("get: %v", err) } if rel.TagName != "v0.3.8" { t.Fatalf("TagName = %q", rel.TagName) } } if calls != 1 { t.Errorf("5 queries should hit the origin only once, got %d", calls) } } func TestReleaseCacheForceBypasses(t *testing.T) { calls := 0 c := newTestCache(func(context.Context, *http.Client) (*selfupdate.Release, error) { calls++ return &selfupdate.Release{TagName: "v0.3.8"}, nil }) if _, err := c.get(t.Context(), nil, false); err != nil { t.Fatal(err) } // Clicking "check for updates" must return a live result, otherwise a freshly // published release stays invisible until the cache expires. if _, err := c.get(t.Context(), nil, true); err != nil { t.Fatal(err) } if calls != 2 { t.Errorf("force should bypass the cache, want 2 origin fetches, got %d", calls) } } func TestReleaseCacheExpiresAfterTTL(t *testing.T) { calls := 0 c := newTestCache(func(context.Context, *http.Client) (*selfupdate.Release, error) { calls++ return &selfupdate.Release{TagName: "v0.3.8"}, nil }) if _, err := c.get(t.Context(), nil, false); err != nil { t.Fatal(err) } // Move the stored timestamp back to just past expiry, simulating TTL timeout. c.at = time.Now().Add(-releaseTTL - time.Second) if _, err := c.get(t.Context(), nil, false); err != nil { t.Fatal(err) } if calls != 2 { t.Errorf("after TTL expiry it should fetch from the origin again, want 2, got %d", calls) } } func TestReleaseCacheUsesShorterTTLForErrors(t *testing.T) { calls := 0 c := newTestCache(func(context.Context, *http.Client) (*selfupdate.Release, error) { calls++ return nil, errors.New("github unreachable") }) if _, err := c.get(t.Context(), nil, false); err == nil { t.Fatal("expected an error") } // Failed results must also be cached briefly, otherwise every page load waits // through a timeout for nothing while GitHub is unreachable. if _, err := c.get(t.Context(), nil, false); err == nil { t.Fatal("expected an error") } if calls != 1 { t.Errorf("errors should be cached briefly, want 1 origin fetch, got %d", calls) } // But the error TTL must be noticeably shorter than the success one, so the // cache can heal quickly once the network recovers. if releaseErrTTL >= releaseTTL { t.Fatalf("error TTL (%v) must be shorter than success TTL (%v)", releaseErrTTL, releaseTTL) } c.at = time.Now().Add(-releaseErrTTL - time.Second) if _, err := c.get(t.Context(), nil, false); err == nil { t.Fatal("expected an error") } if calls != 2 { t.Errorf("after error TTL expiry it should retry, want 2, got %d", calls) } } func TestReleaseCacheDoesNotPoisonOnCallerCancel(t *testing.T) { good := &selfupdate.Release{TagName: "v0.3.8"} c := newTestCache(func(ctx context.Context, _ *http.Client) (*selfupdate.Release, error) { return good, nil }) if _, err := c.get(t.Context(), nil, false); err != nil { t.Fatal(err) } // A visitor closing the tab cancels the request. That does not mean GitHub is // broken, and the "cancelled" error must never be written into the cache — // otherwise every visitor would get a baffling error for the next 30 minutes. c.fetch = func(ctx context.Context, _ *http.Client) (*selfupdate.Release, error) { return nil, ctx.Err() } c.at = time.Now().Add(-releaseTTL - time.Second) // expire the cache to force a refetch ctx, cancel := context.WithCancel(context.Background()) cancel() if _, err := c.get(ctx, nil, false); err == nil { t.Fatal("when the caller has cancelled, the error should be passed through to it") } // Key invariant: the cancelled call leaves no trace — the cache holds neither // the "cancelled" error nor loses the previous good result. if c.err != nil { t.Fatalf("cancel error must not be written into the cache, got %v", c.err) } if c.rel == nil || c.rel.TagName != "v0.3.8" { t.Fatalf("cache should keep the previous good result, got %+v", c.rel) } // The cancellation fetched no new data, so the next visitor should refetch // from the origin — and get a normal result, unaffected by the earlier cancel. c.fetch = func(context.Context, *http.Client) (*selfupdate.Release, error) { return good, nil } rel, err := c.get(t.Context(), nil, false) if err != nil { t.Fatalf("normal requests after a cancellation must not fail: %v", err) } if rel == nil || rel.TagName != "v0.3.8" { t.Fatalf("should get a normal result, got %+v", rel) } }