Skip to content
This repository was archived by the owner on Jul 1, 2025. It is now read-only.

Commit 730671e

Browse files
dfcoffinclaude
andcommitted
Implement comprehensive TestContainers PostgreSQL integration tests for advanced database validation
- Create PostgreSQLTestContainersIntegrationTestBase with PostgreSQL 14 container and schema-based multi-tenancy - Add PostgreSQLFlywayMigrationIntegrationTest validating BIGSERIAL, TIMESTAMP WITH TIME ZONE, and JSONB features - Add PostgreSQLEntityCrudIntegrationTest for PostgreSQL-specific data types and unlimited TEXT storage - Add PostgreSQLAtomHrefIntegrationTest testing ESPI href relationships with PostgreSQL TEXT field capabilities - Add PostgreSQLSchemaCouplingIntegrationTest validating schema-based separation and search path independence - Create testcontainers-postgresql Spring profile with PostgreSQL dialect and timezone configuration - Add PostgreSQL test suite documentation comparing MySQL vs PostgreSQL advantages - Validate GIN indexes on JSONB fields and PostgreSQL-specific query optimization - Test schema-based multi-tenancy (single database with schemas vs multiple databases) - Ensure NAESB ESPI 1.0 compliance with PostgreSQL advanced features and timezone handling 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent df5b01e commit 730671e

7 files changed

+2285
-0
lines changed

src/test/java/org/greenbuttonalliance/espi/common/integration/PostgreSQLAtomHrefIntegrationTest.java

Lines changed: 362 additions & 0 deletions
Large diffs are not rendered by default.

src/test/java/org/greenbuttonalliance/espi/common/integration/PostgreSQLEntityCrudIntegrationTest.java

Lines changed: 433 additions & 0 deletions
Large diffs are not rendered by default.

src/test/java/org/greenbuttonalliance/espi/common/integration/PostgreSQLFlywayMigrationIntegrationTest.java

Lines changed: 394 additions & 0 deletions
Large diffs are not rendered by default.

src/test/java/org/greenbuttonalliance/espi/common/integration/PostgreSQLSchemaCouplingIntegrationTest.java

Lines changed: 438 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
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

Comments
 (0)