package job import ( "testing" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/web/runtime" "github.com/mhsanaei/3x-ui/v3/internal/web/service" ) // REPRO (V1.0.2 fix 2): agent nodes are push-only — their online clients // arrive via agent traffic reports (InboundService.SetAgentOnlineClients) // and are keyed exactly where the node list looks them up (the service-level // alignment is pinned by TestReproAgentNodeOnlineEmailsInNodeList). But the // traffic sync job's pull loop treats every enabled+online node as a panel: // for an agent node the snapshot fetch can never succeed (address is the // literal placeholder "agent", there is no panel API behind it), and the // fetch-failure branch calls ClearNodeOnlineClients — wiping the agent's // pushed online state on every tick. The heartbeat job already skips // agent-managed nodes for probing; the sync job must not pull them either. func TestSyncOne_AgentNodeKeepsReportedOnlines(t *testing.T) { setupIntegrationDB(t) db := database.GetDB() node := &model.Node{ Name: "agent-sync-node", Address: "agent", Port: 2053, Enable: true, Status: "online", } if err := db.Create(node).Error; err != nil { t.Fatalf("create node: %v", err) } if err := db.Create(&model.AgentNode{ TokenHash: "hash-agent-sync-node", Name: "agent-sync-node", NodeID: &node.Id, }).Error; err != nil { t.Fatalf("create agent binding: %v", err) } // Production form: the agent node actually hosts inbounds (without any // node-bound inbound in the DB, NodeService.GetAll takes its early // return and never fills per-node stats at all). if err := db.Create(&model.Inbound{ Tag: "agent-sync-in-1", Enable: true, Port: 46994, Protocol: model.VLESS, Settings: `{"clients":[]}`, NodeID: &node.Id, }).Error; err != nil { t.Fatalf("create inbound: %v", err) } inboundSvc := service.InboundService{} inboundSvc.ClearNodeOnlineClients(node.Id) t.Cleanup(func() { inboundSvc.ClearNodeOnlineClients(node.Id) }) inboundSvc.SetAgentOnlineClients(node.Id, []string{"agent-online@example.com"}) if got := inboundSvc.GetOnlineClients(); len(got) != 1 { t.Fatalf("precondition: online union = %v, want the agent-reported email", got) } j := NewNodeTrafficSyncJob() j.syncOne(runtime.NewManager(), node, false) byGuid := inboundSvc.GetOnlineClientsByGuid() found := false for _, emails := range byGuid { for _, e := range emails { if e == "agent-online@example.com" { found = true } } } if !found { t.Fatalf("agent onlines wiped by sync tick: byGuid = %v", byGuid) } views, err := (&service.NodeService{}).GetNodeTreeView() if err != nil { t.Fatalf("GetNodeTreeView: %v", err) } for _, v := range views { if v.Id == node.Id { if len(v.OnlineEmails) != 1 || v.OnlineEmails[0] != "agent-online@example.com" { t.Fatalf("node view online emails = %v, want [agent-online@example.com]", v.OnlineEmails) } } } }