mirror of
https://github.com/StarFleetCPTN/GoMFT.git
synced 2026-09-08 23:50:48 +02:00
- Integrated new components for job scheduling, including a cron-based scheduler and improved job execution logic. - Refactored job executor to utilize interfaces for better dependency management and testing. - Enhanced notification system with a new Notifier interface, allowing for flexible notification service integration. - Added comprehensive unit tests for job executor, scheduler, and notification components to ensure reliability and correctness. - Updated metadata handling to support new database interfaces, improving testability and maintainability.
309 lines
11 KiB
Go
309 lines
11 KiB
Go
package scheduler
|
|
|
|
import (
|
|
"crypto/hmac" // Added import
|
|
"crypto/sha256" // Added import
|
|
"encoding/hex" // Added import
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/starfleetcptn/gomft/internal/db"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// --- Mock DB Implementation ---
|
|
|
|
// Ensure mockNotificationDB implements the NotificationDB interface
|
|
var _ NotificationDB = (*mockNotificationDB)(nil)
|
|
|
|
type mockNotificationDB struct {
|
|
GetNotificationServicesFunc func(enabledOnly bool) ([]db.NotificationService, error)
|
|
UpdateNotificationServiceFunc func(service *db.NotificationService) error
|
|
GetJobFunc func(jobID uint) (*db.Job, error)
|
|
CreateJobNotificationFunc func(userID uint, jobID uint, historyID uint, notificationType db.NotificationType, title string, message string) error
|
|
CreateFunc func(value interface{}) *gorm.DB
|
|
|
|
// Mutex to protect concurrent access to mock data if needed
|
|
mu sync.Mutex
|
|
// Store data for verification if needed
|
|
updatedServices []*db.NotificationService
|
|
createdNotifications []map[string]interface{}
|
|
createdHistory *db.JobHistory
|
|
}
|
|
|
|
func (m *mockNotificationDB) GetNotificationServices(enabledOnly bool) ([]db.NotificationService, error) {
|
|
if m.GetNotificationServicesFunc != nil {
|
|
return m.GetNotificationServicesFunc(enabledOnly)
|
|
}
|
|
return nil, errors.New("mock GetNotificationServicesFunc not implemented")
|
|
}
|
|
|
|
func (m *mockNotificationDB) UpdateNotificationService(service *db.NotificationService) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.updatedServices = append(m.updatedServices, service) // Store for verification
|
|
if m.UpdateNotificationServiceFunc != nil {
|
|
return m.UpdateNotificationServiceFunc(service)
|
|
}
|
|
return nil // Default success
|
|
}
|
|
|
|
func (m *mockNotificationDB) GetJob(jobID uint) (*db.Job, error) {
|
|
if m.GetJobFunc != nil {
|
|
return m.GetJobFunc(jobID)
|
|
}
|
|
// Default mock behavior: return a basic job
|
|
return &db.Job{ID: jobID, Name: "Mock Job", CreatedBy: 1}, nil
|
|
}
|
|
|
|
func (m *mockNotificationDB) CreateJobNotification(userID uint, jobID uint, historyID uint, notificationType db.NotificationType, title string, message string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.createdNotifications = append(m.createdNotifications, map[string]interface{}{
|
|
"userID": userID, "jobID": jobID, "historyID": historyID, "type": notificationType, "title": title, "message": message,
|
|
})
|
|
if m.CreateJobNotificationFunc != nil {
|
|
return m.CreateJobNotificationFunc(userID, jobID, historyID, notificationType, title, message)
|
|
}
|
|
return nil // Default success
|
|
}
|
|
|
|
func (m *mockNotificationDB) Create(value interface{}) *gorm.DB {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if hist, ok := value.(*db.JobHistory); ok {
|
|
m.createdHistory = hist // Store for verification if needed
|
|
}
|
|
if m.CreateFunc != nil {
|
|
return m.CreateFunc(value)
|
|
}
|
|
// Default mock behavior: return success with no error
|
|
return &gorm.DB{Error: nil}
|
|
}
|
|
|
|
// Helper to reset mock state between tests
|
|
func (m *mockNotificationDB) Reset() {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.updatedServices = nil
|
|
m.createdNotifications = nil
|
|
m.createdHistory = nil
|
|
}
|
|
|
|
// --- Test Helpers ---
|
|
|
|
func createTestJob(id uint, webhookEnabled bool, webhookURL string, notifySuccess bool, notifyFailure bool) *db.Job {
|
|
job := db.Job{
|
|
// Removed gorm.Model nesting, set ID directly
|
|
ID: id,
|
|
Name: "Test Job",
|
|
WebhookEnabled: &webhookEnabled,
|
|
WebhookURL: webhookURL,
|
|
NotifyOnSuccess: ¬ifySuccess,
|
|
NotifyOnFailure: ¬ifyFailure,
|
|
CreatedBy: 1, // Assume user ID 1
|
|
}
|
|
return &job
|
|
}
|
|
|
|
func createTestHistory(id uint, jobID uint, status string, errMsg string) *db.JobHistory {
|
|
now := time.Now()
|
|
hist := db.JobHistory{
|
|
ID: id,
|
|
JobID: jobID,
|
|
Status: status,
|
|
StartTime: now.Add(-1 * time.Minute),
|
|
ErrorMessage: errMsg,
|
|
}
|
|
if status != "running" {
|
|
endTime := now
|
|
hist.EndTime = &endTime
|
|
}
|
|
return &hist
|
|
}
|
|
|
|
func createTestConfig(id uint) *db.TransferConfig {
|
|
return &db.TransferConfig{
|
|
// Removed gorm.Model nesting, set ID directly
|
|
ID: id,
|
|
Name: "Test Config",
|
|
SourceType: "local",
|
|
SourcePath: "/tmp/source",
|
|
DestinationType: "local",
|
|
DestinationPath: "/tmp/dest",
|
|
}
|
|
}
|
|
|
|
func createTestNotificationService(id uint, name, svcType string, enabled bool, triggers []string, config map[string]string) db.NotificationService {
|
|
return db.NotificationService{
|
|
ID: id,
|
|
Name: name,
|
|
Type: svcType,
|
|
IsEnabled: enabled, // Corrected field name
|
|
EventTriggers: triggers,
|
|
Config: config,
|
|
// Initialize other fields as needed for tests, e.g., RetryPolicy
|
|
RetryPolicy: "none",
|
|
}
|
|
}
|
|
|
|
// --- Tests ---
|
|
|
|
func TestSendJobWebhookNotification(t *testing.T) {
|
|
logger, logBuf := newTestLogger(LogLevelDebug)
|
|
defer logger.Close()
|
|
mockDB := &mockNotificationDB{} // Not used directly by this function, but Notifier needs it
|
|
notifier := NewNotifier(mockDB, logger)
|
|
|
|
var receivedPayload map[string]interface{}
|
|
var receivedHeaders http.Header
|
|
var receivedSignature string
|
|
|
|
// Create a mock HTTP server
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
receivedHeaders = r.Header
|
|
receivedSignature = r.Header.Get("X-Hub-Signature-256")
|
|
bodyBytes, _ := io.ReadAll(r.Body)
|
|
json.Unmarshal(bodyBytes, &receivedPayload)
|
|
w.WriteHeader(http.StatusOK) // Respond with success
|
|
}))
|
|
defer server.Close()
|
|
|
|
job := createTestJob(1, true, server.URL, true, true)
|
|
job.WebhookSecret = "test-secret" // Add secret for signature testing
|
|
job.WebhookHeaders = `{"X-Custom-Header": "CustomValue"}` // Add custom headers
|
|
history := createTestHistory(10, 1, "completed", "")
|
|
config := createTestConfig(5)
|
|
|
|
notifier.sendJobWebhookNotification(job, history, config)
|
|
|
|
// Assertions
|
|
if receivedPayload == nil {
|
|
t.Fatal("Webhook server did not receive a payload")
|
|
}
|
|
if receivedPayload["job_id"].(float64) != float64(job.ID) {
|
|
t.Errorf("Expected job_id %d, got %v", job.ID, receivedPayload["job_id"])
|
|
}
|
|
if receivedPayload["status"] != history.Status {
|
|
t.Errorf("Expected status %q, got %q", history.Status, receivedPayload["status"])
|
|
}
|
|
if receivedHeaders.Get("Content-Type") != "application/json" {
|
|
t.Errorf("Expected Content-Type 'application/json', got %q", receivedHeaders.Get("Content-Type"))
|
|
}
|
|
if receivedHeaders.Get("User-Agent") != "GoMFT-Webhook/1.0" {
|
|
t.Errorf("Expected User-Agent 'GoMFT-Webhook/1.0', got %q", receivedHeaders.Get("User-Agent"))
|
|
}
|
|
if receivedHeaders.Get("X-Custom-Header") != "CustomValue" {
|
|
t.Errorf("Expected X-Custom-Header 'CustomValue', got %q", receivedHeaders.Get("X-Custom-Header"))
|
|
}
|
|
|
|
// Verify signature
|
|
// Re-marshal the *received* payload to ensure byte-for-byte match for signature calculation
|
|
payloadBytes, err := json.Marshal(receivedPayload)
|
|
if err != nil {
|
|
t.Fatalf("Failed to re-marshal received payload for signature check: %v", err)
|
|
}
|
|
mac := hmac.New(sha256.New, []byte(job.WebhookSecret))
|
|
mac.Write(payloadBytes)
|
|
expectedSignature := hex.EncodeToString(mac.Sum(nil)) // Use imported hex
|
|
|
|
if receivedSignature == "" {
|
|
t.Error("Expected X-Hub-Signature-256 header, but it was missing")
|
|
} else if receivedSignature != expectedSignature {
|
|
t.Errorf("Signature mismatch: got %q, want %q. Payload received: %s", receivedSignature, expectedSignature, string(payloadBytes))
|
|
}
|
|
|
|
// Check logs
|
|
logOutput := logBuf.String()
|
|
if !strings.Contains(logOutput, "Webhook notification for job 1 sent successfully") {
|
|
t.Errorf("Expected success log message, but got:\n%s", logOutput)
|
|
}
|
|
}
|
|
|
|
func TestSendGlobalNotifications_Webhook(t *testing.T) {
|
|
logger, _ := newTestLogger(LogLevelDebug)
|
|
defer logger.Close()
|
|
|
|
var receivedPayload map[string]interface{}
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
bodyBytes, _ := io.ReadAll(r.Body)
|
|
json.Unmarshal(bodyBytes, &receivedPayload)
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer server.Close()
|
|
|
|
mockDB := &mockNotificationDB{}
|
|
mockDB.GetNotificationServicesFunc = func(enabledOnly bool) ([]db.NotificationService, error) {
|
|
allServices := []db.NotificationService{
|
|
createTestNotificationService(1, "Test Webhook", "webhook", true, []string{"job_complete"}, map[string]string{"webhook_url": server.URL}),
|
|
createTestNotificationService(2, "Disabled Webhook", "webhook", false, []string{"job_complete"}, map[string]string{"webhook_url": "http://disabled.invalid"}),
|
|
createTestNotificationService(3, "Wrong Trigger", "webhook", true, []string{"job_error"}, map[string]string{"webhook_url": "http://wrongtrigger.invalid"}),
|
|
}
|
|
if enabledOnly {
|
|
var enabledServices []db.NotificationService
|
|
for _, s := range allServices {
|
|
if s.IsEnabled { // Use IsEnabled field
|
|
enabledServices = append(enabledServices, s)
|
|
}
|
|
}
|
|
t.Logf("Mock GetNotificationServices(true) returning %d services", len(enabledServices)) // Add log
|
|
return enabledServices, nil
|
|
}
|
|
t.Logf("Mock GetNotificationServices(false) returning %d services", len(allServices)) // Add log
|
|
return allServices, nil
|
|
}
|
|
mockDB.UpdateNotificationServiceFunc = func(service *db.NotificationService) error {
|
|
// Add debug logging
|
|
t.Logf("UpdateNotificationServiceFunc called with service ID: %d, Name: %s, SuccessCount: %d", service.ID, service.Name, service.SuccessCount)
|
|
if service.ID != 1 {
|
|
t.Errorf("Expected UpdateNotificationService for ID 1, got %d", service.ID)
|
|
}
|
|
if service.SuccessCount != 1 {
|
|
t.Errorf("Expected SuccessCount 1, got %d", service.SuccessCount)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
notifier := NewNotifier(mockDB, logger)
|
|
|
|
job := createTestJob(10, false, "", false, false) // Job-specific webhook disabled
|
|
history := createTestHistory(100, 10, "completed", "")
|
|
config := createTestConfig(50)
|
|
|
|
notifier.sendGlobalNotifications(job, history, config)
|
|
|
|
// Assertions
|
|
if receivedPayload == nil {
|
|
t.Fatal("Webhook server did not receive a payload from global notification")
|
|
}
|
|
if jobData, ok := receivedPayload["job"].(map[string]interface{}); ok {
|
|
if jobData["id"].(float64) != float64(job.ID) {
|
|
t.Errorf("Expected job.id %d, got %v", job.ID, jobData["id"])
|
|
}
|
|
if jobData["status"] != history.Status {
|
|
t.Errorf("Expected job.status %q, got %q", history.Status, jobData["status"])
|
|
}
|
|
} else {
|
|
t.Fatal("Payload missing 'job' field or not a map")
|
|
}
|
|
|
|
// Verify DB update was called correctly
|
|
mockDB.mu.Lock()
|
|
if len(mockDB.updatedServices) != 1 || mockDB.updatedServices[0].ID != 1 {
|
|
t.Errorf("Expected 1 call to UpdateNotificationService for service ID 1, got %d calls", len(mockDB.updatedServices))
|
|
}
|
|
mockDB.mu.Unlock()
|
|
}
|
|
|
|
// TODO: Add tests for other notification service types (email, pushbullet, ntfy, gotify, pushover)
|
|
// TODO: Add tests for SendNotifications (combining job-specific and global)
|
|
// TODO: Add tests for template variable replacement (replaceVariables, generateCustomPayload)
|
|
// TODO: Add tests for createJobNotification and updateJobStatus (if kept)
|