diff --git a/.env.example b/.env.example index 5ee27e4..030b1c1 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,46 @@ -# FitPub Environment Configuration +# FitPub environment configuration +# +# This file mirrors the environment variables referenced by the Spring +# configuration and uses the same effective defaults as the development profile +# where defaults exist. -# Registration Settings -# Leave empty for open registration, or set a password to require it for new signups -REGISTRATION_PASSWORD= +# Spring profile +# Use `prod` for production deployments. +# SPRING_PROFILES_ACTIVE=dev -# Example with password (uncomment to enable): -# REGISTRATION_PASSWORD=my-secret-invite-code-2024 +# Local PostgreSQL / PostGIS +# SPRING_DATASOURCE_URL=jdbc:postgresql://localhost:5432/fitpub +# SPRING_DATASOURCE_USERNAME=fitpub +# SPRING_DATASOURCE_PASSWORD=change_me_in_production -# Other settings -# REGISTRATION_ENABLED=true +# Server +# PORT=8080 + +# Public FitPub URLs # FITPUB_DOMAIN=localhost:8080 # FITPUB_BASE_URL=http://localhost:8080 + +# ActivityPub +# FITPUB_ALLOW_PRIVATE_IPS=false +# FITPUB_FEDERATION_PROTOCOL=https + +# Authentication +# JWT_SECRET=dev-secret-key-change-in-production-must-be-at-least-32-characters-long + +# Registration +# REGISTRATION_ENABLED=true +# Leave empty for open registration, or set a password to require it for new signups +# REGISTRATION_PASSWORD= + +# Storage +# Application default is ${java.io.tmpdir}/fitpub/images +# FITPUB_IMAGES_PATH=/tmp/fitpub/images +# Application default is ${java.io.tmpdir}/fitpub/tiles +# FITPUB_TILE_CACHE_PATH=/tmp/fitpub/tiles + +# Image generation +# OSM_TILES_ENABLED=true + +# Weather +# WEATHER_ENABLED=false +# OPENWEATHERMAP_API_KEY= diff --git a/.gitignore b/.gitignore index fa122a4..cd24195 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,10 @@ target/ .kotlin ### IntelliJ IDEA ### -.idea/ +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ *.iws *.iml *.ipr @@ -46,10 +49,3 @@ logs/ /gadm_410.gpkg /.postgresdata/ /peaks_worldwide.geojson - -### Coding Assistants ### -.codex/ -.aider* -.cursor/ -.roo/ -.windsurf/ diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml new file mode 100644 index 0000000..06a2c34 --- /dev/null +++ b/.idea/dataSources.xml @@ -0,0 +1,17 @@ + + + + + postgresql + true + org.postgresql.Driver + jdbc:postgresql://localhost:51826/testdb + + + + + + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/.idea/data_source_mapping.xml b/.idea/data_source_mapping.xml new file mode 100644 index 0000000..ed1c16b --- /dev/null +++ b/.idea/data_source_mapping.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..aa00ffa --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..ad4a613 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,17 @@ + + + + IDE + + + + + + + + + \ No newline at end of file diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml new file mode 100644 index 0000000..27a4b8c --- /dev/null +++ b/.idea/sqldialects.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/CONTAINERS.md b/CONTAINERS.md new file mode 100644 index 0000000..88fb585 --- /dev/null +++ b/CONTAINERS.md @@ -0,0 +1,273 @@ +# Container Deployment Guide + +This guide explains how to run FitPub with containers using either Docker or Podman. + +The repository ships a `docker-compose.yml` file. Despite the name, it can also be used with Podman-compatible compose tooling. + +## Supported runtimes + +- Docker Engine with the `docker compose` plugin +- Podman with `podman compose` or another compatible compose frontend + +Examples in this guide use both command styles where that adds clarity: + +```bash +docker compose ... +podman compose ... +``` + +If your environment uses a different compose wrapper, adapt the command prefix accordingly. + +## Quick start + +### 1. Clone the repository + +```bash +git clone +cd fitpub +``` + +### 2. Create an environment file + +```bash +cp .env.example .env +``` + +### 3. Set production values + +At minimum, review and set the following values in `.env`: + +```bash +SPRING_PROFILES_ACTIVE=prod +POSTGRES_DB=fitpub +POSTGRES_USER=fitpub +POSTGRES_PASSWORD=change-this +APP_PORT=8080 +FITPUB_DOMAIN=your-domain.com +FITPUB_BASE_URL=https://your-domain.com +JWT_SECRET=replace-with-a-long-random-secret +``` + +Recommended command for generating secrets for values such as `JWT_SECRET` and `POSTGRES_PASSWORD`: + +```bash +openssl rand -base64 64 +``` + +### 4. Start the stack + +```bash +docker compose up -d --build +podman compose up -d --build +``` + +### 5. Verify the deployment + +FitPub should become available at: + +- Application: `http://localhost:8080` or your configured public URL +- Health check endpoint: `http://localhost:8080/actuator/health` + +## Environment variables used by the container stack + +The compose file expects these variables: + +| Variable | Purpose | Example / default | +|--------------------------|---------------------------------------------|---------------------------| +| `SPRING_PROFILES_ACTIVE` | Spring profile for the app container | `prod` | +| `POSTGRES_DB` | PostgreSQL database name | `fitpub` | +| `POSTGRES_USER` | PostgreSQL user | `fitpub` | +| `POSTGRES_PASSWORD` | PostgreSQL password | set explicitly | +| `POSTGRES_PORT` | Host port for PostgreSQL | `5432` | +| `APP_PORT` | Host port for FitPub | `8080` | +| `FITPUB_DOMAIN` | Public domain used by the app | `your-domain.com` | +| `FITPUB_BASE_URL` | Public base URL | `https://your-domain.com` | +| `JWT_SECRET` | JWT signing secret | set explicitly | +| `JWT_EXPIRATION_MS` | JWT token lifetime | `86400000` | +| `REGISTRATION_PASSWORD` | Optional invite-style registration password | empty | +| `JPA_SHOW_SQL` | Enable SQL logging | `false` | +| `JPA_FORMAT_SQL` | Format SQL logs | `false` | +| `LOG_LEVEL_ROOT` | Root log level | `INFO` | +| `LOG_LEVEL_APP` | App log level | `INFO` | +| `LOG_LEVEL_SPRING` | Spring log level | `INFO` | +| `LOG_LEVEL_HIBERNATE` | Hibernate log level | `WARN` | +| `LOG_LEVEL_FLYWAY` | Flyway log level | `INFO` | + +`.env.example` documents the application-level environment variables and defaults. The compose file adds a smaller set of container-specific variables around database wiring, ports, and log levels. + +## Services + +### `postgres` + +- Image: `postgis/postgis:16-3.4` +- Exposes container port `5432` +- Uses the named volume `postgres_data` +- Runs a `pg_isready` health check + +### `app` + +- Built from the repository `Dockerfile` +- Exposes container port `8080` +- Uses the named volumes `app_uploads` and `app_logs` +- Waits for the database health check before starting +- Publishes a health check on `/actuator/health` + +## Common operations + +### Show logs + +```bash +docker compose logs -f +docker compose logs -f app +docker compose logs -f postgres +``` + +```bash +podman compose logs -f +podman compose logs -f app +podman compose logs -f postgres +``` + +### Restart services + +```bash +docker compose restart +docker compose restart app +``` + +```bash +podman compose restart +podman compose restart app +``` + +### Stop and remove the stack + +```bash +docker compose stop +docker compose down +docker compose down -v +``` + +```bash +podman compose stop +podman compose down +podman compose down -v +``` + +`down -v` removes persistent volumes and deletes database data. + +### Run commands inside containers + +```bash +docker compose exec app bash +docker compose exec postgres psql -U fitpub -d fitpub +``` + +```bash +podman compose exec app bash +podman compose exec postgres psql -U fitpub -d fitpub +``` + +### Rebuild the application image + +```bash +docker compose up -d --build app +docker compose build --no-cache app +``` + +```bash +podman compose up -d --build app +podman compose build --no-cache app +``` + +## Volumes and backups + +The compose stack creates these named volumes: + +- `postgres_data` +- `app_uploads` +- `app_logs` + +Examples with Docker: + +```bash +docker volume ls | grep fitpub +docker volume inspect fitpub_postgres_data +docker run --rm -v fitpub_postgres_data:/data -v "$(pwd)":/backup \ + alpine tar czf /backup/postgres-backup-YYYYMMDD.tar.gz -C /data . +``` + +Examples with Podman: + +```bash +podman volume ls +podman volume inspect fitpub_postgres_data +podman run --rm -v fitpub_postgres_data:/data -v "$(pwd)":/backup \ + docker.io/library/alpine tar czf /backup/postgres-backup-YYYYMMDD.tar.gz -C /data . +``` + +Actual volume names can vary with the compose project name. Inspect the stack locally if your names differ. + +## Health checks and troubleshooting + +### Application health + +```bash +curl http://localhost:8080/actuator/health +``` + +### Database readiness + +```bash +docker compose exec postgres pg_isready -U fitpub +podman compose exec postgres pg_isready -U fitpub +``` + +### Check rendered compose configuration + +```bash +docker compose config +podman compose config +``` + +### Migration issues + +```bash +docker compose exec postgres psql -U fitpub -d fitpub -c \ + "SELECT * FROM flyway_schema_history ORDER BY installed_rank;" +``` + +```bash +podman compose exec postgres psql -U fitpub -d fitpub -c \ + "SELECT * FROM flyway_schema_history ORDER BY installed_rank;" +``` + +### Reset the stack + +```bash +docker compose down -v +docker compose up -d --build +``` + +```bash +podman compose down -v +podman compose up -d --build +``` + +This removes all persisted data. + +## Production notes + +- Set `SPRING_PROFILES_ACTIVE=prod` +- Use strong, unique values for `POSTGRES_PASSWORD` and `JWT_SECRET` +- Put FitPub behind HTTPS via a reverse proxy such as nginx, Traefik, or Caddy +- Back up the database volume regularly +- Review exposed ports and firewall rules +- Keep the container runtime and base images updated + +## Related files + +- [README.md](./README.md) +- [CONTRIBUTING.md](./CONTRIBUTING.md) +- [docker-compose.yml](./docker-compose.yml) +- [Dockerfile](./Dockerfile) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..52c17bf --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,174 @@ +# Contributing to FitPub + +Thanks for contributing to FitPub. + +This document covers the practical workflow for working on the codebase: setting up a local environment, running the application, executing tests, and preparing changes for review. + +## Before you start + +- Read the [README.md](./README.md) for project context +- Check whether an issue already exists for the change you want to make +- Keep changes focused; avoid mixing unrelated refactors with feature work or bug fixes + +## Development environment + +FitPub currently targets: + +- Java 17 +- Maven Wrapper (`./mvnw`) +- PostgreSQL with PostGIS +- Docker or Podman for local services and Testcontainers-based tests + +The repository includes an `.sdkmanrc` file with Java 17 if you use SDKMAN. + +## Local setup + +### 1. Clone the repository + +```bash +git clone +cd fitpub +``` + +### 2. Start PostgreSQL with PostGIS + +The development profile expects a local PostgreSQL instance on port `5432`. + +You can use either Docker or Podman. Example with Docker: + +```bash +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 +``` + +If you prefer a Compose-based setup, see [CONTAINERS.md](./CONTAINERS.md). The same container layout can also be run with Podman-compatible compose tooling. + +### 3. Optional environment configuration + +The application reads configuration from environment variables, but the development profile already provides sensible defaults for the usual local setup. + +In most cases, you can start the application without setting anything beyond the local PostgreSQL/PostGIS instance. + +If you want to override defaults or configure registration behavior, copy the example file: + +```bash +cp .env.example .env +``` + +Then add or adjust values in `.env` as needed. + +`.env.example` documents the available overrides together with the defaults used for local development. + +For local work, the application defaults to the `dev` profile. For production deployments, set: + +```bash +SPRING_PROFILES_ACTIVE=prod +``` + +## Running the application + +Start the app with the development profile: + +```bash +./mvnw spring-boot:run -Dspring-boot.run.profiles=dev +``` + +By default, the application runs at `http://localhost:8080`. + +The development profile uses: + +- Flyway for schema migrations +- verbose SQL and application logging +- a development JWT secret unless you override it + +## Building + +Create the application artifact with: + +```bash +./mvnw clean package +``` + +## Testing + +Run the full test suite with: + +```bash +./mvnw test +``` + +Important notes: + +- Tests use Testcontainers and require a working local container runtime such as Docker or Podman +- The test configuration provisions PostgreSQL/PostGIS automatically +- Some tests exercise file parsing and integration flows, so the suite is heavier than a pure unit-test run + +To run a single test class or method: + +```bash +./mvnw test -Dtest=ActivityImageServiceTest +./mvnw test -Dtest=ActivityImageServiceTest#testGenerateActivityImage_Manual +``` + +## Database and migrations + +- Database schema changes should go through Flyway migrations in `src/main/resources/db/migration/` +- Do not rely on Hibernate schema generation for persistent changes +- Keep migrations forward-only and review them carefully for compatibility with existing data + +## Code style + +There is no fully codified style toolchain checked into the repository at the moment, so contributors should follow the existing codebase closely. + +In practice, that means: + +- keep naming and package structure consistent with surrounding code +- prefer small, focused controller, service, and repository changes +- add tests for behavior changes and regressions +- avoid incidental formatting churn in unrelated files + +## Pull requests + +When opening a pull request: + +- explain the problem being solved +- describe the behavior change clearly +- mention any schema, API, or federation impact +- include screenshots for UI changes when useful +- call out follow-up work explicitly instead of bundling it into the same PR + +Before submitting, make sure: + +- the application builds successfully +- relevant tests pass locally +- new migrations have been validated +- documentation is updated when behavior or setup changed + +## Commit guidance + +There is no strict commit convention enforced by the repository, but clear history helps review. + +Prefer commits that: + +- have a single purpose +- use descriptive messages +- avoid mixing cleanup with functional changes + +## Security and privacy + +FitPub handles personal activity data, location data, and federation-facing endpoints. + +Please treat the following areas carefully: + +- privacy-zone behavior +- activity visibility rules +- authentication and JWT handling +- ActivityPub request validation and outbound federation logic +- file upload and parsing paths + +If you find a security issue, prefer responsible disclosure over opening a public issue with exploit details. diff --git a/DOCKER.md b/DOCKER.md deleted file mode 100644 index 5d63f07..0000000 --- a/DOCKER.md +++ /dev/null @@ -1,350 +0,0 @@ -# Docker Deployment Guide - -This guide explains how to deploy FitPub using Docker and Docker Compose. - -## Prerequisites - -- Docker Engine 20.10 or later -- Docker Compose 2.0 or later - -## Quick Start - -### 1. Clone the Repository - -```bash -git clone -cd feditrack -``` - -### 2. Create Environment File - -Copy the example environment file and customize it: - -```bash -cp .env.example .env -``` - -### 3. Configure Environment Variables - -Edit `.env` and update the following critical values: - -**Security (REQUIRED):** -```bash -# Generate a secure JWT secret -JWT_SECRET=$(openssl rand -base64 64) - -# Use a strong database password -POSTGRES_PASSWORD=$(openssl rand -base64 32) -``` - -**Domain Configuration (REQUIRED):** -```bash -APP_DOMAIN=your-domain.com -APP_BASE_URL=https://your-domain.com -``` - -### 4. Start the Application - -```bash -# Start all services -docker-compose up -d - -# View logs -docker-compose logs -f - -# Check service status -docker-compose ps -``` - -### 5. Verify Deployment - -The application should be available at: -- Application: http://localhost:8080 -- Health Check: http://localhost:8080/actuator/health - -## Environment Variables - -See `.env.example` for all available configuration options: - -| Variable | Description | Default | -|----------|-------------|---------| -| `POSTGRES_DB` | Database name | fitpub | -| `POSTGRES_USER` | Database user | fitpub | -| `POSTGRES_PASSWORD` | Database password | **MUST CHANGE** | -| `POSTGRES_PORT` | Database port | 5432 | -| `APP_PORT` | Application port | 8080 | -| `APP_DOMAIN` | Your domain name | example.com | -| `APP_BASE_URL` | Full application URL | https://example.com | -| `JWT_SECRET` | JWT signing secret | **MUST CHANGE** | -| `JWT_EXPIRATION_MS` | JWT expiration time | 86400000 (24h) | - -## Docker Compose Services - -### postgres -- **Image:** postgis/postgis:16-3.4 -- **Port:** 5432 (configurable via POSTGRES_PORT) -- **Volume:** `postgres_data` - Persistent database storage -- **Health Check:** PostgreSQL readiness check - -### app -- **Build:** From Dockerfile -- **Port:** 8080 (configurable via APP_PORT) -- **Volumes:** - - `app_uploads` - User uploaded files - - `app_logs` - Application logs -- **Health Check:** Spring Boot Actuator health endpoint -- **Depends On:** postgres (waits for healthy state) - -## Volumes - -Three named volumes are created for data persistence: - -```bash -# List volumes -docker volume ls | grep fitpub - -# Inspect volume -docker volume inspect feditrack_postgres_data - -# Backup database volume -docker run --rm -v feditrack_postgres_data:/data -v $(pwd):/backup \ - alpine tar czf /backup/postgres-backup-$(date +%Y%m%d).tar.gz -C /data . - -# Restore database volume -docker run --rm -v feditrack_postgres_data:/data -v $(pwd):/backup \ - alpine tar xzf /backup/postgres-backup-YYYYMMDD.tar.gz -C /data -``` - -## Common Operations - -### View Logs - -```bash -# All services -docker-compose logs -f - -# Specific service -docker-compose logs -f app -docker-compose logs -f postgres -``` - -### Restart Services - -```bash -# Restart all services -docker-compose restart - -# Restart specific service -docker-compose restart app -``` - -### Stop Services - -```bash -# Stop services (keeps containers) -docker-compose stop - -# Stop and remove containers (keeps volumes) -docker-compose down - -# Stop and remove everything including volumes (DANGER: data loss) -docker-compose down -v -``` - -### Execute Commands in Container - -```bash -# Access app container shell -docker-compose exec app bash - -# Access PostgreSQL CLI -docker-compose exec postgres psql -U fitpub -d fitpub - -# Run SQL query -docker-compose exec postgres psql -U fitpub -d fitpub -c "SELECT version();" -``` - -### Database Operations - -```bash -# Create database backup -docker-compose exec postgres pg_dump -U fitpub fitpub > backup.sql - -# Restore database backup -docker-compose exec -T postgres psql -U fitpub fitpub < backup.sql - -# Check Flyway migration status -docker-compose exec postgres psql -U fitpub -d fitpub -c \ - "SELECT * FROM flyway_schema_history ORDER BY installed_rank;" -``` - -### Rebuild Application - -```bash -# Rebuild and restart app -docker-compose up -d --build app - -# Force rebuild without cache -docker-compose build --no-cache app -docker-compose up -d app -``` - -## Production Deployment - -### Security Checklist - -- [ ] Change `POSTGRES_PASSWORD` to a strong random password -- [ ] Generate secure `JWT_SECRET` using `openssl rand -base64 64` -- [ ] Set correct `APP_DOMAIN` and `APP_BASE_URL` -- [ ] Configure HTTPS/TLS (use reverse proxy like nginx or Traefik) -- [ ] Disable `JPA_SHOW_SQL` and `JPA_FORMAT_SQL` -- [ ] Set appropriate log levels (INFO or WARN for production) -- [ ] Configure firewall rules (only expose necessary ports) -- [ ] Set up regular database backups -- [ ] Configure volume backup strategy -- [ ] Review and restrict network access - -### Reverse Proxy Example (nginx) - -```nginx -server { - listen 80; - server_name your-domain.com; - return 301 https://$server_name$request_uri; -} - -server { - listen 443 ssl http2; - server_name your-domain.com; - - ssl_certificate /path/to/cert.pem; - ssl_certificate_key /path/to/key.pem; - - location / { - proxy_pass http://localhost:8080; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } -} -``` - -## Monitoring - -### Health Checks - -```bash -# Application health -curl http://localhost:8080/actuator/health - -# Database health -docker-compose exec postgres pg_isready -U fitpub -``` - -### Resource Usage - -```bash -# Container stats -docker stats - -# Specific container stats -docker stats fitpub-app fitpub-postgres -``` - -## Troubleshooting - -### Application Won't Start - -```bash -# Check logs -docker-compose logs app - -# Check if database is ready -docker-compose ps postgres -docker-compose exec postgres pg_isready -U fitpub - -# Verify environment variables -docker-compose config -``` - -### Database Connection Issues - -```bash -# Check PostgreSQL logs -docker-compose logs postgres - -# Test database connection -docker-compose exec postgres psql -U fitpub -d fitpub -c "SELECT 1;" - -# Check network connectivity -docker-compose exec app ping postgres -``` - -### Migration Failures - -```bash -# Check Flyway schema history -docker-compose exec postgres psql -U fitpub -d fitpub -c \ - "SELECT * FROM flyway_schema_history;" - -# Reset database (DANGER: data loss) -docker-compose down -v -docker-compose up -d -``` - -### Out of Disk Space - -```bash -# Check Docker disk usage -docker system df - -# Clean up unused resources -docker system prune -a --volumes - -# Remove specific volume -docker volume rm feditrack_postgres_data -``` - -## Development Mode - -For local development with live reload: - -```bash -# Use development profile -echo "SPRING_PROFILES_ACTIVE=dev" >> .env - -# Enable SQL logging -echo "JPA_SHOW_SQL=true" >> .env -echo "JPA_FORMAT_SQL=true" >> .env - -# Mount source code for live reload (modify docker-compose.yml) -# Add under app.volumes: -# - ./src:/app/src -``` - -## Scaling - -To run multiple app instances behind a load balancer: - -```bash -# Scale app service -docker-compose up -d --scale app=3 - -# Note: You'll need to configure a load balancer and remove -# the container_name directive from docker-compose.yml -``` - -## Updating - -```bash -# Pull latest code -git pull - -# Rebuild and restart -docker-compose down -docker-compose up -d --build - -# Check migration status -docker-compose logs app | grep -i flyway -``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..8809c86 --- /dev/null +++ b/README.md @@ -0,0 +1,71 @@ +# FitPub + +FitPub is a self-hosted fitness tracking platform for the Fediverse. It lets people upload workout files, review their activities with maps and metrics, and share them through ActivityPub instead of locking them into a closed social network. + +The project is built for people who want to keep control of their training data while still participating in a social graph that reaches Mastodon-compatible platforms and other ActivityPub servers. + +## What FitPub does + +- Imports activity files from GPS devices and training apps +- Supports FIT and GPX uploads +- Publishes activities to followers over ActivityPub +- Provides public, followers-only, and private visibility modes +- Applies privacy zones to protect sensitive start and end locations +- Shows maps, metrics, timelines, and profile pages in a server-rendered web UI +- Includes analytics such as summaries, personal records, achievements, training load, and heatmaps +- Supports batch imports from ZIP archives + +## Why this project exists + +Most fitness platforms combine activity storage, analysis, and social distribution inside one vendor-controlled product. FitPub separates those concerns. You can run your own instance, keep your own data, and still share workouts with people on the wider Fediverse. + +## Stack + +FitPub is built with: + +- Java 17 +- Spring Boot 3 +- Thymeleaf +- PostgreSQL with PostGIS +- Flyway +- ActivityPub-compatible federation + +## Project layout + +- `src/main/java/` - application code +- `src/main/resources/templates/` - server-rendered views +- `src/main/resources/static/` - frontend assets +- `src/main/resources/db/migration/` - Flyway database migrations +- `src/test/` - automated tests +- `CONTAINERS.md` - container deployment notes for Docker- or Podman-based setups + +## Deployment + +FitPub is intended to be self-hosted. + +For container-based deployment, see [CONTAINERS.md](./CONTAINERS.md). + +## Current scope + +The repository already includes: + +- local accounts and authentication +- activity upload and post-processing +- federation endpoints and delivery logic +- social features such as follows, comments, likes, notifications, and timelines +- analytics and heatmap views +- privacy-zone filtering for track data + +## Status + +FitPub is an actively developed public project. The feature set is already substantial, but the codebase is still evolving and interface details may change as the platform matures. + +## Contributing + +Issues and pull requests are welcome. + +For contributor workflow, local setup, build steps, and test execution, see [CONTRIBUTING.md](./CONTRIBUTING.md). + +## License + +No license file is currently present in this repository. Until one is added, reuse terms are not explicitly defined. diff --git a/pom.xml b/pom.xml index 8ecf1b2..008ac47 100644 --- a/pom.xml +++ b/pom.xml @@ -23,7 +23,7 @@ UTF-8 17 0.12.3 - 2.0.5 + 2.0.3 @@ -170,14 +170,15 @@ org.testcontainers testcontainers-junit-jupiter - ${testcontainers.version} + 2.0.2 test + org.testcontainers testcontainers-postgresql - ${testcontainers.version} + 2.0.1 test @@ -192,4 +193,4 @@ - + \ No newline at end of file diff --git a/src/main/java/net/javahippie/fitpub/controller/ActivityPubController.java b/src/main/java/net/javahippie/fitpub/controller/ActivityPubController.java index c08e0ff..4cf3717 100644 --- a/src/main/java/net/javahippie/fitpub/controller/ActivityPubController.java +++ b/src/main/java/net/javahippie/fitpub/controller/ActivityPubController.java @@ -10,14 +10,13 @@ import net.javahippie.fitpub.model.activitypub.OrderedCollection; import net.javahippie.fitpub.model.entity.Activity; import net.javahippie.fitpub.model.entity.RemoteActor; import net.javahippie.fitpub.model.entity.User; -import net.javahippie.fitpub.repository.ActivityRepository; import net.javahippie.fitpub.repository.FollowRepository; +import net.javahippie.fitpub.repository.ActivityRepository; import net.javahippie.fitpub.repository.UserRepository; import net.javahippie.fitpub.security.HttpSignatureValidator; import net.javahippie.fitpub.service.ActivityImageService; import net.javahippie.fitpub.service.FederationService; import net.javahippie.fitpub.service.InboxProcessor; -import net.javahippie.fitpub.service.WorkoutDataPayloadBuilder; import net.javahippie.fitpub.util.ActivityFormatter; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; @@ -30,7 +29,6 @@ import java.net.URI; import java.net.URISyntaxException; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; -import java.time.ZoneOffset; import java.util.*; import java.util.regex.Pattern; @@ -53,7 +51,6 @@ public class ActivityPubController { private final HttpSignatureValidator signatureValidator; private final FederationService federationService; private final ObjectMapper objectMapper; - private final WorkoutDataPayloadBuilder workoutDataPayloadBuilder; @Value("${fitpub.base-url}") private String baseUrl; @@ -439,10 +436,9 @@ public class ActivityPubController { noteObject.put("id", activityUri); noteObject.put("type", "Note"); noteObject.put("attributedTo", actorUri); - noteObject.put("published", activity.getCreatedAt().atOffset(ZoneOffset.UTC).toInstant().toString()); + noteObject.put("published", activity.getCreatedAt().toString()); noteObject.put("content", formatActivityContent(activity)); noteObject.put("url", activityUri); - noteObject.put("workoutData", workoutDataPayloadBuilder.build(activity)); // Audience — only PUBLIC activities reach this endpoint (the visibility // check above returned 403 for anything else), so audience is always diff --git a/src/main/java/net/javahippie/fitpub/model/entity/RemoteActivity.java b/src/main/java/net/javahippie/fitpub/model/entity/RemoteActivity.java index a3b74da..1fd8105 100644 --- a/src/main/java/net/javahippie/fitpub/model/entity/RemoteActivity.java +++ b/src/main/java/net/javahippie/fitpub/model/entity/RemoteActivity.java @@ -9,7 +9,6 @@ import lombok.NoArgsConstructor; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.Type; import org.hibernate.annotations.UpdateTimestamp; -import org.locationtech.jts.geom.LineString; import java.time.Instant; import java.time.LocalDateTime; @@ -138,12 +137,6 @@ public class RemoteActivity { @Column(name = "track_geojson_url", length = 512) private String trackGeojsonUrl; - /** - * Simplified remote route geometry for local map rendering. - */ - @Column(name = "simplified_track", columnDefinition = "geometry(LineString, 4326)") - private LineString simplifiedTrack; - /** * Visibility level of the activity. */ diff --git a/src/main/java/net/javahippie/fitpub/service/ActivityPostProcessingService.java b/src/main/java/net/javahippie/fitpub/service/ActivityPostProcessingService.java index cad2bc9..8a582bc 100644 --- a/src/main/java/net/javahippie/fitpub/service/ActivityPostProcessingService.java +++ b/src/main/java/net/javahippie/fitpub/service/ActivityPostProcessingService.java @@ -12,7 +12,6 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; -import java.time.ZoneOffset; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -39,7 +38,6 @@ public class ActivityPostProcessingService { private final ActivityImageService activityImageService; private final ActivityRepository activityRepository; private final UserRepository userRepository; - private final WorkoutDataPayloadBuilder workoutDataPayloadBuilder; @Value("${fitpub.base-url}") private String baseUrl; @@ -201,10 +199,9 @@ public class ActivityPostProcessingService { noteObject.put("id", activityUri); noteObject.put("type", "Note"); noteObject.put("attributedTo", actorUri); - noteObject.put("published", activity.getCreatedAt().atOffset(ZoneOffset.UTC).toInstant().toString()); + noteObject.put("published", activity.getCreatedAt().toString()); noteObject.put("content", formatActivityContent(activity)); noteObject.put("url", baseUrl + "/activities/" + activity.getId()); - noteObject.put("workoutData", workoutDataPayloadBuilder.build(activity)); // Extract hashtags from user text and add as tags List hashtags = extractHashtags(activity); diff --git a/src/main/java/net/javahippie/fitpub/service/InboxProcessor.java b/src/main/java/net/javahippie/fitpub/service/InboxProcessor.java index 27efc1e..8dff712 100644 --- a/src/main/java/net/javahippie/fitpub/service/InboxProcessor.java +++ b/src/main/java/net/javahippie/fitpub/service/InboxProcessor.java @@ -16,20 +16,11 @@ import net.javahippie.fitpub.repository.CommentRepository; import net.javahippie.fitpub.repository.FollowRepository; import net.javahippie.fitpub.repository.LikeRepository; import net.javahippie.fitpub.repository.UserRepository; -import org.locationtech.jts.geom.Coordinate; -import org.locationtech.jts.geom.GeometryFactory; -import org.locationtech.jts.geom.LineString; -import org.locationtech.jts.geom.PrecisionModel; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.Instant; -import java.time.LocalDateTime; -import java.time.OffsetDateTime; -import java.time.ZoneOffset; -import java.time.ZonedDateTime; -import java.time.format.DateTimeParseException; import java.util.Map; import java.util.UUID; @@ -40,9 +31,6 @@ import java.util.UUID; @RequiredArgsConstructor @Slf4j public class InboxProcessor { - private static final int GEOMETRY_SRID = 4326; - private static final GeometryFactory GEOMETRY_FACTORY = - new GeometryFactory(new PrecisionModel(), GEOMETRY_SRID); private final UserRepository userRepository; private final FollowRepository followRepository; @@ -423,18 +411,15 @@ public class InboxProcessor { // Parse published timestamp String publishedStr = (String) noteObject.get("published"); - Instant publishedAt = parsePublishedAt(publishedStr); + Instant publishedAt = publishedStr != null ? Instant.parse(publishedStr) : Instant.now(); // Build RemoteActivity entity RemoteActivity remoteActivity = RemoteActivity.builder() .activityUri(activityUri) .remoteActorUri(actor) - .activityType(stringValue(workoutData.get("activityType"))) + .activityType((String) workoutData.get("activityType")) .title((String) noteObject.getOrDefault("name", noteObject.getOrDefault("summary", "Untitled Activity"))) - .description(firstNonBlank( - stringValue(workoutData.get("description")), - stripHtml((String) noteObject.get("content")) - )) + .description(stripHtml((String) noteObject.get("content"))) .publishedAt(publishedAt) .totalDistance(parseLong(workoutData.get("distance"))) .totalDurationSeconds(parseDurationSeconds((String) workoutData.get("duration"))) @@ -446,7 +431,6 @@ public class InboxProcessor { .calories(parseInteger(workoutData.get("calories"))) .mapImageUrl(attachments.get("mapImage")) .trackGeojsonUrl(attachments.get("trackGeojson")) - .simplifiedTrack(extractRoute(workoutData)) .visibility(visibility) .activityPubObject(serializeToJson(noteObject)) .build(); @@ -721,88 +705,6 @@ public class InboxProcessor { return workoutData; } - private String stringValue(Object value) { - return value != null ? String.valueOf(value) : null; - } - - private LineString extractRoute(Map workoutData) { - Object routeObj = workoutData.get("route"); - if (!(routeObj instanceof Map routeMap)) { - return null; - } - - Object featuresObj = routeMap.get("features"); - if (!(featuresObj instanceof java.util.List features) || features.isEmpty()) { - return null; - } - - for (Object featureObj : features) { - if (!(featureObj instanceof Map featureMap)) { - continue; - } - - Object geometryObj = featureMap.get("geometry"); - if (!(geometryObj instanceof Map geometryMap)) { - continue; - } - - if (!"LineString".equals(geometryMap.get("type"))) { - continue; - } - - LineString lineString = parseLineStringCoordinates(geometryMap.get("coordinates")); - if (lineString != null) { - return lineString; - } - } - - return null; - } - - private LineString parseLineStringCoordinates(Object coordinatesObj) { - if (!(coordinatesObj instanceof java.util.List coordinateList) || coordinateList.size() < 2) { - return null; - } - - java.util.List coordinates = new java.util.ArrayList<>(); - for (Object coordinateObj : coordinateList) { - Coordinate coordinate = parseCoordinate(coordinateObj); - if (coordinate == null) { - return null; - } - coordinates.add(coordinate); - } - - if (coordinates.size() < 2) { - return null; - } - - return GEOMETRY_FACTORY.createLineString(coordinates.toArray(new Coordinate[0])); - } - - private Coordinate parseCoordinate(Object coordinateObj) { - if (!(coordinateObj instanceof java.util.List coordinateValues) || coordinateValues.size() < 2) { - return null; - } - - Double longitude = parseDouble(coordinateValues.get(0)); - Double latitude = parseDouble(coordinateValues.get(1)); - if (longitude == null || latitude == null) { - return null; - } - - return new Coordinate(longitude, latitude); - } - - private String firstNonBlank(String... values) { - for (String value : values) { - if (value != null && !value.isBlank()) { - return value; - } - } - return null; - } - /** * Extract attachment URLs (map image, GeoJSON) from a Note object. */ @@ -922,44 +824,6 @@ public class InboxProcessor { } } - /** - * Parse ActivityPub published timestamps. - * - *

