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>
186 lines
5.9 KiB
Go
186 lines
5.9 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"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"
|
|
)
|
|
|
|
func newTestTaskStore(t *testing.T) (*taskStore, *plugintest.API) {
|
|
t.Helper()
|
|
api := &plugintest.API{}
|
|
client := pluginapi.NewClient(api, nil)
|
|
store := newTaskStore(client, nil)
|
|
return store, api
|
|
}
|
|
|
|
func TestSaveTask_New(t *testing.T) {
|
|
store, api := newTestTaskStore(t)
|
|
|
|
task := &DownloadTask{
|
|
ID: "task1",
|
|
BookTitle: "Test Book",
|
|
Status: TaskStatusPending,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
// pluginapi.KV.Set -> KVSetWithOptions
|
|
api.On("KVSetWithOptions", mock.AnythingOfType("string"), mock.AnythingOfType("[]uint8"), mock.Anything).Return(true, nil)
|
|
// pluginapi.KV.Get -> KVGet (returns []byte, *AppError)
|
|
api.On("KVGet", "dl_task_index").Return([]byte(nil), (*model.AppError)(nil))
|
|
|
|
err := store.SaveTask(task)
|
|
require.NoError(t, err)
|
|
assert.False(t, task.UpdatedAt.IsZero())
|
|
}
|
|
|
|
func TestSaveTask_Existing(t *testing.T) {
|
|
store, api := newTestTaskStore(t)
|
|
|
|
task := &DownloadTask{
|
|
ID: "task1",
|
|
BookTitle: "Test Book",
|
|
Status: TaskStatusQueued,
|
|
}
|
|
|
|
api.On("KVSetWithOptions", "dl_task_task1", mock.AnythingOfType("[]uint8"), mock.Anything).Return(true, nil)
|
|
indexData, _ := json.Marshal([]string{"task1"})
|
|
api.On("KVGet", "dl_task_index").Return(indexData, (*model.AppError)(nil))
|
|
|
|
err := store.SaveTask(task)
|
|
require.NoError(t, err)
|
|
// Index should NOT be re-saved since task1 already in index.
|
|
api.AssertNotCalled(t, "KVSetWithOptions", "dl_task_index", mock.Anything, mock.Anything)
|
|
}
|
|
|
|
func TestGetTask_Exists(t *testing.T) {
|
|
store, api := newTestTaskStore(t)
|
|
|
|
task := &DownloadTask{
|
|
ID: "task1",
|
|
BookTitle: "Test Book",
|
|
Status: TaskStatusPending,
|
|
}
|
|
taskData, _ := json.Marshal(task)
|
|
|
|
api.On("KVGet", "dl_task_task1").Return(taskData, (*model.AppError)(nil))
|
|
|
|
result, err := store.GetTask("task1")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
assert.Equal(t, "task1", result.ID)
|
|
assert.Equal(t, "Test Book", result.BookTitle)
|
|
}
|
|
|
|
func TestGetTask_NotFound(t *testing.T) {
|
|
store, api := newTestTaskStore(t)
|
|
|
|
api.On("KVGet", "dl_task_nonexistent").Return([]byte(nil), (*model.AppError)(nil))
|
|
|
|
result, err := store.GetTask("nonexistent")
|
|
require.NoError(t, err)
|
|
assert.Nil(t, result)
|
|
}
|
|
|
|
func TestDeleteTask(t *testing.T) {
|
|
store, api := newTestTaskStore(t)
|
|
|
|
// pluginapi.KV.Delete calls KV.Set(key, nil) -> KVSetWithOptions
|
|
api.On("KVSetWithOptions", mock.AnythingOfType("string"), mock.Anything, mock.Anything).Return(true, nil)
|
|
indexData, _ := json.Marshal([]string{"task1", "task2"})
|
|
api.On("KVGet", "dl_task_index").Return(indexData, (*model.AppError)(nil))
|
|
|
|
err := store.DeleteTask("task1")
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func TestListActiveTasks_MixedStatuses(t *testing.T) {
|
|
store, api := newTestTaskStore(t)
|
|
|
|
taskMap := map[string]*DownloadTask{
|
|
"pending": {ID: "pending", Status: TaskStatusPending},
|
|
"queued": {ID: "queued", Status: TaskStatusQueued},
|
|
"downloading": {ID: "downloading", Status: TaskStatusDownloading},
|
|
"complete": {ID: "complete", Status: TaskStatusComplete},
|
|
"uploaded": {ID: "uploaded", Status: TaskStatusUploaded},
|
|
"failed": {ID: "failed", Status: TaskStatusFailed},
|
|
}
|
|
|
|
indexData, _ := json.Marshal([]string{"pending", "queued", "downloading", "complete", "uploaded", "failed"})
|
|
api.On("KVGet", "dl_task_index").Return(indexData, (*model.AppError)(nil))
|
|
|
|
for id, task := range taskMap {
|
|
taskData, _ := json.Marshal(task)
|
|
api.On("KVGet", "dl_task_"+id).Return(taskData, (*model.AppError)(nil))
|
|
}
|
|
|
|
// For terminal tasks cleanup.
|
|
// pluginapi.KV.Delete calls KV.Set(key,nil) -> KVSetWithOptions
|
|
api.On("KVSetWithOptions", mock.AnythingOfType("string"), mock.Anything, mock.Anything).Return(true, nil)
|
|
|
|
result, err := store.ListActiveTasks()
|
|
require.NoError(t, err)
|
|
|
|
assert.Len(t, result, 4)
|
|
ids := make([]string, len(result))
|
|
for i, task := range result {
|
|
ids[i] = task.ID
|
|
}
|
|
assert.Contains(t, ids, "pending")
|
|
assert.Contains(t, ids, "queued")
|
|
assert.Contains(t, ids, "downloading")
|
|
assert.Contains(t, ids, "complete")
|
|
}
|
|
|
|
func TestListActiveTasks_OrphanedIndex(t *testing.T) {
|
|
store, api := newTestTaskStore(t)
|
|
|
|
indexData, _ := json.Marshal([]string{"orphan"})
|
|
api.On("KVGet", "dl_task_index").Return(indexData, (*model.AppError)(nil))
|
|
api.On("KVGet", "dl_task_orphan").Return([]byte(nil), (*model.AppError)(nil))
|
|
// pluginapi.KV.Delete calls KV.Set(key,nil) -> KVSetWithOptions
|
|
api.On("KVSetWithOptions", mock.AnythingOfType("string"), mock.Anything, mock.Anything).Return(true, nil)
|
|
|
|
result, err := store.ListActiveTasks()
|
|
require.NoError(t, err)
|
|
assert.Empty(t, result)
|
|
}
|
|
|
|
func TestListActiveTasks_LogsGetTaskError(t *testing.T) {
|
|
api := &plugintest.API{}
|
|
client := pluginapi.NewClient(api, nil)
|
|
|
|
var logged bool
|
|
store := newTaskStore(client, func(msg string, keyvals ...string) {
|
|
logged = true
|
|
assert.Equal(t, "Failed to load task from KV store", msg)
|
|
})
|
|
|
|
indexData, _ := json.Marshal([]string{"broken"})
|
|
api.On("KVGet", "dl_task_index").Return(indexData, (*model.AppError)(nil))
|
|
api.On("KVGet", "dl_task_broken").Return([]byte(nil), model.NewAppError("", "", nil, "kv error", 500))
|
|
|
|
result, err := store.ListActiveTasks()
|
|
require.NoError(t, err)
|
|
assert.Empty(t, result)
|
|
assert.True(t, logged, "expected logFunc to be called for GetTask error")
|
|
}
|
|
|
|
func TestEffectiveTitle(t *testing.T) {
|
|
t.Run("localized title present", func(t *testing.T) {
|
|
task := &DownloadTask{BookTitle: "Original", LocalizedTitle: "Localized"}
|
|
assert.Equal(t, "Localized", task.EffectiveTitle())
|
|
})
|
|
|
|
t.Run("localized title empty", func(t *testing.T) {
|
|
task := &DownloadTask{BookTitle: "Original", LocalizedTitle: ""}
|
|
assert.Equal(t, "Original", task.EffectiveTitle())
|
|
})
|
|
}
|