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:
StarFleetCPTN
2025-03-29 21:23:52 -07:00
parent ae18cd1a12
commit b35174f857
15 changed files with 2679 additions and 158 deletions
+52 -23
View File
@@ -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
}
+526
View File
@@ -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()
}
+206
View File
@@ -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).
+23 -6
View File
@@ -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
+226
View File
@@ -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)
}
})
}
}
+28 -14
View File
@@ -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
+308
View File
@@ -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: &notifySuccess,
NotifyOnFailure: &notifyFailure,
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
View File
@@ -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
}
+525
View File
@@ -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()
}
+75 -37
View File
@@ -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
+5 -3
View File
@@ -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 {
+165
View File
@@ -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)
}
})
}
}