diff --git a/.gitignore b/.gitignore index fa122a4..cd24195 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,10 @@ target/ .kotlin ### IntelliJ IDEA ### -.idea/ +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ *.iws *.iml *.ipr @@ -46,10 +49,3 @@ logs/ /gadm_410.gpkg /.postgresdata/ /peaks_worldwide.geojson - -### Coding Assistants ### -.codex/ -.aider* -.cursor/ -.roo/ -.windsurf/ diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# 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 new file mode 100644 index 0000000..06a2c34 --- /dev/null +++ b/.idea/dataSources.xml @@ -0,0 +1,17 @@ + + + + + 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 new file mode 100644 index 0000000..ed1c16b --- /dev/null +++ b/.idea/data_source_mapping.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..aa00ffa --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..ad4a613 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,17 @@ + + + + IDE + + + + + + + + + \ No newline at end of file diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml new file mode 100644 index 0000000..27a4b8c --- /dev/null +++ b/.idea/sqldialects.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties deleted file mode 100644 index 475e649..0000000 --- a/.mvn/wrapper/maven-wrapper.properties +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index f3b3756..0000000 --- a/.sdkmanrc +++ /dev/null @@ -1,3 +0,0 @@ -# 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 deleted file mode 100755 index bd8896b..0000000 --- a/mvnw +++ /dev/null @@ -1,295 +0,0 @@ -#!/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 deleted file mode 100644 index 92450f9..0000000 --- a/mvnw.cmd +++ /dev/null @@ -1,189 +0,0 @@ -<# : 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 8ecf1b2..008ac47 100644 --- a/pom.xml +++ b/pom.xml @@ -23,7 +23,7 @@ UTF-8 17 0.12.3 - 2.0.5 + 2.0.3 @@ -170,14 +170,15 @@ org.testcontainers testcontainers-junit-jupiter - ${testcontainers.version} + 2.0.2 test + org.testcontainers testcontainers-postgresql - ${testcontainers.version} + 2.0.1 test @@ -192,4 +193,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 new file mode 100644 index 0000000..db43c34 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/config/KomootSupport.java @@ -0,0 +1,21 @@ +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 3847ba4..aff2bae 100644 --- a/src/main/java/net/javahippie/fitpub/config/SecurityConfig.java +++ b/src/main/java/net/javahippie/fitpub/config/SecurityConfig.java @@ -87,6 +87,7 @@ 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() @@ -149,6 +150,7 @@ 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 c08e0ff..4cf3717 100644 --- a/src/main/java/net/javahippie/fitpub/controller/ActivityPubController.java +++ b/src/main/java/net/javahippie/fitpub/controller/ActivityPubController.java @@ -10,14 +10,13 @@ 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.ActivityRepository; import net.javahippie.fitpub.repository.FollowRepository; +import net.javahippie.fitpub.repository.ActivityRepository; 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; @@ -30,7 +29,6 @@ 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; @@ -53,7 +51,6 @@ 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; @@ -439,10 +436,9 @@ public class ActivityPubController { noteObject.put("id", activityUri); noteObject.put("type", "Note"); noteObject.put("attributedTo", actorUri); - noteObject.put("published", activity.getCreatedAt().atOffset(ZoneOffset.UTC).toInstant().toString()); + noteObject.put("published", activity.getCreatedAt().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 new file mode 100644 index 0000000..38f631a --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/controller/GlobalModelAttributes.java @@ -0,0 +1,21 @@ +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 new file mode 100644 index 0000000..08e0928 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/controller/KomootImportController.java @@ -0,0 +1,110 @@ +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 new file mode 100644 index 0000000..c5922b8 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/controller/KomootImportViewController.java @@ -0,0 +1,36 @@ +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 new file mode 100644 index 0000000..296acb3 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitiesResponse.java @@ -0,0 +1,22 @@ +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 new file mode 100644 index 0000000..6d9b7eb --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/model/dto/KomootActivityImportRequest.java @@ -0,0 +1,36 @@ +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 new file mode 100644 index 0000000..3bdf613 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitySummaryDTO.java @@ -0,0 +1,33 @@ +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 new file mode 100644 index 0000000..abb31e8 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/model/dto/KomootImportExecutionResponse.java @@ -0,0 +1,23 @@ +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 new file mode 100644 index 0000000..5c58345 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/model/dto/KomootImportRequest.java @@ -0,0 +1,66 @@ +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 new file mode 100644 index 0000000..d922504 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/model/entity/KomootImport.java @@ -0,0 +1,46 @@ +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 a3b74da..1fd8105 100644 --- a/src/main/java/net/javahippie/fitpub/model/entity/RemoteActivity.java +++ b/src/main/java/net/javahippie/fitpub/model/entity/RemoteActivity.java @@ -9,7 +9,6 @@ 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; @@ -138,12 +137,6 @@ 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 new file mode 100644 index 0000000..aaf4ea2 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/repository/KomootImportRepository.java @@ -0,0 +1,33 @@ +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 cad2bc9..8a582bc 100644 --- a/src/main/java/net/javahippie/fitpub/service/ActivityPostProcessingService.java +++ b/src/main/java/net/javahippie/fitpub/service/ActivityPostProcessingService.java @@ -12,7 +12,6 @@ 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; @@ -39,7 +38,6 @@ 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; @@ -201,10 +199,9 @@ public class ActivityPostProcessingService { noteObject.put("id", activityUri); noteObject.put("type", "Note"); noteObject.put("attributedTo", actorUri); - noteObject.put("published", activity.getCreatedAt().atOffset(ZoneOffset.UTC).toInstant().toString()); + noteObject.put("published", activity.getCreatedAt().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 27efc1e..8dff712 100644 --- a/src/main/java/net/javahippie/fitpub/service/InboxProcessor.java +++ b/src/main/java/net/javahippie/fitpub/service/InboxProcessor.java @@ -16,20 +16,11 @@ 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; @@ -40,9 +31,6 @@ 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; @@ -423,18 +411,15 @@ public class InboxProcessor { // Parse published timestamp String publishedStr = (String) noteObject.get("published"); - Instant publishedAt = parsePublishedAt(publishedStr); + Instant publishedAt = publishedStr != null ? Instant.parse(publishedStr) : Instant.now(); // Build RemoteActivity entity RemoteActivity remoteActivity = RemoteActivity.builder() .activityUri(activityUri) .remoteActorUri(actor) - .activityType(stringValue(workoutData.get("activityType"))) + .activityType((String) workoutData.get("activityType")) .title((String) noteObject.getOrDefault("name", noteObject.getOrDefault("summary", "Untitled Activity"))) - .description(firstNonBlank( - stringValue(workoutData.get("description")), - stripHtml((String) noteObject.get("content")) - )) + .description(stripHtml((String) noteObject.get("content"))) .publishedAt(publishedAt) .totalDistance(parseLong(workoutData.get("distance"))) .totalDurationSeconds(parseDurationSeconds((String) workoutData.get("duration"))) @@ -446,7 +431,6 @@ 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(); @@ -721,88 +705,6 @@ 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. */ @@ -922,44 +824,6 @@ 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 new file mode 100644 index 0000000..cce0493 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/service/KomootImportService.java @@ -0,0 +1,478 @@ +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 deleted file mode 100644 index dd8752d..0000000 --- a/src/main/java/net/javahippie/fitpub/service/WorkoutDataPayloadBuilder.java +++ /dev/null @@ -1,86 +0,0 @@ -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 0b32b3d..26e4f32 100644 --- a/src/main/java/net/javahippie/fitpub/util/ActivityFormatter.java +++ b/src/main/java/net/javahippie/fitpub/util/ActivityFormatter.java @@ -98,10 +98,6 @@ 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 ce424c6..84581bd 100644 --- a/src/main/java/net/javahippie/fitpub/util/ActivityPubContexts.java +++ b/src/main/java/net/javahippie/fitpub/util/ActivityPubContexts.java @@ -35,8 +35,7 @@ public final class ActivityPubContexts { /** * Returns the extended JSON-LD {@code @context} value for outbound objects - * that carry both interaction-policy declarations and FitPub's proprietary - * {@code workoutData} extension fields. Shape: + * that carry interaction-policy declarations. Shape: * *
      * [
@@ -46,20 +45,7 @@ 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" },
-     *     "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"
+     *     "manualApproval":     { "@id": "gts:manualApproval",     "@type": "@id" }
      *   }
      * ]
      * 
