Compare commits
15 commits
kabupatix/
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8097d876e5 | |||
| b88e6b0a95 | |||
| de1b0d56f4 | |||
| 9c1b484865 | |||
| 2889bdc529 | |||
| c84377b05a | |||
| 330040c775 | |||
| f47730e1ca | |||
|
|
9e529f8b99 | ||
|
|
d79678aae3 | ||
|
|
102d515b42 | ||
|
|
5df4da86a5 | ||
|
|
5f85417c80 | ||
|
|
fb440b2b8f | ||
|
|
03b8e6d315 |
49 changed files with 1997 additions and 486 deletions
12
.gitignore
vendored
12
.gitignore
vendored
|
|
@ -5,10 +5,7 @@ target/
|
||||||
.kotlin
|
.kotlin
|
||||||
|
|
||||||
### IntelliJ IDEA ###
|
### IntelliJ IDEA ###
|
||||||
.idea/modules.xml
|
.idea/
|
||||||
.idea/jarRepositories.xml
|
|
||||||
.idea/compiler.xml
|
|
||||||
.idea/libraries/
|
|
||||||
*.iws
|
*.iws
|
||||||
*.iml
|
*.iml
|
||||||
*.ipr
|
*.ipr
|
||||||
|
|
@ -49,3 +46,10 @@ logs/
|
||||||
/gadm_410.gpkg
|
/gadm_410.gpkg
|
||||||
/.postgresdata/
|
/.postgresdata/
|
||||||
/peaks_worldwide.geojson
|
/peaks_worldwide.geojson
|
||||||
|
|
||||||
|
### Coding Assistants ###
|
||||||
|
.codex/
|
||||||
|
.aider*
|
||||||
|
.cursor/
|
||||||
|
.roo/
|
||||||
|
.windsurf/
|
||||||
|
|
|
||||||
8
.idea/.gitignore
generated
vendored
8
.idea/.gitignore
generated
vendored
|
|
@ -1,8 +0,0 @@
|
||||||
# Default ignored files
|
|
||||||
/shelf/
|
|
||||||
/workspace.xml
|
|
||||||
# Editor-based HTTP Client requests
|
|
||||||
/httpRequests/
|
|
||||||
# Datasource local storage ignored files
|
|
||||||
/dataSources/
|
|
||||||
/dataSources.local.xml
|
|
||||||
17
.idea/dataSources.xml
generated
17
.idea/dataSources.xml
generated
|
|
@ -1,17 +0,0 @@
|
||||||
<?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
6
.idea/data_source_mapping.xml
generated
|
|
@ -1,6 +0,0 @@
|
||||||
<?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
7
.idea/encodings.xml
generated
|
|
@ -1,7 +0,0 @@
|
||||||
<?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
17
.idea/misc.xml
generated
|
|
@ -1,17 +0,0 @@
|
||||||
<?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
8
.idea/sqldialects.xml
generated
|
|
@ -1,8 +0,0 @@
|
||||||
<?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
6
.idea/vcs.xml
generated
|
|
@ -1,6 +0,0 @@
|
||||||
<?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
Normal file
2
.mvn/wrapper/maven-wrapper.properties
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
distributionType=only-script
|
||||||
|
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.15/apache-maven-3.9.15-bin.zip
|
||||||
3
.sdkmanrc
Normal file
3
.sdkmanrc
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Enable auto-env through the sdkman_auto_env config
|
||||||
|
# Add key=value pairs of SDKs to use below
|
||||||
|
java=17.0.9-tem
|
||||||
295
mvnw
vendored
Executable file
295
mvnw
vendored
Executable file
|
|
@ -0,0 +1,295 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
# or more contributor license agreements. See the NOTICE file
|
||||||
|
# distributed with this work for additional information
|
||||||
|
# regarding copyright ownership. The ASF licenses this file
|
||||||
|
# to you under the Apache License, Version 2.0 (the
|
||||||
|
# "License"); you may not use this file except in compliance
|
||||||
|
# with the License. You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing,
|
||||||
|
# software distributed under the License is distributed on an
|
||||||
|
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
# KIND, either express or implied. See the License for the
|
||||||
|
# specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Apache Maven Wrapper startup batch script, version 3.3.4
|
||||||
|
#
|
||||||
|
# Optional ENV vars
|
||||||
|
# -----------------
|
||||||
|
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
|
||||||
|
# MVNW_REPOURL - repo url base for downloading maven distribution
|
||||||
|
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||||
|
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
set -euf
|
||||||
|
[ "${MVNW_VERBOSE-}" != debug ] || set -x
|
||||||
|
|
||||||
|
# OS specific support.
|
||||||
|
native_path() { printf %s\\n "$1"; }
|
||||||
|
case "$(uname)" in
|
||||||
|
CYGWIN* | MINGW*)
|
||||||
|
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
|
||||||
|
native_path() { cygpath --path --windows "$1"; }
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# set JAVACMD and JAVACCMD
|
||||||
|
set_java_home() {
|
||||||
|
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
|
||||||
|
if [ -n "${JAVA_HOME-}" ]; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||||
|
JAVACCMD="$JAVA_HOME/jre/sh/javac"
|
||||||
|
else
|
||||||
|
JAVACMD="$JAVA_HOME/bin/java"
|
||||||
|
JAVACCMD="$JAVA_HOME/bin/javac"
|
||||||
|
|
||||||
|
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
|
||||||
|
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
|
||||||
|
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD="$(
|
||||||
|
'set' +e
|
||||||
|
'unset' -f command 2>/dev/null
|
||||||
|
'command' -v java
|
||||||
|
)" || :
|
||||||
|
JAVACCMD="$(
|
||||||
|
'set' +e
|
||||||
|
'unset' -f command 2>/dev/null
|
||||||
|
'command' -v javac
|
||||||
|
)" || :
|
||||||
|
|
||||||
|
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
|
||||||
|
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# hash string like Java String::hashCode
|
||||||
|
hash_string() {
|
||||||
|
str="${1:-}" h=0
|
||||||
|
while [ -n "$str" ]; do
|
||||||
|
char="${str%"${str#?}"}"
|
||||||
|
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
|
||||||
|
str="${str#?}"
|
||||||
|
done
|
||||||
|
printf %x\\n $h
|
||||||
|
}
|
||||||
|
|
||||||
|
verbose() { :; }
|
||||||
|
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
|
||||||
|
|
||||||
|
die() {
|
||||||
|
printf %s\\n "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
trim() {
|
||||||
|
# MWRAPPER-139:
|
||||||
|
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
|
||||||
|
# Needed for removing poorly interpreted newline sequences when running in more
|
||||||
|
# exotic environments such as mingw bash on Windows.
|
||||||
|
printf "%s" "${1}" | tr -d '[:space:]'
|
||||||
|
}
|
||||||
|
|
||||||
|
scriptDir="$(dirname "$0")"
|
||||||
|
scriptName="$(basename "$0")"
|
||||||
|
|
||||||
|
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
|
||||||
|
while IFS="=" read -r key value; do
|
||||||
|
case "${key-}" in
|
||||||
|
distributionUrl) distributionUrl=$(trim "${value-}") ;;
|
||||||
|
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
|
||||||
|
esac
|
||||||
|
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||||
|
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||||
|
|
||||||
|
case "${distributionUrl##*/}" in
|
||||||
|
maven-mvnd-*bin.*)
|
||||||
|
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
|
||||||
|
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
|
||||||
|
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
|
||||||
|
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
|
||||||
|
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
|
||||||
|
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
|
||||||
|
*)
|
||||||
|
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
|
||||||
|
distributionPlatform=linux-amd64
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
|
||||||
|
;;
|
||||||
|
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
|
||||||
|
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||||
|
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<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
Normal file
189
mvnw.cmd
vendored
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
<# : batch portion
|
||||||
|
@REM ----------------------------------------------------------------------------
|
||||||
|
@REM Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
@REM or more contributor license agreements. See the NOTICE file
|
||||||
|
@REM distributed with this work for additional information
|
||||||
|
@REM regarding copyright ownership. The ASF licenses this file
|
||||||
|
@REM to you under the Apache License, Version 2.0 (the
|
||||||
|
@REM "License"); you may not use this file except in compliance
|
||||||
|
@REM with the License. You may obtain a copy of the License at
|
||||||
|
@REM
|
||||||
|
@REM http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@REM
|
||||||
|
@REM Unless required by applicable law or agreed to in writing,
|
||||||
|
@REM software distributed under the License is distributed on an
|
||||||
|
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
@REM KIND, either express or implied. See the License for the
|
||||||
|
@REM specific language governing permissions and limitations
|
||||||
|
@REM under the License.
|
||||||
|
@REM ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@REM ----------------------------------------------------------------------------
|
||||||
|
@REM Apache Maven Wrapper startup batch script, version 3.3.4
|
||||||
|
@REM
|
||||||
|
@REM Optional ENV vars
|
||||||
|
@REM MVNW_REPOURL - repo url base for downloading maven distribution
|
||||||
|
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||||
|
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
|
||||||
|
@REM ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
|
||||||
|
@SET __MVNW_CMD__=
|
||||||
|
@SET __MVNW_ERROR__=
|
||||||
|
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
|
||||||
|
@SET PSModulePath=
|
||||||
|
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
|
||||||
|
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
|
||||||
|
)
|
||||||
|
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
|
||||||
|
@SET __MVNW_PSMODULEP_SAVE=
|
||||||
|
@SET __MVNW_ARG0_NAME__=
|
||||||
|
@SET MVNW_USERNAME=
|
||||||
|
@SET MVNW_PASSWORD=
|
||||||
|
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
|
||||||
|
@echo Cannot start maven from wrapper >&2 && exit /b 1
|
||||||
|
@GOTO :EOF
|
||||||
|
: end batch / begin powershell #>
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
if ($env:MVNW_VERBOSE -eq "true") {
|
||||||
|
$VerbosePreference = "Continue"
|
||||||
|
}
|
||||||
|
|
||||||
|
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
|
||||||
|
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
|
||||||
|
if (!$distributionUrl) {
|
||||||
|
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
|
||||||
|
"maven-mvnd-*" {
|
||||||
|
$USE_MVND = $true
|
||||||
|
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
|
||||||
|
$MVN_CMD = "mvnd.cmd"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default {
|
||||||
|
$USE_MVND = $false
|
||||||
|
$MVN_CMD = $script -replace '^mvnw','mvn'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||||
|
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<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>
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
<java.version>17</java.version>
|
<java.version>17</java.version>
|
||||||
<jjwt.version>0.12.3</jjwt.version>
|
<jjwt.version>0.12.3</jjwt.version>
|
||||||
<testcontainers.version>2.0.3</testcontainers.version>
|
<testcontainers.version>2.0.5</testcontainers.version>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
|
|
@ -170,15 +170,14 @@
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.testcontainers</groupId>
|
<groupId>org.testcontainers</groupId>
|
||||||
<artifactId>testcontainers-junit-jupiter</artifactId>
|
<artifactId>testcontainers-junit-jupiter</artifactId>
|
||||||
<version>2.0.2</version>
|
<version>${testcontainers.version}</version>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.testcontainers</groupId>
|
<groupId>org.testcontainers</groupId>
|
||||||
<artifactId>testcontainers-postgresql</artifactId>
|
<artifactId>testcontainers-postgresql</artifactId>
|
||||||
<version>2.0.1</version>
|
<version>${testcontainers.version}</version>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,14 @@ import net.javahippie.fitpub.model.activitypub.OrderedCollection;
|
||||||
import net.javahippie.fitpub.model.entity.Activity;
|
import net.javahippie.fitpub.model.entity.Activity;
|
||||||
import net.javahippie.fitpub.model.entity.RemoteActor;
|
import net.javahippie.fitpub.model.entity.RemoteActor;
|
||||||
import net.javahippie.fitpub.model.entity.User;
|
import net.javahippie.fitpub.model.entity.User;
|
||||||
import net.javahippie.fitpub.repository.FollowRepository;
|
|
||||||
import net.javahippie.fitpub.repository.ActivityRepository;
|
import net.javahippie.fitpub.repository.ActivityRepository;
|
||||||
|
import net.javahippie.fitpub.repository.FollowRepository;
|
||||||
import net.javahippie.fitpub.repository.UserRepository;
|
import net.javahippie.fitpub.repository.UserRepository;
|
||||||
import net.javahippie.fitpub.security.HttpSignatureValidator;
|
import net.javahippie.fitpub.security.HttpSignatureValidator;
|
||||||
import net.javahippie.fitpub.service.ActivityImageService;
|
import net.javahippie.fitpub.service.ActivityImageService;
|
||||||
import net.javahippie.fitpub.service.FederationService;
|
import net.javahippie.fitpub.service.FederationService;
|
||||||
import net.javahippie.fitpub.service.InboxProcessor;
|
import net.javahippie.fitpub.service.InboxProcessor;
|
||||||
|
import net.javahippie.fitpub.service.WorkoutDataPayloadBuilder;
|
||||||
import net.javahippie.fitpub.util.ActivityFormatter;
|
import net.javahippie.fitpub.util.ActivityFormatter;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
|
|
@ -29,6 +30,7 @@ import java.net.URI;
|
||||||
import java.net.URISyntaxException;
|
import java.net.URISyntaxException;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
|
@ -51,6 +53,7 @@ public class ActivityPubController {
|
||||||
private final HttpSignatureValidator signatureValidator;
|
private final HttpSignatureValidator signatureValidator;
|
||||||
private final FederationService federationService;
|
private final FederationService federationService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
private final WorkoutDataPayloadBuilder workoutDataPayloadBuilder;
|
||||||
|
|
||||||
@Value("${fitpub.base-url}")
|
@Value("${fitpub.base-url}")
|
||||||
private String baseUrl;
|
private String baseUrl;
|
||||||
|
|
@ -436,9 +439,10 @@ public class ActivityPubController {
|
||||||
noteObject.put("id", activityUri);
|
noteObject.put("id", activityUri);
|
||||||
noteObject.put("type", "Note");
|
noteObject.put("type", "Note");
|
||||||
noteObject.put("attributedTo", actorUri);
|
noteObject.put("attributedTo", actorUri);
|
||||||
noteObject.put("published", activity.getCreatedAt().toString());
|
noteObject.put("published", activity.getCreatedAt().atOffset(ZoneOffset.UTC).toInstant().toString());
|
||||||
noteObject.put("content", formatActivityContent(activity));
|
noteObject.put("content", formatActivityContent(activity));
|
||||||
noteObject.put("url", activityUri);
|
noteObject.put("url", activityUri);
|
||||||
|
noteObject.put("workoutData", workoutDataPayloadBuilder.build(activity));
|
||||||
|
|
||||||
// Audience — only PUBLIC activities reach this endpoint (the visibility
|
// Audience — only PUBLIC activities reach this endpoint (the visibility
|
||||||
// check above returned 403 for anything else), so audience is always
|
// check above returned 403 for anything else), so audience is always
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,7 @@ public class ActivityDTO {
|
||||||
.elevationLoss(activity.getElevationLoss())
|
.elevationLoss(activity.getElevationLoss())
|
||||||
.createdAt(activity.getCreatedAt())
|
.createdAt(activity.getCreatedAt())
|
||||||
.updatedAt(activity.getUpdatedAt())
|
.updatedAt(activity.getUpdatedAt())
|
||||||
.activityLocation(activity.getActivityLocation());
|
.activityLocation(activity.getActivityLocationNonNull());
|
||||||
|
|
||||||
if (activity.getTotalDurationSeconds() != null) {
|
if (activity.getTotalDurationSeconds() != null) {
|
||||||
builder.totalDurationSeconds(activity.getTotalDurationSeconds());
|
builder.totalDurationSeconds(activity.getTotalDurationSeconds());
|
||||||
|
|
@ -246,7 +246,7 @@ public class ActivityDTO {
|
||||||
.subSport(activity.getSubSport())
|
.subSport(activity.getSubSport())
|
||||||
.indoorDetectionMethod(activity.getIndoorDetectionMethod())
|
.indoorDetectionMethod(activity.getIndoorDetectionMethod())
|
||||||
.race(activity.getRace() != null ? activity.getRace() : false)
|
.race(activity.getRace() != null ? activity.getRace() : false)
|
||||||
.activityLocation(activity.getActivityLocation())
|
.activityLocation(activity.getActivityLocationNonNull())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -266,7 +266,7 @@ public class ActivityDTO {
|
||||||
.elevationLoss(activity.getElevationLoss())
|
.elevationLoss(activity.getElevationLoss())
|
||||||
.createdAt(activity.getCreatedAt())
|
.createdAt(activity.getCreatedAt())
|
||||||
.updatedAt(activity.getUpdatedAt())
|
.updatedAt(activity.getUpdatedAt())
|
||||||
.activityLocation(activity.getActivityLocation());
|
.activityLocation(activity.getActivityLocationNonNull());
|
||||||
|
|
||||||
if (activity.getTotalDurationSeconds() != null) {
|
if (activity.getTotalDurationSeconds() != null) {
|
||||||
builder.totalDurationSeconds(activity.getTotalDurationSeconds());
|
builder.totalDurationSeconds(activity.getTotalDurationSeconds());
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@ public class TimelineActivityDTO {
|
||||||
.indoorDetectionMethod(activity.getIndoorDetectionMethod())
|
.indoorDetectionMethod(activity.getIndoorDetectionMethod())
|
||||||
.race(activity.getRace() != null ? activity.getRace() : false)
|
.race(activity.getRace() != null ? activity.getRace() : false)
|
||||||
.metrics(activity.getMetrics() != null ? ActivityMetricsSummary.fromMetrics(activity.getMetrics()) : null)
|
.metrics(activity.getMetrics() != null ? ActivityMetricsSummary.fromMetrics(activity.getMetrics()) : null)
|
||||||
.activityLocation(activity.getActivityLocation())
|
.activityLocation(activity.getActivityLocationNonNull())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -214,6 +214,10 @@ public class Activity {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getActivityLocationNonNull() {
|
||||||
|
return activityLocation != null ? activityLocation : "";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Activity types supported by the platform
|
* Activity types supported by the platform
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import lombok.NoArgsConstructor;
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
import org.hibernate.annotations.Type;
|
import org.hibernate.annotations.Type;
|
||||||
import org.hibernate.annotations.UpdateTimestamp;
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
|
import org.locationtech.jts.geom.LineString;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
@ -137,6 +138,12 @@ public class RemoteActivity {
|
||||||
@Column(name = "track_geojson_url", length = 512)
|
@Column(name = "track_geojson_url", length = 512)
|
||||||
private String trackGeojsonUrl;
|
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.
|
* Visibility level of the activity.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -378,10 +378,17 @@ public class ActivityFileService {
|
||||||
byte[] rawFile,
|
byte[] rawFile,
|
||||||
ProcessingOptions options
|
ProcessingOptions options
|
||||||
) throws JsonProcessingException {
|
) throws JsonProcessingException {
|
||||||
// Generate title if not provided
|
String activityTitle;
|
||||||
String activityTitle = title != null && !title.isBlank()
|
if (title != null && !title.isBlank()) {
|
||||||
? title
|
activityTitle = title;
|
||||||
: ActivityFormatter.generateActivityTitle(parsedData.getStartTime(), parsedData.getActivityType());
|
} 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());
|
||||||
|
}
|
||||||
|
|
||||||
// Default to PUBLIC if visibility not specified
|
// Default to PUBLIC if visibility not specified
|
||||||
Activity.Visibility activityVisibility = visibility != null ? visibility : Activity.Visibility.PRIVATE;
|
Activity.Visibility activityVisibility = visibility != null ? visibility : Activity.Visibility.PRIVATE;
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.scheduling.annotation.Async;
|
import org.springframework.scheduling.annotation.Async;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.ZoneOffset;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
@ -38,6 +39,7 @@ public class ActivityPostProcessingService {
|
||||||
private final ActivityImageService activityImageService;
|
private final ActivityImageService activityImageService;
|
||||||
private final ActivityRepository activityRepository;
|
private final ActivityRepository activityRepository;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
|
private final WorkoutDataPayloadBuilder workoutDataPayloadBuilder;
|
||||||
|
|
||||||
@Value("${fitpub.base-url}")
|
@Value("${fitpub.base-url}")
|
||||||
private String baseUrl;
|
private String baseUrl;
|
||||||
|
|
@ -199,9 +201,10 @@ public class ActivityPostProcessingService {
|
||||||
noteObject.put("id", activityUri);
|
noteObject.put("id", activityUri);
|
||||||
noteObject.put("type", "Note");
|
noteObject.put("type", "Note");
|
||||||
noteObject.put("attributedTo", actorUri);
|
noteObject.put("attributedTo", actorUri);
|
||||||
noteObject.put("published", activity.getCreatedAt().toString());
|
noteObject.put("published", activity.getCreatedAt().atOffset(ZoneOffset.UTC).toInstant().toString());
|
||||||
noteObject.put("content", formatActivityContent(activity));
|
noteObject.put("content", formatActivityContent(activity));
|
||||||
noteObject.put("url", baseUrl + "/activities/" + activity.getId());
|
noteObject.put("url", baseUrl + "/activities/" + activity.getId());
|
||||||
|
noteObject.put("workoutData", workoutDataPayloadBuilder.build(activity));
|
||||||
|
|
||||||
// Extract hashtags from user text and add as tags
|
// Extract hashtags from user text and add as tags
|
||||||
List<String> hashtags = extractHashtags(activity);
|
List<String> hashtags = extractHashtags(activity);
|
||||||
|
|
|
||||||
|
|
@ -252,6 +252,37 @@ 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.
|
* Send an activity to a remote inbox.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@ import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
|
@ -257,7 +256,7 @@ public class FitFileService {
|
||||||
private String generateTitle(ParsedActivityData parsedData) {
|
private String generateTitle(ParsedActivityData parsedData) {
|
||||||
return ActivityFormatter.generateActivityTitle(
|
return ActivityFormatter.generateActivityTitle(
|
||||||
parsedData.getStartTime(),
|
parsedData.getStartTime(),
|
||||||
parsedData.getActivityType()
|
parsedData.getTimezone(), parsedData.getActivityType()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,20 @@ import net.javahippie.fitpub.repository.CommentRepository;
|
||||||
import net.javahippie.fitpub.repository.FollowRepository;
|
import net.javahippie.fitpub.repository.FollowRepository;
|
||||||
import net.javahippie.fitpub.repository.LikeRepository;
|
import net.javahippie.fitpub.repository.LikeRepository;
|
||||||
import net.javahippie.fitpub.repository.UserRepository;
|
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.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.Instant;
|
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.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
|
@ -31,6 +40,9 @@ import java.util.UUID;
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class InboxProcessor {
|
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 UserRepository userRepository;
|
||||||
private final FollowRepository followRepository;
|
private final FollowRepository followRepository;
|
||||||
|
|
@ -238,6 +250,19 @@ public class InboxProcessor {
|
||||||
return;
|
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");
|
String inReplyTo = (String) noteObject.get("inReplyTo");
|
||||||
|
|
||||||
if (inReplyTo == null) {
|
if (inReplyTo == null) {
|
||||||
|
|
@ -252,6 +277,51 @@ 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).
|
* Process a comment (Note with inReplyTo).
|
||||||
*/
|
*/
|
||||||
|
|
@ -353,15 +423,18 @@ public class InboxProcessor {
|
||||||
|
|
||||||
// Parse published timestamp
|
// Parse published timestamp
|
||||||
String publishedStr = (String) noteObject.get("published");
|
String publishedStr = (String) noteObject.get("published");
|
||||||
Instant publishedAt = publishedStr != null ? Instant.parse(publishedStr) : Instant.now();
|
Instant publishedAt = parsePublishedAt(publishedStr);
|
||||||
|
|
||||||
// Build RemoteActivity entity
|
// Build RemoteActivity entity
|
||||||
RemoteActivity remoteActivity = RemoteActivity.builder()
|
RemoteActivity remoteActivity = RemoteActivity.builder()
|
||||||
.activityUri(activityUri)
|
.activityUri(activityUri)
|
||||||
.remoteActorUri(actor)
|
.remoteActorUri(actor)
|
||||||
.activityType((String) workoutData.get("activityType"))
|
.activityType(stringValue(workoutData.get("activityType")))
|
||||||
.title((String) noteObject.getOrDefault("name", noteObject.getOrDefault("summary", "Untitled Activity")))
|
.title((String) noteObject.getOrDefault("name", noteObject.getOrDefault("summary", "Untitled Activity")))
|
||||||
.description(stripHtml((String) noteObject.get("content")))
|
.description(firstNonBlank(
|
||||||
|
stringValue(workoutData.get("description")),
|
||||||
|
stripHtml((String) noteObject.get("content"))
|
||||||
|
))
|
||||||
.publishedAt(publishedAt)
|
.publishedAt(publishedAt)
|
||||||
.totalDistance(parseLong(workoutData.get("distance")))
|
.totalDistance(parseLong(workoutData.get("distance")))
|
||||||
.totalDurationSeconds(parseDurationSeconds((String) workoutData.get("duration")))
|
.totalDurationSeconds(parseDurationSeconds((String) workoutData.get("duration")))
|
||||||
|
|
@ -373,6 +446,7 @@ public class InboxProcessor {
|
||||||
.calories(parseInteger(workoutData.get("calories")))
|
.calories(parseInteger(workoutData.get("calories")))
|
||||||
.mapImageUrl(attachments.get("mapImage"))
|
.mapImageUrl(attachments.get("mapImage"))
|
||||||
.trackGeojsonUrl(attachments.get("trackGeojson"))
|
.trackGeojsonUrl(attachments.get("trackGeojson"))
|
||||||
|
.simplifiedTrack(extractRoute(workoutData))
|
||||||
.visibility(visibility)
|
.visibility(visibility)
|
||||||
.activityPubObject(serializeToJson(noteObject))
|
.activityPubObject(serializeToJson(noteObject))
|
||||||
.build();
|
.build();
|
||||||
|
|
@ -647,6 +721,88 @@ public class InboxProcessor {
|
||||||
return workoutData;
|
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.
|
* Extract attachment URLs (map image, GeoJSON) from a Note object.
|
||||||
*/
|
*/
|
||||||
|
|
@ -766,6 +922,44 @@ 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.
|
* Serialize object to JSON string.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,7 @@ public class TimelineResultMapper {
|
||||||
.commentsCount(commentsCount)
|
.commentsCount(commentsCount)
|
||||||
.likedByCurrentUser(likedByCurrentUser)
|
.likedByCurrentUser(likedByCurrentUser)
|
||||||
.hasGpsTrack(true) // Will be refined based on actual data
|
.hasGpsTrack(true) // Will be refined based on actual data
|
||||||
.activityLocation(activityLocation)
|
.activityLocation(activityLocation != null ? activityLocation : "")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
package net.javahippie.fitpub.service;
|
package net.javahippie.fitpub.service;
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
@ -9,23 +8,22 @@ import net.javahippie.fitpub.model.entity.Activity;
|
||||||
import net.javahippie.fitpub.model.entity.TrackPoint;
|
import net.javahippie.fitpub.model.entity.TrackPoint;
|
||||||
import net.javahippie.fitpub.model.entity.WeatherData;
|
import net.javahippie.fitpub.model.entity.WeatherData;
|
||||||
import net.javahippie.fitpub.repository.WeatherDataRepository;
|
import net.javahippie.fitpub.repository.WeatherDataRepository;
|
||||||
import org.locationtech.jts.geom.Point;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.client.RestTemplate;
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.net.URI;
|
import java.math.RoundingMode;
|
||||||
import java.time.Instant;
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.ZoneId;
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service for fetching and managing weather data for activities.
|
* Service for fetching and managing weather data for activities.
|
||||||
* Uses OpenWeatherMap API to retrieve historical weather data.
|
* Uses Open-Meteo archive API to retrieve historical weather data.
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
@Slf4j
|
@Slf4j
|
||||||
|
|
@ -36,14 +34,10 @@ public class WeatherService {
|
||||||
private final RestTemplate restTemplate = new RestTemplate();
|
private final RestTemplate restTemplate = new RestTemplate();
|
||||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
@Value("${fitpub.weather.api-key:}")
|
|
||||||
private String apiKey;
|
|
||||||
|
|
||||||
@Value("${fitpub.weather.enabled:false}")
|
@Value("${fitpub.weather.enabled:false}")
|
||||||
private boolean weatherEnabled;
|
private boolean weatherEnabled;
|
||||||
|
|
||||||
private static final String OPENWEATHERMAP_API_URL = "https://api.openweathermap.org/data/2.5/weather";
|
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_TIMEMACHINE_URL = "https://api.openweathermap.org/data/3.0/onecall/timemachine";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch and store weather data for an activity.
|
* Fetch and store weather data for an activity.
|
||||||
|
|
@ -55,24 +49,6 @@ public class WeatherService {
|
||||||
@Transactional
|
@Transactional
|
||||||
public Optional<WeatherData> fetchWeatherForActivity(Activity activity) {
|
public Optional<WeatherData> fetchWeatherForActivity(Activity activity) {
|
||||||
log.info("=== Weather fetch requested for activity {} ===", activity.getId());
|
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()) {
|
if (activity.getTrackPointsJson() == null || activity.getTrackPointsJson().isEmpty()) {
|
||||||
log.warn("No track points available for activity {} - cannot fetch weather", activity.getId());
|
log.warn("No track points available for activity {} - cannot fetch weather", activity.getId());
|
||||||
|
|
@ -88,21 +64,7 @@ public class WeatherService {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
} else {
|
} else {
|
||||||
var resolvedTrackPoint = trackPoint.get();
|
var resolvedTrackPoint = trackPoint.get();
|
||||||
// Check if activity is recent (within 5 days) because it's free to use. Don't call other timeframes, expensive.
|
WeatherData weatherData = fetchCurrentWeather(resolvedTrackPoint.lat(), resolvedTrackPoint.lon(), activity.getId(), activity.getStartedAt());
|
||||||
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) {
|
if (weatherData != null) {
|
||||||
log.info("Successfully fetched and parsed weather data. Attempting to save to database...");
|
log.info("Successfully fetched and parsed weather data. Attempting to save to database...");
|
||||||
|
|
@ -128,221 +90,218 @@ public class WeatherService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch current weather data from OpenWeatherMap.
|
* Fetch current weather data from Open-Meteo archive API.
|
||||||
*/
|
*/
|
||||||
private WeatherData fetchCurrentWeather(double lat, double lon, UUID activityId) {
|
private WeatherData fetchCurrentWeather(double lat, double lon, UUID activityId, LocalDateTime startedAt) {
|
||||||
log.info("=== fetchCurrentWeather START === activityId={}, lat={}, lon={}", activityId, lat, lon);
|
log.info("=== fetchCurrentWeather START === activityId={}, lat={}, lon={}", activityId, lat, lon);
|
||||||
try {
|
try {
|
||||||
String url = String.format("%s?lat=%f&lon=%f&appid=%s&units=metric",
|
|
||||||
OPENWEATHERMAP_API_URL, lat, lon, apiKey);
|
|
||||||
|
|
||||||
String maskedUrl = url.replace(apiKey, "***API_KEY***");
|
Map<String, Object> uriVariables = Map.of(
|
||||||
log.info("Constructed OpenWeatherMap API URL: {}", maskedUrl);
|
"latitude", lat,
|
||||||
log.info("Request parameters: lat={}, lon={}, units=metric", lat, lon);
|
"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);
|
||||||
|
|
||||||
long startTime = System.currentTimeMillis();
|
long startTime = System.currentTimeMillis();
|
||||||
log.info("Sending HTTP GET request to OpenWeatherMap...");
|
log.info("Sending HTTP GET request to Open-Meteo...");
|
||||||
String response = restTemplate.getForObject(URI.create(url), String.class);
|
String response = restTemplate.getForObject(OPEN_METEO_API_URL, String.class, uriVariables);
|
||||||
long duration = System.currentTimeMillis() - startTime;
|
long duration = System.currentTimeMillis() - startTime;
|
||||||
|
|
||||||
log.info("HTTP request completed in {}ms, response received", duration);
|
log.info("HTTP request completed in {}ms, response received", duration);
|
||||||
|
|
||||||
if (response == null) {
|
if (response == null) {
|
||||||
log.error("API response is NULL - RestTemplate returned null, no data from OpenWeatherMap");
|
log.error("API response is NULL - RestTemplate returned null, no data from Open-Meteo");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("API response received: {} characters", response.length());
|
|
||||||
log.info("API response (first 300 chars): {}",
|
log.info("API response (first 300 chars): {}",
|
||||||
response.length() > 300 ? response.substring(0, 300) + "..." : response);
|
response.length() > 300 ? response.substring(0, 300) + "..." : response);
|
||||||
|
|
||||||
log.info("Parsing weather response JSON...");
|
log.info("Parsing weather response JSON...");
|
||||||
WeatherData weatherData = parseWeatherResponse(response, activityId);
|
WeatherData weatherData = parseWeatherResponse(response, activityId, startedAt);
|
||||||
|
|
||||||
if (weatherData == null) {
|
if (weatherData == null) {
|
||||||
log.error("FAILED to parse weather response - see parsing errors above");
|
log.error("FAILED to parse weather response - see parsing errors above");
|
||||||
} else {
|
} else {
|
||||||
log.info("Successfully parsed weather data: temp={}°C, feels_like={}°C, condition='{}', description='{}', humidity={}%, pressure={} hPa, wind={} m/s",
|
log.info("Successfully parsed weather data: temp={}°C, condition='{}', wind={} m/s, precipitation={} mm",
|
||||||
weatherData.getTemperatureCelsius(),
|
weatherData.getTemperatureCelsius(),
|
||||||
weatherData.getFeelsLikeCelsius(),
|
|
||||||
weatherData.getWeatherCondition(),
|
weatherData.getWeatherCondition(),
|
||||||
weatherData.getWeatherDescription(),
|
weatherData.getWindSpeedMps(),
|
||||||
weatherData.getHumidity(),
|
weatherData.getPrecipitationMm());
|
||||||
weatherData.getPressure(),
|
|
||||||
weatherData.getWindSpeedMps());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("=== fetchCurrentWeather END === success={}", (weatherData != null));
|
log.info("=== fetchCurrentWeather END === success={}", (weatherData != null));
|
||||||
return weatherData;
|
return weatherData;
|
||||||
|
|
||||||
} catch (org.springframework.web.client.HttpClientErrorException e) {
|
} catch (org.springframework.web.client.HttpClientErrorException e) {
|
||||||
log.error("=== HTTP CLIENT ERROR (4xx) from OpenWeatherMap API ===");
|
log.error("HTTP client error (4xx) from Open-Meteo API: {} - {}", e.getStatusCode(), e.getResponseBodyAsString(), e);
|
||||||
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;
|
return null;
|
||||||
} catch (org.springframework.web.client.HttpServerErrorException e) {
|
} catch (org.springframework.web.client.HttpServerErrorException e) {
|
||||||
log.error("=== HTTP SERVER ERROR (5xx) from OpenWeatherMap API ===");
|
log.error("HTTP server error (5xx) from Open-Meteo API: {} - {}", e.getStatusCode(), e.getResponseBodyAsString(), e);
|
||||||
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;
|
return null;
|
||||||
} catch (org.springframework.web.client.ResourceAccessException e) {
|
} catch (org.springframework.web.client.ResourceAccessException e) {
|
||||||
log.error("=== NETWORK/CONNECTION ERROR accessing OpenWeatherMap API ===");
|
log.error("Network error accessing Open-Meteo API: {}", e.getMessage(), e);
|
||||||
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;
|
return null;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("=== UNEXPECTED EXCEPTION fetching current weather ===");
|
log.error("Unexpected exception fetching weather for activity {}: {}", activityId, e.getMessage(), e);
|
||||||
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;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse OpenWeatherMap API response and create WeatherData entity.
|
* Parse Open-Meteo archive API response and create WeatherData entity.
|
||||||
|
* Extracts the hourly data point matching the activity's start hour.
|
||||||
*/
|
*/
|
||||||
private WeatherData parseWeatherResponse(String response, UUID activityId) {
|
private WeatherData parseWeatherResponse(String response, UUID activityId, LocalDateTime startedAt) {
|
||||||
log.debug("=== parseWeatherResponse START === activityId={}", activityId);
|
log.debug("=== parseWeatherResponse START === activityId={}", activityId);
|
||||||
try {
|
try {
|
||||||
JsonNode root = objectMapper.readTree(response);
|
JsonNode root = objectMapper.readTree(response);
|
||||||
log.debug("JSON parsed successfully, root node present: {}", root != null);
|
|
||||||
|
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);
|
||||||
|
|
||||||
WeatherData weatherData = new WeatherData();
|
WeatherData weatherData = new WeatherData();
|
||||||
weatherData.setActivityId(activityId);
|
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)));
|
||||||
|
|
||||||
// Main temperature data
|
// Open-Meteo returns wind speed in km/h, convert to m/s
|
||||||
if (root.has("main")) {
|
BigDecimal windKmh = getHourlyBigDecimal(hourly, "wind_speed_10m", hourIndex);
|
||||||
JsonNode main = root.get("main");
|
if (windKmh != null) {
|
||||||
log.debug("Parsing 'main' section: {}", main);
|
weatherData.setWindSpeedMps(windKmh.divide(BigDecimal.valueOf(3.6), 2, RoundingMode.HALF_UP));
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wind data
|
// Open-Meteo returns snowfall in cm, convert to mm
|
||||||
if (root.has("wind")) {
|
BigDecimal snowCm = getHourlyBigDecimal(hourly, "snowfall", hourIndex);
|
||||||
JsonNode wind = root.get("wind");
|
if (snowCm != null) {
|
||||||
log.debug("Parsing 'wind' section: {}", wind);
|
weatherData.setSnowMm(snowCm.multiply(BigDecimal.TEN));
|
||||||
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.setFetchedAt(LocalDateTime.now());
|
||||||
weatherData.setDataSource("openweathermap");
|
weatherData.setDataSource("open-meteo");
|
||||||
|
|
||||||
log.info("Successfully parsed complete weather data");
|
log.info("Successfully parsed weather data: temp={}°C, condition='{}', wind={} m/s",
|
||||||
log.debug("=== parseWeatherResponse END === success=true");
|
weatherData.getTemperatureCelsius(), weatherData.getWeatherCondition(), weatherData.getWindSpeedMps());
|
||||||
return weatherData;
|
return weatherData;
|
||||||
|
|
||||||
} catch (com.fasterxml.jackson.core.JsonProcessingException e) {
|
} catch (com.fasterxml.jackson.core.JsonProcessingException e) {
|
||||||
log.error("=== JSON PARSING ERROR ===");
|
log.error("Failed to parse weather response as JSON: {}", e.getMessage(), e);
|
||||||
log.error("Failed to parse weather response as JSON");
|
|
||||||
log.error("Response content: {}", response);
|
|
||||||
log.error("Parse error: {}", e.getMessage(), e);
|
|
||||||
return null;
|
return null;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("=== UNEXPECTED ERROR parsing weather response ===");
|
log.error("Unexpected error parsing weather response: {}", e.getMessage(), e);
|
||||||
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;
|
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.
|
* Get weather data for an activity.
|
||||||
*
|
*
|
||||||
|
|
@ -363,43 +322,4 @@ public class WeatherService {
|
||||||
weatherDataRepository.deleteByActivityId(activityId);
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
package net.javahippie.fitpub.service;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import net.javahippie.fitpub.model.dto.ActivityDTO;
|
||||||
|
import net.javahippie.fitpub.model.entity.Activity;
|
||||||
|
import net.javahippie.fitpub.model.entity.ActivityMetrics;
|
||||||
|
import net.javahippie.fitpub.model.entity.PrivacyZone;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the proprietary workoutData payload for outbound ActivityPub Notes.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class WorkoutDataPayloadBuilder {
|
||||||
|
|
||||||
|
private final PrivacyZoneService privacyZoneService;
|
||||||
|
private final TrackPrivacyFilter trackPrivacyFilter;
|
||||||
|
|
||||||
|
public Map<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,13 +1,14 @@
|
||||||
package net.javahippie.fitpub.util;
|
package net.javahippie.fitpub.util;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import net.javahippie.fitpub.model.entity.Activity;
|
import net.javahippie.fitpub.model.entity.Activity;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.*;
|
||||||
import java.time.LocalTime;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility class for formatting activity-related data for display.
|
* Utility class for formatting activity-related data for display.
|
||||||
*/
|
*/
|
||||||
|
@Slf4j
|
||||||
public class ActivityFormatter {
|
public class ActivityFormatter {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -47,21 +48,25 @@ public class ActivityFormatter {
|
||||||
* Generates a default activity title based on the time of day and activity type.
|
* Generates a default activity title based on the time of day and activity type.
|
||||||
* Format: "[Time of Day] [Activity Type]" (e.g., "Morning Run", "Evening Ride")
|
* Format: "[Time of Day] [Activity Type]" (e.g., "Morning Run", "Evening Ride")
|
||||||
*
|
*
|
||||||
* @param startedAt the activity start time
|
* @param startedAt the activity start time
|
||||||
|
* @param timezone the timezone ID of the activity
|
||||||
* @param activityType the activity type
|
* @param activityType the activity type
|
||||||
* @return generated title
|
* @return generated title
|
||||||
*/
|
*/
|
||||||
public static String generateActivityTitle(LocalDateTime startedAt, Activity.ActivityType activityType) {
|
public static String generateActivityTitle(LocalDateTime startedAt, String timezone, Activity.ActivityType activityType) {
|
||||||
if (startedAt == null || activityType == null) {
|
if (startedAt == null || activityType == null) {
|
||||||
return "Activity";
|
return "Activity";
|
||||||
}
|
}
|
||||||
|
|
||||||
String timeOfDay = getTimeOfDay(startedAt.toLocalTime());
|
LocalDateTime startedAtLocal = getUtcDateTimeInZone(startedAt, timezone);
|
||||||
|
String timeOfDay = getTimeOfDay(startedAtLocal.toLocalTime());
|
||||||
String formattedType = formatActivityType(activityType);
|
String formattedType = formatActivityType(activityType);
|
||||||
|
|
||||||
return timeOfDay + " " + formattedType;
|
return timeOfDay + " " + formattedType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines the time of day based on the hour.
|
* Determines the time of day based on the hour.
|
||||||
*
|
*
|
||||||
|
|
@ -81,4 +86,29 @@ public class ActivityFormatter {
|
||||||
return "Night";
|
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,7 +35,8 @@ public final class ActivityPubContexts {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the extended JSON-LD {@code @context} value for outbound objects
|
* Returns the extended JSON-LD {@code @context} value for outbound objects
|
||||||
* that carry interaction-policy declarations. Shape:
|
* that carry both interaction-policy declarations and FitPub's proprietary
|
||||||
|
* {@code workoutData} extension fields. Shape:
|
||||||
*
|
*
|
||||||
* <pre>
|
* <pre>
|
||||||
* [
|
* [
|
||||||
|
|
@ -45,7 +46,20 @@ public final class ActivityPubContexts {
|
||||||
* "interactionPolicy": { "@id": "gts:interactionPolicy", "@type": "@id" },
|
* "interactionPolicy": { "@id": "gts:interactionPolicy", "@type": "@id" },
|
||||||
* "canQuote": { "@id": "gts:canQuote", "@type": "@id" },
|
* "canQuote": { "@id": "gts:canQuote", "@type": "@id" },
|
||||||
* "automaticApproval": { "@id": "gts:automaticApproval", "@type": "@id" },
|
* "automaticApproval": { "@id": "gts:automaticApproval", "@type": "@id" },
|
||||||
* "manualApproval": { "@id": "gts:manualApproval", "@type": "@id" }
|
* "manualApproval": { "@id": "gts:manualApproval", "@type": "@id" },
|
||||||
|
* "fitpub": "https://fitpub.social/ns#",
|
||||||
|
* "workoutData": "fitpub:workoutData",
|
||||||
|
* "activityType": "fitpub:activityType",
|
||||||
|
* "description": "fitpub:description",
|
||||||
|
* "distance": "fitpub:distance",
|
||||||
|
* "duration": "fitpub:duration",
|
||||||
|
* "elevationGain": "fitpub:elevationGain",
|
||||||
|
* "averagePace": "fitpub:averagePace",
|
||||||
|
* "averageHeartRate": "fitpub:averageHeartRate",
|
||||||
|
* "averageSpeed": "fitpub:averageSpeed",
|
||||||
|
* "maxSpeed": "fitpub:maxSpeed",
|
||||||
|
* "calories": "fitpub:calories",
|
||||||
|
* "route": "fitpub:route"
|
||||||
* }
|
* }
|
||||||
* ]
|
* ]
|
||||||
* </pre>
|
* </pre>
|
||||||
|
|
@ -56,6 +70,12 @@ public final class ActivityPubContexts {
|
||||||
* Mastodon source, "interaction_policies" extension), so a Mastodon
|
* Mastodon source, "interaction_policies" extension), so a Mastodon
|
||||||
* receiver compacting our object with its own context will recognise the
|
* receiver compacting our object with its own context will recognise the
|
||||||
* field names and apply the policy.
|
* 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() {
|
public static List<Object> extendedContext() {
|
||||||
Map<String, Object> extensions = new LinkedHashMap<>();
|
Map<String, Object> extensions = new LinkedHashMap<>();
|
||||||
|
|
@ -64,6 +84,19 @@ public final class ActivityPubContexts {
|
||||||
extensions.put("canQuote", typedRef("gts:canQuote"));
|
extensions.put("canQuote", typedRef("gts:canQuote"));
|
||||||
extensions.put("automaticApproval", typedRef("gts:automaticApproval"));
|
extensions.put("automaticApproval", typedRef("gts:automaticApproval"));
|
||||||
extensions.put("manualApproval", typedRef("gts:manualApproval"));
|
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(
|
return List.of(
|
||||||
"https://www.w3.org/ns/activitystreams",
|
"https://www.w3.org/ns/activitystreams",
|
||||||
extensions
|
extensions
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static net.javahippie.fitpub.util.ParsedActivityData.MAX_TITLE_LENGTH;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parser for GPX (GPS Exchange Format) files.
|
* Parser for GPX (GPS Exchange Format) files.
|
||||||
* Extracts GPS coordinates, activity metrics from track points.
|
* Extracts GPS coordinates, activity metrics from track points.
|
||||||
|
|
@ -80,8 +82,12 @@ public class GpxParser {
|
||||||
// Calculate duration
|
// Calculate duration
|
||||||
parsedData.setTotalDuration(Duration.between(firstPoint.getTimestamp(), lastPoint.getTimestamp()));
|
parsedData.setTotalDuration(Duration.between(firstPoint.getTimestamp(), lastPoint.getTimestamp()));
|
||||||
|
|
||||||
// Extract activity type from metadata
|
// Extract activity type and title from metadata
|
||||||
extractActivityType(doc, parsedData);
|
Optional<Element> track = getFirstTrack(doc);
|
||||||
|
if (track.isPresent()) {
|
||||||
|
extractActivityType(track.get(), parsedData);
|
||||||
|
extractActivityTitle(track.get(), parsedData);
|
||||||
|
}
|
||||||
|
|
||||||
// Determine timezone from first GPS coordinate
|
// Determine timezone from first GPS coordinate
|
||||||
determineTimezone(parsedData);
|
determineTimezone(parsedData);
|
||||||
|
|
@ -111,6 +117,8 @@ public class GpxParser {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts track points from GPX document.
|
* Extracts track points from GPX document.
|
||||||
*/
|
*/
|
||||||
|
|
@ -245,21 +253,40 @@ public class GpxParser {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/*
|
||||||
* Extracts activity type from GPX metadata.
|
* Returns the first <trk> element from the GPS XML
|
||||||
*/
|
*/
|
||||||
private void extractActivityType(Document doc, ParsedActivityData parsedData) {
|
private Optional<Element> getFirstTrack(Document doc) {
|
||||||
NodeList tracks = doc.getElementsByTagName("trk");
|
NodeList tracks = doc.getElementsByTagName("trk");
|
||||||
if (tracks.getLength() == 0) {
|
if (tracks.getLength() == 0) {
|
||||||
tracks = doc.getElementsByTagNameNS("*", "trk");
|
tracks = doc.getElementsByTagNameNS("*", "trk");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tracks.getLength() > 0) {
|
return tracks.getLength() > 0 ? Optional.of((Element) tracks.item(0)) : Optional.empty();
|
||||||
Element track = (Element) tracks.item(0);
|
}
|
||||||
String type = getElementText(track, "type");
|
|
||||||
if (type != null) {
|
/**
|
||||||
parsedData.setActivityType(mapGpxTypeToActivityType(type));
|
* Extracts activity type from GPX metadata.
|
||||||
|
*/
|
||||||
|
private void extractActivityType(Element track, ParsedActivityData parsedData) {
|
||||||
|
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,6 +20,9 @@ import java.util.List;
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
public class ParsedActivityData {
|
public class ParsedActivityData {
|
||||||
|
|
||||||
|
static final int MAX_TITLE_LENGTH = 255;
|
||||||
|
|
||||||
private List<TrackPointData> trackPoints = new ArrayList<>();
|
private List<TrackPointData> trackPoints = new ArrayList<>();
|
||||||
private LocalDateTime startTime;
|
private LocalDateTime startTime;
|
||||||
private LocalDateTime endTime;
|
private LocalDateTime endTime;
|
||||||
|
|
@ -30,6 +33,7 @@ public class ParsedActivityData {
|
||||||
private BigDecimal elevationGain;
|
private BigDecimal elevationGain;
|
||||||
private BigDecimal elevationLoss;
|
private BigDecimal elevationLoss;
|
||||||
private Activity.ActivityType activityType = Activity.ActivityType.OTHER;
|
private Activity.ActivityType activityType = Activity.ActivityType.OTHER;
|
||||||
|
private String title;
|
||||||
private ActivityMetricsData metrics;
|
private ActivityMetricsData metrics;
|
||||||
private String sourceFormat; // "FIT" or "GPX"
|
private String sourceFormat; // "FIT" or "GPX"
|
||||||
private Boolean indoor = false; // Indicates if this is an indoor activity
|
private Boolean indoor = false; // Indicates if this is an indoor activity
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
# Activated with: mvn spring-boot:run -Dspring-boot.run.profiles=dev
|
# Activated with: mvn spring-boot:run -Dspring-boot.run.profiles=dev
|
||||||
|
|
||||||
spring:
|
spring:
|
||||||
|
main:
|
||||||
|
allow-bean-definition-overriding: true
|
||||||
datasource:
|
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
|
# 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}
|
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/fitpub}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
ALTER TABLE remote_activities
|
||||||
|
ADD COLUMN simplified_track geometry(LineString, 4326);
|
||||||
|
|
||||||
|
CREATE INDEX idx_remote_activity_simplified_track
|
||||||
|
ON remote_activities
|
||||||
|
USING gist (simplified_track);
|
||||||
|
|
||||||
|
COMMENT ON COLUMN remote_activities.simplified_track IS
|
||||||
|
'Simplified remote route geometry for local map rendering';
|
||||||
|
|
@ -92,6 +92,11 @@ p,
|
||||||
letter-spacing: normal;
|
letter-spacing: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Preserve line-breaks */
|
||||||
|
.preserve-linebreaks {
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
/* Navigation */
|
/* Navigation */
|
||||||
.navbar {
|
.navbar {
|
||||||
background: linear-gradient(135deg, var(--dark-color) 0%, #2d0052 100%) !important;
|
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'
|
// Parse the timestamp - backend sends LocalDateTime without 'Z'
|
||||||
// We need to interpret it in the specified timezone
|
// We need to interpret it in the specified timezone
|
||||||
const date = new Date(timestamp);
|
const date = new Date(ensureUTC(timestamp));
|
||||||
|
|
||||||
// Default options for date/time display
|
// Default options for date/time display
|
||||||
const defaultOptions = {
|
const defaultOptions = {
|
||||||
|
|
@ -473,6 +473,17 @@ 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
|
// Make functions available globally for inline scripts
|
||||||
window.FitPub = {
|
window.FitPub = {
|
||||||
createActivityMap,
|
createActivityMap,
|
||||||
|
|
@ -482,5 +493,6 @@ window.FitPub = {
|
||||||
formatDistance,
|
formatDistance,
|
||||||
formatPace,
|
formatPace,
|
||||||
formatDateTimeWithTimezone,
|
formatDateTimeWithTimezone,
|
||||||
formatDateWithTimezone
|
formatDateWithTimezone,
|
||||||
|
ensureUTC
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -209,7 +209,7 @@ const FitPubTimeline = {
|
||||||
@${this.escapeHtml(activity.username)}
|
@${this.escapeHtml(activity.username)}
|
||||||
</a>
|
</a>
|
||||||
${!activity.isLocal ? ' <span class="badge bg-info ms-1" title="Federated Activity"><i class="bi bi-globe2"></i> Remote</span>' : ''}
|
${!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}
|
• ${this.formatTimeAgo(activity.startedAt)} ${activity.activityLocation ? '•' : ''} ${activity.activityLocation}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -727,7 +727,7 @@ const FitPubTimeline = {
|
||||||
* @returns {string} Time ago string
|
* @returns {string} Time ago string
|
||||||
*/
|
*/
|
||||||
formatTimeAgo: function(timestamp) {
|
formatTimeAgo: function(timestamp) {
|
||||||
const date = new Date(timestamp);
|
const date = new Date(FitPub.ensureUTC(timestamp));
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const secondsAgo = Math.floor((now - date) / 1000);
|
const secondsAgo = Math.floor((now - date) / 1000);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@
|
||||||
<span id="activityVisibility"></span>
|
<span id="activityVisibility"></span>
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p id="activityDescription" class="text-muted"></p>
|
<p id="activityDescription" class="preserve-linebreaks text-muted"></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-group" role="group" id="activityActions" style="display: none;">
|
<div class="btn-group" role="group" id="activityActions" style="display: none;">
|
||||||
<a href="#" id="editBtn" class="btn btn-outline-primary">
|
<a href="#" id="editBtn" class="btn btn-outline-primary">
|
||||||
|
|
|
||||||
|
|
@ -212,7 +212,7 @@
|
||||||
function populateForm(activity) {
|
function populateForm(activity) {
|
||||||
// Populate form fields
|
// Populate form fields
|
||||||
document.getElementById('title').value = activity.title || '';
|
document.getElementById('title').value = activity.title || '';
|
||||||
document.getElementById('activityType').value = activity.activityType || 'OTHER';
|
document.getElementById('activityType').value = activity.activityType?.toUpperCase() || 'OTHER';
|
||||||
document.getElementById('description').value = activity.description || '';
|
document.getElementById('description').value = activity.description || '';
|
||||||
document.getElementById('visibility').value = activity.visibility || 'PUBLIC';
|
document.getElementById('visibility').value = activity.visibility || 'PUBLIC';
|
||||||
document.getElementById('race').checked = activity.race || false;
|
document.getElementById('race').checked = activity.race || false;
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@
|
||||||
<p class="text-muted mb-2">
|
<p class="text-muted mb-2">
|
||||||
<span id="username"></span>
|
<span id="username"></span>
|
||||||
</p>
|
</p>
|
||||||
<p id="bio" class="mb-3"></p>
|
<p id="bio" class="mb-3 preserve-linebreaks"></p>
|
||||||
</div>
|
</div>
|
||||||
<div id="followButtonContainer" class="d-none">
|
<div id="followButtonContainer" class="d-none">
|
||||||
<button class="btn btn-primary" id="followBtn">
|
<button class="btn btn-primary" id="followBtn">
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@
|
||||||
<p class="text-muted mb-2">
|
<p class="text-muted mb-2">
|
||||||
<span id="username"></span>
|
<span id="username"></span>
|
||||||
</p>
|
</p>
|
||||||
<p id="bio" class="mb-3"></p>
|
<p id="bio" class="mb-3 preserve-linebreaks"></p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<a th:href="@{/profile/edit}" class="btn btn-outline-primary">
|
<a th:href="@{/profile/edit}" class="btn btn-outline-primary">
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import org.springframework.boot.test.context.TestConfiguration;
|
||||||
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.testcontainers.containers.PostgreSQLContainer;
|
import org.testcontainers.containers.PostgreSQLContainer;
|
||||||
import org.testcontainers.containers.wait.strategy.HostPortWaitStrategy;
|
|
||||||
import org.testcontainers.utility.DockerImageName;
|
import org.testcontainers.utility.DockerImageName;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -23,8 +22,6 @@ public class TestcontainersConfiguration {
|
||||||
)
|
)
|
||||||
.withDatabaseName("testdb")
|
.withDatabaseName("testdb")
|
||||||
.withUsername("test")
|
.withUsername("test")
|
||||||
.withPassword("test")
|
.withPassword("test");
|
||||||
.waitingFor(new HostPortWaitStrategy())
|
|
||||||
.withReuse(true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,165 @@
|
||||||
|
package net.javahippie.fitpub.controller;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import net.javahippie.fitpub.model.entity.Activity;
|
||||||
|
import net.javahippie.fitpub.model.entity.User;
|
||||||
|
import net.javahippie.fitpub.repository.ActivityRepository;
|
||||||
|
import net.javahippie.fitpub.repository.FollowRepository;
|
||||||
|
import net.javahippie.fitpub.repository.UserRepository;
|
||||||
|
import net.javahippie.fitpub.security.HttpSignatureValidator;
|
||||||
|
import net.javahippie.fitpub.service.ActivityImageService;
|
||||||
|
import net.javahippie.fitpub.service.FederationService;
|
||||||
|
import net.javahippie.fitpub.service.InboxProcessor;
|
||||||
|
import net.javahippie.fitpub.service.WorkoutDataPayloadBuilder;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
@DisplayName("ActivityPubController Tests")
|
||||||
|
class ActivityPubControllerTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private UserRepository userRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ActivityRepository activityRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ActivityImageService activityImageService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private InboxProcessor inboxProcessor;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private FollowRepository followRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private HttpSignatureValidator signatureValidator;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private FederationService federationService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private WorkoutDataPayloadBuilder workoutDataPayloadBuilder;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private ActivityPubController controller;
|
||||||
|
|
||||||
|
private UUID activityId;
|
||||||
|
private UUID userId;
|
||||||
|
private Activity activity;
|
||||||
|
private User user;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
activityId = UUID.randomUUID();
|
||||||
|
userId = UUID.randomUUID();
|
||||||
|
createdAt = LocalDateTime.of(2026, 5, 2, 9, 24, 50, 921_241_000);
|
||||||
|
|
||||||
|
ReflectionTestUtils.setField(controller, "baseUrl", "https://fitpub.example");
|
||||||
|
|
||||||
|
activity = Activity.builder()
|
||||||
|
.id(activityId)
|
||||||
|
.userId(userId)
|
||||||
|
.activityType(Activity.ActivityType.RUN)
|
||||||
|
.title("Lunch Run")
|
||||||
|
.description("Sunny run")
|
||||||
|
.visibility(Activity.Visibility.PUBLIC)
|
||||||
|
.totalDistance(BigDecimal.valueOf(5000))
|
||||||
|
.totalDurationSeconds(1800L)
|
||||||
|
.createdAt(createdAt)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
user = User.builder()
|
||||||
|
.id(userId)
|
||||||
|
.username("JaneDoe")
|
||||||
|
.email("janedoe@example.com")
|
||||||
|
.publicKey("public-key")
|
||||||
|
.privateKey("private-key")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Should serialize activity published timestamp with timezone")
|
||||||
|
void getActivity_ShouldSerializePublishedTimestampWithTimezone() {
|
||||||
|
when(activityRepository.findById(activityId)).thenReturn(Optional.of(activity));
|
||||||
|
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
|
||||||
|
when(activityImageService.getActivityImageFile(activityId)).thenReturn(new File("/definitely/nonexistent-fitpub-test-image"));
|
||||||
|
|
||||||
|
ResponseEntity<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,19 +2,25 @@ package net.javahippie.fitpub.integration;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import net.javahippie.fitpub.config.TestcontainersConfiguration;
|
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.BeforeEach;
|
||||||
import org.junit.jupiter.api.Disabled;
|
import org.junit.jupiter.api.Disabled;
|
||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import net.javahippie.fitpub.model.entity.Follow;
|
import net.javahippie.fitpub.model.entity.Follow;
|
||||||
import net.javahippie.fitpub.model.entity.RemoteActor;
|
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.model.entity.User;
|
||||||
|
import net.javahippie.fitpub.repository.ActivityRepository;
|
||||||
import net.javahippie.fitpub.repository.FollowRepository;
|
import net.javahippie.fitpub.repository.FollowRepository;
|
||||||
|
import net.javahippie.fitpub.repository.RemoteActivityRepository;
|
||||||
import net.javahippie.fitpub.repository.RemoteActorRepository;
|
import net.javahippie.fitpub.repository.RemoteActorRepository;
|
||||||
import net.javahippie.fitpub.repository.UserRepository;
|
import net.javahippie.fitpub.repository.UserRepository;
|
||||||
import net.javahippie.fitpub.security.HttpSignatureValidator;
|
import net.javahippie.fitpub.security.HttpSignatureValidator;
|
||||||
import net.javahippie.fitpub.security.JwtTokenProvider;
|
import net.javahippie.fitpub.security.JwtTokenProvider;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
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.beans.factory.annotation.Value;
|
||||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
|
@ -26,15 +32,21 @@ import org.springframework.test.web.servlet.MockMvc;
|
||||||
import org.springframework.test.web.servlet.MvcResult;
|
import org.springframework.test.web.servlet.MvcResult;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.security.KeyPair;
|
import java.security.KeyPair;
|
||||||
import java.security.KeyPairGenerator;
|
import java.security.KeyPairGenerator;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
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.request.MockMvcRequestBuilders.*;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
|
|
||||||
|
|
@ -63,6 +75,12 @@ class FederationFollowFlowIntegrationTest {
|
||||||
@Autowired
|
@Autowired
|
||||||
private RemoteActorRepository remoteActorRepository;
|
private RemoteActorRepository remoteActorRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RemoteActivityRepository remoteActivityRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ActivityRepository activityRepository;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private PasswordEncoder passwordEncoder;
|
private PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
|
@ -72,6 +90,9 @@ class FederationFollowFlowIntegrationTest {
|
||||||
@Autowired
|
@Autowired
|
||||||
private HttpSignatureValidator signatureValidator;
|
private HttpSignatureValidator signatureValidator;
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private ActivityImageService activityImageService;
|
||||||
|
|
||||||
@Value("${fitpub.base-url}")
|
@Value("${fitpub.base-url}")
|
||||||
private String baseUrl;
|
private String baseUrl;
|
||||||
|
|
||||||
|
|
@ -101,6 +122,22 @@ class FederationFollowFlowIntegrationTest {
|
||||||
authToken = jwtTokenProvider.createToken(testUser.getUsername());
|
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 {
|
private KeyPair generateRsaKeyPair() throws NoSuchAlgorithmException {
|
||||||
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
|
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
|
||||||
keyGen.initialize(2048);
|
keyGen.initialize(2048);
|
||||||
|
|
@ -270,6 +307,111 @@ class FederationFollowFlowIntegrationTest {
|
||||||
assertThat(updatedFollow.getStatus()).isEqualTo(Follow.FollowStatus.ACCEPTED);
|
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
|
@Test
|
||||||
@DisplayName("Should reject inbox POST without HTTP signature with 401")
|
@DisplayName("Should reject inbox POST without HTTP signature with 401")
|
||||||
void testInboxRejectsUnsignedRequest() throws Exception {
|
void testInboxRejectsUnsignedRequest() throws Exception {
|
||||||
|
|
@ -310,6 +452,23 @@ class FederationFollowFlowIntegrationTest {
|
||||||
.andExpect(status().isUnauthorized());
|
.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
|
@Test
|
||||||
@DisplayName("Should process Undo Follow activity and remove follow relationship")
|
@DisplayName("Should process Undo Follow activity and remove follow relationship")
|
||||||
void testProcessUndoFollowActivity() throws Exception {
|
void testProcessUndoFollowActivity() throws Exception {
|
||||||
|
|
|
||||||
|
|
@ -27,12 +27,16 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||||
/**
|
/**
|
||||||
* Manual test for ActivityImageService.
|
* Manual test for ActivityImageService.
|
||||||
* These tests are disabled by default and should only be run manually.
|
* These tests are disabled by default and should only be run manually.
|
||||||
|
*
|
||||||
|
* To run this test manually:
|
||||||
|
* mvn test -Dtest=ActivityImageServiceTest
|
||||||
*/
|
*/
|
||||||
@SpringBootTest(properties = {
|
@SpringBootTest(properties = {
|
||||||
"fitpub.image.osm-tiles.enabled=true"
|
"fitpub.image.osm-tiles.enabled=true"
|
||||||
})
|
})
|
||||||
@ActiveProfiles("test")
|
@ActiveProfiles("test")
|
||||||
@Import(TestcontainersConfiguration.class)
|
@Import(TestcontainersConfiguration.class)
|
||||||
|
@Disabled("Manual test - run explicitly when needed")
|
||||||
class ActivityImageServiceTest {
|
class ActivityImageServiceTest {
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
|
|
@ -55,7 +59,6 @@ class ActivityImageServiceTest {
|
||||||
* mvn test -Dtest=ActivityImageServiceTest#testGenerateActivityImage_Manual
|
* mvn test -Dtest=ActivityImageServiceTest#testGenerateActivityImage_Manual
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
@Disabled("Manual test - run explicitly when needed")
|
|
||||||
@DisplayName("Generate activity image from test FIT file")
|
@DisplayName("Generate activity image from test FIT file")
|
||||||
void testGenerateActivityImage_Manual() throws Exception {
|
void testGenerateActivityImage_Manual() throws Exception {
|
||||||
// Load test FIT file
|
// Load test FIT file
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,42 @@
|
||||||
package net.javahippie.fitpub.service;
|
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.BeforeEach;
|
||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
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.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
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 org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDateTime;
|
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.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.anyBoolean;
|
||||||
|
import static org.mockito.Mockito.anyString;
|
||||||
|
import static org.mockito.Mockito.doNothing;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
|
import static org.mockito.Mockito.eq;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unit tests for ActivityPostProcessingService.
|
* Unit tests for ActivityPostProcessingService.
|
||||||
|
|
@ -49,6 +66,9 @@ class ActivityPostProcessingServiceTest {
|
||||||
@Mock
|
@Mock
|
||||||
private UserRepository userRepository;
|
private UserRepository userRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private WorkoutDataPayloadBuilder workoutDataPayloadBuilder;
|
||||||
|
|
||||||
@InjectMocks
|
@InjectMocks
|
||||||
private ActivityPostProcessingService service;
|
private ActivityPostProcessingService service;
|
||||||
|
|
||||||
|
|
@ -56,11 +76,13 @@ class ActivityPostProcessingServiceTest {
|
||||||
private UUID userId;
|
private UUID userId;
|
||||||
private Activity testActivity;
|
private Activity testActivity;
|
||||||
private User testUser;
|
private User testUser;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
activityId = UUID.randomUUID();
|
activityId = UUID.randomUUID();
|
||||||
userId = 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)
|
// Set baseUrl via reflection (since it's @Value injected)
|
||||||
ReflectionTestUtils.setField(service, "baseUrl", "https://test.example");
|
ReflectionTestUtils.setField(service, "baseUrl", "https://test.example");
|
||||||
|
|
@ -76,9 +98,39 @@ class ActivityPostProcessingServiceTest {
|
||||||
.totalDistance(BigDecimal.valueOf(5000))
|
.totalDistance(BigDecimal.valueOf(5000))
|
||||||
.totalDurationSeconds(1800L)
|
.totalDurationSeconds(1800L)
|
||||||
.elevationGain(BigDecimal.valueOf(100))
|
.elevationGain(BigDecimal.valueOf(100))
|
||||||
.startedAt(LocalDateTime.now())
|
.startedAt(createdAt.minusMinutes(30))
|
||||||
.createdAt(LocalDateTime.now())
|
.createdAt(createdAt)
|
||||||
|
.simplifiedTrack(new GeometryFactory().createLineString(new Coordinate[]{
|
||||||
|
new Coordinate(8.55, 47.37),
|
||||||
|
new Coordinate(8.56, 47.38)
|
||||||
|
}))
|
||||||
.build();
|
.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
|
// Create test user
|
||||||
testUser = User.builder()
|
testUser = User.builder()
|
||||||
|
|
@ -232,6 +284,24 @@ class ActivityPostProcessingServiceTest {
|
||||||
verify(federationService).sendCreateActivity(anyString(), any(), eq(testUser), eq(false));
|
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
|
@Test
|
||||||
@DisplayName("Should skip federation for PRIVATE activity")
|
@DisplayName("Should skip federation for PRIVATE activity")
|
||||||
void testPublishToFederationAsync_PrivateActivity() {
|
void testPublishToFederationAsync_PrivateActivity() {
|
||||||
|
|
@ -317,4 +387,47 @@ class ActivityPostProcessingServiceTest {
|
||||||
// Then: Verify federation was called (content formatting is tested indirectly)
|
// Then: Verify federation was called (content formatting is tested indirectly)
|
||||||
verify(federationService).sendCreateActivity(anyString(), any(), any(), anyBoolean());
|
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)
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,217 @@
|
||||||
|
package net.javahippie.fitpub.service;
|
||||||
|
|
||||||
|
import net.javahippie.fitpub.model.entity.Follow;
|
||||||
|
import net.javahippie.fitpub.model.entity.RemoteActivity;
|
||||||
|
import net.javahippie.fitpub.model.entity.RemoteActor;
|
||||||
|
import net.javahippie.fitpub.model.entity.User;
|
||||||
|
import net.javahippie.fitpub.repository.ActivityRepository;
|
||||||
|
import net.javahippie.fitpub.repository.CommentRepository;
|
||||||
|
import net.javahippie.fitpub.repository.FollowRepository;
|
||||||
|
import net.javahippie.fitpub.repository.LikeRepository;
|
||||||
|
import net.javahippie.fitpub.repository.RemoteActivityRepository;
|
||||||
|
import net.javahippie.fitpub.repository.RemoteActorRepository;
|
||||||
|
import net.javahippie.fitpub.repository.UserRepository;
|
||||||
|
import org.locationtech.jts.geom.LineString;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
@DisplayName("InboxProcessor Tests")
|
||||||
|
class InboxProcessorTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private UserRepository userRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private FollowRepository followRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private FederationService federationService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ActivityRepository activityRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private LikeRepository likeRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private CommentRepository commentRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private NotificationService notificationService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private RemoteActivityRepository remoteActivityRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private RemoteActorRepository remoteActorRepository;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private InboxProcessor inboxProcessor;
|
||||||
|
|
||||||
|
private User localUser;
|
||||||
|
private String remoteActorUri;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
localUser = User.builder()
|
||||||
|
.id(UUID.randomUUID())
|
||||||
|
.username("JaneDoe")
|
||||||
|
.email("janedoe@example.com")
|
||||||
|
.passwordHash("irrelevant")
|
||||||
|
.publicKey("public-key")
|
||||||
|
.privateKey("private-key")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
remoteActorUri = "https://fitpub.example.com/users/JohnDoe";
|
||||||
|
|
||||||
|
ReflectionTestUtils.setField(inboxProcessor, "baseUrl", "https://fitpub.example");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Should persist remote activity when published timestamp has fractional seconds but no timezone")
|
||||||
|
void processCreateRemoteActivity_WithPublishedTimestampWithoutTimezone_ShouldPersistRemoteActivity() {
|
||||||
|
when(remoteActivityRepository.existsByActivityUri("https://fitpub.example.com/activities/123"))
|
||||||
|
.thenReturn(false);
|
||||||
|
when(federationService.fetchRemoteActor(remoteActorUri)).thenReturn(RemoteActor.builder()
|
||||||
|
.actorUri(remoteActorUri)
|
||||||
|
.username("JohnDoe")
|
||||||
|
.domain("fitpub.example.com")
|
||||||
|
.inboxUrl("https://fitpub.example.com/users/JohnDoe/inbox")
|
||||||
|
.publicKey("public-key")
|
||||||
|
.build());
|
||||||
|
when(userRepository.findByUsername("JaneDoe")).thenReturn(Optional.of(localUser));
|
||||||
|
when(followRepository.findByFollowerIdAndFollowingActorUri(localUser.getId(), remoteActorUri))
|
||||||
|
.thenReturn(Optional.of(Follow.builder()
|
||||||
|
.followerId(localUser.getId())
|
||||||
|
.followingActorUri(remoteActorUri)
|
||||||
|
.status(Follow.FollowStatus.ACCEPTED)
|
||||||
|
.build()));
|
||||||
|
|
||||||
|
Map<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,13 +18,14 @@ import org.springframework.web.client.ResourceAccessException;
|
||||||
import org.springframework.web.client.RestTemplate;
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.net.URI;
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
|
@ -49,47 +50,26 @@ class WeatherServiceTest {
|
||||||
private Activity testActivity;
|
private Activity testActivity;
|
||||||
private UUID activityId;
|
private UUID activityId;
|
||||||
|
|
||||||
// Sample OpenWeatherMap API response
|
// Sample Open-Meteo archive API response (clear sky, WMO code 0)
|
||||||
private static final String SAMPLE_WEATHER_RESPONSE = """
|
private static final String SAMPLE_WEATHER_RESPONSE = """
|
||||||
{
|
{
|
||||||
"coord": {"lon": 8.2552, "lat": 49.9894},
|
"latitude": 49.98,
|
||||||
"weather": [
|
"longitude": 8.26,
|
||||||
{
|
"hourly": {
|
||||||
"id": 800,
|
"time": ["2025-11-23T17:00", "2025-11-23T18:00", "2025-11-23T19:00"],
|
||||||
"main": "Clear",
|
"temperature_2m": [14.0, 15.5, 13.8],
|
||||||
"description": "clear sky",
|
"apparent_temperature": [12.5, 14.2, 12.0],
|
||||||
"icon": "01d"
|
"relative_humidity_2m": [60, 65, 70],
|
||||||
}
|
"surface_pressure": [1012, 1013, 1012],
|
||||||
],
|
"wind_speed_10m": [10.0, 12.6, 11.0],
|
||||||
"base": "stations",
|
"wind_direction_10m": [170, 180, 190],
|
||||||
"main": {
|
"cloud_cover": [15, 20, 25],
|
||||||
"temp": 15.5,
|
"rain": [0.0, 0.0, 0.0],
|
||||||
"feels_like": 14.2,
|
"snowfall": [0.0, 0.0, 0.0],
|
||||||
"temp_min": 13.0,
|
"precipitation": [0.0, 0.0, 0.0],
|
||||||
"temp_max": 17.0,
|
"visibility": [10000, 10000, 10000],
|
||||||
"pressure": 1013,
|
"weather_code": [0, 0, 1]
|
||||||
"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
|
|
||||||
}
|
}
|
||||||
""";
|
""";
|
||||||
|
|
||||||
|
|
@ -98,12 +78,11 @@ class WeatherServiceTest {
|
||||||
activityId = UUID.randomUUID();
|
activityId = UUID.randomUUID();
|
||||||
testActivity = new Activity();
|
testActivity = new Activity();
|
||||||
testActivity.setId(activityId);
|
testActivity.setId(activityId);
|
||||||
testActivity.setStartedAt(LocalDateTime.now().minusDays(1)); // Recent activity
|
testActivity.setStartedAt(LocalDateTime.of(2025, 11, 23, 18, 8, 9));
|
||||||
|
|
||||||
// Inject the real RestTemplate mock and set config values
|
// Inject the real RestTemplate mock and set config values
|
||||||
ReflectionTestUtils.setField(weatherService, "restTemplate", restTemplate);
|
ReflectionTestUtils.setField(weatherService, "restTemplate", restTemplate);
|
||||||
ReflectionTestUtils.setField(weatherService, "weatherEnabled", true);
|
ReflectionTestUtils.setField(weatherService, "weatherEnabled", true);
|
||||||
ReflectionTestUtils.setField(weatherService, "apiKey", "test-api-key-12345678901234567890");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -123,7 +102,7 @@ class WeatherServiceTest {
|
||||||
""";
|
""";
|
||||||
testActivity.setTrackPointsJson(trackPointsJson);
|
testActivity.setTrackPointsJson(trackPointsJson);
|
||||||
|
|
||||||
when(restTemplate.getForObject(any(URI.class), eq(String.class)))
|
when(restTemplate.getForObject(anyString(), eq(String.class), any(Map.class)))
|
||||||
.thenReturn(SAMPLE_WEATHER_RESPONSE);
|
.thenReturn(SAMPLE_WEATHER_RESPONSE);
|
||||||
when(weatherDataRepository.save(any(WeatherData.class)))
|
when(weatherDataRepository.save(any(WeatherData.class)))
|
||||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
@ -136,7 +115,7 @@ class WeatherServiceTest {
|
||||||
assertEquals(new BigDecimal("15.5"), weatherData.getTemperatureCelsius());
|
assertEquals(new BigDecimal("15.5"), weatherData.getTemperatureCelsius());
|
||||||
assertEquals("Clear", weatherData.getWeatherCondition());
|
assertEquals("Clear", weatherData.getWeatherCondition());
|
||||||
|
|
||||||
verify(restTemplate, times(1)).getForObject(any(URI.class), eq(String.class));
|
verify(restTemplate, times(1)).getForObject(anyString(), eq(String.class), any(Map.class));
|
||||||
verify(weatherDataRepository, times(1)).save(any(WeatherData.class));
|
verify(weatherDataRepository, times(1)).save(any(WeatherData.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -162,7 +141,7 @@ class WeatherServiceTest {
|
||||||
""";
|
""";
|
||||||
testActivity.setTrackPointsJson(trackPointsJson);
|
testActivity.setTrackPointsJson(trackPointsJson);
|
||||||
|
|
||||||
when(restTemplate.getForObject(any(URI.class), eq(String.class)))
|
when(restTemplate.getForObject(anyString(), eq(String.class), any(Map.class)))
|
||||||
.thenReturn(SAMPLE_WEATHER_RESPONSE);
|
.thenReturn(SAMPLE_WEATHER_RESPONSE);
|
||||||
when(weatherDataRepository.save(any(WeatherData.class)))
|
when(weatherDataRepository.save(any(WeatherData.class)))
|
||||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
@ -176,39 +155,13 @@ class WeatherServiceTest {
|
||||||
assertEquals(new BigDecimal("14.2"), weatherData.getFeelsLikeCelsius());
|
assertEquals(new BigDecimal("14.2"), weatherData.getFeelsLikeCelsius());
|
||||||
assertEquals(65, weatherData.getHumidity());
|
assertEquals(65, weatherData.getHumidity());
|
||||||
assertEquals(1013, weatherData.getPressure());
|
assertEquals(1013, weatherData.getPressure());
|
||||||
assertEquals(new BigDecimal("3.5"), weatherData.getWindSpeedMps());
|
assertEquals(new BigDecimal("3.50"), weatherData.getWindSpeedMps());
|
||||||
assertEquals("clear sky", weatherData.getWeatherDescription());
|
assertEquals("clear sky", weatherData.getWeatherDescription());
|
||||||
|
|
||||||
verify(restTemplate, times(1)).getForObject(any(URI.class), eq(String.class));
|
verify(restTemplate, times(1)).getForObject(anyString(), eq(String.class), any(Map.class));
|
||||||
verify(weatherDataRepository, times(1)).save(any(WeatherData.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
|
@Test
|
||||||
@DisplayName("Should return empty when track points JSON is null")
|
@DisplayName("Should return empty when track points JSON is null")
|
||||||
void testFetchWeather_NoTrackPoints() {
|
void testFetchWeather_NoTrackPoints() {
|
||||||
|
|
@ -260,20 +213,7 @@ class WeatherServiceTest {
|
||||||
|
|
||||||
assertTrue(result.isEmpty());
|
assertTrue(result.isEmpty());
|
||||||
verify(weatherDataRepository, never()).save(any(WeatherData.class));
|
verify(weatherDataRepository, never()).save(any(WeatherData.class));
|
||||||
verify(restTemplate, never()).getForObject(any(URI.class), eq(String.class));
|
verify(restTemplate, never()).getForObject(anyString(), eq(String.class), any(Map.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
|
@Test
|
||||||
|
|
@ -281,11 +221,11 @@ class WeatherServiceTest {
|
||||||
void testFetchWeather_AuthenticationError() {
|
void testFetchWeather_AuthenticationError() {
|
||||||
testActivity.setTrackPointsJson("[{\"latitude\":50.0,\"longitude\":8.0}]");
|
testActivity.setTrackPointsJson("[{\"latitude\":50.0,\"longitude\":8.0}]");
|
||||||
|
|
||||||
when(restTemplate.getForObject(any(URI.class), eq(String.class)))
|
when(restTemplate.getForObject(anyString(), eq(String.class), any(Map.class)))
|
||||||
.thenThrow(new HttpClientErrorException(
|
.thenThrow(new HttpClientErrorException(
|
||||||
org.springframework.http.HttpStatus.UNAUTHORIZED,
|
org.springframework.http.HttpStatus.UNAUTHORIZED,
|
||||||
"Unauthorized",
|
"Unauthorized",
|
||||||
"{\"cod\":401,\"message\":\"Invalid API key\"}".getBytes(),
|
"{\"error\":true,\"reason\":\"Unauthorized\"}".getBytes(),
|
||||||
null
|
null
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|
@ -300,7 +240,7 @@ class WeatherServiceTest {
|
||||||
void testFetchWeather_NetworkError() {
|
void testFetchWeather_NetworkError() {
|
||||||
testActivity.setTrackPointsJson("[{\"latitude\":50.0,\"longitude\":8.0}]");
|
testActivity.setTrackPointsJson("[{\"latitude\":50.0,\"longitude\":8.0}]");
|
||||||
|
|
||||||
when(restTemplate.getForObject(any(URI.class), eq(String.class)))
|
when(restTemplate.getForObject(anyString(), eq(String.class), any(Map.class)))
|
||||||
.thenThrow(new ResourceAccessException("Connection timeout"));
|
.thenThrow(new ResourceAccessException("Connection timeout"));
|
||||||
|
|
||||||
Optional<WeatherData> result = weatherService.fetchWeatherForActivity(testActivity);
|
Optional<WeatherData> result = weatherService.fetchWeatherForActivity(testActivity);
|
||||||
|
|
@ -314,7 +254,7 @@ class WeatherServiceTest {
|
||||||
void testFetchWeather_MalformedResponse() {
|
void testFetchWeather_MalformedResponse() {
|
||||||
testActivity.setTrackPointsJson("[{\"latitude\":50.0,\"longitude\":8.0}]");
|
testActivity.setTrackPointsJson("[{\"latitude\":50.0,\"longitude\":8.0}]");
|
||||||
|
|
||||||
when(restTemplate.getForObject(any(URI.class), eq(String.class)))
|
when(restTemplate.getForObject(anyString(), eq(String.class), any(Map.class)))
|
||||||
.thenReturn("this is not valid JSON");
|
.thenReturn("this is not valid JSON");
|
||||||
|
|
||||||
Optional<WeatherData> result = weatherService.fetchWeatherForActivity(testActivity);
|
Optional<WeatherData> result = weatherService.fetchWeatherForActivity(testActivity);
|
||||||
|
|
@ -328,24 +268,29 @@ class WeatherServiceTest {
|
||||||
void testParseWeatherResponse_AllFields() {
|
void testParseWeatherResponse_AllFields() {
|
||||||
String responseWithRain = """
|
String responseWithRain = """
|
||||||
{
|
{
|
||||||
"main": {
|
"latitude": 50.0,
|
||||||
"temp": 10.0,
|
"longitude": 8.0,
|
||||||
"feels_like": 8.5,
|
"hourly": {
|
||||||
"pressure": 1015,
|
"time": ["2025-11-23T17:00", "2025-11-23T18:00", "2025-11-23T19:00"],
|
||||||
"humidity": 80
|
"temperature_2m": [11.0, 10.0, 9.5],
|
||||||
},
|
"apparent_temperature": [9.0, 8.5, 8.0],
|
||||||
"weather": [{"main": "Rain", "description": "light rain", "icon": "10d"}],
|
"relative_humidity_2m": [75, 80, 85],
|
||||||
"wind": {"speed": 5.5, "deg": 270},
|
"surface_pressure": [1014, 1015, 1015],
|
||||||
"clouds": {"all": 75},
|
"wind_speed_10m": [18.0, 19.8, 20.0],
|
||||||
"visibility": 8000,
|
"wind_direction_10m": [260, 270, 275],
|
||||||
"rain": {"1h": 2.5},
|
"cloud_cover": [70, 75, 80],
|
||||||
"sys": {"sunrise": 1700721600, "sunset": 1700757600}
|
"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]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
""";
|
""";
|
||||||
|
|
||||||
testActivity.setTrackPointsJson("[{\"latitude\":50.0,\"longitude\":8.0}]");
|
testActivity.setTrackPointsJson("[{\"latitude\":50.0,\"longitude\":8.0}]");
|
||||||
|
|
||||||
when(restTemplate.getForObject(any(URI.class), eq(String.class)))
|
when(restTemplate.getForObject(anyString(), eq(String.class), any(Map.class)))
|
||||||
.thenReturn(responseWithRain);
|
.thenReturn(responseWithRain);
|
||||||
when(weatherDataRepository.save(any(WeatherData.class)))
|
when(weatherDataRepository.save(any(WeatherData.class)))
|
||||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
@ -357,15 +302,13 @@ class WeatherServiceTest {
|
||||||
assertEquals(new BigDecimal("10.0"), weatherData.getTemperatureCelsius());
|
assertEquals(new BigDecimal("10.0"), weatherData.getTemperatureCelsius());
|
||||||
assertEquals(new BigDecimal("8.5"), weatherData.getFeelsLikeCelsius());
|
assertEquals(new BigDecimal("8.5"), weatherData.getFeelsLikeCelsius());
|
||||||
assertEquals("Rain", weatherData.getWeatherCondition());
|
assertEquals("Rain", weatherData.getWeatherCondition());
|
||||||
assertEquals("light rain", weatherData.getWeatherDescription());
|
assertEquals("slight rain", weatherData.getWeatherDescription());
|
||||||
assertEquals(new BigDecimal("5.5"), weatherData.getWindSpeedMps());
|
assertEquals(new BigDecimal("5.50"), weatherData.getWindSpeedMps());
|
||||||
assertEquals(270, weatherData.getWindDirection());
|
assertEquals(270, weatherData.getWindDirection());
|
||||||
assertEquals(75, weatherData.getCloudiness());
|
assertEquals(75, weatherData.getCloudiness());
|
||||||
assertEquals(8000, weatherData.getVisibilityMeters());
|
assertEquals(8000, weatherData.getVisibilityMeters());
|
||||||
assertEquals(new BigDecimal("2.5"), weatherData.getPrecipitationMm());
|
assertEquals(new BigDecimal("2.5"), weatherData.getPrecipitationMm());
|
||||||
assertNotNull(weatherData.getSunrise());
|
assertEquals("open-meteo", weatherData.getDataSource());
|
||||||
assertNotNull(weatherData.getSunset());
|
|
||||||
assertEquals("openweathermap", weatherData.getDataSource());
|
|
||||||
assertNotNull(weatherData.getFetchedAt());
|
assertNotNull(weatherData.getFetchedAt());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -374,19 +317,29 @@ class WeatherServiceTest {
|
||||||
void testParseWeatherResponse_MinimalFields() {
|
void testParseWeatherResponse_MinimalFields() {
|
||||||
String minimalResponse = """
|
String minimalResponse = """
|
||||||
{
|
{
|
||||||
"main": {
|
"latitude": 50.0,
|
||||||
"temp": 15.0,
|
"longitude": 8.0,
|
||||||
"feels_like": 14.0,
|
"hourly": {
|
||||||
"pressure": 1010,
|
"time": ["2025-11-23T18:00"],
|
||||||
"humidity": 60
|
"temperature_2m": [15.0],
|
||||||
},
|
"apparent_temperature": [14.0],
|
||||||
"weather": [{"main": "Clouds", "description": "few clouds", "icon": "02d"}]
|
"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]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
""";
|
""";
|
||||||
|
|
||||||
testActivity.setTrackPointsJson("[{\"latitude\":50.0,\"longitude\":8.0}]");
|
testActivity.setTrackPointsJson("[{\"latitude\":50.0,\"longitude\":8.0}]");
|
||||||
|
|
||||||
when(restTemplate.getForObject(any(URI.class), eq(String.class)))
|
when(restTemplate.getForObject(anyString(), eq(String.class), any(Map.class)))
|
||||||
.thenReturn(minimalResponse);
|
.thenReturn(minimalResponse);
|
||||||
when(weatherDataRepository.save(any(WeatherData.class)))
|
when(weatherDataRepository.save(any(WeatherData.class)))
|
||||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
@ -441,7 +394,7 @@ class WeatherServiceTest {
|
||||||
""";
|
""";
|
||||||
testActivity.setTrackPointsJson(trackPointsJson);
|
testActivity.setTrackPointsJson(trackPointsJson);
|
||||||
|
|
||||||
when(restTemplate.getForObject(any(URI.class), eq(String.class)))
|
when(restTemplate.getForObject(anyString(), eq(String.class), any(Map.class)))
|
||||||
.thenReturn(SAMPLE_WEATHER_RESPONSE);
|
.thenReturn(SAMPLE_WEATHER_RESPONSE);
|
||||||
when(weatherDataRepository.save(any(WeatherData.class)))
|
when(weatherDataRepository.save(any(WeatherData.class)))
|
||||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
@ -449,6 +402,6 @@ class WeatherServiceTest {
|
||||||
Optional<WeatherData> result = weatherService.fetchWeatherForActivity(testActivity);
|
Optional<WeatherData> result = weatherService.fetchWeatherForActivity(testActivity);
|
||||||
|
|
||||||
assertTrue(result.isPresent());
|
assertTrue(result.isPresent());
|
||||||
verify(restTemplate, times(1)).getForObject(any(URI.class), eq(String.class));
|
verify(restTemplate, times(1)).getForObject(anyString(), eq(String.class), any(Map.class));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
package net.javahippie.fitpub.service;
|
||||||
|
|
||||||
|
import net.javahippie.fitpub.model.entity.Activity;
|
||||||
|
import net.javahippie.fitpub.model.entity.ActivityMetrics;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.locationtech.jts.geom.Coordinate;
|
||||||
|
import org.locationtech.jts.geom.GeometryFactory;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
@DisplayName("WorkoutDataPayloadBuilder Tests")
|
||||||
|
class WorkoutDataPayloadBuilderTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private PrivacyZoneService privacyZoneService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private TrackPrivacyFilter trackPrivacyFilter;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private WorkoutDataPayloadBuilder builder;
|
||||||
|
|
||||||
|
private UUID userId;
|
||||||
|
private Activity activity;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
userId = UUID.randomUUID();
|
||||||
|
activity = Activity.builder()
|
||||||
|
.id(UUID.randomUUID())
|
||||||
|
.userId(userId)
|
||||||
|
.activityType(Activity.ActivityType.RUN)
|
||||||
|
.description("Morning jog")
|
||||||
|
.visibility(Activity.Visibility.PUBLIC)
|
||||||
|
.totalDistance(BigDecimal.valueOf(5000))
|
||||||
|
.totalDurationSeconds(1800L)
|
||||||
|
.elevationGain(BigDecimal.valueOf(100))
|
||||||
|
.simplifiedTrack(new GeometryFactory().createLineString(new Coordinate[]{
|
||||||
|
new Coordinate(8.55, 47.37),
|
||||||
|
new Coordinate(8.56, 47.38)
|
||||||
|
}))
|
||||||
|
.build();
|
||||||
|
activity.setMetrics(ActivityMetrics.builder()
|
||||||
|
.averagePaceSeconds(321L)
|
||||||
|
.averageHeartRate(150)
|
||||||
|
.averageSpeed(BigDecimal.valueOf(10.4))
|
||||||
|
.maxSpeed(BigDecimal.valueOf(14.2))
|
||||||
|
.calories(420)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Should build workoutData payload with route and metrics")
|
||||||
|
void build_ShouldIncludeWorkoutDataRouteAndMetrics() {
|
||||||
|
when(privacyZoneService.getActivePrivacyZones(userId)).thenReturn(List.of());
|
||||||
|
|
||||||
|
Map<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,6 +112,10 @@ class GpxParserIntegrationTest {
|
||||||
|
|
||||||
// Verify at least some basic data
|
// Verify at least some basic data
|
||||||
assertNotNull(parsedData.getActivityType(), "Activity type should be determined");
|
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(),
|
assertEquals(Activity.ActivityType.RUN, parsedData.getActivityType(),
|
||||||
"Activity type should be RUN (from GPX <type>running</type>)");
|
"Activity type should be RUN (from GPX <type>running</type>)");
|
||||||
assertTrue(parsedData.getTrackPoints().size() > 0, "Should have at least one track point");
|
assertTrue(parsedData.getTrackPoints().size() > 0, "Should have at least one track point");
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
<time>2022-07-03T19:47:51Z</time>
|
<time>2022-07-03T19:47:51Z</time>
|
||||||
</metadata>
|
</metadata>
|
||||||
<trk>
|
<trk>
|
||||||
<name>Einmal Frust loswerden</name>
|
<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>
|
||||||
<type>running</type>
|
<type>running</type>
|
||||||
<trkseg>
|
<trkseg>
|
||||||
<trkpt lat="48.0140070" lon="7.8513840">
|
<trkpt lat="48.0140070" lon="7.8513840">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue