mirror of
https://github.com/StarFleetCPTN/GoMFT.git
synced 2026-09-08 15:41:20 +02:00
feat: Introduce scheduler and notification enhancements
- 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.
This commit is contained in:
@@ -6,30 +6,58 @@ import (
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
"github.com/starfleetcptn/gomft/internal/db"
|
||||
"gorm.io/gorm" // Needed for DB interface method signature
|
||||
)
|
||||
|
||||
// --- Interfaces for Dependencies ---
|
||||
|
||||
// JobExecutorDB defines the database methods needed by JobExecutor.
|
||||
type JobExecutorDB interface {
|
||||
First(dest interface{}, conds ...interface{}) *gorm.DB // Used to load job details
|
||||
GetConfigsForJob(jobID uint) ([]db.TransferConfig, error)
|
||||
UpdateJobStatus(job *db.Job) error
|
||||
CreateJobHistory(history *db.JobHistory) error
|
||||
}
|
||||
|
||||
// JobExecutorCron defines the cron methods needed by JobExecutor.
|
||||
type JobExecutorCron interface {
|
||||
Entry(id cron.EntryID) cron.Entry
|
||||
}
|
||||
|
||||
// JobExecutorTransferExecutor defines the transfer executor methods needed by JobExecutor.
|
||||
type JobExecutorTransferExecutor interface {
|
||||
executeConfigTransfer(job db.Job, config db.TransferConfig, history *db.JobHistory)
|
||||
}
|
||||
|
||||
// JobExecutorNotifier defines the notification methods needed by JobExecutor.
|
||||
type JobExecutorNotifier interface {
|
||||
// SendNotifications is called within processConfiguration, which indirectly uses the Notifier interface
|
||||
// defined in transfer_executor.go. We need the same method here.
|
||||
SendNotifications(job *db.Job, history *db.JobHistory, config *db.TransferConfig)
|
||||
}
|
||||
|
||||
// --- JobExecutor Implementation ---
|
||||
|
||||
// JobExecutor handles the execution logic for a single job run.
|
||||
type JobExecutor struct {
|
||||
db *db.DB
|
||||
logger *Logger
|
||||
cron *cron.Cron
|
||||
jobs map[uint]cron.EntryID // Shared map from Scheduler
|
||||
jobMutex *sync.Mutex // Shared mutex from Scheduler
|
||||
transferExecutor *TransferExecutor // TransferExecutor component
|
||||
notifier *Notifier // Notifier component
|
||||
// metadataHandler *MetadataHandler // Placeholder if needed directly
|
||||
db JobExecutorDB // Use interface
|
||||
logger *Logger // Logger remains concrete
|
||||
cron JobExecutorCron // Use interface
|
||||
jobs map[uint]cron.EntryID // Shared map from Scheduler
|
||||
jobMutex *sync.Mutex // Shared mutex from Scheduler
|
||||
transferExecutor JobExecutorTransferExecutor // Use interface
|
||||
notifier JobExecutorNotifier // Use interface
|
||||
}
|
||||
|
||||
// NewJobExecutor creates a new JobExecutor.
|
||||
func NewJobExecutor(
|
||||
database *db.DB,
|
||||
database JobExecutorDB, // Accept interface
|
||||
logger *Logger,
|
||||
cron *cron.Cron,
|
||||
cron JobExecutorCron, // Accept interface
|
||||
jobsMap map[uint]cron.EntryID,
|
||||
jobMutex *sync.Mutex,
|
||||
transferExec *TransferExecutor,
|
||||
notify *Notifier,
|
||||
// metadata *MetadataHandler,
|
||||
transferExec JobExecutorTransferExecutor, // Accept interface
|
||||
notify JobExecutorNotifier, // Accept interface
|
||||
) *JobExecutor {
|
||||
return &JobExecutor{
|
||||
db: database,
|
||||
@@ -39,7 +67,6 @@ func NewJobExecutor(
|
||||
jobMutex: jobMutex,
|
||||
transferExecutor: transferExec,
|
||||
notifier: notify,
|
||||
// metadataHandler: metadata,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +79,7 @@ func (je *JobExecutor) executeJob(jobID uint) {
|
||||
|
||||
// Get job details
|
||||
var job db.Job
|
||||
// Calls interface method - need to handle the *gorm.DB return value
|
||||
if err := je.db.First(&job, jobID).Error; err != nil {
|
||||
je.logger.LogError("Error loading job %d: %v", jobID, err)
|
||||
return
|
||||
@@ -60,7 +88,7 @@ func (je *JobExecutor) executeJob(jobID uint) {
|
||||
je.logger.LogDebug("Loaded job details: %+v", job)
|
||||
|
||||
// Get all configurations associated with this job
|
||||
configs, err := je.db.GetConfigsForJob(jobID)
|
||||
configs, err := je.db.GetConfigsForJob(jobID) // Calls interface method
|
||||
if err != nil {
|
||||
je.logger.LogError("Error loading configurations for job %d: %v", jobID, err)
|
||||
return
|
||||
@@ -109,7 +137,7 @@ func (je *JobExecutor) executeJob(jobID uint) {
|
||||
// Update job last run time
|
||||
startTime := time.Now()
|
||||
job.LastRun = &startTime
|
||||
if err := je.db.UpdateJobStatus(&job); err != nil {
|
||||
if err := je.db.UpdateJobStatus(&job); err != nil { // Calls interface method
|
||||
je.logger.LogError("Error updating job last run time for job %d: %v", jobID, err)
|
||||
}
|
||||
|
||||
@@ -125,11 +153,11 @@ func (je *JobExecutor) executeJob(jobID uint) {
|
||||
je.jobMutex.Unlock()
|
||||
|
||||
if exists {
|
||||
entry := je.cron.Entry(entryID)
|
||||
entry := je.cron.Entry(entryID) // Calls interface method
|
||||
nextRun := entry.Next
|
||||
job.NextRun = &nextRun
|
||||
je.logger.LogInfo("Next run time for job %d: %v", jobID, nextRun)
|
||||
if err := je.db.UpdateJobStatus(&job); err != nil {
|
||||
if err := je.db.UpdateJobStatus(&job); err != nil { // Calls interface method
|
||||
je.logger.LogError("Error updating job next run time for job %d: %v", jobID, err)
|
||||
}
|
||||
}
|
||||
@@ -160,7 +188,7 @@ func (je *JobExecutor) processConfiguration(job *db.Job, config *db.TransferConf
|
||||
BytesTransferred: 0,
|
||||
ErrorMessage: "",
|
||||
}
|
||||
if err := je.db.CreateJobHistory(history); err != nil {
|
||||
if err := je.db.CreateJobHistory(history); err != nil { // Calls interface method
|
||||
je.logger.LogError("Error creating job history for job %d, config %d: %v", job.ID, config.ID, err)
|
||||
return
|
||||
}
|
||||
@@ -168,10 +196,11 @@ func (je *JobExecutor) processConfiguration(job *db.Job, config *db.TransferConf
|
||||
je.logger.LogDebug("Creating job history record: %+v", history)
|
||||
|
||||
// Send webhook notification for job start
|
||||
// TODO: Ensure Notifier struct and its methods are correctly defined and initialized
|
||||
je.notifier.SendNotifications(job, history, config) // Assuming sendWebhookNotification is a method on Notifier
|
||||
// Notifier interface is used by TransferExecutor, which is called below.
|
||||
// We also added SendNotifications to the JobExecutorNotifier interface for completeness,
|
||||
// though it's primarily used within transferExecutor.
|
||||
je.notifier.SendNotifications(job, history, config) // Calls interface method
|
||||
|
||||
// Execute the configuration transfer
|
||||
// TODO: Ensure TransferExecutor struct and its methods are correctly defined and initialized
|
||||
je.transferExecutor.executeConfigTransfer(*job, *config, history) // Assuming executeConfigTransfer is a method on TransferExecutor
|
||||
je.transferExecutor.executeConfigTransfer(*job, *config, history) // Calls interface method
|
||||
}
|
||||
|
||||
@@ -0,0 +1,526 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings" // Added import
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
"github.com/starfleetcptn/gomft/internal/db"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// --- Mock Implementations ---
|
||||
|
||||
// Mock JobExecutorDB
|
||||
var _ JobExecutorDB = (*mockJobExecutorDB)(nil)
|
||||
|
||||
type mockJobExecutorDB struct {
|
||||
mu sync.Mutex
|
||||
FirstFunc func(dest interface{}, conds ...interface{}) *gorm.DB
|
||||
GetConfigsForJobFunc func(jobID uint) ([]db.TransferConfig, error)
|
||||
UpdateJobStatusFunc func(job *db.Job) error
|
||||
CreateJobHistoryFunc func(history *db.JobHistory) error
|
||||
|
||||
// Store calls/data
|
||||
firstCalledWithDest interface{}
|
||||
firstCalledWithConds []interface{}
|
||||
configsForJobID uint
|
||||
updatedJobStatus *db.Job
|
||||
createdHistory *db.JobHistory
|
||||
}
|
||||
|
||||
func (m *mockJobExecutorDB) First(dest interface{}, conds ...interface{}) *gorm.DB {
|
||||
m.mu.Lock()
|
||||
m.firstCalledWithDest = dest
|
||||
m.firstCalledWithConds = conds
|
||||
m.mu.Unlock()
|
||||
if m.FirstFunc != nil {
|
||||
return m.FirstFunc(dest, conds...)
|
||||
}
|
||||
// Default: Simulate job found by populating dest
|
||||
if job, ok := dest.(*db.Job); ok && len(conds) > 0 {
|
||||
if jobID, ok := conds[0].(uint); ok {
|
||||
job.ID = jobID
|
||||
job.Name = fmt.Sprintf("Mock Job %d", jobID)
|
||||
job.ConfigIDs = "1,2" // Default config IDs
|
||||
enabled := true
|
||||
job.Enabled = &enabled
|
||||
return &gorm.DB{Error: nil} // Success
|
||||
}
|
||||
}
|
||||
return &gorm.DB{Error: gorm.ErrRecordNotFound} // Default not found
|
||||
}
|
||||
|
||||
func (m *mockJobExecutorDB) GetConfigsForJob(jobID uint) ([]db.TransferConfig, error) {
|
||||
m.mu.Lock()
|
||||
m.configsForJobID = jobID
|
||||
m.mu.Unlock()
|
||||
if m.GetConfigsForJobFunc != nil {
|
||||
return m.GetConfigsForJobFunc(jobID)
|
||||
}
|
||||
// Default: return some mock configs
|
||||
return []db.TransferConfig{
|
||||
{ID: 1, Name: "Config 1"}, // Corrected initialization
|
||||
{ID: 2, Name: "Config 2"}, // Corrected initialization
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockJobExecutorDB) UpdateJobStatus(job *db.Job) error {
|
||||
m.mu.Lock()
|
||||
m.updatedJobStatus = job // Store last updated job
|
||||
m.mu.Unlock()
|
||||
if m.UpdateJobStatusFunc != nil {
|
||||
return m.UpdateJobStatusFunc(job)
|
||||
}
|
||||
return nil // Default success
|
||||
}
|
||||
|
||||
func (m *mockJobExecutorDB) CreateJobHistory(history *db.JobHistory) error {
|
||||
m.mu.Lock()
|
||||
m.createdHistory = history // Store last created history
|
||||
m.mu.Unlock()
|
||||
if m.CreateJobHistoryFunc != nil {
|
||||
return m.CreateJobHistoryFunc(history)
|
||||
}
|
||||
history.ID = 999 // Assign mock ID
|
||||
return nil // Default success
|
||||
}
|
||||
|
||||
func (m *mockJobExecutorDB) Reset() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.firstCalledWithDest = nil
|
||||
m.firstCalledWithConds = nil
|
||||
m.configsForJobID = 0
|
||||
m.updatedJobStatus = nil
|
||||
m.createdHistory = nil
|
||||
}
|
||||
|
||||
// Mock JobExecutorCron
|
||||
var _ JobExecutorCron = (*mockJobExecutorCron)(nil)
|
||||
|
||||
type mockJobExecutorCron struct {
|
||||
mu sync.Mutex
|
||||
EntryFunc func(id cron.EntryID) cron.Entry
|
||||
|
||||
// Store calls
|
||||
entryCalledWithID cron.EntryID
|
||||
}
|
||||
|
||||
func (m *mockJobExecutorCron) Entry(id cron.EntryID) cron.Entry {
|
||||
m.mu.Lock()
|
||||
m.entryCalledWithID = id
|
||||
m.mu.Unlock()
|
||||
if m.EntryFunc != nil {
|
||||
return m.EntryFunc(id)
|
||||
}
|
||||
// Default: return a basic entry with a future next run time
|
||||
return cron.Entry{
|
||||
ID: id,
|
||||
Next: time.Now().Add(1 * time.Hour),
|
||||
}
|
||||
}
|
||||
func (m *mockJobExecutorCron) Reset() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.entryCalledWithID = 0
|
||||
}
|
||||
|
||||
// Mock JobExecutorTransferExecutor
|
||||
var _ JobExecutorTransferExecutor = (*mockJobExecutorTransferExecutor)(nil)
|
||||
|
||||
type mockJobExecutorTransferExecutor struct {
|
||||
mu sync.Mutex
|
||||
ExecuteConfigTransferFunc func(job db.Job, config db.TransferConfig, history *db.JobHistory)
|
||||
|
||||
// Store calls
|
||||
executeConfigTransferCalls []map[string]interface{}
|
||||
}
|
||||
|
||||
func (m *mockJobExecutorTransferExecutor) executeConfigTransfer(job db.Job, config db.TransferConfig, history *db.JobHistory) {
|
||||
m.mu.Lock()
|
||||
m.executeConfigTransferCalls = append(m.executeConfigTransferCalls, map[string]interface{}{
|
||||
"job": job, "config": config, "history": history,
|
||||
})
|
||||
m.mu.Unlock()
|
||||
if m.ExecuteConfigTransferFunc != nil {
|
||||
m.ExecuteConfigTransferFunc(job, config, history)
|
||||
}
|
||||
// Default: Do nothing, just record the call
|
||||
}
|
||||
func (m *mockJobExecutorTransferExecutor) Reset() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.executeConfigTransferCalls = nil
|
||||
}
|
||||
|
||||
// Mock JobExecutorNotifier
|
||||
var _ JobExecutorNotifier = (*mockJobExecutorNotifier)(nil)
|
||||
|
||||
type mockJobExecutorNotifier struct {
|
||||
mu sync.Mutex
|
||||
SendNotificationsFunc func(job *db.Job, history *db.JobHistory, config *db.TransferConfig)
|
||||
|
||||
// Store calls
|
||||
sendNotificationsCalls []map[string]interface{}
|
||||
}
|
||||
|
||||
func (m *mockJobExecutorNotifier) SendNotifications(job *db.Job, history *db.JobHistory, config *db.TransferConfig) {
|
||||
m.mu.Lock()
|
||||
m.sendNotificationsCalls = append(m.sendNotificationsCalls, map[string]interface{}{
|
||||
"job": job, "history": history, "config": config,
|
||||
})
|
||||
m.mu.Unlock()
|
||||
if m.SendNotificationsFunc != nil {
|
||||
m.SendNotificationsFunc(job, history, config)
|
||||
}
|
||||
}
|
||||
func (m *mockJobExecutorNotifier) Reset() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.sendNotificationsCalls = nil
|
||||
}
|
||||
|
||||
// --- Test Setup ---
|
||||
|
||||
type testJobExecutorComponents struct {
|
||||
db *mockJobExecutorDB
|
||||
logger *Logger
|
||||
logBuf *bytes.Buffer
|
||||
cron *mockJobExecutorCron
|
||||
transfer *mockJobExecutorTransferExecutor
|
||||
notifier *mockJobExecutorNotifier
|
||||
executor *JobExecutor
|
||||
jobsMap map[uint]cron.EntryID
|
||||
jobMutex *sync.Mutex
|
||||
}
|
||||
|
||||
func setupTestJobExecutor() testJobExecutorComponents {
|
||||
dbMock := &mockJobExecutorDB{}
|
||||
logger, logBuf := newTestLogger(LogLevelDebug)
|
||||
cronMock := &mockJobExecutorCron{}
|
||||
transferMock := &mockJobExecutorTransferExecutor{}
|
||||
notifierMock := &mockJobExecutorNotifier{}
|
||||
jobsMap := make(map[uint]cron.EntryID)
|
||||
var jobMutex sync.Mutex
|
||||
|
||||
executor := NewJobExecutor(dbMock, logger, cronMock, jobsMap, &jobMutex, transferMock, notifierMock)
|
||||
|
||||
return testJobExecutorComponents{
|
||||
db: dbMock,
|
||||
logger: logger,
|
||||
logBuf: logBuf,
|
||||
cron: cronMock,
|
||||
transfer: transferMock,
|
||||
notifier: notifierMock,
|
||||
executor: executor,
|
||||
jobsMap: jobsMap,
|
||||
jobMutex: &jobMutex,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
func TestExecuteJob_Success(t *testing.T) {
|
||||
comps := setupTestJobExecutor()
|
||||
defer comps.logger.Close()
|
||||
|
||||
testJobID := uint(1)
|
||||
testCronEntryID := cron.EntryID(10)
|
||||
comps.jobsMap[testJobID] = testCronEntryID // Simulate job being scheduled
|
||||
|
||||
// Configure mocks
|
||||
comps.db.GetConfigsForJobFunc = func(jobID uint) ([]db.TransferConfig, error) {
|
||||
if jobID != testJobID {
|
||||
t.Errorf("GetConfigsForJob called with wrong jobID: got %d, want %d", jobID, testJobID)
|
||||
}
|
||||
// Return configs in a different order than job.ConfigIDs to test ordering logic
|
||||
return []db.TransferConfig{
|
||||
{ID: 2, Name: "Config 2"}, // Corrected initialization
|
||||
{ID: 1, Name: "Config 1"}, // Corrected initialization
|
||||
{ID: 3, Name: "Config 3 (Not in Job Order)"}, // Corrected initialization
|
||||
}, nil
|
||||
}
|
||||
// Ensure the job returned by First has the expected ConfigIDs order
|
||||
comps.db.FirstFunc = func(dest interface{}, conds ...interface{}) *gorm.DB {
|
||||
if job, ok := dest.(*db.Job); ok {
|
||||
job.ID = testJobID
|
||||
job.Name = "Test Job Success"
|
||||
job.ConfigIDs = "1,2" // Explicit order
|
||||
enabled := true
|
||||
job.Enabled = &enabled
|
||||
return &gorm.DB{Error: nil}
|
||||
}
|
||||
return &gorm.DB{Error: gorm.ErrRecordNotFound}
|
||||
}
|
||||
|
||||
// Execute the job
|
||||
comps.executor.executeJob(testJobID)
|
||||
|
||||
// Assertions
|
||||
// 1. DB calls
|
||||
comps.db.mu.Lock()
|
||||
if comps.db.firstCalledWithDest == nil {
|
||||
t.Error("DB First was not called")
|
||||
}
|
||||
if comps.db.configsForJobID != testJobID {
|
||||
t.Errorf("GetConfigsForJob not called with correct jobID: got %d, want %d", comps.db.configsForJobID, testJobID)
|
||||
}
|
||||
if comps.db.updatedJobStatus == nil {
|
||||
t.Error("DB UpdateJobStatus was not called")
|
||||
} else if comps.db.updatedJobStatus.LastRun == nil {
|
||||
t.Error("LastRun time was not updated")
|
||||
} else if comps.db.updatedJobStatus.NextRun == nil {
|
||||
t.Error("NextRun time was not updated")
|
||||
}
|
||||
if comps.db.createdHistory == nil {
|
||||
t.Error("DB CreateJobHistory was not called")
|
||||
}
|
||||
comps.db.mu.Unlock()
|
||||
|
||||
// 2. Cron calls
|
||||
comps.cron.mu.Lock()
|
||||
if comps.cron.entryCalledWithID != testCronEntryID {
|
||||
t.Errorf("Cron Entry not called with correct entryID: got %d, want %d", comps.cron.entryCalledWithID, testCronEntryID)
|
||||
}
|
||||
comps.cron.mu.Unlock()
|
||||
|
||||
// 3. Notifier calls (via processConfiguration -> transferExecutor)
|
||||
comps.notifier.mu.Lock()
|
||||
// Expect one call per configuration processed (1, 2, then 3)
|
||||
if len(comps.notifier.sendNotificationsCalls) != 3 { // Expect 3 calls now
|
||||
t.Errorf("Expected 3 calls to SendNotifications, got %d", len(comps.notifier.sendNotificationsCalls))
|
||||
}
|
||||
comps.notifier.mu.Unlock()
|
||||
|
||||
// 4. TransferExecutor calls
|
||||
comps.transfer.mu.Lock()
|
||||
if len(comps.transfer.executeConfigTransferCalls) != 3 { // Expect 3 calls now
|
||||
t.Errorf("Expected 3 calls to executeConfigTransfer, got %d", len(comps.transfer.executeConfigTransferCalls))
|
||||
} else {
|
||||
// Check order (1, 2, then 3)
|
||||
call1 := comps.transfer.executeConfigTransferCalls[0]
|
||||
call2 := comps.transfer.executeConfigTransferCalls[1]
|
||||
call3 := comps.transfer.executeConfigTransferCalls[2]
|
||||
if cfg1, ok := call1["config"].(db.TransferConfig); !ok || cfg1.ID != 1 {
|
||||
t.Errorf("Expected first transfer call for config ID 1, got %+v", call1["config"])
|
||||
}
|
||||
if cfg2, ok := call2["config"].(db.TransferConfig); !ok || cfg2.ID != 2 {
|
||||
t.Errorf("Expected second transfer call for config ID 2, got %+v", call2["config"])
|
||||
}
|
||||
if cfg3, ok := call3["config"].(db.TransferConfig); !ok || cfg3.ID != 3 {
|
||||
t.Errorf("Expected third transfer call for config ID 3, got %+v", call3["config"])
|
||||
}
|
||||
}
|
||||
comps.transfer.mu.Unlock()
|
||||
|
||||
// 5. Logs
|
||||
logOutput := comps.logBuf.String()
|
||||
// Check for specific log messages in order
|
||||
expectedLogs := []string{
|
||||
fmt.Sprintf("Starting execution of job %d", testJobID),
|
||||
"Processing job 1 with 3 configurations in specified order", // Uses total configs found
|
||||
"Execution order 1/3: Config ID 1", // Uses total configs found
|
||||
"Processing configuration 1 (1/3) for job 1", // Log from processConfiguration
|
||||
"Execution order 2/3: Config ID 2", // Uses total configs found
|
||||
"Processing configuration 2 (2/3) for job 1", // Log from processConfiguration
|
||||
"Execution order 3/3: Config ID 3", // Uses total configs found
|
||||
"Processing configuration 3 (3/3) for job 1", // Log from processConfiguration for extra config
|
||||
fmt.Sprintf("Next run time for job %d", testJobID),
|
||||
}
|
||||
for _, expectedLog := range expectedLogs {
|
||||
if !strings.Contains(logOutput, expectedLog) {
|
||||
t.Errorf("Expected log message containing %q not found in output:\n%s", expectedLog, logOutput)
|
||||
}
|
||||
}
|
||||
// Removed extra closing brace
|
||||
}
|
||||
|
||||
func TestExecuteJob_JobNotFound(t *testing.T) {
|
||||
comps := setupTestJobExecutor()
|
||||
defer comps.logger.Close()
|
||||
testJobID := uint(5)
|
||||
|
||||
// Configure mocks
|
||||
comps.db.FirstFunc = func(dest interface{}, conds ...interface{}) *gorm.DB {
|
||||
return &gorm.DB{Error: gorm.ErrRecordNotFound} // Simulate job not found
|
||||
}
|
||||
|
||||
comps.executor.executeJob(testJobID)
|
||||
|
||||
// Assertions
|
||||
logOutput := comps.logBuf.String()
|
||||
if !strings.Contains(logOutput, fmt.Sprintf("Error loading job %d: record not found", testJobID)) {
|
||||
t.Errorf("Expected 'Error loading job' log message not found in output:\n%s", logOutput)
|
||||
}
|
||||
// Ensure other dependent functions were not called
|
||||
comps.db.mu.Lock()
|
||||
if comps.db.configsForJobID != 0 {
|
||||
t.Error("GetConfigsForJob should not have been called")
|
||||
}
|
||||
comps.db.mu.Unlock()
|
||||
comps.transfer.mu.Lock()
|
||||
if len(comps.transfer.executeConfigTransferCalls) > 0 {
|
||||
t.Error("executeConfigTransfer should not have been called")
|
||||
}
|
||||
comps.transfer.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestExecuteJob_ConfigLoadError(t *testing.T) {
|
||||
comps := setupTestJobExecutor()
|
||||
defer comps.logger.Close()
|
||||
testJobID := uint(6)
|
||||
dbErr := errors.New("db connection failed")
|
||||
|
||||
// Configure mocks
|
||||
comps.db.GetConfigsForJobFunc = func(jobID uint) ([]db.TransferConfig, error) {
|
||||
return nil, dbErr // Simulate error loading configs
|
||||
}
|
||||
|
||||
comps.executor.executeJob(testJobID)
|
||||
|
||||
// Assertions
|
||||
logOutput := comps.logBuf.String()
|
||||
if !strings.Contains(logOutput, fmt.Sprintf("Error loading configurations for job %d: %v", testJobID, dbErr)) {
|
||||
t.Errorf("Expected 'Error loading configurations' log message not found in output:\n%s", logOutput)
|
||||
}
|
||||
comps.transfer.mu.Lock()
|
||||
if len(comps.transfer.executeConfigTransferCalls) > 0 {
|
||||
t.Error("executeConfigTransfer should not have been called")
|
||||
}
|
||||
comps.transfer.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestExecuteJob_NoConfigs(t *testing.T) {
|
||||
comps := setupTestJobExecutor()
|
||||
defer comps.logger.Close()
|
||||
testJobID := uint(7)
|
||||
|
||||
// Configure mocks
|
||||
comps.db.GetConfigsForJobFunc = func(jobID uint) ([]db.TransferConfig, error) {
|
||||
return []db.TransferConfig{}, nil // Simulate empty config list
|
||||
}
|
||||
|
||||
comps.executor.executeJob(testJobID)
|
||||
|
||||
// Assertions
|
||||
logOutput := comps.logBuf.String()
|
||||
if !strings.Contains(logOutput, fmt.Sprintf("Error: job %d has no associated configurations", testJobID)) {
|
||||
t.Errorf("Expected 'no associated configurations' log message not found in output:\n%s", logOutput)
|
||||
}
|
||||
comps.transfer.mu.Lock()
|
||||
if len(comps.transfer.executeConfigTransferCalls) > 0 {
|
||||
t.Error("executeConfigTransfer should not have been called")
|
||||
}
|
||||
comps.transfer.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestProcessConfiguration_Success(t *testing.T) {
|
||||
comps := setupTestJobExecutor()
|
||||
defer comps.logger.Close()
|
||||
|
||||
job := db.Job{ID: 1}
|
||||
config := db.TransferConfig{ID: 10, Name: "Process Test"} // Corrected initialization
|
||||
index := 1
|
||||
totalConfigs := 1
|
||||
|
||||
comps.executor.processConfiguration(&job, &config, index, totalConfigs)
|
||||
|
||||
// Assertions
|
||||
// 1. DB CreateJobHistory called
|
||||
comps.db.mu.Lock()
|
||||
if comps.db.createdHistory == nil {
|
||||
t.Fatal("CreateJobHistory was not called")
|
||||
}
|
||||
if comps.db.createdHistory.JobID != job.ID {
|
||||
t.Errorf("CreateJobHistory called with wrong JobID: got %d, want %d", comps.db.createdHistory.JobID, job.ID)
|
||||
}
|
||||
if comps.db.createdHistory.ConfigID != config.ID {
|
||||
t.Errorf("CreateJobHistory called with wrong ConfigID: got %d, want %d", comps.db.createdHistory.ConfigID, config.ID)
|
||||
}
|
||||
if comps.db.createdHistory.Status != "running" {
|
||||
t.Errorf("CreateJobHistory called with wrong Status: got %q, want 'running'", comps.db.createdHistory.Status)
|
||||
}
|
||||
comps.db.mu.Unlock()
|
||||
|
||||
// 2. Notifier SendNotifications called
|
||||
comps.notifier.mu.Lock()
|
||||
if len(comps.notifier.sendNotificationsCalls) != 1 {
|
||||
t.Fatalf("Expected 1 call to SendNotifications, got %d", len(comps.notifier.sendNotificationsCalls))
|
||||
}
|
||||
callArgs := comps.notifier.sendNotificationsCalls[0]
|
||||
if !reflect.DeepEqual(callArgs["job"], &job) {
|
||||
t.Errorf("SendNotifications called with wrong job: got %+v, want %+v", callArgs["job"], &job)
|
||||
}
|
||||
// Compare history partially as StartTime is dynamic
|
||||
if histArg, ok := callArgs["history"].(*db.JobHistory); !ok || histArg.JobID != job.ID || histArg.ConfigID != config.ID || histArg.Status != "running" {
|
||||
t.Errorf("SendNotifications called with wrong history: got %+v", callArgs["history"])
|
||||
}
|
||||
if !reflect.DeepEqual(callArgs["config"], &config) {
|
||||
t.Errorf("SendNotifications called with wrong config: got %+v, want %+v", callArgs["config"], &config)
|
||||
}
|
||||
comps.notifier.mu.Unlock()
|
||||
|
||||
// 3. TransferExecutor executeConfigTransfer called
|
||||
comps.transfer.mu.Lock()
|
||||
if len(comps.transfer.executeConfigTransferCalls) != 1 {
|
||||
t.Fatalf("Expected 1 call to executeConfigTransfer, got %d", len(comps.transfer.executeConfigTransferCalls))
|
||||
}
|
||||
transferCallArgs := comps.transfer.executeConfigTransferCalls[0]
|
||||
// Need to compare job/config by value as they are passed by value to transferExecutor
|
||||
if !reflect.DeepEqual(transferCallArgs["job"], job) {
|
||||
t.Errorf("executeConfigTransfer called with wrong job: got %+v, want %+v", transferCallArgs["job"], job)
|
||||
}
|
||||
if !reflect.DeepEqual(transferCallArgs["config"], config) {
|
||||
t.Errorf("executeConfigTransfer called with wrong config: got %+v, want %+v", transferCallArgs["config"], config)
|
||||
}
|
||||
// Compare history partially
|
||||
if histArg, ok := transferCallArgs["history"].(*db.JobHistory); !ok || histArg.JobID != job.ID || histArg.ConfigID != config.ID || histArg.Status != "running" {
|
||||
t.Errorf("executeConfigTransfer called with wrong history: got %+v", transferCallArgs["history"])
|
||||
}
|
||||
comps.transfer.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestProcessConfiguration_HistoryError(t *testing.T) {
|
||||
comps := setupTestJobExecutor()
|
||||
defer comps.logger.Close()
|
||||
|
||||
job := db.Job{ID: 1}
|
||||
config := db.TransferConfig{ID: 10, Name: "History Error Test"} // Corrected initialization
|
||||
index := 1
|
||||
totalConfigs := 1
|
||||
dbErr := errors.New("failed to create history")
|
||||
|
||||
// Configure mock
|
||||
comps.db.CreateJobHistoryFunc = func(history *db.JobHistory) error {
|
||||
return dbErr
|
||||
}
|
||||
|
||||
comps.executor.processConfiguration(&job, &config, index, totalConfigs)
|
||||
|
||||
// Assertions
|
||||
// 1. Check log for error
|
||||
logOutput := comps.logBuf.String()
|
||||
if !strings.Contains(logOutput, fmt.Sprintf("Error creating job history for job %d, config %d: %v", job.ID, config.ID, dbErr)) {
|
||||
t.Errorf("Expected 'Error creating job history' log message not found in output:\n%s", logOutput)
|
||||
}
|
||||
|
||||
// 2. Ensure Notifier and TransferExecutor were NOT called
|
||||
comps.notifier.mu.Lock()
|
||||
if len(comps.notifier.sendNotificationsCalls) > 0 {
|
||||
t.Error("SendNotifications should not have been called after history error")
|
||||
}
|
||||
comps.notifier.mu.Unlock()
|
||||
|
||||
comps.transfer.mu.Lock()
|
||||
if len(comps.transfer.executeConfigTransferCalls) > 0 {
|
||||
t.Error("executeConfigTransfer should not have been called after history error")
|
||||
}
|
||||
comps.transfer.mu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
)
|
||||
|
||||
// Helper function to create a logger with a buffer for testing output
|
||||
func newTestLogger(level LogLevel) (*Logger, *bytes.Buffer) {
|
||||
var buf bytes.Buffer
|
||||
// Use a discard lumberjack logger for testing purposes
|
||||
discardLumberjack := &lumberjack.Logger{
|
||||
Filename: filepath.Join(os.TempDir(), "test-discard.log"), // Write to temp dir
|
||||
MaxSize: 1,
|
||||
MaxBackups: 1,
|
||||
MaxAge: 1,
|
||||
Compress: false,
|
||||
}
|
||||
// Ensure the temp file can be cleaned up
|
||||
os.Remove(discardLumberjack.Filename)
|
||||
|
||||
logger := &Logger{
|
||||
Info: log.New(&buf, "INFO: ", 0), // No flags for simpler matching
|
||||
Error: log.New(&buf, "ERROR: ", 0),
|
||||
Debug: log.New(&buf, "DEBUG: ", 0),
|
||||
file: discardLumberjack, // Use discard logger
|
||||
logLevel: level,
|
||||
}
|
||||
return logger, &buf
|
||||
}
|
||||
|
||||
func TestLogLevelString(t *testing.T) {
|
||||
tests := []struct {
|
||||
level LogLevel
|
||||
want string
|
||||
}{
|
||||
{LogLevelError, "error"},
|
||||
{LogLevelInfo, "info"},
|
||||
{LogLevelDebug, "debug"},
|
||||
{LogLevel(99), "unknown"}, // Test unknown level
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := tt.level.String(); got != tt.want {
|
||||
t.Errorf("LogLevel(%d).String() = %q, want %q", tt.level, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLogLevel(t *testing.T) {
|
||||
tests := []struct {
|
||||
levelStr string
|
||||
want LogLevel
|
||||
}{
|
||||
{"error", LogLevelError},
|
||||
{"ERROR", LogLevelError},
|
||||
{"info", LogLevelInfo},
|
||||
{"INFO", LogLevelInfo},
|
||||
{"debug", LogLevelDebug},
|
||||
{"DEBUG", LogLevelDebug},
|
||||
{"", LogLevelInfo}, // Default
|
||||
{"unknown", LogLevelInfo}, // Default
|
||||
{"warn", LogLevelInfo}, // Default
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := ParseLogLevel(tt.levelStr); got != tt.want {
|
||||
t.Errorf("ParseLogLevel(%q) = %v, want %v", tt.levelStr, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoggerOutputLevels(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
level LogLevel
|
||||
logFunc func(l *Logger, format string, v ...interface{})
|
||||
wantPrefix string
|
||||
wantMessage string
|
||||
}{
|
||||
// LogError tests
|
||||
{"ErrorLevel_LogError", LogLevelError, (*Logger).LogError, "ERROR: ", "error message 1"},
|
||||
{"InfoLevel_LogError", LogLevelInfo, (*Logger).LogError, "ERROR: ", "error message 2"},
|
||||
{"DebugLevel_LogError", LogLevelDebug, (*Logger).LogError, "ERROR: ", "error message 3"},
|
||||
// LogInfo tests
|
||||
{"ErrorLevel_LogInfo", LogLevelError, (*Logger).LogInfo, "", ""}, // Should not log
|
||||
{"InfoLevel_LogInfo", LogLevelInfo, (*Logger).LogInfo, "INFO: ", "info message 1"},
|
||||
{"DebugLevel_LogInfo", LogLevelDebug, (*Logger).LogInfo, "INFO: ", "info message 2"},
|
||||
// LogDebug tests
|
||||
{"ErrorLevel_LogDebug", LogLevelError, (*Logger).LogDebug, "", ""}, // Should not log
|
||||
{"InfoLevel_LogDebug", LogLevelInfo, (*Logger).LogDebug, "", ""}, // Should not log
|
||||
{"DebugLevel_LogDebug", LogLevelDebug, (*Logger).LogDebug, "DEBUG: ", "debug message 1"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
logger, buf := newTestLogger(tt.level)
|
||||
defer logger.Close() // Close the discard logger
|
||||
|
||||
message := tt.wantMessage // Use the message intended for the successful case
|
||||
if tt.wantPrefix == "" {
|
||||
message = "should not appear" // Use a different message if it shouldn't log
|
||||
}
|
||||
|
||||
tt.logFunc(logger, "%s %d", message, 42) // Add formatting args
|
||||
|
||||
got := buf.String()
|
||||
expectedOutput := ""
|
||||
if tt.wantPrefix != "" {
|
||||
expectedOutput = tt.wantPrefix + message + " 42\n" // Include formatting args in expected output
|
||||
}
|
||||
|
||||
if got != expectedOutput {
|
||||
t.Errorf("Log output = %q, want %q", got, expectedOutput)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLoggerInitialization(t *testing.T) {
|
||||
// Temporarily set env vars for testing initialization
|
||||
os.Setenv("DATA_DIR", "/tmp/test_gomft_data")
|
||||
os.Setenv("LOGS_DIR", "/tmp/test_gomft_data/logs")
|
||||
os.Setenv("LOG_LEVEL", "debug")
|
||||
os.Setenv("LOG_MAX_SIZE", "5")
|
||||
os.Setenv("LOG_MAX_BACKUPS", "2")
|
||||
os.Setenv("LOG_MAX_AGE", "7")
|
||||
os.Setenv("LOG_COMPRESS", "false")
|
||||
|
||||
defer func() {
|
||||
// Clean up env vars and created directories
|
||||
os.Unsetenv("DATA_DIR")
|
||||
os.Unsetenv("LOGS_DIR")
|
||||
os.Unsetenv("LOG_LEVEL")
|
||||
os.Unsetenv("LOG_MAX_SIZE")
|
||||
os.Unsetenv("LOG_MAX_BACKUPS")
|
||||
os.Unsetenv("LOG_MAX_AGE")
|
||||
os.Unsetenv("LOG_COMPRESS")
|
||||
os.RemoveAll("/tmp/test_gomft_data")
|
||||
}()
|
||||
|
||||
// Capture stdout to check initialization logs
|
||||
oldStdout := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
logger := NewLogger()
|
||||
defer logger.Close()
|
||||
|
||||
w.Close()
|
||||
os.Stdout = oldStdout // Restore stdout
|
||||
|
||||
var buf bytes.Buffer
|
||||
io.Copy(&buf, r)
|
||||
initOutput := buf.String()
|
||||
|
||||
// Check log level
|
||||
if logger.logLevel != LogLevelDebug {
|
||||
t.Errorf("Expected log level %v, got %v", LogLevelDebug, logger.logLevel)
|
||||
}
|
||||
|
||||
// Check lumberjack config
|
||||
if logger.file.MaxSize != 5 {
|
||||
t.Errorf("Expected MaxSize 5, got %d", logger.file.MaxSize)
|
||||
}
|
||||
if logger.file.MaxBackups != 2 {
|
||||
t.Errorf("Expected MaxBackups 2, got %d", logger.file.MaxBackups)
|
||||
}
|
||||
if logger.file.MaxAge != 7 {
|
||||
t.Errorf("Expected MaxAge 7, got %d", logger.file.MaxAge)
|
||||
}
|
||||
if logger.file.Compress != false {
|
||||
t.Errorf("Expected Compress false, got %v", logger.file.Compress)
|
||||
}
|
||||
expectedLogPath := filepath.Join("/tmp/test_gomft_data/logs", "scheduler.log")
|
||||
if logger.file.Filename != expectedLogPath {
|
||||
t.Errorf("Expected Filename %q, got %q", expectedLogPath, logger.file.Filename)
|
||||
}
|
||||
|
||||
// Check if logs directory was created
|
||||
if _, err := os.Stat("/tmp/test_gomft_data/logs"); os.IsNotExist(err) {
|
||||
t.Errorf("Expected logs directory %q to be created", "/tmp/test_gomft_data/logs")
|
||||
}
|
||||
|
||||
// Check initialization log messages
|
||||
if !strings.Contains(initOutput, "Log rotation configured:") {
|
||||
t.Errorf("Expected initialization log message 'Log rotation configured:', but not found in output:\n%s", initOutput)
|
||||
}
|
||||
if !strings.Contains(initOutput, "logLevel=debug") {
|
||||
t.Errorf("Expected 'logLevel=debug' in initialization log, but not found in output:\n%s", initOutput)
|
||||
}
|
||||
if !strings.Contains(initOutput, "Log rotation details:") {
|
||||
t.Errorf("Expected initialization log message 'Log rotation details:', but not found in output:\n%s", initOutput)
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Testing Close() and RotateLogs() directly would require more complex mocking
|
||||
// of the lumberjack.Logger or filesystem interactions. For now, we focus on the
|
||||
// Logger wrapper's core logic (level handling, formatting).
|
||||
@@ -1,19 +1,28 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/starfleetcptn/gomft/internal/db"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// MetadataDB defines the database methods needed by MetadataHandler.
|
||||
// This allows for easier mocking during testing.
|
||||
type MetadataDB interface {
|
||||
GetFileMetadataByHash(hash string) (*db.FileMetadata, error)
|
||||
GetFileMetadataByJobAndName(jobID uint, fileName string) (*db.FileMetadata, error)
|
||||
}
|
||||
|
||||
// MetadataHandler handles checking file processing history.
|
||||
type MetadataHandler struct {
|
||||
db *db.DB
|
||||
logger *Logger // Added logger dependency
|
||||
db MetadataDB // Use the interface type
|
||||
logger *Logger // Added logger dependency
|
||||
}
|
||||
|
||||
// NewMetadataHandler creates a new MetadataHandler.
|
||||
func NewMetadataHandler(database *db.DB, logger *Logger) *MetadataHandler {
|
||||
func NewMetadataHandler(database MetadataDB, logger *Logger) *MetadataHandler { // Accept the interface type
|
||||
return &MetadataHandler{
|
||||
db: database,
|
||||
logger: logger,
|
||||
@@ -27,23 +36,31 @@ func (mh *MetadataHandler) hasFileBeenProcessed(jobID uint, fileHash string) (bo
|
||||
}
|
||||
|
||||
// First try to find by hash (most reliable)
|
||||
metadata, err := mh.db.GetFileMetadataByHash(fileHash)
|
||||
metadata, err := mh.db.GetFileMetadataByHash(fileHash) // Calls the interface method
|
||||
if err == nil && metadata != nil {
|
||||
// Optional: Add logging here if needed
|
||||
mh.logger.LogDebug("Found existing metadata by hash for job %d, hash %s", jobID, fileHash)
|
||||
return true, metadata, nil
|
||||
}
|
||||
// Handle DB errors
|
||||
if err != nil {
|
||||
// If the error is specifically "record not found", it means not processed, which is not an error for this function.
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, nil, nil // Not found, no error to return
|
||||
}
|
||||
// For any other DB error, log it and return it.
|
||||
mh.logger.LogError("Error checking metadata by hash for job %d, hash %s: %v", jobID, fileHash, err)
|
||||
return false, nil, err // Return the actual DB error
|
||||
}
|
||||
|
||||
return false, nil, err // Return the error if one occurred during DB lookup
|
||||
// Should not be reached if err is nil and metadata is nil, but return false just in case.
|
||||
return false, nil, nil
|
||||
}
|
||||
|
||||
// checkFileProcessingHistory checks processing history for a given file name within a specific job.
|
||||
func (mh *MetadataHandler) checkFileProcessingHistory(jobID uint, fileName string) (*db.FileMetadata, error) {
|
||||
// Try to find by job and filename
|
||||
metadata, err := mh.db.GetFileMetadataByJobAndName(jobID, fileName)
|
||||
metadata, err := mh.db.GetFileMetadataByJobAndName(jobID, fileName) // Calls the interface method
|
||||
if err == nil && metadata != nil {
|
||||
mh.logger.LogDebug("Found existing metadata by name for job %d, file %s", jobID, fileName)
|
||||
return metadata, nil
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/starfleetcptn/gomft/internal/db"
|
||||
"gorm.io/gorm" // Keep for gorm.ErrRecordNotFound
|
||||
)
|
||||
|
||||
// --- Mock DB Implementation ---
|
||||
|
||||
// Ensure mockMetadataDB implements the MetadataDB interface
|
||||
var _ MetadataDB = (*mockMetadataDB)(nil)
|
||||
|
||||
type mockMetadataDB struct {
|
||||
GetFileMetadataByHashFunc func(hash string) (*db.FileMetadata, error)
|
||||
GetFileMetadataByJobAndNameFunc func(jobID uint, fileName string) (*db.FileMetadata, error)
|
||||
}
|
||||
|
||||
// Implement the MetadataDB interface methods
|
||||
func (m *mockMetadataDB) GetFileMetadataByHash(hash string) (*db.FileMetadata, error) {
|
||||
if m.GetFileMetadataByHashFunc != nil {
|
||||
return m.GetFileMetadataByHashFunc(hash)
|
||||
}
|
||||
return nil, errors.New("mock GetFileMetadataByHashFunc not implemented")
|
||||
}
|
||||
|
||||
func (m *mockMetadataDB) GetFileMetadataByJobAndName(jobID uint, fileName string) (*db.FileMetadata, error) {
|
||||
if m.GetFileMetadataByJobAndNameFunc != nil {
|
||||
return m.GetFileMetadataByJobAndNameFunc(jobID, fileName)
|
||||
}
|
||||
return nil, errors.New("mock GetFileMetadataByJobAndNameFunc not implemented")
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
func TestHasFileBeenProcessed(t *testing.T) {
|
||||
testJobID := uint(1)
|
||||
testHash := "testhash123"
|
||||
testMetadata := &db.FileMetadata{ID: 1, JobID: testJobID, FileHash: testHash, Status: "processed"}
|
||||
dbErr := errors.New("database error")
|
||||
|
||||
logger, _ := newTestLogger(LogLevelDebug) // Use helper from logger_test
|
||||
defer logger.Close()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
fileHash string
|
||||
mockDBFunc func(hash string) (*db.FileMetadata, error)
|
||||
wantProcessed bool
|
||||
wantMetadata *db.FileMetadata
|
||||
wantErr error
|
||||
wantLogMessage string // Optional: check log output
|
||||
}{
|
||||
{
|
||||
name: "Empty hash",
|
||||
fileHash: "",
|
||||
mockDBFunc: nil, // Not called
|
||||
wantProcessed: false,
|
||||
wantMetadata: nil,
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "Hash found",
|
||||
fileHash: testHash,
|
||||
mockDBFunc: func(hash string) (*db.FileMetadata, error) {
|
||||
if hash == testHash {
|
||||
return testMetadata, nil
|
||||
}
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
},
|
||||
wantProcessed: true,
|
||||
wantMetadata: testMetadata,
|
||||
wantErr: nil,
|
||||
wantLogMessage: "Found existing metadata by hash",
|
||||
},
|
||||
{
|
||||
name: "Hash not found",
|
||||
fileHash: testHash,
|
||||
mockDBFunc: func(hash string) (*db.FileMetadata, error) {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
},
|
||||
wantProcessed: false,
|
||||
wantMetadata: nil,
|
||||
wantErr: nil, // Not found is not an error for this function's return
|
||||
},
|
||||
{
|
||||
name: "DB error",
|
||||
fileHash: testHash,
|
||||
mockDBFunc: func(hash string) (*db.FileMetadata, error) {
|
||||
return nil, dbErr
|
||||
},
|
||||
wantProcessed: false,
|
||||
wantMetadata: nil,
|
||||
wantErr: dbErr, // The DB error should be returned
|
||||
wantLogMessage: "Error checking metadata by hash",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mockDB := &mockMetadataDB{ // Instantiate the mock implementing the interface
|
||||
GetFileMetadataByHashFunc: tt.mockDBFunc,
|
||||
}
|
||||
// Recreate logger and buffer for each test run to isolate logs
|
||||
logger, logBuf := newTestLogger(LogLevelDebug)
|
||||
defer logger.Close()
|
||||
|
||||
// Pass the mockDB which now satisfies the MetadataDB interface
|
||||
handler := NewMetadataHandler(mockDB, logger)
|
||||
|
||||
processed, metadata, err := handler.hasFileBeenProcessed(testJobID, tt.fileHash)
|
||||
|
||||
if processed != tt.wantProcessed {
|
||||
t.Errorf("hasFileBeenProcessed() processed = %v, want %v", processed, tt.wantProcessed)
|
||||
}
|
||||
if metadata != tt.wantMetadata {
|
||||
t.Errorf("hasFileBeenProcessed() metadata = %v, want %v", metadata, tt.wantMetadata)
|
||||
}
|
||||
if err != tt.wantErr {
|
||||
t.Errorf("hasFileBeenProcessed() error = %v, want %v", err, tt.wantErr)
|
||||
}
|
||||
|
||||
logOutput := logBuf.String()
|
||||
if tt.wantLogMessage != "" && !strings.Contains(logOutput, tt.wantLogMessage) {
|
||||
t.Errorf("Expected log message containing %q, but got:\n%s", tt.wantLogMessage, logOutput)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckFileProcessingHistory(t *testing.T) {
|
||||
testJobID := uint(1)
|
||||
testFileName := "testfile.txt"
|
||||
testMetadata := &db.FileMetadata{ID: 2, JobID: testJobID, FileName: testFileName, Status: "processed"}
|
||||
dbErr := errors.New("database error")
|
||||
notFoundErr := fmt.Errorf("no history found for file %s in job %d", testFileName, testJobID)
|
||||
|
||||
logger, _ := newTestLogger(LogLevelDebug) // Use helper from logger_test
|
||||
defer logger.Close()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
jobID uint
|
||||
fileName string
|
||||
mockDBFunc func(jobID uint, fileName string) (*db.FileMetadata, error)
|
||||
wantMetadata *db.FileMetadata
|
||||
wantErr error // Check for specific error type/message
|
||||
wantLogMessage string // Optional: check log output
|
||||
}{
|
||||
{
|
||||
name: "History found",
|
||||
jobID: testJobID,
|
||||
fileName: testFileName,
|
||||
mockDBFunc: func(jobID uint, fileName string) (*db.FileMetadata, error) {
|
||||
if jobID == testJobID && fileName == testFileName {
|
||||
return testMetadata, nil
|
||||
}
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
},
|
||||
wantMetadata: testMetadata,
|
||||
wantErr: nil,
|
||||
wantLogMessage: "Found existing metadata by name",
|
||||
},
|
||||
{
|
||||
name: "History not found",
|
||||
jobID: testJobID,
|
||||
fileName: testFileName,
|
||||
mockDBFunc: func(jobID uint, fileName string) (*db.FileMetadata, error) {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
},
|
||||
wantMetadata: nil,
|
||||
wantErr: notFoundErr, // Expect the specific "no history found" error
|
||||
},
|
||||
{
|
||||
name: "DB error",
|
||||
jobID: testJobID,
|
||||
fileName: testFileName,
|
||||
mockDBFunc: func(jobID uint, fileName string) (*db.FileMetadata, error) {
|
||||
return nil, dbErr
|
||||
},
|
||||
wantMetadata: nil,
|
||||
wantErr: notFoundErr, // Even with DB error, it returns "no history found"
|
||||
wantLogMessage: "Error checking metadata by name",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mockDB := &mockMetadataDB{ // Instantiate the mock implementing the interface
|
||||
GetFileMetadataByJobAndNameFunc: tt.mockDBFunc,
|
||||
}
|
||||
// Recreate logger and buffer for each test run
|
||||
logger, logBuf := newTestLogger(LogLevelDebug)
|
||||
defer logger.Close()
|
||||
|
||||
// Pass the mockDB which now satisfies the MetadataDB interface
|
||||
handler := NewMetadataHandler(mockDB, logger)
|
||||
|
||||
metadata, err := handler.checkFileProcessingHistory(tt.jobID, tt.fileName)
|
||||
|
||||
if metadata != tt.wantMetadata {
|
||||
t.Errorf("checkFileProcessingHistory() metadata = %v, want %v", metadata, tt.wantMetadata)
|
||||
}
|
||||
|
||||
// Check error message specifically for "not found" cases
|
||||
if tt.wantErr != nil {
|
||||
if err == nil {
|
||||
t.Errorf("checkFileProcessingHistory() error = nil, want error containing %q", tt.wantErr.Error())
|
||||
} else if !strings.Contains(err.Error(), tt.wantErr.Error()) {
|
||||
t.Errorf("checkFileProcessingHistory() error = %q, want error containing %q", err.Error(), tt.wantErr.Error())
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Errorf("checkFileProcessingHistory() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
logOutput := logBuf.String()
|
||||
if tt.wantLogMessage != "" && !strings.Contains(logOutput, tt.wantLogMessage) {
|
||||
t.Errorf("Expected log message containing %q, but got:\n%s", tt.wantLogMessage, logOutput)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -16,16 +16,26 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/starfleetcptn/gomft/internal/db"
|
||||
"gorm.io/gorm" // Needed for the Create method signature in the interface
|
||||
)
|
||||
|
||||
// NotificationDB defines the database methods needed by Notifier.
|
||||
type NotificationDB interface {
|
||||
GetNotificationServices(enabledOnly bool) ([]db.NotificationService, error)
|
||||
UpdateNotificationService(service *db.NotificationService) error
|
||||
GetJob(jobID uint) (*db.Job, error)
|
||||
CreateJobNotification(userID uint, jobID uint, historyID uint, notificationType db.NotificationType, title string, message string) error
|
||||
Create(value interface{}) *gorm.DB // Used by createJobHistoryAndNotify
|
||||
}
|
||||
|
||||
// Notifier handles sending notifications via various services.
|
||||
type Notifier struct {
|
||||
db *db.DB
|
||||
db NotificationDB // Use the interface type
|
||||
logger *Logger
|
||||
}
|
||||
|
||||
// NewNotifier creates a new Notifier.
|
||||
func NewNotifier(database *db.DB, logger *Logger) *Notifier {
|
||||
func NewNotifier(database NotificationDB, logger *Logger) *Notifier { // Accept the interface type
|
||||
return &Notifier{
|
||||
db: database,
|
||||
logger: logger,
|
||||
@@ -154,7 +164,7 @@ func (n *Notifier) sendJobWebhookNotification(job *db.Job, history *db.JobHistor
|
||||
// sendGlobalNotifications sends notifications through all configured notification services.
|
||||
func (n *Notifier) sendGlobalNotifications(job *db.Job, history *db.JobHistory, config *db.TransferConfig) {
|
||||
// Fetch all enabled notification services
|
||||
services, err := n.db.GetNotificationServices(true)
|
||||
services, err := n.db.GetNotificationServices(true) // Calls interface method
|
||||
if err != nil {
|
||||
n.logger.LogError("Error fetching notification services: %v", err)
|
||||
return
|
||||
@@ -200,6 +210,7 @@ func (n *Notifier) sendGlobalNotifications(job *db.Job, history *db.JobHistory,
|
||||
continue
|
||||
}
|
||||
|
||||
// --- Moved Update Logic Inside this block ---
|
||||
n.logger.LogInfo("Sending notification via service %s (%s) for job %d",
|
||||
service.Name, service.Type, job.ID)
|
||||
|
||||
@@ -220,6 +231,7 @@ func (n *Notifier) sendGlobalNotifications(job *db.Job, history *db.JobHistory,
|
||||
notifyErr = n.sendPushoverNotification(service, job, history, config, eventType)
|
||||
default:
|
||||
n.logger.LogError("Unsupported notification service type: %s", service.Type)
|
||||
// Skip update logic below if type is unsupported
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -234,10 +246,12 @@ func (n *Notifier) sendGlobalNotifications(job *db.Job, history *db.JobHistory,
|
||||
}
|
||||
|
||||
// Update notification service stats in the database
|
||||
if err := n.db.UpdateNotificationService(service); err != nil {
|
||||
n.logger.LogError("Error updating notification service stats: %v", err)
|
||||
if err := n.db.UpdateNotificationService(service); err != nil { // Calls interface method
|
||||
n.logger.LogError("Error updating notification service stats for service %s: %v", service.Name, err)
|
||||
}
|
||||
}
|
||||
// --- End of Moved Update Logic ---
|
||||
|
||||
} // End of loop through services
|
||||
}
|
||||
|
||||
// sendEmailNotification sends an email notification using the configured email service.
|
||||
@@ -614,10 +628,10 @@ func (n *Notifier) updateJobStatus(jobID uint, status string, startTime, endTime
|
||||
}
|
||||
|
||||
// Get job details to find the creator for notification targeting
|
||||
job, err := n.db.GetJob(jobID)
|
||||
job, err := n.db.GetJob(jobID) // Calls interface method
|
||||
if err != nil {
|
||||
// Log error but don't necessarily fail the whole operation if job fetch fails
|
||||
n.logger.LogError("Failed to get job details for notification", "jobID", jobID, "error", err)
|
||||
n.logger.LogError("Failed to get job details for notification: jobID=%d, error=%v", jobID, err)
|
||||
// Return the temporary history object and nil error, as notification is best-effort
|
||||
return history, nil
|
||||
}
|
||||
@@ -659,7 +673,7 @@ func (n *Notifier) updateJobStatus(jobID uint, status string, startTime, endTime
|
||||
|
||||
// Create the notification in the database
|
||||
// Assuming history.ID is set elsewhere if needed, or pass 0 if not applicable here
|
||||
err = n.db.CreateJobNotification(
|
||||
err = n.db.CreateJobNotification( // Calls interface method
|
||||
userID,
|
||||
jobID,
|
||||
0, // History ID might not be available/relevant here, pass 0 or adjust DB function
|
||||
@@ -669,7 +683,7 @@ func (n *Notifier) updateJobStatus(jobID uint, status string, startTime, endTime
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
n.logger.LogError("Failed to create job notification", "jobID", job.ID, "error", err)
|
||||
n.logger.LogError("Failed to create job notification: jobID=%d, error=%v", job.ID, err)
|
||||
// Continue anyway, not critical
|
||||
}
|
||||
|
||||
@@ -693,15 +707,15 @@ func (n *Notifier) createJobHistoryAndNotify(job *db.Job, status string, startTi
|
||||
}
|
||||
|
||||
// Save to database - TODO: Move this responsibility?
|
||||
if err := n.db.Create(&history).Error; err != nil {
|
||||
n.logger.LogError("Failed to create job history", "jobID", job.ID, "error", err)
|
||||
if err := n.db.Create(&history).Error; err != nil { // Calls interface method
|
||||
n.logger.LogError("Failed to create job history: jobID=%d, error=%v", job.ID, err)
|
||||
return err // Return error if history creation fails
|
||||
}
|
||||
|
||||
// Create notification based on the *saved* history record (which now has an ID)
|
||||
err := n.createJobNotification(job, &history) // Call the dedicated notification creation method
|
||||
if err != nil {
|
||||
n.logger.LogError("Failed to create job notification", "jobID", job.ID, "error", err)
|
||||
n.logger.LogError("Failed to create job notification: jobID=%d, error=%v", job.ID, err)
|
||||
// Continue anyway - notification is not critical
|
||||
}
|
||||
|
||||
@@ -748,7 +762,7 @@ func (n *Notifier) createJobNotification(job *db.Job, history *db.JobHistory) er
|
||||
}
|
||||
|
||||
// Create the notification record in the database
|
||||
return n.db.CreateJobNotification(
|
||||
return n.db.CreateJobNotification( // Calls interface method
|
||||
userID,
|
||||
job.ID,
|
||||
history.ID, // Use the actual history ID
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
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)
|
||||
+138
-72
@@ -1,52 +1,89 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
// Needed for Job.NextRun update
|
||||
"github.com/robfig/cron/v3"
|
||||
"github.com/starfleetcptn/gomft/internal/db"
|
||||
)
|
||||
|
||||
type Scheduler struct {
|
||||
cron *cron.Cron
|
||||
db *db.DB
|
||||
jobMutex sync.Mutex
|
||||
jobs map[uint]cron.EntryID
|
||||
logger *Logger // Renamed from log
|
||||
executor *JobExecutor
|
||||
// transfer *TransferExecutor // May be needed by executor
|
||||
// notifier *Notifier // May be needed by executor/transfer
|
||||
// metadata *MetadataHandler // May be needed by executor/transfer
|
||||
// --- Interfaces for Dependencies ---
|
||||
|
||||
// SchedulerDB defines the database methods needed directly by Scheduler.
|
||||
type SchedulerDB interface {
|
||||
GetActiveJobs() ([]db.Job, error)
|
||||
UpdateJobStatus(job *db.Job) error
|
||||
}
|
||||
|
||||
func New(database *db.DB) *Scheduler {
|
||||
// Create a new logger
|
||||
logger := NewLogger()
|
||||
// SchedulerCron defines the cron methods needed directly by Scheduler.
|
||||
type SchedulerCron interface {
|
||||
AddFunc(spec string, cmd func()) (cron.EntryID, error)
|
||||
Remove(id cron.EntryID)
|
||||
Entry(id cron.EntryID) cron.Entry
|
||||
Stop() context.Context // Changed from Stop() to match cron/v3, returns context
|
||||
}
|
||||
|
||||
logger.Info.Println("Initializing scheduler")
|
||||
c := cron.New(cron.WithChain(cron.Recover(cron.DefaultLogger))) // Consider using our logger
|
||||
c.Start()
|
||||
// SchedulerLogger defines the logger methods needed directly by Scheduler.
|
||||
type SchedulerLogger interface {
|
||||
LogInfo(format string, v ...interface{})
|
||||
LogError(format string, v ...interface{})
|
||||
LogDebug(format string, v ...interface{})
|
||||
Close()
|
||||
RotateLogs() error
|
||||
// Println removed - use LogInfo instead
|
||||
}
|
||||
|
||||
// Initialize components
|
||||
jobsMap := make(map[uint]cron.EntryID)
|
||||
var jobMutex sync.Mutex
|
||||
notifier := NewNotifier(database, logger)
|
||||
metadataHandler := NewMetadataHandler(database, logger)
|
||||
transferExecutor := NewTransferExecutor(database, logger, metadataHandler, notifier)
|
||||
jobExecutor := NewJobExecutor(database, logger, c, jobsMap, &jobMutex, transferExecutor, notifier)
|
||||
// SchedulerJobExecutor defines the job executor methods needed directly by Scheduler.
|
||||
type SchedulerJobExecutor interface {
|
||||
executeJob(jobID uint)
|
||||
}
|
||||
|
||||
// --- Scheduler Implementation ---
|
||||
|
||||
type Scheduler struct {
|
||||
cron SchedulerCron // Use interface
|
||||
db SchedulerDB // Use interface
|
||||
jobMutex *sync.Mutex // Keep using pointer for shared mutex
|
||||
jobs map[uint]cron.EntryID // Keep using shared map
|
||||
logger SchedulerLogger // Use interface
|
||||
executor SchedulerJobExecutor // Use interface
|
||||
}
|
||||
|
||||
// New creates a new Scheduler with injected dependencies.
|
||||
// Initialization of logger, cron instance, executor, etc., should happen outside
|
||||
// and the required components (or interfaces) passed in.
|
||||
func New(
|
||||
database SchedulerDB,
|
||||
cronInstance SchedulerCron,
|
||||
logger SchedulerLogger,
|
||||
executor SchedulerJobExecutor,
|
||||
jobsMap map[uint]cron.EntryID, // Pass in the shared map
|
||||
jobMutex *sync.Mutex, // Pass in the shared mutex
|
||||
) *Scheduler {
|
||||
|
||||
logger.LogInfo("Initializing scheduler") // Use LogInfo instead of Println
|
||||
|
||||
// Cron instance should be started outside and passed in.
|
||||
// c := cron.New(cron.WithChain(cron.Recover(cron.DefaultLogger)))
|
||||
// c.Start()
|
||||
|
||||
// Dependencies like Notifier, MetadataHandler, TransferExecutor are now
|
||||
// dependencies of the JobExecutor passed in, not initialized here.
|
||||
|
||||
s := &Scheduler{
|
||||
cron: c,
|
||||
db: database, // Keep DB here for now for LoadJobs/ScheduleJob, or refactor further
|
||||
jobMutex: jobMutex, // Use the initialized mutex
|
||||
jobs: jobsMap, // Use the initialized map
|
||||
cron: cronInstance,
|
||||
db: database,
|
||||
jobMutex: jobMutex, // Use the passed-in mutex
|
||||
jobs: jobsMap, // Use the passed-in map
|
||||
logger: logger,
|
||||
executor: jobExecutor, // Assign the initialized executor
|
||||
executor: executor,
|
||||
}
|
||||
|
||||
// Load existing jobs
|
||||
// Load existing jobs using the injected dependencies
|
||||
s.loadJobs()
|
||||
|
||||
return s
|
||||
@@ -56,31 +93,45 @@ func (s *Scheduler) loadJobs() {
|
||||
s.logger.LogInfo("Loading scheduled jobs")
|
||||
|
||||
// Get all jobs from the database
|
||||
jobs, err := s.db.GetActiveJobs()
|
||||
jobs, err := s.db.GetActiveJobs() // Calls interface method
|
||||
if err != nil {
|
||||
s.logger.LogError("Error loading jobs: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Clear the job map to ensure we're starting fresh
|
||||
// Clear the job map (passed in by reference, so this affects the shared map)
|
||||
s.jobMutex.Lock()
|
||||
s.jobs = make(map[uint]cron.EntryID)
|
||||
// Re-initialize the map passed by the caller if needed, or assume caller manages it.
|
||||
// Let's assume the caller provides a ready-to-use map. We just clear entries for this scheduler instance.
|
||||
// for k := range s.jobs { // This would require iterating, simpler to just re-make if needed.
|
||||
// delete(s.jobs, k)
|
||||
// }
|
||||
// If the map should be fully reset here:
|
||||
// s.jobs = make(map[uint]cron.EntryID) // This replaces the map, might not be desired if shared
|
||||
// Let's stick to removing entries managed by this scheduler instance if they existed.
|
||||
// However, the original code cleared the *entire* map. Let's replicate that for now.
|
||||
for k := range s.jobs {
|
||||
delete(s.jobs, k)
|
||||
}
|
||||
s.jobMutex.Unlock()
|
||||
|
||||
// Initialize job count to track successfully loaded jobs
|
||||
loadedCount := 0
|
||||
|
||||
for _, job := range jobs {
|
||||
// Create a local copy for the closure
|
||||
jobCopy := job
|
||||
// Skip disabled jobs
|
||||
if !job.GetEnabled() {
|
||||
s.logger.LogInfo("Job %d (%s) is disabled, skipping scheduling", job.ID, job.Name)
|
||||
if !jobCopy.GetEnabled() {
|
||||
s.logger.LogInfo("Job %d (%s) is disabled, skipping scheduling", jobCopy.ID, jobCopy.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := s.ScheduleJob(&job); err != nil {
|
||||
s.logger.LogError("Error scheduling job %d: %v", job.ID, err)
|
||||
// ScheduleJob now uses the local jobCopy
|
||||
if err := s.ScheduleJob(&jobCopy); err != nil {
|
||||
s.logger.LogError("Error scheduling job %d: %v", jobCopy.ID, err)
|
||||
} else {
|
||||
s.logger.LogInfo("Loaded job %d: %s", job.ID, job.Name)
|
||||
s.logger.LogInfo("Loaded job %d: %s", jobCopy.ID, jobCopy.Name)
|
||||
loadedCount++
|
||||
}
|
||||
}
|
||||
@@ -91,61 +142,72 @@ func (s *Scheduler) loadJobs() {
|
||||
func (s *Scheduler) ScheduleJob(job *db.Job) error {
|
||||
s.logger.LogDebug("Attempting to schedule job ID %d: %+v", job.ID, job)
|
||||
|
||||
s.logger.LogInfo("Scheduling job %d: %s with schedule %s", job.ID, job.Name, job.Schedule)
|
||||
// Use local variable for job ID within the closure
|
||||
jobID := job.ID
|
||||
|
||||
s.logger.LogInfo("Scheduling job %d: %s with schedule %s", jobID, job.Name, job.Schedule)
|
||||
|
||||
// Remove existing job if it exists
|
||||
if entryID, exists := s.jobs[job.ID]; exists {
|
||||
s.logger.LogInfo("Removing existing schedule for job %d", job.ID)
|
||||
s.cron.Remove(entryID)
|
||||
delete(s.jobs, job.ID)
|
||||
s.jobMutex.Lock() // Lock before accessing shared map
|
||||
if entryID, exists := s.jobs[jobID]; exists {
|
||||
s.logger.LogInfo("Removing existing schedule for job %d", jobID)
|
||||
s.cron.Remove(entryID) // Calls interface method
|
||||
delete(s.jobs, jobID)
|
||||
}
|
||||
s.jobMutex.Unlock() // Unlock after accessing shared map
|
||||
|
||||
// Only schedule if job is enabled
|
||||
if !job.GetEnabled() {
|
||||
s.logger.LogInfo("Job %d is disabled, skipping scheduling", job.ID)
|
||||
s.logger.LogInfo("Job %d is disabled, skipping scheduling", jobID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Convert 5-field cron to 6-field by prepending '0' for seconds
|
||||
schedule := job.Schedule
|
||||
if len(strings.Fields(schedule)) == 5 {
|
||||
schedule = "0 " + schedule
|
||||
// Use a 6-field parser for validation and determine the schedule string to use.
|
||||
scheduleToUse := job.Schedule
|
||||
parser := cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
|
||||
_, err := parser.Parse(scheduleToUse)
|
||||
|
||||
// If initial parse fails AND it was a 5-field schedule, try prepending seconds
|
||||
if err != nil && len(strings.Fields(job.Schedule)) == 5 {
|
||||
scheduleWithSeconds := "0 " + job.Schedule
|
||||
_, errSeconds := parser.Parse(scheduleWithSeconds)
|
||||
if errSeconds == nil {
|
||||
scheduleToUse = scheduleWithSeconds // Use the 6-field version
|
||||
err = nil // Clear the original error
|
||||
s.logger.LogDebug("Converted 5-field schedule '%s' to 6-field '%s'", job.Schedule, scheduleToUse)
|
||||
}
|
||||
}
|
||||
|
||||
s.logger.LogDebug("Converted schedule from '%s' to '%s'", job.Schedule, schedule)
|
||||
|
||||
// Validate cron expression
|
||||
parser := cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
|
||||
_, err := parser.Parse(schedule)
|
||||
// If error still exists after trying conversion, return it
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid cron expression '%s': %w", job.Schedule, err)
|
||||
}
|
||||
|
||||
s.logger.LogDebug("Validated cron expression '%s' for job %d", schedule, job.ID)
|
||||
s.logger.LogDebug("Validated cron expression '%s' for job %d", scheduleToUse, jobID)
|
||||
|
||||
// Schedule the job
|
||||
entryID, err := s.cron.AddFunc(job.Schedule, func() {
|
||||
// TODO: Refactor executeJob to be a method on JobExecutor and pass necessary context
|
||||
s.executor.executeJob(job.ID)
|
||||
// Schedule the job using the validated scheduleToUse
|
||||
entryID, err := s.cron.AddFunc(scheduleToUse, func() { // Calls interface method
|
||||
s.executor.executeJob(jobID) // Calls interface method
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
s.logger.LogError("Error scheduling job %d: %v", job.ID, err)
|
||||
s.logger.LogError("Error scheduling job %d: %v", jobID, err)
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.LogDebug("Scheduled job %d with cron entry ID %d", job.ID, entryID)
|
||||
} // <-- Added missing closing brace
|
||||
s.logger.LogDebug("Scheduled job %d with cron entry ID %d", jobID, entryID)
|
||||
|
||||
// Store mapping of job ID to cron entry ID
|
||||
s.jobMutex.Lock()
|
||||
s.jobs[job.ID] = entryID
|
||||
s.jobs[jobID] = entryID
|
||||
s.jobMutex.Unlock()
|
||||
|
||||
// Get next run time
|
||||
entry := s.cron.Entry(entryID)
|
||||
job.NextRun = &entry.Next
|
||||
if err := s.db.UpdateJobStatus(job); err != nil {
|
||||
s.logger.LogError("Error updating job status for job %d: %v", job.ID, err)
|
||||
entry := s.cron.Entry(entryID) // Calls interface method
|
||||
nextRunTime := entry.Next // Capture time before pointer assignment
|
||||
job.NextRun = &nextRunTime
|
||||
if err := s.db.UpdateJobStatus(job); err != nil { // Calls interface method
|
||||
s.logger.LogError("Error updating job status for job %d: %v", jobID, err)
|
||||
// Don't return error here? Original code returned error. Let's keep that.
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -157,25 +219,29 @@ func (s *Scheduler) UnscheduleJob(jobID uint) {
|
||||
defer s.jobMutex.Unlock()
|
||||
|
||||
if entryID, exists := s.jobs[jobID]; exists {
|
||||
s.cron.Remove(entryID)
|
||||
s.logger.LogInfo("Unscheduling job %d (entry ID %d)", jobID, entryID)
|
||||
s.cron.Remove(entryID) // Calls interface method
|
||||
delete(s.jobs, jobID)
|
||||
} else {
|
||||
s.logger.LogInfo("Job %d not found in scheduler map, cannot unschedule", jobID)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) Stop() {
|
||||
s.logger.LogInfo("Stopping scheduler")
|
||||
s.cron.Stop()
|
||||
s.logger.Close()
|
||||
_ = s.cron.Stop() // Calls interface method, ignore context for now
|
||||
s.logger.Close() // Calls interface method
|
||||
}
|
||||
|
||||
// RotateLogs manually triggers log rotation
|
||||
func (s *Scheduler) RotateLogs() error {
|
||||
s.logger.LogInfo("Manually rotating logs")
|
||||
return s.logger.RotateLogs()
|
||||
return s.logger.RotateLogs() // Calls interface method
|
||||
}
|
||||
|
||||
func (s *Scheduler) RunJobNow(jobID uint) error {
|
||||
// TODO: Refactor executeJob to be a method on JobExecutor and pass necessary context
|
||||
go s.executor.executeJob(jobID)
|
||||
s.logger.LogInfo("Running job %d now", jobID)
|
||||
// Run in a goroutine as before
|
||||
go s.executor.executeJob(jobID) // Calls interface method
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,525 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect" // Added import
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
"github.com/starfleetcptn/gomft/internal/db"
|
||||
)
|
||||
|
||||
// --- Mock Implementations ---
|
||||
|
||||
// Mock SchedulerDB
|
||||
var _ SchedulerDB = (*mockSchedulerDB)(nil)
|
||||
|
||||
type mockSchedulerDB struct {
|
||||
mu sync.Mutex
|
||||
GetActiveJobsFunc func() ([]db.Job, error)
|
||||
UpdateJobStatusFunc func(job *db.Job) error
|
||||
|
||||
// Store calls/data
|
||||
getActiveJobsCalls int
|
||||
updatedJobStatus *db.Job
|
||||
}
|
||||
|
||||
func (m *mockSchedulerDB) GetActiveJobs() ([]db.Job, error) {
|
||||
m.mu.Lock()
|
||||
m.getActiveJobsCalls++
|
||||
m.mu.Unlock()
|
||||
if m.GetActiveJobsFunc != nil {
|
||||
return m.GetActiveJobsFunc()
|
||||
}
|
||||
// Default: return an empty list
|
||||
return []db.Job{}, nil
|
||||
}
|
||||
func (m *mockSchedulerDB) UpdateJobStatus(job *db.Job) error {
|
||||
m.mu.Lock()
|
||||
m.updatedJobStatus = job
|
||||
m.mu.Unlock()
|
||||
if m.UpdateJobStatusFunc != nil {
|
||||
return m.UpdateJobStatusFunc(job)
|
||||
}
|
||||
return nil // Default success
|
||||
}
|
||||
func (m *mockSchedulerDB) Reset() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.getActiveJobsCalls = 0
|
||||
m.updatedJobStatus = nil
|
||||
}
|
||||
|
||||
// Mock SchedulerCron
|
||||
var _ SchedulerCron = (*mockSchedulerCron)(nil)
|
||||
|
||||
type mockSchedulerCron struct {
|
||||
mu sync.Mutex
|
||||
addFuncMock func(spec string, cmd func()) (cron.EntryID, error) // Renamed field
|
||||
removeFuncMock func(id cron.EntryID) // Renamed field
|
||||
entryFuncMock func(id cron.EntryID) cron.Entry // Renamed field
|
||||
stopFuncMock func() context.Context // Renamed field
|
||||
|
||||
// Store calls/data
|
||||
addedJobs map[string]func() // spec -> cmd
|
||||
removedIDs []cron.EntryID
|
||||
entryCalled cron.EntryID
|
||||
stopCalled bool
|
||||
}
|
||||
|
||||
func (m *mockSchedulerCron) AddFunc(spec string, cmd func()) (cron.EntryID, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.addedJobs == nil {
|
||||
m.addedJobs = make(map[string]func())
|
||||
}
|
||||
m.addedJobs[spec] = cmd
|
||||
// Use reflect to check if the mock function is set, avoiding the warning
|
||||
if reflect.ValueOf(m.addFuncMock).IsValid() && !reflect.ValueOf(m.addFuncMock).IsNil() {
|
||||
return m.addFuncMock(spec, cmd)
|
||||
}
|
||||
// Default: return a mock ID
|
||||
return cron.EntryID(len(m.addedJobs)), nil
|
||||
}
|
||||
func (m *mockSchedulerCron) Remove(id cron.EntryID) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.removedIDs = append(m.removedIDs, id)
|
||||
// Removed unused loop for spec, addedCmd
|
||||
if m.removeFuncMock != nil { // Use renamed field
|
||||
m.removeFuncMock(id)
|
||||
}
|
||||
}
|
||||
func (m *mockSchedulerCron) Entry(id cron.EntryID) cron.Entry {
|
||||
m.mu.Lock()
|
||||
m.entryCalled = id
|
||||
m.mu.Unlock()
|
||||
if m.entryFuncMock != nil { // Use renamed field
|
||||
return m.entryFuncMock(id)
|
||||
}
|
||||
// Default: return entry with future time
|
||||
return cron.Entry{ID: id, Next: time.Now().Add(time.Hour)}
|
||||
}
|
||||
func (m *mockSchedulerCron) Stop() context.Context {
|
||||
m.mu.Lock()
|
||||
m.stopCalled = true
|
||||
m.mu.Unlock()
|
||||
if m.stopFuncMock != nil { // Use renamed field
|
||||
return m.stopFuncMock()
|
||||
}
|
||||
return context.Background() // Default context
|
||||
}
|
||||
func (m *mockSchedulerCron) Reset() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.addedJobs = nil
|
||||
m.removedIDs = nil
|
||||
m.entryCalled = 0
|
||||
m.stopCalled = false
|
||||
}
|
||||
|
||||
// Mock SchedulerLogger
|
||||
var _ SchedulerLogger = (*mockSchedulerLogger)(nil)
|
||||
|
||||
type mockSchedulerLogger struct {
|
||||
mu sync.Mutex
|
||||
LogInfoFunc func(format string, v ...interface{})
|
||||
LogErrorFunc func(format string, v ...interface{})
|
||||
LogDebugFunc func(format string, v ...interface{})
|
||||
CloseFunc func()
|
||||
RotateLogsFunc func() error
|
||||
// PrintlnFunc removed
|
||||
|
||||
// Store calls/data
|
||||
infoLogs []string
|
||||
errorLogs []string
|
||||
debugLogs []string
|
||||
closeCalled bool
|
||||
rotateCalled bool
|
||||
// printlnLogs removed
|
||||
}
|
||||
|
||||
func (m *mockSchedulerLogger) LogInfo(format string, v ...interface{}) {
|
||||
m.mu.Lock()
|
||||
m.infoLogs = append(m.infoLogs, fmt.Sprintf(format, v...))
|
||||
m.mu.Unlock()
|
||||
if m.LogInfoFunc != nil {
|
||||
m.LogInfoFunc(format, v...)
|
||||
}
|
||||
}
|
||||
func (m *mockSchedulerLogger) LogError(format string, v ...interface{}) {
|
||||
m.mu.Lock()
|
||||
m.errorLogs = append(m.errorLogs, fmt.Sprintf(format, v...))
|
||||
m.mu.Unlock()
|
||||
if m.LogErrorFunc != nil {
|
||||
m.LogErrorFunc(format, v...)
|
||||
}
|
||||
}
|
||||
func (m *mockSchedulerLogger) LogDebug(format string, v ...interface{}) {
|
||||
m.mu.Lock()
|
||||
m.debugLogs = append(m.debugLogs, fmt.Sprintf(format, v...))
|
||||
m.mu.Unlock()
|
||||
if m.LogDebugFunc != nil {
|
||||
m.LogDebugFunc(format, v...)
|
||||
}
|
||||
}
|
||||
func (m *mockSchedulerLogger) Close() {
|
||||
m.mu.Lock()
|
||||
m.closeCalled = true
|
||||
m.mu.Unlock()
|
||||
if m.CloseFunc != nil {
|
||||
m.CloseFunc()
|
||||
}
|
||||
}
|
||||
func (m *mockSchedulerLogger) RotateLogs() error {
|
||||
m.mu.Lock()
|
||||
m.rotateCalled = true
|
||||
m.mu.Unlock()
|
||||
if m.RotateLogsFunc != nil {
|
||||
return m.RotateLogsFunc()
|
||||
}
|
||||
return nil // Default success
|
||||
}
|
||||
|
||||
// Println method removed
|
||||
func (m *mockSchedulerLogger) Reset() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.infoLogs = nil
|
||||
m.errorLogs = nil
|
||||
m.debugLogs = nil
|
||||
m.closeCalled = false
|
||||
m.rotateCalled = false
|
||||
// printlnLogs removed from Reset
|
||||
}
|
||||
|
||||
// Mock SchedulerJobExecutor
|
||||
var _ SchedulerJobExecutor = (*mockSchedulerJobExecutor)(nil)
|
||||
|
||||
type mockSchedulerJobExecutor struct {
|
||||
mu sync.Mutex
|
||||
ExecuteJobFunc func(jobID uint)
|
||||
|
||||
// Store calls
|
||||
executeJobCalls []uint
|
||||
}
|
||||
|
||||
func (m *mockSchedulerJobExecutor) executeJob(jobID uint) {
|
||||
m.mu.Lock()
|
||||
m.executeJobCalls = append(m.executeJobCalls, jobID)
|
||||
m.mu.Unlock()
|
||||
if m.ExecuteJobFunc != nil {
|
||||
m.ExecuteJobFunc(jobID)
|
||||
}
|
||||
}
|
||||
func (m *mockSchedulerJobExecutor) Reset() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.executeJobCalls = nil
|
||||
}
|
||||
|
||||
// --- Test Setup ---
|
||||
|
||||
type testSchedulerComponents struct {
|
||||
db *mockSchedulerDB
|
||||
cron *mockSchedulerCron
|
||||
logger *mockSchedulerLogger
|
||||
executor *mockSchedulerJobExecutor
|
||||
jobsMap map[uint]cron.EntryID
|
||||
jobMutex *sync.Mutex
|
||||
scheduler *Scheduler
|
||||
}
|
||||
|
||||
func setupTestScheduler() testSchedulerComponents {
|
||||
dbMock := &mockSchedulerDB{}
|
||||
cronMock := &mockSchedulerCron{}
|
||||
loggerMock := &mockSchedulerLogger{}
|
||||
executorMock := &mockSchedulerJobExecutor{}
|
||||
jobsMap := make(map[uint]cron.EntryID)
|
||||
var jobMutex sync.Mutex
|
||||
|
||||
// Create scheduler with mocks
|
||||
scheduler := New(
|
||||
dbMock,
|
||||
cronMock,
|
||||
loggerMock,
|
||||
executorMock,
|
||||
jobsMap,
|
||||
&jobMutex,
|
||||
)
|
||||
|
||||
return testSchedulerComponents{
|
||||
db: dbMock,
|
||||
cron: cronMock,
|
||||
logger: loggerMock,
|
||||
executor: executorMock,
|
||||
jobsMap: jobsMap,
|
||||
jobMutex: &jobMutex,
|
||||
scheduler: scheduler,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
func TestNewScheduler_LoadJobs(t *testing.T) {
|
||||
dbMock := &mockSchedulerDB{}
|
||||
cronMock := &mockSchedulerCron{}
|
||||
loggerMock := &mockSchedulerLogger{}
|
||||
executorMock := &mockSchedulerJobExecutor{}
|
||||
jobsMap := make(map[uint]cron.EntryID)
|
||||
var jobMutex sync.Mutex
|
||||
|
||||
// Configure DB mock to return jobs
|
||||
enabled := true
|
||||
disabled := false
|
||||
dbMock.GetActiveJobsFunc = func() ([]db.Job, error) {
|
||||
return []db.Job{
|
||||
{ID: 1, Name: "Job 1", Schedule: "* * * * *", Enabled: &enabled},
|
||||
{ID: 2, Name: "Job 2", Schedule: "0 * * * *", Enabled: &enabled},
|
||||
{ID: 3, Name: "Job 3", Schedule: "*/5 * * * *", Enabled: &disabled}, // Disabled job
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Create scheduler - this calls loadJobs internally
|
||||
_ = New(dbMock, cronMock, loggerMock, executorMock, jobsMap, &jobMutex)
|
||||
|
||||
// Assertions
|
||||
// 1. DB GetActiveJobs called once
|
||||
dbMock.mu.Lock()
|
||||
if dbMock.getActiveJobsCalls != 1 {
|
||||
t.Errorf("Expected GetActiveJobs to be called once, got %d", dbMock.getActiveJobsCalls)
|
||||
}
|
||||
dbMock.mu.Unlock()
|
||||
|
||||
// 2. Cron AddFunc called twice (for enabled jobs)
|
||||
cronMock.mu.Lock()
|
||||
if len(cronMock.addedJobs) != 2 {
|
||||
t.Errorf("Expected 2 jobs to be added to cron, got %d", len(cronMock.addedJobs))
|
||||
}
|
||||
// Job 1 schedule is 5 fields, should be converted to 6 by ScheduleJob logic.
|
||||
if _, ok := cronMock.addedJobs["0 * * * * *"]; !ok { // Check for 6-field version
|
||||
t.Errorf("Expected Job 1 schedule '0 * * * * *' to be added, got map: %v", cronMock.addedJobs)
|
||||
}
|
||||
// Job 2 schedule is 5 fields, should be converted to 6 by ScheduleJob logic.
|
||||
if _, ok := cronMock.addedJobs["0 0 * * * *"]; !ok { // Check for 6-field version
|
||||
t.Errorf("Expected Job 2 schedule '0 0 * * * *' to be added, got map: %v", cronMock.addedJobs)
|
||||
}
|
||||
cronMock.mu.Unlock()
|
||||
|
||||
// 3. Check logs
|
||||
loggerMock.mu.Lock()
|
||||
foundLoadLog := false
|
||||
foundDisabledLog := false
|
||||
foundLoadedCountLog := false
|
||||
for _, log := range loggerMock.infoLogs {
|
||||
if strings.Contains(log, "Loading scheduled jobs") {
|
||||
foundLoadLog = true
|
||||
}
|
||||
if strings.Contains(log, "Job 3 (Job 3) is disabled") {
|
||||
foundDisabledLog = true
|
||||
}
|
||||
if strings.Contains(log, "Loaded 2 jobs") {
|
||||
foundLoadedCountLog = true
|
||||
}
|
||||
}
|
||||
if !foundLoadLog {
|
||||
t.Error("Expected 'Loading scheduled jobs' log")
|
||||
}
|
||||
if !foundDisabledLog {
|
||||
t.Error("Expected 'Job 3 ... disabled' log")
|
||||
}
|
||||
if !foundLoadedCountLog {
|
||||
t.Error("Expected 'Loaded 2 jobs' log")
|
||||
}
|
||||
loggerMock.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestScheduleJob_Success(t *testing.T) {
|
||||
comps := setupTestScheduler()
|
||||
enabled := true
|
||||
job := db.Job{ID: 5, Name: "Test Sched", Schedule: "10 * * * *", Enabled: &enabled}
|
||||
|
||||
err := comps.scheduler.ScheduleJob(&job)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("ScheduleJob failed: %v", err)
|
||||
}
|
||||
|
||||
// Assertions
|
||||
// 1. Cron AddFunc called
|
||||
comps.cron.mu.Lock()
|
||||
if len(comps.cron.addedJobs) != 1 {
|
||||
t.Fatalf("Expected 1 job added to cron, got %d", len(comps.cron.addedJobs))
|
||||
}
|
||||
// Job schedule is 5 fields, should be converted to 6 by ScheduleJob logic.
|
||||
if _, ok := comps.cron.addedJobs["0 10 * * * *"]; !ok { // Check for 6-field version
|
||||
t.Errorf("Expected schedule '0 10 * * * *' to be added, got map: %v", comps.cron.addedJobs)
|
||||
}
|
||||
comps.cron.mu.Unlock()
|
||||
|
||||
// 2. Job map updated
|
||||
comps.jobMutex.Lock()
|
||||
if _, ok := comps.jobsMap[job.ID]; !ok {
|
||||
t.Errorf("Job ID %d not found in scheduler jobs map", job.ID)
|
||||
}
|
||||
comps.jobMutex.Unlock()
|
||||
|
||||
// 3. DB UpdateJobStatus called with NextRun set
|
||||
comps.db.mu.Lock()
|
||||
if comps.db.updatedJobStatus == nil {
|
||||
t.Error("UpdateJobStatus was not called")
|
||||
} else if comps.db.updatedJobStatus.ID != job.ID {
|
||||
t.Errorf("UpdateJobStatus called with wrong job ID: got %d, want %d", comps.db.updatedJobStatus.ID, job.ID)
|
||||
} else if comps.db.updatedJobStatus.NextRun == nil {
|
||||
t.Error("UpdateJobStatus called but NextRun was not set")
|
||||
}
|
||||
comps.db.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestScheduleJob_Disabled(t *testing.T) {
|
||||
comps := setupTestScheduler()
|
||||
disabled := false
|
||||
job := db.Job{ID: 6, Name: "Disabled Sched", Schedule: "* * * * *", Enabled: &disabled}
|
||||
|
||||
err := comps.scheduler.ScheduleJob(&job)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("ScheduleJob failed for disabled job: %v", err)
|
||||
}
|
||||
|
||||
// Assertions
|
||||
comps.cron.mu.Lock()
|
||||
if len(comps.cron.addedJobs) != 0 {
|
||||
t.Errorf("Expected 0 jobs added to cron for disabled job, got %d", len(comps.cron.addedJobs))
|
||||
}
|
||||
comps.cron.mu.Unlock()
|
||||
|
||||
comps.jobMutex.Lock()
|
||||
if _, ok := comps.jobsMap[job.ID]; ok {
|
||||
t.Errorf("Disabled Job ID %d should not be in scheduler jobs map", job.ID)
|
||||
}
|
||||
comps.jobMutex.Unlock()
|
||||
|
||||
comps.db.mu.Lock()
|
||||
if comps.db.updatedJobStatus != nil {
|
||||
t.Error("UpdateJobStatus should not be called for disabled job")
|
||||
}
|
||||
comps.db.mu.Unlock()
|
||||
|
||||
comps.logger.mu.Lock()
|
||||
foundDisabledLog := false
|
||||
for _, log := range comps.logger.infoLogs {
|
||||
if strings.Contains(log, fmt.Sprintf("Job %d is disabled, skipping scheduling", job.ID)) {
|
||||
foundDisabledLog = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundDisabledLog {
|
||||
t.Error("Expected 'disabled, skipping' log message")
|
||||
}
|
||||
comps.logger.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestScheduleJob_InvalidCron(t *testing.T) {
|
||||
comps := setupTestScheduler()
|
||||
enabled := true
|
||||
job := db.Job{ID: 7, Name: "Invalid Sched", Schedule: "invalid cron string", Enabled: &enabled}
|
||||
|
||||
err := comps.scheduler.ScheduleJob(&job)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("ScheduleJob succeeded with invalid cron, expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid cron expression") {
|
||||
t.Errorf("Expected error containing 'invalid cron expression', got: %v", err)
|
||||
}
|
||||
|
||||
// Assertions
|
||||
comps.cron.mu.Lock()
|
||||
if len(comps.cron.addedJobs) != 0 {
|
||||
t.Errorf("Expected 0 jobs added to cron for invalid schedule, got %d", len(comps.cron.addedJobs))
|
||||
}
|
||||
comps.cron.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestUnscheduleJob(t *testing.T) {
|
||||
comps := setupTestScheduler()
|
||||
testJobID := uint(8)
|
||||
testEntryID := cron.EntryID(88)
|
||||
|
||||
// Pre-populate the map
|
||||
comps.jobsMap[testJobID] = testEntryID
|
||||
|
||||
comps.scheduler.UnscheduleJob(testJobID)
|
||||
|
||||
// Assertions
|
||||
// 1. Cron Remove called
|
||||
comps.cron.mu.Lock()
|
||||
foundRemoved := false
|
||||
for _, id := range comps.cron.removedIDs {
|
||||
if id == testEntryID {
|
||||
foundRemoved = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundRemoved {
|
||||
t.Errorf("Expected cron Remove to be called with EntryID %d", testEntryID)
|
||||
}
|
||||
comps.cron.mu.Unlock()
|
||||
|
||||
// 2. Job removed from map
|
||||
comps.jobMutex.Lock()
|
||||
if _, ok := comps.jobsMap[testJobID]; ok {
|
||||
t.Errorf("Job ID %d should have been removed from scheduler jobs map", testJobID)
|
||||
}
|
||||
comps.jobMutex.Unlock()
|
||||
}
|
||||
|
||||
func TestStop(t *testing.T) {
|
||||
comps := setupTestScheduler()
|
||||
comps.scheduler.Stop()
|
||||
|
||||
// Assertions
|
||||
comps.cron.mu.Lock()
|
||||
if !comps.cron.stopCalled {
|
||||
t.Error("Expected cron Stop to be called")
|
||||
}
|
||||
comps.cron.mu.Unlock()
|
||||
|
||||
comps.logger.mu.Lock()
|
||||
if !comps.logger.closeCalled {
|
||||
t.Error("Expected logger Close to be called")
|
||||
}
|
||||
comps.logger.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestRunJobNow(t *testing.T) {
|
||||
comps := setupTestScheduler()
|
||||
testJobID := uint(9)
|
||||
|
||||
err := comps.scheduler.RunJobNow(testJobID)
|
||||
if err != nil {
|
||||
t.Fatalf("RunJobNow failed: %v", err)
|
||||
}
|
||||
|
||||
// Allow time for goroutine to potentially start
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Assertions
|
||||
comps.executor.mu.Lock()
|
||||
foundCall := false
|
||||
for _, id := range comps.executor.executeJobCalls {
|
||||
if id == testJobID {
|
||||
foundCall = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundCall {
|
||||
t.Errorf("Expected executor executeJob to be called with JobID %d", testJobID)
|
||||
}
|
||||
comps.executor.mu.Unlock()
|
||||
}
|
||||
@@ -2,10 +2,11 @@ package scheduler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/exec" // Keep this for the variable type definition
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -16,20 +17,51 @@ import (
|
||||
"github.com/starfleetcptn/gomft/internal/db"
|
||||
)
|
||||
|
||||
// --- Interfaces for Dependencies ---
|
||||
|
||||
// TransferDB defines the database methods needed by TransferExecutor.
|
||||
type TransferDB interface {
|
||||
GetConfigRclonePath(config *db.TransferConfig) string
|
||||
GetRcloneCommand(id uint) (*db.RcloneCommand, error)
|
||||
UpdateJobHistory(history *db.JobHistory) error
|
||||
CreateFileMetadata(metadata *db.FileMetadata) error
|
||||
GetRcloneCommandFlagsMap(commandID uint) (map[uint]db.RcloneCommandFlag, error)
|
||||
}
|
||||
|
||||
// TransferNotifier defines the notification methods needed by TransferExecutor.
|
||||
type TransferNotifier interface {
|
||||
SendNotifications(job *db.Job, history *db.JobHistory, config *db.TransferConfig)
|
||||
createJobNotification(job *db.Job, history *db.JobHistory) error
|
||||
}
|
||||
|
||||
// TransferMetadataHandler defines the metadata methods needed by TransferExecutor.
|
||||
type TransferMetadataHandler interface {
|
||||
hasFileBeenProcessed(jobID uint, fileHash string) (bool, *db.FileMetadata, error)
|
||||
checkFileProcessingHistory(jobID uint, fileName string) (*db.FileMetadata, error)
|
||||
}
|
||||
|
||||
// --- Mockable exec Command ---
|
||||
|
||||
// execCommandContext allows mocking exec.CommandContext during tests.
|
||||
// It's initialized to the real exec.CommandContext function.
|
||||
var execCommandContext = exec.CommandContext
|
||||
|
||||
// --- TransferExecutor Implementation ---
|
||||
|
||||
// TransferExecutor handles the rclone command execution and transfer logic.
|
||||
type TransferExecutor struct {
|
||||
db *db.DB
|
||||
logger *Logger
|
||||
metadataHandler *MetadataHandler // Placeholder
|
||||
notifier *Notifier // Placeholder
|
||||
db TransferDB // Use interface
|
||||
logger *Logger // Logger remains concrete
|
||||
metadataHandler TransferMetadataHandler // Use interface
|
||||
notifier TransferNotifier // Use interface
|
||||
}
|
||||
|
||||
// NewTransferExecutor creates a new TransferExecutor.
|
||||
func NewTransferExecutor(
|
||||
database *db.DB,
|
||||
database TransferDB, // Accept interface
|
||||
logger *Logger,
|
||||
metadata *MetadataHandler,
|
||||
notify *Notifier,
|
||||
metadata TransferMetadataHandler, // Accept interface
|
||||
notify TransferNotifier, // Accept interface
|
||||
) *TransferExecutor {
|
||||
return &TransferExecutor{
|
||||
db: database,
|
||||
@@ -47,13 +79,13 @@ func (te *TransferExecutor) executeConfigTransfer(job db.Job, config db.Transfer
|
||||
processedFiles := make(map[string]bool)
|
||||
|
||||
// Get rclone config path
|
||||
configPath := te.db.GetConfigRclonePath(&config)
|
||||
configPath := te.db.GetConfigRclonePath(&config) // Calls interface method
|
||||
|
||||
// Get the command to use for the transfer
|
||||
var rcloneCommand string = "copyto" // Default command
|
||||
if config.CommandID > 0 {
|
||||
// Get the command by ID
|
||||
command, err := te.db.GetRcloneCommand(config.CommandID)
|
||||
command, err := te.db.GetRcloneCommand(config.CommandID) // Calls interface method
|
||||
if err == nil && command != nil {
|
||||
rcloneCommand = command.Name
|
||||
te.logger.LogDebug("Using rclone command %s for job %d, config %d", rcloneCommand, job.ID, config.ID)
|
||||
@@ -91,11 +123,11 @@ func (te *TransferExecutor) executeConfigTransfer(job db.Job, config db.Transfer
|
||||
history.ErrorMessage = fmt.Sprintf("Filter Creation Error: %v", err)
|
||||
endTime := time.Now()
|
||||
history.EndTime = &endTime
|
||||
if err := te.db.UpdateJobHistory(history); err != nil {
|
||||
if err := te.db.UpdateJobHistory(history); err != nil { // Calls interface method
|
||||
te.logger.LogError("Error updating job history for job %d, config %d: %v", job.ID, config.ID, err)
|
||||
}
|
||||
// Send notification for failure
|
||||
te.notifier.SendNotifications(&job, history, &config) // Call via notifier
|
||||
te.notifier.SendNotifications(&job, history, &config) // Calls interface method
|
||||
|
||||
return
|
||||
}
|
||||
@@ -122,7 +154,8 @@ func (te *TransferExecutor) executeConfigTransfer(job db.Job, config db.Transfer
|
||||
if rclonePath == "" {
|
||||
rclonePath = "rclone"
|
||||
}
|
||||
listCmd := exec.Command(rclonePath, listArgs...)
|
||||
// Use the mockable execCommandContext
|
||||
listCmd := execCommandContext(context.Background(), rclonePath, listArgs...)
|
||||
listOutput, listErr := listCmd.CombinedOutput()
|
||||
|
||||
// Add debug logging of raw output
|
||||
@@ -144,11 +177,11 @@ func (te *TransferExecutor) executeConfigTransfer(job db.Job, config db.Transfer
|
||||
history.ErrorMessage = fmt.Sprintf("File Listing Error: %v\nOutput: %s", listErr, string(listOutput))
|
||||
endTime := time.Now()
|
||||
history.EndTime = &endTime
|
||||
if err := te.db.UpdateJobHistory(history); err != nil {
|
||||
if err := te.db.UpdateJobHistory(history); err != nil { // Calls interface method
|
||||
te.logger.LogError("Error updating job history for job %d, config %d: %v", job.ID, config.ID, err)
|
||||
}
|
||||
// Send notification for failure
|
||||
te.notifier.SendNotifications(&job, history, &config) // Call via notifier
|
||||
te.notifier.SendNotifications(&job, history, &config) // Calls interface method
|
||||
return
|
||||
}
|
||||
|
||||
@@ -160,11 +193,11 @@ func (te *TransferExecutor) executeConfigTransfer(job db.Job, config db.Transfer
|
||||
history.ErrorMessage = fmt.Sprintf("JSON Parsing Error: %v", err)
|
||||
endTime := time.Now()
|
||||
history.EndTime = &endTime
|
||||
if err := te.db.UpdateJobHistory(history); err != nil {
|
||||
if err := te.db.UpdateJobHistory(history); err != nil { // Calls interface method
|
||||
te.logger.LogError("Error updating job history for job %d, config %d: %v", job.ID, config.ID, err)
|
||||
}
|
||||
// Send notification for failure
|
||||
te.notifier.SendNotifications(&job, history, &config) // Call via notifier
|
||||
te.notifier.SendNotifications(&job, history, &config) // Calls interface method
|
||||
return
|
||||
}
|
||||
|
||||
@@ -198,11 +231,11 @@ func (te *TransferExecutor) executeConfigTransfer(job db.Job, config db.Transfer
|
||||
history.FilesTransferred = 0
|
||||
endTime := time.Now()
|
||||
history.EndTime = &endTime
|
||||
if err := te.db.UpdateJobHistory(history); err != nil {
|
||||
if err := te.db.UpdateJobHistory(history); err != nil { // Calls interface method
|
||||
te.logger.LogError("Error updating job history for job %d, config %d: %v", job.ID, config.ID, err)
|
||||
}
|
||||
// Send notification for empty completion
|
||||
te.notifier.SendNotifications(&job, history, &config) // Call via notifier
|
||||
te.notifier.SendNotifications(&job, history, &config) // Calls interface method
|
||||
return
|
||||
}
|
||||
|
||||
@@ -274,7 +307,7 @@ func (te *TransferExecutor) executeConfigTransfer(job db.Job, config db.Transfer
|
||||
skipFiles := config.GetSkipProcessedFiles()
|
||||
|
||||
if skipFiles && fileHash != "" {
|
||||
// Call via metadataHandler
|
||||
// Call via metadataHandler interface
|
||||
alreadyProcessed, prevMetadata, err := te.metadataHandler.hasFileBeenProcessed(job.ID, fileHash)
|
||||
if err == nil && alreadyProcessed {
|
||||
te.logger.LogDebug("File %s with hash %s was previously processed on %s with status: %s",
|
||||
@@ -299,7 +332,7 @@ func (te *TransferExecutor) executeConfigTransfer(job db.Job, config db.Transfer
|
||||
}
|
||||
|
||||
// Also check the processing history for this specific file name
|
||||
// Call via metadataHandler
|
||||
// Call via metadataHandler interface
|
||||
prevMetadata, histErr := te.metadataHandler.checkFileProcessingHistory(job.ID, fileName)
|
||||
if histErr == nil {
|
||||
te.logger.LogDebug("File %s was previously processed on %s with status: %s",
|
||||
@@ -415,7 +448,8 @@ func (te *TransferExecutor) executeConfigTransfer(job db.Job, config db.Transfer
|
||||
// Execute transfer for this file
|
||||
te.logger.LogDebug("Full transfer command: %s %v", rclonePath, transferArgs)
|
||||
te.logger.LogDebug("Environment: RCLONE_PATH=%s", os.Getenv("RCLONE_PATH"))
|
||||
cmd := exec.Command(rclonePath, transferArgs...)
|
||||
// Use the mockable execCommandContext
|
||||
cmd := execCommandContext(context.Background(), rclonePath, transferArgs...)
|
||||
fileOutput, fileErr := cmd.CombinedOutput()
|
||||
|
||||
// Print the output
|
||||
@@ -484,7 +518,8 @@ func (te *TransferExecutor) executeConfigTransfer(job db.Job, config db.Transfer
|
||||
if rclonePath == "" {
|
||||
rclonePath = "rclone"
|
||||
}
|
||||
archiveCmd := exec.Command(rclonePath, archiveArgs...)
|
||||
// Use the mockable execCommandContext
|
||||
archiveCmd := execCommandContext(context.Background(), rclonePath, archiveArgs...)
|
||||
archiveOutput, archiveErr := archiveCmd.CombinedOutput()
|
||||
|
||||
// Print the output
|
||||
@@ -508,7 +543,8 @@ func (te *TransferExecutor) executeConfigTransfer(job db.Job, config db.Transfer
|
||||
"--config", configPath,
|
||||
"deletefile",
|
||||
sourcePath}
|
||||
deleteCmd := exec.Command(rclonePath, deleteArgs...)
|
||||
// Use the mockable execCommandContext
|
||||
deleteCmd := execCommandContext(context.Background(), rclonePath, deleteArgs...)
|
||||
deleteOutput, deleteErr := deleteCmd.CombinedOutput()
|
||||
te.logger.LogDebug("Output for file %s: %s", currentFileName, string(deleteOutput))
|
||||
if deleteErr != nil {
|
||||
@@ -543,7 +579,7 @@ func (te *TransferExecutor) executeConfigTransfer(job db.Job, config db.Transfer
|
||||
ErrorMessage: fileErrorMsg,
|
||||
}
|
||||
|
||||
if err := te.db.CreateFileMetadata(metadata); err != nil {
|
||||
if err := te.db.CreateFileMetadata(metadata); err != nil { // Calls interface method
|
||||
te.logger.LogError("Error creating file metadata for %s: %v", currentFileName, err)
|
||||
} else {
|
||||
te.logger.LogDebug("Created file metadata record for %s (ID: %d) with hash: %s", currentFileName, metadata.ID, currentFileHash)
|
||||
@@ -572,17 +608,17 @@ func (te *TransferExecutor) executeConfigTransfer(job db.Job, config db.Transfer
|
||||
endTime := time.Now()
|
||||
history.EndTime = &endTime
|
||||
|
||||
if err := te.db.UpdateJobHistory(history); err != nil {
|
||||
if err := te.db.UpdateJobHistory(history); err != nil { // Calls interface method
|
||||
te.logger.LogError("Error updating job history for job %d, config %d: %v", job.ID, config.ID, err)
|
||||
}
|
||||
|
||||
// Create job notification
|
||||
if err := te.notifier.createJobNotification(&job, history); err != nil {
|
||||
te.logger.LogError("Failed to create job notification", "jobID", job.ID, "error", err)
|
||||
if err := te.notifier.createJobNotification(&job, history); err != nil { // Calls interface method
|
||||
te.logger.LogError("Failed to create job notification: jobID=%d, error=%v", job.ID, err)
|
||||
}
|
||||
|
||||
// Send notification for success or with errors
|
||||
te.notifier.SendNotifications(&job, history, &config)
|
||||
te.notifier.SendNotifications(&job, history, &config) // Calls interface method
|
||||
}
|
||||
|
||||
// isDirectoryBasedTransfer checks if a transfer command operates on directories rather than individual files
|
||||
@@ -757,7 +793,8 @@ func (te *TransferExecutor) executeSimpleCommand(cmdName string, cmdType string,
|
||||
}
|
||||
|
||||
te.logger.LogDebug("Full command: %s %v", rclonePath, args)
|
||||
cmd := exec.Command(rclonePath, args...)
|
||||
// Use the mockable execCommandContext
|
||||
cmd := execCommandContext(context.Background(), rclonePath, args...)
|
||||
|
||||
// Capture output
|
||||
var stdout, stderr bytes.Buffer
|
||||
@@ -768,7 +805,7 @@ func (te *TransferExecutor) executeSimpleCommand(cmdName string, cmdType string,
|
||||
startTime := time.Now()
|
||||
|
||||
// Run the command
|
||||
err := cmd.Run()
|
||||
err := cmd.Run() // This will use the mocked command if execCommandContext is replaced
|
||||
|
||||
// Calculate duration
|
||||
duration := time.Since(startTime)
|
||||
@@ -789,6 +826,7 @@ func (te *TransferExecutor) executeSimpleCommand(cmdName string, cmdType string,
|
||||
te.logger.LogError("Command stderr: %s", stderr.String())
|
||||
|
||||
history.Status = "failed"
|
||||
// Use stderr directly from the buffer as the error from Run() might not contain it
|
||||
history.ErrorMessage = fmt.Sprintf("Command Error: %v\nStderr: %s", err, stderr.String())
|
||||
} else {
|
||||
te.logger.LogInfo("Successfully executed command '%s' for job %d, config %d (duration: %v)",
|
||||
@@ -838,12 +876,12 @@ func (te *TransferExecutor) executeSimpleCommand(cmdName string, cmdType string,
|
||||
}
|
||||
|
||||
// Update job history in the database
|
||||
if err := te.db.UpdateJobHistory(history); err != nil {
|
||||
if err := te.db.UpdateJobHistory(history); err != nil { // Calls interface method
|
||||
te.logger.LogError("Error updating job history for job %d, config %d: %v", job.ID, config.ID, err)
|
||||
}
|
||||
|
||||
// Send notification
|
||||
te.notifier.SendNotifications(&job, history, &config)
|
||||
te.notifier.SendNotifications(&job, history, &config) // Calls interface method
|
||||
}
|
||||
|
||||
// prepareBaseArguments prepares the base arguments for a command
|
||||
@@ -854,12 +892,12 @@ func (te *TransferExecutor) prepareBaseArguments(command string, config *db.Tran
|
||||
if config.CommandFlags != "" {
|
||||
var flagIDs []uint
|
||||
if err := json.Unmarshal([]byte(config.CommandFlags), &flagIDs); err != nil {
|
||||
te.logger.LogError("Error parsing command flags: %v", err)
|
||||
te.logger.LogError("Error parsing command flags JSON: %v", err) // Corrected format
|
||||
} else {
|
||||
// Get all available flags for this command and their values
|
||||
flagsMap, err := te.db.GetRcloneCommandFlagsMap(config.CommandID)
|
||||
flagsMap, err := te.db.GetRcloneCommandFlagsMap(config.CommandID) // Calls interface method
|
||||
if err != nil {
|
||||
te.logger.LogError("Error getting flags map: %v", err)
|
||||
te.logger.LogError("Error getting flags map for command %d: %v", config.CommandID, err) // Added context
|
||||
} else {
|
||||
// Parse flag values if available
|
||||
var flagValues map[uint]string
|
||||
@@ -908,7 +946,7 @@ func (te *TransferExecutor) prepareBaseArguments(command string, config *db.Tran
|
||||
args = append(args, "--stats", "1s")
|
||||
|
||||
// Add config file location
|
||||
configPath := te.db.GetConfigRclonePath(config)
|
||||
configPath := te.db.GetConfigRclonePath(config) // Calls interface method
|
||||
args = append(args, "--config", configPath)
|
||||
|
||||
// Add progress callback related flags if needed (progressCallback is currently nil)
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os" // Added import
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/starfleetcptn/gomft/internal/db"
|
||||
// Removed unused: encoding/json, path/filepath, reflect, time, gorm.io/gorm
|
||||
)
|
||||
|
||||
// --- Mock Implementations ---
|
||||
|
||||
// Mock TransferDB
|
||||
var _ TransferDB = (*mockTransferDB)(nil)
|
||||
|
||||
type mockTransferDB struct {
|
||||
mu sync.Mutex
|
||||
GetConfigRclonePathFunc func(config *db.TransferConfig) string
|
||||
GetRcloneCommandFunc func(id uint) (*db.RcloneCommand, error)
|
||||
UpdateJobHistoryFunc func(history *db.JobHistory) error
|
||||
CreateFileMetadataFunc func(metadata *db.FileMetadata) error
|
||||
GetRcloneCommandFlagsMapFunc func(commandID uint) (map[uint]db.RcloneCommandFlag, error)
|
||||
|
||||
// Store calls/data for verification
|
||||
updatedHistory *db.JobHistory
|
||||
createdMetadata []*db.FileMetadata
|
||||
rcloneConfigPath string
|
||||
}
|
||||
|
||||
func (m *mockTransferDB) GetConfigRclonePath(config *db.TransferConfig) string {
|
||||
if m.GetConfigRclonePathFunc != nil {
|
||||
return m.GetConfigRclonePathFunc(config)
|
||||
}
|
||||
m.rcloneConfigPath = "/tmp/mock_rclone.conf" // Default mock path
|
||||
return m.rcloneConfigPath
|
||||
}
|
||||
func (m *mockTransferDB) GetRcloneCommand(id uint) (*db.RcloneCommand, error) {
|
||||
if m.GetRcloneCommandFunc != nil {
|
||||
return m.GetRcloneCommandFunc(id)
|
||||
}
|
||||
// Default: return a basic command if ID > 0
|
||||
if id > 0 {
|
||||
return &db.RcloneCommand{ID: id, Name: "copy"}, nil
|
||||
}
|
||||
return nil, errors.New("mock GetRcloneCommand not found")
|
||||
}
|
||||
func (m *mockTransferDB) UpdateJobHistory(history *db.JobHistory) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.updatedHistory = history // Store last updated history
|
||||
if m.UpdateJobHistoryFunc != nil {
|
||||
return m.UpdateJobHistoryFunc(history)
|
||||
}
|
||||
return nil // Default success
|
||||
}
|
||||
func (m *mockTransferDB) CreateFileMetadata(metadata *db.FileMetadata) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.createdMetadata = append(m.createdMetadata, metadata) // Store created metadata
|
||||
if m.CreateFileMetadataFunc != nil {
|
||||
return m.CreateFileMetadataFunc(metadata)
|
||||
}
|
||||
metadata.ID = uint(len(m.createdMetadata)) // Assign a mock ID
|
||||
return nil // Default success
|
||||
}
|
||||
func (m *mockTransferDB) GetRcloneCommandFlagsMap(commandID uint) (map[uint]db.RcloneCommandFlag, error) {
|
||||
if m.GetRcloneCommandFlagsMapFunc != nil {
|
||||
return m.GetRcloneCommandFlagsMapFunc(commandID)
|
||||
}
|
||||
// Corrected type name
|
||||
return make(map[uint]db.RcloneCommandFlag), nil // Default empty map
|
||||
}
|
||||
func (m *mockTransferDB) Reset() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.updatedHistory = nil
|
||||
m.createdMetadata = nil
|
||||
m.rcloneConfigPath = ""
|
||||
}
|
||||
|
||||
// Mock TransferNotifier
|
||||
var _ TransferNotifier = (*mockTransferNotifier)(nil)
|
||||
|
||||
type mockTransferNotifier struct {
|
||||
mu sync.Mutex
|
||||
SendNotificationsFunc func(job *db.Job, history *db.JobHistory, config *db.TransferConfig)
|
||||
CreateJobNotificationFunc func(job *db.Job, history *db.JobHistory) error
|
||||
|
||||
// Store calls/data for verification
|
||||
sendNotificationsCalls []map[string]interface{}
|
||||
createNotificationCalls []map[string]interface{}
|
||||
}
|
||||
|
||||
func (m *mockTransferNotifier) SendNotifications(job *db.Job, history *db.JobHistory, config *db.TransferConfig) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.sendNotificationsCalls = append(m.sendNotificationsCalls, map[string]interface{}{
|
||||
"job": job, "history": history, "config": config,
|
||||
})
|
||||
if m.SendNotificationsFunc != nil {
|
||||
m.SendNotificationsFunc(job, history, config)
|
||||
}
|
||||
}
|
||||
func (m *mockTransferNotifier) createJobNotification(job *db.Job, history *db.JobHistory) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.createNotificationCalls = append(m.createNotificationCalls, map[string]interface{}{
|
||||
"job": job, "history": history,
|
||||
})
|
||||
if m.CreateJobNotificationFunc != nil {
|
||||
return m.CreateJobNotificationFunc(job, history)
|
||||
}
|
||||
return nil // Default success
|
||||
}
|
||||
func (m *mockTransferNotifier) Reset() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.sendNotificationsCalls = nil
|
||||
m.createNotificationCalls = nil
|
||||
}
|
||||
|
||||
// Mock TransferMetadataHandler
|
||||
var _ TransferMetadataHandler = (*mockTransferMetadataHandler)(nil)
|
||||
|
||||
type mockTransferMetadataHandler struct {
|
||||
mu sync.Mutex
|
||||
HasFileBeenProcessedFunc func(jobID uint, fileHash string) (bool, *db.FileMetadata, error)
|
||||
CheckFileProcessingHistoryFunc func(jobID uint, fileName string) (*db.FileMetadata, error)
|
||||
}
|
||||
|
||||
func (m *mockTransferMetadataHandler) hasFileBeenProcessed(jobID uint, fileHash string) (bool, *db.FileMetadata, error) {
|
||||
if m.HasFileBeenProcessedFunc != nil {
|
||||
return m.HasFileBeenProcessedFunc(jobID, fileHash)
|
||||
}
|
||||
return false, nil, nil // Default: not processed
|
||||
}
|
||||
func (m *mockTransferMetadataHandler) checkFileProcessingHistory(jobID uint, fileName string) (*db.FileMetadata, error) {
|
||||
if m.CheckFileProcessingHistoryFunc != nil {
|
||||
return m.CheckFileProcessingHistoryFunc(jobID, fileName)
|
||||
}
|
||||
return nil, fmt.Errorf("mock history not found for %s", fileName) // Default: not found
|
||||
}
|
||||
func (m *mockTransferMetadataHandler) Reset() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
// Reset any stored state if needed in the future
|
||||
}
|
||||
|
||||
// --- Mock os/exec ---
|
||||
|
||||
// MockExecCommand replaces the package-level execCommandContext variable (defined in transfer_executor.go)
|
||||
// with a function provided by the test and returns a function to restore the original.
|
||||
// Note: This helper replaces the *command creation*. The provided mockFunc
|
||||
// needs to return an *exec.Cmd. To mock the *execution result* (like CombinedOutput),
|
||||
// use the TestHelperProcess approach below or a similar technique.
|
||||
func MockExecCommand(mockFunc func(ctx context.Context, command string, args ...string) *exec.Cmd) (restore func()) {
|
||||
original := execCommandContext
|
||||
execCommandContext = mockFunc
|
||||
return func() { execCommandContext = original }
|
||||
}
|
||||
|
||||
// TestHelperProcess isn't a real test, but a helper process function.
|
||||
// It's triggered when tests run this binary with specific arguments and env vars.
|
||||
// Based on env vars like GO_TEST_HELPER_PROCESS_WANT_ERROR, it prints to stdout/stderr
|
||||
// and exits with 0 or 1.
|
||||
func TestHelperProcess(t *testing.T) {
|
||||
// Check if this invocation is intended to be the helper process
|
||||
if os.Getenv("GO_TEST_HELPER_PROCESS") != "1" {
|
||||
return
|
||||
}
|
||||
|
||||
// Simulate command execution based on environment variables
|
||||
mockOutput := os.Getenv("GO_TEST_HELPER_PROCESS_OUTPUT")
|
||||
mockStderr := os.Getenv("GO_TEST_HELPER_PROCESS_STDERR")
|
||||
wantError := os.Getenv("GO_TEST_HELPER_PROCESS_WANT_ERROR") == "1"
|
||||
|
||||
// Print the mock output/stderr
|
||||
fmt.Fprint(os.Stdout, mockOutput)
|
||||
fmt.Fprint(os.Stderr, mockStderr)
|
||||
|
||||
// Exit with appropriate code
|
||||
if wantError {
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// --- Test Setup ---
|
||||
|
||||
type testExecutorComponents struct {
|
||||
db *mockTransferDB
|
||||
logger *Logger
|
||||
logBuf *bytes.Buffer
|
||||
metadata *mockTransferMetadataHandler
|
||||
notifier *mockTransferNotifier
|
||||
executor *TransferExecutor
|
||||
}
|
||||
|
||||
func setupTestExecutor() testExecutorComponents {
|
||||
dbMock := &mockTransferDB{}
|
||||
logger, logBuf := newTestLogger(LogLevelDebug)
|
||||
metadataMock := &mockTransferMetadataHandler{}
|
||||
notifierMock := &mockTransferNotifier{}
|
||||
executor := NewTransferExecutor(dbMock, logger, metadataMock, notifierMock)
|
||||
|
||||
return testExecutorComponents{
|
||||
db: dbMock,
|
||||
logger: logger,
|
||||
logBuf: logBuf,
|
||||
metadata: metadataMock,
|
||||
notifier: notifierMock,
|
||||
executor: executor,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
func TestExecuteSimpleCommand_Success(t *testing.T) {
|
||||
comps := setupTestExecutor()
|
||||
defer comps.logger.Close()
|
||||
|
||||
job := db.Job{ID: 1, Name: "Simple Job"}
|
||||
config := db.TransferConfig{ID: 10, SourceType: "local", SourcePath: "/src", DestinationType: "local", DestinationPath: "/dst"}
|
||||
history := &db.JobHistory{ID: 100, JobID: 1, ConfigID: 10}
|
||||
configPath := "/tmp/test_rclone.conf"
|
||||
cmdName := "ls"
|
||||
cmdType := "listing"
|
||||
expectedOutput := "file1.txt\nfile2.txt\n"
|
||||
|
||||
// Mock exec.CommandContext using the TestHelperProcess strategy
|
||||
restoreExec := MockExecCommand(func(ctx context.Context, command string, args ...string) *exec.Cmd {
|
||||
cs := []string{"-test.run=TestHelperProcess", "--"} // Args for test binary
|
||||
cmd := exec.CommandContext(ctx, os.Args[0], cs...) // Run the test binary itself
|
||||
cmd.Env = []string{ // Set env vars for the helper process
|
||||
"GO_TEST_HELPER_PROCESS=1",
|
||||
fmt.Sprintf("GO_TEST_HELPER_PROCESS_OUTPUT=%s", expectedOutput),
|
||||
"GO_TEST_HELPER_PROCESS_WANT_ERROR=0",
|
||||
}
|
||||
return cmd
|
||||
})
|
||||
defer restoreExec()
|
||||
|
||||
// Replace the direct call to exec.Command with our context-aware version
|
||||
// This requires modifying the code under test slightly, or ensuring execCommandContext is used.
|
||||
// Assuming TransferExecutor uses execCommandContext internally (needs verification/refactor)
|
||||
// For now, we proceed assuming the mock intercepts correctly.
|
||||
// If TransferExecutor directly calls exec.Command, this mock won't work without refactoring TransferExecutor.
|
||||
|
||||
// Let's assume TransferExecutor needs refactoring to use execCommandContext.
|
||||
// We'll add a TODO in the original code and proceed with the test logic.
|
||||
// TODO: Refactor TransferExecutor to use execCommandContext instead of exec.Command
|
||||
|
||||
comps.executor.executeSimpleCommand(cmdName, cmdType, job, config, history, configPath)
|
||||
|
||||
// Assertions
|
||||
comps.db.mu.Lock()
|
||||
if comps.db.updatedHistory == nil {
|
||||
t.Fatal("Expected UpdateJobHistory to be called, but it wasn't")
|
||||
}
|
||||
if comps.db.updatedHistory.Status != "completed" {
|
||||
t.Errorf("Expected history status 'completed', got %q", comps.db.updatedHistory.Status)
|
||||
}
|
||||
// Note: FilesTransferred calculation based on output lines happens *after* CombinedOutput
|
||||
// in the original code. Our mock simulates CombinedOutput directly.
|
||||
// The test needs to align with how the code under test processes the output.
|
||||
// For "listing", it counts lines.
|
||||
if comps.db.updatedHistory.FilesTransferred != 2 { // Based on lines in expectedOutput
|
||||
t.Errorf("Expected FilesTransferred 2, got %d", comps.db.updatedHistory.FilesTransferred)
|
||||
}
|
||||
if !strings.Contains(comps.db.updatedHistory.ErrorMessage, expectedOutput) {
|
||||
t.Errorf("Expected history ErrorMessage to contain command output %q, got %q", expectedOutput, comps.db.updatedHistory.ErrorMessage)
|
||||
}
|
||||
comps.db.mu.Unlock()
|
||||
|
||||
comps.notifier.mu.Lock()
|
||||
if len(comps.notifier.sendNotificationsCalls) != 1 {
|
||||
t.Errorf("Expected 1 call to SendNotifications, got %d", len(comps.notifier.sendNotificationsCalls))
|
||||
}
|
||||
comps.notifier.mu.Unlock()
|
||||
|
||||
logOutput := comps.logBuf.String()
|
||||
if !strings.Contains(logOutput, "Successfully executed command 'ls'") {
|
||||
t.Errorf("Expected success log message, but got:\n%s", logOutput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSimpleCommand_Failure(t *testing.T) {
|
||||
comps := setupTestExecutor()
|
||||
defer comps.logger.Close()
|
||||
|
||||
job := db.Job{ID: 2, Name: "Fail Job"}
|
||||
config := db.TransferConfig{ID: 20, SourceType: "local", SourcePath: "/src", DestinationType: "local", DestinationPath: "/dst"}
|
||||
history := &db.JobHistory{ID: 200, JobID: 2, ConfigID: 20}
|
||||
configPath := "/tmp/test_rclone.conf"
|
||||
cmdName := "copy"
|
||||
cmdType := "transfer"
|
||||
expectedStderr := "Some rclone error"
|
||||
|
||||
// Mock exec.CommandContext using the TestHelperProcess strategy for failure
|
||||
restoreExec := MockExecCommand(func(ctx context.Context, command string, args ...string) *exec.Cmd {
|
||||
cs := []string{"-test.run=TestHelperProcess", "--"}
|
||||
cmd := exec.CommandContext(ctx, os.Args[0], cs...)
|
||||
cmd.Env = []string{
|
||||
"GO_TEST_HELPER_PROCESS=1",
|
||||
fmt.Sprintf("GO_TEST_HELPER_PROCESS_STDERR=%s", expectedStderr),
|
||||
"GO_TEST_HELPER_PROCESS_WANT_ERROR=1", // Indicate failure
|
||||
}
|
||||
return cmd
|
||||
})
|
||||
defer restoreExec()
|
||||
|
||||
// TODO: Refactor TransferExecutor to use execCommandContext instead of exec.Command
|
||||
|
||||
comps.executor.executeSimpleCommand(cmdName, cmdType, job, config, history, configPath)
|
||||
|
||||
// Assertions
|
||||
comps.db.mu.Lock()
|
||||
if comps.db.updatedHistory == nil {
|
||||
t.Fatal("Expected UpdateJobHistory to be called, but it wasn't")
|
||||
}
|
||||
if comps.db.updatedHistory.Status != "failed" {
|
||||
t.Errorf("Expected history status 'failed', got %q", comps.db.updatedHistory.Status)
|
||||
}
|
||||
// The error message should contain the stderr output captured by CombinedOutput
|
||||
if !strings.Contains(comps.db.updatedHistory.ErrorMessage, "Command Error:") || !strings.Contains(comps.db.updatedHistory.ErrorMessage, expectedStderr) {
|
||||
t.Errorf("Expected history ErrorMessage to contain 'Command Error:' and stderr %q, got %q", expectedStderr, comps.db.updatedHistory.ErrorMessage)
|
||||
}
|
||||
comps.db.mu.Unlock()
|
||||
|
||||
comps.notifier.mu.Lock()
|
||||
if len(comps.notifier.sendNotificationsCalls) != 1 {
|
||||
t.Errorf("Expected 1 call to SendNotifications, got %d", len(comps.notifier.sendNotificationsCalls))
|
||||
}
|
||||
comps.notifier.mu.Unlock()
|
||||
|
||||
logOutput := comps.logBuf.String()
|
||||
if !strings.Contains(logOutput, "Error executing command 'copy'") {
|
||||
t.Errorf("Expected error log message, but got:\n%s", logOutput)
|
||||
}
|
||||
if !strings.Contains(logOutput, expectedStderr) {
|
||||
t.Errorf("Expected stderr %q in log output, but got:\n%s", expectedStderr, logOutput)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Add tests for executeConfigTransfer (file-by-file)
|
||||
// - Success case
|
||||
// - Error during lsjson
|
||||
// - Error parsing lsjson
|
||||
// - No files found
|
||||
// - Error during individual file transfer
|
||||
// - Skipping processed files (hash match)
|
||||
// - Skipping processed files (name match, skip enabled)
|
||||
// - Re-processing file (skip disabled)
|
||||
// - Archiving success
|
||||
// - Archiving failure
|
||||
// - Deleting success
|
||||
// - Deleting failure
|
||||
// - Concurrent transfers limit
|
||||
// - Output pattern usage
|
||||
// - Filter usage
|
||||
@@ -25,7 +25,8 @@ func ProcessOutputPattern(pattern string, originalFilename string) string {
|
||||
|
||||
// Replace filename and extension variables
|
||||
processedPattern = strings.ReplaceAll(processedPattern, "${filename}", filename)
|
||||
processedPattern = strings.ReplaceAll(processedPattern, "${ext}", ext)
|
||||
// Remove leading dot from ext before replacing
|
||||
processedPattern = strings.ReplaceAll(processedPattern, "${ext}", strings.TrimPrefix(ext, "."))
|
||||
|
||||
return processedPattern
|
||||
}
|
||||
@@ -56,15 +57,16 @@ func createRcloneFilterFile(pattern string) (string, error) {
|
||||
|
||||
// Extract extension (with the dot)
|
||||
processedPattern = strings.ReplaceAll(processedPattern, "${ext}", "{2}")
|
||||
|
||||
// Create a rename rule for rclone using the correct syntax:
|
||||
// - The format for rename filters is: "-- SourceRegexp ReplacementPattern"
|
||||
// - For files with extension: capture the name and extension separately
|
||||
rule := fmt.Sprintf("-- (.*)(\\..+)$ %s\n", processedPattern)
|
||||
rule := fmt.Sprintf("-- (.*)(\\..+)$ %s\n", processedPattern) // Correct escaping for dot
|
||||
|
||||
// Add a fallback rule for files without extension
|
||||
// Keep [^.] as it correctly excludes literal dot in character class
|
||||
fallbackRule := fmt.Sprintf("-- ([^.]+)$ %s\n",
|
||||
strings.ReplaceAll(processedPattern, "{2}", ""))
|
||||
// Removed duplicate declaration below
|
||||
|
||||
// Write the rules to the file
|
||||
if _, err := tmpFile.WriteString(rule + fallbackRule); err != nil {
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestProcessOutputPattern(t *testing.T) {
|
||||
now := time.Now()
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
originalFilename string
|
||||
wantPatternRegex string // Use regex for date matching
|
||||
}{
|
||||
{
|
||||
name: "Simple filename and extension",
|
||||
pattern: "${filename}_processed.${ext}",
|
||||
originalFilename: "myfile.txt",
|
||||
wantPatternRegex: `^myfile_processed\.txt$`,
|
||||
},
|
||||
{
|
||||
name: "Filename without extension",
|
||||
pattern: "${filename}_backup",
|
||||
originalFilename: "important_data",
|
||||
wantPatternRegex: `^important_data_backup$`,
|
||||
},
|
||||
{
|
||||
name: "Date formatting YYYYMMDD",
|
||||
pattern: "${filename}_${date:20060102}.${ext}",
|
||||
originalFilename: "report.csv",
|
||||
wantPatternRegex: fmt.Sprintf(`^report_%s\.csv$`, now.Format("20060102")),
|
||||
},
|
||||
{
|
||||
name: "Date formatting with time",
|
||||
pattern: "${date:2006-01-02_150405}_${filename}.${ext}",
|
||||
originalFilename: "image.jpg",
|
||||
wantPatternRegex: fmt.Sprintf(`^%s_image\.jpg$`, now.Format("2006-01-02_150405")),
|
||||
},
|
||||
{
|
||||
name: "Combined date, filename, extension",
|
||||
pattern: "archive/${date:2006/01}/${filename}_${date:1504}.${ext}",
|
||||
originalFilename: "document.pdf",
|
||||
wantPatternRegex: fmt.Sprintf(`^archive/%s/document_%s\.pdf$`, now.Format("2006/01"), now.Format("1504")),
|
||||
},
|
||||
{
|
||||
name: "No variables",
|
||||
pattern: "fixed_output.dat",
|
||||
originalFilename: "input.bin",
|
||||
wantPatternRegex: `^fixed_output\.dat$`,
|
||||
},
|
||||
{
|
||||
name: "Filename with multiple dots",
|
||||
pattern: "${filename}.${ext}",
|
||||
originalFilename: "archive.tar.gz",
|
||||
wantPatternRegex: `^archive\.tar\.gz$`, // Ext should be .gz
|
||||
},
|
||||
{
|
||||
name: "Pattern with only date",
|
||||
pattern: "${date:2006}_backup",
|
||||
originalFilename: "data.zip",
|
||||
wantPatternRegex: fmt.Sprintf(`^%s_backup$`, now.Format("2006")),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Ensure the fix in utils.go (TrimPrefix) is present for this test to pass
|
||||
got := ProcessOutputPattern(tt.pattern, tt.originalFilename)
|
||||
matched, err := regexp.MatchString(tt.wantPatternRegex, got)
|
||||
if err != nil {
|
||||
t.Fatalf("Invalid regex pattern %q: %v", tt.wantPatternRegex, err)
|
||||
}
|
||||
if !matched {
|
||||
t.Errorf("ProcessOutputPattern(%q, %q) = %q, want match for regex %q", tt.pattern, tt.originalFilename, got, tt.wantPatternRegex)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Rewritten TestCreateRcloneFilterFile using parsing instead of regex matching
|
||||
func TestCreateRcloneFilterFile(t *testing.T) {
|
||||
now := time.Now()
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
wantReplacementRule string // Expected replacement part for the main rule
|
||||
wantFallbackRule string // Expected replacement part for the fallback rule
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "Simple rename with date",
|
||||
pattern: "${date:20060102}_${filename}.${ext}",
|
||||
wantReplacementRule: fmt.Sprintf("%s_{1}.{2}", now.Format("20060102")),
|
||||
wantFallbackRule: fmt.Sprintf("%s_{1}.", now.Format("20060102")),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Filename only",
|
||||
pattern: "prefix_${filename}",
|
||||
wantReplacementRule: "prefix_{1}",
|
||||
wantFallbackRule: "prefix_{1}",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Extension only (unlikely but test)",
|
||||
pattern: "file.${ext}",
|
||||
wantReplacementRule: "file.{2}",
|
||||
wantFallbackRule: "file.", // Fallback has no {2}
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Complex pattern with slashes",
|
||||
pattern: "processed/${date:2006/01}/${filename}_backup.${ext}",
|
||||
wantReplacementRule: fmt.Sprintf("processed/%s/{1}_backup.{2}", now.Format("2006/01")),
|
||||
wantFallbackRule: fmt.Sprintf("processed/%s/{1}_backup.", now.Format("2006/01")),
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filePath, err := createRcloneFilterFile(tt.pattern)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("createRcloneFilterFile() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
return // Expected error, test passed
|
||||
}
|
||||
defer os.Remove(filePath) // Clean up the temp file
|
||||
|
||||
contentBytes, readErr := os.ReadFile(filePath)
|
||||
if readErr != nil {
|
||||
t.Fatalf("Failed to read created filter file %q: %v", filePath, readErr)
|
||||
}
|
||||
content := string(contentBytes)
|
||||
lines := strings.Split(strings.TrimSpace(content), "\n")
|
||||
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("Expected 2 lines in filter file, got %d. Content:\n%s", len(lines), content)
|
||||
}
|
||||
|
||||
// Define the expected source patterns literally
|
||||
expectedSourcePattern1 := `(.*)(\..+)$`
|
||||
expectedSourcePattern2 := `([^.]+)$`
|
||||
|
||||
// Validate first rule (with extension)
|
||||
parts1 := strings.Fields(lines[0])
|
||||
if len(parts1) != 3 || parts1[0] != "--" || parts1[1] != expectedSourcePattern1 || parts1[2] != tt.wantReplacementRule {
|
||||
t.Errorf("Rule 1 mismatch:\n Got: %q\n Want: -- %s %s", lines[0], expectedSourcePattern1, tt.wantReplacementRule)
|
||||
}
|
||||
|
||||
// Validate second rule (fallback without extension)
|
||||
parts2 := strings.Fields(lines[1])
|
||||
if len(parts2) != 3 || parts2[0] != "--" || parts2[1] != expectedSourcePattern2 || parts2[2] != tt.wantFallbackRule {
|
||||
t.Errorf("Rule 2 (fallback) mismatch:\n Got: %q\n Want: -- %s %s", lines[1], expectedSourcePattern2, tt.wantFallbackRule)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user