mattermost-plugin-shelfmark/server/shelfmark/client_test.go
Felipe M. a4ee9379eb
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
ci / plugin-ci (push) Has been cancelled
Validate channel existence and Shelfmark reachability on config change
Add early validation in OnConfigurationChange to verify the configured
channel exists in Mattermost and the Shelfmark server is reachable,
giving admins immediate feedback instead of discovering issues at runtime.

- Add Ping() method to shelfmark.Client for lightweight reachability check
- Add validateConfiguration() on Plugin that checks channel + Shelfmark
- Add i18n messages for channel not found and Shelfmark unreachable
- Add unit tests for Ping and validateConfiguration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 17:55:08 +01:00

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