From 75cdbf583f3dcfc3e846807cdd3a97706c0fcbe8 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Thu, 25 Jun 2026 23:01:27 +0800 Subject: [PATCH] =?UTF-8?q?test(server):=20=E5=88=802=20=E2=80=94=20v2ray?= =?UTF-8?q?=20=E7=9C=9F=E5=AE=9E=E9=87=87=E9=9B=86=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E6=B5=8B=E8=AF=95(=E7=BC=BA=E5=8F=A3=E2=91=A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V2RayUsageSource 是「线上按租户记账」链路的源头(agent 读 sing-box v2ray_api StatsService per-user 计数器 → 聚合 → ReportUsage),此前零测试覆盖 (复查发现之前以为有单测是错的)。本刀按用户定的范围只补 CI 可跑的后端采集: - TestParseUserStat:表驱动,正常 uplink/downlink + 非 user/段数异常应 ok=false。 - TestV2RayCollect:起假 StatsService(loopback gRPC,ServiceName 与 client.go 的 规范全名一致),让真 Collect() 跑真链路,断言:按 dp_uuid 聚合 up/down 正确、 方向不串、全 0 用户与非 user 计数器被丢弃、SessionMinutes=1、且请求以 Reset_=true + pattern "user>>>" 发出(窗口取值清零语义)。 纯 go test、无 docker / 无真 sing-box;自动进 go-server job + 计入覆盖率闸。 真 sing-box 真出网 e2e 仍按计划留文档手测。 Co-Authored-By: Claude Opus 4.8 --- server/internal/agentd/usage_v2ray_test.go | 116 +++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 server/internal/agentd/usage_v2ray_test.go diff --git a/server/internal/agentd/usage_v2ray_test.go b/server/internal/agentd/usage_v2ray_test.go new file mode 100644 index 0000000..2d57c57 --- /dev/null +++ b/server/internal/agentd/usage_v2ray_test.go @@ -0,0 +1,116 @@ +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 不应入账") + } +}