package detect_test import ( "context" "encoding/json" "testing" "time" "github.com/alicebob/miniredis/v2" "github.com/redis/go-redis/v9" "github.com/wangjia/pangolin/server/internal/alert" "github.com/wangjia/pangolin/server/internal/scheduler/detect" "github.com/wangjia/pangolin/server/internal/scheduler/probe" ) // ───────────────────────────────────────────────────────────────────────────── // Test helpers // ───────────────────────────────────────────────────────────────────────────── // mockSnapshotter implements detect.ProbeSnapshotter for unit tests. type mockSnapshotter struct { data map[string]map[string]probe.ProbeSnapshot } func (m *mockSnapshotter) SnapshotsByNode(_ context.Context, nodeID string) (map[string]probe.ProbeSnapshot, error) { if snaps, ok := m.data[nodeID]; ok { return snaps, nil } return nil, nil } // recordingNotifier records Notify calls for assertion. type recordingNotifier struct { events []alert.Event } func (r *recordingNotifier) Notify(_ context.Context, e alert.Event) error { r.events = append(r.events, e) return nil } // hasFaultEvent returns true if any recorded event has type EventTypeFault. func (r *recordingNotifier) hasFaultEvent() bool { for _, e := range r.events { if e.Type == alert.EventTypeFault { return true } } return false } // newTestRedis creates an in-process Redis (miniredis) and returns a connected // client plus a cleanup function. Tests must call cleanup() at the end. func newTestRedis(t *testing.T) (*redis.Client, *miniredis.Miniredis) { t.Helper() mr := miniredis.RunT(t) rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) return rdb, mr } // newEngine wires up an Engine with the given mock components. func newEngine( t *testing.T, rdb *redis.Client, lc *detect.MockLifecycle, snaps *mockSnapshotter, notifier detect.Notifier, cfg *detect.DetectConfig, ) *detect.Engine { t.Helper() streaks := detect.NewStreakStore(rdb) return detect.NewEngine(snaps, lc, streaks, rdb, notifier, cfg) } // snap builds a probe.ProbeSnapshot for one (node, vantage) pair. func snap(country, isp string, l1OK bool, l3OK *bool) probe.ProbeSnapshot { r := probe.NodeReport{L1: probe.L1Result{OK: l1OK}} if l3OK != nil { r.L3 = &probe.L3Result{OK: *l3OK} } return probe.ProbeSnapshot{ Vantage: probe.VantagePoint{Country: country, ISP: isp}, Report: r, } } func boolPtr(b bool) *bool { return &b } // cnFail returns a domestic (CN) probe snapshot where L3 is failing. func cnFail(isp string) probe.ProbeSnapshot { return snap("CN", isp, true, boolPtr(false)) } // cnOK returns a domestic (CN) probe snapshot where L3 passes. func cnOK(isp string) probe.ProbeSnapshot { return snap("CN", isp, true, boolPtr(true)) } // overseasOK returns an overseas probe snapshot where L1 passes. func overseasOK() probe.ProbeSnapshot { return snap("SG", "AWS", true, nil) } // overseasFail returns an overseas probe snapshot where L1 fails. func overseasFail() probe.ProbeSnapshot { return snap("SG", "AWS", false, nil) } // snapsForNode builds the map that mockSnapshotter.data["nodeID"] expects. func snapsForNode(snaps ...probe.ProbeSnapshot) map[string]probe.ProbeSnapshot { m := make(map[string]probe.ProbeSnapshot, len(snaps)) for i, s := range snaps { key := s.Vantage.Country + ":" + s.Vantage.ISP _ = i m[key] = s } return m } // ───────────────────────────────────────────────────────────────────────────── // Core rule tests (table-driven) // ───────────────────────────────────────────────────────────────────────────── const nodeID = "node-001" // TestRules covers the five classification rules and their boundary conditions // through table-driven subtests. func TestRules(t *testing.T) { cfg := detect.DefaultConfig() ctx := context.Background() type tick struct { // snaps to present on this tick (keyed by node ID) snaps map[string]probe.ProbeSnapshot } tests := []struct { name string // initial node status before any ticks initialStatus detect.NodeStatus // ticks is the sequence of probe states to feed to the engine ticks []tick // wantStatus is the expected node status after all ticks wantStatus detect.NodeStatus // wantEvents are (From, To) pairs expected in the lifecycle event log wantEvents [][2]detect.NodeStatus // wantFaultNotified indicates whether NotifyFault should have been called wantFaultNotified bool }{ { // Exactly 2 out of 3 ISPs fail → meets the ≥2/3 threshold. // After 2 consecutive failing cycles (10 min) the node becomes suspect. name: "exactly_2_of_3_ISPs_fail_after_2_cycles", initialStatus: detect.StatusUp, ticks: []tick{ {snaps: snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())}, {snaps: snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())}, }, wantStatus: detect.StatusBlockedSuspect, wantEvents: [][2]detect.NodeStatus{ {detect.StatusUp, detect.StatusBlockedSuspect}, }, }, { // 1 out of 3 ISPs fail → below the 2/3 threshold → no transition. name: "1_of_3_ISPs_fail_no_suspect", initialStatus: detect.StatusUp, ticks: []tick{ {snaps: snapsForNode(cnFail("ChinaTelecom"), cnOK("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())}, {snaps: snapsForNode(cnFail("ChinaTelecom"), cnOK("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())}, }, wantStatus: detect.StatusUp, wantEvents: nil, }, { // 9 min (= 1 complete 5-min cycle): fail_streak reaches 1, not yet 2. // No transition expected after exactly one failing tick. name: "9min_one_cycle_no_transition", initialStatus: detect.StatusUp, ticks: []tick{ {snaps: snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())}, }, wantStatus: detect.StatusUp, wantEvents: nil, }, { // 10 min (= 2 complete 5-min cycles): fail_streak reaches 2 ≥ SuspectStreakMin. // Transition to blocked_suspect expected. name: "10min_two_cycles_triggers_suspect", initialStatus: detect.StatusUp, ticks: []tick{ {snaps: snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())}, {snaps: snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())}, }, wantStatus: detect.StatusBlockedSuspect, wantEvents: [][2]detect.NodeStatus{ {detect.StatusUp, detect.StatusBlockedSuspect}, }, }, { // Traffic warning: drop ≥ 80 % + baseline ≥ 20 → effectiveSuspectStreakMin = 1. // A single failing tick is enough to enter blocked_suspect. name: "traffic_warning_relaxes_threshold_to_1_cycle", initialStatus: detect.StatusUp, ticks: []tick{ // The MockLifecycle will have loads set before running this test; // we pass snaps with 2/3 ISPs failing + overseas OK. {snaps: snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())}, }, wantStatus: detect.StatusBlockedSuspect, wantEvents: [][2]detect.NodeStatus{ {detect.StatusUp, detect.StatusBlockedSuspect}, }, }, { // Confirmed: node spends 6 consecutive cycles in blocked_suspect // with domestic still failing → transitions to blocked_confirmed → down. name: "suspect_6_cycles_triggers_confirmed", initialStatus: detect.StatusBlockedSuspect, ticks: []tick{ {snaps: snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())}, {snaps: snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())}, {snaps: snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())}, {snaps: snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())}, {snaps: snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())}, {snaps: snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())}, }, wantStatus: detect.StatusDown, wantEvents: [][2]detect.NodeStatus{ {detect.StatusBlockedSuspect, detect.StatusBlockedConfirmed}, {detect.StatusBlockedConfirmed, detect.StatusDown}, }, }, { // Fault: domestic + overseas both fail → fault rule fires, no state // transition, NotifyFault is called exactly once. name: "domestic_and_overseas_fail_triggers_fault_not_block", initialStatus: detect.StatusUp, ticks: []tick{ {snaps: snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), overseasFail())}, {snaps: snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), overseasFail())}, {snaps: snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), overseasFail())}, }, wantStatus: detect.StatusUp, // no transition wantEvents: nil, // no lifecycle transitions wantFaultNotified: true, }, { // Recover: 2 consecutive cycles with domestic passing while in // blocked_suspect → transitions back to up. name: "recover_2_cycles_back_to_up", initialStatus: detect.StatusBlockedSuspect, ticks: []tick{ {snaps: snapsForNode(cnOK("ChinaTelecom"), cnOK("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())}, {snaps: snapsForNode(cnOK("ChinaTelecom"), cnOK("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())}, }, wantStatus: detect.StatusUp, wantEvents: [][2]detect.NodeStatus{ {detect.StatusBlockedSuspect, detect.StatusUp}, }, }, { // Recovery not yet reached: 1 cycle passing while in blocked_suspect // → still in blocked_suspect. name: "recover_1_cycle_not_yet_recovered", initialStatus: detect.StatusBlockedSuspect, ticks: []tick{ {snaps: snapsForNode(cnOK("ChinaTelecom"), cnOK("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())}, }, wantStatus: detect.StatusBlockedSuspect, wantEvents: nil, }, { // Third-party ("3rd-") ISP prefix is stripped before grouping: // "3rd-ChinaTelecom" and "ChinaTelecom" must count as the same ISP. // Two ISPs fail out of three → triggers suspect. name: "third_party_isp_prefix_merged", initialStatus: detect.StatusUp, ticks: []tick{ {snaps: snapsForNode(cnFail("3rd-ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())}, {snaps: snapsForNode(cnFail("3rd-ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())}, }, wantStatus: detect.StatusBlockedSuspect, wantEvents: [][2]detect.NodeStatus{ {detect.StatusUp, detect.StatusBlockedSuspect}, }, }, { // L3 failure overrides L1/L2: even if L1 is OK the ISP is counted // as failing when L3 is explicitly false. name: "l3_failure_overrides_l1_l2", initialStatus: detect.StatusUp, ticks: []tick{ // L1 passes but L3 fails for two ISPs → ≥2/3 threshold met. {snaps: snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())}, {snaps: snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK())}, }, wantStatus: detect.StatusBlockedSuspect, wantEvents: [][2]detect.NodeStatus{ {detect.StatusUp, detect.StatusBlockedSuspect}, }, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { rdb, _ := newTestRedis(t) lc := detect.NewMockLifecycle([]detect.NodeInfo{ {ID: nodeID, Status: tc.initialStatus, Weight: 100}, }) // For the traffic-warning test, set up load history showing a large drop. // Older half: 100 connections. Newer half: 20 connections. // Drop = 80 % ≥ TrafficDropThreshold (80 %). // Current baseline = 20 ≥ TrafficBaselineMin (20) → warning applies. if tc.name == "traffic_warning_relaxes_threshold_to_1_cycle" { now := time.Now().Unix() lc.SetLoads(nodeID, []detect.LoadPoint{ {Timestamp: now - 14*60, Online: 100}, {Timestamp: now - 10*60, Online: 100}, {Timestamp: now - 5*60, Online: 20}, {Timestamp: now - 60, Online: 20}, }) } notifier := &recordingNotifier{} snapper := &mockSnapshotter{data: make(map[string]map[string]probe.ProbeSnapshot)} eng := newEngine(t, rdb, lc, snapper, notifier, &cfg) for i, tick := range tc.ticks { snapper.data[nodeID] = tick.snaps if err := eng.Tick(ctx); err != nil { t.Fatalf("tick %d: Tick() error: %v", i, err) } } // Verify final node status. if got := lc.NodeStatus(nodeID); got != tc.wantStatus { t.Errorf("node status = %q, want %q", got, tc.wantStatus) } // Verify lifecycle events (from/to pairs only; detail checked separately). events := lc.Events() if len(events) != len(tc.wantEvents) { t.Errorf("got %d events, want %d: %v", len(events), len(tc.wantEvents), events) } else { for i, ev := range events { want := tc.wantEvents[i] if ev.From != want[0] || ev.To != want[1] { t.Errorf("event[%d]: from=%q to=%q, want from=%q to=%q", i, ev.From, ev.To, want[0], want[1]) } } } // Verify fault notification. if tc.wantFaultNotified && !notifier.hasFaultEvent() { t.Error("expected fault Notify event to be recorded, but it was not") } if !tc.wantFaultNotified && notifier.hasFaultEvent() { t.Errorf("unexpected fault Notify events: %v", notifier.events) } }) } } // TestEventDetailContainsFromTo verifies that every TransitionStatus call // includes "from" and "to" keys in its detail map (node_events audit trail). func TestEventDetailContainsFromTo(t *testing.T) { ctx := context.Background() cfg := detect.DefaultConfig() rdb, _ := newTestRedis(t) lc := detect.NewMockLifecycle([]detect.NodeInfo{ {ID: nodeID, Status: detect.StatusUp, Weight: 100}, }) snapper := &mockSnapshotter{ data: map[string]map[string]probe.ProbeSnapshot{ nodeID: snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK()), }, } eng := newEngine(t, rdb, lc, snapper, nil, &cfg) // Two ticks to trigger suspect. if err := eng.Tick(ctx); err != nil { t.Fatal(err) } if err := eng.Tick(ctx); err != nil { t.Fatal(err) } events := lc.Events() if len(events) == 0 { t.Fatal("no events recorded") } for _, ev := range events { if _, ok := ev.Detail["from"]; !ok { t.Errorf("event %v→%v detail missing 'from'", ev.From, ev.To) } if _, ok := ev.Detail["to"]; !ok { t.Errorf("event %v→%v detail missing 'to'", ev.From, ev.To) } } } // TestOptimisticLockConflict verifies that when TransitionStatus returns 0 // (lock conflict), the current cycle is abandoned without any side-effects, // and the engine is idempotent — the next tick re-attempts cleanly. func TestOptimisticLockConflict(t *testing.T) { ctx := context.Background() cfg := detect.DefaultConfig() rdb, _ := newTestRedis(t) lc := detect.NewMockLifecycle([]detect.NodeInfo{ {ID: nodeID, Status: detect.StatusUp, Weight: 100}, }) // Register a conflict for the up→suspect transition. lc.SetConflict(nodeID, detect.StatusUp, detect.StatusBlockedSuspect) failSnaps := snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK()) snapper := &mockSnapshotter{data: map[string]map[string]probe.ProbeSnapshot{nodeID: failSnaps}} eng := newEngine(t, rdb, lc, snapper, nil, &cfg) // Run two ticks — normally enough to trigger suspect. if err := eng.Tick(ctx); err != nil { t.Fatal(err) } if err := eng.Tick(ctx); err != nil { t.Fatal(err) } // Conflict registered: status must remain "up", no events logged. if got := lc.NodeStatus(nodeID); got != detect.StatusUp { t.Errorf("status = %q after conflict; want %q", got, detect.StatusUp) } if events := lc.Events(); len(events) != 0 { t.Errorf("expected 0 events after conflict, got %d: %v", len(events), events) } // Clear conflict, run one more tick → should transition now (streak already at 1). lc.ClearConflict(nodeID, detect.StatusUp, detect.StatusBlockedSuspect) if err := eng.Tick(ctx); err != nil { t.Fatal(err) } if got := lc.NodeStatus(nodeID); got != detect.StatusBlockedSuspect { t.Errorf("status = %q after conflict cleared; want %q", got, detect.StatusBlockedSuspect) } } // TestStreakPersistenceAcrossRestart verifies that streak counters survive a // simulated process restart: a new Engine reading the same Redis should // continue from the saved streak rather than starting from zero. func TestStreakPersistenceAcrossRestart(t *testing.T) { ctx := context.Background() cfg := detect.DefaultConfig() // SuspectStreakMin = 2 rdb, _ := newTestRedis(t) lc := detect.NewMockLifecycle([]detect.NodeInfo{ {ID: nodeID, Status: detect.StatusUp, Weight: 100}, }) failSnaps := snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK()) snapper := &mockSnapshotter{data: map[string]map[string]probe.ProbeSnapshot{nodeID: failSnaps}} // First "process instance": run one tick (fail_streak becomes 1). eng1 := newEngine(t, rdb, lc, snapper, nil, &cfg) if err := eng1.Tick(ctx); err != nil { t.Fatal(err) } if got := lc.NodeStatus(nodeID); got != detect.StatusUp { t.Errorf("after tick 1 status = %q; want up", got) } // "Restart": create a brand-new Engine pointing at the same Redis. // The streak (fail_streak=1) must still be present. eng2 := newEngine(t, rdb, lc, snapper, nil, &cfg) if err := eng2.Tick(ctx); err != nil { t.Fatal(err) } // The second engine picks up fail_streak=1, increments to 2, and fires // the suspect transition. if got := lc.NodeStatus(nodeID); got != detect.StatusBlockedSuspect { t.Errorf("after restart tick status = %q; want blocked_suspect", got) } } // TestReplaceQueuePopulated verifies that when a node is confirmed, the // detect:replace:queue Redis list receives a valid JSON entry. func TestReplaceQueuePopulated(t *testing.T) { ctx := context.Background() cfg := detect.DefaultConfig() rdb, _ := newTestRedis(t) lc := detect.NewMockLifecycle([]detect.NodeInfo{ {ID: nodeID, Status: detect.StatusBlockedSuspect, Weight: 10}, }) failSnaps := snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK()) snapper := &mockSnapshotter{data: map[string]map[string]probe.ProbeSnapshot{nodeID: failSnaps}} eng := newEngine(t, rdb, lc, snapper, nil, &cfg) // Run ConfirmedStreakMin (6) ticks. for i := 0; i < cfg.ConfirmedStreakMin; i++ { if err := eng.Tick(ctx); err != nil { t.Fatalf("tick %d: %v", i, err) } } if got := lc.NodeStatus(nodeID); got != detect.StatusDown { t.Errorf("node status = %q after 6 suspect cycles; want down", got) } // Verify the replace queue has one entry with the correct shape. qlen, err := rdb.LLen(ctx, "detect:replace:queue").Result() if err != nil { t.Fatalf("LLEN: %v", err) } if qlen != 1 { t.Fatalf("replace queue length = %d; want 1", qlen) } raw, err := rdb.LIndex(ctx, "detect:replace:queue", 0).Result() if err != nil { t.Fatalf("LINDEX: %v", err) } var entry struct { NodeID string `json:"nodeId"` ReplacementUUID string `json:"replacementUuid"` } if err := json.Unmarshal([]byte(raw), &entry); err != nil { t.Fatalf("unmarshal replace queue entry: %v", err) } if entry.NodeID != nodeID { t.Errorf("replace queue nodeId = %q; want %q", entry.NodeID, nodeID) } if entry.ReplacementUUID == "" { t.Error("replace queue replacementUuid is empty") } } // TestProbeFreqMarkWritten verifies that the probe:freq:{nodeID} Redis key is // set when a node transitions to blocked_suspect. func TestProbeFreqMarkWritten(t *testing.T) { ctx := context.Background() cfg := detect.DefaultConfig() rdb, _ := newTestRedis(t) lc := detect.NewMockLifecycle([]detect.NodeInfo{ {ID: nodeID, Status: detect.StatusUp, Weight: 100}, }) failSnaps := snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK()) snapper := &mockSnapshotter{data: map[string]map[string]probe.ProbeSnapshot{nodeID: failSnaps}} eng := newEngine(t, rdb, lc, snapper, nil, &cfg) // Two ticks to trigger suspect. for i := 0; i < 2; i++ { if err := eng.Tick(ctx); err != nil { t.Fatalf("tick %d: %v", i, err) } } if got := lc.NodeStatus(nodeID); got != detect.StatusBlockedSuspect { t.Errorf("status = %q; want blocked_suspect", got) } freqKey := "probe:freq:" + nodeID val, err := rdb.Get(ctx, freqKey).Result() if err != nil { t.Fatalf("probe freq key not found: %v", err) } if val != "60" { t.Errorf("probe:freq value = %q; want 60", val) } } // TestFaultNoTransitionNoStreak verifies that the fault rule leaves the node // status and streaks completely unchanged across multiple ticks, even though // the domestic probes are failing. func TestFaultNoTransitionNoStreak(t *testing.T) { ctx := context.Background() cfg := detect.DefaultConfig() rdb, _ := newTestRedis(t) lc := detect.NewMockLifecycle([]detect.NodeInfo{ {ID: nodeID, Status: detect.StatusUp, Weight: 100}, }) // Both domestic and overseas fail → fault. faultSnaps := snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), overseasFail()) snapper := &mockSnapshotter{data: map[string]map[string]probe.ProbeSnapshot{nodeID: faultSnaps}} notifier := &recordingNotifier{} eng := newEngine(t, rdb, lc, snapper, notifier, &cfg) // Run many ticks — should never transition. for i := 0; i < 10; i++ { if err := eng.Tick(ctx); err != nil { t.Fatalf("tick %d: %v", i, err) } } if got := lc.NodeStatus(nodeID); got != detect.StatusUp { t.Errorf("status = %q; want up (fault must not transition)", got) } if events := lc.Events(); len(events) != 0 { t.Errorf("unexpected events: %v", events) } if !notifier.hasFaultEvent() { t.Error("expected fault Notify event to be recorded at least once") } } // TestSuspectWeightApplied verifies that when a node enters blocked_suspect, // its routing weight is reduced to cfg.SuspectWeight (default 10). func TestSuspectWeightApplied(t *testing.T) { ctx := context.Background() cfg := detect.DefaultConfig() rdb, _ := newTestRedis(t) lc := detect.NewMockLifecycle([]detect.NodeInfo{ {ID: nodeID, Status: detect.StatusUp, Weight: 100}, }) failSnaps := snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile"), overseasOK()) snapper := &mockSnapshotter{data: map[string]map[string]probe.ProbeSnapshot{nodeID: failSnaps}} eng := newEngine(t, rdb, lc, snapper, nil, &cfg) for i := 0; i < 2; i++ { if err := eng.Tick(ctx); err != nil { t.Fatalf("tick %d: %v", i, err) } } if got := lc.NodeWeight(nodeID); got != cfg.SuspectWeight { t.Errorf("weight after suspect = %d; want %d", got, cfg.SuspectWeight) } } // TestNoOverseasDataNoSuspect verifies that when no overseas probe data is // available, the engine does not trigger the suspect rule (we cannot confirm // the node is globally reachable, so we cannot attribute a domestic failure // to GFW censorship). func TestNoOverseasDataNoSuspect(t *testing.T) { ctx := context.Background() cfg := detect.DefaultConfig() rdb, _ := newTestRedis(t) lc := detect.NewMockLifecycle([]detect.NodeInfo{ {ID: nodeID, Status: detect.StatusUp, Weight: 100}, }) // Only domestic probes, no overseas vantage. domesticOnlySnaps := snapsForNode(cnFail("ChinaTelecom"), cnFail("ChinaUnicom"), cnOK("ChinaMobile")) snapper := &mockSnapshotter{data: map[string]map[string]probe.ProbeSnapshot{nodeID: domesticOnlySnaps}} eng := newEngine(t, rdb, lc, snapper, nil, &cfg) for i := 0; i < 5; i++ { if err := eng.Tick(ctx); err != nil { t.Fatalf("tick %d: %v", i, err) } } if got := lc.NodeStatus(nodeID); got != detect.StatusUp { t.Errorf("status = %q; want up (no overseas data → no suspect)", got) } }