Files
StarFleetCPTN b35174f857 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.
2025-03-29 21:23:52 -07:00

527 lines
17 KiB
Go

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