diff --git a/pom.xml b/pom.xml index 19437b2..136bd43 100644 --- a/pom.xml +++ b/pom.xml @@ -206,6 +206,11 @@ + + org.springframework.boot + spring-boot-testcontainers + test + org.testcontainers testcontainers @@ -224,6 +229,12 @@ 1.19.7 test + + org.testcontainers + junit-jupiter + 1.19.7 + test + diff --git a/src/main/java/de/avatic/lcc/database/dialect/MySQLDialectProvider.java b/src/main/java/de/avatic/lcc/database/dialect/MySQLDialectProvider.java index b0baf4a..f405532 100644 --- a/src/main/java/de/avatic/lcc/database/dialect/MySQLDialectProvider.java +++ b/src/main/java/de/avatic/lcc/database/dialect/MySQLDialectProvider.java @@ -128,11 +128,12 @@ public class MySQLDialectProvider implements SqlDialectProvider { @Override public String buildHaversineDistance(String lat1, String lng1, String lat2, String lng2) { - // Haversine formula: 6371000 meters (Earth radius) * acos(...) + // Haversine formula: 6371 km (Earth radius) * acos(...) // Formula: d = 2R * arcsin(sqrt(sin²((lat2-lat1)/2) + cos(lat1)*cos(lat2)*sin²((lon2-lon1)/2))) // Simplified: R * acos(cos(lat1)*cos(lat2)*cos(lng2-lng1) + sin(lat1)*sin(lat2)) + // Returns distance in KILOMETERS return String.format( - "6371000 * ACOS(COS(RADIANS(%s)) * COS(RADIANS(%s)) * " + + "6371 * ACOS(COS(RADIANS(%s)) * COS(RADIANS(%s)) * " + "COS(RADIANS(%s) - RADIANS(%s)) + SIN(RADIANS(%s)) * SIN(RADIANS(%s)))", lat1, lat2, lng2, lng1, lat1, lat2 ); diff --git a/src/main/java/de/avatic/lcc/repositories/NodeRepository.java b/src/main/java/de/avatic/lcc/repositories/NodeRepository.java index db09589..f5fce55 100644 --- a/src/main/java/de/avatic/lcc/repositories/NodeRepository.java +++ b/src/main/java/de/avatic/lcc/repositories/NodeRepository.java @@ -157,10 +157,10 @@ public class NodeRepository { @Transactional public Optional setDeprecatedById(Integer id) { - String query = "UPDATE node SET is_deprecated = TRUE WHERE id = ?"; + String query = "UPDATE node SET is_deprecated = " + dialectProvider.getBooleanTrue() + " WHERE id = ?"; // Mark all linked RouteNodes as outdated - jdbcTemplate.update("UPDATE premise_route_node SET is_outdated = TRUE WHERE node_id = ?", id); + jdbcTemplate.update("UPDATE premise_route_node SET is_outdated = " + dialectProvider.getBooleanTrue() + " WHERE node_id = ?", id); return Optional.ofNullable(jdbcTemplate.update(query, id) == 0 ? null : id); @@ -261,7 +261,7 @@ public class NodeRepository { } // Mark all linked RouteNodes as outdated - jdbcTemplate.update("UPDATE premise_route_node SET is_outdated = TRUE WHERE node_id = ?", node.getId()); + jdbcTemplate.update("UPDATE premise_route_node SET is_outdated = " + dialectProvider.getBooleanTrue() + " WHERE node_id = ?", node.getId()); // Mark all distance matrix entries as stale jdbcTemplate.update("UPDATE distance_matrix SET state = 'STALE' WHERE ((from_node_id = ?) OR (to_node_id = ?))", node.getId(), node.getId()); diff --git a/src/test/java/de/avatic/lcc/config/DatabaseTestConfiguration.java b/src/test/java/de/avatic/lcc/config/DatabaseTestConfiguration.java new file mode 100644 index 0000000..695bda9 --- /dev/null +++ b/src/test/java/de/avatic/lcc/config/DatabaseTestConfiguration.java @@ -0,0 +1,51 @@ +package de.avatic.lcc.config; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Profile; +import org.testcontainers.containers.MSSQLServerContainer; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.utility.DockerImageName; + +/** + * TestContainers configuration for multi-database integration testing. + *

+ * Automatically starts the correct database container based on active Spring profile. + * Uses @ServiceConnection to automatically configure Spring DataSource. + *

+ * Usage: + *

+ * mvn test -Dspring.profiles.active=test,mysql -Dtest=DatabaseConfigurationSmokeTest
+ * mvn test -Dspring.profiles.active=test,mssql -Dtest=DatabaseConfigurationSmokeTest
+ * 
+ */ +@TestConfiguration +public class DatabaseTestConfiguration { + + @Bean + @ServiceConnection + @Profile("mysql") + public MySQLContainer mysqlContainer() { + System.out.println("DatabaseTestConfiguration: Creating MySQL container bean..."); + MySQLContainer container = new MySQLContainer<>(DockerImageName.parse("mysql:8.0")) + .withDatabaseName("lcc_test") + .withUsername("test") + .withPassword("test"); + System.out.println("DatabaseTestConfiguration: MySQL container bean created"); + return container; + } + + @Bean + @ServiceConnection + @Profile("mssql") + public MSSQLServerContainer mssqlContainer() { + System.out.println("DatabaseTestConfiguration: Creating MSSQL container bean..."); + MSSQLServerContainer container = new MSSQLServerContainer<>( + DockerImageName.parse("mcr.microsoft.com/mssql/server:2022-latest")) + .acceptLicense() + .withPassword("YourStrong!Passw0rd123"); + System.out.println("DatabaseTestConfiguration: MSSQL container bean created"); + return container; + } +} diff --git a/src/test/java/de/avatic/lcc/config/RepositoryTestConfig.java b/src/test/java/de/avatic/lcc/config/RepositoryTestConfig.java new file mode 100644 index 0000000..6bab904 --- /dev/null +++ b/src/test/java/de/avatic/lcc/config/RepositoryTestConfig.java @@ -0,0 +1,64 @@ +package de.avatic.lcc.config; + +import de.avatic.lcc.database.dialect.MySQLDialectProvider; +import de.avatic.lcc.database.dialect.MSSQLDialectProvider; +import de.avatic.lcc.database.dialect.SqlDialectProvider; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; +import org.springframework.context.annotation.Profile; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; + +import javax.sql.DataSource; + +/** + * Test configuration that provides only the beans needed for repository tests. + * Does NOT load the full LccApplication context. + * + * Uses @SpringBootConfiguration to prevent Spring Boot from searching for and loading LccApplication. + * + * Excludes repositories with external dependencies (transformers/services) since we're only testing JDBC layer. + */ +@SpringBootConfiguration +@EnableAutoConfiguration +@ComponentScan( + basePackages = "de.avatic.lcc.repositories", + excludeFilters = @ComponentScan.Filter( + type = FilterType.ASSIGNABLE_TYPE, + classes = { + de.avatic.lcc.repositories.error.DumpRepository.class, + de.avatic.lcc.repositories.NomenclatureRepository.class, + de.avatic.lcc.repositories.premise.DestinationRepository.class + } + ) +) +public class RepositoryTestConfig { + + @Bean + public JdbcTemplate jdbcTemplate(DataSource dataSource) { + return new JdbcTemplate(dataSource); + } + + @Bean + public NamedParameterJdbcTemplate namedParameterJdbcTemplate(DataSource dataSource) { + return new NamedParameterJdbcTemplate(dataSource); + } + + @Bean + @Profile("mysql") + public SqlDialectProvider mysqlDialectProvider() { + System.out.println("RepositoryTestConfig: Creating MySQLDialectProvider"); + return new MySQLDialectProvider(); + } + + @Bean + @Profile("mssql") + public SqlDialectProvider mssqlDialectProvider() { + System.out.println("RepositoryTestConfig: Creating MSSQLDialectProvider"); + return new MSSQLDialectProvider(); + } +} diff --git a/src/test/java/de/avatic/lcc/database/dialect/MSSQLDialectProviderTest.java b/src/test/java/de/avatic/lcc/database/dialect/MSSQLDialectProviderTest.java new file mode 100644 index 0000000..c3d1538 --- /dev/null +++ b/src/test/java/de/avatic/lcc/database/dialect/MSSQLDialectProviderTest.java @@ -0,0 +1,301 @@ +package de.avatic.lcc.database.dialect; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for {@link MSSQLDialectProvider}. + */ +@DisplayName("MSSQLDialectProvider Tests") +class MSSQLDialectProviderTest { + + private MSSQLDialectProvider provider; + + @BeforeEach + void setUp() { + provider = new MSSQLDialectProvider(); + } + + @Nested + @DisplayName("Metadata Tests") + class MetadataTests { + + @Test + @DisplayName("Should return correct dialect name") + void shouldReturnCorrectDialectName() { + assertEquals("Microsoft SQL Server", provider.getDialectName()); + } + + @Test + @DisplayName("Should return correct driver class name") + void shouldReturnCorrectDriverClassName() { + assertEquals("com.microsoft.sqlserver.jdbc.SQLServerDriver", provider.getDriverClassName()); + } + } + + @Nested + @DisplayName("Pagination Tests") + class PaginationTests { + + @Test + @DisplayName("Should build correct pagination clause with OFFSET/FETCH") + void shouldBuildCorrectPaginationClause() { + String result = provider.buildPaginationClause(10, 20); + assertEquals("OFFSET ? ROWS FETCH NEXT ? ROWS ONLY", result); + } + + @Test + @DisplayName("Should return pagination parameters in correct order (offset, limit)") + void shouldReturnPaginationParametersInCorrectOrder() { + Object[] params = provider.getPaginationParameters(10, 20); + // MSSQL: offset first, then limit (reversed from MySQL) + assertArrayEquals(new Object[]{20, 10}, params); + } + } + + @Nested + @DisplayName("Upsert Operation Tests") + class UpsertOperationTests { + + @Test + @DisplayName("Should build correct MERGE statement") + void shouldBuildCorrectMergeStatement() { + List uniqueCols = Arrays.asList("id", "user_id"); + List insertCols = Arrays.asList("id", "user_id", "name", "value"); + List updateCols = Arrays.asList("name", "value"); + + String result = provider.buildUpsertStatement("test_table", uniqueCols, insertCols, updateCols); + + assertTrue(result.contains("MERGE INTO test_table AS target")); + assertTrue(result.contains("USING (SELECT")); + assertTrue(result.contains("ON target.id = source.id AND target.user_id = source.user_id")); + assertTrue(result.contains("WHEN MATCHED THEN UPDATE SET")); + assertTrue(result.contains("WHEN NOT MATCHED THEN INSERT")); + assertTrue(result.contains("name = source.name")); + assertTrue(result.contains("value = source.value")); + } + + @Test + @DisplayName("Should build correct conditional INSERT statement") + void shouldBuildCorrectInsertIgnoreStatement() { + List columns = Arrays.asList("user_id", "group_id"); + List uniqueCols = Arrays.asList("user_id", "group_id"); + + String result = provider.buildInsertIgnoreStatement("mapping_table", columns, uniqueCols); + + assertTrue(result.contains("IF NOT EXISTS")); + assertTrue(result.contains("SELECT 1 FROM mapping_table")); + assertTrue(result.contains("WHERE user_id = ? AND group_id = ?")); + assertTrue(result.contains("INSERT INTO mapping_table (user_id, group_id) VALUES (?, ?)")); + } + } + + @Nested + @DisplayName("Locking Strategy Tests") + class LockingStrategyTests { + + @Test + @DisplayName("Should build WITH (UPDLOCK, READPAST) for SKIP LOCKED equivalent") + void shouldBuildSelectForUpdateSkipLocked() { + String baseQuery = "SELECT * FROM calculation_job WHERE state = 'CREATED'"; + String result = provider.buildSelectForUpdateSkipLocked(baseQuery); + + assertTrue(result.contains("WITH (UPDLOCK, READPAST)")); + assertTrue(result.contains("FROM calculation_job WITH (UPDLOCK, READPAST)")); + } + + @Test + @DisplayName("Should build WITH (UPDLOCK, ROWLOCK) for standard locking") + void shouldBuildSelectForUpdate() { + String baseQuery = "SELECT * FROM calculation_job WHERE id = ?"; + String result = provider.buildSelectForUpdate(baseQuery); + + assertTrue(result.contains("WITH (UPDLOCK, ROWLOCK)")); + assertTrue(result.contains("FROM calculation_job WITH (UPDLOCK, ROWLOCK)")); + assertFalse(result.contains("READPAST")); + } + } + + @Nested + @DisplayName("Date/Time Function Tests") + class DateTimeFunctionTests { + + @Test + @DisplayName("Should return GETDATE() for current timestamp") + void shouldReturnGetDateForCurrentTimestamp() { + assertEquals("GETDATE()", provider.getCurrentTimestamp()); + } + + @Test + @DisplayName("Should build date subtraction with GETDATE() using DATEADD") + void shouldBuildDateSubtractionWithGetDate() { + String result = provider.buildDateSubtraction(null, "3", SqlDialectProvider.DateUnit.DAY); + assertEquals("DATEADD(DAY, -3, GETDATE())", result); + } + + @Test + @DisplayName("Should build date subtraction with custom base date") + void shouldBuildDateSubtractionWithCustomBaseDate() { + String result = provider.buildDateSubtraction("calculation_date", "60", SqlDialectProvider.DateUnit.MINUTE); + assertEquals("DATEADD(MINUTE, -60, calculation_date)", result); + } + + @Test + @DisplayName("Should build date addition with GETDATE() using DATEADD") + void shouldBuildDateAdditionWithGetDate() { + String result = provider.buildDateAddition(null, "7", SqlDialectProvider.DateUnit.DAY); + assertEquals("DATEADD(DAY, 7, GETDATE())", result); + } + + @Test + @DisplayName("Should build date addition with custom base date") + void shouldBuildDateAdditionWithCustomBaseDate() { + String result = provider.buildDateAddition("start_date", "1", SqlDialectProvider.DateUnit.MONTH); + assertEquals("DATEADD(MONTH, 1, start_date)", result); + } + + @Test + @DisplayName("Should extract date from column using CAST") + void shouldExtractDateFromColumn() { + String result = provider.extractDate("created_at"); + assertEquals("CAST(created_at AS DATE)", result); + } + + @Test + @DisplayName("Should extract date from expression using CAST") + void shouldExtractDateFromExpression() { + String result = provider.extractDate("GETDATE()"); + assertEquals("CAST(GETDATE() AS DATE)", result); + } + } + + @Nested + @DisplayName("Auto-increment Reset Tests") + class AutoIncrementResetTests { + + @Test + @DisplayName("Should build DBCC CHECKIDENT reset statement") + void shouldBuildAutoIncrementResetStatement() { + String result = provider.buildAutoIncrementReset("test_table"); + assertEquals("DBCC CHECKIDENT ('test_table', RESEED, 0)", result); + } + } + + @Nested + @DisplayName("Geospatial Distance Tests") + class GeospatialDistanceTests { + + @Test + @DisplayName("Should build Haversine distance calculation in kilometers") + void shouldBuildHaversineDistanceCalculation() { + String result = provider.buildHaversineDistance("50.1", "8.6", "node.geo_lat", "node.geo_lng"); + + // MSSQL uses 6371 km (not 6371000 m like MySQL) + assertTrue(result.contains("6371")); + assertFalse(result.contains("6371000")); // Should NOT be in meters + assertTrue(result.contains("ACOS")); + assertTrue(result.contains("COS")); + assertTrue(result.contains("SIN")); + assertTrue(result.contains("RADIANS")); + assertTrue(result.contains("50.1")); + assertTrue(result.contains("8.6")); + assertTrue(result.contains("node.geo_lat")); + assertTrue(result.contains("node.geo_lng")); + } + } + + @Nested + @DisplayName("String/Type Function Tests") + class StringTypeFunctionTests { + + @Test + @DisplayName("Should build CONCAT with multiple expressions") + void shouldBuildConcatWithMultipleExpressions() { + String result = provider.buildConcat("first_name", "' '", "last_name"); + assertEquals("CONCAT(first_name, ' ', last_name)", result); + } + + @Test + @DisplayName("Should build CONCAT with single expression") + void shouldBuildConcatWithSingleExpression() { + String result = provider.buildConcat("column_name"); + assertEquals("CONCAT(column_name)", result); + } + + @Test + @DisplayName("Should cast to string using VARCHAR") + void shouldCastToString() { + String result = provider.castToString("user_id"); + assertEquals("CAST(user_id AS VARCHAR(MAX))", result); + } + } + + @Nested + @DisplayName("Bulk Operation Tests") + class BulkOperationTests { + + @Test + @DisplayName("Should return INT max value for MSSQL") + void shouldReturnMSSQLIntMaxValue() { + // MSSQL returns INT max value (not BIGINT) + assertEquals("2147483647", provider.getMaxLimitValue()); + } + + @Test + @DisplayName("Should support RETURNING clause via OUTPUT") + void shouldSupportReturningClause() { + assertTrue(provider.supportsReturningClause()); + } + + @Test + @DisplayName("Should build OUTPUT clause for RETURNING") + void shouldBuildOutputClause() { + String result = provider.buildReturningClause("id", "name", "created_at"); + + assertEquals("OUTPUT INSERTED.id, INSERTED.name, INSERTED.created_at", result); + } + } + + @Nested + @DisplayName("Schema/DDL Tests") + class SchemaDDLTests { + + @Test + @DisplayName("Should return IDENTITY definition") + void shouldReturnIdentityDefinition() { + String result = provider.getAutoIncrementDefinition(); + assertEquals("IDENTITY(1,1)", result); + } + + @Test + @DisplayName("Should return DATETIME2 with default for timestamp") + void shouldReturnDateTimeWithDefaultDefinition() { + String result = provider.getTimestampDefinition(); + assertEquals("DATETIME2 DEFAULT GETDATE()", result); + } + } + + @Nested + @DisplayName("Boolean Literal Tests") + class BooleanLiteralTests { + + @Test + @DisplayName("Should return '1' for boolean true") + void shouldReturnOneForBooleanTrue() { + assertEquals("1", provider.getBooleanTrue()); + } + + @Test + @DisplayName("Should return '0' for boolean false") + void shouldReturnZeroForBooleanFalse() { + assertEquals("0", provider.getBooleanFalse()); + } + } +} diff --git a/src/test/java/de/avatic/lcc/database/dialect/MySQLDialectProviderTest.java b/src/test/java/de/avatic/lcc/database/dialect/MySQLDialectProviderTest.java index 9830609..109abe2 100644 --- a/src/test/java/de/avatic/lcc/database/dialect/MySQLDialectProviderTest.java +++ b/src/test/java/de/avatic/lcc/database/dialect/MySQLDialectProviderTest.java @@ -187,11 +187,13 @@ class MySQLDialectProviderTest { class GeospatialDistanceTests { @Test - @DisplayName("Should build Haversine distance calculation") + @DisplayName("Should build Haversine distance calculation in kilometers") void shouldBuildHaversineDistanceCalculation() { String result = provider.buildHaversineDistance("50.1", "8.6", "node.geo_lat", "node.geo_lng"); - assertTrue(result.contains("6371000")); + // MySQL now uses 6371 km (not 6371000 m) for consistency with MSSQL + assertTrue(result.contains("6371")); + assertFalse(result.contains("6371000")); // Should NOT be in meters assertTrue(result.contains("ACOS")); assertTrue(result.contains("COS")); assertTrue(result.contains("SIN")); diff --git a/src/test/java/de/avatic/lcc/repositories/AbstractRepositoryIntegrationTest.java b/src/test/java/de/avatic/lcc/repositories/AbstractRepositoryIntegrationTest.java new file mode 100644 index 0000000..f09390f --- /dev/null +++ b/src/test/java/de/avatic/lcc/repositories/AbstractRepositoryIntegrationTest.java @@ -0,0 +1,99 @@ +package de.avatic.lcc.repositories; + +import de.avatic.lcc.config.DatabaseTestConfiguration; +import de.avatic.lcc.config.RepositoryTestConfig; +import de.avatic.lcc.database.dialect.SqlDialectProvider; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Abstract base class for repository integration tests. + *

+ * Provides TestContainers-based database setup for both MySQL and MSSQL. + * Tests extending this class will run against the database specified by the active profile. + * Flyway migrations from db/migration/{mysql|mssql}/ will be automatically applied. + *

+ * Only loads Repository and JDBC beans, not the full application context (no Controllers, no API Services). + *

+ * Usage: + *

+ * // Run against MySQL
+ * mvn test -Dspring.profiles.active=test,mysql -Dtest=NodeRepositoryIntegrationTest
+ *
+ * // Run against MSSQL
+ * mvn test -Dspring.profiles.active=test,mssql -Dtest=NodeRepositoryIntegrationTest
+ * 
+ */ +@SpringBootTest( + classes = {RepositoryTestConfig.class}, + properties = { + "spring.main.web-application-type=none", + "spring.autoconfigure.exclude=" + + "org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration," + + "org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration," + + "org.springframework.boot.autoconfigure.security.oauth2.resource.servlet.OAuth2ResourceServerAutoConfiguration," + + "org.springframework.boot.autoconfigure.webservices.WebServicesAutoConfiguration," + + "org.springframework.boot.autoconfigure.batch.BatchAutoConfiguration" + } +) +@Testcontainers +@Import(DatabaseTestConfiguration.class) +// NOTE: No @ActiveProfiles - profiles come from command line: -Dspring.profiles.active=test,mysql +@Transactional // Rollback after each test for isolation +public abstract class AbstractRepositoryIntegrationTest { + + @Autowired + protected JdbcTemplate jdbcTemplate; + + @Autowired + protected SqlDialectProvider dialectProvider; + + /** + * Gets the active database profile (mysql or mssql). + * Useful for profile-specific test assertions. + */ + protected String getDatabaseProfile() { + return System.getProperty("spring.profiles.active", "mysql"); + } + + /** + * Checks if tests are running against MSSQL. + */ + protected boolean isMssql() { + return getDatabaseProfile().contains("mssql"); + } + + /** + * Checks if tests are running against MySQL. + */ + protected boolean isMysql() { + return getDatabaseProfile().contains("mysql"); + } + + @BeforeEach + void baseSetup() { + // Common setup logic if needed + // Flyway migrations are automatically applied by Spring Boot + } + + /** + * Executes a raw SQL query for test data setup. + * Use with caution - prefer using repositories where possible. + */ + protected void executeRawSql(String sql, Object... params) { + jdbcTemplate.update(sql, params); + } + + /** + * Counts rows in a table. + */ + protected int countRows(String tableName) { + return jdbcTemplate.queryForObject("SELECT COUNT(*) FROM " + tableName, Integer.class); + } +} diff --git a/src/test/java/de/avatic/lcc/repositories/DatabaseConfigurationSmokeTest.java b/src/test/java/de/avatic/lcc/repositories/DatabaseConfigurationSmokeTest.java new file mode 100644 index 0000000..1e73eda --- /dev/null +++ b/src/test/java/de/avatic/lcc/repositories/DatabaseConfigurationSmokeTest.java @@ -0,0 +1,128 @@ +package de.avatic.lcc.repositories; + +import de.avatic.lcc.database.dialect.SqlDialectProvider; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Smoke test to verify TestContainers and Flyway setup. + *

+ * Validates: + * - TestContainers starts correctly + * - Flyway migrations run successfully + * - Database contains expected test data + * - Correct SqlDialectProvider is loaded + *

+ * Run with: + *

+ * mvn test -Dspring.profiles.active=test,mysql -Dtest=DatabaseConfigurationSmokeTest
+ * mvn test -Dspring.profiles.active=test,mssql -Dtest=DatabaseConfigurationSmokeTest
+ * 
+ */ +class DatabaseConfigurationSmokeTest extends AbstractRepositoryIntegrationTest { + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private SqlDialectProvider dialectProvider; + + @Test + void testDatabaseConnectionIsEstablished() { + // When: Query database + Integer result = jdbcTemplate.queryForObject("SELECT 1", Integer.class); + + // Then: Connection works + assertNotNull(result); + assertEquals(1, result); + } + + @Test + void testFlywayMigrationsRanSuccessfully() { + // When: Check if core tables exist + Integer propertySetCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM property_set", Integer.class); + + // Then: Table exists (migrations ran) + assertNotNull(propertySetCount); + } + + @Test + void testCountriesWereLoadedFromMigrations() { + // When: Count countries + Integer countryCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM country", Integer.class); + + // Then: Countries exist (V4__Country.sql ran) + assertNotNull(countryCount); + assertTrue(countryCount > 0, "Countries should be loaded from V4__Country.sql migration"); + System.out.println("Found " + countryCount + " countries in database"); + } + + @Test + void testNodesWereLoadedFromMigrations() { + // When: Count nodes + Integer nodeCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM node", Integer.class); + + // Then: Nodes exist (V5__Nodes.sql ran) + assertNotNull(nodeCount); + assertTrue(nodeCount > 0, "Nodes should be loaded from V5__Nodes.sql migration"); + System.out.println("Found " + nodeCount + " nodes in database"); + } + + @Test + void testCorrectSqlDialectProviderIsLoaded() { + // Debug: Print active profiles + String[] activeProfiles = jdbcTemplate.getDataSource() != null ? + new String[]{getDatabaseProfile()} : new String[]{}; + System.out.println("Active Spring profiles from getDatabaseProfile(): " + getDatabaseProfile()); + System.out.println("System property spring.profiles.active: " + System.getProperty("spring.profiles.active")); + + // When: Check which dialect provider is active + String booleanTrue = dialectProvider.getBooleanTrue(); + + // Then: Correct provider based on profile + if (isMysql()) { + assertEquals("TRUE", booleanTrue, "MySQL should use TRUE literal"); + } else if (isMssql()) { + assertEquals("1", booleanTrue, "MSSQL should use 1 literal"); + } + + System.out.println("Active database profile: " + getDatabaseProfile()); + System.out.println("Dialect provider class: " + dialectProvider.getClass().getSimpleName()); + } + + @Test + void testBooleanLiteralInQuery() { + // When: Query with boolean literal from dialect provider + String query = "SELECT COUNT(*) FROM node WHERE is_deprecated = " + + dialectProvider.getBooleanFalse(); + Integer activeNodeCount = jdbcTemplate.queryForObject(query, Integer.class); + + // Then: Query executes without syntax error + assertNotNull(activeNodeCount); + System.out.println("Active (non-deprecated) nodes: " + activeNodeCount); + } + + @Test + void testPaginationQuery() { + // When: Execute query with pagination (requires ORDER BY in MSSQL) + String paginationClause = dialectProvider.buildPaginationClause(5, 0); + Object[] paginationParams = dialectProvider.getPaginationParameters(5, 0); + + String query = "SELECT id FROM node ORDER BY id " + paginationClause; + var nodeIds = jdbcTemplate.query(query, + (rs, rowNum) -> rs.getInt("id"), + paginationParams[0], paginationParams[1]); + + // Then: Query executes successfully and returns up to 5 results + assertNotNull(nodeIds); + assertFalse(nodeIds.isEmpty(), "Should return at least one node"); + assertTrue(nodeIds.size() <= 5, "Should return at most 5 nodes"); + System.out.println("Returned " + nodeIds.size() + " nodes with pagination: " + nodeIds); + } +} diff --git a/src/test/java/de/avatic/lcc/repositories/NodeRepositoryIntegrationTest.java b/src/test/java/de/avatic/lcc/repositories/NodeRepositoryIntegrationTest.java new file mode 100644 index 0000000..472b02b --- /dev/null +++ b/src/test/java/de/avatic/lcc/repositories/NodeRepositoryIntegrationTest.java @@ -0,0 +1,208 @@ +package de.avatic.lcc.repositories; + +import de.avatic.lcc.dto.generic.NodeType; +import de.avatic.lcc.repositories.pagination.SearchQueryPagination; +import de.avatic.lcc.repositories.pagination.SearchQueryResult; +import de.avatic.lcc.model.db.nodes.Node; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Integration tests for NodeRepository. + *

+ * Tests critical functionality across both MySQL and MSSQL: + * - Basic CRUD operations + * - Pagination with ORDER BY (MSSQL requirement) + * - Haversine distance calculations + * - Complex search queries + *

+ * Run with: + *

+ * mvn test -Dspring.profiles.active=test,mysql -Dtest=NodeRepositoryIntegrationTest
+ * mvn test -Dspring.profiles.active=test,mssql -Dtest=NodeRepositoryIntegrationTest
+ * 
+ */ +class NodeRepositoryIntegrationTest extends AbstractRepositoryIntegrationTest { + + @Autowired + private NodeRepository nodeRepository; + + @Test + void testInsertAndRetrieveNode() { + // Given + Node node = new Node(); + node.setName("Test Node"); + node.setAddress("Test Address 123"); + node.setGeoLat(new BigDecimal("52.5200")); + node.setGeoLng(new BigDecimal("13.4050")); + node.setDeprecated(false); + node.setCountryId(1); // Assuming country with id=1 exists in Flyway migrations + + // When + Integer nodeId = nodeRepository.insert(node); + + // Then + assertNotNull(nodeId, "Node ID should not be null"); + assertTrue(nodeId > 0, "Node ID should be positive"); + + Optional retrieved = nodeRepository.getById(nodeId); + assertTrue(retrieved.isPresent(), "Node should be retrievable after creation"); + assertEquals("Test Node", retrieved.get().getName()); + assertEquals("Test Address 123", retrieved.get().getAddress()); + } + + @Test + void testUpdateNode() { + // Given: Create a node first + Node node = createTestNode("Original Name", "Original Address", "50.0", "10.0"); + Integer nodeId = nodeRepository.insert(node); + + // When: Update the node + Node updatedNode = nodeRepository.getById(nodeId).orElseThrow(); + updatedNode.setName("Updated Name"); + updatedNode.setAddress("Updated Address"); + nodeRepository.update(updatedNode); + + // Then: Verify update + Node result = nodeRepository.getById(nodeId).orElseThrow(); + assertEquals("Updated Name", result.getName()); + assertEquals("Updated Address", result.getAddress()); + } + + @Test + void testDeprecateNode() { + // Given: Create a node + Node node = createTestNode("Node to Deprecate", "Address", "50.0", "10.0"); + Integer nodeId = nodeRepository.insert(node); + + // When: Deprecate the node + nodeRepository.setDeprecatedById(nodeId); + + // Then: Verify node is deprecated + Node deprecated = nodeRepository.getById(nodeId).orElseThrow(); + assertTrue(deprecated.getDeprecated(), "Node should be marked as deprecated"); + } + + @Test + void testListNodesWithPagination() { + // Given: Create multiple nodes + for (int i = 1; i <= 5; i++) { + Node node = createTestNode("Pagination Node " + i, "Address " + i, "50." + i, "10." + i); + nodeRepository.insert(node); + } + + // When: List nodes with pagination (page 1, size 3) + SearchQueryPagination pagination = new SearchQueryPagination(1, 3); + SearchQueryResult result = nodeRepository.listNodes(null, false, pagination); + + // Then: Verify pagination works (ORDER BY is required for MSSQL) + assertNotNull(result); + assertNotNull(result.toList()); + assertTrue(result.toList().size() <= 3, "Should return at most 3 nodes per page"); + } + + @Test + void testSearchNodeWithFilter() { + // Given: Create nodes with different names + Node node1 = createTestNode("Berlin Node Test", "Berlin Street 1", "52.5200", "13.4050"); + Node node2 = createTestNode("Munich Node Test", "Munich Street 1", "48.1351", "11.5820"); + Node node3 = createTestNode("Hamburg Node Test", "Hamburg Street 1", "53.5511", "9.9937"); + nodeRepository.insert(node1); + nodeRepository.insert(node2); + nodeRepository.insert(node3); + + // When: Search for nodes containing "Berlin" + List results = nodeRepository.searchNode("Berlin", 10, null, false); + + // Then: Should find Berlin node + assertFalse(results.isEmpty(), "Should find at least one node"); + assertTrue(results.stream().anyMatch(n -> n.getName().contains("Berlin")), + "Should contain Berlin node"); + } + + @Test + void testGetByDistanceWithHaversineFormula() { + // Given: Create a reference node (Berlin) + Node referenceNode = createTestNode("Berlin Distance Test", "Berlin Center", "52.5200", "13.4050"); + referenceNode.setUserNode(false); + Integer refId = nodeRepository.insert(referenceNode); + referenceNode.setId(refId); + + // Create a nearby node (Potsdam, ~30km from Berlin) + Node nearbyNode = createTestNode("Potsdam Distance Test", "Potsdam Center", "52.3906", "13.0645"); + nodeRepository.insert(nearbyNode); + + // Create a far node (Munich, ~500km from Berlin) + Node farNode = createTestNode("Munich Distance Test", "Munich Center", "48.1351", "11.5820"); + nodeRepository.insert(farNode); + + // When: Get nodes within 100km radius + // The Haversine formula returns distance in kilometers for both MySQL and MSSQL + List nodesWithin100km = nodeRepository.getByDistance(referenceNode, 100); + + // Then: Should find nearby node but not far node + assertNotNull(nodesWithin100km); + assertTrue(nodesWithin100km.stream().anyMatch(n -> n.getName().contains("Potsdam")), + "Should find Potsdam (30km away)"); + assertFalse(nodesWithin100km.stream().anyMatch(n -> n.getName().contains("Munich")), + "Should not find Munich (500km away)"); + } + + @Test + void testGetByDistanceExcludingReferenceNode() { + // Given: Create reference node + Node referenceNode = createTestNode("Reference Node Distance", "Ref Address", "50.0", "10.0"); + referenceNode.setUserNode(false); + Integer refId = nodeRepository.insert(referenceNode); + referenceNode.setId(refId); + + // Create nearby node + Node nearbyNode = createTestNode("Nearby Node Distance", "Nearby Address", "50.1", "10.1"); + nodeRepository.insert(nearbyNode); + + // When: Get nodes within large radius + List results = nodeRepository.getByDistance(referenceNode, 1000); + + // Then: Reference node itself should be excluded (via id != ?) + assertFalse(results.stream().anyMatch(n -> n.getId().equals(refId)), + "Reference node should be excluded from results"); + } + + @Test + void testBooleanLiteralCompatibility() { + // Given: Create deprecated and non-deprecated nodes + Node deprecatedNode = createTestNode("Deprecated Boolean Test", "Addr1", "50.0", "10.0"); + Integer depId = nodeRepository.insert(deprecatedNode); + nodeRepository.setDeprecatedById(depId); + + Node activeNode = createTestNode("Active Boolean Test", "Addr2", "50.1", "10.1"); + nodeRepository.insert(activeNode); + + // When: Search excluding deprecated nodes + List activeNodes = nodeRepository.searchNode("Boolean Test", 100, null, true); + + // Then: Should not include deprecated node + assertFalse(activeNodes.stream().anyMatch(n -> n.getId().equals(depId)), + "Should exclude deprecated nodes when excludeDeprecated=true"); + } + + // ========== Helper Methods ========== + + private Node createTestNode(String name, String address, String lat, String lng) { + Node node = new Node(); + node.setName(name); + node.setAddress(address); + node.setGeoLat(new BigDecimal(lat)); + node.setGeoLng(new BigDecimal(lng)); + node.setDeprecated(false); + node.setCountryId(1); // Assuming country id=1 exists + node.setUserNode(false); + return node; + } +} diff --git a/src/test/resources/application-mssql.properties b/src/test/resources/application-mssql.properties new file mode 100644 index 0000000..1606d52 --- /dev/null +++ b/src/test/resources/application-mssql.properties @@ -0,0 +1,17 @@ +# MSSQL Test Profile Configuration +# Activate with: mvn test -Dspring.profiles.active=test,mssql + +# Flyway Migration - MSSQL specific +spring.flyway.locations=classpath:db/migration/mssql + +# TestContainers will automatically provide: +# - spring.datasource.url +# - spring.datasource.username +# - spring.datasource.password +# - spring.datasource.driver-class-name + +# Disable calculation job processor in tests +calculation.job.processor.enabled=false + +# Disable Azure Maps API calls in tests +# (Mock these services in test classes if needed) diff --git a/src/test/resources/application-mysql.properties b/src/test/resources/application-mysql.properties new file mode 100644 index 0000000..3c9f170 --- /dev/null +++ b/src/test/resources/application-mysql.properties @@ -0,0 +1,17 @@ +# MySQL Test Profile Configuration +# Activate with: mvn test -Dspring.profiles.active=test,mysql + +# Flyway Migration - MySQL specific +spring.flyway.locations=classpath:db/migration/mysql + +# TestContainers will automatically provide: +# - spring.datasource.url +# - spring.datasource.username +# - spring.datasource.password +# - spring.datasource.driver-class-name + +# Disable calculation job processor in tests +calculation.job.processor.enabled=false + +# Disable Azure Maps API calls in tests +# (Mock these services in test classes if needed) diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index 1ae1122..d68718b 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -1,12 +1,40 @@ -spring.config.import=classpath:env.properties +# Test Configuration for TestContainers-based Integration Tests +# DataSource is automatically configured by @ServiceConnection from TestContainers + spring.application.name=lcc -spring.datasource.url=jdbc:mysql://localhost:3306/${DB_DATABASE} -spring.datasource.username=${DB_USER} -spring.datasource.password=${DB_PASSWORD} -spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver -spring.sql.init.mode=always -spring.sql.init.continue-on-error=false -spring.sql.init.schema-locations=classpath:schema.sql + +# Disable env.properties import for tests (TestContainers provides DB config) +# spring.config.import=classpath:env.properties + +# Disable schema.sql - we use Flyway migrations instead +spring.sql.init.mode=never + +# Flyway configuration - migrations will run automatically +spring.flyway.enabled=true +spring.flyway.baseline-on-migrate=true +spring.flyway.clean-disabled=false +# Note: spring.flyway.locations is set in application-mysql.properties or application-mssql.properties + +# Security disabled for integration tests +spring.autoconfigure.exclude=\ + org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration,\ + org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration,\ + org.springframework.boot.autoconfigure.security.oauth2.resource.servlet.OAuth2ResourceServerAutoConfiguration,\ + org.springframework.boot.autoconfigure.webservices.WebServicesAutoConfiguration,\ + org.springframework.boot.autoconfigure.batch.BatchAutoConfiguration + +# Disable Azure AD for tests +spring.cloud.azure.active-directory.enabled=false + +# Disable async processing in tests (EUTaxationApiService uses @Async) +spring.task.execution.pool.core-size=0 +spring.task.scheduling.enabled=false + +# Logging +logging.level.org.flywaydb=INFO +logging.level.org.testcontainers=INFO +logging.level.de.avatic.lcc=DEBUG +logging.level.org.springframework.jdbc=DEBUG