Files
pocket-id/backend/internal/logopreset/service_test.go
T

181 lines
6.0 KiB
Go

package logopreset
import (
"errors"
"net/http"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/utils"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
const testBaseURL = "https://icons.example.com"
const testIndex = `[
{"Name": "Nextcloud", "Reference": "nextcloud", "SVG": "Yes", "PNG": "Yes", "Light": "Yes", "Dark": "Yes", "Tags": "Cloud Storage"},
{"Name": "Nextcloud Talk", "Reference": "nextcloud-talk", "SVG": "Yes", "PNG": "Yes", "Light": "No", "Dark": "No", "Tags": ""},
{"Name": "Ghost", "Reference": "ghost", "SVG": "No", "PNG": "Yes", "Light": "Yes", "Dark": "Yes", "Tags": "Blogging"},
{"Name": "Home Assistant", "Reference": "home-assistant", "SVG": "Yes", "PNG": "Yes", "Light": "Yes", "Dark": "Yes", "Tags": ""},
{"Name": "OwnCloud", "Reference": "owncloud", "SVG": "Yes", "PNG": "Yes", "Light": "No", "Dark": "No", "Tags": "Cloud Storage"},
{"Name": "Bad", "Reference": "../bad", "SVG": "Yes", "PNG": "Yes", "Light": "No", "Dark": "No", "Tags": ""}
]`
// countingRoundTripper counts requests before delegating to the mock, so tests can assert when the index is fetched
type countingRoundTripper struct {
next http.RoundTripper
calls atomic.Int32
}
func (c *countingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
c.calls.Add(1)
return c.next.RoundTrip(req)
}
func newTestService(t *testing.T, mock *testutils.MockRoundTripper) (*Service, *countingRoundTripper) {
t.Helper()
transport := &countingRoundTripper{next: mock}
return newService(Dependencies{HTTPClient: &http.Client{Transport: transport}, BaseURL: testBaseURL}), transport
}
func indexMock() *testutils.MockRoundTripper {
return &testutils.MockRoundTripper{
Responses: map[string]*http.Response{
testBaseURL + "/index.json": testutils.NewMockResponse(http.StatusOK, testIndex), //nolint:bodyclose // mock response, no real body
},
}
}
func references(presets []logoPresetDto) []string {
refs := make([]string, len(presets))
for i, p := range presets {
refs[i] = p.Reference
}
return refs
}
func TestSearch_Disabled(t *testing.T) {
svc, transport := newTestService(t, indexMock())
svc.baseURL = ""
_, err := svc.Search(t.Context(), "nextcloud")
require.Error(t, err)
assert.True(t, apperror.IsCode(err, apperror.CodeLogoPresetsDisabled))
assert.Zero(t, transport.calls.Load(), "a disabled icon library must not be contacted")
}
func TestSearch_Ranking(t *testing.T) {
svc, _ := newTestService(t, indexMock())
tests := []struct {
name string
query string
want []string
}{
{name: "exact match ranks before prefix match", query: "nextcloud", want: []string{"nextcloud", "nextcloud-talk"}},
{name: "substring matches come after prefix matches", query: "cloud", want: []string{"nextcloud", "nextcloud-talk", "owncloud"}},
{name: "separators and case are ignored", query: "HOME assistant", want: []string{"home-assistant"}},
{name: "tags match last", query: "blogging", want: []string{"ghost"}},
{name: "empty query lists everything alphabetically", query: "", want: []string{"ghost", "home-assistant", "nextcloud", "nextcloud-talk", "owncloud"}},
{name: "no match", query: "jellyfin", want: []string{}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
presets, err := svc.Search(t.Context(), tt.query)
require.NoError(t, err)
assert.Equal(t, tt.want, references(presets))
})
}
}
func TestSearch_LimitsResults(t *testing.T) {
presets := make([]preset, maxResults+10)
for i := range presets {
presets[i] = preset{searchName: "app", searchReference: "app"}
}
assert.Len(t, search(presets, "app", maxResults), maxResults)
}
func TestSearch_BuildsIconURLs(t *testing.T) {
svc, _ := newTestService(t, indexMock())
presets, err := svc.Search(t.Context(), "")
require.NoError(t, err)
byReference := make(map[string]logoPresetDto, len(presets))
for _, p := range presets {
byReference[p.Reference] = p
}
// SVG icon with a white variant for dark mode
nextcloud := byReference["nextcloud"]
assert.Equal(t, testBaseURL+"/svg/nextcloud.svg", nextcloud.LogoURL)
require.NotNil(t, nextcloud.DarkLogoURL)
assert.Equal(t, testBaseURL+"/svg/nextcloud-light.svg", *nextcloud.DarkLogoURL)
// SVG icon without a white variant leaves the dark logo empty
talk := byReference["nextcloud-talk"]
assert.Equal(t, testBaseURL+"/svg/nextcloud-talk.svg", talk.LogoURL)
assert.Nil(t, talk.DarkLogoURL)
// PNG-only icon falls back to PNG for both variants
ghost := byReference["ghost"]
assert.Equal(t, testBaseURL+"/png/ghost.png", ghost.LogoURL)
require.NotNil(t, ghost.DarkLogoURL)
assert.Equal(t, testBaseURL+"/png/ghost-light.png", *ghost.DarkLogoURL)
// References that aren't plain slugs are dropped
assert.NotContains(t, byReference, "../bad")
}
func TestSearch_CachesIndex(t *testing.T) {
svc, transport := newTestService(t, indexMock())
for range 3 {
_, err := svc.Search(t.Context(), "nextcloud")
require.NoError(t, err)
}
assert.Equal(t, int32(1), transport.calls.Load())
}
func TestSearch_UsesStaleIndexWhenRefreshFails(t *testing.T) {
mock := indexMock()
svc, _ := newTestService(t, mock)
// Expire the cache immediately so the second search has to refetch
svc.cache = utils.New[[]preset](time.Nanosecond)
_, err := svc.Search(t.Context(), "nextcloud")
require.NoError(t, err)
mock.Err = errors.New("CDN unreachable")
presets, err := svc.Search(t.Context(), "nextcloud")
require.NoError(t, err)
assert.Equal(t, []string{"nextcloud", "nextcloud-talk"}, references(presets))
}
func TestSearch_IndexUnavailable(t *testing.T) {
mock := &testutils.MockRoundTripper{
Responses: map[string]*http.Response{
testBaseURL + "/index.json": testutils.NewMockResponse(http.StatusInternalServerError, ""), //nolint:bodyclose // mock response, no real body
},
}
svc, _ := newTestService(t, mock)
_, err := svc.Search(t.Context(), "nextcloud")
require.Error(t, err)
assert.True(t, apperror.IsCode(err, apperror.CodeLogoPresetsUnavailable))
}