Preferred input is a full ISO-8601 instant with timezone/offset. Some - * remote implementations still send zoneless timestamps, so we accept those - * as a compatibility fallback and interpret them as UTC. - */ - private Instant parsePublishedAt(String publishedStr) { - if (publishedStr == null || publishedStr.isBlank()) { - return Instant.now(); - } - - try { - return Instant.parse(publishedStr); - } catch (DateTimeParseException ignored) { - // Fall through to compatibility parsers below. - } - - try { - return OffsetDateTime.parse(publishedStr).toInstant(); - } catch (DateTimeParseException ignored) { - // Fall through to compatibility parsers below. - } - - try { - return ZonedDateTime.parse(publishedStr).toInstant(); - } catch (DateTimeParseException ignored) { - // Fall through to compatibility parsers below. - } - - try { - return LocalDateTime.parse(publishedStr).atOffset(ZoneOffset.UTC).toInstant(); - } catch (DateTimeParseException e) { - log.warn("Failed to parse published timestamp: {}", publishedStr, e); - return Instant.now(); - } - } - /** * Serialize object to JSON string. */ diff --git a/src/main/java/net/javahippie/fitpub/service/WorkoutDataPayloadBuilder.java b/src/main/java/net/javahippie/fitpub/service/WorkoutDataPayloadBuilder.java deleted file mode 100644 index dd8752d..0000000 --- a/src/main/java/net/javahippie/fitpub/service/WorkoutDataPayloadBuilder.java +++ /dev/null @@ -1,86 +0,0 @@ -package net.javahippie.fitpub.service; - -import lombok.RequiredArgsConstructor; -import net.javahippie.fitpub.model.dto.ActivityDTO; -import net.javahippie.fitpub.model.entity.Activity; -import net.javahippie.fitpub.model.entity.ActivityMetrics; -import net.javahippie.fitpub.model.entity.PrivacyZone; -import org.springframework.stereotype.Service; - -import java.time.Duration; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** - * Builds the proprietary workoutData payload for outbound ActivityPub Notes. - */ -@Service -@RequiredArgsConstructor -public class WorkoutDataPayloadBuilder { - - private final PrivacyZoneService privacyZoneService; - private final TrackPrivacyFilter trackPrivacyFilter; - - public Map build(Activity activity) { - Map workoutData = new HashMap<>(); - workoutData.put("activityType", activity.getActivityType().name()); - - if (activity.getDescription() != null && !activity.getDescription().isBlank()) { - workoutData.put("description", activity.getDescription()); - } - if (activity.getTotalDistance() != null) { - workoutData.put("distance", activity.getTotalDistance().longValue()); - } - if (activity.getTotalDurationSeconds() != null) { - workoutData.put("duration", Duration.ofSeconds(activity.getTotalDurationSeconds()).toString()); - } - if (activity.getElevationGain() != null) { - workoutData.put("elevationGain", activity.getElevationGain().intValue()); - } - - ActivityMetrics metrics = activity.getMetrics(); - if (metrics != null) { - if (metrics.getAveragePaceSeconds() != null) { - workoutData.put("averagePace", Duration.ofSeconds(metrics.getAveragePaceSeconds()).toString()); - } - if (metrics.getAverageHeartRate() != null) { - workoutData.put("averageHeartRate", metrics.getAverageHeartRate()); - } - if (metrics.getAverageSpeed() != null) { - workoutData.put("averageSpeed", metrics.getAverageSpeed().doubleValue()); - } - if (metrics.getMaxSpeed() != null) { - workoutData.put("maxSpeed", metrics.getMaxSpeed().doubleValue()); - } - if (metrics.getCalories() != null) { - workoutData.put("calories", metrics.getCalories()); - } - } - - Map route = buildRoutePayload(activity); - if (route != null) { - workoutData.put("route", route); - } - - return workoutData; - } - - private Map buildRoutePayload(Activity activity) { - List privacyZones = privacyZoneService.getActivePrivacyZones(activity.getUserId()); - ActivityDTO dto = ActivityDTO.fromEntityWithFiltering(activity, null, privacyZones, trackPrivacyFilter); - - if (dto.getSimplifiedTrack() == null) { - return null; - } - - Map feature = new HashMap<>(); - feature.put("type", "Feature"); - feature.put("geometry", dto.getSimplifiedTrack()); - - Map featureCollection = new HashMap<>(); - featureCollection.put("type", "FeatureCollection"); - featureCollection.put("features", List.of(feature)); - return featureCollection; - } -} diff --git a/src/main/java/net/javahippie/fitpub/util/ActivityFormatter.java b/src/main/java/net/javahippie/fitpub/util/ActivityFormatter.java index 0b32b3d..26e4f32 100644 --- a/src/main/java/net/javahippie/fitpub/util/ActivityFormatter.java +++ b/src/main/java/net/javahippie/fitpub/util/ActivityFormatter.java @@ -98,10 +98,6 @@ public class ActivityFormatter { * */ private static LocalDateTime getUtcDateTimeInZone(LocalDateTime utcDateTime, String timezone) { - if (timezone == null || timezone.isBlank()) { - return utcDateTime; - } - try { return utcDateTime.atZone(ZoneOffset.UTC) .withZoneSameInstant(ZoneId.of(timezone)) diff --git a/src/main/java/net/javahippie/fitpub/util/ActivityPubContexts.java b/src/main/java/net/javahippie/fitpub/util/ActivityPubContexts.java index ce424c6..84581bd 100644 --- a/src/main/java/net/javahippie/fitpub/util/ActivityPubContexts.java +++ b/src/main/java/net/javahippie/fitpub/util/ActivityPubContexts.java @@ -35,8 +35,7 @@ public final class ActivityPubContexts { /** * Returns the extended JSON-LD {@code @context} value for outbound objects - * that carry both interaction-policy declarations and FitPub's proprietary - * {@code workoutData} extension fields. Shape: + * that carry interaction-policy declarations. Shape: * *

      * [
@@ -46,20 +45,7 @@ public final class ActivityPubContexts {
      *     "interactionPolicy":  { "@id": "gts:interactionPolicy",  "@type": "@id" },
      *     "canQuote":           { "@id": "gts:canQuote",           "@type": "@id" },
      *     "automaticApproval":  { "@id": "gts:automaticApproval",  "@type": "@id" },
-     *     "manualApproval":     { "@id": "gts:manualApproval",     "@type": "@id" },
-     *     "fitpub": "https://fitpub.social/ns#",
-     *     "workoutData": "fitpub:workoutData",
-     *     "activityType": "fitpub:activityType",
-     *     "description": "fitpub:description",
-     *     "distance": "fitpub:distance",
-     *     "duration": "fitpub:duration",
-     *     "elevationGain": "fitpub:elevationGain",
-     *     "averagePace": "fitpub:averagePace",
-     *     "averageHeartRate": "fitpub:averageHeartRate",
-     *     "averageSpeed": "fitpub:averageSpeed",
-     *     "maxSpeed": "fitpub:maxSpeed",
-     *     "calories": "fitpub:calories",
-     *     "route": "fitpub:route"
+     *     "manualApproval":     { "@id": "gts:manualApproval",     "@type": "@id" }
      *   }
      * ]
      * 
@@ -70,12 +56,6 @@ public final class ActivityPubContexts { * Mastodon source, "interaction_policies" extension), so a Mastodon * receiver compacting our object with its own context will recognise the * field names and apply the policy. - * - *

The {@code fitpub:} prefix is FitPub's own extension namespace - * ({@code https://fitpub.social/ns#}). It declares the proprietary - * {@code workoutData} object and its structured activity fields so FitPub - * instances can exchange machine-readable workout metadata without - * overloading the standard ActivityStreams fields. */ public static List extendedContext() { Map extensions = new LinkedHashMap<>(); @@ -84,19 +64,6 @@ public final class ActivityPubContexts { extensions.put("canQuote", typedRef("gts:canQuote")); extensions.put("automaticApproval", typedRef("gts:automaticApproval")); extensions.put("manualApproval", typedRef("gts:manualApproval")); - extensions.put("fitpub", "https://fitpub.social/ns#"); - extensions.put("workoutData", "fitpub:workoutData"); - extensions.put("activityType", "fitpub:activityType"); - extensions.put("description", "fitpub:description"); - extensions.put("distance", "fitpub:distance"); - extensions.put("duration", "fitpub:duration"); - extensions.put("elevationGain", "fitpub:elevationGain"); - extensions.put("averagePace", "fitpub:averagePace"); - extensions.put("averageHeartRate", "fitpub:averageHeartRate"); - extensions.put("averageSpeed", "fitpub:averageSpeed"); - extensions.put("maxSpeed", "fitpub:maxSpeed"); - extensions.put("calories", "fitpub:calories"); - extensions.put("route", "fitpub:route"); return List.of( "https://www.w3.org/ns/activitystreams", extensions diff --git a/src/main/resources/db/migration/V32__add_simplified_track_to_remote_activities.sql b/src/main/resources/db/migration/V32__add_simplified_track_to_remote_activities.sql deleted file mode 100644 index 49e3b7e..0000000 --- a/src/main/resources/db/migration/V32__add_simplified_track_to_remote_activities.sql +++ /dev/null @@ -1,9 +0,0 @@ -ALTER TABLE remote_activities - ADD COLUMN simplified_track geometry(LineString, 4326); - -CREATE INDEX idx_remote_activity_simplified_track - ON remote_activities - USING gist (simplified_track); - -COMMENT ON COLUMN remote_activities.simplified_track IS - 'Simplified remote route geometry for local map rendering'; diff --git a/src/main/resources/templates/profile/public.html b/src/main/resources/templates/profile/public.html index 828877d..ef43a1b 100644 --- a/src/main/resources/templates/profile/public.html +++ b/src/main/resources/templates/profile/public.html @@ -46,7 +46,7 @@

-

+

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

", "") - .replaceAll("

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

Sunny run

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

Kraremanns Lauf 2026

Run · 9.80 km · 41:09

Legacy content fallback

", - "published", "2026-05-02T09:24:50.921241", - "to", List.of("https://www.w3.org/ns/activitystreams#Public"), - "workoutData", workoutData - ); - - Map activity = Map.of( - "type", "Create", - "actor", remoteActorUri, - "object", note - ); - - ArgumentCaptor remoteActivityCaptor = - ArgumentCaptor.forClass(RemoteActivity.class); - - inboxProcessor.processActivity("JaneDoe", activity); - - verify(remoteActivityRepository).save(remoteActivityCaptor.capture()); - - RemoteActivity remoteActivity = remoteActivityCaptor.getValue(); - assertThat(remoteActivity.getTitle()).isEqualTo("Kraremanns Lauf 2026"); - assertThat(remoteActivity.getDescription()).isEqualTo("Direct workoutData description"); - assertThat(remoteActivity.getActivityType()).isEqualTo("RUN"); - assertThat(remoteActivity.getTotalDistance()).isEqualTo(9800L); - assertThat(remoteActivity.getTotalDurationSeconds()).isEqualTo(2469L); - assertThat(remoteActivity.getAveragePaceSeconds()).isEqualTo(252L); - assertThat(remoteActivity.getElevationGain()).isEqualTo(123); - LineString simplifiedTrack = remoteActivity.getSimplifiedTrack(); - assertThat(simplifiedTrack).isNotNull(); - assertThat(simplifiedTrack.getNumPoints()).isEqualTo(3); - assertThat(simplifiedTrack.getSRID()).isEqualTo(4326); - assertThat(simplifiedTrack.getCoordinateN(0).x).isEqualTo(8.55); - assertThat(simplifiedTrack.getCoordinateN(0).y).isEqualTo(47.37); - } -} diff --git a/src/test/java/net/javahippie/fitpub/service/WorkoutDataPayloadBuilderTest.java b/src/test/java/net/javahippie/fitpub/service/WorkoutDataPayloadBuilderTest.java deleted file mode 100644 index bc21615..0000000 --- a/src/test/java/net/javahippie/fitpub/service/WorkoutDataPayloadBuilderTest.java +++ /dev/null @@ -1,100 +0,0 @@ -package net.javahippie.fitpub.service; - -import net.javahippie.fitpub.model.entity.Activity; -import net.javahippie.fitpub.model.entity.ActivityMetrics; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.locationtech.jts.geom.Coordinate; -import org.locationtech.jts.geom.GeometryFactory; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.math.BigDecimal; -import java.util.List; -import java.util.Map; -import java.util.UUID; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -@DisplayName("WorkoutDataPayloadBuilder Tests") -class WorkoutDataPayloadBuilderTest { - - @Mock - private PrivacyZoneService privacyZoneService; - - @Mock - private TrackPrivacyFilter trackPrivacyFilter; - - @InjectMocks - private WorkoutDataPayloadBuilder builder; - - private UUID userId; - private Activity activity; - - @BeforeEach - void setUp() { - userId = UUID.randomUUID(); - activity = Activity.builder() - .id(UUID.randomUUID()) - .userId(userId) - .activityType(Activity.ActivityType.RUN) - .description("Morning jog") - .visibility(Activity.Visibility.PUBLIC) - .totalDistance(BigDecimal.valueOf(5000)) - .totalDurationSeconds(1800L) - .elevationGain(BigDecimal.valueOf(100)) - .simplifiedTrack(new GeometryFactory().createLineString(new Coordinate[]{ - new Coordinate(8.55, 47.37), - new Coordinate(8.56, 47.38) - })) - .build(); - activity.setMetrics(ActivityMetrics.builder() - .averagePaceSeconds(321L) - .averageHeartRate(150) - .averageSpeed(BigDecimal.valueOf(10.4)) - .maxSpeed(BigDecimal.valueOf(14.2)) - .calories(420) - .build()); - } - - @Test - @DisplayName("Should build workoutData payload with route and metrics") - void build_ShouldIncludeWorkoutDataRouteAndMetrics() { - when(privacyZoneService.getActivePrivacyZones(userId)).thenReturn(List.of()); - - Map workoutData = builder.build(activity); - - assertThat(workoutData) - .containsEntry("activityType", "RUN") - .containsEntry("description", "Morning jog") - .containsEntry("distance", 5000L) - .containsEntry("duration", "PT30M") - .containsEntry("elevationGain", 100) - .containsEntry("averagePace", "PT5M21S") - .containsEntry("averageHeartRate", 150) - .containsEntry("averageSpeed", 10.4) - .containsEntry("maxSpeed", 14.2) - .containsEntry("calories", 420); - - @SuppressWarnings("unchecked") - Map route = (Map) workoutData.get("route"); - assertThat(route).containsEntry("type", "FeatureCollection"); - - @SuppressWarnings("unchecked") - List> features = (List>) route.get("features"); - assertThat(features).hasSize(1); - - @SuppressWarnings("unchecked") - Map geometry = (Map) features.get(0).get("geometry"); - assertThat(geometry).containsEntry("type", "LineString"); - assertThat(geometry.get("coordinates")).isEqualTo(List.of( - List.of(8.55, 47.37), - List.of(8.56, 47.38) - )); - } -}