hub v0.120.0: app_update_undone/held reach the household, per app, in its language
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:
2026-09-23 13:47:13 +02:00
parent 05ea21e918
commit d3b284863e
22 changed files with 563 additions and 5 deletions
+29
View File
@@ -1,3 +1,32 @@
## v0.120.0 — the household is TOLD when an update is undone or held (2026-09-23, `09` §3 decision 15)
Controller v0.264.0 sends two new events when a guarded app update does not go through. This hub
release lets them reach the household, in its language, one mail per app per outcome.
- **`app_update_undone`** (warning) — the update failed and the box put the previous version and its
data back by itself. **`app_update_held`** (error) — the update failed, the undo failed too (or the
update held for another reason), and the app is stopped until a restore.
- **Both in `allowedEventTypes`** (a push no longer 400s), **NOT operator-only**, and **in
`defaultSeedEvents`** so a new household has them on from day one.
- **`mail.event.*` in Hungarian AND English**, and they **name the app**: a new named register
`appNamedMailEvents` renders the entry with `details.stack_name` (`„docmost: a frissítés nem
sikerült, …"` / `"docmost: the update did not work; …"`). No stack_name → the box's own sentence;
neither → `?` in the app's place. Never a literal `%s`, never raw English. The box's sentence
(the undone line, or the hold sentence in the household's language) is the mail's message line.
- **One-time, ADD-ONLY migration** of every existing `customer_notifications.enabled_events` row
(`Store.SeedEventTypesOnce`, guard row `seed_app_update_events_v1` in `hub_settings`). It never
removes a type or reorders a list; a corrupt row is left as it is and named in the log; a later
opt-out sticks because it runs once. The startup log names every customer whose row changed.
- **Cooldown per app for these two types, on both legs.** Operator: joined R-389's
`perAppCooldownEvents` (the fence test widened on purpose, with its reason). Household: a NEW
sibling register `perAppCustomerCooldownEvents` — the customer key was `customer:type` for every
type, so two apps undone on one night reached the household as ONE mail. `app_start_failed`'s
household grain is deliberately unchanged (pinned).
- A skipped customer mail now logs its cooldown key (it was silent).
- Tests: `TestAppUpdateSeed_*` (store, reopened like a real upgrade), `TestAppUpdateEvents_*`
(dispatcher), `TestAppUpdateOutcomesReachTheHousehold` (api), `TestDefaultSeedIncludesTheUpdateOutcomes`
(claim), 8 new mail goldens. Five red-proofs, each seen failing (REPORT).
## v0.119.0 — English households get English words for their codes (2026-09-21, R-597) ## v0.119.0 — English households get English words for their codes (2026-09-21, R-597)
The 2026-09-20 English drill received a setup code of **three Hungarian words with accents** The 2026-09-20 English drill received a setup code of **three Hungarian words with accents**
@@ -47,3 +47,23 @@ func TestRestoreInterruptedReachesTheHouseholdInHungarian(t *testing.T) {
t.Fatalf("negative control matched — the fragment search is broken") t.Fatalf("negative control matched — the fragment search is broken")
} }
} }
// v0.120.0 — the two update outcomes reach the household, in its language, never as raw English.
// RED-PROOF (REPORT.md): drop app_update_undone from allowedEventTypes → "must be in allowedEventTypes".
func TestAppUpdateOutcomesReachTheHousehold(t *testing.T) {
for _, et := range []string{"app_update_undone", "app_update_held"} {
if !allowedEventTypes[et] {
t.Fatalf("%s must be in allowedEventTypes — the controller's push would 400", et)
}
if notify.IsOperatorOnly(et) {
t.Fatalf("%s must reach the household", et)
}
const raw = "RAW-ENGLISH-SENTINEL"
for _, lang := range []string{"hu", "en"} {
subject, _ := notify.FormatCustomerEmail(lang, "c1", et, "warning", raw, "", `{"stack_name":"docmost"}`)
if strings.Contains(subject, raw) || !strings.Contains(subject, "docmost:") {
t.Fatalf("%s/%s subject must be the entry naming the app, got %q", et, lang, subject)
}
}
}
}
+7
View File
@@ -2013,6 +2013,13 @@ var allowedEventTypes = map[string]bool{
// `backup_run_digest_event_test.go` pins the pair. // `backup_run_digest_event_test.go` pins the pair.
"backup_run_failures": true, "backup_run_failures": true,
// v0.120.0 (`09` §3 decision 15): an app update the box UNDID by itself (warning) and one that ended
// HELD — the undo failed too, or the update held for any reason (error). BOTH reach the household
// (one mail per app per outcome — per-app cooldown, notify.perAppCustomerCooldownEvents) and the
// operator; both carry a Hungarian AND English `mail.event.*` entry, never the raw-English fallback.
"app_update_undone": true,
"app_update_held": true,
// Controller-pushed events // Controller-pushed events
"controller_started": true, "controller_started": true,
"claim_lockout": true, // v0.50.0 — claim/reset code brute-force lockout tripped "claim_lockout": true, // v0.50.0 — claim/reset code brute-force lockout tripped
+5
View File
@@ -233,6 +233,11 @@ var defaultSeedEvents = []string{
"host_disk_critical", "host_disk_critical",
"storage_fill_critical", "storage_fill_critical",
"offbox_repo_orphaned", "offbox_repo_orphaned",
// v0.120.0 (`09` §3 decision 15): the household is told ONCE when an update is undone, and when
// it (or its undo) failed and the app is held. Existing households get them by the one-time,
// add-only store.SeedEventTypesOnce.
"app_update_undone",
"app_update_held",
} }
// MarkClaimed records a controller-reported successful claim and sends the one-time confirmation // MarkClaimed records a controller-reported successful claim and sends the one-time confirmation
+14
View File
@@ -76,3 +76,17 @@ func TestMarkClaimed_EmptyEmail_NoRow_ClaimSucceeds(t *testing.T) {
t.Fatalf("idempotent re-claim: %v", err) t.Fatalf("idempotent re-claim: %v", err)
} }
} }
// v0.120.0 — a NEW household hears about an undone or held update from day one.
// RED-PROOF (REPORT.md): drop app_update_undone from defaultSeedEvents → this fails naming it.
func TestDefaultSeedIncludesTheUpdateOutcomes(t *testing.T) {
have := map[string]bool{}
for _, e := range defaultSeedEvents {
have[e] = true
}
for _, et := range []string{"app_update_undone", "app_update_held"} {
if !have[et] {
t.Fatalf("defaultSeedEvents must include %s", et)
}
}
}
+3 -1
View File
@@ -79,5 +79,7 @@
"bind.invalid.body": "The link is valid for 7 days. If it has expired, ask support for a new one, or your operator can do the linking.", "bind.invalid.body": "The link is valid for 7 days. If it has expired, ask support for a new one, or your operator can do the linking.",
"bind.htmllang": "en", "bind.htmllang": "en",
"mail.test.subject": "[Felhom] Test notification", "mail.test.subject": "[Felhom] Test notification",
"mail.test.body": "Dear Customer,\n\nThis is a test notification from the Felhom monitoring system.\nNotifications are working correctly.\n\nBest regards,\nFelhom.eu monitoring" "mail.test.body": "Dear Customer,\n\nThis is a test notification from the Felhom monitoring system.\nNotifications are working correctly.\n\nBest regards,\nFelhom.eu monitoring",
"mail.event.app_update_undone": "%s: the update did not work; the app runs on its previous version",
"mail.event.app_update_held": "%s: the app is stopped and needs a restore"
} }
+3 -1
View File
@@ -79,5 +79,7 @@
"bind.invalid.body": "A hivatkozás 7 napig érvényes. Ha lejárt, kérj újat az ügyfélszolgálattól, vagy az összekötést az üzemeltető is elvégezheti.", "bind.invalid.body": "A hivatkozás 7 napig érvényes. Ha lejárt, kérj újat az ügyfélszolgálattól, vagy az összekötést az üzemeltető is elvégezheti.",
"bind.htmllang": "hu", "bind.htmllang": "hu",
"mail.test.subject": "[Felhom] Teszt értesítés", "mail.test.subject": "[Felhom] Teszt értesítés",
"mail.test.body": "Kedves Ügyfél!\n\nEz egy teszt értesítés a Felhom monitoring rendszerből.\nAz értesítések megfelelően működnek.\n\nÜdvözlettel,\nFelhom.eu monitoring" "mail.test.body": "Kedves Ügyfél!\n\nEz egy teszt értesítés a Felhom monitoring rendszerből.\nAz értesítések megfelelően működnek.\n\nÜdvözlettel,\nFelhom.eu monitoring",
"mail.event.app_update_undone": "%s: a frissítés nem sikerült, az alkalmazás a korábbi változattal fut",
"mail.event.app_update_held": "%s: az alkalmazás leállítva, visszaállítás szükséges"
} }
@@ -0,0 +1,67 @@
package notify
import (
"io"
"log"
"strings"
"testing"
)
// v0.120.0 — the household hears about an update ONCE per app per event (R-629: a storm is a
// defect), and two apps on one night are two mails.
//
// COMPANION RED-PROOF (REPORT.md): drop the perAppCustomerCooldownEvents suffix in processCustomer —
// the second app's mail is swallowed by the first app's cooldown and this fails on the count.
func TestAppUpdateEvents_OneMailPerAppPerEvent(t *testing.T) {
st := opOnlyStore(t)
if err := st.SaveNotificationPrefs("c1", "customer@example.com",
[]string{"app_update_undone", "app_update_held"}, 6); err != nil {
t.Fatal(err)
}
var subjects []string
d := NewDispatcher(st, "test-key", "from@felhom.eu", "operator@felhom.eu", true, log.New(io.Discard, "", 0))
d.sendEmailFn = func(to, subject, _ string, _ map[string]string) error {
if to == "customer@example.com" {
subjects = append(subjects, subject)
}
return nil
}
fire := func(et, app string) {
d.ProcessEvent("c1", et, "warning", "A(z) "+app+" frissitese nem sikerult.",
`{"app":"`+app+`","stack_name":"`+app+`"}`, "controller")
}
fire("app_update_undone", "docmost")
fire("app_update_undone", "romm") // another app, same night → its own mail
fire("app_update_undone", "docmost") // the same app again → no second mail
fire("app_update_held", "docmost") // another EVENT for the same app → its own mail
if len(subjects) != 3 {
t.Fatalf("want 3 household mails (docmost undone, romm undone, docmost held), got %d: %q", len(subjects), subjects)
}
for i, app := range []string{"docmost", "romm", "docmost"} {
if !strings.Contains(subjects[i], app+":") {
t.Errorf("mail %d must name %s in the subject, got %q", i, app, subjects[i])
}
}
}
// The other types keep their customer-side grain: the new register is not a blanket change.
func TestAppUpdateEvents_OtherTypesKeepTheirCustomerGrain(t *testing.T) {
st := opOnlyStore(t)
if err := st.SaveNotificationPrefs("c1", "customer@example.com", []string{"app_start_failed"}, 6); err != nil {
t.Fatal(err)
}
n := 0
d := NewDispatcher(st, "test-key", "from@felhom.eu", "operator@felhom.eu", true, log.New(io.Discard, "", 0))
d.sendEmailFn = func(to, _, _ string, _ map[string]string) error {
if to == "customer@example.com" {
n++
}
return nil
}
d.ProcessEvent("c1", "app_start_failed", "error", "x", `{"stack_name":"a"}`, "controller")
d.ProcessEvent("c1", "app_start_failed", "error", "x", `{"stack_name":"b"}`, "controller")
if n != 1 {
t.Fatalf("app_start_failed's household cooldown stays per TYPE (unchanged by v0.120.0), got %d mails", n)
}
}
+22
View File
@@ -389,6 +389,18 @@ var perAppCooldownEvents = map[string]bool{
// per-app is the only grain available that does not lose alarms. Measured 2026-08-23: two apps // per-app is the only grain available that does not lose alarms. Measured 2026-08-23: two apps
// four minutes apart produced one mail and one suppression. // four minutes apart produced one mail and one suppression.
"app_start_failed": true, "app_start_failed": true,
// v0.120.0: an update outcome is per app with no digest — two apps on one night are two alarms.
"app_update_undone": true,
"app_update_held": true,
}
// perAppCustomerCooldownEvents is the CUSTOMER-leg sibling of perAppCooldownEvents (v0.120.0). The
// household's cooldown was keyed `customer:type` for every type, so two apps undone on the same night
// would reach the household as ONE mail. Its own register, deliberately — putting app_start_failed's
// customer leg on a per-app key would change a type nobody asked to change.
var perAppCustomerCooldownEvents = map[string]bool{
"app_update_undone": true,
"app_update_held": true,
} }
// cooldownStackSuffix returns ":"+stack_name when the event's details carry a non-empty `stack_name` // cooldownStackSuffix returns ":"+stack_name when the event's details carry a non-empty `stack_name`
@@ -417,6 +429,12 @@ func cooldownStackSuffix(eventType, detailsJSON string) string {
if !perAppCooldownEvents[eventType] { if !perAppCooldownEvents[eventType] {
return "" return ""
} }
return cooldownStackSuffixFor(detailsJSON)
}
// cooldownStackSuffixFor is the payload half of cooldownStackSuffix, register-free — callers decide
// which register applies (operator: perAppCooldownEvents; customer: perAppCustomerCooldownEvents).
func cooldownStackSuffixFor(detailsJSON string) string {
if detailsJSON == "" || !strings.Contains(detailsJSON, "\"stack_name\"") { if detailsJSON == "" || !strings.Contains(detailsJSON, "\"stack_name\"") {
return "" return ""
} }
@@ -677,9 +695,13 @@ func (d *Dispatcher) processCustomer(customerID, eventType, severity, message, m
cooldownDur := time.Duration(cooldownHours) * time.Hour cooldownDur := time.Duration(cooldownHours) * time.Hour
cooldownKey := customerID + ":" + eventType cooldownKey := customerID + ":" + eventType
if perAppCustomerCooldownEvents[eventType] {
cooldownKey += cooldownStackSuffixFor(detailsJSON)
}
d.mu.Lock() d.mu.Lock()
if last, ok := d.custCooldowns[cooldownKey]; ok && time.Since(last) < cooldownDur { if last, ok := d.custCooldowns[cooldownKey]; ok && time.Since(last) < cooldownDur {
d.mu.Unlock() d.mu.Unlock()
d.logger.Printf("[INFO] Customer mail skipped for %s/%s — cooldown (key %s)", customerID, eventType, cooldownKey)
return return
} }
d.custCooldowns[cooldownKey] = time.Now() d.custCooldowns[cooldownKey] = time.Now()
+18
View File
@@ -85,6 +85,24 @@ func customerMailCases(lang string) []mailCase {
return FormatCustomerEmail(lang, "demo-fixture", "app_deployed", "info", "", "", `{"app":"bentopdf"}`) 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{ mailCase{
name: "customer_shape_empty_details_object_is_omitted", name: "customer_shape_empty_details_object_is_omitted",
comment: "{} is not details", comment: "{} is not details",
@@ -61,15 +61,24 @@ func TestR389_StackSuffixIsAllowListedAndFailSoft(t *testing.T) {
} }
} }
// The register must stay narrow. A second entry is a deliberate act and should fail this until // The register must stay narrow. A new entry is a deliberate act and should fail this until
// someone changes it on purpose, having read the fence. // someone changes it on purpose, having read the fence.
//
// v0.120.0 widened it ON PURPOSE, by the brief "the undo reaches the fleet" (`09` §3 decision 15):
// an update outcome is one app's event, with no digest behind it — two apps undone on one night are
// two alarms. The backup family stays coarse, as the fence says.
func TestR389_TheAllowListHasExactlyOneMember(t *testing.T) { func TestR389_TheAllowListHasExactlyOneMember(t *testing.T) {
if len(perAppCooldownEvents) != 1 || !perAppCooldownEvents["app_start_failed"] { want := []string{"app_start_failed", "app_update_held", "app_update_undone"}
ok := len(perAppCooldownEvents) == len(want)
for _, w := range want {
ok = ok && perAppCooldownEvents[w]
}
if !ok {
var got []string var got []string
for k := range perAppCooldownEvents { for k := range perAppCooldownEvents {
got = append(got, k) got = append(got, k)
} }
t.Fatalf("perAppCooldownEvents = %v, want exactly [app_start_failed]. Adding a member is the "+ t.Fatalf("perAppCooldownEvents = %v, want exactly [app_start_failed app_update_held app_update_undone]. Adding a member is the "+
"fenced act: the backup family's cooldown is coarse ON PURPOSE (R-97a, R-182) so one full "+ "fenced act: the backup family's cooldown is coarse ON PURPOSE (R-97a, R-182) so one full "+
"disk sends one digest, not one mail per app. Read the fence before widening this.", got) "disk sends one digest, not one mail per app. Read the fence before widening this.", got)
} }
+35
View File
@@ -156,6 +156,20 @@ func FormatCustomerEmail(lang, customerID, eventType, severity, message, message
headline := "" headline := ""
if b.Has(i18n.Default, "mail.event."+eventType) { if b.Has(i18n.Default, "mail.event."+eventType) {
headline = b.Msg(lang, "mail.event."+eventType) headline = b.Msg(lang, "mail.event."+eventType)
// v0.120.0: an entry that NAMES THE APP (`%s`) is rendered with the details' stack_name — the
// subject then reads "docmost: …" rather than a sentence that could be about any app. No
// stack_name → the box's own sentence; neither → "?" in the app's place. Never a literal "%s"
// in a household's inbox, never an empty subject.
if appNamedMailEvents[eventType] {
switch app := stackNameOf(detailsJSON); {
case app != "":
headline = b.Msgf(lang, "mail.event."+eventType, app)
case boxMessage != "":
headline = ""
default:
headline = b.Msgf(lang, "mail.event."+eventType, "?")
}
}
} }
if headline == "" { if headline == "" {
headline = boxMessage headline = boxMessage
@@ -363,3 +377,24 @@ func trimRepeatedUsage(reason, targetPath string) string {
} }
return strings.TrimSpace(reason[:i]) return strings.TrimSpace(reason[:i])
} }
// appNamedMailEvents are the types whose `mail.event.*` entry carries the app's name as `%s`
// (v0.120.0). A named register, like operatorOnlyEvents: an entry gains an argument only on purpose.
var appNamedMailEvents = map[string]bool{
"app_update_undone": true,
"app_update_held": true,
}
// stackNameOf reads `stack_name` from an event's details, "" when absent or unreadable.
func stackNameOf(detailsJSON string) string {
if detailsJSON == "" {
return ""
}
var d struct {
StackName string `json:"stack_name"`
}
if json.Unmarshal([]byte(detailsJSON), &d) != nil {
return ""
}
return d.StackName
}
@@ -0,0 +1,18 @@
SUBJECT: [Felhom] Warning: ?: the app is stopped and needs a restore
---
Dear Customer,
Your Felhom system sent the following notification:
?: the app is stopped and needs a restore
Details:
- Server: demo-fixture
- Time: 2026-01-15 10:30
- Level: Warning
- Type: app_update_held
If you have any questions, contact your operator.
Best regards,
Felhom.eu monitoring
@@ -0,0 +1,18 @@
SUBJECT: [Felhom] Warning: ?: the update did not work; the app runs on its previous version
---
Dear Customer,
Your Felhom system sent the following notification:
?: the update did not work; the app runs on its previous version
Details:
- Server: demo-fixture
- Time: 2026-01-15 10:30
- Level: Warning
- Type: app_update_undone
If you have any questions, contact your operator.
Best regards,
Felhom.eu monitoring
@@ -0,0 +1,21 @@
# v0.120.0: the held entry names the app; the hold sentence is the line
SUBJECT: [Felhom] Error: vikunja: the app is stopped and needs a restore
---
Dear Customer,
Your Felhom system sent the following notification:
vikunja: the app is stopped and needs a restore
Details:
- Server: demo-fixture
- Time: 2026-01-15 10:30
- Level: Error
- Type: app_update_held
- Message: A frissites nem sikerult, es az automatikus visszaallitas sem.
- Note: {"app":"vikunja","stack_name":"vikunja","copy_tier":2}
If you have any questions, contact your operator.
Best regards,
Felhom.eu monitoring
@@ -0,0 +1,21 @@
# v0.120.0: the entry names the app from details.stack_name; the box's sentence is the line
SUBJECT: [Felhom] Warning: docmost: the update did not work; the app runs on its previous version
---
Dear Customer,
Your Felhom system sent the following notification:
docmost: the update did not work; the app runs on its previous version
Details:
- Server: demo-fixture
- Time: 2026-01-15 10:30
- Level: Warning
- Type: app_update_undone
- Message: A(z) docmost frissitese nem sikerult.
- Note: {"app":"docmost","stack_name":"docmost","from":{"docmost":"docmost:0.95.0"},"to":{"docmost":"docmost:0.96.0"}}
If you have any questions, contact your operator.
Best regards,
Felhom.eu monitoring
@@ -0,0 +1,18 @@
SUBJECT: [Felhom] Figyelmeztetés: ?: az alkalmazás leállítva, visszaállítás szükséges
---
Kedves Ügyfél!
A Felhom rendszered a következő értesítést küldte:
?: az alkalmazás leállítva, visszaállítás szükséges
Részletek:
- Szerver: demo-fixture
- Időpont: 2026-01-15 10:30
- Szint: Figyelmeztetés
- Típus: app_update_held
Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel.
Üdvözlettel,
Felhom.eu monitoring
@@ -0,0 +1,18 @@
SUBJECT: [Felhom] Figyelmeztetés: ?: a frissítés nem sikerült, az alkalmazás a korábbi változattal fut
---
Kedves Ügyfél!
A Felhom rendszered a következő értesítést küldte:
?: a frissítés nem sikerült, az alkalmazás a korábbi változattal fut
Részletek:
- Szerver: demo-fixture
- Időpont: 2026-01-15 10:30
- Szint: Figyelmeztetés
- Típus: app_update_undone
Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel.
Üdvözlettel,
Felhom.eu monitoring
@@ -0,0 +1,21 @@
# v0.120.0: the held entry names the app; the hold sentence is the line
SUBJECT: [Felhom] Hiba: vikunja: az alkalmazás leállítva, visszaállítás szükséges
---
Kedves Ügyfél!
A Felhom rendszered a következő értesítést küldte:
vikunja: az alkalmazás leállítva, visszaállítás szükséges
Részletek:
- Szerver: demo-fixture
- Időpont: 2026-01-15 10:30
- Szint: Hiba
- Típus: app_update_held
- Üzenet: A frissites nem sikerult, es az automatikus visszaallitas sem.
- Megjegyzés: {"app":"vikunja","stack_name":"vikunja","copy_tier":2}
Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel.
Üdvözlettel,
Felhom.eu monitoring
@@ -0,0 +1,21 @@
# v0.120.0: the entry names the app from details.stack_name; the box's sentence is the line
SUBJECT: [Felhom] Figyelmeztetés: docmost: a frissítés nem sikerült, az alkalmazás a korábbi változattal fut
---
Kedves Ügyfél!
A Felhom rendszered a következő értesítést küldte:
docmost: a frissítés nem sikerült, az alkalmazás a korábbi változattal fut
Részletek:
- Szerver: demo-fixture
- Időpont: 2026-01-15 10:30
- Szint: Figyelmeztetés
- Típus: app_update_undone
- Üzenet: A(z) docmost frissitese nem sikerult.
- Megjegyzés: {"app":"docmost","stack_name":"docmost","from":{"docmost":"docmost:0.95.0"},"to":{"docmost":"docmost:0.96.0"}}
Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel.
Üdvözlettel,
Felhom.eu monitoring
+101
View File
@@ -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
}
+71
View File
@@ -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 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. // NotificationPrefs holds per-customer notification preferences.
type NotificationPrefs struct { type NotificationPrefs struct {
CustomerID string CustomerID string