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) }