diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..29805753e --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/Sprint 2/prototype2/target +*.log +*.iml +/Sprint 2/prototype2/docker-compose.yml +*.gz +Sprint 2/prototype2/qodana.yaml diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 000000000..6f97dc024 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/dataSources.local.xml b/.idea/dataSources.local.xml new file mode 100644 index 000000000..e5b74ffd6 --- /dev/null +++ b/.idea/dataSources.local.xml @@ -0,0 +1,22 @@ + + + + + + #@ + ` + + + master_key + root + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml new file mode 100644 index 000000000..451913f86 --- /dev/null +++ b/.idea/dataSources.xml @@ -0,0 +1,17 @@ + + + + + mysql.8 + true + com.mysql.cj.jdbc.Driver + jdbc:mysql://localhost:3306 + + + + + + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 000000000..e13a3d47c --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 000000000..1ed94d38f --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml new file mode 100644 index 000000000..712ab9d98 --- /dev/null +++ b/.idea/jarRepositories.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/jsLibraryMappings.xml b/.idea/jsLibraryMappings.xml new file mode 100644 index 000000000..f9eb689a3 --- /dev/null +++ b/.idea/jsLibraryMappings.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 000000000..2cea27280 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/All_Tests.xml b/.idea/runConfigurations/All_Tests.xml new file mode 100644 index 000000000..ea8af431b --- /dev/null +++ b/.idea/runConfigurations/All_Tests.xml @@ -0,0 +1,18 @@ + + + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index b2588bc51..573ed80cc 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ My groupmembers are: -- XXXX -- XXXX -- XXXX -- XXXX - +- Yassine Bihi +- Diego Rivera +- Christian Jackson +- Jad El Masri +- Ozi Jawad ------------------ Fill in some information about your project under this ------------------ diff --git a/Sprint 1/JydocTestCases.pdf b/Sprint 1/JydocTestCases.pdf new file mode 100644 index 000000000..dd678e5f2 Binary files /dev/null and b/Sprint 1/JydocTestCases.pdf differ diff --git a/Sprint 1/TestCasesPlaceholder.txt b/Sprint 1/TestCasesPlaceholder.txt index e69de29bb..927bb8564 100644 --- a/Sprint 1/TestCasesPlaceholder.txt +++ b/Sprint 1/TestCasesPlaceholder.txt @@ -0,0 +1 @@ +//check JydocTestCases.pdf diff --git a/Sprint 1/codePlaceHolder.txt b/Sprint 1/codePlaceHolder.txt index e69de29bb..8fc10861b 100644 --- a/Sprint 1/codePlaceHolder.txt +++ b/Sprint 1/codePlaceHolder.txt @@ -0,0 +1 @@ +//Check deliverable3 folder diff --git a/Sprint 1/deliverable3/HELP.md b/Sprint 1/deliverable3/HELP.md new file mode 100644 index 000000000..6bed04940 --- /dev/null +++ b/Sprint 1/deliverable3/HELP.md @@ -0,0 +1,29 @@ +# Getting Started + +### Reference Documentation +For further reference, please consider the following sections: + +* [Official Apache Maven documentation](https://maven.apache.org/guides/index.html) +* [Spring Boot Maven Plugin Reference Guide](https://docs.spring.io/spring-boot/3.4.4/maven-plugin) +* [Create an OCI image](https://docs.spring.io/spring-boot/3.4.4/maven-plugin/build-image.html) +* [Spring Web](https://docs.spring.io/spring-boot/3.4.4/reference/web/servlet.html) +* [Thymeleaf](https://docs.spring.io/spring-boot/3.4.4/reference/web/servlet.html#web.servlet.spring-mvc.template-engines) +* [htmx](https://github.com/wimdeblauwe/htmx-spring-boot) + +### Guides +The following guides illustrate how to use some features concretely: + +* [Building a RESTful Web Service](https://spring.io/guides/gs/rest-service/) +* [Serving Web Content with Spring MVC](https://spring.io/guides/gs/serving-web-content/) +* [Building REST services with Spring](https://spring.io/guides/tutorials/rest/) +* [Handling Form Submission](https://spring.io/guides/gs/handling-form-submission/) +* [htmx](https://www.youtube.com/watch?v=j-rfPoXe5aE) +* [Accessing data with MySQL](https://spring.io/guides/gs/accessing-data-mysql/) + +### Maven Parent overrides + +Due to Maven's design, elements are inherited from the parent POM to the project POM. +While most of the inheritance is fine, it also inherits unwanted elements like `` and `` from the parent. +To prevent this, the project POM contains empty overrides for these elements. +If you manually switch to a different parent and actually want the inheritance, you need to remove those overrides. + diff --git a/Sprint 1/deliverable3/mvnw b/Sprint 1/deliverable3/mvnw new file mode 100644 index 000000000..b9a45d76e --- /dev/null +++ b/Sprint 1/deliverable3/mvnw @@ -0,0 +1,259 @@ +#!/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.2 +# +# 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:]' +} + +# 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 <"${0%/*}/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.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${0##*/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 +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/Sprint 1/deliverable3/mvnw.cmd b/Sprint 1/deliverable3/mvnw.cmd new file mode 100644 index 000000000..b150b91ed --- /dev/null +++ b/Sprint 1/deliverable3/mvnw.cmd @@ -0,0 +1,149 @@ +<# : 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.2 +@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) { "/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_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain" +if ($env:MAVEN_USER_HOME) { + $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain" +} +$MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::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 +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -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/Sprint 1/deliverable3/pom.xml b/Sprint 1/deliverable3/pom.xml new file mode 100644 index 000000000..d8a68814f --- /dev/null +++ b/Sprint 1/deliverable3/pom.xml @@ -0,0 +1,108 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.4.4 + + + com.jydoc + deliverable3 + 0.0.1-SNAPSHOT + deliverable3 + Demo project for Spring Boot + + + + + + + + + + + + + + + 21 + + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + org.springframework.boot + spring-boot-starter-web + + + io.github.wimdeblauwe + htmx-spring-boot-thymeleaf + 4.0.1 + + + + com.mysql + mysql-connector-j + runtime + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-test + test + + + org.modelmapper + modelmapper + 3.2.0 + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-validation + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + + diff --git a/Sprint 1/deliverable3/src/main/java/com/jydoc/deliverable3/Controller/indexController.java b/Sprint 1/deliverable3/src/main/java/com/jydoc/deliverable3/Controller/indexController.java new file mode 100644 index 000000000..e3bfd2dae --- /dev/null +++ b/Sprint 1/deliverable3/src/main/java/com/jydoc/deliverable3/Controller/indexController.java @@ -0,0 +1,101 @@ +//This controller "listens" for user responses. +//TODO: We need to figure out how to set the user input into a DTO, then convert to Model and add into database + +package com.jydoc.deliverable3.Controller; +import com.jydoc.deliverable3.DTO.UserDTO; +import com.jydoc.deliverable3.Model.UserModel; +import com.jydoc.deliverable3.Repository.UserRepository; +import com.jydoc.deliverable3.Service.UserService; +import jakarta.validation.Valid; + +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PostMapping; + + +import java.time.LocalDate; + + + + +@Controller +public class indexController { + + private final UserRepository userRepository; + private final UserService userService; + + public indexController(UserRepository userRepository) { + this.userRepository = userRepository; + this.userService = new UserService(); + } + + // This method maps to the root URL ("/") + @GetMapping("/") + public String index(Model model) { + + // Add the current date to the model + model.addAttribute("currentDate", LocalDate.now()); + + // Return the name of the Thymeleaf template ("index") + return "index"; + } + + @GetMapping("/login") + public String login(Model model) { + if (!model.containsAttribute("userDTO")) { + model.addAttribute("userDTO", new UserDTO()); + } + return "login"; // Make sure the login page is returned + } + + @PostMapping("/login") + public String handleLogin(@Valid @ModelAttribute("userDTO") UserDTO userDTO, BindingResult result, Model model) { + + if (result.hasErrors()) { + return "login"; + } + + boolean isAuthenticated = userService.authenticate(userDTO.getEmail(), userDTO.getPassword()); + + if(!isAuthenticated) { + model.addAttribute("error", "Invalid email or password"); + return "login"; // Return to the login page and show an error message + } + return "redirect:/"; + + + + } + + @GetMapping("/register") + public String showRegistrationForm(Model model) { + model.addAttribute("user", new UserDTO()); + return "register"; + } + + @PostMapping("/register") + public String registerUser(@Valid @ModelAttribute("user") UserDTO userDTO, BindingResult result, Model model) { + //TODO: Implement registration system + + if (result.hasErrors()) { + + return "register"; // TODO: Implement register error to bring popup then refresh + } + else { + UserModel user = new UserModel(); + user.setEmail(userDTO.getEmail()); + user.setPassword(userDTO.getPassword()); + user.setFirstName(userDTO.getFirstName()); //TODO: Transfer to Service package + user.setLastName(userDTO.getLastName()); + userRepository.save(user); + return "redirect:/"; + } + + + + + } +} diff --git a/Sprint 1/deliverable3/src/main/java/com/jydoc/deliverable3/DTO/UserDTO.java b/Sprint 1/deliverable3/src/main/java/com/jydoc/deliverable3/DTO/UserDTO.java new file mode 100644 index 000000000..dfbdda002 --- /dev/null +++ b/Sprint 1/deliverable3/src/main/java/com/jydoc/deliverable3/DTO/UserDTO.java @@ -0,0 +1,38 @@ +// This DTO accepts user input to fill variables, then is converted by +// UserService into a UserModel. +// Also used for business logic. +//test +package com.jydoc.deliverable3.DTO; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; +//TODO: Authentication needs to be fixed here +@Data +public class UserDTO { //@Data applies Getters, Setters, NoArgsConstructor, and AllArgsConstructor + + private int id; + private boolean admin; + + @NotBlank(message = "Email cannot be empty") + @Email(message = "Invalid email format") + private String email; + + @NotBlank(message = "Password cannot be empty") + @Size(min = 6, message = "Password must be at least 6 characters") + @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).{6,}$", + message = "Password must contain at least one letter and one number") + private String password; + + @NotBlank(message = "First name cannot be empty") + @Pattern(regexp = "^[\\p{L}'-]+$", message = "First name can only contain letters, hyphens, and apostrophes") + @Size(min = 2, max = 30, message = "First name must be between 2 and 30 characters") + private String firstName; + + @NotBlank(message = "Last name cannot be empty") + @Pattern(regexp = "^[\\p{L}'-]+$", message = "Last name can only contain letters, hyphens, and apostrophes") + @Size(min = 2, max = 30, message = "Last name must be between 2 and 30 characters") + private String lastName; +} diff --git a/Sprint 1/deliverable3/src/main/java/com/jydoc/deliverable3/Deliverable3Application.java b/Sprint 1/deliverable3/src/main/java/com/jydoc/deliverable3/Deliverable3Application.java new file mode 100644 index 000000000..64ce3b1c7 --- /dev/null +++ b/Sprint 1/deliverable3/src/main/java/com/jydoc/deliverable3/Deliverable3Application.java @@ -0,0 +1,13 @@ +package com.jydoc.deliverable3; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class Deliverable3Application { + + public static void main(String[] args) { + SpringApplication.run(Deliverable3Application.class, args); + } + +} diff --git a/Sprint 1/deliverable3/src/main/java/com/jydoc/deliverable3/GlobalExceptionHandler.java b/Sprint 1/deliverable3/src/main/java/com/jydoc/deliverable3/GlobalExceptionHandler.java new file mode 100644 index 000000000..cc52e6d56 --- /dev/null +++ b/Sprint 1/deliverable3/src/main/java/com/jydoc/deliverable3/GlobalExceptionHandler.java @@ -0,0 +1,48 @@ +package com.jydoc.deliverable3; + +import jakarta.validation.ConstraintViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.validation.FieldError; + +import java.util.HashMap; +import java.util.Map; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + // Handle validation errors (MethodArgumentNotValidException) + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidationErrors(MethodArgumentNotValidException ex) { + Map errors = new HashMap<>(); + + ex.getBindingResult().getAllErrors().forEach((error) -> { + String fieldName = ((FieldError) error).getField(); + String errorMessage = error.getDefaultMessage(); + errors.put(fieldName, errorMessage); + }); + + return new ResponseEntity<>(errors, HttpStatus.BAD_REQUEST); // Return errors with 400 status + } + + // Handle ConstraintViolationException (if needed for specific validation annotations) + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity> handleConstraintViolation(ConstraintViolationException ex) { + Map errors = new HashMap<>(); + + ex.getConstraintViolations().forEach(violation -> { + errors.put(violation.getPropertyPath().toString(), violation.getMessage()); + }); + + return new ResponseEntity<>(errors, HttpStatus.BAD_REQUEST); + } + + // Global exception handler for all other exceptions + @ExceptionHandler(Exception.class) + public ResponseEntity handleGlobalException(Exception ex) { + return new ResponseEntity<>(ex.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); // 500 status for internal errors + } +} diff --git a/Sprint 1/deliverable3/src/main/java/com/jydoc/deliverable3/Model/UserModel.java b/Sprint 1/deliverable3/src/main/java/com/jydoc/deliverable3/Model/UserModel.java new file mode 100644 index 000000000..f8ddd88bc --- /dev/null +++ b/Sprint 1/deliverable3/src/main/java/com/jydoc/deliverable3/Model/UserModel.java @@ -0,0 +1,57 @@ +//The User Model is used to interact and create the database + + +package com.jydoc.deliverable3.Model; +import jakarta.persistence.*; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.*; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Getter +@Setter +@NoArgsConstructor + +public class UserModel { +//Each row in the database is a unique user containing all of these columns + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name="userid") + private int id; + + + @Column(name="userisadmin") + private boolean admin; + + @NotBlank + @Size(min = 2, max = 50, message = "First name must be 2-50 characters") + @Pattern(regexp = "^[\\p{L}'-]+$", message = "First name can only contain letters, hyphens, and apostrophes") + @Column(name="userfirstname") + private String firstName; + + @NotBlank + @Size(min = 2, max = 50, message = "Last name must be 2-50 characters") + @Pattern(regexp = "^[\\p{L}'-]+$", message = "Last name can only contain letters, hyphens, and apostrophes") + @Column(name="userlastname") + private String lastName; + + @NotBlank + @NotBlank(message = "Email cannot be blank") + @Email(message = "Email must be valid") + @Column(name="useremail", unique=true) + private String email; + + @Size(min = 6, message = "Password must be at least 6 characters") + @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).{6,}$", + message = "Password must contain at least one letter and one number") + @NotBlank(message = "Password cannot be blank") + @Column(name="userpassword") + private String password; + +} diff --git a/Sprint 1/deliverable3/src/main/java/com/jydoc/deliverable3/Repository/UserRepository.java b/Sprint 1/deliverable3/src/main/java/com/jydoc/deliverable3/Repository/UserRepository.java new file mode 100644 index 000000000..d483174a7 --- /dev/null +++ b/Sprint 1/deliverable3/src/main/java/com/jydoc/deliverable3/Repository/UserRepository.java @@ -0,0 +1,26 @@ +//This is where the backend interacts with the database to retrieve or add data + +package com.jydoc.deliverable3.Repository; +import com.jydoc.deliverable3.Model.UserModel; +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface UserRepository extends CrudRepository { + + + + //Custom Queries + List findById(int id); //TODO: Switch this to Long type + List findByEmail(String email); + List findByFirstName(String firstName); + List findByLastName(String lastName); + List findByFirstNameAndLastName(String firstName, String lastName); + + + + + +} diff --git a/Sprint 1/deliverable3/src/main/java/com/jydoc/deliverable3/Service/UserService.java b/Sprint 1/deliverable3/src/main/java/com/jydoc/deliverable3/Service/UserService.java new file mode 100644 index 000000000..5209d3c7b --- /dev/null +++ b/Sprint 1/deliverable3/src/main/java/com/jydoc/deliverable3/Service/UserService.java @@ -0,0 +1,53 @@ +//This is where user data is processed. + +package com.jydoc.deliverable3.Service; +import com.jydoc.deliverable3.DTO.UserDTO; +import com.jydoc.deliverable3.Model.UserModel; +import com.jydoc.deliverable3.Repository.UserRepository; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public class UserService { + + UserRepository userRepository; + + + public UserModel convertToEntity(UserDTO UserDto) { + + UserModel user = new UserModel(); + user.setId(UserDto.getId()); + user.setAdmin(UserDto.isAdmin()); + user.setFirstName(UserDto.getFirstName()); + user.setLastName(UserDto.getLastName()); + user.setEmail(UserDto.getEmail()); + user.setPassword(UserDto.getPassword()); + return user; + } + + public UserDTO convertToDTO(UserModel UserModel) { + + UserDTO user = new UserDTO(); + user.setId(UserModel.getId()); + user.setAdmin(UserModel.isAdmin()); + user.setFirstName(UserModel.getFirstName()); + user.setLastName(UserModel.getLastName()); + user.setEmail(UserModel.getEmail()); + user.setPassword(UserModel.getPassword()); + return user; + } + + + + + + public boolean authenticate(@NotBlank(message = "Email cannot be empty") @Email(message = "Invalid email format") String email, @NotBlank(message = "Password cannot be empty") @Size(min = 6, message = "Password must be at least 6 characters") @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).{6,}$", + message = "Password must contain at least one letter and one number") String password) { + return true; + + + } + + +} diff --git a/Sprint 1/deliverable3/src/main/resources/application.properties b/Sprint 1/deliverable3/src/main/resources/application.properties new file mode 100644 index 000000000..e1fff99c8 --- /dev/null +++ b/Sprint 1/deliverable3/src/main/resources/application.properties @@ -0,0 +1,9 @@ + +#Server Configuration + +spring.application.name=deliverable3 +spring.jpa.hibernate.ddl-auto=create-drop +spring.datasource.url=jdbc:mysql://localhost:3306/user_database +spring.datasource.username=root +spring.datasource.password= +spring.datasource.driverClassName=com.mysql.jdbc.Driver diff --git a/Sprint 1/deliverable3/src/main/resources/templates/error.html b/Sprint 1/deliverable3/src/main/resources/templates/error.html new file mode 100644 index 000000000..130701ba9 --- /dev/null +++ b/Sprint 1/deliverable3/src/main/resources/templates/error.html @@ -0,0 +1,10 @@ + + + + + Error + + +Error + + \ No newline at end of file diff --git a/Sprint 1/deliverable3/src/main/resources/templates/index.html b/Sprint 1/deliverable3/src/main/resources/templates/index.html new file mode 100644 index 000000000..fae1fc07c --- /dev/null +++ b/Sprint 1/deliverable3/src/main/resources/templates/index.html @@ -0,0 +1,28 @@ + + + + + Thymeleaf Example + + + +

Welcome to Thymeleaf!

+ +

+ +
+

Current Date and Time:

+
+ + +
+ +
+ + +
+ +
+ + + diff --git a/Sprint 1/deliverable3/src/main/resources/templates/login.html b/Sprint 1/deliverable3/src/main/resources/templates/login.html new file mode 100644 index 000000000..fe9554099 --- /dev/null +++ b/Sprint 1/deliverable3/src/main/resources/templates/login.html @@ -0,0 +1,33 @@ + + + + + Login + + +

Login

+ + +
+

+
+ + +
+
+ + +
+ +
+ + +
+ +
+ +
+
+ + + \ No newline at end of file diff --git a/Sprint 1/deliverable3/src/main/resources/templates/loginSuccess.html b/Sprint 1/deliverable3/src/main/resources/templates/loginSuccess.html new file mode 100644 index 000000000..7a517bfcb --- /dev/null +++ b/Sprint 1/deliverable3/src/main/resources/templates/loginSuccess.html @@ -0,0 +1,10 @@ + + + + + Title + + +Login Success + + \ No newline at end of file diff --git a/Sprint 1/deliverable3/src/main/resources/templates/register.html b/Sprint 1/deliverable3/src/main/resources/templates/register.html new file mode 100644 index 000000000..89904066b --- /dev/null +++ b/Sprint 1/deliverable3/src/main/resources/templates/register.html @@ -0,0 +1,55 @@ + + + + User Registration + + + +

Register

