package monitor import ( "context" "fmt" "sync" "testing" "time" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerkolabs/uptop/internal/store/storetest" ) // --- Mock Store --- type savedCheck struct { SiteID int LatencyNs int64 IsUp bool } type mockStore struct { storetest.BaseMock mu sync.Mutex sites []models.SiteConfig alerts map[int]models.AlertConfig maintenance map[int]bool logs []models.LogEntry 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) GetSites(context.Context) ([]models.SiteConfig, error) { return m.sites, nil } func (m *mockStore) GetActiveMaintenanceWindows(context.Context) ([]models.MaintenanceWindow, error) { m.mu.Lock() defer m.mu.Unlock() var windows []models.MaintenanceWindow for id := range m.maintenance { windows = append(windows, models.MaintenanceWindow{MonitorID: id}) } return windows, nil } func (m *mockStore) GetAllAlerts(context.Context) ([]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(_ context.Context, 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(_ context.Context, 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(_ context.Context, id int) (bool, error) { m.mu.Lock() defer m.mu.Unlock() return m.maintenance[id], nil } func (m *mockStore) SaveCheck(_ context.Context, 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(_ context.Context, msg string) error { m.mu.Lock() defer m.mu.Unlock() m.savedLogs = append(m.savedLogs, msg) return nil } func (m *mockStore) LoadLogs(_ context.Context, _ int) ([]models.LogEntry, error) { return m.logs, nil } func (m *mockStore) LoadAllHistory(_ context.Context, _ 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 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(context.Background()) 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 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].Message, "log-104") { t.Errorf("expected newest log first, got %s", logs[0].Message) } } func TestInitLogs_LoadsFromDB(t *testing.T) { ms := newMockStore() ms.logs = []models.LogEntry{ {Message: "old-log-1"}, {Message: "old-log-2"}, } e := newTestEngine(ms) e.InitLogs(context.Background()) 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{ SiteConfig: models.SiteConfig{ID: i + 1, Type: "push", Token: fmt.Sprintf("tok-%d", i+1)}, SiteState: models.SiteState{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{ SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 100}, SiteState: models.SiteState{Status: "UP"}, } 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, "test error") }() 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)) } } // --- Group 11: single DB writer --- // Writes enqueued through the engine are persisted by the writer goroutine and // fully drained when the engine stops — no fire-and-forget, no lost writes. func TestDBWriter_DrainsOnStop(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) e.Start(context.Background()) e.enqueueWrite(writeCheck{siteID: 7, latencyNs: 100, isUp: true}) e.enqueueWrite(writeLog{message: "drain-me"}) e.Stop() // blocks until the writer has drained the queue ms.mu.Lock() defer ms.mu.Unlock() gotCheck := false for _, c := range ms.savedChecks { if c.SiteID == 7 { gotCheck = true } } if !gotCheck { t.Error("check was not persisted before Stop returned") } gotLog := false for _, l := range ms.savedLogs { if l == "drain-me" { gotLog = true } } if !gotLog { t.Error("log was not persisted before Stop returned") } } // Stop must be idempotent — safe to call more than once. func TestEngineStop_Idempotent(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) e.Start(context.Background()) e.Stop() e.Stop() // must not panic or block } // --- Group 11: Group State History --- func TestCheckGroup_SavesStateChange(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) group := models.Site{ SiteConfig: models.SiteConfig{ID: 100, Name: "Infra", Type: "group"}, SiteState: models.SiteState{Status: models.StatusUp}, } child := models.Site{ SiteConfig: models.SiteConfig{ID: 101, Name: "Server", Type: "ping", ParentID: 100}, SiteState: models.SiteState{Status: models.StatusUp}, } injectSite(e, group) injectSite(e, child) // Child goes down → group should transition UP→DOWN. child.Status = models.StatusDown injectSite(e, child) e.checkGroup(context.Background(), group) s, ok := getSite(e, 100) if !ok { t.Fatal("group not found") } if s.Status != models.StatusDown { t.Errorf("expected DOWN, got %s", s.Status) } if s.StatusChangedAt.IsZero() { t.Error("expected StatusChangedAt to be set") } logs := e.GetLogs() found := false for _, l := range logs { if containsStr(l.Message, "Group 'Infra' is DOWN") { found = true break } } if !found { t.Error("expected log entry for group state change") } // Drain the write channel to verify a state change was enqueued. select { case w := <-e.dbWrites: if w.desc() != "state-change" { // Could be a check write; try one more. select { case w2 := <-e.dbWrites: if w2.desc() != "state-change" { t.Errorf("expected state-change write, got %s then %s", w.desc(), w2.desc()) } default: t.Errorf("expected state-change write, got only %s", w.desc()) } } default: t.Error("no write enqueued for group state change") } } func TestCheckGroup_NoChangeNoWrite(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) group := models.Site{ SiteConfig: models.SiteConfig{ID: 100, Name: "Infra", Type: "group"}, SiteState: models.SiteState{Status: models.StatusUp}, } child := models.Site{ SiteConfig: models.SiteConfig{ID: 101, Name: "Server", Type: "ping", ParentID: 100}, SiteState: models.SiteState{Status: models.StatusUp}, } injectSite(e, group) injectSite(e, child) e.checkGroup(context.Background(), group) // Only a check write should be enqueued, no state-change. drainCount := 0 for { select { case w := <-e.dbWrites: if w.desc() == "state-change" { t.Error("state-change write enqueued when status didn't change") } drainCount++ default: goto done } } done: if drainCount == 0 { t.Error("expected at least a check write") } } func TestCheckGroup_RecoveryLog(t *testing.T) { ms := newMockStore() e := newTestEngine(ms) group := models.Site{ SiteConfig: models.SiteConfig{ID: 100, Name: "Infra", Type: "group"}, SiteState: models.SiteState{Status: models.StatusDown}, } child := models.Site{ SiteConfig: models.SiteConfig{ID: 101, Name: "Server", Type: "ping", ParentID: 100}, SiteState: models.SiteState{Status: models.StatusUp}, } injectSite(e, group) injectSite(e, child) e.checkGroup(context.Background(), group) logs := e.GetLogs() found := false for _, l := range logs { if containsStr(l.Message, "Group 'Infra' recovered") { found = true break } } if !found { t.Error("expected recovery log entry for group") } } // --- 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 }