Compare commits

..

15 commits

Author SHA1 Message Date
8097d876e5
fix(profile): preserve bio line breaks in profile views (#37)
Signed-off-by: Marcus Fihlon <marcus@fihlon.swiss>
2026-05-05 11:35:13 +02:00
b88e6b0a95
Fix test errors (#31)
* test: disable `ActivityImageService` manual test class in default build

The test method was already disabled, but the Spring test context for the class was still being created during the regular test run. Moving `@Disabled` to the class prevents the Testcontainers-based application context from loading for this manual-only test and stops `verify` from failing.

Signed-off-by: Marcus Fihlon <marcus@fihlon.swiss>

* fix: handle missing timezone in activity title generation

Default title generation could fail when parsed activity data had no timezone set. This change adds a null/blank fallback in `ActivityFormatter` so titles can still be generated without throwing a `NullPointerException`.

Signed-off-by: Marcus Fihlon <marcus@fihlon.swiss>

* fix(testcontainers): align versions and stabilize PostGIS setup

Unify Testcontainers dependencies to a consistent version and remove
custom container tweaks that caused instability with Podman.

- align all Testcontainers dependencies to 2.0.5
- remove `HostPortWaitStrategy` (PostgreSQLContainer already defines an appropriate wait strategy)
- remove `withReuse(true)` (may retain state across runs and break reproducibility)

Signed-off-by: Marcus Fihlon <marcus@fihlon.swiss>

---------

Signed-off-by: Marcus Fihlon <marcus@fihlon.swiss>
2026-05-05 11:34:23 +02:00
de1b0d56f4
chore(gitignore): ignore coding assistant artifacts (#40)
Signed-off-by: Marcus Fihlon <marcus@fihlon.swiss>
2026-05-05 11:34:10 +02:00
9c1b484865
chore: stop tracking IntelliJ project files (#45)
Signed-off-by: Marcus Fihlon <marcus@fihlon.swiss>
2026-05-05 11:33:49 +02:00
2889bdc529
Fix federation payload for activities without proper metadata (#48)
* test: add regression coverage for inbox timestamps without timezone

Signed-off-by: Marcus Fihlon <marcus@fihlon.swiss>

* fix: accept inbox published timestamps without timezone

Signed-off-by: Marcus Fihlon <marcus@fihlon.swiss>

* test: add regression coverage for outgoing published timestamps

Signed-off-by: Marcus Fihlon <marcus@fihlon.swiss>

* fix: serialize outgoing published timestamps with timezone

Signed-off-by: Marcus Fihlon <marcus@fihlon.swiss>

* test: add federation roundtrip coverage for exported activities

Signed-off-by: Marcus Fihlon <marcus@fihlon.swiss>

* fix(federation): exchange remote activity metadata and routes via workoutData (#47)

Signed-off-by: Marcus Fihlon <marcus@fihlon.swiss>

---------

Signed-off-by: Marcus Fihlon <marcus@fihlon.swiss>
2026-05-05 10:39:05 +02:00
c84377b05a
fix(activity-detail): preserve line breaks in activity descriptions (#22)
- implement new CSS class `preserve-linebreaks` in `fitpub.css`
- add new CSS class to activity description element in `detail.html`
2026-04-29 09:18:53 +02:00
330040c775
chore: add .sdkmanrc for SDK version management (#23)
* chore: add .sdkmanrc for SDK version management

Add .sdkmanrc to define and standardize SDK versions
used in the project via SDKMAN.

* revert: remove unrelated change from feature branch

Signed-off-by: Marcus Fihlon <marcus@fihlon.swiss>

* revert: remove unrelated change from feature branch

---------

Signed-off-by: Marcus Fihlon <marcus@fihlon.swiss>
2026-04-29 09:18:20 +02:00
f47730e1ca
build: add Maven Wrapper (#27)
Add Maven Wrapper to ensure consistent build environment and
eliminate the need for a preinstalled Maven version.

Signed-off-by: Marcus Fihlon <marcus@fihlon.swiss>
2026-04-29 09:18:03 +02:00
Tim Zöller
9e529f8b99 Quote Post Fixe 2026-04-27 22:27:16 +02:00
Tim Zöller
d79678aae3 Accept Quote Post Requests 2026-04-27 22:16:40 +02:00
Niklas
102d515b42
Display activity date in local time (using the time zone that is stored with the activity), not in UTC (#4)
* Display timestamps using the timezone that is stored at the activity (fix 'new Date()' invocation)

* Display timestamps using the timezone that is stored at the activity (relative date in timeline views)

* Use correct timezone for auto-generated activity title

---------

Co-authored-by: Niklas Deutschmann <sonstharmlos@noreply.codeberg.org>
2026-04-27 22:01:08 +02:00
Tim Zöller
5df4da86a5 Switched Weather Provider to OpenMeteo #15 2026-04-27 21:54:46 +02:00
Niklas
5f85417c80
Don't display "null" as activity location in UI when reverse geocoding didn't took place (#3)
* Fix display of "null" for activity location (backend)

* Fix display of "null" for activity location (frontend)

---------

Co-authored-by: Niklas Deutschmann <sonstharmlos@noreply.codeberg.org>
2026-04-13 14:20:04 +02:00
Niklas
fb440b2b8f
The activity type was not preserved when editing an activity (#6)
Co-authored-by: Niklas Deutschmann <sonstharmlos@noreply.codeberg.org>
2026-04-13 14:18:38 +02:00
Niklas
03b8e6d315
Get activity title from uploaded file, if it is present (#7)
* Use activity title from GPX file when present

* Extend test

* Test shortening of long names

* Extract constant

---------

Co-authored-by: Niklas Deutschmann <sonstharmlos@noreply.codeberg.org>
2026-04-13 10:12:38 +02:00
50 changed files with 1998 additions and 486 deletions

12
.gitignore vendored
View file

@ -5,10 +5,7 @@ target/
.kotlin
### IntelliJ IDEA ###
.idea/modules.xml
.idea/jarRepositories.xml
.idea/compiler.xml
.idea/libraries/
.idea/
*.iws
*.iml
*.ipr
@ -49,3 +46,10 @@ logs/
/gadm_410.gpkg
/.postgresdata/
/peaks_worldwide.geojson
### Coding Assistants ###
.codex/
.aider*
.cursor/
.roo/
.windsurf/

8
.idea/.gitignore generated vendored
View file

@ -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
View file

@ -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>

View file

@ -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
View file

@ -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
View file

@ -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
View file

@ -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
View file

@ -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
View 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
View 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
View 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
View 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"

View file

@ -23,7 +23,7 @@
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<java.version>17</java.version>
<jjwt.version>0.12.3</jjwt.version>
<testcontainers.version>2.0.3</testcontainers.version>
<testcontainers.version>2.0.5</testcontainers.version>
</properties>
<dependencies>
@ -170,15 +170,14 @@
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<version>2.0.2</version>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-postgresql</artifactId>
<version>2.0.1</version>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>

View file

@ -10,13 +10,14 @@ import net.javahippie.fitpub.model.activitypub.OrderedCollection;
import net.javahippie.fitpub.model.entity.Activity;
import net.javahippie.fitpub.model.entity.RemoteActor;
import net.javahippie.fitpub.model.entity.User;
import net.javahippie.fitpub.repository.FollowRepository;
import net.javahippie.fitpub.repository.ActivityRepository;
import net.javahippie.fitpub.repository.FollowRepository;
import net.javahippie.fitpub.repository.UserRepository;
import net.javahippie.fitpub.security.HttpSignatureValidator;
import net.javahippie.fitpub.service.ActivityImageService;
import net.javahippie.fitpub.service.FederationService;
import net.javahippie.fitpub.service.InboxProcessor;
import net.javahippie.fitpub.service.WorkoutDataPayloadBuilder;
import net.javahippie.fitpub.util.ActivityFormatter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
@ -29,6 +30,7 @@ import java.net.URI;
import java.net.URISyntaxException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.ZoneOffset;
import java.util.*;
import java.util.regex.Pattern;
@ -51,6 +53,7 @@ public class ActivityPubController {
private final HttpSignatureValidator signatureValidator;
private final FederationService federationService;
private final ObjectMapper objectMapper;
private final WorkoutDataPayloadBuilder workoutDataPayloadBuilder;
@Value("${fitpub.base-url}")
private String baseUrl;
@ -436,9 +439,10 @@ public class ActivityPubController {
noteObject.put("id", activityUri);
noteObject.put("type", "Note");
noteObject.put("attributedTo", actorUri);
noteObject.put("published", activity.getCreatedAt().toString());
noteObject.put("published", activity.getCreatedAt().atOffset(ZoneOffset.UTC).toInstant().toString());
noteObject.put("content", formatActivityContent(activity));
noteObject.put("url", activityUri);
noteObject.put("workoutData", workoutDataPayloadBuilder.build(activity));
// Audience only PUBLIC activities reach this endpoint (the visibility
// check above returned 403 for anything else), so audience is always

View file

@ -133,7 +133,7 @@ public class ActivityDTO {
.elevationLoss(activity.getElevationLoss())
.createdAt(activity.getCreatedAt())
.updatedAt(activity.getUpdatedAt())
.activityLocation(activity.getActivityLocation());
.activityLocation(activity.getActivityLocationNonNull());
if (activity.getTotalDurationSeconds() != null) {
builder.totalDurationSeconds(activity.getTotalDurationSeconds());
@ -246,7 +246,7 @@ public class ActivityDTO {
.subSport(activity.getSubSport())
.indoorDetectionMethod(activity.getIndoorDetectionMethod())
.race(activity.getRace() != null ? activity.getRace() : false)
.activityLocation(activity.getActivityLocation())
.activityLocation(activity.getActivityLocationNonNull())
.build();
}
@ -266,7 +266,7 @@ public class ActivityDTO {
.elevationLoss(activity.getElevationLoss())
.createdAt(activity.getCreatedAt())
.updatedAt(activity.getUpdatedAt())
.activityLocation(activity.getActivityLocation());
.activityLocation(activity.getActivityLocationNonNull());
if (activity.getTotalDurationSeconds() != null) {
builder.totalDurationSeconds(activity.getTotalDurationSeconds());

View file

@ -104,7 +104,7 @@ public class TimelineActivityDTO {
.indoorDetectionMethod(activity.getIndoorDetectionMethod())
.race(activity.getRace() != null ? activity.getRace() : false)
.metrics(activity.getMetrics() != null ? ActivityMetricsSummary.fromMetrics(activity.getMetrics()) : null)
.activityLocation(activity.getActivityLocation())
.activityLocation(activity.getActivityLocationNonNull())
.build();
}

View file

@ -214,6 +214,10 @@ public class Activity {
}
}
public String getActivityLocationNonNull() {
return activityLocation != null ? activityLocation : "";
}
/**
* Activity types supported by the platform
*/

View file

@ -9,6 +9,7 @@ import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.Type;
import org.hibernate.annotations.UpdateTimestamp;
import org.locationtech.jts.geom.LineString;
import java.time.Instant;
import java.time.LocalDateTime;
@ -137,6 +138,12 @@ public class RemoteActivity {
@Column(name = "track_geojson_url", length = 512)
private String trackGeojsonUrl;
/**
* Simplified remote route geometry for local map rendering.
*/
@Column(name = "simplified_track", columnDefinition = "geometry(LineString, 4326)")
private LineString simplifiedTrack;
/**
* Visibility level of the activity.
*/

View file

@ -378,10 +378,17 @@ public class ActivityFileService {
byte[] rawFile,
ProcessingOptions options
) throws JsonProcessingException {
String activityTitle;
if (title != null && !title.isBlank()) {
activityTitle = title;
} else if (parsedData.getTitle() != null) {
// Try to use title from input file
activityTitle = parsedData.getTitle();
} else {
// Generate title if not provided
String activityTitle = title != null && !title.isBlank()
? title
: ActivityFormatter.generateActivityTitle(parsedData.getStartTime(), parsedData.getActivityType());
activityTitle = ActivityFormatter.generateActivityTitle(parsedData.getStartTime(), parsedData.getTimezone(),
parsedData.getActivityType());
}
// Default to PUBLIC if visibility not specified
Activity.Visibility activityVisibility = visibility != null ? visibility : Activity.Visibility.PRIVATE;

View file

@ -12,6 +12,7 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.time.ZoneOffset;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -38,6 +39,7 @@ public class ActivityPostProcessingService {
private final ActivityImageService activityImageService;
private final ActivityRepository activityRepository;
private final UserRepository userRepository;
private final WorkoutDataPayloadBuilder workoutDataPayloadBuilder;
@Value("${fitpub.base-url}")
private String baseUrl;
@ -199,9 +201,10 @@ public class ActivityPostProcessingService {
noteObject.put("id", activityUri);
noteObject.put("type", "Note");
noteObject.put("attributedTo", actorUri);
noteObject.put("published", activity.getCreatedAt().toString());
noteObject.put("published", activity.getCreatedAt().atOffset(ZoneOffset.UTC).toInstant().toString());
noteObject.put("content", formatActivityContent(activity));
noteObject.put("url", baseUrl + "/activities/" + activity.getId());
noteObject.put("workoutData", workoutDataPayloadBuilder.build(activity));
// Extract hashtags from user text and add as tags
List<String> hashtags = extractHashtags(activity);

View file

@ -39,6 +39,7 @@ public class ActivitySummaryService {
* Update summary for an activity's period.
* Called after an activity is saved.
*/
@Async
@Transactional
public void updateSummariesForActivity(Activity activity) {
if (activity.getUserId() == null || activity.getStartedAt() == null) {

View file

@ -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.
*

View file

@ -24,7 +24,6 @@ import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
@ -257,7 +256,7 @@ public class FitFileService {
private String generateTitle(ParsedActivityData parsedData) {
return ActivityFormatter.generateActivityTitle(
parsedData.getStartTime(),
parsedData.getActivityType()
parsedData.getTimezone(), parsedData.getActivityType()
);
}

View file

@ -16,11 +16,20 @@ import net.javahippie.fitpub.repository.CommentRepository;
import net.javahippie.fitpub.repository.FollowRepository;
import net.javahippie.fitpub.repository.LikeRepository;
import net.javahippie.fitpub.repository.UserRepository;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.LineString;
import org.locationtech.jts.geom.PrecisionModel;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeParseException;
import java.util.Map;
import java.util.UUID;
@ -31,6 +40,9 @@ import java.util.UUID;
@RequiredArgsConstructor
@Slf4j
public class InboxProcessor {
private static final int GEOMETRY_SRID = 4326;
private static final GeometryFactory GEOMETRY_FACTORY =
new GeometryFactory(new PrecisionModel(), GEOMETRY_SRID);
private final UserRepository userRepository;
private final FollowRepository followRepository;
@ -238,6 +250,19 @@ public class InboxProcessor {
return;
}
// Check if this Note quotes a local activity (FEP-5e53).
// Mastodon and other implementations use various field names for the quote reference.
String quoteUri = firstNonNull(
(String) noteObject.get("quoteUri"),
(String) noteObject.get("quote"),
(String) noteObject.get("quoteUrl"),
(String) noteObject.get("_misskey_quote")
);
if (quoteUri != null) {
handleQuoteApproval(username, activity, actor, quoteUri);
}
String inReplyTo = (String) noteObject.get("inReplyTo");
if (inReplyTo == null) {
@ -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).
*/
@ -353,15 +423,18 @@ public class InboxProcessor {
// Parse published timestamp
String publishedStr = (String) noteObject.get("published");
Instant publishedAt = publishedStr != null ? Instant.parse(publishedStr) : Instant.now();
Instant publishedAt = parsePublishedAt(publishedStr);
// Build RemoteActivity entity
RemoteActivity remoteActivity = RemoteActivity.builder()
.activityUri(activityUri)
.remoteActorUri(actor)
.activityType((String) workoutData.get("activityType"))
.activityType(stringValue(workoutData.get("activityType")))
.title((String) noteObject.getOrDefault("name", noteObject.getOrDefault("summary", "Untitled Activity")))
.description(stripHtml((String) noteObject.get("content")))
.description(firstNonBlank(
stringValue(workoutData.get("description")),
stripHtml((String) noteObject.get("content"))
))
.publishedAt(publishedAt)
.totalDistance(parseLong(workoutData.get("distance")))
.totalDurationSeconds(parseDurationSeconds((String) workoutData.get("duration")))
@ -373,6 +446,7 @@ public class InboxProcessor {
.calories(parseInteger(workoutData.get("calories")))
.mapImageUrl(attachments.get("mapImage"))
.trackGeojsonUrl(attachments.get("trackGeojson"))
.simplifiedTrack(extractRoute(workoutData))
.visibility(visibility)
.activityPubObject(serializeToJson(noteObject))
.build();
@ -647,6 +721,88 @@ public class InboxProcessor {
return workoutData;
}
private String stringValue(Object value) {
return value != null ? String.valueOf(value) : null;
}
private LineString extractRoute(Map<String, Object> workoutData) {
Object routeObj = workoutData.get("route");
if (!(routeObj instanceof Map<?, ?> routeMap)) {
return null;
}
Object featuresObj = routeMap.get("features");
if (!(featuresObj instanceof java.util.List<?> features) || features.isEmpty()) {
return null;
}
for (Object featureObj : features) {
if (!(featureObj instanceof Map<?, ?> featureMap)) {
continue;
}
Object geometryObj = featureMap.get("geometry");
if (!(geometryObj instanceof Map<?, ?> geometryMap)) {
continue;
}
if (!"LineString".equals(geometryMap.get("type"))) {
continue;
}
LineString lineString = parseLineStringCoordinates(geometryMap.get("coordinates"));
if (lineString != null) {
return lineString;
}
}
return null;
}
private LineString parseLineStringCoordinates(Object coordinatesObj) {
if (!(coordinatesObj instanceof java.util.List<?> coordinateList) || coordinateList.size() < 2) {
return null;
}
java.util.List<Coordinate> coordinates = new java.util.ArrayList<>();
for (Object coordinateObj : coordinateList) {
Coordinate coordinate = parseCoordinate(coordinateObj);
if (coordinate == null) {
return null;
}
coordinates.add(coordinate);
}
if (coordinates.size() < 2) {
return null;
}
return GEOMETRY_FACTORY.createLineString(coordinates.toArray(new Coordinate[0]));
}
private Coordinate parseCoordinate(Object coordinateObj) {
if (!(coordinateObj instanceof java.util.List<?> coordinateValues) || coordinateValues.size() < 2) {
return null;
}
Double longitude = parseDouble(coordinateValues.get(0));
Double latitude = parseDouble(coordinateValues.get(1));
if (longitude == null || latitude == null) {
return null;
}
return new Coordinate(longitude, latitude);
}
private String firstNonBlank(String... values) {
for (String value : values) {
if (value != null && !value.isBlank()) {
return value;
}
}
return null;
}
/**
* Extract attachment URLs (map image, GeoJSON) from a Note object.
*/
@ -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.
*/

View file

@ -98,7 +98,7 @@ public class TimelineResultMapper {
.commentsCount(commentsCount)
.likedByCurrentUser(likedByCurrentUser)
.hasGpsTrack(true) // Will be refined based on actual data
.activityLocation(activityLocation)
.activityLocation(activityLocation != null ? activityLocation : "")
.build();
} catch (Exception e) {

View file

@ -1,6 +1,5 @@
package net.javahippie.fitpub.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
@ -9,23 +8,22 @@ import net.javahippie.fitpub.model.entity.Activity;
import net.javahippie.fitpub.model.entity.TrackPoint;
import net.javahippie.fitpub.model.entity.WeatherData;
import net.javahippie.fitpub.repository.WeatherDataRepository;
import org.locationtech.jts.geom.Point;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestTemplate;
import java.math.BigDecimal;
import java.net.URI;
import java.time.Instant;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
/**
* 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
@Slf4j
@ -36,14 +34,10 @@ public class WeatherService {
private final RestTemplate restTemplate = new RestTemplate();
private final ObjectMapper objectMapper = new ObjectMapper();
@Value("${fitpub.weather.api-key:}")
private String apiKey;
@Value("${fitpub.weather.enabled:false}")
private boolean weatherEnabled;
private static final String OPENWEATHERMAP_API_URL = "https://api.openweathermap.org/data/2.5/weather";
private static final String OPENWEATHERMAP_TIMEMACHINE_URL = "https://api.openweathermap.org/data/3.0/onecall/timemachine";
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}";
/**
* Fetch and store weather data for an activity.
@ -55,24 +49,6 @@ public class WeatherService {
@Transactional
public Optional<WeatherData> fetchWeatherForActivity(Activity activity) {
log.info("=== Weather fetch requested for activity {} ===", activity.getId());
log.info("Weather configuration: enabled={}, API key configured={}, API key length={}",
weatherEnabled, (apiKey != null && !apiKey.isBlank()),
(apiKey != null ? apiKey.length() : 0));
if (!weatherEnabled) {
log.warn("Weather fetching is DISABLED in configuration (fitpub.weather.enabled=false). " +
"Set fitpub.weather.enabled=true in application properties to enable.");
return Optional.empty();
}
if (apiKey == null || apiKey.isBlank()) {
log.error("Weather API key is NOT CONFIGURED (fitpub.weather.api-key is empty). " +
"Please set fitpub.weather.api-key in application properties.");
return Optional.empty();
}
log.debug("Weather API key present: length={} chars, first 4 chars={}...",
apiKey.length(), apiKey.length() > 4 ? apiKey.substring(0, 4) : "???");
if (activity.getTrackPointsJson() == null || activity.getTrackPointsJson().isEmpty()) {
log.warn("No track points available for activity {} - cannot fetch weather", activity.getId());
@ -88,21 +64,7 @@ public class WeatherService {
return Optional.empty();
} else {
var resolvedTrackPoint = trackPoint.get();
// Check if activity is recent (within 5 days) because it's free to use. Don't call other timeframes, expensive.
long activityTimestamp = activity.getStartedAt().atZone(ZoneId.systemDefault()).toEpochSecond();
long currentTimestamp = Instant.now().getEpochSecond();
long daysDifference = (currentTimestamp - activityTimestamp) / 86400;
log.info("Activity started at: {}, days ago: {}", activity.getStartedAt(), daysDifference);
WeatherData weatherData;
if (daysDifference <= 5) {
log.info("Activity is RECENT ({} days old, within 5 day threshold), fetching current weather from OpenWeatherMap", daysDifference);
weatherData = fetchCurrentWeather(resolvedTrackPoint.lat(), resolvedTrackPoint.lon(), activity.getId());
} else {
log.warn("Activity is {} days old (exceeds 5 day threshold). Historical weather data requires OpenWeatherMap paid API plan. Skipping weather fetch.", daysDifference);
return Optional.empty();
}
WeatherData weatherData = fetchCurrentWeather(resolvedTrackPoint.lat(), resolvedTrackPoint.lon(), activity.getId(), activity.getStartedAt());
if (weatherData != null) {
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);
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***");
log.info("Constructed OpenWeatherMap API URL: {}", maskedUrl);
log.info("Request parameters: lat={}, lon={}, units=metric", lat, lon);
Map<String, Object> uriVariables = Map.of(
"latitude", lat,
"longitude", lon,
"start_date", startedAt.format(DateTimeFormatter.ISO_DATE),
"end_date", startedAt.format(DateTimeFormatter.ISO_DATE),
"hourly", "temperature_2m,apparent_temperature,relative_humidity_2m,surface_pressure,wind_speed_10m,wind_direction_10m,cloud_cover,rain,snowfall,precipitation,visibility,weather_code"
);
log.info("Request parameters: lat={}, lon={}, date={}", lat, lon, startedAt);
long startTime = System.currentTimeMillis();
log.info("Sending HTTP GET request to OpenWeatherMap...");
String response = restTemplate.getForObject(URI.create(url), String.class);
log.info("Sending HTTP GET request to Open-Meteo...");
String response = restTemplate.getForObject(OPEN_METEO_API_URL, String.class, uriVariables);
long duration = System.currentTimeMillis() - startTime;
log.info("HTTP request completed in {}ms, response received", duration);
if (response == null) {
log.error("API response is NULL - RestTemplate returned null, no data from OpenWeatherMap");
log.error("API response is NULL - RestTemplate returned null, no data from Open-Meteo");
return null;
}
log.info("API response received: {} characters", response.length());
log.info("API response (first 300 chars): {}",
response.length() > 300 ? response.substring(0, 300) + "..." : response);
log.info("Parsing weather response JSON...");
WeatherData weatherData = parseWeatherResponse(response, activityId);
WeatherData weatherData = parseWeatherResponse(response, activityId, startedAt);
if (weatherData == null) {
log.error("FAILED to parse weather response - see parsing errors above");
} 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.getFeelsLikeCelsius(),
weatherData.getWeatherCondition(),
weatherData.getWeatherDescription(),
weatherData.getHumidity(),
weatherData.getPressure(),
weatherData.getWindSpeedMps());
weatherData.getWindSpeedMps(),
weatherData.getPrecipitationMm());
}
log.info("=== fetchCurrentWeather END === success={}", (weatherData != null));
return weatherData;
} catch (org.springframework.web.client.HttpClientErrorException e) {
log.error("=== HTTP CLIENT ERROR (4xx) from OpenWeatherMap API ===");
log.error("Status Code: {}", e.getStatusCode());
log.error("Status Text: {}", e.getStatusText());
log.error("Response Body: {}", e.getResponseBodyAsString());
log.error("Request URL (masked): {}", OPENWEATHERMAP_API_URL + "?lat=" + lat + "&lon=" + lon + "&appid=***&units=metric");
if (e.getStatusCode().value() == 401) {
log.error("AUTHENTICATION FAILED - Check your OpenWeatherMap API key is valid and active");
} else if (e.getStatusCode().value() == 404) {
log.error("API ENDPOINT NOT FOUND - Check coordinates are valid: lat={}, lon={}", lat, lon);
} else if (e.getStatusCode().value() == 429) {
log.error("RATE LIMIT EXCEEDED - Too many API requests. Check your OpenWeatherMap plan limits.");
}
log.error("Exception details:", e);
log.error("HTTP client error (4xx) from Open-Meteo API: {} - {}", e.getStatusCode(), e.getResponseBodyAsString(), e);
return null;
} catch (org.springframework.web.client.HttpServerErrorException e) {
log.error("=== HTTP SERVER ERROR (5xx) from OpenWeatherMap API ===");
log.error("Status Code: {}", e.getStatusCode());
log.error("Status Text: {}", e.getStatusText());
log.error("Response Body: {}", e.getResponseBodyAsString());
log.error("OpenWeatherMap service may be experiencing issues. Try again later.");
log.error("Exception details:", e);
log.error("HTTP server error (5xx) from Open-Meteo API: {} - {}", e.getStatusCode(), e.getResponseBodyAsString(), e);
return null;
} catch (org.springframework.web.client.ResourceAccessException e) {
log.error("=== NETWORK/CONNECTION ERROR accessing OpenWeatherMap API ===");
log.error("Error message: {}", e.getMessage());
log.error("This could indicate: DNS resolution failure, network connectivity issues, firewall blocking, or SSL certificate problems");
log.error("API URL attempted: {}", OPENWEATHERMAP_API_URL);
log.error("Exception details:", e);
return null;
} catch (org.springframework.web.client.RestClientException e) {
log.error("=== REST CLIENT EXCEPTION calling OpenWeatherMap API ===");
log.error("Exception type: {}", e.getClass().getName());
log.error("Error message: {}", e.getMessage());
log.error("Exception details:", e);
log.error("Network error accessing Open-Meteo API: {}", e.getMessage(), e);
return null;
} catch (Exception e) {
log.error("=== UNEXPECTED EXCEPTION fetching current weather ===");
log.error("Exception type: {}", e.getClass().getName());
log.error("Error message: {}", e.getMessage());
log.error("Activity ID: {}", activityId);
log.error("Coordinates: lat={}, lon={}", lat, lon);
log.error("Full stack trace:", e);
log.error("Unexpected exception fetching weather for activity {}: {}", activityId, e.getMessage(), e);
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);
try {
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.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
if (root.has("main")) {
JsonNode main = root.get("main");
log.debug("Parsing 'main' section: {}", main);
weatherData.setTemperatureCelsius(getBigDecimal(main, "temp"));
weatherData.setFeelsLikeCelsius(getBigDecimal(main, "feels_like"));
weatherData.setHumidity(getInteger(main, "humidity"));
weatherData.setPressure(getInteger(main, "pressure"));
log.debug("Extracted main data: temp={}, feels_like={}, humidity={}, pressure={}",
weatherData.getTemperatureCelsius(), weatherData.getFeelsLikeCelsius(),
weatherData.getHumidity(), weatherData.getPressure());
} else {
log.warn("Response JSON does not contain 'main' section");
// Open-Meteo returns wind speed in km/h, convert to m/s
BigDecimal windKmh = getHourlyBigDecimal(hourly, "wind_speed_10m", hourIndex);
if (windKmh != null) {
weatherData.setWindSpeedMps(windKmh.divide(BigDecimal.valueOf(3.6), 2, RoundingMode.HALF_UP));
}
// Wind data
if (root.has("wind")) {
JsonNode wind = root.get("wind");
log.debug("Parsing 'wind' section: {}", wind);
weatherData.setWindSpeedMps(getBigDecimal(wind, "speed"));
weatherData.setWindDirection(getInteger(wind, "deg"));
log.debug("Extracted wind data: speed={} m/s, direction={} degrees",
weatherData.getWindSpeedMps(), weatherData.getWindDirection());
} else {
log.debug("Response JSON does not contain 'wind' section");
}
// Weather condition
if (root.has("weather") && root.get("weather").isArray() && !root.get("weather").isEmpty()) {
JsonNode weather = root.get("weather").get(0);
log.debug("Parsing 'weather' array (first element): {}", weather);
weatherData.setWeatherCondition(getString(weather, "main"));
weatherData.setWeatherDescription(getString(weather, "description"));
weatherData.setWeatherIcon(getString(weather, "icon"));
log.debug("Extracted weather condition: main='{}', description='{}', icon='{}'",
weatherData.getWeatherCondition(), weatherData.getWeatherDescription(),
weatherData.getWeatherIcon());
} else {
log.warn("Response JSON does not contain valid 'weather' array");
}
// Clouds
if (root.has("clouds")) {
weatherData.setCloudiness(getInteger(root.get("clouds"), "all"));
log.debug("Extracted cloudiness: {}%", weatherData.getCloudiness());
}
// Visibility
if (root.has("visibility")) {
weatherData.setVisibilityMeters(root.get("visibility").asInt());
log.debug("Extracted visibility: {} meters", weatherData.getVisibilityMeters());
}
// Rain
if (root.has("rain")) {
JsonNode rain = root.get("rain");
if (rain.has("1h")) {
weatherData.setPrecipitationMm(BigDecimal.valueOf(rain.get("1h").asDouble()));
log.debug("Extracted rain: {} mm/h", weatherData.getPrecipitationMm());
}
}
// Snow
if (root.has("snow")) {
JsonNode snow = root.get("snow");
if (snow.has("1h")) {
weatherData.setSnowMm(BigDecimal.valueOf(snow.get("1h").asDouble()));
log.debug("Extracted snow: {} mm/h", weatherData.getSnowMm());
}
}
// Sun times
if (root.has("sys")) {
JsonNode sys = root.get("sys");
if (sys.has("sunrise")) {
weatherData.setSunrise(LocalDateTime.ofInstant(
Instant.ofEpochSecond(sys.get("sunrise").asLong()), ZoneId.systemDefault()));
log.debug("Extracted sunrise: {}", weatherData.getSunrise());
}
if (sys.has("sunset")) {
weatherData.setSunset(LocalDateTime.ofInstant(
Instant.ofEpochSecond(sys.get("sunset").asLong()), ZoneId.systemDefault()));
log.debug("Extracted sunset: {}", weatherData.getSunset());
}
// Open-Meteo returns snowfall in cm, convert to mm
BigDecimal snowCm = getHourlyBigDecimal(hourly, "snowfall", hourIndex);
if (snowCm != null) {
weatherData.setSnowMm(snowCm.multiply(BigDecimal.TEN));
}
weatherData.setFetchedAt(LocalDateTime.now());
weatherData.setDataSource("openweathermap");
weatherData.setDataSource("open-meteo");
log.info("Successfully parsed complete weather data");
log.debug("=== parseWeatherResponse END === success=true");
log.info("Successfully parsed weather data: temp={}°C, condition='{}', wind={} m/s",
weatherData.getTemperatureCelsius(), weatherData.getWeatherCondition(), weatherData.getWindSpeedMps());
return weatherData;
} catch (com.fasterxml.jackson.core.JsonProcessingException e) {
log.error("=== JSON PARSING ERROR ===");
log.error("Failed to parse weather response as JSON");
log.error("Response content: {}", response);
log.error("Parse error: {}", e.getMessage(), e);
log.error("Failed to parse weather response as JSON: {}", e.getMessage(), e);
return null;
} catch (Exception e) {
log.error("=== UNEXPECTED ERROR parsing weather response ===");
log.error("Exception type: {}", e.getClass().getName());
log.error("Error message: {}", e.getMessage());
log.error("Response content: {}", response);
log.error("Full stack trace:", e);
log.error("Unexpected error parsing weather response: {}", e.getMessage(), e);
return null;
}
}
private BigDecimal getHourlyBigDecimal(JsonNode hourly, String field, int index) {
JsonNode array = hourly.get(field);
if (array != null && index < array.size() && !array.get(index).isNull()) {
return BigDecimal.valueOf(array.get(index).asDouble());
}
return null;
}
private Integer getHourlyInteger(JsonNode hourly, String field, int index) {
JsonNode array = hourly.get(field);
if (array != null && index < array.size() && !array.get(index).isNull()) {
return array.get(index).asInt();
}
return null;
}
/**
* Map WMO weather code to a condition string.
* See https://open-meteo.com/en/docs#weathervariables
*/
private String mapWmoCodeToCondition(Integer code) {
if (code == null) return null;
return switch (code) {
case 0 -> "Clear";
case 1, 2, 3 -> "Clouds";
case 45, 48 -> "Fog";
case 51, 53, 55 -> "Drizzle";
case 56, 57 -> "Drizzle";
case 61, 63, 65 -> "Rain";
case 66, 67 -> "Rain";
case 71, 73, 75, 77 -> "Snow";
case 80, 81, 82 -> "Rain";
case 85, 86 -> "Snow";
case 95, 96, 99 -> "Thunderstorm";
default -> "Unknown";
};
}
/**
* Map WMO weather code to a human-readable description.
*/
private String mapWmoCodeToDescription(Integer code) {
if (code == null) return null;
return switch (code) {
case 0 -> "clear sky";
case 1 -> "mainly clear";
case 2 -> "partly cloudy";
case 3 -> "overcast";
case 45 -> "fog";
case 48 -> "depositing rime fog";
case 51 -> "light drizzle";
case 53 -> "moderate drizzle";
case 55 -> "dense drizzle";
case 56 -> "light freezing drizzle";
case 57 -> "dense freezing drizzle";
case 61 -> "slight rain";
case 63 -> "moderate rain";
case 65 -> "heavy rain";
case 66 -> "light freezing rain";
case 67 -> "heavy freezing rain";
case 71 -> "slight snow fall";
case 73 -> "moderate snow fall";
case 75 -> "heavy snow fall";
case 77 -> "snow grains";
case 80 -> "slight rain showers";
case 81 -> "moderate rain showers";
case 82 -> "violent rain showers";
case 85 -> "slight snow showers";
case 86 -> "heavy snow showers";
case 95 -> "thunderstorm";
case 96 -> "thunderstorm with slight hail";
case 99 -> "thunderstorm with heavy hail";
default -> "unknown";
};
}
/**
* Get weather data for an activity.
*
@ -363,43 +322,4 @@ public class WeatherService {
weatherDataRepository.deleteByActivityId(activityId);
}
// Helper methods to safely extract values from JSON
private BigDecimal getBigDecimal(JsonNode node, String field) {
if (node != null && node.has(field) && !node.get(field).isNull()) {
try {
return BigDecimal.valueOf(node.get(field).asDouble());
} catch (Exception e) {
log.warn("Failed to extract BigDecimal from field '{}': {}", field, e.getMessage());
return null;
}
}
log.debug("Field '{}' not found or is null in node", field);
return null;
}
private Integer getInteger(JsonNode node, String field) {
if (node != null && node.has(field) && !node.get(field).isNull()) {
try {
return node.get(field).asInt();
} catch (Exception e) {
log.warn("Failed to extract Integer from field '{}': {}", field, e.getMessage());
return null;
}
}
log.debug("Field '{}' not found or is null in node", field);
return null;
}
private String getString(JsonNode node, String field) {
if (node != null && node.has(field) && !node.get(field).isNull()) {
try {
return node.get(field).asText();
} catch (Exception e) {
log.warn("Failed to extract String from field '{}': {}", field, e.getMessage());
return null;
}
}
log.debug("Field '{}' not found or is null in node", field);
return null;
}
}

View file

@ -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;
}
}

View file

@ -1,13 +1,14 @@
package net.javahippie.fitpub.util;
import lombok.extern.slf4j.Slf4j;
import net.javahippie.fitpub.model.entity.Activity;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.*;
/**
* Utility class for formatting activity-related data for display.
*/
@Slf4j
public class ActivityFormatter {
/**
@ -48,20 +49,24 @@ public class ActivityFormatter {
* Format: "[Time of Day] [Activity Type]" (e.g., "Morning Run", "Evening Ride")
*
* @param startedAt the activity start time
* @param timezone the timezone ID of the activity
* @param activityType the activity type
* @return generated title
*/
public static String generateActivityTitle(LocalDateTime startedAt, Activity.ActivityType activityType) {
public static String generateActivityTitle(LocalDateTime startedAt, String timezone, Activity.ActivityType activityType) {
if (startedAt == null || activityType == null) {
return "Activity";
}
String timeOfDay = getTimeOfDay(startedAt.toLocalTime());
LocalDateTime startedAtLocal = getUtcDateTimeInZone(startedAt, timezone);
String timeOfDay = getTimeOfDay(startedAtLocal.toLocalTime());
String formattedType = formatActivityType(activityType);
return timeOfDay + " " + formattedType;
}
/**
* Determines the time of day based on the hour.
*
@ -81,4 +86,29 @@ public class ActivityFormatter {
return "Night";
}
}
/**
* Attempts to convert the given LocalDateTime (which is assumed to be UTC) into a LocalDateTime in the given
* timezone
*
* @param utcDateTime The original date and time (UTC)
* @param timezone A timezone ID
* @return The original date and time adjusted to the timezone, if the zone ID could be parsed. The original date
* and time otherwise
*
*/
private static LocalDateTime getUtcDateTimeInZone(LocalDateTime utcDateTime, String timezone) {
if (timezone == null || timezone.isBlank()) {
return utcDateTime;
}
try {
return utcDateTime.atZone(ZoneOffset.UTC)
.withZoneSameInstant(ZoneId.of(timezone))
.toLocalDateTime();
} catch (DateTimeException e) {
log.warn("Invalid time zone ID: {}", timezone);
return utcDateTime;
}
}
}

View file

@ -35,7 +35,8 @@ public final class ActivityPubContexts {
/**
* Returns the extended JSON-LD {@code @context} value for outbound objects
* that carry interaction-policy declarations. Shape:
* that carry both interaction-policy declarations and FitPub's proprietary
* {@code workoutData} extension fields. Shape:
*
* <pre>
* [
@ -45,7 +46,20 @@ public final class ActivityPubContexts {
* "interactionPolicy": { "@id": "gts:interactionPolicy", "@type": "@id" },
* "canQuote": { "@id": "gts:canQuote", "@type": "@id" },
* "automaticApproval": { "@id": "gts:automaticApproval", "@type": "@id" },
* "manualApproval": { "@id": "gts:manualApproval", "@type": "@id" }
* "manualApproval": { "@id": "gts:manualApproval", "@type": "@id" },
* "fitpub": "https://fitpub.social/ns#",
* "workoutData": "fitpub:workoutData",
* "activityType": "fitpub:activityType",
* "description": "fitpub:description",
* "distance": "fitpub:distance",
* "duration": "fitpub:duration",
* "elevationGain": "fitpub:elevationGain",
* "averagePace": "fitpub:averagePace",
* "averageHeartRate": "fitpub:averageHeartRate",
* "averageSpeed": "fitpub:averageSpeed",
* "maxSpeed": "fitpub:maxSpeed",
* "calories": "fitpub:calories",
* "route": "fitpub:route"
* }
* ]
* </pre>
@ -56,6 +70,12 @@ public final class ActivityPubContexts {
* Mastodon source, "interaction_policies" extension), so a Mastodon
* receiver compacting our object with its own context will recognise the
* field names and apply the policy.
*
* <p>The {@code fitpub:} prefix is FitPub's own extension namespace
* ({@code https://fitpub.social/ns#}). It declares the proprietary
* {@code workoutData} object and its structured activity fields so FitPub
* instances can exchange machine-readable workout metadata without
* overloading the standard ActivityStreams fields.
*/
public static List<Object> extendedContext() {
Map<String, Object> extensions = new LinkedHashMap<>();
@ -64,6 +84,19 @@ public final class ActivityPubContexts {
extensions.put("canQuote", typedRef("gts:canQuote"));
extensions.put("automaticApproval", typedRef("gts:automaticApproval"));
extensions.put("manualApproval", typedRef("gts:manualApproval"));
extensions.put("fitpub", "https://fitpub.social/ns#");
extensions.put("workoutData", "fitpub:workoutData");
extensions.put("activityType", "fitpub:activityType");
extensions.put("description", "fitpub:description");
extensions.put("distance", "fitpub:distance");
extensions.put("duration", "fitpub:duration");
extensions.put("elevationGain", "fitpub:elevationGain");
extensions.put("averagePace", "fitpub:averagePace");
extensions.put("averageHeartRate", "fitpub:averageHeartRate");
extensions.put("averageSpeed", "fitpub:averageSpeed");
extensions.put("maxSpeed", "fitpub:maxSpeed");
extensions.put("calories", "fitpub:calories");
extensions.put("route", "fitpub:route");
return List.of(
"https://www.w3.org/ns/activitystreams",
extensions

View file

@ -26,6 +26,8 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import static net.javahippie.fitpub.util.ParsedActivityData.MAX_TITLE_LENGTH;
/**
* Parser for GPX (GPS Exchange Format) files.
* Extracts GPS coordinates, activity metrics from track points.
@ -80,8 +82,12 @@ public class GpxParser {
// Calculate duration
parsedData.setTotalDuration(Duration.between(firstPoint.getTimestamp(), lastPoint.getTimestamp()));
// Extract activity type from metadata
extractActivityType(doc, parsedData);
// Extract activity type and title from metadata
Optional<Element> track = getFirstTrack(doc);
if (track.isPresent()) {
extractActivityType(track.get(), parsedData);
extractActivityTitle(track.get(), parsedData);
}
// Determine timezone from first GPS coordinate
determineTimezone(parsedData);
@ -111,6 +117,8 @@ public class GpxParser {
}
}
/**
* Extracts track points from GPX document.
*/
@ -245,22 +253,41 @@ 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");
if (tracks.getLength() == 0) {
tracks = doc.getElementsByTagNameNS("*", "trk");
}
if (tracks.getLength() > 0) {
Element track = (Element) tracks.item(0);
return tracks.getLength() > 0 ? Optional.of((Element) tracks.item(0)) : Optional.empty();
}
/**
* Extracts activity type from GPX metadata.
*/
private void extractActivityType(Element track, ParsedActivityData parsedData) {
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);
}
}
/**

View file

@ -20,6 +20,9 @@ import java.util.List;
*/
@Data
public class ParsedActivityData {
static final int MAX_TITLE_LENGTH = 255;
private List<TrackPointData> trackPoints = new ArrayList<>();
private LocalDateTime startTime;
private LocalDateTime endTime;
@ -30,6 +33,7 @@ public class ParsedActivityData {
private BigDecimal elevationGain;
private BigDecimal elevationLoss;
private Activity.ActivityType activityType = Activity.ActivityType.OTHER;
private String title;
private ActivityMetricsData metrics;
private String sourceFormat; // "FIT" or "GPX"
private Boolean indoor = false; // Indicates if this is an indoor activity

View file

@ -2,6 +2,8 @@
# Activated with: mvn spring-boot:run -Dspring-boot.run.profiles=dev
spring:
main:
allow-bean-definition-overriding: true
datasource:
# For dev: Start PostgreSQL with: docker run -d --name fitpub-postgres -p 5432:5432 -e POSTGRES_DB=fitpub -e POSTGRES_USER=fitpub -e POSTGRES_PASSWORD=change_me_in_production postgis/postgis:16-3.4
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/fitpub}

View file

@ -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';

View file

@ -92,6 +92,11 @@ p,
letter-spacing: normal;
}
/* Preserve line-breaks */
.preserve-linebreaks {
white-space: pre-line;
}
/* Navigation */
.navbar {
background: linear-gradient(135deg, var(--dark-color) 0%, #2d0052 100%) !important;

View file

@ -434,7 +434,7 @@ function formatDateTimeWithTimezone(timestamp, timezone, options = {}) {
// Parse the timestamp - backend sends LocalDateTime without 'Z'
// We need to interpret it in the specified timezone
const date = new Date(timestamp);
const date = new Date(ensureUTC(timestamp));
// Default options for date/time display
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
window.FitPub = {
createActivityMap,
@ -482,5 +493,6 @@ window.FitPub = {
formatDistance,
formatPace,
formatDateTimeWithTimezone,
formatDateWithTimezone
formatDateWithTimezone,
ensureUTC
};

View file

@ -209,7 +209,7 @@ const FitPubTimeline = {
@${this.escapeHtml(activity.username)}
</a>
${!activity.isLocal ? ' <span class="badge bg-info ms-1" title="Federated Activity"><i class="bi bi-globe2"></i> Remote</span>' : ''}
${this.formatTimeAgo(activity.startedAt)} ${activity.activityLocation}
${this.formatTimeAgo(activity.startedAt)} ${activity.activityLocation ? '•' : ''} ${activity.activityLocation}
</div>
</div>
<div>
@ -727,7 +727,7 @@ const FitPubTimeline = {
* @returns {string} Time ago string
*/
formatTimeAgo: function(timestamp) {
const date = new Date(timestamp);
const date = new Date(FitPub.ensureUTC(timestamp));
const now = new Date();
const secondsAgo = Math.floor((now - date) / 1000);

View file

@ -53,7 +53,7 @@
<span id="activityVisibility"></span>
</span>
</p>
<p id="activityDescription" class="text-muted"></p>
<p id="activityDescription" class="preserve-linebreaks text-muted"></p>
</div>
<div class="btn-group" role="group" id="activityActions" style="display: none;">
<a href="#" id="editBtn" class="btn btn-outline-primary">

View file

@ -212,7 +212,7 @@
function populateForm(activity) {
// Populate form fields
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('visibility').value = activity.visibility || 'PUBLIC';
document.getElementById('race').checked = activity.race || false;

View file

@ -46,7 +46,7 @@
<p class="text-muted mb-2">
<span id="username"></span>
</p>
<p id="bio" class="mb-3"></p>
<p id="bio" class="mb-3 preserve-linebreaks"></p>
</div>
<div id="followButtonContainer" class="d-none">
<button class="btn btn-primary" id="followBtn">

View file

@ -46,7 +46,7 @@
<p class="text-muted mb-2">
<span id="username"></span>
</p>
<p id="bio" class="mb-3"></p>
<p id="bio" class="mb-3 preserve-linebreaks"></p>
</div>
<div>
<a th:href="@{/profile/edit}" class="btn btn-outline-primary">

View file

@ -4,7 +4,6 @@ import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.containers.wait.strategy.HostPortWaitStrategy;
import org.testcontainers.utility.DockerImageName;
/**
@ -23,8 +22,6 @@ public class TestcontainersConfiguration {
)
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test")
.waitingFor(new HostPortWaitStrategy())
.withReuse(true);
.withPassword("test");
}
}

View file

@ -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");
}
}

View file

@ -2,19 +2,25 @@ package net.javahippie.fitpub.integration;
import com.fasterxml.jackson.databind.ObjectMapper;
import net.javahippie.fitpub.config.TestcontainersConfiguration;
import net.javahippie.fitpub.model.entity.Activity;
import net.javahippie.fitpub.service.ActivityImageService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import net.javahippie.fitpub.model.entity.Follow;
import net.javahippie.fitpub.model.entity.RemoteActor;
import net.javahippie.fitpub.model.entity.RemoteActivity;
import net.javahippie.fitpub.model.entity.User;
import net.javahippie.fitpub.repository.ActivityRepository;
import net.javahippie.fitpub.repository.FollowRepository;
import net.javahippie.fitpub.repository.RemoteActivityRepository;
import net.javahippie.fitpub.repository.RemoteActorRepository;
import net.javahippie.fitpub.repository.UserRepository;
import net.javahippie.fitpub.security.HttpSignatureValidator;
import net.javahippie.fitpub.security.JwtTokenProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
@ -26,15 +32,21 @@ import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.transaction.annotation.Transactional;
import java.io.File;
import java.math.BigDecimal;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@ -63,6 +75,12 @@ class FederationFollowFlowIntegrationTest {
@Autowired
private RemoteActorRepository remoteActorRepository;
@Autowired
private RemoteActivityRepository remoteActivityRepository;
@Autowired
private ActivityRepository activityRepository;
@Autowired
private PasswordEncoder passwordEncoder;
@ -72,6 +90,9 @@ class FederationFollowFlowIntegrationTest {
@Autowired
private HttpSignatureValidator signatureValidator;
@MockBean
private ActivityImageService activityImageService;
@Value("${fitpub.base-url}")
private String baseUrl;
@ -101,6 +122,22 @@ class FederationFollowFlowIntegrationTest {
authToken = jwtTokenProvider.createToken(testUser.getUsername());
}
private User createFederatedUser(String username, String email, String displayName) throws NoSuchAlgorithmException {
KeyPair keyPair = generateRsaKeyPair();
String publicKey = encodePublicKey(keyPair.getPublic().getEncoded());
String privateKey = encodePrivateKey(keyPair.getPrivate().getEncoded());
return userRepository.save(User.builder()
.username(username)
.email(email)
.passwordHash(passwordEncoder.encode("password123"))
.displayName(displayName)
.publicKey(publicKey)
.privateKey(privateKey)
.enabled(true)
.build());
}
private KeyPair generateRsaKeyPair() throws NoSuchAlgorithmException {
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
keyGen.initialize(2048);
@ -270,6 +307,111 @@ class FederationFollowFlowIntegrationTest {
assertThat(updatedFollow.getStatus()).isEqualTo(Follow.FollowStatus.ACCEPTED);
}
@Test
@DisplayName("Should import its own exported public activity through inbox")
void testActivityRoundtripThroughExportAndInbox() throws Exception {
User importingUser = testUser;
User exportingUser = createFederatedUser("janedoe", "janedoe@example.com", "Jane Doe");
Activity activity = activityRepository.save(Activity.builder()
.userId(exportingUser.getId())
.activityType(Activity.ActivityType.RUN)
.title("Lunch Run")
.description("Sunny run in the city")
.startedAt(LocalDateTime.of(2026, 5, 2, 12, 0))
.endedAt(LocalDateTime.of(2026, 5, 2, 12, 30))
.createdAt(LocalDateTime.of(2026, 5, 2, 12, 31, 45, 123_000_000))
.visibility(Activity.Visibility.PUBLIC)
.totalDistance(BigDecimal.valueOf(5000))
.totalDurationSeconds(1800L)
.elevationGain(BigDecimal.valueOf(100))
.sourceFileFormat("FIT")
.published(true)
.build());
String exportingActorUri = baseUrl + "/users/" + exportingUser.getUsername();
when(activityImageService.getActivityImageFile(activity.getId()))
.thenReturn(new File("/definitely/nonexistent-fitpub-roundtrip-image"));
remoteActorRepository.save(RemoteActor.builder()
.actorUri(exportingActorUri)
.username(exportingUser.getUsername())
.domain(java.net.URI.create(baseUrl).getHost())
.displayName(exportingUser.getDisplayName())
.inboxUrl(exportingActorUri + "/inbox")
.outboxUrl(exportingActorUri + "/outbox")
.publicKey(exportingUser.getPublicKey())
.publicKeyId(exportingActorUri + "#main-key")
.lastFetchedAt(Instant.now())
.build());
followRepository.save(Follow.builder()
.followerId(importingUser.getId())
.followingActorUri(exportingActorUri)
.status(Follow.FollowStatus.ACCEPTED)
.activityId(baseUrl + "/activities/follow/" + UUID.randomUUID())
.build());
MvcResult exportResult = mockMvc.perform(get("/activities/" + activity.getId())
.accept("application/activity+json"))
.andExpect(status().isOk())
.andReturn();
@SuppressWarnings("unchecked")
Map<String, Object> exportedNote = objectMapper.readValue(exportResult.getResponse().getContentAsByteArray(), Map.class);
Map<String, Object> createActivity = Map.of(
"@context", "https://www.w3.org/ns/activitystreams",
"type", "Create",
"id", baseUrl + "/activities/create/" + UUID.randomUUID(),
"actor", exportingActorUri,
"object", exportedNote
);
String privateKeyPem = exportingUser.getPrivateKey();
String inboxPath = "/users/" + importingUser.getUsername() + "/inbox";
String inboxUrl = "http://localhost" + inboxPath;
String body = objectMapper.writeValueAsString(createActivity);
HttpSignatureValidator.SignatureHeaders sigHeaders = signatureValidator.signRequest(
"POST", inboxUrl, body, privateKeyPem, exportingActorUri + "#main-key"
);
mockMvc.perform(post(inboxPath)
.contentType("application/activity+json")
.header("Host", sigHeaders.host)
.header("Date", sigHeaders.date)
.header("Digest", sigHeaders.digest)
.header("Signature", sigHeaders.signature)
.content(body))
.andExpect(status().isAccepted());
RemoteActivity imported = remoteActivityRepository.findByActivityUri((String) exportedNote.get("id"))
.orElseThrow();
@SuppressWarnings("unchecked")
Map<String, Object> workoutData = (Map<String, Object>) exportedNote.get("workoutData");
assertThat(imported.getActivityUri()).isEqualTo(exportedNote.get("id"));
assertThat(imported.getRemoteActorUri()).isEqualTo(exportingActorUri);
assertThat(imported.getTitle()).isEqualTo(exportedNote.getOrDefault("name",
exportedNote.getOrDefault("summary", "Untitled Activity")));
assertThat(imported.getDescription()).isEqualTo(workoutData.get("description"));
assertThat(imported.getPublishedAt()).isEqualTo(Instant.parse((String) exportedNote.get("published")));
assertThat(imported.getVisibility()).isEqualTo(RemoteActivity.Visibility.PUBLIC);
assertThat(imported.getActivityType()).isEqualTo(workoutData.get("activityType"));
assertThat(imported.getTotalDistance()).isEqualTo(5000L);
assertThat(imported.getTotalDurationSeconds()).isEqualTo(1800L);
assertThat(imported.getElevationGain()).isEqualTo(workoutData.get("elevationGain"));
assertThat(imported.getAveragePaceSeconds()).isNull();
assertThat(imported.getAverageHeartRate()).isNull();
assertThat(imported.getMaxSpeed()).isNull();
assertThat(imported.getAverageSpeed()).isNull();
assertThat(imported.getCalories()).isNull();
assertThat(imported.getMapImageUrl()).isNull();
assertThat(imported.getTrackGeojsonUrl()).isNull();
assertThat(imported.getSimplifiedTrack()).isNull();
}
@Test
@DisplayName("Should reject inbox POST without HTTP signature with 401")
void testInboxRejectsUnsignedRequest() throws Exception {
@ -310,6 +452,23 @@ class FederationFollowFlowIntegrationTest {
.andExpect(status().isUnauthorized());
}
private String stripHtml(String html) {
if (html == null) {
return "";
}
return html
.replaceAll("<br\\s*/?>", "\n")
.replaceAll("<p>", "")
.replaceAll("</p>", "\n")
.replaceAll("<[^>]+>", "")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&amp;", "&")
.trim();
}
@Test
@DisplayName("Should process Undo Follow activity and remove follow relationship")
void testProcessUndoFollowActivity() throws Exception {

View file

@ -27,12 +27,16 @@ import static org.junit.jupiter.api.Assertions.*;
/**
* Manual test for ActivityImageService.
* These tests are disabled by default and should only be run manually.
*
* To run this test manually:
* mvn test -Dtest=ActivityImageServiceTest
*/
@SpringBootTest(properties = {
"fitpub.image.osm-tiles.enabled=true"
})
@ActiveProfiles("test")
@Import(TestcontainersConfiguration.class)
@Disabled("Manual test - run explicitly when needed")
class ActivityImageServiceTest {
@Autowired
@ -55,7 +59,6 @@ class ActivityImageServiceTest {
* mvn test -Dtest=ActivityImageServiceTest#testGenerateActivityImage_Manual
*/
@Test
@Disabled("Manual test - run explicitly when needed")
@DisplayName("Generate activity image from test FIT file")
void testGenerateActivityImage_Manual() throws Exception {
// Load test FIT file

View file

@ -1,25 +1,42 @@
package net.javahippie.fitpub.service;
import net.javahippie.fitpub.model.entity.Activity;
import net.javahippie.fitpub.model.entity.ActivityMetrics;
import net.javahippie.fitpub.model.entity.User;
import net.javahippie.fitpub.repository.ActivityRepository;
import net.javahippie.fitpub.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.GeometryFactory;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import net.javahippie.fitpub.model.entity.Activity;
import net.javahippie.fitpub.model.entity.User;
import net.javahippie.fitpub.repository.ActivityRepository;
import net.javahippie.fitpub.repository.UserRepository;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import static org.mockito.Mockito.anyBoolean;
import static org.mockito.Mockito.anyString;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Unit tests for ActivityPostProcessingService.
@ -49,6 +66,9 @@ class ActivityPostProcessingServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private WorkoutDataPayloadBuilder workoutDataPayloadBuilder;
@InjectMocks
private ActivityPostProcessingService service;
@ -56,11 +76,13 @@ class ActivityPostProcessingServiceTest {
private UUID userId;
private Activity testActivity;
private User testUser;
private LocalDateTime createdAt;
@BeforeEach
void setUp() {
activityId = UUID.randomUUID();
userId = UUID.randomUUID();
createdAt = LocalDateTime.of(2026, 5, 2, 9, 24, 50, 921_241_000);
// Set baseUrl via reflection (since it's @Value injected)
ReflectionTestUtils.setField(service, "baseUrl", "https://test.example");
@ -76,9 +98,39 @@ class ActivityPostProcessingServiceTest {
.totalDistance(BigDecimal.valueOf(5000))
.totalDurationSeconds(1800L)
.elevationGain(BigDecimal.valueOf(100))
.startedAt(LocalDateTime.now())
.createdAt(LocalDateTime.now())
.startedAt(createdAt.minusMinutes(30))
.createdAt(createdAt)
.simplifiedTrack(new GeometryFactory().createLineString(new Coordinate[]{
new Coordinate(8.55, 47.37),
new Coordinate(8.56, 47.38)
}))
.build();
testActivity.setMetrics(ActivityMetrics.builder()
.averagePaceSeconds(321L)
.build());
Map<String, Object> workoutData = new HashMap<>();
workoutData.put("activityType", "RUN");
workoutData.put("description", "Morning jog");
workoutData.put("distance", 5000L);
workoutData.put("duration", "PT30M");
workoutData.put("averagePace", "PT5M21S");
workoutData.put("elevationGain", 100);
workoutData.put("route", Map.of(
"type", "FeatureCollection",
"features", List.of(
Map.of(
"type", "Feature",
"geometry", Map.of(
"type", "LineString",
"coordinates", List.of(
List.of(8.55, 47.37),
List.of(8.56, 47.38)
)
)
)
)
));
lenient().when(workoutDataPayloadBuilder.build(testActivity)).thenReturn(workoutData);
// Create test user
testUser = User.builder()
@ -232,6 +284,24 @@ class ActivityPostProcessingServiceTest {
verify(federationService).sendCreateActivity(anyString(), any(), eq(testUser), eq(false));
}
@Test
@DisplayName("Should serialize federation note published timestamp with timezone")
void testPublishToFederationAsync_PublishedTimestampIncludesTimezone() {
when(activityRepository.findById(activityId)).thenReturn(Optional.of(testActivity));
when(userRepository.findById(userId)).thenReturn(Optional.of(testUser));
when(activityImageService.generateActivityImage(testActivity)).thenReturn(null);
doNothing().when(federationService).sendCreateActivity(anyString(), any(), any(), anyBoolean());
@SuppressWarnings("unchecked")
ArgumentCaptor<java.util.Map<String, Object>> noteCaptor = ArgumentCaptor.forClass(java.util.Map.class);
service.publishToFederationAsync(activityId, userId);
verify(federationService).sendCreateActivity(anyString(), noteCaptor.capture(), eq(testUser), eq(true));
assertThat(noteCaptor.getValue().get("published"))
.isEqualTo(createdAt.atOffset(ZoneOffset.UTC).toInstant().toString());
}
@Test
@DisplayName("Should skip federation for PRIVATE activity")
void testPublishToFederationAsync_PrivateActivity() {
@ -317,4 +387,47 @@ class ActivityPostProcessingServiceTest {
// Then: Verify federation was called (content formatting is tested indirectly)
verify(federationService).sendCreateActivity(anyString(), any(), any(), anyBoolean());
}
@Test
@DisplayName("Should include workoutData payload in federation note")
void testPublishToFederationAsync_IncludesWorkoutDataPayload() {
when(activityRepository.findById(activityId)).thenReturn(Optional.of(testActivity));
when(userRepository.findById(userId)).thenReturn(Optional.of(testUser));
when(activityImageService.generateActivityImage(testActivity)).thenReturn(null);
doNothing().when(federationService).sendCreateActivity(anyString(), any(), any(), anyBoolean());
@SuppressWarnings("unchecked")
ArgumentCaptor<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)
));
}
}

View file

@ -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);
}
}

View file

@ -18,13 +18,14 @@ import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestTemplate;
import java.math.BigDecimal;
import java.net.URI;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@ -49,47 +50,26 @@ class WeatherServiceTest {
private Activity testActivity;
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 = """
{
"coord": {"lon": 8.2552, "lat": 49.9894},
"weather": [
{
"id": 800,
"main": "Clear",
"description": "clear sky",
"icon": "01d"
"latitude": 49.98,
"longitude": 8.26,
"hourly": {
"time": ["2025-11-23T17:00", "2025-11-23T18:00", "2025-11-23T19:00"],
"temperature_2m": [14.0, 15.5, 13.8],
"apparent_temperature": [12.5, 14.2, 12.0],
"relative_humidity_2m": [60, 65, 70],
"surface_pressure": [1012, 1013, 1012],
"wind_speed_10m": [10.0, 12.6, 11.0],
"wind_direction_10m": [170, 180, 190],
"cloud_cover": [15, 20, 25],
"rain": [0.0, 0.0, 0.0],
"snowfall": [0.0, 0.0, 0.0],
"precipitation": [0.0, 0.0, 0.0],
"visibility": [10000, 10000, 10000],
"weather_code": [0, 0, 1]
}
],
"base": "stations",
"main": {
"temp": 15.5,
"feels_like": 14.2,
"temp_min": 13.0,
"temp_max": 17.0,
"pressure": 1013,
"humidity": 65
},
"visibility": 10000,
"wind": {
"speed": 3.5,
"deg": 180
},
"clouds": {
"all": 20
},
"dt": 1700758089,
"sys": {
"type": 2,
"id": 2012516,
"country": "DE",
"sunrise": 1700721600,
"sunset": 1700757600
},
"timezone": 3600,
"id": 2873891,
"name": "Mannheim",
"cod": 200
}
""";
@ -98,12 +78,11 @@ class WeatherServiceTest {
activityId = UUID.randomUUID();
testActivity = new Activity();
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
ReflectionTestUtils.setField(weatherService, "restTemplate", restTemplate);
ReflectionTestUtils.setField(weatherService, "weatherEnabled", true);
ReflectionTestUtils.setField(weatherService, "apiKey", "test-api-key-12345678901234567890");
}
@Test
@ -123,7 +102,7 @@ class WeatherServiceTest {
""";
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);
when(weatherDataRepository.save(any(WeatherData.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
@ -136,7 +115,7 @@ class WeatherServiceTest {
assertEquals(new BigDecimal("15.5"), weatherData.getTemperatureCelsius());
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));
}
@ -162,7 +141,7 @@ class WeatherServiceTest {
""";
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);
when(weatherDataRepository.save(any(WeatherData.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
@ -176,39 +155,13 @@ class WeatherServiceTest {
assertEquals(new BigDecimal("14.2"), weatherData.getFeelsLikeCelsius());
assertEquals(65, weatherData.getHumidity());
assertEquals(1013, weatherData.getPressure());
assertEquals(new BigDecimal("3.5"), weatherData.getWindSpeedMps());
assertEquals(new BigDecimal("3.50"), weatherData.getWindSpeedMps());
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));
}
@Test
@DisplayName("Should return empty when weather is disabled in config")
void testFetchWeather_Disabled() {
ReflectionTestUtils.setField(weatherService, "weatherEnabled", false);
testActivity.setTrackPointsJson("[{\"lat\":50.0,\"lon\":8.0}]");
Optional<WeatherData> result = weatherService.fetchWeatherForActivity(testActivity);
assertTrue(result.isEmpty());
verify(weatherDataRepository, never()).save(any(WeatherData.class));
verify(restTemplate, never()).getForObject(any(URI.class), eq(String.class));
}
@Test
@DisplayName("Should return empty when API key is not configured")
void testFetchWeather_NoApiKey() {
ReflectionTestUtils.setField(weatherService, "apiKey", "");
testActivity.setTrackPointsJson("[{\"lat\":50.0,\"lon\":8.0}]");
Optional<WeatherData> result = weatherService.fetchWeatherForActivity(testActivity);
assertTrue(result.isEmpty());
verify(weatherDataRepository, never()).save(any(WeatherData.class));
verify(restTemplate, never()).getForObject(any(URI.class), eq(String.class));
}
@Test
@DisplayName("Should return empty when track points JSON is null")
void testFetchWeather_NoTrackPoints() {
@ -260,20 +213,7 @@ class WeatherServiceTest {
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 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));
verify(restTemplate, never()).getForObject(anyString(), eq(String.class), any(Map.class));
}
@Test
@ -281,11 +221,11 @@ class WeatherServiceTest {
void testFetchWeather_AuthenticationError() {
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(
org.springframework.http.HttpStatus.UNAUTHORIZED,
"Unauthorized",
"{\"cod\":401,\"message\":\"Invalid API key\"}".getBytes(),
"{\"error\":true,\"reason\":\"Unauthorized\"}".getBytes(),
null
));
@ -300,7 +240,7 @@ class WeatherServiceTest {
void testFetchWeather_NetworkError() {
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"));
Optional<WeatherData> result = weatherService.fetchWeatherForActivity(testActivity);
@ -314,7 +254,7 @@ class WeatherServiceTest {
void testFetchWeather_MalformedResponse() {
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");
Optional<WeatherData> result = weatherService.fetchWeatherForActivity(testActivity);
@ -328,24 +268,29 @@ class WeatherServiceTest {
void testParseWeatherResponse_AllFields() {
String responseWithRain = """
{
"main": {
"temp": 10.0,
"feels_like": 8.5,
"pressure": 1015,
"humidity": 80
},
"weather": [{"main": "Rain", "description": "light rain", "icon": "10d"}],
"wind": {"speed": 5.5, "deg": 270},
"clouds": {"all": 75},
"visibility": 8000,
"rain": {"1h": 2.5},
"sys": {"sunrise": 1700721600, "sunset": 1700757600}
"latitude": 50.0,
"longitude": 8.0,
"hourly": {
"time": ["2025-11-23T17:00", "2025-11-23T18:00", "2025-11-23T19:00"],
"temperature_2m": [11.0, 10.0, 9.5],
"apparent_temperature": [9.0, 8.5, 8.0],
"relative_humidity_2m": [75, 80, 85],
"surface_pressure": [1014, 1015, 1015],
"wind_speed_10m": [18.0, 19.8, 20.0],
"wind_direction_10m": [260, 270, 275],
"cloud_cover": [70, 75, 80],
"rain": [1.5, 2.5, 3.0],
"snowfall": [0.0, 0.0, 0.0],
"precipitation": [1.5, 2.5, 3.0],
"visibility": [9000, 8000, 7000],
"weather_code": [61, 61, 63]
}
}
""";
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);
when(weatherDataRepository.save(any(WeatherData.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
@ -357,15 +302,13 @@ class WeatherServiceTest {
assertEquals(new BigDecimal("10.0"), weatherData.getTemperatureCelsius());
assertEquals(new BigDecimal("8.5"), weatherData.getFeelsLikeCelsius());
assertEquals("Rain", weatherData.getWeatherCondition());
assertEquals("light rain", weatherData.getWeatherDescription());
assertEquals(new BigDecimal("5.5"), weatherData.getWindSpeedMps());
assertEquals("slight rain", weatherData.getWeatherDescription());
assertEquals(new BigDecimal("5.50"), weatherData.getWindSpeedMps());
assertEquals(270, weatherData.getWindDirection());
assertEquals(75, weatherData.getCloudiness());
assertEquals(8000, weatherData.getVisibilityMeters());
assertEquals(new BigDecimal("2.5"), weatherData.getPrecipitationMm());
assertNotNull(weatherData.getSunrise());
assertNotNull(weatherData.getSunset());
assertEquals("openweathermap", weatherData.getDataSource());
assertEquals("open-meteo", weatherData.getDataSource());
assertNotNull(weatherData.getFetchedAt());
}
@ -374,19 +317,29 @@ class WeatherServiceTest {
void testParseWeatherResponse_MinimalFields() {
String minimalResponse = """
{
"main": {
"temp": 15.0,
"feels_like": 14.0,
"pressure": 1010,
"humidity": 60
},
"weather": [{"main": "Clouds", "description": "few clouds", "icon": "02d"}]
"latitude": 50.0,
"longitude": 8.0,
"hourly": {
"time": ["2025-11-23T18:00"],
"temperature_2m": [15.0],
"apparent_temperature": [14.0],
"relative_humidity_2m": [60],
"surface_pressure": [1010],
"wind_speed_10m": [null],
"wind_direction_10m": [null],
"cloud_cover": [null],
"rain": [null],
"snowfall": [null],
"precipitation": [null],
"visibility": [null],
"weather_code": [2]
}
}
""";
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);
when(weatherDataRepository.save(any(WeatherData.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
@ -441,7 +394,7 @@ class WeatherServiceTest {
""";
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);
when(weatherDataRepository.save(any(WeatherData.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
@ -449,6 +402,6 @@ class WeatherServiceTest {
Optional<WeatherData> result = weatherService.fetchWeatherForActivity(testActivity);
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));
}
}

View file

@ -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)
));
}
}

View file

@ -112,6 +112,10 @@ class GpxParserIntegrationTest {
// Verify at least some basic data
assertNotNull(parsedData.getActivityType(), "Activity type should be determined");
String parsedTitle = parsedData.getTitle();
assertEquals(255, parsedTitle.length());
assertTrue(parsedTitle.startsWith("Einmal Frust loswerden"));
assertFalse(parsedTitle.contains("Shouldn't appear"));
assertEquals(Activity.ActivityType.RUN, parsedData.getActivityType(),
"Activity type should be RUN (from GPX <type>running</type>)");
assertTrue(parsedData.getTrackPoints().size() > 0, "Should have at least one track point");

View file

@ -4,7 +4,7 @@
<time>2022-07-03T19:47:51Z</time>
</metadata>
<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>
<trkseg>
<trkpt lat="48.0140070" lon="7.8513840">