diff --git a/internal/testutil/sqlite_test.go b/internal/testutil/sqlite_test.go new file mode 100644 index 00000000..f80afc41 --- /dev/null +++ b/internal/testutil/sqlite_test.go @@ -0,0 +1,209 @@ +package testutil + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "sub2api-cn-relay-manager/internal/store/sqlite" +) + +func TestSQLiteTestDSN(t *testing.T) { + tests := []struct { + name string + fileName string + disableForeignKeys bool + wantContains []string + }{ + { + name: "with foreign keys enabled", + fileName: "test.db", + disableForeignKeys: false, + wantContains: []string{ + "file:", + "test.db", + "_busy_timeout=5000", + }, + }, + { + name: "with foreign keys disabled", + fileName: "test_fk.db", + disableForeignKeys: true, + wantContains: []string{ + "file:", + "test_fk.db", + "_busy_timeout=5000", + "_pragma=foreign_keys(0)", + }, + }, + { + name: "with special characters in filename", + fileName: "test-file_123.db", + disableForeignKeys: false, + wantContains: []string{ + "test-file_123.db", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dsn := SQLiteTestDSN(t, tt.fileName, tt.disableForeignKeys) + + for _, want := range tt.wantContains { + if !strings.Contains(dsn, want) { + t.Errorf("SQLiteTestDSN() = %v, want to contain %v", dsn, want) + } + } + + // Verify DSN starts with file: + if !strings.HasPrefix(dsn, "file:") { + t.Errorf("SQLiteTestDSN() = %v, want to start with 'file:'", dsn) + } + }) + } +} + +func TestSQLiteTestDSN_CreatesUniquePaths(t *testing.T) { + dsn1 := SQLiteTestDSN(t, "test.db", false) + dsn2 := SQLiteTestDSN(t, "test.db", false) + + // Each call should return a different path (different temp dirs) + if dsn1 == dsn2 { + t.Error("SQLiteTestDSN() should create unique paths for each call") + } +} + +func TestOpenSQLiteStore(t *testing.T) { + dsn := SQLiteTestDSN(t, "open_test.db", false) + store := OpenSQLiteStore(t, dsn) + + if store == nil { + t.Fatal("OpenSQLiteStore() returned nil store") + } + + // Verify store is functional by checking connection + ctx := context.Background() + if err := store.SQLDB().PingContext(ctx); err != nil { + t.Errorf("store.SQLDB().PingContext() error = %v", err) + } + + // Cleanup + CloseSQLiteStore(t, store) +} + +func TestOpenSQLiteStore_WithForeignKeysDisabled(t *testing.T) { + dsn := SQLiteTestDSN(t, "open_test_nofk.db", true) + store := OpenSQLiteStore(t, dsn) + + if store == nil { + t.Fatal("OpenSQLiteStore() returned nil store") + } + + // Verify store works + ctx := context.Background() + if err := store.SQLDB().PingContext(ctx); err != nil { + t.Errorf("store.SQLDB().PingContext() error = %v", err) + } + + CloseSQLiteStore(t, store) +} + +func TestCloseSQLiteStore(t *testing.T) { + dsn := SQLiteTestDSN(t, "close_test.db", false) + store := OpenSQLiteStore(t, dsn) + + // This should succeed without errors + CloseSQLiteStore(t, store) + + // Verify store is closed by attempting another operation + // Note: This behavior may vary by driver, but typically + // operations on closed database return an error +} + +func TestOpenSQLiteStore_InvalidDSN(t *testing.T) { + // Create a mock testing.T that captures fatal calls + mockT := &mockTestingTB{} + + // Use an invalid DSN that should cause Open to fail + func() { + defer func() { + // Recover from the t.Fatalf panic that OpenSQLiteStore will call + if r := recover(); r != nil { + // Expected panic from t.Fatalf + } + }() + OpenSQLiteStore(mockT, "invalid:/path/with/special/chars/that/might/not/work") + }() + + // The mock testing.TB should have recorded the fatal call + if !mockT.fatalfCalled { + t.Error("OpenSQLiteStore() should call t.Fatalf for invalid DSN") + } +} + +// mockTestingTB is a mock implementation of testing.TB for testing failure cases +type mockTestingTB struct { + testing.TB + helperCalled bool + fatalfCalled bool + fatalfMsg string +} + +func (m *mockTestingTB) Helper() { + m.helperCalled = true +} + +func (m *mockTestingTB) Fatalf(format string, args ...interface{}) { + m.fatalfCalled = true + m.fatalfMsg = format + panic("mock Fatalf called") +} + +func (m *mockTestingTB) TempDir() string { + return "/tmp/mock_test_dir" +} + +func (m *mockTestingTB) Cleanup(func()) {} + +func TestSQLiteTestDSN_PathHandling(t *testing.T) { + // Test that paths with directory separators are handled correctly + fileName := filepath.Join("subdir", "deep", "test.db") + dsn := SQLiteTestDSN(t, fileName, false) + + // The DSN should contain the file: prefix + if !strings.HasPrefix(dsn, "file:") { + t.Errorf("SQLiteTestDSN() path handling failed, got %v", dsn) + } + + // The DSN should be using forward slashes (URI format) + // and should not contain backslashes even on Windows + if strings.Contains(dsn, "\\") { + t.Errorf("SQLiteTestDSN() should use forward slashes, got %v", dsn) + } +} + +func TestSQLiteStoreConcurrency(t *testing.T) { + // Test that multiple stores can be opened concurrently + const numStores = 5 + stores := make([]*sqlite.DB, numStores) + + for i := 0; i < numStores; i++ { + dsn := SQLiteTestDSN(t, "concurrent_test.db", false) + stores[i] = OpenSQLiteStore(t, dsn) + } + + // Verify all stores are functional + ctx := context.Background() + for i, store := range stores { + if err := store.SQLDB().PingContext(ctx); err != nil { + t.Errorf("store[%d].SQLDB().PingContext() error = %v", i, err) + } + } + + // Cleanup all stores + for _, store := range stores { + CloseSQLiteStore(t, store) + } +}