package agentd // 问题② 真实流量采集后端测试(CI 可跑,无需 docker / 真 sing-box)。 // // V2RayUsageSource 是「线上按租户记账」链路的源头:agent 读 sing-box v2ray_api // StatsService 的 per-user 计数器 → 聚合 → ReportUsage。此前零测试覆盖。 // 这里:① parseUserStat 纯函数表驱动;② 起一个假 StatsService(loopback gRPC, // 注册与 client.go 一致的规范全名),让真 Collect() 跑真解析/聚合/清零语义。 import ( "context" "net" "testing" "github.com/wangjia/pangolin/server/internal/agentd/v2rayapi" "google.golang.org/grpc" ) func TestParseUserStat(t *testing.T) { cases := []struct { name string wantUUID string wantDir string wantOK bool }{ {"user>>>abc>>>traffic>>>uplink", "abc", "uplink", true}, {"user>>>abc>>>traffic>>>downlink", "abc", "downlink", true}, {"inbound>>>mixed>>>traffic>>>uplink", "", "", false}, // 非 user 计数器 {"user>>>abc>>>other>>>uplink", "", "", false}, // 第三段非 traffic {"user>>>abc>>>traffic", "", "", false}, // 段数不足 {"user>>>abc>>>traffic>>>uplink>>>extra", "", "", false}, // 段数过多 {"", "", "", false}, } for _, c := range cases { uuid, dir, ok := parseUserStat(c.name) if ok != c.wantOK || uuid != c.wantUUID || dir != c.wantDir { t.Errorf("parseUserStat(%q) = (%q,%q,%v), want (%q,%q,%v)", c.name, uuid, dir, ok, c.wantUUID, c.wantDir, c.wantOK) } } } // fakeStats 实现 v2ray StatsService.QueryStats:返回预设计数器,记录收到的请求。 type fakeStats struct { resp *v2rayapi.QueryStatsResponse lastReq *v2rayapi.QueryStatsRequest } func (f *fakeStats) queryStats(_ any, _ context.Context, dec func(any) error, _ grpc.UnaryServerInterceptor) (any, error) { in := new(v2rayapi.QueryStatsRequest) if err := dec(in); err != nil { return nil, err } f.lastReq = in return f.resp, nil } // startFakeStats 在 loopback 起假 StatsService,返回地址 + 取最近请求的闭包。 // ServiceName 与 client.go 硬编码的规范全名一致,否则 raw Invoke 命中不到。 func startFakeStats(t *testing.T, resp *v2rayapi.QueryStatsResponse) (string, func() *v2rayapi.QueryStatsRequest) { t.Helper() lis, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("listen: %v", err) } f := &fakeStats{resp: resp} srv := grpc.NewServer() srv.RegisterService(&grpc.ServiceDesc{ ServiceName: "v2ray.core.app.stats.command.StatsService", HandlerType: (*any)(nil), Methods: []grpc.MethodDesc{{MethodName: "QueryStats", Handler: f.queryStats}}, }, f) go func() { _ = srv.Serve(lis) }() t.Cleanup(srv.Stop) return lis.Addr().String(), func() *v2rayapi.QueryStatsRequest { return f.lastReq } } func TestV2RayCollect(t *testing.T) { resp := &v2rayapi.QueryStatsResponse{Stat: []*v2rayapi.Stat{ {Name: "user>>>uuid-a>>>traffic>>>uplink", Value: 1000}, {Name: "user>>>uuid-a>>>traffic>>>downlink", Value: 2000}, {Name: "user>>>uuid-b>>>traffic>>>uplink", Value: 500}, // b 只有上行 {Name: "user>>>uuid-b>>>traffic>>>downlink", Value: 0}, {Name: "user>>>uuid-c>>>traffic>>>uplink", Value: 0}, // c 全 0 → 应丢弃 {Name: "user>>>uuid-c>>>traffic>>>downlink", Value: 0}, {Name: "inbound>>>mixed>>>traffic>>>uplink", Value: 9999}, // 非 user → 应丢弃 }} addr, lastReq := startFakeStats(t, resp) s := &V2RayUsageSource{addr: addr} entries := s.Collect() // 窗口语义:必须以 reset=true + pattern "user>>>" 拉取(取值并清零)。 if r := lastReq(); r == nil || !r.Reset_ || r.Pattern != "user>>>" { t.Fatalf("QueryStats 请求 = %+v, 期望 Reset_=true Pattern=\"user>>>\"", r) } byUUID := make(map[string]struct{ up, down, mins int64 }) for _, e := range entries { byUUID[e.DpUUID] = struct{ up, down, mins int64 }{e.BytesUp, e.BytesDown, e.SessionMinutes} } // uuid-c(全 0)与非 user 计数器都不应入账。 if len(entries) != 2 { t.Fatalf("entries=%d (%+v), 期望 2(a/b;c 全0 与 inbound 丢弃)", len(entries), byUUID) } if a := byUUID["uuid-a"]; a.up != 1000 || a.down != 2000 || a.mins != 1 { t.Errorf("uuid-a = %+v, 期望 up=1000 down=2000 mins=1", a) } if b := byUUID["uuid-b"]; b.up != 500 || b.down != 0 || b.mins != 1 { t.Errorf("uuid-b = %+v, 期望 up=500 down=0 mins=1", b) } if _, ok := byUUID["uuid-c"]; ok { t.Errorf("uuid-c 全 0 不应入账") } }