Compare commits

..
27 Commits
Author SHA1 Message Date
lobo 1044e53978 feat(library): Steam name and avatar for the header
Build, Test & Push Core Service / quality-check (push) Successful in 39s
Build, Test & Push Core Service / docker (push) Successful in 1m26s
2026-09-24 15:27:33 +02:00
lobo 12970be9b2 feat(library): time to beat from IGDB 2026-09-24 14:40:41 +02:00
lobo 466f787ba9 feat(profile): public profile data for a ticket from Auth
GET /api/public/profiles/library with X-Profile-Ticket returns totals, status
counts, playing and finished games with rating; never notes, priority or the
Steam ID. Login filters reject the ticket, and Core keeps no copy of the
privacy flag.
2026-09-24 14:18:32 +02:00
lobo adb92293ba feat(library): sync the Steam wishlist with store prices
Each sync fetches the public wishlist and the store name, price and discount
(batches of 100) and replaces the user's wishlist_game rows, without games
the user owns. A Steam error keeps the old list, a failed store batch keeps
the last known price, and a Steam ID change drops the old account's list.
GET /api/library/wishlist returns it newest first.
2026-09-24 13:58:44 +02:00
lobo 349eac0806 feat(library): playtime history per day and a nightly sync
Each sync records the growth of playtime per game and day (Europe/Berlin).
The first successful sync of a Steam account is only the baseline, tracked in
user_steam_sync.history_steam_id, so a new or failed account never turns its
whole playtime into one day. A nightly sync at 23:55 runs the recently used
accounts one after the other. GET /api/library/activity returns minutes per
day and the games of Steam's last two weeks.
2026-09-24 13:41:37 +02:00
lobo 83b9c0e1cf feat(library): sort by rating and priority with a stable order
Games without a rating or priority come last in both directions. Priority
sorts HIGH, MEDIUM, LOW instead of by enum name, and appid breaks every tie,
so pages no longer repeat or skip games with equal values.
2026-09-24 13:17:12 +02:00
lobo d085080c13 feat(library): summary endpoint with status counts and a backlog pick
Build, Test & Push Core Service / quality-check (push) Successful in 30s
Build, Test & Push Core Service / docker (push) Successful in 1m19s
GET /api/library/summary returns the game count per status, the never-started
count and a random backlog game. exclude=<appid> makes a new pick differ from
the current one.
2026-09-24 11:22:29 +02:00
lobo 82973fad55 test: share one Postgres container across test classes
The per-class container was stopped under the Spring context that the next
test class reused, so a second integration test class failed with
"Connection is not available".
2026-09-24 11:22:29 +02:00
lobo 9ff00c1f8f fix(library): keep backlog edits made during a sync, reject fractional ratings
Build, Test & Push Core Service / quality-check (push) Successful in 29s
Build, Test & Push Core Service / docker (push) Successful in 1m21s
UserGame now uses @DynamicUpdate, so the sync flush writes only the columns it
changed and no longer overwrites a backlog update committed meanwhile.
2026-09-24 11:04:02 +02:00
lobo c815a29993 feat(library): backlog status, rating, notes and priority per game
New games start as BACKLOG, or PLAYING when they have playtime. The sync
never changes a status later. PUT /api/library/games/{appid}/backlog sets
all four fields; /api/library/games filters by status and name (q).
2026-09-24 10:51:17 +02:00
lobo 22bc036c9c fix(security): close the Steam test endpoint, /internal and Prometheus to the public
/api/steam/test/{steamId} let anyone query any Steam profile with our API key. /internal/** had no controller but was open for any future one. Actuator now exposes only health, without details. The management block was also indented under endpoints, so show-details never applied.
2026-09-24 10:24:34 +02:00
lobo 8499678a4d chore(deps): update Spring Boot to 4.1.1, ArchUnit to 1.5.0, resilience4j to 2.4.0
Build, Test & Push Core Service / quality-check (push) Successful in 39s
Build, Test & Push Core Service / docker (push) Successful in 1m37s
resilience4j-spring-boot3 does not match Spring Boot 4, so switch to resilience4j-spring-boot4.
2026-09-24 08:56:45 +02:00
lobo b29cff76c8 fix(library): sync again after a Steam ID change and keep library on empty result
An empty Steam answer (error, private profile, wrong ID) was stored as OK and
soft-deleted the whole library. It is now FAILED, keeps the library and is
retried after 15 minutes (app.library.failed-retry-minutes).

The automatic sync now also runs when the Steam ID in the token differs from
the stored one, and removes the games of the previous Steam account.
2026-09-24 08:56:45 +02:00
Andreas Dahm 342ce1bea1 Small fix
Build, Test & Push Core Service / quality-check (push) Successful in 18s
Build, Test & Push Core Service / docker (push) Successful in 2m2s
2026-05-12 09:50:47 +02:00
lobo 6706db5b6e Merge pull request 'Changed from JWT token to public/privat key' (#1) from feat/jwt-eddsa-verify into main
Build, Test & Push Core Service / quality-check (push) Failing after 12s
Build, Test & Push Core Service / docker (push) Has been skipped
Reviewed-on: #1
2026-05-12 08:58:43 +02:00
Andreas Dahm f8d39bcda6 Changed from JWT token to public/privat key 2026-05-12 08:57:12 +02:00
Andreas DahmandClaude Opus 4.7 50366eb6bd Add Core-owned product roadmap to README
Build, Test & Push Core Service / quality-check (push) Failing after 11s
Build, Test & Push Core Service / docker (push) Has been skipped
Captures the Core slices of the cross-service product roadmap: per-game
tracking schema (status, rating, notes, completion date), picker
endpoint, HLTB integration, achievement aggregation, goals + digest
data, wishlist + sale alerts, library enrichment, and public profile
data. Cross-links to the umbrella roadmap in ToBePlayed-Deployment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:51:29 +02:00
Andreas Dahm 6e31373a6e Changed README.md
Build, Test & Push Core Service / quality-check (push) Failing after 11s
Build, Test & Push Core Service / docker (push) Has been skipped
2026-05-11 14:40:29 +02:00
Andreas Dahm b8b427b9f9 Added tests
Build, Test & Push Core Service / quality-check (push) Failing after 12s
Build, Test & Push Core Service / docker (push) Has been skipped
2026-05-11 14:31:38 +02:00
Andreas Dahm fb3a5140b8 Added more insight to the games of a user
Build, Test & Push Core Service / quality-check (push) Failing after 9s
Build, Test & Push Core Service / docker (push) Has been skipped
2026-05-04 15:14:25 +02:00
Andreas Dahm 9d8bb674d6 Added new endpoints to sync the list of games from steam if a user logged in.
Build, Test & Push Core Service / quality-check (push) Failing after 31s
Build, Test & Push Core Service / docker (push) Has been skipped
2026-05-04 11:47:26 +02:00
Andreas Dahm 44783ea523 Added dev impersonation and changed api a little bit
Build, Test & Push Core Service / quality-check (push) Failing after 10s
Build, Test & Push Core Service / docker (push) Has been skipped
2026-04-29 08:50:02 +02:00
Andreas Dahm 697fdcb208 Try to fetch steam game count if requested and cache it
Build, Test & Push Core Service / quality-check (push) Successful in 44s
Build, Test & Push Core Service / docker (push) Successful in 1m48s
2026-04-28 15:43:02 +02:00
lobo 2e3896b00d Add owned-games DTO and full profile support
Build, Test & Push Core Service / quality-check (push) Successful in 10s
Build, Test & Push Core Service / docker (push) Successful in 1m44s
Introduce SteamOwnedGamesResponse and SteamProfileAndGamesResponse DTOs and extend SteamApiService with getOwnedGames(...) and getFullPlayerProfile(...) to fetch and combine player summary and owned games. Update SteamTestController to return the combined profile/games response. Remove redundant Steam service URL entries from application.yml. Add SteamApiServiceTest to cover player summary, owned games (including null fields), and the combined profile flow; service falls back to empty lists on errors.
2026-04-19 15:15:15 +02:00
lobo 1f980c50af Refactor Steam adapter packages and update docs
Build, Test & Push Core Service / quality-check (push) Successful in 8s
Build, Test & Push Core Service / docker (push) Successful in 1m39s
Move steam_adapter sources from domain.* to modules.* and rename controller package to api. Update package declarations and imports across SteamTestController, SteamPlayerSummaryResponse, SteamClientConfig, and SteamApiService. Update README to re-label Controller -> API layer and Domain -> Business layer and adjust wording about layer boundaries. Also normalize file endings/newlines in several Java files. No functional logic changes were made.
2026-04-19 12:49:32 +02:00
lobo bba756728d Add Steam API client, service, controller
Build, Test & Push Core Service / quality-check (push) Successful in 9s
Build, Test & Push Core Service / docker (push) Successful in 1m44s
Introduce Steam integration: add SteamClientConfig (RestClient bean), SteamApiService (calls GetPlayerSummaries and returns a Player DTO), SteamPlayerSummaryResponse DTO, and SteamTestController (/api/steam/test/{steamId}) for quick testing. Update SecurityConfig to permit the test endpoint and adjust the SecurityFilterChain bean signature. Add Steam API settings: base-url in application.yml and a dev API key in application-dev.yml. Includes basic error logging and optional response handling when no player is found.
2026-04-19 11:38:31 +02:00
lobo 04dcab45da Initial project setup: CI, Docker & configs
Build, Test & Push Core Service / quality-check (push) Successful in 42s
Build, Test & Push Core Service / docker (push) Successful in 2m4s
Add project bootstrap for build, CI/CD, and local development. Introduces Maven Wrapper (mvnw, mvnw.cmd, .mvn/wrapper), multi-stage Dockerfile, docker-compose files (compose.yaml, docker-compose.dev.yml), .env, banner and README. Adds Gitea Actions workflow to build, test and push container images. Update application.yml (port -> 8090, virtual threads, API versioning, management/actuator/prometheus settings, Steam API config) and add application-dev.yml for local overrides. Extend pom.xml with caching, observability (actuator, micrometer/prometheus), testing/dev dependencies (testcontainers, devtools, additional spring test modules) to support CI and local testing.
2026-04-19 10:44:29 +02:00
13 changed files with 199 additions and 129 deletions

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
# .env file for local development
DB_PASSWORD=my_super_secret_local_password
-7
View File
@@ -31,10 +31,3 @@ build/
### VS Code ###
.vscode/
### Local secrets ###
.env
### Local dev config, never committed ###
application-dev.yml
application-dev.yaml
+138 -82
View File
@@ -1,113 +1,169 @@
# To Be Played – Core Service
# To Be Played (TBP) - Core Service
Business service of **To Be Played**, a Steam backlog tracker. It syncs the user's Steam library, stores the backlog
(status, rating, notes, priority), tracks playtime per day, syncs the wishlist and adds time-to-beat data from IGDB.
This is the Core Service for the "To Be Played" project. It acts as the central modulith, handling all business logic in vertical micro-service ready slices.
To Be Played is split into three repositories:
## 🚀 Tech Stack
| Repository | Role |
|---|---|
| ToBePlayed-Auth | Users, login, settings, signs JWTs |
| **ToBePlayed-Core** (this one) | Steam library, backlog, wishlist, stats; verifies JWTs |
| ToBePlayed-Frontend | React single-page app |
* **Language:** Java 25
* **Framework:** Spring Boot 4.1.x
* **Security:** Spring Security & JSON Web Tokens (JWT)
* **Database:** PostgreSQL 17
* **Migrations:** Flyway
* **Observability:** Spring Boot Actuator & Micrometer (Prometheus)
* **Build Tool:** Maven Wrapper (`mvnw`)
* **CI/CD:** Gitea Actions (Forgejo)
## Features
## 🏗️ Architecture (Clean Architecture)
- **Steam sync**: owned games, playtime and achievements through the Steam Web API. Manual sync plus a nightly
sync. Resilience4j retries and rate-limits every Steam call; Caffeine caches game counts and player summaries.
- **Backlog**: status (`BACKLOG`, `PLAYING`, `FINISHED`, `COMPLETED` = 100 %, `DROPPED`), rating 1–5, notes, priority,
filters and sorting. "Pile of Shame" summary with a random suggestion of what to play.
- **Playtime history**: one row per game and day, for the activity view and the "most played in the last two weeks"
dashboard banner.
- **Wishlist**: synced from Steam with store price and discount.
- **Time to beat**: story, extras and completionist hours from the IGDB API. Optional: without credentials the
feature is off and the rest works.
- **Public profile data**: totals, status counts, games in progress and finished games with rating. Readable only
with a profile ticket issued by Auth. Never returns notes, priority or the Steam ID.
The project strictly follows a Domain-Driven / Clean Architecture (Ports & Adapters) approach to ensure high maintainability and testability. The codebase is divided into three main layers to prevent framework lock-in and keep business logic isolated.
## Authentication
### Layer Breakdown
Core never issues tokens. It verifies Auth's EdDSA (Ed25519) JWTs with the public key only (`JWT_PUBLIC_KEY`) and
reads `userId` and `steamId` from the claims. A token with a `typ` claim (for example a profile ticket) is rejected
on normal endpoints; the profile ticket is accepted only by `GET /api/public/profiles/library` in the
`X-Profile-Ticket` header.
1. **API / Web Layer (`api`)**
* Contains REST endpoints, DTOs (`Request`/`Response` records), and Global Exception Handlers.
* Uses `ApiMapper` to map between DTOs and Domain Models.
* **Rule:** This layer delegates to the Service layer and never accesses persistence directly.
## API
2. **Business / Core Layer (`model`, `service`, `exception`)**
* The heart of the application. Contains pure Java classes (Models) and Business Logic (Services).
* Throws specific business exceptions (e.g., `InvalidCredentialsException`).
* **Rule:** This layer has **no dependencies** on the API or Persistence layer. It defines interfaces that the persistence layer must implement.
| Method | Path | Auth |
|---|---|---|
| `GET` | `/api/library/games`, `/api/library/games/{appid}` | JWT |
| `PUT` | `/api/library/games/{appid}/backlog` | JWT |
| `POST` | `/api/library/sync` | JWT |
| `GET` | `/api/library/sync-status`, `/summary`, `/activity`, `/wishlist`, `/steam-profile`, `/games-count` | JWT |
| `GET` | `/api/public/profiles/library` | profile ticket |
| `GET` | `/api/stats/public` | public |
3. **Persistence Layer (`persistence`)**
* Handles database operations. Contains JPA Entities (`UserEntity`), Spring Data Repositories, and the `EntityMapper`.
* Implements the interfaces defined in the Domain layer.
* **Rule:** Entities never leak into the API layer.
## Tech stack
Architectural boundaries are enforced via ArchUnit (`src/test/java/core_service/architecture/ArchitectureTest.java`), which runs as part of the standard `./mvnw test` quality gate. Abstract test bases live under `core_service.shared.test` (`AbstractUnitTest`, `AbstractIntegrationTest`, `AbstractPersistenceTest`, `AbstractWebMvcTest`, `AbstractArchitectureTest`).
- Java 25, Spring Boot 4.1 (Web MVC, Security, Data JPA, Validation, Cache with Caffeine), virtual threads
- PostgreSQL 17, Flyway migrations
- Resilience4j: retry and rate limiter for Steam, rate limiter for IGDB
- JJWT with Ed25519 public key
- Spring Boot Actuator + Micrometer (Prometheus)
- Tests: JUnit 5, Testcontainers (PostgreSQL), ArchUnit
- CI: Forgejo Actions, multi-stage Docker image
> **Core-specific structure note.** Unlike `auth-service`, Core keeps JPA `@Entity` classes in `..domain..` (rather than a separate `..persistence.entities..` package) and repository interfaces in `..repo..`. The ArchUnit rules are adapted to this layout. See "Roadmap / Open Topics" below for the planned tightening.
## Architecture
## ⚙️ Local Development
A modular monolith. Each module under `core_service.modules` is a vertical slice that could move to its own
service later:
Running the application locally is fully automated via Spring Boot's Docker Compose Support.
| Module | Content |
|---|---|
| `library` | Sync, backlog, playtime history, wishlist, public profile data |
| `stats` | Public numbers for the landing page |
| `steam_adapter` | Steam Web API client |
| `igdb_adapter` | IGDB client (Twitch OAuth) |
### Prerequisites
Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain` (JPA entities) and `repo`
(Spring Data repositories). ArchUnit enforces cycle freedom, controller-to-repository direction, naming and module
isolation in `src/test/java/core_service/architecture/ArchitectureTest.java`.
* Java 25 installed
* Docker & Docker Compose running
## Run locally
### How to start
Needs Java 25 and Docker.
1. Simply run the application from your IDE (e.g., IntelliJ) via the `CoreServiceApplication.java` main class.
2. Spring Boot will automatically detect the `compose.yaml` and spin up a local PostgreSQL container (`tbp_db_local`) on port `5432`.
3. **Flyway** will automatically apply all database migrations located in `src/main/resources/db/migration`.
4. The application will be available at `http://localhost:8090`.
To stop the environment and clean the database volume (if needed):
```bash
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev
docker compose down -v
```
Spring Boot's Docker Compose support starts PostgreSQL from `compose.yaml`, Flyway applies the migrations, and the
service listens on `http://localhost:8090`. Health: `/actuator/health`, metrics: `/actuator/prometheus`.
## 🔐 API Versioning
In the `dev` profile a request without an `Authorization` header gets a dummy user, so the API is usable without a
running Auth service.
All API endpoints utilize Query Parameter Versioning to keep URLs clean and manageable.
Example: GET /api/users/me?v=1
Configuration comes from environment variables:
## 🐳 CI/CD Pipeline
| Variable | Purpose |
|---|---|
| `JWT_PUBLIC_KEY` | Auth's Ed25519 public key, Base64 of the X.509 DER bytes |
| `STEAM_API_KEY` | Steam Web API key |
| `IGDB_CLIENT_ID`, `IGDB_CLIENT_SECRET` | Twitch app for IGDB (optional) |
| `DB_PASSWORD` | Local PostgreSQL password |
The project uses Gitea Actions for Continuous Integration and Deployment.
## Tests
Quality Gate: Runs ./mvnw clean verify to ensure tests and builds pass.
```bash
./mvnw clean verify
```
Docker Build: Packages the application using a multi-stage Dockerfile.
Runs unit, WebMvc, persistence and integration tests plus the ArchUnit rules. Needs Docker for Testcontainers.
Registry: Pushes the lightweight Alpine-based image to the private Gitea container registry (git.andreas-dahm.eu/lobo_corp/tbp-core-service:latest).
## Roadmap
## 📈 Observability
- "What to play next" picker: time budget and mood on top of the current random suggestion.
- Achievement summary per game and library-wide, "closest to 100 %".
- Sale alerts for wishlist games.
- Move `steam_adapter` DTOs out of `api`, then turn on strict module independence in ArchUnit.
- Fetch the public key from a JWKS endpoint in Auth instead of an environment variable.
Metrics and health checks are exposed out-of-the-box via Spring Boot Actuator:
## License
Health: http://localhost:8090/actuator/health
All rights reserved. The source is public for reading only.
Prometheus Metrics: http://localhost:8090/actuator/prometheus
## 🗺️ Roadmap / Open Topics
Tracking known work that is not yet implemented. Each item is independently actionable.
### Architecture / ArchUnit follow-ups
Currently active rules cover: cycles, controller→repository direction, repository naming, controller/service annotations, shared/configuration isolation, and a relaxed slice rule. The following rules are intentionally **not** yet enforced because they would fail today; addressing them is a separate refactor.
- **Strict module independence.** `library` currently imports from `steam_adapter` (e.g. `SteamApiService`, response DTOs). The ArchUnit rule exempts that edge via `ignoreDependency(library.., steam_adapter..)`. Long-term: either move `steam_adapter` out of `modules` (into `core_service.adapters.steam` or `core_service.shared.steam`) to make its role as a shared outbound port explicit, then enable strict `notDependOnEachOther()` without exemptions.
- **"DTOs only in `..api..`" rule.** Steam adapter response records currently live in `..api.dto..` and are consumed by `library` services. Move them to `core_service.modules.steam_adapter.model` (or `.port`) so they read as domain types, not API DTOs, then re-enable the strict rule.
- **"Entities only in `..persistence..`" rule.** Skipped because Core mixes entities with the domain package. Decide whether to (a) accept that pattern and keep the rule off, or (b) split entities into `..repo.entity..` and re-enable the rule for consistency with `auth-service`.
### Security
- ~~**Drop the shared `JWT_SECRET`.**~~ Done. Core now holds only `JWT_PUBLIC_KEY` and verifies with the Ed25519 public key via `Jwts.parser().verifyWith(publicKey).build()`. `JwtConfig.secret` removed.
- ~~**Add a `JwtServiceTest`.**~~ Done. `src/test/java/core_service/configuration/security/JwtServiceTest.java` covers extract / valid / wrong-key paths with an Ed25519 fixture.
- **Consume a JWKS endpoint** once Auth exposes one (`GET https://auth.tbp.andreas-dahm.eu/.well-known/jwks.json`). Cache keys at startup, refresh on `kid` miss. Avoids redeploying Core when keys rotate. With the `kid` header now present in issued tokens (`JWT_KID`), this is unblocked.
### Testing
- **First Steam adapter integration test.** `SteamApiServiceTest` is a unit test; an integration test with WireMock (or Spring's `MockRestServiceServer`) would lock in the contract with the external Steam API and the Resilience4j retry/rate-limit configuration.
- **Persistence-layer tests.** `library/repo/*Repository` has no `AbstractPersistenceTest`-based test today. Good first targets: `UserGameRepository`, `UserSteamSyncRepository`.
- **Controller WebMvc tests.** `LibraryController`, `PublicStatsController`, `SteamController` have no `AbstractWebMvcTest`-based tests yet. The Auth project's `*ControllerTest` classes are the pattern to follow.
### CI
- **Wire the ArchUnit + scaffolding into the existing Forgejo Actions quality gate.** `./mvnw verify` already runs them; confirm CI fails (not just warns) on architecture violations so drift is caught before merge.
## 🎯 Product Roadmap (Core-owned slices)
The full cross-service product roadmap lives in `ToBePlayed-Deployment/README.md`. This section lists only the slices that land in Core, in suggested execution order.
### 1. Per-game tracking — the namesake feature
Today `user_game` mirrors Steam ownership and playtime, nothing else. Closing this gap turns the library from a Steam mirror into an actual backlog tracker.
- Add a `status` column on `user_game` (`BACKLOG / PLAYING / COMPLETED / DROPPED / ON_HOLD / WISHLIST`) with a Flyway migration. Default at import: playtime > 0 → `PLAYING`, otherwise `BACKLOG`.
- Add `rating` (smallint 1–10, nullable), `completed_at` (timestamp, nullable), `notes` (text, markdown, server-side rendered or sanitised on read).
- Endpoint: `PATCH /api/library/games/{appid}` for partial updates of these fields. Stick with query-parameter versioning (`?v=1`).
- Endpoint: filter `GET /api/library/games?status=BACKLOG` (extend the existing pagination contract).
- New module candidate or extend `library` — keep it in `library` until tracking grows its own queries / reports, then split.
### 2. Picker / "what to play next"
- Endpoint: `GET /api/library/picker?budget=<minutes>&mood=<tag>&count=3`. Pure read, uses status + (eventually) HLTB hours + tags as filters. Randomised within filter set.
- No new persistence yet; build it on top of (1).
### 3. HowLongToBeat integration
- New module `core_service.modules.hltb` (or `library/hltb` if kept internal). External adapter pattern mirrors `steam_adapter`.
- Schema: `game_completion_time(appid, main_hours, plus_sides_hours, completionist_hours, fetched_at)`.
- Scheduled job to backfill + refresh (low cadence: monthly).
- Risk: HLTB has no official API; community libs scrape. Cache aggressively, degrade gracefully when missing.
### 4. Achievement aggregation (data already in place)
- `UserGameAchievement` + `GameAchievementSchema` are already synced. Add aggregate endpoints:
- `GET /api/library/games/{appid}/achievements/summary` → `{ unlocked, total, percent }`.
- `GET /api/library/me/achievements/summary` → library-wide unlocked / total / percent.
- `GET /api/library/me/achievements/closest-to-completion?limit=10`.
- Pure SQL views or projection DTOs; no new external calls.
### 5. Goals and digests
- Schema: `user_goal(user_id, year, target_games_completed, ...)`.
- Endpoint: `GET/PUT /api/library/me/goal?v=1`.
- Scheduled weekly digest job: aggregate per-user "played hours, backlog delta, status changes" for last 7 days. Push to Auth (which owns email) or to a notification queue.
### 6. Wishlist + sale alerts
- Extend `steam_adapter` with a wishlist endpoint.
- New module / adapter for IsThereAnyDeal price polling.
- Scheduled job; threshold per-user; trigger an email via Auth.
### 7. Library enrichment
- Genre / tag filters: Steam store tags exist in some Steam API responses — verify which calls return them and add to the cache.
- DLC awareness: Steam's `GetAppDetails` returns DLC; persist a `game_dlc(appid_base, appid_dlc)` table and a `GET .../games/{appid}/dlc` endpoint.
- Series / franchise grouping: needs IGDB integration (separate adapter, requires a free IGDB OAuth token).
- **Multi-platform support**: bigger schema change. Decouple `Game` (platform-agnostic) from `SteamGame` (platform-specific). New `source` enum (`STEAM`, `GOG`, `EPIC`, `MANUAL`, ...). Defer until at least one non-Steam adapter is on the immediate horizon.
### 8. ~~Public profile data~~ Done
- `GET /api/public/profiles/library` with header `X-Profile-Ticket` (issued by Auth for a public profile, valid 10 minutes, see the Auth readme) returns totals, status counts, up to 8 playing and 12 finished games with rating. Never notes, priority or the Steam ID. Core keeps no copy of the privacy flag; the ticket is the proof.
@@ -82,6 +82,6 @@ public class DevJwtAuthenticationFilter extends OncePerRequestFilter {
private UserPrincipal dummyPrincipal() {
log.trace("Dev mode: no JWT present, injecting dummy principal");
return new UserPrincipal("1", "dev@example.com", 76561198000000001L);
return new UserPrincipal("1", "dev@example.com", 76561198005679788L);
}
}
+20
View File
@@ -0,0 +1,20 @@
# ─────────────────────────────────────────────
# DEVELOPMENT CONFIGURATION
# ─────────────────────────────────────────────
# Overrides properties from application.yml when the 'dev' profile is active
app:
cors:
# Local development: allow all origins to prevent CORS issues
allowed-origins: "*"
steam:
api-key: "54EF220BF9A9BF849DB4E33FC4B0FDA3"
# Optional: Make SQL logs visible for debugging
spring:
jpa:
show-sql: true
security:
jwt:
# Dev-only Ed25519 public key. Must match Auth's dev private key.
public-key: "MCowBQYDK2VwAyEAPSmUISg0IUO1LrRPhF8RZAT/Yjh9HEVf5hzR84AL1Zw="
@@ -68,7 +68,7 @@ class PublicEndpointsTest {
}
@ParameterizedTest
@ValueSource(strings = {"/actuator/prometheus", "/api/steam/test/76561198000000001", "/internal/anything"})
@ValueSource(strings = {"/actuator/prometheus", "/api/steam/test/76561198005679788", "/internal/anything"})
void endpointIsNotPublic(String path) throws Exception {
int status = mockMvc.perform(get(path)).andReturn().getResponse().getStatus();
assertTrue(Set.of(401, 403).contains(status), path + " answered " + status);
@@ -18,7 +18,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* The dev profile authenticates every request as user 1 with Steam ID 76561198000000001.
* The dev profile authenticates every request as user 1 with Steam ID 76561198005679788.
*/
@AutoConfigureMockMvc
class LibraryControllerSteamProfileTest extends AbstractIntegrationTest {
@@ -31,8 +31,8 @@ class LibraryControllerSteamProfileTest extends AbstractIntegrationTest {
@Test
void steamProfile_SendsNameAndAvatar() throws Exception {
when(steamApiService.getPlayerSummary("76561198000000001")).thenReturn(Optional.of(new SteamPlayerSummaryResponse.Player(
"76561198000000001", "Lobo", null, "https://avatars.steamstatic.com/abc_full.jpg")));
when(steamApiService.getPlayerSummary("76561198005679788")).thenReturn(Optional.of(new SteamPlayerSummaryResponse.Player(
"76561198005679788", "Lobo", null, "https://avatars.steamstatic.com/abc_full.jpg")));
mockMvc.perform(get("/api/library/steam-profile"))
.andExpect(status().isOk())
@@ -42,7 +42,7 @@ class LibraryControllerSteamProfileTest extends AbstractIntegrationTest {
@Test
void steamProfile_SteamDoesNotAnswer_SendsEmptyFields() throws Exception {
when(steamApiService.getPlayerSummary("76561198000000001")).thenReturn(Optional.empty());
when(steamApiService.getPlayerSummary("76561198005679788")).thenReturn(Optional.empty());
mockMvc.perform(get("/api/library/steam-profile"))
.andExpect(status().isOk())
@@ -12,9 +12,14 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.time.Instant;
import java.util.Base64;
import java.util.Date;
import java.util.List;
@@ -24,10 +29,28 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Signs its own tickets: the test key pair replaces the dev public key.
*/
@AutoConfigureMockMvc
class PublicProfileControllerTest extends AbstractIntegrationTest {
private static final long USER_ID = 7L;
private static final KeyPair KEYS = generateKeys();
private static KeyPair generateKeys() {
try {
return KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("security.jwt.public-key", () -> Base64.getEncoder().encodeToString(KEYS.getPublic().getEncoded()));
}
@Autowired
private MockMvc mockMvc;
@@ -39,7 +62,7 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
private static String ticket(long userId) {
return Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", String.valueOf(userId))
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA).compact();
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(KEYS.getPrivate(), Jwts.SIG.EdDSA).compact();
}
private UserGame game(long appid, int playtime, GameStatus status, Integer rating) {
@@ -26,7 +26,7 @@ import static org.mockito.Mockito.when;
class LibrarySyncServiceTest extends AbstractUnitTest {
private static final long USER_ID = 1L;
private static final String STEAM_ID = "76561198000000001";
private static final String STEAM_ID = "76561198005679788";
@Mock
private UserSteamSyncRepository userSteamSyncRepository;
@@ -109,12 +109,12 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
@Test
void syncAllNightly_SyncsRecentlyUsedAccountsOneAfterTheOther() {
when(userSteamSyncRepository.findByUserSeenAtAfter(any())).thenReturn(List.of(
new UserSteamSync(1L, "76561198000000001"),
new UserSteamSync(1L, "76561198005679788"),
new UserSteamSync(2L, "76561198005679790")));
syncService.syncAllNightly();
verify(worker).runSyncNow(1L, "76561198000000001");
verify(worker).runSyncNow(1L, "76561198005679788");
verify(worker).runSyncNow(2L, "76561198005679790");
verify(worker, never()).runSync(anyLong(), anyString());
}
@@ -44,7 +44,7 @@ import static org.mockito.Mockito.when;
class LibrarySyncWorkerTest extends AbstractUnitTest {
private static final long USER_ID = 1L;
private static final String STEAM_ID = "76561198000000001";
private static final String STEAM_ID = "76561198005679788";
private static final String OTHER_STEAM_ID = "76561198005679790";
private static SteamOwnedGamesResponse.Game game(int playtime, int playtime2Weeks) {
@@ -28,10 +28,10 @@ class SteamProfileServiceTest extends AbstractUnitTest {
@Test
void getProfile_TakesNameAndAvatarFromSteam() {
when(steamApiService.getPlayerSummary("76561198000000001")).thenReturn(Optional.of(
new SteamPlayerSummaryResponse.Player("76561198000000001", "Lobo", "https://steamcommunity.com/id/lobo", AVATAR)));
when(steamApiService.getPlayerSummary("76561198005679788")).thenReturn(Optional.of(
new SteamPlayerSummaryResponse.Player("76561198005679788", "Lobo", "https://steamcommunity.com/id/lobo", AVATAR)));
assertEquals(new SteamProfileDto("Lobo", AVATAR), service.getProfile(76561198000000001L));
assertEquals(new SteamProfileDto("Lobo", AVATAR), service.getProfile(76561198005679788L));
}
@Test
@@ -26,7 +26,7 @@ import static org.mockito.Mockito.when;
*/
class SteamApiServiceCacheTest extends AbstractIntegrationTest {
private static final String STEAM_ID = "76561198000000001";
private static final String STEAM_ID = "76561198005679788";
@MockitoBean(name = "steamRestClient", answers = Answers.RETURNS_DEEP_STUBS)
private RestClient steamRestClient;
@@ -4,21 +4,14 @@ import org.junit.jupiter.api.Tag;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
/**
* Base class for integration tests.
* Full Spring context with Testcontainers.
*/
@Tag("IntegrationTest")
// The nightly sync would call Steam if a test ran at 23:55.
// The nightly sync would call Steam with the dev key if a test ran at 23:55.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = "app.library.nightly-sync-cron=-")
@ActiveProfiles("dev")
public abstract class AbstractIntegrationTest {
@@ -31,21 +24,4 @@ public abstract class AbstractIntegrationTest {
static {
postgres.start();
}
// No key is committed: a fresh one per JVM. Tests that sign tokens use TEST_KEYS.getPrivate().
protected static final KeyPair TEST_KEYS = generateKeys();
@DynamicPropertySource
static void secrets(DynamicPropertyRegistry registry) {
registry.add("security.jwt.public-key", () -> Base64.getEncoder().encodeToString(TEST_KEYS.getPublic().getEncoded()));
registry.add("app.steam.api-key", () -> "test-key");
}
private static KeyPair generateKeys() {
try {
return KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
}