From 94296e82860b184c7f3c500252a8e545406169f7 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 23 May 2026 21:06:28 -0400 Subject: [PATCH] test(monitor): add comprehensive test suite for engine and checkers 55 tests covering state machine transitions, heartbeat handling, push deadline checks, group aggregation, history recording, probe aggregation, log management, state management, and concurrency safety. Checker tests cover HTTP (via httptest), port (via net.Listen), isCodeAccepted ranges, and siteTimeout defaults. Ping and DNS checkers skipped (need ICMP privileges and DNS server). Coverage: 64.2% overall, 100% on handleStatusChange, triggerAlert, checkPush, recordCheck, and AggregateStatus. --- internal/monitor/checker_test.go | 203 ++++++ internal/monitor/monitor_test.go | 1044 ++++++++++++++++++++++++++++++ 2 files changed, 1247 insertions(+) create mode 100644 internal/monitor/checker_test.go create mode 100644 internal/monitor/monitor_test.go diff --git a/internal/monitor/checker_test.go b/internal/monitor/checker_test.go new file mode 100644 index 0000000..39698b4 --- /dev/null +++ b/internal/monitor/checker_test.go @@ -0,0 +1,203 @@ +package monitor + +import ( + "crypto/tls" + "go-upkeep/internal/models" + "net" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" +) + +func TestRunCheck_HTTP_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + defer srv.Close() + + site := models.Site{ID: 1, Type: "http", URL: srv.URL} + result := RunCheck(site, http.DefaultClient, http.DefaultClient, false) + + if result.Status != "UP" { + t.Errorf("expected UP, got %s", result.Status) + } + if result.StatusCode != 200 { + t.Errorf("expected 200, got %d", result.StatusCode) + } + if result.LatencyNs <= 0 { + t.Error("expected positive latency") + } +} + +func TestRunCheck_HTTP_ServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + })) + defer srv.Close() + + site := models.Site{ID: 1, Type: "http", URL: srv.URL} + result := RunCheck(site, http.DefaultClient, http.DefaultClient, false) + + if result.Status != "DOWN" { + t.Errorf("expected DOWN, got %s", result.Status) + } + if result.StatusCode != 500 { + t.Errorf("expected 500, got %d", result.StatusCode) + } +} + +func TestRunCheck_HTTP_CustomAcceptedCodes(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(302) + })) + defer srv.Close() + + client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }} + + site := models.Site{ID: 1, Type: "http", URL: srv.URL, AcceptedCodes: "200-399"} + result := RunCheck(site, client, client, false) + + if result.Status != "UP" { + t.Errorf("expected UP with accepted 200-399, got %s", result.Status) + } +} + +func TestRunCheck_HTTP_MethodRespected(t *testing.T) { + var receivedMethod string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedMethod = r.Method + w.WriteHeader(200) + })) + defer srv.Close() + + site := models.Site{ID: 1, Type: "http", URL: srv.URL, Method: "HEAD"} + RunCheck(site, http.DefaultClient, http.DefaultClient, false) + + if receivedMethod != "HEAD" { + t.Errorf("expected HEAD, got %s", receivedMethod) + } +} + +func TestRunCheck_HTTP_Timeout(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(2 * time.Second) + w.WriteHeader(200) + })) + defer srv.Close() + + site := models.Site{ID: 1, Type: "http", URL: srv.URL, Timeout: 1} + result := RunCheck(site, http.DefaultClient, http.DefaultClient, false) + + if result.Status != "DOWN" { + t.Errorf("expected DOWN on timeout, got %s", result.Status) + } +} + +func TestRunCheck_HTTP_SSLFields(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + defer srv.Close() + + insecureClient := &http.Client{ + Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, + } + + site := models.Site{ID: 1, Type: "http", URL: srv.URL, CheckSSL: true, IgnoreTLS: true} + result := RunCheck(site, http.DefaultClient, insecureClient, false) + + if result.Status != "UP" { + t.Errorf("expected UP, got %s", result.Status) + } + if !result.HasSSL { + t.Error("expected HasSSL=true") + } + if result.CertExpiry.IsZero() { + t.Error("expected CertExpiry populated") + } +} + +func TestRunCheck_Port_Open(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + + _, portStr, _ := net.SplitHostPort(ln.Addr().String()) + port, _ := strconv.Atoi(portStr) + + site := models.Site{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 2} + result := RunCheck(site, nil, nil, false) + + if result.Status != "UP" { + t.Errorf("expected UP, got %s", result.Status) + } + if result.LatencyNs <= 0 { + t.Error("expected positive latency") + } +} + +func TestRunCheck_Port_Closed(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + _, portStr, _ := net.SplitHostPort(ln.Addr().String()) + port, _ := strconv.Atoi(portStr) + ln.Close() + + site := models.Site{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 1} + result := RunCheck(site, nil, nil, false) + + if result.Status != "DOWN" { + t.Errorf("expected DOWN, got %s", result.Status) + } +} + +func TestRunCheck_UnknownType(t *testing.T) { + site := models.Site{ID: 1, Type: "invalid"} + result := RunCheck(site, nil, nil, false) + + if result.Status != "DOWN" { + t.Errorf("expected DOWN for unknown type, got %s", result.Status) + } +} + +func TestIsCodeAccepted(t *testing.T) { + tests := []struct { + code int + accepted string + want bool + }{ + {200, "", true}, + {299, "", true}, + {300, "", false}, + {302, "200-399", true}, + {400, "200-399", false}, + {301, "200,301,404", true}, + {500, "200,301,404", false}, + {404, "200-299,400-499", true}, + {500, "200-299,400-499", false}, + } + + for _, tt := range tests { + got := isCodeAccepted(tt.code, tt.accepted) + if got != tt.want { + t.Errorf("isCodeAccepted(%d, %q) = %v, want %v", tt.code, tt.accepted, got, tt.want) + } + } +} + +func TestSiteTimeout(t *testing.T) { + if got := siteTimeout(models.Site{Timeout: 0}); got != 5*time.Second { + t.Errorf("expected 5s default, got %v", got) + } + if got := siteTimeout(models.Site{Timeout: 10}); got != 10*time.Second { + t.Errorf("expected 10s, got %v", got) + } +} diff --git a/internal/monitor/monitor_test.go b/internal/monitor/monitor_test.go new file mode 100644 index 0000000..dd392de --- /dev/null +++ b/internal/monitor/monitor_test.go @@ -0,0 +1,1044 @@ +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 +}