commit 8cd737ef31ef08fb3f4553933be678b1d9419c41 Author: José Antonio Zamora Date: Mon Jan 12 11:33:54 2026 +0100 Technical exercise ITX v1.0 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3b41682 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +/mvnw text eol=lf +*.cmd text eol=crlf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4efdd85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,51 @@ +###################### +# Eclipse +###################### +.project +.metadata +tmp/ +tmp/**/* +*.tmp +*.bak +*.swp +*~.nib +local.properties +.classpath +.settings/ +.loadpath +.factorypath + +###################### +# Maven +###################### +/log/ +/target/ + +###################### +# Package Files +###################### +*.jar +*.war +*.ear +*.db + +###################### +# Windows +###################### +# Windows image file caches +Thumbs.db + +# Folder config file +Desktop.ini + +###################### +# Directories +###################### +/bin/ +/deploy/ + +###################### +# Logs +###################### +*.log* + diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..8dea6c2 --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.12/apache-maven-3.9.12-bin.zip diff --git a/README.md b/README.md new file mode 100644 index 0000000..b78c6e0 --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# Price Service – Technical Exercise + +## Description + +Spring Boot service that exposes a REST endpoint to retrieve the applicable price for a product, brand and application date. + +The service uses: +- Spring Boot +- Spring Data JPA +- H2 in-memory database +- Liquibase for schema and data initialization +- Maven +- Integration tests with MockMvc + +The pricing logic follows the specification provided in the exercise, including date ranges and priority handling. + +--- + +## Data Model + +The service is initialized with sample data equivalent to the provided `PRICES` table, including: + +- Brand identifier +- Product identifier +- Price list +- Application date range (start / end) +- Priority +- Final price + +If multiple prices apply for the same date range, the one with the highest priority is selected. + +--- + +## REST Endpoint + +### Request + +GET /api/price/{brandId}/{productId}?applicationDate={applicationDate} + +**Path parameters:** +- `brandId`: brand identifier +- `productId`: product identifier + +**Request parameters:** +- `applicationDate`: date and time of application (ISO-8601 format) + +**Example:** + +/api/price/1/35455?applicationDate=2020-06-14T10:00:00 + +--- + +### Response + +The service returns: +- product identifier +- brand identifier +- applicable price list +- start date +- end date +- final price + +--- + +## Running the Application + +To start the application locally: + +```bash +mvn spring-boot:run +``` + +The service will be available on port 8080. + +--- + +## Running Tests + +The project includes integration tests that validate the scenarios described in the exercise. + +To execute the tests: + +```bash +mvn test +``` + +The tests: +- Load the Spring context +- Initialize the in-memory H2 database via Liquibase +- Validate the REST endpoint responses + +--- + +## Notes + +The database is fully in-memory and requires no external setup. + +Liquibase is used to manage database schema and initial data. diff --git a/mvnw b/mvnw new file mode 100644 index 0000000..bd8896b --- /dev/null +++ b/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 0000000..92450f9 --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..6c6fade --- /dev/null +++ b/pom.xml @@ -0,0 +1,144 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.4.5 + + + com.jzamoram + itx + 0.0.1-SNAPSHOT + itx + itx example + + + + + + + + + + + + + + + 17 + dev + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-starter-test + test + + + org.liquibase + liquibase-core + + + com.h2database + h2 + runtime + + + org.springframework.boot + spring-boot-starter-aop + + + org.springframework.boot + spring-boot-starter-data-jpa + + + com.querydsl + querydsl-apt + 5.0.0 + provided + jakarta + + + com.querydsl + querydsl-jpa + 5.0.0 + jakarta + + + + + + + + com.mysema.maven + apt-maven-plugin + 1.1.3 + + + generate-sources + + process + + + target/generated-sources + + com.mysema.query.apt.jpa.JPAAnnotationProcessor + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + + + spring-snapshots + Spring Snapshots + https://repo.spring.io/snapshot + + false + + + + + + spring-snapshots + Spring Snapshots + https://repo.spring.io/snapshot + + false + + + + + + dev + + true + + + dev + + + + prod + + prod + + + + diff --git a/src/main/java/com/jzamoram/itx/ItxApplication.java b/src/main/java/com/jzamoram/itx/ItxApplication.java new file mode 100644 index 0000000..5355f19 --- /dev/null +++ b/src/main/java/com/jzamoram/itx/ItxApplication.java @@ -0,0 +1,65 @@ +package com.jzamoram.itx; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.Optional; +import java.util.TimeZone; + +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.h2.H2ConsoleAutoConfiguration; +import org.springframework.boot.autoconfigure.liquibase.LiquibaseProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.core.env.Environment; + +import com.jzamoram.itx.config.ApplicationProperties; +import com.jzamoram.itx.config.Constants; + +@SpringBootApplication(exclude = { H2ConsoleAutoConfiguration.class }) +@EnableConfigurationProperties({ LiquibaseProperties.class, ApplicationProperties.class }) +public class ItxApplication { + private static final Logger LOG = LoggerFactory.getLogger(ItxApplication.class); + + public ItxApplication() { + } + + /** + * Main method, used to run the application. + * + * @param args the command line arguments. + */ + public static void main(String[] args) { + TimeZone.setDefault(TimeZone.getTimeZone(Constants.DEFAULT_TIMEZONE)); + SpringApplication app = new SpringApplication(ItxApplication.class); + Environment env = app.run(args).getEnvironment(); + logApplicationStartup(env); + } + + private static void logApplicationStartup(Environment env) { + String protocol = Optional.ofNullable(env.getProperty("server.ssl.key-store")).map(key -> "https") + .orElse("http"); + String applicationName = env.getProperty("spring.application.name"); + String serverPort = env.getProperty("server.port"); + String contextPath = Optional.ofNullable(env.getProperty("server.servlet.context-path")) + .filter(StringUtils::isNotBlank).orElse("/"); + String hostAddress = "localhost"; + try { + hostAddress = InetAddress.getLocalHost().getHostAddress(); + } catch (UnknownHostException e) { + LOG.warn("The host name could not be determined, using `localhost` as fallback"); + } + LOG.info(""" + + ---------------------------------------------------------- + \tApplication '{}' is running! Access URLs: + \tLocal: \t\t{}://localhost:{}{} + \tExternal: \t{}://{}:{}{} + \tProfile(s): \t{} + ----------------------------------------------------------""", applicationName, protocol, serverPort, + contextPath, protocol, hostAddress, serverPort, contextPath, + env.getActiveProfiles().length == 0 ? env.getDefaultProfiles() : env.getActiveProfiles()); + } +} diff --git a/src/main/java/com/jzamoram/itx/config/ApplicationProperties.java b/src/main/java/com/jzamoram/itx/config/ApplicationProperties.java new file mode 100644 index 0000000..19d8cb1 --- /dev/null +++ b/src/main/java/com/jzamoram/itx/config/ApplicationProperties.java @@ -0,0 +1,28 @@ +package com.jzamoram.itx.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + + +@ConfigurationProperties(prefix = "application", ignoreUnknownFields = false) +public class ApplicationProperties { + + private final Liquibase liquibase = new Liquibase(); + + public Liquibase getLiquibase() { + return liquibase; + } + + + public static class Liquibase { + + private Boolean asyncStart = true; + + public Boolean getAsyncStart() { + return asyncStart; + } + + public void setAsyncStart(Boolean asyncStart) { + this.asyncStart = asyncStart; + } + } +} diff --git a/src/main/java/com/jzamoram/itx/config/Constants.java b/src/main/java/com/jzamoram/itx/config/Constants.java new file mode 100644 index 0000000..27f11c9 --- /dev/null +++ b/src/main/java/com/jzamoram/itx/config/Constants.java @@ -0,0 +1,15 @@ +package com.jzamoram.itx.config; + +/** + * Application constants. + */ +public final class Constants { + + public static final String SYSTEM = "system"; + public static final String DEFAULT_LANGUAGE = "es"; + + public static final String DEFAULT_TIMEZONE = "UTC"; + public static final String PROFILE_DEVELOPMENT = "dev"; + + private Constants() {} +} diff --git a/src/main/java/com/jzamoram/itx/config/DatabaseConfiguration.java b/src/main/java/com/jzamoram/itx/config/DatabaseConfiguration.java new file mode 100644 index 0000000..d20f243 --- /dev/null +++ b/src/main/java/com/jzamoram/itx/config/DatabaseConfiguration.java @@ -0,0 +1,58 @@ +package com.jzamoram.itx.config; + +import java.sql.SQLException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.h2.H2ConsoleProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +import com.jzamoram.itx.config.h2.H2ConfigurationHelper; + +@Configuration +@EnableJpaRepositories({"com.jzamoram.itx.repository"}) +@EnableTransactionManagement +@EnableConfigurationProperties(H2ConsoleProperties.class) +public class DatabaseConfiguration { + + private static final Logger LOG = LoggerFactory.getLogger(DatabaseConfiguration.class); + + private final Environment env; + + public DatabaseConfiguration(Environment env) { + this.env = env; + } + + /** + * Open the TCP port for the H2 database, so it is available remotely. + * + * @return the H2 database TCP server. + * @throws SQLException if the server failed to start. + */ + @Bean(initMethod = "start", destroyMethod = "stop") + @ConditionalOnProperty(prefix = "spring.h2.console", name = "enabled", havingValue = "true") + public Object h2TCPServer() throws SQLException { + String port = getValidPortForH2(); + LOG.info("H2 database is available on port {}", port); + return H2ConfigurationHelper.createServer(port); + } + + private String getValidPortForH2() { + int port = Integer.parseInt(env.getProperty("server.port")); + if (port < 10000) { + port = 10000 + port; + } else { + if (port < 63536) { + port = port + 2000; + } else { + port = port - 2000; + } + } + return String.valueOf(port); + } +} diff --git a/src/main/java/com/jzamoram/itx/config/DateTimeFormatConfiguration.java b/src/main/java/com/jzamoram/itx/config/DateTimeFormatConfiguration.java new file mode 100644 index 0000000..f71da7a --- /dev/null +++ b/src/main/java/com/jzamoram/itx/config/DateTimeFormatConfiguration.java @@ -0,0 +1,20 @@ +package com.jzamoram.itx.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.format.FormatterRegistry; +import org.springframework.format.datetime.standard.DateTimeFormatterRegistrar; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * Configure the converters to use the ISO format for dates by default. + */ +@Configuration +public class DateTimeFormatConfiguration implements WebMvcConfigurer { + + @Override + public void addFormatters(FormatterRegistry registry) { + DateTimeFormatterRegistrar registrar = new DateTimeFormatterRegistrar(); + registrar.setUseIsoFormat(true); + registrar.registerFormatters(registry); + } +} diff --git a/src/main/java/com/jzamoram/itx/config/LiquibaseConfiguration.java b/src/main/java/com/jzamoram/itx/config/LiquibaseConfiguration.java new file mode 100644 index 0000000..90545f8 --- /dev/null +++ b/src/main/java/com/jzamoram/itx/config/LiquibaseConfiguration.java @@ -0,0 +1,83 @@ +package com.jzamoram.itx.config; + +import java.util.concurrent.Executor; +import javax.sql.DataSource; +import liquibase.integration.spring.SpringLiquibase; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; +import org.springframework.boot.autoconfigure.liquibase.LiquibaseDataSource; +import org.springframework.boot.autoconfigure.liquibase.LiquibaseProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; + +import com.jzamoram.itx.config.liquibase.SpringLiquibaseUtil; + +@Configuration +public class LiquibaseConfiguration { + + private static final Logger LOG = LoggerFactory.getLogger(LiquibaseConfiguration.class); + + private final Environment env; + + public LiquibaseConfiguration(Environment env) { + this.env = env; + } + + @Bean + public SpringLiquibase liquibase( + @Qualifier("taskExecutor") Executor executor, + LiquibaseProperties liquibaseProperties, + @LiquibaseDataSource ObjectProvider liquibaseDataSource, + ObjectProvider dataSource, + ApplicationProperties applicationProperties, + DataSourceProperties dataSourceProperties + ) { + SpringLiquibase liquibase; + if (Boolean.TRUE.equals(applicationProperties.getLiquibase().getAsyncStart())) { + liquibase = SpringLiquibaseUtil.createAsyncSpringLiquibase( + this.env, + executor, + liquibaseDataSource.getIfAvailable(), + liquibaseProperties, + dataSource.getIfUnique(), + dataSourceProperties + ); + } else { + liquibase = SpringLiquibaseUtil.createSpringLiquibase( + liquibaseDataSource.getIfAvailable(), + liquibaseProperties, + dataSource.getIfUnique(), + dataSourceProperties + ); + } + liquibase.setChangeLog("classpath:liquibase/master.xml"); + if (!CollectionUtils.isEmpty(liquibaseProperties.getContexts())) { + liquibase.setContexts(StringUtils.collectionToCommaDelimitedString(liquibaseProperties.getContexts())); + } + liquibase.setDefaultSchema(liquibaseProperties.getDefaultSchema()); + liquibase.setLiquibaseSchema(liquibaseProperties.getLiquibaseSchema()); + liquibase.setLiquibaseTablespace(liquibaseProperties.getLiquibaseTablespace()); + liquibase.setDatabaseChangeLogLockTable(liquibaseProperties.getDatabaseChangeLogLockTable()); + liquibase.setDatabaseChangeLogTable(liquibaseProperties.getDatabaseChangeLogTable()); + liquibase.setDropFirst(liquibaseProperties.isDropFirst()); + if (!CollectionUtils.isEmpty(liquibaseProperties.getLabelFilter())) { + liquibase.setLabelFilter(StringUtils.collectionToCommaDelimitedString(liquibaseProperties.getLabelFilter())); + } + liquibase.setChangeLogParameters(liquibaseProperties.getParameters()); + liquibase.setRollbackFile(liquibaseProperties.getRollbackFile()); + liquibase.setTestRollbackOnUpdate(liquibaseProperties.isTestRollbackOnUpdate()); + if (env.matchesProfiles("no-liquibase")) { + liquibase.setShouldRun(false); + } else { + liquibase.setShouldRun(liquibaseProperties.isEnabled()); + LOG.debug("Configuring Liquibase"); + } + return liquibase; + } +} diff --git a/src/main/java/com/jzamoram/itx/config/WebConfigurer.java b/src/main/java/com/jzamoram/itx/config/WebConfigurer.java new file mode 100644 index 0000000..34724a2 --- /dev/null +++ b/src/main/java/com/jzamoram/itx/config/WebConfigurer.java @@ -0,0 +1,49 @@ +package com.jzamoram.itx.config; + +import jakarta.servlet.*; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.web.servlet.ServletContextInitializer; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.core.env.Profiles; + +import com.jzamoram.itx.config.h2.H2ConfigurationHelper; + +@Configuration +public class WebConfigurer implements ServletContextInitializer { + + private static final Logger LOG = LoggerFactory.getLogger(WebConfigurer.class); + + private final Environment env; + + + public WebConfigurer(Environment env) { + this.env = env; + } + + @Override + public void onStartup(ServletContext servletContext) { + if (env.getActiveProfiles().length != 0) { + LOG.info("Web application configuration, using profiles: {}", (Object[]) env.getActiveProfiles()); + } + + if (h2ConsoleIsEnabled(env)) { + initH2Console(servletContext); + } + LOG.info("Web application fully configured"); + } + + private boolean h2ConsoleIsEnabled(Environment env) { + return ( + env.acceptsProfiles(Profiles.of(Constants.PROFILE_DEVELOPMENT)) && + "true".equals(env.getProperty("spring.h2.console.enabled")) + ); + } + + private void initH2Console(ServletContext servletContext) { + LOG.info("Initialize H2 console"); + H2ConfigurationHelper.initH2Console(servletContext); + } +} diff --git a/src/main/java/com/jzamoram/itx/config/h2/H2ConfigurationHelper.java b/src/main/java/com/jzamoram/itx/config/h2/H2ConfigurationHelper.java new file mode 100644 index 0000000..a054e93 --- /dev/null +++ b/src/main/java/com/jzamoram/itx/config/h2/H2ConfigurationHelper.java @@ -0,0 +1,113 @@ +package com.jzamoram.itx.config.h2; + +import jakarta.servlet.Servlet; +import jakarta.servlet.ServletContext; +import jakarta.servlet.ServletRegistration; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.sql.SQLException; + +/** + * Utility class to configure H2 in development. + *

