hub v0.71.0: paired recovery mails (F11), prefs seeding at claim + empty-email no-clobber (F12), priority headers + operator test leg (F14-light)

This commit is contained in:
2026-07-22 20:57:29 +02:00
parent 5b35023574
commit c766c8af82
15 changed files with 832 additions and 30 deletions
@@ -0,0 +1,115 @@
package store
import (
"testing"
"time"
)
// insertNotifLog inserts a notification_log row with an EXPLICIT created_at so pairing tests can
// order rows deterministically (datetime('now') is second-granularity — real calls tie).
func insertNotifLog(t *testing.T, s *Store, customerID, eventType, status, channel, createdAt string) {
t.Helper()
if _, err := s.db.Exec(`
INSERT INTO notification_log (customer_id, event_type, severity, message, status, error_message, channel, created_at)
VALUES (?, ?, 'error', 'm', ?, '', ?, ?)`,
customerID, eventType, status, channel, createdAt); err != nil {
t.Fatalf("insertNotifLog: %v", err)
}
}
// TestLastCustomerSentAt_PairingQuery (v0.71.0 F11): the pairing-evidence query returns the max
// created_at over customer-channel status=sent rows of the given types ONLY — operator rows,
// failed rows and other event types must not count.
func TestLastCustomerSentAt_PairingQuery(t *testing.T) {
s := newTestStore(t)
// Noise that must NOT count:
insertNotifLog(t, s, "c1", "node_down", "sent", "operator", "2026-07-22 15:00:00") // wrong channel
insertNotifLog(t, s, "c1", "node_down", "failed", "customer", "2026-07-22 16:00:00") // wrong status
insertNotifLog(t, s, "c1", "backup_failed", "sent", "customer", "2026-07-22 17:00:00") // wrong type
insertNotifLog(t, s, "c2", "node_down", "sent", "customer", "2026-07-22 18:00:00") // wrong customer
// No qualifying row yet:
_, ok, err := s.LastCustomerSentAt("c1", []string{"node_stale", "node_down"})
if err != nil {
t.Fatalf("LastCustomerSentAt: %v", err)
}
if ok {
t.Fatal("no qualifying row must yield ok=false")
}
// Two qualifying rows — max wins:
insertNotifLog(t, s, "c1", "node_stale", "sent", "customer", "2026-07-22 12:59:00")
insertNotifLog(t, s, "c1", "node_down", "sent", "customer", "2026-07-22 13:29:00")
got, ok, err := s.LastCustomerSentAt("c1", []string{"node_stale", "node_down"})
if err != nil {
t.Fatalf("LastCustomerSentAt: %v", err)
}
if !ok {
t.Fatal("qualifying rows exist — ok must be true")
}
want := time.Date(2026, 7, 22, 13, 29, 0, 0, time.UTC)
if !got.Equal(want) {
t.Fatalf("max created_at = %v, want %v", got, want)
}
// Empty type list is a defined no-op:
if _, ok, err := s.LastCustomerSentAt("c1", nil); err != nil || ok {
t.Fatalf("empty eventTypes must return (zero, false, nil), got ok=%v err=%v", ok, err)
}
}
// TestSeedNotificationPrefs_InsertIfAbsent (v0.71.0 F12, Scenario D): the seed creates a row when
// none exists, and NEVER modifies a pre-existing row (insert-if-absent, not upsert). Companion
// red-proof: replacing the INSERT OR IGNORE with SaveNotificationPrefs makes the second half fail.
func TestSeedNotificationPrefs_InsertIfAbsent(t *testing.T) {
s := newTestStore(t)
defaults := []string{"node_down", "backup_failed", "disk_critical"}
seeded, err := s.SeedNotificationPrefs("c1", "x@example.com", defaults)
if err != nil {
t.Fatalf("seed: %v", err)
}
if !seeded {
t.Fatal("first seed must create a row")
}
prefs, err := s.GetNotificationPrefs("c1")
if err != nil || prefs == nil {
t.Fatalf("prefs after seed: %v %v", prefs, err)
}
if prefs.Email != "x@example.com" || prefs.CooldownHours != 6 || len(prefs.EnabledEvents) != 3 {
t.Fatalf("seeded row wrong: %+v", prefs)
}
// A customer-edited row must survive a later seed byte-identical:
if err := s.SaveNotificationPrefs("c1", "edited@example.com", []string{"node_down"}, 12); err != nil {
t.Fatalf("customer edit: %v", err)
}
seeded, err = s.SeedNotificationPrefs("c1", "reinstall@example.com", defaults)
if err != nil {
t.Fatalf("re-seed: %v", err)
}
if seeded {
t.Fatal("seed over an existing row must report seeded=false")
}
prefs, _ = s.GetNotificationPrefs("c1")
if prefs.Email != "edited@example.com" || prefs.CooldownHours != 12 || len(prefs.EnabledEvents) != 1 {
t.Fatalf("seed MODIFIED an existing row (upsert bug): %+v", prefs)
}
}
// TestSeedNotificationPrefs_EmptyEmailNoop: an empty registered email must not seed an
// unnotifiable row.
func TestSeedNotificationPrefs_EmptyEmailNoop(t *testing.T) {
s := newTestStore(t)
seeded, err := s.SeedNotificationPrefs("c1", "", []string{"node_down"})
if err != nil {
t.Fatalf("seed: %v", err)
}
if seeded {
t.Fatal("empty email must be a no-op")
}
if prefs, _ := s.GetNotificationPrefs("c1"); prefs != nil {
t.Fatalf("no row must exist, got %+v", prefs)
}
}
+57
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"log"
"strconv"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/semver"
@@ -811,6 +812,62 @@ func (s *Store) GetRecentNotifications(customerID string, limit int) ([]Notifica
return entries, rows.Err()
}
// LastCustomerSentAt returns the most recent notification_log created_at over the given event
// types on the CUSTOMER channel with status='sent', and whether any such row exists. It is the
// pairing-evidence query for recovery notifications (v0.71.0, audit F11): "was the customer told
// about the down since they were last told about a recovery?" Uses the
// (customer_id, created_at DESC) index. An empty eventTypes slice returns (zero, false, nil).
func (s *Store) LastCustomerSentAt(customerID string, eventTypes []string) (time.Time, bool, error) {
if len(eventTypes) == 0 {
return time.Time{}, false, nil
}
placeholders := make([]string, len(eventTypes))
args := make([]interface{}, 0, len(eventTypes)+1)
args = append(args, customerID)
for i, et := range eventTypes {
placeholders[i] = "?"
args = append(args, et)
}
var createdAt sql.NullString
err := s.db.QueryRow(`
SELECT MAX(created_at) FROM notification_log
WHERE customer_id = ? AND channel = 'customer' AND status = 'sent'
AND event_type IN (`+strings.Join(placeholders, ",")+`)`,
args...,
).Scan(&createdAt)
if err != nil {
return time.Time{}, false, err
}
if !createdAt.Valid || createdAt.String == "" {
return time.Time{}, false, nil
}
return parseSQLiteTime(createdAt.String), true, nil
}
// SeedNotificationPrefs creates a customer_notifications row IF AND ONLY IF none exists —
// insert-if-absent, never an upsert (a customer-edited row must never be overwritten by a seed;
// audit F12). An empty email is a no-op: seeding an unnotifiable row would only mask the gap.
// Returns whether a row was created.
func (s *Store) SeedNotificationPrefs(customerID, email string, enabledEvents []string) (bool, error) {
if email == "" {
return false, nil
}
eventsJSON, _ := json.Marshal(enabledEvents)
res, err := s.db.Exec(`
INSERT OR IGNORE INTO customer_notifications (customer_id, email, enabled_events, cooldown_hours)
VALUES (?, ?, ?, 6)`,
customerID, email, string(eventsJSON),
)
if err != nil {
return false, err
}
n, err := res.RowsAffected()
if err != nil {
return false, err
}
return n > 0, nil
}
// SaveReport stores a new report. The reportJSON should be the raw JSON payload.
func (s *Store) SaveReport(customerID string, reportJSON []byte) error {
// Parse denormalized fields from the JSON