mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 16:46:17 +00:00
feat(datastore): add UpdateCheckState persistence
First piece of #591 (opt-in periodic update check). A small persisted state (last_checked_at, last_seen_version) under update-check.json, mirroring Settings' Get/Save shape — separate from Settings itself since this is runtime state, not operator-editable config. Not wired to anything yet. Refs #591
This commit is contained in:
@@ -8,3 +8,4 @@ parity_mismatches/
|
||||
stats/
|
||||
patterns.json
|
||||
settings.json
|
||||
update-check.json
|
||||
|
||||
@@ -2720,6 +2720,62 @@ func (ds *DataStore) SaveSettings(settings Settings) error {
|
||||
return ds.atomicWriteFile(path, data)
|
||||
}
|
||||
|
||||
// UpdateCheckState is the small persisted state for the opt-in periodic
|
||||
// update check (#591, _/i591/design-update-check.md): when it last ran and
|
||||
// what it last saw, so a restart doesn't lose the "already logged this
|
||||
// version" and "don't hammer GitHub on every startup" context. Separate
|
||||
// from Settings, which is operator-editable config, not runtime state.
|
||||
type UpdateCheckState struct {
|
||||
LastCheckedAt string `json:"last_checked_at,omitempty"`
|
||||
LastSeenVersion string `json:"last_seen_version,omitempty"`
|
||||
}
|
||||
|
||||
// GetUpdateCheckState retrieves the persisted update-check state. Same
|
||||
// missing-file-is-not-an-error shape as GetSettings — a fresh install (or
|
||||
// one that has never had the check enabled) has no file yet.
|
||||
func (ds *DataStore) GetUpdateCheckState() (UpdateCheckState, error) {
|
||||
if ds == nil || ds.DataDir == "" {
|
||||
return UpdateCheckState{}, nil
|
||||
}
|
||||
|
||||
path := filepath.Join(ds.DataDir, "update-check.json")
|
||||
if !ds.rootExists(path) {
|
||||
return UpdateCheckState{}, nil
|
||||
}
|
||||
|
||||
data, err := ds.rootReadFile(path)
|
||||
if err != nil {
|
||||
return UpdateCheckState{}, err
|
||||
}
|
||||
|
||||
var state UpdateCheckState
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return UpdateCheckState{}, err
|
||||
}
|
||||
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// SaveUpdateCheckState persists the update-check state.
|
||||
func (ds *DataStore) SaveUpdateCheckState(state UpdateCheckState) error {
|
||||
if ds == nil || ds.DataDir == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := ds.rootMkdirAll(ds.DataDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create data directory: %w", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(ds.DataDir, "update-check.json")
|
||||
|
||||
data, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return ds.atomicWriteFile(path, data)
|
||||
}
|
||||
|
||||
// SaveUsageStats saves usage statistics to the datastore.
|
||||
func (ds *DataStore) SaveUsageStats(stats models.UsageStats) error {
|
||||
dir := filepath.Join(ds.DataDir, "stats", "usage")
|
||||
|
||||
@@ -458,6 +458,57 @@ func TestSettingsPersistence(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateCheckState_MissingFileReturnsZeroValue verifies a fresh install
|
||||
// (or one where the update check has never run) gets a zero-value state,
|
||||
// not an error — same shape as GetSettings on a missing settings.json.
|
||||
func TestUpdateCheckState_MissingFileReturnsZeroValue(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "update-check-missing-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
|
||||
state, err := ds.GetUpdateCheckState()
|
||||
if err != nil {
|
||||
t.Fatalf("GetUpdateCheckState on a fresh install should not error, got: %v", err)
|
||||
}
|
||||
if state != (UpdateCheckState{}) {
|
||||
t.Errorf("Expected zero-value state, got %+v", state)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateCheckState_Persistence is the roundtrip test, mirroring
|
||||
// TestSettingsPersistence.
|
||||
func TestUpdateCheckState_Persistence(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "update-check-persist-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := NewDataStore(tempDir)
|
||||
|
||||
state := UpdateCheckState{
|
||||
LastCheckedAt: "2026-08-09T12:00:00Z",
|
||||
LastSeenVersion: "v0.122.0",
|
||||
}
|
||||
|
||||
if err := ds.SaveUpdateCheckState(state); err != nil {
|
||||
t.Fatalf("SaveUpdateCheckState failed: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := ds.GetUpdateCheckState()
|
||||
if err != nil {
|
||||
t.Fatalf("GetUpdateCheckState failed: %v", err)
|
||||
}
|
||||
|
||||
if loaded != state {
|
||||
t.Errorf("Expected %+v, got %+v", state, loaded)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordActivity_EmptyKindReturnsNilNotError verifies GetActivityRecords
|
||||
// for a kind that was never recorded returns an empty, non-error result —
|
||||
// the "nothing recorded yet" case, not a failure.
|
||||
|
||||
Reference in New Issue
Block a user