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 }