mattermost-plugin-shelfmark/server/shelfmark/client_test.go
Felipe M. 620d601091
All checks were successful
ci / lint (push) Successful in 4m44s
ci / test (push) Successful in 4m0s
ci / build (push) Successful in 7m17s
release / release (push) Successful in 7m19s
Add System Console button to test Shelfmark connection.
Expose an admin-only endpoint that validates the saved plugin configuration against the Shelfmark server, and surface it in the plugin settings UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 10:39:10 +02:00

365 lines
11 KiB
Go

package shelfmark
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// newTestClient creates a test server that does not require auth and a client pointing at it.
func newTestClient(t *testing.T, handler http.HandlerFunc) (*httptest.Server, *Client) {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/api/auth/check", func(w http.ResponseWriter, r *http.Request) {
require.NoError(t, json.NewEncoder(w).Encode(AuthCheckResponse{AuthRequired: false}))
})
mux.HandleFunc("/", handler)
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
client := NewClient(srv.URL, "", "")
return srv, client
}
// newTestClientWithAuth creates a test server that requires auth.
func newTestClientWithAuth(t *testing.T, handler http.HandlerFunc) (*httptest.Server, *Client) {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/api/auth/check", func(w http.ResponseWriter, r *http.Request) {
require.NoError(t, json.NewEncoder(w).Encode(AuthCheckResponse{AuthRequired: true}))
})
mux.HandleFunc("/api/auth/login", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, err := w.Write([]byte(`{}`))
require.NoError(t, err)
})
mux.HandleFunc("/", handler)
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
client := NewClient(srv.URL, "user", "pass")
return srv, client
}
func TestNewClient(t *testing.T) {
client := NewClient("http://example.com/", "user", "pass")
assert.Equal(t, "http://example.com", client.baseURL)
assert.Equal(t, "user", client.username)
assert.Equal(t, "pass", client.password)
assert.NotNil(t, client.httpClient.Jar)
}
func TestNewClient_TrailingSlashTrimmed(t *testing.T) {
client := NewClient("http://example.com///", "u", "p")
assert.Equal(t, "http://example.com", client.baseURL)
}
func TestDoRequest_BodyReplayOnRetry(t *testing.T) {
var requestCount atomic.Int32
var retryBody []byte
mux := http.NewServeMux()
mux.HandleFunc("/api/auth/check", func(w http.ResponseWriter, r *http.Request) {
require.NoError(t, json.NewEncoder(w).Encode(AuthCheckResponse{AuthRequired: true}))
})
mux.HandleFunc("/api/auth/login", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, err := w.Write([]byte(`{}`))
require.NoError(t, err)
})
mux.HandleFunc("/api/releases/download", func(w http.ResponseWriter, r *http.Request) {
count := requestCount.Add(1)
if count == 1 {
// First request: return 401 to trigger retry.
w.WriteHeader(http.StatusUnauthorized)
return
}
// Second request (retry): capture the body.
body, err := io.ReadAll(r.Body)
require.NoError(t, err)
retryBody = body
require.NoError(t, json.NewEncoder(w).Encode(QueueResponse{Status: "queued"}))
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := NewClient(srv.URL, "user", "pass")
release := &Release{Source: "test", SourceID: "123", Title: "Test Book"}
result, err := client.QueueDownload(release)
require.NoError(t, err)
assert.Equal(t, "queued", result.Status)
assert.Equal(t, int32(2), requestCount.Load())
// Verify the retry had the full body (not empty).
assert.NotEmpty(t, retryBody)
var parsed Release
err = json.Unmarshal(retryBody, &parsed)
require.NoError(t, err)
assert.Equal(t, "123", parsed.SourceID)
}
func TestSearchBooks(t *testing.T) {
_, client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/metadata/search", r.URL.Path)
assert.Equal(t, "test query", r.URL.Query().Get("query"))
require.NoError(t, json.NewEncoder(w).Encode(SearchResponse{
Books: []Book{{Title: "Test Book", Provider: "prov", ProviderID: "1"}},
Query: "test query",
}))
})
result, err := client.SearchBooks("test query")
require.NoError(t, err)
require.Len(t, result.Books, 1)
assert.Equal(t, "Test Book", result.Books[0].Title)
}
func TestSearchBooks_Error(t *testing.T) {
_, client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, err := w.Write([]byte("server error"))
require.NoError(t, err)
})
_, err := client.SearchBooks("test")
require.Error(t, err)
assert.Contains(t, err.Error(), "500")
}
func TestGetReleases(t *testing.T) {
_, client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/releases", r.URL.Path)
assert.Equal(t, "prov", r.URL.Query().Get("provider"))
assert.Equal(t, "123", r.URL.Query().Get("book_id"))
assert.Equal(t, "en", r.URL.Query().Get("languages"))
require.NoError(t, json.NewEncoder(w).Encode(ReleasesResponse{
Releases: []Release{{Source: "src", SourceID: "abc", Title: "Test"}},
}))
})
result, err := client.GetReleases("prov", "123", "en")
require.NoError(t, err)
require.Len(t, result.Releases, 1)
assert.Equal(t, "abc", result.Releases[0].SourceID)
}
func TestGetReleases_NoLanguage(t *testing.T) {
_, client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
assert.Empty(t, r.URL.Query().Get("languages"))
require.NoError(t, json.NewEncoder(w).Encode(ReleasesResponse{}))
})
_, err := client.GetReleases("prov", "123", "")
require.NoError(t, err)
}
func TestQueueDownload(t *testing.T) {
_, client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "/api/releases/download", r.URL.Path)
body, err := io.ReadAll(r.Body)
require.NoError(t, err)
var release Release
require.NoError(t, json.Unmarshal(body, &release))
assert.Equal(t, "test-source", release.SourceID)
require.NoError(t, json.NewEncoder(w).Encode(QueueResponse{Status: "queued", Priority: 1}))
})
result, err := client.QueueDownload(&Release{Source: "s", SourceID: "test-source"})
require.NoError(t, err)
assert.Equal(t, "queued", result.Status)
}
func TestGetStatus(t *testing.T) {
_, client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/status", r.URL.Path)
_, err := w.Write([]byte(`{"complete":{"task1":{}},"queued":{"task2":{}}}`))
require.NoError(t, err)
})
result, err := client.GetStatus()
require.NoError(t, err)
assert.Contains(t, result, "complete")
assert.Contains(t, result["complete"], "task1")
}
func TestGetTaskStatus(t *testing.T) {
status := statusResponse{
"complete": {"task1": json.RawMessage(`{}`)},
"queued": {"task2": json.RawMessage(`{}`)},
"downloading": {"task3": json.RawMessage(`{}`)},
}
t.Run("found in complete", func(t *testing.T) {
cat, found := GetTaskStatus(status, "task1")
assert.True(t, found)
assert.Equal(t, "complete", cat)
})
t.Run("found in queued", func(t *testing.T) {
cat, found := GetTaskStatus(status, "task2")
assert.True(t, found)
assert.Equal(t, "queued", cat)
})
t.Run("not found", func(t *testing.T) {
cat, found := GetTaskStatus(status, "nonexistent")
assert.False(t, found)
assert.Empty(t, cat)
})
t.Run("nil status", func(t *testing.T) {
cat, found := GetTaskStatus(nil, "task1")
assert.False(t, found)
assert.Empty(t, cat)
})
}
func TestDownloadFile(t *testing.T) {
_, client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/localdownload", r.URL.Path)
assert.Equal(t, "task123", r.URL.Query().Get("id"))
w.Header().Set("Content-Disposition", `attachment; filename="book.epub"`)
_, err := w.Write([]byte("file-content"))
require.NoError(t, err)
})
data, filename, err := client.DownloadFile("task123")
require.NoError(t, err)
assert.Equal(t, []byte("file-content"), data)
assert.Equal(t, "book.epub", filename)
// Verify the shared httpClient.Timeout was NOT mutated.
assert.Equal(t, 30*time.Second, client.httpClient.Timeout)
}
func TestDownloadFile_NoContentDisposition(t *testing.T) {
_, client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
_, err := w.Write([]byte("data"))
require.NoError(t, err)
})
_, filename, err := client.DownloadFile("task456")
require.NoError(t, err)
assert.Equal(t, "task456", filename)
}
func TestDownloadCover(t *testing.T) {
tests := []struct {
name string
contentType string
expected string
}{
{"jpeg", "image/jpeg", "cover.jpg"},
{"png", "image/png", "cover.png"},
{"gif", "image/gif", "cover.gif"},
{"webp", "image/webp", "cover.webp"},
{"default", "application/octet-stream", "cover.jpg"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/covers/123", r.URL.Path)
w.Header().Set("Content-Type", tt.contentType)
_, err := w.Write([]byte("image-data"))
require.NoError(t, err)
})
data, filename, err := client.DownloadCover("/api/covers/123")
require.NoError(t, err)
assert.Equal(t, []byte("image-data"), data)
assert.Equal(t, tt.expected, filename)
})
}
}
func TestEnsureAuthenticated_NoAuthRequired(t *testing.T) {
_, client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {})
err := client.ensureAuthenticated()
require.NoError(t, err)
assert.True(t, client.authenticated)
}
func TestEnsureAuthenticated_AuthRequired(t *testing.T) {
_, client := newTestClientWithAuth(t, func(w http.ResponseWriter, r *http.Request) {})
err := client.ensureAuthenticated()
require.NoError(t, err)
assert.True(t, client.authenticated)
}
func TestPing_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/auth/check", r.URL.Path)
require.NoError(t, json.NewEncoder(w).Encode(AuthCheckResponse{AuthRequired: false}))
}))
t.Cleanup(srv.Close)
client := NewClient(srv.URL, "", "")
err := client.Ping()
require.NoError(t, err)
}
func TestPing_ServerError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
t.Cleanup(srv.Close)
client := NewClient(srv.URL, "", "")
err := client.Ping()
require.Error(t, err)
assert.Contains(t, err.Error(), "500")
}
func TestPing_Unreachable(t *testing.T) {
client := NewClient("http://127.0.0.1:1", "", "")
err := client.Ping()
require.Error(t, err)
assert.Contains(t, err.Error(), "unreachable")
}
func TestTestConnection_SuccessWithoutAuth(t *testing.T) {
_, client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
})
err := client.TestConnection()
require.NoError(t, err)
}
func TestTestConnection_RequiresCredentials(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.NoError(t, json.NewEncoder(w).Encode(AuthCheckResponse{AuthRequired: true}))
}))
t.Cleanup(srv.Close)
client := NewClient(srv.URL, "", "")
err := client.TestConnection()
require.Error(t, err)
assert.Contains(t, err.Error(), "no credentials are configured")
}
func TestUpdateCredentials_ResetsState(t *testing.T) {
client := NewClient("http://old.example.com", "old", "pass")
client.authenticated = true
client.UpdateCredentials("http://new.example.com/", "new", "newpass")
assert.Equal(t, "http://new.example.com", client.baseURL)
assert.Equal(t, "new", client.username)
assert.Equal(t, "newpass", client.password)
assert.False(t, client.authenticated)
assert.NotNil(t, client.httpClient.Jar)
}