+
+ + + + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + diff --git a/Sprint 1/deliverable3/src/main/resources/templates/styles.css b/Sprint 1/deliverable3/src/main/resources/templates/styles.css new file mode 100644 index 000000000..046e065ca --- /dev/null +++ b/Sprint 1/deliverable3/src/main/resources/templates/styles.css @@ -0,0 +1,14 @@ +.error { + color: #dc3545; + font-size: 0.875em; +} +.error-border { + border-color: #dc3545; +} +.error-list { + background-color: #f8d7da; + border: 1px solid #f5c6cb; + border-radius: 4px; + padding: 10px; + margin-bottom: 20px; +} diff --git a/Sprint 1/deliverable3/src/test/java/com/jydoc/deliverable3/Deliverable3ApplicationTests.java b/Sprint 1/deliverable3/src/test/java/com/jydoc/deliverable3/Deliverable3ApplicationTests.java new file mode 100644 index 000000000..bd3bea53d --- /dev/null +++ b/Sprint 1/deliverable3/src/test/java/com/jydoc/deliverable3/Deliverable3ApplicationTests.java @@ -0,0 +1,156 @@ +package com.jydoc.deliverable3; + +import com.jydoc.deliverable3.Model.UserModel; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +class Deliverable3ApplicationTests { + + private Validator validator; + + @BeforeEach + void setUp() { + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + validator = factory.getValidator(); + } + + @Test + void contextLoads() { + assertDoesNotThrow(() -> {}, "Context should load successfully"); + } + + @Test + void testValidUserModel() { + UserModel validUser = new UserModel(); + validUser.setFirstName("John"); + validUser.setLastName("Doe"); + validUser.setEmail("john.doe@example.com"); + validUser.setPassword("Password123"); + validUser.setAdmin(false); + + Set> violations = validator.validate(validUser); + assertTrue(violations.isEmpty(), "Valid user should have no violations"); + } + + @Test + void testFirstNameValidations() { + UserModel user = new UserModel(); + user.setLastName("Doe"); + user.setEmail("test@example.com"); + user.setPassword("Password123"); + + // Test first name too short + user.setFirstName("A"); + Set> violations = validator.validate(user); + assertFalse(violations.isEmpty(), "First name less than 2 characters should be invalid"); + + // Test first name too long + user.setFirstName("ThisIsAVeryLongFirstNameThatExceedsFiftyCharactersLimit"); + violations = validator.validate(user); + assertFalse(violations.isEmpty(), "First name over 50 characters should be invalid"); + + // Test valid first name + user.setFirstName("John"); + violations = validator.validate(user); + assertTrue(violations.isEmpty(), "Valid first name should pass"); + } + + @Test + void testLastNameValidations() { + UserModel user = new UserModel(); + user.setFirstName("John"); + user.setEmail("test@example.com"); + user.setPassword("Password123"); + + // Test last name too short + user.setLastName("A"); + Set> violations = validator.validate(user); + assertFalse(violations.isEmpty(), "Last name less than 2 characters should be invalid"); + + // Test last name too long + user.setLastName("ThisIsAVeryLongLastNameThatExceedsFiftyCharactersLimit"); + violations = validator.validate(user); + assertFalse(violations.isEmpty(), "Last name over 50 characters should be invalid"); + + // Test valid last name + user.setLastName("Doe"); + violations = validator.validate(user); + assertTrue(violations.isEmpty(), "Valid last name should pass"); + } + + @Test + void testEmailValidations() { + UserModel user = new UserModel(); + user.setFirstName("John"); + user.setLastName("Doe"); + user.setPassword("Password123"); + + // Test blank email + user.setEmail(""); + Set> violations = validator.validate(user); + assertFalse(violations.isEmpty(), "Blank email should be invalid"); + + // Test invalid email format + user.setEmail("invalid-email"); + violations = validator.validate(user); + assertFalse(violations.isEmpty(), "Invalid email format should be rejected"); + + // Test valid email + user.setEmail("valid.email@example.com"); + violations = validator.validate(user); + assertTrue(violations.isEmpty(), "Valid email should pass"); + } + + @Test + void testPasswordValidations() { + UserModel user = new UserModel(); + user.setFirstName("John"); + user.setLastName("Doe"); + user.setEmail("test@example.com"); + + // Test password too short + user.setPassword("Pwd1"); + Set> violations = validator.validate(user); + assertFalse(violations.isEmpty(), "Password less than 6 characters should be invalid"); + + // Test password without numbers + user.setPassword("Password"); + violations = validator.validate(user); + assertFalse(violations.isEmpty(), "Password without numbers should be invalid"); + + // Test password without letters + user.setPassword("123456"); + violations = validator.validate(user); + assertFalse(violations.isEmpty(), "Password without letters should be invalid"); + + // Test valid password + user.setPassword("Password123"); + violations = validator.validate(user); + assertTrue(violations.isEmpty(), "Valid password should pass"); + } + + @Test + void testAdminFlag() { + UserModel user = new UserModel(); + user.setFirstName("Admin"); + user.setLastName("User"); + user.setEmail("admin@example.com"); + user.setPassword("Admin123"); + + user.setAdmin(true); + assertTrue(user.isAdmin(), "Admin flag should be settable to true"); + + user.setAdmin(false); + assertFalse(user.isAdmin(), "Admin flag should be settable to false"); + } +} \ No newline at end of file diff --git a/Sprint 2/codePlaceHolder.txt b/Sprint 2/codePlaceHolder.txt index e69de29bb..d3f5a12fa 100644 --- a/Sprint 2/codePlaceHolder.txt +++ b/Sprint 2/codePlaceHolder.txt @@ -0,0 +1 @@ + diff --git a/Sprint 2/prototype2/.idea/.gitignore b/Sprint 2/prototype2/.idea/.gitignore new file mode 100644 index 000000000..759b648e2 --- /dev/null +++ b/Sprint 2/prototype2/.idea/.gitignore @@ -0,0 +1,105 @@ +### Java ### +*.class +*.jar +*.war +*.ear +*.nar +hs_err_pid* + +# Build output +target/ +build/ +out/ +bin/ + +# IDE specific files +.idea/ +*.iml +*.ipr +*.iws +.settings/ +.vscode/ +.classpath +.project +.factorypath + +# Eclipse +.metadata/ + +# NetBeans +nbproject/private/ + +nbbuild/ + +nbdist/ +.nb-gradle/ + +### IntelliJ ### +# User-specific +.idea/workspace.xml +.idea/tasks.xml +.idea/dictionaries +.idea/shelf/ +.idea/httpRequests/ + +# Datasource storage +.idea/dataSources/ +.idea/dataSources.local.xml +.idea/dataSources.ids + +# Gradle +.gradle/ +gradle-app.setting + +# Maven +log/ +dependency-reduced-pom.xml + +### Spring Boot ### +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ + +### Database ### +*.db +*.sql +*.h2.db + +### OS generated files ### +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +### Logs ### +*.log +logs/ + +### Environment files ### +.env +*.env +.env.local +.env.development +.env.test +.env.production + +### System Files ### +*.swp +*.swo +*~ +~$* + +### Test files ### +/temp/ +/test-output/ + +### Frontend (if applicable) ### +node_modules/ +dist/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +.class \ No newline at end of file diff --git a/Sprint 2/prototype2/.idea/.name b/Sprint 2/prototype2/.idea/.name new file mode 100644 index 000000000..0df699cf7 --- /dev/null +++ b/Sprint 2/prototype2/.idea/.name @@ -0,0 +1 @@ +deliverable4 \ No newline at end of file diff --git a/Sprint 2/prototype2/.idea/compiler.xml b/Sprint 2/prototype2/.idea/compiler.xml new file mode 100644 index 000000000..d43d9f45e --- /dev/null +++ b/Sprint 2/prototype2/.idea/compiler.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Sprint 2/prototype2/.idea/dataSources.local.xml b/Sprint 2/prototype2/.idea/dataSources.local.xml new file mode 100644 index 000000000..a38d5b959 --- /dev/null +++ b/Sprint 2/prototype2/.idea/dataSources.local.xml @@ -0,0 +1,19 @@ + + + + + + #@ + ` + + + master_key + root + + + + + + + + \ No newline at end of file diff --git a/Sprint 2/prototype2/.idea/dataSources.xml b/Sprint 2/prototype2/.idea/dataSources.xml new file mode 100644 index 000000000..0769c867d --- /dev/null +++ b/Sprint 2/prototype2/.idea/dataSources.xml @@ -0,0 +1,17 @@ + + + + + mysql.8 + true + com.mysql.cj.jdbc.Driver + jdbc:mysql://localhost:3306 + + + + + + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/Sprint 2/prototype2/.idea/dataSources/1d4a9770-c32d-446d-a5c2-de35cbf1f8dd.xml b/Sprint 2/prototype2/.idea/dataSources/1d4a9770-c32d-446d-a5c2-de35cbf1f8dd.xml new file mode 100644 index 000000000..a010eb832 --- /dev/null +++ b/Sprint 2/prototype2/.idea/dataSources/1d4a9770-c32d-446d-a5c2-de35cbf1f8dd.xml @@ -0,0 +1,1275 @@ + + + + + lower/lower + InnoDB + InnoDB + |root||root|localhost|ALTER|G +|root||root|localhost|ALTER ROUTINE|G +|root||root|localhost|APPLICATION_PASSWORD_ADMIN|G +|root||mysql.infoschema|localhost|AUDIT_ABORT_EXEMPT|G +|root||mysql.session|localhost|AUDIT_ABORT_EXEMPT|G +|root||mysql.sys|localhost|AUDIT_ABORT_EXEMPT|G +|root||root|localhost|AUDIT_ABORT_EXEMPT|G +|root||root|localhost|AUDIT_ADMIN|G +|root||mysql.session|localhost|AUTHENTICATION_POLICY_ADMIN|G +|root||root|localhost|AUTHENTICATION_POLICY_ADMIN|G +|root||mysql.session|localhost|BACKUP_ADMIN|G +|root||root|localhost|BACKUP_ADMIN|G +|root||root|localhost|BINLOG_ADMIN|G +|root||root|localhost|BINLOG_ENCRYPTION_ADMIN|G +|root||mysql.session|localhost|CLONE_ADMIN|G +|root||root|localhost|CLONE_ADMIN|G +|root||mysql.session|localhost|CONNECTION_ADMIN|G +|root||root|localhost|CONNECTION_ADMIN|G +|root||root|localhost|CREATE|G +|root||root|localhost|CREATE ROLE|G +|root||root|localhost|CREATE ROUTINE|G +|root||root|localhost|CREATE TABLESPACE|G +|root||root|localhost|CREATE TEMPORARY TABLES|G +|root||root|localhost|CREATE USER|G +|root||root|localhost|CREATE VIEW|G +|root||root|localhost|DELETE|G +|root||root|localhost|DROP|G +|root||root|localhost|DROP ROLE|G +|root||root|localhost|ENCRYPTION_KEY_ADMIN|G +|root||root|localhost|EVENT|G +|root||root|localhost|EXECUTE|G +|root||root|localhost|FILE|G +|root||mysql.infoschema|localhost|FIREWALL_EXEMPT|G +|root||mysql.session|localhost|FIREWALL_EXEMPT|G +|root||mysql.sys|localhost|FIREWALL_EXEMPT|G +|root||root|localhost|FIREWALL_EXEMPT|G +|root||root|localhost|FLUSH_OPTIMIZER_COSTS|G +|root||root|localhost|FLUSH_STATUS|G +|root||root|localhost|FLUSH_TABLES|G +|root||root|localhost|FLUSH_USER_RESOURCES|G +|root||root|localhost|GROUP_REPLICATION_ADMIN|G +|root||root|localhost|GROUP_REPLICATION_STREAM|G +|root||root|localhost|INDEX|G +|root||root|localhost|INNODB_REDO_LOG_ARCHIVE|G +|root||root|localhost|INNODB_REDO_LOG_ENABLE|G +|root||root|localhost|INSERT|G +|root||root|localhost|LOCK TABLES|G +|root||root|localhost|PASSWORDLESS_USER_ADMIN|G +|root||mysql.session|localhost|PERSIST_RO_VARIABLES_ADMIN|G +|root||root|localhost|PERSIST_RO_VARIABLES_ADMIN|G +|root||root|localhost|PROCESS|G +|root||root|localhost|REFERENCES|G +|root||root|localhost|RELOAD|G +|root||root|localhost|REPLICATION CLIENT|G +|root||root|localhost|REPLICATION SLAVE|G +|root||root|localhost|REPLICATION_APPLIER|G +|root||root|localhost|REPLICATION_SLAVE_ADMIN|G +|root||root|localhost|RESOURCE_GROUP_ADMIN|G +|root||root|localhost|RESOURCE_GROUP_USER|G +|root||root|localhost|ROLE_ADMIN|G +|root||mysql.infoschema|localhost|SELECT|G +|root||root|localhost|SELECT|G +|root||root|localhost|SENSITIVE_VARIABLES_OBSERVER|G +|root||root|localhost|SERVICE_CONNECTION_ADMIN|G +|root||mysql.session|localhost|SESSION_VARIABLES_ADMIN|G +|root||root|localhost|SESSION_VARIABLES_ADMIN|G +|root||root|localhost|SET_USER_ID|G +|root||root|localhost|SHOW DATABASES|G +|root||root|localhost|SHOW VIEW|G +|root||root|localhost|SHOW_ROUTINE|G +|root||mysql.session|localhost|SHUTDOWN|G +|root||root|localhost|SHUTDOWN|G +|root||mysql.session|localhost|SUPER|G +|root||root|localhost|SUPER|G +|root||mysql.infoschema|localhost|SYSTEM_USER|G +|root||mysql.session|localhost|SYSTEM_USER|G +|root||mysql.sys|localhost|SYSTEM_USER|G +|root||root|localhost|SYSTEM_USER|G +|root||mysql.session|localhost|SYSTEM_VARIABLES_ADMIN|G +|root||root|localhost|SYSTEM_VARIABLES_ADMIN|G +|root||root|localhost|TABLE_ENCRYPTION_ADMIN|G +|root||root|localhost|TELEMETRY_LOG_ADMIN|G +|root||root|localhost|TRIGGER|G +|root||root|localhost|UPDATE|G +|root||root|localhost|XA_RECOVER_ADMIN|G +|root||root|localhost|grant option|G +performance_schema|schema||mysql.session|localhost|SELECT|G +sys|schema||mysql.sys|localhost|TRIGGER|G + 8.0.41 + + + armscii8 + + + armscii8 + 1 + + + ascii + + + ascii + 1 + + + big5 + + + big5 + 1 + + + binary + 1 + + + cp1250 + + + cp1250 + + + cp1250 + + + cp1250 + 1 + + + cp1250 + + + cp1251 + + + cp1251 + + + cp1251 + 1 + + + cp1251 + + + cp1251 + + + cp1256 + + + cp1256 + 1 + + + cp1257 + + + cp1257 + 1 + + + cp1257 + + + cp850 + + + cp850 + 1 + + + cp852 + + + cp852 + 1 + + + cp866 + + + cp866 + 1 + + + cp932 + + + cp932 + 1 + + + dec8 + + + dec8 + 1 + + + eucjpms + + + eucjpms + 1 + + + euckr + + + euckr + 1 + + + gb18030 + + + gb18030 + 1 + + + gb18030 + + + gb2312 + + + gb2312 + 1 + + + gbk + + + gbk + 1 + + + geostd8 + + + geostd8 + 1 + + + greek + + + greek + 1 + + + hebrew + + + hebrew + 1 + + + hp8 + + + hp8 + 1 + + + keybcs2 + + + keybcs2 + 1 + + + koi8r + + + koi8r + 1 + + + koi8u + + + koi8u + 1 + + + latin1 + + + latin1 + + + latin1 + + + latin1 + + + latin1 + + + latin1 + + + latin1 + + + latin1 + 1 + + + latin2 + + + latin2 + + + latin2 + + + latin2 + 1 + + + latin2 + + + latin5 + + + latin5 + 1 + + + latin7 + + + latin7 + + + latin7 + 1 + + + latin7 + + + macce + + + macce + 1 + + + macroman + + + macroman + 1 + + + sjis + + + sjis + 1 + + + swe7 + + + swe7 + 1 + + + tis620 + + + tis620 + 1 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + 1 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ujis + + + ujis + 1 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + 1 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16le + + + utf16le + 1 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + 1 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + 1 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb4 + 1 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb3_general_ci + + + utf8mb4_0900_ai_ci + + + utf8mb4_0900_ai_ci + + + utf8mb4_0900_ai_ci + + + 2025-03-29.14:40:43 + utf8mb4_0900_ai_ci + + + 0 + localhost + caching_sha2_password + + + 0 + localhost + caching_sha2_password + + + 0 + localhost + caching_sha2_password + + + localhost + caching_sha2_password + + + InnoDB + utf8mb4_0900_ai_ci +
+ + InnoDB + utf8mb4_0900_ai_ci +
+ + InnoDB + row_format +DYNAMIC + utf8mb4_0900_ai_ci +
+ + InnoDB + utf8mb4_0900_ai_ci +
+ + InnoDB + utf8mb4_0900_ai_ci +
+ + 4 + 1 + 1 + bigint|0s + + + 1 + 2 + varchar(50)|0s + + + 3 + varchar(255)|0s + + + id + btree + 1 + + + authority + btree + 1 + + + 1 + 1 + PRIMARY + + + UKq0u5f2cdlshec8tlh6818bhbk + + + 1 + 1 + char(36)|0s + + + 1 + 2 + char(36)|0s + + + 1 + 3 + bigint|0s + + + 1 + 4 + bigint|0s + + + 1 + 5 + int|0s + + + 1 + 6 + bigint|0s + + + 7 + varchar(100)|0s + + + PRIMARY_ID + btree + 1 + + + 1 + 1 + PRIMARY + + + 1 + 1 + char(36)|0s + + + 1 + 2 + varchar(200)|0s + + + 1 + 3 + blob|0s + + + session_primary_id + cascade + PRIMARY_ID + spring_session + + + session_primary_id +attribute_name + btree + 1 + + + 1 + 1 + PRIMARY + + + 1 + 1 + bigint|0s + + + 1 + 2 + bigint|0s + + + authority_id + id + authorities + + + user_id + id + users + + + authority_id +user_id + btree + 1 + + + user_id + btree + + + 1 + 1 + PRIMARY + + + 2 + 1 + 1 + bigint|0s + + + 1 + 2 + bit(1)|0s + + + 1 + 3 + bit(1)|0s + + + 1 + 4 + bit(1)|0s + + + 5 + varchar(100)|0s + + + 1 + 6 + bit(1)|0s + + + 1 + 7 + varchar(100)|0s + + + 1 + 8 + varchar(50)|0s + + + 1 + 9 + varchar(50)|0s + + + 1 + 10 + varchar(50)|0s + + + id + btree + 1 + + + email + btree + 1 + + + username + btree + 1 + + + 1 + 1 + PRIMARY + + + UK6dotkott2kjsp8vw4d0m25fb7 + + + UKr43af9ap4edm43mmtq01oddj6 + +
+
\ No newline at end of file diff --git a/Sprint 2/prototype2/.idea/dataSources/1d4a9770-c32d-446d-a5c2-de35cbf1f8dd/storage_v2/_src_/schema/information_schema.FNRwLQ.meta b/Sprint 2/prototype2/.idea/dataSources/1d4a9770-c32d-446d-a5c2-de35cbf1f8dd/storage_v2/_src_/schema/information_schema.FNRwLQ.meta new file mode 100644 index 000000000..1ff3db2eb --- /dev/null +++ b/Sprint 2/prototype2/.idea/dataSources/1d4a9770-c32d-446d-a5c2-de35cbf1f8dd/storage_v2/_src_/schema/information_schema.FNRwLQ.meta @@ -0,0 +1,2 @@ +#n:information_schema +! [null, 0, null, null, -2147483648, -2147483648] diff --git a/Sprint 2/prototype2/.idea/dataSources/1d4a9770-c32d-446d-a5c2-de35cbf1f8dd/storage_v2/_src_/schema/mysql.osA4Bg.meta b/Sprint 2/prototype2/.idea/dataSources/1d4a9770-c32d-446d-a5c2-de35cbf1f8dd/storage_v2/_src_/schema/mysql.osA4Bg.meta new file mode 100644 index 000000000..86a53f191 --- /dev/null +++ b/Sprint 2/prototype2/.idea/dataSources/1d4a9770-c32d-446d-a5c2-de35cbf1f8dd/storage_v2/_src_/schema/mysql.osA4Bg.meta @@ -0,0 +1,2 @@ +#n:mysql +! [null, 0, null, null, -2147483648, -2147483648] diff --git a/Sprint 2/prototype2/.idea/dataSources/1d4a9770-c32d-446d-a5c2-de35cbf1f8dd/storage_v2/_src_/schema/performance_schema.kIw0nw.meta b/Sprint 2/prototype2/.idea/dataSources/1d4a9770-c32d-446d-a5c2-de35cbf1f8dd/storage_v2/_src_/schema/performance_schema.kIw0nw.meta new file mode 100644 index 000000000..9394db147 --- /dev/null +++ b/Sprint 2/prototype2/.idea/dataSources/1d4a9770-c32d-446d-a5c2-de35cbf1f8dd/storage_v2/_src_/schema/performance_schema.kIw0nw.meta @@ -0,0 +1,2 @@ +#n:performance_schema +! [null, 0, null, null, -2147483648, -2147483648] diff --git a/Sprint 2/prototype2/.idea/dataSources/1d4a9770-c32d-446d-a5c2-de35cbf1f8dd/storage_v2/_src_/schema/sys.zb4BAA.meta b/Sprint 2/prototype2/.idea/dataSources/1d4a9770-c32d-446d-a5c2-de35cbf1f8dd/storage_v2/_src_/schema/sys.zb4BAA.meta new file mode 100644 index 000000000..2f4470bb4 --- /dev/null +++ b/Sprint 2/prototype2/.idea/dataSources/1d4a9770-c32d-446d-a5c2-de35cbf1f8dd/storage_v2/_src_/schema/sys.zb4BAA.meta @@ -0,0 +1,2 @@ +#n:sys +! [null, 0, null, null, -2147483648, -2147483648] diff --git a/Sprint 2/prototype2/.idea/dataSources/1d4a9770-c32d-446d-a5c2-de35cbf1f8dd/storage_v2/_src_/schema/userdatabase.RgIJOQ.meta b/Sprint 2/prototype2/.idea/dataSources/1d4a9770-c32d-446d-a5c2-de35cbf1f8dd/storage_v2/_src_/schema/userdatabase.RgIJOQ.meta new file mode 100644 index 000000000..f6ace1307 --- /dev/null +++ b/Sprint 2/prototype2/.idea/dataSources/1d4a9770-c32d-446d-a5c2-de35cbf1f8dd/storage_v2/_src_/schema/userdatabase.RgIJOQ.meta @@ -0,0 +1,2 @@ +#n:userdatabase +! [0, 0, null, null, -2147483648, -2147483648] diff --git a/Sprint 2/prototype2/.idea/encodings.xml b/Sprint 2/prototype2/.idea/encodings.xml new file mode 100644 index 000000000..63e900193 --- /dev/null +++ b/Sprint 2/prototype2/.idea/encodings.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Sprint 2/prototype2/.idea/inspectionProfiles/Project_Default.xml b/Sprint 2/prototype2/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 000000000..611627079 --- /dev/null +++ b/Sprint 2/prototype2/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/Sprint 2/prototype2/.idea/jarRepositories.xml b/Sprint 2/prototype2/.idea/jarRepositories.xml new file mode 100644 index 000000000..712ab9d98 --- /dev/null +++ b/Sprint 2/prototype2/.idea/jarRepositories.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/Sprint 2/prototype2/.idea/misc.xml b/Sprint 2/prototype2/.idea/misc.xml new file mode 100644 index 000000000..f24c79d1b --- /dev/null +++ b/Sprint 2/prototype2/.idea/misc.xml @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/Sprint 2/prototype2/.idea/vcs.xml b/Sprint 2/prototype2/.idea/vcs.xml new file mode 100644 index 000000000..b2bdec2d7 --- /dev/null +++ b/Sprint 2/prototype2/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Sprint 2/prototype2/.mvn/wrapper/maven-wrapper.properties b/Sprint 2/prototype2/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 000000000..d58dfb70b --- /dev/null +++ b/Sprint 2/prototype2/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,19 @@ +# 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. +wrapperVersion=3.3.2 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip diff --git a/Sprint 2/prototype2/HELP.md b/Sprint 2/prototype2/HELP.md new file mode 100644 index 000000000..21906b98c --- /dev/null +++ b/Sprint 2/prototype2/HELP.md @@ -0,0 +1,39 @@ +# Getting Started + +### Reference Documentation +For further reference, please consider the following sections: + +* [Official Apache Maven documentation](https://maven.apache.org/guides/index.html) +* [Spring Boot Maven Plugin Reference Guide](https://docs.spring.io/spring-boot/3.4.4/maven-plugin) +* [Create an OCI image](https://docs.spring.io/spring-boot/3.4.4/maven-plugin/build-image.html) +* [Spring Web](https://docs.spring.io/spring-boot/3.4.4/reference/web/servlet.html) +* [Spring Security](https://docs.spring.io/spring-boot/3.4.4/reference/web/spring-security.html) +* [Spring Session](https://docs.spring.io/spring-session/reference/) +* [htmx](https://github.com/wimdeblauwe/htmx-spring-boot) +* [Thymeleaf](https://docs.spring.io/spring-boot/3.4.4/reference/web/servlet.html#web.servlet.spring-mvc.template-engines) +* [JDBC API](https://docs.spring.io/spring-boot/3.4.4/reference/data/sql.html) +* [Validation](https://docs.spring.io/spring-boot/3.4.4/reference/io/validation.html) + +### Guides +The following guides illustrate how to use some features concretely: + +* [Building a RESTful Web Service](https://spring.io/guides/gs/rest-service/) +* [Serving Web Content with Spring MVC](https://spring.io/guides/gs/serving-web-content/) +* [Building REST services with Spring](https://spring.io/guides/tutorials/rest/) +* [Securing a Web Application](https://spring.io/guides/gs/securing-web/) +* [Spring Boot and OAuth2](https://spring.io/guides/tutorials/spring-boot-oauth2/) +* [Authenticating a User with LDAP](https://spring.io/guides/gs/authenticating-ldap/) +* [htmx](https://www.youtube.com/watch?v=j-rfPoXe5aE) +* [Handling Form Submission](https://spring.io/guides/gs/handling-form-submission/) +* [Accessing Relational Data using JDBC with Spring](https://spring.io/guides/gs/relational-data-access/) +* [Managing Transactions](https://spring.io/guides/gs/managing-transactions/) +* [Accessing data with MySQL](https://spring.io/guides/gs/accessing-data-mysql/) +* [Validation](https://spring.io/guides/gs/validating-form-input/) + +### Maven Parent overrides + +Due to Maven's design, elements are inherited from the parent POM to the project POM. +While most of the inheritance is fine, it also inherits unwanted elements like `` and `` from the parent. +To prevent this, the project POM contains empty overrides for these elements. +If you manually switch to a different parent and actually want the inheritance, you need to remove those overrides. + diff --git a/Sprint 2/prototype2/mvnw b/Sprint 2/prototype2/mvnw new file mode 100644 index 000000000..19529ddf8 --- /dev/null +++ b/Sprint 2/prototype2/mvnw @@ -0,0 +1,259 @@ +#!/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.2 +# +# 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:]' +} + +# 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 <"${0%/*}/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.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${0##*/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 +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/Sprint 2/prototype2/mvnw.cmd b/Sprint 2/prototype2/mvnw.cmd new file mode 100644 index 000000000..249bdf382 --- /dev/null +++ b/Sprint 2/prototype2/mvnw.cmd @@ -0,0 +1,149 @@ +<# : 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.2 +@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) { "/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_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain" +if ($env:MAVEN_USER_HOME) { + $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain" +} +$MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::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 +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -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/Sprint 2/prototype2/pom.xml b/Sprint 2/prototype2/pom.xml new file mode 100644 index 000000000..c84f8f99f --- /dev/null +++ b/Sprint 2/prototype2/pom.xml @@ -0,0 +1,217 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.4.4 + + + com.jydoc + deliverable4 + 0.0.1-SNAPSHOT + prototype2 + Demo project for Spring Boot + + + + + + + + + + + + + + + 21 + + + + org.springframework.boot + spring-boot-starter-jdbc + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springframework.boot + spring-boot-starter-web + + + io.github.wimdeblauwe + htmx-spring-boot-thymeleaf + 4.0.1 + + + org.thymeleaf.extras + thymeleaf-extras-springsecurity6 + + + + + javax.validation + validation-api + 2.0.1.Final + + + + + com.mysql + mysql-connector-j + runtime + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-test-autoconfigure + test + + + jakarta.validation + jakarta.validation-api + 3.0.2 + + + + jakarta.servlet + jakarta.servlet-api + 6.0.0 + provided + + + + org.hibernate.validator + hibernate-validator + 8.0.2.Final + + + + + org.junit.jupiter + junit-jupiter + 5.11.4 + test + + + org.junit.jupiter + junit-jupiter-api + 5.11.4 + test + + + org.junit.jupiter + junit-jupiter-engine + 5.11.4 + test + + + org.glassfish + jakarta.el + 3.0.4 + + + + com.h2database + h2 + test + + + com.h2database + h2 + + + org.springframework.boot + spring-boot-test + + + org.springframework.boot + spring-boot-starter-actuator + + + + org.assertj + assertj-core + 3.26.3 + test + + + + io.github.cdimascio + dotenv-java + 3.0.0 + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + + + + + + com.github.spotbugs + spotbugs-maven-plugin + 4.8.6.0 + + Max + Low + true + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + + diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/Deliverable4Application.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/Deliverable4Application.java new file mode 100644 index 000000000..4a850f5a4 --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/Deliverable4Application.java @@ -0,0 +1,149 @@ +package com.jydoc.deliverable4; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.core.env.Environment; +import java.util.Arrays; + +/** + * Main entry point for the Deliverable 4 Spring Boot application. + *

+ * This class serves as the configuration class and application launcher. It enables: + *

    + *
  • Spring Boot auto-configuration
  • + *
  • Component scanning within the base package and sub-packages
  • + *
  • Externalized configuration through application.properties/yml
  • + *
  • Embedded server startup
  • + *
+ * + *

The {@code @SpringBootApplication} annotation is a convenience annotation that combines: + *

    + *
  • {@code @Configuration} - Tags the class as a source of bean definitions
  • + *
  • {@code @EnableAutoConfiguration} - Enables Spring Boot's autoconfiguration
  • + *
  • {@code @ComponentScan} - Enables component scanning within the package
  • + *
+ */ +@SpringBootApplication +public class Deliverable4Application { + + /** + * Main method that serves as the entry point for the Spring Boot application. + *

+ * Initializes the Spring application context and starts the embedded server. + * Performs the following operations: + *

    + *
  1. Creates a new SpringApplication instance
  2. + *
  3. Applies custom configuration
  4. + *
  5. Runs the application
  6. + *
  7. Logs startup information
  8. + *
+ * + * @param args command line arguments passed to the application. These can include: + *
    + *
  • Spring profile activation (--spring.profiles.active=dev)
  • + *
  • Property overrides (--server.port=9090)
  • + *
  • Other Spring Boot configuration options
  • + *
+ */ + public static void main(String[] args) { + // Create and configure the Spring application + SpringApplication application = new SpringApplication(Deliverable4Application.class); + + // Apply any additional configuration + configureApplication(application); + + // Run the application and get the environment context + Environment env = application.run(args).getEnvironment(); + + // Log application startup information + logApplicationStartup(env); + } + + /** + * Configures additional Spring application settings before startup. + *

+ * This method provides a hook for custom application configuration that needs to execute + * before the application context is refreshed. Current implementation serves as a + * placeholder for potential customizations. + * + * @param application the SpringApplication instance being configured + * @see org.springframework.boot.SpringApplication + * + *

Example customizations that could be added:

+ *
{@code
+	 * // Disable Spring banner
+	 * application.setBannerMode(Banner.Mode.OFF);
+	 *
+	 * // Set additional profiles
+	 * application.setAdditionalProfiles("dev");
+	 *
+	 * // Disable startup info logging
+	 * application.setLogStartupInfo(false);
+	 * }
+ */ + private static void configureApplication(SpringApplication application) { + // Configuration placeholder - see method documentation for examples + } + + /** + * Logs comprehensive application startup information including: + *
    + *
  • Application name
  • + *
  • Access URLs (local and external)
  • + *
  • Active profiles
  • + *
  • Protocol (HTTP/HTTPS)
  • + *
+ * + * @param env the Spring Environment containing configuration properties + * @see org.springframework.core.env.Environment + * + *

The method detects SSL configuration to determine the protocol + * and formats a visual startup banner with key information.

+ */ + private static void logApplicationStartup(Environment env) { + // Determine protocol (HTTP/HTTPS) based on SSL configuration + String protocol = env.getProperty("server.ssl.key-store") != null ? "https" : "http"; + + // Get server configuration with defaults + String serverPort = env.getProperty("server.port", "8080"); + String contextPath = env.getProperty("server.servlet.context-path", ""); + String appName = env.getProperty("spring.application.name", "application"); + + // Format and display startup information + System.out.printf("%n----------------------------------------------------------%n" + + "Application '%s' is running!%n%n" + + "Access URLs:%n" + + "Local: \t\t%s://localhost:%s%s%n" + + "External: \t%s://%s:%s%s%n" + + "Profile(s): \t%s%n" + + "----------------------------------------------------------%n", + appName, + protocol, + serverPort, + contextPath, + protocol, + getHostAddress(), + serverPort, + contextPath, + Arrays.toString(env.getActiveProfiles())); + } + + /** + * Gets the host address for external access display. + *

+ * Currently returns "localhost" but in a production environment could be extended to: + *

    + *
  • Detect actual host IP address
  • + *
  • Handle network interface enumeration
  • + *
  • Support containerized environments
  • + *
+ * + * @return the host address string, currently hardcoded to "localhost" + * + * @implNote For production use, consider implementing with: + * {@code InetAddress.getLocalHost().getHostAddress()} + */ + private static String getHostAddress() { + return "localhost"; + } +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/config/SecurityConfig.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/config/SecurityConfig.java new file mode 100644 index 000000000..fda96de63 --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/config/SecurityConfig.java @@ -0,0 +1,161 @@ +package com.jydoc.deliverable4.config; + +import com.jydoc.deliverable4.security.auth.CustomUserDetailsService; +import com.jydoc.deliverable4.security.handlers.CustomAuthenticationSuccessHandler; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.Authentication; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.authentication.logout.LogoutHandler; +import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler; +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +import org.springframework.security.web.csrf.CsrfTokenRepository; +import org.springframework.security.web.header.writers.XXssProtectionHeaderWriter; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + private static final String[] PUBLIC_ENDPOINTS = { + "/", + "/home", + "/auth/login", + "/auth/register", + "/css/**", + "/js/**", + "/images/**", + "/error", + "/webjars/**", + "/api/public/**" + }; + + private final CustomUserDetailsService userDetailsService; + + public SecurityConfig(CustomUserDetailsService userDetailsService) { + this.userDetailsService = userDetailsService; + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + // Configure CSRF token repository + CookieCsrfTokenRepository tokenRepository = CookieCsrfTokenRepository.withHttpOnlyFalse(); + tokenRepository.setCookieName("XSRF-TOKEN"); + tokenRepository.setHeaderName("X-XSRF-TOKEN"); + tokenRepository.setCookiePath("/"); + + http + // Configure authorization + .authorizeHttpRequests(auth -> auth + .requestMatchers(PUBLIC_ENDPOINTS).permitAll() + .requestMatchers("/admin/**").hasRole("ADMIN") + .anyRequest().authenticated() + ) + + // Configure form login + .formLogin(form -> form + .loginPage("/auth/login") + .loginProcessingUrl("/auth/login") + .successHandler(authenticationSuccessHandler()) + .failureUrl("/auth/login?error=true") + .defaultSuccessUrl("/user/dashboard", true) + .usernameParameter("username") + .passwordParameter("password") + .permitAll() + ) + + // Configure logout + .logout(logout -> logout + .logoutUrl("/auth/logout") + .logoutSuccessUrl("/auth/login?logout=true") + .deleteCookies("JSESSIONID", "XSRF-TOKEN") + .invalidateHttpSession(true) + .clearAuthentication(true) + .addLogoutHandler(new SecurityContextLogoutHandler()) + .addLogoutHandler(new CsrfTokenLogoutHandler(tokenRepository)) + .permitAll() + ) + + // Configure session management + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) + .invalidSessionUrl("/auth/login?invalid-session") + .maximumSessions(1) + .maxSessionsPreventsLogin(false) + .expiredUrl("/auth/login?session-expired") + ) + + // Configure CSRF protection + .csrf(csrf -> csrf + .csrfTokenRepository(tokenRepository) + .ignoringRequestMatchers("/api/public/**") + ) + + // Configure security headers + .headers(headers -> headers + .xssProtection(xss -> xss + .headerValue(XXssProtectionHeaderWriter.HeaderValue.ENABLED_MODE_BLOCK) + ) + .contentSecurityPolicy(csp -> csp + .policyDirectives("default-src 'self'; script-src 'self' 'unsafe-inline' cdn.jsdelivr.net; " + + "style-src 'self' 'unsafe-inline' cdn.jsdelivr.net; img-src 'self' data:") + ) + .frameOptions(frame -> frame.deny()) + ) + + // Configure exception handling + .exceptionHandling(exceptions -> exceptions + .accessDeniedPage("/access-denied") + ); + + return http.build(); + } + + @Bean + public AuthenticationSuccessHandler authenticationSuccessHandler() { + return new CustomAuthenticationSuccessHandler(); + } + + @Bean + public AuthenticationManager authenticationManager( + AuthenticationConfiguration authenticationConfiguration) throws Exception { + return authenticationConfiguration.getAuthenticationManager(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + /** + * Custom logout handler to properly clear CSRF tokens + */ + public static class CsrfTokenLogoutHandler implements LogoutHandler { + private final CsrfTokenRepository csrfTokenRepository; + + public CsrfTokenLogoutHandler(CsrfTokenRepository csrfTokenRepository) { + this.csrfTokenRepository = csrfTokenRepository; + } + + @Override + public void logout(HttpServletRequest request, + HttpServletResponse response, + Authentication authentication) { + // Clear the CSRF token from the repository + csrfTokenRepository.saveToken(null, request, response); + + // Clear the cookie explicitly + response.setHeader("Set-Cookie", + "XSRF-TOKEN=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax"); + } + } +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/config/TestSecurityConfig.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/config/TestSecurityConfig.java new file mode 100644 index 000000000..3204cf419 --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/config/TestSecurityConfig.java @@ -0,0 +1,51 @@ +package com.jydoc.deliverable4.config; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Profile; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.web.SecurityFilterChain; + +/** + * Test-specific security configuration that disables all security for testing purposes. + * This configuration is only active when the "test" profile is enabled. + * + *

Features: + *

    + *
  • Disables CSRF protection
  • + *
  • Permits all requests without authentication
  • + *
  • Disables frame options for easier testing in iframes
  • + *
+ */ +@TestConfiguration +@EnableWebSecurity +@Profile("test") +public class TestSecurityConfig { + + /** + * Configures a permissive security filter chain for testing environments. + * + * @param http the HttpSecurity to configure + * @return the configured SecurityFilterChain + * @throws Exception if an error occurs during configuration + */ + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + // Disable CSRF protection for testing + .csrf(csrf -> csrf.disable()) + + // Permit all requests without authentication + .authorizeHttpRequests(auth -> auth + .anyRequest().permitAll() + ) + + // Disable frame options for iframe testing + .headers(headers -> headers + .frameOptions(frame -> frame.disable()) + ); + + return http.build(); + } +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/controllers/AdminController.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/controllers/AdminController.java new file mode 100644 index 000000000..c9bc64ab1 --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/controllers/AdminController.java @@ -0,0 +1,168 @@ +package com.jydoc.deliverable4.controllers; + +import com.jydoc.deliverable4.model.UserModel; +import com.jydoc.deliverable4.services.userservices.UserService; +import com.jydoc.deliverable4.security.Exceptions.UserNotFoundException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import javax.validation.Valid; +import java.util.List; + +/** + * Controller for handling administrative operations. + * Provides endpoints for user management, system settings, and activity logs. + * Requires ADMIN role for all operations. + */ +@Controller +@RequestMapping("/admin") +@PreAuthorize("hasRole('ROLE_ADMIN')") +public class AdminController { + + // Constants for view names and attribute keys + private static final String USERS_REDIRECT = "redirect:/admin/users"; + private static final String USER_ATTR = "user"; + private static final String ERROR_ATTR = "error"; + private static final String MESSAGE_ATTR = "message"; + private static final String USERS_ATTR = "users"; + private static final String USER_COUNT_ATTR = "userCount"; + + // View paths + private static final String DASHBOARD_VIEW = "admin/dashboard"; + private static final String USER_LIST_VIEW = "admin/users/list"; + private static final String USER_EDIT_VIEW = "admin/users/edit"; + private static final String SETTINGS_VIEW = "admin/settings"; + private static final String ACTIVITY_LOGS_VIEW = "admin/activity-logs"; + private static final String ERROR_VIEW = "error/user-not-found"; + + private final UserService userService; + + /** + * Constructs an AdminController with the required UserService dependency. + * + * @param userService The service for user-related operations + */ + @Autowired + public AdminController(UserService userService) { + this.userService = userService; + } + + /** + * Displays the admin dashboard with system statistics. + * + * @param model The Spring MVC model to add attributes + * @return The dashboard view name + */ + @GetMapping("/dashboard") + public String adminDashboard(Model model) { + model.addAttribute(USER_COUNT_ATTR, userService.getUserCount()); + return DASHBOARD_VIEW; + } + + /** + * Displays the user management page with a list of all users. + * + * @param model The Spring MVC model to add attributes + * @return The user list view name + */ + @GetMapping("/users") + public String userManagement(Model model) { + List users = userService.getAllUsers(); + model.addAttribute(USERS_ATTR, users); + return USER_LIST_VIEW; + } + + /** + * Displays the user edit form for a specific user. + * + * @param id The ID of the user to edit + * @param model The Spring MVC model to add attributes + * @return The user edit view name + * @throws UserNotFoundException if the user with the given ID doesn't exist + */ + @GetMapping("/users/edit/{id}") + public String editUserForm(@PathVariable Long id, Model model) { + UserModel user = userService.getUserById(id) + .orElseThrow(() -> new UserNotFoundException(id)); + model.addAttribute(USER_ATTR, user); + return USER_EDIT_VIEW; + } + + /** + * Processes user update requests. + * + * @param user The user data to update (validated) + * @param result The binding result for validation errors + * @param redirectAttributes Attributes for the redirect scenario + * @return Redirect to user list if successful, back to edit form if validation fails + */ + @PostMapping("/users/update") + public String updateUser(@Valid @ModelAttribute(USER_ATTR) UserModel user, + BindingResult result, + RedirectAttributes redirectAttributes) { + if (result.hasErrors()) { + return USER_EDIT_VIEW; + } + userService.updateUser(user); + redirectAttributes.addFlashAttribute(MESSAGE_ATTR, "User updated successfully"); + return USERS_REDIRECT; + } + + /** + * Deletes a user with the specified ID. + * + * @param id The ID of the user to delete + * @param redirectAttributes Attributes for the redirect scenario + * @return Redirect to user list + * @throws UserNotFoundException if the user with the given ID doesn't exist + */ + @PostMapping("/users/delete/{id}") + public String deleteUser(@PathVariable Long id, RedirectAttributes redirectAttributes) { + if (!userService.existsById(id)) { + throw new UserNotFoundException(id); + } + userService.deleteUser(id); + redirectAttributes.addFlashAttribute(MESSAGE_ATTR, "User deleted successfully"); + return USERS_REDIRECT; + } + + /** + * Handles UserNotFoundException across all controller methods. + * + * @param ex The exception that was thrown + * @param model The Spring MVC model to add attributes + * @return The error view name + */ + @ExceptionHandler(UserNotFoundException.class) + public String handleUserNotFound(UserNotFoundException ex, Model model) { + model.addAttribute(ERROR_ATTR, ex.getMessage()); + return ERROR_VIEW; + } + + /** + * Displays the system settings page. + * + * @param model The Spring MVC model to add attributes + * @return The settings view name + */ + @GetMapping("/settings") + public String systemSettings(Model model) { + return SETTINGS_VIEW; + } + + /** + * Displays the activity logs page. + * + * @param model The Spring MVC model to add attributes + * @return The activity logs view name + */ + @GetMapping("/activity-logs") + public String viewActivityLogs(Model model) { + return ACTIVITY_LOGS_VIEW; + } +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/controllers/AuthController.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/controllers/AuthController.java new file mode 100644 index 000000000..12c48099f --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/controllers/AuthController.java @@ -0,0 +1,277 @@ +package com.jydoc.deliverable4.controllers; + +import com.jydoc.deliverable4.dtos.userdtos.UserDTO; +import com.jydoc.deliverable4.dtos.userdtos.LoginDTO; +import com.jydoc.deliverable4.model.UserModel; +import com.jydoc.deliverable4.services.authservices.AuthService; +import com.jydoc.deliverable4.services.userservices.UserService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import jakarta.validation.Valid; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +/** + * Controller handling all authentication-related operations including + * user registration, login, and logout. This controller manages the + * authentication workflow and redirects users to appropriate views + * based on their authentication status and roles. + */ +@Controller +@RequestMapping("/auth") +public class AuthController { + private static final Logger logger = LoggerFactory.getLogger(AuthController.class); + + // Session attribute constants + private static final String USER_ATTRIBUTE = "user"; + private static final String LOGIN_DATA_ATTRIBUTE = "loginData"; + private static final String BINDING_RESULT_PREFIX = "org.springframework.validation.BindingResult."; + + // View paths + private static final String REDIRECT_LOGIN = "redirect:/auth/login"; + private static final String LOGIN_VIEW = "auth/login"; + private static final String REGISTER_VIEW = "auth/register"; + + // Redirect URLs based on role + private static final String ADMIN_REDIRECT = "redirect:/admin/dashboard"; + private static final String USER_REDIRECT = "redirect:/user/dashboard"; + + // Flash attribute keys + private static final String ERROR_ATTRIBUTE = "error"; + private static final String SUCCESS_ATTRIBUTE = "success"; + + private final AuthService authService; + private final UserService userService; + + /** + * Constructs an AuthController with required services. + * + * @param authService Service handling authentication logic + * @param userService Service handling user-related operations + */ + public AuthController(AuthService authService, UserService userService) { + this.authService = authService; + this.userService = userService; + } + + /* ==================== REGISTRATION ENDPOINTS ==================== */ + + /** + * Displays the user registration form. + * + * @param model The Spring MVC model to populate with attributes + * @return The registration view name + */ + @GetMapping("/register") + public String showRegistrationForm(Model model) { + if (!model.containsAttribute(USER_ATTRIBUTE)) { + model.addAttribute(USER_ATTRIBUTE, new UserDTO()); + } + return REGISTER_VIEW; + } + + /** + * Processes user registration form submission. + * + * @param userDto The user data transfer object containing registration details + * @param result BindingResult for validation errors + * @param redirectAttributes Attributes for redirect scenarios + * @return Redirect to appropriate view based on registration outcome + */ + @PostMapping("/register") + public String registerUser( + @Valid @ModelAttribute(USER_ATTRIBUTE) UserDTO userDto, + BindingResult result, + RedirectAttributes redirectAttributes) { + + if (hasValidationErrors(result, redirectAttributes, userDto)) { + return "redirect:/auth/register"; + } + + try { + authService.registerNewUser(userDto); + logger.info("User registered successfully: {}", userDto.getUsername()); + redirectAttributes.addFlashAttribute(SUCCESS_ATTRIBUTE, "Registration successful! Please login."); + return REDIRECT_LOGIN; + } catch (AuthService.UsernameExistsException | AuthService.EmailExistsException e) { + handleRegistrationError(e.getMessage(), redirectAttributes, userDto); + return "redirect:/auth/register"; + } catch (Exception e) { + handleRegistrationError("Registration failed. Please try again.", redirectAttributes, userDto); + return "redirect:/auth/register"; + } + } + + /* ==================== LOGIN ENDPOINTS ==================== */ + + /** + * Displays the login form with optional error/success messages. + * + * @param model The Spring MVC model to populate with attributes + * @param error Optional error parameter indicating login failure + * @param logout Optional logout parameter indicating successful logout + * @return The login view name + */ + @GetMapping("/login") + public String showLoginForm( + Model model, + @RequestParam(required = false) String error, + @RequestParam(required = false) String logout) { + + if (!model.containsAttribute(LOGIN_DATA_ATTRIBUTE)) { + model.addAttribute(LOGIN_DATA_ATTRIBUTE, new LoginDTO("", "")); + } + + if (error != null) { + model.addAttribute(ERROR_ATTRIBUTE, "Invalid username or password"); + } + if (logout != null) { + model.addAttribute(SUCCESS_ATTRIBUTE, "You have been logged out successfully"); + } + + return LOGIN_VIEW; + } + + /** + * Processes login form submission. + * + * @param loginDto The login data transfer object containing credentials + * @param result BindingResult for validation errors + * @param session The HTTP session to store authentication details + * @param redirectAttributes Attributes for redirect scenarios + * @return Redirect to appropriate dashboard based on user role or back to login on failure + */ + @PostMapping("/login") + public String loginUser( + @Valid @ModelAttribute(LOGIN_DATA_ATTRIBUTE) LoginDTO loginDto, + BindingResult result, + HttpSession session, + RedirectAttributes redirectAttributes) { + + if (hasValidationErrors(result, redirectAttributes, loginDto)) { + return "redirect:/auth/login"; + } + + try { + UserModel user = authService.validateLogin(loginDto); + session.setAttribute(USER_ATTRIBUTE, user); + logger.info("User logged in: {}", user.getUsername()); + return determineRedirectUrl(user); + } catch (AuthService.AuthenticationException e) { + handleLoginError(e.getMessage(), redirectAttributes, loginDto); + return "redirect:/auth/login"; + } + } + + /* ==================== LOGOUT ENDPOINT ==================== */ + + /** + * Processes user logout by invalidating the session. + * Requires the user to be authenticated. + * + * @param session The HTTP session to invalidate + * @return Redirect to login page with logout success message + */ + @GetMapping("/logout") + @PreAuthorize("isAuthenticated()") + public String performLogout(HttpSession session, HttpServletRequest request) { + UserModel user = (UserModel) session.getAttribute(USER_ATTRIBUTE); + if (user != null) { + logger.info("User logged out: {}", user.getUsername()); + } + + // Invalidate session and clear authentication + SecurityContextHolder.clearContext(); + session.invalidate(); + + // Create new CSRF token for the next login + CsrfToken newToken = new HttpSessionCsrfTokenRepository().generateToken(request); + request.getSession().setAttribute(HttpSessionCsrfTokenRepository.class.getName() + .concat(".CSRF_TOKEN"), newToken); + + return REDIRECT_LOGIN + "?logout=true"; + } + + /* ==================== HELPER METHODS ==================== */ + + /** + * Determines the appropriate redirect URL based on user role. + * + * @param user The authenticated user model + * @return Redirect URL string based on user role + */ + private String determineRedirectUrl(UserModel user) { + return isAdmin(user) ? ADMIN_REDIRECT : USER_REDIRECT; + } + + /** + * Checks if the user has admin privileges. + * + * @param user The user model to check + * @return true if user has ROLE_ADMIN authority, false otherwise + */ + private boolean isAdmin(UserModel user) { + return user.getAuthorities().stream() + .anyMatch(auth -> "ROLE_ADMIN".equals(auth.getAuthority())); + } + + /** + * Handles validation errors by populating redirect attributes. + * + * @param result The binding result containing validation errors + * @param redirectAttributes The redirect attributes to populate + * @param dto The DTO object that failed validation + * @return true if validation errors exist, false otherwise + */ + private boolean hasValidationErrors(BindingResult result, + RedirectAttributes redirectAttributes, + Object dto) { + if (result.hasErrors()) { + String attributeName = dto instanceof UserDTO ? USER_ATTRIBUTE : LOGIN_DATA_ATTRIBUTE; + logger.debug("Validation errors for {}: {}", attributeName, result.getAllErrors()); + redirectAttributes.addFlashAttribute(BINDING_RESULT_PREFIX + attributeName, result); + redirectAttributes.addFlashAttribute(attributeName, dto); + return true; + } + return false; + } + + /** + * Handles registration errors by logging and setting flash attributes. + * + * @param errorMessage The error message to display + * @param redirectAttributes The redirect attributes to populate + * @param userDto The user DTO associated with the failed registration + */ + private void handleRegistrationError(String errorMessage, + RedirectAttributes redirectAttributes, + UserDTO userDto) { + logger.error("Registration failed: {}", errorMessage); + redirectAttributes.addFlashAttribute(ERROR_ATTRIBUTE, errorMessage); + redirectAttributes.addFlashAttribute(USER_ATTRIBUTE, userDto); + } + + /** + * Handles login errors by logging and setting flash attributes. + * + * @param errorMessage The error message to display + * @param redirectAttributes The redirect attributes to populate + * @param loginDto The login DTO associated with the failed attempt + */ + private void handleLoginError(String errorMessage, + RedirectAttributes redirectAttributes, + LoginDTO loginDto) { + logger.error("Login failed: {}", errorMessage); + redirectAttributes.addFlashAttribute(ERROR_ATTRIBUTE, errorMessage); + redirectAttributes.addFlashAttribute(LOGIN_DATA_ATTRIBUTE, loginDto); + } +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/controllers/CustomErrorController.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/controllers/CustomErrorController.java new file mode 100644 index 000000000..2bf3b95df --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/controllers/CustomErrorController.java @@ -0,0 +1,157 @@ +package com.jydoc.deliverable4.controllers; + +import jakarta.servlet.RequestDispatcher; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.boot.web.servlet.error.ErrorController; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.time.Instant; +import java.util.Date; +import java.util.Optional; + +/** + * Custom error controller to handle application errors and display user-friendly error pages. + *

+ * This controller implements Spring Boot's {@link ErrorController} interface to override + * the default white-label error page behavior. It provides: + *

    + *
  • Custom error page rendering with user-friendly messages
  • + *
  • HTTP status code resolution from request attributes
  • + *
  • Exception handling and message sanitization
  • + *
  • Security-conscious error information disclosure
  • + *
+ *

+ * + *

Security Note: All error messages are sanitized to prevent + * exposure of sensitive information before being displayed to users.

+ */ +@Controller +public class CustomErrorController implements ErrorController { + + /** + * Default error message displayed when no specific message is available. + */ + private static final String DEFAULT_ERROR_MESSAGE = "An unexpected error occurred"; + + /** + * Specific error message for database validation failures. + */ + private static final String DB_VALIDATION_ERROR = + "Database error: Required role configuration is missing. Please contact support."; + + /** + * Handles all error requests and prepares error information for display. + *

+ * This method: + *

    + *
  • Resolves the HTTP status code from the request
  • + *
  • Extracts any associated exception
  • + *
  • Sanitizes error messages for security
  • + *
  • Populates the model with error details for the view
  • + *
  • Returns the error view template
  • + *
+ *

+ * + * @param request The HTTP request containing error attributes. Must not be null. + * @param model The Spring MVC model to populate with error details. Automatically + * provided by Spring MVC. + * @return The logical view name "error" which resolves to the error template + * + * @see jakarta.servlet.RequestDispatcher#ERROR_STATUS_CODE + * @see jakarta.servlet.RequestDispatcher#ERROR_EXCEPTION + * @see jakarta.servlet.RequestDispatcher#ERROR_REQUEST_URI + */ + @RequestMapping("/error") + public String handleError(HttpServletRequest request, Model model) { + HttpStatus httpStatus = resolveHttpStatus(request); + Throwable exception = resolveException(request); + + model.addAttribute("status", httpStatus.value()); + model.addAttribute("error", httpStatus.getReasonPhrase()); + model.addAttribute("message", getSafeErrorMessage(exception)); + model.addAttribute("path", request.getAttribute(RequestDispatcher.ERROR_REQUEST_URI)); + model.addAttribute("timestamp", Instant.now()); + + return "error"; + } + + /** + * Resolves the HTTP status code from the request attributes. + *

+ * Attempts to extract the status code from request attributes, falling back to + * HTTP 500 (Internal Server Error) if not available. + *

+ * + * @param request The HTTP request containing error attributes + * @return The resolved HttpStatus, never null + * @throws NumberFormatException if the status code attribute contains + * a non-numeric value (should not occur in normal operation) + */ + private HttpStatus resolveHttpStatus(HttpServletRequest request) { + return Optional.ofNullable(request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE)) + .map(status -> HttpStatus.valueOf(Integer.parseInt(status.toString()))) + .orElse(HttpStatus.INTERNAL_SERVER_ERROR); + } + + /** + * Resolves the exception from the request attributes. + *

+ * Extracts any exception associated with the error request, if available. + *

+ * + * @param request The HTTP request containing error attributes + * @return The associated Throwable, or null if no exception is present + */ + private Throwable resolveException(HttpServletRequest request) { + return Optional.ofNullable(request.getAttribute(RequestDispatcher.ERROR_EXCEPTION)) + .map(Throwable.class::cast) + .orElse(null); + } + + /** + * Provides a safe error message for display. + *

+ * This method ensures: + *

    + *
  • Null exceptions return a default message
  • + *
  • Specific database errors return a standardized message
  • + *
  • All other messages are sanitized before display
  • + *
+ *

+ * + * @param exception The exception to derive the message from, may be null + * @return A safe, user-appropriate error message, never null + */ + private String getSafeErrorMessage(Throwable exception) { + if (exception == null) { + return DEFAULT_ERROR_MESSAGE; + } + + String message = exception.getMessage(); + if (message == null) { + return DEFAULT_ERROR_MESSAGE; + } + + return message.contains("Field 'authority' doesn't have a default value") + ? DB_VALIDATION_ERROR + : sanitizeMessage(message); + } + + /** + * Sanitizes error messages to prevent exposing sensitive information. + *

+ * Replaces sensitive patterns (like passwords, tokens, etc.) with [REDACTED]. + * Extend this method to include additional sensitive patterns as needed. + *

+ * + * @param message The raw error message to sanitize + * @return A sanitized version of the message safe for user display + */ + private String sanitizeMessage(String message) { + // Basic sanitization - extend this for your specific security requirements + return message.replaceAll("(?i)password|secret|key|token", "[REDACTED]"); + } +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/controllers/UserController.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/controllers/UserController.java new file mode 100644 index 000000000..39e7280b5 --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/controllers/UserController.java @@ -0,0 +1,510 @@ +package com.jydoc.deliverable4.controllers; + +import com.jydoc.deliverable4.dtos.userdtos.DashboardDTO; +import com.jydoc.deliverable4.dtos.MedicationDTO; +import com.jydoc.deliverable4.dtos.userdtos.UserDTO; +import com.jydoc.deliverable4.security.Exceptions.PasswordMismatchException; +import com.jydoc.deliverable4.security.Exceptions.WeakPasswordException; +import com.jydoc.deliverable4.services.userservices.DashboardService; +import com.jydoc.deliverable4.services.medicationservices.MedicationService; +import com.jydoc.deliverable4.services.userservices.UserService; +import jakarta.validation.constraints.NotBlank; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.*; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import javax.validation.Valid; + +/** + * Controller handling all user-related operations including profile management, + * medication tracking, and dashboard display. + * + *

This controller serves as the main interface between the user-facing views + * and backend services for user-specific operations.

+ * + *

All endpoints are prefixed with "/user" and require authentication.

+ */ +@Controller +@RequestMapping("/user") +public class UserController { + + private static final Logger logger = LoggerFactory.getLogger(UserController.class); + + private final DashboardService dashboardService; + private final MedicationService medicationService; + private final UserService userService; + + /** + * Constructs a new UserController with required services. + * + * @param dashboardService Service for dashboard-related operations + * @param medicationService Service for medication management + * @param userService Service for user profile operations + */ + public UserController(DashboardService dashboardService, + MedicationService medicationService, + UserService userService) { + this.dashboardService = dashboardService; + this.medicationService = medicationService; + this.userService = userService; + logger.info("UserController initialized with all required services"); + } + + /** + * Displays the user dashboard with summary information. + * + * @param userDetails Authenticated user details + * @param model Spring MVC model for view data + * @return The dashboard view template + */ + @GetMapping("/dashboard") + public String showDashboard(@AuthenticationPrincipal UserDetails userDetails, Model model) { + if (userDetails == null || userDetails.getUsername() == null) { + logger.error("Unauthenticated access attempt to dashboard"); + return "redirect:/login"; + } + + String username = userDetails.getUsername(); + logger.debug("Loading dashboard for user: {}", username); + + try { + DashboardDTO dashboard = dashboardService.getUserDashboardData(userDetails); + boolean hasMedications = dashboard.isHasMedications(); + + logger.debug("Dashboard data retrieved successfully for user: {}", username); + model.addAttribute("dashboard", dashboard); + model.addAttribute("hasMedications", hasMedications); + + return "user/dashboard"; + } catch (IllegalArgumentException e) { + logger.warn("Invalid request for dashboard: {}", e.getMessage()); + model.addAttribute("error", "Invalid request: " + e.getMessage()); + return "user/dashboard"; + } catch (Exception e) { + logger.error("Failed to load dashboard for user {}: {}", username, e.getMessage(), e); + model.addAttribute("error", "Failed to load dashboard data. Please try again later."); + return "user/dashboard"; + } + } + + /** + * Displays the user's profile information. + * + * @param userDetails Authenticated user details + * @param model Spring MVC model for view data + * @return The profile view template + */ + @GetMapping("/profile") + public String showProfile(@AuthenticationPrincipal UserDetails userDetails, Model model) { + logger.debug("Loading profile for user: {}", userDetails.getUsername()); + try { + UserDTO user = userService.getUserByUsername(userDetails.getUsername()); + model.addAttribute("user", user); + logger.info("Profile data loaded successfully for user: {}", userDetails.getUsername()); + return "user/profile"; + } catch (Exception e) { + logger.error("Failed to load profile for user {}: {}", userDetails.getUsername(), e.getMessage(), e); + model.addAttribute("error", "Failed to load profile data"); + return "user/profile"; + } + } + + /** + * Handles profile updates for the authenticated user. + * + * @param userDTO Data transfer object containing updated profile information + * @param result Binding result for validation errors + * @param currentPassword Current password for verification + * @param userDetails Authenticated user details + * @param redirectAttributes Attributes for redirect scenarios + * @return Redirect to profile page with success/error message + */ + @PostMapping("/profile/update") + public String updateProfile( + @ModelAttribute("user") UserDTO userDTO, + BindingResult result, + @RequestParam("currentPassword") String currentPassword, + @AuthenticationPrincipal UserDetails userDetails, + RedirectAttributes redirectAttributes) { + + logger.info("Attempting profile update for user: {}", userDetails.getUsername()); + + // Manually validate only the fields we want to update + if (userDTO.getEmail() == null || userDTO.getEmail().trim().isEmpty()) { + logger.warn("Email validation failed for user: {}", userDetails.getUsername()); + result.rejectValue("email", "NotBlank", "Email is required"); + } + if (userDTO.getFirstName() == null || userDTO.getFirstName().trim().isEmpty()) { + logger.warn("First name validation failed for user: {}", userDetails.getUsername()); + result.rejectValue("firstName", "NotBlank", "First name is required"); + } + if (userDTO.getLastName() == null || userDTO.getLastName().trim().isEmpty()) { + logger.warn("Last name validation failed for user: {}", userDetails.getUsername()); + result.rejectValue("lastName", "NotBlank", "Last name is required"); + } + + if (result.hasErrors()) { + logger.warn("Profile update validation failed with {} errors for user: {}", + result.getErrorCount(), userDetails.getUsername()); + return "user/profile"; + } + + try { + logger.debug("Verifying current password for user: {}", userDetails.getUsername()); + if (!userService.verifyCurrentPassword(userDetails.getUsername(), currentPassword)) { + logger.warn("Current password verification failed for user: {}", userDetails.getUsername()); + redirectAttributes.addFlashAttribute("error", "Current password is incorrect"); + return "redirect:/user/profile"; + } + + // Create a clean DTO with only updatable fields + UserDTO updateDto = new UserDTO(); + updateDto.setEmail(userDTO.getEmail()); + updateDto.setFirstName(userDTO.getFirstName()); + updateDto.setLastName(userDTO.getLastName()); + + logger.debug("Updating profile for user: {}", userDetails.getUsername()); + userService.updateUserProfile(userDetails.getUsername(), updateDto); + + logger.info("Profile updated successfully for user: {}", userDetails.getUsername()); + redirectAttributes.addFlashAttribute("success", "Profile updated successfully"); + } catch (Exception e) { + logger.error("Profile update failed for user {}: {}", userDetails.getUsername(), e.getMessage(), e); + redirectAttributes.addFlashAttribute("error", "Failed to update profile"); + } + + return "redirect:/user/profile"; + } + + /** + * Handles password change requests for authenticated users. + * + * @param currentPassword User's current password for verification + * @param newPassword New password to set + * @param confirmPassword Confirmation of new password + * @param userDetails Authenticated user details + * @param redirectAttributes Attributes for redirect scenarios + * @return Redirect to profile page with success/error message + */ + @PostMapping("/profile/change-password") + public String changePassword( + @RequestParam("currentPassword") @NotBlank String currentPassword, + @RequestParam("newPassword") @NotBlank String newPassword, + @RequestParam("confirmPassword") @NotBlank String confirmPassword, + @AuthenticationPrincipal UserDetails userDetails, + RedirectAttributes redirectAttributes) { + + logger.info("Password change request received for user: {}", userDetails.getUsername()); + + // Validate password match first + if (!newPassword.equals(confirmPassword)) { + logger.warn("Password mismatch during change attempt for user: {}", userDetails.getUsername()); + redirectAttributes.addFlashAttribute("error", "New passwords do not match"); + return "redirect:/user/profile"; + } + + try { + logger.debug("Attempting password change for user: {}", userDetails.getUsername()); + boolean success = userService.changePassword( + userDetails.getUsername(), + currentPassword, + newPassword + ); + + if (!success) { + logger.warn("Password change failed - current password incorrect for user: {}", + userDetails.getUsername()); + throw new PasswordMismatchException("Current password is incorrect"); + } + + logger.info("Password changed successfully for user: {}", userDetails.getUsername()); + redirectAttributes.addFlashAttribute("success", "Password changed successfully"); + } catch (PasswordMismatchException e) { + logger.warn("Password verification failed for user {}: {}", + userDetails.getUsername(), e.getMessage()); + redirectAttributes.addFlashAttribute("error", "Current password is incorrect"); + } catch (IllegalArgumentException e) { + logger.warn("Invalid password change request for user {}: {}", + userDetails.getUsername(), e.getMessage()); + redirectAttributes.addFlashAttribute("error", e.getMessage()); + } catch (Exception e) { + logger.error("Password change failed unexpectedly for user {}: {}", + userDetails.getUsername(), e.getMessage(), e); + redirectAttributes.addFlashAttribute("error", "Failed to change password"); + } + return "redirect:/user/profile"; + } + + /** + * Handles account deletion requests. + * + * @param password Current password for verification + * @param confirmDelete User confirmation flag + * @param userDetails Authenticated user details + * @param redirectAttributes Attributes for redirect scenarios + * @return Redirect to logout on success, profile page on failure + */ + @PostMapping("/profile/delete") + public String deleteAccount( + @RequestParam("deletePassword") String password, + @RequestParam("confirmDelete") boolean confirmDelete, + @AuthenticationPrincipal UserDetails userDetails, + RedirectAttributes redirectAttributes) { + + logger.info("Account deletion request received for user: {}", userDetails.getUsername()); + + if (!confirmDelete) { + logger.warn("Account deletion not confirmed for user: {}", userDetails.getUsername()); + redirectAttributes.addFlashAttribute("error", "Please confirm account deletion"); + return "redirect:/user/profile"; + } + + try { + logger.debug("Attempting account deletion for user: {}", userDetails.getUsername()); + boolean deleted = userService.deleteAccount(userDetails.getUsername(), password); + + if (deleted) { + logger.info("Account deleted successfully for user: {}", userDetails.getUsername()); + return "redirect:/auth/logout"; + } else { + logger.warn("Incorrect password provided for account deletion by user: {}", + userDetails.getUsername()); + redirectAttributes.addFlashAttribute("error", "Incorrect password"); + } + } catch (Exception e) { + logger.error("Account deletion failed for user {}: {}", + userDetails.getUsername(), e.getMessage(), e); + redirectAttributes.addFlashAttribute("error", "Failed to delete account"); + } + return "redirect:/user/profile"; + } + + /** + * Displays the user's medication list. + * + * @param userDetails Authenticated user details + * @param model Spring MVC model for view data + * @return The medication list view template + */ + @GetMapping("/medication") + public String showMedications(@AuthenticationPrincipal UserDetails userDetails, Model model) { + logger.debug("Loading medications for user: {}", userDetails.getUsername()); + try { + model.addAttribute("medications", + medicationService.getUserMedications(userDetails.getUsername())); + // Add the username to the model + model.addAttribute("username", userDetails.getUsername()); + logger.info("Medications loaded successfully for user: {}", userDetails.getUsername()); + return "user/medication/list"; + } catch (Exception e) { + logger.error("Failed to load medications for user {}: {}", + userDetails.getUsername(), e.getMessage(), e); + model.addAttribute("error", "Failed to load medications"); + return "user/medication/list"; + } + } + + /** + * Alternative endpoint for displaying medication list. + * + * @param userDetails Authenticated user details + * @param model Spring MVC model for view data + * @return The medication list view template + */ + @GetMapping("/medication/list") + public String showMedicationList(@AuthenticationPrincipal UserDetails userDetails, Model model) { + logger.debug("Loading medications via list endpoint for user: {}", userDetails.getUsername()); + try { + model.addAttribute("medications", + medicationService.getUserMedications(userDetails.getUsername())); + + // Add the username to the model + model.addAttribute("username", userDetails.getUsername()); + return "user/medication/list"; + } catch (Exception e) { + logger.error("Failed to load medications via list endpoint for user {}: {}", + userDetails.getUsername(), e.getMessage(), e); + model.addAttribute("error", "Failed to load medications"); + return "user/medication/list"; + } + } + + /** + * Displays the form for adding new medications. + * + * @param model Spring MVC model for view data + * @return The add medication form view + */ + @GetMapping("/medication/add") + public String showAddMedicationForm(@AuthenticationPrincipal UserDetails userDetails, Model model) { + logger.debug("Displaying add medication form"); + model.addAttribute("medicationDTO", new MedicationDTO()); + model.addAttribute("username", userDetails.getUsername()); + return "user/medication/add"; + } + + /** + * Handles submission of new medication information. + * + * @param medicationDTO Data transfer object containing medication details + * @param result Binding result for validation errors + * @param userDetails Authenticated user details + * @return Redirect to medication list on success, form view on failure + */ + @PostMapping("/medication") + public String addMedication( + @Valid @ModelAttribute("medicationDTO") MedicationDTO medicationDTO, + BindingResult result, + @AuthenticationPrincipal UserDetails userDetails, + RedirectAttributes redirectAttributes) { + + logger.info("Attempting to add new medication for user: {}", userDetails.getUsername()); + + // Additional validation for days of week + if (medicationDTO.getDaysOfWeek() == null || medicationDTO.getDaysOfWeek().isEmpty()) { + result.rejectValue("daysOfWeek", "NotEmpty", "Please select at least one day"); + } + + // Additional validation for intake times + if (medicationDTO.getIntakeTimes() == null || medicationDTO.getIntakeTimes().isEmpty()) { + result.rejectValue("intakeTimes", "NotEmpty", "Please add at least one intake time"); + } + + if (result.hasErrors()) { + logger.warn("Medication validation failed with {} errors for user: {}", + result.getErrorCount(), userDetails.getUsername()); + return "user/medication/add"; + } + + try { + // Set default active status if not provided + if (medicationDTO.getActive() == null) { + medicationDTO.setActive(true); + } + + medicationService.createMedication(medicationDTO, userDetails.getUsername()); + logger.info("Medication added successfully for user: {}", userDetails.getUsername()); + redirectAttributes.addFlashAttribute("success", "Medication added successfully"); + return "redirect:/user/medication"; + } catch (Exception e) { + logger.error("Failed to add medication for user {}: {}", + userDetails.getUsername(), e.getMessage(), e); + redirectAttributes.addFlashAttribute("error", "Failed to add medication: " + e.getMessage()); + return "user/medication/add"; + } + } + + /** + * Displays the form for editing existing medications. + * + * @param id ID of the medication to edit + * @param model Spring MVC model for view data + * @return The edit medication form view + */ + @GetMapping("/medication/{id}/edit") + public String showEditMedicationForm(@PathVariable Long id, Model model) { + logger.debug("Displaying edit form for medication ID: {}", id); + try { + MedicationDTO medicationDTO = medicationService.getMedicationById(id); + model.addAttribute("medicationDTO", medicationDTO); + return "user/medication/edit"; + } catch (Exception e) { + logger.error("Failed to load medication for editing (ID: {}): {}", id, e.getMessage(), e); + model.addAttribute("error", "Failed to load medication data"); + return "user/medication/edit"; + } + } + + /** + * Handles submission of updated medication information. + * + * @param id ID of the medication being updated + * @param medicationDTO Updated medication details + * @param result Binding result for validation errors + * @param model Spring MVC model for view data + * @return Redirect to medication list on success, form view on failure + */ + @PostMapping("/medication/{id}") + public String updateMedication( + @PathVariable Long id, + @Valid @ModelAttribute("medicationDTO") MedicationDTO medicationDTO, + BindingResult result, + Model model) { + + logger.info("Attempting to update medication ID: {}", id); + + if (result.hasErrors()) { + logger.warn("Medication update validation failed with {} errors for ID: {}", + result.getErrorCount(), id); + return "user/medication/edit"; + } + + try { + medicationService.updateMedication(id, medicationDTO); + logger.info("Medication updated successfully (ID: {})", id); + return "redirect:/user/medication?updated"; + } catch (Exception e) { + logger.error("Failed to update medication (ID: {}): {}", id, e.getMessage(), e); + model.addAttribute("error", "Error updating medication: " + e.getMessage()); + return "user/medication/edit"; + } + } + + /** + * Handles medication deletion requests. + * + * @param id ID of the medication to delete + * @return Redirect to medication list with status parameter + */ + @PostMapping("/medication/{id}/delete") + public String deleteMedication(@PathVariable Long id) { + logger.info("Attempting to delete medication ID: {}", id); + try { + medicationService.deleteMedication(id); + logger.info("Medication deleted successfully (ID: {})", id); + return "redirect:/user/medication?deleted"; + } catch (Exception e) { + logger.error("Failed to delete medication (ID: {}): {}", id, e.getMessage(), e); + return "redirect:/user/medication?error"; + } + } + + /** + * Displays upcoming medication refills. + * + * @param userDetails Authenticated user details + * @param model Spring MVC model for view data + * @return The refills view template + */ + @GetMapping("/refills") + public String showRefills(@AuthenticationPrincipal UserDetails userDetails, Model model) { + logger.debug("Loading upcoming refills for user: {}", userDetails.getUsername()); + try { + model.addAttribute("refills", + medicationService.getUpcomingRefills(userDetails.getUsername())); + return "user/refills"; + } catch (Exception e) { + logger.error("Failed to load refills for user {}: {}", + userDetails.getUsername(), e.getMessage(), e); + model.addAttribute("error", "Failed to load refill data"); + return "user/refills"; + } + } + + /** + * Displays health metrics information. + * + * @return The health metrics view template + */ + @GetMapping("/health") + public String showHealthMetrics() { + logger.debug("Displaying health metrics view"); + return "user/health"; + } +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/dtos/MedicationDTO.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/dtos/MedicationDTO.java new file mode 100644 index 000000000..155b345df --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/dtos/MedicationDTO.java @@ -0,0 +1,166 @@ +package com.jydoc.deliverable4.dtos; + +import lombok.*; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import java.time.LocalTime; +import java.util.Set; + +/** + * Data Transfer Object (DTO) representing medication information in the system. + * This class serves as the primary data structure for transferring medication-related data + * between different layers of the application while enforcing validation rules. + * + *

The class includes comprehensive validation annotations to ensure data integrity + * and provides utility methods for common medication-related operations.

+ * + *

Usage Example: + *

{@code
+ * MedicationDTO medication = MedicationDTO.builder()
+ *     .medicationName("Ibuprofen")
+ *     .urgency(MedicationUrgency.ROUTINE)
+ *     .intakeTimes(Set.of(LocalTime.of(8, 0), LocalTime.of(20, 0)))
+ *     .daysOfWeek(Set.of(DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY, DayOfWeek.FRIDAY))
+ *     .dosage("200mg")
+ *     .build();
+ * }
+ *

+ */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MedicationDTO { + + /** + * Unique identifier for the medication record. + * This field is automatically generated by the persistence layer. + */ + private Long id; + + /** + * Identifier of the user associated with this medication. + * Must not be null. + */ + @NotNull(message = "User ID cannot be null") + private Long userId; + + /** + * Name of the medication. Must not be blank. + *

Example: "Amoxicillin", "Lisinopril"

+ */ + @NotBlank(message = "Medication name cannot be blank") + private String medicationName; + + /** + * Urgency level of the medication. Must not be null. + * See {@link MedicationUrgency} for possible values. + */ + @NotNull(message = "Urgency level must be specified") + private MedicationUrgency urgency; + + /** + * Set of times when the medication should be taken. + * Must not be null, but can be empty for PRN medications. + */ + @NotNull(message = "Intake times must be specified") + private Set intakeTimes; + + /** + * Days of the week when the medication should be taken. + * Must not be null, but can be empty for as-needed medications. + * See {@link DayOfWeek} for possible values. + */ + @NotNull(message = "Days of week must be specified") + private Set daysOfWeek; + + /** + * Dosage information for the medication. + *

Example: "200mg", "1 tablet"

+ */ + private String dosage; + + /** + * Special instructions for taking the medication. + *

Example: "Take with food", "Avoid alcohol"

+ */ + private String instructions; + + /** + * Flag indicating whether the medication is currently active. + * Null values are treated as inactive. + */ + private Boolean active; + + /** + * Enumeration representing the urgency level of a medication. + * + *

Possible Values: + *

    + *
  • URGENT - Requires immediate attention
  • + *
  • NONURGENT - Important but not time-critical
  • + *
  • ROUTINE - Standard scheduled medication
  • + *
+ */ + public enum MedicationUrgency { + URGENT, + NONURGENT, + ROUTINE; + + /** + * Converts a string value to the corresponding MedicationUrgency enum. + * + * @param value The string value to convert (case-insensitive) + * @return Corresponding MedicationUrgency enum + * @throws IllegalArgumentException if the value doesn't match any enum constant + * @apiNote Returns ROUTINE as default if input is null + */ + public static MedicationUrgency fromString(String value) { + if (value == null) { + return ROUTINE; // Default value + } + try { + return MedicationUrgency.valueOf(value.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException( + "Invalid urgency value. Must be 'URGENT', 'NONURGENT' or 'ROUTINE'"); + } + } + } + + /** + * Enumeration representing days of the week with display names. + * Each enum constant includes a user-friendly display name. + */ + @Getter + public enum DayOfWeek { + MONDAY("Monday"), + TUESDAY("Tuesday"), + WEDNESDAY("Wednesday"), + THURSDAY("Thursday"), + FRIDAY("Friday"), + SATURDAY("Saturday"), + SUNDAY("Sunday"); + + private final String displayName; + + /** + * Constructs a DayOfWeek enum constant with the specified display name. + * + * @param displayName The user-friendly name of the day + */ + DayOfWeek(String displayName) { + this.displayName = displayName; + } + } + + /** + * Determines if the medication is currently active. + * + * @return true if the medication is explicitly marked as active, + * false otherwise (including null active status) + */ + public boolean isActive() { + return active != null && active; + } +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/dtos/MedicationScheduleDTO.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/dtos/MedicationScheduleDTO.java new file mode 100644 index 000000000..c48fb7945 --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/dtos/MedicationScheduleDTO.java @@ -0,0 +1,58 @@ +package com.jydoc.deliverable4.dtos; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalTime; +import java.util.Set; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MedicationScheduleDTO { + + public enum MedicationUrgency { + URGENT, NONURGENT, ROUTINE + } + + // Days of the week enum + public enum DayOfWeek { + MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY + } + + private Long medicationId; + private String medicationName; + private String dosage; + private LocalTime scheduleTime; + private boolean isTaken; + private String instructions; + private String status; // "UPCOMING", "MISSED", "TAKEN", etc. + private MedicationUrgency urgency; + private Set daysOfWeek; // Days when medication should be taken + + public String getFormattedTime() { + return scheduleTime != null ? scheduleTime.toString() : ""; + } + + public String getStatusBadgeClass() { + return switch (status) { + case "TAKEN" -> "badge bg-success"; + case "MISSED" -> "badge bg-danger"; + default -> "badge bg-warning text-dark"; + }; + } + + // Helper method to get days as comma-separated string + public String getDaysAsString() { + if (daysOfWeek == null || daysOfWeek.isEmpty()) { + return "Everyday"; + } + return daysOfWeek.stream() + .map(Enum::name) + .reduce((a, b) -> a + ", " + b) + .orElse(""); + } +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/dtos/RefillReminderDTO.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/dtos/RefillReminderDTO.java new file mode 100644 index 000000000..b7ec5ef31 --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/dtos/RefillReminderDTO.java @@ -0,0 +1,35 @@ +package com.jydoc.deliverable4.dtos; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RefillReminderDTO { + private Long medicationId; + private String medicationName; + private int remainingDoses; + private LocalDate refillByDate; + private String pharmacyInfo; + private String urgency; // "CRITICAL", "WARNING", "INFO" + + public String getUrgencyBadgeClass() { + return switch (urgency) { + case "CRITICAL" -> "badge bg-danger"; + case "WARNING" -> "badge bg-warning text-dark"; + default -> "badge bg-info text-dark"; + }; + } + + public String getDaysUntilRefill() { + if (refillByDate == null) return "N/A"; + long days = LocalDate.now().datesUntil(refillByDate).count(); + return days + " day" + (days != 1 ? "s" : ""); + } +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/dtos/UpcomingMedicationDto.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/dtos/UpcomingMedicationDto.java new file mode 100644 index 000000000..4e5652f0d --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/dtos/UpcomingMedicationDto.java @@ -0,0 +1,42 @@ +package com.jydoc.deliverable4.dtos; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * DTO representing an upcoming medication for the dashboard view. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UpcomingMedicationDto { + private String name; + private String dosage; + private String nextDoseTime; + private boolean taken; + + // Optional additional fields that might be useful + private String instructions; + private String status; // "UPCOMING", "MISSED", "TAKEN" + private String urgency; // "URGENT", "NONURGENT", "ROUTINE" + + /** + * Helper method to get a CSS class for status display + */ + public String getStatusBadgeClass() { + if (taken) { + return "badge bg-success"; + } + return "badge bg-warning text-dark"; + } + + /** + * Helper method to get status text + */ + public String getStatusText() { + return taken ? "Taken" : "Pending"; + } +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/dtos/userdtos/DashboardDTO.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/dtos/userdtos/DashboardDTO.java new file mode 100644 index 000000000..df0c65d26 --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/dtos/userdtos/DashboardDTO.java @@ -0,0 +1,40 @@ +package com.jydoc.deliverable4.dtos.userdtos; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +public class DashboardDTO { + + + private String username; + private List healthConditions; + private int activeMedicationsCount; + private int todaysDosesCount; + private int healthMetricsCount; + private List alerts; + private List upcomingMedications; + private boolean hasMedications; + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class MedicationAlertDto { + private String type; + private String message; + private String medicationName; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class UpcomingMedicationDto { + private String name; + private String dosage; + private String nextDoseTime; + private boolean taken; + } +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/dtos/userdtos/LoginDTO.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/dtos/userdtos/LoginDTO.java new file mode 100644 index 000000000..24492c4b9 --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/dtos/userdtos/LoginDTO.java @@ -0,0 +1,52 @@ +package com.jydoc.deliverable4.dtos.userdtos; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import java.io.Serial; +import java.io.Serializable; + +/** + * Data Transfer Object (DTO) for user login requests. + *

+ * Validates credentials format and provides utility methods for credential processing. + * + * @param username The username or email for authentication (case-insensitive) + * @param password The password for authentication + */ +public record LoginDTO( + @NotBlank(message = "Username or email cannot be blank") + String username, + + @NotBlank(message = "Password cannot be blank") + @Size(min = 8, message = "Password must be at least 8 characters long") + String password +) implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * Creates an empty LoginDTO instance. + * @return A LoginDTO with empty strings for both fields + */ + public static LoginDTO empty() { + return new LoginDTO("", ""); + } + + /** + * Returns a normalized version of the username (trimmed and lowercase). + * @return The processed username ready for comparison + */ + public String getNormalizedUsername() { + return username.trim().toLowerCase(); + } + + /** + * Checks if this LoginDTO represents an empty credential set. + * @return true if both username and password are blank + */ + public boolean isEmpty() { + return username.isBlank() && password.isBlank(); + } +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/dtos/userdtos/UserDTO.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/dtos/userdtos/UserDTO.java new file mode 100644 index 000000000..77e4581d1 --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/dtos/userdtos/UserDTO.java @@ -0,0 +1,108 @@ +package com.jydoc.deliverable4.dtos.userdtos; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.Getter; +import lombok.Setter; + +/** + * Data Transfer Object (DTO) for user-related operations. + * This class represents the user data that is transferred between layers, + * particularly between the controller and service layers. + * + *

Includes validation constraints for user input fields to ensure data integrity + * before processing. Uses Lombok annotations to reduce boilerplate code for getters/setters.

+ * + * + * @version 1.0 + * @see com.jydoc.deliverable4.model.UserModel + * @since 1.0 + */ +@Setter +@Getter +@Data +public class UserDTO { + + /** + * The username for authentication. Must be unique and between 3-20 characters. + * + * @NotBlank Ensures the username is not null or empty + * @Size Constrains the length between 3-20 characters + */ + @NotBlank(message = "Username is required") + @Size(min = 3, max = 20, message = "Username must be 3-20 characters") + private String username; + + /** + * The password for authentication. Must meet complexity requirements. + * + * @NotBlank Ensures the password is not null or empty + * @Size Requires minimum 6 characters + * @Pattern Enforces at least one uppercase, one lowercase letter and one number + */ + @NotBlank(message = "Password is required") + @Size(min = 6, message = "Password must be at least 6 characters") + @Pattern( + regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).+$", + message = "Password must contain at least one uppercase, lowercase letter and number" + ) + private String password; + + /** + * The user's email address. Must be in valid format. + * + * @NotBlank Ensures the email is not null or empty + * @Pattern Validates the email format using regex pattern + */ + @NotBlank(message = "Email is required") + @Pattern(regexp = "^[a-zA-Z0-9_+&*-]+(?:\\.[a-zA-Z0-9_+&*-]+)*@" + + "(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,7}$", + message = "Invalid email format") + private String email; + + /** + * The user's first name. Cannot be blank. + */ + @NotBlank(message = "First name is required") + private String firstName; + + /** + * The user's last name. Cannot be blank. + */ + @NotBlank(message = "Last name is required") + private String lastName; + + /** + * The authority/role assigned to the user. Defaults to "ROLE_USER". + */ + private String authority = "ROLE_USER"; + + /** + * Default constructor. + */ + public UserDTO() { + } + + /** + * Constructs a UserDTO with specified parameters. + * + * @param username the username + * @param password the password + * @param email the email address + * @param firstName the first name + * @param lastName the last name + */ + public UserDTO(String username, String password, String email, String firstName, String lastName) { + this.username = username; + this.password = password; + this.email = email; + this.firstName = firstName; + this.lastName = lastName; + } + + + + +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/initializers/AuthorityInitializer.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/initializers/AuthorityInitializer.java new file mode 100644 index 000000000..1228b3775 --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/initializers/AuthorityInitializer.java @@ -0,0 +1,86 @@ +package com.jydoc.deliverable4.initializers; + +import com.jydoc.deliverable4.model.auth.AuthorityModel; +import com.jydoc.deliverable4.repositories.userrepositories.AuthorityRepository; +import jakarta.annotation.PostConstruct; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Set; + +/** + * Component responsible for initializing default system authorities during application startup. + * + *

This initializer ensures that essential role-based authorities exist in the system + * before the application becomes fully operational. The initialization occurs automatically + * after dependency injection is complete.

+ * + *

Key features:

+ *
    + *
  • Transactional initialization to maintain data consistency
  • + *
  • Idempotent operations - won't duplicate existing authorities
  • + *
  • Predefined set of standard authorities
  • + *
  • Lazy creation of missing authorities
  • + *
+ */ +@Component +@RequiredArgsConstructor +public class AuthorityInitializer { + + /** + * Default system authorities that will be created if they don't exist. + * + *

Contains the standard role-based authorities used throughout the application:

+ *
    + *
  • ROLE_USER - Basic authenticated user privileges
  • + *
  • ROLE_ADMIN - Full administrative privileges
  • + *
  • ROLE_MODERATOR - Content moderation privileges
  • + *
+ */ + private static final Set DEFAULT_AUTHORITIES = Set.of( + "ROLE_USER", + "ROLE_ADMIN", + "ROLE_MODERATOR" + ); + + private final AuthorityRepository authorityRepository; + + /** + * Initializes default authorities during application startup. + * + *

This method runs automatically after the bean is constructed and performs:

+ *
    + *
  1. Iteration through all default authorities
  2. + *
  3. Conditional creation of each authority if it doesn't exist
  4. + *
+ * + *

The operation is transactional, ensuring all authorities are created atomically.

+ */ + @PostConstruct + @Transactional + public void initializeDefaultAuthorities() { + DEFAULT_AUTHORITIES.forEach(this::createAuthorityIfNotExists); + } + + /** + * Creates an authority in the system if it doesn't already exist. + * + *

This method implements an idempotent create operation that:

+ *
    + *
  1. Checks for authority existence
  2. + *
  3. Only creates new authority if no matching record exists
  4. + *
  5. Returns the existing authority if found
  6. + *
+ * + * @param authorityName the name of the authority to create (e.g., "ROLE_ADMIN") + */ + private void createAuthorityIfNotExists(String authorityName) { + authorityRepository.findByAuthority(authorityName) + .orElseGet(() -> { + AuthorityModel authority = new AuthorityModel(); + authority.setAuthority(authorityName); + return authorityRepository.save(authority); + }); + } +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/model/MedicationIntakeTime.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/model/MedicationIntakeTime.java new file mode 100644 index 000000000..523ac5c9a --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/model/MedicationIntakeTime.java @@ -0,0 +1,176 @@ +package com.jydoc.deliverable4.model; + +import jakarta.persistence.*; +import lombok.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.LocalTime; +import java.util.Objects; + +/** + * Represents a scheduled medication intake time associated with a specific medication. + * This entity maps to the 'medication_intake_times' table in the database. + */ +@Entity +@Table(name = "medication_intake_times") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class MedicationIntakeTime { + private static final Logger logger = LoggerFactory.getLogger(MedicationIntakeTime.class); + + /** + * Unique identifier for the medication intake time record. + */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * The medication associated with this intake time. + * This is a many-to-one relationship with MedicationModel. + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "medication_id", nullable = false) + private MedicationModel medication; + + /** + * The scheduled time for medication intake. + * This field cannot be null. + */ + @Column(nullable = false) + private LocalTime intakeTime; + + /** + * Constructs a new MedicationIntakeTime with the specified medication and intake time. + * + * @param medication The medication associated with this intake time (cannot be null) + * @param intakeTime The scheduled time for medication intake (cannot be null) + * @throws IllegalArgumentException if intakeTime is null + * @throws NullPointerException if medication is null + */ + public MedicationIntakeTime(MedicationModel medication, LocalTime intakeTime) { + logger.debug("Constructing new MedicationIntakeTime with medication: {} and intake time: {}", + medication, intakeTime); + + if (intakeTime == null) { + logger.error("Attempted to create MedicationIntakeTime with null intakeTime"); + throw new IllegalArgumentException("Intake time cannot be null"); + } + + this.medication = Objects.requireNonNull(medication, "Medication cannot be null"); + this.intakeTime = Objects.requireNonNull(intakeTime, "Intake time cannot be null"); + + logger.info("Created new MedicationIntakeTime for medication ID: {} at time: {}", + medication.getId(), intakeTime); + } + + /** + * Sets the medication associated with this intake time. + * Maintains bidirectional relationship by updating both sides of the association. + * + * @param medication The medication to associate with this intake time (cannot be null) + * @throws NullPointerException if medication is null + */ + public void setMedication(MedicationModel medication) { + logger.debug("Setting medication for intake time ID: {}. New medication ID: {}", + this.id, medication != null ? medication.getId() : "null"); + + Objects.requireNonNull(medication, "Medication cannot be null"); + + // Remove this intake time from the previous medication's list + if (this.medication != null) { + logger.trace("Removing intake time from previous medication's list"); + this.medication.getIntakeTimes().remove(this); + } + + this.medication = medication; + + // Add this intake time to the new medication's list if not already present + if (!medication.getIntakeTimes().contains(this)) { + logger.trace("Adding intake time to new medication's list"); + medication.getIntakeTimes().add(this); + } + + logger.info("Updated medication association for intake time ID: {}. New medication ID: {}", + this.id, medication.getId()); + } + + /** + * Sets the scheduled time for medication intake. + * + * @param intakeTime The time for medication intake (cannot be null) + * @throws NullPointerException if intakeTime is null + */ + public void setIntakeTime(LocalTime intakeTime) { + logger.debug("Setting intake time for ID: {}. New time: {}", this.id, intakeTime); + + if (intakeTime == null) { + logger.error("Attempted to set null intake time for ID: {}", this.id); + throw new NullPointerException("Intake time cannot be null"); + } + + this.intakeTime = Objects.requireNonNull(intakeTime, "Intake time cannot be null"); + logger.info("Updated intake time for ID: {}. New time: {}", this.id, intakeTime); + } + + /** + * Compares this MedicationIntakeTime with another object for equality. + * Two MedicationIntakeTimes are considered equal if they have the same + * medication and intake time. + * + * @param o The object to compare with + * @return true if the objects are equal, false otherwise + */ + @Override + public boolean equals(Object o) { + if (this == o) { + logger.trace("Equals comparison with same instance"); + return true; + } + + if (!(o instanceof MedicationIntakeTime)) { + logger.trace("Equals comparison with different type"); + return false; + } + + MedicationIntakeTime that = (MedicationIntakeTime) o; + boolean isEqual = getIntakeTime().equals(that.getIntakeTime()) && + getMedication().equals(that.getMedication()); + + logger.debug("Equals comparison result: {} for IDs: {} and {}", + isEqual, this.id, that.id); + + return isEqual; + } + + /** + * Generates a hash code for this MedicationIntakeTime based on + * its medication and intake time. + * + * @return The computed hash code + */ + @Override + public int hashCode() { + int hash = Objects.hash(getIntakeTime(), getMedication()); + logger.trace("Generated hash code: {} for ID: {}", hash, this.id); + return hash; + } + + /** + * Returns a string representation of this MedicationIntakeTime. + * + * @return String representation of the object + */ + @Override + public String toString() { + return "MedicationIntakeTime{" + + "id=" + id + + ", medicationId=" + (medication != null ? medication.getId() : "null") + + ", intakeTime=" + intakeTime + + '}'; + } +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/model/MedicationModel.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/model/MedicationModel.java new file mode 100644 index 000000000..6b2ffcdb8 --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/model/MedicationModel.java @@ -0,0 +1,230 @@ +package com.jydoc.deliverable4.model; + +import com.jydoc.deliverable4.dtos.MedicationDTO; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.LocalTime; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Entity class representing a medication in the system. + *

+ * This class models a medication prescribed to a user, including its properties, + * intake times, urgency level, days of week, and other relevant information. + *

+ */ +@Entity +@Table(name = "medications") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@EqualsAndHashCode(of = "id") +public class MedicationModel { + private static final Logger logger = LoggerFactory.getLogger(MedicationModel.class); + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + @NotNull + private UserModel user; + + @Column(nullable = false) + @NotBlank + private String name; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + @NotNull + private MedicationUrgency urgency; + + @ElementCollection(targetClass = DayOfWeek.class, fetch = FetchType.EAGER) + @CollectionTable(name = "medication_days", + joinColumns = @JoinColumn(name = "medication_id")) + @Enumerated(EnumType.STRING) + @Column(name = "day_of_week") + @Builder.Default + private Set daysOfWeek = new HashSet<>(); + + @OneToMany(mappedBy = "medication", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private Set intakeTimes = new HashSet<>(); + + private String dosage; + private String instructions; + + @Column(name = "is_active", nullable = false) + @Builder.Default + private Boolean isActive = true; + + /** + * Enum representing the urgency level of a medication. + */ + public enum MedicationUrgency { + /** Medication requires immediate attention */ + URGENT, + /** Medication is important but not immediately critical */ + NONURGENT, + /** Regular medication without special urgency */ + ROUTINE + } + + /** + * Enum representing days of the week when medication should be taken. + */ + public enum DayOfWeek { + MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY + } + + /** + * Adds an intake time for this medication. + *

+ * Ensures bidirectional relationship consistency and prevents duplicate times. + *

+ * + * @param time The LocalTime to add as an intake time (must not be null) + * @throws NullPointerException if the time parameter is null + */ + public void addIntakeTime(LocalTime time) { + logger.debug("Attempting to add intake time: {} for medication ID: {}", time, id); + Objects.requireNonNull(time, "Intake time cannot be null"); + + if (getIntakeTimesAsLocalTimes().contains(time)) { + logger.info("Intake time {} already exists for medication ID: {}, skipping addition", time, id); + return; + } + + MedicationIntakeTime newIntake = new MedicationIntakeTime(this, time); + intakeTimes.add(newIntake); + logger.info("Added new intake time: {} for medication ID: {}", time, id); + } + + /** + * Removes an intake time from this medication. + *

+ * Handles the bidirectional relationship cleanup. + *

+ * + * @param time The LocalTime to remove (must not be null) + * @throws NullPointerException if the time parameter is null + */ + public void removeIntakeTime(LocalTime time) { + logger.debug("Attempting to remove intake time: {} from medication ID: {}", time, id); + Objects.requireNonNull(time, "Intake time cannot be null"); + + int initialSize = intakeTimes.size(); + intakeTimes.removeIf(intake -> time.equals(intake.getIntakeTime())); + + if (intakeTimes.size() < initialSize) { + logger.info("Removed intake time: {} from medication ID: {}", time, id); + } else { + logger.debug("No matching intake time found for removal: {} in medication ID: {}", time, id); + } + } + + /** + * Adds a day when this medication should be taken. + * + * @param day The DayOfWeek to add (must not be null) + * @throws NullPointerException if the day parameter is null + */ + public void addDay(DayOfWeek day) { + logger.debug("Attempting to add day: {} for medication ID: {}", day, id); + Objects.requireNonNull(day, "Day cannot be null"); + daysOfWeek.add(day); + logger.info("Added day {} to medication ID: {}", day, id); + } + + /** + * Removes a day when this medication should be taken. + * + * @param day The DayOfWeek to remove (must not be null) + * @throws NullPointerException if the day parameter is null + */ + public void removeDay(DayOfWeek day) { + logger.debug("Attempting to remove day: {} from medication ID: {}", day, id); + Objects.requireNonNull(day, "Day cannot be null"); + daysOfWeek.remove(day); + logger.info("Removed day {} from medication ID: {}", day, id); + } + + /** + * Checks if medication should be taken on a specific day. + * + * @param day The DayOfWeek to check + * @return true if medication should be taken on this day + */ + public boolean isTakenOnDay(DayOfWeek day) { + return daysOfWeek.contains(day); + } + + /** + * Gets all intake times as LocalTime objects. + * + * @return A Set of LocalTime representing all intake times for this medication + */ + public Set getIntakeTimesAsLocalTimes() { + logger.debug("Retrieving intake times as LocalTime for medication ID: {}", id); + return intakeTimes.stream() + .map(MedicationIntakeTime::getIntakeTime) + .collect(Collectors.toSet()); + } + + /** + * Converts this MedicationModel to a MedicationDTO. + *

+ * Includes all relevant fields including days of week and intake times. + *

+ * + * @return A MedicationDTO representation of this model + */ + public MedicationDTO toDto() { + logger.debug("Converting MedicationModel to DTO for medication ID: {}", id); + return MedicationDTO.builder() + .id(this.id) + .userId(this.user.getId()) + .medicationName(this.name) + .urgency(this.urgency != null ? + MedicationDTO.MedicationUrgency.valueOf(this.urgency.name()) : null) + .intakeTimes(this.getIntakeTimesAsLocalTimes()) + .daysOfWeek(this.daysOfWeek.stream() + .map(day -> MedicationDTO.DayOfWeek.valueOf(day.name())) + .collect(Collectors.toSet())) + .dosage(this.dosage) + .instructions(this.instructions) + .build(); + } + + /** + * Sets the intake times for this medication. + *

+ * Ensures bidirectional relationship consistency and prevents null values. + * This is a private method as direct replacement of the set should be controlled. + *

+ * + * @param intakeTimes The set of MedicationIntakeTime objects to set + */ + private void setIntakeTimes(Set intakeTimes) { + logger.debug("Setting intake times for medication ID: {}", id); + this.intakeTimes = intakeTimes != null ? intakeTimes : new HashSet<>(); + + // Ensure bidirectional consistency + this.intakeTimes.forEach(it -> { + it.setMedication(this); + logger.trace("Ensured bidirectional consistency for intake time: {} in medication ID: {}", + it.getIntakeTime(), id); + }); + } +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/model/UserModel.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/model/UserModel.java new file mode 100644 index 000000000..7b915847f --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/model/UserModel.java @@ -0,0 +1,210 @@ +package com.jydoc.deliverable4.model; + +import com.jydoc.deliverable4.model.auth.AuthorityModel; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.Fetch; +import org.hibernate.annotations.FetchMode; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * Entity class representing a user in the system. + * + *

This class maps to the "users" database table and includes:

+ *
    + *
  • Core user credentials (username, password)
  • + *
  • Personal information (name, email)
  • + *
  • Account status flags
  • + *
  • Role-based authorities
  • + *
+ * + *

Security Features:

+ *
    + *
  • Immutable authority collections to prevent unintended modifications
  • + *
  • Account status tracking (locked, expired, etc.)
  • + *
  • Proper JPA relationship mapping for authorities
  • + *
+ * + * @version 1.2 + * @see AuthorityModel The authority/role entity + * @since 1.0 + */ +@Entity +@Table(name = "users") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder(toBuilder = true) +public class UserModel { + + /** + * Primary key identifier. + */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * Unique username for authentication. + * + *

Constraints:

+ *
    + *
  • Database unique constraint
  • + *
  • Non-nullable
  • + *
  • Maximum 50 characters
  • + *
+ */ + @Column(unique = true, nullable = false, length = 50) + private String username; + + /** + * Hashed password for authentication. + * + *

Constraints:

+ *
    + *
  • Non-nullable
  • + *
  • Maximum 100 characters (to accommodate hashing)
  • + *
+ * + *

Security Note: Should always be stored hashed

+ */ + @Column(nullable = false, length = 100) + private String password; + + /** + * User's email address. + * + *

Constraints:

+ *
    + *
  • Database unique constraint
  • + *
  • Maximum 100 characters
  • + *
+ */ + @Column(unique = true, length = 100) + private String email; + + /** + * User's first/given name. + * + *

Constraints:

+ *
    + *
  • Non-nullable
  • + *
  • Maximum 50 characters
  • + *
+ */ + @Column(name = "first_name", nullable = false, length = 50) + private String firstName; + + /** + * User's last/family name. + * + *

Constraints:

+ *
    + *
  • Non-nullable
  • + *
  • Maximum 50 characters
  • + *
+ */ + @Column(name = "last_name", nullable = false, length = 50) + private String lastName; + + /** + * Flag indicating if the account is enabled. + * + *

Default: true (accounts are enabled by default)

+ */ + @Builder.Default + private boolean enabled = true; + + /** + * Flag indicating if the account is non-expired. + * + *

Default: true (accounts don't expire by default)

+ */ + @Builder.Default + private boolean accountNonExpired = true; + + /** + * Flag indicating if credentials are non-expired. + * + *

Default: true (credentials don't expire by default)

+ */ + @Builder.Default + private boolean credentialsNonExpired = true; + + /** + * Flag indicating if the account is non-locked. + * + *

Default: true (accounts aren't locked by default)

+ */ + @Builder.Default + private boolean accountNonLocked = true; + + /** + * Set of authorities/roles granted to the user. + * + *

Relationship Details:

+ *
    + *
  • Many-to-many with AuthorityModel
  • + *
  • Eager fetching for immediate availability
  • + *
  • Cascade persist and merge operations
  • + *
  • Mapped via join table "user_authorities"
  • + *
+ */ + @ManyToMany(fetch = FetchType.EAGER, cascade = {CascadeType.PERSIST, CascadeType.MERGE}) + @JoinTable( + name = "user_authorities", + joinColumns = @JoinColumn(name = "user_id"), + inverseJoinColumns = @JoinColumn(name = "authority_id") + ) + @Fetch(FetchMode.JOIN) + @Builder.Default + private Set authorities = new HashSet<>(); + + /** + * Returns an unmodifiable view of the user's authorities. + * + * @return immutable set of authorities + */ + public Set getAuthorities() { + return Collections.unmodifiableSet(new HashSet<>(this.authorities)); + } + + /** + * Replaces all authorities with the given collection. + * + * @param authorities new collection of authorities (null creates empty set) + */ + public void setAuthorities(Collection authorities) { + this.authorities = authorities != null ? new HashSet<>(authorities) : new HashSet<>(); + } + + /** + * Adds a single authority to the user. + * + * @param authority the authority to add + */ + public void addAuthority(AuthorityModel authority) { + this.authorities.add(authority); + authority.getUsers().add(this); + } + + /** + * Removes a single authority from the user. + * + * @param authority the authority to remove + */ + public void removeAuthority(AuthorityModel authority) { + this.authorities.remove(authority); + authority.getUsers().remove(this); + } + + + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + private Set medications; + +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/model/auth/AuthorityModel.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/model/auth/AuthorityModel.java new file mode 100644 index 000000000..9612e1369 --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/model/auth/AuthorityModel.java @@ -0,0 +1,62 @@ +package com.jydoc.deliverable4.model.auth; + +import com.jydoc.deliverable4.model.UserModel; +import jakarta.persistence.*; +import lombok.*; +import java.util.HashSet; +import java.util.Set; + +/** + * Represents an authority/role in the system that can be assigned to users. + * Authorities define permissions or access levels for users. + */ +@Entity +@Table(name = "authorities") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder(toBuilder = true) +public class AuthorityModel { + + /** + * Unique identifier for the authority. + */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * Name of the authority (e.g., "ROLE_ADMIN", "ROLE_USER"). + * Must be unique and cannot be null. + */ + @Column(nullable = false, unique = true, length = 50) + private String authority; + + /** + * Set of users who have been granted this authority. + * Represents the many-to-many relationship with UserModel. + */ + @ManyToMany(mappedBy = "authorities") + @Builder.Default + private Set users = new HashSet<>(); + + /** + * Constructs an AuthorityModel with the given authority name. + * + * @param authority the name of the authority + */ + public AuthorityModel(String authority) { + this.authority = authority; + } + + /** + * Custom builder class for AuthorityModel that initializes the users set. + */ + public static class AuthorityModelBuilder { + /** + * The set of users for this authority, initialized as empty HashSet. + */ + private Set users = new HashSet<>(); + } +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/model/auth/UserAuthority.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/model/auth/UserAuthority.java new file mode 100644 index 000000000..5205320cc --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/model/auth/UserAuthority.java @@ -0,0 +1,40 @@ +package com.jydoc.deliverable4.model.auth; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +/** + * Represents the many-to-many relationship between users and authorities. + * This entity serves as a join table that maps users to their assigned authorities/roles. + *

+ * Uses a composite primary key represented by {@link UserAuthorityId} consisting of + * {@code userId} and {@code authorityId}. + * + * @see UserAuthorityId + */ +@Entity +@Getter +@Setter +@Table(name = "user_authorities") +@IdClass(UserAuthorityId.class) +public class UserAuthority { + + /** + * The ID of the user associated with this authority mapping. + * Part of the composite primary key. + * Cannot be null. + */ + @Id + @Column(name = "user_id", nullable = false) + private Long userId; + + /** + * The ID of the authority associated with this user mapping. + * Part of the composite primary key. + * Cannot be null. + */ + @Id + @Column(name = "authority_id", nullable = false) + private Long authorityId; +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/model/auth/UserAuthorityId.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/model/auth/UserAuthorityId.java new file mode 100644 index 000000000..3a3420ab2 --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/model/auth/UserAuthorityId.java @@ -0,0 +1,59 @@ +package com.jydoc.deliverable4.model.auth; + +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Objects; + +/** + * Composite primary key class for {@link UserAuthority} entity. + *

+ * Represents the compound key consisting of user ID and authority ID + * used in the user-authority many-to-many relationship mapping. + * + * @see UserAuthority + */ +@NoArgsConstructor +@AllArgsConstructor +public class UserAuthorityId implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * The ID of the user in the relationship. + * Part of the composite primary key. + */ + private Long userId; + + /** + * The ID of the authority in the relationship. + * Part of the composite primary key. + */ + private Long authorityId; + + /** + * Compares this composite key with another object for equality. + * @param o the object to compare with + * @return true if the objects are equal + */ + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + UserAuthorityId that = (UserAuthorityId) o; + return Objects.equals(userId, that.userId) && + Objects.equals(authorityId, that.authorityId); + } + + /** + * Generates a hash code for this composite key. + * @return the hash code + */ + @Override + public int hashCode() { + return Objects.hash(userId, authorityId); + } +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/repositories/medicationrepositories/MedicationIntakeTimeRepository.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/repositories/medicationrepositories/MedicationIntakeTimeRepository.java new file mode 100644 index 000000000..1618a719d --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/repositories/medicationrepositories/MedicationIntakeTimeRepository.java @@ -0,0 +1,41 @@ +package com.jydoc.deliverable4.repositories.medicationrepositories; + +import com.jydoc.deliverable4.model.MedicationIntakeTime; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalTime; +import java.util.List; + +@Repository +public interface MedicationIntakeTimeRepository extends JpaRepository { + + // Find all intake times for a specific medication + List findByMedicationId(Long medicationId); + + // Delete all intake times for a specific medication + @Modifying + @Query("DELETE FROM MedicationIntakeTime mit WHERE mit.medication.id = :medicationId") + int deleteByMedicationId(@Param("medicationId") Long medicationId); + + @Modifying + @Query("DELETE FROM MedicationIntakeTime mit WHERE mit.medication.id = :medicationId AND mit.intakeTime = :intakeTime") + void deleteByMedicationIdAndIntakeTime(@Param("medicationId") Long medicationId, @Param("intakeTime") LocalTime intakeTime); + + // Check if a specific intake time exists for a medication + boolean existsByMedicationIdAndIntakeTime(Long medicationId, LocalTime intakeTime); + + // Find intake times by time range for a specific medication + @Query("SELECT mit FROM MedicationIntakeTime mit WHERE mit.medication.id = :medicationId " + + "AND mit.intakeTime BETWEEN :startTime AND :endTime") + List findByMedicationAndTimeRange( + @Param("medicationId") Long medicationId, + @Param("startTime") LocalTime startTime, + @Param("endTime") LocalTime endTime); + + + +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/repositories/medicationrepositories/MedicationRepository.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/repositories/medicationrepositories/MedicationRepository.java new file mode 100644 index 000000000..ae67bcd89 --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/repositories/medicationrepositories/MedicationRepository.java @@ -0,0 +1,26 @@ +package com.jydoc.deliverable4.repositories.medicationrepositories; + +import com.jydoc.deliverable4.model.MedicationModel; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface MedicationRepository extends JpaRepository { + + // Basic user medication query + List findByUserUsername(String username); + + // In MedicationRepository.java + @Query("SELECT DISTINCT m FROM MedicationModel m LEFT JOIN FETCH m.intakeTimes WHERE m.user.username = :username") + List findByUserUsernameWithIntakeTimes(@Param("username") String username); + + + @Query("SELECT DISTINCT m FROM MedicationModel m " + + "LEFT JOIN FETCH m.intakeTimes " + + "LEFT JOIN FETCH m.daysOfWeek " + + "WHERE m.user.username = :username") + List findByUserUsernameWithMedicationDetails(@Param("username") String username); + +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/repositories/userrepositories/AuthorityRepository.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/repositories/userrepositories/AuthorityRepository.java new file mode 100644 index 000000000..86782faf7 --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/repositories/userrepositories/AuthorityRepository.java @@ -0,0 +1,33 @@ +package com.jydoc.deliverable4.repositories.userrepositories; + +import com.jydoc.deliverable4.model.auth.AuthorityModel; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +/** + * Repository for managing {@link AuthorityModel} entities. + * Provides methods to query authority data from the database. + */ +public interface AuthorityRepository extends JpaRepository { + + /** + * Finds all authority models associated with a specific user ID. + * + * @param userId the ID of the user to search for + * @return a list of authority models associated with the user + */ + @Query("SELECT a FROM AuthorityModel a JOIN a.users u WHERE u.id = :userId") + List findAllByUserId(@Param("userId") Long userId); + + /** + * Finds an authority model by its authority string. + * + * @param authority the authority string to search for + * @return an Optional containing the authority model if found + */ + Optional findByAuthority(String authority); +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/repositories/userrepositories/UserAuthorityRepository.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/repositories/userrepositories/UserAuthorityRepository.java new file mode 100644 index 000000000..841234fe7 --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/repositories/userrepositories/UserAuthorityRepository.java @@ -0,0 +1,34 @@ +package com.jydoc.deliverable4.repositories.userrepositories; + +import com.jydoc.deliverable4.model.auth.UserAuthority; +import com.jydoc.deliverable4.model.auth.UserAuthorityId; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +/** + * Repository for managing {@link UserAuthority} entities. + * Provides methods to query user-authority relationships from the database. + */ +public interface UserAuthorityRepository extends JpaRepository { + + /** + * Finds all user-authority relationships for a given user ID using JPQL. + * + * @param userId the ID of the user to search for + * @return list of user-authority relationships + */ + @Query("SELECT ua FROM UserAuthority ua WHERE ua.userId = :userId") + List findAllByUserId(@Param("userId") Long userId); + + /** + * Finds all user-authority relationships for a given user ID using native SQL. + * + * @param userId the ID of the user to search for + * @return list of user-authority relationships + */ + @Query(value = "SELECT * FROM user_authorities WHERE user_id = :userId", nativeQuery = true) + List findAllByUserIdNative(@Param("userId") Long userId); +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/repositories/userrepositories/UserRepository.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/repositories/userrepositories/UserRepository.java new file mode 100644 index 000000000..0b362e50a --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/repositories/userrepositories/UserRepository.java @@ -0,0 +1,73 @@ +package com.jydoc.deliverable4.repositories.userrepositories; + +import com.jydoc.deliverable4.model.UserModel; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +/** + * Repository for {@link UserModel} entities providing user lookup operations. + * Includes methods for finding users with different loading strategies for authorities. + */ +@Repository +public interface UserRepository extends JpaRepository { + + // Basic user lookups + Optional findByUsername(String username); + boolean existsByUsername(String username); + boolean existsByEmail(String email); + + /** + * Finds a user by username with authorities eagerly loaded. + * @param username the username to search for + * @return user with authorities loaded + */ + @Query("SELECT DISTINCT u FROM UserModel u LEFT JOIN FETCH u.authorities WHERE u.username = :username") + Optional findByUsernameWithAuthorities(@Param("username") String username); + + /** + * Finds a user by username or email (case-insensitive). + * @param credential username or email to search for + * @return matching user if found + */ + @Query("SELECT u FROM UserModel u WHERE LOWER(u.username) = LOWER(:credential) OR LOWER(u.email) = LOWER(:credential)") + Optional findByUsernameOrEmail(@Param("credential") String credential); + + /** + * Finds a user by username or email with authorities eagerly loaded. + * @param credential username or email to search for + * @return matching user with authorities if found + */ + @Query("SELECT DISTINCT u FROM UserModel u LEFT JOIN FETCH u.authorities " + + "WHERE u.username = :credential OR u.email = :credential") + Optional findByUsernameOrEmailWithAuthorities(@Param("credential") String credential); + +// /** TODO: Implement this +// * Finds the most recent users ordered by creation date. +// * @param limit maximum number of users to return +// * @return list of recent users +// */ +// @Query("SELECT u FROM UserModel u ORDER BY u.createdDate DESC LIMIT :limit") +// List findTopNByOrderByCreatedDateDesc(@Param("limit") int limit); + + /** + * Finds all users with their authorities eagerly loaded. + * + * @return list of all users with authorities + */ + @Query("SELECT DISTINCT u FROM UserModel u LEFT JOIN FETCH u.authorities") + List findAllWithAuthorities(); + + /** + * Checks if a user exists by ID. + * + * @param id the user ID to check + * @return true if user exists, false otherwise + */ + boolean existsById(Long id); + +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/security/Exceptions/EmailExistsException.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/security/Exceptions/EmailExistsException.java new file mode 100644 index 000000000..90c1d9935 --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/security/Exceptions/EmailExistsException.java @@ -0,0 +1,52 @@ +package com.jydoc.deliverable4.security.Exceptions; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Exception thrown when attempting to register with an email address + * that already exists in the system. + * + *

This exception should be thrown during user registration validation + * when a duplicate email address is detected.

+ */ +public class EmailExistsException extends RuntimeException { + private static final Logger logger = LogManager.getLogger(EmailExistsException.class); + + /** + * Constructs a new exception with a standardized message format. + * + * @param email the duplicate email address that caused the exception + */ + public EmailExistsException(String email) { + super(String.format("The email address '%s' is already registered", email)); + logger.warn("Registration attempt with existing email: {}", email); + } + + /** + * Constructs a new exception with custom message and cause. + * + * @param message the detail message + * @param cause the underlying cause + */ + public EmailExistsException(String message, Throwable cause) { + super(message, cause); + logger.warn("Email conflict detected: {}", message, cause); + } + + /** + * Gets the duplicate email that caused this exception. + * + * @return the duplicate email address + */ + public String getEmail() { + return extractEmailFromMessage(getMessage()); + } + + private String extractEmailFromMessage(String message) { + if (message != null && message.contains("'")) { + return message.substring(message.indexOf("'") + 1, message.lastIndexOf("'")); + } + return "unknown"; + } +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/security/Exceptions/MedicationCreationException.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/security/Exceptions/MedicationCreationException.java new file mode 100644 index 000000000..3970ffaa8 --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/security/Exceptions/MedicationCreationException.java @@ -0,0 +1,11 @@ +package com.jydoc.deliverable4.security.Exceptions; + +public class MedicationCreationException extends RuntimeException { + public MedicationCreationException(String message) { + super(message); + } + + public MedicationCreationException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/security/Exceptions/MedicationNotFoundException.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/security/Exceptions/MedicationNotFoundException.java new file mode 100644 index 000000000..c3e9f26cd --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/security/Exceptions/MedicationNotFoundException.java @@ -0,0 +1,7 @@ +package com.jydoc.deliverable4.security.Exceptions; + +public class MedicationNotFoundException extends RuntimeException { + public MedicationNotFoundException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/security/Exceptions/MedicationScheduleException.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/security/Exceptions/MedicationScheduleException.java new file mode 100644 index 000000000..edcb9e0b2 --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/security/Exceptions/MedicationScheduleException.java @@ -0,0 +1,7 @@ +package com.jydoc.deliverable4.security.Exceptions; + +public class MedicationScheduleException extends RuntimeException { + public MedicationScheduleException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/security/Exceptions/PasswordMismatchException.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/security/Exceptions/PasswordMismatchException.java new file mode 100644 index 000000000..797a72c70 --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/security/Exceptions/PasswordMismatchException.java @@ -0,0 +1,7 @@ +package com.jydoc.deliverable4.security.Exceptions; + +public class PasswordMismatchException extends RuntimeException { + public PasswordMismatchException(String message) { + super(message); + } +} diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/security/Exceptions/UserNotFoundException.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/security/Exceptions/UserNotFoundException.java new file mode 100644 index 000000000..58a854677 --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/security/Exceptions/UserNotFoundException.java @@ -0,0 +1,26 @@ +package com.jydoc.deliverable4.security.Exceptions; + +/** + * Exception thrown when a requested user cannot be found in the system. + * + *

This exception typically occurs when attempting to retrieve, update, or delete + * a user with a specific ID that doesn't exist in the database.

+ * + *

The exception includes the ID that was searched for in its error message, + * making it easier to diagnose the issue during debugging.

+ */ +public class UserNotFoundException extends RuntimeException { + + /** + * Constructs a new UserNotFoundException with a detailed message containing + * the ID that couldn't be found. + * + * @param id The user ID that could not be found in the system. The ID will + * be included in the exception's detail message. + * @throws NullPointerException if the provided id is null (though primitive + * long can't be null, this note is included for documentation completeness) + */ + public UserNotFoundException(Long id) { + super("Could not find user with id: " + id); + } +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/security/Exceptions/UsernameExistsException.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/security/Exceptions/UsernameExistsException.java new file mode 100644 index 000000000..7db42464b --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/security/Exceptions/UsernameExistsException.java @@ -0,0 +1,31 @@ +package com.jydoc.deliverable4.security.Exceptions; + +/** + * Custom exception thrown when attempting to create or update a user with a username + * that already exists in the system. + * + *

This exception should be used during user registration or profile updates + * to enforce unique username constraints. + */ +public class UsernameExistsException extends RuntimeException { + + /** + * Constructs a new UsernameExistsException with the specified detail message. + * + * @param message the detail message that explains which username already exists. + * The message is saved for later retrieval by the {@link #getMessage()} method. + */ + public UsernameExistsException(String message) { + super(message); + } + + /** + * Constructs a new UsernameExistsException with the specified detail message and cause. + * + * @param message the detail message that explains which username already exists + * @param cause the underlying cause of this exception + */ + public UsernameExistsException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/security/Exceptions/WeakPasswordException.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/security/Exceptions/WeakPasswordException.java new file mode 100644 index 000000000..7747a46f0 --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/security/Exceptions/WeakPasswordException.java @@ -0,0 +1,7 @@ +package com.jydoc.deliverable4.security.Exceptions; + +public class WeakPasswordException extends RuntimeException { + public WeakPasswordException(String message) { + super(message); + } +} diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/security/auth/CustomUserDetails.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/security/auth/CustomUserDetails.java new file mode 100644 index 000000000..bd81f954b --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/security/auth/CustomUserDetails.java @@ -0,0 +1,199 @@ +package com.jydoc.deliverable4.security.auth; + +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.io.Serial; +import java.util.Collection; +import java.util.Collections; +import java.util.Objects; + +/** + * Custom implementation of Spring Security's {@link UserDetails} interface. + * This class represents an authenticated user's details and is used throughout + * the security context of the application. + * + *

It extends Spring Security's core user details with additional user ID field + * while implementing all required authentication and authorization contract methods.

+ * + *

The class is immutable and thread-safe by design, with all fields being final.

+ * + * @author Your Name + * @version 1.0 + * @see org.springframework.security.core.userdetails.UserDetails + * @since 1.0 + */ +public class CustomUserDetails implements UserDetails { + @Serial + private static final long serialVersionUID = 1L; + + /** + * The unique identifier of the user in the system + * -- GETTER -- + * Returns the unique identifier of the user. + * + * @return the user ID + + */ + @Getter + private final Long userId; + + /** + * The username used to authenticate the user + */ + private final String username; + + /** + * The encrypted password of the user + */ + private final String password; + + /** + * Flag indicating whether the user is enabled + */ + private final boolean enabled; + + /** + * Flag indicating whether the user's account is non-expired + */ + private final boolean accountNonExpired; + + /** + * Flag indicating whether the user's account is non-locked + */ + private final boolean accountNonLocked; + + /** + * Flag indicating whether the user's credentials are non-expired + */ + private final boolean credentialsNonExpired; + + /** + * Collection of authorities (roles/permissions) granted to the user + */ + private final Collection authorities; + + /** + * Constructs a new CustomUserDetails with the specified parameters. + * + * @param userId the unique identifier of the user (cannot be null) + * @param username the username used for authentication (cannot be null) + * @param password the encrypted password (cannot be null) + * @param enabled whether the user is enabled + * @param accountNonExpired whether the account is non-expired + * @param accountNonLocked whether the account is non-locked + * @param credentialsNonExpired whether the credentials are non-expired + * @param authorities the collection of granted authorities (cannot be null) + * @throws NullPointerException if any of the non-null parameters are null + */ + public CustomUserDetails(Long userId, String username, String password, + boolean enabled, boolean accountNonExpired, + boolean accountNonLocked, boolean credentialsNonExpired, + Collection authorities) { + this.userId = Objects.requireNonNull(userId); + this.username = Objects.requireNonNull(username); + this.password = Objects.requireNonNull(password); + this.enabled = enabled; + this.accountNonExpired = accountNonExpired; + this.accountNonLocked = accountNonLocked; + this.credentialsNonExpired = credentialsNonExpired; + this.authorities = Objects.requireNonNull(authorities); + } + + /** + * Returns the authorities granted to the user. + * + * @return a collection of granted authorities + */ + @Override + public Collection getAuthorities() { + return Collections.unmodifiableCollection(authorities); // ✅ Safe + } + + /** + * Returns the password used to authenticate the user. + * + * @return the password + */ + @Override + public String getPassword() { + return password; + } + + /** + * Returns the username used to authenticate the user. + * + * @return the username + */ + @Override + public String getUsername() { + return username; + } + + /** + * Indicates whether the user's account has expired. + * + * @return true if the account is non-expired, false otherwise + */ + @Override + public boolean isAccountNonExpired() { + return accountNonExpired; + } + + /** + * Indicates whether the user is locked or unlocked. + * + * @return true if the account is non-locked, false otherwise + */ + @Override + public boolean isAccountNonLocked() { + return accountNonLocked; + } + + /** + * Indicates whether the user's credentials (password) have expired. + * + * @return true if the credentials are non-expired, false otherwise + */ + @Override + public boolean isCredentialsNonExpired() { + return credentialsNonExpired; + } + + /** + * Indicates whether the user is enabled or disabled. + * + * @return true if the user is enabled, false otherwise + */ + @Override + public boolean isEnabled() { + return enabled; + } + + /** + * Compares this CustomUserDetails with another object for equality. + * Two CustomUserDetails are considered equal if they have the same userId and username. + * + * @param o the object to compare with + * @return true if the objects are equal, false otherwise + */ + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + CustomUserDetails that = (CustomUserDetails) o; + return Objects.equals(userId, that.userId) && + Objects.equals(username, that.username); + } + + /** + * Returns a hash code value for this CustomUserDetails. + * + * @return a hash code value based on userId and username + */ + @Override + public int hashCode() { + return Objects.hash(userId, username); + } +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/security/auth/CustomUserDetailsService.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/security/auth/CustomUserDetailsService.java new file mode 100644 index 000000000..6f46c3f2e --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/security/auth/CustomUserDetailsService.java @@ -0,0 +1,85 @@ +package com.jydoc.deliverable4.security.auth; + +import com.jydoc.deliverable4.model.UserModel; +import com.jydoc.deliverable4.repositories.userrepositories.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.stream.Collectors; + +/** + * Custom implementation of Spring Security's {@link UserDetailsService}. + *

+ * This service is responsible for loading user-specific data during authentication. + * It bridges the application's {@link UserModel} with Spring Security's authentication framework. + * + *

Key responsibilities include: + *

    + *
  • Loading user details by username or email
  • + *
  • Converting application roles to Spring Security authorities
  • + *
  • Handling user not found scenarios
  • + *
  • Providing transactional access to user data
  • + *
+ * + * @Service Marks this class as a Spring service component + * @RequiredArgsConstructor Generates constructor for final fields (Dependency Injection) + * @see UserDetailsService + * @see UserDetails + * @see UserModel + */ +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + /** + * Repository for accessing user data. + * Injected automatically by Spring via constructor. + */ + private final UserRepository userRepository; + + /** + * Loads user details by username or email. + *

+ * This is the core method of the UserDetailsService interface. It: + *

    + *
  1. Attempts to find the user by username or email
  2. + *
  3. Throws UsernameNotFoundException if user not found
  4. + *
  5. Converts the user's authorities to Spring Security GrantedAuthority objects
  6. + *
  7. Constructs a CustomUserDetails object with all required authentication information
  8. + *
+ * + * @param usernameOrEmail the username or email address to search for + * @return UserDetails implementation containing the user's authentication information + * @throws UsernameNotFoundException if no user is found with the given username/email + * @implNote The method is transactional with readOnly=true since it only reads data + * @see CustomUserDetails + * @see Transactional + */ + @Override + @Transactional(readOnly = true) + public UserDetails loadUserByUsername(String usernameOrEmail) throws UsernameNotFoundException { + // Find user with their authorities in a single query + UserModel user = userRepository.findByUsernameOrEmailWithAuthorities(usernameOrEmail) + .orElseThrow(() -> new UsernameNotFoundException( + "User not found with username or email: " + usernameOrEmail)); + + // Convert UserModel to Spring Security's UserDetails implementation + return new CustomUserDetails( + user.getId(), + user.getUsername(), + user.getPassword(), + user.isEnabled(), + user.isAccountNonExpired(), + user.isAccountNonLocked(), + user.isCredentialsNonExpired(), + user.getAuthorities().stream() + .map(auth -> new SimpleGrantedAuthority(auth.getAuthority())) + .collect(Collectors.toSet()) + ); + } +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/security/handlers/CustomAuthenticationSuccessHandler.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/security/handlers/CustomAuthenticationSuccessHandler.java new file mode 100644 index 000000000..56883ee7b --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/security/handlers/CustomAuthenticationSuccessHandler.java @@ -0,0 +1,95 @@ +package com.jydoc.deliverable4.security.handlers; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; + +import java.io.IOException; + +/** + * Custom authentication success handler that determines the redirect target URL + * based on the user's authorities after successful login. + * + *

This handler extends Spring Security's {@link SimpleUrlAuthenticationSuccessHandler} + * to provide role-based redirection logic:

+ *
    + *
  • Administrators are redirected to the admin dashboard
  • + *
  • Regular users are redirected to the standard dashboard
  • + *
+ * + *

Security Considerations:

+ *
    + *
  • Ensures proper redirection based on verified authorities
  • + *
  • Handles already-committed responses gracefully
  • + *
  • Follows Spring Security's authentication flow
  • + *
+ */ +public class CustomAuthenticationSuccessHandler + extends SimpleUrlAuthenticationSuccessHandler + implements AuthenticationSuccessHandler { + + /** + * Path for admin dashboard redirect + */ + private static final String ADMIN_DASHBOARD_URL = "/admin/dashboard"; + + /** + * Path for regular user dashboard redirect + */ + private static final String USER_DASHBOARD_URL = "/dashboard"; + + /** + * Handles successful authentication by redirecting to the appropriate target URL. + * + * @param request the HTTP request + * @param response the HTTP response + * @param authentication the authentication object containing user authorities + * @throws IOException if a redirect error occurs + */ + @Override + public void onAuthenticationSuccess(HttpServletRequest request, + HttpServletResponse response, + Authentication authentication) throws IOException { + + String targetUrl = determineTargetUrl(authentication); + + if (response.isCommitted()) { + logger.debug("Response already committed - unable to redirect to " + targetUrl); + return; + } + + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } + + /** + * Determines the target URL based on the user's authorities. + * + *

The logic checks for the presence of ROLE_ADMIN authority:

+ *
    + *
  • If present: redirects to admin dashboard
  • + *
  • Otherwise: redirects to regular dashboard
  • + *
+ * + * @param authentication the authentication object containing user authorities + * @return the appropriate target URL + */ + protected String determineTargetUrl(Authentication authentication) { + boolean isAdmin = authentication.getAuthorities().stream() + .anyMatch(this::isAdminAuthority); + + return isAdmin ? ADMIN_DASHBOARD_URL : USER_DASHBOARD_URL; + } + + /** + * Checks if a given authority represents an admin role. + * + * @param authority the granted authority to check + * @return true if the authority is ROLE_ADMIN, false otherwise + */ + private boolean isAdminAuthority(GrantedAuthority authority) { + return authority.getAuthority().equals("ROLE_ADMIN"); + } +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/services/authservices/AuthService.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/services/authservices/AuthService.java new file mode 100644 index 000000000..3571648af --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/services/authservices/AuthService.java @@ -0,0 +1,350 @@ +package com.jydoc.deliverable4.services.authservices; + +import com.jydoc.deliverable4.dtos.userdtos.LoginDTO; +import com.jydoc.deliverable4.dtos.userdtos.UserDTO; +import com.jydoc.deliverable4.model.auth.AuthorityModel; +import com.jydoc.deliverable4.model.UserModel; +import com.jydoc.deliverable4.repositories.userrepositories.AuthorityRepository; +import com.jydoc.deliverable4.repositories.userrepositories.UserRepository; +import com.jydoc.deliverable4.services.userservices.UserValidationHelper; +import lombok.RequiredArgsConstructor; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.DisabledException; +import org.springframework.security.authentication.LockedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashSet; + +/** + * Service handling all authentication and authorization operations including: + * - User registration and credential management + * - Login authentication and validation + * - Account status checks + * - Role assignment and management + * + *

This service integrates with Spring Security's authentication mechanisms + * while providing additional business logic for user management.

+ * + *

All methods perform comprehensive validation and throw appropriate exceptions + * for error conditions.

+ */ +@Service +@RequiredArgsConstructor +public class AuthService { + private static final Logger logger = LogManager.getLogger(AuthService.class); + private static final String DEFAULT_ROLE = "ROLE_USER"; + + // Dependencies injected via constructor (using Lombok @RequiredArgsConstructor) + private final UserRepository userRepository; + private final AuthorityRepository authorityRepository; + private final PasswordEncoder passwordEncoder; + private final AuthenticationManager authenticationManager; + private final UserValidationHelper validationHelper; + + /* ====================== Public API Methods ====================== */ + + /** + * Registers a new user in the system with comprehensive validation. + * + *

Performs the following operations:

+ *
    + *
  1. Validates all required user fields
  2. + *
  3. Checks for duplicate username/email
  4. + *
  5. Encodes the password
  6. + *
  7. Assigns default role (ROLE_USER)
  8. + *
  9. Persists the user entity
  10. + *
+ * + * @param userDto Data Transfer Object containing user registration information + * @throws IllegalArgumentException if any required field is missing or invalid + * @throws UsernameExistsException if the username is already taken + * @throws EmailExistsException if the email is already registered + */ + @Transactional + public void registerNewUser(UserDTO userDto) { + validationHelper.validateUserRegistration(userDto); + validateUserDto(userDto); + checkForExistingCredentials(userDto); + + UserModel user = createUserFromDto(userDto); + assignDefaultRole(user); + userRepository.save(user); + logger.info("Registered new user: {}", user.getUsername()); + } + + /** + * Authenticates a user using Spring Security's authentication manager. + * + *

This method:

+ *
    + *
  • Delegates authentication to Spring Security
  • + *
  • Handles various authentication failure scenarios
  • + *
  • Returns the authenticated user entity on success
  • + *
+ * + * @param loginDto Data Transfer Object containing login credentials + * @return Authenticated UserModel entity + * @throws IllegalArgumentException if loginDto is null + * @throws AuthenticationException for authentication failures (bad credentials, + * disabled account, locked account, etc.) + */ + @Transactional(readOnly = true) + public UserModel authenticate(LoginDTO loginDto) { + if (loginDto == null) { + throw new IllegalArgumentException("Login credentials cannot be null"); + } + return authenticateUser(loginDto.username(), loginDto.password()); + } + + /** + * Validates user credentials directly against the database. + * + *

This method provides an alternative to Spring Security authentication + * with more direct control over the validation process.

+ * + * @param loginDto Data Transfer Object containing login credentials + * @return Validated UserModel entity + * @throws IllegalArgumentException if loginDto is null + * @throws AuthenticationException for invalid credentials or account issues + */ + @Transactional(readOnly = true) + public UserModel validateLogin(LoginDTO loginDto) { + if (loginDto == null) { + throw new IllegalArgumentException("Login credentials cannot be null"); + } + + String credential = loginDto.username().trim().toLowerCase(); + String rawPassword = loginDto.password(); + + logger.debug("Login attempt for: {}", credential); + UserModel user = findUserByCredential(credential); + validateUserPassword(user, rawPassword); + checkAccountEnabled(user); + + logger.info("Login successful for: {}", user.getUsername()); + return user; + } + + /* ====================== Core Business Logic ====================== */ + + /** + * Authenticates a user with Spring Security's authentication manager. + * + * @param username The username to authenticate + * @param password The raw (unencoded) password + * @return Authenticated UserModel entity + * @throws AuthenticationException wrapping various Spring Security exceptions + */ + private UserModel authenticateUser(String username, String password) { + try { + Authentication authentication = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(username, password) + ); + return findAuthenticatedUser(authentication.getName()); + } catch (BadCredentialsException e) { + handleAuthenticationFailure("Bad credentials for user: {}", username, "Invalid username or password"); + } catch (DisabledException e) { + handleAuthenticationFailure("Disabled account: {}", username, "Account is disabled"); + } catch (LockedException e) { + handleAuthenticationFailure("Locked account: {}", username, "Account is locked"); + } + return null; // Unreachable due to exception handling + } + + /** + * Creates a new UserModel entity from registration DTO. + * + * @param userDto Source data for user creation + * @return New UserModel with encoded password and trimmed fields + */ + private UserModel createUserFromDto(UserDTO userDto) { + return UserModel.builder() + .username(userDto.getUsername().trim()) + .password(passwordEncoder.encode(userDto.getPassword())) + .email(userDto.getEmail().toLowerCase().trim()) + .firstName(userDto.getFirstName().trim()) + .lastName(userDto.getLastName().trim()) + .enabled(true) + .accountNonExpired(true) + .credentialsNonExpired(true) + .accountNonLocked(true) + .build(); + } + + /** + * Assigns the default role (ROLE_USER) to a new user. + * + *

If the default role doesn't exist in the database, it will be created.

+ * + * @param user The user to receive the default role + */ + @Transactional(propagation = Propagation.MANDATORY) + protected void assignDefaultRole(UserModel user) { + AuthorityModel authority = authorityRepository.findByAuthority(DEFAULT_ROLE) + .orElseGet(this::createAndSaveNewAuthority); + user.addAuthority(authority); + } + + /* ====================== Validation Helpers ====================== */ + + /** + * Validates that all required fields in UserDTO are present and non-empty. + * + * @param userDto The DTO to validate + * @throws IllegalArgumentException if any required field is missing or empty + */ + private void validateUserDto(UserDTO userDto) { + if (userDto == null) throw new IllegalArgumentException("UserDTO cannot be null"); + if (userDto.getUsername() == null || userDto.getUsername().trim().isEmpty()) + throw new IllegalArgumentException("Username cannot be empty"); + if (userDto.getPassword() == null || userDto.getPassword().trim().isEmpty()) + throw new IllegalArgumentException("Password cannot be empty"); + if (userDto.getEmail() == null || userDto.getEmail().trim().isEmpty()) + throw new IllegalArgumentException("Email cannot be empty"); + if (userDto.getFirstName() == null || userDto.getFirstName().trim().isEmpty()) + throw new IllegalArgumentException("First name cannot be empty"); + if (userDto.getLastName() == null || userDto.getLastName().trim().isEmpty()) + throw new IllegalArgumentException("Last name cannot be empty"); + } + + /** + * Checks if username or email already exist in the system. + * + * @param userDto The DTO containing credentials to check + * @throws UsernameExistsException if username is taken + * @throws EmailExistsException if email is registered + */ + private void checkForExistingCredentials(UserDTO userDto) { + if (userRepository.existsByUsername(userDto.getUsername())) { + throw new UsernameExistsException(userDto.getUsername()); + } + if (userRepository.existsByEmail(userDto.getEmail())) { + throw new EmailExistsException(userDto.getEmail()); + } + } + + /** + * Validates that the provided raw password matches the user's encoded password. + * + * @param user The user to validate + * @param rawPassword The raw (unencoded) password to check + * @throws AuthenticationException if passwords don't match + */ + private void validateUserPassword(UserModel user, String rawPassword) { + if (!passwordEncoder.matches(rawPassword, user.getPassword())) { + logger.warn("Password mismatch for user: {}", user.getUsername()); + throw new AuthenticationException("Invalid credentials"); + } + } + + /** + * Verifies that the user account is enabled. + * + * @param user The user to check + * @throws AuthenticationException if account is disabled + */ + private void checkAccountEnabled(UserModel user) { + if (!user.isEnabled()) { + logger.warn("Disabled account login attempt: {}", user.getUsername()); + throw new AuthenticationException("Account is disabled"); + } + } + + /* ====================== Repository Helpers ====================== */ + + /** + * Finds a user by either username or email (case-insensitive). + * + * @param credential Username or email to search for + * @return Found UserModel entity + * @throws AuthenticationException if no user found + */ + private UserModel findUserByCredential(String credential) { + return userRepository.findByUsernameOrEmail(credential) + .orElseThrow(() -> { + logger.warn("User not found: {}", credential); + return new AuthenticationException("Invalid credentials"); + }); + } + + /** + * Finds a user by username after successful authentication. + * + * @param username The authenticated username + * @return Found UserModel entity + * @throws AuthenticationException if user not found (unexpected after auth) + */ + private UserModel findAuthenticatedUser(String username) { + return userRepository.findByUsername(username) + .orElseThrow(() -> { + logger.error("Authenticated user not found: {}", username); + return new AuthenticationException("User account error"); + }); + } + + /** + * Creates and persists a new authority with the default role. + * + * @return The newly created AuthorityModel + */ + private AuthorityModel createAndSaveNewAuthority() { + AuthorityModel newRole = new AuthorityModel(DEFAULT_ROLE); + newRole.setUsers(new HashSet<>()); + return authorityRepository.save(newRole); + } + + /* ====================== Exception Handling ====================== */ + + /** + * Handles authentication failures consistently with logging and exception throwing. + * + * @param logMessage The log message template + * @param username The username that failed authentication + * @param exceptionMessage The exception message for clients + * @throws AuthenticationException always + */ + private void handleAuthenticationFailure(String logMessage, String username, String exceptionMessage) { + logger.warn(logMessage, username); + throw new AuthenticationException(exceptionMessage); + } + + /* ====================== Custom Exceptions ====================== */ + + /** + * Exception indicating authentication failure. + */ + public static class AuthenticationException extends RuntimeException { + public AuthenticationException(String message) { + super(message); + logger.error("Authentication failed: {}", message); + } + } + + /** + * Exception indicating duplicate username during registration. + */ + public static class UsernameExistsException extends RuntimeException { + public UsernameExistsException(String username) { + super(String.format("Username '%s' already exists", username)); + logger.warn("Duplicate username: {}", username); + } + } + + /** + * Exception indicating duplicate email during registration. + */ + public static class EmailExistsException extends RuntimeException { + public EmailExistsException(String email) { + super(String.format("Email '%s' already registered", email)); + logger.warn("Duplicate email: {}", email); + } + } +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/services/impl/DashboardServiceImpl.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/services/impl/DashboardServiceImpl.java new file mode 100644 index 000000000..f84b500b7 --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/services/impl/DashboardServiceImpl.java @@ -0,0 +1,233 @@ +package com.jydoc.deliverable4.services.impl; + +import com.jydoc.deliverable4.dtos.MedicationScheduleDTO; +import com.jydoc.deliverable4.dtos.userdtos.DashboardDTO; +import com.jydoc.deliverable4.model.MedicationModel; +import com.jydoc.deliverable4.repositories.medicationrepositories.MedicationRepository; +import com.jydoc.deliverable4.services.userservices.DashboardService; +import com.jydoc.deliverable4.services.medicationservices.MedicationService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Service; + +import java.time.LocalTime; +import java.util.*; +import java.util.stream.Collectors; + +/** + * Implementation of the DashboardService interface that provides methods for retrieving + * and processing user dashboard data including medication schedules, health metrics, + * and alerts. + */ +@Service +public class DashboardServiceImpl implements DashboardService { + private static final Logger logger = LoggerFactory.getLogger(DashboardServiceImpl.class); + + private final MedicationService medicationService; + private final MedicationRepository medicationRepository; + + /** + * Constructs a new DashboardServiceImpl with the required MedicationService dependency. + * + * @param medicationService The medication service used to retrieve medication data + */ + public DashboardServiceImpl(MedicationService medicationService, MedicationRepository medicationRepository) { + this.medicationService = medicationService; + this.medicationRepository = medicationRepository; + logger.debug("DashboardServiceImpl initialized with MedicationService"); + } + + /** + * Retrieves and processes all dashboard data for the specified user. + * This includes medication schedules, health conditions, metrics, and alerts. + * + * @param userDetails The authenticated user details + * @return DashboardDTO containing all dashboard data for the user + * @throws IllegalArgumentException if userDetails is null + * @throws RuntimeException if there's an error processing dashboard data + */ + @Override + public DashboardDTO getUserDashboardData(UserDetails userDetails) { + logger.debug("Entering getUserDashboardData for user: {}", + userDetails != null ? userDetails.getUsername() : "null"); + + if (userDetails == null) { + logger.error("UserDetails parameter cannot be null"); + throw new IllegalArgumentException("UserDetails cannot be null"); + } + + String username = userDetails.getUsername(); + logger.info("Building dashboard data for user: {}", username); + + DashboardDTO dashboard = new DashboardDTO(); + dashboard.setUsername(username); + + try { + // Retrieve and process medication schedule + logger.debug("Retrieving medication schedule for user: {}", username); + List schedule = medicationService.getMedicationSchedule(username); + logger.debug("Retrieved {} medication schedule entries for user: {}", + schedule.size(), username); + + List upcomingMeds = processMedicationSchedule(schedule); + logger.debug("Processed {} upcoming medications for user: {}", + upcomingMeds.size(), username); + + // Set all dashboard metrics + logger.debug("Setting dashboard metrics for user: {}", username); + dashboard.setActiveMedicationsCount(countActiveMedications(schedule)); + dashboard.setTodaysDosesCount(upcomingMeds.size()); + dashboard.setHealthMetricsCount(0); // Placeholder - would come from health service + dashboard.setHasMedications(hasMedications(userDetails)); + dashboard.setUpcomingMedications(upcomingMeds); + + // Generate medication alerts + logger.debug("Generating medication alerts for user: {}", username); + dashboard.setAlerts(generateMedicationAlerts(schedule)); + + logger.info("Successfully built dashboard for user: {}", username); + } catch (Exception e) { + logger.error("Error building dashboard for user {}: {}", username, e.getMessage(), e); + throw new RuntimeException("Failed to build dashboard data", e); + } + + return dashboard; + } + + /** + * Processes the medication schedule to extract upcoming doses. + * Filters medications with future schedule times and sorts them chronologically. + * + * @param schedule The list of medication schedule DTOs + * @return List of upcoming medications sorted by schedule time + */ + private List processMedicationSchedule(List schedule) { + logger.debug("Processing medication schedule with {} entries", + schedule != null ? schedule.size() : "null"); + + if (schedule == null || schedule.isEmpty()) { + logger.debug("Empty or null schedule provided, returning empty list"); + return Collections.emptyList(); + } + + LocalTime now = LocalTime.now(); + logger.debug("Current time for schedule filtering: {}", now); + + List result = schedule.stream() + .filter(med -> { + boolean isValid = med.getScheduleTime() != null && med.getScheduleTime().isAfter(now); + if (!isValid) { + logger.trace("Filtered out medication {} with schedule time {}", + med.getMedicationName(), med.getScheduleTime()); + } + return isValid; + }) + .sorted(Comparator.comparing(MedicationScheduleDTO::getScheduleTime)) + .map(this::convertToUpcomingMedicationDto) + .collect(Collectors.toList()); + + logger.debug("Processed {} upcoming medications from schedule", result.size()); + return result; + } + + /** + * Converts a MedicationScheduleDTO to an UpcomingMedicationDto for dashboard display. + * + * @param scheduleDto The medication schedule DTO to convert + * @return UpcomingMedicationDto with relevant medication details + */ + private DashboardDTO.UpcomingMedicationDto convertToUpcomingMedicationDto(MedicationScheduleDTO scheduleDto) { + logger.debug("Converting MedicationScheduleDTO to UpcomingMedicationDto for medication: {}", + scheduleDto.getMedicationName()); + + DashboardDTO.UpcomingMedicationDto dto = new DashboardDTO.UpcomingMedicationDto(); + dto.setName(scheduleDto.getMedicationName()); + dto.setDosage(scheduleDto.getDosage()); + dto.setNextDoseTime(scheduleDto.getScheduleTime().toString()); + dto.setTaken(scheduleDto.isTaken()); + + + logger.trace("Converted medication details: name={}, dosage={}, time={}, taken={}", + dto.getName(), dto.getDosage(), dto.getNextDoseTime(), dto.isTaken()); + + return dto; + } + + /** + * Counts the number of distinct active medications in the schedule. + * + * @param schedule The list of medication schedule DTOs + * @return Count of distinct active medications + */ + private int countActiveMedications(List schedule) { + logger.debug("Counting active medications in schedule with {} entries", + schedule != null ? schedule.size() : "null"); + + if (schedule == null) { + logger.debug("Null schedule provided, returning 0 active medications"); + return 0; + } + + int count = (int) schedule.stream() + .map(MedicationScheduleDTO::getMedicationId) + .distinct() + .count(); + + logger.debug("Found {} distinct active medications", count); + return count; + } + + /** + * Generates placeholder medication alerts for the dashboard. + * In a production environment, this would check for actual refill needs, + * interactions, and other medication-related alerts. + * + * @param schedule The list of medication schedule DTOs + * @return List of generated medication alerts + */ + private List generateMedicationAlerts(List schedule) { + logger.debug("Generating medication alerts for schedule with {} entries", + schedule != null ? schedule.size() : "null"); + + List alerts = new ArrayList<>(); + + // Sample refill alert (would check actual refill status in production) + if (!schedule.isEmpty()) { + logger.trace("Adding placeholder refill alert"); + alerts.add(new DashboardDTO.MedicationAlertDto( + "Placeholder", + "Placeholder", + schedule.get(0).getMedicationName() + )); + } + + // Sample interaction alert (would check actual interactions in production) + logger.trace("Adding placeholder interaction alert"); + alerts.add(new DashboardDTO.MedicationAlertDto( + "Placeholder", + "Placeholder", + "Placeholder" + )); + + logger.debug("Generated {} medication alerts", alerts.size()); + return alerts; + } + + /** + * Checks if the user has any medications in their schedule. + * + * @param userDetails The authenticated user details + * @return true if the user has medications, false otherwise + * @throws IllegalArgumentException if userDetails is null + */ + @Override + public boolean hasMedications(UserDetails userDetails) { + if (userDetails == null) { + throw new IllegalArgumentException("UserDetails cannot be null"); + } + List user = medicationRepository.findByUserUsernameWithMedicationDetails(userDetails.getUsername()); + return user != null && !user.isEmpty(); + } + +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/services/impl/MedicationServiceImpl.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/services/impl/MedicationServiceImpl.java new file mode 100644 index 000000000..40e980ab6 --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/services/impl/MedicationServiceImpl.java @@ -0,0 +1,723 @@ +package com.jydoc.deliverable4.services.impl; + +import com.jydoc.deliverable4.dtos.MedicationDTO; +import com.jydoc.deliverable4.dtos.MedicationScheduleDTO; +import com.jydoc.deliverable4.dtos.RefillReminderDTO; +import com.jydoc.deliverable4.model.*; +import com.jydoc.deliverable4.repositories.medicationrepositories.MedicationIntakeTimeRepository; +import com.jydoc.deliverable4.repositories.medicationrepositories.MedicationRepository; +import com.jydoc.deliverable4.repositories.userrepositories.UserRepository; +import com.jydoc.deliverable4.security.Exceptions.MedicationCreationException; +import com.jydoc.deliverable4.security.Exceptions.MedicationNotFoundException; +import com.jydoc.deliverable4.security.Exceptions.MedicationScheduleException; +import com.jydoc.deliverable4.security.Exceptions.UserNotFoundException; +import com.jydoc.deliverable4.services.medicationservices.MedicationService; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Implementation of the MedicationService interface providing medication management functionality. + * This service handles CRUD operations for medications, schedule generation, and refill reminders. + */ +@Service +@RequiredArgsConstructor +public class MedicationServiceImpl implements MedicationService { + + private static final Logger logger = LoggerFactory.getLogger(MedicationServiceImpl.class); + + private final MedicationRepository medicationRepository; + private final UserRepository userRepository; + private final MedicationIntakeTimeRepository intakeTimeRepository; + + /** + * Retrieves all medications for a specific user. + * + * @param username The username of the user whose medications to retrieve + * @return List of MedicationDTO objects representing the user's medications + * @throws UserNotFoundException if the specified user doesn't exist + */ + @Override + @Transactional(readOnly = true) + public List getUserMedications(String username) { + logger.debug("Fetching medications for user: {}", username); + long startTime = System.currentTimeMillis(); + + try { + validateUserExists(username); + + List medications = medicationRepository.findByUserUsername(username); + logger.debug("Found {} medications in database query", medications.size()); + + List result = medications.stream() + .map(this::convertToDto) + .collect(Collectors.toList()); + + logOperationSuccess("retrieved", result.size(), username, startTime); + return result; + } catch (Exception e) { + logOperationError("fetching medications for user", username, e); + throw e; + } + } + + /** + * Retrieves a single medication by its ID. + * + * @param id The ID of the medication to retrieve + * @return MedicationDTO representing the requested medication + * @throws MedicationNotFoundException if no medication exists with the specified ID + */ + @Override + @Transactional(readOnly = true) + public MedicationDTO getMedicationById(Long id) { + logger.debug("Fetching medication by ID: {}", id); + + try { + MedicationModel medication = medicationRepository.findById(id) + .orElseThrow(() -> { + logger.error("Medication not found with ID: {}", id); + return new MedicationNotFoundException("Medication not found with ID: " + id); + }); + + MedicationDTO result = convertToDto(medication); + logger.info("Successfully retrieved medication ID: {}", id); + return result; + } catch (Exception e) { + logOperationError("fetching medication by ID", id.toString(), e); + throw e; + } + } + + /** + * Creates a new medication for a user. + * + * @param medicationDTO DTO containing medication details + * @param username The username of the user who will own this medication + * @return MedicationDTO representing the created medication + * @throws UserNotFoundException if the specified user doesn't exist + */ + @Override + @Transactional + public MedicationDTO createMedication(MedicationDTO medicationDTO, String username) { + logger.info("Creating new medication for user: {}", username); + long startTime = System.currentTimeMillis(); + + try { + // Validate and get user + UserModel user = validateAndGetUser(username); + + // Set default values if not provided + if (medicationDTO.getActive() == null) { + medicationDTO.setActive(true); + } + + // Validate days of week + if (medicationDTO.getDaysOfWeek() == null || medicationDTO.getDaysOfWeek().isEmpty()) { + throw new IllegalArgumentException("At least one day of week must be selected"); + } + + // Build and convert the medication + MedicationModel medication = convertToEntity(medicationDTO, user); + + // Process intake times + processIntakeTimes(medicationDTO, medication); + + // Process days of week + processDaysOfWeek(medicationDTO, medication); + + // Save the medication + MedicationModel savedMedication = saveMedication(medication); + + // Convert to DTO and return + MedicationDTO result = savedMedication.toDto(); + logOperationSuccess("created", savedMedication.getId(), username, startTime); + return result; + } catch (Exception e) { + logOperationError("creating medication", username, e); + throw new MedicationCreationException("Failed to create medication: " + e.getMessage(), e); + } + } + + /** + * Retrieves the days of medication intake for a specific medication. + * + * @param medicationId The ID of the medication to retrieve intake days for + * @return Set of days of the week when the medication should be taken + * @throws MedicationNotFoundException if no medication exists with the specified ID + */ + @Override + @Transactional(readOnly = true) + public Set getMedicationIntakeDays(Long medicationId) { + logger.debug("Fetching intake days for medication ID: {}", medicationId); + long startTime = System.currentTimeMillis(); + + try { + MedicationModel medication = medicationRepository.findById(medicationId) + .orElseThrow(() -> { + logger.error("Medication not found with ID: {}", medicationId); + return new MedicationNotFoundException("Medication not found with ID: " + medicationId); + }); + + Set days = medication.getDaysOfWeek().stream() + .map(day -> MedicationDTO.DayOfWeek.valueOf(day.name())) + .collect(Collectors.toSet()); + + logger.info("Successfully retrieved {} intake days for medication ID {} in {} ms", + days.size(), medicationId, System.currentTimeMillis() - startTime); + return days; + } catch (Exception e) { + logOperationError("fetching intake days for medication", medicationId.toString(), e); + throw new MedicationNotFoundException("Failed to retrieve intake days for medication"); + } + } + + + /** + * Updates an existing medication. + * + * @param id The ID of the medication to update + * @param medicationDTO DTO containing updated medication details + * @return MedicationDTO representing the updated medication + * @throws MedicationNotFoundException if no medication exists with the specified ID + */ + @Override + @Transactional + public MedicationDTO updateMedication(Long id, MedicationDTO medicationDTO) { + logger.info("Updating medication ID: {}", id); + long startTime = System.currentTimeMillis(); + + try { + MedicationModel existing = getExistingMedication(id); + updateMedicationFields(existing, medicationDTO); + + if (medicationDTO.getIntakeTimes() != null) { + logger.debug("Processing {} intake times for update", medicationDTO.getIntakeTimes().size()); + updateIntakeTimes(existing, medicationDTO.getIntakeTimes()); + } + + MedicationModel updatedMedication = medicationRepository.save(existing); + MedicationDTO result = convertToDto(updatedMedication); + + logOperationSuccess("updated", id, "medication", startTime); + return result; + } catch (Exception e) { + logOperationError("updating medication", id.toString(), e); + throw new RuntimeException("Failed to update medication", e); + } + } + + /** + * Deletes a medication. + * + * @param id The ID of the medication to delete + * @throws MedicationNotFoundException if no medication exists with the specified ID + */ + @Override + @Transactional + public void deleteMedication(Long id) { + logger.info("Deleting medication ID: {}", id); + long startTime = System.currentTimeMillis(); + + try { + validateMedicationExists(id); + deleteIntakeTimes(id); + medicationRepository.deleteById(id); + + logOperationSuccess("deleted", id, "medication", startTime); + } catch (Exception e) { + logOperationError("deleting medication", id.toString(), e); + throw new RuntimeException("Failed to delete medication", e); + } + } + + /** + * Generates a medication schedule for a user. + * + * @param username The username of the user whose schedule to generate + * @return List of MedicationScheduleDTO objects representing the schedule + * @throws MedicationScheduleException if there's an error generating the schedule + */ + + + @Override + @Transactional(readOnly = true) + public List getMedicationSchedule(String username) { + logger.debug("Fetching medication schedule for user: {}", username); + long startTime = System.currentTimeMillis(); + + try { + // 1. Fetch medications with all necessary relationships + List medications = medicationRepository.findByUserUsernameWithMedicationDetails(username); + logger.debug("Found {} medications for user {}", medications.size(), username); + + if (medications.isEmpty()) { + logger.info("No medications found for user {}", username); + return Collections.emptyList(); + } + + // 2. Get current day of week + DayOfWeek currentDay = LocalDate.now().getDayOfWeek(); + logger.debug("Current day of week: {}", currentDay); + + // 3. Convert to your model's DayOfWeek enum + MedicationModel.DayOfWeek currentMedDay; + try { + currentMedDay = MedicationModel.DayOfWeek.valueOf(currentDay.name()); + logger.debug("Converted to model day: {}", currentMedDay); + } catch (IllegalArgumentException e) { + logger.error("Day of week conversion failed for {}", currentDay.name(), e); + throw new MedicationScheduleException("Day of week conversion failed", e); + } + + // 4. Process medications + List schedule = medications.stream() + .filter(Objects::nonNull) + .peek(med -> logger.trace("Processing medication: {}", med.getId())) + .filter(medication -> { + // Skip if no intake times + if (medication.getIntakeTimes() == null || medication.getIntakeTimes().isEmpty()) { + logger.debug("Medication {} skipped - no intake times", medication.getId()); + return false; + } + return true; + }) + .filter(medication -> { + // Include if no days specified OR current day matches + if (medication.getDaysOfWeek() == null || medication.getDaysOfWeek().isEmpty()) { + logger.trace("Medication {} included - no day restrictions", medication.getId()); + return true; + } + + boolean matchesDay = medication.getDaysOfWeek().contains(currentMedDay); + logger.trace("Medication {} day check: {}", medication.getId(), matchesDay); + return matchesDay; + }) + .flatMap(this::processMedicationForSchedule) + .collect(Collectors.toList()); + + logger.info("Generated schedule with {} entries for user {} in {} ms", + schedule.size(), username, System.currentTimeMillis() - startTime); + + return schedule; + } catch (Exception e) { + logger.error("Error generating schedule for user {}: {}", username, e.getMessage(), e); + throw new MedicationScheduleException("Failed to generate medication schedule", e); + } + } + + /** + * Retrieves upcoming medication refill reminders for a user. + * + * @param username The username of the user whose reminders to generate + * @return List of RefillReminderDTO objects representing the reminders + */ + @Override + @Transactional(readOnly = true) + public List getUpcomingRefills(String username) { + logger.debug("Fetching upcoming refills for user: {}", username); + long startTime = System.currentTimeMillis(); + + try { + List medications = medicationRepository.findByUserUsername(username); + logger.debug("Found {} medications for refill reminders", medications.size()); + + List reminders = medications.stream() + .map(this::mapToRefillReminderDTO) + .collect(Collectors.toList()); + + logger.info("Generated {} refill reminders for user {} in {} ms", + reminders.size(), username, System.currentTimeMillis() - startTime); + return reminders; + } catch (Exception e) { + logger.error("Error generating refill reminders for user {}: {}", username, e.getMessage(), e); + throw e; + } + } + + // ==================== PRIVATE HELPER METHODS ==================== + + /** + * Validates that a user exists in the system. + * + * @param username The username to validate + * @throws UserNotFoundException if the user doesn't exist + */ + private void validateUserExists(String username) { + if (!userRepository.existsByUsername(username)) { + logger.error("User not found: {}", username); + throw new UserNotFoundException(1L); + } + } + + /** + * Validates and retrieves a user entity. + * + * @param username The username of the user to retrieve + * @return UserModel entity + * @throws UserNotFoundException if the user doesn't exist + */ + private UserModel validateAndGetUser(String username) { + return userRepository.findByUsername(username) + .orElseThrow(() -> { + logger.error("User not found: {}", username); + return new UserNotFoundException(1L); + }); + } + + /** + * Builds a MedicationDTO with user association. + * + * @param medicationDTO Source DTO with medication details + * @param user The user who will own the medication + * @return Prepared MedicationDTO + */ + private MedicationDTO buildMedicationDTO(MedicationDTO medicationDTO, UserModel user) { + return MedicationDTO.builder() + .medicationName(medicationDTO.getMedicationName()) + .userId(user.getId()) + .urgency(medicationDTO.getUrgency()) + .dosage(medicationDTO.getDosage()) + .instructions(medicationDTO.getInstructions()) + .intakeTimes(medicationDTO.getIntakeTimes() != null ? + new HashSet<>(medicationDTO.getIntakeTimes()) : new HashSet<>()) + .daysOfWeek(medicationDTO.getDaysOfWeek() != null ? + new HashSet<>(medicationDTO.getDaysOfWeek()) : new HashSet<>()) + .active(medicationDTO.getActive() != null ? + medicationDTO.getActive() : true) // Default to true if null + .build(); + } + + /** + * Processes intake times for a medication during creation. + * + * @param medicationDTO DTO containing intake times + * @param medication The medication entity to associate with intake times + */ + private void processIntakeTimes(MedicationDTO medicationDTO, MedicationModel medication) { + if (medicationDTO.getIntakeTimes() != null && !medicationDTO.getIntakeTimes().isEmpty()) { + logger.debug("Processing {} intake times", medicationDTO.getIntakeTimes().size()); + medicationDTO.getIntakeTimes().forEach(time -> { + MedicationIntakeTime mit = MedicationIntakeTime.builder() + .medication(medication) + .intakeTime(time) + .build(); + medication.addIntakeTime(mit.getIntakeTime()); + }); + } + } + + /** + * Saves a medication entity and validates the result. + * + * @param medication The medication to save + * @return The saved medication entity + * @throws RuntimeException if the save operation fails + */ + private MedicationModel saveMedication(MedicationModel medication) { + MedicationModel savedMedication = medicationRepository.saveAndFlush(medication); + if (savedMedication.getId() == null) { + logger.error("Saved medication has null ID!"); + throw new RuntimeException("Medication ID is null after save"); + } + return savedMedication; + } + + /** + * Retrieves an existing medication entity. + * + * @param id The ID of the medication to retrieve + * @return The medication entity + * @throws MedicationNotFoundException if the medication doesn't exist + */ + private MedicationModel getExistingMedication(Long id) { + return medicationRepository.findById(id) + .orElseThrow(() -> { + logger.error("Medication not found: {}", id); + return new MedicationNotFoundException("Medication not found with ID: " + id); + }); + } + + /** + * Validates that a medication exists. + * + * @param id The ID of the medication to validate + * @throws MedicationNotFoundException if the medication doesn't exist + */ + private void validateMedicationExists(Long id) { + if (!medicationRepository.existsById(id)) { + logger.error("Medication not found: {}", id); + throw new MedicationNotFoundException("Medication not found with ID: " + id); + } + } + + /** + * Deletes intake times associated with a medication. + * + * @param medicationId The ID of the medication whose intake times to delete + */ + private void deleteIntakeTimes(Long medicationId) { + int deletedIntakeTimes = intakeTimeRepository.deleteByMedicationId(medicationId); + logger.debug("Deleted {} intake times", deletedIntakeTimes); + } + + /** + * Processes a medication for schedule generation. + * + * @param medication The medication to process + * @return Stream of MedicationScheduleDTO objects + */ + private Stream processMedicationForSchedule(MedicationModel medication) { + logger.trace("Processing medication ID: {}", medication.getId()); + return medication.getIntakeTimes().stream() + .filter(Objects::nonNull) + .map(intakeTimeEntity -> createScheduleDTO(medication, intakeTimeEntity)); + } + + /** + * Creates a schedule DTO from medication and intake time. + * + * @param medication The medication entity + * @param intakeTimeEntity The intake time entity + * @return MedicationScheduleDTO + */ + private MedicationScheduleDTO createScheduleDTO(MedicationModel medication, MedicationIntakeTime intakeTimeEntity) { + if (intakeTimeEntity == null) { + throw new IllegalArgumentException("Intake time entity cannot be null"); + } + return createScheduleDTO(medication, intakeTimeEntity.getIntakeTime()); + } + + /** + * Creates a schedule DTO from medication and time. + * + * @param medication The medication entity + * @param intakeTime The intake time + * @return MedicationScheduleDTO + */ + private MedicationScheduleDTO createScheduleDTO(MedicationModel medication, LocalTime intakeTime) { + if (intakeTime == null) { + logger.warn("Null intake time encountered for medication ID: {}", medication.getId()); + intakeTime = LocalTime.MIDNIGHT; + } + + return MedicationScheduleDTO.builder() + .medicationId(medication.getId()) + .medicationName(medication.getName()) + .dosage(medication.getDosage()) + .scheduleTime(intakeTime) + .isTaken(false) + .instructions(medication.getInstructions()) + .status("UPCOMING") + .urgency(convertToDtoUrgency(medication.getUrgency())) + .build(); + } + + /** + * Converts medication urgency to DTO format. + * + * @param modelUrgency The urgency from the model + * @return Converted urgency for DTO + */ + private MedicationScheduleDTO.MedicationUrgency convertToDtoUrgency(MedicationModel.MedicationUrgency modelUrgency) { + if (modelUrgency == null) { + logger.debug("Null urgency encountered, defaulting to ROUTINE"); + return MedicationScheduleDTO.MedicationUrgency.ROUTINE; + } + try { + return MedicationScheduleDTO.MedicationUrgency.valueOf(modelUrgency.name()); + } catch (IllegalArgumentException e) { + logger.warn("Unknown urgency value: {}, defaulting to ROUTINE", modelUrgency); + return MedicationScheduleDTO.MedicationUrgency.ROUTINE; + } + } + + /** + * Updates intake times for a medication. + * + * @param medication The medication to update + * @param newIntakeTimes The new intake times to set + */ + private void updateIntakeTimes(MedicationModel medication, Set newIntakeTimes) { + Set currentTimes = medication.getIntakeTimesAsLocalTimes(); + + // Remove times that are no longer present + currentTimes.stream() + .filter(time -> !newIntakeTimes.contains(time)) + .forEach(medication::removeIntakeTime); + + // Add new times that aren't already present + newIntakeTimes.stream() + .filter(time -> !currentTimes.contains(time)) + .forEach(medication::addIntakeTime); + + logger.trace("Intake times updated for medication ID: {}. Total times now: {}", + medication.getId(), medication.getIntakeTimes().size()); + } + + /** + * Updates medication fields from DTO. + * + * @param medication The medication to update + * @param dto The DTO containing new values + */ + private void updateMedicationFields(MedicationModel medication, MedicationDTO dto) { + logger.trace("Updating fields for medication ID: {}", medication.getId()); + + if (dto.getMedicationName() != null) { + medication.setName(dto.getMedicationName()); + } else { + logger.warn("Medication name is null in DTO for medication ID: {}", medication.getId()); + } + + if (dto.getUrgency() != null) { + try { + medication.setUrgency(MedicationModel.MedicationUrgency.valueOf(dto.getUrgency().name())); + } catch (IllegalArgumentException e) { + logger.error("Invalid urgency value in DTO: {}. Keeping existing value for medication ID: {}", + dto.getUrgency(), medication.getId()); + } + } else { + logger.warn("Urgency is null in DTO for medication ID: {}", medication.getId()); + } + + medication.setDosage(dto.getDosage()); + medication.setInstructions(dto.getInstructions()); + } + + /** + * Converts a medication entity to DTO. + * + * @param medication The medication to convert + * @return MedicationDTO + */ + private MedicationDTO convertToDto(MedicationModel medication) { + if (medication == null) { + logger.warn("Attempted to convert null MedicationModel to DTO"); + return null; + } + + logger.trace("Converting medication ID {} to DTO", medication.getId()); + Set intakeTimes = medication.getIntakeTimesAsLocalTimes(); + + try { + // Convert days of week if they exist + Set daysOfWeek = null; + if (medication.getDaysOfWeek() != null && !medication.getDaysOfWeek().isEmpty()) { + daysOfWeek = medication.getDaysOfWeek().stream() + .map(day -> MedicationDTO.DayOfWeek.valueOf(day.name())) + .collect(Collectors.toSet()); + } + + return MedicationDTO.builder() + .id(medication.getId()) + .userId(medication.getUser() != null ? medication.getUser().getId() : null) + .medicationName(medication.getName()) + .urgency(medication.getUrgency() != null ? + MedicationDTO.MedicationUrgency.valueOf(medication.getUrgency().name()) : null) + .dosage(medication.getDosage()) + .instructions(medication.getInstructions()) + .intakeTimes(intakeTimes) + .daysOfWeek(daysOfWeek) // Add this line + .build(); + } catch (IllegalArgumentException e) { + logger.error("Failed to convert urgency {} to DTO enum for medication ID: {}", + medication.getUrgency(), medication.getId(), e); + throw new RuntimeException("Invalid urgency value during DTO conversion", e); + } + } + + /** + * Converts a medication DTO to entity. + * + * @param dto The DTO to convert + * @param user The user who will own the medication + * @return MedicationModel + */ + private MedicationModel convertToEntity(MedicationDTO dto, UserModel user) { + logger.trace("Converting DTO to medication entity for user ID: {}", user.getId()); + + return MedicationModel.builder() + .user(user) + .name(dto.getMedicationName()) + .urgency(MedicationModel.MedicationUrgency.valueOf(dto.getUrgency().name())) + .dosage(dto.getDosage()) + .instructions(dto.getInstructions()) + .build(); + } + + /** + * Maps a medication to a refill reminder DTO. + * + * @param medication The medication to map + * @return RefillReminderDTO + */ + private RefillReminderDTO mapToRefillReminderDTO(MedicationModel medication) { + logger.trace("Mapping medication ID {} to refill reminder", medication.getId()); + + int remainingDoses = calculateRemainingDoses(medication); + logger.trace("Calculated {} remaining doses", remainingDoses); + + return RefillReminderDTO.builder() + .medicationId(medication.getId()) + .medicationName(medication.getName()) + .remainingDoses(remainingDoses) + .refillByDate(LocalDate.now().plusDays(7)) + .urgency(medication.getUrgency().name()) + .build(); + } + + /** + * Calculates remaining doses for a medication. + * + * @param medication The medication to calculate for + * @return Estimated number of remaining doses + */ + private int calculateRemainingDoses(MedicationModel medication) { + int count = medication.getIntakeTimes().size() * 7; // Assume 1 week supply + logger.trace("Calculated remaining doses: {} ({} intake times × 7 days)", + count, medication.getIntakeTimes().size()); + return count; + } + + /** + * Logs a successful operation. + * + * @param operation Description of the operation performed + * @param identifier Identifier of the affected entity + * @param entityType Type of entity affected + * @param startTime Start time of the operation (for duration calculation) + */ + private void logOperationSuccess(String operation, Object identifier, String entityType, long startTime) { + logger.info("Successfully {} {} {} in {} ms", + operation, entityType, identifier, System.currentTimeMillis() - startTime); + } + + /** + * Logs an operation error. + * + * @param operation Description of the operation attempted + * @param identifier Identifier of the affected entity + * @param exception The exception that occurred + */ + private void logOperationError(String operation, String identifier, Exception exception) { + logger.error("Error {} {}: {}", operation, identifier, exception.getMessage(), exception); + } + + + + private void processDaysOfWeek(MedicationDTO medicationDTO, MedicationModel medication) { + medicationDTO.getDaysOfWeek().forEach(day -> + medication.addDay(MedicationModel.DayOfWeek.valueOf(day.name()))); + } + +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/services/medicationservices/MedicationService.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/services/medicationservices/MedicationService.java new file mode 100644 index 000000000..d7dcc21b8 --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/services/medicationservices/MedicationService.java @@ -0,0 +1,101 @@ +package com.jydoc.deliverable4.services.medicationservices; + +import com.jydoc.deliverable4.dtos.MedicationDTO; +import com.jydoc.deliverable4.dtos.MedicationScheduleDTO; +import com.jydoc.deliverable4.dtos.RefillReminderDTO; +import com.jydoc.deliverable4.security.Exceptions.MedicationNotFoundException; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Set; + +/** + * Service interface for medication-related operations. + *

+ * Defines the contract for services that manage medication data including CRUD operations, + * scheduling, and refill reminders. This interface serves as the main API for all + * medication management functionality in the system. + *

+ */ +@Service +public interface MedicationService { + + /** + * Retrieves all medications associated with a specific user. + * + * @param username The username of the patient whose medications to retrieve. Must not be null or empty. + * @return A list of {@link MedicationDTO} objects representing the user's medications. + * Returns empty list if no medications found. + * @throws IllegalArgumentException if username is null or empty + */ + List getUserMedications(String username); + + /** + * Retrieves a specific medication by its unique identifier. + * + * @param id The unique identifier of the medication to retrieve. Must not be null. + * @return The {@link MedicationDTO} representing the requested medication + * @throws IllegalArgumentException if id is null + * @throws com.jydoc.deliverable4.security.Exceptions.MedicationNotFoundException if medication with specified id doesn't exist + */ + MedicationDTO getMedicationById(Long id); + + /** + * Creates a new medication record for the specified user. + * + * @param medicationDTO The medication data to create. Must not be null and must contain valid data. + * @param username The username of the patient who will own this medication. Must not be null or empty. + * @return The created {@link MedicationDTO} with generated fields populated (e.g., id) + * @throws IllegalArgumentException if either parameter is null or contains invalid data + */ + MedicationDTO createMedication(MedicationDTO medicationDTO, String username); + + /** + * Updates an existing medication record. + * + * @param id The unique identifier of the medication to update. Must not be null. + * @param medicationDTO The updated medication data. Must not be null and must contain valid data. + * @return The updated {@link MedicationDTO} + * @throws IllegalArgumentException if either parameter is null or contains invalid data + * @throws com.jydoc.deliverable4.security.Exceptions.MedicationNotFoundException if medication with specified id doesn't exist + */ + MedicationDTO updateMedication(Long id, MedicationDTO medicationDTO); + + /** + * Deletes a medication record. + * + * @param id The unique identifier of the medication to delete. Must not be null. + * @throws IllegalArgumentException if id is null + * @throws com.jydoc.deliverable4.security.Exceptions.MedicationNotFoundException if medication with specified id doesn't exist + */ + void deleteMedication(Long id); + + /** + * Retrieves the days of medication intake for a specific medication. + * + * @param medicationId The ID of the medication to retrieve intake days for + * @return Set of days of the week when the medication should be taken + * @throws MedicationNotFoundException if no medication exists with the specified ID + */ + Set getMedicationIntakeDays(Long medicationId); + + /** + * Retrieves the medication schedule for a specific user. + * + * @param username The username of the patient whose schedule to retrieve. Must not be null or empty. + * @return A list of {@link MedicationScheduleDTO} objects representing the user's medication schedule. + * Returns empty list if no scheduled medications found. + * @throws IllegalArgumentException if username is null or empty + */ + List getMedicationSchedule(String username); + + /** + * Retrieves upcoming medication refill reminders for a specific user. + * + * @param username The username of the patient whose refill reminders to retrieve. Must not be null or empty. + * @return A list of {@link RefillReminderDTO} objects representing upcoming refills. + * Returns empty list if no upcoming refills found. + * @throws IllegalArgumentException if username is null or empty + */ + List getUpcomingRefills(String username); +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/services/userservices/DashboardService.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/services/userservices/DashboardService.java new file mode 100644 index 000000000..4b1d52dee --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/services/userservices/DashboardService.java @@ -0,0 +1,48 @@ +package com.jydoc.deliverable4.services.userservices; + +import com.jydoc.deliverable4.dtos.userdtos.DashboardDTO; +import org.springframework.security.core.userdetails.UserDetails; + +/** + * Service interface for dashboard-related operations. + *

+ * Defines the contract for services that provide dashboard data and functionality + * to the presentation layer. Implementations should handle the retrieval and + * processing of user-specific dashboard information. + *

+ */ +public interface DashboardService { + + /** + * Retrieves comprehensive dashboard data for the authenticated user. + *

+ * The implementation should gather all necessary information to populate + * the user's dashboard view, including: + *

    + *
  • User information and profile data
  • + *
  • Health conditions and status
  • + *
  • Medication information and alerts
  • + *
  • Upcoming medication schedules
  • + *
+ *

+ * + * @param userDetails The authenticated user's details, containing security + * and identification information. Must not be null. + * @return A fully populated {@link DashboardDTO} containing all dashboard data + * @throws IllegalArgumentException if userDetails parameter is null + */ + DashboardDTO getUserDashboardData(UserDetails userDetails); + + /** + * Checks whether the user has any medications associated with their account. + *

+ * This information is typically used to determine whether to display + * medication-related UI components or reminders to add medications. + *

+ * + * @param userDetails The authenticated user's details. Must not be null. + * @return true if the user has one or more medications, false otherwise + * @throws IllegalArgumentException if userDetails parameter is null + */ + boolean hasMedications(UserDetails userDetails); +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/services/userservices/UserService.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/services/userservices/UserService.java new file mode 100644 index 000000000..7f62cd65f --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/services/userservices/UserService.java @@ -0,0 +1,380 @@ +package com.jydoc.deliverable4.services.userservices; + +import com.jydoc.deliverable4.dtos.userdtos.UserDTO; +import com.jydoc.deliverable4.model.UserModel; +import com.jydoc.deliverable4.repositories.userrepositories.UserRepository; +import lombok.RequiredArgsConstructor; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * Service handling comprehensive user management operations including: + * - User retrieval by various criteria (ID, username, email) + * - User existence verification and counting + * - User profile updates and account deletions + * - Password management and verification + * - Conversion between entity and DTO representations + * + *

All operations are transactional with appropriate read-only or read-write semantics. + * Security-sensitive operations include proper validation and password handling.

+ * + *

This service provides a clean API for user-related operations while abstracting + * repository access and ensuring proper transaction management and audit logging.

+ * + *

Logging is implemented at different levels: + * - TRACE: Detailed flow tracing + * - DEBUG: Important variable states and minor events + * - INFO: Significant business operations + * - WARN: Unexpected but handled situations + * - ERROR: Critical failures

+ */ +@Service +@RequiredArgsConstructor +public class UserService { + private static final Logger logger = LogManager.getLogger(UserService.class); + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + /* ====================== User Retrieval Methods ====================== */ + + /** + * Finds an active user by username or email. + * + * @param usernameOrEmail The username or email to search for (case-insensitive) + * @return Optional containing the user if found and active, empty otherwise + * @throws IllegalArgumentException if input is null or empty after trimming + */ + @Transactional(readOnly = true) + public Optional findActiveUser(String usernameOrEmail) { + logger.trace("Entering findActiveUser with parameter: {}", usernameOrEmail); + + if (usernameOrEmail == null || usernameOrEmail.trim().isEmpty()) { + logger.warn("Attempted to find user with null/empty usernameOrEmail"); + throw new IllegalArgumentException("Username or email cannot be null or empty"); + } + + String normalizedInput = usernameOrEmail.trim().toLowerCase(); + logger.debug("Searching for active user with normalized input: {}", normalizedInput); + + Optional user = userRepository.findByUsernameOrEmail(normalizedInput) + .filter(UserModel::isEnabled); + + if (user.isPresent()) { + logger.debug("Found active user: {}", user.get().getUsername()); + } else { + logger.debug("No active user found for: {}", normalizedInput); + } + + return user; + } + + /** + * Finds a user by username (exact match, case-sensitive). + * + * @param username The username to search for + * @return The found user entity + * @throws IllegalArgumentException if user is not found + */ + @Transactional(readOnly = true) + public UserModel findByUsername(String username) { + logger.trace("Entering findByUsername with parameter: {}", username); + + if (username == null || username.trim().isEmpty()) { + logger.warn("Attempted to find user with null/empty username"); + throw new IllegalArgumentException("Username cannot be null or empty"); + } + + logger.debug("Searching for user by username: {}", username); + return userRepository.findByUsername(username) + .orElseThrow(() -> { + logger.warn("User not found with username: {}", username); + return new IllegalArgumentException("User not found"); + }); + } + + /** + * Retrieves a user by their unique ID. + * + * @param id The user ID to search for + * @return Optional containing the user if found, empty otherwise + */ + @Transactional(readOnly = true) + public Optional getUserById(Long id) { + logger.trace("Entering getUserById with parameter: {}", id); + + if (id == null || id <= 0) { + logger.warn("Attempted to get user with invalid ID: {}", id); + return Optional.empty(); + } + + logger.debug("Retrieving user by ID: {}", id); + return userRepository.findById(id); + } + + /** + * Retrieves all users in the system. + * + * @return List of all user entities + */ + @Transactional(readOnly = true) + public List getAllUsers() { + logger.trace("Entering getAllUsers"); + logger.debug("Retrieving all users from repository"); + + List users = userRepository.findAll(); + logger.info("Retrieved {} users from database", users.size()); + + return users; + } + + /** + * Verifies if the provided password matches the user's current password. + * + * @param username The username of the user to verify + * @param currentPassword The password to verify + * @return true if passwords match, false otherwise + * @throws IllegalArgumentException if user is not found + */ + @Transactional(readOnly = true) + public boolean verifyCurrentPassword(String username, String currentPassword) { + logger.trace("Entering verifyCurrentPassword for user: {}", username); + + if (currentPassword == null || currentPassword.isEmpty()) { + logger.warn("Attempted password verification with empty password for user: {}", username); + return false; + } + + UserModel user = findByUsername(username); + boolean matches = passwordEncoder.matches(currentPassword, user.getPassword()); + + logger.debug("Password verification {} for user: {}", + matches ? "succeeded" : "failed", username); + + return matches; + } + + /* ====================== User Status Methods ====================== */ + + /** + * Checks if a user exists with the given ID. + * + * @param id The user ID to check + * @return true if user exists, false otherwise + */ + @Transactional(readOnly = true) + public boolean existsById(Long id) { + logger.trace("Entering existsById with parameter: {}", id); + + if (id == null || id <= 0) { + logger.debug("Exists check for invalid ID: {}", id); + return false; + } + + boolean exists = userRepository.existsById(id); + logger.debug("User existence check for ID {}: {}", id, exists); + + return exists; + } + + /** + * Gets the total count of users in the system. + * + * @return The number of users + */ + @Transactional(readOnly = true) + public long getUserCount() { + logger.trace("Entering getUserCount"); + + long count = userRepository.count(); + logger.info("System currently contains {} users", count); + + return count; + } + + /* ====================== User Modification Methods ====================== */ + + /** + * Updates a user entity in the database. + * + * @param user The user entity to update + * @throws IllegalArgumentException if user is null + */ + @Transactional + public void updateUser(UserModel user) { + logger.trace("Entering updateUser"); + + if (user == null) { + logger.error("Attempted to update null user"); + throw new IllegalArgumentException("User cannot be null"); + } + + logger.debug("Updating user with ID: {}", user.getId()); + userRepository.save(user); + logger.info("Successfully updated user with ID: {}", user.getId()); + } + + /** + * Deletes a user by their ID. + * + * @param id The ID of the user to delete + * @throws IllegalArgumentException if user is not found + */ + @Transactional + public void deleteUser(Long id) { + logger.trace("Entering deleteUser with parameter: {}", id); + + if (!userRepository.existsById(id)) { + logger.error("Attempted to delete non-existent user with ID: {}", id); + throw new IllegalArgumentException("User not found with ID: " + id); + } + + logger.debug("Deleting user with ID: {}", id); + userRepository.deleteById(id); + logger.info("Successfully deleted user with ID: {}", id); + } + + /* ====================== Profile Management Methods ====================== */ + + /** + * Retrieves a user's profile data as a DTO by username. + * + * @param username The username to search for + * @return UserDTO containing profile information + * @throws IllegalArgumentException if user is not found + */ + @Transactional(readOnly = true) + public UserDTO getUserByUsername(String username) { + logger.trace("Entering getUserByUsername with parameter: {}", username); + + UserModel user = findByUsername(username); + UserDTO dto = convertToDTO(user); + + logger.debug("Returning DTO for user: {}", username); + return dto; + } + + /** + * Updates a user's profile information. + * + * @param username The username of the user to update + * @param userDTO The DTO containing updated profile data + * @return The updated user DTO + * @throws IllegalArgumentException if DTO is null or email is already in use + */ + @Transactional + public UserDTO updateUserProfile(String username, UserDTO userDTO) { + logger.trace("Entering updateUserProfile for user: {}", username); + + Objects.requireNonNull(userDTO, "UserDTO cannot be null"); + logger.debug("Updating profile for user: {} with data: {}", username, userDTO); + + UserModel user = findByUsername(username); + + // Verify email uniqueness if changing email + if (!user.getEmail().equalsIgnoreCase(userDTO.getEmail())) { + logger.debug("Email change detected for user: {}", username); + if (userRepository.existsByEmail(userDTO.getEmail())) { + logger.warn("Email already in use: {}", userDTO.getEmail()); + throw new IllegalArgumentException("Email already in use"); + } + } + + // Update allowed fields + user.setFirstName(userDTO.getFirstName().trim()); + user.setLastName(userDTO.getLastName().trim()); + user.setEmail(userDTO.getEmail().trim().toLowerCase()); + + UserModel updatedUser = userRepository.save(user); + logger.info("Successfully updated profile for user: {}", username); + + return convertToDTO(updatedUser); + } + + /** + * Changes a user's password after verifying the current password. + * + * @param username The username of the user + * @param currentPassword The current password for verification + * @param newPassword The new password to set + * @return true if password was changed successfully, false if verification failed + */ + @Transactional + public boolean changePassword(String username, String currentPassword, String newPassword) { + logger.trace("Entering changePassword for user: {}", username); + + UserModel user = findByUsername(username); + + // Verify current password + if (!passwordEncoder.matches(currentPassword, user.getPassword())) { + logger.warn("Password change failed for user: {} - current password mismatch", username); + return false; + } + + // Set new password + user.setPassword(passwordEncoder.encode(newPassword)); + userRepository.save(user); + logger.info("Password successfully changed for user: {}", username); + + return true; + } + + /** + * Deletes a user account after password verification. + * + * @param username The username of the account to delete + * @param password The password for verification + * @return true if account was deleted, false if verification failed + */ + @Transactional + public boolean deleteAccount(String username, String password) { + logger.trace("Entering deleteAccount for user: {}", username); + + UserModel user = findByUsername(username); + + // Verify password before deletion + if (!passwordEncoder.matches(password, user.getPassword())) { + logger.warn("Account deletion failed for user: {} - password verification failed", username); + return false; + } + + userRepository.delete(user); + logger.info("Account successfully deleted for user: {}", username); + + return true; + } + + /* ====================== Helper Methods ====================== */ + + /** + * Converts a UserModel entity to a UserDTO. + * + * @param user The user entity to convert + * @return The converted DTO + * @throws IllegalArgumentException if user is null + */ + private UserDTO convertToDTO(UserModel user) { + logger.trace("Entering convertToDTO for user: {}", user != null ? user.getUsername() : "null"); + + if (user == null) { + logger.error("Attempted to convert null user to DTO"); + throw new IllegalArgumentException("User cannot be null"); + } + + UserDTO dto = new UserDTO(); + dto.setUsername(user.getUsername()); + dto.setEmail(user.getEmail()); + dto.setFirstName(user.getFirstName()); + dto.setLastName(user.getLastName()); + + logger.debug("Converted user {} to DTO", user.getUsername()); + return dto; + } +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/services/userservices/UserValidationHelper.java b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/services/userservices/UserValidationHelper.java new file mode 100644 index 000000000..f7e47589f --- /dev/null +++ b/Sprint 2/prototype2/src/main/java/com/jydoc/deliverable4/services/userservices/UserValidationHelper.java @@ -0,0 +1,90 @@ +package com.jydoc.deliverable4.services.userservices; + +import com.jydoc.deliverable4.dtos.userdtos.UserDTO; +import com.jydoc.deliverable4.security.Exceptions.EmailExistsException; +import com.jydoc.deliverable4.security.Exceptions.UsernameExistsException; +import com.jydoc.deliverable4.repositories.userrepositories.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Service component responsible for validating user registration data. + *

+ * Performs checks for duplicate usernames and email addresses before user registration. + * All validation methods are transactional and read-only to ensure data consistency. + */ +@Service +@RequiredArgsConstructor +public class UserValidationHelper { + + private final UserRepository userRepository; + + /** + * Validates user registration data for potential conflicts. + * + * @param userDto the user data transfer object containing registration information + * @throws IllegalArgumentException if the provided user data is null + * @throws UsernameExistsException if the username is already registered + * @throws EmailExistsException if the email address is already registered + */ + @Transactional(readOnly = true) + public void validateUserRegistration(UserDTO userDto) { + if (userDto == null) { + throw new IllegalArgumentException("User data cannot be null"); + } + + validateUsername(userDto.getUsername()); + validateEmail(userDto.getEmail()); + } + + /** + * Checks if a username already exists in the system. + * + * @param username the username to check (will be trimmed before checking) + * @throws UsernameExistsException if the username is already taken + */ + @Transactional(readOnly = true) + public void validateUsername(String username) { + String normalizedUsername = username.trim(); + if (existsByUsername(normalizedUsername)) { + throw new UsernameExistsException(normalizedUsername); + } + } + + /** + * Checks if an email address already exists in the system. + * + * @param email the email to check (will be normalized to lowercase and trimmed) + * @throws EmailExistsException if the email is already registered + */ + @Transactional(readOnly = true) + public void validateEmail(String email) { + String normalizedEmail = email.trim().toLowerCase(); + if (existsByEmail(normalizedEmail)) { + throw new EmailExistsException(normalizedEmail); + } + } + + /** + * Checks if a username exists in the repository. + * + * @param username the username to check + * @return true if the username exists, false otherwise + */ + @Transactional(readOnly = true) + public boolean existsByUsername(String username) { + return userRepository.existsByUsername(username); + } + + /** + * Checks if an email exists in the repository. + * + * @param email the email to check + * @return true if the email exists, false otherwise + */ + @Transactional(readOnly = true) + public boolean existsByEmail(String email) { + return userRepository.existsByEmail(email); + } +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/resources/application-test.properties b/Sprint 2/prototype2/src/main/resources/application-test.properties new file mode 100644 index 000000000..50a306302 --- /dev/null +++ b/Sprint 2/prototype2/src/main/resources/application-test.properties @@ -0,0 +1,55 @@ +# ====================== +# Test Metadata +# ====================== +spring.application.name=prototype2-test +spring.main.allow-bean-definition-overriding=true +# ====================== +# Security Test Configuration +# ====================== +# Disable security auto-configuration for tests +spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration + +# ====================== +# Test Database Configuration (H2) +# ====================== +spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=MYSQL +spring.datasource.username=sa +spring.datasource.password= +spring.datasource.driver-class-name=org.h2.Driver + +# Hikari connection pool for tests +spring.datasource.hikari.maximum-pool-size=5 +spring.datasource.hikari.connection-timeout=20000 + +# ====================== +# Test JPA/Hibernate Settings +# ====================== +spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.format_sql=true +spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation=true +spring.jpa.defer-datasource-initialization=true + +# ====================== +# Test Session Management +# ====================== +spring.session.jdbc.initialize-schema=always +spring.session.jdbc.table-name=SPRING_SESSION +server.servlet.session.timeout=1m + +# ====================== +# Test Logging Configuration +# ====================== +logging.level.com.jydoc=DEBUG +logging.level.org.springframework.security=DEBUG +logging.level.org.hibernate.SQL=DEBUG +logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE +logging.level.org.springframework.jdbc.core=DEBUG + +# Disable banner for cleaner test output +spring.main.banner-mode=off +# Enable H2 console for test debugging +spring.h2.console.enabled=true +spring.h2.console.path=/h2-console + + diff --git a/Sprint 2/prototype2/src/main/resources/application.properties b/Sprint 2/prototype2/src/main/resources/application.properties new file mode 100644 index 000000000..062a3d939 --- /dev/null +++ b/Sprint 2/prototype2/src/main/resources/application.properties @@ -0,0 +1,55 @@ +# ====================== +# Application Metadata +# ====================== +spring.application.name=prototype2 + +# ====================== +# Database Configuration +# ====================== +spring.datasource.username=${SPRING_DATASOURCE_USERNAME} +spring.datasource.password=${SPRING_DATASOURCE_PASSWORD} +spring.datasource.url=${SPRING_DATASOURCE_URL} +spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver + +# Connection pool settings +spring.datasource.hikari.maximum-pool-size=10 +spring.datasource.hikari.connection-timeout=30000 + +# ====================== +# JPA/Hibernate Settings +# ====================== +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.format_sql=true +spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation=true + + + +# ====================== +# Logging Configuration +# ====================== + +logging.level.org.springframework.security=INFO +logging.level.root=DEBUG +logging.level.org.hibernate.SQL=INFO +logging.level.org.hibernate.type.descriptor.sql.BasicBinder=INFO +logging.level.org.springframework.jdbc.core=INFO +# Thymeleaf +spring.thymeleaf.prefix=classpath:/templates/ +spring.thymeleaf.suffix=.html +# Additional logging +logging.level.com.jydoc.deliverable4.services.impl.=TRACE +logging.level.com.jydoc.deliverable4.controllers=INFO +logging.level.org.springframework.web=INFO +logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n +logging.file.name=application.log +logging.logback.rollingpolicy.max-file-size=10MB +logging.logback.rollingpolicy.max-history=2 +spring.sql.init.mode=always +spring.jpa.hibernate.ddl-auto=update + +spring.thymeleaf.cache=false + +logging.level.org.thymeleaf=INFO +spring.datasource.hikari.transaction-isolation=TRANSACTION_READ_COMMITTED + +spring.thymeleaf.check-template-location=true diff --git a/Sprint 2/prototype2/src/main/resources/static/css/styles.css b/Sprint 2/prototype2/src/main/resources/static/css/styles.css new file mode 100644 index 000000000..ff30abe6f --- /dev/null +++ b/Sprint 2/prototype2/src/main/resources/static/css/styles.css @@ -0,0 +1,107 @@ +body { + font-family: Arial, sans-serif; + line-height: 1.6; + margin: 0; + padding: 20px; +} + +.error { + color: red; +} + +.success { + color: green; +} + +form { + max-width: 400px; + margin: 20px 0; +} + +form div { + margin-bottom: 10px; +} + +label { + display: inline-block; + width: 100px; +} + +/* Logout Page Styles */ +.logout-container { + max-width: 500px; + margin: 2rem auto; + padding: 2rem; + text-align: center; + border: 1px solid #ddd; + border-radius: 8px; +} + +.btn-logout { + background-color: #dc3545; + color: white; + padding: 0.5rem 1rem; + border: none; + border-radius: 4px; + cursor: pointer; + margin-right: 1rem; +} + +.btn-cancel { + padding: 0.5rem 1rem; + border: 1px solid #ddd; + border-radius: 4px; + text-decoration: none; + color: #333; +} + +.btn-logout:hover { + background-color: #c82333; +} + +.btn-cancel:hover { + background-color: #f8f9fa; +} + +/* Custom styles */ +body { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +main { + flex: 1; +} + +.btn-lg { + min-width: 180px; +} + +.shadow-sm { + box-shadow: 0 .125rem .25rem rgba(0,0,0,.075) !important; +} + +/* Animation for buttons */ +.btn { + transition: all 0.3s ease; +} + +.btn:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0,0,0,0.1); +} + +/* Footer styling */ +footer { + box-shadow: 0 -2px 5px rgba(0,0,0,0.05); +} + +footer a { + transition: color 0.2s ease; +} + +footer a:hover { + color: #0d6efd !important; + text-decoration: underline; +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/resources/static/js/script.js b/Sprint 2/prototype2/src/main/resources/static/js/script.js new file mode 100644 index 000000000..e69de29bb diff --git a/Sprint 2/prototype2/src/main/resources/templates/access-denied.html b/Sprint 2/prototype2/src/main/resources/templates/access-denied.html new file mode 100644 index 000000000..244c4d386 --- /dev/null +++ b/Sprint 2/prototype2/src/main/resources/templates/access-denied.html @@ -0,0 +1,43 @@ + + + + + + Access Denied + + + + +

+
+ +
+

Access Denied

+

You don't have permission to access this page.

+

+ Logged in as: +

+ +
+ + + + + + + \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/resources/templates/admin/dashboard.html b/Sprint 2/prototype2/src/main/resources/templates/admin/dashboard.html new file mode 100644 index 000000000..74fa99c2d --- /dev/null +++ b/Sprint 2/prototype2/src/main/resources/templates/admin/dashboard.html @@ -0,0 +1,154 @@ + + + + + + Admin Dashboard | System Control Panel + + + + + + + + + + + + +
+ +
+ + + + +
+ + + + +
+
+
+
+
+
+
Total Users
+

0

+
+
+ +
+
+
+
+
+
+ User statistics +
+
+
+
+ + +
+ + +
+
+
Recent Users
+
+
+
+ + + + + + + + + + + + + + + + + +
UsernameEmailRegisteredActions
adminadmin@example.com2023-01-01 + + View + +
+
+
+
+
+
+ + +
+ + + + + + + + + + + \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/resources/templates/auth/login.html b/Sprint 2/prototype2/src/main/resources/templates/auth/login.html new file mode 100644 index 000000000..71507fba9 --- /dev/null +++ b/Sprint 2/prototype2/src/main/resources/templates/auth/login.html @@ -0,0 +1,214 @@ + + + + + + Login | HealthTrack + + + + + + + + + + + +
+
+
+ +
+
+
+ + + + + + + + \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/resources/templates/auth/logout.html b/Sprint 2/prototype2/src/main/resources/templates/auth/logout.html new file mode 100644 index 000000000..0cef10909 --- /dev/null +++ b/Sprint 2/prototype2/src/main/resources/templates/auth/logout.html @@ -0,0 +1,214 @@ + + + + + + Logout | HealthTrack + + + + + + + + + + + +
+
+
+ +
+
+
+ + + + + + + + \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/resources/templates/auth/register.html b/Sprint 2/prototype2/src/main/resources/templates/auth/register.html new file mode 100644 index 000000000..841375325 --- /dev/null +++ b/Sprint 2/prototype2/src/main/resources/templates/auth/register.html @@ -0,0 +1,221 @@ + + + + + + Register | HealthTrack + + + + + + + + + + + +
+
+
+ +
+
+
+ + + + + + + + \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/resources/templates/error.html b/Sprint 2/prototype2/src/main/resources/templates/error.html new file mode 100644 index 000000000..b342e2dbd --- /dev/null +++ b/Sprint 2/prototype2/src/main/resources/templates/error.html @@ -0,0 +1,96 @@ + + + + + + Error Page | JYDoc System + + + + + + +
+
+ +

⚠️ Oops! Something went wrong

+

We're sorry, but an error occurred while processing your request.

+ + +
+

Error Type

+

Error message

+

+ Request path: +

+

+ Status code: +

+

+ Timestamp: +

+
+ + +
+ Go to Home Page + +
+
+
+ + + + \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/resources/templates/index.html b/Sprint 2/prototype2/src/main/resources/templates/index.html new file mode 100644 index 000000000..0f9bef590 --- /dev/null +++ b/Sprint 2/prototype2/src/main/resources/templates/index.html @@ -0,0 +1,594 @@ + + + + + + HealthTrack - Medication Management System + + + + + + + + + + + + +
+
+
+
+
+

Take Control of Your Medication

+

HealthTrack helps you manage medications, track doses, and monitor health metrics - all in one place.

+ +
+
+ Medication management +
+
+
+
+ + +
+
+
+
+
10,000+
+
Users Trust Us
+
+
+
98%
+
Adherence Rate
+
+
+
24/7
+
Support Available
+
+
+
100+
+
Medications Tracked
+
+
+
+
+ + +
+
+
+

Powerful Features

+

Everything you need to manage your medications effectively and improve your health outcomes.

+
+ +
+
+
+
+ +
+

Medication Tracking

+

Keep track of all your medications, dosages, and schedules in one convenient place.

+
+
+
+
+
+ +
+

Dose Reminders

+

Never miss a dose with customizable reminders via email, SMS, or push notifications.

+
+
+
+
+
+ +
+

Health Metrics

+

Track vital signs, symptoms, and other health metrics alongside your medication regimen.

+
+
+
+
+
+ +
+

Interaction Alerts

+

Get warnings about potential drug interactions and side effects.

+
+
+
+
+
+ +
+

Refill Management

+

Receive alerts when it's time to refill prescriptions and track pharmacy information.

+
+
+
+
+
+ +
+

Progress Reports

+

Generate detailed reports to share with your healthcare providers.

+
+
+
+
+
+ + +
+
+
+

How HealthTrack Works

+

Simple steps to better medication management.

+
+ +
+
+
+
+ 1 +
+

Create Account

+

Sign up for free in just a few seconds.

+
+
+
+
+
+ 2 +
+

Add Medications

+

Enter your medications and dosages.

+
+
+
+
+
+ 3 +
+

Set Reminders

+

Configure your preferred notification methods.

+
+
+
+
+
+ 4 +
+

Track Progress

+

Monitor your adherence and health metrics.

+
+
+
+
+
+ + +
+
+
+

What Our Users Say

+

Hear from people who have transformed their medication management.

+
+ +
+
+
+ Sarah J. +
Sarah J.
+
+ + + + + +
+

"HealthTrack has completely changed how I manage my medications. The reminders ensure I never miss a dose, and the interaction alerts have been lifesavers!"

+
+
+
+
+ Michael T. +
Michael T.
+
+ + + + + +
+

"As someone with multiple chronic conditions, keeping track of my medications was overwhelming. HealthTrack simplified everything and gave me peace of mind."

+
+
+
+
+ Patricia L. +
Patricia L.
+
+ + + + + +
+

"I use HealthTrack to manage my elderly mother's medications. The caregiver features make it easy to stay on top of her complex regimen from anywhere."

+
+
+
+
+
+ + +
+
+

Ready to Take Control of Your Health?

+

Join thousands of users who are managing their medications more effectively with HealthTrack.

+ +
+
+ + +
+
+
+

Frequently Asked Questions

+

Find answers to common questions about HealthTrack.

+
+ +
+
+
+
+
+
+ Is HealthTrack free to use? +
+
+
+
+

Yes! HealthTrack offers a free plan with all the essential features for medication management. We also offer premium plans with additional features for those who need them.

+
+
+
+
+ +
+
+

We take your privacy and security very seriously. All data is encrypted both in transit and at rest. We comply with healthcare data protection regulations and never share your information without your consent.

+
+
+
+
+ +
+
+

Absolutely! HealthTrack allows you to generate comprehensive reports that you can share with your healthcare providers. You can also grant temporary access to specific providers if needed.

+
+
+
+
+ +
+
+

Our support team is available 24/7 to help you get started. We also have detailed guides and video tutorials to walk you through the process step by step.

+
+
+
+
+
+
+
+
+ + + + + + + + + + + \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/resources/templates/user/dashboard.html b/Sprint 2/prototype2/src/main/resources/templates/user/dashboard.html new file mode 100644 index 000000000..762b57adf --- /dev/null +++ b/Sprint 2/prototype2/src/main/resources/templates/user/dashboard.html @@ -0,0 +1,449 @@ + + + + + + HealthTrack - Medication Dashboard + + + + + + + + + + + +
+
+ + + + +
+
+

+ Medication Dashboard +

+
+
+
+
+ + +
+
+
+
+
+
+
ACTIVE MEDICATIONS
+
+
+
+ +
+
+ +
+
+
+ +
+
+
+
+
+
TODAY'S DOSES
+
+
+
+ +
+
+ +
+
+
+ +
+
+
+
+
+
HEALTH METRICS
+
+
+
+ +
+
+ +
+
+
+
+ +
+ +
+
+
+ + + Medication Alerts + + +
+
+
+ No current medication alerts +
+ +
+
+
+ + +
+
+
+

+ +
+
+
+
+
+
+ + +
+
+
+ + + Upcoming Medications + + +
+
+
+ + + + + + + + + + + + + + + + + + + + +
MedicationDosageTimeStatus
+
+ Medication + +
+
+ + Taken + + + Pending + +
+ +

No upcoming medications scheduled

+
+
+
+ +
+
+
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/resources/templates/user/medication/add.html b/Sprint 2/prototype2/src/main/resources/templates/user/medication/add.html new file mode 100644 index 000000000..342644840 --- /dev/null +++ b/Sprint 2/prototype2/src/main/resources/templates/user/medication/add.html @@ -0,0 +1,435 @@ + + + + + + Add Medication - HealthTrack System + + + + + + + + +
+
+ + + + +
+
+

+ Add New Medication +

+ +
+ +
+ +
+ Medication added successfully! +
+
+ Error message +
+ +
+ + + + +
+ + +
+ Validation error +
+
+ + +
+ +
+ Validation error +
+
+ + +
+
+ + +
+
+ +
+ +
+ + Validation error +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ Select all days when you need to take this medication +
+ + +
+ +
+ Validation error +
+
+ + +
+
+ +
+ + Add all times when you need to take this medication +
+ + +
+ +
+
+
+
+
+
+ + + + + \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/resources/templates/user/medication/list.html b/Sprint 2/prototype2/src/main/resources/templates/user/medication/list.html new file mode 100644 index 000000000..dc80243a0 --- /dev/null +++ b/Sprint 2/prototype2/src/main/resources/templates/user/medication/list.html @@ -0,0 +1,314 @@ + + + + + + HealthTrack - My Medications + + + + + + + + +
+
+ + + + +
+
+

+ My Medications +

+ +
+ + +
+ Medication added successfully! + +
+
+ Medication updated successfully! + +
+
+ Medication deleted successfully! + +
+ + +
+
+
+ +
+

No medications found

+

You haven't added any medications yet. Get started by adding your first medication.

+ + Add Medication + +
+
+ +
+
+
+
+
+
+ Medication +
Medication Name
+
+
+ Urgency +
+
+ +
+
+
Dosage
+

+
+ +
+
Instructions
+

+
+ + +
+
Days to Take
+
+ + Day + +
+
+ + +
+
Intake Times
+
+ + 8:00 AM + +
+
+
+
+ +
+
+
+
+
+
+ + + + + \ No newline at end of file diff --git a/Sprint 2/prototype2/src/main/resources/templates/user/profile.html b/Sprint 2/prototype2/src/main/resources/templates/user/profile.html new file mode 100644 index 000000000..5fd22770f --- /dev/null +++ b/Sprint 2/prototype2/src/main/resources/templates/user/profile.html @@ -0,0 +1,552 @@ + + + + + + HealthTrack - User Profile + + + + + + + + +
+
+ + + +
+
+

+ User Profile +

+
+
+ +
+
+
+ + + + + + +
+
+
+

+

+ +
+
+ +
+ +
+
+
+ + Account Information +
+
+
+ + + +
+ + +
+ + +
+ + +
+ Please provide a valid email address. +
+
+ + +
+
+ + +
+ First name is required. +
+
+ + +
+ + +
+ Last name is required. +
+
+
+ + +
+ + +
+ Please provide your current password to save changes. +
+
+ + +
+
+
+
+ + +
+
+
+ + Security Settings +
+
+
+ + +
+ + +
+ Please provide your current password. +
+
+
+ + +
+ Password must be at least 6 characters with one uppercase, one lowercase letter and one number +
+
+ Password must meet complexity requirements. +
+
+
+ + +
+ Passwords must match. +
+
+ +
+
+
+
+
+ + +
+
+ + Account Actions +
+
+
+
+
+
+
+ Delete Account +
+

Permanently delete your account and all associated data.

+ +
+
+
+
+
+
+
+ Export Data +
+

Download all your health data in a portable format.

+ +
+
+
+
+
+
+
+
+
+ + + + + + + + + + \ No newline at end of file diff --git a/Sprint 2/prototype2/src/test/java/com/jydoc/deliverable4/Prototype2ApplicationTests.java b/Sprint 2/prototype2/src/test/java/com/jydoc/deliverable4/Prototype2ApplicationTests.java new file mode 100644 index 000000000..7dcad2b3f --- /dev/null +++ b/Sprint 2/prototype2/src/test/java/com/jydoc/deliverable4/Prototype2ApplicationTests.java @@ -0,0 +1,18 @@ +package com.jydoc.deliverable4; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@Disabled +@SpringBootTest +@ActiveProfiles("test") +class Prototype2ApplicationTests { + + @Test + + void contextLoads() { + } + +} diff --git a/Sprint 2/prototype2/src/test/java/com/jydoc/deliverable4/dtotests/LoginDtoValidationTest.java b/Sprint 2/prototype2/src/test/java/com/jydoc/deliverable4/dtotests/LoginDtoValidationTest.java new file mode 100644 index 000000000..619291795 --- /dev/null +++ b/Sprint 2/prototype2/src/test/java/com/jydoc/deliverable4/dtotests/LoginDtoValidationTest.java @@ -0,0 +1,180 @@ +package com.jydoc.deliverable4.dtotests; + +import com.jydoc.deliverable4.dtos.userdtos.LoginDTO; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.Set; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + +class LoginDtoValidationTest { + + private static Validator validator; + + @BeforeAll + static void setUp() { + try (ValidatorFactory factory = Validation.buildDefaultValidatorFactory()) { + validator = factory.getValidator(); + } + } + + // Test data providers + static Stream validUsernames() { + return Stream.of("user123", "admin", "test.user@example.com", "a".repeat(100)); + } + + static Stream validPasswords() { + return Stream.of("password123", "P@ssw0rd", "a".repeat(128), "12345678"); + } + + // Constructor tests + @Test + void constructor_ShouldCreateInstanceWithProvidedValues() { + String testUsername = "testUser"; + String testPassword = "securePassword123"; + + LoginDTO loginDTO = new LoginDTO(testUsername, testPassword); + + assertEquals(testUsername, loginDTO.username()); + assertEquals(testPassword, loginDTO.password()); + } + + // Validation tests + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", "\t", "\n"}) + void usernameValidation_ShouldFailForBlankValues(String invalidUsername) { + LoginDTO loginDTO = new LoginDTO(invalidUsername, "validPassword123"); + Set> violations = validator.validate(loginDTO); + + assertFalse(violations.isEmpty()); + assertEquals("Username or email cannot be blank", violations.iterator().next().getMessage()); + } + + @ParameterizedTest + @MethodSource("validUsernames") + void usernameValidation_ShouldPassForValidValues(String validUsername) { + LoginDTO loginDTO = new LoginDTO(validUsername, "validPassword123"); + Set> violations = validator.validate(loginDTO); + + assertTrue(violations.isEmpty()); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", "\t", "\n"}) + void passwordValidation_ShouldFailForBlankValues(String invalidPassword) { + LoginDTO loginDTO = new LoginDTO("validUser", invalidPassword); + Set> violations = validator.validate(loginDTO); + + assertFalse(violations.isEmpty(), "Should have violations for blank password"); + + // Check that at least one of the expected messages is present + boolean hasBlankViolation = violations.stream() + .anyMatch(v -> v.getMessage().equals("Password cannot be blank")); + boolean hasSizeViolation = violations.stream() + .anyMatch(v -> v.getMessage().equals("Password must be at least 8 characters long")); + + // For blank values, we should get EITHER the blank message OR the size message + assertTrue(hasBlankViolation || hasSizeViolation, + "Should have either blank or size violation for empty password"); + } + + @ParameterizedTest + @ValueSource(strings = {"short", "1234567"}) + void passwordValidation_ShouldFailForShortPasswords(String shortPassword) { + LoginDTO loginDTO = new LoginDTO("validUser", shortPassword); + Set> violations = validator.validate(loginDTO); + + assertFalse(violations.isEmpty()); + assertEquals("Password must be at least 8 characters long", violations.iterator().next().getMessage()); + } + + @ParameterizedTest + @MethodSource("validPasswords") + void passwordValidation_ShouldPassForValidValues(String validPassword) { + LoginDTO loginDTO = new LoginDTO("validUser", validPassword); + Set> violations = validator.validate(loginDTO); + + assertTrue(violations.isEmpty()); + } + + // empty() method tests + @Test + void empty_ShouldCreateInstanceWithBlankCredentials() { + LoginDTO emptyDTO = LoginDTO.empty(); + + assertTrue(emptyDTO.username().isEmpty()); + assertTrue(emptyDTO.password().isEmpty()); + } + + // getNormalizedUsername() tests + @Test + void getNormalizedUsername_ShouldTrimWhitespace() { + LoginDTO loginDTO = new LoginDTO(" testUser ", "password"); + + assertEquals("testuser", loginDTO.getNormalizedUsername()); + } + + @Test + void getNormalizedUsername_ShouldConvertToLowerCase() { + LoginDTO loginDTO = new LoginDTO("TestUser", "password"); + + assertEquals("testuser", loginDTO.getNormalizedUsername()); + } + + @Test + void getNormalizedUsername_ShouldHandleEmptyString() { + LoginDTO loginDTO = new LoginDTO("", "password"); + + assertEquals("", loginDTO.getNormalizedUsername()); + } + + // isEmpty() tests + @Test + void isEmpty_ShouldReturnTrueForEmptyInstance() { + LoginDTO emptyDTO = LoginDTO.empty(); + + assertTrue(emptyDTO.isEmpty()); + } + + @Test + void isEmpty_ShouldReturnFalseForNonEmptyUsername() { + LoginDTO loginDTO = new LoginDTO("user", ""); + + assertFalse(loginDTO.isEmpty()); + } + + @Test + void isEmpty_ShouldReturnFalseForNonEmptyPassword() { + LoginDTO loginDTO = new LoginDTO("", "password"); + + assertFalse(loginDTO.isEmpty()); + } + + @Test + void isEmpty_ShouldReturnFalseForFullyPopulatedInstance() { + LoginDTO loginDTO = new LoginDTO("user", "password"); + + assertFalse(loginDTO.isEmpty()); + } + + // Record component tests + @Test + void recordComponents_ShouldBeAccessible() { + LoginDTO loginDTO = new LoginDTO("test", "password"); + + assertEquals("test", loginDTO.username()); + assertEquals("password", loginDTO.password()); + } +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/test/java/com/jydoc/deliverable4/dtotests/UserDtoValidationTest.java b/Sprint 2/prototype2/src/test/java/com/jydoc/deliverable4/dtotests/UserDtoValidationTest.java new file mode 100644 index 000000000..13f359015 --- /dev/null +++ b/Sprint 2/prototype2/src/test/java/com/jydoc/deliverable4/dtotests/UserDtoValidationTest.java @@ -0,0 +1,225 @@ +package com.jydoc.deliverable4.dtotests; + +import com.jydoc.deliverable4.dtos.userdtos.UserDTO; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import jakarta.validation.ConstraintViolation; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; + +//NOTE: Passes all tests + +@DisplayName("UserDTO Validation Tests") +@SpringBootTest +@ActiveProfiles("test") +class UserDtoValidationTest { + + private static Validator validator; + + @BeforeAll + static void setupValidator() { + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + validator = factory.getValidator(); + } + + @Nested + @DisplayName("When user is valid") + class ValidUserTests { + @Test + @DisplayName("Should pass all validations") + void validUser_shouldPassValidation() { + UserDTO user = createValidUser(); + assertNoViolations(user); + } + + @Test + @DisplayName("Should have default authority ROLE_USER") + void shouldHaveDefaultAuthority() { + UserDTO user = createValidUser(); + assertEquals("ROLE_USER", user.getAuthority()); + } + } + + @Nested + @DisplayName("Username validation") + class UsernameValidation { + @ParameterizedTest(name = "[{index}] ''{0}'' should fail validation") + @NullAndEmptySource + @ValueSource(strings = {" ", "\t", "\n"}) + @DisplayName("Should reject blank username") + void blankUsername_shouldFail(String username) { + UserDTO user = createValidUser(); + user.setUsername(username); + assertHasViolationWithMessage(user, "Username is required"); + } + + @Test + @DisplayName("Should reject username shorter than 3 chars") + void tooShortUsername_shouldFail() { + UserDTO user = createValidUser(); + user.setUsername("ab"); + assertHasViolationWithMessage(user, "Username must be 3-20 characters"); + } + + @Test + @DisplayName("Should reject username longer than 20 chars") + void tooLongUsername_shouldFail() { + UserDTO user = createValidUser(); + user.setUsername("a".repeat(21)); + assertHasViolationWithMessage(user, "Username must be 3-20 characters"); + } + } + + @Nested + @DisplayName("Password validation") + class PasswordValidation { + @ParameterizedTest(name = "[{index}] ''{0}'' should fail validation") + @NullAndEmptySource + @ValueSource(strings = {" ", "\t", "\n"}) + @DisplayName("Should reject blank password") + void blankPassword_shouldFail(String password) { + UserDTO user = createValidUser(); + user.setPassword(password); + assertHasViolationWithMessage(user, "Password is required"); + } + + @Test + @DisplayName("Should reject password shorter than 6 chars") + void tooShortPassword_shouldFail() { + UserDTO user = createValidUser(); + user.setPassword("Short"); + Set> violations = validator.validate(user); + assertTrue(violations.stream().anyMatch(v -> + v.getMessage().equals("Password must be at least 6 characters"))); + assertTrue(violations.stream().anyMatch(v -> + v.getMessage().equals("Password must contain at least one uppercase, lowercase letter and number"))); + } + + @ParameterizedTest(name = "[{index}] ''{0}'' should fail pattern validation") + @ValueSource(strings = {"nouppercase1", "NOLOWERCASE1", "NoNumbersHere"}) + @DisplayName("Should reject invalid password patterns") + void invalidPatternPassword_shouldFail(String password) { + UserDTO user = createValidUser(); + user.setPassword(password); + assertHasViolationWithMessage(user, + "Password must contain at least one uppercase, lowercase letter and number"); + } + } + + @Nested + @DisplayName("Email validation") + class EmailValidation { + @ParameterizedTest(name = "[{index}] ''{0}'' should fail validation") + @NullAndEmptySource + @ValueSource(strings = {" ", "\t", "\n"}) + @DisplayName("Should reject blank email") + void blankEmail_shouldFail(String email) { + UserDTO user = createValidUser(); + user.setEmail(email); + Set> violations = validator.validate(user); + assertTrue(violations.stream().anyMatch(v -> + v.getMessage().equals("Email is required")), + "Should have 'Email is required' violation"); + } + + @ParameterizedTest(name = "[{index}] ''{0}'' should fail format validation") + @ValueSource(strings = { + "plainstring", + "missing@dot", + "@domain.com", + "user@.com", + "user@domain..com", + "user@domain.c", + "user@domain,com", + "user@domain_com" + }) + @DisplayName("Should reject invalid email formats") + void invalidFormatEmail_shouldFail(String email) { + UserDTO user = createValidUser(); + user.setEmail(email); + Set> violations = validator.validate(user); + + // First check if there's a format violation + boolean hasFormatViolation = violations.stream() + .anyMatch(v -> v.getMessage().equals("Invalid email format")); + + // If no format violation, check if it's being caught by the @NotBlank constraint + if (!hasFormatViolation) { + boolean hasBlankViolation = violations.stream() + .anyMatch(v -> v.getMessage().equals("Email is required")); + assertTrue(hasBlankViolation, + "Expected either format violation or blank violation for: " + email); + } else { + assertTrue(true); // Format violation found as expected + } + } + } + + @Nested + @DisplayName("Name validation") + class NameValidation { + @ParameterizedTest(name = "[{index}] ''{0}'' should fail validation") + @NullAndEmptySource + @ValueSource(strings = {" ", "\t", "\n"}) + @DisplayName("Should reject blank first name") + void blankFirstName_shouldFail(String firstName) { + UserDTO user = createValidUser(); + user.setFirstName(firstName); + assertHasViolationWithMessage(user, "First name is required"); + } + + @ParameterizedTest(name = "[{index}] ''{0}'' should fail validation") + @NullAndEmptySource + @ValueSource(strings = {" ", "\t", "\n"}) + @DisplayName("Should reject blank last name") + void blankLastName_shouldFail(String lastName) { + UserDTO user = createValidUser(); + user.setLastName(lastName); + assertHasViolationWithMessage(user, "Last name is required"); + } + } + + // Helper methods + private UserDTO createValidUser() { + UserDTO user = new UserDTO(); + user.setUsername("validUser123"); + user.setPassword("Valid1Password"); + user.setEmail("valid@example.com"); + user.setFirstName("John"); + user.setLastName("Doe"); + return user; + } + + private void assertNoViolations(UserDTO user) { + Set> violations = validator.validate(user); + assertTrue(violations.isEmpty(), + "Expected no violations but found: " + violations.size() + "\n" + + violations.stream() + .map(v -> v.getPropertyPath() + ": " + v.getMessage()) + .collect(Collectors.joining("\n"))); + } + + private void assertHasViolationWithMessage(UserDTO user, String expectedMessage) { + Set> violations = validator.validate(user); + assertFalse(violations.isEmpty(), "Expected at least one violation but found none"); + assertTrue(violations.stream() + .anyMatch(v -> v.getMessage().equals(expectedMessage)), + "Expected violation with message: '" + expectedMessage + + "' but found:\n" + violations.stream() + .map(v -> v.getPropertyPath() + ": " + v.getMessage()) + .collect(Collectors.joining("\n"))); + } +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/test/java/com/jydoc/deliverable4/modeltests/MedicationIntakeTimeTest.java b/Sprint 2/prototype2/src/test/java/com/jydoc/deliverable4/modeltests/MedicationIntakeTimeTest.java new file mode 100644 index 000000000..88661c28f --- /dev/null +++ b/Sprint 2/prototype2/src/test/java/com/jydoc/deliverable4/modeltests/MedicationIntakeTimeTest.java @@ -0,0 +1,135 @@ +package com.jydoc.deliverable4.modeltests; + +import com.jydoc.deliverable4.model.MedicationIntakeTime; +import com.jydoc.deliverable4.model.MedicationModel; +import jakarta.persistence.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalTime; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class MedicationIntakeTimeTest { + + private MedicationIntakeTime intakeTime; + + @Mock + private MedicationModel mockMedication; + + @BeforeEach + void setUp() { + intakeTime = MedicationIntakeTime.builder() + .id(1L) + .medication(mockMedication) + .intakeTime(LocalTime.of(8, 0)) + .build(); + } + + @Test + void testBuilderCreatesValidObject() { + assertNotNull(intakeTime); + assertEquals(1L, intakeTime.getId()); + assertEquals(mockMedication, intakeTime.getMedication()); + assertEquals(LocalTime.of(8, 0), intakeTime.getIntakeTime()); + } + + @Test + void testConstructorWithParameters() { + LocalTime time = LocalTime.of(12, 30); + MedicationIntakeTime newIntakeTime = new MedicationIntakeTime(mockMedication, time); + + assertEquals(mockMedication, newIntakeTime.getMedication()); + assertEquals(time, newIntakeTime.getIntakeTime()); + assertNull(newIntakeTime.getId()); // ID not set by constructor + } + + @Test + void testEqualsAndHashCode() { + MedicationIntakeTime sameIntakeTime = MedicationIntakeTime.builder() + .id(2L) // Different ID shouldn't matter for equality + .medication(mockMedication) + .intakeTime(LocalTime.of(8, 0)) + .build(); + + MedicationIntakeTime differentTime = MedicationIntakeTime.builder() + .medication(mockMedication) + .intakeTime(LocalTime.of(9, 0)) + .build(); + + MedicationIntakeTime differentMedication = MedicationIntakeTime.builder() + .medication(mock(MedicationModel.class)) // Different medication + .intakeTime(LocalTime.of(8, 0)) + .build(); + + // Test equality + assertEquals(intakeTime, sameIntakeTime); + assertEquals(intakeTime.hashCode(), sameIntakeTime.hashCode()); + + // Test inequality + assertNotEquals(intakeTime, differentTime); + assertNotEquals(intakeTime, differentMedication); + assertNotEquals(intakeTime, null); + assertNotEquals(intakeTime, new Object()); + } + + @Test + void testSetters() { + LocalTime newTime = LocalTime.of(14, 0); + MedicationModel newMedication = mock(MedicationModel.class); + + intakeTime.setIntakeTime(newTime); + intakeTime.setMedication(newMedication); + + assertEquals(newTime, intakeTime.getIntakeTime()); + assertEquals(newMedication, intakeTime.getMedication()); + } + + @Test + void testNullChecks() { + assertThrows(NullPointerException.class, () -> new MedicationIntakeTime(null, LocalTime.now())); + assertThrows(NullPointerException.class, () -> intakeTime.setMedication(null)); + assertThrows(NullPointerException.class, () -> intakeTime.setIntakeTime(null)); + } + + @Test + void testJpaRelationshipManagement() throws NoSuchFieldException { + // Verify the @ManyToOne relationship is properly configured + ManyToOne manyToOne = MedicationIntakeTime.class + .getDeclaredField("medication") + .getAnnotation(ManyToOne.class); + + assertNotNull(manyToOne); + assertEquals(FetchType.LAZY, manyToOne.fetch()); + + JoinColumn joinColumn = MedicationIntakeTime.class + .getDeclaredField("medication") + .getAnnotation(JoinColumn.class); + + assertNotNull(joinColumn); + assertEquals("medication_id", joinColumn.name()); + assertFalse(joinColumn.nullable()); + } + + @Test + void testTableConfiguration() { + Table table = MedicationIntakeTime.class.getAnnotation(Table.class); + assertNotNull(table); + assertEquals("medication_intake_times", table.name()); + } + + @Test + void testIdGenerationStrategy() throws NoSuchFieldException { + GeneratedValue generatedValue = MedicationIntakeTime.class + .getDeclaredField("id") + .getAnnotation(GeneratedValue.class); + + assertNotNull(generatedValue); + assertEquals(GenerationType.IDENTITY, generatedValue.strategy()); + } +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/test/java/com/jydoc/deliverable4/modeltests/MedicationModelTest.java b/Sprint 2/prototype2/src/test/java/com/jydoc/deliverable4/modeltests/MedicationModelTest.java new file mode 100644 index 000000000..1e5699290 --- /dev/null +++ b/Sprint 2/prototype2/src/test/java/com/jydoc/deliverable4/modeltests/MedicationModelTest.java @@ -0,0 +1,250 @@ +package com.jydoc.deliverable4.modeltests; + +import com.jydoc.deliverable4.dtos.MedicationDTO; +import com.jydoc.deliverable4.model.MedicationIntakeTime; +import com.jydoc.deliverable4.model.MedicationModel; +import com.jydoc.deliverable4.model.UserModel; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalTime; +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + + + +@ExtendWith(MockitoExtension.class) +class MedicationModelTest { + + private MedicationModel medicationModel; + + @Mock + private UserModel mockUser; + + @BeforeEach + void setUp() { + medicationModel = MedicationModel.builder() + .id(1L) + .user(mockUser) + .name("Test Medication") + .urgency(MedicationModel.MedicationUrgency.ROUTINE) + .intakeTimes(new HashSet<>()) + .dosage("500mg") + .instructions("Take with food") + .isActive(true) + .build(); + // Remove the stubbing from here + } + + @Test + void testToDto() { + // Stub only when needed + when(mockUser.getId()).thenReturn(100L); + + LocalTime time1 = LocalTime.of(8, 0); + medicationModel.addIntakeTime(time1); + + MedicationDTO dto = medicationModel.toDto(); + assertEquals(100L, dto.getUserId()); + } + + @Test + void testAddIntakeTime() { + LocalTime testTime = LocalTime.of(8, 0); + medicationModel.addIntakeTime(testTime); + + assertEquals(1, medicationModel.getIntakeTimes().size()); + assertTrue(medicationModel.getIntakeTimesAsLocalTimes().contains(testTime)); + } + + @Test + void testAddDuplicateIntakeTime() { + LocalTime testTime = LocalTime.of(12, 0); + medicationModel.addIntakeTime(testTime); + medicationModel.addIntakeTime(testTime); // duplicate + + assertEquals(1, medicationModel.getIntakeTimes().size()); + } + + + @Test + void testAddNullIntakeTime() { + assertThrows(NullPointerException.class, () -> { + medicationModel.addIntakeTime(null); + }); + assertTrue(medicationModel.getIntakeTimes().isEmpty()); + } + + @Test + void testRemoveIntakeTime() { + LocalTime testTime = LocalTime.of(20, 0); + medicationModel.addIntakeTime(testTime); // Should be the same variable name + medicationModel.removeIntakeTime(testTime); // Should be the same variable name + + assertTrue(medicationModel.getIntakeTimes().isEmpty()); // Fix the typo here + } + + @Test + void testRemoveNonExistentIntakeTime() { + medicationModel.addIntakeTime(LocalTime.of(9, 0)); + medicationModel.removeIntakeTime(LocalTime.of(10, 0)); + + assertEquals(1, medicationModel.getIntakeTimes().size()); + } + + @Test + void testRemoveNullIntakeTime() { + medicationModel.addIntakeTime(LocalTime.of(7, 0)); + + assertThrows(NullPointerException.class, () -> { + medicationModel.removeIntakeTime(null); + }); + + // Verify nothing was removed + assertEquals(1, medicationModel.getIntakeTimes().size()); + } + + @Test + void testGetIntakeTimesAsLocalTimes() { + LocalTime time1 = LocalTime.of(8, 0); + LocalTime time2 = LocalTime.of(12, 0); + LocalTime time3 = LocalTime.of(18, 0); + + medicationModel.addIntakeTime(time1); + medicationModel.addIntakeTime(time2); + medicationModel.addIntakeTime(time3); + + Set times = medicationModel.getIntakeTimesAsLocalTimes(); + + assertEquals(3, times.size()); + assertTrue(times.contains(time1)); + assertTrue(times.contains(time2)); + assertTrue(times.contains(time3)); + } + + @Test + void testGetIntakeTimesAsLocalTimesWithNullValues() { + // Verify that null intake times are rejected + assertThrows(IllegalArgumentException.class, () -> { + new MedicationIntakeTime(medicationModel, null); + }); + + // Test that the method handles empty sets + Set times = medicationModel.getIntakeTimesAsLocalTimes(); + assertTrue(times.isEmpty()); + } + + + + @Test + void testIntakeTimesHandling() { + // Test that initially intakeTimes is not null and empty + assertNotNull(medicationModel.getIntakeTimes()); + assertTrue(medicationModel.getIntakeTimes().isEmpty()); + + // Test adding times works + LocalTime time = LocalTime.of(8, 0); + medicationModel.addIntakeTime(time); + assertFalse(medicationModel.getIntakeTimes().isEmpty()); + } + + @Test + void testAddIntakeTimeMaintainsBidirectionalRelationship() { + LocalTime testTime = LocalTime.of(9, 0); + + // This creates and adds a new MedicationIntakeTime with proper bidirectional relationship + medicationModel.addIntakeTime(testTime); + + // Verify the relationship through the public API + assertFalse(medicationModel.getIntakeTimes().isEmpty(), "Should have one intake time"); + + MedicationIntakeTime addedIntake = medicationModel.getIntakeTimes().iterator().next(); + assertEquals(testTime, addedIntake.getIntakeTime(), "Time should match"); + assertEquals(medicationModel, addedIntake.getMedication(), "Medication reference should match"); + } + + + @Test + void testToDtoWithNullUrgency() { + medicationModel.setUrgency(null); + MedicationDTO dto = medicationModel.toDto(); + assertNull(dto.getUrgency()); + } + + @Test + void testEqualsAndHashCode() { + MedicationModel model1 = MedicationModel.builder() + .id(1L) + .user(mockUser) + .name("Med1") + .urgency(MedicationModel.MedicationUrgency.ROUTINE) + .build(); + + MedicationModel model2 = MedicationModel.builder() + .id(1L) + .user(mockUser) + .name("Med2") // different name + .urgency(MedicationModel.MedicationUrgency.URGENT) // different urgency + .build(); + + MedicationModel model3 = MedicationModel.builder() + .id(2L) // different ID + .user(mockUser) + .name("Med1") + .urgency(MedicationModel.MedicationUrgency.ROUTINE) + .build(); + + assertEquals(model1, model2); // only ID matters for equality + assertNotEquals(model1, model3); + assertEquals(model1.hashCode(), model2.hashCode()); + assertNotEquals(model1.hashCode(), model3.hashCode()); + } + + @Test + void testBuilder() { + MedicationModel model = MedicationModel.builder() + .id(2L) + .user(mockUser) + .name("Builder Test") + .urgency(MedicationModel.MedicationUrgency.NONURGENT) + .dosage("100mg") + .instructions("Before sleep") + .isActive(false) + .build(); + + assertEquals(2L, model.getId()); + assertEquals(mockUser, model.getUser()); + assertEquals("Builder Test", model.getName()); + assertEquals(MedicationModel.MedicationUrgency.NONURGENT, model.getUrgency()); + assertEquals("100mg", model.getDosage()); + assertEquals("Before sleep", model.getInstructions()); + assertFalse(model.getIsActive()); + } + + @Test + void testDefaultIsActive() { + MedicationModel model = new MedicationModel(); + assertTrue(model.getIsActive()); + } + + @Test + void testDefaultIntakeTimes() { + MedicationModel model = new MedicationModel(); + assertNotNull(model.getIntakeTimes()); + assertTrue(model.getIntakeTimes().isEmpty()); + } + + @Test + void testMedicationUrgencyValues() { + assertEquals(3, MedicationModel.MedicationUrgency.values().length); + assertEquals(MedicationModel.MedicationUrgency.URGENT, MedicationModel.MedicationUrgency.valueOf("URGENT")); + assertEquals(MedicationModel.MedicationUrgency.NONURGENT, MedicationModel.MedicationUrgency.valueOf("NONURGENT")); + assertEquals(MedicationModel.MedicationUrgency.ROUTINE, MedicationModel.MedicationUrgency.valueOf("ROUTINE")); + } +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/test/java/com/jydoc/deliverable4/modeltests/UserModelTest.java b/Sprint 2/prototype2/src/test/java/com/jydoc/deliverable4/modeltests/UserModelTest.java new file mode 100644 index 000000000..915a168ec --- /dev/null +++ b/Sprint 2/prototype2/src/test/java/com/jydoc/deliverable4/modeltests/UserModelTest.java @@ -0,0 +1,305 @@ +package com.jydoc.deliverable4.modeltests; + +import com.jydoc.deliverable4.model.UserModel; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@ActiveProfiles("test") +class UserModelTest { + + @PersistenceContext + private EntityManager entityManager; + + @Autowired + private PlatformTransactionManager transactionManager; + + // Helper method for transactional operations + private void executeInTransaction(Runnable operation) { + new TransactionTemplate(transactionManager).execute(status -> { + operation.run(); + return null; + }); + } + + @Test + @Transactional + void testUserEntityPersistence() { + // Given + UserModel user = UserModel.builder() + .username("testuser") + .password("encodedPassword") + .email("test@example.com") + .firstName("Test") // Added + .lastName("User") // Added + .build(); + + // When + entityManager.persist(user); + entityManager.flush(); + entityManager.clear(); + + // Then + UserModel found = entityManager.find(UserModel.class, user.getId()); + assertNotNull(found); + assertEquals("testuser", found.getUsername()); + assertEquals("encodedPassword", found.getPassword()); + assertEquals("test@example.com", found.getEmail()); + assertEquals("Test", found.getFirstName()); // Added + assertEquals("User", found.getLastName()); // Added + } + + @Test + void testUsernameUniquenessConstraint() { + // First user (should succeed) + executeInTransaction(() -> { + UserModel user1 = UserModel.builder() + .username("uniqueuser") + .password("password1") + .firstName("Unique") // Added + .lastName("User") // Added + .build(); + entityManager.persist(user1); + }); + + // Second user with same username (should fail) + assertThrows(org.hibernate.exception.ConstraintViolationException.class, () -> { + executeInTransaction(() -> { + UserModel user2 = UserModel.builder() + .username("uniqueuser") + .password("password2") + .firstName("Unique2") // Added + .lastName("User2") // Added + .build(); + entityManager.persist(user2); + entityManager.flush(); // Explicit flush to force immediate constraint check + }); + }); + } + + @Test + void testEmailUniquenessConstraint() { + // First user with email (should succeed) + executeInTransaction(() -> { + UserModel user1 = UserModel.builder() + .username("user1") + .password("password1") + .email("unique@example.com") + .firstName("User1") // Added + .lastName("One") // Added + .build(); + entityManager.persist(user1); + }); + + // Second user with same email (should fail) + assertThrows(org.hibernate.exception.ConstraintViolationException.class, () -> { + executeInTransaction(() -> { + UserModel user2 = UserModel.builder() + .username("user2") + .password("password2") + .email("unique@example.com") + .firstName("User2") // Added + .lastName("Two") // Added + .build(); + entityManager.persist(user2); + entityManager.flush(); // Explicit flush + }); + }); + } + + @Test + @Transactional + void testOptionalEmailField() { + // Given + UserModel user = UserModel.builder() + .username("noemail") + .password("password") + .email(null) // Email is optional + .firstName("No") // Added + .lastName("Email") // Added + .build(); + + // When + entityManager.persist(user); + entityManager.flush(); + entityManager.clear(); + + // Then + UserModel found = entityManager.find(UserModel.class, user.getId()); + assertNull(found.getEmail()); + } + + @Test + @Transactional + void testDefaultSecurityFlags() { + UserModel user = UserModel.builder() + .username("defaultuser") + .password("password") + .firstName("Default") // Added + .lastName("User") // Added + .build(); + + assertAll( + () -> assertTrue(user.isEnabled()), + () -> assertTrue(user.isAccountNonExpired()), + () -> assertTrue(user.isCredentialsNonExpired()), + () -> assertTrue(user.isAccountNonLocked()) + ); + } + + @Test + @Transactional + void testFieldLengthConstraints() { + // Test username max length (50) + assertThrows(Exception.class, () -> { + UserModel user = UserModel.builder() + .username("a".repeat(51)) + .password("password") + .firstName("Length") // Added + .lastName("Test") // Added + .build(); + entityManager.persist(user); + entityManager.flush(); + }); + + // Test password max length (100) + assertThrows(Exception.class, () -> { + UserModel user = UserModel.builder() + .username("lengthuser") + .password("a".repeat(101)) + .firstName("Length") // Added + .lastName("Test") // Added + .build(); + entityManager.persist(user); + entityManager.flush(); + }); + + // Test email max length (100) + assertThrows(Exception.class, () -> { + UserModel user = UserModel.builder() + .username("emailuser") + .password("password") + .email("a".repeat(90) + "@example.com") // > 100 chars + .firstName("Email") // Added + .lastName("Test") // Added + .build(); + entityManager.persist(user); + entityManager.flush(); + }); + + // Test firstName max length (50) + assertThrows(Exception.class, () -> { + UserModel user = UserModel.builder() + .username("firstuser") + .password("password") + .firstName("a".repeat(51)) // Added + .lastName("Test") // Added + .build(); + entityManager.persist(user); + entityManager.flush(); + }); + + // Test lastName max length (50) + assertThrows(Exception.class, () -> { + UserModel user = UserModel.builder() + .username("lastuser") + .password("password") + .firstName("Test") // Added + .lastName("a".repeat(51)) // Added + .build(); + entityManager.persist(user); + entityManager.flush(); + }); + } + + @Test + void testBuilderPattern() { + // Given + UserModel user = UserModel.builder() + .username("builderuser") + .password("password") + .email("builder@example.com") + .firstName("Builder") // Added + .lastName("User") // Added + .enabled(false) + .accountNonExpired(false) + .credentialsNonExpired(false) + .accountNonLocked(false) + .build(); + + // Then + assertAll( + () -> assertEquals("builderuser", user.getUsername()), + () -> assertEquals("password", user.getPassword()), + () -> assertEquals("builder@example.com", user.getEmail()), + () -> assertEquals("Builder", user.getFirstName()), // Added + () -> assertEquals("User", user.getLastName()), // Added + () -> assertFalse(user.isEnabled()), + () -> assertFalse(user.isAccountNonExpired()), + () -> assertFalse(user.isCredentialsNonExpired()), + () -> assertFalse(user.isAccountNonLocked()) + ); + } + + @Test + void testToBuilder() { + // Given + UserModel original = UserModel.builder() + .username("original") + .password("password") + .firstName("Original") // Added + .lastName("User") // Added + .build(); + + // When + UserModel modified = original.toBuilder() + .username("modified") + .enabled(false) + .build(); + + // Then + assertAll( + () -> assertEquals("original", original.getUsername()), + () -> assertTrue(original.isEnabled()), + () -> assertEquals("modified", modified.getUsername()), + () -> assertFalse(modified.isEnabled()), + () -> assertEquals("Original", modified.getFirstName()), // Added + () -> assertEquals("User", modified.getLastName()) // Added + ); + } + + @Test + @Transactional + void testFirstNameAndLastNameNotNull() { + // Test missing firstName + assertThrows(Exception.class, () -> { + UserModel user = UserModel.builder() + .username("nofirst") + .password("password") + .lastName("User") // Only lastName + .build(); + entityManager.persist(user); + entityManager.flush(); + }); + + // Test missing lastName + assertThrows(Exception.class, () -> { + UserModel user = UserModel.builder() + .username("nolast") + .password("password") + .firstName("No") // Only firstName + .build(); + entityManager.persist(user); + entityManager.flush(); + }); + } +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/test/java/com/jydoc/deliverable4/repositorytests/UserRepositoryTest.java b/Sprint 2/prototype2/src/test/java/com/jydoc/deliverable4/repositorytests/UserRepositoryTest.java new file mode 100644 index 000000000..f21458578 --- /dev/null +++ b/Sprint 2/prototype2/src/test/java/com/jydoc/deliverable4/repositorytests/UserRepositoryTest.java @@ -0,0 +1,227 @@ +package com.jydoc.deliverable4.repositorytests; + +import com.jydoc.deliverable4.model.auth.AuthorityModel; +import com.jydoc.deliverable4.model.UserModel; +import com.jydoc.deliverable4.repositories.userrepositories.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Collections; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +@DataJpaTest +@ActiveProfiles("test") +class UserRepositoryTest { + + @Autowired + private TestEntityManager entityManager; + + @Autowired + private UserRepository userRepository; + + private UserModel testUser; + private AuthorityModel testAuthority; + + @BeforeEach + void setUp() { + testAuthority = new AuthorityModel(); + testAuthority.setAuthority("ROLE_USER"); + entityManager.persist(testAuthority); + + testUser = UserModel.builder() + .username("testuser") + .password("password") + .email("test@example.com") + .firstName("Test") // Added + .lastName("User") // Added + .accountNonExpired(true) + .accountNonLocked(true) + .credentialsNonExpired(true) + .enabled(true) + .authorities(Collections.singleton(testAuthority)) + .build(); + entityManager.persist(testUser); + entityManager.flush(); + } + + // Basic CRUD operations + @Test + void whenFindById_thenReturnUser() { + Optional foundUser = userRepository.findById(testUser.getId()); + assertTrue(foundUser.isPresent()); + assertEquals(testUser.getUsername(), foundUser.get().getUsername()); + assertEquals("Test", foundUser.get().getFirstName()); // Added + assertEquals("User", foundUser.get().getLastName()); // Added + } + + @Test + void whenSaveNewUser_thenUserIsPersisted() { + UserModel newUser = UserModel.builder() + .username("newuser") + .password("newpass") + .email("new@example.com") + .firstName("New") // Added + .lastName("User") // Added + .build(); + + UserModel savedUser = userRepository.save(newUser); + assertNotNull(savedUser.getId()); + assertEquals(newUser.getUsername(), savedUser.getUsername()); + assertEquals("New", savedUser.getFirstName()); // Added + assertEquals("User", savedUser.getLastName()); // Added + } + + // Unique constraint tests + @Test + void whenSaveDuplicateUsername_thenThrowException() { + UserModel duplicateUser = UserModel.builder() + .username("testuser") // duplicate username + .password("password") + .email("different@example.com") + .firstName("Diff") // Added + .lastName("User") // Added + .build(); + + assertThrows(DataIntegrityViolationException.class, () -> { + userRepository.saveAndFlush(duplicateUser); + }); + } + + @Test + void whenSaveDuplicateEmail_thenThrowException() { + UserModel duplicateUser = UserModel.builder() + .username("differentuser") + .password("password") + .email("test@example.com") // duplicate email + .firstName("Diff") // Added + .lastName("User") // Added + .build(); + + assertThrows(DataIntegrityViolationException.class, () -> { + userRepository.saveAndFlush(duplicateUser); + }); + } + + // Query method tests + @Test + void whenFindByUsername_thenReturnUser() { + Optional foundUser = userRepository.findByUsername("testuser"); + assertTrue(foundUser.isPresent()); + assertEquals(testUser.getEmail(), foundUser.get().getEmail()); + assertEquals("Test", foundUser.get().getFirstName()); // Added + assertEquals("User", foundUser.get().getLastName()); // Added + } + + @Test + void whenFindByNonExistentUsername_thenReturnEmpty() { + Optional foundUser = userRepository.findByUsername("nonexistent"); + assertFalse(foundUser.isPresent()); + } + + @Test + void whenExistsByUsername_thenReturnCorrectBoolean() { + assertTrue(userRepository.existsByUsername("testuser")); + assertFalse(userRepository.existsByUsername("nonexistent")); + } + + @Test + void whenExistsByEmail_thenReturnCorrectBoolean() { + assertTrue(userRepository.existsByEmail("test@example.com")); + assertFalse(userRepository.existsByEmail("nonexistent@example.com")); + } + + // Custom query tests + @Test + void whenFindByUsernameWithAuthorities_thenReturnUserWithAuthorities() { + Optional foundUser = userRepository.findByUsernameWithAuthorities("testuser"); + assertTrue(foundUser.isPresent()); + assertFalse(foundUser.get().getAuthorities().isEmpty()); + assertEquals("ROLE_USER", foundUser.get().getAuthorities().iterator().next().getAuthority()); + assertEquals("Test", foundUser.get().getFirstName()); // Added + assertEquals("User", foundUser.get().getLastName()); // Added + } + + @Test + void whenFindByUsernameOrEmail_thenReturnUser() { + // Test with username + Optional byUsername = userRepository.findByUsernameOrEmail("testuser"); + assertTrue(byUsername.isPresent()); + assertEquals("Test", byUsername.get().getFirstName()); // Added + + // Test with email + Optional byEmail = userRepository.findByUsernameOrEmail("test@example.com"); + assertTrue(byEmail.isPresent()); + assertEquals("Test", byEmail.get().getFirstName()); // Added + + // Test case insensitivity + Optional byUpperCase = userRepository.findByUsernameOrEmail("TESTUSER"); + assertTrue(byUpperCase.isPresent()); + assertEquals("Test", byUpperCase.get().getFirstName()); // Added + } + + @Test + void whenFindByUsernameOrEmailWithAuthorities_thenReturnUserWithAuthorities() { + Optional foundUser = userRepository.findByUsernameOrEmailWithAuthorities("testuser"); + assertTrue(foundUser.isPresent()); + assertFalse(foundUser.get().getAuthorities().isEmpty()); + assertEquals("Test", foundUser.get().getFirstName()); // Added + } + + @Test + void whenFindByNonExistentUsernameOrEmail_thenReturnEmpty() { + Optional foundUser = userRepository.findByUsernameOrEmail("nonexistent"); + assertFalse(foundUser.isPresent()); + } + + // Additional edge cases + @Test + void whenFindByNullUsername_thenReturnEmpty() { + Optional foundUser = userRepository.findByUsername(null); + assertFalse(foundUser.isPresent()); + } + + @Test + void whenExistsByNullUsername_thenReturnFalse() { + assertFalse(userRepository.existsByUsername(null)); + } + + @Test + void whenFindByEmptyUsername_thenReturnEmpty() { + Optional foundUser = userRepository.findByUsername(""); + assertFalse(foundUser.isPresent()); + } + + @Test + void whenSaveUserWithoutFirstNameOrLastName_thenThrowException() { + // Missing firstName + UserModel noFirstName = UserModel.builder() + .username("nofirst") + .password("password") + .email("nofirst@example.com") + .lastName("User") // Only lastName + .build(); + + assertThrows(DataIntegrityViolationException.class, () -> { + userRepository.saveAndFlush(noFirstName); + }); + + // Missing lastName + UserModel noLastName = UserModel.builder() + .username("nolast") + .password("password") + .email("nolast@example.com") + .firstName("No") // Only firstName + .build(); + + assertThrows(DataIntegrityViolationException.class, () -> { + userRepository.saveAndFlush(noLastName); + }); + } +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/test/java/com/jydoc/deliverable4/securitytests/AuthServiceTest.java b/Sprint 2/prototype2/src/test/java/com/jydoc/deliverable4/securitytests/AuthServiceTest.java new file mode 100644 index 000000000..47f908c04 --- /dev/null +++ b/Sprint 2/prototype2/src/test/java/com/jydoc/deliverable4/securitytests/AuthServiceTest.java @@ -0,0 +1,224 @@ +package com.jydoc.deliverable4.securitytests; + +import com.jydoc.deliverable4.dtos.userdtos.LoginDTO; +import com.jydoc.deliverable4.dtos.userdtos.UserDTO; +import com.jydoc.deliverable4.model.auth.AuthorityModel; +import com.jydoc.deliverable4.model.UserModel; +import com.jydoc.deliverable4.repositories.userrepositories.AuthorityRepository; +import com.jydoc.deliverable4.repositories.userrepositories.UserRepository; +import com.jydoc.deliverable4.services.authservices.AuthService; +import com.jydoc.deliverable4.services.userservices.UserValidationHelper; +import org.junit.jupiter.api.BeforeEach; +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.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.DisabledException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.HashSet; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class AuthServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private AuthorityRepository authorityRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + @Mock + private AuthenticationManager authenticationManager; + + @Mock + private UserValidationHelper validationHelper; + + @InjectMocks + private AuthService authService; + + private UserDTO testUserDto; + private LoginDTO testLoginDto; + private UserModel testUser; + private AuthorityModel testAuthority; + + @BeforeEach + void setUp() { + testUserDto = new UserDTO(); + testUserDto.setUsername("testuser"); + testUserDto.setEmail("test@example.com"); + testUserDto.setPassword("Password123!"); + testUserDto.setFirstName("Test"); + testUserDto.setLastName("User"); + + testLoginDto = new LoginDTO("testuser", "Password123!"); + + testUser = UserModel.builder() + .username("testuser") + .email("test@example.com") + .firstName("Test") + .lastName("User") + .password("encodedPassword") + .enabled(true) + .accountNonExpired(true) + .credentialsNonExpired(true) + .accountNonLocked(true) + .authorities(new HashSet<>()) + .build(); + + testAuthority = AuthorityModel.builder() + .authority("ROLE_USER") + .users(new HashSet<>()) + .build(); + } + + /* ======================== Registration Tests ======================== */ + + @Test + void registerNewUser_ValidUser_Success() { + when(userRepository.existsByUsername("testuser")).thenReturn(false); + when(userRepository.existsByEmail("test@example.com")).thenReturn(false); + when(passwordEncoder.encode("Password123!")).thenReturn("encodedPassword"); + when(authorityRepository.findByAuthority("ROLE_USER")).thenReturn(Optional.of(testAuthority)); + when(userRepository.save(any(UserModel.class))).thenReturn(testUser); + + authService.registerNewUser(testUserDto); + + verify(validationHelper).validateUserRegistration(testUserDto); + verify(userRepository).existsByUsername("testuser"); + verify(userRepository).existsByEmail("test@example.com"); + verify(passwordEncoder).encode("Password123!"); + verify(authorityRepository).findByAuthority("ROLE_USER"); + verify(userRepository).save(any(UserModel.class)); + } + + @Test + void registerNewUser_NullUserDto_ThrowsException() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> authService.registerNewUser(null)); + assertEquals("UserDTO cannot be null", exception.getMessage()); + } + + @Test + void registerNewUser_ExistingUsername_ThrowsException() { + when(userRepository.existsByUsername("testuser")).thenReturn(true); + + AuthService.UsernameExistsException exception = assertThrows( + AuthService.UsernameExistsException.class, + () -> authService.registerNewUser(testUserDto) + ); + assertEquals("Username 'testuser' already exists", exception.getMessage()); + verify(userRepository, never()).save(any(UserModel.class)); + } + + @Test + void registerNewUser_EmptyPassword_ThrowsException() { + testUserDto.setPassword(""); + + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> authService.registerNewUser(testUserDto)); + assertEquals("Password cannot be empty", exception.getMessage()); + verifyNoInteractions(userRepository); + } + + /* ======================== Authentication Tests ======================== */ + + @Test + void authenticate_ValidCredentials_ReturnsUser() { + Authentication auth = mock(Authentication.class); + when(authenticationManager.authenticate(any())).thenReturn(auth); + when(auth.getName()).thenReturn("testuser"); + when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser)); + + UserModel result = authService.authenticate(testLoginDto); + + assertEquals("testuser", result.getUsername()); + verify(authenticationManager).authenticate( + new UsernamePasswordAuthenticationToken("testuser", "Password123!") + ); + } + + @Test + void authenticate_BadCredentials_ThrowsException() { + when(authenticationManager.authenticate(any())) + .thenThrow(new BadCredentialsException("Invalid credentials")); + + AuthService.AuthenticationException exception = assertThrows( + AuthService.AuthenticationException.class, + () -> authService.authenticate(testLoginDto) + ); + assertEquals("Invalid username or password", exception.getMessage()); + } + + @Test + void authenticate_DisabledAccount_ThrowsException() { + when(authenticationManager.authenticate(any())) + .thenThrow(new DisabledException("Account disabled")); + + AuthService.AuthenticationException exception = assertThrows( + AuthService.AuthenticationException.class, + () -> authService.authenticate(testLoginDto) + ); + assertEquals("Account is disabled", exception.getMessage()); + } + + /* ======================== Direct Validation Tests ======================== */ + + @Test + void validateLogin_ValidCredentials_ReturnsUser() { + when(userRepository.findByUsernameOrEmail("testuser")) + .thenReturn(Optional.of(testUser)); + when(passwordEncoder.matches("Password123!", "encodedPassword")).thenReturn(true); + + UserModel result = authService.validateLogin(testLoginDto); + + assertEquals("testuser", result.getUsername()); + verify(passwordEncoder).matches("Password123!", "encodedPassword"); + } + + @Test + void validateLogin_InvalidPassword_ThrowsException() { + when(userRepository.findByUsernameOrEmail("testuser")) + .thenReturn(Optional.of(testUser)); + when(passwordEncoder.matches("Password123!", "encodedPassword")).thenReturn(false); + + AuthService.AuthenticationException exception = assertThrows( + AuthService.AuthenticationException.class, + () -> authService.validateLogin(testLoginDto) + ); + assertEquals("Invalid credentials", exception.getMessage()); + } + + @Test + void validateLogin_DisabledAccount_ThrowsException() { + testUser.setEnabled(false); + when(userRepository.findByUsernameOrEmail("testuser")) + .thenReturn(Optional.of(testUser)); + when(passwordEncoder.matches("Password123!", "encodedPassword")).thenReturn(true); + + AuthService.AuthenticationException exception = assertThrows( + AuthService.AuthenticationException.class, + () -> authService.validateLogin(testLoginDto) + ); + assertEquals("Account is disabled", exception.getMessage()); + } + + @Test + void validateLogin_NullLoginDto_ThrowsException() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> authService.validateLogin(null)); + assertEquals("Login credentials cannot be null", exception.getMessage()); + } +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/test/java/com/jydoc/deliverable4/securitytests/AuthorityInitializerTest.java b/Sprint 2/prototype2/src/test/java/com/jydoc/deliverable4/securitytests/AuthorityInitializerTest.java new file mode 100644 index 000000000..0d71933a3 --- /dev/null +++ b/Sprint 2/prototype2/src/test/java/com/jydoc/deliverable4/securitytests/AuthorityInitializerTest.java @@ -0,0 +1,156 @@ +package com.jydoc.deliverable4.securitytests; + +import com.jydoc.deliverable4.initializers.AuthorityInitializer; +import com.jydoc.deliverable4.model.auth.AuthorityModel; +import com.jydoc.deliverable4.repositories.userrepositories.AuthorityRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("AuthorityInitializer Test Suite") +class AuthorityInitializerTest { + + private static final Set EXPECTED_DEFAULT_AUTHORITIES = Set.of( + "ROLE_USER", + "ROLE_ADMIN", + "ROLE_MODERATOR" + ); + @Mock + private AuthorityRepository authorityRepository; + @InjectMocks + private AuthorityInitializer authorityInitializer; + + @BeforeEach + void setUp() { + reset(authorityRepository); + } + + @Test + @DisplayName("Should create all default authorities when none exist") + void shouldCreateAllDefaultAuthoritiesWhenNoneExist() { + // Arrange + when(authorityRepository.findByAuthority(anyString())) + .thenReturn(Optional.empty()); + + // Act + authorityInitializer.initializeDefaultAuthorities(); + + // Assert + verify(authorityRepository, times(3)).save(any(AuthorityModel.class)); + } + + @Test + @DisplayName("Should skip creation of existing authorities") + void shouldSkipCreationOfExistingAuthorities() { + // Arrange + AuthorityModel existingAdmin = new AuthorityModel(); + existingAdmin.setAuthority("ROLE_ADMIN"); + + when(authorityRepository.findByAuthority("ROLE_ADMIN")) + .thenReturn(Optional.of(existingAdmin)); + when(authorityRepository.findByAuthority("ROLE_USER")) + .thenReturn(Optional.empty()); + when(authorityRepository.findByAuthority("ROLE_MODERATOR")) + .thenReturn(Optional.empty()); + + // Act + authorityInitializer.initializeDefaultAuthorities(); + + // Assert + verify(authorityRepository, times(2)).save(any(AuthorityModel.class)); + verify(authorityRepository, never()).save(existingAdmin); + } + + @Test + @DisplayName("Should handle repository exceptions gracefully") + void shouldHandleRepositoryExceptions() { + // Arrange + when(authorityRepository.findByAuthority(anyString())) + .thenThrow(new RuntimeException("Database connection failed")); + + // Act & Assert + Exception exception = assertThrows(RuntimeException.class, + () -> authorityInitializer.initializeDefaultAuthorities()); + + assertEquals("Database connection failed", exception.getMessage()); + } + + @Test + @DisplayName("Should verify exact default authority names") + void shouldVerifyExactDefaultAuthorityNames() { + // Arrange + when(authorityRepository.findByAuthority(anyString())) + .thenReturn(Optional.empty()); + + // Act + authorityInitializer.initializeDefaultAuthorities(); + + // Assert + ArgumentCaptor authorityCaptor = ArgumentCaptor.forClass(String.class); + verify(authorityRepository, times(3)).findByAuthority(authorityCaptor.capture()); + + assertTrue(authorityCaptor.getAllValues().containsAll(EXPECTED_DEFAULT_AUTHORITIES)); + } + + @Test + @DisplayName("Should not modify existing authorities") + void shouldNotModifyExistingAuthorities() { + // Arrange + AuthorityModel existingUser = new AuthorityModel(); + existingUser.setAuthority("ROLE_USER"); + + when(authorityRepository.findByAuthority("ROLE_USER")) + .thenReturn(Optional.of(existingUser)); + when(authorityRepository.findByAuthority("ROLE_ADMIN")) + .thenReturn(Optional.empty()); + when(authorityRepository.findByAuthority("ROLE_MODERATOR")) + .thenReturn(Optional.empty()); + + // Act + authorityInitializer.initializeDefaultAuthorities(); + + // Assert + verify(authorityRepository, never()).save(existingUser); + verify(authorityRepository, times(2)).save(any(AuthorityModel.class)); + } + + @Test + @DisplayName("Should handle case when all authorities already exist") + void shouldHandleCaseWhenAllAuthoritiesExist() { + // Arrange + AuthorityModel existingUser = new AuthorityModel(); + existingUser.setAuthority("ROLE_USER"); + + AuthorityModel existingAdmin = new AuthorityModel(); + existingAdmin.setAuthority("ROLE_ADMIN"); + + AuthorityModel existingModerator = new AuthorityModel(); + existingModerator.setAuthority("ROLE_MODERATOR"); + + when(authorityRepository.findByAuthority("ROLE_USER")) + .thenReturn(Optional.of(existingUser)); + when(authorityRepository.findByAuthority("ROLE_ADMIN")) + .thenReturn(Optional.of(existingAdmin)); + when(authorityRepository.findByAuthority("ROLE_MODERATOR")) + .thenReturn(Optional.of(existingModerator)); + + // Act + authorityInitializer.initializeDefaultAuthorities(); + + // Assert + verify(authorityRepository, never()).save(any(AuthorityModel.class)); + } +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/test/java/com/jydoc/deliverable4/securitytests/CustomUserDetailsServiceTest.java b/Sprint 2/prototype2/src/test/java/com/jydoc/deliverable4/securitytests/CustomUserDetailsServiceTest.java new file mode 100644 index 000000000..6f21134ec --- /dev/null +++ b/Sprint 2/prototype2/src/test/java/com/jydoc/deliverable4/securitytests/CustomUserDetailsServiceTest.java @@ -0,0 +1,229 @@ +package com.jydoc.deliverable4.securitytests; + +import com.jydoc.deliverable4.model.auth.AuthorityModel; +import com.jydoc.deliverable4.model.UserModel; +import com.jydoc.deliverable4.repositories.userrepositories.UserRepository; +import com.jydoc.deliverable4.security.auth.CustomUserDetailsService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Collections; +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("CustomUserDetailsService Tests") +@ActiveProfiles("test") +class CustomUserDetailsServiceTest { + + @Mock + private UserRepository userRepository; + + @InjectMocks + private CustomUserDetailsService userDetailsService; + + private static final String USERNAME = "testuser"; + private static final String EMAIL = "test@example.com"; + private static final String PASSWORD = "password123"; + private static final Long USER_ID = 1L; + + @Nested + @DisplayName("loadUserByUsername Tests") + class LoadUserByUsernameTests { + + private AuthorityModel createMockAuthority(String authorityName) { + AuthorityModel authority = new AuthorityModel(); + authority.setAuthority(authorityName); + return authority; + } + + private UserModel createMockUser(Set authorities) { + UserModel user = new UserModel(); + user.setId(USER_ID); + user.setUsername(USERNAME); + user.setPassword(PASSWORD); + user.setEnabled(true); + user.setAccountNonExpired(true); + user.setAccountNonLocked(true); + user.setCredentialsNonExpired(true); + user.setAuthorities(authorities); + return user; + } + + @Test + @DisplayName("Should load user by username successfully") + void shouldLoadUserByUsername() { + // Arrange + AuthorityModel userRole = createMockAuthority("ROLE_USER"); + AuthorityModel adminRole = createMockAuthority("ROLE_ADMIN"); + Set authorities = Set.of(userRole, adminRole); + + UserModel mockUser = createMockUser(authorities); + when(userRepository.findByUsernameOrEmailWithAuthorities(USERNAME)) + .thenReturn(Optional.of(mockUser)); + + // Act + UserDetails userDetails = userDetailsService.loadUserByUsername(USERNAME); + + // Assert + assertNotNull(userDetails); + assertEquals(USERNAME, userDetails.getUsername()); + assertEquals(PASSWORD, userDetails.getPassword()); + assertTrue(userDetails.isEnabled()); + assertTrue(userDetails.isAccountNonExpired()); + assertTrue(userDetails.isAccountNonLocked()); + assertTrue(userDetails.isCredentialsNonExpired()); + + // Verify authorities + assertEquals(2, userDetails.getAuthorities().size()); + assertTrue(userDetails.getAuthorities().contains(new SimpleGrantedAuthority("ROLE_USER"))); + assertTrue(userDetails.getAuthorities().contains(new SimpleGrantedAuthority("ROLE_ADMIN"))); + + verify(userRepository).findByUsernameOrEmailWithAuthorities(USERNAME); + } + + @Test + @DisplayName("Should load user by email successfully") + void shouldLoadUserByEmail() { + // Arrange + AuthorityModel userRole = createMockAuthority("ROLE_USER"); + UserModel mockUser = createMockUser(Set.of(userRole)); + when(userRepository.findByUsernameOrEmailWithAuthorities(EMAIL)) + .thenReturn(Optional.of(mockUser)); + + // Act + UserDetails userDetails = userDetailsService.loadUserByUsername(EMAIL); + + // Assert + assertNotNull(userDetails); + assertEquals(USERNAME, userDetails.getUsername()); + verify(userRepository).findByUsernameOrEmailWithAuthorities(EMAIL); + } + + @Test + @DisplayName("Should throw UsernameNotFoundException when user not found") + void shouldThrowWhenUserNotFound() { + // Arrange + String invalidUsername = "nonexistent"; + when(userRepository.findByUsernameOrEmailWithAuthorities(invalidUsername)) + .thenReturn(Optional.empty()); + + // Act & Assert + UsernameNotFoundException exception = assertThrows( + UsernameNotFoundException.class, + () -> userDetailsService.loadUserByUsername(invalidUsername) + ); + + assertEquals("User not found with username or email: " + invalidUsername, exception.getMessage()); + verify(userRepository).findByUsernameOrEmailWithAuthorities(invalidUsername); + } + + @Test + @DisplayName("Should handle disabled user account") + void shouldHandleDisabledUser() { + // Arrange + UserModel mockUser = createMockUser(Collections.emptySet()); + mockUser.setEnabled(false); + when(userRepository.findByUsernameOrEmailWithAuthorities(USERNAME)) + .thenReturn(Optional.of(mockUser)); + + // Act + UserDetails userDetails = userDetailsService.loadUserByUsername(USERNAME); + + // Assert + assertFalse(userDetails.isEnabled()); + } + + @Test + @DisplayName("Should handle expired user account") + void shouldHandleExpiredAccount() { + // Arrange + UserModel mockUser = createMockUser(Collections.emptySet()); + mockUser.setAccountNonExpired(false); + when(userRepository.findByUsernameOrEmailWithAuthorities(USERNAME)) + .thenReturn(Optional.of(mockUser)); + + // Act + UserDetails userDetails = userDetailsService.loadUserByUsername(USERNAME); + + // Assert + assertFalse(userDetails.isAccountNonExpired()); + } + + @Test + @DisplayName("Should handle locked user account") + void shouldHandleLockedAccount() { + // Arrange + UserModel mockUser = createMockUser(Collections.emptySet()); + mockUser.setAccountNonLocked(false); + when(userRepository.findByUsernameOrEmailWithAuthorities(USERNAME)) + .thenReturn(Optional.of(mockUser)); + + // Act + UserDetails userDetails = userDetailsService.loadUserByUsername(USERNAME); + + // Assert + assertFalse(userDetails.isAccountNonLocked()); + } + + @Test + @DisplayName("Should handle expired credentials") + void shouldHandleExpiredCredentials() { + // Arrange + UserModel mockUser = createMockUser(Collections.emptySet()); + mockUser.setCredentialsNonExpired(false); + when(userRepository.findByUsernameOrEmailWithAuthorities(USERNAME)) + .thenReturn(Optional.of(mockUser)); + + // Act + UserDetails userDetails = userDetailsService.loadUserByUsername(USERNAME); + + // Assert + assertFalse(userDetails.isCredentialsNonExpired()); + } + + @Test + @DisplayName("Should handle empty authorities") + void shouldHandleEmptyAuthorities() { + // Arrange + UserModel mockUser = createMockUser(Collections.emptySet()); + when(userRepository.findByUsernameOrEmailWithAuthorities(USERNAME)) + .thenReturn(Optional.of(mockUser)); + + // Act + UserDetails userDetails = userDetailsService.loadUserByUsername(USERNAME); + + // Assert + assertTrue(userDetails.getAuthorities().isEmpty()); + } + } + + @Nested + @DisplayName("Transactional Behavior Tests") + class TransactionalBehaviorTests { + + @Test + @DisplayName("Should have readOnly transaction for loadUserByUsername") + void shouldHaveReadOnlyTransaction() throws NoSuchMethodException { + // This test verifies the annotation is present + var method = CustomUserDetailsService.class.getMethod( + "loadUserByUsername", String.class); + var transactional = method.getAnnotation(org.springframework.transaction.annotation.Transactional.class); + + assertNotNull(transactional); + assertTrue(transactional.readOnly()); + } + } +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/test/java/com/jydoc/deliverable4/securitytests/CustomUserDetailsTest.java b/Sprint 2/prototype2/src/test/java/com/jydoc/deliverable4/securitytests/CustomUserDetailsTest.java new file mode 100644 index 000000000..4b330777d --- /dev/null +++ b/Sprint 2/prototype2/src/test/java/com/jydoc/deliverable4/securitytests/CustomUserDetailsTest.java @@ -0,0 +1,316 @@ +package com.jydoc.deliverable4.securitytests; + +import com.jydoc.deliverable4.security.auth.CustomUserDetails; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +@DisplayName("CustomUserDetails Tests") +@ActiveProfiles("test") +class CustomUserDetailsTest { + + // Test data + private static final Long USER_ID = 1L; + private static final String USERNAME = "testuser"; + private static final String PASSWORD = "password123"; + private static final Collection AUTHORITIES = + Arrays.asList(new SimpleGrantedAuthority("ROLE_USER")); + + @Nested + @DisplayName("Constructor Tests") + class ConstructorTests { + + @Test + void shouldCreateInstanceWithValidParameters() { + // Arrange + Long userId = 1L; + String username = "testUser"; + String password = "securePassword"; + boolean enabled = true; + boolean accountNonExpired = true; + boolean accountNonLocked = true; + boolean credentialsNonExpired = true; + Collection expectedAuthorities = + Arrays.asList(new SimpleGrantedAuthority("ROLE_USER")); + + // Act + CustomUserDetails userDetails = new CustomUserDetails( + userId, + username, + password, + enabled, + accountNonExpired, + accountNonLocked, + credentialsNonExpired, + expectedAuthorities + ); + + // Assert + assertEquals(userId, userDetails.getUserId()); + assertEquals(username, userDetails.getUsername()); + assertEquals(password, userDetails.getPassword()); + assertTrue(userDetails.isEnabled()); + assertTrue(userDetails.isAccountNonExpired()); + assertTrue(userDetails.isAccountNonLocked()); + assertTrue(userDetails.isCredentialsNonExpired()); + + // This is the key assertion that fixes the original error + assertIterableEquals(expectedAuthorities, userDetails.getAuthorities()); + } + + @Test + @DisplayName("Should throw NullPointerException for null userId") + void shouldThrowForNullUserId() { + assertThrows(NullPointerException.class, () -> + new CustomUserDetails( + null, USERNAME, PASSWORD, + true, true, true, true, + AUTHORITIES + ) + ); + } + + @Test + @DisplayName("Should throw NullPointerException for null username") + void shouldThrowForNullUsername() { + assertThrows(NullPointerException.class, () -> + new CustomUserDetails( + USER_ID, null, PASSWORD, + true, true, true, true, + AUTHORITIES + ) + ); + } + + @Test + @DisplayName("Should throw NullPointerException for null password") + void shouldThrowForNullPassword() { + assertThrows(NullPointerException.class, () -> + new CustomUserDetails( + USER_ID, USERNAME, null, + true, true, true, true, + AUTHORITIES + ) + ); + } + + @Test + @DisplayName("Should throw NullPointerException for null authorities") + void shouldThrowForNullAuthorities() { + assertThrows(NullPointerException.class, () -> + new CustomUserDetails( + USER_ID, USERNAME, PASSWORD, + true, true, true, true, + null + ) + ); + } + } + + @Nested + @DisplayName("Account Status Tests") + class AccountStatusTests { + + @Test + @DisplayName("Should reflect disabled account status") + void shouldReflectDisabledAccount() { + CustomUserDetails userDetails = new CustomUserDetails( + USER_ID, USERNAME, PASSWORD, + false, true, true, true, + AUTHORITIES + ); + + assertFalse(userDetails.isEnabled()); + } + + @Test + @DisplayName("Should reflect expired account status") + void shouldReflectExpiredAccount() { + CustomUserDetails userDetails = new CustomUserDetails( + USER_ID, USERNAME, PASSWORD, + true, false, true, true, + AUTHORITIES + ); + + assertFalse(userDetails.isAccountNonExpired()); + } + + @Test + @DisplayName("Should reflect locked account status") + void shouldReflectLockedAccount() { + CustomUserDetails userDetails = new CustomUserDetails( + USER_ID, USERNAME, PASSWORD, + true, true, false, true, + AUTHORITIES + ); + + assertFalse(userDetails.isAccountNonLocked()); + } + + @Test + @DisplayName("Should reflect expired credentials status") + void shouldReflectExpiredCredentials() { + CustomUserDetails userDetails = new CustomUserDetails( + USER_ID, USERNAME, PASSWORD, + true, true, true, false, + AUTHORITIES + ); + + assertFalse(userDetails.isCredentialsNonExpired()); + } + } + + @Nested + @DisplayName("Equals and HashCode Tests") + class EqualsAndHashCodeTests { + + @Test + @DisplayName("Should be equal when userId and username match") + void shouldBeEqualWhenUserIdAndUsernameMatch() { + CustomUserDetails user1 = new CustomUserDetails( + USER_ID, USERNAME, PASSWORD, + true, true, true, true, + AUTHORITIES + ); + + CustomUserDetails user2 = new CustomUserDetails( + USER_ID, USERNAME, "differentPassword", + false, false, false, false, + Collections.emptyList() + ); + + assertEquals(user1, user2); + assertEquals(user1.hashCode(), user2.hashCode()); + } + + @Test + @DisplayName("Should not be equal when userId differs") + void shouldNotBeEqualWhenUserIdDiffers() { + CustomUserDetails user1 = new CustomUserDetails( + USER_ID, USERNAME, PASSWORD, + true, true, true, true, + AUTHORITIES + ); + + CustomUserDetails user2 = new CustomUserDetails( + 2L, USERNAME, PASSWORD, + true, true, true, true, + AUTHORITIES + ); + + assertNotEquals(user1, user2); + } + + @Test + @DisplayName("Should not be equal when username differs") + void shouldNotBeEqualWhenUsernameDiffers() { + CustomUserDetails user1 = new CustomUserDetails( + USER_ID, USERNAME, PASSWORD, + true, true, true, true, + AUTHORITIES + ); + + CustomUserDetails user2 = new CustomUserDetails( + USER_ID, "differentuser", PASSWORD, + true, true, true, true, + AUTHORITIES + ); + + assertNotEquals(user1, user2); + } + + @Test + @DisplayName("Should not be equal to null") + void shouldNotBeEqualToNull() { + CustomUserDetails userDetails = new CustomUserDetails( + USER_ID, USERNAME, PASSWORD, + true, true, true, true, + AUTHORITIES + ); + + assertNotEquals(null, userDetails); + } + + @Test + @DisplayName("Should not be equal to different class") + void shouldNotBeEqualToDifferentClass() { + CustomUserDetails userDetails = new CustomUserDetails( + USER_ID, USERNAME, PASSWORD, + true, true, true, true, + AUTHORITIES + ); + + assertNotEquals("Not a user object", userDetails); + } + } + + @Nested + @DisplayName("Getter Tests") + class GetterTests { + + @Test + @DisplayName("Should return correct userId") + void shouldReturnCorrectUserId() { + CustomUserDetails userDetails = new CustomUserDetails( + USER_ID, USERNAME, PASSWORD, + true, true, true, true, + AUTHORITIES + ); + + assertEquals(USER_ID, userDetails.getUserId()); + } + + @Test + @DisplayName("Should return correct username") + void shouldReturnCorrectUsername() { + CustomUserDetails userDetails = new CustomUserDetails( + USER_ID, USERNAME, PASSWORD, + true, true, true, true, + AUTHORITIES + ); + + assertEquals(USERNAME, userDetails.getUsername()); + } + + @Test + @DisplayName("Should return correct password") + void shouldReturnCorrectPassword() { + CustomUserDetails userDetails = new CustomUserDetails( + USER_ID, USERNAME, PASSWORD, + true, true, true, true, + AUTHORITIES + ); + + assertEquals(PASSWORD, userDetails.getPassword()); + } + + @Test + @DisplayName("Should return correct authorities") + void shouldReturnCorrectAuthorities() { + // Setup + CustomUserDetails userDetails = new CustomUserDetails( + USER_ID, USERNAME, PASSWORD, + true, true, true, true, + AUTHORITIES + ); + + // Verify + Collection authorities = userDetails.getAuthorities(); + + assertEquals(1, authorities.size()); + GrantedAuthority authority = authorities.iterator().next(); + assertEquals("ROLE_USER", authority.getAuthority()); + } + } +} \ No newline at end of file diff --git a/Sprint 2/prototype2/src/test/java/com/jydoc/deliverable4/servicetests/UserServiceTest.java b/Sprint 2/prototype2/src/test/java/com/jydoc/deliverable4/servicetests/UserServiceTest.java new file mode 100644 index 000000000..5c6485455 --- /dev/null +++ b/Sprint 2/prototype2/src/test/java/com/jydoc/deliverable4/servicetests/UserServiceTest.java @@ -0,0 +1,174 @@ +package com.jydoc.deliverable4.servicetests; + +import com.jydoc.deliverable4.model.auth.AuthorityModel; +import com.jydoc.deliverable4.model.UserModel; +import com.jydoc.deliverable4.repositories.userrepositories.AuthorityRepository; +import com.jydoc.deliverable4.repositories.userrepositories.UserRepository; +import com.jydoc.deliverable4.services.userservices.UserService; +import com.jydoc.deliverable4.services.userservices.UserValidationHelper; +import org.junit.jupiter.api.BeforeEach; +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.security.crypto.password.PasswordEncoder; + +import java.util.HashSet; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private AuthorityRepository authorityRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + @Mock + private UserValidationHelper validationHelper; + + @InjectMocks + private UserService userService; + + private UserModel testUser; + private AuthorityModel testAuthority; + + @BeforeEach + void setUp() { + testUser = UserModel.builder() + .id(1L) + .username("testuser") + .email("test@example.com") + .firstName("Test") + .lastName("User") + .password("encodedPassword") + .enabled(true) + .accountNonExpired(true) + .credentialsNonExpired(true) + .accountNonLocked(true) + .authorities(new HashSet<>()) + .build(); + + testAuthority = AuthorityModel.builder() + .authority("ROLE_USER") + .users(new HashSet<>()) + .build(); + testUser.addAuthority(testAuthority); + } + + /* ======================== User Management Tests ======================== */ + + @Test + void findActiveUser_ValidUsername_ReturnsUser() { + when(userRepository.findByUsernameOrEmail("testuser")) + .thenReturn(Optional.of(testUser)); + + Optional result = userService.findActiveUser("testuser"); + + assertTrue(result.isPresent()); + assertEquals("testuser", result.get().getUsername()); + verify(userRepository).findByUsernameOrEmail("testuser"); + } + + @Test + void findActiveUser_DisabledUser_ReturnsEmpty() { + testUser.setEnabled(false); + when(userRepository.findByUsernameOrEmail("testuser")) + .thenReturn(Optional.of(testUser)); + + Optional result = userService.findActiveUser("testuser"); + + assertFalse(result.isPresent()); + } + + @Test + void getUserCount_ReturnsCount() { + when(userRepository.count()).thenReturn(5L); + + long count = userService.getUserCount(); + + assertEquals(5L, count); + verify(userRepository).count(); + } + + @Test + void existsById_UserExists_ReturnsTrue() { + when(userRepository.existsById(1L)).thenReturn(true); + + boolean exists = userService.existsById(1L); + + assertTrue(exists); + } + + @Test + void getAllUsers_ReturnsUserList() { + when(userRepository.findAll()).thenReturn(List.of(testUser)); + + List users = userService.getAllUsers(); + + assertEquals(1, users.size()); + assertEquals("testuser", users.get(0).getUsername()); + } + + @Test + void getUserById_ValidId_ReturnsUser() { + when(userRepository.findById(1L)).thenReturn(Optional.of(testUser)); + + Optional result = userService.getUserById(1L); + + assertTrue(result.isPresent()); + assertEquals("testuser", result.get().getUsername()); + } + + @Test + void updateUser_SavesUser() { + userService.updateUser(testUser); + + verify(userRepository).save(testUser); + } + + @Test + void deleteUser_DeletesById() { + // Mock existsById() to return true (if your service checks existence) + when(userRepository.existsById(1L)).thenReturn(true); + + // Mock deleteById to do nothing + doNothing().when(userRepository).deleteById(1L); + + // Execute + userService.deleteUser(1L); + + // Verify deletion was called + verify(userRepository).deleteById(1L); + } + + @Test + void findByUsername_ValidUsername_ReturnsUser() { + when(userRepository.findByUsername("testuser")) + .thenReturn(Optional.of(testUser)); + + UserModel result = userService.findByUsername("testuser"); + + assertEquals("testuser", result.getUsername()); + } + + @Test + void findByUsername_InvalidUsername_ThrowsException() { + when(userRepository.findByUsername("unknown")) + .thenReturn(Optional.empty()); + + assertThrows(IllegalArgumentException.class, () -> { + userService.findByUsername("unknown"); + }); + } +} \ No newline at end of file diff --git a/Sprint 3/codePlaceHolder.txt b/Sprint 3/codePlaceHolder.txt index e69de29bb..101a42c77 100644 --- a/Sprint 3/codePlaceHolder.txt +++ b/Sprint 3/codePlaceHolder.txt @@ -0,0 +1,12 @@ +// Update patient records & generate reports +public class RecordUpdater { + public void updateIntake(String patientName, String med, String status) { + System.out.println("Updating " + patientName + "'s record: " + med + " - " + status); + // Save to DB + } + + public String generateMonthlyReport(String patientName) { + // Simulated report content + return "Monthly Report for " + patientName + ":\nMissed: MedA\nTaken: MedB"; + } +}