1+ /*
2+ *
3+ * Copyright (c) 2018-2025 Green Button Alliance, Inc.
4+ *
5+ * Licensed under the Apache License, Version 2.0 (the "License");
6+ * you may not use this file except in compliance with the License.
7+ * You may obtain a copy of the License at
8+ *
9+ * http://www.apache.org/licenses/LICENSE-2.0
10+ *
11+ * Unless required by applicable law or agreed to in writing, software
12+ * distributed under the License is distributed on an "AS IS" BASIS,
13+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+ * See the License for the specific language governing permissions and
15+ * limitations under the License.
16+ *
17+ */
18+
19+ package org .greenbuttonalliance .espi .common .integration ;
20+
21+ import jakarta .persistence .EntityManager ;
22+ import jakarta .persistence .PersistenceContext ;
23+ import javax .sql .DataSource ;
24+ import jakarta .validation .Validator ;
25+ import org .flywaydb .core .Flyway ;
26+ import org .greenbuttonalliance .espi .common .TestApplication ;
27+ import org .junit .jupiter .api .AfterEach ;
28+ import org .junit .jupiter .api .BeforeEach ;
29+ import org .junit .jupiter .api .extension .ExtendWith ;
30+ import org .springframework .beans .factory .annotation .Autowired ;
31+ import org .springframework .boot .test .context .SpringBootTest ;
32+ import org .springframework .test .context .ActiveProfiles ;
33+ import org .springframework .test .context .DynamicPropertyRegistry ;
34+ import org .springframework .test .context .DynamicPropertySource ;
35+ import org .springframework .test .context .junit .jupiter .SpringExtension ;
36+ import org .springframework .transaction .annotation .Transactional ;
37+ import org .testcontainers .containers .PostgreSQLContainer ;
38+ import org .testcontainers .junit .jupiter .Container ;
39+ import org .testcontainers .junit .jupiter .Testcontainers ;
40+ import java .sql .Connection ;
41+ import java .sql .SQLException ;
42+ import java .sql .Statement ;
43+
44+ /**
45+ * Base class for TestContainers integration tests with PostgreSQL.
46+ *
47+ * This class provides:
48+ * - PostgreSQL TestContainer setup with proper schema initialization
49+ * - Flyway migration execution for both usage and customer schemas
50+ * - Spring Boot test configuration with real PostgreSQL database
51+ * - Common utilities for database operations and validation
52+ * - PostgreSQL-specific features testing (JSONB, BIGSERIAL, TIMEZONE)
53+ *
54+ * Tests extending this class will have access to:
55+ * - Full PostgreSQL database with proper schema structure
56+ * - Applied Flyway migrations (V1-V6) with PostgreSQL-specific features
57+ * - Entity Manager for JPA operations
58+ * - Bean Validator for entity validation
59+ * - Transaction management
60+ * - PostgreSQL-specific data types and indexing
61+ */
62+ @ ExtendWith (SpringExtension .class )
63+ @ SpringBootTest (classes = TestApplication .class )
64+ @ ActiveProfiles ("testcontainers-postgresql" )
65+ @ Testcontainers
66+ @ Transactional
67+ public abstract class PostgreSQLTestContainersIntegrationTestBase {
68+
69+ /**
70+ * PostgreSQL TestContainer with version 14 for PostgreSQL-specific features.
71+ * Configured with proper timezone and locale settings.
72+ */
73+ @ Container
74+ static final PostgreSQLContainer <?> POSTGRESQL_CONTAINER = new PostgreSQLContainer <>("postgres:14" )
75+ .withDatabaseName ("openespi_test" )
76+ .withUsername ("testuser" )
77+ .withPassword ("testpass" )
78+ .withCommand ("postgres" ,
79+ "-c" , "timezone=UTC" ,
80+ "-c" , "log_statement=all" ,
81+ "-c" , "log_duration=on" ,
82+ "-c" , "max_connections=200" );
83+
84+ @ PersistenceContext
85+ protected EntityManager entityManager ;
86+
87+ @ Autowired
88+ protected Validator validator ;
89+
90+ @ Autowired
91+ protected DataSource dataSource ;
92+
93+ /**
94+ * Configure Spring Boot properties dynamically from TestContainer.
95+ */
96+ @ DynamicPropertySource
97+ static void configureProperties (DynamicPropertyRegistry registry ) {
98+ registry .add ("spring.datasource.url" , POSTGRESQL_CONTAINER ::getJdbcUrl );
99+ registry .add ("spring.datasource.username" , POSTGRESQL_CONTAINER ::getUsername );
100+ registry .add ("spring.datasource.password" , POSTGRESQL_CONTAINER ::getPassword );
101+ registry .add ("spring.datasource.driver-class-name" , () -> "org.postgresql.Driver" );
102+
103+ // JPA configuration for TestContainers
104+ registry .add ("spring.jpa.hibernate.ddl-auto" , () -> "none" );
105+ registry .add ("spring.jpa.properties.hibernate.dialect" , () -> "org.hibernate.dialect.PostgreSQLDialect" );
106+ registry .add ("spring.jpa.show-sql" , () -> "true" );
107+ registry .add ("spring.jpa.properties.hibernate.format_sql" , () -> "true" );
108+
109+ // PostgreSQL-specific JPA properties
110+ registry .add ("spring.jpa.properties.hibernate.jdbc.time_zone" , () -> "UTC" );
111+ registry .add ("spring.jpa.properties.hibernate.type.preferred_instant_jdbc_type" , () -> "TIMESTAMP_WITH_TIMEZONE" );
112+
113+ // Flyway configuration
114+ registry .add ("spring.flyway.enabled" , () -> "false" ); // We'll manage Flyway manually
115+ registry .add ("spring.flyway.locations" , () -> "classpath:db/migration/postgresql" );
116+ }
117+
118+ /**
119+ * Setup method executed before each test to ensure clean database state.
120+ */
121+ @ BeforeEach
122+ void setUp () throws SQLException {
123+ setupDatabaseSchemas ();
124+ runFlywayMigrations ();
125+ }
126+
127+ /**
128+ * Cleanup method executed after each test.
129+ */
130+ @ AfterEach
131+ void tearDown () {
132+ // TestContainers automatically cleans up, but we can add custom cleanup if needed
133+ }
134+
135+ /**
136+ * Create the required database schemas for OpenESPI.
137+ * Creates both usage and customer schemas as separate schemas within the same database.
138+ */
139+ private void setupDatabaseSchemas () throws SQLException {
140+ try (Connection connection = dataSource .getConnection ();
141+ Statement statement = connection .createStatement ()) {
142+
143+ // Create schemas if they don't exist
144+ statement .execute ("CREATE SCHEMA IF NOT EXISTS openespi_usage" );
145+ statement .execute ("CREATE SCHEMA IF NOT EXISTS openespi_customer" );
146+
147+ // Grant permissions
148+ statement .execute ("GRANT ALL PRIVILEGES ON SCHEMA openespi_usage TO " + POSTGRESQL_CONTAINER .getUsername ());
149+ statement .execute ("GRANT ALL PRIVILEGES ON SCHEMA openespi_customer TO " + POSTGRESQL_CONTAINER .getUsername ());
150+ statement .execute ("GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA openespi_usage TO " + POSTGRESQL_CONTAINER .getUsername ());
151+ statement .execute ("GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA openespi_customer TO " + POSTGRESQL_CONTAINER .getUsername ());
152+ statement .execute ("GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA openespi_usage TO " + POSTGRESQL_CONTAINER .getUsername ());
153+ statement .execute ("GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA openespi_customer TO " + POSTGRESQL_CONTAINER .getUsername ());
154+ }
155+ }
156+
157+ /**
158+ * Run Flyway migrations for both usage and customer schemas.
159+ * This ensures the database structure matches the application expectations.
160+ */
161+ private void runFlywayMigrations () {
162+ // Migrate usage schema (V1-V3)
163+ Flyway flywayUsage = Flyway .configure ()
164+ .dataSource (createSchemaDataSource ("openespi_usage" ))
165+ .locations ("classpath:db/migration/postgresql" )
166+ .table ("flyway_schema_history_usage" )
167+ .schemas ("openespi_usage" )
168+ .load ();
169+ flywayUsage .migrate ();
170+
171+ // Migrate customer schema (V4-V6)
172+ Flyway flywayCustomer = Flyway .configure ()
173+ .dataSource (createSchemaDataSource ("openespi_customer" ))
174+ .locations ("classpath:db/migration/postgresql" )
175+ .table ("flyway_schema_history_customer" )
176+ .schemas ("openespi_customer" )
177+ .load ();
178+ flywayCustomer .migrate ();
179+ }
180+
181+ /**
182+ * Create a DataSource for a specific schema.
183+ */
184+ private DataSource createSchemaDataSource (String schema ) {
185+ org .springframework .boot .jdbc .DataSourceBuilder <?> builder =
186+ org .springframework .boot .jdbc .DataSourceBuilder .create ();
187+
188+ return builder
189+ .url (POSTGRESQL_CONTAINER .getJdbcUrl () + "?currentSchema=" + schema )
190+ .username (POSTGRESQL_CONTAINER .getUsername ())
191+ .password (POSTGRESQL_CONTAINER .getPassword ())
192+ .driverClassName ("org.postgresql.Driver" )
193+ .build ();
194+ }
195+
196+ /**
197+ * Execute SQL statement in the usage schema.
198+ */
199+ protected void executeInUsageSchema (String sql ) throws SQLException {
200+ try (Connection connection = createSchemaDataSource ("openespi_usage" ).getConnection ();
201+ Statement statement = connection .createStatement ()) {
202+ statement .execute (sql );
203+ }
204+ }
205+
206+ /**
207+ * Execute SQL statement in the customer schema.
208+ */
209+ protected void executeInCustomerSchema (String sql ) throws SQLException {
210+ try (Connection connection = createSchemaDataSource ("openespi_customer" ).getConnection ();
211+ Statement statement = connection .createStatement ()) {
212+ statement .execute (sql );
213+ }
214+ }
215+
216+ /**
217+ * Execute JSONB query in PostgreSQL.
218+ */
219+ protected void executeJsonbQuery (String sql ) throws SQLException {
220+ try (Connection connection = dataSource .getConnection ();
221+ Statement statement = connection .createStatement ()) {
222+ statement .execute (sql );
223+ }
224+ }
225+
226+ /**
227+ * Flush and clear the entity manager.
228+ */
229+ protected void flushAndClear () {
230+ entityManager .flush ();
231+ entityManager .clear ();
232+ }
233+
234+ /**
235+ * Persist an entity and flush.
236+ */
237+ protected <T > T persistAndFlush (T entity ) {
238+ entityManager .persist (entity );
239+ entityManager .flush ();
240+ return entity ;
241+ }
242+
243+ /**
244+ * Merge an entity and flush.
245+ */
246+ protected <T > T mergeAndFlush (T entity ) {
247+ T merged = entityManager .merge (entity );
248+ entityManager .flush ();
249+ return merged ;
250+ }
251+
252+ /**
253+ * Get the PostgreSQL container instance for direct database operations.
254+ */
255+ protected static PostgreSQLContainer <?> getPostgreSQLContainer () {
256+ return POSTGRESQL_CONTAINER ;
257+ }
258+
259+ /**
260+ * Helper method to validate ESPI ATOM href URL format.
261+ * ESPI requires specific URL patterns for resource links.
262+ */
263+ protected boolean isValidEspiHref (String href ) {
264+ if (href == null || href .isEmpty ()) {
265+ return false ;
266+ }
267+
268+ // ESPI href pattern: /espi/1_1/resource/{ResourceType}/{id}
269+ return href .matches (".*\\ /espi\\ /1_1\\ /resource\\ /\\ w+\\ /[\\ w-]+.*" );
270+ }
271+
272+ /**
273+ * Helper method to extract resource ID from ESPI href URL.
274+ */
275+ protected String extractResourceIdFromHref (String href ) {
276+ if (!isValidEspiHref (href )) {
277+ return null ;
278+ }
279+
280+ String [] parts = href .split ("/" );
281+ // Find the part after "resource/{ResourceType}/"
282+ for (int i = 0 ; i < parts .length - 1 ; i ++) {
283+ if ("resource" .equals (parts [i ]) && i + 2 < parts .length ) {
284+ return parts [i + 2 ];
285+ }
286+ }
287+
288+ return null ;
289+ }
290+
291+ /**
292+ * Helper method to test PostgreSQL-specific data types.
293+ */
294+ protected boolean testBigSerialSequence (String schema , String table , String column ) throws SQLException {
295+ try (Connection connection = createSchemaDataSource (schema ).getConnection ();
296+ Statement statement = connection .createStatement ()) {
297+
298+ String sequenceName = schema + "." + table + "_" + column + "_seq" ;
299+ String sql = "SELECT last_value FROM " + sequenceName ;
300+ var resultSet = statement .executeQuery (sql );
301+ return resultSet .next () && resultSet .getLong (1 ) >= 0 ;
302+ }
303+ }
304+
305+ /**
306+ * Helper method to test PostgreSQL JSONB functionality.
307+ */
308+ protected boolean testJsonbQuery (String schema , String table , String jsonbColumn , String jsonPath ) throws SQLException {
309+ try (Connection connection = createSchemaDataSource (schema ).getConnection ();
310+ Statement statement = connection .createStatement ()) {
311+
312+ String sql = "SELECT " + jsonbColumn + " -> '" + jsonPath + "' FROM " + schema + "." + table + " LIMIT 1" ;
313+ var resultSet = statement .executeQuery (sql );
314+ return true ; // If query executes without error, JSONB is working
315+ } catch (SQLException e ) {
316+ return false ;
317+ }
318+ }
319+
320+ /**
321+ * Helper method to test PostgreSQL GIN indexes on JSONB fields.
322+ */
323+ protected boolean testGinIndexExists (String schema , String table , String indexName ) throws SQLException {
324+ try (Connection connection = createSchemaDataSource (schema ).getConnection ();
325+ Statement statement = connection .createStatement ()) {
326+
327+ String sql = "SELECT indexname FROM pg_indexes WHERE schemaname = '" + schema +
328+ "' AND tablename = '" + table + "' AND indexname = '" + indexName + "'" ;
329+ var resultSet = statement .executeQuery (sql );
330+ return resultSet .next ();
331+ }
332+ }
333+
334+ /**
335+ * Helper method to test PostgreSQL timezone handling.
336+ */
337+ protected boolean testTimezoneHandling () throws SQLException {
338+ try (Connection connection = dataSource .getConnection ();
339+ Statement statement = connection .createStatement ()) {
340+
341+ String sql = "SELECT EXTRACT(TIMEZONE FROM NOW()) AS current_timezone" ;
342+ var resultSet = statement .executeQuery (sql );
343+ return resultSet .next () && resultSet .getDouble (1 ) == 0.0 ; // UTC = 0
344+ }
345+ }
346+ }
0 commit comments