diff --git a/.gitignore b/.gitignore index cd24195..fa122a4 100644 --- a/.gitignore +++ b/.gitignore @@ -5,10 +5,7 @@ target/ .kotlin ### IntelliJ IDEA ### -.idea/modules.xml -.idea/jarRepositories.xml -.idea/compiler.xml -.idea/libraries/ +.idea/ *.iws *.iml *.ipr @@ -49,3 +46,10 @@ logs/ /gadm_410.gpkg /.postgresdata/ /peaks_worldwide.geojson + +### Coding Assistants ### +.codex/ +.aider* +.cursor/ +.roo/ +.windsurf/ diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 13566b8..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml deleted file mode 100644 index 06a2c34..0000000 --- a/.idea/dataSources.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - postgresql - true - org.postgresql.Driver - jdbc:postgresql://localhost:51826/testdb - - - - - - $ProjectFileDir$ - - - \ No newline at end of file diff --git a/.idea/data_source_mapping.xml b/.idea/data_source_mapping.xml deleted file mode 100644 index ed1c16b..0000000 --- a/.idea/data_source_mapping.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml deleted file mode 100644 index aa00ffa..0000000 --- a/.idea/encodings.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index ad4a613..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - IDE - - - - - - - - - \ No newline at end of file diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml deleted file mode 100644 index 27a4b8c..0000000 --- a/.idea/sqldialects.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1dd..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..475e649 --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,2 @@ +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.15/apache-maven-3.9.15-bin.zip diff --git a/.sdkmanrc b/.sdkmanrc new file mode 100644 index 0000000..f3b3756 --- /dev/null +++ b/.sdkmanrc @@ -0,0 +1,3 @@ +# Enable auto-env through the sdkman_auto_env config +# Add key=value pairs of SDKs to use below +java=17.0.9-tem diff --git a/mvnw b/mvnw new file mode 100755 index 0000000..bd8896b --- /dev/null +++ b/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 0000000..92450f9 --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/pom.xml b/pom.xml index 008ac47..8ecf1b2 100644 --- a/pom.xml +++ b/pom.xml @@ -23,7 +23,7 @@ UTF-8 17 0.12.3 - 2.0.3 + 2.0.5 @@ -170,15 +170,14 @@ org.testcontainers testcontainers-junit-jupiter - 2.0.2 + ${testcontainers.version} test - org.testcontainers testcontainers-postgresql - 2.0.1 + ${testcontainers.version} test @@ -193,4 +192,4 @@ - \ No newline at end of file + diff --git a/src/main/java/net/javahippie/fitpub/config/KomootSupport.java b/src/main/java/net/javahippie/fitpub/config/KomootSupport.java deleted file mode 100644 index db43c34..0000000 --- a/src/main/java/net/javahippie/fitpub/config/KomootSupport.java +++ /dev/null @@ -1,21 +0,0 @@ -package net.javahippie.fitpub.config; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -/** - * Central support flag for Komoot integration availability. - */ -@Component -public class KomootSupport { - - private final boolean enabled; - - public KomootSupport(@Value("${fitpub.komoot.enabled:false}") boolean enabled) { - this.enabled = enabled; - } - - public boolean isEnabled() { - return enabled; - } -} diff --git a/src/main/java/net/javahippie/fitpub/config/SecurityConfig.java b/src/main/java/net/javahippie/fitpub/config/SecurityConfig.java index aff2bae..3847ba4 100644 --- a/src/main/java/net/javahippie/fitpub/config/SecurityConfig.java +++ b/src/main/java/net/javahippie/fitpub/config/SecurityConfig.java @@ -87,7 +87,6 @@ public class SecurityConfig { // Protected view pages - require authentication .requestMatchers("/activities", "/activities/upload").authenticated() - .requestMatchers("/komoot-import").authenticated() .requestMatchers("/profile", "/profile/**", "/settings").authenticated() .requestMatchers("/notifications").authenticated() .requestMatchers("/analytics", "/analytics/**").authenticated() @@ -150,7 +149,6 @@ public class SecurityConfig { // Protected endpoints - Batch Import API .requestMatchers("/api/batch-import/**").authenticated() - .requestMatchers("/api/komoot-import/**").authenticated() // Protected endpoints - Privacy Zones API .requestMatchers("/api/privacy-zones/**").authenticated() diff --git a/src/main/java/net/javahippie/fitpub/controller/ActivityPubController.java b/src/main/java/net/javahippie/fitpub/controller/ActivityPubController.java index 4cf3717..c08e0ff 100644 --- a/src/main/java/net/javahippie/fitpub/controller/ActivityPubController.java +++ b/src/main/java/net/javahippie/fitpub/controller/ActivityPubController.java @@ -10,13 +10,14 @@ import net.javahippie.fitpub.model.activitypub.OrderedCollection; import net.javahippie.fitpub.model.entity.Activity; import net.javahippie.fitpub.model.entity.RemoteActor; import net.javahippie.fitpub.model.entity.User; -import net.javahippie.fitpub.repository.FollowRepository; import net.javahippie.fitpub.repository.ActivityRepository; +import net.javahippie.fitpub.repository.FollowRepository; import net.javahippie.fitpub.repository.UserRepository; import net.javahippie.fitpub.security.HttpSignatureValidator; import net.javahippie.fitpub.service.ActivityImageService; import net.javahippie.fitpub.service.FederationService; import net.javahippie.fitpub.service.InboxProcessor; +import net.javahippie.fitpub.service.WorkoutDataPayloadBuilder; import net.javahippie.fitpub.util.ActivityFormatter; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; @@ -29,6 +30,7 @@ import java.net.URI; import java.net.URISyntaxException; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.time.ZoneOffset; import java.util.*; import java.util.regex.Pattern; @@ -51,6 +53,7 @@ public class ActivityPubController { private final HttpSignatureValidator signatureValidator; private final FederationService federationService; private final ObjectMapper objectMapper; + private final WorkoutDataPayloadBuilder workoutDataPayloadBuilder; @Value("${fitpub.base-url}") private String baseUrl; @@ -436,9 +439,10 @@ public class ActivityPubController { noteObject.put("id", activityUri); noteObject.put("type", "Note"); noteObject.put("attributedTo", actorUri); - noteObject.put("published", activity.getCreatedAt().toString()); + noteObject.put("published", activity.getCreatedAt().atOffset(ZoneOffset.UTC).toInstant().toString()); noteObject.put("content", formatActivityContent(activity)); noteObject.put("url", activityUri); + noteObject.put("workoutData", workoutDataPayloadBuilder.build(activity)); // Audience — only PUBLIC activities reach this endpoint (the visibility // check above returned 403 for anything else), so audience is always diff --git a/src/main/java/net/javahippie/fitpub/controller/GlobalModelAttributes.java b/src/main/java/net/javahippie/fitpub/controller/GlobalModelAttributes.java deleted file mode 100644 index 38f631a..0000000 --- a/src/main/java/net/javahippie/fitpub/controller/GlobalModelAttributes.java +++ /dev/null @@ -1,21 +0,0 @@ -package net.javahippie.fitpub.controller; - -import lombok.RequiredArgsConstructor; -import net.javahippie.fitpub.config.KomootSupport; -import org.springframework.web.bind.annotation.ControllerAdvice; -import org.springframework.web.bind.annotation.ModelAttribute; - -/** - * Exposes global model attributes required by shared layouts. - */ -@ControllerAdvice -@RequiredArgsConstructor -public class GlobalModelAttributes { - - private final KomootSupport komootSupport; - - @ModelAttribute("komootSupportEnabled") - public boolean komootSupportEnabled() { - return komootSupport.isEnabled(); - } -} diff --git a/src/main/java/net/javahippie/fitpub/controller/KomootImportController.java b/src/main/java/net/javahippie/fitpub/controller/KomootImportController.java deleted file mode 100644 index 08e0928..0000000 --- a/src/main/java/net/javahippie/fitpub/controller/KomootImportController.java +++ /dev/null @@ -1,110 +0,0 @@ -package net.javahippie.fitpub.controller; - -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import net.javahippie.fitpub.config.KomootSupport; -import net.javahippie.fitpub.model.dto.KomootActivityImportRequest; -import net.javahippie.fitpub.model.dto.KomootActivitiesResponse; -import net.javahippie.fitpub.model.dto.KomootImportExecutionResponse; -import net.javahippie.fitpub.model.dto.KomootImportRequest; -import net.javahippie.fitpub.repository.UserRepository; -import net.javahippie.fitpub.service.KomootImportService; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.web.bind.MethodArgumentNotValidException; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import java.util.UUID; - -/** - * REST API for loading and importing Komoot activities. - */ -@RestController -@RequestMapping("/api/komoot-import") -@RequiredArgsConstructor -@Slf4j -public class KomootImportController { - - private final KomootSupport komootSupport; - private final KomootImportService komootImportService; - private final UserRepository userRepository; - - @PostMapping("/activities") - public ResponseEntity listActivities( - @Valid @RequestBody KomootImportRequest request, - Authentication authentication - ) { - ensureKomootSupportEnabled(); - - UUID fitPubUserId = userRepository.findByUsername(authentication.getName()) - .orElseThrow(() -> new IllegalArgumentException("Authenticated user not found")) - .getId(); - - log.info("User {} requested Komoot activity preview for Komoot ID {}", - authentication.getName(), request.getUserId()); - KomootActivitiesResponse response = komootImportService.fetchCompletedActivities(request, fitPubUserId); - return ResponseEntity.ok(response); - } - - @PostMapping("/activities/import") - public ResponseEntity importActivity( - @Valid @RequestBody KomootActivityImportRequest request, - Authentication authentication - ) { - ensureKomootSupportEnabled(); - - UUID fitPubUserId = userRepository.findByUsername(authentication.getName()) - .orElseThrow(() -> new IllegalArgumentException("Authenticated user not found")) - .getId(); - - log.info("User {} requested Komoot import for activity {}", - authentication.getName(), request.getActivityId()); - - KomootImportExecutionResponse response = komootImportService.importActivity( - request, - fitPubUserId - ); - return ResponseEntity.ok(response); - } - - private void ensureKomootSupportEnabled() { - if (!komootSupport.isEnabled()) { - throw new KomootSupportDisabledException(); - } - } - - @ExceptionHandler(IllegalArgumentException.class) - public ResponseEntity handleIllegalArgument(IllegalArgumentException e) { - return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage())); - } - - @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity handleValidation(MethodArgumentNotValidException e) { - String message = e.getBindingResult().getFieldErrors().stream() - .findFirst() - .map(error -> error.getDefaultMessage() != null ? error.getDefaultMessage() : "Invalid request") - .orElse("Invalid request"); - return ResponseEntity.badRequest().body(new ErrorResponse(message)); - } - - @ExceptionHandler(IllegalStateException.class) - public ResponseEntity handleIllegalState(IllegalStateException e) { - return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(new ErrorResponse(e.getMessage())); - } - - @ExceptionHandler(KomootSupportDisabledException.class) - public ResponseEntity handleKomootSupportDisabled(KomootSupportDisabledException e) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("Komoot support is disabled.")); - } - - record ErrorResponse(String error) {} - - static class KomootSupportDisabledException extends RuntimeException { - } -} diff --git a/src/main/java/net/javahippie/fitpub/controller/KomootImportViewController.java b/src/main/java/net/javahippie/fitpub/controller/KomootImportViewController.java deleted file mode 100644 index c5922b8..0000000 --- a/src/main/java/net/javahippie/fitpub/controller/KomootImportViewController.java +++ /dev/null @@ -1,36 +0,0 @@ -package net.javahippie.fitpub.controller; - -import lombok.RequiredArgsConstructor; -import net.javahippie.fitpub.config.KomootSupport; -import org.springframework.stereotype.Controller; -import org.springframework.ui.Model; -import org.springframework.web.bind.annotation.GetMapping; - -import java.time.LocalDate; - -/** - * Serves the Komoot import preview page. - */ -@Controller -@RequiredArgsConstructor -public class KomootImportViewController { - - private final KomootSupport komootSupport; - - @GetMapping("/komoot-import") - public String komootImportPage(Model model) { - if (!komootSupport.isEnabled()) { - model.addAttribute("pageTitle", "Komoot Import Unavailable"); - model.addAttribute("featureName", "Komoot Import"); - model.addAttribute("featureMessage", "Komoot support is currently disabled on this instance."); - model.addAttribute("featureIcon", "bi bi-signpost-split text-secondary"); - return "feature-disabled"; - } - - LocalDate today = LocalDate.now(); - model.addAttribute("pageTitle", "Komoot Import"); - model.addAttribute("defaultStartDate", today.withDayOfYear(1)); - model.addAttribute("defaultEndDate", today); - return "activities/komoot"; - } -} diff --git a/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitiesResponse.java b/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitiesResponse.java deleted file mode 100644 index 296acb3..0000000 --- a/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitiesResponse.java +++ /dev/null @@ -1,22 +0,0 @@ -package net.javahippie.fitpub.model.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.util.List; - -/** - * Response payload for the Komoot import preview. - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class KomootActivitiesResponse { - - private String userId; - private int totalCount; - private List activities; -} diff --git a/src/main/java/net/javahippie/fitpub/model/dto/KomootActivityImportRequest.java b/src/main/java/net/javahippie/fitpub/model/dto/KomootActivityImportRequest.java deleted file mode 100644 index 6d9b7eb..0000000 --- a/src/main/java/net/javahippie/fitpub/model/dto/KomootActivityImportRequest.java +++ /dev/null @@ -1,36 +0,0 @@ -package net.javahippie.fitpub.model.dto; - -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Pattern; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -/** - * Request payload for importing one specific Komoot activity. - * - *

