hub v0.120.0: app_update_undone/held reach the household, per app, in its language
gates / gates (push) Successful in 26s
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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user