hub v0.120.0: app_update_undone/held reach the household, per app, in its language
gates / gates (push) Successful in 26s

Allowlisted, not operator-only, seeded for new households and added
once (add-only) to every existing enabled_events row. mail.event entries
in hu and en name the app from details.stack_name. Per-app cooldown on
both the operator and the household leg.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-23 13:47:13 +02:00
parent 05ea21e918
commit d3b284863e
22 changed files with 563 additions and 5 deletions
+101
View File
@@ -0,0 +1,101 @@
package store
import (
"io"
"log"
"path/filepath"
"reflect"
"testing"
)
// v0.120.0 — every EXISTING household hears about an undone or held update (`09` §3 decision 15),
// by a ONE-TIME, ADD-ONLY migration. Run the real way: a DB written by an older hub (no guard row),
// reopened by this one.
//
// COMPANION RED-PROOF (REPORT.md): drop the SeedEventTypesOnce call from migrate() — c1 then keeps
// [backup_failed] and this test fails on the first row.
func TestAppUpdateSeed_ExistingHouseholdsGainBothTypesOnce(t *testing.T) {
path := filepath.Join(t.TempDir(), "hub.db")
s, err := New(path, log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
// An older hub's DB: no guard row, four households.
if _, err := s.db.Exec(`DELETE FROM hub_settings WHERE key = 'seed_app_update_events_v1'`); err != nil {
t.Fatal(err)
}
for id, ev := range map[string][]string{
"c1": {"backup_failed"},
"c2": {"app_update_undone"},
"c3": {"app_update_undone", "app_update_held", "disk_warning"},
} {
if err := s.SaveNotificationPrefs(id, id+"@example.com", ev, 6); err != nil {
t.Fatal(err)
}
}
if err := s.SaveNotificationPrefs("c4", "c4@example.com", nil, 6); err != nil {
t.Fatal(err)
}
if _, err := s.db.Exec(`UPDATE customer_notifications SET enabled_events = 'not json' WHERE customer_id = 'c4'`); err != nil {
t.Fatal(err)
}
s.Close()
s = reopen(t, path)
want := map[string][]string{
"c1": {"backup_failed", "app_update_undone", "app_update_held"},
"c2": {"app_update_undone", "app_update_held"},
"c3": {"app_update_undone", "app_update_held", "disk_warning"}, // untouched — order kept
}
for id, w := range want {
p, err := s.GetNotificationPrefs(id)
if err != nil || p == nil {
t.Fatalf("%s: %v", id, err)
}
if !reflect.DeepEqual(p.EnabledEvents, w) {
t.Errorf("%s: enabled_events = %v, want %v (add-only: nothing removed, nothing reordered)", id, p.EnabledEvents, w)
}
}
var raw string
if err := s.db.QueryRow(`SELECT enabled_events FROM customer_notifications WHERE customer_id='c4'`).Scan(&raw); err != nil || raw != "not json" {
t.Errorf("a corrupt row must be left exactly as it was, got %q (%v)", raw, err)
}
// A household that opts OUT afterwards stays out: the migration runs once.
if err := s.SaveNotificationPrefs("c1", "c1@example.com", []string{"backup_failed"}, 6); err != nil {
t.Fatal(err)
}
s.Close()
s = reopen(t, path)
if p, _ := s.GetNotificationPrefs("c1"); !reflect.DeepEqual(p.EnabledEvents, []string{"backup_failed"}) {
t.Fatalf("a later opt-out must stick — the seed must run ONCE; got %v", p.EnabledEvents)
}
}
// The direct call reports which rows changed — that list is what the startup log names.
func TestAppUpdateSeed_ReportsTheChangedRows(t *testing.T) {
s := newTestStore(t)
if err := s.SaveNotificationPrefs("a", "a@example.com", []string{"backup_failed"}, 6); err != nil {
t.Fatal(err)
}
if err := s.SaveNotificationPrefs("b", "b@example.com", []string{"x_undone", "x_held"}, 6); err != nil {
t.Fatal(err)
}
got, err := s.SeedEventTypesOnce("test_guard", []string{"x_undone", "x_held"})
if err != nil || !reflect.DeepEqual(got, []string{"a"}) {
t.Fatalf("changed = %v (%v), want [a]", got, err)
}
if again, err := s.SeedEventTypesOnce("test_guard", []string{"x_undone", "x_held"}); err != nil || again != nil {
t.Fatalf("a second run must do nothing, got %v (%v)", again, err)
}
}
func reopen(t *testing.T, path string) *Store {
t.Helper()
s, err := New(path, log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { s.Close() })
return s
}
+71
View File
@@ -793,9 +793,80 @@ func (s *Store) migrate() error {
}
}
// v0.120.0 (`09` §3 decision 15): every existing household hears about an undone or held update.
// ONE-TIME and ADD-ONLY — see SeedEventTypesOnce.
if changed, err := s.SeedEventTypesOnce("seed_app_update_events_v1", []string{"app_update_undone", "app_update_held"}); err != nil {
return fmt.Errorf("app-update event seed: %w", err)
} else if changed != nil && s.logger != nil {
s.logger.Printf("[INFO] [store] app-update event types added to %d household(s)' notification prefs (one-time, add-only): %v", len(changed), changed)
}
return nil
}
// SeedEventTypesOnce adds event types to EVERY existing household's enabled_events, once (guarded by a
// hub_settings row named guardKey), and returns the customers whose row changed (nil when the guard was
// already set).
//
// ADD-ONLY, and that is the whole safety property: it never removes a type and never touches a row that
// already lists them. A household could not have switched off a type that did not exist, so adding it
// respects their choice; a LATER opt-out sticks because the guard makes this run once. A corrupt
// enabled_events value is left alone and named in the log (never "repaired" into a guess).
func (s *Store) SeedEventTypesOnce(guardKey string, types []string) ([]string, error) {
var done string
if err := s.db.QueryRow(`SELECT value FROM hub_settings WHERE key = ?`, guardKey).Scan(&done); err == nil && done != "" {
return nil, nil
}
rows, err := s.db.Query(`SELECT customer_id, enabled_events FROM customer_notifications`)
if err != nil {
return nil, err
}
type row struct{ id, ev string }
var all []row
for rows.Next() {
var r row
if err := rows.Scan(&r.id, &r.ev); err != nil {
rows.Close()
return nil, err
}
all = append(all, r)
}
rows.Close()
changed := []string{}
for _, r := range all {
var list []string
if err := json.Unmarshal([]byte(r.ev), &list); err != nil {
if s.logger != nil {
s.logger.Printf("[WARN] [store] event seed %s: %s has corrupt enabled_events — left as it is: %v", guardKey, r.id, err)
}
continue
}
have := map[string]bool{}
for _, e := range list {
have[e] = true
}
added := false
for _, t := range types {
if !have[t] {
list = append(list, t)
added = true
}
}
if !added {
continue
}
b, _ := json.Marshal(list)
if _, err := s.db.Exec(`UPDATE customer_notifications SET enabled_events = ?, updated_at = datetime('now') WHERE customer_id = ?`, string(b), r.id); err != nil {
return nil, err
}
changed = append(changed, r.id)
}
if _, err := s.db.Exec(`INSERT INTO hub_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, guardKey, time.Now().UTC().Format(time.RFC3339)); err != nil {
return nil, err
}
return changed, nil
}
// NotificationPrefs holds per-customer notification preferences.
type NotificationPrefs struct {
CustomerID string