bb7d6aaa5e
Replace fragile strings.Contains checks with proper JSON unmarshalling to validate truncated fields by their exact values and verify URL fields are correctly omitted.
564 lines
21 KiB
Go
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(""))
|
|
})
|
|
})
|
|
})
|