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 org .junit .jupiter .api .DisplayName ;
22+ import org .junit .jupiter .api .Test ;
23+ import org .springframework .test .context .jdbc .Sql ;
24+
25+ import java .sql .Connection ;
26+ import java .sql .DatabaseMetaData ;
27+ import java .sql .ResultSet ;
28+ import java .sql .SQLException ;
29+ import java .util .ArrayList ;
30+ import java .util .List ;
31+
32+ import static org .junit .jupiter .api .Assertions .*;
33+
34+ /**
35+ * Integration tests for MySQL Flyway migrations using TestContainers.
36+ *
37+ * This test class validates:
38+ * - Flyway migration scripts execute successfully
39+ * - Database schema structure matches expectations
40+ * - All required tables are created with correct structure
41+ * - Indexes and constraints are properly applied
42+ * - Usage and customer schemas are properly separated
43+ */
44+ @ DisplayName ("MySQL Flyway Migration Integration Tests" )
45+ class MySQLFlywayMigrationIntegrationTest extends TestContainersIntegrationTestBase {
46+
47+ @ Test
48+ @ DisplayName ("Should successfully execute all Flyway migrations" )
49+ void shouldExecuteAllFlywayMigrations () throws SQLException {
50+ // Test verifies that setUp() method ran successfully without exceptions
51+ // and that database connections are working
52+
53+ try (Connection usageConn = createSchemaDataSource ("openespi_usage" ).getConnection ();
54+ Connection customerConn = createSchemaDataSource ("openespi_customer" ).getConnection ()) {
55+
56+ assertNotNull (usageConn , "Usage schema connection should be available" );
57+ assertNotNull (customerConn , "Customer schema connection should be available" );
58+
59+ assertTrue (usageConn .isValid (5 ), "Usage schema connection should be valid" );
60+ assertTrue (customerConn .isValid (5 ), "Customer schema connection should be valid" );
61+ }
62+ }
63+
64+ @ Test
65+ @ DisplayName ("Should create all required tables in usage schema" )
66+ void shouldCreateUsageSchemaTables () throws SQLException {
67+ List <String > expectedTables = List .of (
68+ // V1 - Base tables
69+ "application_information" ,
70+ "subscriptions" ,
71+ "authorizations" ,
72+ "retail_customers" ,
73+ "usage_points" ,
74+ "service_delivery_points" ,
75+
76+ // V2 - Meter and reading tables
77+ "meter_readings" ,
78+ "interval_blocks" ,
79+ "interval_readings" ,
80+ "reading_types" ,
81+
82+ // V3 - Quality and summary tables
83+ "reading_qualities" ,
84+ "electric_power_quality_summaries" ,
85+ "usage_summaries" ,
86+ "summary_measurements"
87+ );
88+
89+ List <String > actualTables = getTablesInSchema ("openespi_usage" );
90+
91+ for (String expectedTable : expectedTables ) {
92+ assertTrue (actualTables .contains (expectedTable ),
93+ "Usage schema should contain table: " + expectedTable );
94+ }
95+ }
96+
97+ @ Test
98+ @ DisplayName ("Should create all required tables in customer schema" )
99+ void shouldCreateCustomerSchemaTables () throws SQLException {
100+ List <String > expectedTables = List .of (
101+ // V4 - Base customer tables
102+ "customers" ,
103+ "customer_agreements" ,
104+ "customer_accounts" ,
105+ "pricing_structures" ,
106+ "tariff_profiles" ,
107+
108+ // V5 - Device and asset tables
109+ "end_devices" ,
110+ "assets" ,
111+ "asset_properties" ,
112+ "device_functions" ,
113+
114+ // V6 - Organization and support tables
115+ "organisations" ,
116+ "service_locations" ,
117+ "postal_addresses" ,
118+ "electronic_addresses" ,
119+ "phone_numbers" ,
120+ "names" ,
121+ "statements"
122+ );
123+
124+ List <String > actualTables = getTablesInSchema ("openespi_customer" );
125+
126+ for (String expectedTable : expectedTables ) {
127+ assertTrue (actualTables .contains (expectedTable ),
128+ "Customer schema should contain table: " + expectedTable );
129+ }
130+ }
131+
132+ @ Test
133+ @ DisplayName ("Should create proper table structure for application_information" )
134+ void shouldCreateApplicationInformationTableStructure () throws SQLException {
135+ List <String > expectedColumns = List .of (
136+ "id" , "uuid" , "uuid_msb" , "uuid_lsb" , "description" ,
137+ "created" , "updated" , "published" ,
138+ "up_link_rel" , "up_link_href" , "self_link_rel" , "self_link_href" ,
139+ "client_name" , "client_id" , "client_secret" , "client_id_issued_at" ,
140+ "client_secret_expires_at" , "registration_client_uri" ,
141+ "registration_access_token" , "redirect_uris" , "software_id" ,
142+ "software_version" , "token_endpoint_auth_method" , "response_types" ,
143+ "grant_types" , "application_type" , "contacts" , "logo_uri" ,
144+ "policy_uri" , "tos_uri" , "jwks_uri" , "scope"
145+ );
146+
147+ List <String > actualColumns = getColumnsInTable ("openespi_usage" , "application_information" );
148+
149+ for (String expectedColumn : expectedColumns ) {
150+ assertTrue (actualColumns .contains (expectedColumn ),
151+ "application_information table should contain column: " + expectedColumn );
152+ }
153+ }
154+
155+ @ Test
156+ @ DisplayName ("Should create proper table structure for retail_customers" )
157+ void shouldCreateRetailCustomersTableStructure () throws SQLException {
158+ List <String > expectedColumns = List .of (
159+ "id" , "uuid" , "uuid_msb" , "uuid_lsb" , "description" ,
160+ "created" , "updated" , "published" ,
161+ "up_link_rel" , "up_link_href" , "self_link_rel" , "self_link_href" ,
162+ "enabled" , "first_name" , "last_name" , "password" , "role" , "username"
163+ );
164+
165+ List <String > actualColumns = getColumnsInTable ("openespi_usage" , "retail_customers" );
166+
167+ for (String expectedColumn : expectedColumns ) {
168+ assertTrue (actualColumns .contains (expectedColumn ),
169+ "retail_customers table should contain column: " + expectedColumn );
170+ }
171+ }
172+
173+ @ Test
174+ @ DisplayName ("Should create proper table structure for customers with PII fields" )
175+ void shouldCreateCustomersTableStructure () throws SQLException {
176+ List <String > expectedColumns = List .of (
177+ "id" , "uuid" , "uuid_msb" , "uuid_lsb" , "description" ,
178+ "created" , "updated" , "published" ,
179+ "up_link_rel" , "up_link_href" , "self_link_rel" , "self_link_href" ,
180+ "kind" , "special_need" , "vip" , "puc_number" , "status" , "priority" ,
181+ "locale" , "customer_name" , "retail_customer_href"
182+ );
183+
184+ List <String > actualColumns = getColumnsInTable ("openespi_customer" , "customers" );
185+
186+ for (String expectedColumn : expectedColumns ) {
187+ assertTrue (actualColumns .contains (expectedColumn ),
188+ "customers table should contain column: " + expectedColumn );
189+ }
190+ }
191+
192+ @ Test
193+ @ DisplayName ("Should create proper indexes for performance" )
194+ void shouldCreateProperIndexes () throws SQLException {
195+ // Test critical indexes exist for performance
196+ assertTrue (indexExists ("openespi_usage" , "application_information" , "idx_app_info_uuid" ),
197+ "application_information should have UUID index" );
198+ assertTrue (indexExists ("openespi_usage" , "application_information" , "idx_app_info_client_id" ),
199+ "application_information should have client_id index" );
200+
201+ assertTrue (indexExists ("openespi_usage" , "retail_customers" , "idx_retail_customer_uuid" ),
202+ "retail_customers should have UUID index" );
203+ assertTrue (indexExists ("openespi_usage" , "retail_customers" , "idx_retail_customer_username" ),
204+ "retail_customers should have username index" );
205+
206+ assertTrue (indexExists ("openespi_customer" , "customers" , "idx_customer_uuid" ),
207+ "customers should have UUID index" );
208+ assertTrue (indexExists ("openespi_customer" , "customers" , "idx_customer_puc_number" ),
209+ "customers should have PUC number index" );
210+ }
211+
212+ @ Test
213+ @ DisplayName ("Should enforce unique constraints" )
214+ void shouldEnforceUniqueConstraints () throws SQLException {
215+ // Test that unique constraints are properly applied
216+ assertTrue (hasUniqueConstraint ("openespi_usage" , "application_information" , "uuid" ),
217+ "application_information.uuid should have unique constraint" );
218+ assertTrue (hasUniqueConstraint ("openespi_usage" , "application_information" , "client_id" ),
219+ "application_information.client_id should have unique constraint" );
220+
221+ assertTrue (hasUniqueConstraint ("openespi_usage" , "retail_customers" , "uuid" ),
222+ "retail_customers.uuid should have unique constraint" );
223+ assertTrue (hasUniqueConstraint ("openespi_usage" , "retail_customers" , "username" ),
224+ "retail_customers.username should have unique constraint" );
225+
226+ assertTrue (hasUniqueConstraint ("openespi_customer" , "customers" , "uuid" ),
227+ "customers.uuid should have unique constraint" );
228+ }
229+
230+ @ Test
231+ @ DisplayName ("Should use proper column types for ESPI compliance" )
232+ void shouldUseProperColumnTypes () throws SQLException {
233+ // Test critical column types for ESPI compliance
234+ assertEquals ("VARCHAR" , getColumnType ("openespi_usage" , "application_information" , "uuid" ),
235+ "UUID column should be VARCHAR(36)" );
236+ assertEquals ("DATETIME" , getColumnType ("openespi_usage" , "application_information" , "created" ),
237+ "Created timestamp should be DATETIME(6)" );
238+ assertEquals ("TEXT" , getColumnType ("openespi_usage" , "application_information" , "redirect_uris" ),
239+ "Redirect URIs should be TEXT for multiple URLs" );
240+
241+ assertEquals ("VARCHAR" , getColumnType ("openespi_customer" , "customers" , "uuid" ),
242+ "Customer UUID should be VARCHAR(36)" );
243+ assertEquals ("TINYINT" , getColumnType ("openespi_customer" , "customers" , "vip" ),
244+ "VIP flag should be BOOLEAN (TINYINT in MySQL)" );
245+ }
246+
247+ @ Test
248+ @ DisplayName ("Should verify schema separation between usage and customer data" )
249+ void shouldVerifySchemaSeparation () throws SQLException {
250+ // Verify that usage schema doesn't contain customer tables
251+ List <String > usageTables = getTablesInSchema ("openespi_usage" );
252+ assertFalse (usageTables .contains ("customers" ),
253+ "Usage schema should not contain customers table" );
254+ assertFalse (usageTables .contains ("organisations" ),
255+ "Usage schema should not contain organisations table" );
256+
257+ // Verify that customer schema doesn't contain usage tables
258+ List <String > customerTables = getTablesInSchema ("openespi_customer" );
259+ assertFalse (customerTables .contains ("meter_readings" ),
260+ "Customer schema should not contain meter_readings table" );
261+ assertFalse (customerTables .contains ("interval_readings" ),
262+ "Customer schema should not contain interval_readings table" );
263+ }
264+
265+ // Helper methods for database introspection
266+
267+ private List <String > getTablesInSchema (String schema ) throws SQLException {
268+ List <String > tables = new ArrayList <>();
269+ try (Connection conn = createSchemaDataSource (schema ).getConnection ()) {
270+ DatabaseMetaData metaData = conn .getMetaData ();
271+ try (ResultSet rs = metaData .getTables (schema , null , null , new String []{"TABLE" })) {
272+ while (rs .next ()) {
273+ tables .add (rs .getString ("TABLE_NAME" ));
274+ }
275+ }
276+ }
277+ return tables ;
278+ }
279+
280+ private List <String > getColumnsInTable (String schema , String table ) throws SQLException {
281+ List <String > columns = new ArrayList <>();
282+ try (Connection conn = createSchemaDataSource (schema ).getConnection ()) {
283+ DatabaseMetaData metaData = conn .getMetaData ();
284+ try (ResultSet rs = metaData .getColumns (schema , null , table , null )) {
285+ while (rs .next ()) {
286+ columns .add (rs .getString ("COLUMN_NAME" ));
287+ }
288+ }
289+ }
290+ return columns ;
291+ }
292+
293+ private boolean indexExists (String schema , String table , String indexName ) throws SQLException {
294+ try (Connection conn = createSchemaDataSource (schema ).getConnection ()) {
295+ DatabaseMetaData metaData = conn .getMetaData ();
296+ try (ResultSet rs = metaData .getIndexInfo (schema , null , table , false , false )) {
297+ while (rs .next ()) {
298+ if (indexName .equals (rs .getString ("INDEX_NAME" ))) {
299+ return true ;
300+ }
301+ }
302+ }
303+ }
304+ return false ;
305+ }
306+
307+ private boolean hasUniqueConstraint (String schema , String table , String column ) throws SQLException {
308+ try (Connection conn = createSchemaDataSource (schema ).getConnection ()) {
309+ DatabaseMetaData metaData = conn .getMetaData ();
310+ try (ResultSet rs = metaData .getIndexInfo (schema , null , table , true , false )) {
311+ while (rs .next ()) {
312+ if (column .equals (rs .getString ("COLUMN_NAME" ))) {
313+ return true ;
314+ }
315+ }
316+ }
317+ }
318+ return false ;
319+ }
320+
321+ private String getColumnType (String schema , String table , String column ) throws SQLException {
322+ try (Connection conn = createSchemaDataSource (schema ).getConnection ()) {
323+ DatabaseMetaData metaData = conn .getMetaData ();
324+ try (ResultSet rs = metaData .getColumns (schema , null , table , column )) {
325+ if (rs .next ()) {
326+ return rs .getString ("TYPE_NAME" );
327+ }
328+ }
329+ }
330+ return null ;
331+ }
332+
333+ private javax .sql .DataSource createSchemaDataSource (String schema ) {
334+ org .springframework .boot .jdbc .DataSourceBuilder <?> builder =
335+ org .springframework .boot .jdbc .DataSourceBuilder .create ();
336+
337+ return builder
338+ .url (getMySQLContainer ().getJdbcUrl ().replace ("/openespi_test" , "/" + schema ))
339+ .username (getMySQLContainer ().getUsername ())
340+ .password (getMySQLContainer ().getPassword ())
341+ .driverClassName ("com.mysql.cj.jdbc.Driver" )
342+ .build ();
343+ }
344+ }
0 commit comments