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>
365 lines
11 KiB
Go
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)
|
|
}
|