feat: support webhook (#1187)

This commit is contained in:
Copilot authored and GitHub committed 2025-11-25 23:34:45 +08:00
1 parent 5bb8541385
commit 9d9f1caaaa
15 files changed
+1361 -33

No files matched your search

+2
View File
@@ -857,6 +857,7 @@ func (d *Downloader) watch(task *Task) {
d.emit(EventKeyFinally, task, err)
d.notifyRunning()
d.triggerOnDone(task)
d.triggerWebhooks(WebhookEventDownloadDone, task, nil)
if e, ok := task.Meta.Opts.Extra.(*http.OptsExtra); ok {
downloadFilePath := task.Meta.SingleFilepath()
@@ -887,6 +888,7 @@ func (d *Downloader) doOnError(task *Task, err error) {
d.emit(EventKeyError, task, err)
d.emit(EventKeyFinally, task, err)
d.notifyRunning()
d.triggerWebhooks(WebhookEventDownloadError, task, err)
}
}
+210
View File
@@ -0,0 +1,210 @@
package download
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/pkg/base"
)
const (
webhookTimeout = 10 * time.Second
)
// WebhookEvent represents the type of webhook event
type WebhookEvent string
const (
WebhookEventDownloadDone WebhookEvent = "DOWNLOAD_DONE"
WebhookEventDownloadError WebhookEvent = "DOWNLOAD_ERROR"
)
// WebhookData is the data sent to webhook URLs
type WebhookData struct {
Event WebhookEvent `json:"event"`
Time int64 `json:"time"` // Unix timestamp in milliseconds
Payload *WebhookPayload `json:"payload"`
}
// WebhookPayload contains the task data
type WebhookPayload struct {
Task *Task `json:"task"`
}
// getWebhookUrls extracts and converts webhook URLs from config
func (d *Downloader) getWebhookUrls() []string {
cfg := d.cfg.DownloaderStoreConfig
if cfg == nil || cfg.Extra == nil {
return nil
}
webhookUrls, ok := cfg.Extra["webhookUrls"]
if !ok {
return nil
}
// Try direct string slice first
if urlsStr, ok := webhookUrls.([]string); ok {
if len(urlsStr) == 0 {
return nil
}
return urlsStr
}
// Convert []interface{} to []string
if urlsInterface, ok := webhookUrls.([]any); ok {
if len(urlsInterface) == 0 {
return nil
}
urls := make([]string, 0, len(urlsInterface))
for _, urlInterface := range urlsInterface {
if url, ok := urlInterface.(string); ok && url != "" {
urls = append(urls, url)
}
}
if len(urls) == 0 {
return nil
}
return urls
}
return nil
}
// sendWebhookToUrl sends webhook data to a single URL
// Returns the HTTP status code and any error that occurred
func (d *Downloader) sendWebhookToUrl(url string, data *WebhookData) (int, error) {
if url == "" {
return 0, fmt.Errorf("webhook URL is empty")
}
jsonData, err := json.Marshal(data)
if err != nil {
return 0, err
}
client := &http.Client{
Timeout: webhookTimeout,
}
req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonData))
if err != nil {
return 0, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Gopeed-Webhook/1.0")
resp, err := client.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
return resp.StatusCode, nil
}
// triggerWebhooks sends webhook notifications to all configured URLs
func (d *Downloader) triggerWebhooks(event WebhookEvent, task *Task, err error) {
urls := d.getWebhookUrls()
if len(urls) == 0 {
return
}
data := &WebhookData{
Event: event,
Time: time.Now().UnixMilli(),
Payload: &WebhookPayload{
Task: task.clone(),
},
}
go d.sendWebhooks(urls, data)
}
func (d *Downloader) sendWebhooks(urls []string, data *WebhookData) {
for _, url := range urls {
if url == "" {
continue
}
go func(webhookUrl string) {
statusCode, err := d.sendWebhookToUrl(webhookUrl, data)
if err != nil {
d.Logger.Warn().Err(err).Str("url", webhookUrl).Msg("webhook: failed to send request")
return
}
if statusCode >= 200 && statusCode < 300 {
d.Logger.Debug().Str("url", webhookUrl).Int("status", statusCode).Msg("webhook: sent successfully")
} else {
d.Logger.Warn().Str("url", webhookUrl).Int("status", statusCode).Msg("webhook: received non-success status")
}
}(url)
}
}
// SendTestWebhook sends a test webhook with a simulated payload
// Returns error if any webhook URL does not respond with HTTP 200
func (d *Downloader) SendTestWebhook() error {
urls := d.getWebhookUrls()
if len(urls) == 0 {
return nil
}
for _, url := range urls {
if url == "" {
continue
}
if err := d.TestWebhookUrl(url); err != nil {
return err
}
}
return nil
}
// TestWebhookUrl tests a single webhook URL with a simulated payload
// Returns error if the URL does not respond with HTTP 200
func (d *Downloader) TestWebhookUrl(url string) error {
// Create a simulated test task with minimal required fields
testTask := NewTask()
testTask.Protocol = "http"
testTask.Status = base.DownloadStatusDone
testTask.Meta = &fetcher.FetcherMeta{
Req: &base.Request{
URL: "https://example.com/test-file.zip",
},
Opts: &base.Options{
Name: "test-file.zip",
Path: "/downloads",
},
Res: &base.Resource{
Size: 1024 * 1024 * 100, // 100MB
Files: []*base.FileInfo{
{Name: "test-file.zip", Size: 1024 * 1024 * 100},
},
},
}
// Create test data
testData := &WebhookData{
Event: WebhookEventDownloadDone,
Time: time.Now().UnixMilli(),
Payload: &WebhookPayload{
Task: testTask,
},
}
statusCode, err := d.sendWebhookToUrl(url, testData)
if err != nil {
return err
}
if statusCode != http.StatusOK {
return fmt.Errorf("webhook test failed: %s returned status %d", url, statusCode)
}
return nil
}
+799
View File
@@ -0,0 +1,799 @@
package download
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/pkg/base"
)
var mockFetcherMeta = fetcher.FetcherMeta{
Req: &base.Request{
URL: "https://example.com/test.zip",
},
Opts: &base.Options{
Path: "/downloads",
},
Res: &base.Resource{
Size: 1024 * 1024,
Files: []*base.FileInfo{
{Name: "test.zip", Size: 1024 * 1024},
},
},
}
func TestWebhook_TriggerOnDone(t *testing.T) {
receivedData := make(chan *WebhookData, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("Expected POST request, got %s", r.Method)
}
if r.Header.Get("Content-Type") != "application/json" {
t.Errorf("Expected Content-Type application/json, got %s", r.Header.Get("Content-Type"))
}
var data WebhookData
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
t.Errorf("Failed to decode data: %v", err)
return
}
receivedData <- &data
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
setupWebhookTest(t, func(downloader *Downloader) {
// Configure webhook URLs
cfg, _ := downloader.GetConfig()
if cfg.Extra == nil {
cfg.Extra = make(map[string]any)
}
cfg.Extra["webhookUrls"] = []string{server.URL}
downloader.PutConfig(cfg)
// Create a mock task
task := NewTask()
task.Protocol = "http"
task.Meta = &mockFetcherMeta
// Trigger webhook
downloader.triggerWebhooks(WebhookEventDownloadDone, task, nil)
select {
case data := <-receivedData:
if data.Event != WebhookEventDownloadDone {
t.Errorf("Expected event 'DOWNLOAD_DONE', got '%s'", data.Event)
}
if data.Payload == nil || data.Payload.Task == nil {
t.Error("Expected payload.task to be present")
} else if data.Payload.Task.ID != task.ID {
t.Errorf("Expected task ID '%s', got '%s'", task.ID, data.Payload.Task.ID)
}
if data.Time == 0 {
t.Error("Expected time to be set")
}
case <-time.After(2 * time.Second):
t.Error("Timeout waiting for webhook")
}
})
}
func TestWebhook_TriggerOnError(t *testing.T) {
receivedData := make(chan *WebhookData, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var data WebhookData
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
t.Errorf("Failed to decode data: %v", err)
return
}
receivedData <- &data
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
setupWebhookTest(t, func(downloader *Downloader) {
// Configure webhook URLs
cfg, _ := downloader.GetConfig()
if cfg.Extra == nil {
cfg.Extra = make(map[string]any)
}
cfg.Extra["webhookUrls"] = []string{server.URL}
downloader.PutConfig(cfg)
// Create a mock task
task := NewTask()
task.Protocol = "http"
task.Meta = &mockFetcherMeta
// Trigger webhook with error
testError := http.ErrServerClosed
downloader.triggerWebhooks(WebhookEventDownloadError, task, testError)
select {
case data := <-receivedData:
if data.Event != WebhookEventDownloadError {
t.Errorf("Expected event 'DOWNLOAD_ERROR', got '%s'", data.Event)
}
if data.Payload == nil || data.Payload.Task == nil {
t.Error("Expected payload.task to be present")
}
case <-time.After(2 * time.Second):
t.Error("Timeout waiting for webhook")
}
})
}
func TestWebhook_SendTestWebhook(t *testing.T) {
receivedData := make(chan *WebhookData, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var data WebhookData
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
t.Errorf("Failed to decode data: %v", err)
return
}
receivedData <- &data
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
setupWebhookTest(t, func(downloader *Downloader) {
// Configure webhook URLs
cfg, _ := downloader.GetConfig()
if cfg.Extra == nil {
cfg.Extra = make(map[string]any)
}
cfg.Extra["webhookUrls"] = []string{server.URL}
downloader.PutConfig(cfg)
// Send test webhook
err := downloader.SendTestWebhook()
if err != nil {
t.Errorf("SendTestWebhook failed: %v", err)
}
select {
case data := <-receivedData:
if data.Event != WebhookEventDownloadDone {
t.Errorf("Expected event 'DOWNLOAD_DONE', got '%s'", data.Event)
}
if data.Payload == nil || data.Payload.Task == nil {
t.Error("Expected payload.task to be present")
}
if data.Time == 0 {
t.Error("Expected time to be set")
}
case <-time.After(2 * time.Second):
t.Error("Timeout waiting for webhook")
}
})
}
func TestWebhook_NoWebhookConfigured(t *testing.T) {
setupWebhookTest(t, func(downloader *Downloader) {
// Create a mock task
task := NewTask()
task.Protocol = "http"
task.Meta = &mockFetcherMeta
// Trigger webhook (should not panic with no webhooks configured)
downloader.triggerWebhooks(WebhookEventDownloadDone, task, nil)
// Send test webhook (should not panic)
err := downloader.SendTestWebhook()
if err != nil {
t.Errorf("SendTestWebhook failed: %v", err)
}
})
}
func TestWebhook_MultipleUrls(t *testing.T) {
count := 0
server1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
count++
w.WriteHeader(http.StatusOK)
}))
defer server1.Close()
server2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
count++
w.WriteHeader(http.StatusOK)
}))
defer server2.Close()
setupWebhookTest(t, func(downloader *Downloader) {
// Configure multiple webhook URLs
cfg, _ := downloader.GetConfig()
if cfg.Extra == nil {
cfg.Extra = make(map[string]any)
}
cfg.Extra["webhookUrls"] = []string{server1.URL, server2.URL}
downloader.PutConfig(cfg)
// Create a mock task
task := NewTask()
task.Protocol = "http"
task.Meta = &mockFetcherMeta
// Trigger webhook
downloader.triggerWebhooks(WebhookEventDownloadDone, task, nil)
// Wait for webhooks
time.Sleep(500 * time.Millisecond)
if count != 2 {
t.Errorf("Expected 2 webhook calls, got %d", count)
}
})
}
func TestWebhook_TestWebhookFailsOnNon200(t *testing.T) {
// Test that SendTestWebhook returns error for non-200 status codes
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError) // 500
}))
defer server.Close()
setupWebhookTest(t, func(downloader *Downloader) {
// Configure webhook URLs
cfg, _ := downloader.GetConfig()
if cfg.Extra == nil {
cfg.Extra = make(map[string]any)
}
cfg.Extra["webhookUrls"] = []string{server.URL}
downloader.PutConfig(cfg)
// Send test webhook - should fail with non-200 status
err := downloader.SendTestWebhook()
if err == nil {
t.Error("Expected SendTestWebhook to return error for non-200 status")
}
})
}
func TestWebhook_TestWebhookFailsOn201(t *testing.T) {
// Test that SendTestWebhook returns error for 201 (only 200 is success)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated) // 201
}))
defer server.Close()
setupWebhookTest(t, func(downloader *Downloader) {
// Configure webhook URLs
cfg, _ := downloader.GetConfig()
if cfg.Extra == nil {
cfg.Extra = make(map[string]any)
}
cfg.Extra["webhookUrls"] = []string{server.URL}
downloader.PutConfig(cfg)
// Send test webhook - should fail with 201 status (only 200 is success)
err := downloader.SendTestWebhook()
if err == nil {
t.Error("Expected SendTestWebhook to return error for 201 status")
}
})
}
func TestWebhook_TestWebhookUrl(t *testing.T) {
receivedData := make(chan *WebhookData, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var data WebhookData
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
t.Errorf("Failed to decode data: %v", err)
return
}
receivedData <- &data
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
setupWebhookTest(t, func(downloader *Downloader) {
// Test single URL
err := downloader.TestWebhookUrl(server.URL)
if err != nil {
t.Errorf("TestWebhookUrl failed: %v", err)
}
select {
case data := <-receivedData:
if data.Event != WebhookEventDownloadDone {
t.Errorf("Expected event 'DOWNLOAD_DONE', got '%s'", data.Event)
}
if data.Payload == nil || data.Payload.Task == nil {
t.Error("Expected payload.task to be present")
}
case <-time.After(2 * time.Second):
t.Error("Timeout waiting for webhook")
}
})
}
func TestWebhook_TestWebhookUrlEmpty(t *testing.T) {
setupWebhookTest(t, func(downloader *Downloader) {
// Test with empty URL - should return error
err := downloader.TestWebhookUrl("")
if err == nil {
t.Error("Expected TestWebhookUrl to return error for empty URL")
}
})
}
func TestWebhook_GetWebhookUrls_EmptyConfig(t *testing.T) {
setupWebhookTest(t, func(downloader *Downloader) {
urls := downloader.getWebhookUrls()
if urls != nil {
t.Errorf("Expected nil, got %v", urls)
}
})
}
func TestWebhook_GetWebhookUrls_NoExtraField(t *testing.T) {
setupWebhookTest(t, func(downloader *Downloader) {
cfg, _ := downloader.GetConfig()
cfg.Extra = nil
downloader.PutConfig(cfg)
urls := downloader.getWebhookUrls()
if urls != nil {
t.Errorf("Expected nil, got %v", urls)
}
})
}
func TestWebhook_GetWebhookUrls_NoWebhookUrlsKey(t *testing.T) {
setupWebhookTest(t, func(downloader *Downloader) {
cfg, _ := downloader.GetConfig()
cfg.Extra = make(map[string]any)
cfg.Extra["otherKey"] = "value"
downloader.PutConfig(cfg)
urls := downloader.getWebhookUrls()
if urls != nil {
t.Errorf("Expected nil, got %v", urls)
}
})
}
func TestWebhook_GetWebhookUrls_StringSlice(t *testing.T) {
setupWebhookTest(t, func(downloader *Downloader) {
cfg, _ := downloader.GetConfig()
cfg.Extra = make(map[string]any)
cfg.Extra["webhookUrls"] = []string{"http://example.com", "http://example2.com"}
downloader.PutConfig(cfg)
urls := downloader.getWebhookUrls()
if len(urls) != 2 {
t.Errorf("Expected 2 URLs, got %d", len(urls))
}
if urls[0] != "http://example.com" || urls[1] != "http://example2.com" {
t.Errorf("URLs don't match expected values: %v", urls)
}
})
}
func TestWebhook_GetWebhookUrls_InterfaceSlice(t *testing.T) {
setupWebhookTest(t, func(downloader *Downloader) {
cfg, _ := downloader.GetConfig()
cfg.Extra = make(map[string]any)
cfg.Extra["webhookUrls"] = []interface{}{"http://example.com", "http://example2.com"}
downloader.PutConfig(cfg)
urls := downloader.getWebhookUrls()
if len(urls) != 2 {
t.Errorf("Expected 2 URLs, got %d", len(urls))
}
if urls[0] != "http://example.com" || urls[1] != "http://example2.com" {
t.Errorf("URLs don't match expected values: %v", urls)
}
})
}
func TestWebhook_GetWebhookUrls_EmptyStringSlice(t *testing.T) {
setupWebhookTest(t, func(downloader *Downloader) {
cfg, _ := downloader.GetConfig()
cfg.Extra = make(map[string]any)
cfg.Extra["webhookUrls"] = []string{}
downloader.PutConfig(cfg)
urls := downloader.getWebhookUrls()
if urls != nil {
t.Errorf("Expected nil, got %v", urls)
}
})
}
func TestWebhook_GetWebhookUrls_InterfaceSliceWithEmptyStrings(t *testing.T) {
setupWebhookTest(t, func(downloader *Downloader) {
cfg, _ := downloader.GetConfig()
cfg.Extra = make(map[string]any)
cfg.Extra["webhookUrls"] = []interface{}{"", "", ""}
downloader.PutConfig(cfg)
urls := downloader.getWebhookUrls()
if urls != nil {
t.Errorf("Expected nil for all empty strings, got %v", urls)
}
})
}
func TestWebhook_GetWebhookUrls_InterfaceSliceMixedTypes(t *testing.T) {
setupWebhookTest(t, func(downloader *Downloader) {
cfg, _ := downloader.GetConfig()
cfg.Extra = make(map[string]any)
cfg.Extra["webhookUrls"] = []interface{}{"http://example.com", 123, "http://example2.com", nil, ""}
downloader.PutConfig(cfg)
urls := downloader.getWebhookUrls()
if len(urls) != 2 {
t.Errorf("Expected 2 valid URLs (ignoring non-strings and empty), got %d: %v", len(urls), urls)
}
if urls[0] != "http://example.com" || urls[1] != "http://example2.com" {
t.Errorf("URLs don't match expected values: %v", urls)
}
})
}
func TestWebhook_GetWebhookUrls_InvalidType(t *testing.T) {
setupWebhookTest(t, func(downloader *Downloader) {
cfg, _ := downloader.GetConfig()
cfg.Extra = make(map[string]any)
cfg.Extra["webhookUrls"] = "not-a-slice"
downloader.PutConfig(cfg)
urls := downloader.getWebhookUrls()
if urls != nil {
t.Errorf("Expected nil for invalid type, got %v", urls)
}
})
}
func TestWebhook_SendWebhookToUrl_Success(t *testing.T) {
receivedData := make(chan *WebhookData, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify headers
if r.Header.Get("Content-Type") != "application/json" {
t.Errorf("Expected Content-Type application/json, got %s", r.Header.Get("Content-Type"))
}
if r.Header.Get("User-Agent") != "Gopeed-Webhook/1.0" {
t.Errorf("Expected User-Agent Gopeed-Webhook/1.0, got %s", r.Header.Get("User-Agent"))
}
var data WebhookData
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
t.Errorf("Failed to decode data: %v", err)
return
}
receivedData <- &data
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
setupWebhookTest(t, func(downloader *Downloader) {
task := NewTask()
task.Protocol = "http"
task.Meta = &mockFetcherMeta
data := &WebhookData{
Event: WebhookEventDownloadDone,
Time: time.Now().UnixMilli(),
Payload: &WebhookPayload{
Task: task,
},
}
statusCode, err := downloader.sendWebhookToUrl(server.URL, data)
if err != nil {
t.Errorf("sendWebhookToUrl failed: %v", err)
}
if statusCode != http.StatusOK {
t.Errorf("Expected status 200, got %d", statusCode)
}
select {
case received := <-receivedData:
if received.Event != WebhookEventDownloadDone {
t.Errorf("Expected event DOWNLOAD_DONE, got %s", received.Event)
}
case <-time.After(1 * time.Second):
t.Error("Timeout waiting for webhook")
}
})
}
func TestWebhook_SendWebhookToUrl_EmptyUrl(t *testing.T) {
setupWebhookTest(t, func(downloader *Downloader) {
data := &WebhookData{
Event: WebhookEventDownloadDone,
Time: time.Now().UnixMilli(),
}
_, err := downloader.sendWebhookToUrl("", data)
if err == nil {
t.Error("Expected error for empty URL")
}
if err.Error() != "webhook URL is empty" {
t.Errorf("Expected 'webhook URL is empty' error, got: %v", err)
}
})
}
func TestWebhook_SendWebhookToUrl_InvalidUrl(t *testing.T) {
setupWebhookTest(t, func(downloader *Downloader) {
data := &WebhookData{
Event: WebhookEventDownloadDone,
Time: time.Now().UnixMilli(),
}
_, err := downloader.sendWebhookToUrl("://invalid-url", data)
if err == nil {
t.Error("Expected error for invalid URL")
}
})
}
func TestWebhook_SendWebhookToUrl_NonExistentHost(t *testing.T) {
setupWebhookTest(t, func(downloader *Downloader) {
data := &WebhookData{
Event: WebhookEventDownloadDone,
Time: time.Now().UnixMilli(),
}
_, err := downloader.sendWebhookToUrl("http://non-existent-host-12345.example", data)
if err == nil {
t.Error("Expected error for non-existent host")
}
})
}
func TestWebhook_SendWebhookToUrl_Timeout(t *testing.T) {
// Create a server that delays response beyond webhook timeout
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(15 * time.Second) // Longer than webhookTimeout (10s)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
setupWebhookTest(t, func(downloader *Downloader) {
data := &WebhookData{
Event: WebhookEventDownloadDone,
Time: time.Now().UnixMilli(),
}
_, err := downloader.sendWebhookToUrl(server.URL, data)
if err == nil {
t.Error("Expected timeout error")
}
})
}
func TestWebhook_SendWebhookToUrl_VariousStatusCodes(t *testing.T) {
testCases := []struct {
name string
statusCode int
}{
{"200 OK", http.StatusOK},
{"201 Created", http.StatusCreated},
{"204 No Content", http.StatusNoContent},
{"400 Bad Request", http.StatusBadRequest},
{"404 Not Found", http.StatusNotFound},
{"500 Internal Server Error", http.StatusInternalServerError},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tc.statusCode)
}))
defer server.Close()
setupWebhookTest(t, func(downloader *Downloader) {
data := &WebhookData{
Event: WebhookEventDownloadDone,
Time: time.Now().UnixMilli(),
}
statusCode, err := downloader.sendWebhookToUrl(server.URL, data)
if err != nil {
t.Errorf("sendWebhookToUrl failed: %v", err)
}
if statusCode != tc.statusCode {
t.Errorf("Expected status %d, got %d", tc.statusCode, statusCode)
}
})
})
}
}
func TestWebhook_TriggerWebhooks_EmptyUrlSkipped(t *testing.T) {
requestCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
setupWebhookTest(t, func(downloader *Downloader) {
cfg, _ := downloader.GetConfig()
cfg.Extra = make(map[string]any)
cfg.Extra["webhookUrls"] = []string{server.URL, "", server.URL}
downloader.PutConfig(cfg)
task := NewTask()
task.Protocol = "http"
task.Meta = &mockFetcherMeta
downloader.triggerWebhooks(WebhookEventDownloadDone, task, nil)
time.Sleep(500 * time.Millisecond)
if requestCount != 2 {
t.Errorf("Expected 2 requests (empty URL should be skipped), got %d", requestCount)
}
})
}
func TestWebhook_WebhookDataStructure(t *testing.T) {
receivedData := make(chan *WebhookData, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var data WebhookData
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
t.Errorf("Failed to decode data: %v", err)
return
}
receivedData <- &data
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
setupWebhookTest(t, func(downloader *Downloader) {
cfg, _ := downloader.GetConfig()
cfg.Extra = make(map[string]any)
cfg.Extra["webhookUrls"] = []string{server.URL}
downloader.PutConfig(cfg)
task := NewTask()
task.Protocol = "http"
task.Status = base.DownloadStatusDone
task.Meta = &mockFetcherMeta
downloader.triggerWebhooks(WebhookEventDownloadDone, task, nil)
select {
case data := <-receivedData:
// Verify event
if data.Event != WebhookEventDownloadDone {
t.Errorf("Expected event DOWNLOAD_DONE, got %s", data.Event)
}
// Verify time is set
if data.Time == 0 {
t.Error("Expected time to be set")
}
// Verify payload
if data.Payload == nil {
t.Error("Expected payload to be present")
}
if data.Payload.Task == nil {
t.Error("Expected task in payload to be present")
}
// Verify task is cloned (has same ID but different pointer)
if data.Payload.Task.ID != task.ID {
t.Errorf("Expected task ID %s, got %s", task.ID, data.Payload.Task.ID)
}
case <-time.After(2 * time.Second):
t.Error("Timeout waiting for webhook")
}
})
}
func TestWebhook_SendTestWebhook_EmptyUrls(t *testing.T) {
setupWebhookTest(t, func(downloader *Downloader) {
cfg, _ := downloader.GetConfig()
cfg.Extra = make(map[string]any)
cfg.Extra["webhookUrls"] = []string{}
downloader.PutConfig(cfg)
err := downloader.SendTestWebhook()
if err != nil {
t.Errorf("Expected no error for empty URLs, got: %v", err)
}
})
}
func TestWebhook_SendTestWebhook_MixedResults(t *testing.T) {
// First server returns 200
server1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer server1.Close()
// Second server returns 500
server2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server2.Close()
setupWebhookTest(t, func(downloader *Downloader) {
cfg, _ := downloader.GetConfig()
cfg.Extra = make(map[string]any)
cfg.Extra["webhookUrls"] = []string{server1.URL, server2.URL}
downloader.PutConfig(cfg)
// Should fail because server2 returns 500
err := downloader.SendTestWebhook()
if err == nil {
t.Error("Expected error when one server returns non-200 status")
}
})
}
func TestWebhook_TestWebhookUrl_InvalidUrl(t *testing.T) {
setupWebhookTest(t, func(downloader *Downloader) {
err := downloader.TestWebhookUrl("://invalid")
if err == nil {
t.Error("Expected error for invalid URL")
}
})
}
func TestWebhook_TestWebhookUrl_VerifyTestPayload(t *testing.T) {
receivedData := make(chan *WebhookData, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var data WebhookData
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
t.Errorf("Failed to decode data: %v", err)
return
}
receivedData <- &data
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
setupWebhookTest(t, func(downloader *Downloader) {
err := downloader.TestWebhookUrl(server.URL)
if err != nil {
t.Errorf("TestWebhookUrl failed: %v", err)
}
select {
case data := <-receivedData:
// Verify it's a test webhook
if data.Event != WebhookEventDownloadDone {
t.Errorf("Expected event DOWNLOAD_DONE, got %s", data.Event)
}
if data.Payload == nil || data.Payload.Task == nil {
t.Error("Expected payload with task")
}
// Verify test task properties
task := data.Payload.Task
if task.Protocol != "http" {
t.Errorf("Expected protocol 'http', got '%s'", task.Protocol)
}
if task.Status != base.DownloadStatusDone {
t.Errorf("Expected status Done, got %s", task.Status)
}
if task.Meta == nil || task.Meta.Req == nil {
t.Error("Expected meta and request in test task")
}
if task.Meta.Req.URL != "https://example.com/test-file.zip" {
t.Errorf("Expected test URL, got %s", task.Meta.Req.URL)
}
case <-time.After(2 * time.Second):
t.Error("Timeout waiting for webhook")
}
})
}
func setupWebhookTest(t *testing.T, fn func(downloader *Downloader)) {
defaultDownloader.Setup()
defaultDownloader.cfg.StorageDir = ".test_storage"
defaultDownloader.cfg.DownloadDir = ".test_download"
defer func() {
defaultDownloader.Clear()
os.RemoveAll(defaultDownloader.cfg.StorageDir)
os.RemoveAll(defaultDownloader.cfg.DownloadDir)
}()
fn(defaultDownloader)
}
+11
View File
@@ -389,3 +389,14 @@ func getServerConfig() *base.DownloaderStoreConfig {
cfg, _ := Downloader.GetConfig()
return cfg
}
func TestWebhook(w http.ResponseWriter, r *http.Request) {
var req model.TestWebhookReq
if ReadJson(r, w, &req) {
if err := Downloader.TestWebhookUrl(req.URL); err != nil {
WriteJson(w, model.NewErrorResult(err.Error()))
return
}
WriteJson(w, model.NewNilResult())
}
}
+6
View File
@@ -0,0 +1,6 @@
package model
// TestWebhookReq is the request body for testing a single webhook URL
type TestWebhookReq struct {
URL string `json:"url"`
}
+1
View File
@@ -141,6 +141,7 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
r.Methods(http.MethodDelete).Path("/api/v1/extensions/{identity}").HandlerFunc(DeleteExtension)
r.Methods(http.MethodGet).Path("/api/v1/extensions/{identity}/update").HandlerFunc(UpdateCheckExtension)
r.Methods(http.MethodPost).Path("/api/v1/extensions/{identity}/update").HandlerFunc(UpdateExtension)
r.Methods(http.MethodPost).Path("/api/v1/webhook/test").HandlerFunc(TestWebhook)
r.Path("/api/v1/proxy").HandlerFunc(DoProxy)
enableApiToken := startCfg.ApiToken != ""
+115 -4
View File
@@ -6,10 +6,6 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/GopeedLab/gopeed/internal/test"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/download"
"github.com/GopeedLab/gopeed/pkg/rest/model"
"io"
"net"
"net/http"
@@ -20,6 +16,11 @@ import (
"sync"
"testing"
"time"
"github.com/GopeedLab/gopeed/internal/test"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/download"
"github.com/GopeedLab/gopeed/pkg/rest/model"
)
var (
@@ -606,6 +607,116 @@ func TestDoProxy(t *testing.T) {
})
}
func TestTestWebhook(t *testing.T) {
doTest(func() {
// Set up a mock webhook server
webhookReceived := false
var receivedData map[string]interface{}
var wg sync.WaitGroup
wg.Add(1)
webhookServer := http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
if err := json.Unmarshal(body, &receivedData); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
// Check Content-Type
if r.Header.Get("Content-Type") != "application/json" {
t.Errorf("TestWebhook() Content-Type got = %v, want %v", r.Header.Get("Content-Type"), "application/json")
}
webhookReceived = true
w.WriteHeader(http.StatusOK)
wg.Done()
}),
}
// Start webhook server
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
webhookPort := listener.Addr().(*net.TCPAddr).Port
webhookURL := fmt.Sprintf("http://127.0.0.1:%d/webhook", webhookPort)
go webhookServer.Serve(listener)
defer webhookServer.Close()
// Test with valid webhook URL
httpRequestCheckOk[any](http.MethodPost, "/api/v1/webhook/test", &model.TestWebhookReq{
URL: webhookURL,
})
// Wait for webhook to be received
wg.Wait()
if !webhookReceived {
t.Error("TestWebhook() webhook was not received")
}
// Verify webhook data structure
if receivedData["event"] == nil {
t.Error("TestWebhook() missing 'event' field")
}
if receivedData["time"] == nil {
t.Error("TestWebhook() missing 'time' field")
}
if receivedData["payload"] == nil {
t.Error("TestWebhook() missing 'payload' field")
}
// Test with invalid webhook URL
code, _ := httpRequest[any](http.MethodPost, "/api/v1/webhook/test", &model.TestWebhookReq{
URL: "http://invalid-webhook-url-that-does-not-exist.local:99999/webhook",
})
checkCode(code, model.CodeError)
// Test with empty URL
code, _ = httpRequest[any](http.MethodPost, "/api/v1/webhook/test", &model.TestWebhookReq{
URL: "",
})
checkCode(code, model.CodeError)
// Test with webhook server returning non-200 status
wg.Add(1)
badWebhookServer := http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
wg.Done()
}),
}
badListener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
badWebhookPort := badListener.Addr().(*net.TCPAddr).Port
badWebhookURL := fmt.Sprintf("http://127.0.0.1:%d/webhook", badWebhookPort)
go badWebhookServer.Serve(badListener)
defer badWebhookServer.Close()
code, _ = httpRequest[any](http.MethodPost, "/api/v1/webhook/test", &model.TestWebhookReq{
URL: badWebhookURL,
})
checkCode(code, model.CodeError)
wg.Wait()
})
}
func TestApiToken(t *testing.T) {
var cfg = &model.StartConfig{}
cfg.Init()