diff --git a/.codecov.yml b/.codecov.yml index 84d903d0..88a9c27d 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -107,6 +107,12 @@ ignore: - "backend/internal/metrics/**" - "backend/internal/trace/**" + # Backend test utilities (test infrastructure, not application code) + # These files contain testing helpers that take *testing.T and are only + # callable from *_test.go files - they cannot be covered by production code + - "backend/internal/api/handlers/testdb.go" + - "backend/internal/api/handlers/test_helpers.go" + # ========================================================================== # Frontend test utilities and helpers # These are test infrastructure, not application code diff --git a/backend/internal/api/handlers/test_helpers_test.go b/backend/internal/api/handlers/test_helpers_test.go new file mode 100644 index 00000000..451d078a --- /dev/null +++ b/backend/internal/api/handlers/test_helpers_test.go @@ -0,0 +1,82 @@ +package handlers + +import ( + "sync/atomic" + "testing" + "time" +) + +// TestWaitForCondition_PassesImmediately tests that waitForCondition +// returns immediately when the condition is already true. +func TestWaitForCondition_PassesImmediately(t *testing.T) { + start := time.Now() + waitForCondition(t, 1*time.Second, func() bool { + return true // Always true + }) + elapsed := time.Since(start) + + // Should complete almost instantly (allow up to 50ms for overhead) + if elapsed > 50*time.Millisecond { + t.Errorf("expected immediate return, took %v", elapsed) + } +} + +// TestWaitForCondition_PassesAfterIterations tests that waitForCondition +// waits and retries until the condition becomes true. +func TestWaitForCondition_PassesAfterIterations(t *testing.T) { + var counter atomic.Int32 + + start := time.Now() + waitForCondition(t, 500*time.Millisecond, func() bool { + counter.Add(1) + return counter.Load() >= 3 // Pass after 3 checks + }) + elapsed := time.Since(start) + + // Should have taken at least 2 polling intervals (20ms minimum) + // but complete well before timeout + if elapsed < 20*time.Millisecond { + t.Errorf("expected at least 2 iterations (~20ms), took only %v", elapsed) + } + if elapsed > 400*time.Millisecond { + t.Errorf("should complete well before timeout, took %v", elapsed) + } + if counter.Load() < 3 { + t.Errorf("expected at least 3 checks, got %d", counter.Load()) + } +} + +// TestWaitForConditionWithInterval_PassesImmediately tests that +// waitForConditionWithInterval returns immediately when condition is true. +func TestWaitForConditionWithInterval_PassesImmediately(t *testing.T) { + start := time.Now() + waitForConditionWithInterval(t, 1*time.Second, 50*time.Millisecond, func() bool { + return true + }) + elapsed := time.Since(start) + + if elapsed > 50*time.Millisecond { + t.Errorf("expected immediate return, took %v", elapsed) + } +} + +// TestWaitForConditionWithInterval_CustomInterval tests that the custom +// interval is respected when polling. +func TestWaitForConditionWithInterval_CustomInterval(t *testing.T) { + var counter atomic.Int32 + + start := time.Now() + waitForConditionWithInterval(t, 500*time.Millisecond, 30*time.Millisecond, func() bool { + counter.Add(1) + return counter.Load() >= 3 + }) + elapsed := time.Since(start) + + // With 30ms interval, 3 checks should take at least 60ms + if elapsed < 50*time.Millisecond { + t.Errorf("expected at least ~60ms with 30ms interval, took %v", elapsed) + } + if counter.Load() < 3 { + t.Errorf("expected at least 3 checks, got %d", counter.Load()) + } +} diff --git a/backend/internal/api/handlers/testdb_test.go b/backend/internal/api/handlers/testdb_test.go new file mode 100644 index 00000000..2982f166 --- /dev/null +++ b/backend/internal/api/handlers/testdb_test.go @@ -0,0 +1,164 @@ +package handlers + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/Wikid82/charon/backend/internal/models" +) + +// TestGetTemplateDB tests that the template database is initialized correctly +// and can be retrieved multiple times (singleton pattern). +func TestGetTemplateDB(t *testing.T) { + // First call should initialize the template + tmpl1, err1 := GetTemplateDB() + require.NoError(t, err1, "first GetTemplateDB should succeed") + require.NotNil(t, tmpl1, "template DB should not be nil") + + // Second call should return the same instance + tmpl2, err2 := GetTemplateDB() + require.NoError(t, err2, "second GetTemplateDB should succeed") + require.NotNil(t, tmpl2, "template DB should not be nil on second call") + + // Both should be the same instance (singleton) + assert.Equal(t, tmpl1, tmpl2, "GetTemplateDB should return same instance") +} + +// TestGetTemplateDB_HasTables verifies the template DB has migrated tables. +func TestGetTemplateDB_HasTables(t *testing.T) { + tmpl, err := GetTemplateDB() + require.NoError(t, err) + require.NotNil(t, tmpl) + + // Check that some key tables exist in the template + var tables []string + rows, err := tmpl.Raw("SELECT name FROM sqlite_master WHERE type='table'").Rows() + require.NoError(t, err) + defer rows.Close() + + for rows.Next() { + var name string + require.NoError(t, rows.Scan(&name)) + tables = append(tables, name) + } + + // Verify some expected tables exist + assert.Contains(t, tables, "users", "template should have users table") + assert.Contains(t, tables, "proxy_hosts", "template should have proxy_hosts table") + assert.Contains(t, tables, "settings", "template should have settings table") +} + +// TestOpenTestDB creates a basic test database. +func TestOpenTestDB(t *testing.T) { + db := OpenTestDB(t) + require.NotNil(t, db, "OpenTestDB should return non-nil DB") + + // Verify we can execute basic SQL + var result int + err := db.Raw("SELECT 1").Scan(&result).Error + require.NoError(t, err) + assert.Equal(t, 1, result) +} + +// TestOpenTestDB_Uniqueness ensures each call creates a unique database. +func TestOpenTestDB_Uniqueness(t *testing.T) { + db1 := OpenTestDB(t) + db2 := OpenTestDB(t) + + require.NotNil(t, db1) + require.NotNil(t, db2) + + // Create a table in db1 + err := db1.Exec("CREATE TABLE test_unique (id INTEGER PRIMARY KEY)").Error + require.NoError(t, err) + + // Insert a row in db1 + err = db1.Exec("INSERT INTO test_unique (id) VALUES (1)").Error + require.NoError(t, err) + + // db2 should NOT have this table (different database) + var count int64 + err = db2.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='test_unique'").Scan(&count).Error + require.NoError(t, err) + assert.Equal(t, int64(0), count, "db2 should not have db1's table") +} + +// TestOpenTestDBWithMigrations tests the function that creates a DB with migrations. +func TestOpenTestDBWithMigrations(t *testing.T) { + db := OpenTestDBWithMigrations(t) + require.NotNil(t, db, "OpenTestDBWithMigrations should return non-nil DB") + + // Verify that tables were created + var tables []string + rows, err := db.Raw("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Rows() + require.NoError(t, err) + defer rows.Close() + + for rows.Next() { + var name string + require.NoError(t, rows.Scan(&name)) + tables = append(tables, name) + } + + // Should have key tables from the migration + assert.Contains(t, tables, "users", "should have users table") + assert.Contains(t, tables, "proxy_hosts", "should have proxy_hosts table") + assert.Contains(t, tables, "settings", "should have settings table") +} + +// TestOpenTestDBWithMigrations_CanInsertData verifies we can actually use the migrated DB. +func TestOpenTestDBWithMigrations_CanInsertData(t *testing.T) { + db := OpenTestDBWithMigrations(t) + require.NotNil(t, db) + + // Create a user + user := &models.User{ + UUID: "test-uuid-123", + Name: "testuser", + Email: "test@example.com", + PasswordHash: "fakehash", + } + err := db.Create(user).Error + require.NoError(t, err, "should be able to create user in migrated DB") + assert.NotZero(t, user.ID, "user should have an ID after creation") + + // Query it back + var fetched models.User + err = db.First(&fetched, user.ID).Error + require.NoError(t, err) + assert.Equal(t, "testuser", fetched.Name) +} + +// TestOpenTestDBWithMigrations_MultipleModels tests various model operations. +func TestOpenTestDBWithMigrations_MultipleModels(t *testing.T) { + db := OpenTestDBWithMigrations(t) + require.NotNil(t, db) + + // Test ProxyHost + host := &models.ProxyHost{ + UUID: "test-host-uuid", + DomainNames: "example.com", + ForwardHost: "localhost", + ForwardPort: 8080, + } + err := db.Create(host).Error + require.NoError(t, err, "should be able to create proxy host") + + // Test Setting + setting := &models.Setting{ + Key: "test_key", + Value: "test_value", + } + err = db.Create(setting).Error + require.NoError(t, err, "should be able to create setting") + + // Verify counts + var hostCount, settingCount int64 + db.Model(&models.ProxyHost{}).Count(&hostCount) + db.Model(&models.Setting{}).Count(&settingCount) + + assert.Equal(t, int64(1), hostCount) + assert.Equal(t, int64(1), settingCount) +}