package grpc import ( "context" "net" "os" "testing" "time" roomv1 "hyapp.local/api/proto/room/v1" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/appcode" "hyapp/pkg/xerr" "hyapp/services/room-service/internal/integration" roomservice "hyapp/services/room-service/internal/room/service" "hyapp/services/room-service/internal/router" "hyapp/services/room-service/internal/testutil/mysqltest" grpcpkg "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials/insecure" grpcstatus "google.golang.org/grpc/status" ) // fakeWallet 只满足 room-service 构造依赖;本测试走参数校验失败分支,不触发扣费。 type fakeWallet struct{} // DebitGift 返回固定结果,避免 transport 测试依赖 wallet-service 进程。 func (fakeWallet) DebitGift(context.Context, *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) { return &walletv1.DebitGiftResponse{BillingReceiptId: "receipt-test"}, nil } // BatchDebitGift 返回固定结果,避免 transport 测试依赖 wallet-service 批量扣费实现。 func (fakeWallet) BatchDebitGift(context.Context, *walletv1.BatchDebitGiftRequest) (*walletv1.BatchDebitGiftResponse, error) { return &walletv1.BatchDebitGiftResponse{Aggregate: &walletv1.DebitGiftResponse{BillingReceiptId: "receipt-test"}}, nil } // GrantResourceGroup 返回固定发放结果,避免 transport 测试依赖 wallet-service 资源组实现。 func (fakeWallet) GrantResourceGroup(context.Context, *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error) { return &walletv1.ResourceGrantResponse{Grant: &walletv1.ResourceGrant{GrantId: "grant-test"}}, nil } // newTransportTestServer 构造完整领域服务,确保测试覆盖真实 service -> transport 错误边界。 func newTransportTestServer(t *testing.T) *Server { t.Helper() svc := roomservice.New(roomservice.Config{ NodeID: "node-transport-test", LeaseTTL: 10 * time.Second, RankLimit: 20, SnapshotEveryN: 1, }, router.NewMemoryDirectory(), mysqltest.NewRepository(t), fakeWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher()) return NewServer(svc) } func TestCreateRoomMapsDomainErrorToGRPCReason(t *testing.T) { server := newTransportTestServer(t) _, err := server.CreateRoom(context.Background(), &roomv1.CreateRoomRequest{ // room_id 缺失属于客户端参数错误,不能在 gRPC 边界丢失 reason 后变成 INTERNAL_ERROR。 SeatCount: 10, Mode: "voice", }) if err == nil { t.Fatalf("CreateRoom without room_id should fail") } if got := grpcstatus.Code(err); got != codes.InvalidArgument { t.Fatalf("gRPC code mismatch: got %s", got) } if got := xerr.ReasonFromGRPC(err); got != xerr.InvalidArgument { t.Fatalf("reason mismatch: got %s", got) } if message := grpcstatus.Convert(err).Message(); message != "room_id is required" { t.Fatalf("message mismatch: got %q", message) } } func TestOwnerForwardingUsesRedisRouteAndNodeRegistry(t *testing.T) { redisAddr := os.Getenv("ROOM_SERVICE_REDIS_TEST_ADDR") if redisAddr == "" { t.Skip("set ROOM_SERVICE_REDIS_TEST_ADDR to run Redis-backed owner forwarding test") } ctx := context.Background() redisClient, err := router.NewRedisClient(ctx, redisAddr, os.Getenv("ROOM_SERVICE_REDIS_TEST_PASSWORD"), 0) if err != nil { t.Fatalf("open redis directory failed: %v", err) } t.Cleanup(func() { _ = redisClient.Close() }) directory := router.NewRedisDirectory(redisClient) repository := mysqltest.NewRepository(t) addrA, stopA := startForwardingRoomServer(t, "node-forward-a", directory, repository) defer stopA() addrB, stopB := startForwardingRoomServer(t, "node-forward-b", directory, repository) defer stopB() if err := directory.RegisterNode(ctx, router.NodeRegistration{NodeID: "node-forward-a", GRPCAddr: addrA}, time.Minute); err != nil { t.Fatalf("register node a failed: %v", err) } if err := directory.RegisterNode(ctx, router.NodeRegistration{NodeID: "node-forward-b", GRPCAddr: addrB}, time.Minute); err != nil { t.Fatalf("register node b failed: %v", err) } t.Cleanup(func() { _ = directory.UnregisterNode(context.Background(), "node-forward-a") _ = directory.UnregisterNode(context.Background(), "node-forward-b") }) connA, err := grpcpkg.Dial(addrA, grpcpkg.WithTransportCredentials(insecure.NewCredentials())) if err != nil { t.Fatalf("dial node a failed: %v", err) } defer connA.Close() connB, err := grpcpkg.Dial(addrB, grpcpkg.WithTransportCredentials(insecure.NewCredentials())) if err != nil { t.Fatalf("dial node b failed: %v", err) } defer connB.Close() roomID := "room-forward-test" directory.ForceExpire(roomservice.RuntimeRoomKey(appcode.Default, roomID)) t.Cleanup(func() { directory.ForceExpire(roomservice.RuntimeRoomKey(appcode.Default, roomID)) }) createResp, err := roomv1.NewRoomCommandServiceClient(connA).CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: &roomv1.RequestMeta{ RequestId: "req-forward-create", CommandId: "cmd-forward-create", ActorUserId: 1001, RoomId: roomID, SentAtMs: time.Now().UTC().UnixMilli(), AppCode: appcode.Default, }, SeatCount: 10, Mode: "voice", RoomName: "Forward Room", RoomAvatar: "https://cdn.example.com/room-cover.png", RoomShortId: "forward-short", }) if err != nil { t.Fatalf("create room on node a failed: %v", err) } if createResp.GetRoom().GetRoomId() != roomID { t.Fatalf("create room id mismatch: %+v", createResp.GetRoom()) } lease, exists, err := directory.Lookup(ctx, roomservice.RuntimeRoomKey(appcode.Default, roomID)) if err != nil || !exists { t.Fatalf("lookup owner lease failed: exists=%v err=%v", exists, err) } if lease.NodeID != "node-forward-a" { t.Fatalf("room should be owned by node a before forwarding, got %+v", lease) } joinResp, err := roomv1.NewRoomCommandServiceClient(connB).JoinRoom(ctx, &roomv1.JoinRoomRequest{ Meta: &roomv1.RequestMeta{ RequestId: "req-forward-join", CommandId: "cmd-forward-join", ActorUserId: 1002, RoomId: roomID, SentAtMs: time.Now().UTC().UnixMilli(), AppCode: appcode.Default, }, Role: "audience", }) if err != nil { t.Fatalf("join room through node b should forward to owner: %v", err) } if findUser(joinResp.GetRoom(), 1002) == nil { t.Fatalf("forwarded join did not update owner room snapshot: %+v", joinResp.GetRoom().GetOnlineUsers()) } guardResp, err := roomv1.NewRoomGuardServiceClient(connB).CheckSpeakPermission(ctx, &roomv1.CheckSpeakPermissionRequest{ RoomId: roomID, UserId: 1002, AppCode: appcode.Default, }) if err != nil { t.Fatalf("guard through node b should forward to owner: %v", err) } if !guardResp.GetAllowed() { t.Fatalf("forwarded guard should allow joined user, got %+v", guardResp) } snapshotResp, err := roomv1.NewRoomQueryServiceClient(connB).GetRoomSnapshot(ctx, &roomv1.GetRoomSnapshotRequest{ Meta: &roomv1.RequestMeta{ RequestId: "req-forward-snapshot", ActorUserId: 1002, RoomId: roomID, AppCode: appcode.Default, }, RoomId: roomID, ViewerUserId: 1002, }) if err != nil { t.Fatalf("snapshot through node b should forward to owner: %v", err) } if snapshotResp.GetRoom().GetVersion() != joinResp.GetRoom().GetVersion() { t.Fatalf("forwarded snapshot version mismatch: got %d want %d", snapshotResp.GetRoom().GetVersion(), joinResp.GetRoom().GetVersion()) } } func startForwardingRoomServer(t *testing.T, nodeID string, directory *router.RedisDirectory, repository roomservice.Repository) (string, func()) { t.Helper() svc := roomservice.New(roomservice.Config{ NodeID: nodeID, LeaseTTL: 10 * time.Second, RankLimit: 20, SnapshotEveryN: 1, }, directory, repository, fakeWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher()) listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("listen forwarding test server failed: %v", err) } grpcServer := grpcpkg.NewServer() roomServer := NewServer(svc, WithOwnerForwarding(nodeID, directory, directory, time.Second)) roomv1.RegisterRoomCommandServiceServer(grpcServer, roomServer) roomv1.RegisterRoomGuardServiceServer(grpcServer, roomServer) roomv1.RegisterRoomQueryServiceServer(grpcServer, roomServer) errCh := make(chan error, 1) go func() { errCh <- grpcServer.Serve(listener) }() stop := func() { roomServer.ownerForwarder.close() grpcServer.GracefulStop() select { case err := <-errCh: if err != nil { t.Fatalf("forwarding test server stopped with error: %v", err) } case <-time.After(2 * time.Second): t.Fatal("forwarding test server did not stop") } } return listener.Addr().String(), stop } func findUser(snapshot *roomv1.RoomSnapshot, userID int64) *roomv1.RoomUser { for _, user := range snapshot.GetOnlineUsers() { if user.GetUserId() == userID { return user } } return nil }