@@ -70,12 +56,6 @@ 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<>(); @@ -84,19 +64,6 @@ 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 3a4e4e5..59e175e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -104,6 +104,14 @@ 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 deleted file mode 100644 index 49e3b7e..0000000 --- a/src/main/resources/db/migration/V32__add_simplified_track_to_remote_activities.sql +++ /dev/null @@ -1,9 +0,0 @@ -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 new file mode 100644 index 0000000..e6b7524 --- /dev/null +++ b/src/main/resources/db/migration/V32__create_komoot_imports_table.sql @@ -0,0 +1,23 @@ +-- 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 475edfe..40ce267 100644 --- a/src/main/resources/static/css/fitpub.css +++ b/src/main/resources/static/css/fitpub.css @@ -92,11 +92,6 @@ 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 a85d14c..b548cf3 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 4819da6..3053571 100644 --- a/src/test/java/net/javahippie/fitpub/config/TestcontainersConfiguration.java +++ b/src/test/java/net/javahippie/fitpub/config/TestcontainersConfiguration.java @@ -4,6 +4,7 @@ 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; /** @@ -22,6 +23,8 @@ public class TestcontainersConfiguration { ) .withDatabaseName("testdb") .withUsername("test") - .withPassword("test"); + .withPassword("test") + .waitingFor(new HostPortWaitStrategy()) + .withReuse(true); } } diff --git a/src/test/java/net/javahippie/fitpub/controller/ActivityPubControllerTest.java b/src/test/java/net/javahippie/fitpub/controller/ActivityPubControllerTest.java deleted file mode 100644 index a0d9129..0000000 --- a/src/test/java/net/javahippie/fitpub/controller/ActivityPubControllerTest.java +++ /dev/null @@ -1,165 +0,0 @@ -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 b07d325..99e3411 100644 --- a/src/test/java/net/javahippie/fitpub/integration/FederationFollowFlowIntegrationTest.java +++ b/src/test/java/net/javahippie/fitpub/integration/FederationFollowFlowIntegrationTest.java @@ -2,25 +2,19 @@ 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; @@ -32,21 +26,15 @@ 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.*; @@ -75,12 +63,6 @@ class FederationFollowFlowIntegrationTest { @Autowired private RemoteActorRepository remoteActorRepository; - @Autowired - private RemoteActivityRepository remoteActivityRepository; - - @Autowired - private ActivityRepository activityRepository; - @Autowired private PasswordEncoder passwordEncoder; @@ -90,9 +72,6 @@ class FederationFollowFlowIntegrationTest { @Autowired private HttpSignatureValidator signatureValidator; - @MockBean - private ActivityImageService activityImageService; - @Value("${fitpub.base-url}") private String baseUrl; @@ -122,22 +101,6 @@ 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); @@ -307,111 +270,6 @@ 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 { @@ -452,23 +310,6 @@ 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 0343ab4..687eb45 100644 --- a/src/test/java/net/javahippie/fitpub/service/ActivityImageServiceTest.java +++ b/src/test/java/net/javahippie/fitpub/service/ActivityImageServiceTest.java @@ -27,16 +27,12 @@ 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 @@ -59,6 +55,7 @@ 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 5507c23..08ef492 100644 --- a/src/test/java/net/javahippie/fitpub/service/ActivityPostProcessingServiceTest.java +++ b/src/test/java/net/javahippie/fitpub/service/ActivityPostProcessingServiceTest.java @@ -1,42 +1,25 @@ 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.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; +import static org.mockito.Mockito.*; /** * Unit tests for ActivityPostProcessingService. @@ -66,9 +49,6 @@ class ActivityPostProcessingServiceTest { @Mock private UserRepository userRepository; - @Mock - private WorkoutDataPayloadBuilder workoutDataPayloadBuilder; - @InjectMocks private ActivityPostProcessingService service; @@ -76,13 +56,11 @@ 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"); @@ -98,39 +76,9 @@ class ActivityPostProcessingServiceTest { .totalDistance(BigDecimal.valueOf(5000)) .totalDurationSeconds(1800L) .elevationGain(BigDecimal.valueOf(100)) - .startedAt(createdAt.minusMinutes(30)) - .createdAt(createdAt) - .simplifiedTrack(new GeometryFactory().createLineString(new Coordinate[]{ - new Coordinate(8.55, 47.37), - new Coordinate(8.56, 47.38) - })) + .startedAt(LocalDateTime.now()) + .createdAt(LocalDateTime.now()) .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() @@ -284,24 +232,6 @@ 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() { @@ -387,47 +317,4 @@ 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 deleted file mode 100644 index f1ae088..0000000 --- a/src/test/java/net/javahippie/fitpub/service/InboxProcessorTest.java +++ /dev/null @@ -1,217 +0,0 @@ -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 new file mode 100644 index 0000000..1ea5a13 --- /dev/null +++ b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java @@ -0,0 +1,472 @@ +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 deleted file mode 100644 index bc21615..0000000 --- a/src/test/java/net/javahippie/fitpub/service/WorkoutDataPayloadBuilderTest.java +++ /dev/null @@ -1,100 +0,0 @@ -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 new file mode 100644 index 0000000..fdbd0b1 --- /dev/null +++ b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-subclass