diff --git a/plugins/examples/discord-rich-presence/go.mod b/plugins/examples/discord-rich-presence/go.mod index a3ac0d326..59f36ad06 100644 --- a/plugins/examples/discord-rich-presence/go.mod +++ b/plugins/examples/discord-rich-presence/go.mod @@ -2,17 +2,30 @@ module discord-rich-presence go 1.25 -require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0 +require ( + github.com/navidrome/navidrome/plugins/pdk/go v0.0.0 + github.com/onsi/ginkgo/v2 v2.27.3 + github.com/onsi/gomega v1.38.3 + github.com/stretchr/testify v1.11.1 +) require ( + github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/extism/go-pdk v1.1.3 // indirect - github.com/kr/pretty v0.3.1 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rogpeppe/go-internal v1.13.1 // indirect github.com/stretchr/objx v0.5.2 // indirect - github.com/stretchr/testify v1.11.1 // indirect - gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/mod v0.27.0 // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect + golang.org/x/tools v0.36.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/plugins/examples/discord-rich-presence/go.sum b/plugins/examples/discord-rich-presence/go.sum index 9baba6d50..3e12b44fb 100644 --- a/plugins/examples/discord-rich-presence/go.sum +++ b/plugins/examples/discord-rich-presence/go.sum @@ -1,22 +1,71 @@ -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ= github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= +github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= +github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= +github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= +github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= +github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= +github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= +github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= +github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= +github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= +github.com/onsi/ginkgo/v2 v2.27.3 h1:ICsZJ8JoYafeXFFlFAG75a7CxMsJHwgKwtO+82SE9L8= +github.com/onsi/ginkgo/v2 v2.27.3/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM= +github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= +google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/plugins/examples/discord-rich-presence/main_test.go b/plugins/examples/discord-rich-presence/main_test.go new file mode 100644 index 000000000..01f79b02e --- /dev/null +++ b/plugins/examples/discord-rich-presence/main_test.go @@ -0,0 +1,251 @@ +package main + +import ( + "errors" + "strings" + "testing" + + "github.com/navidrome/navidrome/plugins/pdk/go/host" + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" + "github.com/navidrome/navidrome/plugins/pdk/go/scheduler" + "github.com/navidrome/navidrome/plugins/pdk/go/scrobbler" + "github.com/stretchr/testify/mock" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestDiscordPlugin(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Discord Plugin Main Suite") +} + +var _ = Describe("discordPlugin", func() { + var plugin discordPlugin + + BeforeEach(func() { + plugin = discordPlugin{} + pdk.ResetMock() + host.CacheMock.ExpectedCalls = nil + host.CacheMock.Calls = nil + host.WebSocketMock.ExpectedCalls = nil + host.WebSocketMock.Calls = nil + host.SchedulerMock.ExpectedCalls = nil + host.SchedulerMock.Calls = nil + host.ArtworkMock.ExpectedCalls = nil + host.ArtworkMock.Calls = nil + }) + + Describe("getConfig", func() { + It("returns config values when properly set", func() { + pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true) + pdk.PDKMock.On("GetConfig", usersKey).Return("user1:token1,user2:token2", true) + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + + clientID, users, err := getConfig() + Expect(err).ToNot(HaveOccurred()) + Expect(clientID).To(Equal("test-client-id")) + Expect(users).To(HaveLen(2)) + Expect(users["user1"]).To(Equal("token1")) + Expect(users["user2"]).To(Equal("token2")) + }) + + It("returns empty client ID when not set", func() { + pdk.PDKMock.On("GetConfig", clientIDKey).Return("", false) + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + + clientID, users, err := getConfig() + Expect(err).ToNot(HaveOccurred()) + Expect(clientID).To(BeEmpty()) + Expect(users).To(BeNil()) + }) + + It("returns nil users when users not configured", func() { + pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true) + pdk.PDKMock.On("GetConfig", usersKey).Return("", false) + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + + clientID, users, err := getConfig() + Expect(err).ToNot(HaveOccurred()) + Expect(clientID).To(Equal("test-client-id")) + Expect(users).To(BeNil()) + }) + + It("returns error for invalid user format", func() { + pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true) + pdk.PDKMock.On("GetConfig", usersKey).Return("invalid-format", true) + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + + _, _, err := getConfig() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid user config")) + }) + }) + + Describe("IsAuthorized", func() { + BeforeEach(func() { + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + }) + + It("returns true for authorized user", func() { + pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true) + pdk.PDKMock.On("GetConfig", usersKey).Return("testuser:token123", true) + + authorized, err := plugin.IsAuthorized(scrobbler.IsAuthorizedRequest{ + Username: "testuser", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(authorized).To(BeTrue()) + }) + + It("returns false for unauthorized user", func() { + pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true) + pdk.PDKMock.On("GetConfig", usersKey).Return("otheruser:token123", true) + + authorized, err := plugin.IsAuthorized(scrobbler.IsAuthorizedRequest{ + Username: "testuser", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(authorized).To(BeFalse()) + }) + + It("returns error when config parsing fails", func() { + pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true) + pdk.PDKMock.On("GetConfig", usersKey).Return("invalid-format", true) + + _, err := plugin.IsAuthorized(scrobbler.IsAuthorizedRequest{ + Username: "testuser", + }) + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("NowPlaying", func() { + BeforeEach(func() { + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + }) + + It("returns error when config parsing fails", func() { + pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true) + pdk.PDKMock.On("GetConfig", usersKey).Return("invalid-format", true) + + err := plugin.NowPlaying(scrobbler.NowPlayingRequest{ + Username: "testuser", + Track: scrobbler.TrackInfo{Title: "Test Song"}, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to get config")) + }) + + It("returns not authorized error when user not in config", func() { + pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true) + pdk.PDKMock.On("GetConfig", usersKey).Return("otheruser:token", true) + + err := plugin.NowPlaying(scrobbler.NowPlayingRequest{ + Username: "testuser", + Track: scrobbler.TrackInfo{Title: "Test Song"}, + }) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, scrobbler.ScrobblerErrorNotAuthorized)).To(BeTrue()) + }) + + It("successfully sends now playing update", func() { + pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true) + pdk.PDKMock.On("GetConfig", usersKey).Return("testuser:test-token", true) + + // Connect mocks (isConnected check via heartbeat) + host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("not found")) + + // Mock HTTP GET request for gateway discovery + gatewayResp := []byte(`{"url":"wss://gateway.discord.gg"}`) + gatewayReq := &pdk.HTTPRequest{} + pdk.PDKMock.On("NewHTTPRequest", pdk.MethodGet, "https://discord.com/api/gateway").Return(gatewayReq).Once() + pdk.PDKMock.On("Send", gatewayReq).Return(pdk.NewStubHTTPResponse(200, nil, gatewayResp)).Once() + + // Mock WebSocket connection + host.WebSocketMock.On("Connect", mock.MatchedBy(func(url string) bool { + return strings.Contains(url, "gateway.discord.gg") + }), mock.Anything, "testuser").Return("testuser", nil) + host.WebSocketMock.On("SendText", "testuser", mock.Anything).Return(nil) + host.SchedulerMock.On("ScheduleRecurring", mock.Anything, payloadHeartbeat, "testuser").Return("testuser", nil) + + // Cancel existing clear schedule (may or may not exist) + host.SchedulerMock.On("CancelSchedule", "testuser-clear").Return(nil) + + // Image mocks - cache miss, will make HTTP request to Discord + host.CacheMock.On("GetString", mock.MatchedBy(func(key string) bool { + return strings.HasPrefix(key, "discord.image.") + })).Return("", false, nil) + host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil) + host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil) + + // Mock HTTP request for Discord external assets API + assetsReq := &pdk.HTTPRequest{} + pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.MatchedBy(func(url string) bool { + return strings.Contains(url, "external-assets") + })).Return(assetsReq) + pdk.PDKMock.On("Send", assetsReq).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`{"key":"test-key"}`))) + + // Schedule clear activity callback + host.SchedulerMock.On("ScheduleOneTime", mock.Anything, payloadClearActivity, "testuser-clear").Return("testuser-clear", nil) + + err := plugin.NowPlaying(scrobbler.NowPlayingRequest{ + Username: "testuser", + Position: 10, + Track: scrobbler.TrackInfo{ + ID: "track1", + Title: "Test Song", + Artist: "Test Artist", + Album: "Test Album", + Duration: 180, + }, + }) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Describe("Scrobble", func() { + It("does nothing (returns nil)", func() { + err := plugin.Scrobble(scrobbler.ScrobbleRequest{}) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Describe("OnCallback", func() { + BeforeEach(func() { + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + }) + + It("handles heartbeat callback", func() { + host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(42), true, nil) + host.WebSocketMock.On("SendText", "testuser", mock.Anything).Return(nil) + + err := plugin.OnCallback(scheduler.SchedulerCallbackRequest{ + ScheduleID: "testuser", + Payload: payloadHeartbeat, + IsRecurring: true, + }) + Expect(err).ToNot(HaveOccurred()) + }) + + It("handles clearActivity callback", func() { + host.WebSocketMock.On("SendText", "testuser", mock.Anything).Return(nil) + host.SchedulerMock.On("CancelSchedule", "testuser").Return(nil) + host.WebSocketMock.On("CloseConnection", "testuser", int32(1000), "Navidrome disconnect").Return(nil) + + err := plugin.OnCallback(scheduler.SchedulerCallbackRequest{ + ScheduleID: "testuser-clear", + Payload: payloadClearActivity, + }) + Expect(err).ToNot(HaveOccurred()) + }) + + It("logs warning for unknown payload", func() { + err := plugin.OnCallback(scheduler.SchedulerCallbackRequest{ + ScheduleID: "testuser", + Payload: "unknown", + }) + Expect(err).ToNot(HaveOccurred()) + }) + }) +}) diff --git a/plugins/examples/discord-rich-presence/rpc_test.go b/plugins/examples/discord-rich-presence/rpc_test.go new file mode 100644 index 000000000..b85c27ee5 --- /dev/null +++ b/plugins/examples/discord-rich-presence/rpc_test.go @@ -0,0 +1,279 @@ +package main + +import ( + "errors" + "strings" + + "github.com/navidrome/navidrome/plugins/pdk/go/host" + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" + "github.com/navidrome/navidrome/plugins/pdk/go/websocket" + "github.com/stretchr/testify/mock" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("discordRPC", func() { + var r *discordRPC + + BeforeEach(func() { + r = &discordRPC{} + pdk.ResetMock() + host.CacheMock.ExpectedCalls = nil + host.CacheMock.Calls = nil + host.WebSocketMock.ExpectedCalls = nil + host.WebSocketMock.Calls = nil + host.SchedulerMock.ExpectedCalls = nil + host.SchedulerMock.Calls = nil + }) + + Describe("sendMessage", func() { + It("sends JSON message over WebSocket", func() { + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool { + return strings.Contains(msg, `"op":3`) + })).Return(nil) + + err := r.sendMessage("testuser", presenceOpCode, map[string]string{"status": "online"}) + Expect(err).ToNot(HaveOccurred()) + host.WebSocketMock.AssertExpectations(GinkgoT()) + }) + + It("returns error when WebSocket send fails", func() { + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + host.WebSocketMock.On("SendText", mock.Anything, mock.Anything). + Return(errors.New("connection closed")) + + err := r.sendMessage("testuser", presenceOpCode, map[string]string{}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("connection closed")) + }) + }) + + Describe("sendHeartbeat", func() { + It("retrieves sequence number from cache and sends heartbeat", func() { + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(123), true, nil) + host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool { + return strings.Contains(msg, `"op":1`) && strings.Contains(msg, "123") + })).Return(nil) + + err := r.sendHeartbeat("testuser") + Expect(err).ToNot(HaveOccurred()) + host.CacheMock.AssertExpectations(GinkgoT()) + host.WebSocketMock.AssertExpectations(GinkgoT()) + }) + + It("returns error when cache get fails", func() { + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("cache error")) + + err := r.sendHeartbeat("testuser") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("cache error")) + }) + }) + + Describe("connect", func() { + It("establishes WebSocket connection and sends identify payload", func() { + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("not found")) + + // Mock HTTP GET request for gateway discovery + gatewayResp := []byte(`{"url":"wss://gateway.discord.gg"}`) + httpReq := &pdk.HTTPRequest{} + pdk.PDKMock.On("NewHTTPRequest", pdk.MethodGet, "https://discord.com/api/gateway").Return(httpReq) + pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(200, nil, gatewayResp)) + + // Mock WebSocket connection + host.WebSocketMock.On("Connect", mock.MatchedBy(func(url string) bool { + return strings.Contains(url, "gateway.discord.gg") + }), mock.Anything, "testuser").Return("testuser", nil) + host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool { + return strings.Contains(msg, `"op":2`) && strings.Contains(msg, "test-token") + })).Return(nil) + host.SchedulerMock.On("ScheduleRecurring", "@every 41s", payloadHeartbeat, "testuser"). + Return("testuser", nil) + + err := r.connect("testuser", "test-token") + Expect(err).ToNot(HaveOccurred()) + }) + + It("reuses existing connection if connected", func() { + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(42), true, nil) + host.WebSocketMock.On("SendText", "testuser", mock.Anything).Return(nil) + + err := r.connect("testuser", "test-token") + Expect(err).ToNot(HaveOccurred()) + host.WebSocketMock.AssertNotCalled(GinkgoT(), "Connect", mock.Anything, mock.Anything, mock.Anything) + }) + }) + + Describe("disconnect", func() { + It("cancels schedule and closes WebSocket connection", func() { + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + host.SchedulerMock.On("CancelSchedule", "testuser").Return(nil) + host.WebSocketMock.On("CloseConnection", "testuser", int32(1000), "Navidrome disconnect").Return(nil) + + err := r.disconnect("testuser") + Expect(err).ToNot(HaveOccurred()) + host.SchedulerMock.AssertExpectations(GinkgoT()) + host.WebSocketMock.AssertExpectations(GinkgoT()) + }) + }) + + Describe("cleanupFailedConnection", func() { + It("cancels schedule, closes WebSocket, and clears cache", func() { + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + host.SchedulerMock.On("CancelSchedule", "testuser").Return(nil) + host.WebSocketMock.On("CloseConnection", "testuser", int32(1000), "Connection lost").Return(nil) + host.CacheMock.On("Remove", "discord.seq.testuser").Return(nil) + + r.cleanupFailedConnection("testuser") + + host.SchedulerMock.AssertExpectations(GinkgoT()) + host.WebSocketMock.AssertExpectations(GinkgoT()) + }) + }) + + Describe("handleHeartbeatCallback", func() { + It("sends heartbeat successfully", func() { + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(42), true, nil) + host.WebSocketMock.On("SendText", "testuser", mock.Anything).Return(nil) + + err := r.handleHeartbeatCallback("testuser") + Expect(err).ToNot(HaveOccurred()) + }) + + It("cleans up connection on heartbeat failure", func() { + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("cache miss")) + host.SchedulerMock.On("CancelSchedule", "testuser").Return(nil) + host.WebSocketMock.On("CloseConnection", "testuser", int32(1000), "Connection lost").Return(nil) + host.CacheMock.On("Remove", "discord.seq.testuser").Return(nil) + + err := r.handleHeartbeatCallback("testuser") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("connection cleaned up")) + }) + }) + + Describe("handleClearActivityCallback", func() { + It("clears activity and disconnects", func() { + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool { + return strings.Contains(msg, `"op":3`) && strings.Contains(msg, `"activities":null`) + })).Return(nil) + host.SchedulerMock.On("CancelSchedule", "testuser").Return(nil) + host.WebSocketMock.On("CloseConnection", "testuser", int32(1000), "Navidrome disconnect").Return(nil) + + err := r.handleClearActivityCallback("testuser") + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Describe("WebSocket callbacks", func() { + Describe("OnTextMessage", func() { + It("handles valid JSON message", func() { + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + host.CacheMock.On("SetInt", mock.Anything, mock.Anything, mock.Anything).Return(nil) + + err := r.OnTextMessage(websocket.OnTextMessageRequest{ + ConnectionID: "testuser", + Message: `{"s":42}`, + }) + Expect(err).ToNot(HaveOccurred()) + }) + + It("returns error for invalid JSON", func() { + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + err := r.OnTextMessage(websocket.OnTextMessageRequest{ + ConnectionID: "testuser", + Message: `not json`, + }) + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("OnBinaryMessage", func() { + It("handles binary message without error", func() { + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + err := r.OnBinaryMessage(websocket.OnBinaryMessageRequest{ + ConnectionID: "testuser", + Data: "AQID", // base64 encoded [0x01, 0x02, 0x03] + }) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Describe("OnError", func() { + It("handles error without returning error", func() { + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + err := r.OnError(websocket.OnErrorRequest{ + ConnectionID: "testuser", + Error: "test error", + }) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Describe("OnClose", func() { + It("handles close without returning error", func() { + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + err := r.OnClose(websocket.OnCloseRequest{ + ConnectionID: "testuser", + Code: 1000, + Reason: "normal close", + }) + Expect(err).ToNot(HaveOccurred()) + }) + }) + }) + + Describe("sendActivity", func() { + BeforeEach(func() { + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + host.CacheMock.On("GetString", mock.MatchedBy(func(key string) bool { + return strings.HasPrefix(key, "discord.image.") + })).Return("", false, nil) + host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil) + + // Mock HTTP request for Discord external assets API (image processing) + // When processImage is called, it makes an HTTP request + httpReq := &pdk.HTTPRequest{} + pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.Anything).Return(httpReq) + pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`{"key":"test-key"}`))) + }) + + It("sends activity update to Discord", func() { + host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool { + return strings.Contains(msg, `"op":3`) && + strings.Contains(msg, `"name":"Test Song"`) && + strings.Contains(msg, `"state":"Test Artist"`) + })).Return(nil) + + err := r.sendActivity("client123", "testuser", "token123", activity{ + Application: "client123", + Name: "Test Song", + Type: 2, + State: "Test Artist", + Details: "Test Album", + }) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Describe("clearActivity", func() { + It("sends presence update with nil activities", func() { + pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() + host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool { + return strings.Contains(msg, `"op":3`) && strings.Contains(msg, `"activities":null`) + })).Return(nil) + + err := r.clearActivity("testuser") + Expect(err).ToNot(HaveOccurred()) + }) + }) +})