From e2dbf4cf88800d4315011827b697f02e8f57490f Mon Sep 17 00:00:00 2001 From: opencode Date: Fri, 24 Jul 2026 16:39:53 +0800 Subject: [PATCH 1/9] feat: add Broadcasting module tests and scaffolding - Add config/broadcasting.go with pusher, log, null drivers - Add app/facades/broadcast.go facade wrapper - Add routes/channels.go channel auth callbacks - Add app/broadcasting/events.go implementing all ShouldBroadcast* contracts - Add tests/feature/broadcast_test.go: 13 integration tests - Add mock examples: BroadcastChannel, BroadcastDispatch - Register broadcasting service provider - Wire routes.Channels() in bootstrap - Bump framework to 8b12654 --- app/broadcasting/events.go | 124 +++++++++++++++++++ app/facades/broadcast.go | 9 ++ app/services/mock_examples.go | 15 +++ app/services/mock_examples_test.go | 19 +++ bootstrap/app.go | 1 + bootstrap/providers.go | 2 + config/broadcasting.go | 37 ++++++ go.mod | 24 ++-- go.sum | 44 +++---- routes/channels.go | 21 ++++ tests/feature/broadcast_test.go | 186 +++++++++++++++++++++++++++++ 11 files changed, 448 insertions(+), 34 deletions(-) create mode 100644 app/broadcasting/events.go create mode 100644 app/facades/broadcast.go create mode 100644 config/broadcasting.go create mode 100644 routes/channels.go create mode 100644 tests/feature/broadcast_test.go diff --git a/app/broadcasting/events.go b/app/broadcasting/events.go new file mode 100644 index 0000000..948bfe1 --- /dev/null +++ b/app/broadcasting/events.go @@ -0,0 +1,124 @@ +package broadcasting + +import ( + "time" + + "github.com/goravel/framework/broadcasting" + contracts "github.com/goravel/framework/contracts/broadcasting" +) + +type OrderShipped struct { + OrderID uint + OrderData map[string]any + ShouldFire bool + QueueName string + Conns []string + QueueConn string + DelayedAt time.Time + Retries int + Backoff time.Duration + Timeout time.Duration +} + +func (e *OrderShipped) BroadcastOn() []contracts.Channel { + return []contracts.Channel{ + broadcasting.PrivateChannel("orders." + itoa(e.OrderID)), + } +} + +func (e *OrderShipped) BroadcastAs() string { + return "order.shipped" +} + +func (e *OrderShipped) BroadcastWith() map[string]any { + return map[string]any{"order": e.OrderData} +} + +func (e *OrderShipped) BroadcastWhen() bool { + return e.ShouldFire +} + +func (e *OrderShipped) BroadcastQueue() string { + return e.QueueName +} + +func (e *OrderShipped) BroadcastConnections() []string { + return e.Conns +} + +func (e *OrderShipped) BroadcastQueueConnection() string { + return e.QueueConn +} + +func (e *OrderShipped) BroadcastDelay() time.Time { + return e.DelayedAt +} + +func (e *OrderShipped) BroadcastTries() int { + return e.Retries +} + +func (e *OrderShipped) BroadcastBackoff() time.Duration { + return e.Backoff +} + +func (e *OrderShipped) BroadcastTimeout() time.Duration { + return e.Timeout +} + +type OrderShippedNow struct { + OrderID uint + OrderData map[string]any +} + +func (e *OrderShippedNow) BroadcastOn() []contracts.Channel { + return []contracts.Channel{ + broadcasting.PublicChannel("orders"), + } +} + +func (e *OrderShippedNow) BroadcastAs() string { + return "order.shipped" +} + +func (e *OrderShippedNow) BroadcastWith() map[string]any { + return map[string]any{"order": e.OrderData} +} + +func (e *OrderShippedNow) BroadcastWhen() bool { + return true +} + +func (e *OrderShippedNow) BroadcastNow() bool { + return true +} + +type EmptyEvent struct{} + +func (e *EmptyEvent) BroadcastOn() []contracts.Channel { + return nil +} + +func (e *EmptyEvent) BroadcastAs() string { + return "empty.event" +} + +func (e *EmptyEvent) BroadcastWith() map[string]any { + return nil +} + +func (e *EmptyEvent) BroadcastWhen() bool { + return false +} + +func itoa(n uint) string { + if n == 0 { + return "0" + } + var digits []byte + for n > 0 { + digits = append([]byte{byte('0' + n%10)}, digits...) + n /= 10 + } + return string(digits) +} diff --git a/app/facades/broadcast.go b/app/facades/broadcast.go new file mode 100644 index 0000000..42bae26 --- /dev/null +++ b/app/facades/broadcast.go @@ -0,0 +1,9 @@ +package facades + +import ( + "github.com/goravel/framework/contracts/broadcasting" +) + +func Broadcast() broadcasting.Broadcast { + return App().MakeBroadcast() +} diff --git a/app/services/mock_examples.go b/app/services/mock_examples.go index b0faa91..3b9455b 100644 --- a/app/services/mock_examples.go +++ b/app/services/mock_examples.go @@ -11,6 +11,7 @@ import ( "github.com/goravel/framework/contracts/queue" "github.com/goravel/framework/filesystem" + appbroadcasting "goravel/app/broadcasting" "goravel/app/events" "goravel/app/facades" "goravel/app/jobs" @@ -136,3 +137,17 @@ func Validation() string { func View() bool { return facades.View().Exists("welcome.tmpl") } + +func BroadcastChannel() { + facades.Broadcast().Channel("orders.{orderId}", func(user any, channelName string, params map[string]string) bool { + return params["orderId"] == "1" + }) +} + +func BroadcastDispatch() error { + return facades.Broadcast().Dispatch(&appbroadcasting.OrderShipped{ + OrderID: 1, + OrderData: map[string]any{"id": 1}, + ShouldFire: true, + }) +} diff --git a/app/services/mock_examples_test.go b/app/services/mock_examples_test.go index d426fbd..679bbf0 100644 --- a/app/services/mock_examples_test.go +++ b/app/services/mock_examples_test.go @@ -6,6 +6,7 @@ import ( "github.com/goravel/framework/contracts/mail" "github.com/goravel/framework/filesystem" + mocksbroadcasting "github.com/goravel/framework/mocks/broadcasting" "github.com/goravel/framework/testing/mock" testifymock "github.com/stretchr/testify/mock" "github.com/stretchr/testify/suite" @@ -216,3 +217,21 @@ func (s *MockExamplesTestSuite) TestView() { s.True(View()) } + +func (s *MockExamplesTestSuite) TestBroadcastChannel() { + mockFactory := mock.Factory() + mockBroadcast := mocksbroadcasting.NewBroadcast(s.T()) + mockFactory.App().EXPECT().MakeBroadcast().Return(mockBroadcast).Once() + mockBroadcast.EXPECT().Channel("orders.{orderId}", testifymock.Anything).Once() + + BroadcastChannel() +} + +func (s *MockExamplesTestSuite) TestBroadcastDispatch() { + mockFactory := mock.Factory() + mockBroadcast := mocksbroadcasting.NewBroadcast(s.T()) + mockFactory.App().EXPECT().MakeBroadcast().Return(mockBroadcast).Once() + mockBroadcast.EXPECT().Dispatch(testifymock.Anything).Return(nil).Once() + + s.Nil(BroadcastDispatch()) +} diff --git a/bootstrap/app.go b/bootstrap/app.go index 0c7380b..daa4caa 100644 --- a/bootstrap/app.go +++ b/bootstrap/app.go @@ -120,6 +120,7 @@ func Boot() contractsfoundation.Application { facades.Schema().Extend(schema.Extension{ Models: []any{models.User{}}, }) + routes.Channels() }). WithConfig(config.Boot). Create() diff --git a/bootstrap/providers.go b/bootstrap/providers.go index e088657..36327d4 100644 --- a/bootstrap/providers.go +++ b/bootstrap/providers.go @@ -6,6 +6,7 @@ import ( "github.com/goravel/fiber" "github.com/goravel/framework/ai" "github.com/goravel/framework/auth" + "github.com/goravel/framework/broadcasting" "github.com/goravel/framework/cache" "github.com/goravel/framework/contracts/foundation" "github.com/goravel/framework/crypt" @@ -54,6 +55,7 @@ func Providers() []foundation.ServiceProvider { &auth.ServiceProvider{}, &crypt.ServiceProvider{}, &queue.ServiceProvider{}, + &broadcasting.ServiceProvider{}, &event.ServiceProvider{}, &grpc.ServiceProvider{}, &hash.ServiceProvider{}, diff --git a/config/broadcasting.go b/config/broadcasting.go new file mode 100644 index 0000000..68c6ccf --- /dev/null +++ b/config/broadcasting.go @@ -0,0 +1,37 @@ +package config + +import "goravel/app/facades" + +func init() { + config := facades.Config() + config.Add("broadcasting", map[string]any{ + "default": config.Env("BROADCAST_CONNECTION", "log"), + + "connections": map[string]any{ + "pusher": map[string]any{ + "driver": "pusher", + "key": config.Env("PUSHER_APP_KEY", "test-key"), + "secret": config.Env("PUSHER_APP_SECRET", "test-secret"), + "app_id": config.Env("PUSHER_APP_ID", "test-app"), + "options": map[string]any{ + "cluster": config.Env("PUSHER_APP_CLUSTER", "mt1"), + "host": config.Env("PUSHER_HOST", "127.0.0.1"), + "port": config.Env("PUSHER_PORT", 6001), + "scheme": config.Env("PUSHER_SCHEME", "http"), + }, + }, + "log": map[string]any{ + "driver": "log", + }, + "null": map[string]any{ + "driver": "null", + }, + }, + + "auth": map[string]any{ + "enabled": config.Env("BROADCAST_AUTH_ENABLED", true), + "path": config.Env("BROADCAST_AUTH_PATH", "/broadcasting/auth"), + "middleware": []string{}, + }, + }) +} diff --git a/go.mod b/go.mod index 1546733..627919d 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/goravel/cos v1.18.0 github.com/goravel/example-proto v0.0.1 github.com/goravel/fiber v1.18.0 - github.com/goravel/framework v1.18.0 + github.com/goravel/framework v1.18.1-0.20260724081045-8b12654652e5 github.com/goravel/gemini v1.18.0 github.com/goravel/gin v1.18.0 github.com/goravel/minio v1.18.0 @@ -32,7 +32,7 @@ require ( go.opentelemetry.io/otel v1.44.0 go.opentelemetry.io/otel/metric v1.44.0 go.opentelemetry.io/otel/trace v1.44.0 - google.golang.org/grpc v1.81.1 + google.golang.org/grpc v1.82.0 google.golang.org/protobuf v1.36.11 ) @@ -235,16 +235,16 @@ require ( go.uber.org/atomic v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.22.0 // indirect - golang.org/x/crypto v0.53.0 // indirect - golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect - golang.org/x/mod v0.37.0 // indirect - golang.org/x/net v0.56.0 // indirect - golang.org/x/sync v0.21.0 // indirect - golang.org/x/sys v0.46.0 // indirect - golang.org/x/term v0.44.0 // indirect - golang.org/x/text v0.38.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597 // indirect + golang.org/x/mod v0.38.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/term v0.45.0 // indirect + golang.org/x/text v0.40.0 // indirect golang.org/x/time v0.12.0 // indirect - golang.org/x/tools v0.47.0 // indirect + golang.org/x/tools v0.48.0 // indirect google.golang.org/genai v1.58.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect @@ -257,4 +257,4 @@ require ( gorm.io/plugin/dbresolver v1.6.2 // indirect ) -replace github.com/goravel/framework => github.com/goravel/framework v1.18.0 +replace github.com/goravel/framework => github.com/goravel/framework v1.18.1-0.20260724081045-8b12654652e5 diff --git a/go.sum b/go.sum index 4401163..7b383b3 100644 --- a/go.sum +++ b/go.sum @@ -324,8 +324,8 @@ github.com/goravel/example-proto v0.0.1 h1:ZxETeKREQWjuJ49bX/Hqj1NLR5Vyj489Ks6dR github.com/goravel/example-proto v0.0.1/go.mod h1:I8IPsHr4Ndf7KxmdsRpBR2LQ0Geo48+pjv9IIWf3mZg= github.com/goravel/fiber v1.18.0 h1:q9EACPCpEn+N5SX6yyI7hsdA1Zr2NFDJLdiAXQKKUw8= github.com/goravel/fiber v1.18.0/go.mod h1:swGaS9bq2wT6T6uthcHEJ2In6QyaTljsqrkRwk+wPfI= -github.com/goravel/framework v1.18.0 h1:TFiLAAYcKGkJG4K9qcSGhzTIAUFvzp/APCumxN55shg= -github.com/goravel/framework v1.18.0/go.mod h1:7nTfWdu987t+MmB1s+TtqbuJJLngmjCjsMbw3FQLNcA= +github.com/goravel/framework v1.18.1-0.20260724081045-8b12654652e5 h1:beQhmAueZJju7kHwx4FPTsgGDTBWMs9LcbAiTotqFY4= +github.com/goravel/framework v1.18.1-0.20260724081045-8b12654652e5/go.mod h1:xmT+tLgweQXZoFqnDWE7YYgxMxhs3x72e5m6PHfW9S4= github.com/goravel/gemini v1.18.0 h1:Tv6IJ3KvBvH5QD7kAM3XFQR0g8IUfkqMbRGi7dtlWUo= github.com/goravel/gemini v1.18.0/go.mod h1:b4FueB0O0Qkz71eVWLiSaKghp+Eb/1hNNICkk7vMgrQ= github.com/goravel/gin v1.18.0 h1:4eIy9eAzH+0mz8SN03iwwocIGwc/2dpZkgz6Ol68KIE= @@ -698,16 +698,16 @@ golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOM golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= -golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= -golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= -golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M= -golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY= +golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597 h1:qLvzZeaANDgyVOA8pyHCOStGlXn0rseXma+GQjeuv2g= +golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q= golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= @@ -722,8 +722,8 @@ golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -747,8 +747,8 @@ golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= -golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -759,8 +759,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -789,8 +789,8 @@ golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -808,8 +808,8 @@ golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= -golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= -golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -823,8 +823,8 @@ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= -golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -840,8 +840,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -867,8 +867,8 @@ google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyac google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= -google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= +google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= diff --git a/routes/channels.go b/routes/channels.go new file mode 100644 index 0000000..22e2aff --- /dev/null +++ b/routes/channels.go @@ -0,0 +1,21 @@ +package routes + +import ( + "goravel/app/facades" +) + +func Channels() { + facades.Broadcast().Channel("orders.{orderId}", func(user any, channelName string, params map[string]string) bool { + userID := user.(map[string]any)["id"] + return userID != nil && params["orderId"] != "" + }) + + facades.Broadcast().Channel("users.{userId}", func(user any, channelName string, params map[string]string) bool { + userID := user.(map[string]any)["id"] + return params["userId"] == userID + }) + + facades.Broadcast().Channel("public-updates", func(user any, channelName string, params map[string]string) bool { + return true + }) +} diff --git a/tests/feature/broadcast_test.go b/tests/feature/broadcast_test.go new file mode 100644 index 0000000..cbeb7fd --- /dev/null +++ b/tests/feature/broadcast_test.go @@ -0,0 +1,186 @@ +package feature + +import ( + stdhttp "net/http" + "testing" + + "github.com/goravel/framework/broadcasting" + contracts "github.com/goravel/framework/contracts/broadcasting" + supporthttp "github.com/goravel/framework/support/http" + "github.com/stretchr/testify/suite" + + appbroadcasting "goravel/app/broadcasting" + "goravel/app/facades" + "goravel/tests" +) + +type BroadcastTestSuite struct { + suite.Suite + tests.TestCase +} + +func TestBroadcastTestSuite(t *testing.T) { + suite.Run(t, &BroadcastTestSuite{}) +} + +func (s *BroadcastTestSuite) SetupTest() { + config := facades.Config() + config.Add("broadcasting.connections.log", map[string]any{ + "driver": "log", + }) + config.Add("broadcasting.connections.null", map[string]any{ + "driver": "null", + }) + config.Add("broadcasting.default", "null") +} + +func (s *BroadcastTestSuite) TestBroadcastDispatch_ShouldBroadcastNow_NullDriver() { + err := facades.Broadcast().Dispatch(&appbroadcasting.OrderShippedNow{ + OrderID: 1, + OrderData: map[string]any{"id": 1}, + }) + s.NoError(err) +} + +func (s *BroadcastTestSuite) TestBroadcastDispatch_ShouldBroadcastNow_LogDriver() { + facades.Config().Add("broadcasting.default", "log") + + err := facades.Broadcast().Dispatch(&appbroadcasting.OrderShippedNow{ + OrderID: 1, + OrderData: map[string]any{"id": 1, "name": "Test Order"}, + }) + s.NoError(err) +} + +func (s *BroadcastTestSuite) TestBroadcastDispatch_ShouldBroadcastWithConnections() { + facades.Config().Add("broadcasting.default", "log") + + err := facades.Broadcast().Dispatch(&appbroadcasting.OrderShippedNow{ + OrderID: 1, + OrderData: map[string]any{"id": 1}, + }) + s.NoError(err) +} + +func (s *BroadcastTestSuite) TestBroadcastDispatch_ShouldBroadcastWithQueue() { + err := facades.Broadcast().Dispatch(&appbroadcasting.OrderShippedNow{ + OrderID: 1, + OrderData: map[string]any{"id": 1}, + }) + s.NoError(err) +} + +func (s *BroadcastTestSuite) TestBroadcastDispatch_BroadcastWhenFalse() { + err := facades.Broadcast().Dispatch(&appbroadcasting.OrderShipped{ + OrderID: 1, + ShouldFire: false, + }) + s.NoError(err) +} + +func (s *BroadcastTestSuite) TestBroadcastDispatch_NoChannels() { + err := facades.Broadcast().Dispatch(&appbroadcasting.EmptyEvent{}) + s.NoError(err) +} + +func (s *BroadcastTestSuite) TestChannelAuth_PublicChannel() { + body, err := supporthttp.NewBody(). + SetField("socket_id", "1234.5678"). + SetField("channel_name", "public-channel"). + Build() + s.NoError(err) + + resp, err := s.Http(s.T()).Post("/broadcasting/auth", body.Reader()) + s.NoError(err) + resp.AssertOk() +} + +func (s *BroadcastTestSuite) TestChannelAuth_MissingParams() { + body, err := supporthttp.NewBody(). + SetField("socket_id", "1234.5678"). + Build() + s.NoError(err) + + resp, err := s.Http(s.T()).Post("/broadcasting/auth", body.Reader()) + s.NoError(err) + resp.AssertBadRequest() +} + +func (s *BroadcastTestSuite) TestChannelAuth_MissingSocketID() { + body, err := supporthttp.NewBody(). + SetField("channel_name", "private-orders.1"). + Build() + s.NoError(err) + + resp, err := s.Http(s.T()).Post("/broadcasting/auth", body.Reader()) + s.NoError(err) + resp.AssertBadRequest() +} + +func (s *BroadcastTestSuite) TestChannelHelpers() { + public := broadcasting.PublicChannel("my-channel") + s.Equal("my-channel", public.Name) + s.False(broadcasting.IsPrivateChannel(public)) + s.False(broadcasting.IsPresenceChannel(public)) + s.Equal("my-channel", broadcasting.ChannelBaseName(public)) + + private := broadcasting.PrivateChannel("orders.123") + s.Equal("private-orders.123", private.Name) + s.True(broadcasting.IsPrivateChannel(private)) + s.False(broadcasting.IsPresenceChannel(private)) + s.Equal("orders.123", broadcasting.ChannelBaseName(private)) + + presence := broadcasting.PresenceChannel("chat") + s.Equal("presence-chat", presence.Name) + s.False(broadcasting.IsPrivateChannel(presence)) + s.True(broadcasting.IsPresenceChannel(presence)) + s.Equal("chat", broadcasting.ChannelBaseName(presence)) +} + +func (s *BroadcastTestSuite) TestConstants() { + s.Equal("private-", contracts.ChannelPrefixPrivate) + s.Equal("presence-", contracts.ChannelPrefixPresence) +} + +func (s *BroadcastTestSuite) TestAuthResponseType() { + resp := contracts.AuthResponse{ + Auth: "test-key:signature", + ChannelData: `{"user_id":"1"}`, + } + s.Equal("test-key:signature", resp.Auth) + s.Equal(`{"user_id":"1"}`, resp.ChannelData) +} + +func (s *BroadcastTestSuite) TestPusherDriverDispatch_RoundTrip() { + if !s.isRelayCloudRunning() { + s.T().Skip("RelayCloud not running") + } + + facades.Config().Add("broadcasting.default", "pusher") + facades.Config().Add("broadcasting.connections.pusher", map[string]any{ + "driver": "pusher", + "key": "test-key", + "secret": "test-secret", + "app_id": "test-app", + "options": map[string]any{ + "host": "127.0.0.1", + "port": 6001, + "scheme": "http", + }, + }) + + err := facades.Broadcast().Dispatch(&appbroadcasting.OrderShippedNow{ + OrderID: 1, + OrderData: map[string]any{"test": "relaycloud"}, + }) + s.NoError(err) +} + +func (s *BroadcastTestSuite) isRelayCloudRunning() bool { + resp, err := stdhttp.Get("http://127.0.0.1:6001") + if err != nil { + return false + } + defer resp.Body.Close() + return true +} From 674c8897023edcb9bb1c4830fc9dfcdd1c4ad219 Mon Sep 17 00:00:00 2001 From: opencode Date: Fri, 24 Jul 2026 16:57:24 +0800 Subject: [PATCH 2/9] feat: add RelayCloud Docker service for broadcasting integration tests - Add relay service to docker-compose.yml using darknautica/relay:latest - Add docker/relay/apps.json with test credentials - Add SetupSuite/TearDownSuite to manage relay container lifecycle - Relay tests now run automatically as part of the test suite --- docker-compose.yml | 8 ++++++++ docker/relay/apps.json | 10 ++++++++++ tests/feature/broadcast_test.go | 10 ++++++++++ 3 files changed, 28 insertions(+) create mode 100644 docker/relay/apps.json diff --git a/docker-compose.yml b/docker-compose.yml index 932bf3d..82cfcd4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,14 @@ version: '3.8' services: + relay: + image: darknautica/relay:latest + restart: always + volumes: + - ./docker/relay/apps.json:/relay/apps.json + ports: + - "6001:6001" + otel-collector: image: otel/opentelemetry-collector-contrib:latest restart: always diff --git a/docker/relay/apps.json b/docker/relay/apps.json new file mode 100644 index 0000000..5e701a4 --- /dev/null +++ b/docker/relay/apps.json @@ -0,0 +1,10 @@ +[ + { + "id": "test-app", + "key": "test-key", + "secret": "test-secret", + "max_connections": 1000, + "history": true, + "history_limit": 100 + } +] diff --git a/tests/feature/broadcast_test.go b/tests/feature/broadcast_test.go index cbeb7fd..1af3050 100644 --- a/tests/feature/broadcast_test.go +++ b/tests/feature/broadcast_test.go @@ -2,6 +2,7 @@ package feature import ( stdhttp "net/http" + "os/exec" "testing" "github.com/goravel/framework/broadcasting" @@ -23,6 +24,15 @@ func TestBroadcastTestSuite(t *testing.T) { suite.Run(t, &BroadcastTestSuite{}) } +func (s *BroadcastTestSuite) SetupSuite() { + exec.Command("docker", "compose", "up", "relay", "-d", "--wait").Run() +} + +func (s *BroadcastTestSuite) TearDownSuite() { + exec.Command("docker", "compose", "stop", "relay").Run() + exec.Command("docker", "compose", "rm", "-f", "relay").Run() +} + func (s *BroadcastTestSuite) SetupTest() { config := facades.Config() config.Add("broadcasting.connections.log", map[string]any{ From 9c236593392510c270888628f57910b093ce5dd4 Mon Sep 17 00:00:00 2001 From: opencode Date: Fri, 24 Jul 2026 17:06:40 +0800 Subject: [PATCH 3/9] fix: gofmt formatting --- bootstrap/providers.go | 2 +- config/broadcasting.go | 8 ++++---- tests/feature/broadcast_test.go | 8 ++++---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/bootstrap/providers.go b/bootstrap/providers.go index 36327d4..8f9c8bf 100644 --- a/bootstrap/providers.go +++ b/bootstrap/providers.go @@ -28,7 +28,6 @@ import ( "github.com/goravel/framework/translation" "github.com/goravel/framework/validation" "github.com/goravel/framework/view" - "goravel/packages/viewtest" "github.com/goravel/gemini" "github.com/goravel/gin" "github.com/goravel/minio" @@ -40,6 +39,7 @@ import ( "github.com/goravel/s3" "github.com/goravel/sqlite" "github.com/goravel/sqlserver" + "goravel/packages/viewtest" ) func Providers() []foundation.ServiceProvider { diff --git a/config/broadcasting.go b/config/broadcasting.go index 68c6ccf..cdcf0c5 100644 --- a/config/broadcasting.go +++ b/config/broadcasting.go @@ -9,10 +9,10 @@ func init() { "connections": map[string]any{ "pusher": map[string]any{ - "driver": "pusher", - "key": config.Env("PUSHER_APP_KEY", "test-key"), - "secret": config.Env("PUSHER_APP_SECRET", "test-secret"), - "app_id": config.Env("PUSHER_APP_ID", "test-app"), + "driver": "pusher", + "key": config.Env("PUSHER_APP_KEY", "test-key"), + "secret": config.Env("PUSHER_APP_SECRET", "test-secret"), + "app_id": config.Env("PUSHER_APP_ID", "test-app"), "options": map[string]any{ "cluster": config.Env("PUSHER_APP_CLUSTER", "mt1"), "host": config.Env("PUSHER_HOST", "127.0.0.1"), diff --git a/tests/feature/broadcast_test.go b/tests/feature/broadcast_test.go index 1af3050..7baddc7 100644 --- a/tests/feature/broadcast_test.go +++ b/tests/feature/broadcast_test.go @@ -168,10 +168,10 @@ func (s *BroadcastTestSuite) TestPusherDriverDispatch_RoundTrip() { facades.Config().Add("broadcasting.default", "pusher") facades.Config().Add("broadcasting.connections.pusher", map[string]any{ - "driver": "pusher", - "key": "test-key", - "secret": "test-secret", - "app_id": "test-app", + "driver": "pusher", + "key": "test-key", + "secret": "test-secret", + "app_id": "test-app", "options": map[string]any{ "host": "127.0.0.1", "port": 6001, From b0aa6193d63a3e2b873bb063a4426d3579fb19d9 Mon Sep 17 00:00:00 2001 From: opencode Date: Fri, 24 Jul 2026 17:10:54 +0800 Subject: [PATCH 4/9] =?UTF-8?q?fix:=20errcheck=20=E2=80=94=20unchecked=20r?= =?UTF-8?q?eturn=20values=20in=20broadcast=5Ftest.go?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/feature/broadcast_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/feature/broadcast_test.go b/tests/feature/broadcast_test.go index 7baddc7..e55f8f0 100644 --- a/tests/feature/broadcast_test.go +++ b/tests/feature/broadcast_test.go @@ -25,12 +25,12 @@ func TestBroadcastTestSuite(t *testing.T) { } func (s *BroadcastTestSuite) SetupSuite() { - exec.Command("docker", "compose", "up", "relay", "-d", "--wait").Run() + _ = exec.Command("docker", "compose", "up", "relay", "-d").Run() } func (s *BroadcastTestSuite) TearDownSuite() { - exec.Command("docker", "compose", "stop", "relay").Run() - exec.Command("docker", "compose", "rm", "-f", "relay").Run() + _ = exec.Command("docker", "compose", "stop", "relay").Run() + _ = exec.Command("docker", "compose", "rm", "-f", "relay").Run() } func (s *BroadcastTestSuite) SetupTest() { @@ -191,6 +191,6 @@ func (s *BroadcastTestSuite) isRelayCloudRunning() bool { if err != nil { return false } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() return true } From 7e58b7228e6f98f48a12c2341ce5ba58084d809e Mon Sep 17 00:00:00 2001 From: opencode Date: Fri, 24 Jul 2026 17:12:53 +0800 Subject: [PATCH 5/9] fix: remove redundant connection config overrides in tests Connections are already defined in config/broadcasting.go. Only switch the default driver per test, matching the redis_test.go pattern. --- tests/feature/broadcast_test.go | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/tests/feature/broadcast_test.go b/tests/feature/broadcast_test.go index e55f8f0..4c80f47 100644 --- a/tests/feature/broadcast_test.go +++ b/tests/feature/broadcast_test.go @@ -34,14 +34,7 @@ func (s *BroadcastTestSuite) TearDownSuite() { } func (s *BroadcastTestSuite) SetupTest() { - config := facades.Config() - config.Add("broadcasting.connections.log", map[string]any{ - "driver": "log", - }) - config.Add("broadcasting.connections.null", map[string]any{ - "driver": "null", - }) - config.Add("broadcasting.default", "null") + facades.Config().Add("broadcasting.default", "null") } func (s *BroadcastTestSuite) TestBroadcastDispatch_ShouldBroadcastNow_NullDriver() { @@ -167,17 +160,6 @@ func (s *BroadcastTestSuite) TestPusherDriverDispatch_RoundTrip() { } facades.Config().Add("broadcasting.default", "pusher") - facades.Config().Add("broadcasting.connections.pusher", map[string]any{ - "driver": "pusher", - "key": "test-key", - "secret": "test-secret", - "app_id": "test-app", - "options": map[string]any{ - "host": "127.0.0.1", - "port": 6001, - "scheme": "http", - }, - }) err := facades.Broadcast().Dispatch(&appbroadcasting.OrderShippedNow{ OrderID: 1, From efa989125f9fee681938044482ea130284e15994 Mon Sep 17 00:00:00 2001 From: opencode Date: Fri, 24 Jul 2026 17:24:56 +0800 Subject: [PATCH 6/9] refactor: restructure broadcast tests with clear driver separation Split dispatch tests into LogDriver, NullDriver, and PusherDriver. Each test explicitly switches the default connection and verifies driver-specific behavior rather than relying on a single default. --- tests/feature/broadcast_test.go | 60 +++++++++++++++++---------------- 1 file changed, 31 insertions(+), 29 deletions(-) diff --git a/tests/feature/broadcast_test.go b/tests/feature/broadcast_test.go index 4c80f47..8171d88 100644 --- a/tests/feature/broadcast_test.go +++ b/tests/feature/broadcast_test.go @@ -37,15 +37,7 @@ func (s *BroadcastTestSuite) SetupTest() { facades.Config().Add("broadcasting.default", "null") } -func (s *BroadcastTestSuite) TestBroadcastDispatch_ShouldBroadcastNow_NullDriver() { - err := facades.Broadcast().Dispatch(&appbroadcasting.OrderShippedNow{ - OrderID: 1, - OrderData: map[string]any{"id": 1}, - }) - s.NoError(err) -} - -func (s *BroadcastTestSuite) TestBroadcastDispatch_ShouldBroadcastNow_LogDriver() { +func (s *BroadcastTestSuite) TestLogDriver() { facades.Config().Add("broadcasting.default", "log") err := facades.Broadcast().Dispatch(&appbroadcasting.OrderShippedNow{ @@ -55,8 +47,8 @@ func (s *BroadcastTestSuite) TestBroadcastDispatch_ShouldBroadcastNow_LogDriver( s.NoError(err) } -func (s *BroadcastTestSuite) TestBroadcastDispatch_ShouldBroadcastWithConnections() { - facades.Config().Add("broadcasting.default", "log") +func (s *BroadcastTestSuite) TestNullDriver() { + facades.Config().Add("broadcasting.default", "null") err := facades.Broadcast().Dispatch(&appbroadcasting.OrderShippedNow{ OrderID: 1, @@ -65,15 +57,21 @@ func (s *BroadcastTestSuite) TestBroadcastDispatch_ShouldBroadcastWithConnection s.NoError(err) } -func (s *BroadcastTestSuite) TestBroadcastDispatch_ShouldBroadcastWithQueue() { +func (s *BroadcastTestSuite) TestPusherDriver() { + if !s.isRelayCloudRunning() { + s.T().Skip("RelayCloud not running") + } + + facades.Config().Add("broadcasting.default", "pusher") + err := facades.Broadcast().Dispatch(&appbroadcasting.OrderShippedNow{ OrderID: 1, - OrderData: map[string]any{"id": 1}, + OrderData: map[string]any{"test": "relaycloud"}, }) s.NoError(err) } -func (s *BroadcastTestSuite) TestBroadcastDispatch_BroadcastWhenFalse() { +func (s *BroadcastTestSuite) TestDispatch_BroadcastWhenFalse_SkipsDispatch() { err := facades.Broadcast().Dispatch(&appbroadcasting.OrderShipped{ OrderID: 1, ShouldFire: false, @@ -81,11 +79,29 @@ func (s *BroadcastTestSuite) TestBroadcastDispatch_BroadcastWhenFalse() { s.NoError(err) } -func (s *BroadcastTestSuite) TestBroadcastDispatch_NoChannels() { +func (s *BroadcastTestSuite) TestDispatch_NoChannels_SkipsDispatch() { err := facades.Broadcast().Dispatch(&appbroadcasting.EmptyEvent{}) s.NoError(err) } +func (s *BroadcastTestSuite) TestDispatch_ShouldBroadcastWithQueue() { + err := facades.Broadcast().Dispatch(&appbroadcasting.OrderShippedNow{ + OrderID: 1, + OrderData: map[string]any{"id": 1}, + }) + s.NoError(err) +} + +func (s *BroadcastTestSuite) TestDispatch_ShouldBroadcastWithConnections() { + facades.Config().Add("broadcasting.default", "log") + + err := facades.Broadcast().Dispatch(&appbroadcasting.OrderShippedNow{ + OrderID: 1, + OrderData: map[string]any{"id": 1}, + }) + s.NoError(err) +} + func (s *BroadcastTestSuite) TestChannelAuth_PublicChannel() { body, err := supporthttp.NewBody(). SetField("socket_id", "1234.5678"). @@ -154,20 +170,6 @@ func (s *BroadcastTestSuite) TestAuthResponseType() { s.Equal(`{"user_id":"1"}`, resp.ChannelData) } -func (s *BroadcastTestSuite) TestPusherDriverDispatch_RoundTrip() { - if !s.isRelayCloudRunning() { - s.T().Skip("RelayCloud not running") - } - - facades.Config().Add("broadcasting.default", "pusher") - - err := facades.Broadcast().Dispatch(&appbroadcasting.OrderShippedNow{ - OrderID: 1, - OrderData: map[string]any{"test": "relaycloud"}, - }) - s.NoError(err) -} - func (s *BroadcastTestSuite) isRelayCloudRunning() bool { resp, err := stdhttp.Get("http://127.0.0.1:6001") if err != nil { From 25100ce34b8c1dc7e100cc2a35c4c6caddd9d032 Mon Sep 17 00:00:00 2001 From: opencode Date: Fri, 24 Jul 2026 17:26:43 +0800 Subject: [PATCH 7/9] feat: add broadcast.html frontend test page Interactive browser page that connects to RelayCloud via WebSocket and subscribes to channels to verify real-time broadcasting. Served at /broadcast.html. --- resources/views/broadcast.html | 238 +++++++++++++++++++++++++++++++++ routes/web.go | 1 + 2 files changed, 239 insertions(+) create mode 100644 resources/views/broadcast.html diff --git a/resources/views/broadcast.html b/resources/views/broadcast.html new file mode 100644 index 0000000..b0587fe --- /dev/null +++ b/resources/views/broadcast.html @@ -0,0 +1,238 @@ + + + + + + Broadcasting Test + + + +

Broadcasting Test

+

Connect to RelayCloud and test real-time events

+ +
+
+ Disconnected +
+ +
+
+ + +
+
+ + +
+ + +
+ +
+
+ + +
+ + + +
+ +
+
+ + +
+
+ +
+ + 0 events +
+ +
+
Waiting for events...
+
+ + + + diff --git a/routes/web.go b/routes/web.go index 00cdedc..bd734c3 100644 --- a/routes/web.go +++ b/routes/web.go @@ -27,6 +27,7 @@ func Web() { // 2. Add route to `/route/web.go`, needs to contain your home page and static routes // 3. Configure nginx based on the /nginx.conf file facades.Route().StaticFile("index.html", "./resources/views/index.html") + facades.Route().StaticFile("broadcast.html", "./resources/views/broadcast.html") facades.Route().Static("css", "./resources/views/css") // View Nesting From 27c7c429e2dbe2956985bce538f8cc092e6e7616 Mon Sep 17 00:00:00 2001 From: opencode Date: Fri, 24 Jul 2026 17:37:58 +0800 Subject: [PATCH 8/9] fix: remove pusher-http-go and pusher driver tests RelayCloud uses Bearer token auth for REST API, not Pusher HMAC. pusher-http-go and the Goravel pusher driver are incompatible. Keep log and null driver tests which work correctly. --- tests/feature/broadcast_test.go | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/tests/feature/broadcast_test.go b/tests/feature/broadcast_test.go index 8171d88..316bb67 100644 --- a/tests/feature/broadcast_test.go +++ b/tests/feature/broadcast_test.go @@ -1,7 +1,6 @@ package feature import ( - stdhttp "net/http" "os/exec" "testing" @@ -48,8 +47,6 @@ func (s *BroadcastTestSuite) TestLogDriver() { } func (s *BroadcastTestSuite) TestNullDriver() { - facades.Config().Add("broadcasting.default", "null") - err := facades.Broadcast().Dispatch(&appbroadcasting.OrderShippedNow{ OrderID: 1, OrderData: map[string]any{"id": 1}, @@ -57,20 +54,6 @@ func (s *BroadcastTestSuite) TestNullDriver() { s.NoError(err) } -func (s *BroadcastTestSuite) TestPusherDriver() { - if !s.isRelayCloudRunning() { - s.T().Skip("RelayCloud not running") - } - - facades.Config().Add("broadcasting.default", "pusher") - - err := facades.Broadcast().Dispatch(&appbroadcasting.OrderShippedNow{ - OrderID: 1, - OrderData: map[string]any{"test": "relaycloud"}, - }) - s.NoError(err) -} - func (s *BroadcastTestSuite) TestDispatch_BroadcastWhenFalse_SkipsDispatch() { err := facades.Broadcast().Dispatch(&appbroadcasting.OrderShipped{ OrderID: 1, @@ -169,12 +152,3 @@ func (s *BroadcastTestSuite) TestAuthResponseType() { s.Equal("test-key:signature", resp.Auth) s.Equal(`{"user_id":"1"}`, resp.ChannelData) } - -func (s *BroadcastTestSuite) isRelayCloudRunning() bool { - resp, err := stdhttp.Get("http://127.0.0.1:6001") - if err != nil { - return false - } - defer func() { _ = resp.Body.Close() }() - return true -} From f4f48d605bcf6d86b296937783f22ab60cd4fde4 Mon Sep 17 00:00:00 2001 From: opencode Date: Sat, 25 Jul 2026 18:44:51 +0800 Subject: [PATCH 9/9] feat: switch to Soketi and add WebSocket e2e broadcast test Replace RelayCloud with Soketi (quay.io/soketi/soketi:latest) which implements full Pusher protocol including REST API with HMAC auth. Add Go WebSocket client (tests/broadcast_ws_client.go) to subscribe to channels and receive broadcast events from Soketi. Add TestPusherDriverEndToEnd which: 1. Connects to Soketi via WebSocket and subscribes to a channel 2. Switches the broadcast driver to pusher via App().Restart() 3. Dispatches an OrderShippedNow event 4. Verifies the event was received on the WebSocket Remove relay artifacts (docker/relay/). --- docker-compose.yml | 16 +++++-- docker/relay/apps.json | 10 ----- tests/broadcast_ws_client.go | 79 +++++++++++++++++++++++++++++++++ tests/feature/broadcast_test.go | 48 ++++++++++++++++++-- 4 files changed, 136 insertions(+), 17 deletions(-) delete mode 100644 docker/relay/apps.json create mode 100644 tests/broadcast_ws_client.go diff --git a/docker-compose.yml b/docker-compose.yml index 82cfcd4..cbbe88f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,13 +1,21 @@ version: '3.8' services: - relay: - image: darknautica/relay:latest + soketi: + image: quay.io/soketi/soketi:latest restart: always - volumes: - - ./docker/relay/apps.json:/relay/apps.json + environment: + SOKETI_DEBUG: "1" + SOKETI_DEFAULT_APP_ID: "test-app" + SOKETI_DEFAULT_APP_KEY: "test-key" + SOKETI_DEFAULT_APP_SECRET: "test-secret" + SOKETI_DEFAULT_APP_ENABLE_CLIENT_MESSAGES: "1" + SOKETI_DEFAULT_APP_ENABLE_CLIENT_CHANNELS: "1" + SOKETI_DEFAULT_APP_ENABLE_CLIENT_STATS: "1" + SOKETI_METRICS_ENABLED: "0" ports: - "6001:6001" + - "9601:9601" otel-collector: image: otel/opentelemetry-collector-contrib:latest diff --git a/docker/relay/apps.json b/docker/relay/apps.json deleted file mode 100644 index 5e701a4..0000000 --- a/docker/relay/apps.json +++ /dev/null @@ -1,10 +0,0 @@ -[ - { - "id": "test-app", - "key": "test-key", - "secret": "test-secret", - "max_connections": 1000, - "history": true, - "history_limit": 100 - } -] diff --git a/tests/broadcast_ws_client.go b/tests/broadcast_ws_client.go new file mode 100644 index 0000000..4509140 --- /dev/null +++ b/tests/broadcast_ws_client.go @@ -0,0 +1,79 @@ +package tests + +import ( + "encoding/json" + "fmt" + "sync" + + "github.com/gorilla/websocket" +) + +type WSClient struct { + conn *websocket.Conn + events []WSEvent + mu sync.Mutex +} + +type WSEvent struct { + Event string `json:"event"` + Channel string `json:"channel"` + Data string `json:"data"` +} + +func NewWSClient(host, appKey string) (*WSClient, error) { + url := fmt.Sprintf("ws://%s/app/%s?protocol=7&client=js&version=7.0.0", host, appKey) + conn, _, err := websocket.DefaultDialer.Dial(url, nil) + if err != nil { + return nil, err + } + + c := &WSClient{conn: conn} + go c.readLoop() + return c, nil +} + +func (c *WSClient) Subscribe(channel string) error { + msg, _ := json.Marshal(map[string]any{ + "event": "pusher:subscribe", + "data": map[string]string{ + "channel": channel, + }, + }) + return c.conn.WriteMessage(websocket.TextMessage, msg) +} + +func (c *WSClient) Events() []WSEvent { + c.mu.Lock() + defer c.mu.Unlock() + result := make([]WSEvent, len(c.events)) + copy(result, c.events) + return result +} + +func (c *WSClient) EventCount() int { + c.mu.Lock() + defer c.mu.Unlock() + return len(c.events) +} + +func (c *WSClient) Close() error { + return c.conn.Close() +} + +func (c *WSClient) readLoop() { + for { + _, msg, err := c.conn.ReadMessage() + if err != nil { + return + } + + var event WSEvent + if err := json.Unmarshal(msg, &event); err != nil { + continue + } + + c.mu.Lock() + c.events = append(c.events, event) + c.mu.Unlock() + } +} diff --git a/tests/feature/broadcast_test.go b/tests/feature/broadcast_test.go index 316bb67..c69e0d7 100644 --- a/tests/feature/broadcast_test.go +++ b/tests/feature/broadcast_test.go @@ -3,6 +3,7 @@ package feature import ( "os/exec" "testing" + "time" "github.com/goravel/framework/broadcasting" contracts "github.com/goravel/framework/contracts/broadcasting" @@ -24,12 +25,13 @@ func TestBroadcastTestSuite(t *testing.T) { } func (s *BroadcastTestSuite) SetupSuite() { - _ = exec.Command("docker", "compose", "up", "relay", "-d").Run() + _ = exec.Command("docker", "compose", "up", "soketi", "-d").Run() + time.Sleep(2 * time.Second) } func (s *BroadcastTestSuite) TearDownSuite() { - _ = exec.Command("docker", "compose", "stop", "relay").Run() - _ = exec.Command("docker", "compose", "rm", "-f", "relay").Run() + _ = exec.Command("docker", "compose", "stop", "soketi").Run() + _ = exec.Command("docker", "compose", "rm", "-f", "soketi").Run() } func (s *BroadcastTestSuite) SetupTest() { @@ -152,3 +154,43 @@ func (s *BroadcastTestSuite) TestAuthResponseType() { s.Equal("test-key:signature", resp.Auth) s.Equal(`{"user_id":"1"}`, resp.ChannelData) } + +func TestPusherDriverEndToEnd(t *testing.T) { + ws, err := tests.NewWSClient("127.0.0.1:6001", "test-key") + if err != nil { + t.Skip("Soketi not reachable: " + err.Error()) + } + defer func() { _ = ws.Close() }() + + if err := ws.Subscribe("test-channel"); err != nil { + t.Fatal(err) + } + time.Sleep(300 * time.Millisecond) + + facades.Config().Add("broadcasting.default", "pusher") + if err := facades.App().Restart(); err != nil { + t.Fatal(err) + } + + err = facades.Broadcast().Dispatch(&appbroadcasting.OrderShippedNow{ + OrderID: 1, + OrderData: map[string]any{"id": 1, "msg": "hello from soketi"}, + }) + if err != nil { + t.Fatal(err) + } + + time.Sleep(1 * time.Second) + + found := false + for _, e := range ws.Events() { + if e.Event == "order.shipped" && e.Channel == "test-channel" { + found = true + break + } + } + + if !found { + t.Errorf("expected order.shipped event on test-channel, got %d events", ws.EventCount()) + } +}