package notify import ( "flag" "os" "path/filepath" "sort" "testing" "gitea.dooplex.hu/admin/felhom-hub/internal/i18n" "time" ) // Mail goldens — the measurement localisation slice 3 rests on (R-558). // // Every customer-facing mail the hub can send is rendered here and compared against bytes captured // from the code as it stood BEFORE any string moved (felhom.eu 20aafc3dec20, hub v0.117.0). The // claim "the Hungarian mails are unchanged" is then a diff, not a reading. // // THE RULE THAT MAKES THEM WORTH HAVING: a golden is never regenerated to make a change pass. It is // regenerated only for a block that has been declared and measured, and the declaration says which // mail and why. Slice 2 on the controller earned that sentence twice. // // Regenerate deliberately: go test ./internal/notify/ -run TestCustomerMailGoldens -update-mail-goldens var updateMailGoldens = flag.Bool("update-mail-goldens", false, "rewrite the captured mail goldens (deliberate, declared blocks only)") // goldenClock is the instant every golden is stamped with. A fixed winter date, so the Budapest // offset in the operator mail's `MST` field is stable too (CET, not CEST). var goldenClock = time.Date(2026, 1, 15, 9, 30, 0, 0, time.UTC) // mailCase is one rendered mail: a name for its golden file, and the call that produces it. type mailCase struct { name string render func() (string, string) // subject, body comment string } // customerMailCases enumerates every customer mail the hub can produce. // // The event-type cases are generated FROM `customerMessages` itself rather than from a list retyped // here, so a new entry cannot be added without a golden appearing beside it — the list cannot drift // out of date the way a hand-maintained one does (R-423's lesson, other side). func customerMailCases(lang string) []mailCase { var cases []mailCase keys := make([]string, 0, len(customerMessages)) for k := range customerMessages { keys = append(keys, k) } sort.Strings(keys) // 1. Every known event type, rendered with no box message and no details — the plain shape. for _, k := range keys { k := k cases = append(cases, mailCase{ name: "customer_event_" + k, render: func() (string, string) { return FormatCustomerEmail(lang, "demo-fixture", k, "warning", "", "", "") }, }) } // 2. The shapes that are NOT one per event type: the fallbacks and the optional lines. cases = append(cases, mailCase{ name: "customer_shape_unknown_type_uses_box_message", comment: "an event type with no entry: the box's own Hungarian sentence IS the mail", render: func() (string, string) { return FormatCustomerEmail(lang, "demo-fixture", "disk_warning", "warning", "A /mnt/adat lemez 91%-ig megtelt (12,4 GB szabad).", "", "") }, }, mailCase{ name: "customer_shape_box_message_beside_entry", comment: "a known type AND a box message: the entry is the headline, the message is a line", render: func() (string, string) { return FormatCustomerEmail(lang, "demo-fixture", "backup_failed", "error", "A cel nincs beallitva.", "", "") }, }, mailCase{ name: "customer_shape_with_details", comment: "details render as the Megjegyzes line", render: func() (string, string) { return FormatCustomerEmail(lang, "demo-fixture", "app_deployed", "info", "", "", `{"app":"bentopdf"}`) }, }, mailCase{ name: "customer_shape_app_update_undone_names_the_app", comment: "v0.120.0: the entry names the app from details.stack_name; the box's sentence is the line", render: func() (string, string) { return FormatCustomerEmail(lang, "demo-fixture", "app_update_undone", "warning", "A(z) docmost frissitese nem sikerult.", "", `{"app":"docmost","stack_name":"docmost","from":{"docmost":"docmost:0.95.0"},"to":{"docmost":"docmost:0.96.0"}}`) }, }, mailCase{ name: "customer_shape_app_update_held_names_the_app", comment: "v0.120.0: the held entry names the app; the hold sentence is the line", render: func() (string, string) { return FormatCustomerEmail(lang, "demo-fixture", "app_update_held", "error", "A frissites nem sikerult, es az automatikus visszaallitas sem.", "", `{"app":"vikunja","stack_name":"vikunja","copy_tier":2}`) }, }, mailCase{ name: "customer_shape_empty_details_object_is_omitted", comment: "{} is not details", render: func() (string, string) { return FormatCustomerEmail(lang, "demo-fixture", "app_deployed", "info", "", "", "{}") }, }, mailCase{ name: "customer_shape_unknown_severity_prints_raw", comment: "an unlabelled severity falls through to its own name", render: func() (string, string) { return FormatCustomerEmail(lang, "demo-fixture", "test", "verbose", "", "", "") }, }, ) for _, sev := range []string{"info", "warning", "error", "critical"} { sev := sev cases = append(cases, mailCase{ name: "customer_severity_" + sev, render: func() (string, string) { return FormatCustomerEmail(lang, "demo-fixture", "test", sev, "", "", "") }, }) } // 3. The claim arc and the self-bind link — the mails sent before any box reports. for _, kind := range []string{"claim", "reset", "reenroll", "claimed"} { kind := kind cases = append(cases, mailCase{ name: "claim_" + kind, render: func() (string, string) { return FormatClaimEmail(lang, kind, "demo-fixture", "fixture.example", "alma-korte-szilva") }, }) } cases = append(cases, mailCase{ name: "selfbind", render: func() (string, string) { return FormatSelfBindEmail(lang, "demo-fixture", "https://hub.felhom.eu/bind/FIXTURETOKEN") }, }) // 4. The OPERATOR mails. They are English and this slice does not touch them — which is exactly // why they are goldened: the claim "the operator's mail did not move" needs the same diff. cases = append(cases, mailCase{ name: "operator_event_backup_failed", render: func() (string, string) { return FormatOperatorEmail("demo-fixture", "backup_failed", "error", "A mentes sikertelen.", "") }, }, mailCase{ name: "operator_event_recovered", render: func() (string, string) { return FormatOperatorEmail("demo-fixture", "node_recovered", "info", "", "") }, }, mailCase{ name: "operator_backup_run_failures_digest", comment: "the R-182 digest — operator-only, already English, never localised", render: func() (string, string) { return FormatOperatorEmail("demo-fixture", "backup_run_failures", "error", "3 app failed", `{ "run_id":"r1","run_kind":"nightly","failed":2,"attempted":4, "target_path":"/mnt/backup","used_gb":81.5,"avail_gb":12.5,"total_gb":94.0, "used_percent":86.7,"space_known":true, "apps":[{"app":"bookstack","leg":"db","reason":"mysqldump exit 1 — /mnt/backup: 81.5/94.0 GB used (87%), 12.5 GB free"}, {"app":"vaultwarden","leg":"volumes","reason":"no space left on device"}]}`) }, }, ) return cases } func goldenDir(lang string) string { return filepath.Join("testdata", "mail_goldens", lang) } // renderGolden is the on-disk form: subject and body in one file, so a diff shows both. func renderGolden(c mailCase) string { subject, body := c.render() out := "" if c.comment != "" { out += "# " + c.comment + "\n" } return out + "SUBJECT: " + subject + "\n---\n" + body + "\n" } func TestCustomerMailGoldens(t *testing.T) { old := nowFn nowFn = func() time.Time { return goldenClock } t.Cleanup(func() { nowFn = old }) for _, lang := range i18n.Supported { t.Run(lang, func(t *testing.T) { runMailGoldens(t, lang) }) } } // runMailGoldens renders every mail in one language and diffs it against that language's captured // bytes. Hungarian proves nothing moved; English proves what shipped is what was reviewed. func runMailGoldens(t *testing.T, lang string) { cases := customerMailCases(lang) if len(cases) < 50 { t.Fatalf("only %d mail cases enumerated — the table stopped covering the mails", len(cases)) } if *updateMailGoldens { if err := os.MkdirAll(goldenDir(lang), 0o755); err != nil { t.Fatal(err) } for _, c := range cases { p := filepath.Join(goldenDir(lang), c.name+".txt") if err := os.WriteFile(p, []byte(renderGolden(c)), 0o644); err != nil { t.Fatal(err) } } t.Logf("wrote %d %s mail goldens to %s", len(cases), lang, goldenDir(lang)) return } for _, c := range cases { t.Run(c.name, func(t *testing.T) { p := filepath.Join(goldenDir(lang), c.name+".txt") want, err := os.ReadFile(p) if err != nil { t.Fatalf("no golden for %s — capture it deliberately: %v", c.name, err) } got := renderGolden(c) if got != string(want) { t.Errorf("mail %s changed.\n--- golden ---\n%s\n--- now ---\n%s", c.name, want, got) } }) } // Every golden on disk belongs to a case: a file left behind after a mail is deleted would // otherwise sit there proving nothing about a mail that no longer exists. entries, err := os.ReadDir(goldenDir(lang)) if err != nil { t.Fatal(err) } known := map[string]bool{} for _, c := range cases { known[c.name+".txt"] = true } for _, e := range entries { if !known[e.Name()] { t.Errorf("orphan golden %s — no case renders it", e.Name()) } } }