Implement single non-blocking notifications when features become available. Events: - DiurnalBaselineActivated (7 days) - FirstSleepSessionComplete - WeightUpdateApproved - AutomationFirstFired - PredictionModelReady (7 days per person) Each notification is keyed by unique event ID in SQLite (feature_notifications table). Never fires twice. Dismissed by tapping. Respects quiet hours. Files: - mothership/internal/help/notifier.go: Notifier manages one-time feature notifications - mothership/internal/help/notifier_test.go: Tests for notifier - mothership/internal/help/monitor.go: FeatureMonitor checks for feature availability - mothership/internal/help/monitor_test.go: Tests for monitor - mothership/cmd/mothership/main.go: Integration with mothership - mothership/internal/db/migrations.go: Add migration_015 for feature_notifications table Acceptance: - Each notification fires exactly once per feature - Plain language messages - Respects quiet hours - SQLite persistence prevents duplicates Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
226 lines
6.5 KiB
Go
226 lines
6.5 KiB
Go
// Package help provides tests for the feature notification manager.
|
|
package help
|
|
|
|
import (
|
|
"database/sql"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
// TestNotifierFireAndRetrieve tests firing a notification and retrieving it.
|
|
func TestNotifierFireAndRetrieve(t *testing.T) {
|
|
db := createTestDB(t)
|
|
defer db.Close()
|
|
|
|
notifier, err := NewNotifier(db)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create notifier: %v", err)
|
|
}
|
|
|
|
// Fire a notification
|
|
eventID := "test_feature_1"
|
|
fired := notifier.FireNotification(eventID, "Test Feature", "This is a test notification")
|
|
if !fired {
|
|
t.Error("Expected notification to be fired")
|
|
}
|
|
|
|
// Verify it was recorded
|
|
var firedAt int64
|
|
err = db.QueryRow("SELECT fired_at FROM feature_notifications WHERE event_id = ?", eventID).Scan(&firedAt)
|
|
if err != nil {
|
|
t.Errorf("Failed to query notification: %v", err)
|
|
}
|
|
|
|
// Try firing again - should not fire
|
|
firedAgain := notifier.FireNotification(eventID, "Test Feature", "This is a test notification")
|
|
if firedAgain {
|
|
t.Error("Expected notification to not fire again")
|
|
}
|
|
|
|
// Get pending notifications
|
|
notifications, err := notifier.GetPendingNotifications()
|
|
if err != nil {
|
|
t.Fatalf("Failed to get pending notifications: %v", err)
|
|
}
|
|
|
|
if len(notifications) != 1 {
|
|
t.Errorf("Expected 1 notification, got %d", len(notifications))
|
|
}
|
|
|
|
if notifications[0].EventID != eventID {
|
|
t.Errorf("Expected event_id %s, got %s", eventID, notifications[0].EventID)
|
|
}
|
|
}
|
|
|
|
// TestNotifierAcknowledge tests acknowledging a notification.
|
|
func TestNotifierAcknowledge(t *testing.T) {
|
|
db := createTestDB(t)
|
|
defer db.Close()
|
|
|
|
notifier, err := NewNotifier(db)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create notifier: %v", err)
|
|
}
|
|
|
|
// Fire a notification
|
|
eventID := "test_feature_2"
|
|
notifier.FireNotification(eventID, "Test Feature", "This is a test notification")
|
|
|
|
// Verify it's pending
|
|
notifications, _ := notifier.GetPendingNotifications()
|
|
if len(notifications) != 1 {
|
|
t.Errorf("Expected 1 pending notification, got %d", len(notifications))
|
|
}
|
|
|
|
// Acknowledge it
|
|
err = notifier.AcknowledgeNotification(eventID)
|
|
if err != nil {
|
|
t.Errorf("Failed to acknowledge notification: %v", err)
|
|
}
|
|
|
|
// Verify it's no longer pending
|
|
notifications, _ = notifier.GetPendingNotifications()
|
|
if len(notifications) != 0 {
|
|
t.Errorf("Expected 0 pending notifications after acknowledge, got %d", len(notifications))
|
|
}
|
|
}
|
|
|
|
// TestNotifierQuietHours tests that notifications are suppressed during quiet hours.
|
|
func TestNotifierQuietHours(t *testing.T) {
|
|
db := createTestDB(t)
|
|
defer db.Close()
|
|
|
|
notifier, err := NewNotifier(db)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create notifier: %v", err)
|
|
}
|
|
|
|
// Set quiet hours for current time
|
|
now := time.Now()
|
|
_ = now.Hour()*60 + now.Minute() // Current time in minutes (not directly used)
|
|
|
|
notifier.SetQuietHours(&QuietHours{
|
|
Enabled: true,
|
|
StartHour: now.Hour(),
|
|
StartMin: now.Minute(),
|
|
EndHour: now.Hour(),
|
|
EndMin: now.Minute() + 30,
|
|
DaysMask: 1 << uint(now.Weekday()),
|
|
})
|
|
|
|
// Try to fire during quiet hours - should be suppressed
|
|
fired := notifier.FireNotification("quiet_test", "Quiet Test", "Should be suppressed")
|
|
if fired {
|
|
t.Error("Expected notification to be suppressed during quiet hours")
|
|
}
|
|
|
|
// Disable quiet hours and try again
|
|
notifier.SetQuietHours(&QuietHours{Enabled: false})
|
|
fired = notifier.FireNotification("quiet_test_2", "Quiet Test 2", "Should fire now")
|
|
if !fired {
|
|
t.Error("Expected notification to fire when quiet hours disabled")
|
|
}
|
|
}
|
|
|
|
// TestNotifierContentHelpers tests the content generation for known event types.
|
|
func TestNotifierContentHelpers(t *testing.T) {
|
|
tests := []struct {
|
|
eventID string
|
|
wantTitle string
|
|
wantMessage string
|
|
}{
|
|
{EventDiurnalBaselineActivated, "Your system has learned your home's daily patterns", ""},
|
|
{EventFirstSleepSessionComplete, "Your first sleep session was tracked overnight", ""},
|
|
{EventWeightUpdateApproved, "Localization accuracy improved", ""},
|
|
{EventAutomationFirstFired, "Your first automation just ran", ""},
|
|
{EventPredictionModelReady, "Presence predictions are now available", ""},
|
|
{"unknown_event", "New Feature Available", "A new feature is now available in your Spaxel system."},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.eventID, func(t *testing.T) {
|
|
title := getNotificationTitle(tt.eventID)
|
|
if title != tt.wantTitle {
|
|
t.Errorf("getNotificationTitle() = %q, want %q", title, tt.wantTitle)
|
|
}
|
|
|
|
message := getNotificationMessage(tt.eventID)
|
|
if tt.wantMessage != "" && message != tt.wantMessage {
|
|
t.Errorf("getNotificationMessage() = %q, want %q", message, tt.wantMessage)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestNotifierFireWithAction tests firing notifications with action buttons.
|
|
func TestNotifierFireWithAction(t *testing.T) {
|
|
db := createTestDB(t)
|
|
defer db.Close()
|
|
|
|
notifier, err := NewNotifier(db)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create notifier: %v", err)
|
|
}
|
|
|
|
// Fire notification with action for a known event type
|
|
// The implementation uses predefined action labels/URLs for known events
|
|
eventID := EventDiurnalBaselineActivated
|
|
fired := notifier.FireNotificationWithAction(
|
|
eventID,
|
|
"Diurnal Baseline Ready",
|
|
"Your system has learned patterns",
|
|
"Ignored Label", // This is ignored for known events
|
|
"#/ignored", // This is ignored for known events
|
|
)
|
|
|
|
if !fired {
|
|
t.Error("Expected notification to be fired")
|
|
}
|
|
|
|
// Retrieve and check action
|
|
notifications, _ := notifier.GetPendingNotifications()
|
|
if len(notifications) != 1 {
|
|
t.Fatalf("Expected 1 notification, got %d", len(notifications))
|
|
}
|
|
|
|
// Known events use predefined action labels/URLs
|
|
if notifications[0].ActionLabel != "View Diurnal Baseline" {
|
|
t.Errorf("Expected action label 'View Diurnal Baseline', got %q", notifications[0].ActionLabel)
|
|
}
|
|
|
|
if notifications[0].ActionURL != "#/settings/diurnal" {
|
|
t.Errorf("Expected action URL '#/settings/diurnal', got %q", notifications[0].ActionURL)
|
|
}
|
|
}
|
|
|
|
// createTestDB creates an in-memory test database with the feature_notifications schema.
|
|
func createTestDB(t *testing.T) *sql.DB {
|
|
db, err := sql.Open("sqlite", ":memory:")
|
|
if err != nil {
|
|
t.Fatalf("Failed to open test database: %v", err)
|
|
}
|
|
|
|
// Create the feature_notifications table directly (same as migration_015)
|
|
schema := `
|
|
CREATE TABLE IF NOT EXISTS feature_notifications (
|
|
event_id TEXT PRIMARY KEY,
|
|
fired_at INTEGER NOT NULL,
|
|
acknowledged_at INTEGER
|
|
);
|
|
`
|
|
if _, err := db.Exec(schema); err != nil {
|
|
t.Fatalf("Failed to create schema: %v", err)
|
|
}
|
|
|
|
return db
|
|
}
|
|
|
|
// TestMain sets up test environment
|
|
func TestMain(m *testing.M) {
|
|
// Run tests
|
|
code := m.Run()
|
|
os.Exit(code)
|
|
}
|