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: []byte("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(""))
|
|
})
|
|
})
|
|
})
|