package main import ( "errors" "fmt" "strings" "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" ) var _ = Describe("discordPlugin", func() { var plugin discordPlugin BeforeEach(func() { plugin = discordPlugin{} pdk.ResetMock() host.CacheMock.ExpectedCalls = nil host.CacheMock.Calls = nil host.ConfigMock.ExpectedCalls = nil host.ConfigMock.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 host.SubsonicAPIMock.ExpectedCalls = nil host.SubsonicAPIMock.Calls = nil host.HTTPMock.ExpectedCalls = nil host.HTTPMock.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(`[{"username":"user1","token":"token1"},{"username":"user2","token":"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()) }) }) 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(`[{"username":"testuser","token":"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(`[{"username":"otheruser","token":"token123"}]`, true) authorized, err := plugin.IsAuthorized(scrobbler.IsAuthorizedRequest{ Username: "testuser", }) Expect(err).ToNot(HaveOccurred()) Expect(authorized).To(BeFalse()) }) }) Describe("NowPlaying", func() { BeforeEach(func() { pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe() }) 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(`[{"username":"otheruser","token":"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(`[{"username":"testuser","token":"test-token"}]`, true) pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("", false) pdk.PDKMock.On("GetConfig", activityNameKey).Return("", false) pdk.PDKMock.On("GetConfig", spotifyLinksKey).Return("", false) // 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"}`) host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { return req.Method == "GET" && req.URL == "https://discord.com/api/gateway" })).Return(&host.HTTPResponse{StatusCode: 200, Body: gatewayResp}, nil) // 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) // Cache mocks (Discord image processing) host.CacheMock.On("GetString", discordImageKey).Return("", false, nil) host.CacheMock.On("SetString", discordImageKey, mock.Anything, mock.Anything).Return(nil) host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil) // Mock HTTP POST requests (Discord external assets API) host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`{}`)}, nil) // 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()) }) DescribeTable("activity name configuration", func(configValue string, configExists bool, expectedName string, expectedDisplayType int) { pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true) pdk.PDKMock.On("GetConfig", usersKey).Return(`[{"username":"testuser","token":"test-token"}]`, true) pdk.PDKMock.On("GetConfig", uguuEnabledKey).Return("", false) pdk.PDKMock.On("GetConfig", activityNameKey).Return(configValue, configExists) pdk.PDKMock.On("GetConfig", spotifyLinksKey).Return("", false) // Connect mocks host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("not found")) gatewayResp := []byte(`{"url":"wss://gateway.discord.gg"}`) host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { return req.Method == "GET" && req.URL == "https://discord.com/api/gateway" })).Return(&host.HTTPResponse{StatusCode: 200, Body: gatewayResp}, nil) host.WebSocketMock.On("Connect", mock.MatchedBy(func(url string) bool { return strings.Contains(url, "gateway.discord.gg") }), mock.Anything, "testuser").Return("testuser", nil) // Capture the activity payload sent to Discord var sentPayload string host.WebSocketMock.On("SendText", "testuser", mock.Anything).Run(func(args mock.Arguments) { sentPayload = args.Get(1).(string) }).Return(nil) host.SchedulerMock.On("ScheduleRecurring", mock.Anything, payloadHeartbeat, "testuser").Return("testuser", nil) host.SchedulerMock.On("CancelSchedule", "testuser-clear").Return(nil) // Cache mocks (Discord image processing) host.CacheMock.On("GetString", discordImageKey).Return("", false, nil) host.CacheMock.On("SetString", discordImageKey, mock.Anything, mock.Anything).Return(nil) host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil) host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`{}`)}, nil) 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()) Expect(sentPayload).To(ContainSubstring(fmt.Sprintf(`"name":"%s"`, expectedName))) Expect(sentPayload).To(ContainSubstring(fmt.Sprintf(`"status_display_type":%d`, expectedDisplayType))) }, Entry("defaults to Navidrome when not configured", "", false, "Navidrome", 2), Entry("defaults to Navidrome with explicit default value", "Default", true, "Navidrome", 2), Entry("uses track title when configured", "Track", true, "Test Song", 0), Entry("uses track album when configured", "Album", true, "Test Album", 0), Entry("uses track artist when configured", "Artist", true, "Test Artist", 0), ) }) 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()) }) }) })