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/.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/.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 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 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 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"] 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 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/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 + + + + + + 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/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); + } +} 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/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; + } +} 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/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); + } +} 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..8d4cb34 --- /dev/null +++ b/core/common/src/main/java/com/edgareldy/springjdbctutorial/core/common/service/impl/HealthServiceImpl.java @@ -0,0 +1,42 @@ +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.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +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 { + + // 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) { + 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. + // Logged so an operator sees the cause (bad credentials, network, exhausted pool). + LOG.warn("Database health check failed", e); + } + return new HealthDto("DOWN", "DOWN"); + } +} 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); 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="); + } +} 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/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/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")); + } +} 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()); + } +} 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; 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 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: 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 + + + 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 + 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, "/*"); + } +} 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..1c93d58 --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/config/WebMvcConfig.java @@ -0,0 +1,68 @@ +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.Bean; +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 { + + // 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 extendMessageConverters(List> converters) { + ObjectMapper mapper = Jackson2ObjectMapperBuilder.json() + .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .build(); + converters.removeIf(MappingJackson2HttpMessageConverter.class::isInstance); + converters.add(0, new MappingJackson2HttpMessageConverter(mapper)); + } + + // LocalValidatorFactoryBean bootstraps Bean Validation (Hibernate Validator on the classpath). + // 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() { + return validator(); + } +} 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()); + } +} 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..47ddacc --- /dev/null +++ b/ws/src/main/java/com/edgareldy/springjdbctutorial/ws/exception/GlobalExceptionHandler.java @@ -0,0 +1,150 @@ +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 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) { + 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/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; + } +} 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(); + } +} 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..149923f --- /dev/null +++ b/ws/src/test/java/com/edgareldy/springjdbctutorial/ws/mvc/GlobalExceptionHandlerTest.java @@ -0,0 +1,292 @@ +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"); + } + + // 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(); + } + + @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"); + } + + // 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"); + } +} 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(); + } +} 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); + } +}