Address 18 issues identified in codebase review: - Fix doRequest body consumed on 401 retry by accepting []byte instead of io.Reader - Fix race condition on httpClient.Timeout in DownloadFile using context.WithTimeout - Fix data race on baseURL read in doRequest by reading under mutex - Add sync.Mutex to taskStore for atomic index operations - Replace all silent `_ = SaveTask()` calls with proper error logging via failTask helper - Add notifyRequester calls to all processTaskComplete failure paths - Mark missing Shelfmark tasks as failed instead of optimistically promoting to complete - Remove goto in processTaskPending with structured control flow - Validate ShelfmarkHost URL scheme in IsValid() - Call IsValid() in OnConfigurationChange to warn on invalid config - Guard ExecuteCommand against empty command panic - Add EffectiveTitle() method on DownloadTask to deduplicate title fallback - Add shelfmarkCredentials() to deduplicate TrimSpace calls - Log ReleasesResponse.Errors when non-empty - Batch-delete terminal tasks in ListActiveTasks instead of during iteration - Log GetTask errors in ListActiveTasks instead of silently skipping - Properly handle resp.Body.Close() errors throughout shelfmark client - Add 93 unit tests across 8 new test files covering all packages Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
377 lines
12 KiB
Go
377 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/mattermost/mattermost/server/public/model"
|
|
"github.com/mattermost/mattermost/server/public/plugin/plugintest"
|
|
"github.com/mattermost/mattermost/server/public/pluginapi"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/mock"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"git.nakama.town/fmartingr/mattermost-plugin-shelfmark/server/shelfmark"
|
|
)
|
|
|
|
// writeJSON encodes v as JSON to w, failing the test on error.
|
|
func writeJSON(t *testing.T, w http.ResponseWriter, v any) {
|
|
t.Helper()
|
|
require.NoError(t, json.NewEncoder(w).Encode(v))
|
|
}
|
|
|
|
// writeBytes writes data to w, failing the test on error.
|
|
func writeBytes(t *testing.T, w http.ResponseWriter, data []byte) {
|
|
t.Helper()
|
|
_, err := w.Write(data)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// testPlugin creates a Plugin wired to mocks for testing job processing.
|
|
func testPlugin(t *testing.T, handler http.HandlerFunc) (*Plugin, *plugintest.API, *httptest.Server) {
|
|
t.Helper()
|
|
|
|
api := &plugintest.API{}
|
|
// Suppress unexpected log calls.
|
|
api.On("LogDebug", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return()
|
|
api.On("LogInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return()
|
|
api.On("LogWarn", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return()
|
|
api.On("LogError", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return()
|
|
|
|
// Build a Shelfmark test server with no-auth + user handler.
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/auth/check", func(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(t, w, shelfmark.AuthCheckResponse{AuthRequired: false})
|
|
})
|
|
mux.HandleFunc("/", handler)
|
|
srv := httptest.NewServer(mux)
|
|
t.Cleanup(srv.Close)
|
|
|
|
client := pluginapi.NewClient(api, nil)
|
|
store := newTaskStore(client, nil)
|
|
|
|
p := &Plugin{
|
|
botUserID: "bot123",
|
|
shelfmarkClient: shelfmark.NewClient(srv.URL, "", ""),
|
|
taskStore: store,
|
|
configurationLock: sync.RWMutex{},
|
|
configuration: &configuration{
|
|
ShelfmarkHost: "http://localhost",
|
|
ChannelID: "ch1",
|
|
},
|
|
}
|
|
p.API = api
|
|
|
|
return p, api, srv
|
|
}
|
|
|
|
func TestProcessTask_Expired(t *testing.T) {
|
|
p, api, _ := testPlugin(t, func(w http.ResponseWriter, r *http.Request) {})
|
|
|
|
// Mock save + DM notification.
|
|
api.On("KVSetWithOptions", mock.Anything, mock.Anything, mock.Anything).Return(true, nil)
|
|
api.On("KVGet", "dl_task_index").Return([]byte("[]"), (*model.AppError)(nil))
|
|
api.On("GetDirectChannel", "bot123", "user1").Return(&model.Channel{Id: "dm1"}, nil)
|
|
api.On("CreatePost", mock.Anything).Return(&model.Post{Id: "post1"}, nil)
|
|
|
|
task := &DownloadTask{
|
|
ID: "task1",
|
|
BookTitle: "Old Book",
|
|
Status: TaskStatusPending,
|
|
CreatedAt: time.Now().Add(-31 * time.Minute),
|
|
RequestedBy: "user1",
|
|
}
|
|
|
|
p.processTask(task)
|
|
|
|
assert.Equal(t, TaskStatusFailed, task.Status)
|
|
assert.Contains(t, task.ErrorMessage, "timed out")
|
|
}
|
|
|
|
func TestProcessTaskPending_NoReleases(t *testing.T) {
|
|
p, api, _ := testPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/api/releases" {
|
|
writeJSON(t, w, shelfmark.ReleasesResponse{Releases: []shelfmark.Release{}})
|
|
}
|
|
})
|
|
|
|
api.On("KVSetWithOptions", mock.Anything, mock.Anything, mock.Anything).Return(true, nil)
|
|
api.On("KVGet", "dl_task_index").Return([]byte("[]"), (*model.AppError)(nil))
|
|
api.On("GetDirectChannel", "bot123", "user1").Return(&model.Channel{Id: "dm1"}, nil)
|
|
api.On("CreatePost", mock.Anything).Return(&model.Post{Id: "post1"}, nil)
|
|
|
|
task := &DownloadTask{
|
|
ID: "task1",
|
|
BookTitle: "Missing Book",
|
|
Status: TaskStatusPending,
|
|
CreatedAt: time.Now(),
|
|
RequestedBy: "user1",
|
|
}
|
|
|
|
p.processTaskPending(task)
|
|
|
|
assert.Equal(t, TaskStatusFailed, task.Status)
|
|
assert.Contains(t, task.ErrorMessage, "No downloadable releases")
|
|
}
|
|
|
|
func TestProcessTaskPending_QueueSuccess(t *testing.T) {
|
|
p, api, _ := testPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/releases":
|
|
writeJSON(t, w, shelfmark.ReleasesResponse{
|
|
Releases: []shelfmark.Release{{Source: "src", SourceID: "rel1", Title: "Test"}},
|
|
})
|
|
case "/api/status":
|
|
writeBytes(t, w, []byte(`{}`))
|
|
case "/api/releases/download":
|
|
writeJSON(t, w, shelfmark.QueueResponse{Status: "queued"})
|
|
}
|
|
})
|
|
|
|
api.On("KVSetWithOptions", mock.Anything, mock.Anything, mock.Anything).Return(true, nil)
|
|
api.On("KVGet", "dl_task_index").Return([]byte("[]"), (*model.AppError)(nil))
|
|
|
|
task := &DownloadTask{
|
|
ID: "task1",
|
|
BookTitle: "Good Book",
|
|
Status: TaskStatusPending,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
p.processTaskPending(task)
|
|
|
|
assert.Equal(t, TaskStatusQueued, task.Status)
|
|
assert.Equal(t, "rel1", task.ShelfmarkTaskID)
|
|
}
|
|
|
|
func TestProcessTaskPending_AlreadyComplete(t *testing.T) {
|
|
p, api, _ := testPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/releases":
|
|
writeJSON(t, w, shelfmark.ReleasesResponse{
|
|
Releases: []shelfmark.Release{{Source: "src", SourceID: "rel1", Title: "Test"}},
|
|
})
|
|
case "/api/status":
|
|
writeBytes(t, w, []byte(`{"complete":{"rel1":{}}}`))
|
|
}
|
|
})
|
|
|
|
api.On("KVSetWithOptions", mock.Anything, mock.Anything, mock.Anything).Return(true, nil)
|
|
api.On("KVGet", "dl_task_index").Return([]byte("[]"), (*model.AppError)(nil))
|
|
|
|
task := &DownloadTask{
|
|
ID: "task1",
|
|
BookTitle: "Done Book",
|
|
Status: TaskStatusPending,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
p.processTaskPending(task)
|
|
|
|
assert.Equal(t, TaskStatusComplete, task.Status)
|
|
assert.Equal(t, "rel1", task.ShelfmarkTaskID)
|
|
}
|
|
|
|
func TestProcessTaskQueued_Complete(t *testing.T) {
|
|
p, api, _ := testPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
|
writeBytes(t, w, []byte(`{"complete":{"shelfmark1":{}}}`))
|
|
})
|
|
|
|
api.On("KVSetWithOptions", mock.Anything, mock.Anything, mock.Anything).Return(true, nil)
|
|
api.On("KVGet", "dl_task_index").Return([]byte("[]"), (*model.AppError)(nil))
|
|
|
|
task := &DownloadTask{
|
|
ID: "task1",
|
|
ShelfmarkTaskID: "shelfmark1",
|
|
Status: TaskStatusQueued,
|
|
}
|
|
|
|
p.processTaskQueued(task)
|
|
|
|
assert.Equal(t, TaskStatusComplete, task.Status)
|
|
}
|
|
|
|
func TestProcessTaskQueued_Error(t *testing.T) {
|
|
p, api, _ := testPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
|
writeBytes(t, w, []byte(`{"error":{"shelfmark1":{}}}`))
|
|
})
|
|
|
|
api.On("KVSetWithOptions", mock.Anything, mock.Anything, mock.Anything).Return(true, nil)
|
|
api.On("KVGet", "dl_task_index").Return([]byte("[]"), (*model.AppError)(nil))
|
|
api.On("GetDirectChannel", "bot123", "user1").Return(&model.Channel{Id: "dm1"}, nil)
|
|
api.On("CreatePost", mock.Anything).Return(&model.Post{Id: "post1"}, nil)
|
|
|
|
task := &DownloadTask{
|
|
ID: "task1",
|
|
ShelfmarkTaskID: "shelfmark1",
|
|
BookTitle: "Error Book",
|
|
Status: TaskStatusQueued,
|
|
RequestedBy: "user1",
|
|
}
|
|
|
|
p.processTaskQueued(task)
|
|
|
|
assert.Equal(t, TaskStatusFailed, task.Status)
|
|
assert.Contains(t, task.ErrorMessage, "error")
|
|
}
|
|
|
|
func TestProcessTaskQueued_NotFound_Timeout(t *testing.T) {
|
|
p, api, _ := testPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
|
writeBytes(t, w, []byte(`{}`)) // Empty status — task not found.
|
|
})
|
|
|
|
api.On("KVSetWithOptions", mock.Anything, mock.Anything, mock.Anything).Return(true, nil)
|
|
api.On("KVGet", "dl_task_index").Return([]byte("[]"), (*model.AppError)(nil))
|
|
api.On("GetDirectChannel", "bot123", "user1").Return(&model.Channel{Id: "dm1"}, nil)
|
|
api.On("CreatePost", mock.Anything).Return(&model.Post{Id: "post1"}, nil)
|
|
|
|
task := &DownloadTask{
|
|
ID: "task1",
|
|
ShelfmarkTaskID: "missing",
|
|
BookTitle: "Lost Book",
|
|
Status: TaskStatusQueued,
|
|
UpdatedAt: time.Now().Add(-6 * time.Minute),
|
|
RequestedBy: "user1",
|
|
}
|
|
|
|
p.processTaskQueued(task)
|
|
|
|
// Should be marked as failed (not promoted to complete).
|
|
assert.Equal(t, TaskStatusFailed, task.Status)
|
|
assert.Contains(t, task.ErrorMessage, "disappeared")
|
|
}
|
|
|
|
func TestProcessTaskQueued_NotFound_Recent(t *testing.T) {
|
|
p, _, _ := testPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
|
writeBytes(t, w, []byte(`{}`))
|
|
})
|
|
|
|
task := &DownloadTask{
|
|
ID: "task1",
|
|
ShelfmarkTaskID: "missing",
|
|
Status: TaskStatusQueued,
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
p.processTaskQueued(task)
|
|
|
|
// Should remain queued (too recent to timeout).
|
|
assert.Equal(t, TaskStatusQueued, task.Status)
|
|
}
|
|
|
|
func TestFailTask(t *testing.T) {
|
|
p, api, _ := testPlugin(t, func(w http.ResponseWriter, r *http.Request) {})
|
|
|
|
api.On("KVSetWithOptions", mock.Anything, mock.Anything, mock.Anything).Return(true, nil)
|
|
api.On("KVGet", "dl_task_index").Return([]byte("[]"), (*model.AppError)(nil))
|
|
api.On("GetDirectChannel", "bot123", "user1").Return(&model.Channel{Id: "dm1"}, nil)
|
|
api.On("CreatePost", mock.Anything).Return(&model.Post{Id: "p1"}, nil)
|
|
|
|
task := &DownloadTask{
|
|
ID: "task1",
|
|
BookTitle: "Fail Book",
|
|
Status: TaskStatusPending,
|
|
RequestedBy: "user1",
|
|
}
|
|
|
|
p.failTask(task, "internal error", "Something went wrong.")
|
|
|
|
assert.Equal(t, TaskStatusFailed, task.Status)
|
|
assert.Equal(t, "internal error", task.ErrorMessage)
|
|
}
|
|
|
|
func TestProcessTaskComplete_HappyPath(t *testing.T) {
|
|
p, api, _ := testPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/api/localdownload" {
|
|
w.Header().Set("Content-Disposition", `attachment; filename="book.epub"`)
|
|
writeBytes(t, w, []byte("book-data"))
|
|
}
|
|
})
|
|
|
|
api.On("KVSetWithOptions", mock.Anything, mock.Anything, mock.Anything).Return(true, nil)
|
|
api.On("KVGet", "dl_task_index").Return([]byte("[]"), (*model.AppError)(nil))
|
|
api.On("CreatePost", mock.Anything).Return(&model.Post{Id: "post1"}, nil)
|
|
api.On("UploadFile", mock.Anything, "ch1", "book.epub").Return(&model.FileInfo{Id: "file1"}, nil)
|
|
|
|
task := &DownloadTask{
|
|
ID: "task1",
|
|
ShelfmarkTaskID: "sm1",
|
|
BookTitle: "Happy Book",
|
|
ChannelID: "ch1",
|
|
Status: TaskStatusComplete,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
p.processTaskComplete(task)
|
|
|
|
assert.Equal(t, TaskStatusUploaded, task.Status)
|
|
assert.Equal(t, "post1", task.PostID)
|
|
}
|
|
|
|
func TestProcessTaskComplete_PostFailure_NotifiesRequester(t *testing.T) {
|
|
p, api, _ := testPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/api/localdownload" {
|
|
writeBytes(t, w, []byte("data"))
|
|
}
|
|
})
|
|
|
|
api.On("KVSetWithOptions", mock.Anything, mock.Anything, mock.Anything).Return(true, nil)
|
|
api.On("KVGet", "dl_task_index").Return([]byte("[]"), (*model.AppError)(nil))
|
|
// CreatePost fails.
|
|
api.On("CreatePost", mock.Anything).Return(nil, model.NewAppError("", "", nil, "post error", 500))
|
|
api.On("GetDirectChannel", "bot123", "user1").Return(&model.Channel{Id: "dm1"}, nil)
|
|
|
|
task := &DownloadTask{
|
|
ID: "task1",
|
|
ShelfmarkTaskID: "sm1",
|
|
BookTitle: "Fail Post Book",
|
|
ChannelID: "ch1",
|
|
Status: TaskStatusComplete,
|
|
CreatedAt: time.Now(),
|
|
RequestedBy: "user1",
|
|
}
|
|
|
|
p.processTaskComplete(task)
|
|
|
|
assert.Equal(t, TaskStatusFailed, task.Status)
|
|
assert.Contains(t, task.ErrorMessage, "Failed to create post")
|
|
// Verify notification was attempted (GetDirectChannel was called).
|
|
api.AssertCalled(t, "GetDirectChannel", "bot123", "user1")
|
|
}
|
|
|
|
func TestProcessTaskPending_LogsReleasesErrors(t *testing.T) {
|
|
p, api, _ := testPlugin(t, func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/releases":
|
|
writeJSON(t, w, shelfmark.ReleasesResponse{
|
|
Releases: []shelfmark.Release{{Source: "src", SourceID: "rel1", Title: "Test"}},
|
|
Errors: []string{"source1 failed"},
|
|
})
|
|
case "/api/status":
|
|
writeBytes(t, w, []byte(`{}`))
|
|
case "/api/releases/download":
|
|
writeJSON(t, w, shelfmark.QueueResponse{Status: "queued"})
|
|
}
|
|
})
|
|
|
|
api.On("KVSetWithOptions", mock.Anything, mock.Anything, mock.Anything).Return(true, nil)
|
|
api.On("KVGet", "dl_task_index").Return([]byte("[]"), (*model.AppError)(nil))
|
|
|
|
task := &DownloadTask{
|
|
ID: "task1",
|
|
BookTitle: "Partial Error Book",
|
|
Status: TaskStatusPending,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
p.processTaskPending(task)
|
|
|
|
// Should still succeed (releases found) but log the partial error.
|
|
require.Equal(t, TaskStatusQueued, task.Status)
|
|
api.AssertCalled(t, "LogWarn", "Shelfmark reported partial errors for releases",
|
|
"task_id", "task1", "errors", "source1 failed")
|
|
}
|