package monitor import ( "fmt" "go-upkeep/internal/models" "sync" "testing" "time" ) // --- Mock Store --- type savedCheck struct { SiteID int LatencyNs int64 IsUp bool } type mockStore struct { mu sync.Mutex sites []models.Site alerts map[int]models.AlertConfig maintenance map[int]bool logs []string history map[int][]models.CheckRecord savedChecks []savedCheck savedLogs []string getAlertCalls []int } func newMockStore() *mockStore { return &mockStore{ alerts: make(map[int]models.AlertConfig), maintenance: make(map[int]bool), history: make(map[int][]models.CheckRecord), } } func (m *mockStore) Init() error { return nil } func (m *mockStore) GetSites() ([]models.Site, error) { return m.sites, nil } func (m *mockStore) AddSite(models.Site) error { return nil } func (m *mockStore) UpdateSite(models.Site) error { return nil } func (m *mockStore) UpdateSitePaused(int, bool) error { return nil } func (m *mockStore) DeleteSite(int) error { return nil } func (m *mockStore) AddAlert(string, string, map[string]string) error { return nil } func (m *mockStore) UpdateAlert(int, string, string, map[string]string) error { return nil } func (m *mockStore) DeleteAlert(int) error { return nil } func (m *mockStore) GetAllUsers() ([]models.User, error) { return nil, nil } func (m *mockStore) AddUser(string, string, string) error { return nil } func (m *mockStore) UpdateUser(int, string, string, string) error { return nil } func (m *mockStore) DeleteUser(int) error { return nil } func (m *mockStore) ExportData() (models.Backup, error) { return models.Backup{}, nil } func (m *mockStore) ImportData(models.Backup) error { return nil } func (m *mockStore) GetSiteByName(string) (models.Site, error) { return models.Site{}, nil } func (m *mockStore) AddSiteReturningID(models.Site) (int, error) { return 0, nil } func (m *mockStore) AddAlertReturningID(string, string, map[string]string) (int, error) { return 0, nil } func (m *mockStore) SaveCheckFromNode(int, string, int64, bool) error { return nil } func (m *mockStore) RegisterNode(models.ProbeNode) error { return nil } func (m *mockStore) GetNode(string) (models.ProbeNode, error) { return models.ProbeNode{}, nil } func (m *mockStore) GetAllNodes() ([]models.ProbeNode, error) { return nil, nil } func (m *mockStore) UpdateNodeLastSeen(string) error { return nil } func (m *mockStore) DeleteNode(string) error { return nil } func (m *mockStore) GetActiveMaintenanceWindows() ([]models.MaintenanceWindow, error) { return nil, nil } func (m *mockStore) GetAllMaintenanceWindows(int) ([]models.MaintenanceWindow, error) { return nil, nil } func (m *mockStore) AddMaintenanceWindow(models.MaintenanceWindow) error { return nil } func (m *mockStore) EndMaintenanceWindow(int) error { return nil } func (m *mockStore) DeleteMaintenanceWindow(int) error { return nil } func (m *mockStore) GetPreference(string) (string, error) { return "", nil } func (m *mockStore) SetPreference(string, string) error { return nil } func (m *mockStore) Close() error { return nil } func (m *mockStore) GetAllAlerts() ([]models.AlertConfig, error) { m.mu.Lock() defer m.mu.Unlock() var result []models.AlertConfig for _, a := range m.alerts { result = append(result, a) } return result, nil } func (m *mockStore) GetAlert(id int) (models.AlertConfig, error) { m.mu.Lock() defer m.mu.Unlock() m.getAlertCalls = append(m.getAlertCalls, id) if a, ok := m.alerts[id]; ok { return a, nil } return models.AlertConfig{}, fmt.Errorf("alert %d not found", id) } func (m *mockStore) GetAlertByName(name string) (models.AlertConfig, error) { m.mu.Lock() defer m.mu.Unlock() for _, a := range m.alerts { if a.Name == name { return a, nil } } return models.AlertConfig{}, fmt.Errorf("alert %q not found", name) } func (m *mockStore) IsMonitorInMaintenance(id int) (bool, error) { m.mu.Lock() defer m.mu.Unlock() return m.maintenance[id], nil } func (m *mockStore) SaveCheck(siteID int, latencyNs int64, isUp bool) error { m.mu.Lock() defer m.mu.Unlock() m.savedChecks = append(m.savedChecks, savedCheck{siteID, latencyNs, isUp}) return nil } func (m *mockStore) SaveLog(msg string) error { m.mu.Lock() defer m.mu.Unlock() m.savedLogs = append(m.savedLogs, msg) return nil } func (m *mockStore) LoadLogs(limit int) ([]string, error) { return m.logs, nil } func (m *mockStore) LoadAllHistory(limit int) (map[int][]models.CheckRecord, error) { return m.history, nil } // --- Helpers --- func newTestEngine(ms *mockStore) *Engine { return NewEngine(ms) } func injectSite(e *Engine, site models.Site) { e.mu.Lock() e.liveState[site.ID] = site e.addToTokenIndex(site) e.mu.Unlock() } func getSite(e *Engine, id int) (models.Site, bool) { e.mu.RLock() defer e.mu.RUnlock() s, ok := e.liveState[id] return s, ok } func waitAsync() { time.Sleep(50 * time.Millisecond) } func (m *mockStore) getAlertCallsSnapshot() []int { m.mu.Lock() defer m.mu.Unlock() cp := make([]int, len(m.getAlertCalls)) copy(cp, m.getAlertCalls) return cp } // --- Group 1: State Machine --- func TestHandleStatusChange_PendingToUp(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) site := models.Site{ID: 1, Name: "test", Status: "PENDING", MaxRetries: 3, AlertID: 1} injectSite(e, site) e.handleStatusChange(site, "UP", 200, 10*time.Millisecond) s, _ := getSite(e, 1) if s.Status != "UP" { t.Errorf("expected UP, got %s", s.Status) } if s.FailureCount != 0 { t.Errorf("expected FailureCount 0, got %d", s.FailureCount) } waitAsync() if len(ms.getAlertCallsSnapshot()) != 0 { t.Error("expected no alert for PENDING→UP") } } func TestHandleStatusChange_UpIncrementFailure(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) site := models.Site{ID: 1, Name: "test", Status: "UP", MaxRetries: 3, FailureCount: 0} injectSite(e, site) e.handleStatusChange(site, "DOWN", 500, 0) s, _ := getSite(e, 1) if s.Status != "UP" { t.Errorf("expected UP (under retry threshold), got %s", s.Status) } if s.FailureCount != 1 { t.Errorf("expected FailureCount 1, got %d", s.FailureCount) } } func TestHandleStatusChange_UpToDown_ExceedsRetries(t *testing.T) { ms := newMockStore() ms.alerts[1] = models.AlertConfig{ID: 1, Name: "discord", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}} e := newTestEngine(ms) site := models.Site{ID: 1, Name: "test", Status: "UP", MaxRetries: 2, FailureCount: 2, AlertID: 1} injectSite(e, site) e.handleStatusChange(site, "DOWN", 500, 0) s, _ := getSite(e, 1) if s.Status != "DOWN" { t.Errorf("expected DOWN, got %s", s.Status) } if s.FailureCount != 3 { t.Errorf("expected FailureCount 3, got %d", s.FailureCount) } waitAsync() calls := ms.getAlertCallsSnapshot() if len(calls) == 0 || calls[0] != 1 { t.Errorf("expected alert call for alertID 1, got %v", calls) } } func TestHandleStatusChange_UpToDown_ZeroRetries(t *testing.T) { ms := newMockStore() ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}} e := newTestEngine(ms) site := models.Site{ID: 1, Name: "test", Status: "UP", MaxRetries: 0, FailureCount: 0, AlertID: 1} injectSite(e, site) e.handleStatusChange(site, "DOWN", 0, 0) s, _ := getSite(e, 1) if s.Status != "DOWN" { t.Errorf("expected DOWN, got %s", s.Status) } waitAsync() if len(ms.getAlertCallsSnapshot()) == 0 { t.Error("expected alert on immediate DOWN") } } func TestHandleStatusChange_DownToUp_Recovery(t *testing.T) { ms := newMockStore() ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}} e := newTestEngine(ms) site := models.Site{ID: 1, Name: "test", Status: "DOWN", FailureCount: 4, AlertID: 1} injectSite(e, site) e.handleStatusChange(site, "UP", 200, 5*time.Millisecond) s, _ := getSite(e, 1) if s.Status != "UP" { t.Errorf("expected UP, got %s", s.Status) } if s.FailureCount != 0 { t.Errorf("expected FailureCount 0, got %d", s.FailureCount) } waitAsync() if len(ms.getAlertCallsSnapshot()) == 0 { t.Error("expected recovery alert") } } func TestHandleStatusChange_DownStaysDown(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) site := models.Site{ID: 1, Name: "test", Status: "DOWN", MaxRetries: 2, FailureCount: 3} injectSite(e, site) e.handleStatusChange(site, "DOWN", 0, 0) s, _ := getSite(e, 1) if s.Status != "DOWN" { t.Errorf("expected DOWN, got %s", s.Status) } waitAsync() if len(ms.getAlertCallsSnapshot()) != 0 { t.Error("expected no re-alert for already DOWN") } } func TestHandleStatusChange_SSLExpired(t *testing.T) { ms := newMockStore() ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}} e := newTestEngine(ms) site := models.Site{ID: 1, Name: "test", Status: "UP", MaxRetries: 0, AlertID: 1} injectSite(e, site) e.handleStatusChange(site, "SSL EXP", 0, 0) s, _ := getSite(e, 1) if s.Status != "SSL EXP" { t.Errorf("expected SSL EXP, got %s", s.Status) } waitAsync() if len(ms.getAlertCallsSnapshot()) == 0 { t.Error("expected alert on SSL EXP") } } func TestHandleStatusChange_AlertSuppressedMaintenance(t *testing.T) { ms := newMockStore() ms.maintenance[1] = true ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}} e := newTestEngine(ms) site := models.Site{ID: 1, Name: "test", Status: "UP", MaxRetries: 0, AlertID: 1} injectSite(e, site) e.handleStatusChange(site, "DOWN", 0, 0) s, _ := getSite(e, 1) if s.Status != "DOWN" { t.Errorf("expected DOWN, got %s", s.Status) } waitAsync() if len(ms.getAlertCallsSnapshot()) != 0 { t.Error("expected no alert during maintenance") } logs := e.GetLogs() found := false for _, l := range logs { if containsStr(l, "suppressed") { found = true break } } if !found { t.Error("expected log mentioning suppressed") } } func TestHandleStatusChange_RecoverySuppressedMaintenance(t *testing.T) { ms := newMockStore() ms.maintenance[1] = true ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}} e := newTestEngine(ms) site := models.Site{ID: 1, Name: "test", Status: "DOWN", AlertID: 1} injectSite(e, site) e.handleStatusChange(site, "UP", 200, 0) s, _ := getSite(e, 1) if s.Status != "UP" { t.Errorf("expected UP, got %s", s.Status) } waitAsync() if len(ms.getAlertCallsSnapshot()) != 0 { t.Error("expected no alert during maintenance recovery") } } func TestHandleStatusChange_SSLWarning(t *testing.T) { ms := newMockStore() ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}} e := newTestEngine(ms) site := models.Site{ ID: 1, Name: "test", Status: "UP", Type: "http", CheckSSL: true, HasSSL: true, ExpiryThreshold: 30, SentSSLWarning: false, AlertID: 1, CertExpiry: time.Now().Add(15 * 24 * time.Hour), } injectSite(e, site) e.handleStatusChange(site, "UP", 200, 0) s, _ := getSite(e, 1) if !s.SentSSLWarning { t.Error("expected SentSSLWarning=true") } waitAsync() if len(ms.getAlertCallsSnapshot()) == 0 { t.Error("expected SSL warning alert") } } func TestHandleStatusChange_SSLWarningNotRepeated(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) site := models.Site{ ID: 1, Name: "test", Status: "UP", Type: "http", CheckSSL: true, HasSSL: true, ExpiryThreshold: 30, SentSSLWarning: true, AlertID: 1, CertExpiry: time.Now().Add(15 * 24 * time.Hour), } injectSite(e, site) e.handleStatusChange(site, "UP", 200, 0) waitAsync() if len(ms.getAlertCallsSnapshot()) != 0 { t.Error("expected no repeat SSL warning") } } func TestHandleStatusChange_SSLWarningReset(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) site := models.Site{ ID: 1, Name: "test", Status: "UP", Type: "http", CheckSSL: true, HasSSL: true, ExpiryThreshold: 30, SentSSLWarning: true, CertExpiry: time.Now().Add(60 * 24 * time.Hour), } injectSite(e, site) e.handleStatusChange(site, "UP", 200, 0) s, _ := getSite(e, 1) if s.SentSSLWarning { t.Error("expected SentSSLWarning reset to false") } } func TestHandleStatusChange_SSLWarningSuppressedMaint(t *testing.T) { ms := newMockStore() ms.maintenance[1] = true ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}} e := newTestEngine(ms) site := models.Site{ ID: 1, Name: "test", Status: "UP", Type: "http", CheckSSL: true, HasSSL: true, ExpiryThreshold: 30, SentSSLWarning: false, AlertID: 1, CertExpiry: time.Now().Add(15 * 24 * time.Hour), } injectSite(e, site) e.handleStatusChange(site, "UP", 200, 0) s, _ := getSite(e, 1) if !s.SentSSLWarning { t.Error("expected SentSSLWarning=true even in maintenance") } waitAsync() if len(ms.getAlertCallsSnapshot()) != 0 { t.Error("expected no alert during maintenance") } } func TestHandleStatusChange_InactiveEngine(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) site := models.Site{ID: 1, Name: "test", Status: "UP", MaxRetries: 0} injectSite(e, site) e.SetActive(false) e.handleStatusChange(site, "DOWN", 0, 0) s, _ := getSite(e, 1) if s.Status != "UP" { t.Error("expected no state change when inactive") } } // --- Group 2: Heartbeat --- func TestRecordHeartbeat_ValidToken(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) site := models.Site{ID: 1, Name: "push-test", Type: "push", Token: "abc123", Status: "UP"} injectSite(e, site) if !e.RecordHeartbeat("abc123") { t.Error("expected true for valid token") } s, _ := getSite(e, 1) if s.Status != "UP" { t.Errorf("expected UP, got %s", s.Status) } if time.Since(s.LastCheck) > time.Second { t.Error("expected LastCheck to be recent") } } func TestRecordHeartbeat_RecoveryFromDown(t *testing.T) { ms := newMockStore() ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}} e := newTestEngine(ms) site := models.Site{ID: 1, Name: "push-test", Type: "push", Token: "abc123", Status: "DOWN", AlertID: 1, FailureCount: 3} injectSite(e, site) if !e.RecordHeartbeat("abc123") { t.Error("expected true") } s, _ := getSite(e, 1) if s.Status != "UP" { t.Errorf("expected UP, got %s", s.Status) } if s.FailureCount != 0 { t.Errorf("expected FailureCount 0, got %d", s.FailureCount) } waitAsync() if len(ms.getAlertCallsSnapshot()) == 0 { t.Error("expected recovery alert") } } func TestRecordHeartbeat_UnknownToken(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) if e.RecordHeartbeat("unknown") { t.Error("expected false for unknown token") } } func TestRecordHeartbeat_InactiveEngine(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) site := models.Site{ID: 1, Type: "push", Token: "abc123", Status: "UP"} injectSite(e, site) e.SetActive(false) if e.RecordHeartbeat("abc123") { t.Error("expected false when inactive") } } // --- Group 3: Push Deadline --- func TestCheckPush_DeadlineMissed(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) site := models.Site{ ID: 1, Name: "push", Type: "push", Status: "UP", Interval: 10, MaxRetries: 0, LastCheck: time.Now().Add(-20 * time.Second), } injectSite(e, site) e.checkPush(site) s, _ := getSite(e, 1) if s.Status != "DOWN" { t.Errorf("expected DOWN after missed deadline, got %s", s.Status) } } func TestCheckPush_WithinDeadline(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) site := models.Site{ ID: 1, Name: "push", Type: "push", Status: "UP", Interval: 60, LastCheck: time.Now(), } injectSite(e, site) e.checkPush(site) s, _ := getSite(e, 1) if s.Status != "UP" { t.Errorf("expected UP, got %s", s.Status) } } func TestCheckPush_PendingToUp(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) site := models.Site{ ID: 1, Name: "push", Type: "push", Status: "PENDING", Interval: 60, LastCheck: time.Now(), } injectSite(e, site) e.checkPush(site) s, _ := getSite(e, 1) if s.Status != "UP" { t.Errorf("expected UP, got %s", s.Status) } } // --- Group 4: Group Checks --- func TestCheckGroup_AllChildrenUp(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) group := models.Site{ID: 1, Name: "group", Type: "group", Status: "PENDING"} child1 := models.Site{ID: 2, Name: "child1", Type: "http", ParentID: 1, Status: "UP"} child2 := models.Site{ID: 3, Name: "child2", Type: "http", ParentID: 1, Status: "UP"} injectSite(e, group) injectSite(e, child1) injectSite(e, child2) e.checkGroup(group) s, _ := getSite(e, 1) if s.Status != "UP" { t.Errorf("expected group UP, got %s", s.Status) } } func TestCheckGroup_OneChildDown(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) group := models.Site{ID: 1, Name: "group", Type: "group", Status: "UP"} child1 := models.Site{ID: 2, Name: "child1", Type: "http", ParentID: 1, Status: "UP"} child2 := models.Site{ID: 3, Name: "child2", Type: "http", ParentID: 1, Status: "DOWN"} injectSite(e, group) injectSite(e, child1) injectSite(e, child2) e.checkGroup(group) s, _ := getSite(e, 1) if s.Status != "DOWN" { t.Errorf("expected group DOWN, got %s", s.Status) } } func TestCheckGroup_PausedChildIgnored(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) group := models.Site{ID: 1, Name: "group", Type: "group"} child1 := models.Site{ID: 2, Name: "child1", Type: "http", ParentID: 1, Status: "UP"} child2 := models.Site{ID: 3, Name: "child2", Type: "http", ParentID: 1, Status: "DOWN", Paused: true} injectSite(e, group) injectSite(e, child1) injectSite(e, child2) e.checkGroup(group) s, _ := getSite(e, 1) if s.Status != "UP" { t.Errorf("expected UP (paused child ignored), got %s", s.Status) } } func TestCheckGroup_MaintenanceChildIgnored(t *testing.T) { ms := newMockStore() ms.maintenance[3] = true e := newTestEngine(ms) group := models.Site{ID: 1, Name: "group", Type: "group"} child1 := models.Site{ID: 2, Name: "child1", Type: "http", ParentID: 1, Status: "UP"} child2 := models.Site{ID: 3, Name: "child2", Type: "http", ParentID: 1, Status: "DOWN"} injectSite(e, group) injectSite(e, child1) injectSite(e, child2) e.checkGroup(group) s, _ := getSite(e, 1) if s.Status != "UP" { t.Errorf("expected UP (maint child ignored), got %s", s.Status) } } func TestCheckGroup_NoChildren(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) group := models.Site{ID: 1, Name: "group", Type: "group", Status: "UP"} injectSite(e, group) e.checkGroup(group) s, _ := getSite(e, 1) if s.Status != "PENDING" { t.Errorf("expected PENDING for no children, got %s", s.Status) } } // --- Group 5: History --- func TestRecordCheck_Appends(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) e.recordCheck(1, 5*time.Millisecond, true) h, ok := e.GetHistory(1) if !ok { t.Fatal("expected history for site 1") } if h.TotalChecks != 1 || h.UpChecks != 1 { t.Errorf("expected 1/1, got %d/%d", h.TotalChecks, h.UpChecks) } if len(h.Latencies) != 1 || h.Latencies[0] != 5*time.Millisecond { t.Errorf("unexpected latencies: %v", h.Latencies) } } func TestRecordCheck_RollingWindow(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) for i := 0; i < 65; i++ { e.recordCheck(1, time.Duration(i)*time.Millisecond, i%2 == 0) } h, _ := e.GetHistory(1) if len(h.Latencies) != 60 { t.Errorf("expected 60 latencies, got %d", len(h.Latencies)) } if len(h.Statuses) != 60 { t.Errorf("expected 60 statuses, got %d", len(h.Statuses)) } if h.TotalChecks != 65 { t.Errorf("expected TotalChecks 65, got %d", h.TotalChecks) } } func TestGetHistory_DeepCopy(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) e.recordCheck(1, 5*time.Millisecond, true) h1, _ := e.GetHistory(1) h1.Latencies[0] = 999 * time.Second h1.TotalChecks = 999 h2, _ := e.GetHistory(1) if h2.Latencies[0] == 999*time.Second { t.Error("GetHistory returned reference, not copy") } if h2.TotalChecks == 999 { t.Error("GetHistory returned reference, not copy") } } func TestInitHistory_LoadsFromDB(t *testing.T) { ms := newMockStore() ms.history[1] = []models.CheckRecord{ {SiteID: 1, LatencyNs: 5000000, IsUp: true}, {SiteID: 1, LatencyNs: 3000000, IsUp: false}, } e := newTestEngine(ms) e.InitHistory() h, ok := e.GetHistory(1) if !ok { t.Fatal("expected history for site 1") } if h.TotalChecks != 2 { t.Errorf("expected TotalChecks 2, got %d", h.TotalChecks) } if h.UpChecks != 1 { t.Errorf("expected UpChecks 1, got %d", h.UpChecks) } } // --- Group 6: State Management --- func TestUpdateSiteConfig_PreservesRuntime(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) site := models.Site{ID: 1, Name: "test", URL: "http://old.com", Status: "DOWN", FailureCount: 3, Latency: 100 * time.Millisecond} injectSite(e, site) updated := models.Site{ID: 1, Name: "test", URL: "http://new.com", Interval: 60} e.UpdateSiteConfig(updated) s, _ := getSite(e, 1) if s.URL != "http://new.com" { t.Errorf("expected URL updated, got %s", s.URL) } if s.Status != "DOWN" { t.Errorf("expected Status preserved, got %s", s.Status) } if s.FailureCount != 3 { t.Errorf("expected FailureCount preserved, got %d", s.FailureCount) } if s.Latency != 100*time.Millisecond { t.Errorf("expected Latency preserved, got %v", s.Latency) } } func TestRemoveSite_CleansUp(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) site := models.Site{ID: 1, Name: "test", Type: "push", Token: "tok1", Status: "UP"} injectSite(e, site) e.recordCheck(1, 5*time.Millisecond, true) e.RemoveSite(1) if _, ok := getSite(e, 1); ok { t.Error("expected site removed from liveState") } if e.RecordHeartbeat("tok1") { t.Error("expected token removed from index") } if _, ok := e.GetHistory(1); ok { t.Error("expected history removed") } } func TestToggleSitePause(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) site := models.Site{ID: 1, Name: "test", Status: "UP"} injectSite(e, site) paused := e.ToggleSitePause(1) if !paused { t.Error("expected paused=true after first toggle") } s, _ := getSite(e, 1) if !s.Paused { t.Error("expected Paused=true in state") } paused = e.ToggleSitePause(1) if paused { t.Error("expected paused=false after second toggle") } } func TestToggleSitePause_NonexistentSite(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) if e.ToggleSitePause(999) { t.Error("expected false for nonexistent site") } } func TestGetAllSites_ReturnsCopy(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) injectSite(e, models.Site{ID: 1, Name: "s1", Status: "UP"}) injectSite(e, models.Site{ID: 2, Name: "s2", Status: "DOWN"}) sites := e.GetAllSites() if len(sites) != 2 { t.Fatalf("expected 2 sites, got %d", len(sites)) } sites[0].Name = "mutated" fresh := e.GetAllSites() for _, s := range fresh { if s.Name == "mutated" { t.Error("GetAllSites returned reference, not copy") } } } func TestGetLiveState_ReturnsCopy(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) injectSite(e, models.Site{ID: 1, Name: "s1", Status: "UP"}) state := e.GetLiveState() state[1] = models.Site{Name: "mutated"} fresh := e.GetLiveState() if fresh[1].Name == "mutated" { t.Error("GetLiveState returned reference, not copy") } } // --- Group 7: Logs --- func TestAddLog_PrependAndCap(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) for i := 0; i < 105; i++ { e.AddLog(fmt.Sprintf("log-%d", i)) } logs := e.GetLogs() if len(logs) != 100 { t.Errorf("expected 100 logs, got %d", len(logs)) } if !containsStr(logs[0], "log-104") { t.Errorf("expected newest log first, got %s", logs[0]) } } func TestInitLogs_LoadsFromDB(t *testing.T) { ms := newMockStore() ms.logs = []string{"old-log-1", "old-log-2"} e := newTestEngine(ms) e.InitLogs() logs := e.GetLogs() if len(logs) != 2 { t.Errorf("expected 2 logs, got %d", len(logs)) } } // --- Group 8: Probe Aggregation --- func TestAggregateStatus_AnyDown(t *testing.T) { results := []NodeResult{ {IsUp: true, LatencyNs: 100}, {IsUp: false, LatencyNs: 200}, } isUp, _ := AggregateStatus(results, AggAnyDown) if isUp { t.Error("AggAnyDown: expected DOWN when any node is down") } } func TestAggregateStatus_AnyDown_AllUp(t *testing.T) { results := []NodeResult{ {IsUp: true, LatencyNs: 100}, {IsUp: true, LatencyNs: 200}, } isUp, _ := AggregateStatus(results, AggAnyDown) if !isUp { t.Error("AggAnyDown: expected UP when all nodes up") } } func TestAggregateStatus_Majority(t *testing.T) { results := []NodeResult{ {IsUp: true, LatencyNs: 100}, {IsUp: true, LatencyNs: 200}, {IsUp: false, LatencyNs: 300}, } isUp, _ := AggregateStatus(results, AggMajorityDown) if !isUp { t.Error("AggMajority: expected UP when 2/3 are up") } } func TestAggregateStatus_AllDown(t *testing.T) { results := []NodeResult{ {IsUp: false, LatencyNs: 100}, {IsUp: false, LatencyNs: 200}, {IsUp: true, LatencyNs: 300}, } isUp, _ := AggregateStatus(results, AggAllDown) if !isUp { t.Error("AggAllDown: expected UP when at least one node up") } } func TestAggregateStatus_Empty(t *testing.T) { isUp, avg := AggregateStatus(nil, AggAnyDown) if !isUp { t.Error("expected UP for empty results") } if avg != 0 { t.Errorf("expected 0 avg latency, got %d", avg) } } func TestAggregateStatus_LatencyAverage(t *testing.T) { results := []NodeResult{ {IsUp: true, LatencyNs: 100}, {IsUp: true, LatencyNs: 200}, {IsUp: true, LatencyNs: 300}, } _, avg := AggregateStatus(results, AggAnyDown) if avg != 200 { t.Errorf("expected avg 200, got %d", avg) } } // --- Group 9: Concurrency --- func TestConcurrent_RecordHeartbeat(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) for i := 0; i < 10; i++ { injectSite(e, models.Site{ ID: i + 1, Type: "push", Token: fmt.Sprintf("tok-%d", i+1), Status: "UP", }) } var wg sync.WaitGroup for i := 0; i < 100; i++ { wg.Add(1) go func(n int) { defer wg.Done() e.RecordHeartbeat(fmt.Sprintf("tok-%d", (n%10)+1)) }(i) } wg.Wait() } func TestConcurrent_HandleStatusChangeAndGetState(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) site := models.Site{ID: 1, Name: "test", Status: "UP", MaxRetries: 100} injectSite(e, site) var wg sync.WaitGroup for i := 0; i < 50; i++ { wg.Add(2) go func() { defer wg.Done() e.handleStatusChange(site, "DOWN", 500, 0) }() go func() { defer wg.Done() e.GetLiveState() }() } wg.Wait() } func TestConcurrent_RecordCheckAndGetHistory(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) var wg sync.WaitGroup for i := 0; i < 50; i++ { wg.Add(2) go func(n int) { defer wg.Done() e.recordCheck(1, time.Duration(n)*time.Millisecond, true) }(i) go func() { defer wg.Done() e.GetHistory(1) }() } wg.Wait() h, ok := e.GetHistory(1) if !ok { t.Fatal("expected history") } if len(h.Latencies) > maxHistoryLen { t.Errorf("history exceeded max: %d", len(h.Latencies)) } } // --- Utilities --- func containsStr(s, substr string) bool { return len(s) >= len(substr) && searchStr(s, substr) } func searchStr(s, substr string) bool { for i := 0; i <= len(s)-len(substr); i++ { if s[i:i+len(substr)] == substr { return true } } return false }