package store import ( "encoding/json" "testing" ) func seedIssue(t *testing.T, s *Store, app, fp, affected, ctxCust, ctx string) { t.Helper() if _, err := s.db.Exec(`INSERT INTO app_log_issues (app_name, fingerprint, severity, message, first_seen, last_seen, affected_customers, context, context_customer) VALUES (?, ?, 'error', 'm', datetime('now'), datetime('now'), ?, ?, ?)`, app, fp, affected, ctx, ctxCust); err != nil { t.Fatal(err) } } func issueRow(t *testing.T, s *Store, app, fp string) (affected []string, ctxCust, ctx string, found bool) { t.Helper() var a string err := s.db.QueryRow(`SELECT affected_customers, COALESCE(context_customer,''), COALESCE(context,'') FROM app_log_issues WHERE app_name=? AND fingerprint=?`, app, fp).Scan(&a, &ctxCust, &ctx) if err != nil { return nil, "", "", false } _ = json.Unmarshal([]byte(a), &affected) return affected, ctxCust, ctx, true } // R-244 (S6): the customer delete de-references the shared rows. The CONSEQUENCE: the gone customer is // named nowhere, a row only they had is gone, a row shared with a live customer keeps that customer's // history, and a near-namesake ("gone" vs "gone2") is untouched. // RED-PROOF: remove the dereferenceCustomerFromAppIssues call from PurgeCustomerResidue → the orphan row // survives ("orphan row survived"). func TestDereferenceCustomerFromAppIssues(t *testing.T) { s := newTestStore(t) seedIssue(t, s, "immich", "a", `["gone"]`, "gone", `["line from gone"]`) seedIssue(t, s, "immich", "b", `["gone","live"]`, "gone", `["line from gone"]`) seedIssue(t, s, "immich", "c", `["live","gone"]`, "live", `["line from live"]`) seedIssue(t, s, "immich", "d", `["gone2"]`, "gone2", `["x"]`) if err := s.PurgeCustomerResidue("gone"); err != nil { t.Fatal(err) } if _, _, _, found := issueRow(t, s, "immich", "a"); found { t.Fatal("orphan row survived — a row only the deleted customer had must go") } if a, cc, ctx, _ := issueRow(t, s, "immich", "b"); len(a) != 1 || a[0] != "live" || cc != "" || ctx != "" { t.Fatalf("shared row b: affected=%v context_customer=%q context=%q — want [live], the gone customer's lines dropped", a, cc, ctx) } if a, cc, ctx, _ := issueRow(t, s, "immich", "c"); len(a) != 1 || a[0] != "live" || cc != "live" || ctx == "" { t.Fatalf("shared row c: affected=%v context_customer=%q — the live customer's lines must stay", a, cc) } if a, _, _, found := issueRow(t, s, "immich", "d"); !found || len(a) != 1 || a[0] != "gone2" { t.Fatalf("near-namesake row d was touched: %v found=%v", a, found) } } // The one-time sweep: count first (changes nothing), then apply; a customer the hub still knows by any // of config / host / report is never touched. func TestSweepDeletedAppIssueRefs(t *testing.T) { s := newTestStore(t) if err := s.SaveReport("live", []byte(`{}`)); err != nil { t.Fatal(err) } seedIssue(t, s, "x", "1", `["old-venue"]`, "old-venue", `["l"]`) seedIssue(t, s, "x", "2", `["old-venue","live"]`, "live", `["l"]`) plan, err := s.SweepDeletedAppIssueRefs(false) if err != nil || len(plan) != 1 || plan[0].CustomerID != "old-venue" || plan[0].Deleted != 1 || plan[0].Dereferenced != 1 { t.Fatalf("dry run plan = %+v err=%v, want old-venue deleted 1 dereferenced 1", plan, err) } if _, _, _, found := issueRow(t, s, "x", "1"); !found { t.Fatal("the dry run changed the table") } if _, err := s.SweepDeletedAppIssueRefs(true); err != nil { t.Fatal(err) } if _, _, _, found := issueRow(t, s, "x", "1"); found { t.Fatal("apply left the orphan row") } if a, cc, _, _ := issueRow(t, s, "x", "2"); len(a) != 1 || a[0] != "live" || cc != "live" { t.Fatalf("apply damaged the live customer's row: %v %q", a, cc) } again, _ := s.SweepDeletedAppIssueRefs(false) if len(again) != 0 { t.Fatalf("the sweep is not idempotent: %+v", again) } }