mattermost-plugin-shelfmark/server/job_test.go
Felipe M. 98f5c6d446
Fix concurrency bugs, error handling, and add comprehensive unit tests
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>
2026-03-17 17:17:38 +01:00

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