5db3e48262
* feat: add opt-in plugin auto-updater Automatically update installed plugins in the background, off by default and opt-in. Builds on the existing verified install pipeline — an auto-update is just an automatic reinstall of a newer catalog package_url. Two-tier toggle on Settings > Plugins: an instance-wide default (SQLite plugin_settings) plus a per-plugin override (tri-state auto_update on the plugin record, carried forward on upgrade). The AutoUpdatePoller (~90-min cadence + a boot sweep) upgrades only plugins that are currently active, opted in, and report update_available; disabled/errored plugins stay on their installed version. New endpoints: GET/PUT /api/plugins/settings and PUT /api/plugins/:id/auto-update. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(plugins): recheck auto-update eligibility at install and fix test mock Address PR #1903 review feedback: - Re-check each plugin's current status + effective opt-in immediately before InstallFromURL (eligibleForAutoUpdate), closing the TOCTOU window between the candidate snapshot / catalog fetch and the install so a plugin disabled or opted out mid-sweep is never reinstalled and reactivated. - Correct the marketplace spec to describe the real failure behavior (shared with manual updates) instead of promising a version rollback the install pipeline does not perform. - Fix the frontend CI failure: the plugins page test mocked the whole plugins-api module without the new settings/auto-update functions, so useAutoUpdateSettings threw at mount. - Add an aria-label to the per-plugin auto-update Switch and Reset control. - Add a use-auto-update-settings hook test (load, failure, optimistic setDefault, rollback) and an eligibleForAutoUpdate unit test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
95 lines
2.4 KiB
Go
95 lines
2.4 KiB
Go
package plugins
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/jmoiron/sqlx"
|
|
_ "github.com/mattn/go-sqlite3"
|
|
|
|
"github.com/kandev/kandev/internal/db"
|
|
)
|
|
|
|
func newTestSettingsStore(t *testing.T) *settingsStore {
|
|
t.Helper()
|
|
conn, err := sqlx.Open("sqlite3", ":memory:")
|
|
if err != nil {
|
|
t.Fatalf("open sqlite: %v", err)
|
|
}
|
|
conn.SetMaxOpenConns(1)
|
|
t.Cleanup(func() { _ = conn.Close() })
|
|
s, err := newSettingsStore(db.NewPool(conn, conn))
|
|
if err != nil {
|
|
t.Fatalf("new settings store: %v", err)
|
|
}
|
|
return s
|
|
}
|
|
|
|
func TestSettingsStoreDefaultsToAutoUpdateOff(t *testing.T) {
|
|
s := newTestSettingsStore(t)
|
|
got, err := s.Get()
|
|
if err != nil {
|
|
t.Fatalf("Get(): %v", err)
|
|
}
|
|
if got.AutoUpdateDefault {
|
|
t.Fatal("fresh settings store should default AutoUpdateDefault to false (opt-in)")
|
|
}
|
|
}
|
|
|
|
func TestSettingsStoreSetAndGetRoundTrips(t *testing.T) {
|
|
s := newTestSettingsStore(t)
|
|
if err := s.SetAutoUpdateDefault(true); err != nil {
|
|
t.Fatalf("SetAutoUpdateDefault(true): %v", err)
|
|
}
|
|
got, err := s.Get()
|
|
if err != nil {
|
|
t.Fatalf("Get(): %v", err)
|
|
}
|
|
if !got.AutoUpdateDefault {
|
|
t.Fatal("AutoUpdateDefault = false after SetAutoUpdateDefault(true)")
|
|
}
|
|
|
|
// Toggling back off overwrites the single row rather than inserting a
|
|
// second one (the id=1 CHECK + ON CONFLICT upsert).
|
|
if err := s.SetAutoUpdateDefault(false); err != nil {
|
|
t.Fatalf("SetAutoUpdateDefault(false): %v", err)
|
|
}
|
|
got, err = s.Get()
|
|
if err != nil {
|
|
t.Fatalf("Get(): %v", err)
|
|
}
|
|
if got.AutoUpdateDefault {
|
|
t.Fatal("AutoUpdateDefault = true after SetAutoUpdateDefault(false)")
|
|
}
|
|
}
|
|
|
|
func TestSettingsStoreSchemaInitIsReplayable(t *testing.T) {
|
|
conn, err := sqlx.Open("sqlite3", ":memory:")
|
|
if err != nil {
|
|
t.Fatalf("open sqlite: %v", err)
|
|
}
|
|
conn.SetMaxOpenConns(1)
|
|
t.Cleanup(func() { _ = conn.Close() })
|
|
pool := db.NewPool(conn, conn)
|
|
|
|
first, err := newSettingsStore(pool)
|
|
if err != nil {
|
|
t.Fatalf("first newSettingsStore(): %v", err)
|
|
}
|
|
if err := first.SetAutoUpdateDefault(true); err != nil {
|
|
t.Fatalf("SetAutoUpdateDefault(): %v", err)
|
|
}
|
|
// A second construction on the same DB (e.g. a restart) must not error on
|
|
// the CREATE TABLE and must see the previously persisted value.
|
|
second, err := newSettingsStore(pool)
|
|
if err != nil {
|
|
t.Fatalf("second newSettingsStore(): %v", err)
|
|
}
|
|
got, err := second.Get()
|
|
if err != nil {
|
|
t.Fatalf("Get(): %v", err)
|
|
}
|
|
if !got.AutoUpdateDefault {
|
|
t.Fatal("persisted AutoUpdateDefault did not survive a second store construction")
|
|
}
|
|
}
|