Public Access
Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a5186782f | ||
|
|
9ad4c4b1bd | ||
|
|
ba75c00301 | ||
|
|
00a7991380 | ||
|
|
2f2f8a2918 | ||
|
|
ea1e1e9aec | ||
|
|
ba1f1e6fe1 | ||
|
|
afa2a7ea75 | ||
|
|
0482fb0c88 | ||
|
|
474a5dd570 | ||
|
|
3351ef75f0 | ||
|
|
0ac93394d9 | ||
|
|
b46e2badbd | ||
|
|
d96e682511 | ||
|
|
50f5f26157 | ||
|
|
8de138487e | ||
|
|
aaa3345651 | ||
|
|
dbea8922d5 | ||
|
|
b7efe27a54 | ||
|
|
32339e2ff5 | ||
|
|
1eb445e7a0 | ||
|
|
6b91463c4a | ||
|
|
2f3507b57b | ||
|
|
ac97442312 | ||
|
|
1b08e7902c | ||
|
|
be9b70c541 | ||
|
|
1dc2efba01 | ||
|
|
a1b11882de | ||
|
|
5881dd0613 |
No files matched your search
@@ -1,2 +0,0 @@
|
||||
# .env file for local development
|
||||
DB_PASSWORD=my_super_secret_local_password
|
||||
@@ -31,3 +31,10 @@ build/
|
||||
|
||||
### VS Code ###
|
||||
.vscode/
|
||||
|
||||
### Local secrets ###
|
||||
.env
|
||||
|
||||
### Local dev config, never committed ###
|
||||
application-dev.yml
|
||||
application-dev.yaml
|
||||
@@ -1,169 +1,113 @@
|
||||
# To Be Played (TBP) - Core Service
|
||||
# To Be Played – Core Service
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## 🚀 Tech Stack
|
||||
To Be Played is split into three repositories:
|
||||
|
||||
* **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)
|
||||
| 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 |
|
||||
|
||||
## 🏗️ Architecture (Clean Architecture)
|
||||
## Features
|
||||
|
||||
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.
|
||||
- **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.
|
||||
|
||||
### Layer Breakdown
|
||||
## Authentication
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
## API
|
||||
|
||||
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.
|
||||
| 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 |
|
||||
|
||||
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`).
|
||||
## Tech stack
|
||||
|
||||
> **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.
|
||||
- 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
|
||||
|
||||
## ⚙️ Local Development
|
||||
## Architecture
|
||||
|
||||
Running the application locally is fully automated via Spring Boot's Docker Compose Support.
|
||||
A modular monolith. Each module under `core_service.modules` is a vertical slice that could move to its own
|
||||
service later:
|
||||
|
||||
### Prerequisites
|
||||
| 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) |
|
||||
|
||||
* Java 25 installed
|
||||
* Docker & Docker Compose running
|
||||
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`.
|
||||
|
||||
### How to start
|
||||
## Run locally
|
||||
|
||||
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):
|
||||
Needs Java 25 and Docker.
|
||||
|
||||
```bash
|
||||
docker compose down -v
|
||||
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev
|
||||
```
|
||||
|
||||
## 🔐 API Versioning
|
||||
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`.
|
||||
|
||||
All API endpoints utilize Query Parameter Versioning to keep URLs clean and manageable.
|
||||
Example: GET /api/users/me?v=1
|
||||
In the `dev` profile a request without an `Authorization` header gets a dummy user, so the API is usable without a
|
||||
running Auth service.
|
||||
|
||||
## 🐳 CI/CD Pipeline
|
||||
Configuration comes from environment variables:
|
||||
|
||||
The project uses Gitea Actions for Continuous Integration and Deployment.
|
||||
| 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 |
|
||||
|
||||
Quality Gate: Runs ./mvnw clean verify to ensure tests and builds pass.
|
||||
## Tests
|
||||
|
||||
Docker Build: Packages the application using a multi-stage Dockerfile.
|
||||
```bash
|
||||
./mvnw clean verify
|
||||
```
|
||||
|
||||
Registry: Pushes the lightweight Alpine-based image to the private Gitea container registry (git.andreas-dahm.eu/lobo_corp/tbp-core-service:latest).
|
||||
Runs unit, WebMvc, persistence and integration tests plus the ArchUnit rules. Needs Docker for Testcontainers.
|
||||
|
||||
## 📈 Observability
|
||||
## Roadmap
|
||||
|
||||
Metrics and health checks are exposed out-of-the-box via Spring Boot Actuator:
|
||||
- "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.
|
||||
|
||||
Health: http://localhost:8090/actuator/health
|
||||
## License
|
||||
|
||||
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.
|
||||
All rights reserved. The source is public for reading only.
|
||||
@@ -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", 76561198005679788L);
|
||||
return new UserPrincipal("1", "dev@example.com", 76561198000000001L);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
# ─────────────────────────────────────────────
|
||||
# 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/76561198005679788", "/internal/anything"})
|
||||
@ValueSource(strings = {"/actuator/prometheus", "/api/steam/test/76561198000000001", "/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);
|
||||
|
||||
+4
-4
@@ -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 76561198005679788.
|
||||
* The dev profile authenticates every request as user 1 with Steam ID 76561198000000001.
|
||||
*/
|
||||
@AutoConfigureMockMvc
|
||||
class LibraryControllerSteamProfileTest extends AbstractIntegrationTest {
|
||||
@@ -31,8 +31,8 @@ class LibraryControllerSteamProfileTest extends AbstractIntegrationTest {
|
||||
|
||||
@Test
|
||||
void steamProfile_SendsNameAndAvatar() throws Exception {
|
||||
when(steamApiService.getPlayerSummary("76561198005679788")).thenReturn(Optional.of(new SteamPlayerSummaryResponse.Player(
|
||||
"76561198005679788", "Lobo", null, "https://avatars.steamstatic.com/abc_full.jpg")));
|
||||
when(steamApiService.getPlayerSummary("76561198000000001")).thenReturn(Optional.of(new SteamPlayerSummaryResponse.Player(
|
||||
"76561198000000001", "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("76561198005679788")).thenReturn(Optional.empty());
|
||||
when(steamApiService.getPlayerSummary("76561198000000001")).thenReturn(Optional.empty());
|
||||
|
||||
mockMvc.perform(get("/api/library/steam-profile"))
|
||||
.andExpect(status().isOk())
|
||||
|
||||
@@ -12,14 +12,9 @@ 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;
|
||||
|
||||
@@ -29,28 +24,10 @@ 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;
|
||||
|
||||
@@ -62,7 +39,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(KEYS.getPrivate(), Jwts.SIG.EdDSA).compact();
|
||||
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(TEST_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 = "76561198005679788";
|
||||
private static final String STEAM_ID = "76561198000000001";
|
||||
|
||||
@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, "76561198005679788"),
|
||||
new UserSteamSync(1L, "76561198000000001"),
|
||||
new UserSteamSync(2L, "76561198005679790")));
|
||||
|
||||
syncService.syncAllNightly();
|
||||
|
||||
verify(worker).runSyncNow(1L, "76561198005679788");
|
||||
verify(worker).runSyncNow(1L, "76561198000000001");
|
||||
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 = "76561198005679788";
|
||||
private static final String STEAM_ID = "76561198000000001";
|
||||
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("76561198005679788")).thenReturn(Optional.of(
|
||||
new SteamPlayerSummaryResponse.Player("76561198005679788", "Lobo", "https://steamcommunity.com/id/lobo", AVATAR)));
|
||||
when(steamApiService.getPlayerSummary("76561198000000001")).thenReturn(Optional.of(
|
||||
new SteamPlayerSummaryResponse.Player("76561198000000001", "Lobo", "https://steamcommunity.com/id/lobo", AVATAR)));
|
||||
|
||||
assertEquals(new SteamProfileDto("Lobo", AVATAR), service.getProfile(76561198005679788L));
|
||||
assertEquals(new SteamProfileDto("Lobo", AVATAR), service.getProfile(76561198000000001L));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ import static org.mockito.Mockito.when;
|
||||
*/
|
||||
class SteamApiServiceCacheTest extends AbstractIntegrationTest {
|
||||
|
||||
private static final String STEAM_ID = "76561198005679788";
|
||||
private static final String STEAM_ID = "76561198000000001";
|
||||
|
||||
@MockitoBean(name = "steamRestClient", answers = Answers.RETURNS_DEEP_STUBS)
|
||||
private RestClient steamRestClient;
|
||||
|
||||
@@ -4,14 +4,21 @@ 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 with the dev key if a test ran at 23:55.
|
||||
// The nightly sync would call Steam 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 {
|
||||
@@ -24,4 +31,21 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user