247 lines
7.4 KiB
Go
247 lines
7.4 KiB
Go
package updates
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync/atomic"
|
|
"testing"
|
|
"testing/synctest"
|
|
"time"
|
|
|
|
"github.com/jmoiron/sqlx"
|
|
_ "github.com/mattn/go-sqlite3"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/internal/db"
|
|
"github.com/kandev/kandev/internal/persistence"
|
|
)
|
|
|
|
// newTestPool builds an in-memory SQLite pool with kandev_meta migrated.
|
|
// Both Writer/Reader share the same connection because we never need WAL
|
|
// snapshots in these tests.
|
|
func newTestPool(t *testing.T) *db.Pool {
|
|
t.Helper()
|
|
conn, err := sqlx.Open("sqlite3", ":memory:")
|
|
if err != nil {
|
|
t.Fatalf("open sqlite: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = conn.Close() })
|
|
// kandev_meta needs to exist before Write/Read; reuse persistence's
|
|
// public roundtrip on a fresh DB by calling WriteLatestVersion with
|
|
// zero values to lazily create the table is not possible — meta.go
|
|
// keeps ensureMetaTable internal. Create the table directly with the
|
|
// same DDL.
|
|
if _, err := conn.Exec(`CREATE TABLE IF NOT EXISTS kandev_meta (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL DEFAULT ''
|
|
)`); err != nil {
|
|
t.Fatalf("create kandev_meta: %v", err)
|
|
}
|
|
return db.NewPool(conn, conn)
|
|
}
|
|
|
|
// newStubGitHub stands up an httptest.Server that returns the given tag/url
|
|
// payload on every request and records the call count via the returned
|
|
// atomic counter.
|
|
func newStubGitHub(t *testing.T, tag, url string) (*httptest.Server, *int32) {
|
|
t.Helper()
|
|
var count int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
atomic.AddInt32(&count, 1)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"tag_name":"` + tag + `","html_url":"` + url + `"}`))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return srv, &count
|
|
}
|
|
|
|
func TestService_Get_FreshDB_ReturnsZeroValues(t *testing.T) {
|
|
pool := newTestPool(t)
|
|
svc := NewService(pool, "v1.0.0", nil, logger.Default())
|
|
|
|
resp, err := svc.Get()
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
if resp.Current != "v1.0.0" {
|
|
t.Errorf("current=%q", resp.Current)
|
|
}
|
|
if resp.Latest != "" || resp.LatestURL != "" {
|
|
t.Errorf("expected zero latest, got %+v", resp)
|
|
}
|
|
if !resp.LatestCheckedAt.IsZero() {
|
|
t.Errorf("expected zero checkedAt, got %v", resp.LatestCheckedAt)
|
|
}
|
|
if resp.UpdateAvailable {
|
|
t.Errorf("expected UpdateAvailable=false on fresh DB")
|
|
}
|
|
}
|
|
|
|
func TestService_Check_PersistsToMeta(t *testing.T) {
|
|
pool := newTestPool(t)
|
|
srv, count := newStubGitHub(t, "v1.0.1", "https://example/v1.0.1")
|
|
svc := NewService(pool, "v1.0.0", srv.Client(), logger.Default())
|
|
svc.SetReleaseURL(srv.URL)
|
|
|
|
resp, err := svc.Check(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Check: %v", err)
|
|
}
|
|
if resp.Latest != "v1.0.1" || resp.LatestURL != "https://example/v1.0.1" {
|
|
t.Errorf("unexpected resp %+v", resp)
|
|
}
|
|
if !resp.UpdateAvailable {
|
|
t.Errorf("expected UpdateAvailable=true (v1.0.1 > v1.0.0)")
|
|
}
|
|
if atomic.LoadInt32(count) != 1 {
|
|
t.Errorf("expected 1 github call, got %d", *count)
|
|
}
|
|
|
|
// Persistence side-effect: a fresh Get should return the same values.
|
|
got, err := svc.Get()
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
if got.Latest != "v1.0.1" {
|
|
t.Errorf("Get latest=%q", got.Latest)
|
|
}
|
|
}
|
|
|
|
func TestService_Check_RateLimited(t *testing.T) {
|
|
pool := newTestPool(t)
|
|
srv, _ := newStubGitHub(t, "v1.0.1", "https://example")
|
|
svc := NewService(pool, "v1.0.0", srv.Client(), logger.Default())
|
|
svc.SetReleaseURL(srv.URL)
|
|
|
|
if _, err := svc.Check(context.Background()); err != nil {
|
|
t.Fatalf("first Check: %v", err)
|
|
}
|
|
_, err := svc.Check(context.Background())
|
|
if !errors.Is(err, ErrRateLimited) {
|
|
t.Fatalf("expected ErrRateLimited, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestService_Check_FailurePreservesPersisted(t *testing.T) {
|
|
pool := newTestPool(t)
|
|
// First a successful stub to seed kandev_meta.
|
|
okSrv, _ := newStubGitHub(t, "v1.0.1", "https://example/v1.0.1")
|
|
svc := NewService(pool, "v1.0.0", okSrv.Client(), logger.Default())
|
|
svc.SetReleaseURL(okSrv.URL)
|
|
if _, err := svc.Check(context.Background()); err != nil {
|
|
t.Fatalf("seed Check: %v", err)
|
|
}
|
|
// Drain limiter so the next Check is allowed.
|
|
svc.limiter = NewLimiter(ManualCheckWindow)
|
|
|
|
// Now swap in a failing stub.
|
|
failSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
http.Error(w, "boom", http.StatusInternalServerError)
|
|
}))
|
|
defer failSrv.Close()
|
|
svc.SetReleaseURL(failSrv.URL)
|
|
|
|
resp, err := svc.Check(context.Background())
|
|
if err == nil {
|
|
t.Fatalf("expected error on failing fetch")
|
|
}
|
|
if resp.Latest != "v1.0.1" {
|
|
t.Errorf("expected preserved latest=v1.0.1, got %q", resp.Latest)
|
|
}
|
|
|
|
// kandev_meta itself must remain unchanged.
|
|
v, u, _, rerr := persistence.ReadLatestVersion(pool.Reader())
|
|
if rerr != nil {
|
|
t.Fatalf("read meta: %v", rerr)
|
|
}
|
|
if v != "v1.0.1" || u != "https://example/v1.0.1" {
|
|
t.Errorf("kandev_meta mutated: v=%q u=%q", v, u)
|
|
}
|
|
}
|
|
|
|
func TestService_UpdateAvailable_Cases(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
current string
|
|
latest string
|
|
want bool
|
|
}{
|
|
{"newer", "v1.0.0", "v1.0.1", true},
|
|
{"equal", "v1.0.1", "v1.0.1", false},
|
|
{"older", "v1.0.1", "v1.0.0", false},
|
|
{"dev_current", "dev", "v1.0.1", false},
|
|
{"empty_current", "", "v1.0.1", false},
|
|
{"invalid_latest", "v1.0.0", "not-a-version", false},
|
|
{"no_prefix", "1.0.0", "1.0.1", true},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
svc := NewService(nil, tc.current, nil, logger.Default())
|
|
if got := svc.updateAvailable(tc.latest); got != tc.want {
|
|
t.Errorf("updateAvailable(%q,%q)=%v want %v", tc.current, tc.latest, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestService_StartPoller_TicksEvery6h(t *testing.T) {
|
|
// We use an injected Fetcher rather than an httptest.Server because
|
|
// real HTTP I/O sits outside the synctest fake-time bubble and would
|
|
// prevent synctest.Wait from settling on parked goroutines.
|
|
synctest.Test(t, func(t *testing.T) {
|
|
pool := newTestPool(t)
|
|
var count int32
|
|
svc := NewService(pool, "v1.0.0", nil, logger.Default())
|
|
svc.SetFetcher(func(_ context.Context) (string, string, error) {
|
|
atomic.AddInt32(&count, 1)
|
|
return "v1.0.1", "https://example/v1.0.1", nil
|
|
})
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
svc.StartPoller(ctx)
|
|
defer svc.StopPoller()
|
|
|
|
// Initial probe runs immediately after Start.
|
|
synctest.Wait()
|
|
if got := atomic.LoadInt32(&count); got != 1 {
|
|
t.Fatalf("expected 1 initial call, got %d", got)
|
|
}
|
|
|
|
// Advance fake time by one full interval; one extra tick should fire.
|
|
time.Sleep(PollInterval)
|
|
synctest.Wait()
|
|
if got := atomic.LoadInt32(&count); got != 2 {
|
|
t.Fatalf("expected 2 calls after 6h, got %d", got)
|
|
}
|
|
|
|
// And one more interval = one more call.
|
|
time.Sleep(PollInterval)
|
|
synctest.Wait()
|
|
if got := atomic.LoadInt32(&count); got != 3 {
|
|
t.Fatalf("expected 3 calls after 12h, got %d", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestService_tickOnce_PersistsAndSurvivesFailures(t *testing.T) {
|
|
pool := newTestPool(t)
|
|
srv, count := newStubGitHub(t, "v1.0.1", "https://example/v1.0.1")
|
|
svc := NewService(pool, "v1.0.0", srv.Client(), logger.Default())
|
|
svc.SetReleaseURL(srv.URL)
|
|
|
|
svc.tickOnce(context.Background())
|
|
if atomic.LoadInt32(count) != 1 {
|
|
t.Fatalf("expected 1 github call, got %d", *count)
|
|
}
|
|
v, _, _, err := persistence.ReadLatestVersion(pool.Reader())
|
|
if err != nil {
|
|
t.Fatalf("ReadLatestVersion: %v", err)
|
|
}
|
|
if v != "v1.0.1" {
|
|
t.Errorf("expected persisted v1.0.1, got %q", v)
|
|
}
|
|
}
|