From 403886eb6c1d4800605b8fd80636f295a5b6ee6c Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:41:16 +0400 Subject: [PATCH 01/30] build(pom): share test dependencies and expose the common test-jar Every module now inherits JUnit 5, Mockito, AssertJ and Spring Test with test scope from the parent, so a module pom only lists what is specific to it. The parent also manages the common test-jar, letting the DAO tests of each business module reuse the shared PostgreSQL Testcontainers helper instead of copying it. Testcontainers and the PostgreSQL driver stay out of the shared list on purpose: only the modules that really run DAO tests declare them. --- pom.xml | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/pom.xml b/pom.xml index 70d26de..5d64409 100644 --- a/pom.xml +++ b/pom.xml @@ -39,6 +39,7 @@ 5.14.4 5.23.0 1.21.4 + 3.27.7 10.1.60 @@ -173,6 +174,18 @@ logback-classic ${logback.version} + + com.edgareldy.springjdbctutorial + common + ${project.version} + test-jar + test + + + org.assertj + assertj-core + ${assertj.version} + org.mockito mockito-core @@ -186,6 +199,31 @@ + + + + org.junit.jupiter + junit-jupiter + test + + + org.mockito + mockito-junit-jupiter + test + + + org.assertj + assertj-core + test + + + org.springframework + spring-test + test + + + From b61861d66373469e219ad5ac50a6a52b4af2ebfe Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:41:16 +0400 Subject: [PATCH 02/30] build(core): declare the auth, catalog, customer and order modules The core aggregator now lists every business module of the README. Each one starts as an empty jar that depends only on common; order additionally depends on catalog and customer, which is the single cross-module edge of the design (through their service interfaces once they exist). Declaring them now lets ws depend on the whole core from the start and keeps each later branch limited to adding classes, not restructuring the reactor. --- core/auth/pom.xml | 23 +++++++++++++++++++++++ core/catalog/pom.xml | 23 +++++++++++++++++++++++ core/customer/pom.xml | 23 +++++++++++++++++++++++ core/order/pom.xml | 27 +++++++++++++++++++++++++++ core/pom.xml | 4 ++++ 5 files changed, 100 insertions(+) create mode 100644 core/auth/pom.xml create mode 100644 core/catalog/pom.xml create mode 100644 core/customer/pom.xml create mode 100644 core/order/pom.xml diff --git a/core/auth/pom.xml b/core/auth/pom.xml new file mode 100644 index 0000000..def61c8 --- /dev/null +++ b/core/auth/pom.xml @@ -0,0 +1,23 @@ + + + 4.0.0 + + + com.edgareldy.springjdbctutorial + core + 0.1.0-SNAPSHOT + + + auth + auth + Identity and RBAC domain: users, roles, permissions, tokens, audit logs + + + + com.edgareldy.springjdbctutorial + common + + + diff --git a/core/catalog/pom.xml b/core/catalog/pom.xml new file mode 100644 index 0000000..5c4dca8 --- /dev/null +++ b/core/catalog/pom.xml @@ -0,0 +1,23 @@ + + + 4.0.0 + + + com.edgareldy.springjdbctutorial + core + 0.1.0-SNAPSHOT + + + catalog + catalog + E-commerce catalog: categories and products + + + + com.edgareldy.springjdbctutorial + common + + + diff --git a/core/customer/pom.xml b/core/customer/pom.xml new file mode 100644 index 0000000..227da5e --- /dev/null +++ b/core/customer/pom.xml @@ -0,0 +1,23 @@ + + + 4.0.0 + + + com.edgareldy.springjdbctutorial + core + 0.1.0-SNAPSHOT + + + customer + customer + E-commerce customers + + + + com.edgareldy.springjdbctutorial + common + + + diff --git a/core/order/pom.xml b/core/order/pom.xml new file mode 100644 index 0000000..a3ac330 --- /dev/null +++ b/core/order/pom.xml @@ -0,0 +1,27 @@ + + + 4.0.0 + + + com.edgareldy.springjdbctutorial + core + 0.1.0-SNAPSHOT + + + order + order + E-commerce orders: depends on the catalog and customer services + + + + com.edgareldy.springjdbctutorial + catalog + + + com.edgareldy.springjdbctutorial + customer + + + diff --git a/core/pom.xml b/core/pom.xml index b1cc02e..ff32bc9 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -17,5 +17,9 @@ common + auth + catalog + customer + order From a01445ceb2c2f0e886539448773f099f498add76 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:41:16 +0400 Subject: [PATCH 03/30] build(common): add the JDBC, Flyway and test-jar dependencies common carries what every business module needs: spring-jdbc for JdbcTemplate and transactions, spring-context for @Configuration, HikariCP for pooling, the PostgreSQL driver, Flyway with its PostgreSQL module, and the jakarta.persistence annotations used only as documentation on entities. It also publishes its test classes as a test-jar (maven-jar-plugin test-jar goal), so the Testcontainers PostgreSQL holder written once here can be imported by the DAO tests of the other modules. --- core/common/pom.xml | 57 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/core/common/pom.xml b/core/common/pom.xml index cb055cb..c68fb4d 100644 --- a/core/common/pom.xml +++ b/core/common/pom.xml @@ -15,9 +15,66 @@ Shared exceptions, AbstractDao and DataSourceConfig + org.springframework spring-jdbc + + + org.springframework + spring-context + + + com.zaxxer + HikariCP + + + org.postgresql + postgresql + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-database-postgresql + + + + jakarta.persistence + jakarta.persistence-api + + + + org.testcontainers + junit-jupiter + test + + + org.testcontainers + postgresql + test + + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + + test-jar + + + + + + From c9178aa57e21ebba39de054fe6648c7e9e58b8e6 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:41:16 +0400 Subject: [PATCH 04/30] build(ws): add Spring MVC, Jackson, validation and logging dependencies ws is the web layer: it depends on every core module, on spring-webmvc and on Jackson with the JSR-310 module for Instant. The Servlet API is provided scope because Tomcat 10.1 supplies Servlet 6.0 at runtime, and Bean Validation runs standalone on Hibernate Validator with its Expressly EL. The WAR plugin is told there is no web.xml, since a WebApplicationInitializer replaces it, and the artifact is named ws so the package is ws/target/ws.war as the README states. --- ws/pom.xml | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/ws/pom.xml b/ws/pom.xml index e2cda26..2136e9f 100644 --- a/ws/pom.xml +++ b/ws/pom.xml @@ -20,6 +20,75 @@ com.edgareldy.springjdbctutorial common + + com.edgareldy.springjdbctutorial + auth + + + com.edgareldy.springjdbctutorial + catalog + + + com.edgareldy.springjdbctutorial + customer + + + com.edgareldy.springjdbctutorial + order + + + + org.springframework + spring-webmvc + + + + jakarta.servlet + jakarta.servlet-api + provided + + + com.fasterxml.jackson.core + jackson-databind + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + + jakarta.validation + jakarta.validation-api + + + org.hibernate.validator + hibernate-validator + + + org.glassfish.expressly + expressly + + + ch.qos.logback + logback-classic + + + + com.edgareldy.springjdbctutorial + common + test-jar + test + + + org.testcontainers + junit-jupiter + test + + + org.testcontainers + postgresql + test + From 91c82657aef5f197444c3e77e9d025c14181f2d4 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:41:16 +0400 Subject: [PATCH 05/30] feat(common): add ResourceNotFoundException and BusinessRuleException These two unchecked exceptions are the shared vocabulary between services and the web layer. A service throws ResourceNotFoundException when an id matches nothing and BusinessRuleException when a domain rule refuses an operation; neither knows anything about HTTP. Keeping them in common lets every core module raise them without depending on ws, while GlobalExceptionHandler translates them to 404 and 422 later. --- .../exception/BusinessRuleException.java | 22 ++++++++++++++++++ .../exception/ResourceNotFoundException.java | 23 +++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/exception/BusinessRuleException.java create mode 100644 core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/exception/ResourceNotFoundException.java diff --git a/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/exception/BusinessRuleException.java b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/exception/BusinessRuleException.java new file mode 100644 index 0000000..73baa6b --- /dev/null +++ b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/exception/BusinessRuleException.java @@ -0,0 +1,22 @@ +package com.edgareldy.springjdbctutorial.core.common.exception; + +/** + * Thrown by a service when a business rule refuses an operation (mapped to HTTP 422 by the web layer). + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class BusinessRuleException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public BusinessRuleException(String message) { + super(message); + } + + public BusinessRuleException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/exception/ResourceNotFoundException.java b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/exception/ResourceNotFoundException.java new file mode 100644 index 0000000..d835123 --- /dev/null +++ b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/exception/ResourceNotFoundException.java @@ -0,0 +1,23 @@ +package com.edgareldy.springjdbctutorial.core.common.exception; + +/** + * Thrown by a service when a requested resource does not exist (mapped to HTTP 404 by the web layer). + * It lives in core so no module depends on anything web-related. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class ResourceNotFoundException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public ResourceNotFoundException(String message) { + super(message); + } + + public ResourceNotFoundException(String message, Throwable cause) { + super(message, cause); + } +} From b7f207a763b16c7649d7e2a211a06b4c0a0ed854 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:41:16 +0400 Subject: [PATCH 06/30] feat(common): add the Flyway V1 schema for both domains The migration creates every table of the README data model: the identity and RBAC domain (users, roles, permissions, join tables, activation, blacklist and reset tokens, audit logs) and the e-commerce domain (categories, products, customers, orders). Flyway is the only thing that creates or alters tables, no framework generates the schema. Constraints carry the invariants the database can enforce: a unique index on the lower-cased email, tokens stored as 64 character digests, restrictive foreign keys where a service must refuse the delete first, and no foreign key between the two domains. --- .../db/migration/V1__init_schema.sql | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 core/common/src/main/resources/db/migration/V1__init_schema.sql diff --git a/core/common/src/main/resources/db/migration/V1__init_schema.sql b/core/common/src/main/resources/db/migration/V1__init_schema.sql new file mode 100644 index 0000000..e6ac40a --- /dev/null +++ b/core/common/src/main/resources/db/migration/V1__init_schema.sql @@ -0,0 +1,160 @@ +-- Initial schema of both independent domains (identity/RBAC and e-commerce). +-- Flyway is the single source of truth of the schema: nothing else creates or alters tables. +-- The two domains share one database but have no foreign key between them. + +-- --------------------------------------------------------------------------- +-- Identity / RBAC domain +-- --------------------------------------------------------------------------- + +CREATE TABLE users ( + id BIGSERIAL PRIMARY KEY, + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + email VARCHAR(255) NOT NULL, + -- bcrypt hash, never the clear password + password VARCHAR(100) NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + account_locked BOOLEAN NOT NULL DEFAULT FALSE +); + +-- Emails are compared case-insensitively: the unique index is on the lower-cased value +CREATE UNIQUE INDEX uq_users_email_lower ON users (LOWER(email)); + +CREATE TABLE roles ( + id BIGSERIAL PRIMARY KEY, + role_name VARCHAR(100) NOT NULL, + CONSTRAINT uq_roles_role_name UNIQUE (role_name) +); + +CREATE TABLE permissions ( + id BIGSERIAL PRIMARY KEY, + resource VARCHAR(100) NOT NULL, + action VARCHAR(100) NOT NULL, + CONSTRAINT uq_permissions_resource_action UNIQUE (resource, action) +); + +-- Roles are assigned onto a user, never the reverse +CREATE TABLE role_user ( + role_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + PRIMARY KEY (role_id, user_id), + -- No cascade on the role: deleting a role still assigned to a user is refused by the service + CONSTRAINT fk_role_user_role FOREIGN KEY (role_id) REFERENCES roles (id), + CONSTRAINT fk_role_user_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); + +CREATE INDEX idx_role_user_user ON role_user (user_id); + +-- Permissions are assigned onto a role, never the reverse +CREATE TABLE role_permission ( + role_id BIGINT NOT NULL, + permission_id BIGINT NOT NULL, + PRIMARY KEY (role_id, permission_id), + CONSTRAINT fk_role_permission_role FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE, + -- No cascade on the permission: deleting a permission still held by a role is refused by the service + CONSTRAINT fk_role_permission_permission FOREIGN KEY (permission_id) REFERENCES permissions (id) +); + +CREATE INDEX idx_role_permission_permission ON role_permission (permission_id); + +-- Activation and password reset tokens are stored as SHA-256 hex digests only (64 characters) +CREATE TABLE activation_tokens ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + token VARCHAR(64) NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + validated_at TIMESTAMPTZ, + CONSTRAINT uq_activation_tokens_token UNIQUE (token), + CONSTRAINT fk_activation_tokens_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); + +CREATE INDEX idx_activation_tokens_user ON activation_tokens (user_id); + +-- A revoked JWT: token is the SHA-256 hex digest of the JWT (never the JWT itself), jti its unique id +CREATE TABLE blacklisted_tokens ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + token VARCHAR(64) NOT NULL, + jti VARCHAR(100) NOT NULL, + blacklisted_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + validated_at TIMESTAMPTZ, + CONSTRAINT uq_blacklisted_tokens_token UNIQUE (token), + CONSTRAINT uq_blacklisted_tokens_jti UNIQUE (jti), + CONSTRAINT fk_blacklisted_tokens_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); + +-- The scheduled cleanup deletes rows by expiry +CREATE INDEX idx_blacklisted_tokens_expires_at ON blacklisted_tokens (expires_at); + +CREATE TABLE password_reset_tokens ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + token VARCHAR(64) NOT NULL, + type VARCHAR(30) NOT NULL, + expiry_date TIMESTAMPTZ NOT NULL, + CONSTRAINT uq_password_reset_tokens_token UNIQUE (token), + CONSTRAINT fk_password_reset_tokens_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); + +CREATE INDEX idx_password_reset_tokens_user ON password_reset_tokens (user_id); + +-- Audit trail of every RBAC mutation. No foreign key on purpose: the trail must outlive +-- whatever it references, and a refusal row is written in an independent transaction. +CREATE TABLE audit_logs ( + id BIGSERIAL PRIMARY KEY, + actor_user_id BIGINT, + action VARCHAR(100) NOT NULL, + entity_type VARCHAR(100) NOT NULL, + entity_id BIGINT, + details TEXT, + created_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX idx_audit_logs_created_at ON audit_logs (created_at); + +-- --------------------------------------------------------------------------- +-- E-commerce domain +-- --------------------------------------------------------------------------- + +CREATE TABLE categories ( + id BIGSERIAL PRIMARY KEY, + category_name VARCHAR(100) NOT NULL, + CONSTRAINT uq_categories_category_name UNIQUE (category_name) +); + +CREATE TABLE products ( + id BIGSERIAL PRIMARY KEY, + category_id BIGINT NOT NULL, + product_name VARCHAR(150) NOT NULL, + unit_price NUMERIC(12, 2) NOT NULL, + CONSTRAINT ck_products_unit_price CHECK (unit_price >= 0), + CONSTRAINT fk_products_category FOREIGN KEY (category_id) REFERENCES categories (id) +); + +CREATE INDEX idx_products_category ON products (category_id); + +CREATE TABLE customers ( + id BIGSERIAL PRIMARY KEY, + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + telephone VARCHAR(30), + email VARCHAR(255), + address VARCHAR(255) +); + +CREATE TABLE orders ( + id BIGSERIAL PRIMARY KEY, + customer_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + quantity INTEGER NOT NULL, + total NUMERIC(14, 2) NOT NULL, + CONSTRAINT ck_orders_quantity CHECK (quantity > 0), + CONSTRAINT fk_orders_customer FOREIGN KEY (customer_id) REFERENCES customers (id), + CONSTRAINT fk_orders_product FOREIGN KEY (product_id) REFERENCES products (id) +); + +CREATE INDEX idx_orders_customer ON orders (customer_id); +CREATE INDEX idx_orders_product ON orders (product_id); From 5fb25ac42305732ef5e95148a3e92834045aa565 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:41:16 +0400 Subject: [PATCH 07/30] feat(common): add AbstractDao with shared JdbcTemplate helpers JdbcTemplate is Spring JDBC's core class: it borrows a connection, runs the SQL, binds the parameters, and translates SQLException into Spring's unchecked DataAccessException hierarchy. A RowMapper turns one ResultSet row into one object, which together replace what an ORM would hide. AbstractDao holds the shared template and three helpers every DAO reuses: findOne (first row or empty, never EmptyResultDataAccessException), insertReturningId for PostgreSQL's INSERT ... RETURNING id, and count. SQL itself stays in the concrete DAO classes. --- .../core/common/dao/support/AbstractDao.java | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/dao/support/AbstractDao.java diff --git a/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/dao/support/AbstractDao.java b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/dao/support/AbstractDao.java new file mode 100644 index 0000000..faaa3f0 --- /dev/null +++ b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/dao/support/AbstractDao.java @@ -0,0 +1,63 @@ +package com.edgareldy.springjdbctutorial.core.common.dao.support; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * Base class of every DAO implementation: holds the shared {@link JdbcTemplate} and a few query helpers. + * SQL itself always stays in the concrete DAO classes under {@code dao/impl}. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public abstract class AbstractDao { + + // JdbcTemplate is Spring JDBC's central class: it opens/releases the connection, runs the SQL, + // binds the "?" parameters and translates SQLException into Spring's unchecked DataAccessException + // hierarchy. A RowMapper is the callback that turns ONE ResultSet row into one object. Together + // they replace an ORM: we write the SQL and the mapping ourselves. It is thread-safe, so a single + // instance (declared once in DataSourceConfig) is shared by all DAOs. + private final JdbcTemplate jdbcTemplate; + + protected AbstractDao(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = Objects.requireNonNull(jdbcTemplate, "jdbcTemplate"); + } + + protected JdbcTemplate getJdbcTemplate() { + return jdbcTemplate; + } + + /** + * Runs a query and returns its first row, or empty when there is none. + * Never throws {@code EmptyResultDataAccessException}, unlike {@code queryForObject}. + */ + protected Optional findOne(String sql, RowMapper mapper, Object... args) { + List rows = jdbcTemplate.query(sql, mapper, args); + return rows.isEmpty() ? Optional.empty() : Optional.of(rows.get(0)); + } + + /** + * Runs an INSERT whose SQL ends with {@code RETURNING id} (PostgreSQL) and returns the generated id. + */ + protected long insertReturningId(String sql, Object... args) { + Long id = jdbcTemplate.queryForObject(sql, Long.class, args); + if (id == null) { + throw new IllegalStateException("INSERT ... RETURNING id returned no id"); + } + return id; + } + + /** + * Runs a {@code SELECT COUNT(*)} style query and returns the number (0 when the result is null). + */ + protected long count(String sql, Object... args) { + Long total = jdbcTemplate.queryForObject(sql, Long.class, args); + return total == null ? 0L : total; + } +} From bdcf0e45780e4f48430c6cee468328bf5f7375d5 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:41:16 +0400 Subject: [PATCH 08/30] feat(common): declare DataSource, Flyway, JdbcTemplate and transactions DataSourceConfig is the only place these infrastructure beans exist. A @Configuration class declares beans explicitly with @Bean instead of relying on scanning or auto-configuration. The HikariCP pool reads its settings with getRequiredProperty, so a missing url, user or password fails the startup with a clear message and no default secret exists. Flyway migrates as the bean is created and JdbcTemplate and the DataSourceTransactionManager depend on it, so migrations always run before any DAO. @EnableTransactionManagement makes @Transactional service beans proxied. The PostgreSQL driver class is named explicitly because inside a WAR DriverManager cannot see a driver held in WEB-INF/lib. --- .../core/common/config/DataSourceConfig.java | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/config/DataSourceConfig.java diff --git a/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/config/DataSourceConfig.java b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/config/DataSourceConfig.java new file mode 100644 index 0000000..e150de5 --- /dev/null +++ b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/config/DataSourceConfig.java @@ -0,0 +1,94 @@ +package com.edgareldy.springjdbctutorial.core.common.config; + +import com.zaxxer.hikari.HikariDataSource; +import org.flywaydb.core.Flyway; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.DependsOn; +import org.springframework.core.env.Environment; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +import javax.sql.DataSource; + +/** + * The only place where DataSource, Flyway, JdbcTemplate and the transaction manager are declared. + * Settings come from the Spring Environment (system properties, environment variables) with no default + * for anything sensitive, so startup fails clearly when one is missing. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// @Configuration marks a class whose @Bean methods declare Spring beans explicitly. This is how the +// whole project is wired (no component scanning of core classes, no auto-configuration): a bean's +// existence and wiring are visible by reading the config class of its module. Spring calls each +// @Bean method once and registers the returned object under the method name. +// +// @EnableTransactionManagement turns on annotation-driven transactions: for every bean that has +// @Transactional methods (the service implementations declared by @Bean in the ServiceConfig +// classes), Spring wraps the bean in a proxy that opens/commits/rolls back a transaction around the +// call. It looks up the PlatformTransactionManager bean declared below. +@Configuration +@EnableTransactionManagement +public class DataSourceConfig { + + private static final int DEFAULT_MAX_POOL_SIZE = 10; + + private final Environment environment; + + public DataSourceConfig(Environment environment) { + this.environment = environment; + } + + // HikariCP is a fast JDBC connection pool: connections are opened once and reused instead of + // being created per request. getRequiredProperty throws IllegalStateException when a key is + // missing, so a wrong deployment fails at startup instead of using a hidden default (never put a + // default password here). The pool size is not a secret, so it has a default. + // destroyMethod "close" shuts the pool down when the context closes. + @Bean(destroyMethod = "close") + public DataSource dataSource() { + HikariDataSource dataSource = new HikariDataSource(); + // The driver class is named explicitly: inside a WAR the PostgreSQL driver lives in WEB-INF/lib, + // in the webapp classloader, and java.sql.DriverManager (which only auto-registers drivers seen + // by the system classloader) fails with "No suitable driver". Hikari loads the named class + // through the classloader of the application instead. + dataSource.setDriverClassName("org.postgresql.Driver"); + dataSource.setJdbcUrl(environment.getRequiredProperty("app.datasource.url")); + dataSource.setUsername(environment.getRequiredProperty("app.datasource.username")); + dataSource.setPassword(environment.getRequiredProperty("app.datasource.password")); + dataSource.setMaximumPoolSize( + environment.getProperty("app.datasource.max-pool-size", Integer.class, DEFAULT_MAX_POOL_SIZE)); + return dataSource; + } + + // Flyway applies the versioned SQL scripts of db/migration (the schema source of truth) to the + // database. initMethod "migrate" runs them as soon as the bean is created. The beans that use the + // database (JdbcTemplate below, hence every DAO) are declared @DependsOn("flyway"), so Spring + // always creates and runs Flyway BEFORE any DAO can touch a table. + @Bean(initMethod = "migrate") + public Flyway flyway(DataSource dataSource) { + return Flyway.configure() + .dataSource(dataSource) + .locations("classpath:db/migration") + .load(); + } + + @Bean + @DependsOn("flyway") + public JdbcTemplate jdbcTemplate(DataSource dataSource) { + return new JdbcTemplate(dataSource); + } + + // DataSourceTransactionManager implements local JDBC transactions: it binds one Connection to the + // current thread for the duration of a @Transactional method, so every JdbcTemplate call made in + // it (through any DAO) shares the same transaction and commits or rolls back together. + @Bean + @DependsOn("flyway") + public PlatformTransactionManager transactionManager(DataSource dataSource) { + return new DataSourceTransactionManager(dataSource); + } +} From fbb1d84dec8b76eeaaf37af7aa38d1e3eafe84f2 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:41:17 +0400 Subject: [PATCH 09/30] feat(common): add a health check through the five-layer split A tiny vertical slice gives the packaged WAR a public route to probe: a DAO runs SELECT 1, a service turns the outcome into a HealthDto (DOWN when a DataAccessException is raised) and CommonConfig registers both as explicit @Bean methods typed by their interfaces, importing DataSourceConfig. It shows the layering without an entity: the DAO owns SQL, the service owns the decision, and only a dto leaves the module. No stereotype annotation is used on either implementation. --- .../core/common/config/CommonConfig.java | 36 +++++++++++ .../core/common/dao/HealthDao.java | 19 ++++++ .../core/common/dao/impl/HealthDaoImpl.java | 25 ++++++++ .../core/common/dto/HealthDto.java | 63 +++++++++++++++++++ .../core/common/service/HealthService.java | 19 ++++++ .../service/impl/HealthServiceImpl.java | 35 +++++++++++ 6 files changed, 197 insertions(+) create mode 100644 core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/config/CommonConfig.java create mode 100644 core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/dao/HealthDao.java create mode 100644 core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/dao/impl/HealthDaoImpl.java create mode 100644 core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/dto/HealthDto.java create mode 100644 core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/service/HealthService.java create mode 100644 core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/service/impl/HealthServiceImpl.java diff --git a/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/config/CommonConfig.java b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/config/CommonConfig.java new file mode 100644 index 0000000..1034a89 --- /dev/null +++ b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/config/CommonConfig.java @@ -0,0 +1,36 @@ +package com.edgareldy.springjdbctutorial.core.common.config; + +import com.edgareldy.springjdbctutorial.core.common.dao.HealthDao; +import com.edgareldy.springjdbctutorial.core.common.dao.impl.HealthDaoImpl; +import com.edgareldy.springjdbctutorial.core.common.service.HealthService; +import com.edgareldy.springjdbctutorial.core.common.service.impl.HealthServiceImpl; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; + +/** + * Wiring of the common module: imports the infrastructure and declares the health DAO and service. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// @Import pulls DataSourceConfig into whichever context imports this class, so a module config only +// has to import what it directly needs. Beans are typed by their interface: callers never see the +// implementation class. +@Configuration +@Import(DataSourceConfig.class) +public class CommonConfig { + + @Bean + public HealthDao healthDao(JdbcTemplate jdbcTemplate) { + return new HealthDaoImpl(jdbcTemplate); + } + + @Bean + public HealthService healthService(HealthDao healthDao) { + return new HealthServiceImpl(healthDao); + } +} diff --git a/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/dao/HealthDao.java b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/dao/HealthDao.java new file mode 100644 index 0000000..4229b54 --- /dev/null +++ b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/dao/HealthDao.java @@ -0,0 +1,19 @@ +package com.edgareldy.springjdbctutorial.core.common.dao; + +/** + * Data access contract for the health check (no table involved, only connectivity). + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public interface HealthDao { + + /** + * Runs a trivial query to check the database answers. + * + * @return true when the query succeeded + */ + boolean isDatabaseReachable(); +} diff --git a/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/dao/impl/HealthDaoImpl.java b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/dao/impl/HealthDaoImpl.java new file mode 100644 index 0000000..b4b0ffd --- /dev/null +++ b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/dao/impl/HealthDaoImpl.java @@ -0,0 +1,25 @@ +package com.edgareldy.springjdbctutorial.core.common.dao.impl; + +import com.edgareldy.springjdbctutorial.core.common.dao.HealthDao; +import com.edgareldy.springjdbctutorial.core.common.dao.support.AbstractDao; +import org.springframework.jdbc.core.JdbcTemplate; + +/** + * JdbcTemplate implementation of {@link HealthDao}. Declared by @Bean in CommonConfig, no stereotype. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class HealthDaoImpl extends AbstractDao implements HealthDao { + + public HealthDaoImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + public boolean isDatabaseReachable() { + return count("SELECT 1") == 1L; + } +} diff --git a/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/dto/HealthDto.java b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/dto/HealthDto.java new file mode 100644 index 0000000..357b4b9 --- /dev/null +++ b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/dto/HealthDto.java @@ -0,0 +1,63 @@ +package com.edgareldy.springjdbctutorial.core.common.dto; + +import java.util.Objects; + +/** + * Business shape of the health check result. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class HealthDto { + + private String status; + private String database; + + public HealthDto() { + } + + public HealthDto(String status, String database) { + this.status = status; + this.database = database; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getDatabase() { + return database; + } + + public void setDatabase(String database) { + this.database = database; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + HealthDto that = (HealthDto) o; + return Objects.equals(status, that.status) && Objects.equals(database, that.database); + } + + @Override + public int hashCode() { + return Objects.hash(status, database); + } + + @Override + public String toString() { + return "HealthDto{status='" + status + "', database='" + database + "'}"; + } +} diff --git a/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/service/HealthService.java b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/service/HealthService.java new file mode 100644 index 0000000..729c972 --- /dev/null +++ b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/service/HealthService.java @@ -0,0 +1,19 @@ +package com.edgareldy.springjdbctutorial.core.common.service; + +import com.edgareldy.springjdbctutorial.core.common.dto.HealthDto; + +/** + * Health check use case, exposed to the web layer as a public route. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public interface HealthService { + + /** + * Reports the application and database status; never throws when the database is down. + */ + HealthDto check(); +} diff --git a/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/service/impl/HealthServiceImpl.java b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/service/impl/HealthServiceImpl.java new file mode 100644 index 0000000..e184677 --- /dev/null +++ b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/service/impl/HealthServiceImpl.java @@ -0,0 +1,35 @@ +package com.edgareldy.springjdbctutorial.core.common.service.impl; + +import com.edgareldy.springjdbctutorial.core.common.dao.HealthDao; +import com.edgareldy.springjdbctutorial.core.common.dto.HealthDto; +import com.edgareldy.springjdbctutorial.core.common.service.HealthService; +import org.springframework.dao.DataAccessException; + +/** + * Implementation of {@link HealthService}: DOWN when the database cannot be queried. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class HealthServiceImpl implements HealthService { + + private final HealthDao healthDao; + + public HealthServiceImpl(HealthDao healthDao) { + this.healthDao = healthDao; + } + + @Override + public HealthDto check() { + try { + if (healthDao.isDatabaseReachable()) { + return new HealthDto("UP", "UP"); + } + } catch (DataAccessException e) { + // JdbcTemplate translates SQLException into DataAccessException: database unreachable + } + return new HealthDto("DOWN", "DOWN"); + } +} From 8fb2304f9931c2129d8955a0e1d997c13104d3de Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:41:45 +0400 Subject: [PATCH 10/30] feat(ws): add ApiResponse, PageResponse and HealthResponse payloads ApiResponse wraps every answer, success or error, in the same envelope (success, message, data, timestamp) built through two static factories. PageResponse carries a page of results with its totals and refuses a size below 1, so a bad size can never produce a fake total. They are named payload, not dto, on purpose: core dto classes are the business shape, payload classes are the HTTP shape. They are plain classes with explicit accessors, no Lombok and no record. --- .../ws/payload/common/ApiResponse.java | 68 +++++++++++++++++++ .../ws/payload/common/HealthResponse.java | 39 +++++++++++ .../ws/payload/common/PageResponse.java | 56 +++++++++++++++ 3 files changed, 163 insertions(+) create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/common/ApiResponse.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/common/HealthResponse.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/common/PageResponse.java diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/common/ApiResponse.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/common/ApiResponse.java new file mode 100644 index 0000000..2b0ed53 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/common/ApiResponse.java @@ -0,0 +1,68 @@ +package com.edgareldy.springjdbctutorial.ws.payload.common; + +import java.time.Instant; + +/** + * Envelope of every HTTP response, success or error. data is null on errors. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class ApiResponse { + + private boolean success; + private String message; + private T data; + private Instant timestamp; + + public static ApiResponse success(T data, String message) { + ApiResponse response = new ApiResponse<>(); + response.success = true; + response.message = message; + response.data = data; + response.timestamp = Instant.now(); + return response; + } + + public static ApiResponse error(String message) { + ApiResponse response = new ApiResponse<>(); + response.success = false; + response.message = message; + response.timestamp = Instant.now(); + return response; + } + + public boolean isSuccess() { + return success; + } + + public void setSuccess(boolean success) { + this.success = success; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public T getData() { + return data; + } + + public void setData(T data) { + this.data = data; + } + + public Instant getTimestamp() { + return timestamp; + } + + public void setTimestamp(Instant timestamp) { + this.timestamp = timestamp; + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/common/HealthResponse.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/common/HealthResponse.java new file mode 100644 index 0000000..417ed83 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/common/HealthResponse.java @@ -0,0 +1,39 @@ +package com.edgareldy.springjdbctutorial.ws.payload.common; + +/** + * HTTP shape of the health check. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class HealthResponse { + + private String status; + private String database; + + public HealthResponse() { + } + + public HealthResponse(String status, String database) { + this.status = status; + this.database = database; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getDatabase() { + return database; + } + + public void setDatabase(String database) { + this.database = database; + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/common/PageResponse.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/common/PageResponse.java new file mode 100644 index 0000000..194ac69 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/payload/common/PageResponse.java @@ -0,0 +1,56 @@ +package com.edgareldy.springjdbctutorial.ws.payload.common; + +import java.util.List; + +/** + * Paged list payload. size must be at least 1 so totalPages is never a fake value. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public class PageResponse { + + private List content; + private int page; + private int size; + private long totalElements; + private int totalPages; + + public PageResponse() { + } + + public static PageResponse of(List content, int page, int size, long totalElements) { + if (size < 1) { + throw new IllegalArgumentException("size must be at least 1"); + } + PageResponse response = new PageResponse<>(); + response.content = content; + response.page = page; + response.size = size; + response.totalElements = totalElements; + response.totalPages = (int) ((totalElements + size - 1) / size); + return response; + } + + public List getContent() { + return content; + } + + public int getPage() { + return page; + } + + public int getSize() { + return size; + } + + public long getTotalElements() { + return totalElements; + } + + public int getTotalPages() { + return totalPages; + } +} From bb048a77eb56f90bf85a6de2eb47466e00457e1c Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:41:45 +0400 Subject: [PATCH 11/30] feat(ws): map every exception to an ApiResponse in GlobalExceptionHandler @RestControllerAdvice makes these @ExceptionHandler methods apply to every controller, so exceptions become HTTP in exactly one place. Domain exceptions give 404 and 422, Bean Validation failures give 400 with one "field: message" entry per error, malformed JSON, bad parameters, unknown URLs, wrong methods and media types get their own statuses. The catch-all returns a generic 500 and only logs the real error, so no internal detail leaks. A marked spot is left for the 401 and 403 handlers that the auth branch adds with Spring Security. --- .../ws/exception/GlobalExceptionHandler.java | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/exception/GlobalExceptionHandler.java diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/exception/GlobalExceptionHandler.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..4b4b21f --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/exception/GlobalExceptionHandler.java @@ -0,0 +1,143 @@ +package com.edgareldy.springjdbctutorial.ws.exception; + +import com.edgareldy.springjdbctutorial.core.common.exception.BusinessRuleException; +import com.edgareldy.springjdbctutorial.core.common.exception.ResourceNotFoundException; +import com.edgareldy.springjdbctutorial.ws.payload.common.ApiResponse; +import jakarta.validation.ConstraintViolationException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.FieldError; +import org.springframework.web.ErrorResponse; +import org.springframework.web.HttpMediaTypeNotAcceptableException; +import org.springframework.web.HttpMediaTypeNotSupportedException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.HandlerMethodValidationException; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.servlet.NoHandlerFoundException; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +import java.util.stream.Collectors; + +/** + * Turns every exception into the ApiResponse error format with the right HTTP status. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// @RestControllerAdvice makes these @ExceptionHandler methods apply to every controller and writes +// their return value as the response body. It is the one place where exceptions become HTTP, so +// controllers and services never build error responses. It is one of the two stereotypes allowed in ws. +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger LOG = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + private static ResponseEntity> build(HttpStatusCode status, String message) { + return ResponseEntity.status(status).body(ApiResponse.error(message)); + } + + @ExceptionHandler(ResourceNotFoundException.class) + public ResponseEntity> handleNotFound(ResourceNotFoundException e) { + return build(HttpStatus.NOT_FOUND, e.getMessage()); + } + + @ExceptionHandler(BusinessRuleException.class) + public ResponseEntity> handleBusinessRule(BusinessRuleException e) { + return build(HttpStatus.UNPROCESSABLE_ENTITY, e.getMessage()); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleInvalidBody(MethodArgumentNotValidException e) { + String message = e.getBindingResult().getAllErrors().stream() + .map(error -> error instanceof FieldError fieldError + ? fieldError.getField() + ": " + fieldError.getDefaultMessage() + : error.getObjectName() + ": " + error.getDefaultMessage()) + .sorted() + .collect(Collectors.joining("; ")); + return build(HttpStatus.BAD_REQUEST, message); + } + + @ExceptionHandler(HandlerMethodValidationException.class) + public ResponseEntity> handleMethodValidation(HandlerMethodValidationException e) { + String message = e.getAllErrors().stream() + .map(error -> error.getDefaultMessage()) + .sorted() + .collect(Collectors.joining("; ")); + return build(HttpStatus.BAD_REQUEST, message); + } + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity> handleConstraintViolation(ConstraintViolationException e) { + String message = e.getConstraintViolations().stream() + .map(v -> v.getPropertyPath() + ": " + v.getMessage()) + .sorted() + .collect(Collectors.joining("; ")); + return build(HttpStatus.BAD_REQUEST, message); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity> handleUnreadable(HttpMessageNotReadableException e) { + return build(HttpStatus.BAD_REQUEST, "Malformed JSON request"); + } + + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity> handleMissingParameter(MissingServletRequestParameterException e) { + return build(HttpStatus.BAD_REQUEST, "Missing parameter: " + e.getParameterName()); + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity> handleTypeMismatch(MethodArgumentTypeMismatchException e) { + return build(HttpStatus.BAD_REQUEST, "Invalid value for parameter: " + e.getName()); + } + + @ExceptionHandler({NoHandlerFoundException.class, NoResourceFoundException.class}) + public ResponseEntity> handleNoResource(Exception e) { + return build(HttpStatus.NOT_FOUND, "Resource not found"); + } + + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity> handleMethodNotSupported(HttpRequestMethodNotSupportedException e) { + ResponseEntity.BodyBuilder builder = ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED); + HttpHeaders headers = e.getHeaders(); + if (!headers.isEmpty()) { + builder.headers(headers); + } + return builder.body(ApiResponse.error("Method not allowed")); + } + + @ExceptionHandler(HttpMediaTypeNotSupportedException.class) + public ResponseEntity> handleMediaTypeNotSupported(HttpMediaTypeNotSupportedException e) { + return build(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "Unsupported media type"); + } + + @ExceptionHandler(HttpMediaTypeNotAcceptableException.class) + public ResponseEntity> handleNotAcceptable(HttpMediaTypeNotAcceptableException e) { + return build(HttpStatus.NOT_ACCEPTABLE, "Not acceptable"); + } + + // AUTH BRANCH: add handlers here for AccessDeniedException (403) and AuthenticationException (401). + // They are intentionally absent now: no Spring Security dependency exists yet. + + // Catch-all. Other Spring MVC exceptions implementing ErrorResponse keep the status they carry; + // anything else is a 500 with a generic message, the real error is only logged. + @ExceptionHandler(Exception.class) + public ResponseEntity> handleUnexpected(Exception e) { + if (e instanceof ErrorResponse errorResponse) { + return build(errorResponse.getStatusCode(), errorResponse.getBody().getDetail() != null + ? errorResponse.getBody().getDetail() : "Request failed"); + } + LOG.error("Unexpected error", e); + return build(HttpStatus.INTERNAL_SERVER_ERROR, "Internal server error"); + } +} From 8739721d39177ca977bc3925bf0a2d6a1c337e44 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:41:45 +0400 Subject: [PATCH 12/30] feat(ws): add the public health route HealthController exposes GET /api/v1/health. It sees only the HealthService interface and a payload type: a converter maps the core HealthDto to the HealthResponse, keeping core dto and web payload distinct. The route lets a packaged WAR prove that it starts, reaches PostgreSQL and answers in the ApiResponse envelope. --- .../ws/controller/HealthController.java | 33 +++++++++++++++++++ .../ws/converter/HealthConverter.java | 22 +++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/HealthController.java create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/HealthConverter.java diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/HealthController.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/HealthController.java new file mode 100644 index 0000000..be17307 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/controller/HealthController.java @@ -0,0 +1,33 @@ +package com.edgareldy.springjdbctutorial.ws.controller; + +import com.edgareldy.springjdbctutorial.core.common.service.HealthService; +import com.edgareldy.springjdbctutorial.ws.converter.HealthConverter; +import com.edgareldy.springjdbctutorial.ws.payload.common.ApiResponse; +import com.edgareldy.springjdbctutorial.ws.payload.common.HealthResponse; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Public health route, used to check that a deployed WAR is alive and reaches its database. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +@RestController +@RequestMapping("/api/v1/health") +public class HealthController { + + private final HealthService healthService; + + public HealthController(HealthService healthService) { + this.healthService = healthService; + } + + @GetMapping + public ApiResponse health() { + return ApiResponse.success(HealthConverter.toResponse(healthService.check()), "Health check"); + } +} diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/HealthConverter.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/HealthConverter.java new file mode 100644 index 0000000..3764633 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/converter/HealthConverter.java @@ -0,0 +1,22 @@ +package com.edgareldy.springjdbctutorial.ws.converter; + +import com.edgareldy.springjdbctutorial.core.common.dto.HealthDto; +import com.edgareldy.springjdbctutorial.ws.payload.common.HealthResponse; + +/** + * Converts the core HealthDto to the HTTP HealthResponse payload. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +public final class HealthConverter { + + private HealthConverter() { + } + + public static HealthResponse toResponse(HealthDto dto) { + return new HealthResponse(dto.getStatus(), dto.getDatabase()); + } +} From 9aace489dce44a046d7ff171b7e749f987adf440 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:41:45 +0400 Subject: [PATCH 13/30] feat(ws): configure Spring MVC explicitly with WebMvcConfig @EnableWebMvc turns on Spring MVC in a plain Spring application, without Boot, and WebMvcConfigurer customises it: one Jackson converter that writes Instants as ISO-8601 text and a Hibernate Validator backed validator. The class imports the core configuration, and every later branch adds its module config to the same import. The component scan is limited to the controller and exception packages: those are the only stereotypes of the project, DAOs and services are never scanned. --- .../ws/config/WebMvcConfig.java | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java new file mode 100644 index 0000000..e1723cc --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java @@ -0,0 +1,61 @@ +package com.edgareldy.springjdbctutorial.ws.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.edgareldy.springjdbctutorial.core.common.config.CommonConfig; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.validation.Validator; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +/** + * Spring MVC configuration and single entry point of the wiring (imports the core module configs). + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// @EnableWebMvc activates Spring MVC (handler mappings, argument resolvers, message converters) in +// plain Spring, without Boot. The WebMvcConfigurer callbacks below customise it. +// +// Core module configs are imported here in the SAME context (one shared context). CommonConfig comes +// first; each later branch adds its module's DaoConfig and ServiceConfig to the @Import list. +// +// The component scan is limited to the web layer (controllers and the advice): those are the only +// stereotypes of the project. DAO and service implementations are never scanned, they are explicit +// @Bean methods, so nothing gets wired by accident. +@Configuration +@EnableWebMvc +@Import(CommonConfig.class) +@ComponentScan({"com.edgareldy.springjdbctutorial.ws.controller", "com.edgareldy.springjdbctutorial.ws.exception"}) +public class WebMvcConfig implements WebMvcConfigurer { + + // Replaces the default converters with a single Jackson one. The builder registers the JSR-310 + // module (jackson-datatype-jsr310) found on the classpath; disabling WRITE_DATES_AS_TIMESTAMPS + // makes Instant serialise as ISO-8601 text instead of a number. Null fields stay in the output. + @Override + public void configureMessageConverters(List> converters) { + ObjectMapper mapper = Jackson2ObjectMapperBuilder.json() + .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .build(); + converters.add(new MappingJackson2HttpMessageConverter(mapper)); + } + + // LocalValidatorFactoryBean bootstraps Bean Validation (Hibernate Validator on the classpath). + // Returning it from getValidator() makes @Valid on request bodies use it. + @Override + public Validator getValidator() { + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + return validator; + } +} From a7c5f6bcc4f6aa9844703e14b4e7b2448e68abc2 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:41:45 +0400 Subject: [PATCH 14/30] feat(ws): replace web.xml with a WebApplicationInitializer Tomcat discovers WebApplicationInitializer implementations through Spring's servlet container initializer, so servlets and filters are registered in Java. The initializer builds one annotation-based context, exposes it as the root context through ContextLoaderListener and hands the same context to the DispatcherServlet mapped on "/". Sharing one context keeps every bean defined once and lets the security filter added later, which lives outside the DispatcherServlet, find it in the root context. A UTF-8 character encoding filter is registered as well. --- .../ws/config/WebAppInitializer.java | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebAppInitializer.java diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebAppInitializer.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebAppInitializer.java new file mode 100644 index 0000000..9bec963 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebAppInitializer.java @@ -0,0 +1,49 @@ +package com.edgareldy.springjdbctutorial.ws.config; + +import jakarta.servlet.FilterRegistration; +import jakarta.servlet.ServletContext; +import jakarta.servlet.ServletRegistration; +import org.springframework.web.WebApplicationInitializer; +import org.springframework.web.context.ContextLoaderListener; +import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; +import org.springframework.web.filter.CharacterEncodingFilter; +import org.springframework.web.servlet.DispatcherServlet; + +import java.util.EnumSet; + +/** + * Programmatic replacement of web.xml: builds the Spring context and registers servlet and filters. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// Tomcat finds every WebApplicationInitializer through Spring's SpringServletContainerInitializer +// (Servlet 3+ mechanism) and calls onStartup, so no web.xml is needed. +// +// ONE AnnotationConfigWebApplicationContext is shared as the root context (ContextLoaderListener) AND +// as the DispatcherServlet's context. Everything (DAOs, services, controllers, security) then lives in +// one place, so a bean is defined once and the security filter, which is created outside the +// DispatcherServlet, can find it through the root context. +public class WebAppInitializer implements WebApplicationInitializer { + + @Override + public void onStartup(ServletContext servletContext) { + AnnotationConfigWebApplicationContext context = new AnnotationConfigWebApplicationContext(); + context.register(WebMvcConfig.class); + + servletContext.addListener(new ContextLoaderListener(context)); + + ServletRegistration.Dynamic dispatcher = + servletContext.addServlet("dispatcher", new DispatcherServlet(context)); + dispatcher.setLoadOnStartup(1); + dispatcher.setAsyncSupported(true); + dispatcher.addMapping("/"); + + FilterRegistration.Dynamic encoding = + servletContext.addFilter("characterEncodingFilter", new CharacterEncodingFilter("UTF-8", true)); + encoding.setAsyncSupported(true); + encoding.addMappingForUrlPatterns(EnumSet.of(jakarta.servlet.DispatcherType.REQUEST), false, "/*"); + } +} From b7526cc385cb89c9d352a64b1d0046f1e758bb8b Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:41:45 +0400 Subject: [PATCH 15/30] test(common): verify the Flyway schema against a real PostgreSQL A singleton Testcontainers PostgreSQL is started once per JVM and its connection settings are injected into the Spring test context with @DynamicPropertySource. The holder is published in the common test-jar so other modules reuse it. FlywayMigrationTest checks that all thirteen tables exist after startup, that the unique email index is case-insensitive and that the restrictive foreign key blocks deleting a referenced category. --- .../common/config/FlywayMigrationTest.java | 74 +++++++++++++++++++ .../common/support/PostgresTestContainer.java | 50 +++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/config/FlywayMigrationTest.java create mode 100644 core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/support/PostgresTestContainer.java diff --git a/core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/config/FlywayMigrationTest.java b/core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/config/FlywayMigrationTest.java new file mode 100644 index 0000000..c5111e9 --- /dev/null +++ b/core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/config/FlywayMigrationTest.java @@ -0,0 +1,74 @@ +package com.edgareldy.springjdbctutorial.core.common.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +/** + * Checks that Flyway builds the whole V1 schema and that its key constraints hold on a real PostgreSQL. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// @SpringJUnitConfig starts a real Spring context from DataSourceConfig (Flyway migrates at startup) +// and injects beans into the test. @DynamicPropertySource feeds the datasource settings into the +// context Environment once the Testcontainers database is running. +@SpringJUnitConfig(DataSourceConfig.class) +class FlywayMigrationTest { + + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + PostgresTestContainer.register(registry); + } + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + void _01_ShouldCreateEveryTableOfTheDataModel_WhenApplicationStarts() { + List tables = jdbcTemplate.queryForList( + "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'", String.class); + + assertThat(tables).contains("users", "roles", "permissions", "role_user", "role_permission", + "activation_tokens", "blacklisted_tokens", "password_reset_tokens", "audit_logs", + "categories", "products", "customers", "orders"); + } + + @Test + void _02_ShouldRejectDuplicateEmail_WhenOnlyTheCaseDiffers() { + String insert = "INSERT INTO users (first_name, last_name, email, password) VALUES ('A', 'B', ?, 'x')"; + jdbcTemplate.update(insert, "Case.Test@Example.com"); + try { + assertThatThrownBy(() -> jdbcTemplate.update(insert, "case.test@example.COM")) + .isInstanceOf(DataIntegrityViolationException.class); + } finally { + jdbcTemplate.update("DELETE FROM users WHERE LOWER(email) = 'case.test@example.com'"); + } + } + + @Test + void _03_ShouldRefuseCategoryDeletion_WhenAProductStillReferencesIt() { + Long categoryId = jdbcTemplate.queryForObject( + "INSERT INTO categories (category_name) VALUES ('fk-restrict-test') RETURNING id", Long.class); + jdbcTemplate.update("INSERT INTO products (category_id, product_name, unit_price) VALUES (?, 'p', 1.00)", + categoryId); + try { + assertThatThrownBy(() -> jdbcTemplate.update("DELETE FROM categories WHERE id = ?", categoryId)) + .isInstanceOf(DataIntegrityViolationException.class); + } finally { + jdbcTemplate.update("DELETE FROM products WHERE category_id = ?", categoryId); + jdbcTemplate.update("DELETE FROM categories WHERE id = ?", categoryId); + } + } +} diff --git a/core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/support/PostgresTestContainer.java b/core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/support/PostgresTestContainer.java new file mode 100644 index 0000000..c150a19 --- /dev/null +++ b/core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/support/PostgresTestContainer.java @@ -0,0 +1,50 @@ +package com.edgareldy.springjdbctutorial.core.common.support; + +import org.springframework.test.context.DynamicPropertyRegistry; +import org.testcontainers.containers.PostgreSQLContainer; + +/** + * Singleton PostgreSQL 16 Testcontainer shared by every DB test of the JVM (published in the common test-jar). + * The container is started once, on first use, and killed by Testcontainers' Ryuk when the JVM exits. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// Testcontainers starts a throwaway real PostgreSQL in Docker, so DAO tests run real SQL against the +// real Flyway schema instead of an in-memory fake. The container lives in a static holder class +// (initialised lazily, once, by the JVM class loader) so all test classes share one database instead +// of paying the startup cost per class. Tests therefore must isolate the global state they touch. +public final class PostgresTestContainer { + + private PostgresTestContainer() { + } + + /** Lazy holder: the container is created and started the first time this class is loaded. */ + private static final class Holder { + static final PostgreSQLContainer CONTAINER = new PostgreSQLContainer<>("postgres:16-alpine"); + + static { + CONTAINER.start(); + } + } + + /** + * Returns the running container, starting it on first call. + */ + public static PostgreSQLContainer get() { + return Holder.CONTAINER; + } + + /** + * Registers app.datasource.url/username/password. Call it from a {@code @DynamicPropertySource} + * method: Spring evaluates the suppliers lazily, after the container is up, and adds them to the + * test context Environment (the container port is random, so it cannot be written statically). + */ + public static void register(DynamicPropertyRegistry registry) { + registry.add("app.datasource.url", () -> get().getJdbcUrl()); + registry.add("app.datasource.username", () -> get().getUsername()); + registry.add("app.datasource.password", () -> get().getPassword()); + } +} From a360c64ea29b95b23e6f5628096605831c6b3c96 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:41:45 +0400 Subject: [PATCH 16/30] test(common): check that DataSourceConfig fails without its settings The context is built from an empty MockEnvironment, with no database, and must fail with a clear message when the url or the password is missing. The test also asserts that no default password leaks into the message. It documents the rule that a wrong deployment fails at startup instead of silently using a hidden default. --- .../common/config/DataSourceConfigTest.java | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/config/DataSourceConfigTest.java diff --git a/core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/config/DataSourceConfigTest.java b/core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/config/DataSourceConfigTest.java new file mode 100644 index 0000000..820245e --- /dev/null +++ b/core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/config/DataSourceConfigTest.java @@ -0,0 +1,67 @@ +package com.edgareldy.springjdbctutorial.core.common.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.BeanCreationException; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.mock.env.MockEnvironment; + +/** + * Checks that startup fails with a clear message when a required datasource setting is missing. + * No database is needed: the failure happens while the DataSource bean is being created. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +class DataSourceConfigTest { + + // MockEnvironment (from spring-test) is an Environment holding only the properties we give it, + // so the test does not depend on the developer's real environment variables. + private static void startWith(MockEnvironment environment) { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + context.setEnvironment(environment); + context.register(DataSourceConfig.class); + try { + context.refresh(); + } finally { + context.close(); + } + } + + @Test + void _01_ShouldFailStartup_WhenDatasourceUrlIsMissing() { + MockEnvironment environment = new MockEnvironment(); + + assertThatThrownBy(() -> startWith(environment)) + .isInstanceOf(BeanCreationException.class) + .rootCause() + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("app.datasource.url"); + } + + @Test + void _02_ShouldFailStartup_WhenDatasourcePasswordIsMissing() { + MockEnvironment environment = new MockEnvironment() + .withProperty("app.datasource.url", "jdbc:postgresql://localhost:1/x") + .withProperty("app.datasource.username", "u"); + + assertThatThrownBy(() -> startWith(environment)) + .isInstanceOf(BeanCreationException.class) + .rootCause() + .hasMessageContaining("app.datasource.password"); + } + + @Test + void _03_ShouldNotMentionAnyDefaultPassword_WhenStartupFails() { + MockEnvironment environment = new MockEnvironment(); + + Throwable failure = org.assertj.core.api.Assertions.catchThrowable(() -> startWith(environment)); + + assertThat(failure).isNotNull(); + assertThat(failure.getMessage()).doesNotContain("password="); + } +} From fee88859741fa69fc75e1e7641fbba0df881a49a Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:41:45 +0400 Subject: [PATCH 17/30] test(common): cover AbstractDao and HealthDao against PostgreSQL Each DAO test class loads its own dedicated SQL fixture through @Sql before every test: abstract-dao-dataset.sql for the AbstractDao helpers (findOne present and empty, increasing generated ids, count) and health-dao-dataset.sql for the health DAO. No dataset is shared between classes. The fixture of the AbstractDao test restarts the sequence explicitly, because rows inserted with explicit ids do not advance it. --- .../common/dao/impl/HealthDaoImplTest.java | 39 +++++++++ .../common/dao/support/AbstractDaoTest.java | 85 +++++++++++++++++++ .../test/resources/abstract-dao-dataset.sql | 6 ++ .../src/test/resources/health-dao-dataset.sql | 2 + 4 files changed, 132 insertions(+) create mode 100644 core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/dao/impl/HealthDaoImplTest.java create mode 100644 core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/dao/support/AbstractDaoTest.java create mode 100644 core/common/src/test/resources/abstract-dao-dataset.sql create mode 100644 core/common/src/test/resources/health-dao-dataset.sql diff --git a/core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/dao/impl/HealthDaoImplTest.java b/core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/dao/impl/HealthDaoImplTest.java new file mode 100644 index 0000000..40696cc --- /dev/null +++ b/core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/dao/impl/HealthDaoImplTest.java @@ -0,0 +1,39 @@ +package com.edgareldy.springjdbctutorial.core.common.dao.impl; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.edgareldy.springjdbctutorial.core.common.config.DataSourceConfig; +import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +/** + * Tests HealthDaoImpl against the real PostgreSQL, with its own fixture file health-dao-dataset.sql. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +@SpringJUnitConfig(DataSourceConfig.class) +@Sql("/health-dao-dataset.sql") +class HealthDaoImplTest { + + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + PostgresTestContainer.register(registry); + } + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + void _01_ShouldReturnTrue_WhenDatabaseAnswers() { + assertThat(new HealthDaoImpl(jdbcTemplate).isDatabaseReachable()).isTrue(); + } +} diff --git a/core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/dao/support/AbstractDaoTest.java b/core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/dao/support/AbstractDaoTest.java new file mode 100644 index 0000000..c75dd7f --- /dev/null +++ b/core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/dao/support/AbstractDaoTest.java @@ -0,0 +1,85 @@ +package com.edgareldy.springjdbctutorial.core.common.dao.support; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.edgareldy.springjdbctutorial.core.common.config.DataSourceConfig; +import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +/** + * Tests the shared query helpers of AbstractDao (findOne, insertReturningId, count) on the categories + * table, with its own fixture file abstract-dao-dataset.sql. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// @Sql runs the fixture script before EACH test method, so every test starts from the same known rows. +// The fixture is dedicated to this class and never reused by another DAO test class. +@SpringJUnitConfig(DataSourceConfig.class) +@Sql("/abstract-dao-dataset.sql") +class AbstractDaoTest { + + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + PostgresTestContainer.register(registry); + } + + @Autowired + private JdbcTemplate jdbcTemplate; + + /** Minimal concrete DAO exposing the protected helpers under test. */ + private static final class CategoryProbeDao extends AbstractDao { + + CategoryProbeDao(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + Optional findName(long id) { + return findOne("SELECT category_name FROM categories WHERE id = ?", + (rs, rowNum) -> rs.getString("category_name"), id); + } + + long insert(String name) { + return insertReturningId("INSERT INTO categories (category_name) VALUES (?) RETURNING id", name); + } + + long total() { + return count("SELECT COUNT(*) FROM categories"); + } + } + + @Test + void _01_ShouldReturnRow_WhenFindOneMatchesARow() { + assertThat(new CategoryProbeDao(jdbcTemplate).findName(1L)).contains("Books"); + } + + @Test + void _02_ShouldReturnEmpty_WhenFindOneMatchesNoRow() { + assertThat(new CategoryProbeDao(jdbcTemplate).findName(999L)).isEmpty(); + } + + @Test + void _03_ShouldReturnIncreasingIds_WhenInsertingTwice() { + CategoryProbeDao dao = new CategoryProbeDao(jdbcTemplate); + + long first = dao.insert("Music"); + long second = dao.insert("Games"); + + assertThat(first).isGreaterThanOrEqualTo(100L); + assertThat(second).isGreaterThan(first); + } + + @Test + void _04_ShouldCountFixtureRows_WhenCounting() { + assertThat(new CategoryProbeDao(jdbcTemplate).total()).isEqualTo(2L); + } +} diff --git a/core/common/src/test/resources/abstract-dao-dataset.sql b/core/common/src/test/resources/abstract-dao-dataset.sql new file mode 100644 index 0000000..3283e93 --- /dev/null +++ b/core/common/src/test/resources/abstract-dao-dataset.sql @@ -0,0 +1,6 @@ +-- Fixture of AbstractDaoTest only: two categories on a clean table. +DELETE FROM products; +DELETE FROM categories; +INSERT INTO categories (id, category_name) VALUES (1, 'Books'), (2, 'Tools'); +-- Explicit ids do not advance the sequence: restart it high so generated ids never collide +ALTER SEQUENCE categories_id_seq RESTART WITH 100; diff --git a/core/common/src/test/resources/health-dao-dataset.sql b/core/common/src/test/resources/health-dao-dataset.sql new file mode 100644 index 0000000..6dfb911 --- /dev/null +++ b/core/common/src/test/resources/health-dao-dataset.sql @@ -0,0 +1,2 @@ +-- Fixture of HealthDaoImplTest only: the health check reads no table, nothing to load. +SELECT 1; From ce291f60802e736077236fd14e51170c05b08e42 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:41:45 +0400 Subject: [PATCH 18/30] test(common): unit test HealthServiceImpl with a mocked DAO Mockito replaces the DAO, so no database is involved: the service must report UP when the DAO answers true, and DOWN both when it answers false and when it throws a DataAccessException. This is the service layer of the strategy: business decisions verified in isolation, with assertions on the dto that leaves the module. --- .../service/impl/HealthServiceImplTest.java | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/service/impl/HealthServiceImplTest.java diff --git a/core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/service/impl/HealthServiceImplTest.java b/core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/service/impl/HealthServiceImplTest.java new file mode 100644 index 0000000..0b84250 --- /dev/null +++ b/core/common/src/test/java/com/edgareldy/springjdbctutorial/core/common/service/impl/HealthServiceImplTest.java @@ -0,0 +1,54 @@ +package com.edgareldy.springjdbctutorial.core.common.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import com.edgareldy.springjdbctutorial.core.common.dao.HealthDao; +import com.edgareldy.springjdbctutorial.core.common.dto.HealthDto; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataAccessResourceFailureException; + +/** + * Unit tests of HealthServiceImpl with the DAO mocked (no database). + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// MockitoExtension creates the @Mock objects and injects them into the @InjectMocks service, so the +// service is tested alone: the DAO answers are scripted with when(...).thenReturn/thenThrow. +@ExtendWith(MockitoExtension.class) +class HealthServiceImplTest { + + @Mock + private HealthDao healthDao; + + @InjectMocks + private HealthServiceImpl service; + + @Test + void _01_ShouldReturnUp_WhenDatabaseIsReachable() { + when(healthDao.isDatabaseReachable()).thenReturn(true); + + assertThat(service.check()).isEqualTo(new HealthDto("UP", "UP")); + } + + @Test + void _02_ShouldReturnDown_WhenDaoThrowsDataAccessException() { + when(healthDao.isDatabaseReachable()).thenThrow(new DataAccessResourceFailureException("db down")); + + assertThat(service.check()).isEqualTo(new HealthDto("DOWN", "DOWN")); + } + + @Test + void _03_ShouldReturnDown_WhenDatabaseIsNotReachable() { + when(healthDao.isDatabaseReachable()).thenReturn(false); + + assertThat(service.check()).isEqualTo(new HealthDto("DOWN", "DOWN")); + } +} From 6a855531b8485fa094d13550cfe64ddd8ac9763a Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:41:45 +0400 Subject: [PATCH 19/30] test(ws): cover ApiResponse and PageResponse The tests check the success and error factories, the ceiling computation of totalPages, the refusal of a size of zero or below, and that a page beyond the last one keeps an empty content list. They are plain unit tests without Spring: payload classes hold no logic beyond construction. --- .../ws/payload/common/ApiResponseTest.java | 54 +++++++++++++++++ .../ws/payload/common/PageResponseTest.java | 59 +++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/payload/common/ApiResponseTest.java create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/payload/common/PageResponseTest.java diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/payload/common/ApiResponseTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/payload/common/ApiResponseTest.java new file mode 100644 index 0000000..b146d89 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/payload/common/ApiResponseTest.java @@ -0,0 +1,54 @@ +package com.edgareldy.springjdbctutorial.ws.payload.common; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.Test; + +/** + * Unit tests of the ApiResponse envelope (success and error shapes). + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +class ApiResponseTest { + + private final ObjectMapper mapper = new ObjectMapper() + .registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + @Test + void _01_ShouldCarryDataAndMessage_WhenBuildingASuccess() { + ApiResponse response = ApiResponse.success("payload", "done"); + + assertThat(response.isSuccess()).isTrue(); + assertThat(response.getData()).isEqualTo("payload"); + assertThat(response.getMessage()).isEqualTo("done"); + assertThat(response.getTimestamp()).isNotNull(); + } + + @Test + void _02_ShouldHaveNoData_WhenBuildingAnError() { + ApiResponse response = ApiResponse.error("boom"); + + assertThat(response.isSuccess()).isFalse(); + assertThat(response.getMessage()).isEqualTo("boom"); + assertThat(response.getData()).isNull(); + assertThat(response.getTimestamp()).isNotNull(); + } + + @Test + void _03_ShouldSerializeTheFourFields_WhenWritingAnErrorAsJson() throws Exception { + JsonNode json = mapper.readTree(mapper.writeValueAsString(ApiResponse.error("boom"))); + + assertThat(json.get("success").asBoolean()).isFalse(); + assertThat(json.get("message").asText()).isEqualTo("boom"); + assertThat(json.get("data").isNull()).isTrue(); + assertThat(json.get("timestamp").asText()).isNotBlank(); + } +} diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/payload/common/PageResponseTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/payload/common/PageResponseTest.java new file mode 100644 index 0000000..49ad6a6 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/payload/common/PageResponseTest.java @@ -0,0 +1,59 @@ +package com.edgareldy.springjdbctutorial.ws.payload.common; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * Unit tests of PageResponse (total pages arithmetic and size validation). + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +class PageResponseTest { + + @Test + void _01_ShouldRoundTotalPagesUp_WhenLastPageIsPartial() { + PageResponse page = PageResponse.of(List.of("a", "b"), 0, 2, 5); + + assertThat(page.getTotalPages()).isEqualTo(3); + assertThat(page.getTotalElements()).isEqualTo(5); + assertThat(page.getPage()).isZero(); + assertThat(page.getSize()).isEqualTo(2); + } + + @Test + void _02_ShouldHaveZeroTotalPages_WhenThereAreNoElements() { + assertThat(PageResponse.of(List.of(), 0, 10, 0).getTotalPages()).isZero(); + } + + @Test + void _03_ShouldHaveExactTotalPages_WhenTotalIsAMultipleOfSize() { + assertThat(PageResponse.of(List.of("a"), 0, 5, 10).getTotalPages()).isEqualTo(2); + } + + @Test + void _04_ShouldThrow_WhenSizeIsZero() { + assertThatThrownBy(() -> PageResponse.of(List.of(), 0, 0, 10)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void _05_ShouldThrow_WhenSizeIsNegative() { + assertThatThrownBy(() -> PageResponse.of(List.of(), 0, -3, 10)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void _06_ShouldKeepEmptyContent_WhenPageIsOutOfRange() { + PageResponse page = PageResponse.of(List.of(), 99, 10, 5); + + assertThat(page.getContent()).isNotNull().isEmpty(); + assertThat(page.getPage()).isEqualTo(99); + assertThat(page.getTotalPages()).isEqualTo(1); + } +} From 177723ba54f3053d474b47c7b62cee712ab5eb7d Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:41:45 +0400 Subject: [PATCH 20/30] test(ws): verify HTTP error mapping and the health controller with MockMvc MockMvc drives a WebApplicationContext built from a small test config, a test-only controller that throws each case and the real GlobalExceptionHandler. Every error is asserted in the ApiResponse shape: 404, 422, 400 with per-field messages, malformed JSON, constrained request parameters, unknown URL, 405 with its Allow header, 415 and a 500 that does not leak the real message. The health controller is tested with the service mocked. These classes live in ws.mvc because the component scan of WebMvcConfig would otherwise pick up test controllers. --- .../ws/mvc/GlobalExceptionHandlerTest.java | 273 ++++++++++++++++++ .../ws/mvc/HealthControllerTest.java | 121 ++++++++ 2 files changed, 394 insertions(+) create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/GlobalExceptionHandlerTest.java create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/HealthControllerTest.java diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/GlobalExceptionHandlerTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/GlobalExceptionHandlerTest.java new file mode 100644 index 0000000..84a24e3 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/GlobalExceptionHandlerTest.java @@ -0,0 +1,273 @@ +package com.edgareldy.springjdbctutorial.ws.mvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; + +import com.edgareldy.springjdbctutorial.core.common.exception.BusinessRuleException; +import com.edgareldy.springjdbctutorial.core.common.exception.ResourceNotFoundException; +import com.edgareldy.springjdbctutorial.ws.config.WebMvcConfig; +import com.edgareldy.springjdbctutorial.ws.exception.GlobalExceptionHandler; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.validation.Validator; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * Verifies that GlobalExceptionHandler turns every exception into the ApiResponse error format with + * the right HTTP status, through MockMvc and a test-only controller that throws each case. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// These MVC tests live in ws.mvc, NOT in ws.controller/ws.exception: WebMvcConfig component-scans those +// two packages, and it would pick up the test-only configuration and controllers found on the test classpath. +// MockMvc drives the DispatcherServlet in memory (no HTTP port). @SpringJUnitWebConfig builds a +// WebApplicationContext from the nested TestConfig; webAppContextSetup then wires MockMvc on it, so +// the real handler mappings, argument resolvers, validator and @RestControllerAdvice all run. +@SpringJUnitWebConfig(GlobalExceptionHandlerTest.TestConfig.class) +class GlobalExceptionHandlerTest { + + /** + * Test context: MVC enabled, the real advice, the throwing controller, and the converters and + * validator of the real WebMvcConfig so JSON and validation behave as in production. + */ + @Configuration + @EnableWebMvc + static class TestConfig implements WebMvcConfigurer { + + private final WebMvcConfig real = new WebMvcConfig(); + + @Bean + GlobalExceptionHandler globalExceptionHandler() { + return new GlobalExceptionHandler(); + } + + @Bean + ThrowingController throwingController() { + return new ThrowingController(); + } + + @Override + public void configureMessageConverters(List> converters) { + real.configureMessageConverters(converters); + } + + @Override + public Validator getValidator() { + return real.getValidator(); + } + } + + /** + * Test-only controller whose endpoints throw or validate to trigger each handler. + */ + @RestController + @RequestMapping("/test") + static class ThrowingController { + + @GetMapping("/not-found") + String notFound() { + throw new ResourceNotFoundException("Category 5 not found"); + } + + @GetMapping("/business") + String business() { + throw new BusinessRuleException("Category is still referenced"); + } + + @GetMapping("/boom") + String boom() { + throw new IllegalStateException("secret internal detail"); + } + + @PostMapping(value = "/body", consumes = MediaType.APPLICATION_JSON_VALUE) + String body(@Valid @RequestBody SampleBody body) { + return body.getName(); + } + + @GetMapping("/param") + String param(@RequestParam int count) { + return String.valueOf(count); + } + + // Constraint annotations directly on a parameter trigger Spring 6.1+ method validation + @GetMapping("/range") + String range(@RequestParam @Min(1) @Max(100) int size) { + return String.valueOf(size); + } + } + + /** + * Request body validated by the test endpoint. + */ + public static class SampleBody { + + @NotBlank + private String name; + + @Min(1) + private int age = 1; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public int getAge() { + return age; + } + + public void setAge(int age) { + this.age = age; + } + } + + private final ObjectMapper mapper = new ObjectMapper(); + + @Autowired + private WebApplicationContext context; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.webAppContextSetup(context).build(); + } + + /** Asserts the README error shape and returns the parsed body. */ + private JsonNode assertErrorShape(MvcResult result, int status) throws Exception { + assertThat(result.getResponse().getStatus()).isEqualTo(status); + JsonNode json = mapper.readTree(result.getResponse().getContentAsString()); + assertThat(json.get("success").asBoolean()).isFalse(); + assertThat(json.get("message").asText()).isNotBlank(); + assertThat(json.get("data") == null || json.get("data").isNull()).isTrue(); + assertThat(json.get("timestamp").asText()).isNotBlank(); + return json; + } + + @Test + void _01_ShouldReturn404_WhenResourceNotFoundExceptionIsThrown() throws Exception { + MvcResult result = mockMvc.perform(get("/test/not-found")).andReturn(); + + assertThat(assertErrorShape(result, 404).get("message").asText()).isEqualTo("Category 5 not found"); + } + + @Test + void _02_ShouldReturn422_WhenBusinessRuleExceptionIsThrown() throws Exception { + MvcResult result = mockMvc.perform(get("/test/business")).andReturn(); + + assertThat(assertErrorShape(result, 422).get("message").asText()).isEqualTo("Category is still referenced"); + } + + @Test + void _03_ShouldReturn400WithPerFieldMessages_WhenBodyValidationFails() throws Exception { + MvcResult result = mockMvc.perform(post("/test/body") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\" \",\"age\":0}")).andReturn(); + + String message = assertErrorShape(result, 400).get("message").asText(); + assertThat(message).contains("name: must not be blank").contains("age: must be greater than or equal to 1"); + } + + @Test + void _04_ShouldReturn400_WhenJsonIsMalformed() throws Exception { + MvcResult result = mockMvc.perform(post("/test/body") + .contentType(MediaType.APPLICATION_JSON) + .content("{not json")).andReturn(); + + assertThat(assertErrorShape(result, 400).get("message").asText()).isEqualTo("Malformed JSON request"); + } + + @Test + void _05_ShouldReturn400_WhenRequiredParameterIsMissing() throws Exception { + MvcResult result = mockMvc.perform(get("/test/param")).andReturn(); + + assertThat(assertErrorShape(result, 400).get("message").asText()).contains("count"); + } + + @Test + void _06_ShouldReturn400_WhenParameterHasTheWrongType() throws Exception { + MvcResult result = mockMvc.perform(get("/test/param").param("count", "abc")).andReturn(); + + assertThat(assertErrorShape(result, 400).get("message").asText()).contains("count"); + } + + @Test + void _07_ShouldReturn400_WhenParameterIsBelowMin() throws Exception { + MvcResult result = mockMvc.perform(get("/test/range").param("size", "0")).andReturn(); + + assertThat(assertErrorShape(result, 400).get("message").asText()) + .contains("must be greater than or equal to 1"); + } + + @Test + void _08_ShouldReturn400_WhenParameterIsAboveMax() throws Exception { + MvcResult result = mockMvc.perform(get("/test/range").param("size", "101")).andReturn(); + + assertThat(assertErrorShape(result, 400).get("message").asText()) + .contains("must be less than or equal to 100"); + } + + @Test + void _09_ShouldReturn404_WhenUrlIsUnknown() throws Exception { + MvcResult result = mockMvc.perform(get("/test/does-not-exist")).andReturn(); + + assertErrorShape(result, 404); + } + + @Test + void _10_ShouldReturn405WithAllowHeader_WhenMethodIsNotSupported() throws Exception { + MvcResult result = mockMvc.perform(post("/test/not-found")).andReturn(); + + assertErrorShape(result, 405); + assertThat(result.getResponse().getHeader("Allow")).contains("GET"); + } + + @Test + void _11_ShouldReturn415_WhenContentTypeIsNotSupported() throws Exception { + MvcResult result = mockMvc.perform(post("/test/body") + .contentType(MediaType.TEXT_PLAIN) + .content("name")).andReturn(); + + assertErrorShape(result, 415); + } + + @Test + void _12_ShouldReturn500WithGenericMessage_WhenUnexpectedExceptionIsThrown() throws Exception { + MvcResult result = mockMvc.perform(get("/test/boom")).andReturn(); + + JsonNode json = assertErrorShape(result, 500); + assertThat(json.get("message").asText()).isEqualTo("Internal server error"); + assertThat(result.getResponse().getContentAsString()).doesNotContain("secret internal detail"); + } +} diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/HealthControllerTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/HealthControllerTest.java new file mode 100644 index 0000000..290b4a3 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/HealthControllerTest.java @@ -0,0 +1,121 @@ +package com.edgareldy.springjdbctutorial.ws.mvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; + +import com.edgareldy.springjdbctutorial.core.common.dto.HealthDto; +import com.edgareldy.springjdbctutorial.core.common.service.HealthService; +import com.edgareldy.springjdbctutorial.ws.config.WebMvcConfig; +import com.edgareldy.springjdbctutorial.ws.controller.HealthController; +import com.edgareldy.springjdbctutorial.ws.exception.GlobalExceptionHandler; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * MockMvc tests of HealthController with the HealthService mocked (no database). + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// Kept out of ws.controller on purpose: the component scan of WebMvcConfig would pick up this test config. +@SpringJUnitWebConfig(HealthControllerTest.TestConfig.class) +class HealthControllerTest { + + /** + * Test context: MVC, the real controller and advice, and a Mockito mock in place of the service. + */ + @Configuration + @EnableWebMvc + static class TestConfig implements WebMvcConfigurer { + + @Bean + HealthService healthService() { + return mock(HealthService.class); + } + + @Bean + HealthController healthController(HealthService healthService) { + return new HealthController(healthService); + } + + @Bean + GlobalExceptionHandler globalExceptionHandler() { + return new GlobalExceptionHandler(); + } + + @Override + public void configureMessageConverters(List> converters) { + new WebMvcConfig().configureMessageConverters(converters); + } + } + + private final ObjectMapper mapper = new ObjectMapper(); + + @Autowired + private WebApplicationContext context; + + @Autowired + private HealthService healthService; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + reset(healthService); + mockMvc = MockMvcBuilders.webAppContextSetup(context).build(); + } + + @Test + void _01_ShouldReturn200WithStatusUp_WhenServiceReportsUp() throws Exception { + when(healthService.check()).thenReturn(new HealthDto("UP", "UP")); + + MvcResult result = mockMvc.perform(get("/api/v1/health")).andReturn(); + + assertThat(result.getResponse().getStatus()).isEqualTo(200); + JsonNode json = mapper.readTree(result.getResponse().getContentAsString()); + assertThat(json.get("success").asBoolean()).isTrue(); + assertThat(json.get("data").get("status").asText()).isEqualTo("UP"); + assertThat(json.get("data").get("database").asText()).isEqualTo("UP"); + assertThat(json.get("timestamp").asText()).isNotBlank(); + } + + @Test + void _02_ShouldExposeStatusDown_WhenServiceReportsDown() throws Exception { + when(healthService.check()).thenReturn(new HealthDto("DOWN", "DOWN")); + + MvcResult result = mockMvc.perform(get("/api/v1/health")).andReturn(); + + JsonNode json = mapper.readTree(result.getResponse().getContentAsString()); + assertThat(json.get("data").get("status").asText()).isEqualTo("DOWN"); + } + + @Test + void _03_ShouldReturn405WithErrorShape_WhenMethodIsPost() throws Exception { + MvcResult result = mockMvc.perform(post("/api/v1/health")).andReturn(); + + assertThat(result.getResponse().getStatus()).isEqualTo(405); + JsonNode json = mapper.readTree(result.getResponse().getContentAsString()); + assertThat(json.get("success").asBoolean()).isFalse(); + } +} From c3bf50a38d7e0b7dee766128143b51c8583952f7 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:41:45 +0400 Subject: [PATCH 21/30] test(ws): boot the real WebMvcConfig against PostgreSQL The integration test loads the real WebMvcConfig, hence CommonConfig, DataSourceConfig and Flyway, on a Testcontainers database and checks that the health route answers 200 with status UP and that an unknown URL gives a 404 in the ApiResponse envelope. WebAppInitializerTest uses a mocked ServletContext to verify the registrations: the dispatcher servlet on "/", the ContextLoaderListener and the UTF-8 filter. --- .../ws/config/WebAppInitializerTest.java | 81 +++++++++++++++++++ .../config/WebMvcConfigIntegrationTest.java | 73 +++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/config/WebAppInitializerTest.java create mode 100644 ws/src/test/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfigIntegrationTest.java diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/config/WebAppInitializerTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/config/WebAppInitializerTest.java new file mode 100644 index 0000000..fa1304d --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/config/WebAppInitializerTest.java @@ -0,0 +1,81 @@ +package com.edgareldy.springjdbctutorial.ws.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterRegistration; +import jakarta.servlet.Servlet; +import jakarta.servlet.ServletContext; +import jakarta.servlet.ServletRegistration; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.web.context.ContextLoaderListener; +import org.springframework.web.filter.CharacterEncodingFilter; +import org.springframework.web.servlet.DispatcherServlet; + +/** + * Checks what WebAppInitializer registers on the ServletContext. The context is never refreshed + * (the ServletContext is a Mockito mock), so no database is involved. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +class WebAppInitializerTest { + + @Test + void _01_ShouldRegisterDispatcherServletOnRoot_WhenApplicationStarts() { + ServletContext servletContext = mock(ServletContext.class); + ServletRegistration.Dynamic servletRegistration = mock(ServletRegistration.Dynamic.class); + FilterRegistration.Dynamic filterRegistration = mock(FilterRegistration.Dynamic.class); + when(servletContext.addServlet(eq("dispatcher"), any(Servlet.class))).thenReturn(servletRegistration); + when(servletContext.addFilter(eq("characterEncodingFilter"), any(Filter.class))) + .thenReturn(filterRegistration); + + new WebAppInitializer().onStartup(servletContext); + + ArgumentCaptor servlet = ArgumentCaptor.forClass(Servlet.class); + verify(servletContext).addServlet(eq("dispatcher"), servlet.capture()); + assertThat(servlet.getValue()).isInstanceOf(DispatcherServlet.class); + verify(servletRegistration).addMapping("/"); + verify(servletRegistration).setLoadOnStartup(1); + } + + @Test + void _02_ShouldRegisterContextLoaderListener_WhenApplicationStarts() { + ServletContext servletContext = mock(ServletContext.class); + when(servletContext.addServlet(eq("dispatcher"), any(Servlet.class))) + .thenReturn(mock(ServletRegistration.Dynamic.class)); + when(servletContext.addFilter(eq("characterEncodingFilter"), any(Filter.class))) + .thenReturn(mock(FilterRegistration.Dynamic.class)); + + new WebAppInitializer().onStartup(servletContext); + + ArgumentCaptor listener = ArgumentCaptor.forClass(ContextLoaderListener.class); + verify(servletContext).addListener(listener.capture()); + assertThat(listener.getValue()).isNotNull(); + } + + @Test + void _03_ShouldRegisterUtf8EncodingFilterOnEveryUrl_WhenApplicationStarts() { + ServletContext servletContext = mock(ServletContext.class); + FilterRegistration.Dynamic filterRegistration = mock(FilterRegistration.Dynamic.class); + when(servletContext.addServlet(eq("dispatcher"), any(Servlet.class))) + .thenReturn(mock(ServletRegistration.Dynamic.class)); + when(servletContext.addFilter(eq("characterEncodingFilter"), any(Filter.class))) + .thenReturn(filterRegistration); + + new WebAppInitializer().onStartup(servletContext); + + ArgumentCaptor filter = ArgumentCaptor.forClass(Filter.class); + verify(servletContext).addFilter(eq("characterEncodingFilter"), filter.capture()); + assertThat(filter.getValue()).isInstanceOf(CharacterEncodingFilter.class); + verify(filterRegistration).addMappingForUrlPatterns(any(), eq(false), eq("/*")); + } +} diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfigIntegrationTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfigIntegrationTest.java new file mode 100644 index 0000000..a9a4333 --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfigIntegrationTest.java @@ -0,0 +1,73 @@ +package com.edgareldy.springjdbctutorial.ws.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; + +import com.edgareldy.springjdbctutorial.core.common.support.PostgresTestContainer; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +/** + * Full-stack integration test: the REAL WebMvcConfig (CommonConfig, DataSourceConfig, Flyway, real + * service and DAO) against the Testcontainers PostgreSQL, driven through MockMvc. + *

+ * Created edgar.muhamyangabo on 9/19/26 + * Author : edgar.muhamyangabo + * Date : 9/19/26 + * Project : spring-jdbc-tutorial + */ +// The PostgreSQL container class comes from the common module test-jar. @DynamicPropertySource injects +// its random JDBC url into the Environment before the real configuration is loaded. +@SpringJUnitWebConfig(WebMvcConfig.class) +class WebMvcConfigIntegrationTest { + + @DynamicPropertySource + static void datasource(DynamicPropertyRegistry registry) { + PostgresTestContainer.register(registry); + } + + private final ObjectMapper mapper = new ObjectMapper(); + + @Autowired + private WebApplicationContext context; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.webAppContextSetup(context).build(); + } + + @Test + void _01_ShouldReturn200WithStatusUp_WhenHealthRouteIsCalledOnARealDatabase() throws Exception { + MvcResult result = mockMvc.perform(get("/api/v1/health")).andReturn(); + + assertThat(result.getResponse().getStatus()).isEqualTo(200); + JsonNode json = mapper.readTree(result.getResponse().getContentAsString()); + assertThat(json.get("success").asBoolean()).isTrue(); + assertThat(json.get("data").get("status").asText()).isEqualTo("UP"); + assertThat(json.get("timestamp").asText()).isNotBlank(); + } + + @Test + void _02_ShouldReturn404ApiResponse_WhenUrlIsUnknown() throws Exception { + MvcResult result = mockMvc.perform(get("/api/v1/nothing-here")).andReturn(); + + assertThat(result.getResponse().getStatus()).isEqualTo(404); + JsonNode json = mapper.readTree(result.getResponse().getContentAsString()); + assertThat(json.get("success").asBoolean()).isFalse(); + assertThat(json.get("message").asText()).isNotBlank(); + assertThat(json.get("data") == null || json.get("data").isNull()).isTrue(); + assertThat(json.get("timestamp").asText()).isNotBlank(); + } +} From b8a2051f0b74b4e7b9217ac5146d62833afdc2fe Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:42:00 +0400 Subject: [PATCH 22/30] build(docker): add a multi-stage Dockerfile for the WAR on Tomcat 10.1 The first stage builds the reactor with Maven, copying the poms first so the dependency download is cached until a pom changes. The second stage is a Tomcat 10.1 JRE 17 image that keeps only the WAR, deployed as spring-jdbc-tutorial.war so the context path matches the URLs of the README. The image runs as an unprivileged user and the build context excludes everything but the sources. Tests are skipped in the image build: the DAO tests need a Docker daemon and run in CI instead. --- .dockerignore | 8 ++++++++ Dockerfile | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d04e138 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +# Keep the build context small and free of local files (keys, env files, git data) +* +!pom.xml +!core/ +!ws/ +!mvnw +!.mvn/ +**/target/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8979d06 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +# Multi-stage image for the packaged WAR. +# Stage 1 compiles and packages with Maven, stage 2 is a Tomcat 10.1 runtime holding only the WAR. + +FROM maven:3.9-eclipse-temurin-17 AS build +WORKDIR /build + +# Copy the poms first so Docker caches the dependency download until a pom changes. +COPY pom.xml ./ +COPY core/pom.xml core/pom.xml +COPY core/common/pom.xml core/common/pom.xml +COPY core/auth/pom.xml core/auth/pom.xml +COPY core/catalog/pom.xml core/catalog/pom.xml +COPY core/customer/pom.xml core/customer/pom.xml +COPY core/order/pom.xml core/order/pom.xml +COPY ws/pom.xml ws/pom.xml +RUN mvn -B -q dependency:go-offline -DskipTests || true + +COPY core ./core +COPY ws ./ws +# Tests are skipped here: the DAO tests need Testcontainers, i.e. a Docker daemon that a build +# stage does not have. They run in CI with mvn verify instead. +RUN mvn -B -q package -DskipTests + +FROM tomcat:10.1-jre17-temurin +# The README documents the API under /spring-jdbc-tutorial/api/v1: the WAR name is the context path. +RUN rm -rf /usr/local/tomcat/webapps/* +COPY --from=build /build/ws/target/ws.war /usr/local/tomcat/webapps/spring-jdbc-tutorial.war + +# Run as a non-root user so a compromised process has no rights on the container. +RUN useradd --system --no-create-home spring \ + && chown -R spring /usr/local/tomcat/webapps /usr/local/tomcat/logs /usr/local/tomcat/temp /usr/local/tomcat/work /usr/local/tomcat/conf +USER spring + +EXPOSE 8080 +CMD ["catalina.sh", "run"] From 78173a678054061127cb3b387052b0d47dce1ab8 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:42:00 +0400 Subject: [PATCH 23/30] build(docker): add docker-compose for the WAR and PostgreSQL 16 The compose file runs the packaged application beside a PostgreSQL 16 container with a health check. The database password is mandatory and has no default, the published port is configurable through APP_PORT because 8080 is often taken, and the datasource is passed as environment variables that Spring maps to app.datasource.* properties. It is meant for the packaged WAR only: the tests start their own database through Testcontainers. .env.example lists the variables to copy into .env. --- .env.example | 5 +++++ docker-compose.yml | 47 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 .env.example create mode 100644 docker-compose.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..99ea9d3 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Copy to .env (ignored by git) and adjust. Read by docker-compose.yml only. +DB_NAME=spring_jdbc_tutorial +DB_USER=spring +DB_PASSWORD=change-me +APP_PORT=8080 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d19c854 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,47 @@ +# Runs the PACKAGED WAR (Tomcat 10.1) with a real PostgreSQL 16. It is not used for the tests: +# the DAO tests start their own PostgreSQL through Testcontainers. +# +# Before the first "docker compose up --build": +# 1. cp .env.example .env and set DB_PASSWORD (there is no default password on purpose) +# 2. from the feature/auth branch on, create a production RSA key pair in ./keys (the keys under +# dev-keys/ are for development only): +# mkdir -p keys +# openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out keys/privateKey.pem +# openssl pkey -in keys/privateKey.pem -pubout -out keys/publicKey.pem +# chmod 644 keys/*.pem +# The container runs as an unprivileged user whose uid differs from yours, so the keys must +# be readable by others. The keys directory is ignored by git and mounted read-only. +# +# The API is then served at http://localhost:${APP_PORT}/spring-jdbc-tutorial/api/v1/... + +services: + db: + image: postgres:16 + environment: + POSTGRES_DB: ${DB_NAME:-spring_jdbc_tutorial} + POSTGRES_USER: ${DB_USER:-spring} + POSTGRES_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD in .env} + volumes: + - db-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-spring} -d ${DB_NAME:-spring_jdbc_tutorial}"] + interval: 5s + timeout: 3s + retries: 10 + + app: + build: + context: . + depends_on: + db: + condition: service_healthy + environment: + # Spring reads these through Environment: APP_DATASOURCE_URL is the property app.datasource.url + APP_DATASOURCE_URL: jdbc:postgresql://db:5432/${DB_NAME:-spring_jdbc_tutorial} + APP_DATASOURCE_USERNAME: ${DB_USER:-spring} + APP_DATASOURCE_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD in .env} + ports: + - "${APP_PORT:-8080}:8080" + +volumes: + db-data: From 351b8546f761feeec5c5bce5f75fbe1774922e33 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:42:00 +0400 Subject: [PATCH 24/30] ci(actions): run mvn verify on the whole reactor The workflow checks out the code, sets up Temurin Java 17 with the Maven cache and runs ./mvnw -B verify on pushes and pull requests to master and develop. There is no services block for PostgreSQL on purpose: the DAO tests start their own container through Testcontainers, using the Docker daemon already present on ubuntu-latest, exactly as they do locally. --- .github/workflows/ci.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e28ed59 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + push: + branches: [master, develop] + pull_request: + branches: [master, develop] + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Java 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + cache: maven + + # No "services: postgres" block on purpose. The DAO tests start their own PostgreSQL + # container through Testcontainers and the Docker daemon already available on + # ubuntu-latest, exactly as they do locally, so nothing is provisioned by the workflow. + - name: Build and test the whole reactor + run: ./mvnw -B verify From 60dd8f7313970dcf8f0a83400760948157ceca51 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:42:00 +0400 Subject: [PATCH 25/30] ci(actions): lint commit messages against Conventional Commits On every pull request the workflow reads the commits of the range between the base and the head and rejects any subject that does not match type(scope): summary. The scope is mandatory and the summary is limited to 100 characters. Merge commits are skipped, so the merge of a feature branch does not need to follow the convention. --- .github/workflows/pr-checks.yml | 43 +++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/workflows/pr-checks.yml diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml new file mode 100644 index 0000000..8188482 --- /dev/null +++ b/.github/workflows/pr-checks.yml @@ -0,0 +1,43 @@ +name: PR checks + +on: + pull_request: + branches: [master, develop] + +jobs: + commit-lint: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate commit messages (Conventional Commits) + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + + pattern='^(feat|fix|refactor|test|docs|chore|build|ci|style|perf)\([a-z0-9.,/_-]+\): .{1,100}$' + failed=0 + + while IFS= read -r subject; do + [ -z "$subject" ] && continue + if [[ "$subject" =~ $pattern ]]; then + echo "OK: $subject" + else + echo "INVALID: $subject" + failed=1 + fi + done < <(git log "$BASE_SHA..$HEAD_SHA" --no-merges --pretty=format:'%s') + + if [ "$failed" -ne 0 ]; then + echo "" + echo "One or more commit messages do not follow Conventional Commits." + echo "Expected format: (): " + echo "Allowed types: feat, fix, refactor, test, docs, chore, build, ci, style, perf" + exit 1 + fi From 63b93ca1c878c341921256905cc62cbb534291c9 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:42:00 +0400 Subject: [PATCH 26/30] docs(github): add the pull request template The template asks for the branch name, the ticked task checklist copied from the README, a commit summary and a test plan covering the five test layers and the dedicated fixture files. The review checklist encodes the rules of this project: no business logic in controllers, layer boundaries between entity and dto, ApiResponse everywhere, declarative permissions, no stereotype on DAOs and services, no Lombok, no record, no ORM, audit logging and Flyway as the only schema source. --- .github/PULL_REQUEST_TEMPLATE.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..aa26cfb --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,32 @@ +## Branch + + + +## Task checklist + + + +- [ ] + +## Commit summary + + + +## Test plan + +- [ ] Tests exist at every layer the branch's checklist calls for (DAO, RowMapper, EntityMapper, service, controller) +- [ ] Each DAO test class loads its own dedicated SQL fixture file +- [ ] `./mvnw clean verify` is green on the whole reactor + +## Code review checklist + +- [ ] No business logic in controllers; controllers only see services and `payload/` types +- [ ] DAOs only use `entity/` types, services only `dto/` types at their boundary +- [ ] Every response, success or error, is an `ApiResponse` (errors through `GlobalExceptionHandler`, 401/403 through the security handlers) +- [ ] Permission checks are declarative (`@PreAuthorize("hasPermission(...)")`), no manual permission `if` +- [ ] No `@Repository`, `@Service` or `@Component` on a DAO or service; every bean is an explicit `@Bean` in `DaoConfig`/`ServiceConfig` +- [ ] No Lombok, no `record`, no ORM (`JdbcTemplate` only) +- [ ] Every RBAC mutation calls `AuditLogger` +- [ ] The database schema only changes through a Flyway migration +- [ ] Test methods are named `_NN_Should_When` +- [ ] Commits are atomic, follow Conventional Commits with a scope, no em dash, no tool attribution From 8619f3d2951de2dbe91aceb39f3f6d96703c6d32 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:42:09 +0400 Subject: [PATCH 27/30] docs(readme): tick core-architecture tasks and note two deviations All seven core-architecture tasks are checked. The project structure now states two facts learned while implementing them: OpenApiConfig is not delivered because springdoc-openapi needs Spring Boot classes, and WebMvcConfig scans only the controller and exception packages, the sole stereotype-annotated classes of the project. The README stays the specification, so corrections found in practice are written back into it. --- README.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 3175301..2268d3e 100644 --- a/README.md +++ b/README.md @@ -235,9 +235,9 @@ spring-jdbc-tutorial/ │ │ in this project, since Spring MVC requires it for this mechanism) │ └── config/ │ ├── WebAppInitializer.java (WebApplicationInitializer, replaces web.xml) - │ ├── WebMvcConfig.java (@EnableWebMvc, imports every core module's DaoConfig/ServiceConfig) + │ ├── WebMvcConfig.java (@EnableWebMvc, imports every core module's DaoConfig/ServiceConfig, scans only ws.controller and ws.exception) │ ├── SecurityConfig.java (SecurityFilterChain, method security enabling @PreAuthorize) - │ └── OpenApiConfig.java + │ └── OpenApiConfig.java (not delivered: springdoc-openapi relies on Spring Boot classes) └── webapp/ └── (empty - no JSPs, API-only) ├── docker-compose.yml @@ -310,13 +310,13 @@ No other naming style (`shouldX()`, `testX()`, `givenX_whenY_thenZ()`) is used a ### Tasks -- [ ] Parent `pom.xml` (packaging `pom`, modules `core`/`ws`, `dependencyManagement` for Spring Framework 6.2.x, Testcontainers, etc.) -- [ ] `core/pom.xml` (packaging `pom`, modules `common`/`auth`/`catalog`/`customer`/`order`) -- [ ] `core/common`: `ResourceNotFoundException`, `BusinessRuleException`, `AbstractDao` (holds a `JdbcTemplate` reference, offers a couple of shared helpers), `DataSourceConfig` (`DataSource` via HikariCP, `JdbcTemplate`, `DataSourceTransactionManager`) -- [ ] Flyway script `V1__init_schema.sql` (all tables from both domains, including `audit_logs`) -- [ ] `ws` skeleton: `WebAppInitializer` (`WebApplicationInitializer`), `WebMvcConfig` (`@EnableWebMvc`, no module config imported yet), `ApiResponse`, `PageResponse`, `GlobalExceptionHandler` -- [ ] `docker-compose.yml` (`ws` deployed on Tomcat + PostgreSQL), `Dockerfile` (multi-stage: Maven build, Tomcat 10.1 runtime) -- [ ] `.github/workflows/ci.yml`: `mvn verify` across the whole reactor +- [x] Parent `pom.xml` (packaging `pom`, modules `core`/`ws`, `dependencyManagement` for Spring Framework 6.2.x, Testcontainers, etc.) +- [x] `core/pom.xml` (packaging `pom`, modules `common`/`auth`/`catalog`/`customer`/`order`) +- [x] `core/common`: `ResourceNotFoundException`, `BusinessRuleException`, `AbstractDao` (holds a `JdbcTemplate` reference, offers a couple of shared helpers), `DataSourceConfig` (`DataSource` via HikariCP, `JdbcTemplate`, `DataSourceTransactionManager`) +- [x] Flyway script `V1__init_schema.sql` (all tables from both domains, including `audit_logs`) +- [x] `ws` skeleton: `WebAppInitializer` (`WebApplicationInitializer`), `WebMvcConfig` (`@EnableWebMvc`, no module config imported yet), `ApiResponse`, `PageResponse`, `GlobalExceptionHandler` +- [x] `docker-compose.yml` (`ws` deployed on Tomcat + PostgreSQL), `Dockerfile` (multi-stage: Maven build, Tomcat 10.1 runtime) +- [x] `.github/workflows/ci.yml`: `mvn verify` across the whole reactor ## feature/auth From 8122095ea0a7d37089ffdd141f52ce502f7d11f2 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:45:58 +0400 Subject: [PATCH 28/30] fix(ws): stop the catch-all from leaking ErrorResponse detail text Spring MVC exceptions that implement ErrorResponse and have no dedicated handler used to reach the catch-all, which copied their detail text into the message, so framework wording could reach the client. The handler now keeps only the status they carry and answers with its standard reason phrase, or a generic message plus an error log for a 5xx. A non-regression test throws an ErrorResponseException carrying a secret detail and checks that only the reason phrase is returned. --- .../ws/exception/GlobalExceptionHandler.java | 15 +++++++++++---- .../ws/mvc/GlobalExceptionHandlerTest.java | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/exception/GlobalExceptionHandler.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/exception/GlobalExceptionHandler.java index 4b4b21f..47ddacc 100644 --- a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/exception/GlobalExceptionHandler.java +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/exception/GlobalExceptionHandler.java @@ -129,13 +129,20 @@ public ResponseEntity> handleNotAcceptable(HttpMediaTypeNotAcc // AUTH BRANCH: add handlers here for AccessDeniedException (403) and AuthenticationException (401). // They are intentionally absent now: no Spring Security dependency exists yet. - // Catch-all. Other Spring MVC exceptions implementing ErrorResponse keep the status they carry; - // anything else is a 500 with a generic message, the real error is only logged. + // Catch-all. Other Spring MVC exceptions implementing ErrorResponse keep the status they carry but + // never their detail text (framework wording): the client gets the standard reason phrase of the + // status. A 5xx is logged like any unexpected error. Anything else is a 500 with a generic message, + // the real error is only logged. @ExceptionHandler(Exception.class) public ResponseEntity> handleUnexpected(Exception e) { if (e instanceof ErrorResponse errorResponse) { - return build(errorResponse.getStatusCode(), errorResponse.getBody().getDetail() != null - ? errorResponse.getBody().getDetail() : "Request failed"); + HttpStatusCode status = errorResponse.getStatusCode(); + if (status.is5xxServerError()) { + LOG.error("Unexpected error", e); + return build(status, "Internal server error"); + } + HttpStatus known = HttpStatus.resolve(status.value()); + return build(status, known != null ? known.getReasonPhrase() : "Request failed"); } LOG.error("Unexpected error", e); return build(HttpStatus.INTERNAL_SERVER_ERROR, "Internal server error"); diff --git a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/GlobalExceptionHandlerTest.java b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/GlobalExceptionHandlerTest.java index 84a24e3..149923f 100644 --- a/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/GlobalExceptionHandlerTest.java +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/GlobalExceptionHandlerTest.java @@ -107,6 +107,15 @@ String boom() { throw new IllegalStateException("secret internal detail"); } + // A Spring ErrorResponse exception without a dedicated handler, carrying framework wording + @GetMapping("/error-response") + String errorResponse() { + org.springframework.web.ErrorResponseException e = new org.springframework.web.ErrorResponseException( + org.springframework.http.HttpStatus.CONFLICT); + e.getBody().setDetail("secret framework detail"); + throw e; + } + @PostMapping(value = "/body", consumes = MediaType.APPLICATION_JSON_VALUE) String body(@Valid @RequestBody SampleBody body) { return body.getName(); @@ -270,4 +279,14 @@ void _12_ShouldReturn500WithGenericMessage_WhenUnexpectedExceptionIsThrown() thr assertThat(json.get("message").asText()).isEqualTo("Internal server error"); assertThat(result.getResponse().getContentAsString()).doesNotContain("secret internal detail"); } + + // Non-regression: the catch-all used to copy the detail text of any ErrorResponse to the client + @Test + void _13_ShouldReturnReasonPhraseOnly_WhenAnErrorResponseExceptionHasNoDedicatedHandler() throws Exception { + MvcResult result = mockMvc.perform(get("/test/error-response")).andReturn(); + + JsonNode json = assertErrorShape(result, 409); + assertThat(json.get("message").asText()).isEqualTo("Conflict"); + assertThat(result.getResponse().getContentAsString()).doesNotContain("secret framework detail"); + } } From 31f4f3cc32651bd9c5ddf28eacca41307dfb44d7 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:45:58 +0400 Subject: [PATCH 29/30] fix(ws): register the validator as a bean and keep default converters The LocalValidatorFactoryBean is now a @Bean, so Spring manages its lifecycle and injects the context, and getValidator() returns that same instance instead of building a new unmanaged one on every call. The Jackson customisation moved from configureMessageConverters to extendMessageConverters: the default converters (String, byte[], Resource) are kept and only the default Jackson converter is replaced by the one that writes Instants as ISO-8601 text. --- .../ws/config/WebMvcConfig.java | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java index e1723cc..1c93d58 100644 --- a/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.edgareldy.springjdbctutorial.core.common.config.CommonConfig; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; @@ -39,23 +40,29 @@ @ComponentScan({"com.edgareldy.springjdbctutorial.ws.controller", "com.edgareldy.springjdbctutorial.ws.exception"}) public class WebMvcConfig implements WebMvcConfigurer { - // Replaces the default converters with a single Jackson one. The builder registers the JSR-310 - // module (jackson-datatype-jsr310) found on the classpath; disabling WRITE_DATES_AS_TIMESTAMPS - // makes Instant serialise as ISO-8601 text instead of a number. Null fields stay in the output. + // Swaps the default Jackson converter for one built on our ObjectMapper and keeps every other + // default converter (String, byte[], Resource...). The builder registers the JSR-310 module + // (jackson-datatype-jsr310) found on the classpath; disabling WRITE_DATES_AS_TIMESTAMPS makes + // Instant serialise as ISO-8601 text instead of a number. Null fields stay in the output. @Override - public void configureMessageConverters(List> converters) { + public void extendMessageConverters(List> converters) { ObjectMapper mapper = Jackson2ObjectMapperBuilder.json() .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) .build(); - converters.add(new MappingJackson2HttpMessageConverter(mapper)); + converters.removeIf(MappingJackson2HttpMessageConverter.class::isInstance); + converters.add(0, new MappingJackson2HttpMessageConverter(mapper)); } // LocalValidatorFactoryBean bootstraps Bean Validation (Hibernate Validator on the classpath). - // Returning it from getValidator() makes @Valid on request bodies use it. + // Declared as a bean so Spring manages its lifecycle and injects the context; getValidator() + // returns that same instance so @Valid on request bodies uses it. + @Bean + public LocalValidatorFactoryBean validator() { + return new LocalValidatorFactoryBean(); + } + @Override public Validator getValidator() { - LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); - validator.afterPropertiesSet(); - return validator; + return validator(); } } From 9473c7c75d9103e3a0f50f767b648414a3baf5ea Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Sat, 19 Sep 2026 21:45:58 +0400 Subject: [PATCH 30/30] fix(common): log the cause when the health check fails HealthServiceImpl swallowed the DataAccessException and reported a bare DOWN, leaving an operator blind to the reason (bad credentials, network, exhausted pool). It now logs a warning with the exception before answering DOWN. The logger is Spring's own commons-logging facade (spring-jcl), so core still needs no logging dependency: the ws module picks the backend. --- .../core/common/service/impl/HealthServiceImpl.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/service/impl/HealthServiceImpl.java b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/service/impl/HealthServiceImpl.java index e184677..8d4cb34 100644 --- a/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/service/impl/HealthServiceImpl.java +++ b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/service/impl/HealthServiceImpl.java @@ -3,6 +3,8 @@ import com.edgareldy.springjdbctutorial.core.common.dao.HealthDao; import com.edgareldy.springjdbctutorial.core.common.dto.HealthDto; import com.edgareldy.springjdbctutorial.core.common.service.HealthService; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; import org.springframework.dao.DataAccessException; /** @@ -15,6 +17,9 @@ */ public class HealthServiceImpl implements HealthService { + // Spring's own logging facade (spring-jcl): core needs no logging dependency, ws chooses the backend + private static final Log LOG = LogFactory.getLog(HealthServiceImpl.class); + private final HealthDao healthDao; public HealthServiceImpl(HealthDao healthDao) { @@ -28,7 +33,9 @@ public HealthDto check() { return new HealthDto("UP", "UP"); } } catch (DataAccessException e) { - // JdbcTemplate translates SQLException into DataAccessException: database unreachable + // JdbcTemplate translates SQLException into DataAccessException: database unreachable. + // Logged so an operator sees the cause (bad credentials, network, exhausted pool). + LOG.warn("Database health check failed", e); } return new HealthDto("DOWN", "DOWN"); }