Files
bookhoard/internal/app/app_test.go
T
john-okeefe 42b6fae297 chore(router): remove unused CollectionHandler from config
Remove the CollectionHandler field from router.Config struct and its
initialization in main.go. This field was never used - collections are
registered directly in handlers.SetupRoutes() where a CollectionHandler
is created locally.

Changes:
- Remove CollectionHandler field from internal/router/router.go Config
- Remove CollectionHandler: nil line from cmd/server/main.go

This cleans up dead code from the router refactoring. Collections
continue to work correctly as they are registered in SetupRoutes().

Related: Router refactoring completion
2026-02-07 17:59:06 -05:00

374 lines
10 KiB
Go

package app
import (
"context"
"os"
"syscall"
"testing"
"time"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// mockHandler is a mock implementation of the Handler interface for testing
type mockHandler struct {
startSchedulerCalled bool
stopSchedulerCalled bool
startDelay time.Duration
stopDelay time.Duration
startError error
stopError error
}
func (m *mockHandler) StartScheduler() {
m.startSchedulerCalled = true
if m.startDelay > 0 {
time.Sleep(m.startDelay)
}
if m.startError != nil {
panic(m.startError)
}
}
func (m *mockHandler) StopScheduler() {
m.stopSchedulerCalled = true
if m.stopDelay > 0 {
time.Sleep(m.stopDelay)
}
if m.stopError != nil {
panic(m.stopError)
}
}
// reset resets the mock handler state
func (m *mockHandler) reset() {
m.startSchedulerCalled = false
m.stopSchedulerCalled = false
}
// TestApp_New tests Phase 5: App constructor
func TestApp_New(t *testing.T) {
e := echo.New()
handler := &mockHandler{}
app := New(e, handler)
assert.NotNil(t, app, "App should not be nil")
assert.Equal(t, e, app.echo, "Echo instance should be stored")
assert.Equal(t, handler, app.handler, "Handler should be stored")
assert.Equal(t, 30*time.Second, app.shutdownTimeout, "Default shutdown timeout should be 30 seconds")
assert.NotNil(t, app.shutdownDone, "Shutdown done channel should be initialized")
}
// TestApp_SetShutdownTimeout tests Phase 5: configurable shutdown timeout
func TestApp_SetShutdownTimeout(t *testing.T) {
e := echo.New()
handler := &mockHandler{}
app := New(e, handler)
customTimeout := 15 * time.Second
app.SetShutdownTimeout(customTimeout)
assert.Equal(t, customTimeout, app.shutdownTimeout, "Shutdown timeout should be updated")
}
// TestApp_ShutdownDone tests Phase 5: shutdown done channel
func TestApp_ShutdownDone(t *testing.T) {
e := echo.New()
handler := &mockHandler{}
app := New(e, handler)
channel := app.ShutdownDone()
assert.NotNil(t, channel, "ShutdownDone should return a channel")
// Verify it's the same channel by checking if it's readable
select {
case <-channel:
// Channel should not be closed yet
t.Error("ShutdownDone channel should not be closed immediately")
default:
// Expected - channel is open but not ready
}
}
// TestApp_Start_BackgroundServices tests Phase 1 & 5: background service startup
func TestApp_Start_BackgroundServices(t *testing.T) {
e := echo.New()
handler := &mockHandler{
startDelay: 100 * time.Millisecond, // Short delay to verify async startup
}
app := New(e, handler)
// Start the app in a goroutine (simulating main.go)
done := make(chan error, 1)
go func() {
// This will block until shutdown signal
err := app.Start()
done <- err
}()
// Wait a bit for background services to start
time.Sleep(150 * time.Millisecond)
// Verify scheduler was started
assert.True(t, handler.startSchedulerCalled, "StartScheduler should be called")
// Send shutdown signal to clean up
done2 := make(chan bool)
go func() {
// Send shutdown signal
process, err := os.FindProcess(os.Getpid())
require.NoError(t, err, "Should find current process")
err = process.Signal(syscall.SIGTERM)
done2 <- (err == nil)
}()
// Wait for app to finish
select {
case err := <-done:
assert.NoError(t, err, "App should shut down without error")
case <-time.After(5 * time.Second):
t.Fatal("App did not shut down within timeout")
}
<-done2
}
// TestApp_Shutdown_GracefulShutdown tests Phase 5: graceful shutdown sequence
func TestApp_Shutdown_GracefulShutdown(t *testing.T) {
e := echo.New()
handler := &mockHandler{
stopDelay: 50 * time.Millisecond,
}
app := New(e, handler)
// Test shutdown
err := app.Shutdown()
assert.NoError(t, err, "Shutdown should complete without error")
assert.True(t, handler.stopSchedulerCalled, "StopScheduler should be called")
}
// TestApp_Shutdown_ThreadSafety tests Phase 5: thread-safe shutdown
func TestApp_Shutdown_ThreadSafety(t *testing.T) {
e := echo.New()
handler := &mockHandler{}
app := New(e, handler)
// Call shutdown multiple times concurrently
errors := make(chan error, 3)
for i := 0; i < 3; i++ {
go func() {
errors <- app.Shutdown()
}()
}
// Collect results
for i := 0; i < 3; i++ {
err := <-errors
// First call should succeed, subsequent calls should also succeed (no-op)
assert.NoError(t, err, "Concurrent shutdown calls should not error")
}
// Verify handler was stopped only once
assert.True(t, handler.stopSchedulerCalled, "StopScheduler should be called at least once")
}
// TestApp_Shutdown_Timeout tests Phase 5: shutdown timeout handling
func TestApp_Shutdown_Timeout(t *testing.T) {
e := echo.New()
handler := &mockHandler{
stopDelay: 2 * time.Second, // Longer than shutdown timeout
}
app := New(e, handler)
app.SetShutdownTimeout(100 * time.Millisecond) // Set very short timeout
// Test shutdown with timeout
err := app.Shutdown()
assert.Error(t, err, "Shutdown should timeout and return error")
assert.True(t, handler.stopSchedulerCalled, "StopScheduler should still be called")
}
// TestApp_Shutdown_ClosesEchoServer tests Phase 5: HTTP server shutdown
func TestApp_Shutdown_ClosesEchoServer(t *testing.T) {
e := echo.New()
handler := &mockHandler{}
// Start a simple server
go func() {
e.Start(":0") // Use random port
}()
// Give server time to start
time.Sleep(100 * time.Millisecond)
app := New(e, handler)
// Shutdown should close the echo server
err := app.Shutdown()
assert.NoError(t, err, "Shutdown should complete")
assert.True(t, handler.stopSchedulerCalled, "StopScheduler should be called")
}
// TestApp_SignalHandling tests Phase 5: signal handling (SIGINT, SIGTERM, SIGQUIT)
func TestApp_SignalHandling(t *testing.T) {
tests := []struct {
name string
signal os.Signal
}{
{"SIGINT", syscall.SIGINT},
{"SIGTERM", syscall.SIGTERM},
{"SIGQUIT", syscall.SIGQUIT},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
e := echo.New()
handler := &mockHandler{}
app := New(e, handler)
// Start app in goroutine
done := make(chan error, 1)
go func() {
done <- app.Start()
}()
// Wait for background services to start
time.Sleep(100 * time.Millisecond)
assert.True(t, handler.startSchedulerCalled, "StartScheduler should be called")
// Send signal
process, err := os.FindProcess(os.Getpid())
require.NoError(t, err, "Should find current process")
err = process.Signal(tt.signal)
require.NoError(t, err, "Should send signal")
// Wait for shutdown
select {
case err := <-done:
assert.NoError(t, err, "App should shut down without error")
assert.True(t, handler.stopSchedulerCalled, "StopScheduler should be called")
case <-time.After(5 * time.Second):
t.Fatal("App did not shut down within timeout")
}
})
}
}
// TestApp_Integration_StartupSequence tests Phase 1 & 5: complete startup sequence
func TestApp_Integration_StartupSequence(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
e := echo.New()
handler := &mockHandler{}
app := New(e, handler)
// This test simulates the sequence in cmd/server/main.go:
// 1. Create app
// 2. Start HTTP server in background
// 3. Call app.Start() which blocks until signal
// Step 1: App already created above
assert.NotNil(t, app, "App should be created")
// Step 2: Start HTTP server (simulated)
serverReady := make(chan bool)
go func() {
// In real main.go, this would be: e.Start(":" + cfg.ServerPort)
// For testing, we don't actually start the server
serverReady <- true
<-context.Background().Done() // Block until done
}()
<-serverReady
// Step 3: Start app lifecycle in background
appDone := make(chan error, 1)
go func() {
appDone <- app.Start()
}()
// Verify background services started
time.Sleep(150 * time.Millisecond)
assert.True(t, handler.startSchedulerCalled, "Scheduler should be started")
// Send shutdown signal (simulating user pressing Ctrl+C)
process, err := os.FindProcess(os.Getpid())
require.NoError(t, err)
err = process.Signal(syscall.SIGINT)
require.NoError(t, err)
// Wait for graceful shutdown
select {
case err := <-appDone:
assert.NoError(t, err, "App should shut down gracefully")
assert.True(t, handler.stopSchedulerCalled, "Scheduler should be stopped")
case <-time.After(5 * time.Second):
t.Fatal("App did not complete shutdown within timeout")
}
}
// TestApp_HandlerInterface tests that Handler interface is properly implemented
func TestApp_HandlerInterface(t *testing.T) {
// This test verifies that the mock handler satisfies the Handler interface
var _ Handler = &mockHandler{}
}
// TestPhase1_AutoStartVerification tests Phase 1: auto-start functionality
func TestPhase1_AutoStartVerification(t *testing.T) {
t.Run("Auto-start runs asynchronously", func(t *testing.T) {
e := echo.New()
handler := &mockHandler{
startDelay: 100 * time.Millisecond,
}
app := New(e, handler)
// Start the app - it should start background services and return immediately
// Then we send a shutdown signal right away
done := make(chan error, 1)
go func() {
// This will start services in background, then wait for signal
err := app.Start()
done <- err
}()
// Wait a bit for background services to start
time.Sleep(150 * time.Millisecond)
// Verify scheduler was started in background
assert.True(t, handler.startSchedulerCalled, "StartScheduler should be called in background")
// Send shutdown signal
process, err := os.FindProcess(os.Getpid())
require.NoError(t, err)
_ = process.Signal(syscall.SIGTERM)
// Wait for clean shutdown
select {
case err := <-done:
assert.NoError(t, err, "App should shut down gracefully")
case <-time.After(5 * time.Second):
t.Fatal("App did not shut down within timeout")
}
})
}
// BenchmarkApp_Shutdown benchmarks the shutdown process
func BenchmarkApp_Shutdown(b *testing.B) {
e := echo.New()
handler := &mockHandler{}
b.ResetTimer()
for i := 0; i < b.N; i++ {
handler.reset()
app := New(e, handler)
app.Shutdown()
}
}