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