Files
Discodrome/rpc_test.go
T
deluan bb7d6aaa5e test: use JSON unmarshalling for truncation test assertions
Replace fragile strings.Contains checks with proper JSON unmarshalling
to validate truncated fields by their exact values and verify URL fields
are correctly omitted.
2026-03-04 12:44:24 -05:00

564 lines
21 KiB
Go

package main
import (
"encoding/json"
"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
host.HTTPMock.ExpectedCalls = nil
host.HTTPMock.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"}`)
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.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("processImage", func() {
BeforeEach(func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
})
It("returns error for empty URL", func() {
_, err := r.processImage("", "client123", "token123", imageCacheTTL)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("image URL is empty"))
})
It("returns mp: prefixed URL as-is", func() {
result, err := r.processImage("mp:external/abc123", "client123", "token123", imageCacheTTL)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(Equal("mp:external/abc123"))
})
It("returns cached value on cache hit", func() {
host.CacheMock.On("GetString", mock.MatchedBy(func(key string) bool {
return strings.HasPrefix(key, "discord.image.")
})).Return("mp:cached/image", true, nil)
result, err := r.processImage("https://example.com/art.jpg", "client123", "token123", imageCacheTTL)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(Equal("mp:cached/image"))
})
It("processes image via Discord API and caches result", func() {
host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
host.CacheMock.On("SetString", discordImageKey, mock.MatchedBy(func(val string) bool {
return val == "mp:external/new-asset"
}), int64(imageCacheTTL)).Return(nil)
host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`[{"external_asset_path":"external/new-asset"}]`)}, nil)
result, err := r.processImage("https://example.com/art.jpg", "client123", "token123", imageCacheTTL)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(Equal("mp:external/new-asset"))
})
It("returns error on HTTP failure", func() {
host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 500, Body: []byte(`error`)}, nil)
_, err := r.processImage("https://example.com/art.jpg", "client123", "token123", imageCacheTTL)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("HTTP 500"))
})
It("returns error on unmarshal failure", func() {
host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`{"not":"an-array"}`)}, nil)
_, err := r.processImage("https://example.com/art.jpg", "client123", "token123", imageCacheTTL)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to unmarshal"))
})
It("returns error on empty response array", func() {
host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`[]`)}, nil)
_, err := r.processImage("https://example.com/art.jpg", "client123", "token123", imageCacheTTL)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("no data returned"))
})
It("returns error on empty external_asset_path", func() {
host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`[{"external_asset_path":""}]`)}, nil)
_, err := r.processImage("https://example.com/art.jpg", "client123", "token123", imageCacheTTL)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("empty external_asset_path"))
})
})
Describe("sendActivity", func() {
BeforeEach(func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
})
It("sends activity with track artwork and SmallImage overlay", func() {
host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
host.CacheMock.On("SetString", discordImageKey, mock.Anything, mock.Anything).Return(nil)
host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`[{"external_asset_path":"external/art"}]`)}, nil)
host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool {
return strings.Contains(msg, `"op":3`) &&
strings.Contains(msg, `"large_image":"mp:external/art"`) &&
strings.Contains(msg, `"small_image":"mp:external/art"`) &&
strings.Contains(msg, `"small_text":"Navidrome"`)
})).Return(nil)
err := r.sendActivity("client123", "testuser", "token123", activity{
Application: "client123",
Name: "Test Song",
Type: 2,
State: "Test Artist",
Details: "Test Album",
Assets: activityAssets{
LargeImage: "https://example.com/art.jpg",
LargeText: "Test Album",
SmallImage: navidromeLogoURL,
SmallText: "Navidrome",
},
})
Expect(err).ToNot(HaveOccurred())
})
It("falls back to default image and clears SmallImage", func() {
// Track art fails (HTTP error), default image succeeds
host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
host.CacheMock.On("SetString", discordImageKey, mock.Anything, mock.Anything).Return(nil)
// First call (track art) returns 500, second call (default) succeeds
host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 500, Body: []byte(`error`)}, nil).Once()
host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`[{"external_asset_path":"external/logo"}]`)}, nil).Once()
host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool {
return strings.Contains(msg, `"op":3`) &&
strings.Contains(msg, `"large_image":"mp:external/logo"`) &&
!strings.Contains(msg, `"small_image":"mp:`) &&
!strings.Contains(msg, `"small_text":"Navidrome"`)
})).Return(nil)
err := r.sendActivity("client123", "testuser", "token123", activity{
Application: "client123",
Name: "Test Song",
Type: 2,
State: "Test Artist",
Details: "Test Album",
Assets: activityAssets{
LargeImage: "https://example.com/art.jpg",
LargeText: "Test Album",
SmallImage: navidromeLogoURL,
SmallText: "Navidrome",
},
})
Expect(err).ToNot(HaveOccurred())
})
It("clears all images when both track art and default fail", func() {
host.CacheMock.On("GetString", discordImageKey).Return("", false, nil)
host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 200, Body: []byte(`{"not":"array"}`)}, nil)
host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool {
return strings.Contains(msg, `"op":3`) &&
strings.Contains(msg, `"large_image":""`) &&
!strings.Contains(msg, `"small_image":"mp:`)
})).Return(nil)
err := r.sendActivity("client123", "testuser", "token123", activity{
Application: "client123",
Name: "Test Song",
Type: 2,
State: "Test Artist",
Details: "Test Album",
Assets: activityAssets{
LargeImage: "https://example.com/art.jpg",
LargeText: "Test Album",
SmallImage: navidromeLogoURL,
SmallText: "Navidrome",
},
})
Expect(err).ToNot(HaveOccurred())
})
It("handles SmallImage processing failure gracefully", func() {
// LargeImage from cache (succeeds), SmallImage API fails
host.CacheMock.On("GetString", discordImageKey).Return("mp:cached/large", true, nil).Once()
host.CacheMock.On("GetString", discordImageKey).Return("", false, nil).Once()
host.HTTPMock.On("Send", externalAssetsReq).Return(&host.HTTPResponse{StatusCode: 500, Body: []byte(`error`)}, nil)
host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool {
return strings.Contains(msg, `"large_image":"mp:cached/large"`) &&
!strings.Contains(msg, `"small_image":"mp:`)
})).Return(nil)
err := r.sendActivity("client123", "testuser", "token123", activity{
Application: "client123",
Name: "Test Song",
Type: 2,
State: "Test Artist",
Details: "Test Album",
Assets: activityAssets{
LargeImage: "https://example.com/art.jpg",
LargeText: "Test Album",
SmallImage: navidromeLogoURL,
SmallText: "Navidrome",
},
})
Expect(err).ToNot(HaveOccurred())
})
It("truncates long text fields and omits long URLs", func() {
host.CacheMock.On("GetString", discordImageKey).Return("mp:cached/art", true, nil).Once()
host.CacheMock.On("GetString", discordImageKey).Return("mp:cached/logo", true, nil).Once()
longName := strings.Repeat("N", 200)
longTitle := strings.Repeat("T", 200)
longArtist := strings.Repeat("A", 200)
longAlbum := strings.Repeat("B", 200)
longURL := "https://example.com/" + strings.Repeat("x", 237)
truncatedName := strings.Repeat("N", 127) + "…"
truncatedTitle := strings.Repeat("T", 127) + "…"
truncatedArtist := strings.Repeat("A", 127) + "…"
truncatedAlbum := strings.Repeat("B", 127) + "…"
host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool {
var message struct {
D json.RawMessage `json:"d"`
}
if err := json.Unmarshal([]byte(msg), &message); err != nil {
return false
}
var presence presencePayload
if err := json.Unmarshal(message.D, &presence); err != nil {
return false
}
if len(presence.Activities) != 1 {
return false
}
act := presence.Activities[0]
return act.Name == truncatedName &&
act.Details == truncatedTitle &&
act.State == truncatedArtist &&
act.Assets.LargeText == truncatedAlbum &&
act.DetailsURL == "" &&
act.StateURL == "" &&
act.Assets.LargeURL == "" &&
act.Assets.SmallURL == ""
})).Return(nil)
err := r.sendActivity("client123", "testuser", "token123", activity{
Application: "client123",
Name: longName,
Type: 2,
Details: longTitle,
DetailsURL: longURL,
State: longArtist,
StateURL: longURL,
Assets: activityAssets{
LargeImage: "https://example.com/art.jpg",
LargeText: longAlbum,
LargeURL: longURL,
SmallImage: navidromeLogoURL,
SmallText: "Navidrome",
SmallURL: longURL,
},
})
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())
})
})
Describe("truncateText", func() {
It("returns short strings unchanged", func() {
Expect(truncateText("hello")).To(Equal("hello"))
})
It("returns exactly 128-char strings unchanged", func() {
s := strings.Repeat("a", 128)
Expect(truncateText(s)).To(Equal(s))
})
It("truncates strings over 128 chars to 127 + ellipsis", func() {
s := strings.Repeat("a", 200)
result := truncateText(s)
Expect([]rune(result)).To(HaveLen(128))
Expect(result).To(HaveSuffix("…"))
})
It("handles multi-byte characters correctly", func() {
// 130 Japanese characters — each is one rune but 3 bytes
s := strings.Repeat("あ", 130)
result := truncateText(s)
runes := []rune(result)
Expect(runes).To(HaveLen(128))
Expect(string(runes[127])).To(Equal("…"))
})
It("returns empty string unchanged", func() {
Expect(truncateText("")).To(Equal(""))
})
})
Describe("truncateURL", func() {
It("returns short URLs unchanged", func() {
Expect(truncateURL("https://example.com")).To(Equal("https://example.com"))
})
It("returns exactly 256-char URLs unchanged", func() {
u := "https://example.com/" + strings.Repeat("a", 236)
Expect(truncateURL(u)).To(Equal(u))
})
It("returns empty string for URLs over 256 chars", func() {
u := "https://example.com/" + strings.Repeat("a", 237)
Expect(truncateURL(u)).To(Equal(""))
})
It("returns empty string unchanged", func() {
Expect(truncateURL("")).To(Equal(""))
})
})
})