Files
photoview/api/scanner/periodic_scanner/periodic_scanner_test.go
2025-07-22 21:14:20 +02:00

280 lines
8.3 KiB
Go

package periodic_scanner
import (
"errors"
"sync"
"testing"
"time"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/test_utils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"gorm.io/gorm"
)
// MockScannerQueue implements the ScannerQueue interface for testing
type MockScannerQueue struct {
mock.Mock
}
func (m *MockScannerQueue) AddAllToQueue() error {
return m.Called().Error(0)
}
func TestMain(m *testing.M) {
test_utils.UnitTestRun(m)
}
func resetPeriodicScanner() {
if mainPeriodicScanner != nil {
select {
case <-mainPeriodicScanner.done:
// Already closed
default:
close(mainPeriodicScanner.done)
}
if mainPeriodicScanner.ticker != nil {
mainPeriodicScanner.ticker.Stop()
}
mainPeriodicScanner = nil
}
}
func createTestSiteInfo(db *gorm.DB, interval int) error {
siteInfo := models.SiteInfo{
InitialSetup: false,
PeriodicScanInterval: interval,
ConcurrentWorkers: 1,
}
return db.Create(&siteInfo).Error
}
func TestGetPeriodicScanInterval(t *testing.T) {
db := test_utils.DatabaseTest(t)
t.Run("successful retrieval", func(t *testing.T) {
assert.NoError(t, createTestSiteInfo(db, 300), "Failed to create test site info with 300 second interval")
duration, err := getPeriodicScanInterval(db)
assert.NoError(t, err, "Failed to retrieve periodic scan interval from database")
assert.Equal(t, 300*time.Second, duration,
"Periodic scan interval should be 300 seconds but got %v", duration)
})
t.Run("database error - no site info", func(t *testing.T) {
db.Exec("DELETE FROM site_info")
duration, err := getPeriodicScanInterval(db)
assert.Error(t, err, "Expected error when no site info exists in database")
assert.Equal(t, time.Duration(0), duration,
"Duration should be zero when database error occurs, but got %v", duration)
})
}
func TestInitializePeriodicScanner(t *testing.T) {
db := test_utils.DatabaseTest(t)
t.Run("successful initialization with injection", func(t *testing.T) {
defer resetPeriodicScanner()
mockQueue := &MockScannerQueue{}
assert.NoError(t, createTestSiteInfo(db, 300), "Failed to create test site info with 300 second interval")
assert.NoError(t, InitializePeriodicScannerWithQueue(db, mockQueue),
"Failed to initialize periodic scanner with mock queue")
// Verify initialization
assert.NotNil(t, mainPeriodicScanner, "mainPeriodicScanner should not be nil after successful initialization")
assert.NotNil(t, mainPeriodicScanner.scannerQueue, "Scanner queue should not be nil after initialization")
assert.Equal(t, mockQueue, mainPeriodicScanner.scannerQueue, "Scanner should use the injected mock queue instance")
// Verify ticker is set up
mainPeriodicScanner.tickerLocker.Lock()
tickerExists := mainPeriodicScanner.ticker != nil
mainPeriodicScanner.tickerLocker.Unlock()
assert.True(t, tickerExists, "Ticker should be created and set up after scanner initialization")
})
t.Run("backward compatibility with original function", func(t *testing.T) {
defer resetPeriodicScanner()
assert.NoError(t, createTestSiteInfo(db, 300), "Failed to create test site info with 300 second interval")
assert.NoError(t, InitializePeriodicScanner(db),
"Failed to initialize periodic scanner using original function")
// Verify it uses RealScannerQueue
assert.NotNil(t, mainPeriodicScanner,
"mainPeriodicScanner should be initialized by original InitializePeriodicScanner function")
assert.IsType(t, &RealScannerQueue{}, mainPeriodicScanner.scannerQueue,
"Original InitializePeriodicScanner should use RealScannerQueue by default")
})
t.Run("double initialization error", func(t *testing.T) {
defer resetPeriodicScanner()
mockQueue := &MockScannerQueue{}
assert.NoError(t, createTestSiteInfo(db, 300), "Failed to create test site info with 300 second interval")
assert.NoError(t, InitializePeriodicScannerWithQueue(db, mockQueue),
"Failed first initialization for double initialization test")
err := InitializePeriodicScannerWithQueue(db, mockQueue)
assert.Error(t, err, "Second initialization attempt should return an error")
assert.Contains(t, err.Error(), "already been initialized",
"Double initialization error should contain 'already been initialized' message")
})
}
func TestScanIntervalRunnerWithMocking(t *testing.T) {
t.Run("runner calls queue on ticker events", func(t *testing.T) {
mockQueue := &MockScannerQueue{}
// Use a channel to synchronize and count calls
callChan := make(chan struct{}, 5) // Buffer for multiple calls
mockQueue.On("AddAllToQueue").Return(nil).Run(func(args mock.Arguments) {
select {
case callChan <- struct{}{}:
default:
// Channel full, but that's okay
}
}).Maybe()
ps := &periodicScanner{
ticker: time.NewTicker(50 * time.Millisecond),
ticker_changed: make(chan bool, 1),
done: make(chan struct{}),
tickerLocker: sync.Mutex{},
scannerQueue: mockQueue,
}
// Start runner
go ps.scanIntervalRunner()
// Wait for at least one call with timeout
select {
case <-callChan:
// Success - at least one call received
case <-time.After(200 * time.Millisecond):
t.Fatal("Expected at least one call to AddAllToQueue within 200ms timeout, but ticker events were not processed")
}
// Proper cleanup - stop ticker first, then close done
ps.ticker.Stop()
close(ps.done)
// Give time for goroutine to finish
time.Sleep(50 * time.Millisecond)
// Verify the queue was called as expected
mockQueue.AssertExpectations(t)
})
t.Run("runner handles queue errors gracefully", func(t *testing.T) {
mockQueue := &MockScannerQueue{}
// Mock queue to return an error
mockQueue.On("AddAllToQueue").Return(errors.New("queue error")).Maybe()
ps := &periodicScanner{
ticker: time.NewTicker(30 * time.Millisecond),
ticker_changed: make(chan bool, 1),
done: make(chan struct{}),
tickerLocker: sync.Mutex{},
scannerQueue: mockQueue,
}
// Start runner and let it run briefly
go ps.scanIntervalRunner()
time.Sleep(80 * time.Millisecond)
// Proper cleanup
ps.ticker.Stop()
close(ps.done)
time.Sleep(50 * time.Millisecond)
// Test passes if no panic occurred - errors should be logged gracefully
mockQueue.AssertExpectations(t)
})
t.Run("runner responds to shutdown signal", func(t *testing.T) {
mockQueue := &MockScannerQueue{}
ps := &periodicScanner{
ticker: nil, // No ticker to avoid timing issues
ticker_changed: make(chan bool, 1),
done: make(chan struct{}),
tickerLocker: sync.Mutex{},
scannerQueue: mockQueue,
}
runnerDone := make(chan bool)
go func() {
ps.scanIntervalRunner()
close(runnerDone)
}()
close(ps.done)
select {
case <-runnerDone:
// Success
case <-time.After(1 * time.Second):
t.Fatal("scanIntervalRunner goroutine did not exit within 1 second after closing done channel")
}
mockQueue.AssertExpectations(t)
})
t.Run("runner responds to ticker changes", func(t *testing.T) {
mockQueue := &MockScannerQueue{}
ps := &periodicScanner{
ticker: nil, // Start without ticker
ticker_changed: make(chan bool, 1),
done: make(chan struct{}),
tickerLocker: sync.Mutex{},
scannerQueue: mockQueue,
}
runnerDone := make(chan bool)
go func() {
ps.scanIntervalRunner()
close(runnerDone)
}()
// Send ticker change signal
select {
case ps.ticker_changed <- true:
case <-time.After(100 * time.Millisecond):
t.Fatal("Could not send ticker change signal within 100ms - channel may be blocked")
}
// Clean shutdown
close(ps.done)
// Wait for completion
select {
case <-runnerDone:
// Success
case <-time.After(500 * time.Millisecond):
t.Fatal("scanIntervalRunner did not exit within 500ms after receiving ticker change signal and shutdown")
}
// Test passes if no deadlock occurs
mockQueue.AssertExpectations(t)
})
}
func TestRealScannerQueue(t *testing.T) {
t.Run("real scanner queue interface compliance", func(t *testing.T) {
queue := &RealScannerQueue{}
// Just verify it implements the interface correctly
var _ ScannerQueue = queue
// Test that it doesn't panic when created
assert.NotNil(t, queue, "RealScannerQueue instance should not be nil after creation")
// We don't test the actual call since it requires external setup
})
}