The password is only used for the current request and is never persisted.

- */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class KomootActivityImportRequest { - - @NotBlank - @Email - private String email; - - @NotBlank - private String password; - - @NotBlank - @Pattern(regexp = "\\d+", message = "Komoot user ID must contain digits only") - private String userId; - - @NotNull - private Long activityId; -} diff --git a/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitySummaryDTO.java b/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitySummaryDTO.java deleted file mode 100644 index 3bdf613..0000000 --- a/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitySummaryDTO.java +++ /dev/null @@ -1,33 +0,0 @@ -package net.javahippie.fitpub.model.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.time.OffsetDateTime; -import java.util.UUID; - -/** - * Reduced activity representation returned by the Komoot import preview. - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class KomootActivitySummaryDTO { - - private long id; - private String name; - private String sport; - private String mappedActivityType; - private String status; - private String type; - private OffsetDateTime date; - private Double distanceMeters; - private Integer durationSeconds; - private Integer timeInMotionSeconds; - private Double elevationUp; - private boolean imported; - private UUID fitPubActivityId; -} diff --git a/src/main/java/net/javahippie/fitpub/model/dto/KomootImportExecutionResponse.java b/src/main/java/net/javahippie/fitpub/model/dto/KomootImportExecutionResponse.java deleted file mode 100644 index abb31e8..0000000 --- a/src/main/java/net/javahippie/fitpub/model/dto/KomootImportExecutionResponse.java +++ /dev/null @@ -1,23 +0,0 @@ -package net.javahippie.fitpub.model.dto; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.util.UUID; - -/** - * Response for importing exactly one Komoot activity into FitPub. - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class KomootImportExecutionResponse { - - private UUID importedActivityId; - private Long importedKomootActivityId; - private String status; - private String message; -} diff --git a/src/main/java/net/javahippie/fitpub/model/dto/KomootImportRequest.java b/src/main/java/net/javahippie/fitpub/model/dto/KomootImportRequest.java deleted file mode 100644 index 5c58345..0000000 --- a/src/main/java/net/javahippie/fitpub/model/dto/KomootImportRequest.java +++ /dev/null @@ -1,66 +0,0 @@ -package net.javahippie.fitpub.model.dto; - -import jakarta.validation.constraints.AssertTrue; -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Pattern; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.time.LocalDate; - -/** - * Request payload for fetching completed activities from Komoot. - * - *

The password is only used for the current request and is never persisted.

- */ -@Data -@Builder -@NoArgsConstructor -public class KomootImportRequest { - - @NotBlank - @Email - private String email; - - @NotBlank - private String password; - - @NotBlank - @Pattern(regexp = "\\d+", message = "Komoot user ID must contain digits only") - private String userId; - - private LocalDate startDate; - - private LocalDate endDate; - - public KomootImportRequest(String email, String password, String userId, LocalDate startDate, LocalDate endDate) { - this.email = email; - this.password = password; - this.userId = userId; - this.startDate = startDate; - this.endDate = endDate; - validateDateRange(); - } - - @AssertTrue(message = "Start date and end date must either both be set or both be empty, and start date must be before or equal to end date.") - public boolean isDateRangeConsistent() { - try { - validateDateRange(); - return true; - } catch (IllegalArgumentException e) { - return false; - } - } - - private void validateDateRange() { - boolean onlyOneDateProvided = (startDate == null) != (endDate == null); - if (onlyOneDateProvided) { - throw new IllegalArgumentException("Start date and end date must either both be set or both be empty."); - } - if (startDate != null && startDate.isAfter(endDate)) { - throw new IllegalArgumentException("Start date must be before or equal to end date."); - } - } -} diff --git a/src/main/java/net/javahippie/fitpub/model/entity/KomootImport.java b/src/main/java/net/javahippie/fitpub/model/entity/KomootImport.java deleted file mode 100644 index d922504..0000000 --- a/src/main/java/net/javahippie/fitpub/model/entity/KomootImport.java +++ /dev/null @@ -1,46 +0,0 @@ -package net.javahippie.fitpub.model.entity; - -import jakarta.persistence.*; -import lombok.*; -import org.hibernate.annotations.CreationTimestamp; - -import java.time.LocalDateTime; -import java.util.UUID; - -/** - * Internal link between a FitPub activity and its originating Komoot activity. - */ -@Entity -@Table(name = "komoot_imports", - uniqueConstraints = { - @UniqueConstraint(name = "uk_komoot_imports_activity_id", columnNames = "activity_id"), - @UniqueConstraint(name = "uk_komoot_imports_user_komoot_activity_id", columnNames = {"user_id", "komoot_activity_id"}) - }, - indexes = { - @Index(name = "idx_komoot_imports_user_id", columnList = "user_id"), - @Index(name = "idx_komoot_imports_komoot_activity_id", columnList = "komoot_activity_id") - }) -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class KomootImport { - - @Id - @GeneratedValue(strategy = GenerationType.UUID) - private UUID id; - - @Column(name = "user_id", nullable = false) - private UUID userId; - - @Column(name = "activity_id", nullable = false) - private UUID activityId; - - @Column(name = "komoot_activity_id", nullable = false) - private Long komootActivityId; - - @CreationTimestamp - @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; -} diff --git a/src/main/java/net/javahippie/fitpub/model/entity/RemoteActivity.java b/src/main/java/net/javahippie/fitpub/model/entity/RemoteActivity.java index 1fd8105..a3b74da 100644 --- a/src/main/java/net/javahippie/fitpub/model/entity/RemoteActivity.java +++ b/src/main/java/net/javahippie/fitpub/model/entity/RemoteActivity.java @@ -9,6 +9,7 @@ import lombok.NoArgsConstructor; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.Type; import org.hibernate.annotations.UpdateTimestamp; +import org.locationtech.jts.geom.LineString; import java.time.Instant; import java.time.LocalDateTime; @@ -137,6 +138,12 @@ public class RemoteActivity { @Column(name = "track_geojson_url", length = 512) private String trackGeojsonUrl; + /** + * Simplified remote route geometry for local map rendering. + */ + @Column(name = "simplified_track", columnDefinition = "geometry(LineString, 4326)") + private LineString simplifiedTrack; + /** * Visibility level of the activity. */ diff --git a/src/main/java/net/javahippie/fitpub/repository/KomootImportRepository.java b/src/main/java/net/javahippie/fitpub/repository/KomootImportRepository.java deleted file mode 100644 index aaf4ea2..0000000 --- a/src/main/java/net/javahippie/fitpub/repository/KomootImportRepository.java +++ /dev/null @@ -1,33 +0,0 @@ -package net.javahippie.fitpub.repository; - -import net.javahippie.fitpub.model.entity.KomootImport; -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; -import java.util.UUID; - -@Repository -public interface KomootImportRepository extends JpaRepository { - - interface KomootImportLinkProjection { - UUID getActivityId(); - Long getKomootActivityId(); - } - - @Query("SELECT k.komootActivityId FROM KomootImport k WHERE k.userId = :userId") - List findImportedKomootActivityIdsByUserId(@Param("userId") UUID userId); - - Optional findByUserIdAndKomootActivityId(UUID userId, Long komootActivityId); - - @Query("SELECT k.activityId AS activityId, k.komootActivityId AS komootActivityId " + - "FROM KomootImport k " + - "WHERE k.userId = :userId AND k.komootActivityId IN :komootActivityIds") - List findKomootImportLinksByUserIdAndKomootActivityIdIn( - @Param("userId") UUID userId, - @Param("komootActivityIds") List komootActivityIds - ); -} diff --git a/src/main/java/net/javahippie/fitpub/service/ActivityPostProcessingService.java b/src/main/java/net/javahippie/fitpub/service/ActivityPostProcessingService.java index 8a582bc..cad2bc9 100644 --- a/src/main/java/net/javahippie/fitpub/service/ActivityPostProcessingService.java +++ b/src/main/java/net/javahippie/fitpub/service/ActivityPostProcessingService.java @@ -12,6 +12,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; +import java.time.ZoneOffset; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -38,6 +39,7 @@ public class ActivityPostProcessingService { private final ActivityImageService activityImageService; private final ActivityRepository activityRepository; private final UserRepository userRepository; + private final WorkoutDataPayloadBuilder workoutDataPayloadBuilder; @Value("${fitpub.base-url}") private String baseUrl; @@ -199,9 +201,10 @@ public class ActivityPostProcessingService { noteObject.put("id", activityUri); noteObject.put("type", "Note"); noteObject.put("attributedTo", actorUri); - noteObject.put("published", activity.getCreatedAt().toString()); + noteObject.put("published", activity.getCreatedAt().atOffset(ZoneOffset.UTC).toInstant().toString()); noteObject.put("content", formatActivityContent(activity)); noteObject.put("url", baseUrl + "/activities/" + activity.getId()); + noteObject.put("workoutData", workoutDataPayloadBuilder.build(activity)); // Extract hashtags from user text and add as tags List hashtags = extractHashtags(activity); diff --git a/src/main/java/net/javahippie/fitpub/service/InboxProcessor.java b/src/main/java/net/javahippie/fitpub/service/InboxProcessor.java index 8dff712..27efc1e 100644 --- a/src/main/java/net/javahippie/fitpub/service/InboxProcessor.java +++ b/src/main/java/net/javahippie/fitpub/service/InboxProcessor.java @@ -16,11 +16,20 @@ import net.javahippie.fitpub.repository.CommentRepository; import net.javahippie.fitpub.repository.FollowRepository; import net.javahippie.fitpub.repository.LikeRepository; import net.javahippie.fitpub.repository.UserRepository; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.geom.PrecisionModel; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.Instant; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.format.DateTimeParseException; import java.util.Map; import java.util.UUID; @@ -31,6 +40,9 @@ import java.util.UUID; @RequiredArgsConstructor @Slf4j public class InboxProcessor { + private static final int GEOMETRY_SRID = 4326; + private static final GeometryFactory GEOMETRY_FACTORY = + new GeometryFactory(new PrecisionModel(), GEOMETRY_SRID); private final UserRepository userRepository; private final FollowRepository followRepository; @@ -411,15 +423,18 @@ public class InboxProcessor { // Parse published timestamp String publishedStr = (String) noteObject.get("published"); - Instant publishedAt = publishedStr != null ? Instant.parse(publishedStr) : Instant.now(); + Instant publishedAt = parsePublishedAt(publishedStr); // Build RemoteActivity entity RemoteActivity remoteActivity = RemoteActivity.builder() .activityUri(activityUri) .remoteActorUri(actor) - .activityType((String) workoutData.get("activityType")) + .activityType(stringValue(workoutData.get("activityType"))) .title((String) noteObject.getOrDefault("name", noteObject.getOrDefault("summary", "Untitled Activity"))) - .description(stripHtml((String) noteObject.get("content"))) + .description(firstNonBlank( + stringValue(workoutData.get("description")), + stripHtml((String) noteObject.get("content")) + )) .publishedAt(publishedAt) .totalDistance(parseLong(workoutData.get("distance"))) .totalDurationSeconds(parseDurationSeconds((String) workoutData.get("duration"))) @@ -431,6 +446,7 @@ public class InboxProcessor { .calories(parseInteger(workoutData.get("calories"))) .mapImageUrl(attachments.get("mapImage")) .trackGeojsonUrl(attachments.get("trackGeojson")) + .simplifiedTrack(extractRoute(workoutData)) .visibility(visibility) .activityPubObject(serializeToJson(noteObject)) .build(); @@ -705,6 +721,88 @@ public class InboxProcessor { return workoutData; } + private String stringValue(Object value) { + return value != null ? String.valueOf(value) : null; + } + + private LineString extractRoute(Map workoutData) { + Object routeObj = workoutData.get("route"); + if (!(routeObj instanceof Map routeMap)) { + return null; + } + + Object featuresObj = routeMap.get("features"); + if (!(featuresObj instanceof java.util.List features) || features.isEmpty()) { + return null; + } + + for (Object featureObj : features) { + if (!(featureObj instanceof Map featureMap)) { + continue; + } + + Object geometryObj = featureMap.get("geometry"); + if (!(geometryObj instanceof Map geometryMap)) { + continue; + } + + if (!"LineString".equals(geometryMap.get("type"))) { + continue; + } + + LineString lineString = parseLineStringCoordinates(geometryMap.get("coordinates")); + if (lineString != null) { + return lineString; + } + } + + return null; + } + + private LineString parseLineStringCoordinates(Object coordinatesObj) { + if (!(coordinatesObj instanceof java.util.List coordinateList) || coordinateList.size() < 2) { + return null; + } + + java.util.List coordinates = new java.util.ArrayList<>(); + for (Object coordinateObj : coordinateList) { + Coordinate coordinate = parseCoordinate(coordinateObj); + if (coordinate == null) { + return null; + } + coordinates.add(coordinate); + } + + if (coordinates.size() < 2) { + return null; + } + + return GEOMETRY_FACTORY.createLineString(coordinates.toArray(new Coordinate[0])); + } + + private Coordinate parseCoordinate(Object coordinateObj) { + if (!(coordinateObj instanceof java.util.List coordinateValues) || coordinateValues.size() < 2) { + return null; + } + + Double longitude = parseDouble(coordinateValues.get(0)); + Double latitude = parseDouble(coordinateValues.get(1)); + if (longitude == null || latitude == null) { + return null; + } + + return new Coordinate(longitude, latitude); + } + + private String firstNonBlank(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } + /** * Extract attachment URLs (map image, GeoJSON) from a Note object. */ @@ -824,6 +922,44 @@ public class InboxProcessor { } } + /** + * Parse ActivityPub published timestamps. + * + *

Preferred input is a full ISO-8601 instant with timezone/offset. Some + * remote implementations still send zoneless timestamps, so we accept those + * as a compatibility fallback and interpret them as UTC. + */ + private Instant parsePublishedAt(String publishedStr) { + if (publishedStr == null || publishedStr.isBlank()) { + return Instant.now(); + } + + try { + return Instant.parse(publishedStr); + } catch (DateTimeParseException ignored) { + // Fall through to compatibility parsers below. + } + + try { + return OffsetDateTime.parse(publishedStr).toInstant(); + } catch (DateTimeParseException ignored) { + // Fall through to compatibility parsers below. + } + + try { + return ZonedDateTime.parse(publishedStr).toInstant(); + } catch (DateTimeParseException ignored) { + // Fall through to compatibility parsers below. + } + + try { + return LocalDateTime.parse(publishedStr).atOffset(ZoneOffset.UTC).toInstant(); + } catch (DateTimeParseException e) { + log.warn("Failed to parse published timestamp: {}", publishedStr, e); + return Instant.now(); + } + } + /** * Serialize object to JSON string. */ diff --git a/src/main/java/net/javahippie/fitpub/service/KomootImportService.java b/src/main/java/net/javahippie/fitpub/service/KomootImportService.java deleted file mode 100644 index cce0493..0000000 --- a/src/main/java/net/javahippie/fitpub/service/KomootImportService.java +++ /dev/null @@ -1,478 +0,0 @@ -package net.javahippie.fitpub.service; - -import com.fasterxml.jackson.databind.JsonNode; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import net.javahippie.fitpub.model.dto.KomootActivityImportRequest; -import net.javahippie.fitpub.model.dto.KomootActivitiesResponse; -import net.javahippie.fitpub.model.dto.KomootActivitySummaryDTO; -import net.javahippie.fitpub.model.dto.KomootImportExecutionResponse; -import net.javahippie.fitpub.model.dto.KomootImportRequest; -import net.javahippie.fitpub.model.entity.Activity; -import net.javahippie.fitpub.model.entity.KomootImport; -import net.javahippie.fitpub.repository.ActivityRepository; -import net.javahippie.fitpub.repository.KomootImportRepository; -import net.javahippie.fitpub.util.ByteArrayMultipartFile; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Service; -import org.springframework.web.client.HttpClientErrorException; -import org.springframework.web.client.RestClientException; -import org.springframework.web.client.RestTemplate; -import org.springframework.web.util.UriComponentsBuilder; - -import java.net.URI; -import java.nio.charset.StandardCharsets; -import java.time.LocalDate; -import java.time.LocalTime; -import java.time.OffsetDateTime; -import java.time.ZoneId; -import java.time.ZoneOffset; -import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.Base64; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.UUID; - -/** - * Fetches a temporary preview of completed Komoot activities for an authenticated FitPub user. - * - *

Komoot does not expose a public API for this use case. This service currently talks to the - * same web API endpoints used by the Komoot website and therefore depends on their current - * behavior.

- */ -@Service -@RequiredArgsConstructor -@Slf4j -public class KomootImportService { - - private static final int PAGE_SIZE = 100; - private static final String KOMOOT_LANGUAGE = "en"; - private static final DateTimeFormatter KOMOOT_DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSX"); - private final RestTemplate restTemplate; - private final ActivityRepository activityRepository; - private final KomootImportRepository komootImportRepository; - private final ActivityFileService activityFileService; - private final ActivityPostProcessingService activityPostProcessingService; - - @Value("${fitpub.komoot.base-url:https://www.komoot.com}") - private String komootBaseUrl; - - @Value("${fitpub.komoot.paginated-request-delay-ms:1000}") - private long paginatedRequestDelayMillis; - - @Value("${fitpub.komoot.detail-to-gpx-delay-ms:500}") - private long detailToGpxDelayMillis; - - @Value("${fitpub.komoot.activity-import-delay-ms:3000}") - private long activityImportDelayMillis; - - public KomootActivitiesResponse fetchCompletedActivities(KomootImportRequest request, UUID fitPubUserId) { - List activities = new ArrayList<>(); - Set importedKomootActivityIds = new HashSet<>( - komootImportRepository.findImportedKomootActivityIdsByUserId(fitPubUserId)); - Map fitPubActivityIdsByKomootId = new HashMap<>(); - if (!importedKomootActivityIds.isEmpty()) { - komootImportRepository.findKomootImportLinksByUserIdAndKomootActivityIdIn( - fitPubUserId, - new ArrayList<>(importedKomootActivityIds) - ) - .forEach(link -> fitPubActivityIdsByKomootId.put(link.getKomootActivityId(), link.getActivityId())); - } - - URI nextUri = buildInitialUri(request); - HttpEntity httpEntity = new HttpEntity<>(buildHeaders(request.getEmail(), request.getPassword())); - - try { - while (nextUri != null) { - ResponseEntity response = restTemplate.exchange( - nextUri, HttpMethod.GET, httpEntity, JsonNode.class); - - JsonNode root = response.getBody(); - if (root == null) { - throw new IllegalStateException("Komoot returned an empty response body."); - } - extractActivities(root, activities, importedKomootActivityIds, fitPubActivityIdsByKomootId); - nextUri = extractNextUri(root); - if (nextUri != null) { - pauseBeforeNextPageRequest(); - } - } - } catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) { - throw new IllegalArgumentException("Komoot login failed. Check email, password and Komoot ID.", e); - } catch (HttpClientErrorException.NotFound e) { - throw new IllegalArgumentException("Komoot user or activities endpoint not found for the given Komoot ID.", e); - } catch (RestClientException e) { - throw new IllegalStateException("Failed to reach Komoot. The remote service may be unavailable.", e); - } catch (Exception e) { - throw new IllegalStateException("Failed to parse Komoot activity list.", e); - } - - log.info("Fetched {} completed Komoot activities for user ID {}", activities.size(), request.getUserId()); - return new KomootActivitiesResponse(request.getUserId(), activities.size(), activities); - } - - void pauseBeforeNextPageRequest() { - pause(paginatedRequestDelayMillis, "Interrupted while throttling paginated Komoot requests."); - } - - public KomootImportExecutionResponse importActivity(KomootActivityImportRequest request, UUID fitPubUserId) { - KomootImport existingImport = komootImportRepository.findByUserIdAndKomootActivityId(fitPubUserId, request.getActivityId()).orElse(null); - if (existingImport != null) { - return new KomootImportExecutionResponse( - existingImport.getActivityId(), - request.getActivityId(), - "SKIPPED_ALREADY_IMPORTED", - "Komoot activity " + request.getActivityId() + " was already imported." - ); - } - - JsonNode details = fetchActivityDetails(request.getEmail(), request.getPassword(), request.getActivityId()); - pauseBetweenDetailAndGpxRequest(); - byte[] gpxData = fetchActivityGpx(request.getEmail(), request.getPassword(), request.getActivityId()); - - ByteArrayMultipartFile gpxFile = new ByteArrayMultipartFile( - "file", - "komoot-" + request.getActivityId() + ".gpx", - "application/gpx+xml", - gpxData - ); - - Activity.Visibility mappedVisibility = mapVisibility(nullableText(details, "status")); - String mappedTitle = firstNonBlank(nullableText(details, "name"), null, "Komoot Activity " + request.getActivityId()); - String mappedDescription = nullableText(details, "description"); - Activity.ActivityType mappedActivityType = mapKomootSportToActivityType(nullableText(details, "sport")); - - Activity importedActivity = activityFileService.processActivityFile( - gpxFile, - fitPubUserId, - mappedTitle, - mappedDescription, - mappedVisibility - ); - - importedActivity.setTitle(mappedTitle); - importedActivity.setDescription(mappedDescription); - importedActivity.setVisibility(mappedVisibility); - importedActivity.setActivityType(mappedActivityType); - - importedActivity = activityRepository.save(importedActivity); - komootImportRepository.save(KomootImport.builder() - .userId(fitPubUserId) - .activityId(importedActivity.getId()) - .komootActivityId(request.getActivityId()) - .build()); - activityPostProcessingService.processActivityAsync(importedActivity.getId(), fitPubUserId); - - log.info( - "Imported Komoot activity {} into FitPub activity {} with visibility {} and type {}", - request.getActivityId(), - importedActivity.getId(), - importedActivity.getVisibility(), - importedActivity.getActivityType() - ); - - pauseAfterActivityImport(); - - return new KomootImportExecutionResponse( - importedActivity.getId(), - request.getActivityId(), - "IMPORTED", - "Imported Komoot activity " + request.getActivityId() + " into FitPub activity " + importedActivity.getId() - ); - } - - void pauseBetweenDetailAndGpxRequest() { - pause(detailToGpxDelayMillis, "Interrupted while throttling Komoot detail and GPX requests."); - } - - void pauseAfterActivityImport() { - pause(activityImportDelayMillis, "Interrupted while throttling Komoot activity imports."); - } - - private URI buildInitialUri(KomootImportRequest request) { - String normalizedBaseUrl = komootBaseUrl.endsWith("/") ? komootBaseUrl.substring(0, komootBaseUrl.length() - 1) : komootBaseUrl; - UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(normalizedBaseUrl + "/api/v007/users/" + request.getUserId() + "/tours/") - .queryParam("type", "tour_recorded") - .queryParam("sort_field", "date") - .queryParam("sort_direction", "desc") - .queryParam("limit", PAGE_SIZE); - - if (request.getStartDate() != null && request.getEndDate() != null) { - builder.queryParam("start_date", formatKomootStartDate(request.getStartDate())) - .queryParam("end_date", formatKomootEndDate(request.getEndDate())); - } else { - builder.queryParam("status", "private") - .queryParam("name", "") - .queryParam("hl", KOMOOT_LANGUAGE) - .queryParam("page", 0); - } - - return builder.build().toUri(); - } - - private URI buildDetailUri(long activityId) { - String normalizedBaseUrl = komootBaseUrl.endsWith("/") ? komootBaseUrl.substring(0, komootBaseUrl.length() - 1) : komootBaseUrl; - return UriComponentsBuilder.fromUriString(normalizedBaseUrl + "/api/v007/tours/" + activityId) - .queryParam("hl", KOMOOT_LANGUAGE) - .build() - .toUri(); - } - - private List buildGpxCandidateUris(long activityId) { - String normalizedBaseUrl = komootBaseUrl.endsWith("/") ? komootBaseUrl.substring(0, komootBaseUrl.length() - 1) : komootBaseUrl; - String apiBaseUrl = normalizedBaseUrl.replace("://www.komoot.com", "://api.komoot.de"); - - return List.of( - URI.create(normalizedBaseUrl + "/api/v007/tours/" + activityId + ".gpx"), - URI.create(apiBaseUrl + "/v007/tours/" + activityId + ".gpx"), - URI.create(normalizedBaseUrl + "/tour/" + activityId + ".gpx") - ); - } - - private HttpHeaders buildHeaders(String email, String password) { - HttpHeaders headers = new HttpHeaders(); - headers.setAccept(List.of(MediaType.parseMediaType("application/hal+json"), MediaType.APPLICATION_JSON)); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.setAcceptLanguageAsLocales(List.of(java.util.Locale.ENGLISH)); - headers.set(HttpHeaders.USER_AGENT, "FitPub Komoot Import"); - headers.set(HttpHeaders.AUTHORIZATION, basicAuth(email, password)); - return headers; - } - - private HttpHeaders buildGpxHeaders(String email, String password) { - HttpHeaders headers = buildHeaders(email, password); - headers.setAccept(List.of( - MediaType.parseMediaType("application/gpx+xml"), - MediaType.APPLICATION_XML, - MediaType.TEXT_XML - )); - return headers; - } - - private String basicAuth(String email, String password) { - String credentials = email + ":" + password; - String encoded = Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); - return "Basic " + encoded; - } - - private void extractActivities( - JsonNode root, - List activities, - Set importedKomootActivityIds, - Map fitPubActivityIdsByKomootId - ) { - JsonNode tours = root.path("_embedded").path("tours"); - if (!tours.isArray()) { - return; - } - - for (JsonNode tour : tours) { - long activityId = tour.path("id").asLong(); - activities.add(new KomootActivitySummaryDTO( - activityId, - nullableText(tour, "name"), - nullableText(tour, "sport"), - mapKomootSportToActivityType(nullableText(tour, "sport")).name(), - nullableText(tour, "status"), - nullableText(tour, "type"), - parseDate(tour.path("date").asText(null)), - nullableDouble(tour, "distance"), - nullableInteger(tour, "duration"), - nullableInteger(tour, "time_in_motion"), - nullableDouble(tour, "elevation_up"), - importedKomootActivityIds.contains(activityId), - fitPubActivityIdsByKomootId.get(activityId) - )); - } - } - - private JsonNode fetchActivityDetails(String email, String password, long activityId) { - try { - ResponseEntity response = restTemplate.exchange( - buildDetailUri(activityId), - HttpMethod.GET, - new HttpEntity<>(buildHeaders(email, password)), - JsonNode.class - ); - - JsonNode body = response.getBody(); - if (body == null) { - throw new IllegalStateException("Komoot returned an empty activity detail response."); - } - return body; - } catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) { - throw new IllegalArgumentException("Komoot login failed while loading activity details.", e); - } catch (HttpClientErrorException.NotFound e) { - throw new IllegalArgumentException("Komoot activity details could not be found.", e); - } catch (RestClientException e) { - throw new IllegalStateException("Failed to reach Komoot while loading activity details.", e); - } - } - - private byte[] fetchActivityGpx(String email, String password, long activityId) { - HttpEntity httpEntity = new HttpEntity<>(buildGpxHeaders(email, password)); - List candidateUris = buildGpxCandidateUris(activityId); - Exception lastException = null; - - for (URI candidateUri : candidateUris) { - try { - byte[] body = restTemplate.execute( - candidateUri, - HttpMethod.GET, - request -> request.getHeaders().putAll(httpEntity.getHeaders()), - response -> { - if (response.getBody() == null) { - return null; - } - return response.getBody().readAllBytes(); - } - ); - if (body == null || body.length == 0) { - throw new IllegalStateException("Komoot returned an empty GPX response."); - } - - String gpxText = new String(body, StandardCharsets.UTF_8); - if (!gpxText.contains(" Activity.Visibility.PUBLIC; - case "friends", "followers", "close_friends" -> Activity.Visibility.FOLLOWERS; - default -> Activity.Visibility.PRIVATE; - }; - } - - private Activity.ActivityType mapKomootSportToActivityType(String komootSport) { - if (komootSport == null || komootSport.isBlank()) { - return Activity.ActivityType.OTHER; - } - - return switch (komootSport.toLowerCase(java.util.Locale.ROOT)) { - case "hike" -> Activity.ActivityType.HIKE; - case "walk" -> Activity.ActivityType.WALK; - case "run", "trailrunning", "jogging" -> Activity.ActivityType.RUN; - case "touringbicycle", "road_bike", "racebike", "bike", "bicycle", "gravel", "mtb", "mtb_easy", "mtb_advanced", "ebike" -> - Activity.ActivityType.RIDE; - case "alpine_ski" -> Activity.ActivityType.ALPINE_SKI; - case "backcountry_ski" -> Activity.ActivityType.BACKCOUNTRY_SKI; - case "nordic_ski", "cross_country_ski" -> Activity.ActivityType.NORDIC_SKI; - case "snowboard" -> Activity.ActivityType.SNOWBOARD; - case "swim" -> Activity.ActivityType.SWIM; - case "rowing" -> Activity.ActivityType.ROWING; - case "kayak", "kayaking" -> Activity.ActivityType.KAYAKING; - case "canoe", "canoeing" -> Activity.ActivityType.CANOEING; - case "inline_skate", "inline_skating" -> Activity.ActivityType.INLINE_SKATING; - case "rock_climbing" -> Activity.ActivityType.ROCK_CLIMBING; - case "mountaineering" -> Activity.ActivityType.MOUNTAINEERING; - case "yoga" -> Activity.ActivityType.YOGA; - case "workout", "gym" -> Activity.ActivityType.WORKOUT; - default -> Activity.ActivityType.OTHER; - }; - } - - private String firstNonBlank(String first, String second, String fallback) { - if (first != null && !first.isBlank()) { - return first; - } - if (second != null && !second.isBlank()) { - return second; - } - return fallback; - } - - private URI extractNextUri(JsonNode root) { - String nextHref = root.path("_links").path("next").path("href").asText(null); - if (nextHref == null || nextHref.isBlank()) { - return null; - } - - if (nextHref.startsWith("http://") || nextHref.startsWith("https://")) { - return URI.create(nextHref); - } - - String normalizedBaseUrl = komootBaseUrl.endsWith("/") ? komootBaseUrl.substring(0, komootBaseUrl.length() - 1) : komootBaseUrl; - String normalizedNextHref = nextHref.startsWith("/") ? nextHref : "/" + nextHref; - return URI.create(normalizedBaseUrl + normalizedNextHref); - } - - private OffsetDateTime parseDate(String value) { - if (value == null || value.isBlank()) { - return null; - } - return OffsetDateTime.parse(value); - } - - private String nullableText(JsonNode node, String field) { - JsonNode value = node.get(field); - return value == null || value.isNull() ? null : value.asText(); - } - - private Double nullableDouble(JsonNode node, String field) { - JsonNode value = node.get(field); - return value == null || value.isNull() ? null : value.asDouble(); - } - - private Integer nullableInteger(JsonNode node, String field) { - JsonNode value = node.get(field); - return value == null || value.isNull() ? null : value.asInt(); - } - - private void pause(long delayMillis, String interruptedMessage) { - if (delayMillis <= 0) { - return; - } - - try { - Thread.sleep(delayMillis); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IllegalStateException(interruptedMessage, e); - } - } -} diff --git a/src/main/java/net/javahippie/fitpub/service/WorkoutDataPayloadBuilder.java b/src/main/java/net/javahippie/fitpub/service/WorkoutDataPayloadBuilder.java new file mode 100644 index 0000000..dd8752d --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/service/WorkoutDataPayloadBuilder.java @@ -0,0 +1,86 @@ +package net.javahippie.fitpub.service; + +import lombok.RequiredArgsConstructor; +import net.javahippie.fitpub.model.dto.ActivityDTO; +import net.javahippie.fitpub.model.entity.Activity; +import net.javahippie.fitpub.model.entity.ActivityMetrics; +import net.javahippie.fitpub.model.entity.PrivacyZone; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Builds the proprietary workoutData payload for outbound ActivityPub Notes. + */ +@Service +@RequiredArgsConstructor +public class WorkoutDataPayloadBuilder { + + private final PrivacyZoneService privacyZoneService; + private final TrackPrivacyFilter trackPrivacyFilter; + + public Map build(Activity activity) { + Map workoutData = new HashMap<>(); + workoutData.put("activityType", activity.getActivityType().name()); + + if (activity.getDescription() != null && !activity.getDescription().isBlank()) { + workoutData.put("description", activity.getDescription()); + } + if (activity.getTotalDistance() != null) { + workoutData.put("distance", activity.getTotalDistance().longValue()); + } + if (activity.getTotalDurationSeconds() != null) { + workoutData.put("duration", Duration.ofSeconds(activity.getTotalDurationSeconds()).toString()); + } + if (activity.getElevationGain() != null) { + workoutData.put("elevationGain", activity.getElevationGain().intValue()); + } + + ActivityMetrics metrics = activity.getMetrics(); + if (metrics != null) { + if (metrics.getAveragePaceSeconds() != null) { + workoutData.put("averagePace", Duration.ofSeconds(metrics.getAveragePaceSeconds()).toString()); + } + if (metrics.getAverageHeartRate() != null) { + workoutData.put("averageHeartRate", metrics.getAverageHeartRate()); + } + if (metrics.getAverageSpeed() != null) { + workoutData.put("averageSpeed", metrics.getAverageSpeed().doubleValue()); + } + if (metrics.getMaxSpeed() != null) { + workoutData.put("maxSpeed", metrics.getMaxSpeed().doubleValue()); + } + if (metrics.getCalories() != null) { + workoutData.put("calories", metrics.getCalories()); + } + } + + Map route = buildRoutePayload(activity); + if (route != null) { + workoutData.put("route", route); + } + + return workoutData; + } + + private Map buildRoutePayload(Activity activity) { + List privacyZones = privacyZoneService.getActivePrivacyZones(activity.getUserId()); + ActivityDTO dto = ActivityDTO.fromEntityWithFiltering(activity, null, privacyZones, trackPrivacyFilter); + + if (dto.getSimplifiedTrack() == null) { + return null; + } + + Map feature = new HashMap<>(); + feature.put("type", "Feature"); + feature.put("geometry", dto.getSimplifiedTrack()); + + Map featureCollection = new HashMap<>(); + featureCollection.put("type", "FeatureCollection"); + featureCollection.put("features", List.of(feature)); + return featureCollection; + } +} diff --git a/src/main/java/net/javahippie/fitpub/util/ActivityFormatter.java b/src/main/java/net/javahippie/fitpub/util/ActivityFormatter.java index 26e4f32..0b32b3d 100644 --- a/src/main/java/net/javahippie/fitpub/util/ActivityFormatter.java +++ b/src/main/java/net/javahippie/fitpub/util/ActivityFormatter.java @@ -98,6 +98,10 @@ public class ActivityFormatter { * */ private static LocalDateTime getUtcDateTimeInZone(LocalDateTime utcDateTime, String timezone) { + if (timezone == null || timezone.isBlank()) { + return utcDateTime; + } + try { return utcDateTime.atZone(ZoneOffset.UTC) .withZoneSameInstant(ZoneId.of(timezone)) diff --git a/src/main/java/net/javahippie/fitpub/util/ActivityPubContexts.java b/src/main/java/net/javahippie/fitpub/util/ActivityPubContexts.java index 84581bd..ce424c6 100644 --- a/src/main/java/net/javahippie/fitpub/util/ActivityPubContexts.java +++ b/src/main/java/net/javahippie/fitpub/util/ActivityPubContexts.java @@ -35,7 +35,8 @@ public final class ActivityPubContexts { /** * Returns the extended JSON-LD {@code @context} value for outbound objects - * that carry interaction-policy declarations. Shape: + * that carry both interaction-policy declarations and FitPub's proprietary + * {@code workoutData} extension fields. Shape: * *
      * [
@@ -45,7 +46,20 @@ public final class ActivityPubContexts {
      *     "interactionPolicy":  { "@id": "gts:interactionPolicy",  "@type": "@id" },
      *     "canQuote":           { "@id": "gts:canQuote",           "@type": "@id" },
      *     "automaticApproval":  { "@id": "gts:automaticApproval",  "@type": "@id" },
-     *     "manualApproval":     { "@id": "gts:manualApproval",     "@type": "@id" }
+     *     "manualApproval":     { "@id": "gts:manualApproval",     "@type": "@id" },
+     *     "fitpub": "https://fitpub.social/ns#",
+     *     "workoutData": "fitpub:workoutData",
+     *     "activityType": "fitpub:activityType",
+     *     "description": "fitpub:description",
+     *     "distance": "fitpub:distance",
+     *     "duration": "fitpub:duration",
+     *     "elevationGain": "fitpub:elevationGain",
+     *     "averagePace": "fitpub:averagePace",
+     *     "averageHeartRate": "fitpub:averageHeartRate",
+     *     "averageSpeed": "fitpub:averageSpeed",
+     *     "maxSpeed": "fitpub:maxSpeed",
+     *     "calories": "fitpub:calories",
+     *     "route": "fitpub:route"
      *   }
      * ]
      * 
@@ -56,6 +70,12 @@ public final class ActivityPubContexts { * Mastodon source, "interaction_policies" extension), so a Mastodon * receiver compacting our object with its own context will recognise the * field names and apply the policy. + * + *

The {@code fitpub:} prefix is FitPub's own extension namespace + * ({@code https://fitpub.social/ns#}). It declares the proprietary + * {@code workoutData} object and its structured activity fields so FitPub + * instances can exchange machine-readable workout metadata without + * overloading the standard ActivityStreams fields. */ public static List extendedContext() { Map extensions = new LinkedHashMap<>(); @@ -64,6 +84,19 @@ public final class ActivityPubContexts { extensions.put("canQuote", typedRef("gts:canQuote")); extensions.put("automaticApproval", typedRef("gts:automaticApproval")); extensions.put("manualApproval", typedRef("gts:manualApproval")); + extensions.put("fitpub", "https://fitpub.social/ns#"); + extensions.put("workoutData", "fitpub:workoutData"); + extensions.put("activityType", "fitpub:activityType"); + extensions.put("description", "fitpub:description"); + extensions.put("distance", "fitpub:distance"); + extensions.put("duration", "fitpub:duration"); + extensions.put("elevationGain", "fitpub:elevationGain"); + extensions.put("averagePace", "fitpub:averagePace"); + extensions.put("averageHeartRate", "fitpub:averageHeartRate"); + extensions.put("averageSpeed", "fitpub:averageSpeed"); + extensions.put("maxSpeed", "fitpub:maxSpeed"); + extensions.put("calories", "fitpub:calories"); + extensions.put("route", "fitpub:route"); return List.of( "https://www.w3.org/ns/activitystreams", extensions diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 59e175e..3a4e4e5 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -104,14 +104,6 @@ fitpub: enabled: ${WEATHER_ENABLED:false} api-key: ${OPENWEATHERMAP_API_KEY:} -# Komoot settings - komoot: - enabled: ${KOMOOT_ENABLED:false} - base-url: ${KOMOOT_BASE_URL:https://www.komoot.com} - paginated-request-delay-ms: ${KOMOOT_PAGINATED_REQUEST_DELAY_MS:1000} - detail-to-gpx-delay-ms: ${KOMOOT_DETAIL_TO_GPX_DELAY_MS:500} - activity-import-delay-ms: ${KOMOOT_ACTIVITY_IMPORT_DELAY_MS:3000} - # Logging configuration logging: level: diff --git a/src/main/resources/db/migration/V32__add_simplified_track_to_remote_activities.sql b/src/main/resources/db/migration/V32__add_simplified_track_to_remote_activities.sql new file mode 100644 index 0000000..49e3b7e --- /dev/null +++ b/src/main/resources/db/migration/V32__add_simplified_track_to_remote_activities.sql @@ -0,0 +1,9 @@ +ALTER TABLE remote_activities + ADD COLUMN simplified_track geometry(LineString, 4326); + +CREATE INDEX idx_remote_activity_simplified_track + ON remote_activities + USING gist (simplified_track); + +COMMENT ON COLUMN remote_activities.simplified_track IS + 'Simplified remote route geometry for local map rendering'; diff --git a/src/main/resources/db/migration/V32__create_komoot_imports_table.sql b/src/main/resources/db/migration/V32__create_komoot_imports_table.sql deleted file mode 100644 index e6b7524..0000000 --- a/src/main/resources/db/migration/V32__create_komoot_imports_table.sql +++ /dev/null @@ -1,23 +0,0 @@ --- Track imported Komoot activities separately from the core activities table. --- --- This keeps the import-specific state isolated and allows all import-related --- columns to be strictly non-nullable. - -CREATE TABLE komoot_imports ( - id UUID PRIMARY KEY, - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - activity_id UUID NOT NULL REFERENCES activities(id) ON DELETE CASCADE, - komoot_activity_id BIGINT NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT uk_komoot_imports_activity_id UNIQUE (activity_id), - CONSTRAINT uk_komoot_imports_user_komoot_activity_id UNIQUE (user_id, komoot_activity_id) -); - -CREATE INDEX idx_komoot_imports_user_id - ON komoot_imports(user_id); - -CREATE INDEX idx_komoot_imports_komoot_activity_id - ON komoot_imports(komoot_activity_id); - -COMMENT ON TABLE komoot_imports IS - 'Internal mapping between FitPub activities and their originating Komoot activities'; diff --git a/src/main/resources/static/css/fitpub.css b/src/main/resources/static/css/fitpub.css index 40ce267..475edfe 100644 --- a/src/main/resources/static/css/fitpub.css +++ b/src/main/resources/static/css/fitpub.css @@ -92,6 +92,11 @@ p, letter-spacing: normal; } +/* Preserve line-breaks */ +.preserve-linebreaks { + white-space: pre-line; +} + /* Navigation */ .navbar { background: linear-gradient(135deg, var(--dark-color) 0%, #2d0052 100%) !important; diff --git a/src/main/resources/templates/activities/detail.html b/src/main/resources/templates/activities/detail.html index b548cf3..a85d14c 100644 --- a/src/main/resources/templates/activities/detail.html +++ b/src/main/resources/templates/activities/detail.html @@ -53,7 +53,7 @@

-

+

diff --git a/src/test/java/net/javahippie/fitpub/config/TestcontainersConfiguration.java b/src/test/java/net/javahippie/fitpub/config/TestcontainersConfiguration.java index 3053571..4819da6 100644 --- a/src/test/java/net/javahippie/fitpub/config/TestcontainersConfiguration.java +++ b/src/test/java/net/javahippie/fitpub/config/TestcontainersConfiguration.java @@ -4,7 +4,6 @@ import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; import org.springframework.context.annotation.Bean; import org.testcontainers.containers.PostgreSQLContainer; -import org.testcontainers.containers.wait.strategy.HostPortWaitStrategy; import org.testcontainers.utility.DockerImageName; /** @@ -23,8 +22,6 @@ public class TestcontainersConfiguration { ) .withDatabaseName("testdb") .withUsername("test") - .withPassword("test") - .waitingFor(new HostPortWaitStrategy()) - .withReuse(true); + .withPassword("test"); } } diff --git a/src/test/java/net/javahippie/fitpub/controller/ActivityPubControllerTest.java b/src/test/java/net/javahippie/fitpub/controller/ActivityPubControllerTest.java new file mode 100644 index 0000000..a0d9129 --- /dev/null +++ b/src/test/java/net/javahippie/fitpub/controller/ActivityPubControllerTest.java @@ -0,0 +1,165 @@ +package net.javahippie.fitpub.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import net.javahippie.fitpub.model.entity.Activity; +import net.javahippie.fitpub.model.entity.User; +import net.javahippie.fitpub.repository.ActivityRepository; +import net.javahippie.fitpub.repository.FollowRepository; +import net.javahippie.fitpub.repository.UserRepository; +import net.javahippie.fitpub.security.HttpSignatureValidator; +import net.javahippie.fitpub.service.ActivityImageService; +import net.javahippie.fitpub.service.FederationService; +import net.javahippie.fitpub.service.InboxProcessor; +import net.javahippie.fitpub.service.WorkoutDataPayloadBuilder; +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.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseEntity; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.File; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ActivityPubController Tests") +class ActivityPubControllerTest { + + @Mock + private UserRepository userRepository; + + @Mock + private ActivityRepository activityRepository; + + @Mock + private ActivityImageService activityImageService; + + @Mock + private InboxProcessor inboxProcessor; + + @Mock + private FollowRepository followRepository; + + @Mock + private HttpSignatureValidator signatureValidator; + + @Mock + private FederationService federationService; + + @Mock + private ObjectMapper objectMapper; + + @Mock + private WorkoutDataPayloadBuilder workoutDataPayloadBuilder; + + @InjectMocks + private ActivityPubController controller; + + private UUID activityId; + private UUID userId; + private Activity activity; + private User user; + private LocalDateTime createdAt; + + @BeforeEach + void setUp() { + activityId = UUID.randomUUID(); + userId = UUID.randomUUID(); + createdAt = LocalDateTime.of(2026, 5, 2, 9, 24, 50, 921_241_000); + + ReflectionTestUtils.setField(controller, "baseUrl", "https://fitpub.example"); + + activity = Activity.builder() + .id(activityId) + .userId(userId) + .activityType(Activity.ActivityType.RUN) + .title("Lunch Run") + .description("Sunny run") + .visibility(Activity.Visibility.PUBLIC) + .totalDistance(BigDecimal.valueOf(5000)) + .totalDurationSeconds(1800L) + .createdAt(createdAt) + .build(); + + user = User.builder() + .id(userId) + .username("JaneDoe") + .email("janedoe@example.com") + .publicKey("public-key") + .privateKey("private-key") + .build(); + } + + @Test + @DisplayName("Should serialize activity published timestamp with timezone") + void getActivity_ShouldSerializePublishedTimestampWithTimezone() { + when(activityRepository.findById(activityId)).thenReturn(Optional.of(activity)); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(activityImageService.getActivityImageFile(activityId)).thenReturn(new File("/definitely/nonexistent-fitpub-test-image")); + + ResponseEntity> response = controller.getActivity(activityId); + + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().get("published")) + .isEqualTo(createdAt.atOffset(ZoneOffset.UTC).toInstant().toString()); + } + + @Test + @DisplayName("Should include workoutData and FitPub context terms in activity note") + void getActivity_ShouldIncludeWorkoutDataAndExtendedContext() { + when(activityRepository.findById(activityId)).thenReturn(Optional.of(activity)); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(activityImageService.getActivityImageFile(activityId)).thenReturn(new File("/definitely/nonexistent-fitpub-test-image")); + when(workoutDataPayloadBuilder.build(activity)).thenReturn(Map.of( + "activityType", "RUN", + "description", "Sunny run", + "distance", 5000L, + "duration", "PT30M", + "averagePace", "PT6M", + "route", Map.of( + "type", "FeatureCollection", + "features", List.of() + ) + )); + + ResponseEntity> response = controller.getActivity(activityId); + + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().get("workoutData")).isEqualTo(Map.of( + "activityType", "RUN", + "description", "Sunny run", + "distance", 5000L, + "duration", "PT30M", + "averagePace", "PT6M", + "route", Map.of( + "type", "FeatureCollection", + "features", List.of() + ) + )); + + @SuppressWarnings("unchecked") + List context = (List) response.getBody().get("@context"); + assertThat(context).hasSize(2); + + @SuppressWarnings("unchecked") + Map extensions = (Map) context.get(1); + assertThat(extensions) + .containsEntry("fitpub", "https://fitpub.social/ns#") + .containsEntry("workoutData", "fitpub:workoutData") + .containsEntry("route", "fitpub:route"); + } +} diff --git a/src/test/java/net/javahippie/fitpub/integration/FederationFollowFlowIntegrationTest.java b/src/test/java/net/javahippie/fitpub/integration/FederationFollowFlowIntegrationTest.java index 99e3411..b07d325 100644 --- a/src/test/java/net/javahippie/fitpub/integration/FederationFollowFlowIntegrationTest.java +++ b/src/test/java/net/javahippie/fitpub/integration/FederationFollowFlowIntegrationTest.java @@ -2,19 +2,25 @@ package net.javahippie.fitpub.integration; import com.fasterxml.jackson.databind.ObjectMapper; import net.javahippie.fitpub.config.TestcontainersConfiguration; +import net.javahippie.fitpub.model.entity.Activity; +import net.javahippie.fitpub.service.ActivityImageService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import net.javahippie.fitpub.model.entity.Follow; import net.javahippie.fitpub.model.entity.RemoteActor; +import net.javahippie.fitpub.model.entity.RemoteActivity; import net.javahippie.fitpub.model.entity.User; +import net.javahippie.fitpub.repository.ActivityRepository; import net.javahippie.fitpub.repository.FollowRepository; +import net.javahippie.fitpub.repository.RemoteActivityRepository; import net.javahippie.fitpub.repository.RemoteActorRepository; import net.javahippie.fitpub.repository.UserRepository; import net.javahippie.fitpub.security.HttpSignatureValidator; import net.javahippie.fitpub.security.JwtTokenProvider; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; @@ -26,15 +32,21 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import org.springframework.transaction.annotation.Transactional; +import java.io.File; +import java.math.BigDecimal; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.NoSuchAlgorithmException; import java.time.Instant; +import java.time.LocalDateTime; import java.util.Base64; +import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @@ -63,6 +75,12 @@ class FederationFollowFlowIntegrationTest { @Autowired private RemoteActorRepository remoteActorRepository; + @Autowired + private RemoteActivityRepository remoteActivityRepository; + + @Autowired + private ActivityRepository activityRepository; + @Autowired private PasswordEncoder passwordEncoder; @@ -72,6 +90,9 @@ class FederationFollowFlowIntegrationTest { @Autowired private HttpSignatureValidator signatureValidator; + @MockBean + private ActivityImageService activityImageService; + @Value("${fitpub.base-url}") private String baseUrl; @@ -101,6 +122,22 @@ class FederationFollowFlowIntegrationTest { authToken = jwtTokenProvider.createToken(testUser.getUsername()); } + private User createFederatedUser(String username, String email, String displayName) throws NoSuchAlgorithmException { + KeyPair keyPair = generateRsaKeyPair(); + String publicKey = encodePublicKey(keyPair.getPublic().getEncoded()); + String privateKey = encodePrivateKey(keyPair.getPrivate().getEncoded()); + + return userRepository.save(User.builder() + .username(username) + .email(email) + .passwordHash(passwordEncoder.encode("password123")) + .displayName(displayName) + .publicKey(publicKey) + .privateKey(privateKey) + .enabled(true) + .build()); + } + private KeyPair generateRsaKeyPair() throws NoSuchAlgorithmException { KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); keyGen.initialize(2048); @@ -270,6 +307,111 @@ class FederationFollowFlowIntegrationTest { assertThat(updatedFollow.getStatus()).isEqualTo(Follow.FollowStatus.ACCEPTED); } + @Test + @DisplayName("Should import its own exported public activity through inbox") + void testActivityRoundtripThroughExportAndInbox() throws Exception { + User importingUser = testUser; + User exportingUser = createFederatedUser("janedoe", "janedoe@example.com", "Jane Doe"); + + Activity activity = activityRepository.save(Activity.builder() + .userId(exportingUser.getId()) + .activityType(Activity.ActivityType.RUN) + .title("Lunch Run") + .description("Sunny run in the city") + .startedAt(LocalDateTime.of(2026, 5, 2, 12, 0)) + .endedAt(LocalDateTime.of(2026, 5, 2, 12, 30)) + .createdAt(LocalDateTime.of(2026, 5, 2, 12, 31, 45, 123_000_000)) + .visibility(Activity.Visibility.PUBLIC) + .totalDistance(BigDecimal.valueOf(5000)) + .totalDurationSeconds(1800L) + .elevationGain(BigDecimal.valueOf(100)) + .sourceFileFormat("FIT") + .published(true) + .build()); + + String exportingActorUri = baseUrl + "/users/" + exportingUser.getUsername(); + when(activityImageService.getActivityImageFile(activity.getId())) + .thenReturn(new File("/definitely/nonexistent-fitpub-roundtrip-image")); + + remoteActorRepository.save(RemoteActor.builder() + .actorUri(exportingActorUri) + .username(exportingUser.getUsername()) + .domain(java.net.URI.create(baseUrl).getHost()) + .displayName(exportingUser.getDisplayName()) + .inboxUrl(exportingActorUri + "/inbox") + .outboxUrl(exportingActorUri + "/outbox") + .publicKey(exportingUser.getPublicKey()) + .publicKeyId(exportingActorUri + "#main-key") + .lastFetchedAt(Instant.now()) + .build()); + + followRepository.save(Follow.builder() + .followerId(importingUser.getId()) + .followingActorUri(exportingActorUri) + .status(Follow.FollowStatus.ACCEPTED) + .activityId(baseUrl + "/activities/follow/" + UUID.randomUUID()) + .build()); + + MvcResult exportResult = mockMvc.perform(get("/activities/" + activity.getId()) + .accept("application/activity+json")) + .andExpect(status().isOk()) + .andReturn(); + + @SuppressWarnings("unchecked") + Map exportedNote = objectMapper.readValue(exportResult.getResponse().getContentAsByteArray(), Map.class); + + Map createActivity = Map.of( + "@context", "https://www.w3.org/ns/activitystreams", + "type", "Create", + "id", baseUrl + "/activities/create/" + UUID.randomUUID(), + "actor", exportingActorUri, + "object", exportedNote + ); + + String privateKeyPem = exportingUser.getPrivateKey(); + String inboxPath = "/users/" + importingUser.getUsername() + "/inbox"; + String inboxUrl = "http://localhost" + inboxPath; + String body = objectMapper.writeValueAsString(createActivity); + HttpSignatureValidator.SignatureHeaders sigHeaders = signatureValidator.signRequest( + "POST", inboxUrl, body, privateKeyPem, exportingActorUri + "#main-key" + ); + + mockMvc.perform(post(inboxPath) + .contentType("application/activity+json") + .header("Host", sigHeaders.host) + .header("Date", sigHeaders.date) + .header("Digest", sigHeaders.digest) + .header("Signature", sigHeaders.signature) + .content(body)) + .andExpect(status().isAccepted()); + + RemoteActivity imported = remoteActivityRepository.findByActivityUri((String) exportedNote.get("id")) + .orElseThrow(); + + @SuppressWarnings("unchecked") + Map workoutData = (Map) exportedNote.get("workoutData"); + + assertThat(imported.getActivityUri()).isEqualTo(exportedNote.get("id")); + assertThat(imported.getRemoteActorUri()).isEqualTo(exportingActorUri); + assertThat(imported.getTitle()).isEqualTo(exportedNote.getOrDefault("name", + exportedNote.getOrDefault("summary", "Untitled Activity"))); + assertThat(imported.getDescription()).isEqualTo(workoutData.get("description")); + assertThat(imported.getPublishedAt()).isEqualTo(Instant.parse((String) exportedNote.get("published"))); + assertThat(imported.getVisibility()).isEqualTo(RemoteActivity.Visibility.PUBLIC); + assertThat(imported.getActivityType()).isEqualTo(workoutData.get("activityType")); + assertThat(imported.getTotalDistance()).isEqualTo(5000L); + assertThat(imported.getTotalDurationSeconds()).isEqualTo(1800L); + assertThat(imported.getElevationGain()).isEqualTo(workoutData.get("elevationGain")); + assertThat(imported.getAveragePaceSeconds()).isNull(); + assertThat(imported.getAverageHeartRate()).isNull(); + assertThat(imported.getMaxSpeed()).isNull(); + assertThat(imported.getAverageSpeed()).isNull(); + assertThat(imported.getCalories()).isNull(); + assertThat(imported.getMapImageUrl()).isNull(); + assertThat(imported.getTrackGeojsonUrl()).isNull(); + assertThat(imported.getSimplifiedTrack()).isNull(); + } + @Test @DisplayName("Should reject inbox POST without HTTP signature with 401") void testInboxRejectsUnsignedRequest() throws Exception { @@ -310,6 +452,23 @@ class FederationFollowFlowIntegrationTest { .andExpect(status().isUnauthorized()); } + private String stripHtml(String html) { + if (html == null) { + return ""; + } + return html + .replaceAll("", "\n") + .replaceAll("

", "") + .replaceAll("

", "\n") + .replaceAll("<[^>]+>", "") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("&", "&") + .trim(); + } + @Test @DisplayName("Should process Undo Follow activity and remove follow relationship") void testProcessUndoFollowActivity() throws Exception { diff --git a/src/test/java/net/javahippie/fitpub/service/ActivityImageServiceTest.java b/src/test/java/net/javahippie/fitpub/service/ActivityImageServiceTest.java index 687eb45..0343ab4 100644 --- a/src/test/java/net/javahippie/fitpub/service/ActivityImageServiceTest.java +++ b/src/test/java/net/javahippie/fitpub/service/ActivityImageServiceTest.java @@ -27,12 +27,16 @@ import static org.junit.jupiter.api.Assertions.*; /** * Manual test for ActivityImageService. * These tests are disabled by default and should only be run manually. + * + * To run this test manually: + * mvn test -Dtest=ActivityImageServiceTest */ @SpringBootTest(properties = { "fitpub.image.osm-tiles.enabled=true" }) @ActiveProfiles("test") @Import(TestcontainersConfiguration.class) +@Disabled("Manual test - run explicitly when needed") class ActivityImageServiceTest { @Autowired @@ -55,7 +59,6 @@ class ActivityImageServiceTest { * mvn test -Dtest=ActivityImageServiceTest#testGenerateActivityImage_Manual */ @Test - @Disabled("Manual test - run explicitly when needed") @DisplayName("Generate activity image from test FIT file") void testGenerateActivityImage_Manual() throws Exception { // Load test FIT file diff --git a/src/test/java/net/javahippie/fitpub/service/ActivityPostProcessingServiceTest.java b/src/test/java/net/javahippie/fitpub/service/ActivityPostProcessingServiceTest.java index 08ef492..5507c23 100644 --- a/src/test/java/net/javahippie/fitpub/service/ActivityPostProcessingServiceTest.java +++ b/src/test/java/net/javahippie/fitpub/service/ActivityPostProcessingServiceTest.java @@ -1,25 +1,42 @@ package net.javahippie.fitpub.service; +import net.javahippie.fitpub.model.entity.Activity; +import net.javahippie.fitpub.model.entity.ActivityMetrics; +import net.javahippie.fitpub.model.entity.User; +import net.javahippie.fitpub.repository.ActivityRepository; +import net.javahippie.fitpub.repository.UserRepository; 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.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.GeometryFactory; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import net.javahippie.fitpub.model.entity.Activity; -import net.javahippie.fitpub.model.entity.User; -import net.javahippie.fitpub.repository.ActivityRepository; -import net.javahippie.fitpub.repository.UserRepository; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.UUID; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.anyBoolean; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; /** * Unit tests for ActivityPostProcessingService. @@ -49,6 +66,9 @@ class ActivityPostProcessingServiceTest { @Mock private UserRepository userRepository; + @Mock + private WorkoutDataPayloadBuilder workoutDataPayloadBuilder; + @InjectMocks private ActivityPostProcessingService service; @@ -56,11 +76,13 @@ class ActivityPostProcessingServiceTest { private UUID userId; private Activity testActivity; private User testUser; + private LocalDateTime createdAt; @BeforeEach void setUp() { activityId = UUID.randomUUID(); userId = UUID.randomUUID(); + createdAt = LocalDateTime.of(2026, 5, 2, 9, 24, 50, 921_241_000); // Set baseUrl via reflection (since it's @Value injected) ReflectionTestUtils.setField(service, "baseUrl", "https://test.example"); @@ -76,9 +98,39 @@ class ActivityPostProcessingServiceTest { .totalDistance(BigDecimal.valueOf(5000)) .totalDurationSeconds(1800L) .elevationGain(BigDecimal.valueOf(100)) - .startedAt(LocalDateTime.now()) - .createdAt(LocalDateTime.now()) + .startedAt(createdAt.minusMinutes(30)) + .createdAt(createdAt) + .simplifiedTrack(new GeometryFactory().createLineString(new Coordinate[]{ + new Coordinate(8.55, 47.37), + new Coordinate(8.56, 47.38) + })) .build(); + testActivity.setMetrics(ActivityMetrics.builder() + .averagePaceSeconds(321L) + .build()); + Map workoutData = new HashMap<>(); + workoutData.put("activityType", "RUN"); + workoutData.put("description", "Morning jog"); + workoutData.put("distance", 5000L); + workoutData.put("duration", "PT30M"); + workoutData.put("averagePace", "PT5M21S"); + workoutData.put("elevationGain", 100); + workoutData.put("route", Map.of( + "type", "FeatureCollection", + "features", List.of( + Map.of( + "type", "Feature", + "geometry", Map.of( + "type", "LineString", + "coordinates", List.of( + List.of(8.55, 47.37), + List.of(8.56, 47.38) + ) + ) + ) + ) + )); + lenient().when(workoutDataPayloadBuilder.build(testActivity)).thenReturn(workoutData); // Create test user testUser = User.builder() @@ -232,6 +284,24 @@ class ActivityPostProcessingServiceTest { verify(federationService).sendCreateActivity(anyString(), any(), eq(testUser), eq(false)); } + @Test + @DisplayName("Should serialize federation note published timestamp with timezone") + void testPublishToFederationAsync_PublishedTimestampIncludesTimezone() { + when(activityRepository.findById(activityId)).thenReturn(Optional.of(testActivity)); + when(userRepository.findById(userId)).thenReturn(Optional.of(testUser)); + when(activityImageService.generateActivityImage(testActivity)).thenReturn(null); + doNothing().when(federationService).sendCreateActivity(anyString(), any(), any(), anyBoolean()); + + @SuppressWarnings("unchecked") + ArgumentCaptor> noteCaptor = ArgumentCaptor.forClass(java.util.Map.class); + + service.publishToFederationAsync(activityId, userId); + + verify(federationService).sendCreateActivity(anyString(), noteCaptor.capture(), eq(testUser), eq(true)); + assertThat(noteCaptor.getValue().get("published")) + .isEqualTo(createdAt.atOffset(ZoneOffset.UTC).toInstant().toString()); + } + @Test @DisplayName("Should skip federation for PRIVATE activity") void testPublishToFederationAsync_PrivateActivity() { @@ -317,4 +387,47 @@ class ActivityPostProcessingServiceTest { // Then: Verify federation was called (content formatting is tested indirectly) verify(federationService).sendCreateActivity(anyString(), any(), any(), anyBoolean()); } + + @Test + @DisplayName("Should include workoutData payload in federation note") + void testPublishToFederationAsync_IncludesWorkoutDataPayload() { + when(activityRepository.findById(activityId)).thenReturn(Optional.of(testActivity)); + when(userRepository.findById(userId)).thenReturn(Optional.of(testUser)); + when(activityImageService.generateActivityImage(testActivity)).thenReturn(null); + doNothing().when(federationService).sendCreateActivity(anyString(), any(), any(), anyBoolean()); + + @SuppressWarnings("unchecked") + ArgumentCaptor> noteCaptor = ArgumentCaptor.forClass(Map.class); + + service.publishToFederationAsync(activityId, userId); + + verify(federationService).sendCreateActivity(anyString(), noteCaptor.capture(), eq(testUser), eq(true)); + + @SuppressWarnings("unchecked") + Map workoutData = (Map) noteCaptor.getValue().get("workoutData"); + assertThat(workoutData) + .containsEntry("activityType", "RUN") + .containsEntry("description", "Morning jog") + .containsEntry("distance", 5000L) + .containsEntry("duration", "PT30M") + .containsEntry("averagePace", "PT5M21S") + .containsEntry("elevationGain", 100); + + @SuppressWarnings("unchecked") + Map route = (Map) workoutData.get("route"); + assertThat(route).containsEntry("type", "FeatureCollection"); + + @SuppressWarnings("unchecked") + List> features = (List>) route.get("features"); + assertThat(features).hasSize(1); + assertThat(features.get(0)).containsEntry("type", "Feature"); + + @SuppressWarnings("unchecked") + Map geometry = (Map) features.get(0).get("geometry"); + assertThat(geometry).containsEntry("type", "LineString"); + assertThat(geometry.get("coordinates")).isEqualTo(List.of( + List.of(8.55, 47.37), + List.of(8.56, 47.38) + )); + } } diff --git a/src/test/java/net/javahippie/fitpub/service/InboxProcessorTest.java b/src/test/java/net/javahippie/fitpub/service/InboxProcessorTest.java new file mode 100644 index 0000000..f1ae088 --- /dev/null +++ b/src/test/java/net/javahippie/fitpub/service/InboxProcessorTest.java @@ -0,0 +1,217 @@ +package net.javahippie.fitpub.service; + +import net.javahippie.fitpub.model.entity.Follow; +import net.javahippie.fitpub.model.entity.RemoteActivity; +import net.javahippie.fitpub.model.entity.RemoteActor; +import net.javahippie.fitpub.model.entity.User; +import net.javahippie.fitpub.repository.ActivityRepository; +import net.javahippie.fitpub.repository.CommentRepository; +import net.javahippie.fitpub.repository.FollowRepository; +import net.javahippie.fitpub.repository.LikeRepository; +import net.javahippie.fitpub.repository.RemoteActivityRepository; +import net.javahippie.fitpub.repository.RemoteActorRepository; +import net.javahippie.fitpub.repository.UserRepository; +import org.locationtech.jts.geom.LineString; +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 org.springframework.test.util.ReflectionTestUtils; + +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("InboxProcessor Tests") +class InboxProcessorTest { + + @Mock + private UserRepository userRepository; + + @Mock + private FollowRepository followRepository; + + @Mock + private FederationService federationService; + + @Mock + private ActivityRepository activityRepository; + + @Mock + private LikeRepository likeRepository; + + @Mock + private CommentRepository commentRepository; + + @Mock + private NotificationService notificationService; + + @Mock + private RemoteActivityRepository remoteActivityRepository; + + @Mock + private RemoteActorRepository remoteActorRepository; + + @InjectMocks + private InboxProcessor inboxProcessor; + + private User localUser; + private String remoteActorUri; + + @BeforeEach + void setUp() { + localUser = User.builder() + .id(UUID.randomUUID()) + .username("JaneDoe") + .email("janedoe@example.com") + .passwordHash("irrelevant") + .publicKey("public-key") + .privateKey("private-key") + .build(); + + remoteActorUri = "https://fitpub.example.com/users/JohnDoe"; + + ReflectionTestUtils.setField(inboxProcessor, "baseUrl", "https://fitpub.example"); + } + + @Test + @DisplayName("Should persist remote activity when published timestamp has fractional seconds but no timezone") + void processCreateRemoteActivity_WithPublishedTimestampWithoutTimezone_ShouldPersistRemoteActivity() { + when(remoteActivityRepository.existsByActivityUri("https://fitpub.example.com/activities/123")) + .thenReturn(false); + when(federationService.fetchRemoteActor(remoteActorUri)).thenReturn(RemoteActor.builder() + .actorUri(remoteActorUri) + .username("JohnDoe") + .domain("fitpub.example.com") + .inboxUrl("https://fitpub.example.com/users/JohnDoe/inbox") + .publicKey("public-key") + .build()); + when(userRepository.findByUsername("JaneDoe")).thenReturn(Optional.of(localUser)); + when(followRepository.findByFollowerIdAndFollowingActorUri(localUser.getId(), remoteActorUri)) + .thenReturn(Optional.of(Follow.builder() + .followerId(localUser.getId()) + .followingActorUri(remoteActorUri) + .status(Follow.FollowStatus.ACCEPTED) + .build())); + + Map note = Map.of( + "id", "https://fitpub.example.com/activities/123", + "type", "Note", + "name", "Lunch Run", + "content", "

Sunny run

", + "published", "2026-05-02T09:24:50.921241", + "to", List.of("https://www.w3.org/ns/activitystreams#Public") + ); + + Map activity = Map.of( + "type", "Create", + "actor", remoteActorUri, + "object", note + ); + + ArgumentCaptor remoteActivityCaptor = + ArgumentCaptor.forClass(net.javahippie.fitpub.model.entity.RemoteActivity.class); + + inboxProcessor.processActivity("JaneDoe", activity); + + verify(remoteActivityRepository).existsByActivityUri("https://fitpub.example.com/activities/123"); + verify(federationService).fetchRemoteActor(remoteActorUri); + verify(remoteActivityRepository).save(remoteActivityCaptor.capture()); + + assertThat(remoteActivityCaptor.getValue().getPublishedAt()) + .isEqualTo(Instant.parse("2026-05-02T09:24:50.921241Z")); + } + + @Test + @DisplayName("Should prefer workoutData fields over legacy content parsing") + void processCreateRemoteActivity_WithWorkoutDataPayload_ShouldPreferWorkoutDataFields() { + when(remoteActivityRepository.existsByActivityUri("https://fitpub.example.com/activities/456")) + .thenReturn(false); + when(federationService.fetchRemoteActor(remoteActorUri)).thenReturn(RemoteActor.builder() + .actorUri(remoteActorUri) + .username("JohnDoe") + .domain("fitpub.example.com") + .inboxUrl("https://fitpub.example.com/users/JohnDoe/inbox") + .publicKey("public-key") + .build()); + when(userRepository.findByUsername("JaneDoe")).thenReturn(Optional.of(localUser)); + when(followRepository.findByFollowerIdAndFollowingActorUri(localUser.getId(), remoteActorUri)) + .thenReturn(Optional.of(Follow.builder() + .followerId(localUser.getId()) + .followingActorUri(remoteActorUri) + .status(Follow.FollowStatus.ACCEPTED) + .build())); + + Map workoutData = new HashMap<>(); + workoutData.put("activityType", "RUN"); + workoutData.put("description", "Direct workoutData description"); + workoutData.put("distance", 9800L); + workoutData.put("duration", "PT41M9S"); + workoutData.put("averagePace", "PT4M12S"); + workoutData.put("elevationGain", 123); + workoutData.put("route", Map.of( + "type", "FeatureCollection", + "features", List.of(Map.of( + "type", "Feature", + "geometry", Map.of( + "type", "LineString", + "coordinates", List.of( + List.of(8.55, 47.37), + List.of(8.56, 47.38), + List.of(8.57, 47.39) + ) + ) + )) + )); + + Map note = Map.of( + "id", "https://fitpub.example.com/activities/456", + "type", "Note", + "name", "Kraremanns Lauf 2026", + "content", "

Kraremanns Lauf 2026

Run · 9.80 km · 41:09

Legacy content fallback

", + "published", "2026-05-02T09:24:50.921241", + "to", List.of("https://www.w3.org/ns/activitystreams#Public"), + "workoutData", workoutData + ); + + Map activity = Map.of( + "type", "Create", + "actor", remoteActorUri, + "object", note + ); + + ArgumentCaptor remoteActivityCaptor = + ArgumentCaptor.forClass(RemoteActivity.class); + + inboxProcessor.processActivity("JaneDoe", activity); + + verify(remoteActivityRepository).save(remoteActivityCaptor.capture()); + + RemoteActivity remoteActivity = remoteActivityCaptor.getValue(); + assertThat(remoteActivity.getTitle()).isEqualTo("Kraremanns Lauf 2026"); + assertThat(remoteActivity.getDescription()).isEqualTo("Direct workoutData description"); + assertThat(remoteActivity.getActivityType()).isEqualTo("RUN"); + assertThat(remoteActivity.getTotalDistance()).isEqualTo(9800L); + assertThat(remoteActivity.getTotalDurationSeconds()).isEqualTo(2469L); + assertThat(remoteActivity.getAveragePaceSeconds()).isEqualTo(252L); + assertThat(remoteActivity.getElevationGain()).isEqualTo(123); + LineString simplifiedTrack = remoteActivity.getSimplifiedTrack(); + assertThat(simplifiedTrack).isNotNull(); + assertThat(simplifiedTrack.getNumPoints()).isEqualTo(3); + assertThat(simplifiedTrack.getSRID()).isEqualTo(4326); + assertThat(simplifiedTrack.getCoordinateN(0).x).isEqualTo(8.55); + assertThat(simplifiedTrack.getCoordinateN(0).y).isEqualTo(47.37); + } +} diff --git a/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java deleted file mode 100644 index 1ea5a13..0000000 --- a/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java +++ /dev/null @@ -1,472 +0,0 @@ -package net.javahippie.fitpub.service; - -import net.javahippie.fitpub.model.dto.KomootActivityImportRequest; -import net.javahippie.fitpub.model.dto.KomootActivitiesResponse; -import net.javahippie.fitpub.model.dto.KomootImportExecutionResponse; -import net.javahippie.fitpub.model.dto.KomootImportRequest; -import net.javahippie.fitpub.model.entity.Activity; -import net.javahippie.fitpub.model.entity.KomootImport; -import net.javahippie.fitpub.repository.ActivityRepository; -import net.javahippie.fitpub.repository.KomootImportRepository; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.MediaType; -import org.springframework.test.util.ReflectionTestUtils; -import org.springframework.test.web.client.MockRestServiceServer; -import org.springframework.web.client.RestTemplate; - -import java.nio.charset.StandardCharsets; -import java.time.LocalDate; -import java.util.Base64; -import java.util.List; -import java.util.Optional; -import java.util.TimeZone; -import java.util.UUID; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import static org.springframework.test.web.client.ExpectedCount.once; -import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; -import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; -import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; -import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; - -class KomootImportServiceTest { - - private static KomootImportRepository.KomootImportLinkProjection importLink(UUID activityId, Long komootActivityId) { - return new KomootImportRepository.KomootImportLinkProjection() { - @Override - public UUID getActivityId() { - return activityId; - } - - @Override - public Long getKomootActivityId() { - return komootActivityId; - } - }; - } - - private MockRestServiceServer server; - private KomootImportService service; - private ActivityRepository activityRepository; - private KomootImportRepository komootImportRepository; - private ActivityFileService activityFileService; - private ActivityPostProcessingService activityPostProcessingService; - private TimeZone originalTimeZone; - - @BeforeEach - void setUp() { - originalTimeZone = TimeZone.getDefault(); - TimeZone.setDefault(TimeZone.getTimeZone("Europe/Zurich")); - RestTemplate restTemplate = new RestTemplate(); - server = MockRestServiceServer.bindTo(restTemplate).build(); - activityRepository = mock(ActivityRepository.class); - komootImportRepository = mock(KomootImportRepository.class); - activityFileService = mock(ActivityFileService.class); - activityPostProcessingService = mock(ActivityPostProcessingService.class); - service = new KomootImportService(restTemplate, activityRepository, komootImportRepository, activityFileService, activityPostProcessingService); - ReflectionTestUtils.setField(service, "komootBaseUrl", "https://www.komoot.com"); - ReflectionTestUtils.setField(service, "paginatedRequestDelayMillis", 0L); - ReflectionTestUtils.setField(service, "detailToGpxDelayMillis", 0L); - ReflectionTestUtils.setField(service, "activityImportDelayMillis", 0L); - } - - @AfterEach - void tearDown() { - TimeZone.setDefault(originalTimeZone); - } - - @Test - void shouldFetchAndMergePagedCompletedActivities() { - String authHeader = "Basic " + Base64.getEncoder() - .encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8)); - UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); - KomootImportService throttledService = spy(service); - doNothing().when(throttledService).pauseBeforeNextPageRequest(); - UUID existingActivityId = UUID.fromString("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"); - - when(komootImportRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of(1002L)); - when(komootImportRepository.findKomootImportLinksByUserIdAndKomootActivityIdIn(userId, List.of(1002L))) - .thenReturn(List.of(importLink(existingActivityId, 1002L))); - - server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours/?type=tour_recorded&sort_field=date&sort_direction=desc&limit=100&status=private&name=&hl=en&page=0")) - .andExpect(method(HttpMethod.GET)) - .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) - .andRespond(withSuccess(""" - { - "_embedded": { - "tours": [ - { - "id": 1001, - "name": "Evening Ride", - "sport": "touringbicycle", - "status": "private", - "type": "tour_recorded", - "date": "2026-04-27T18:15:00+02:00", - "distance": 42350.4, - "duration": 8120, - "time_in_motion": 7800, - "elevation_up": 520.2 - } - ] - }, - "_links": { - "next": { - "href": "/api/v007/users/123456/tours?type=tour_recorded&page=1&limit=100" - } - } - } - """, MediaType.APPLICATION_JSON)); - - server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours?type=tour_recorded&page=1&limit=100")) - .andExpect(method(HttpMethod.GET)) - .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) - .andRespond(withSuccess(""" - { - "_embedded": { - "tours": [ - { - "id": 1002, - "name": "Lunch Walk", - "sport": "hike", - "status": "private", - "type": "tour_recorded", - "date": "2026-04-26T12:30:00+02:00", - "distance": 5120.0, - "duration": 3600, - "time_in_motion": 3400, - "elevation_up": 75.0 - } - ] - }, - "_links": {} - } - """, MediaType.APPLICATION_JSON)); - - KomootActivitiesResponse response = throttledService.fetchCompletedActivities( - new KomootImportRequest("user@example.com", "secret", "123456", null, null), - userId); - - assertThat(response.getTotalCount()).isEqualTo(2); - assertThat(response.getActivities()).hasSize(2); - assertThat(response.getActivities().get(0).getId()).isEqualTo(1001L); - assertThat(response.getActivities().get(0).isImported()).isFalse(); - assertThat(response.getActivities().get(0).getFitPubActivityId()).isNull(); - assertThat(response.getActivities().get(0).getTimeInMotionSeconds()).isEqualTo(7800); - assertThat(response.getActivities().get(1).getName()).isEqualTo("Lunch Walk"); - assertThat(response.getActivities().get(1).isImported()).isTrue(); - assertThat(response.getActivities().get(1).getFitPubActivityId()).isEqualTo(existingActivityId); - - verify(throttledService).pauseBeforeNextPageRequest(); - server.verify(); - } - - @Test - @DisplayName("Should filter loaded Komoot activities by inclusive date range") - void shouldFilterCompletedActivitiesByInclusiveDateRange() { - String authHeader = "Basic " + Base64.getEncoder() - .encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8)); - UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); - UUID existingActivityId = UUID.fromString("ffffffff-ffff-ffff-ffff-ffffffffffff"); - - when(komootImportRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of(1003L)); - when(komootImportRepository.findKomootImportLinksByUserIdAndKomootActivityIdIn(userId, List.of(1003L))) - .thenReturn(List.of(importLink(existingActivityId, 1003L))); - - server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours/?type=tour_recorded&sort_field=date&sort_direction=desc&limit=100&start_date=2026-04-25T22:00:00.000Z&end_date=2026-04-27T21:59:59.999Z")) - .andExpect(method(HttpMethod.GET)) - .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) - .andRespond(withSuccess(""" - { - "_embedded": { - "tours": [ - { - "id": 1002, - "name": "Included Start", - "sport": "hike", - "status": "private", - "type": "tour_recorded", - "date": "2026-04-26T00:00:00+02:00" - }, - { - "id": 1003, - "name": "Included End", - "sport": "run", - "status": "private", - "type": "tour_recorded", - "date": "2026-04-27T23:59:59+02:00" - } - ] - }, - "_links": {} - } - """, MediaType.APPLICATION_JSON)); - - KomootActivitiesResponse response = service.fetchCompletedActivities( - new KomootImportRequest( - "user@example.com", - "secret", - "123456", - LocalDate.of(2026, 4, 26), - LocalDate.of(2026, 4, 27) - ), - userId); - - assertThat(response.getTotalCount()).isEqualTo(2); - assertThat(response.getActivities()).extracting("id").containsExactly(1002L, 1003L); - assertThat(response.getActivities().get(0).isImported()).isFalse(); - assertThat(response.getActivities().get(1).isImported()).isTrue(); - assertThat(response.getActivities().get(1).getFitPubActivityId()).isEqualTo(existingActivityId); - - server.verify(); - } - - @Test - @DisplayName("Should reject incomplete Komoot date range") - void shouldRejectIncompleteDateRange() { - assertThatThrownBy(() -> new KomootImportRequest( - "user@example.com", - "secret", - "123456", - LocalDate.of(2026, 4, 27), - null - )).isInstanceOf(IllegalArgumentException.class) - .hasMessage("Start date and end date must either both be set or both be empty."); - } - - @Test - @DisplayName("Should import a specific Komoot activity via GPX and override metadata") - void shouldImportSpecificKomootActivity() { - String authHeader = "Basic " + Base64.getEncoder() - .encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8)); - UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); - UUID importedActivityId = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); - KomootImportService throttledService = spy(service); - doNothing().when(throttledService).pauseBetweenDetailAndGpxRequest(); - doNothing().when(throttledService).pauseAfterActivityImport(); - - when(komootImportRepository.findByUserIdAndKomootActivityId(userId, 2880957035L)).thenReturn(Optional.empty()); - - server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957035?hl=en")) - .andExpect(method(HttpMethod.GET)) - .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) - .andRespond(withSuccess(""" - { - "id": "2880957035", - "name": "Latest Ride", - "description": "Imported from Komoot", - "status": "public", - "sport": "mtb_easy" - } - """, MediaType.APPLICATION_JSON)); - - server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957035.gpx")) - .andExpect(method(HttpMethod.GET)) - .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) - .andExpect(header(HttpHeaders.ACCEPT, "application/gpx+xml, application/xml, text/xml")) - .andRespond(withSuccess(""" - - - Latest Ride - - """, MediaType.APPLICATION_XML)); - - Activity importedActivity = Activity.builder() - .id(importedActivityId) - .userId(userId) - .activityType(Activity.ActivityType.OTHER) - .title("GPX Title") - .description(null) - .visibility(Activity.Visibility.PRIVATE) - .sourceFileFormat("GPX") - .build(); - - when(activityFileService.processActivityFile(any(), any(), any(), any(), any())).thenReturn(importedActivity); - when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0)); - when(komootImportRepository.save(any(KomootImport.class))).thenAnswer(invocation -> invocation.getArgument(0)); - - KomootImportExecutionResponse response = throttledService.importActivity( - new KomootActivityImportRequest("user@example.com", "secret", "123456", 2880957035L), - userId - ); - - assertThat(response.getImportedActivityId()).isEqualTo(importedActivityId); - assertThat(response.getImportedKomootActivityId()).isEqualTo(2880957035L); - assertThat(response.getStatus()).isEqualTo("IMPORTED"); - assertThat(importedActivity.getTitle()).isEqualTo("Latest Ride"); - assertThat(importedActivity.getDescription()).isEqualTo("Imported from Komoot"); - assertThat(importedActivity.getVisibility()).isEqualTo(Activity.Visibility.PUBLIC); - assertThat(importedActivity.getActivityType()).isEqualTo(Activity.ActivityType.RIDE); - verify(komootImportRepository).save(any(KomootImport.class)); - - verify(throttledService).pauseBetweenDetailAndGpxRequest(); - verify(throttledService).pauseAfterActivityImport(); - verify(activityPostProcessingService).processActivityAsync(importedActivityId, userId); - server.verify(); - } - - @Test - @DisplayName("Should skip already imported Komoot activity") - void shouldSkipAlreadyImportedKomootActivity() { - UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); - UUID existingActivityId = UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd"); - - when(komootImportRepository.findByUserIdAndKomootActivityId(userId, 3002L)).thenReturn( - Optional.of(KomootImport.builder().activityId(existingActivityId).userId(userId).komootActivityId(3002L).build()) - ); - - KomootImportExecutionResponse response = service.importActivity( - new KomootActivityImportRequest("user@example.com", "secret", "123456", 3002L), - userId - ); - - assertThat(response.getImportedActivityId()).isEqualTo(existingActivityId); - assertThat(response.getImportedKomootActivityId()).isEqualTo(3002L); - assertThat(response.getStatus()).isEqualTo("SKIPPED_ALREADY_IMPORTED"); - } - - @Test - @DisplayName("Should map Komoot cycling sport racebike to ride") - void shouldMapKomootRacebikeToRide() { - String authHeader = "Basic " + Base64.getEncoder() - .encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8)); - UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); - UUID importedActivityId = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); - KomootImportService throttledService = spy(service); - doNothing().when(throttledService).pauseBetweenDetailAndGpxRequest(); - doNothing().when(throttledService).pauseAfterActivityImport(); - - when(komootImportRepository.findByUserIdAndKomootActivityId(userId, 2880957037L)).thenReturn(Optional.empty()); - - server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957037?hl=en")) - .andExpect(method(HttpMethod.GET)) - .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) - .andRespond(withSuccess(""" - { - "id": "2880957037", - "name": "Road Ride", - "description": "Komoot road cycling type", - "status": "private", - "sport": "racebike" - } - """, MediaType.APPLICATION_JSON)); - - server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957037.gpx")) - .andExpect(method(HttpMethod.GET)) - .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) - .andExpect(header(HttpHeaders.ACCEPT, "application/gpx+xml, application/xml, text/xml")) - .andRespond(withSuccess(""" - - - Road Ride - - """, MediaType.APPLICATION_XML)); - - Activity importedActivity = Activity.builder() - .id(importedActivityId) - .userId(userId) - .activityType(Activity.ActivityType.OTHER) - .title("GPX Title") - .description(null) - .visibility(Activity.Visibility.PRIVATE) - .sourceFileFormat("GPX") - .build(); - - when(activityFileService.processActivityFile(any(), any(), any(), any(), any())).thenReturn(importedActivity); - when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0)); - when(komootImportRepository.save(any(KomootImport.class))).thenAnswer(invocation -> invocation.getArgument(0)); - - KomootImportExecutionResponse response = throttledService.importActivity( - new KomootActivityImportRequest("user@example.com", "secret", "123456", 2880957037L), - userId - ); - - assertThat(response.getImportedActivityId()).isEqualTo(importedActivityId); - assertThat(response.getStatus()).isEqualTo("IMPORTED"); - assertThat(importedActivity.getActivityType()).isEqualTo(Activity.ActivityType.RIDE); - verify(komootImportRepository).save(any(KomootImport.class)); - - verify(throttledService).pauseBetweenDetailAndGpxRequest(); - verify(throttledService).pauseAfterActivityImport(); - verify(activityPostProcessingService).processActivityAsync(importedActivityId, userId); - server.verify(); - } - - @Test - @DisplayName("Should fall back to OTHER when Komoot sport cannot be mapped") - void shouldFallbackToOtherForUnknownKomootSport() { - String authHeader = "Basic " + Base64.getEncoder() - .encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8)); - UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); - UUID importedActivityId = UUID.fromString("cccccccc-cccc-cccc-cccc-cccccccccccc"); - KomootImportService throttledService = spy(service); - doNothing().when(throttledService).pauseBetweenDetailAndGpxRequest(); - doNothing().when(throttledService).pauseAfterActivityImport(); - - when(komootImportRepository.findByUserIdAndKomootActivityId(userId, 2880957036L)).thenReturn(Optional.empty()); - - server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957036?hl=en")) - .andExpect(method(HttpMethod.GET)) - .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) - .andRespond(withSuccess(""" - { - "id": "2880957036", - "name": "Unknown Sport", - "description": "No mapping available", - "status": "private", - "sport": "space_biking" - } - """, MediaType.APPLICATION_JSON)); - - server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957036.gpx")) - .andExpect(method(HttpMethod.GET)) - .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) - .andExpect(header(HttpHeaders.ACCEPT, "application/gpx+xml, application/xml, text/xml")) - .andRespond(withSuccess(""" - - - Unknown Sport - - """, MediaType.APPLICATION_XML)); - - Activity importedActivity = Activity.builder() - .id(importedActivityId) - .userId(userId) - .activityType(Activity.ActivityType.RIDE) - .title("GPX Title") - .description(null) - .visibility(Activity.Visibility.PUBLIC) - .sourceFileFormat("GPX") - .build(); - - when(activityFileService.processActivityFile(any(), any(), any(), any(), any())).thenReturn(importedActivity); - when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0)); - when(komootImportRepository.save(any(KomootImport.class))).thenAnswer(invocation -> invocation.getArgument(0)); - - KomootImportExecutionResponse response = throttledService.importActivity( - new KomootActivityImportRequest("user@example.com", "secret", "123456", 2880957036L), - userId - ); - - assertThat(response.getImportedActivityId()).isEqualTo(importedActivityId); - assertThat(response.getStatus()).isEqualTo("IMPORTED"); - assertThat(importedActivity.getActivityType()).isEqualTo(Activity.ActivityType.OTHER); - verify(komootImportRepository).save(any(KomootImport.class)); - - verify(throttledService).pauseBetweenDetailAndGpxRequest(); - verify(throttledService).pauseAfterActivityImport(); - verify(activityPostProcessingService).processActivityAsync(importedActivityId, userId); - server.verify(); - } -} diff --git a/src/test/java/net/javahippie/fitpub/service/WorkoutDataPayloadBuilderTest.java b/src/test/java/net/javahippie/fitpub/service/WorkoutDataPayloadBuilderTest.java new file mode 100644 index 0000000..bc21615 --- /dev/null +++ b/src/test/java/net/javahippie/fitpub/service/WorkoutDataPayloadBuilderTest.java @@ -0,0 +1,100 @@ +package net.javahippie.fitpub.service; + +import net.javahippie.fitpub.model.entity.Activity; +import net.javahippie.fitpub.model.entity.ActivityMetrics; +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.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.GeometryFactory; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("WorkoutDataPayloadBuilder Tests") +class WorkoutDataPayloadBuilderTest { + + @Mock + private PrivacyZoneService privacyZoneService; + + @Mock + private TrackPrivacyFilter trackPrivacyFilter; + + @InjectMocks + private WorkoutDataPayloadBuilder builder; + + private UUID userId; + private Activity activity; + + @BeforeEach + void setUp() { + userId = UUID.randomUUID(); + activity = Activity.builder() + .id(UUID.randomUUID()) + .userId(userId) + .activityType(Activity.ActivityType.RUN) + .description("Morning jog") + .visibility(Activity.Visibility.PUBLIC) + .totalDistance(BigDecimal.valueOf(5000)) + .totalDurationSeconds(1800L) + .elevationGain(BigDecimal.valueOf(100)) + .simplifiedTrack(new GeometryFactory().createLineString(new Coordinate[]{ + new Coordinate(8.55, 47.37), + new Coordinate(8.56, 47.38) + })) + .build(); + activity.setMetrics(ActivityMetrics.builder() + .averagePaceSeconds(321L) + .averageHeartRate(150) + .averageSpeed(BigDecimal.valueOf(10.4)) + .maxSpeed(BigDecimal.valueOf(14.2)) + .calories(420) + .build()); + } + + @Test + @DisplayName("Should build workoutData payload with route and metrics") + void build_ShouldIncludeWorkoutDataRouteAndMetrics() { + when(privacyZoneService.getActivePrivacyZones(userId)).thenReturn(List.of()); + + Map workoutData = builder.build(activity); + + assertThat(workoutData) + .containsEntry("activityType", "RUN") + .containsEntry("description", "Morning jog") + .containsEntry("distance", 5000L) + .containsEntry("duration", "PT30M") + .containsEntry("elevationGain", 100) + .containsEntry("averagePace", "PT5M21S") + .containsEntry("averageHeartRate", 150) + .containsEntry("averageSpeed", 10.4) + .containsEntry("maxSpeed", 14.2) + .containsEntry("calories", 420); + + @SuppressWarnings("unchecked") + Map route = (Map) workoutData.get("route"); + assertThat(route).containsEntry("type", "FeatureCollection"); + + @SuppressWarnings("unchecked") + List> features = (List>) route.get("features"); + assertThat(features).hasSize(1); + + @SuppressWarnings("unchecked") + Map geometry = (Map) features.get(0).get("geometry"); + assertThat(geometry).containsEntry("type", "LineString"); + assertThat(geometry.get("coordinates")).isEqualTo(List.of( + List.of(8.55, 47.37), + List.of(8.56, 47.38) + )); + } +} diff --git a/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker deleted file mode 100644 index fdbd0b1..0000000 --- a/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker +++ /dev/null @@ -1 +0,0 @@ -mock-maker-subclass