spaxel/mothership/internal/help/notifier_test.go
jedarden 77a2fbc9c0 test: implement acceptance scenario integration tests (AS-1 through AS-6)
- Added comprehensive integration tests in test/acceptance/ covering all 6 acceptance scenarios from plan.md
- AS-1: First-time setup in under 5 minutes - verifies PIN setup and node auto-discovery
- AS-2: Person detected while walking - verifies blob detection during walker simulation
- AS-3: Fall alert fires correctly - verifies fall detection with webhook integration
- AS-4: BLE identity resolves to person name - verifies BLE device registration and identity matching
- AS-5: OTA update succeeds / rollback on bad firmware - verifies OTA workflow and rollback
- AS-6: Replay shows recorded history - verifies replay session creation, seeking, and playback

Tests use spaxel-sim CLI as the test harness and verify:
- API endpoint responses (/api/auth/setup, /api/nodes, /api/blobs, /api/events, /api/ble/devices, /api/replay/*)
- Detection accuracy thresholds (>60% blob presence during walking)
- Alert generation and webhook delivery
- Firmware version updates and rollback behavior
- Replay session lifecycle management

All tests skip by default unless ACCEPTANCE_TEST=1 or SPAXEL_INTEGRATION_TEST=1 is set.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 05:45:15 -04:00

226 lines
6.6 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() //nolint:errcheck
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() //nolint:errcheck
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() //nolint:errcheck
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() //nolint:errcheck
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)
}