mattermost-plugin-shelfmark/server/kvstore_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

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