Compare commits
1 commit
main
...
kabupatix/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bd1dd6be3 |
50 changed files with 486 additions and 1998 deletions
12
.gitignore
vendored
12
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
|
|
@ -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
|
||||
17
.idea/dataSources.xml
generated
Normal file
17
.idea/dataSources.xml
generated
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||
<data-source source="LOCAL" name="testdb@localhost" uuid="2564811a-81f9-4d83-b1b1-04cb2763e3fa">
|
||||
<driver-ref>postgresql</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
|
||||
<jdbc-url>jdbc:postgresql://localhost:51826/testdb</jdbc-url>
|
||||
<jdbc-additional-properties>
|
||||
<property name="com.intellij.clouds.kubernetes.db.host.port" />
|
||||
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
|
||||
<property name="com.intellij.clouds.kubernetes.db.container.port" />
|
||||
</jdbc-additional-properties>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
</data-source>
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/data_source_mapping.xml
generated
Normal file
6
.idea/data_source_mapping.xml
generated
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourcePerFileMappings">
|
||||
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/2564811a-81f9-4d83-b1b1-04cb2763e3fa/console_1.sql" value="2564811a-81f9-4d83-b1b1-04cb2763e3fa" />
|
||||
</component>
|
||||
</project>
|
||||
7
.idea/encodings.xml
generated
Normal file
7
.idea/encodings.xml
generated
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Encoding">
|
||||
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
|
||||
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
|
||||
</component>
|
||||
</project>
|
||||
17
.idea/misc.xml
generated
Normal file
17
.idea/misc.xml
generated
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ClojureProjectResolveSettings">
|
||||
<currentScheme>IDE</currentScheme>
|
||||
</component>
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="MavenProjectsManager">
|
||||
<option name="originalFiles">
|
||||
<list>
|
||||
<option value="$PROJECT_DIR$/pom.xml" />
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_23" default="true" project-jdk-name="temurin-23" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
</project>
|
||||
8
.idea/sqldialects.xml
generated
Normal file
8
.idea/sqldialects.xml
generated
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="SqlDialectMappings">
|
||||
<file url="file://$PROJECT_DIR$/src/main/resources/db/migration/V23__add_gadm_table.sql" dialect="PostgreSQL" />
|
||||
<file url="file://$PROJECT_DIR$/src/main/resources/db/migration/V24__add_location_to_activity.sql" dialect="PostgreSQL" />
|
||||
<file url="file://$PROJECT_DIR$/src/main/resources/db/migration/V26__add_published_to_activities.sql" dialect="H2" />
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
2
.mvn/wrapper/maven-wrapper.properties
vendored
2
.mvn/wrapper/maven-wrapper.properties
vendored
|
|
@ -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
|
||||
|
|
@ -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
|
||||
295
mvnw
vendored
295
mvnw
vendored
|
|
@ -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-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||
[ -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 "$@"
|
||||
189
mvnw.cmd
vendored
189
mvnw.cmd
vendored
|
|
@ -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-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||
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"
|
||||
7
pom.xml
7
pom.xml
|
|
@ -23,7 +23,7 @@
|
|||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<java.version>17</java.version>
|
||||
<jjwt.version>0.12.3</jjwt.version>
|
||||
<testcontainers.version>2.0.5</testcontainers.version>
|
||||
<testcontainers.version>2.0.3</testcontainers.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
|
|
@ -170,14 +170,15 @@
|
|||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>testcontainers-junit-jupiter</artifactId>
|
||||
<version>${testcontainers.version}</version>
|
||||
<version>2.0.2</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>testcontainers-postgresql</artifactId>
|
||||
<version>${testcontainers.version}</version>
|
||||
<version>2.0.1</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -133,7 +133,7 @@ public class ActivityDTO {
|
|||
.elevationLoss(activity.getElevationLoss())
|
||||
.createdAt(activity.getCreatedAt())
|
||||
.updatedAt(activity.getUpdatedAt())
|
||||
.activityLocation(activity.getActivityLocationNonNull());
|
||||
.activityLocation(activity.getActivityLocation());
|
||||
|
||||
if (activity.getTotalDurationSeconds() != null) {
|
||||
builder.totalDurationSeconds(activity.getTotalDurationSeconds());
|
||||
|
|
@ -246,7 +246,7 @@ public class ActivityDTO {
|
|||
.subSport(activity.getSubSport())
|
||||
.indoorDetectionMethod(activity.getIndoorDetectionMethod())
|
||||
.race(activity.getRace() != null ? activity.getRace() : false)
|
||||
.activityLocation(activity.getActivityLocationNonNull())
|
||||
.activityLocation(activity.getActivityLocation())
|
||||
.build();
|
||||
}
|
||||
|
||||
|
|
@ -266,7 +266,7 @@ public class ActivityDTO {
|
|||
.elevationLoss(activity.getElevationLoss())
|
||||
.createdAt(activity.getCreatedAt())
|
||||
.updatedAt(activity.getUpdatedAt())
|
||||
.activityLocation(activity.getActivityLocationNonNull());
|
||||
.activityLocation(activity.getActivityLocation());
|
||||
|
||||
if (activity.getTotalDurationSeconds() != null) {
|
||||
builder.totalDurationSeconds(activity.getTotalDurationSeconds());
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ public class TimelineActivityDTO {
|
|||
.indoorDetectionMethod(activity.getIndoorDetectionMethod())
|
||||
.race(activity.getRace() != null ? activity.getRace() : false)
|
||||
.metrics(activity.getMetrics() != null ? ActivityMetricsSummary.fromMetrics(activity.getMetrics()) : null)
|
||||
.activityLocation(activity.getActivityLocationNonNull())
|
||||
.activityLocation(activity.getActivityLocation())
|
||||
.build();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -214,10 +214,6 @@ public class Activity {
|
|||
}
|
||||
}
|
||||
|
||||
public String getActivityLocationNonNull() {
|
||||
return activityLocation != null ? activityLocation : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Activity types supported by the platform
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -378,17 +378,10 @@ public class ActivityFileService {
|
|||
byte[] rawFile,
|
||||
ProcessingOptions options
|
||||
) throws JsonProcessingException {
|
||||
String activityTitle;
|
||||
if (title != null && !title.isBlank()) {
|
||||
activityTitle = title;
|
||||
} else if (parsedData.getTitle() != null) {
|
||||
// Try to use title from input file
|
||||
activityTitle = parsedData.getTitle();
|
||||
} else {
|
||||
// Generate title if not provided
|
||||
activityTitle = ActivityFormatter.generateActivityTitle(parsedData.getStartTime(), parsedData.getTimezone(),
|
||||
parsedData.getActivityType());
|
||||
}
|
||||
String activityTitle = title != null && !title.isBlank()
|
||||
? title
|
||||
: ActivityFormatter.generateActivityTitle(parsedData.getStartTime(), parsedData.getActivityType());
|
||||
|
||||
// Default to PUBLIC if visibility not specified
|
||||
Activity.Visibility activityVisibility = visibility != null ? visibility : Activity.Visibility.PRIVATE;
|
||||
|
|
|
|||
|
|
@ -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<String> hashtags = extractHashtags(activity);
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@ public class ActivitySummaryService {
|
|||
* Update summary for an activity's period.
|
||||
* Called after an activity is saved.
|
||||
*/
|
||||
@Async
|
||||
@Transactional
|
||||
public void updateSummariesForActivity(Activity activity) {
|
||||
if (activity.getUserId() == null || activity.getStartedAt() == null) {
|
||||
|
|
|
|||
|
|
@ -252,37 +252,6 @@ public class FederationService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an Accept for a quote interaction (FEP-5e53).
|
||||
* This tells the quoting server that the quote has been approved.
|
||||
*
|
||||
* @param noteUri the URI of the remote Note that quotes our post
|
||||
* @param remoteActorUri the actor URI of the user who quoted the post
|
||||
* @param localUser the local user who owns the quoted post
|
||||
*/
|
||||
@Async("taskExecutor")
|
||||
public void sendAcceptQuote(String noteUri, String remoteActorUri, User localUser) {
|
||||
try {
|
||||
RemoteActor remoteActor = fetchRemoteActor(remoteActorUri);
|
||||
|
||||
String acceptId = baseUrl + "/activities/" + UUID.randomUUID();
|
||||
String actorUri = baseUrl + "/users/" + localUser.getUsername();
|
||||
|
||||
Map<String, Object> acceptActivity = new HashMap<>();
|
||||
acceptActivity.put("@context", "https://www.w3.org/ns/activitystreams");
|
||||
acceptActivity.put("type", "Accept");
|
||||
acceptActivity.put("id", acceptId);
|
||||
acceptActivity.put("actor", actorUri);
|
||||
acceptActivity.put("object", noteUri);
|
||||
|
||||
sendActivity(remoteActor.getInboxUrl(), acceptActivity, localUser);
|
||||
log.info("Sent Accept (quote approval) to: {} for Note {}", remoteActor.getActorUri(), noteUri);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to send Accept for quote: {}", noteUri, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an activity to a remote inbox.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import org.springframework.web.multipart.MultipartFile;
|
|||
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
|
|
@ -256,7 +257,7 @@ public class FitFileService {
|
|||
private String generateTitle(ParsedActivityData parsedData) {
|
||||
return ActivityFormatter.generateActivityTitle(
|
||||
parsedData.getStartTime(),
|
||||
parsedData.getTimezone(), parsedData.getActivityType()
|
||||
parsedData.getActivityType()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -250,19 +238,6 @@ public class InboxProcessor {
|
|||
return;
|
||||
}
|
||||
|
||||
// Check if this Note quotes a local activity (FEP-5e53).
|
||||
// Mastodon and other implementations use various field names for the quote reference.
|
||||
String quoteUri = firstNonNull(
|
||||
(String) noteObject.get("quoteUri"),
|
||||
(String) noteObject.get("quote"),
|
||||
(String) noteObject.get("quoteUrl"),
|
||||
(String) noteObject.get("_misskey_quote")
|
||||
);
|
||||
|
||||
if (quoteUri != null) {
|
||||
handleQuoteApproval(username, activity, actor, quoteUri);
|
||||
}
|
||||
|
||||
String inReplyTo = (String) noteObject.get("inReplyTo");
|
||||
|
||||
if (inReplyTo == null) {
|
||||
|
|
@ -277,51 +252,6 @@ public class InboxProcessor {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If the quoted URI points to a local activity, send an Accept back to
|
||||
* the quoting actor so that Mastodon (and other FEP-5e53 implementations)
|
||||
* marks the quote as approved.
|
||||
*/
|
||||
private void handleQuoteApproval(String username, Map<String, Object> createActivity, String actor, String quoteUri) {
|
||||
try {
|
||||
UUID activityId = extractActivityIdFromUri(quoteUri);
|
||||
if (activityId == null) {
|
||||
log.debug("Quote URI {} does not reference a local activity, skipping approval", quoteUri);
|
||||
return;
|
||||
}
|
||||
|
||||
Activity localActivity = activityRepository.findById(activityId).orElse(null);
|
||||
if (localActivity == null) {
|
||||
log.warn("Quoted activity not found: {}", activityId);
|
||||
return;
|
||||
}
|
||||
|
||||
User localUser = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found: " + username));
|
||||
|
||||
// Mastodon tracks pending quote approvals by the Note URI (the inner
|
||||
// object's "id"), not by the wrapping Create activity's "id". The Accept
|
||||
// we send back must therefore reference the Note URI so Mastodon can match
|
||||
// it to the pending approval.
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> noteObject = (Map<String, Object>) createActivity.get("object");
|
||||
String noteUri = (String) noteObject.get("id");
|
||||
log.info("Approving quote from {} for activity {} (Note URI: {})", actor, activityId, noteUri);
|
||||
|
||||
federationService.sendAcceptQuote(noteUri, actor, localUser);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Error handling quote approval for {}", quoteUri, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String firstNonNull(String... values) {
|
||||
for (String v : values) {
|
||||
if (v != null) return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a comment (Note with inReplyTo).
|
||||
*/
|
||||
|
|
@ -423,18 +353,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 +373,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 +647,6 @@ public class InboxProcessor {
|
|||
return workoutData;
|
||||
}
|
||||
|
||||
private String stringValue(Object value) {
|
||||
return value != null ? String.valueOf(value) : null;
|
||||
}
|
||||
|
||||
private LineString extractRoute(Map<String, Object> 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<Coordinate> 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 +766,6 @@ public class InboxProcessor {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse ActivityPub published timestamps.
|
||||
*
|
||||
* <p>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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ public class TimelineResultMapper {
|
|||
.commentsCount(commentsCount)
|
||||
.likedByCurrentUser(likedByCurrentUser)
|
||||
.hasGpsTrack(true) // Will be refined based on actual data
|
||||
.activityLocation(activityLocation != null ? activityLocation : "")
|
||||
.activityLocation(activityLocation)
|
||||
.build();
|
||||
|
||||
} catch (Exception e) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package net.javahippie.fitpub.service;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
|
@ -8,22 +9,23 @@ import net.javahippie.fitpub.model.entity.Activity;
|
|||
import net.javahippie.fitpub.model.entity.TrackPoint;
|
||||
import net.javahippie.fitpub.model.entity.WeatherData;
|
||||
import net.javahippie.fitpub.repository.WeatherDataRepository;
|
||||
import org.locationtech.jts.geom.Point;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.net.URI;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Map;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Service for fetching and managing weather data for activities.
|
||||
* Uses Open-Meteo archive API to retrieve historical weather data.
|
||||
* Uses OpenWeatherMap API to retrieve historical weather data.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
|
|
@ -34,10 +36,14 @@ public class WeatherService {
|
|||
private final RestTemplate restTemplate = new RestTemplate();
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Value("${fitpub.weather.api-key:}")
|
||||
private String apiKey;
|
||||
|
||||
@Value("${fitpub.weather.enabled:false}")
|
||||
private boolean weatherEnabled;
|
||||
|
||||
private static final String OPEN_METEO_API_URL = "https://archive-api.open-meteo.com/v1/archive?latitude={latitude}&longitude={longitude}&start_date={start_date}&end_date={end_date}&hourly={hourly}";
|
||||
private static final String OPENWEATHERMAP_API_URL = "https://api.openweathermap.org/data/2.5/weather";
|
||||
private static final String OPENWEATHERMAP_TIMEMACHINE_URL = "https://api.openweathermap.org/data/3.0/onecall/timemachine";
|
||||
|
||||
/**
|
||||
* Fetch and store weather data for an activity.
|
||||
|
|
@ -49,6 +55,24 @@ public class WeatherService {
|
|||
@Transactional
|
||||
public Optional<WeatherData> fetchWeatherForActivity(Activity activity) {
|
||||
log.info("=== Weather fetch requested for activity {} ===", activity.getId());
|
||||
log.info("Weather configuration: enabled={}, API key configured={}, API key length={}",
|
||||
weatherEnabled, (apiKey != null && !apiKey.isBlank()),
|
||||
(apiKey != null ? apiKey.length() : 0));
|
||||
|
||||
if (!weatherEnabled) {
|
||||
log.warn("Weather fetching is DISABLED in configuration (fitpub.weather.enabled=false). " +
|
||||
"Set fitpub.weather.enabled=true in application properties to enable.");
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
if (apiKey == null || apiKey.isBlank()) {
|
||||
log.error("Weather API key is NOT CONFIGURED (fitpub.weather.api-key is empty). " +
|
||||
"Please set fitpub.weather.api-key in application properties.");
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
log.debug("Weather API key present: length={} chars, first 4 chars={}...",
|
||||
apiKey.length(), apiKey.length() > 4 ? apiKey.substring(0, 4) : "???");
|
||||
|
||||
if (activity.getTrackPointsJson() == null || activity.getTrackPointsJson().isEmpty()) {
|
||||
log.warn("No track points available for activity {} - cannot fetch weather", activity.getId());
|
||||
|
|
@ -64,7 +88,21 @@ public class WeatherService {
|
|||
return Optional.empty();
|
||||
} else {
|
||||
var resolvedTrackPoint = trackPoint.get();
|
||||
WeatherData weatherData = fetchCurrentWeather(resolvedTrackPoint.lat(), resolvedTrackPoint.lon(), activity.getId(), activity.getStartedAt());
|
||||
// Check if activity is recent (within 5 days) because it's free to use. Don't call other timeframes, expensive.
|
||||
long activityTimestamp = activity.getStartedAt().atZone(ZoneId.systemDefault()).toEpochSecond();
|
||||
long currentTimestamp = Instant.now().getEpochSecond();
|
||||
long daysDifference = (currentTimestamp - activityTimestamp) / 86400;
|
||||
|
||||
log.info("Activity started at: {}, days ago: {}", activity.getStartedAt(), daysDifference);
|
||||
|
||||
WeatherData weatherData;
|
||||
if (daysDifference <= 5) {
|
||||
log.info("Activity is RECENT ({} days old, within 5 day threshold), fetching current weather from OpenWeatherMap", daysDifference);
|
||||
weatherData = fetchCurrentWeather(resolvedTrackPoint.lat(), resolvedTrackPoint.lon(), activity.getId());
|
||||
} else {
|
||||
log.warn("Activity is {} days old (exceeds 5 day threshold). Historical weather data requires OpenWeatherMap paid API plan. Skipping weather fetch.", daysDifference);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
if (weatherData != null) {
|
||||
log.info("Successfully fetched and parsed weather data. Attempting to save to database...");
|
||||
|
|
@ -90,218 +128,221 @@ public class WeatherService {
|
|||
}
|
||||
|
||||
/**
|
||||
* Fetch current weather data from Open-Meteo archive API.
|
||||
* Fetch current weather data from OpenWeatherMap.
|
||||
*/
|
||||
private WeatherData fetchCurrentWeather(double lat, double lon, UUID activityId, LocalDateTime startedAt) {
|
||||
private WeatherData fetchCurrentWeather(double lat, double lon, UUID activityId) {
|
||||
log.info("=== fetchCurrentWeather START === activityId={}, lat={}, lon={}", activityId, lat, lon);
|
||||
try {
|
||||
String url = String.format("%s?lat=%f&lon=%f&appid=%s&units=metric",
|
||||
OPENWEATHERMAP_API_URL, lat, lon, apiKey);
|
||||
|
||||
Map<String, Object> uriVariables = Map.of(
|
||||
"latitude", lat,
|
||||
"longitude", lon,
|
||||
"start_date", startedAt.format(DateTimeFormatter.ISO_DATE),
|
||||
"end_date", startedAt.format(DateTimeFormatter.ISO_DATE),
|
||||
"hourly", "temperature_2m,apparent_temperature,relative_humidity_2m,surface_pressure,wind_speed_10m,wind_direction_10m,cloud_cover,rain,snowfall,precipitation,visibility,weather_code"
|
||||
);
|
||||
|
||||
log.info("Request parameters: lat={}, lon={}, date={}", lat, lon, startedAt);
|
||||
String maskedUrl = url.replace(apiKey, "***API_KEY***");
|
||||
log.info("Constructed OpenWeatherMap API URL: {}", maskedUrl);
|
||||
log.info("Request parameters: lat={}, lon={}, units=metric", lat, lon);
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
log.info("Sending HTTP GET request to Open-Meteo...");
|
||||
String response = restTemplate.getForObject(OPEN_METEO_API_URL, String.class, uriVariables);
|
||||
log.info("Sending HTTP GET request to OpenWeatherMap...");
|
||||
String response = restTemplate.getForObject(URI.create(url), String.class);
|
||||
long duration = System.currentTimeMillis() - startTime;
|
||||
|
||||
log.info("HTTP request completed in {}ms, response received", duration);
|
||||
|
||||
if (response == null) {
|
||||
log.error("API response is NULL - RestTemplate returned null, no data from Open-Meteo");
|
||||
log.error("API response is NULL - RestTemplate returned null, no data from OpenWeatherMap");
|
||||
return null;
|
||||
}
|
||||
|
||||
log.info("API response received: {} characters", response.length());
|
||||
log.info("API response (first 300 chars): {}",
|
||||
response.length() > 300 ? response.substring(0, 300) + "..." : response);
|
||||
|
||||
log.info("Parsing weather response JSON...");
|
||||
WeatherData weatherData = parseWeatherResponse(response, activityId, startedAt);
|
||||
WeatherData weatherData = parseWeatherResponse(response, activityId);
|
||||
|
||||
if (weatherData == null) {
|
||||
log.error("FAILED to parse weather response - see parsing errors above");
|
||||
} else {
|
||||
log.info("Successfully parsed weather data: temp={}°C, condition='{}', wind={} m/s, precipitation={} mm",
|
||||
log.info("Successfully parsed weather data: temp={}°C, feels_like={}°C, condition='{}', description='{}', humidity={}%, pressure={} hPa, wind={} m/s",
|
||||
weatherData.getTemperatureCelsius(),
|
||||
weatherData.getFeelsLikeCelsius(),
|
||||
weatherData.getWeatherCondition(),
|
||||
weatherData.getWindSpeedMps(),
|
||||
weatherData.getPrecipitationMm());
|
||||
weatherData.getWeatherDescription(),
|
||||
weatherData.getHumidity(),
|
||||
weatherData.getPressure(),
|
||||
weatherData.getWindSpeedMps());
|
||||
}
|
||||
|
||||
log.info("=== fetchCurrentWeather END === success={}", (weatherData != null));
|
||||
return weatherData;
|
||||
|
||||
} catch (org.springframework.web.client.HttpClientErrorException e) {
|
||||
log.error("HTTP client error (4xx) from Open-Meteo API: {} - {}", e.getStatusCode(), e.getResponseBodyAsString(), e);
|
||||
log.error("=== HTTP CLIENT ERROR (4xx) from OpenWeatherMap API ===");
|
||||
log.error("Status Code: {}", e.getStatusCode());
|
||||
log.error("Status Text: {}", e.getStatusText());
|
||||
log.error("Response Body: {}", e.getResponseBodyAsString());
|
||||
log.error("Request URL (masked): {}", OPENWEATHERMAP_API_URL + "?lat=" + lat + "&lon=" + lon + "&appid=***&units=metric");
|
||||
if (e.getStatusCode().value() == 401) {
|
||||
log.error("AUTHENTICATION FAILED - Check your OpenWeatherMap API key is valid and active");
|
||||
} else if (e.getStatusCode().value() == 404) {
|
||||
log.error("API ENDPOINT NOT FOUND - Check coordinates are valid: lat={}, lon={}", lat, lon);
|
||||
} else if (e.getStatusCode().value() == 429) {
|
||||
log.error("RATE LIMIT EXCEEDED - Too many API requests. Check your OpenWeatherMap plan limits.");
|
||||
}
|
||||
log.error("Exception details:", e);
|
||||
return null;
|
||||
} catch (org.springframework.web.client.HttpServerErrorException e) {
|
||||
log.error("HTTP server error (5xx) from Open-Meteo API: {} - {}", e.getStatusCode(), e.getResponseBodyAsString(), e);
|
||||
log.error("=== HTTP SERVER ERROR (5xx) from OpenWeatherMap API ===");
|
||||
log.error("Status Code: {}", e.getStatusCode());
|
||||
log.error("Status Text: {}", e.getStatusText());
|
||||
log.error("Response Body: {}", e.getResponseBodyAsString());
|
||||
log.error("OpenWeatherMap service may be experiencing issues. Try again later.");
|
||||
log.error("Exception details:", e);
|
||||
return null;
|
||||
} catch (org.springframework.web.client.ResourceAccessException e) {
|
||||
log.error("Network error accessing Open-Meteo API: {}", e.getMessage(), e);
|
||||
log.error("=== NETWORK/CONNECTION ERROR accessing OpenWeatherMap API ===");
|
||||
log.error("Error message: {}", e.getMessage());
|
||||
log.error("This could indicate: DNS resolution failure, network connectivity issues, firewall blocking, or SSL certificate problems");
|
||||
log.error("API URL attempted: {}", OPENWEATHERMAP_API_URL);
|
||||
log.error("Exception details:", e);
|
||||
return null;
|
||||
} catch (org.springframework.web.client.RestClientException e) {
|
||||
log.error("=== REST CLIENT EXCEPTION calling OpenWeatherMap API ===");
|
||||
log.error("Exception type: {}", e.getClass().getName());
|
||||
log.error("Error message: {}", e.getMessage());
|
||||
log.error("Exception details:", e);
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
log.error("Unexpected exception fetching weather for activity {}: {}", activityId, e.getMessage(), e);
|
||||
log.error("=== UNEXPECTED EXCEPTION fetching current weather ===");
|
||||
log.error("Exception type: {}", e.getClass().getName());
|
||||
log.error("Error message: {}", e.getMessage());
|
||||
log.error("Activity ID: {}", activityId);
|
||||
log.error("Coordinates: lat={}, lon={}", lat, lon);
|
||||
log.error("Full stack trace:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Open-Meteo archive API response and create WeatherData entity.
|
||||
* Extracts the hourly data point matching the activity's start hour.
|
||||
* Parse OpenWeatherMap API response and create WeatherData entity.
|
||||
*/
|
||||
private WeatherData parseWeatherResponse(String response, UUID activityId, LocalDateTime startedAt) {
|
||||
private WeatherData parseWeatherResponse(String response, UUID activityId) {
|
||||
log.debug("=== parseWeatherResponse START === activityId={}", activityId);
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(response);
|
||||
|
||||
JsonNode hourly = root.get("hourly");
|
||||
if (hourly == null) {
|
||||
log.warn("Response JSON does not contain 'hourly' section");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the index matching the activity start hour
|
||||
JsonNode times = hourly.get("time");
|
||||
String targetHour = startedAt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:00"));
|
||||
int hourIndex = -1;
|
||||
for (int i = 0; i < times.size(); i++) {
|
||||
if (times.get(i).asText().equals(targetHour)) {
|
||||
hourIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hourIndex == -1) {
|
||||
log.warn("No matching hour found for {} in response", targetHour);
|
||||
return null;
|
||||
}
|
||||
|
||||
log.debug("Matched hour index {} for {}", hourIndex, targetHour);
|
||||
log.debug("JSON parsed successfully, root node present: {}", root != null);
|
||||
|
||||
WeatherData weatherData = new WeatherData();
|
||||
weatherData.setActivityId(activityId);
|
||||
weatherData.setTemperatureCelsius(getHourlyBigDecimal(hourly, "temperature_2m", hourIndex));
|
||||
weatherData.setFeelsLikeCelsius(getHourlyBigDecimal(hourly, "apparent_temperature", hourIndex));
|
||||
weatherData.setHumidity(getHourlyInteger(hourly, "relative_humidity_2m", hourIndex));
|
||||
weatherData.setPressure(getHourlyInteger(hourly, "surface_pressure", hourIndex));
|
||||
weatherData.setWindDirection(getHourlyInteger(hourly, "wind_direction_10m", hourIndex));
|
||||
weatherData.setCloudiness(getHourlyInteger(hourly, "cloud_cover", hourIndex));
|
||||
weatherData.setVisibilityMeters(getHourlyInteger(hourly, "visibility", hourIndex));
|
||||
weatherData.setPrecipitationMm(getHourlyBigDecimal(hourly, "precipitation", hourIndex));
|
||||
weatherData.setWeatherCondition(mapWmoCodeToCondition(getHourlyInteger(hourly, "weather_code", hourIndex)));
|
||||
weatherData.setWeatherDescription(mapWmoCodeToDescription(getHourlyInteger(hourly, "weather_code", hourIndex)));
|
||||
|
||||
// Open-Meteo returns wind speed in km/h, convert to m/s
|
||||
BigDecimal windKmh = getHourlyBigDecimal(hourly, "wind_speed_10m", hourIndex);
|
||||
if (windKmh != null) {
|
||||
weatherData.setWindSpeedMps(windKmh.divide(BigDecimal.valueOf(3.6), 2, RoundingMode.HALF_UP));
|
||||
// Main temperature data
|
||||
if (root.has("main")) {
|
||||
JsonNode main = root.get("main");
|
||||
log.debug("Parsing 'main' section: {}", main);
|
||||
weatherData.setTemperatureCelsius(getBigDecimal(main, "temp"));
|
||||
weatherData.setFeelsLikeCelsius(getBigDecimal(main, "feels_like"));
|
||||
weatherData.setHumidity(getInteger(main, "humidity"));
|
||||
weatherData.setPressure(getInteger(main, "pressure"));
|
||||
log.debug("Extracted main data: temp={}, feels_like={}, humidity={}, pressure={}",
|
||||
weatherData.getTemperatureCelsius(), weatherData.getFeelsLikeCelsius(),
|
||||
weatherData.getHumidity(), weatherData.getPressure());
|
||||
} else {
|
||||
log.warn("Response JSON does not contain 'main' section");
|
||||
}
|
||||
|
||||
// Open-Meteo returns snowfall in cm, convert to mm
|
||||
BigDecimal snowCm = getHourlyBigDecimal(hourly, "snowfall", hourIndex);
|
||||
if (snowCm != null) {
|
||||
weatherData.setSnowMm(snowCm.multiply(BigDecimal.TEN));
|
||||
// Wind data
|
||||
if (root.has("wind")) {
|
||||
JsonNode wind = root.get("wind");
|
||||
log.debug("Parsing 'wind' section: {}", wind);
|
||||
weatherData.setWindSpeedMps(getBigDecimal(wind, "speed"));
|
||||
weatherData.setWindDirection(getInteger(wind, "deg"));
|
||||
log.debug("Extracted wind data: speed={} m/s, direction={} degrees",
|
||||
weatherData.getWindSpeedMps(), weatherData.getWindDirection());
|
||||
} else {
|
||||
log.debug("Response JSON does not contain 'wind' section");
|
||||
}
|
||||
|
||||
// Weather condition
|
||||
if (root.has("weather") && root.get("weather").isArray() && !root.get("weather").isEmpty()) {
|
||||
JsonNode weather = root.get("weather").get(0);
|
||||
log.debug("Parsing 'weather' array (first element): {}", weather);
|
||||
weatherData.setWeatherCondition(getString(weather, "main"));
|
||||
weatherData.setWeatherDescription(getString(weather, "description"));
|
||||
weatherData.setWeatherIcon(getString(weather, "icon"));
|
||||
log.debug("Extracted weather condition: main='{}', description='{}', icon='{}'",
|
||||
weatherData.getWeatherCondition(), weatherData.getWeatherDescription(),
|
||||
weatherData.getWeatherIcon());
|
||||
} else {
|
||||
log.warn("Response JSON does not contain valid 'weather' array");
|
||||
}
|
||||
|
||||
// Clouds
|
||||
if (root.has("clouds")) {
|
||||
weatherData.setCloudiness(getInteger(root.get("clouds"), "all"));
|
||||
log.debug("Extracted cloudiness: {}%", weatherData.getCloudiness());
|
||||
}
|
||||
|
||||
// Visibility
|
||||
if (root.has("visibility")) {
|
||||
weatherData.setVisibilityMeters(root.get("visibility").asInt());
|
||||
log.debug("Extracted visibility: {} meters", weatherData.getVisibilityMeters());
|
||||
}
|
||||
|
||||
// Rain
|
||||
if (root.has("rain")) {
|
||||
JsonNode rain = root.get("rain");
|
||||
if (rain.has("1h")) {
|
||||
weatherData.setPrecipitationMm(BigDecimal.valueOf(rain.get("1h").asDouble()));
|
||||
log.debug("Extracted rain: {} mm/h", weatherData.getPrecipitationMm());
|
||||
}
|
||||
}
|
||||
|
||||
// Snow
|
||||
if (root.has("snow")) {
|
||||
JsonNode snow = root.get("snow");
|
||||
if (snow.has("1h")) {
|
||||
weatherData.setSnowMm(BigDecimal.valueOf(snow.get("1h").asDouble()));
|
||||
log.debug("Extracted snow: {} mm/h", weatherData.getSnowMm());
|
||||
}
|
||||
}
|
||||
|
||||
// Sun times
|
||||
if (root.has("sys")) {
|
||||
JsonNode sys = root.get("sys");
|
||||
if (sys.has("sunrise")) {
|
||||
weatherData.setSunrise(LocalDateTime.ofInstant(
|
||||
Instant.ofEpochSecond(sys.get("sunrise").asLong()), ZoneId.systemDefault()));
|
||||
log.debug("Extracted sunrise: {}", weatherData.getSunrise());
|
||||
}
|
||||
if (sys.has("sunset")) {
|
||||
weatherData.setSunset(LocalDateTime.ofInstant(
|
||||
Instant.ofEpochSecond(sys.get("sunset").asLong()), ZoneId.systemDefault()));
|
||||
log.debug("Extracted sunset: {}", weatherData.getSunset());
|
||||
}
|
||||
}
|
||||
|
||||
weatherData.setFetchedAt(LocalDateTime.now());
|
||||
weatherData.setDataSource("open-meteo");
|
||||
weatherData.setDataSource("openweathermap");
|
||||
|
||||
log.info("Successfully parsed weather data: temp={}°C, condition='{}', wind={} m/s",
|
||||
weatherData.getTemperatureCelsius(), weatherData.getWeatherCondition(), weatherData.getWindSpeedMps());
|
||||
log.info("Successfully parsed complete weather data");
|
||||
log.debug("=== parseWeatherResponse END === success=true");
|
||||
return weatherData;
|
||||
|
||||
} catch (com.fasterxml.jackson.core.JsonProcessingException e) {
|
||||
log.error("Failed to parse weather response as JSON: {}", e.getMessage(), e);
|
||||
log.error("=== JSON PARSING ERROR ===");
|
||||
log.error("Failed to parse weather response as JSON");
|
||||
log.error("Response content: {}", response);
|
||||
log.error("Parse error: {}", e.getMessage(), e);
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
log.error("Unexpected error parsing weather response: {}", e.getMessage(), e);
|
||||
log.error("=== UNEXPECTED ERROR parsing weather response ===");
|
||||
log.error("Exception type: {}", e.getClass().getName());
|
||||
log.error("Error message: {}", e.getMessage());
|
||||
log.error("Response content: {}", response);
|
||||
log.error("Full stack trace:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private BigDecimal getHourlyBigDecimal(JsonNode hourly, String field, int index) {
|
||||
JsonNode array = hourly.get(field);
|
||||
if (array != null && index < array.size() && !array.get(index).isNull()) {
|
||||
return BigDecimal.valueOf(array.get(index).asDouble());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Integer getHourlyInteger(JsonNode hourly, String field, int index) {
|
||||
JsonNode array = hourly.get(field);
|
||||
if (array != null && index < array.size() && !array.get(index).isNull()) {
|
||||
return array.get(index).asInt();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map WMO weather code to a condition string.
|
||||
* See https://open-meteo.com/en/docs#weathervariables
|
||||
*/
|
||||
private String mapWmoCodeToCondition(Integer code) {
|
||||
if (code == null) return null;
|
||||
return switch (code) {
|
||||
case 0 -> "Clear";
|
||||
case 1, 2, 3 -> "Clouds";
|
||||
case 45, 48 -> "Fog";
|
||||
case 51, 53, 55 -> "Drizzle";
|
||||
case 56, 57 -> "Drizzle";
|
||||
case 61, 63, 65 -> "Rain";
|
||||
case 66, 67 -> "Rain";
|
||||
case 71, 73, 75, 77 -> "Snow";
|
||||
case 80, 81, 82 -> "Rain";
|
||||
case 85, 86 -> "Snow";
|
||||
case 95, 96, 99 -> "Thunderstorm";
|
||||
default -> "Unknown";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map WMO weather code to a human-readable description.
|
||||
*/
|
||||
private String mapWmoCodeToDescription(Integer code) {
|
||||
if (code == null) return null;
|
||||
return switch (code) {
|
||||
case 0 -> "clear sky";
|
||||
case 1 -> "mainly clear";
|
||||
case 2 -> "partly cloudy";
|
||||
case 3 -> "overcast";
|
||||
case 45 -> "fog";
|
||||
case 48 -> "depositing rime fog";
|
||||
case 51 -> "light drizzle";
|
||||
case 53 -> "moderate drizzle";
|
||||
case 55 -> "dense drizzle";
|
||||
case 56 -> "light freezing drizzle";
|
||||
case 57 -> "dense freezing drizzle";
|
||||
case 61 -> "slight rain";
|
||||
case 63 -> "moderate rain";
|
||||
case 65 -> "heavy rain";
|
||||
case 66 -> "light freezing rain";
|
||||
case 67 -> "heavy freezing rain";
|
||||
case 71 -> "slight snow fall";
|
||||
case 73 -> "moderate snow fall";
|
||||
case 75 -> "heavy snow fall";
|
||||
case 77 -> "snow grains";
|
||||
case 80 -> "slight rain showers";
|
||||
case 81 -> "moderate rain showers";
|
||||
case 82 -> "violent rain showers";
|
||||
case 85 -> "slight snow showers";
|
||||
case 86 -> "heavy snow showers";
|
||||
case 95 -> "thunderstorm";
|
||||
case 96 -> "thunderstorm with slight hail";
|
||||
case 99 -> "thunderstorm with heavy hail";
|
||||
default -> "unknown";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get weather data for an activity.
|
||||
*
|
||||
|
|
@ -322,4 +363,43 @@ public class WeatherService {
|
|||
weatherDataRepository.deleteByActivityId(activityId);
|
||||
}
|
||||
|
||||
// Helper methods to safely extract values from JSON
|
||||
private BigDecimal getBigDecimal(JsonNode node, String field) {
|
||||
if (node != null && node.has(field) && !node.get(field).isNull()) {
|
||||
try {
|
||||
return BigDecimal.valueOf(node.get(field).asDouble());
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to extract BigDecimal from field '{}': {}", field, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
log.debug("Field '{}' not found or is null in node", field);
|
||||
return null;
|
||||
}
|
||||
|
||||
private Integer getInteger(JsonNode node, String field) {
|
||||
if (node != null && node.has(field) && !node.get(field).isNull()) {
|
||||
try {
|
||||
return node.get(field).asInt();
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to extract Integer from field '{}': {}", field, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
log.debug("Field '{}' not found or is null in node", field);
|
||||
return null;
|
||||
}
|
||||
|
||||
private String getString(JsonNode node, String field) {
|
||||
if (node != null && node.has(field) && !node.get(field).isNull()) {
|
||||
try {
|
||||
return node.get(field).asText();
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to extract String from field '{}': {}", field, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
log.debug("Field '{}' not found or is null in node", field);
|
||||
return 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<String, Object> build(Activity activity) {
|
||||
Map<String, Object> 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<String, Object> route = buildRoutePayload(activity);
|
||||
if (route != null) {
|
||||
workoutData.put("route", route);
|
||||
}
|
||||
|
||||
return workoutData;
|
||||
}
|
||||
|
||||
private Map<String, Object> buildRoutePayload(Activity activity) {
|
||||
List<PrivacyZone> privacyZones = privacyZoneService.getActivePrivacyZones(activity.getUserId());
|
||||
ActivityDTO dto = ActivityDTO.fromEntityWithFiltering(activity, null, privacyZones, trackPrivacyFilter);
|
||||
|
||||
if (dto.getSimplifiedTrack() == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Map<String, Object> feature = new HashMap<>();
|
||||
feature.put("type", "Feature");
|
||||
feature.put("geometry", dto.getSimplifiedTrack());
|
||||
|
||||
Map<String, Object> featureCollection = new HashMap<>();
|
||||
featureCollection.put("type", "FeatureCollection");
|
||||
featureCollection.put("features", List.of(feature));
|
||||
return featureCollection;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,13 @@
|
|||
package net.javahippie.fitpub.util;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.javahippie.fitpub.model.entity.Activity;
|
||||
|
||||
import java.time.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
|
||||
/**
|
||||
* Utility class for formatting activity-related data for display.
|
||||
*/
|
||||
@Slf4j
|
||||
public class ActivityFormatter {
|
||||
|
||||
/**
|
||||
|
|
@ -49,24 +48,20 @@ public class ActivityFormatter {
|
|||
* Format: "[Time of Day] [Activity Type]" (e.g., "Morning Run", "Evening Ride")
|
||||
*
|
||||
* @param startedAt the activity start time
|
||||
* @param timezone the timezone ID of the activity
|
||||
* @param activityType the activity type
|
||||
* @return generated title
|
||||
*/
|
||||
public static String generateActivityTitle(LocalDateTime startedAt, String timezone, Activity.ActivityType activityType) {
|
||||
public static String generateActivityTitle(LocalDateTime startedAt, Activity.ActivityType activityType) {
|
||||
if (startedAt == null || activityType == null) {
|
||||
return "Activity";
|
||||
}
|
||||
|
||||
LocalDateTime startedAtLocal = getUtcDateTimeInZone(startedAt, timezone);
|
||||
String timeOfDay = getTimeOfDay(startedAtLocal.toLocalTime());
|
||||
String timeOfDay = getTimeOfDay(startedAt.toLocalTime());
|
||||
String formattedType = formatActivityType(activityType);
|
||||
|
||||
return timeOfDay + " " + formattedType;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Determines the time of day based on the hour.
|
||||
*
|
||||
|
|
@ -86,29 +81,4 @@ public class ActivityFormatter {
|
|||
return "Night";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to convert the given LocalDateTime (which is assumed to be UTC) into a LocalDateTime in the given
|
||||
* timezone
|
||||
*
|
||||
* @param utcDateTime The original date and time (UTC)
|
||||
* @param timezone A timezone ID
|
||||
* @return The original date and time adjusted to the timezone, if the zone ID could be parsed. The original date
|
||||
* and time otherwise
|
||||
*
|
||||
*/
|
||||
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))
|
||||
.toLocalDateTime();
|
||||
} catch (DateTimeException e) {
|
||||
log.warn("Invalid time zone ID: {}", timezone);
|
||||
return utcDateTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
*
|
||||
* <pre>
|
||||
* [
|
||||
|
|
@ -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" }
|
||||
* }
|
||||
* ]
|
||||
* </pre>
|
||||
|
|
@ -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.
|
||||
*
|
||||
* <p>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<Object> extendedContext() {
|
||||
Map<String, Object> 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
|
||||
|
|
|
|||
|
|
@ -26,8 +26,6 @@ import java.util.ArrayList;
|
|||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static net.javahippie.fitpub.util.ParsedActivityData.MAX_TITLE_LENGTH;
|
||||
|
||||
/**
|
||||
* Parser for GPX (GPS Exchange Format) files.
|
||||
* Extracts GPS coordinates, activity metrics from track points.
|
||||
|
|
@ -82,12 +80,8 @@ public class GpxParser {
|
|||
// Calculate duration
|
||||
parsedData.setTotalDuration(Duration.between(firstPoint.getTimestamp(), lastPoint.getTimestamp()));
|
||||
|
||||
// Extract activity type and title from metadata
|
||||
Optional<Element> track = getFirstTrack(doc);
|
||||
if (track.isPresent()) {
|
||||
extractActivityType(track.get(), parsedData);
|
||||
extractActivityTitle(track.get(), parsedData);
|
||||
}
|
||||
// Extract activity type from metadata
|
||||
extractActivityType(doc, parsedData);
|
||||
|
||||
// Determine timezone from first GPS coordinate
|
||||
determineTimezone(parsedData);
|
||||
|
|
@ -117,8 +111,6 @@ public class GpxParser {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Extracts track points from GPX document.
|
||||
*/
|
||||
|
|
@ -253,41 +245,22 @@ public class GpxParser {
|
|||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Returns the first <trk> element from the GPS XML
|
||||
/**
|
||||
* Extracts activity type from GPX metadata.
|
||||
*/
|
||||
private Optional<Element> getFirstTrack(Document doc) {
|
||||
private void extractActivityType(Document doc, ParsedActivityData parsedData) {
|
||||
NodeList tracks = doc.getElementsByTagName("trk");
|
||||
if (tracks.getLength() == 0) {
|
||||
tracks = doc.getElementsByTagNameNS("*", "trk");
|
||||
}
|
||||
|
||||
return tracks.getLength() > 0 ? Optional.of((Element) tracks.item(0)) : Optional.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts activity type from GPX metadata.
|
||||
*/
|
||||
private void extractActivityType(Element track, ParsedActivityData parsedData) {
|
||||
if (tracks.getLength() > 0) {
|
||||
Element track = (Element) tracks.item(0);
|
||||
String type = getElementText(track, "type");
|
||||
if (type != null) {
|
||||
parsedData.setActivityType(mapGpxTypeToActivityType(type));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts activity title from GPX metadata.
|
||||
*/
|
||||
private void extractActivityTitle(Element track, ParsedActivityData parsedData) {
|
||||
String title = getElementText(track, "name");
|
||||
if (title != null) {
|
||||
String shortenedTitle = title;
|
||||
if (title.length() > MAX_TITLE_LENGTH) {
|
||||
log.debug("Activity title was shortened to {} characters: {}", MAX_TITLE_LENGTH, title);
|
||||
shortenedTitle = title.substring(0, MAX_TITLE_LENGTH);
|
||||
}
|
||||
parsedData.setTitle(shortenedTitle);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -20,9 +20,6 @@ import java.util.List;
|
|||
*/
|
||||
@Data
|
||||
public class ParsedActivityData {
|
||||
|
||||
static final int MAX_TITLE_LENGTH = 255;
|
||||
|
||||
private List<TrackPointData> trackPoints = new ArrayList<>();
|
||||
private LocalDateTime startTime;
|
||||
private LocalDateTime endTime;
|
||||
|
|
@ -33,7 +30,6 @@ public class ParsedActivityData {
|
|||
private BigDecimal elevationGain;
|
||||
private BigDecimal elevationLoss;
|
||||
private Activity.ActivityType activityType = Activity.ActivityType.OTHER;
|
||||
private String title;
|
||||
private ActivityMetricsData metrics;
|
||||
private String sourceFormat; // "FIT" or "GPX"
|
||||
private Boolean indoor = false; // Indicates if this is an indoor activity
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@
|
|||
# Activated with: mvn spring-boot:run -Dspring-boot.run.profiles=dev
|
||||
|
||||
spring:
|
||||
main:
|
||||
allow-bean-definition-overriding: true
|
||||
datasource:
|
||||
# For dev: Start PostgreSQL with: docker run -d --name fitpub-postgres -p 5432:5432 -e POSTGRES_DB=fitpub -e POSTGRES_USER=fitpub -e POSTGRES_PASSWORD=change_me_in_production postgis/postgis:16-3.4
|
||||
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/fitpub}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -434,7 +434,7 @@ function formatDateTimeWithTimezone(timestamp, timezone, options = {}) {
|
|||
|
||||
// Parse the timestamp - backend sends LocalDateTime without 'Z'
|
||||
// We need to interpret it in the specified timezone
|
||||
const date = new Date(ensureUTC(timestamp));
|
||||
const date = new Date(timestamp);
|
||||
|
||||
// Default options for date/time display
|
||||
const defaultOptions = {
|
||||
|
|
@ -473,17 +473,6 @@ function formatDateWithTimezone(timestamp, timezone) {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that a timestamp will be interpreted as UTC by new Date()
|
||||
* See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date (Date time string format)
|
||||
*
|
||||
* @param {string} timestamp - ISO timestamp or LocalDateTime string
|
||||
* @returns {string} The input string, but with a trailing 'Z'
|
||||
*/
|
||||
function ensureUTC(timestamp) {
|
||||
return timestamp.endsWith('Z') ? timestamp : timestamp + 'Z';
|
||||
}
|
||||
|
||||
// Make functions available globally for inline scripts
|
||||
window.FitPub = {
|
||||
createActivityMap,
|
||||
|
|
@ -493,6 +482,5 @@ window.FitPub = {
|
|||
formatDistance,
|
||||
formatPace,
|
||||
formatDateTimeWithTimezone,
|
||||
formatDateWithTimezone,
|
||||
ensureUTC
|
||||
formatDateWithTimezone
|
||||
};
|
||||
|
|
|
|||
|
|
@ -209,7 +209,7 @@ const FitPubTimeline = {
|
|||
@${this.escapeHtml(activity.username)}
|
||||
</a>
|
||||
${!activity.isLocal ? ' <span class="badge bg-info ms-1" title="Federated Activity"><i class="bi bi-globe2"></i> Remote</span>' : ''}
|
||||
• ${this.formatTimeAgo(activity.startedAt)} ${activity.activityLocation ? '•' : ''} ${activity.activityLocation}
|
||||
• ${this.formatTimeAgo(activity.startedAt)} • ${activity.activityLocation}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -727,7 +727,7 @@ const FitPubTimeline = {
|
|||
* @returns {string} Time ago string
|
||||
*/
|
||||
formatTimeAgo: function(timestamp) {
|
||||
const date = new Date(FitPub.ensureUTC(timestamp));
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const secondsAgo = Math.floor((now - date) / 1000);
|
||||
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@
|
|||
<span id="activityVisibility"></span>
|
||||
</span>
|
||||
</p>
|
||||
<p id="activityDescription" class="preserve-linebreaks text-muted"></p>
|
||||
<p id="activityDescription" class="text-muted"></p>
|
||||
</div>
|
||||
<div class="btn-group" role="group" id="activityActions" style="display: none;">
|
||||
<a href="#" id="editBtn" class="btn btn-outline-primary">
|
||||
|
|
|
|||
|
|
@ -212,7 +212,7 @@
|
|||
function populateForm(activity) {
|
||||
// Populate form fields
|
||||
document.getElementById('title').value = activity.title || '';
|
||||
document.getElementById('activityType').value = activity.activityType?.toUpperCase() || 'OTHER';
|
||||
document.getElementById('activityType').value = activity.activityType || 'OTHER';
|
||||
document.getElementById('description').value = activity.description || '';
|
||||
document.getElementById('visibility').value = activity.visibility || 'PUBLIC';
|
||||
document.getElementById('race').checked = activity.race || false;
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@
|
|||
<p class="text-muted mb-2">
|
||||
<span id="username"></span>
|
||||
</p>
|
||||
<p id="bio" class="mb-3 preserve-linebreaks"></p>
|
||||
<p id="bio" class="mb-3"></p>
|
||||
</div>
|
||||
<div id="followButtonContainer" class="d-none">
|
||||
<button class="btn btn-primary" id="followBtn">
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@
|
|||
<p class="text-muted mb-2">
|
||||
<span id="username"></span>
|
||||
</p>
|
||||
<p id="bio" class="mb-3 preserve-linebreaks"></p>
|
||||
<p id="bio" class="mb-3"></p>
|
||||
</div>
|
||||
<div>
|
||||
<a th:href="@{/profile/edit}" class="btn btn-outline-primary">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Map<String, Object>> 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<Map<String, Object>> 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<Object> context = (List<Object>) response.getBody().get("@context");
|
||||
assertThat(context).hasSize(2);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> extensions = (Map<String, Object>) context.get(1);
|
||||
assertThat(extensions)
|
||||
.containsEntry("fitpub", "https://fitpub.social/ns#")
|
||||
.containsEntry("workoutData", "fitpub:workoutData")
|
||||
.containsEntry("route", "fitpub:route");
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, Object> exportedNote = objectMapper.readValue(exportResult.getResponse().getContentAsByteArray(), Map.class);
|
||||
|
||||
Map<String, Object> 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<String, Object> workoutData = (Map<String, Object>) 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("<br\\s*/?>", "\n")
|
||||
.replaceAll("<p>", "")
|
||||
.replaceAll("</p>", "\n")
|
||||
.replaceAll("<[^>]+>", "")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("&", "&")
|
||||
.trim();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should process Undo Follow activity and remove follow relationship")
|
||||
void testProcessUndoFollowActivity() throws Exception {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<String, Object> 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<java.util.Map<String, Object>> 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<Map<String, Object>> noteCaptor = ArgumentCaptor.forClass(Map.class);
|
||||
|
||||
service.publishToFederationAsync(activityId, userId);
|
||||
|
||||
verify(federationService).sendCreateActivity(anyString(), noteCaptor.capture(), eq(testUser), eq(true));
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> workoutData = (Map<String, Object>) 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<String, Object> route = (Map<String, Object>) workoutData.get("route");
|
||||
assertThat(route).containsEntry("type", "FeatureCollection");
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> features = (List<Map<String, Object>>) route.get("features");
|
||||
assertThat(features).hasSize(1);
|
||||
assertThat(features.get(0)).containsEntry("type", "Feature");
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> geometry = (Map<String, Object>) 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)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, Object> note = Map.of(
|
||||
"id", "https://fitpub.example.com/activities/123",
|
||||
"type", "Note",
|
||||
"name", "Lunch Run",
|
||||
"content", "<p>Sunny run</p>",
|
||||
"published", "2026-05-02T09:24:50.921241",
|
||||
"to", List.of("https://www.w3.org/ns/activitystreams#Public")
|
||||
);
|
||||
|
||||
Map<String, Object> activity = Map.of(
|
||||
"type", "Create",
|
||||
"actor", remoteActorUri,
|
||||
"object", note
|
||||
);
|
||||
|
||||
ArgumentCaptor<net.javahippie.fitpub.model.entity.RemoteActivity> 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<String, Object> 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<String, Object> note = Map.of(
|
||||
"id", "https://fitpub.example.com/activities/456",
|
||||
"type", "Note",
|
||||
"name", "Kraremanns Lauf 2026",
|
||||
"content", "<p>Kraremanns Lauf 2026</p><p>Run · 9.80 km · 41:09</p><p>Legacy content fallback</p>",
|
||||
"published", "2026-05-02T09:24:50.921241",
|
||||
"to", List.of("https://www.w3.org/ns/activitystreams#Public"),
|
||||
"workoutData", workoutData
|
||||
);
|
||||
|
||||
Map<String, Object> activity = Map.of(
|
||||
"type", "Create",
|
||||
"actor", remoteActorUri,
|
||||
"object", note
|
||||
);
|
||||
|
||||
ArgumentCaptor<RemoteActivity> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -18,14 +18,13 @@ import org.springframework.web.client.ResourceAccessException;
|
|||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.net.URI;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
|
|
@ -50,26 +49,47 @@ class WeatherServiceTest {
|
|||
private Activity testActivity;
|
||||
private UUID activityId;
|
||||
|
||||
// Sample Open-Meteo archive API response (clear sky, WMO code 0)
|
||||
// Sample OpenWeatherMap API response
|
||||
private static final String SAMPLE_WEATHER_RESPONSE = """
|
||||
{
|
||||
"latitude": 49.98,
|
||||
"longitude": 8.26,
|
||||
"hourly": {
|
||||
"time": ["2025-11-23T17:00", "2025-11-23T18:00", "2025-11-23T19:00"],
|
||||
"temperature_2m": [14.0, 15.5, 13.8],
|
||||
"apparent_temperature": [12.5, 14.2, 12.0],
|
||||
"relative_humidity_2m": [60, 65, 70],
|
||||
"surface_pressure": [1012, 1013, 1012],
|
||||
"wind_speed_10m": [10.0, 12.6, 11.0],
|
||||
"wind_direction_10m": [170, 180, 190],
|
||||
"cloud_cover": [15, 20, 25],
|
||||
"rain": [0.0, 0.0, 0.0],
|
||||
"snowfall": [0.0, 0.0, 0.0],
|
||||
"precipitation": [0.0, 0.0, 0.0],
|
||||
"visibility": [10000, 10000, 10000],
|
||||
"weather_code": [0, 0, 1]
|
||||
"coord": {"lon": 8.2552, "lat": 49.9894},
|
||||
"weather": [
|
||||
{
|
||||
"id": 800,
|
||||
"main": "Clear",
|
||||
"description": "clear sky",
|
||||
"icon": "01d"
|
||||
}
|
||||
],
|
||||
"base": "stations",
|
||||
"main": {
|
||||
"temp": 15.5,
|
||||
"feels_like": 14.2,
|
||||
"temp_min": 13.0,
|
||||
"temp_max": 17.0,
|
||||
"pressure": 1013,
|
||||
"humidity": 65
|
||||
},
|
||||
"visibility": 10000,
|
||||
"wind": {
|
||||
"speed": 3.5,
|
||||
"deg": 180
|
||||
},
|
||||
"clouds": {
|
||||
"all": 20
|
||||
},
|
||||
"dt": 1700758089,
|
||||
"sys": {
|
||||
"type": 2,
|
||||
"id": 2012516,
|
||||
"country": "DE",
|
||||
"sunrise": 1700721600,
|
||||
"sunset": 1700757600
|
||||
},
|
||||
"timezone": 3600,
|
||||
"id": 2873891,
|
||||
"name": "Mannheim",
|
||||
"cod": 200
|
||||
}
|
||||
""";
|
||||
|
||||
|
|
@ -78,11 +98,12 @@ class WeatherServiceTest {
|
|||
activityId = UUID.randomUUID();
|
||||
testActivity = new Activity();
|
||||
testActivity.setId(activityId);
|
||||
testActivity.setStartedAt(LocalDateTime.of(2025, 11, 23, 18, 8, 9));
|
||||
testActivity.setStartedAt(LocalDateTime.now().minusDays(1)); // Recent activity
|
||||
|
||||
// Inject the real RestTemplate mock and set config values
|
||||
ReflectionTestUtils.setField(weatherService, "restTemplate", restTemplate);
|
||||
ReflectionTestUtils.setField(weatherService, "weatherEnabled", true);
|
||||
ReflectionTestUtils.setField(weatherService, "apiKey", "test-api-key-12345678901234567890");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -102,7 +123,7 @@ class WeatherServiceTest {
|
|||
""";
|
||||
testActivity.setTrackPointsJson(trackPointsJson);
|
||||
|
||||
when(restTemplate.getForObject(anyString(), eq(String.class), any(Map.class)))
|
||||
when(restTemplate.getForObject(any(URI.class), eq(String.class)))
|
||||
.thenReturn(SAMPLE_WEATHER_RESPONSE);
|
||||
when(weatherDataRepository.save(any(WeatherData.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
|
@ -115,7 +136,7 @@ class WeatherServiceTest {
|
|||
assertEquals(new BigDecimal("15.5"), weatherData.getTemperatureCelsius());
|
||||
assertEquals("Clear", weatherData.getWeatherCondition());
|
||||
|
||||
verify(restTemplate, times(1)).getForObject(anyString(), eq(String.class), any(Map.class));
|
||||
verify(restTemplate, times(1)).getForObject(any(URI.class), eq(String.class));
|
||||
verify(weatherDataRepository, times(1)).save(any(WeatherData.class));
|
||||
}
|
||||
|
||||
|
|
@ -141,7 +162,7 @@ class WeatherServiceTest {
|
|||
""";
|
||||
testActivity.setTrackPointsJson(trackPointsJson);
|
||||
|
||||
when(restTemplate.getForObject(anyString(), eq(String.class), any(Map.class)))
|
||||
when(restTemplate.getForObject(any(URI.class), eq(String.class)))
|
||||
.thenReturn(SAMPLE_WEATHER_RESPONSE);
|
||||
when(weatherDataRepository.save(any(WeatherData.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
|
@ -155,13 +176,39 @@ class WeatherServiceTest {
|
|||
assertEquals(new BigDecimal("14.2"), weatherData.getFeelsLikeCelsius());
|
||||
assertEquals(65, weatherData.getHumidity());
|
||||
assertEquals(1013, weatherData.getPressure());
|
||||
assertEquals(new BigDecimal("3.50"), weatherData.getWindSpeedMps());
|
||||
assertEquals(new BigDecimal("3.5"), weatherData.getWindSpeedMps());
|
||||
assertEquals("clear sky", weatherData.getWeatherDescription());
|
||||
|
||||
verify(restTemplate, times(1)).getForObject(anyString(), eq(String.class), any(Map.class));
|
||||
verify(restTemplate, times(1)).getForObject(any(URI.class), eq(String.class));
|
||||
verify(weatherDataRepository, times(1)).save(any(WeatherData.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should return empty when weather is disabled in config")
|
||||
void testFetchWeather_Disabled() {
|
||||
ReflectionTestUtils.setField(weatherService, "weatherEnabled", false);
|
||||
testActivity.setTrackPointsJson("[{\"lat\":50.0,\"lon\":8.0}]");
|
||||
|
||||
Optional<WeatherData> result = weatherService.fetchWeatherForActivity(testActivity);
|
||||
|
||||
assertTrue(result.isEmpty());
|
||||
verify(weatherDataRepository, never()).save(any(WeatherData.class));
|
||||
verify(restTemplate, never()).getForObject(any(URI.class), eq(String.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should return empty when API key is not configured")
|
||||
void testFetchWeather_NoApiKey() {
|
||||
ReflectionTestUtils.setField(weatherService, "apiKey", "");
|
||||
testActivity.setTrackPointsJson("[{\"lat\":50.0,\"lon\":8.0}]");
|
||||
|
||||
Optional<WeatherData> result = weatherService.fetchWeatherForActivity(testActivity);
|
||||
|
||||
assertTrue(result.isEmpty());
|
||||
verify(weatherDataRepository, never()).save(any(WeatherData.class));
|
||||
verify(restTemplate, never()).getForObject(any(URI.class), eq(String.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should return empty when track points JSON is null")
|
||||
void testFetchWeather_NoTrackPoints() {
|
||||
|
|
@ -213,7 +260,20 @@ class WeatherServiceTest {
|
|||
|
||||
assertTrue(result.isEmpty());
|
||||
verify(weatherDataRepository, never()).save(any(WeatherData.class));
|
||||
verify(restTemplate, never()).getForObject(anyString(), eq(String.class), any(Map.class));
|
||||
verify(restTemplate, never()).getForObject(any(URI.class), eq(String.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should return empty for old activities (>5 days)")
|
||||
void testFetchWeather_OldActivity() {
|
||||
testActivity.setStartedAt(LocalDateTime.now().minusDays(10)); // Old activity
|
||||
testActivity.setTrackPointsJson("[{\"latitude\":50.0,\"longitude\":8.0}]");
|
||||
|
||||
Optional<WeatherData> result = weatherService.fetchWeatherForActivity(testActivity);
|
||||
|
||||
assertTrue(result.isEmpty());
|
||||
verify(restTemplate, never()).getForObject(any(URI.class), eq(String.class));
|
||||
verify(weatherDataRepository, never()).save(any(WeatherData.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -221,11 +281,11 @@ class WeatherServiceTest {
|
|||
void testFetchWeather_AuthenticationError() {
|
||||
testActivity.setTrackPointsJson("[{\"latitude\":50.0,\"longitude\":8.0}]");
|
||||
|
||||
when(restTemplate.getForObject(anyString(), eq(String.class), any(Map.class)))
|
||||
when(restTemplate.getForObject(any(URI.class), eq(String.class)))
|
||||
.thenThrow(new HttpClientErrorException(
|
||||
org.springframework.http.HttpStatus.UNAUTHORIZED,
|
||||
"Unauthorized",
|
||||
"{\"error\":true,\"reason\":\"Unauthorized\"}".getBytes(),
|
||||
"{\"cod\":401,\"message\":\"Invalid API key\"}".getBytes(),
|
||||
null
|
||||
));
|
||||
|
||||
|
|
@ -240,7 +300,7 @@ class WeatherServiceTest {
|
|||
void testFetchWeather_NetworkError() {
|
||||
testActivity.setTrackPointsJson("[{\"latitude\":50.0,\"longitude\":8.0}]");
|
||||
|
||||
when(restTemplate.getForObject(anyString(), eq(String.class), any(Map.class)))
|
||||
when(restTemplate.getForObject(any(URI.class), eq(String.class)))
|
||||
.thenThrow(new ResourceAccessException("Connection timeout"));
|
||||
|
||||
Optional<WeatherData> result = weatherService.fetchWeatherForActivity(testActivity);
|
||||
|
|
@ -254,7 +314,7 @@ class WeatherServiceTest {
|
|||
void testFetchWeather_MalformedResponse() {
|
||||
testActivity.setTrackPointsJson("[{\"latitude\":50.0,\"longitude\":8.0}]");
|
||||
|
||||
when(restTemplate.getForObject(anyString(), eq(String.class), any(Map.class)))
|
||||
when(restTemplate.getForObject(any(URI.class), eq(String.class)))
|
||||
.thenReturn("this is not valid JSON");
|
||||
|
||||
Optional<WeatherData> result = weatherService.fetchWeatherForActivity(testActivity);
|
||||
|
|
@ -268,29 +328,24 @@ class WeatherServiceTest {
|
|||
void testParseWeatherResponse_AllFields() {
|
||||
String responseWithRain = """
|
||||
{
|
||||
"latitude": 50.0,
|
||||
"longitude": 8.0,
|
||||
"hourly": {
|
||||
"time": ["2025-11-23T17:00", "2025-11-23T18:00", "2025-11-23T19:00"],
|
||||
"temperature_2m": [11.0, 10.0, 9.5],
|
||||
"apparent_temperature": [9.0, 8.5, 8.0],
|
||||
"relative_humidity_2m": [75, 80, 85],
|
||||
"surface_pressure": [1014, 1015, 1015],
|
||||
"wind_speed_10m": [18.0, 19.8, 20.0],
|
||||
"wind_direction_10m": [260, 270, 275],
|
||||
"cloud_cover": [70, 75, 80],
|
||||
"rain": [1.5, 2.5, 3.0],
|
||||
"snowfall": [0.0, 0.0, 0.0],
|
||||
"precipitation": [1.5, 2.5, 3.0],
|
||||
"visibility": [9000, 8000, 7000],
|
||||
"weather_code": [61, 61, 63]
|
||||
}
|
||||
"main": {
|
||||
"temp": 10.0,
|
||||
"feels_like": 8.5,
|
||||
"pressure": 1015,
|
||||
"humidity": 80
|
||||
},
|
||||
"weather": [{"main": "Rain", "description": "light rain", "icon": "10d"}],
|
||||
"wind": {"speed": 5.5, "deg": 270},
|
||||
"clouds": {"all": 75},
|
||||
"visibility": 8000,
|
||||
"rain": {"1h": 2.5},
|
||||
"sys": {"sunrise": 1700721600, "sunset": 1700757600}
|
||||
}
|
||||
""";
|
||||
|
||||
testActivity.setTrackPointsJson("[{\"latitude\":50.0,\"longitude\":8.0}]");
|
||||
|
||||
when(restTemplate.getForObject(anyString(), eq(String.class), any(Map.class)))
|
||||
when(restTemplate.getForObject(any(URI.class), eq(String.class)))
|
||||
.thenReturn(responseWithRain);
|
||||
when(weatherDataRepository.save(any(WeatherData.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
|
@ -302,13 +357,15 @@ class WeatherServiceTest {
|
|||
assertEquals(new BigDecimal("10.0"), weatherData.getTemperatureCelsius());
|
||||
assertEquals(new BigDecimal("8.5"), weatherData.getFeelsLikeCelsius());
|
||||
assertEquals("Rain", weatherData.getWeatherCondition());
|
||||
assertEquals("slight rain", weatherData.getWeatherDescription());
|
||||
assertEquals(new BigDecimal("5.50"), weatherData.getWindSpeedMps());
|
||||
assertEquals("light rain", weatherData.getWeatherDescription());
|
||||
assertEquals(new BigDecimal("5.5"), weatherData.getWindSpeedMps());
|
||||
assertEquals(270, weatherData.getWindDirection());
|
||||
assertEquals(75, weatherData.getCloudiness());
|
||||
assertEquals(8000, weatherData.getVisibilityMeters());
|
||||
assertEquals(new BigDecimal("2.5"), weatherData.getPrecipitationMm());
|
||||
assertEquals("open-meteo", weatherData.getDataSource());
|
||||
assertNotNull(weatherData.getSunrise());
|
||||
assertNotNull(weatherData.getSunset());
|
||||
assertEquals("openweathermap", weatherData.getDataSource());
|
||||
assertNotNull(weatherData.getFetchedAt());
|
||||
}
|
||||
|
||||
|
|
@ -317,29 +374,19 @@ class WeatherServiceTest {
|
|||
void testParseWeatherResponse_MinimalFields() {
|
||||
String minimalResponse = """
|
||||
{
|
||||
"latitude": 50.0,
|
||||
"longitude": 8.0,
|
||||
"hourly": {
|
||||
"time": ["2025-11-23T18:00"],
|
||||
"temperature_2m": [15.0],
|
||||
"apparent_temperature": [14.0],
|
||||
"relative_humidity_2m": [60],
|
||||
"surface_pressure": [1010],
|
||||
"wind_speed_10m": [null],
|
||||
"wind_direction_10m": [null],
|
||||
"cloud_cover": [null],
|
||||
"rain": [null],
|
||||
"snowfall": [null],
|
||||
"precipitation": [null],
|
||||
"visibility": [null],
|
||||
"weather_code": [2]
|
||||
}
|
||||
"main": {
|
||||
"temp": 15.0,
|
||||
"feels_like": 14.0,
|
||||
"pressure": 1010,
|
||||
"humidity": 60
|
||||
},
|
||||
"weather": [{"main": "Clouds", "description": "few clouds", "icon": "02d"}]
|
||||
}
|
||||
""";
|
||||
|
||||
testActivity.setTrackPointsJson("[{\"latitude\":50.0,\"longitude\":8.0}]");
|
||||
|
||||
when(restTemplate.getForObject(anyString(), eq(String.class), any(Map.class)))
|
||||
when(restTemplate.getForObject(any(URI.class), eq(String.class)))
|
||||
.thenReturn(minimalResponse);
|
||||
when(weatherDataRepository.save(any(WeatherData.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
|
@ -394,7 +441,7 @@ class WeatherServiceTest {
|
|||
""";
|
||||
testActivity.setTrackPointsJson(trackPointsJson);
|
||||
|
||||
when(restTemplate.getForObject(anyString(), eq(String.class), any(Map.class)))
|
||||
when(restTemplate.getForObject(any(URI.class), eq(String.class)))
|
||||
.thenReturn(SAMPLE_WEATHER_RESPONSE);
|
||||
when(weatherDataRepository.save(any(WeatherData.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
|
@ -402,6 +449,6 @@ class WeatherServiceTest {
|
|||
Optional<WeatherData> result = weatherService.fetchWeatherForActivity(testActivity);
|
||||
|
||||
assertTrue(result.isPresent());
|
||||
verify(restTemplate, times(1)).getForObject(anyString(), eq(String.class), any(Map.class));
|
||||
verify(restTemplate, times(1)).getForObject(any(URI.class), eq(String.class));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, Object> 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<String, Object> route = (Map<String, Object>) workoutData.get("route");
|
||||
assertThat(route).containsEntry("type", "FeatureCollection");
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> features = (List<Map<String, Object>>) route.get("features");
|
||||
assertThat(features).hasSize(1);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> geometry = (Map<String, Object>) 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)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -112,10 +112,6 @@ class GpxParserIntegrationTest {
|
|||
|
||||
// Verify at least some basic data
|
||||
assertNotNull(parsedData.getActivityType(), "Activity type should be determined");
|
||||
String parsedTitle = parsedData.getTitle();
|
||||
assertEquals(255, parsedTitle.length());
|
||||
assertTrue(parsedTitle.startsWith("Einmal Frust loswerden"));
|
||||
assertFalse(parsedTitle.contains("Shouldn't appear"));
|
||||
assertEquals(Activity.ActivityType.RUN, parsedData.getActivityType(),
|
||||
"Activity type should be RUN (from GPX <type>running</type>)");
|
||||
assertTrue(parsedData.getTrackPoints().size() > 0, "Should have at least one track point");
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<time>2022-07-03T19:47:51Z</time>
|
||||
</metadata>
|
||||
<trk>
|
||||
<name>Einmal Frust loswerden blafasel blafasel blafasel blafasel blafasel blafasel blafasel blafasel blafasel blafasel blafasel blafasel blafasel blafasel blafasel blafasel blafasel blafasel blafasel blafasel blafasel blafasel blafasel blafasel blafasel blafasel Shouldn't appear</name>
|
||||
<name>Einmal Frust loswerden</name>
|
||||
<type>running</type>
|
||||
<trkseg>
|
||||
<trkpt lat="48.0140070" lon="7.8513840">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue