d3b284863e
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
249 lines
9.1 KiB
Go
249 lines
9.1 KiB
Go
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())
|
|
}
|
|
}
|
|
}
|