+ * We don't want to include H2 when we are packaging for the "prod" profile and won't + * actually need it, so we have to load / invoke things at runtime through reflection. + */ +public class H2ConfigurationHelper { + + private H2ConfigurationHelper() { + throw new AssertionError("The class should not be instantiated"); + } + + /** + *

createServer.

+ * + * @return a {@link java.lang.Object} object. + * @throws java.sql.SQLException if any. + */ + public static Object createServer() throws SQLException { + return createServer("9092"); + } + + /** + *

createServer.

+ * + * @param port a {@link java.lang.String} object. + * @return a {@link java.lang.Object} object. + * @throws java.sql.SQLException if any. + */ + public static Object createServer(String port) throws SQLException { + try { + ClassLoader loader = Thread.currentThread().getContextClassLoader(); + Class serverClass = Class.forName("org.h2.tools.Server", true, loader); + Method createServer = serverClass.getMethod("createTcpServer", String[].class); + return createServer.invoke(null, new Object[] { new String[] { "-tcp", "-tcpAllowOthers", "-tcpPort", port } }); + } catch (ClassNotFoundException | LinkageError e) { + throw new RuntimeException("Failed to load and initialize org.h2.tools.Server", e); + } catch (SecurityException | NoSuchMethodException e) { + throw new RuntimeException("Failed to get method org.h2.tools.Server.createTcpServer()", e); + } catch (IllegalAccessException | IllegalArgumentException e) { + throw new RuntimeException("Failed to invoke org.h2.tools.Server.createTcpServer()", e); + } catch (InvocationTargetException e) { + Throwable t = e.getTargetException(); + if (t instanceof SQLException) { + throw (SQLException) t; + } + throw new RuntimeException("Unchecked exception in org.h2.tools.Server.createTcpServer()", t); + } + } + + /** + * Init the H2 console via H2's webserver when no servletContext {@link jakarta.servlet.ServletContext} + * is available. + */ + public static void initH2Console() { + initH2Console("src/main/resources"); + } + + /** + * Init the H2 console via H2's webserver when no servletContext {@link jakarta.servlet.ServletContext} + * is available. + * + * @param propertiesLocation the location where to find .h2.server.properties + */ + static void initH2Console(String propertiesLocation) { + try { + // We don't want to include H2 when we are packaging for the "prod" profile and won't + // actually need it, so we have to load / invoke things at runtime through reflection. + ClassLoader loader = Thread.currentThread().getContextClassLoader(); + Class serverClass = Class.forName("org.h2.tools.Server", true, loader); + Method createWebServer = serverClass.getMethod("createWebServer", String[].class); + Method start = serverClass.getMethod("start"); + + Object server = createWebServer.invoke(null, new Object[] { new String[] { "-properties", propertiesLocation } }); + start.invoke(server); + } catch (Exception e) { + throw new RuntimeException("Failed to start h2 webserver console", e); + } + } + + /** + *

initH2Console.

+ * + * @param servletContext a {@link jakarta.servlet.ServletContext} object. + */ + public static void initH2Console(ServletContext servletContext) { + try { + // We don't want to include H2 when we are packaging for the "prod" profile and won't + // actually need it, so we have to load / invoke things at runtime through reflection. + ClassLoader loader = Thread.currentThread().getContextClassLoader(); + Class servletClass = Class.forName("org.h2.server.web.JakartaWebServlet", true, loader); + Servlet servlet = (Servlet) servletClass.getDeclaredConstructor().newInstance(); + + ServletRegistration.Dynamic h2ConsoleServlet = servletContext.addServlet("H2Console", servlet); + h2ConsoleServlet.addMapping("/h2-console/*"); + h2ConsoleServlet.setInitParameter("-properties", "src/main/resources/"); + h2ConsoleServlet.setLoadOnStartup(1); + } catch (ClassNotFoundException | LinkageError | NoSuchMethodException | InvocationTargetException e) { + throw new RuntimeException("Failed to load and initialize org.h2.server.web.JakartaWebServlet", e); + } catch (IllegalAccessException | InstantiationException e) { + throw new RuntimeException("Failed to instantiate org.h2.server.web.JakartaWebServlet", e); + } + } +} diff --git a/src/main/java/com/jzamoram/itx/config/liquibase/AsyncSpringLiquibase.java b/src/main/java/com/jzamoram/itx/config/liquibase/AsyncSpringLiquibase.java new file mode 100644 index 0000000..fb3b90a --- /dev/null +++ b/src/main/java/com/jzamoram/itx/config/liquibase/AsyncSpringLiquibase.java @@ -0,0 +1,116 @@ +package com.jzamoram.itx.config.liquibase; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.concurrent.Executor; +import liquibase.exception.LiquibaseException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.liquibase.DataSourceClosingSpringLiquibase; +import org.springframework.core.env.Environment; +import org.springframework.core.env.Profiles; +import org.springframework.util.StopWatch; + +/** + * Specific liquibase.integration.spring.SpringLiquibase that will update the database asynchronously and close + * DataSource if necessary.

By default, this asynchronous version only works when using the "dev" profile.

The standard + * liquibase.integration.spring.SpringLiquibase starts Liquibase in the current thread:

  • This is needed if you + * want to do some database requests at startup
  • This ensure that the database is ready when the application + * starts
But as this is a rather slow process, we use this asynchronous version to speed up our start-up + * time:
  • On a recent MacBook Pro, start-up time is down from 14 seconds to 8 seconds
  • In production, + * this can help your application run on platforms like Heroku, where it must start/restart very quickly
+ */ +public class AsyncSpringLiquibase extends DataSourceClosingSpringLiquibase { + + /** Constant DISABLED_MESSAGE="Liquibase is disabled" */ + public static final String DISABLED_MESSAGE = "Liquibase is disabled"; + /** Constant STARTING_ASYNC_MESSAGE="Starting Liquibase asynchronously, your"{trunked} */ + public static final String STARTING_ASYNC_MESSAGE = "Starting Liquibase asynchronously, your database might not be ready at startup!"; + /** Constant STARTING_SYNC_MESSAGE="Starting Liquibase synchronously" */ + public static final String STARTING_SYNC_MESSAGE = "Starting Liquibase synchronously"; + /** Constant STARTED_MESSAGE="Liquibase has updated your database in "{trunked} */ + public static final String STARTED_MESSAGE = "Liquibase has updated your database in {} ms"; + /** Constant EXCEPTION_MESSAGE="Liquibase could not start correctly, yo"{trunked} */ + public static final String EXCEPTION_MESSAGE = "Liquibase could not start correctly, your database is NOT ready: {}"; + + /** Constant SLOWNESS_THRESHOLD=5 */ + public static final long SLOWNESS_THRESHOLD = 5; // seconds + /** Constant SLOWNESS_MESSAGE="Warning, Liquibase took more than {} se"{trunked} */ + public static final String SLOWNESS_MESSAGE = "Warning, Liquibase took more than {} seconds to start up!"; + + // named "logger" because there is already a field called "log" in "SpringLiquibase" + private final Logger logger = LoggerFactory.getLogger(AsyncSpringLiquibase.class); + + private final Executor executor; + + private final Environment env; + + /** + *

Constructor for AsyncSpringLiquibase.

+ * + * @param executor a {@link java.util.concurrent.Executor} object. + * @param env a {@link org.springframework.core.env.Environment} object. + */ + public AsyncSpringLiquibase(Executor executor, Environment env) { + this.executor = executor; + this.env = env; + } + + /** {@inheritDoc} */ + @Override + public void afterPropertiesSet() throws LiquibaseException { + if (isLiquibaseDisabled()) { + logger.debug(DISABLED_MESSAGE); + return; + } + + if (isAsyncProfileActive()) { + handleAsyncExecution(); + } else { + logger.debug(STARTING_SYNC_MESSAGE); + initDb(); + } + } + + private boolean isLiquibaseDisabled() { + return env.acceptsProfiles(Profiles.of("no-liquibase")); + } + + private boolean isAsyncProfileActive() { + return env.acceptsProfiles(Profiles.of("dev" + "|" + "heroku")); + } + + private void handleAsyncExecution() { + // Prevent Thread Lock with spring-cloud-context GenericScope + // https://github.com/spring-cloud/spring-cloud-commons/commit/aaa7288bae3bb4d6fdbef1041691223238d77b7b#diff-afa0715eafc2b0154475fe672dab70e4R328 + try (Connection connection = getDataSource().getConnection()) { + executor.execute(() -> { + try { + logger.warn(STARTING_ASYNC_MESSAGE); + initDb(); + } catch (LiquibaseException e) { + logger.error(EXCEPTION_MESSAGE, e.getMessage(), e); + } + }); + } catch (SQLException e) { + logger.error(EXCEPTION_MESSAGE, e.getMessage(), e); + } + } + + /** + *

initDb.

+ * + * @throws liquibase.exception.LiquibaseException if any. + */ + protected void initDb() throws LiquibaseException { + StopWatch watch = new StopWatch(); + watch.start(); + super.afterPropertiesSet(); + watch.stop(); + logger.debug(STARTED_MESSAGE, watch.getTotalTimeMillis()); + boolean isExecutionTimeLong = watch.getTotalTimeMillis() > SLOWNESS_THRESHOLD * 1000L; + if (isExecutionTimeLong) { + logger.warn(SLOWNESS_MESSAGE, SLOWNESS_THRESHOLD); + } + } +} diff --git a/src/main/java/com/jzamoram/itx/config/liquibase/SpringLiquibaseUtil.java b/src/main/java/com/jzamoram/itx/config/liquibase/SpringLiquibaseUtil.java new file mode 100644 index 0000000..cfbf7cb --- /dev/null +++ b/src/main/java/com/jzamoram/itx/config/liquibase/SpringLiquibaseUtil.java @@ -0,0 +1,106 @@ +package com.jzamoram.itx.config.liquibase; + +import java.util.Optional; +import java.util.concurrent.Executor; +import java.util.function.Supplier; +import javax.sql.DataSource; +import liquibase.integration.spring.SpringLiquibase; +import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; +import org.springframework.boot.autoconfigure.liquibase.DataSourceClosingSpringLiquibase; +import org.springframework.boot.autoconfigure.liquibase.LiquibaseProperties; +import org.springframework.boot.jdbc.DataSourceBuilder; +import org.springframework.core.env.Environment; + +/** + * Utility class for handling SpringLiquibase. + * + *

+ * It follows implementation of + * LiquibaseAutoConfiguration. + */ +public final class SpringLiquibaseUtil { + + private SpringLiquibaseUtil() {} + + /** + *

createSpringLiquibase.

+ * + * @param liquibaseDatasource a {@link javax.sql.DataSource} object. + * @param liquibaseProperties a {@link org.springframework.boot.autoconfigure.liquibase.LiquibaseProperties} object. + * @param dataSource a {@link javax.sql.DataSource} object. + * @param dataSourceProperties a {@link org.springframework.boot.autoconfigure.jdbc.DataSourceProperties} object. + * @return a {@link liquibase.integration.spring.SpringLiquibase} object. + */ + public static SpringLiquibase createSpringLiquibase( + DataSource liquibaseDatasource, + LiquibaseProperties liquibaseProperties, + DataSource dataSource, + DataSourceProperties dataSourceProperties + ) { + SpringLiquibase liquibase; + DataSource liquibaseDataSource = getDataSource(liquibaseDatasource, liquibaseProperties, dataSource); + if (liquibaseDataSource != null) { + liquibase = new SpringLiquibase(); + liquibase.setDataSource(liquibaseDataSource); + return liquibase; + } + liquibase = new DataSourceClosingSpringLiquibase(); + liquibase.setDataSource(createNewDataSource(liquibaseProperties, dataSourceProperties)); + return liquibase; + } + + /** + *

createAsyncSpringLiquibase.

+ * + * @param env a {@link org.springframework.core.env.Environment} object. + * @param executor a {@link java.util.concurrent.Executor} object. + * @param liquibaseDatasource a {@link javax.sql.DataSource} object. + * @param liquibaseProperties a {@link org.springframework.boot.autoconfigure.liquibase.LiquibaseProperties} object. + * @param dataSource a {@link javax.sql.DataSource} object. + * @param dataSourceProperties a {@link org.springframework.boot.autoconfigure.jdbc.DataSourceProperties} object. + * @return a {@link AsyncSpringLiquibase} object. + */ + public static AsyncSpringLiquibase createAsyncSpringLiquibase( + Environment env, + Executor executor, + DataSource liquibaseDatasource, + LiquibaseProperties liquibaseProperties, + DataSource dataSource, + DataSourceProperties dataSourceProperties + ) { + AsyncSpringLiquibase liquibase = new AsyncSpringLiquibase(executor, env); + DataSource liquibaseDataSource = getDataSource(liquibaseDatasource, liquibaseProperties, dataSource); + if (liquibaseDataSource != null) { + liquibase.setCloseDataSourceOnceMigrated(false); + liquibase.setDataSource(liquibaseDataSource); + } else { + liquibase.setDataSource(createNewDataSource(liquibaseProperties, dataSourceProperties)); + } + return liquibase; + } + + private static DataSource getDataSource( + DataSource liquibaseDataSource, + LiquibaseProperties liquibaseProperties, + DataSource dataSource + ) { + if (liquibaseDataSource != null) { + return liquibaseDataSource; + } + if (liquibaseProperties.getUrl() == null && liquibaseProperties.getUser() == null) { + return dataSource; + } + return null; + } + + private static DataSource createNewDataSource(LiquibaseProperties liquibaseProperties, DataSourceProperties dataSourceProperties) { + String url = getProperty(liquibaseProperties::getUrl, dataSourceProperties::determineUrl); + String user = getProperty(liquibaseProperties::getUser, dataSourceProperties::determineUsername); + String password = getProperty(liquibaseProperties::getPassword, dataSourceProperties::determinePassword); + return DataSourceBuilder.create().url(url).username(user).password(password).build(); + } + + private static String getProperty(Supplier property, Supplier defaultValue) { + return Optional.of(property).map(Supplier::get).orElseGet(defaultValue); + } +} diff --git a/src/main/java/com/jzamoram/itx/domain/Price.java b/src/main/java/com/jzamoram/itx/domain/Price.java new file mode 100644 index 0000000..10d225d --- /dev/null +++ b/src/main/java/com/jzamoram/itx/domain/Price.java @@ -0,0 +1,158 @@ +package com.jzamoram.itx.domain; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.*; +import java.io.Serializable; +import java.time.Instant; + +@Entity +@Table(name = "prices") +public class Price implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sequenceGenerator") + @SequenceGenerator(name = "sequenceGenerator") + @Column(name = "id") + @JsonIgnore + private Long id; + + + @Column(name = "brand_id") + private Long brandId; + + @Column(name = "start_date") + private Instant startDate; + + @Column(name = "end_date") + private Instant endDate; + + @Column(name = "price_list") + private Long priceList; + + @Column(name = "product_id") + private Long productId; + + @Column(name = "priority") + @JsonIgnore + private Long priority; + + @Column(name = "price") + private Double price; + + @Column(name = "curr") + @JsonIgnore + private String curr; + + public Long getId() { + return this.id; + } + + public Price id(Long id) { + this.setId(id); + return this; + } + + public void setId(Long id) { + this.id = id; + } + + + public Long getBrandId() { + return brandId; + } + + public void setBrandId(Long brandId) { + this.brandId = brandId; + } + + public Instant getStartDate() { + return startDate; + } + + public void setStartDate(Instant startDate) { + this.startDate = startDate; + } + + public Instant getEndDate() { + return endDate; + } + + public void setEndDate(Instant endDate) { + this.endDate = endDate; + } + + public Long getPriceList() { + return priceList; + } + + public void setPriceList(Long priceList) { + this.priceList = priceList; + } + + public Long getProductId() { + return productId; + } + + public void setProductId(Long productId) { + this.productId = productId; + } + + public Long getPriority() { + return priority; + } + + public void setPriority(Long priority) { + this.priority = priority; + } + + public Double getPrice() { + return price; + } + + public void setPrice(Double price) { + this.price = price; + } + + public String getCurr() { + return curr; + } + + public void setCurr(String curr) { + this.curr = curr; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Price)) { + return false; + } + return getId() != null && getId().equals(((Price) o).getId()); + } + + @Override + public int hashCode() { + // see https://vladmihalcea.com/how-to-implement-equals-and-hashcode-using-the-jpa-entity-identifier/ + return getClass().hashCode(); + } + + // prettier-ignore + @Override + public String toString() { + return "Price{" + + "id=" + getId() + + ", brandId=" + getBrandId() + + ", startDate='" + getStartDate() + "'" + + ", endDate='" + getEndDate() + "'" + + ", price_list=" + getPriceList() + + ", product_id=" + getProductId() + + ", priority=" + getPriority() + + ", price=" + getPrice() + + ", curr='" + getCurr() + "'" + + "}"; + } +} diff --git a/src/main/java/com/jzamoram/itx/repository/PriceRepository.java b/src/main/java/com/jzamoram/itx/repository/PriceRepository.java new file mode 100644 index 0000000..4f7a85f --- /dev/null +++ b/src/main/java/com/jzamoram/itx/repository/PriceRepository.java @@ -0,0 +1,10 @@ +package com.jzamoram.itx.repository; + +import com.jzamoram.itx.domain.Price; + +import org.springframework.data.jpa.repository.*; +import org.springframework.data.querydsl.QuerydslPredicateExecutor; +import org.springframework.stereotype.Repository; + +@Repository +public interface PriceRepository extends JpaRepository, QuerydslPredicateExecutor {} diff --git a/src/main/java/com/jzamoram/itx/service/PriceService.java b/src/main/java/com/jzamoram/itx/service/PriceService.java new file mode 100644 index 0000000..f5719f1 --- /dev/null +++ b/src/main/java/com/jzamoram/itx/service/PriceService.java @@ -0,0 +1,15 @@ +package com.jzamoram.itx.service; + +import com.jzamoram.itx.domain.Price; + +import java.time.Instant; +import java.util.List; + +/** + * Service Interface for managing {@link com.jzamoram.itx.domain.Price}. + */ +public interface PriceService { + + List query(Long brandId, Long productId, Instant applicationDate); + +} diff --git a/src/main/java/com/jzamoram/itx/service/impl/PriceServiceImpl.java b/src/main/java/com/jzamoram/itx/service/impl/PriceServiceImpl.java new file mode 100644 index 0000000..a13b3c1 --- /dev/null +++ b/src/main/java/com/jzamoram/itx/service/impl/PriceServiceImpl.java @@ -0,0 +1,45 @@ +package com.jzamoram.itx.service.impl; + +import com.jzamoram.itx.domain.Price; +import com.jzamoram.itx.domain.QPrice; +import com.jzamoram.itx.repository.PriceRepository; +import com.jzamoram.itx.service.PriceService; +import com.querydsl.core.types.dsl.BooleanExpression; + +import java.time.Instant; +import java.util.List; + +import org.apache.commons.collections4.IterableUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +public class PriceServiceImpl implements PriceService { + + private static final Logger LOG = LoggerFactory.getLogger(PriceServiceImpl.class); + + private final PriceRepository priceRepository; + + public PriceServiceImpl(PriceRepository priceRepository) { + this.priceRepository = priceRepository; + } + + @Override + @Transactional(readOnly = true) + public List query(Long brandId, Long productId, Instant applicationDate) { + LOG.debug("Request to query Price : productId {}, brandId {}, applicationDate {}", productId, brandId, + applicationDate); + QPrice priceFilter = QPrice.price1; + BooleanExpression productIdBE = priceFilter.productId.eq(productId); + BooleanExpression brandIdBE = priceFilter.brandId.eq(brandId); + BooleanExpression applicationDateGteBE = priceFilter.startDate.lt(applicationDate).or(priceFilter.startDate.eq(applicationDate)); + BooleanExpression applicationDateLteBE = priceFilter.endDate.gt(applicationDate).or(priceFilter.endDate.eq(applicationDate)); + + return IterableUtils.toList( + priceRepository.findAll(productIdBE.and(brandIdBE).and(applicationDateGteBE).and(applicationDateLteBE), Sort.by(Sort.Direction.DESC, "priority"))); + } +} diff --git a/src/main/java/com/jzamoram/itx/web/rest/PriceResource.java b/src/main/java/com/jzamoram/itx/web/rest/PriceResource.java new file mode 100644 index 0000000..66b97c1 --- /dev/null +++ b/src/main/java/com/jzamoram/itx/web/rest/PriceResource.java @@ -0,0 +1,38 @@ +package com.jzamoram.itx.web.rest; + +import com.jzamoram.itx.domain.Price; +import com.jzamoram.itx.repository.PriceRepository; +import com.jzamoram.itx.service.PriceService; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/price") +public class PriceResource { + + private static final Logger LOG = LoggerFactory.getLogger(PriceResource.class); + + private final PriceService priceService; + + public PriceResource(PriceService priceService, PriceRepository priceRepository) { + this.priceService = priceService; + } + + @GetMapping(value = "/{brandId}/{productId}", params = "applicationDate") + public ResponseEntity getPriceFilter(@PathVariable("brandId") Long brandId, + @PathVariable("productId") Long productId, + @RequestParam("applicationDate") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime applicationDate) { + LOG.debug("REST request to get Price for brandId {}, productId {} and applyDate {}", brandId, productId, + applicationDate); + List price = priceService.query(brandId, productId, + applicationDate.toInstant(OffsetDateTime.now().getOffset())); + return ResponseUtil.getFirstElement(price, null); + } + +} diff --git a/src/main/java/com/jzamoram/itx/web/rest/ResponseUtil.java b/src/main/java/com/jzamoram/itx/web/rest/ResponseUtil.java new file mode 100644 index 0000000..431287f --- /dev/null +++ b/src/main/java/com/jzamoram/itx/web/rest/ResponseUtil.java @@ -0,0 +1,22 @@ +package com.jzamoram.itx.web.rest; + +import java.util.List; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.server.ResponseStatusException; + +public interface ResponseUtil { + + static ResponseEntity getFirstElement(List maybeResponse, HttpHeaders header) { + + if(maybeResponse!=null && maybeResponse.size()>0) + { + return ResponseEntity.ok().headers(header).body(maybeResponse.get(0)); + } + else { + throw new ResponseStatusException(HttpStatus.NOT_FOUND); + } + } + +} diff --git a/src/main/resources/.h2.server.properties b/src/main/resources/.h2.server.properties new file mode 100644 index 0000000..8774a29 --- /dev/null +++ b/src/main/resources/.h2.server.properties @@ -0,0 +1,27 @@ +#H2 Server Properties +#Thu Jan 08 14:07:46 CET 2026 +0=Generic JNDI Data Source|javax.naming.InitialContext|java\:comp/env/jdbc/Test|sa +1=Generic Teradata|com.teradata.jdbc.TeraDriver|jdbc\:teradata\://whomooz/| +10=Generic DB2|com.ibm.db2.jcc.DB2Driver|jdbc\:db2\://localhost/test| +11=Generic Oracle|oracle.jdbc.driver.OracleDriver|jdbc\:oracle\:thin\:@localhost\:1521\:XE|sa +12=Generic MS SQL Server 2000|com.microsoft.jdbc.sqlserver.SQLServerDriver|jdbc\:microsoft\:sqlserver\://localhost\:1433;DatabaseName\=sqlexpress|sa +13=Generic MS SQL Server 2005|com.microsoft.sqlserver.jdbc.SQLServerDriver|jdbc\:sqlserver\://localhost;DatabaseName\=test|sa +14=Generic PostgreSQL|org.postgresql.Driver|jdbc\:postgresql\:test| +15=Generic MySQL|com.mysql.cj.jdbc.Driver|jdbc\:mysql\://localhost\:3306/test| +16=Generic MariaDB|org.mariadb.jdbc.Driver|jdbc\:mariadb\://localhost\:3306/test| +17=Generic HSQLDB|org.hsqldb.jdbcDriver|jdbc\:hsqldb\:test;hsqldb.default_table_type\=cached|sa +18=Generic Derby (Server)|org.apache.derby.client.ClientAutoloadedDriver|jdbc\:derby\://localhost\:1527/test;create\=true|sa +19=Generic Derby (Embedded)|org.apache.derby.iapi.jdbc.AutoloadedDriver|jdbc\:derby\:test;create\=true|sa +2=Generic Snowflake|com.snowflake.client.jdbc.SnowflakeDriver|jdbc\:snowflake\://accountName.snowflakecomputing.com| +20=Generic H2 (Server)|org.h2.Driver|jdbc\:h2\:tcp\://localhost/~/test|sa +21=Generic H2 (Embedded)|org.h2.Driver|jdbc\:h2\:mem\:itx;DB_CLOSE_DELAY\=-1|itx +3=Generic Redshift|com.amazon.redshift.jdbc42.Driver|jdbc\:redshift\://endpoint\:5439/database| +4=Generic Impala|org.cloudera.impala.jdbc41.Driver|jdbc\:impala\://clustername\:21050/default| +5=Generic Hive 2|org.apache.hive.jdbc.HiveDriver|jdbc\:hive2\://clustername\:10000/default| +6=Generic Hive|org.apache.hadoop.hive.jdbc.HiveDriver|jdbc\:hive\://clustername\:10000/default| +7=Generic Azure SQL|com.microsoft.sqlserver.jdbc.SQLServerDriver|jdbc\:sqlserver\://name.database.windows.net\:1433| +8=Generic Firebird Server|org.firebirdsql.jdbc.FBDriver|jdbc\:firebirdsql\:localhost\:c\:/temp/firebird/test|sysdba +9=Generic SQLite|org.sqlite.JDBC|jdbc\:sqlite\:test|sa +webAllowOthers=false +webPort=8082 +webSSL=false diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 0000000..c8a996b --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,36 @@ +logging: + level: + ROOT: INFO + org.hibernate.SQL: DEBUG + com.jzamoram: DEBUG + +spring: + application: + name: itx + jackson: + serialization: + indent-output: true + datasource: + type: com.zaxxer.hikari.HikariDataSource + url: jdbc:h2:mem:itx;DB_CLOSE_DELAY=-1 + username: itx + password: + hikari: + poolName: Hikari + auto-commit: false + h2: + console: + enabled: true + path: /h2-console + + liquibase: + # Remove 'faker' if you do not want the sample data to be loaded automatically + contexts: dev, faker + main: + allow-bean-definition-overriding: true + profiles: + active: '@spring.profiles.active@' + +server: + port: 8080 + forward-headers-strategy: native diff --git a/src/main/resources/liquibase/changelog/20260103_added_entity_Prices.xml b/src/main/resources/liquibase/changelog/20260103_added_entity_Prices.xml new file mode 100644 index 0000000..f0c594d --- /dev/null +++ b/src/main/resources/liquibase/changelog/20260103_added_entity_Prices.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/liquibase/fake-data/prices.csv b/src/main/resources/liquibase/fake-data/prices.csv new file mode 100644 index 0000000..bc8f069 --- /dev/null +++ b/src/main/resources/liquibase/fake-data/prices.csv @@ -0,0 +1,5 @@ +id;brand_id;start_date;end_date;price_list;product_id;priority;price;curr +1;1;2020-06-14T00:00:00;2020-12-31T23:59:59;1;35455;0;35.50;EUR +2;1;2020-06-14T15:00:00;2020-06-14T18:30:00;2;35455;1;25.45;EUR +3;1;2020-06-15T00:00:00;2020-06-15T11:00:00;3;35455;1;30.50;EUR +4;1;2020-06-15T16:00:00;2020-12-31T23:59:59;4;35455;1;38.95;EUR diff --git a/src/main/resources/liquibase/master.xml b/src/main/resources/liquibase/master.xml new file mode 100644 index 0000000..4cbc623 --- /dev/null +++ b/src/main/resources/liquibase/master.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/java/com/jzamoram/itx/PricesResourceTest.java b/src/test/java/com/jzamoram/itx/PricesResourceTest.java new file mode 100644 index 0000000..80ba0c2 --- /dev/null +++ b/src/test/java/com/jzamoram/itx/PricesResourceTest.java @@ -0,0 +1,71 @@ +package com.jzamoram.itx; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * Integration tests for the {@link PricesResource} REST controller. + */ +@SpringBootTest +@AutoConfigureMockMvc +class PricesResourceTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void test1_priceAt10OnDay14() throws Exception { + StringBuffer sbUrl = new StringBuffer(); + sbUrl.append("/api/price").append("/1").append("/35455"); + mockMvc.perform(get(sbUrl.toString()).param("applicationDate", "2020-06-14T10:00:00")) + .andExpect(status().isOk()).andExpect(jsonPath("$.productId").value(35455)) + .andExpect(jsonPath("$.brandId").value(1)).andExpect(jsonPath("$.priceList").value(1)) + .andExpect(jsonPath("$.price").value(35.50)); + } + + @Test + void test2_priceAt16OnDay14() throws Exception { + StringBuffer sbUrl = new StringBuffer(); + sbUrl.append("/api/price").append("/1").append("/35455"); + mockMvc.perform(get(sbUrl.toString()).param("applicationDate", "2020-06-14T16:00:00")) + .andExpect(status().isOk()).andExpect(jsonPath("$.productId").value(35455)) + .andExpect(jsonPath("$.brandId").value(1)).andExpect(jsonPath("$.priceList").value(2)) + .andExpect(jsonPath("$.price").value(25.45)); + } + + @Test + void test3_priceAt21OnDay14() throws Exception { + StringBuffer sbUrl = new StringBuffer(); + sbUrl.append("/api/price").append("/1").append("/35455"); + mockMvc.perform(get(sbUrl.toString()).param("applicationDate", "2020-06-14T21:00:00")) + .andExpect(status().isOk()).andExpect(jsonPath("$.productId").value(35455)) + .andExpect(jsonPath("$.brandId").value(1)).andExpect(jsonPath("$.priceList").value(1)) + .andExpect(jsonPath("$.price").value(35.50)); + } + + @Test + void test4_priceAt10OnDay15() throws Exception { + StringBuffer sbUrl = new StringBuffer(); + sbUrl.append("/api/price").append("/1").append("/35455"); + mockMvc.perform(get(sbUrl.toString()).param("applicationDate", "2020-06-15T10:00:00")) + .andExpect(status().isOk()).andExpect(jsonPath("$.productId").value(35455)) + .andExpect(jsonPath("$.brandId").value(1)).andExpect(jsonPath("$.priceList").value(3)) + .andExpect(jsonPath("$.price").value(30.50)); + } + + @Test + void test5_priceAt21OnDay16() throws Exception { + StringBuffer sbUrl = new StringBuffer(); + sbUrl.append("/api/price").append("/1").append("/35455"); + mockMvc.perform(get(sbUrl.toString()).param("applicationDate", "2020-06-16T21:00:00")) + .andExpect(status().isOk()).andExpect(jsonPath("$.productId").value(35455)) + .andExpect(jsonPath("$.brandId").value(1)).andExpect(jsonPath("$.priceList").value(4)) + .andExpect(jsonPath("$.price").value(38.95)); + } +}