Compare commits

...
3 Commits
Author SHA1 Message Date
lobo 3bd1eda0ec docs(profile): note that the ticket claims come from the tile layout
Build, Test & Push Core Service / docker (push) Successful in 1m27s
Build, Test & Push Core Service / quality-check (push) Successful in 1m6s
2026-09-28 15:05:11 +02:00
lobo 07d287cd43 feat(profile): leave out hidden public profile sections 2026-09-28 14:20:14 +02:00
lobo 5c0c0c6a78 feat(dev): add the local profile with recorded third-party answers
Profile "local" points Steam, Twitch/IGDB and speedrun.com to a WireMock
container from compose.yaml (recordings in local/wiremock) and checks
logins against the local Auth.

The IGDB lookups now keep only the ids they asked for: an answer with
another game made tags and release years drop the whole batch.
2026-09-28 13:07:40 +02:00
62 changed files with 1673 additions and 35 deletions

No files matched your search

+9
View File
@@ -8,3 +8,12 @@ services:
- POSTGRES_DB=tbp_core - POSTGRES_DB=tbp_core
- POSTGRES_USER=tbp_user - POSTGRES_USER=tbp_user
- POSTGRES_PASSWORD=local_secret_password - POSTGRES_PASSWORD=local_secret_password
# Recorded answers of Steam, Twitch/IGDB and speedrun.com (local/wiremock). Starts only with the local profile.
wiremock:
image: wiremock/wiremock:3.9.1
container_name: tbp_core_mocks_local
profiles: ["local"]
ports:
- "8089:8080"
volumes:
- ./local/wiremock:/home/wiremock
@@ -0,0 +1,34 @@
[
{
"game": 72,
"uid": "620"
},
{
"game": 113112,
"uid": "1145360"
},
{
"game": 17000,
"uid": "413150"
},
{
"game": 7046,
"uid": "427520"
},
{
"game": 26226,
"uid": "504230"
},
{
"game": 14593,
"uid": "367520"
},
{
"game": 233,
"uid": "220"
},
{
"game": 133236,
"uid": "892970"
}
]
@@ -0,0 +1,27 @@
[
{
"id": 113112,
"name": "Hades",
"first_release_date": 1600387200,
"cover": {
"image_id": "co1r7f"
},
"platforms": [
{
"name": "PC (Microsoft Windows)",
"abbreviation": "PC"
},
{
"name": "Nintendo Switch",
"abbreviation": "Switch"
}
],
"total_rating_count": 1500,
"external_games": [
{
"external_game_source": 1,
"uid": "1145360"
}
]
}
]
@@ -0,0 +1,17 @@
[
{
"id": 119388,
"name": "The Legend of Zelda: Tears of the Kingdom",
"first_release_date": 1683849600,
"cover": {
"image_id": "co1r7f"
},
"platforms": [
{
"name": "Nintendo Switch",
"abbreviation": "Switch"
}
],
"total_rating_count": 900
}
]
@@ -0,0 +1,27 @@
[
{
"id": 26226,
"name": "Celeste",
"first_release_date": 1516838400,
"cover": {
"image_id": "co1r7f"
},
"platforms": [
{
"name": "PC (Microsoft Windows)",
"abbreviation": "PC"
},
{
"name": "Nintendo Switch",
"abbreviation": "Switch"
}
],
"total_rating_count": 1300,
"external_games": [
{
"external_game_source": 1,
"uid": "504230"
}
]
}
]
@@ -0,0 +1,67 @@
[
{
"id": 119388,
"name": "The Legend of Zelda: Tears of the Kingdom",
"first_release_date": 1683849600,
"cover": {
"image_id": "co1r7f"
},
"platforms": [
{
"name": "Nintendo Switch",
"abbreviation": "Switch"
}
],
"total_rating_count": 900
},
{
"id": 113112,
"name": "Hades",
"first_release_date": 1600387200,
"cover": {
"image_id": "co1r7f"
},
"platforms": [
{
"name": "PC (Microsoft Windows)",
"abbreviation": "PC"
},
{
"name": "Nintendo Switch",
"abbreviation": "Switch"
}
],
"total_rating_count": 1500,
"external_games": [
{
"external_game_source": 1,
"uid": "1145360"
}
]
},
{
"id": 26226,
"name": "Celeste",
"first_release_date": 1516838400,
"cover": {
"image_id": "co1r7f"
},
"platforms": [
{
"name": "PC (Microsoft Windows)",
"abbreviation": "PC"
},
{
"name": "Nintendo Switch",
"abbreviation": "Switch"
}
],
"total_rating_count": 1300,
"external_games": [
{
"external_game_source": 1,
"uid": "504230"
}
]
}
]
@@ -0,0 +1,38 @@
[
{
"id": 72,
"first_release_date": 1303171200
},
{
"id": 113112,
"first_release_date": 1600387200
},
{
"id": 17000,
"first_release_date": 1456444800
},
{
"id": 7046,
"first_release_date": 1600387200
},
{
"id": 26226,
"first_release_date": 1516838400
},
{
"id": 14593,
"first_release_date": 1487980800
},
{
"id": 233,
"first_release_date": 1100736000
},
{
"id": 133236,
"first_release_date": 1612828800
},
{
"id": 119388,
"first_release_date": 1683849600
}
]
+257
View File
@@ -0,0 +1,257 @@
[
{
"id": 72,
"genres": [
{
"name": "Puzzle"
},
{
"name": "Platform"
},
{
"name": "Shooter"
}
],
"themes": [
{
"name": "Science fiction"
},
{
"name": "Comedy"
}
],
"game_modes": [
{
"name": "Single player"
},
{
"name": "Co-operative"
}
]
},
{
"id": 113112,
"genres": [
{
"name": "Role-playing (RPG)"
},
{
"name": "Hack and slash/Beat 'em up"
},
{
"name": "Indie"
}
],
"themes": [
{
"name": "Action"
},
{
"name": "Fantasy"
}
],
"game_modes": [
{
"name": "Single player"
}
]
},
{
"id": 17000,
"genres": [
{
"name": "Role-playing (RPG)"
},
{
"name": "Simulator"
},
{
"name": "Indie"
}
],
"themes": [
{
"name": "Sandbox"
}
],
"game_modes": [
{
"name": "Single player"
},
{
"name": "Multiplayer"
},
{
"name": "Co-operative"
}
]
},
{
"id": 7046,
"genres": [
{
"name": "Simulator"
},
{
"name": "Strategy"
},
{
"name": "Indie"
}
],
"themes": [
{
"name": "Science fiction"
},
{
"name": "Sandbox"
},
{
"name": "Survival"
}
],
"game_modes": [
{
"name": "Single player"
},
{
"name": "Multiplayer"
}
]
},
{
"id": 26226,
"genres": [
{
"name": "Platform"
},
{
"name": "Indie"
}
],
"themes": [
{
"name": "Action"
}
],
"game_modes": [
{
"name": "Single player"
}
]
},
{
"id": 14593,
"genres": [
{
"name": "Platform"
},
{
"name": "Adventure"
},
{
"name": "Indie"
}
],
"themes": [
{
"name": "Action"
},
{
"name": "Fantasy"
}
],
"game_modes": [
{
"name": "Single player"
}
]
},
{
"id": 233,
"genres": [
{
"name": "Shooter"
}
],
"themes": [
{
"name": "Action"
},
{
"name": "Science fiction"
}
],
"game_modes": [
{
"name": "Single player"
}
]
},
{
"id": 133236,
"genres": [
{
"name": "Adventure"
},
{
"name": "Indie"
},
{
"name": "Simulator"
}
],
"themes": [
{
"name": "Action"
},
{
"name": "Fantasy"
},
{
"name": "Sandbox"
},
{
"name": "Survival"
}
],
"game_modes": [
{
"name": "Single player"
},
{
"name": "Multiplayer"
},
{
"name": "Co-operative"
}
]
},
{
"id": 119388,
"genres": [
{
"name": "Adventure"
},
{
"name": "Role-playing (RPG)"
}
],
"themes": [
{
"name": "Action"
},
{
"name": "Fantasy"
},
{
"name": "Open world"
}
],
"game_modes": [
{
"name": "Single player"
}
]
}
]
@@ -0,0 +1,58 @@
[
{
"game_id": 72,
"hastily": 21600,
"normally": 32400,
"completely": 79200,
"count": 100
},
{
"game_id": 113112,
"hastily": 72000,
"normally": 90000,
"completely": 342000,
"count": 100
},
{
"game_id": 17000,
"hastily": 144000,
"normally": 190800,
"completely": 576000,
"count": 100
},
{
"game_id": 7046,
"hastily": 108000,
"normally": 216000,
"completely": 900000,
"count": 100
},
{
"game_id": 26226,
"hastily": 28800,
"normally": 43200,
"completely": 144000,
"count": 100
},
{
"game_id": 14593,
"hastily": 97200,
"normally": 147600,
"completely": 226800,
"count": 100
},
{
"game_id": 233,
"hastily": 43200,
"normally": 50400,
"completely": 68400,
"count": 100
},
{
"game_id": 119388,
"hastily": 180000,
"normally": 216000,
"completely": 684000,
"count": 100
}
]
@@ -0,0 +1,17 @@
{
"data": [
{
"id": "o1y9j9v6",
"names": {
"international": "Celeste"
},
"released": 2018,
"weblink": "https://www.speedrun.com/celeste",
"links": [
{
"rel": "self"
}
]
}
]
}
@@ -0,0 +1,7 @@
{
"data": [
{
"id": "y8dwozoj"
}
]
}
@@ -0,0 +1,22 @@
{
"achievementpercentages": {
"achievements": [
{
"name": "FIRST_STEPS",
"percent": 88.0
},
{
"name": "CRYSTAL_HEART",
"percent": 35.2
},
{
"name": "SUMMIT",
"percent": 30.6
},
{
"name": "FAREWELL",
"percent": 5.9
}
]
}
}
@@ -0,0 +1,22 @@
{
"achievementpercentages": {
"achievements": [
{
"name": "ACH_WAKE_UP",
"percent": 92.1
},
{
"name": "ACH_SURVIVE_CONTAINMENT",
"percent": 70.4
},
{
"name": "ACH_CO_OP",
"percent": 22.5
},
{
"name": "ACH_END_SP",
"percent": 48.3
}
]
}
}
@@ -0,0 +1,71 @@
{
"response": {
"game_count": 8,
"games": [
{
"appid": 620,
"name": "Portal 2",
"playtime_forever": 1450,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true
},
{
"appid": 1145360,
"name": "Hades",
"playtime_forever": 3200,
"playtime_2weeks": 240,
"img_icon_url": "",
"has_community_visible_stats": true
},
{
"appid": 413150,
"name": "Stardew Valley",
"playtime_forever": 5400,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true
},
{
"appid": 427520,
"name": "Factorio",
"playtime_forever": 12000,
"playtime_2weeks": 600,
"img_icon_url": "",
"has_community_visible_stats": true
},
{
"appid": 504230,
"name": "Celeste",
"playtime_forever": 800,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true
},
{
"appid": 367520,
"name": "Hollow Knight",
"playtime_forever": 0,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true
},
{
"appid": 220,
"name": "Half-Life 2",
"playtime_forever": 780,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true
},
{
"appid": 892970,
"name": "Valheim",
"playtime_forever": 90,
"playtime_2weeks": 90,
"img_icon_url": "",
"has_community_visible_stats": true
}
]
}
}
@@ -0,0 +1,29 @@
{
"playerstats": {
"steamID": "76561198000000001",
"gameName": "Celeste",
"success": true,
"achievements": [
{
"apiname": "FIRST_STEPS",
"achieved": 1,
"unlocktime": 1700000000
},
{
"apiname": "CRYSTAL_HEART",
"achieved": 1,
"unlocktime": 1700003600
},
{
"apiname": "SUMMIT",
"achieved": 0,
"unlocktime": 0
},
{
"apiname": "FAREWELL",
"achieved": 0,
"unlocktime": 0
}
]
}
}
@@ -0,0 +1,29 @@
{
"playerstats": {
"steamID": "76561198000000001",
"gameName": "Portal 2",
"success": true,
"achievements": [
{
"apiname": "ACH_WAKE_UP",
"achieved": 1,
"unlocktime": 1700000000
},
{
"apiname": "ACH_SURVIVE_CONTAINMENT",
"achieved": 1,
"unlocktime": 1700003600
},
{
"apiname": "ACH_CO_OP",
"achieved": 0,
"unlocktime": 0
},
{
"apiname": "ACH_END_SP",
"achieved": 1,
"unlocktime": 1700010800
}
]
}
}
@@ -0,0 +1,12 @@
{
"response": {
"players": [
{
"steamid": "76561198000000001",
"personaname": "Dev",
"profileurl": "https://steamcommunity.com/profiles/76561198000000001/",
"avatarfull": "https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg"
}
]
}
}
@@ -0,0 +1,42 @@
{
"game": {
"gameName": "Celeste",
"gameVersion": "1",
"availableGameStats": {
"achievements": [
{
"name": "FIRST_STEPS",
"displayName": "First Steps",
"description": "Complete the Prologue.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/504230/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/504230/capsule_184x69.jpg"
},
{
"name": "CRYSTAL_HEART",
"displayName": "Heart of the Mountain",
"description": "Collect a Crystal Heart.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/504230/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/504230/capsule_184x69.jpg"
},
{
"name": "SUMMIT",
"displayName": "Summit",
"description": "Reach the summit and finish the story.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/504230/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/504230/capsule_184x69.jpg"
},
{
"name": "FAREWELL",
"displayName": "Farewell",
"description": "Complete Chapter 9.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/504230/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/504230/capsule_184x69.jpg"
}
]
}
}
}
@@ -0,0 +1,42 @@
{
"game": {
"gameName": "Portal 2",
"gameVersion": "1",
"availableGameStats": {
"achievements": [
{
"name": "ACH_WAKE_UP",
"displayName": "Wake Up Call",
"description": "Survive the manual override of the relaxation center.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/620/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/620/capsule_184x69.jpg"
},
{
"name": "ACH_SURVIVE_CONTAINMENT",
"displayName": "Pit Boss",
"description": "Show that pit who is boss.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/620/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/620/capsule_184x69.jpg"
},
{
"name": "ACH_CO_OP",
"displayName": "Team Building",
"description": "Complete all test chambers in the Team Building co-op course.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/620/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/620/capsule_184x69.jpg"
},
{
"name": "ACH_END_SP",
"displayName": "Lunacy",
"description": "Complete the single player campaign.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/620/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/620/capsule_184x69.jpg"
}
]
}
}
}
@@ -0,0 +1,34 @@
{
"response": {
"store_items": [
{
"appid": 1030300,
"success": 1,
"name": "Hollow Knight: Silksong",
"best_purchase_option": {
"formatted_final_price": "19,50€",
"formatted_original_price": "19,50€",
"discount_pct": 0
},
"assets": {
"asset_url_format": "steam/apps/1030300/${FILENAME}",
"header": "header.jpg"
}
},
{
"appid": 1145350,
"success": 1,
"name": "Hades II",
"best_purchase_option": {
"formatted_final_price": "22,49€",
"formatted_original_price": "29,99€",
"discount_pct": 25
},
"assets": {
"asset_url_format": "steam/apps/1145350/${FILENAME}",
"header": "header.jpg"
}
}
]
}
}
@@ -0,0 +1,14 @@
{
"response": {
"items": [
{
"appid": 1030300,
"date_added": 1735689600
},
{
"appid": 1145350,
"date_added": 1735776000
}
]
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"access_token": "local",
"expires_in": 5000000,
"token_type": "bearer"
}
@@ -0,0 +1,13 @@
{
"request": {
"method": "POST",
"urlPath": "/igdb/external_games"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "igdb-external-games.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "POST",
"urlPath": "/igdb/games",
"bodyPatterns": [
{
"contains": "where id = 113112;"
}
]
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "igdb-game-113112.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "POST",
"urlPath": "/igdb/games",
"bodyPatterns": [
{
"contains": "where id = 119388;"
}
]
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "igdb-game-119388.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "POST",
"urlPath": "/igdb/games",
"bodyPatterns": [
{
"contains": "where id = 26226;"
}
]
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "igdb-game-26226.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "POST",
"urlPath": "/igdb/games",
"bodyPatterns": [
{
"contains": "search \""
}
]
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "igdb-game-search.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "POST",
"urlPath": "/igdb/games",
"bodyPatterns": [
{
"contains": "fields first_release_date;"
}
]
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "igdb-game-years.json"
}
}
@@ -0,0 +1,14 @@
{
"request": {
"method": "POST",
"urlPath": "/igdb/games"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"jsonBody": []
},
"priority": 10
}
+18
View File
@@ -0,0 +1,18 @@
{
"request": {
"method": "POST",
"urlPath": "/igdb/games",
"bodyPatterns": [
{
"contains": "genres.name"
}
]
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "igdb-tags.json"
}
}
@@ -0,0 +1,13 @@
{
"request": {
"method": "POST",
"urlPath": "/igdb/game_time_to_beats"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "igdb-time-to-beats.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/speedrun/games",
"queryParameters": {
"name": {
"equalTo": "Celeste"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "speedrun-games-celeste.json"
}
}
@@ -0,0 +1,16 @@
{
"request": {
"method": "GET",
"urlPath": "/speedrun/games"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"jsonBody": {
"data": []
}
},
"priority": 10
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/speedrun/runs",
"queryParameters": {
"game": {
"equalTo": "o1y9j9v6"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "speedrun-runs-celeste.json"
}
}
@@ -0,0 +1,16 @@
{
"request": {
"method": "GET",
"urlPath": "/speedrun/runs"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"jsonBody": {
"data": []
}
},
"priority": 10
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v2/",
"queryParameters": {
"gameid": {
"equalTo": "504230"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-global-percentages-504230.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v2/",
"queryParameters": {
"gameid": {
"equalTo": "620"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-global-percentages-620.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v2/"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"jsonBody": {
"achievementpercentages": {
"achievements": []
}
}
},
"priority": 10
}
@@ -0,0 +1,13 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/IPlayerService/GetOwnedGames/v1/"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-owned-games.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetPlayerAchievements/v1/",
"queryParameters": {
"appid": {
"equalTo": "504230"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-player-achievements-504230.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetPlayerAchievements/v1/",
"queryParameters": {
"appid": {
"equalTo": "620"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-player-achievements-620.json"
}
}
@@ -0,0 +1,19 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetPlayerAchievements/v1/"
},
"response": {
"status": 400,
"headers": {
"Content-Type": "application/json"
},
"jsonBody": {
"playerstats": {
"error": "Requested app has no stats",
"success": false
}
}
},
"priority": 10
}
@@ -0,0 +1,13 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUser/GetPlayerSummaries/v0002/"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-player-summary.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetSchemaForGame/v2/",
"queryParameters": {
"appid": {
"equalTo": "504230"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-schema-504230.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetSchemaForGame/v2/",
"queryParameters": {
"appid": {
"equalTo": "620"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-schema-620.json"
}
}
@@ -0,0 +1,14 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetSchemaForGame/v2/"
},
"response": {
"status": 400,
"headers": {
"Content-Type": "application/json"
},
"jsonBody": {}
},
"priority": 10
}
@@ -0,0 +1,13 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/IStoreBrowseService/GetItems/v1/"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-store-items.json"
}
}
@@ -0,0 +1,13 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/IWishlistService/GetWishlist/v1/"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-wishlist.json"
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"request": {
"method": "POST",
"urlPath": "/twitch/oauth2/token"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "twitch-token.json"
}
}
+44 -2
View File
@@ -63,7 +63,10 @@ To Be Played is split into three repositories:
- **Public profile data**: totals, status counts, games in progress and finished games with rating, perfect games - **Public profile data**: totals, status counts, games in progress and finished games with rating, perfect games
and average completion. Readable only and average completion. Readable only
with a profile ticket issued by Auth. Never returns notes, priority or the Steam ID. `stats` holds only the stat with a profile ticket issued by Auth. Never returns notes, priority or the Steam ID. `stats` holds only the stat
groups the user shares (the ticket's `profileStats` claim, set in Auth); none by default. groups the user shares (the ticket's `profileStats` claim, derived in Auth from the profile tiles); none by
default. A section the user
hides (the ticket's `sections` claim: `SUMMARY`, `STATUS`, `PLAYING`, `FINISHED`, `STATS`) is null and runs no
query; a ticket without the claim shows all.
## Authentication ## Authentication
@@ -264,7 +267,46 @@ a second row when IGDB learns its Steam appid later and the user owns it on Stea
## Run locally ## Run locally
Needs Java 25 and Docker. ### Local development without keys (profile `local`)
The whole stack runs on your machine with no API keys and no calls to Steam, Twitch/IGDB or speedrun.com. Needs
Java 25, Node 24 and Docker. Clone `ToBePlayed-Auth`, `ToBePlayed-Core` and `ToBePlayed-Frontend` next to each
other, then start them in this order (Core also starts without Auth and finds it later):
1. **Auth**: in `ToBePlayed-Auth`, `./mvnw spring-boot:run -Dspring-boot.run.profiles=local`
(IDE: active profile `local`). Port 8080.
2. **Core**: in `ToBePlayed-Core`, `./mvnw spring-boot:run -Dspring-boot.run.profiles=local`. Port 8090.
3. **Frontend**: in `ToBePlayed-Frontend`, `npm install`, then `npm run dev`, and open `http://localhost:5173`
(`localhost`, not `127.0.0.1`, so the browser sends the login cookies to every local port). The development
values must be `VITE_API_URL=http://localhost:8080` and `VITE_CORE_API_URL=http://localhost:8090`.
4. **Log in** with `dev@localhost` / `password123`. On the dashboard, start a Steam sync: the library fills with 8
games, achievements, time to beat, moods, a wishlist and one speedrun game from recorded answers.
What the `local` profile does:
- Spring Boot's Docker Compose support starts each service's `compose.yaml`: PostgreSQL for Auth (port 5432) and
for Core (port 5433), and in Core a WireMock container (port 8089, compose profile `local`) that answers in
place of Steam, Twitch/IGDB and speedrun.com from `ToBePlayed-Core/local/wiremock`.
- Auth makes a new signing key at each start (`LocalKeysEnvironmentPostProcessor`, only in this profile), so no
key is in the repo. After an Auth restart (also a devtools restart), log in again.
- Never use `local` on a server: it adds an admin with a public password. Auth refuses `local` together with
`prod`.
- Auth adds the dev user (Flyway callback in `db/local`): role `PLATFORM_ADMIN`, so user, auditor and admin pages
can all be tried ("View as" in the menu switches down), and a linked Steam ID that the recorded Steam answers
belong to. The invite code for more users is `local`.
- Core checks the login with Auth's local JWKS, like in production.
- The browser still loads game pictures from the Steam and IGDB image servers; offline they stay gray.
- "Sign in through Steam" still opens the real Steam page (no key needed). The dev user is already linked.
- A request to WireMock that has no recording answers 404 and shows up in Core's log.
To change the recorded answers, edit the JSON in `local/wiremock/mappings` (which request) and
`local/wiremock/__files` (the answer), then restart Core (it restarts the container). `LocalMocksParseTest` checks
that every answer still fits the adapters' response types. `http://localhost:8089/__admin/requests` lists what Core
asked.
### With the real services (profile `dev`)
Needs Java 25, Docker and your own keys.
```bash ```bash
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev ./mvnw spring-boot:run -Dspring-boot.run.profiles=dev
@@ -66,6 +66,10 @@ public class JwtService implements ProfileTicketVerifier {
} }
} }
private static Set<String> names(List<?> claim) {
return claim.stream().filter(String.class::isInstance).map(String.class::cast).collect(Collectors.toSet());
}
@Override @Override
public Optional<ProfileTicket> verifyProfileTicket(String ticket) { public Optional<ProfileTicket> verifyProfileTicket(String ticket) {
try { try {
@@ -75,10 +79,9 @@ public class JwtService implements ProfileTicketVerifier {
} }
long userId = Long.parseLong(claims.get("profileUserId", String.class)); long userId = Long.parseLong(claims.get("profileUserId", String.class));
// A ticket of an Auth from before the claim has none: then the profile shows no stats. // A ticket of an Auth from before the claim has none: then the profile shows no stats.
Set<String> profileStats = claims.get("profileStats") instanceof List<?> groups Set<String> profileStats = claims.get("profileStats") instanceof List<?> groups ? names(groups) : Set.of();
? groups.stream().filter(String.class::isInstance).map(String.class::cast).collect(Collectors.toSet()) Set<String> sections = claims.get("sections") instanceof List<?> shown ? names(shown) : null;
: Set.of(); return Optional.of(new ProfileTicket(userId, profileStats, sections));
return Optional.of(new ProfileTicket(userId, profileStats));
} catch (Exception e) { } catch (Exception e) {
return Optional.empty(); return Optional.empty();
} }
@@ -20,6 +20,7 @@ import java.util.Arrays;
import java.util.Comparator; import java.util.Comparator;
import java.util.Collection; import java.util.Collection;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -275,13 +276,14 @@ public class IgdbApiService {
List<Long> ids = List.copyOf(igdbIds); List<Long> ids = List.copyOf(igdbIds);
Map<Long, TimeToBeat> result = new HashMap<>(); Map<Long, TimeToBeat> result = new HashMap<>();
for (int from = 0; from < ids.size(); from += BATCH) { for (int from = 0; from < ids.size(); from += BATCH) {
String gameIds = ids.subList(from, Math.min(from + BATCH, ids.size())).stream() List<Long> batchIds = ids.subList(from, Math.min(from + BATCH, ids.size()));
.map(String::valueOf).collect(Collectors.joining(",")); Set<Long> batch = new HashSet<>(batchIds);
String gameIds = batchIds.stream().map(String::valueOf).collect(Collectors.joining(","));
IgdbResponses.TimeToBeat[] times = query("/game_time_to_beats", IgdbResponses.TimeToBeat[] times = query("/game_time_to_beats",
"fields game_id,hastily,normally,completely,count; where game_id = (" + gameIds + "); limit " + LIMIT + ";", "fields game_id,hastily,normally,completely,count; where game_id = (" + gameIds + "); limit " + LIMIT + ";",
IgdbResponses.TimeToBeat[].class); IgdbResponses.TimeToBeat[].class);
for (IgdbResponses.TimeToBeat time : times) { for (IgdbResponses.TimeToBeat time : times) {
if (time.gameId() != null) { if (time.gameId() != null && batch.contains(time.gameId())) {
result.putIfAbsent(time.gameId(), result.putIfAbsent(time.gameId(),
new TimeToBeat(minutes(time.hastily()), minutes(time.normally()), minutes(time.completely()))); new TimeToBeat(minutes(time.hastily()), minutes(time.normally()), minutes(time.completely())));
} }
@@ -353,8 +355,10 @@ public class IgdbApiService {
"fields genres.name,themes.name,game_modes.name; where id = (" + gameIds + "); limit " + LIMIT + ";", "fields genres.name,themes.name,game_modes.name; where id = (" + gameIds + "); limit " + LIMIT + ";",
IgdbResponses.GameTags[].class); IgdbResponses.GameTags[].class);
Map<Long, GameTags> result = new HashMap<>(); Map<Long, GameTags> result = new HashMap<>();
// Only the asked ids, in this and the other lookups: callers index their maps by them, and an answer can
// hold more games.
for (IgdbResponses.GameTags game : games) { for (IgdbResponses.GameTags game : games) {
if (game.id() != null) { if (game.id() != null && igdbIds.contains(game.id())) {
result.put(game.id(), new GameTags(names(game.genres()), names(game.themes()), names(game.gameModes()))); result.put(game.id(), new GameTags(names(game.genres()), names(game.themes()), names(game.gameModes())));
} }
} }
@@ -371,7 +375,7 @@ public class IgdbApiService {
IgdbResponses.Game[].class); IgdbResponses.Game[].class);
Map<Long, Integer> result = new HashMap<>(); Map<Long, Integer> result = new HashMap<>();
for (IgdbResponses.Game game : games) { for (IgdbResponses.Game game : games) {
if (game.id() != null && game.firstReleaseDate() != null) { if (game.id() != null && game.firstReleaseDate() != null && igdbIds.contains(game.id())) {
result.put(game.id(), year(game.firstReleaseDate())); result.put(game.id(), year(game.firstReleaseDate()));
} }
} }
@@ -397,7 +401,8 @@ public class IgdbApiService {
IgdbResponses.ExternalGame[].class); IgdbResponses.ExternalGame[].class);
Map<Long, List<Long>> steamByIgdb = new HashMap<>(); Map<Long, List<Long>> steamByIgdb = new HashMap<>();
for (IgdbResponses.ExternalGame external : externals) { for (IgdbResponses.ExternalGame external : externals) {
if (external.game() != null && external.uid() != null && external.uid().matches("\\d+")) { if (external.game() != null && external.uid() != null && external.uid().matches("\\d+")
&& steamAppids.contains(Long.parseLong(external.uid()))) {
steamByIgdb.computeIfAbsent(external.game(), game -> new ArrayList<>()).add(Long.parseLong(external.uid())); steamByIgdb.computeIfAbsent(external.game(), game -> new ArrayList<>()).add(Long.parseLong(external.uid()));
} }
} }
@@ -29,7 +29,7 @@ public class PublicProfileController {
@GetMapping("/library") @GetMapping("/library")
public ResponseEntity<?> getPublicLibrary(@RequestHeader(value = "X-Profile-Ticket", required = false) String ticket) { public ResponseEntity<?> getPublicLibrary(@RequestHeader(value = "X-Profile-Ticket", required = false) String ticket) {
return (ticket == null ? Optional.<ProfileTicket>empty() : profileTicketVerifier.verifyProfileTicket(ticket)) return (ticket == null ? Optional.<ProfileTicket>empty() : profileTicketVerifier.verifyProfileTicket(ticket))
.<ResponseEntity<?>>map(t -> ResponseEntity.ok(queryService.getPublicLibrary(t.userId(), t.profileStats()))) .<ResponseEntity<?>>map(t -> ResponseEntity.ok(queryService.getPublicLibrary(t.userId(), t.profileStats(), t.sections())))
.orElseGet(() -> ResponseEntity.status(HttpStatus.UNAUTHORIZED) .orElseGet(() -> ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("code", "INVALID_TICKET", "message", "Missing, invalid or expired profile ticket"))); .body(Map.of("code", "INVALID_TICKET", "message", "Missing, invalid or expired profile ticket")));
} }
@@ -6,8 +6,10 @@ import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
* What a public profile shows. Never notes, priority or anything that identifies the Steam account. * What a public profile shows. Never notes, priority or anything that identifies the Steam account. The fields of a
* section the user hides ({@link core_service.modules.library.domain.ProfileSection}) are null.
* *
* @param finishedGames FINISHED and COMPLETED; part of the summary, so it stays when the status counts are hidden
* @param byStatus has every status, also those with 0 games * @param byStatus has every status, also those with 0 games
* @param playing up to 8 games, most played first * @param playing up to 8 games, most played first
* @param finished up to 12 FINISHED or COMPLETED games, best rated first * @param finished up to 12 FINISHED or COMPLETED games, best rated first
@@ -15,12 +17,13 @@ import java.util.Map;
* @param stats only the stat groups the user shares, the others null; null when the user shares none * @param stats only the stat groups the user shares, the others null; null when the user shares none
*/ */
public record PublicLibraryDto( public record PublicLibraryDto(
long totalGames, Long totalGames,
long totalPlaytimeMinutes, Long totalPlaytimeMinutes,
Long finishedGames,
Map<GameStatus, Long> byStatus, Map<GameStatus, Long> byStatus,
List<Game> playing, List<Game> playing,
List<Game> finished, List<Game> finished,
int perfectGames, Integer perfectGames,
Double averageCompletion, Double averageCompletion,
UserStatsDto stats UserStatsDto stats
) { ) {
@@ -0,0 +1,26 @@
package core_service.modules.library.domain;
import java.util.Collection;
import java.util.EnumSet;
import java.util.Set;
/**
* The parts of the public profile a user can hide. Core sends no data for a hidden part.
*/
public enum ProfileSection {
SUMMARY, STATUS, PLAYING, FINISHED, STATS;
/**
* @param names from the profile ticket; null (a ticket from before the sections) shows all. Unknown names are
* left out.
*/
public static Set<ProfileSection> shown(Collection<String> names) {
Set<ProfileSection> sections = EnumSet.noneOf(ProfileSection.class);
for (ProfileSection section : values()) {
if (names == null || names.contains(section.name())) {
sections.add(section);
}
}
return sections;
}
}
@@ -15,6 +15,7 @@ import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.GameTimeToBeat; import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.Mood; import core_service.modules.library.domain.Mood;
import core_service.modules.library.domain.ProfileSection;
import core_service.modules.library.domain.StatGroup; import core_service.modules.library.domain.StatGroup;
import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGame;
@@ -47,6 +48,7 @@ import java.util.Collection;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -212,27 +214,45 @@ public class LibraryQueryService {
* @param profileStats the stat group names of the profile ticket * @param profileStats the stat group names of the profile ticket
*/ */
@Public @Public
public PublicLibraryDto getPublicLibrary(Long userId, Collection<String> profileStats) { /**
Map<GameStatus, Long> byStatus = userGameRepository.countEveryStatus(userId); * @param sectionNames see {@link ProfileSection#shown}; a hidden section runs no query
List<UserGame> playing = userGameRepository.findListedByStatusIn(userId, */
List.of(GameStatus.PLAYING), public PublicLibraryDto getPublicLibrary(Long userId, Collection<String> profileStats, Collection<String> sectionNames) {
PageRequest.of(0, PUBLIC_PLAYING, Sort.by(Sort.Direction.DESC, "playtimeMinutes").and(Sort.by("appid")))); Set<ProfileSection> sections = ProfileSection.shown(sectionNames);
List<UserGame> finished = userGameRepository.findListedByStatusIn(userId, boolean summary = sections.contains(ProfileSection.SUMMARY);
List.of(GameStatus.FINISHED, GameStatus.COMPLETED), Map<GameStatus, Long> byStatus = summary || sections.contains(ProfileSection.STATUS)
PageRequest.of(0, PUBLIC_FINISHED, Sort.by(Sort.Order.desc("rating").nullsLast()).and(Sort.by("appid")))); ? userGameRepository.countEveryStatus(userId) : null;
Map<Long, SteamGame> meta = steamGameRepository.findAllById( List<UserGame> playing = sections.contains(ProfileSection.PLAYING)
Stream.concat(playing.stream(), finished.stream()).map(UserGame::getAppid).toList()) ? userGameRepository.findListedByStatusIn(userId, List.of(GameStatus.PLAYING),
PageRequest.of(0, PUBLIC_PLAYING, Sort.by(Sort.Direction.DESC, "playtimeMinutes").and(Sort.by("appid"))))
: null;
List<UserGame> finished = sections.contains(ProfileSection.FINISHED)
? userGameRepository.findListedByStatusIn(userId, List.of(GameStatus.FINISHED, GameStatus.COMPLETED),
PageRequest.of(0, PUBLIC_FINISHED, Sort.by(Sort.Order.desc("rating").nullsLast()).and(Sort.by("appid"))))
: null;
Map<Long, SteamGame> meta = steamGameRepository.findAllById(Stream.of(playing, finished)
.filter(Objects::nonNull).flatMap(List::stream).map(UserGame::getAppid).toList())
.stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g)); .stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g));
long total = byStatus.values().stream().mapToLong(Long::longValue).sum(); List<AchievementProgress> started = summary ? startedGames(userGameRepository.findAchievementProgress(userId)) : null;
List<AchievementProgress> started = startedGames(userGameRepository.findAchievementProgress(userId)); Set<StatGroup> shared = sections.contains(ProfileSection.STATS) ? StatGroup.shared(profileStats) : Set.of();
Set<StatGroup> shared = StatGroup.shared(profileStats); return new PublicLibraryDto(
return new PublicLibraryDto(total, userGameRepository.sumPlaytime(userId), byStatus, summary ? byStatus.values().stream().mapToLong(Long::longValue).sum() : null,
summary ? userGameRepository.sumPlaytime(userId) : null,
summary ? byStatus.get(GameStatus.FINISHED) + byStatus.get(GameStatus.COMPLETED) : null,
sections.contains(ProfileSection.STATUS) ? byStatus : null,
toPublicGames(playing, meta), toPublicGames(finished, meta), toPublicGames(playing, meta), toPublicGames(finished, meta),
perfectGames(started), averageCompletion(started), summary ? perfectGames(started) : null,
summary ? averageCompletion(started) : null,
shared.isEmpty() ? null : userStatsService.getStats(userId, shared)); shared.isEmpty() ? null : userStatsService.getStats(userId, shared));
} }
/**
* @return null for a hidden section
*/
private static List<PublicLibraryDto.Game> toPublicGames(List<UserGame> games, Map<Long, SteamGame> meta) { private static List<PublicLibraryDto.Game> toPublicGames(List<UserGame> games, Map<Long, SteamGame> meta) {
if (games == null) {
return null;
}
return games.stream() return games.stream()
.map(ug -> new PublicLibraryDto.Game(ug.getAppid(), .map(ug -> new PublicLibraryDto.Game(ug.getAppid(),
meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getName() : null, meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getName() : null,
@@ -6,5 +6,6 @@ import java.util.Set;
* A valid public-profile ticket from Auth. * A valid public-profile ticket from Auth.
* *
* @param profileStats the names of the stat groups the user shows on the profile; empty for a ticket without them * @param profileStats the names of the stat groups the user shows on the profile; empty for a ticket without them
* @param sections the names of the profile sections the user shows; null for a ticket without them, which shows all
*/ */
public record ProfileTicket(long userId, Set<String> profileStats) {} public record ProfileTicket(long userId, Set<String> profileStats, Set<String> sections) {}
+31
View File
@@ -0,0 +1,31 @@
# Local development without keys or third parties: start with the profile "local" (alone), after Auth's local
# profile. Postgres and WireMock (local/wiremock) come from compose.yaml. See the readme, "Run locally".
spring:
docker:
compose:
profiles:
active: local
# Also when Postgres still runs from a dev run; else WireMock would not start.
start:
skip: never
security:
jwt:
jwks-url: "http://localhost:8080/.well-known/jwks.json"
app:
cors:
allowed-origins: "http://localhost:5173"
steam:
api-key: local
base-url: "http://localhost:8089/steam"
igdb:
client-id: local
client-secret: local
base-url: "http://localhost:8089/igdb"
token-base-url: "http://localhost:8089/twitch"
speedrun:
base-url: "http://localhost:8089/speedrun"
library:
# The image repair checks the real Steam CDN.
image-repair-cron: "-"
@@ -0,0 +1,104 @@
package core_service.configuration;
import core_service.modules.igdb_adapter.api.dto.response.IgdbResponses;
import core_service.modules.speedrun_adapter.api.dto.response.SpeedrunResponses;
import core_service.modules.steam_adapter.api.dto.response.SteamGlobalAchievementPercentagesResponse;
import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesResponse;
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerAchievementsResponse;
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerSummaryResponse;
import core_service.modules.steam_adapter.api.dto.response.SteamSchemaForGameResponse;
import core_service.modules.steam_adapter.api.dto.response.SteamStoreItemsResponse;
import core_service.modules.steam_adapter.api.dto.response.SteamWishlistResponse;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;
import java.io.IOException;
import java.lang.reflect.RecordComponent;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The recorded answers of the local profile (local/wiremock) still fit the adapters' response types.
*/
class LocalMocksParseTest extends AbstractUnitTest {
private static final Path FILES = Path.of("local/wiremock/__files");
private static final Path MAPPINGS = Path.of("local/wiremock/mappings");
// By file name prefix; no prefix starts another one.
private static final Map<String, Class<?>> TYPES = Map.ofEntries(
Map.entry("steam-owned-games", SteamOwnedGamesResponse.class),
Map.entry("steam-player-summary", SteamPlayerSummaryResponse.class),
Map.entry("steam-wishlist", SteamWishlistResponse.class),
Map.entry("steam-store-items", SteamStoreItemsResponse.class),
Map.entry("steam-schema-", SteamSchemaForGameResponse.class),
Map.entry("steam-global-percentages-", SteamGlobalAchievementPercentagesResponse.class),
Map.entry("steam-player-achievements-", SteamPlayerAchievementsResponse.class),
Map.entry("twitch-token", IgdbResponses.Token.class),
Map.entry("igdb-external-games", IgdbResponses.ExternalGame[].class),
Map.entry("igdb-time-to-beats", IgdbResponses.TimeToBeat[].class),
Map.entry("igdb-tags", IgdbResponses.GameTags[].class),
Map.entry("igdb-game-", IgdbResponses.Game[].class),
Map.entry("speedrun-games-", SpeedrunResponses.Games.class),
Map.entry("speedrun-runs-", SpeedrunResponses.Runs.class));
private final JsonMapper jsonMapper = JsonMapper.builder().build();
@Test
void everyMapping_PointsToAnExistingAnswer() throws IOException {
List<Path> mappings;
try (Stream<Path> listed = Files.list(MAPPINGS)) {
mappings = listed.sorted().toList();
}
assertThat(mappings).isNotEmpty();
for (Path mapping : mappings) {
JsonNode response = jsonMapper.readTree(mapping.toFile()).path("response");
assertThat(response.has("bodyFileName") || response.has("jsonBody")).as(mapping.toString()).isTrue();
if (response.has("bodyFileName")) {
assertThat(FILES.resolve(response.get("bodyFileName").asString())).as(mapping.toString()).exists();
}
}
}
// Every top-level field of an answer, and the first field (the id) of every list element, is not null.
private static void assertFilled(String name, Object parsed) throws ReflectiveOperationException {
if (parsed instanceof Object[] elements) {
assertThat(elements).as(name).isNotEmpty();
for (Object element : elements) {
RecordComponent id = element.getClass().getRecordComponents()[0];
assertThat(id.getAccessor().invoke(element)).as(name + " " + id.getName()).isNotNull();
}
return;
}
for (RecordComponent component : parsed.getClass().getRecordComponents()) {
assertThat(component.getAccessor().invoke(parsed)).as(name + " " + component.getName()).isNotNull();
}
}
@Test
void everyRecordedAnswer_ParsesIntoItsResponseType() throws IOException, ReflectiveOperationException {
List<Path> files;
try (Stream<Path> listed = Files.list(FILES)) {
files = listed.sorted().toList();
}
assertThat(files).isNotEmpty();
for (Path file : files) {
String name = file.getFileName().toString();
Class<?> type = TYPES.entrySet().stream()
.filter(entry -> name.startsWith(entry.getKey()))
.map(Map.Entry::getValue)
.findFirst()
.orElseThrow(() -> new AssertionError("No response type for " + name));
assertFilled(name, jsonMapper.readValue(file.toFile(), type));
}
}
}
@@ -106,7 +106,7 @@ class JwtServiceTest extends AbstractUnitTest {
@Test @Test
void verifyProfileTicket_ReturnsTheUser() { void verifyProfileTicket_ReturnsTheUser() {
assertEquals(Optional.of(new ProfileTicket(42L, Set.of())), assertEquals(Optional.of(new ProfileTicket(42L, Set.of(), null)),
jwtService.verifyProfileTicket(signTicket("public-profile", "42", 60_000))); jwtService.verifyProfileTicket(signTicket("public-profile", "42", 60_000)));
} }
@@ -116,7 +116,16 @@ class JwtServiceTest extends AbstractUnitTest {
.claim("profileStats", List.of("PILE", "TREND")) .claim("profileStats", List.of("PILE", "TREND"))
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(signingKey, Jwts.SIG.EdDSA).compact(); .expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(signingKey, Jwts.SIG.EdDSA).compact();
assertEquals(Optional.of(new ProfileTicket(42L, Set.of("PILE", "TREND"))), jwtService.verifyProfileTicket(ticket)); assertEquals(Optional.of(new ProfileTicket(42L, Set.of("PILE", "TREND"), null)), jwtService.verifyProfileTicket(ticket));
}
@Test
void verifyProfileTicket_ReadsTheShownSections() {
String ticket = Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", "42")
.claim("sections", List.of("PLAYING", "STATS"))
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(signingKey, Jwts.SIG.EdDSA).compact();
assertEquals(Optional.of(new ProfileTicket(42L, Set.of(), Set.of("PLAYING", "STATS"))), jwtService.verifyProfileTicket(ticket));
} }
@Test @Test
@@ -127,6 +127,38 @@ class IgdbApiServiceTest extends AbstractUnitTest {
assertThat(tags.get(620L).themes()).contains("Horror"); assertThat(tags.get(620L).themes()).contains("Horror");
} }
@Test
void getTimeToBeat_AnswersForAppsAndGamesNotAskedFor_AreLeftOut() {
expectToken();
igdb.expect(requestTo(containsString("/external_games")))
.andRespond(withSuccess("[{\"game\": 72, \"uid\": \"620\"}, {\"game\": 73, \"uid\": \"999\"}]",
MediaType.APPLICATION_JSON));
igdb.expect(requestTo(containsString("/game_time_to_beats")))
.andRespond(withSuccess("[{\"game_id\": 72, \"normally\": 3600}, {\"game_id\": 73, \"normally\": 7200}]",
MediaType.APPLICATION_JSON));
igdb.expect(requestTo(containsString("/game_time_to_beats")))
.andRespond(withSuccess("[{\"game_id\": 1025, \"normally\": 3600}, {\"game_id\": 1026, \"normally\": 7200}]",
MediaType.APPLICATION_JSON));
assertThat(service.getTimeToBeat(List.of(620L)).orElseThrow()).containsOnlyKeys(620L);
assertThat(service.getTimeToBeatByGameIds(List.of(1025L)).orElseThrow()).containsOnlyKeys(1025L);
}
@Test
void getGameTagsAndYears_AnswerWithAGameNotAskedFor_KeepTheAskedGames() {
expectToken();
for (int call = 0; call < 2; call++) {
igdb.expect(requestTo(containsString("/external_games")))
.andRespond(withSuccess("[{\"game\": 72, \"uid\": \"620\"}]", MediaType.APPLICATION_JSON));
igdb.expect(requestTo(containsString("/games")))
.andRespond(withSuccess("[{\"id\": 72, \"first_release_date\": 1149120000, \"themes\": [{\"name\": \"Horror\"}]},"
+ " {\"id\": 999, \"first_release_date\": 1493942400}]", MediaType.APPLICATION_JSON));
}
assertThat(service.getGameTags(List.of(620L)).orElseThrow().get(620L).themes()).contains("Horror");
assertThat(service.getReleaseYears(List.of(620L))).contains(Map.of(620L, Set.of(2006)));
}
@Test @Test
void getReleaseYears_SteamAppLinkedToTwoIgdbGames_GetsBothYears() { void getReleaseYears_SteamAppLinkedToTwoIgdbGames_GetsBothYears() {
expectToken(); expectToken();
@@ -59,6 +59,12 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA).compact(); .expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA).compact();
} }
private static String ticket(long userId, List<String> profileStats, List<String> sections) {
return Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", String.valueOf(userId))
.claim("profileStats", profileStats).claim("sections", sections)
.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) { private UserGame game(long appid, int playtime, GameStatus status, Integer rating) {
UserGame game = new UserGame(USER_ID, appid, playtime, Instant.now(), Instant.now()); UserGame game = new UserGame(USER_ID, appid, playtime, Instant.now(), Instant.now());
game.setStatus(status); game.setStatus(status);
@@ -94,6 +100,7 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.totalGames").value(3)) .andExpect(jsonPath("$.totalGames").value(3))
.andExpect(jsonPath("$.totalPlaytimeMinutes").value(1260)) .andExpect(jsonPath("$.totalPlaytimeMinutes").value(1260))
.andExpect(jsonPath("$.finishedGames").value(2))
.andExpect(jsonPath("$.byStatus.FINISHED").value(1)) .andExpect(jsonPath("$.byStatus.FINISHED").value(1))
.andExpect(jsonPath("$.byStatus.COMPLETED").value(1)) .andExpect(jsonPath("$.byStatus.COMPLETED").value(1))
.andExpect(jsonPath("$.playing", hasSize(1))) .andExpect(jsonPath("$.playing", hasSize(1)))
@@ -142,6 +149,41 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.stats.trend").value(nullValue())); .andExpect(jsonPath("$.stats.trend").value(nullValue()));
} }
@Test
void publicLibrary_HiddenSections_SendNoData() throws Exception {
mockMvc.perform(get("/api/public/profiles/library")
.header("X-Profile-Ticket", ticket(USER_ID, List.of("TOP_GAMES"), List.of("PLAYING"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.playing", hasSize(1)))
.andExpect(jsonPath("$.totalGames").value(nullValue()))
.andExpect(jsonPath("$.totalPlaytimeMinutes").value(nullValue()))
.andExpect(jsonPath("$.finishedGames").value(nullValue()))
.andExpect(jsonPath("$.perfectGames").value(nullValue()))
.andExpect(jsonPath("$.byStatus").value(nullValue()))
.andExpect(jsonPath("$.finished").value(nullValue()))
.andExpect(jsonPath("$.stats").value(nullValue()));
}
@Test
void publicLibrary_SummaryWithoutStatus_KeepsTheFinishedCountButNoStatusCounts() throws Exception {
mockMvc.perform(get("/api/public/profiles/library")
.header("X-Profile-Ticket", ticket(USER_ID, List.of(), List.of("SUMMARY"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.totalGames").value(3))
.andExpect(jsonPath("$.finishedGames").value(2))
.andExpect(jsonPath("$.byStatus").value(nullValue()));
}
@Test
void publicLibrary_OnlyStatusShown_HasTheCountsButNoTotals() throws Exception {
mockMvc.perform(get("/api/public/profiles/library")
.header("X-Profile-Ticket", ticket(USER_ID, List.of(), List.of("STATUS", "MONEY"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.byStatus.FINISHED").value(1))
.andExpect(jsonPath("$.totalGames").value(nullValue()))
.andExpect(jsonPath("$.playing").value(nullValue()));
}
@Test @Test
void publicLibrary_WithoutOrWithABadTicket_Is401() throws Exception { void publicLibrary_WithoutOrWithABadTicket_Is401() throws Exception {
mockMvc.perform(get("/api/public/profiles/library")).andExpect(status().isUnauthorized()); mockMvc.perform(get("/api/public/profiles/library")).andExpect(status().isUnauthorized());