Compare commits

...
3 Commits
Author SHA1 Message Date
lobo dba4fedfa1 test(wiremock): add 30-game library and 6 achievement sets
Build, Test & Push Core Service / docker (push) Successful in 1m32s
Build, Test & Push Core Service / quality-check (push) Successful in 2m32s
Local WireMock library grows from 8 to 30 games.
Six games get schema, achievement and percentage stubs.
LocalMocksParseTest checks each stub appid is an owned game.
2026-10-13 12:03:56 +02:00
lobo cdfbb4a863 fix(follow): keep sync status rows for the sync digest
A hand-only digest used to move the cursor past all status rows.
It now stops at the last by_user change, so the sync digest still
gets its status detail lines.
2026-10-13 11:15:10 +02:00
lobo c6bfea1976 feat(inbox): add details for digest notes
Digest notes now keep the lines behind their counts in
inbox_note_detail (V48). GET /api/inbox/{id}/details shows them
while the reader still follows the subject and it is public.
2026-10-13 09:17:41 +02:00
59 changed files with 2171 additions and 42 deletions

No files matched your search

@@ -0,0 +1,30 @@
{
"achievementpercentages": {
"achievements": [
{
"name": "ACH_B1",
"percent": 81.0
},
{
"name": "ACH_B2",
"percent": 57.3
},
{
"name": "ACH_B3",
"percent": 39.8
},
{
"name": "ACH_B4",
"percent": 26.5
},
{
"name": "ACH_B5",
"percent": 23.6
},
{
"name": "ACH_B6",
"percent": 18.2
}
]
}
}
@@ -0,0 +1,38 @@
{
"achievementpercentages": {
"achievements": [
{
"name": "ACH_A1",
"percent": 88.2
},
{
"name": "ACH_A2",
"percent": 52.7
},
{
"name": "ACH_A3",
"percent": 74.1
},
{
"name": "ACH_A4",
"percent": 31.4
},
{
"name": "ACH_A5",
"percent": 44.9
},
{
"name": "ACH_A6",
"percent": 36.0
},
{
"name": "ACH_A7",
"percent": 28.3
},
{
"name": "ACH_A8",
"percent": 4.6
}
]
}
}
@@ -0,0 +1,30 @@
{
"achievementpercentages": {
"achievements": [
{
"name": "ACH_F1",
"percent": 91.2
},
{
"name": "ACH_F2",
"percent": 64.8
},
{
"name": "ACH_F3",
"percent": 48.5
},
{
"name": "ACH_F4",
"percent": 37.1
},
{
"name": "ACH_F5",
"percent": 22.4
},
{
"name": "ACH_F6",
"percent": 11.0
}
]
}
}
@@ -0,0 +1,30 @@
{
"achievementpercentages": {
"achievements": [
{
"name": "ACH_E1",
"percent": 83.4
},
{
"name": "ACH_E2",
"percent": 62.0
},
{
"name": "ACH_E3",
"percent": 44.3
},
{
"name": "ACH_E4",
"percent": 31.9
},
{
"name": "ACH_E5",
"percent": 27.5
},
{
"name": "ACH_E6",
"percent": 9.7
}
]
}
}
@@ -0,0 +1,30 @@
{
"achievementpercentages": {
"achievements": [
{
"name": "ACH_C1",
"percent": 78.5
},
{
"name": "ACH_C2",
"percent": 47.2
},
{
"name": "ACH_C3",
"percent": 33.6
},
{
"name": "ACH_C4",
"percent": 35.4
},
{
"name": "ACH_C5",
"percent": 41.0
},
{
"name": "ACH_C6",
"percent": 12.9
}
]
}
}
@@ -0,0 +1,30 @@
{
"achievementpercentages": {
"achievements": [
{
"name": "ACH_D1",
"percent": 70.3
},
{
"name": "ACH_D2",
"percent": 25.8
},
{
"name": "ACH_D3",
"percent": 30.2
},
{
"name": "ACH_D4",
"percent": 15.6
},
{
"name": "ACH_D5",
"percent": 12.1
},
{
"name": "ACH_D6",
"percent": 8.4
}
]
}
}
+205 -7
View File
@@ -1,6 +1,6 @@
{
"response": {
"game_count": 8,
"game_count": 30,
"games": [
{
"appid": 620,
@@ -8,7 +8,8 @@
"playtime_forever": 1450,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true
"has_community_visible_stats": true,
"rtime_last_played": 1787300000
},
{
"appid": 1145360,
@@ -16,7 +17,8 @@
"playtime_forever": 3200,
"playtime_2weeks": 240,
"img_icon_url": "",
"has_community_visible_stats": true
"has_community_visible_stats": true,
"rtime_last_played": 1790500000
},
{
"appid": 413150,
@@ -24,7 +26,8 @@
"playtime_forever": 5400,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true
"has_community_visible_stats": true,
"rtime_last_played": 1788900000
},
{
"appid": 427520,
@@ -32,7 +35,8 @@
"playtime_forever": 12000,
"playtime_2weeks": 600,
"img_icon_url": "",
"has_community_visible_stats": true
"has_community_visible_stats": true,
"rtime_last_played": 1790900000
},
{
"appid": 504230,
@@ -40,7 +44,8 @@
"playtime_forever": 800,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true
"has_community_visible_stats": true,
"rtime_last_played": 1789200000
},
{
"appid": 367520,
@@ -56,7 +61,8 @@
"playtime_forever": 780,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true
"has_community_visible_stats": true,
"rtime_last_played": 1786900000
},
{
"appid": 892970,
@@ -64,6 +70,198 @@
"playtime_forever": 90,
"playtime_2weeks": 90,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1790700000
},
{
"appid": 1245620,
"name": "ELDEN RING",
"playtime_forever": 6100,
"playtime_2weeks": 900,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1790000000
},
{
"appid": 1091500,
"name": "Cyberpunk 2077",
"playtime_forever": 4300,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1787900000
},
{
"appid": 292030,
"name": "The Witcher 3: Wild Hunt",
"playtime_forever": 5200,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1789800000
},
{
"appid": 1086940,
"name": "Baldur's Gate 3",
"playtime_forever": 2800,
"playtime_2weeks": 300,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1790150000
},
{
"appid": 730,
"name": "Counter-Strike 2",
"playtime_forever": 1900,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1788600000
},
{
"appid": 570,
"name": "Dota 2",
"playtime_forever": 0,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true
},
{
"appid": 1174180,
"name": "Red Dead Redemption 2",
"playtime_forever": 0,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true
},
{
"appid": 105600,
"name": "Terraria",
"playtime_forever": 2600,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1789300000
},
{
"appid": 294100,
"name": "RimWorld",
"playtime_forever": 3900,
"playtime_2weeks": 120,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1790300000
},
{
"appid": 632470,
"name": "Disco Elysium - The Final Cut",
"playtime_forever": 1500,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1788100000
},
{
"appid": 548430,
"name": "Deep Rock Galactic",
"playtime_forever": 700,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1790000000
},
{
"appid": 646570,
"name": "Slay the Spire",
"playtime_forever": 2200,
"playtime_2weeks": 60,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1790450000
},
{
"appid": 2379780,
"name": "Balatro",
"playtime_forever": 1100,
"playtime_2weeks": 420,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1790600000
},
{
"appid": 1794680,
"name": "Vampire Survivors",
"playtime_forever": 450,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1790700000
},
{
"appid": 268910,
"name": "Cuphead",
"playtime_forever": 900,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1787600000
},
{
"appid": 814380,
"name": "Sekiro: Shadows Die Twice",
"playtime_forever": 0,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true
},
{
"appid": 374320,
"name": "DARK SOULS III",
"playtime_forever": 0,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true
},
{
"appid": 1057090,
"name": "Ori and the Will of the Wisps",
"playtime_forever": 0,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true
},
{
"appid": 275850,
"name": "No Man's Sky",
"playtime_forever": 120,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1790200000
},
{
"appid": 526870,
"name": "Satisfactory",
"playtime_forever": 0,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true
},
{
"appid": 1332010,
"name": "Stray",
"playtime_forever": 330,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1789000000
},
{
"appid": 108600,
"name": "Project Zomboid",
"playtime_forever": 0,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true
}
]
@@ -0,0 +1,39 @@
{
"playerstats": {
"steamID": "76561198000000001",
"gameName": "Cyberpunk 2077",
"success": true,
"achievements": [
{
"apiname": "ACH_B1",
"achieved": 1,
"unlocktime": 1786000000
},
{
"apiname": "ACH_B2",
"achieved": 1,
"unlocktime": 1786259200
},
{
"apiname": "ACH_B3",
"achieved": 1,
"unlocktime": 1786518400
},
{
"apiname": "ACH_B4",
"achieved": 1,
"unlocktime": 1786777600
},
{
"apiname": "ACH_B5",
"achieved": 1,
"unlocktime": 1787036800
},
{
"apiname": "ACH_B6",
"achieved": 0,
"unlocktime": 0
}
]
}
}
@@ -0,0 +1,49 @@
{
"playerstats": {
"steamID": "76561198000000001",
"gameName": "ELDEN RING",
"success": true,
"achievements": [
{
"apiname": "ACH_A1",
"achieved": 1,
"unlocktime": 1786000000
},
{
"apiname": "ACH_A2",
"achieved": 1,
"unlocktime": 1786259200
},
{
"apiname": "ACH_A3",
"achieved": 1,
"unlocktime": 1786518400
},
{
"apiname": "ACH_A4",
"achieved": 0,
"unlocktime": 0
},
{
"apiname": "ACH_A5",
"achieved": 0,
"unlocktime": 0
},
{
"apiname": "ACH_A6",
"achieved": 0,
"unlocktime": 0
},
{
"apiname": "ACH_A7",
"achieved": 0,
"unlocktime": 0
},
{
"apiname": "ACH_A8",
"achieved": 0,
"unlocktime": 0
}
]
}
}
@@ -0,0 +1,39 @@
{
"playerstats": {
"steamID": "76561198000000001",
"gameName": "Stray",
"success": true,
"achievements": [
{
"apiname": "ACH_F1",
"achieved": 1,
"unlocktime": 1786000000
},
{
"apiname": "ACH_F2",
"achieved": 1,
"unlocktime": 1786259200
},
{
"apiname": "ACH_F3",
"achieved": 0,
"unlocktime": 0
},
{
"apiname": "ACH_F4",
"achieved": 0,
"unlocktime": 0
},
{
"apiname": "ACH_F5",
"achieved": 0,
"unlocktime": 0
},
{
"apiname": "ACH_F6",
"achieved": 0,
"unlocktime": 0
}
]
}
}
@@ -0,0 +1,39 @@
{
"playerstats": {
"steamID": "76561198000000001",
"gameName": "Cuphead",
"success": true,
"achievements": [
{
"apiname": "ACH_E1",
"achieved": 1,
"unlocktime": 1786000000
},
{
"apiname": "ACH_E2",
"achieved": 1,
"unlocktime": 1786259200
},
{
"apiname": "ACH_E3",
"achieved": 1,
"unlocktime": 1786518400
},
{
"apiname": "ACH_E4",
"achieved": 1,
"unlocktime": 1786777600
},
{
"apiname": "ACH_E5",
"achieved": 1,
"unlocktime": 1787036800
},
{
"apiname": "ACH_E6",
"achieved": 0,
"unlocktime": 0
}
]
}
}
@@ -0,0 +1,39 @@
{
"playerstats": {
"steamID": "76561198000000001",
"gameName": "Disco Elysium - The Final Cut",
"success": true,
"achievements": [
{
"apiname": "ACH_C1",
"achieved": 1,
"unlocktime": 1786000000
},
{
"apiname": "ACH_C2",
"achieved": 1,
"unlocktime": 1786259200
},
{
"apiname": "ACH_C3",
"achieved": 1,
"unlocktime": 1786518400
},
{
"apiname": "ACH_C4",
"achieved": 1,
"unlocktime": 1786777600
},
{
"apiname": "ACH_C5",
"achieved": 1,
"unlocktime": 1787036800
},
{
"apiname": "ACH_C6",
"achieved": 0,
"unlocktime": 0
}
]
}
}
@@ -0,0 +1,39 @@
{
"playerstats": {
"steamID": "76561198000000001",
"gameName": "Slay the Spire",
"success": true,
"achievements": [
{
"apiname": "ACH_D1",
"achieved": 1,
"unlocktime": 1786000000
},
{
"apiname": "ACH_D2",
"achieved": 1,
"unlocktime": 1786259200
},
{
"apiname": "ACH_D3",
"achieved": 1,
"unlocktime": 1786518400
},
{
"apiname": "ACH_D4",
"achieved": 1,
"unlocktime": 1786777600
},
{
"apiname": "ACH_D5",
"achieved": 1,
"unlocktime": 1787036800
},
{
"apiname": "ACH_D6",
"achieved": 1,
"unlocktime": 1787296000
}
]
}
}
@@ -0,0 +1,58 @@
{
"game": {
"gameName": "Cyberpunk 2077",
"gameVersion": "1",
"availableGameStats": {
"achievements": [
{
"name": "ACH_B1",
"displayName": "Street Kid",
"description": "Get through the opening job.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg"
},
{
"name": "ACH_B2",
"displayName": "Chrome Fan",
"description": "Install five implants.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg"
},
{
"name": "ACH_B3",
"displayName": "Fast Driver",
"description": "Win a street race.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg"
},
{
"name": "ACH_B4",
"displayName": "Netrunner",
"description": "Hack ten enemies in one fight.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg"
},
{
"name": "ACH_B5",
"displayName": "Final Run",
"description": "Complete the main story.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg"
},
{
"name": "ACH_B6",
"displayName": "Rich Merc",
"description": "Hold a big pile of eddies.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg"
}
]
}
}
}
@@ -0,0 +1,74 @@
{
"game": {
"gameName": "ELDEN RING",
"gameVersion": "1",
"availableGameStats": {
"achievements": [
{
"name": "ACH_A1",
"displayName": "Rune Bearer",
"description": "Pick up your first rune from the grace.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg"
},
{
"name": "ACH_A2",
"displayName": "Margit Down",
"description": "Defeat the warden of the gate.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg"
},
{
"name": "ACH_A3",
"displayName": "Roundtable Guest",
"description": "Visit the hold between worlds.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg"
},
{
"name": "ACH_A4",
"displayName": "Smith Friend",
"description": "Upgrade a weapon to a high level.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg"
},
{
"name": "ACH_A5",
"displayName": "Mounted Hunter",
"description": "Slay a foe from horseback.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg"
},
{
"name": "ACH_A6",
"displayName": "Great Rune",
"description": "Restore a great rune.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg"
},
{
"name": "ACH_A7",
"displayName": "Deep Delver",
"description": "Explore a deep underground city.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg"
},
{
"name": "ACH_A8",
"displayName": "Lord of Lands",
"description": "Gather every legendary talisman.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg"
}
]
}
}
}
@@ -0,0 +1,58 @@
{
"game": {
"gameName": "Stray",
"gameVersion": "1",
"availableGameStats": {
"achievements": [
{
"name": "ACH_F1",
"displayName": "Cat Nap",
"description": "Sleep in a sunny spot.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg"
},
{
"name": "ACH_F2",
"displayName": "Rooftop Walker",
"description": "Cross the roofs of the old slum.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg"
},
{
"name": "ACH_F3",
"displayName": "Robot Friend",
"description": "Make a friend of a small robot.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg"
},
{
"name": "ACH_F4",
"displayName": "Meow Choir",
"description": "Meow in front of ten robots.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg"
},
{
"name": "ACH_F5",
"displayName": "Fish Lover",
"description": "Find a cold bowl of fish.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg"
},
{
"name": "ACH_F6",
"displayName": "Secret Paw",
"description": "Open every hidden door.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg"
}
]
}
}
}
@@ -0,0 +1,58 @@
{
"game": {
"gameName": "Cuphead",
"gameVersion": "1",
"availableGameStats": {
"achievements": [
{
"name": "ACH_E1",
"displayName": "Contract Signed",
"description": "Take the deal at the casino.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg"
},
{
"name": "ACH_E2",
"displayName": "Run and Gun",
"description": "Pass a platform stage.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg"
},
{
"name": "ACH_E3",
"displayName": "Boss Rush",
"description": "Defeat five bosses.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg"
},
{
"name": "ACH_E4",
"displayName": "Devil Beaten",
"description": "Complete the main story.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg"
},
{
"name": "ACH_E5",
"displayName": "Coin Collector",
"description": "Find every coin of a world.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg"
},
{
"name": "ACH_E6",
"displayName": "Perfect Parry",
"description": "Parry twelve pink shots in a row.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg"
}
]
}
}
}
@@ -0,0 +1,58 @@
{
"game": {
"gameName": "Disco Elysium - The Final Cut",
"gameVersion": "1",
"availableGameStats": {
"achievements": [
{
"name": "ACH_C1",
"displayName": "Fresh Case",
"description": "Wake up in the hostel room.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg"
},
{
"name": "ACH_C2",
"displayName": "Good Listener",
"description": "Talk to every skill voice.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg"
},
{
"name": "ACH_C3",
"displayName": "Thought Cabinet",
"description": "Fill all slots of the cabinet.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg"
},
{
"name": "ACH_C4",
"displayName": "Case Closed",
"description": "Finish the main story.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg"
},
{
"name": "ACH_C5",
"displayName": "Best Friend",
"description": "Win the trust of your partner.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg"
},
{
"name": "ACH_C6",
"displayName": "Sweet Tunes",
"description": "Hear every song on the radio.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg"
}
]
}
}
}
@@ -0,0 +1,58 @@
{
"game": {
"gameName": "Slay the Spire",
"gameVersion": "1",
"availableGameStats": {
"achievements": [
{
"name": "ACH_D1",
"displayName": "Spire Climber",
"description": "Reach the top of the first act.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg"
},
{
"name": "ACH_D2",
"displayName": "Card Hoarder",
"description": "Own forty cards in one run.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg"
},
{
"name": "ACH_D3",
"displayName": "Relic Fan",
"description": "Hold ten relics in one run.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg"
},
{
"name": "ACH_D4",
"displayName": "Silent Hunter",
"description": "Win a run as the second hero.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg"
},
{
"name": "ACH_D5",
"displayName": "Cold Heart",
"description": "Win a run as the third hero.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg"
},
{
"name": "ACH_D6",
"displayName": "Pure Deck",
"description": "Win a run with a tiny deck.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg"
}
]
}
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v2/",
"queryParameters": {
"gameid": {
"equalTo": "1091500"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-global-percentages-1091500.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v2/",
"queryParameters": {
"gameid": {
"equalTo": "1245620"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-global-percentages-1245620.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v2/",
"queryParameters": {
"gameid": {
"equalTo": "1332010"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-global-percentages-1332010.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v2/",
"queryParameters": {
"gameid": {
"equalTo": "268910"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-global-percentages-268910.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v2/",
"queryParameters": {
"gameid": {
"equalTo": "632470"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-global-percentages-632470.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v2/",
"queryParameters": {
"gameid": {
"equalTo": "646570"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-global-percentages-646570.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetPlayerAchievements/v1/",
"queryParameters": {
"appid": {
"equalTo": "1091500"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-player-achievements-1091500.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetPlayerAchievements/v1/",
"queryParameters": {
"appid": {
"equalTo": "1245620"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-player-achievements-1245620.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetPlayerAchievements/v1/",
"queryParameters": {
"appid": {
"equalTo": "1332010"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-player-achievements-1332010.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetPlayerAchievements/v1/",
"queryParameters": {
"appid": {
"equalTo": "268910"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-player-achievements-268910.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetPlayerAchievements/v1/",
"queryParameters": {
"appid": {
"equalTo": "632470"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-player-achievements-632470.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetPlayerAchievements/v1/",
"queryParameters": {
"appid": {
"equalTo": "646570"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-player-achievements-646570.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetSchemaForGame/v2/",
"queryParameters": {
"appid": {
"equalTo": "1091500"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-schema-1091500.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetSchemaForGame/v2/",
"queryParameters": {
"appid": {
"equalTo": "1245620"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-schema-1245620.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetSchemaForGame/v2/",
"queryParameters": {
"appid": {
"equalTo": "1332010"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-schema-1332010.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetSchemaForGame/v2/",
"queryParameters": {
"appid": {
"equalTo": "268910"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-schema-268910.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetSchemaForGame/v2/",
"queryParameters": {
"appid": {
"equalTo": "632470"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-schema-632470.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetSchemaForGame/v2/",
"queryParameters": {
"appid": {
"equalTo": "646570"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-schema-646570.json"
}
}
+10 -5
View File
@@ -45,7 +45,11 @@ To Be Played is split into three repositories:
achievement run, or the hand changes of a quiet 10 minutes. A note counts new games, achievement unlocks, status
changes and Steam wishlist adds, and leaves out what the followed user's profile hides (`public_user.sections`).
Follows end for good, with a note, when the followed user goes private, and without a note when the user is blocked or
deleted. The nightly job at 03:15 deletes notes older than 90 days (`app.inbox.keep-days`); the list shows the newest 50.
deleted. Each note keeps one copy of the lines behind its counts (`inbox_note_detail`: games, achievement or trophy
names, status moves collapsed per game, wishlist adds; at most 20 achievements per game, the newest unlocks stand for
the new ones). `GET /api/inbox/{id}/details` shows them only while the reader still follows the subject, the subject
is public, else `404`. Rows of a hidden part or a hidden game are left out. The nightly job at 03:15 deletes notes
older than 90 days (`app.inbox.keep-days`); the list shows the newest 50.
- **Backlog order**: every listed `BACKLOG` game (not removed, not hidden) has a row in `backlog_order (user_id,
appid, position)`, kept by a database trigger on `user_game` like the status history: a game entering the backlog
goes to the bottom, a game leaving it loses its row. V34 seeded the first order (priority HIGH, MEDIUM, rest, then
@@ -282,7 +286,8 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
| `GET` | `/api/follows` (`{following: [{id, displayName}], followers: [{id, displayName}]}`, `id` is the follow row; a follower with a private profile has `displayName` null) | login |
| `PUT`, `DELETE` | `/api/follows/by-name/{displayName}` (follow, unfollow; `204`; PUT: `404 PROFILE_NOT_FOUND` for an unknown, private, blocked or deleted name, `400 FOLLOW_SELF`, `400 FOLLOW_LIMIT`) | login |
| `DELETE` | `/api/follows/{id}` (unfollow, or remove a follower; only rows of the caller change; `204`) | login |
| `GET` | `/api/inbox` (`{results: [{id, kind, displayName, newGames, achievements, statusChanges, wishlistAdds, createdAt, read}], unread}`, newest 50, `kind` `DIGEST` or `VISIBILITY`) | login |
| `GET` | `/api/inbox` (`{results: [{id, kind, displayName, newGames, achievements, statusChanges, wishlistAdds, createdAt, read, details}], unread}`, newest 50, `kind` `DIGEST` or `VISIBILITY`) | login |
| `GET` | `/api/inbox/{id}/details` (`{results: [{kind, gameName, itemName, fromStatus, toStatus}]}`, `kind` `NEW_GAME`, `ACHIEVEMENT`, `STATUS` or `WISHLIST`; `404` for a note that is not the caller's or not open any more) | login |
| `PUT` | `/api/inbox/read` (marks all read; `204`) | login |
| `POST`, `PUT` | `/internal/accounts/deleted` (`{userIds}`; POST adds, PUT replaces the whole set; `204`) | service token |
| `POST`, `PUT` | `/internal/accounts/states` (`{accounts: [{userId, steamId, displayName, blocked, roleChangedAt, listed, publicProfile, profileSections}]}`, `displayName`, `roleChangedAt`, `listed` and `publicProfile` optional (`listed`: Auth lists the user by name, kept in `listed_user`, `null` keeps it; `publicProfile`: the profile is public, kept as a row in `public_user` with `profileSections`, `null` keeps row and sections, `false` ends the follows of the user with one `VISIBILITY` note each; `profileSections` `null` = all shown), `displayName` at most 100 characters, kept in `account_name` for the audit log, `roleChangedAt` kept in `account_role_change`; POST updates, PUT replaces the whole set; `204`) | service token |
@@ -338,8 +343,8 @@ flowchart LR
Psn -->|"NPSSO sign-in, played titles, trophy sets and lists"| PsnApi["PlayStation API (unofficial)"]
LibRepo -->|"own tables; picker also follow, public_user, account_name, blocked_user, deleted_user; inbox_note (PSN expiry notes)"| Db[("PostgreSQL tbp_core")]
Stats -->|"listed_user_game, counted_user_game, game_mood, coming_game, user_game_status_change, deleted_user, blocked_user, listed_user, account_name, game_alias"| Db
Follow -->|"follow, follow_digest, inbox_note, public_user, sync_change, user_game_status_change, account_name"| Db
Inbox --> Db
Follow -->|"follow, follow_digest, inbox_note, inbox_note_detail, public_user, sync_change, user_game_status_change, user_game_achievement, user_psn_trophy, wishlist_game, account_name"| Db
Inbox -->|"inbox_note, inbox_note_detail, follow, public_user, user_game"| Db
Auth -->|"service token<br/>/internal/accounts"| Account
Account -->|"per-user rows, deleted_user, blocked_user, listed_user, public_user, follow, account_name, user_seen_at"| Db
```
@@ -350,7 +355,7 @@ flowchart LR
| `stats` | Public numbers for the landing page |
| `version` | `GET /api/version`: commit and build time of the running image |
| `follow` | Follows (`follow`), the digest cursor per followed user (`follow_digest`) and `FollowDigestService`: one note per follower and batch, from `sync_change` and the hand rows of `user_game_status_change` |
| `inbox` | `inbox_note`: the notes of a user, `GET /api/inbox`, the 90-day purge |
| `inbox` | `inbox_note`, `inbox_note_detail`: the notes of a user, `GET /api/inbox`, `GET /api/inbox/{id}/details`, the 90-day purge |
| `account` | Driven by Auth: tombstones in `deleted_user`, purge and orphan prune of the per-user rows; blocks in `blocked_user`, listing in `listed_user` (users Auth shows by name in owner lists, #26), public profiles and their sections in `public_user` (#27), role changes in `account_role_change`, display names in `account_name`, Steam ID changes stop the nightly sync |
| `steam_adapter` | Steam Web API client, availability check of Steam CDN pictures |
| `igdb_adapter` | IGDB client (Twitch OAuth): time to beat, game tags for moods, release years, game search |
@@ -9,8 +9,8 @@ import java.util.List;
*/
public record Digest(int newGames, int achievements, int statusChanges, int wishlistAdds) {
private static final String SUMMARY = "SUMMARY";
private static final List<String> STATUS_SECTIONS = List.of("STATUS", "PLAYING", "FINISHED");
static final String SUMMARY = "SUMMARY";
static final List<String> STATUS_SECTIONS = List.of("STATUS", "PLAYING", "FINISHED");
public boolean isEmpty() {
return newGames == 0 && achievements == 0 && statusChanges == 0 && wishlistAdds == 0;
@@ -0,0 +1,28 @@
package core_service.modules.follow.domain;
/**
* One line behind a digest count. Names are snapshots. The kind is a plain string: the inbox module has its own enum.
*/
public record DigestItem(String kind, long appid, String gameName, String itemName, String fromStatus, String toStatus) {
public static final String NEW_GAME = "NEW_GAME";
public static final String ACHIEVEMENT = "ACHIEVEMENT";
public static final String STATUS = "STATUS";
public static final String WISHLIST = "WISHLIST";
public static DigestItem newGame(long appid, String gameName) {
return new DigestItem(NEW_GAME, appid, gameName, null, null, null);
}
public static DigestItem achievement(long appid, String gameName, String itemName) {
return new DigestItem(ACHIEVEMENT, appid, gameName, itemName, null, null);
}
public static DigestItem status(long appid, String gameName, String from, String to) {
return new DigestItem(STATUS, appid, gameName, null, from, to);
}
public static DigestItem wishlist(long appid, String gameName) {
return new DigestItem(WISHLIST, appid, gameName, null, null, null);
}
}
@@ -0,0 +1,66 @@
package core_service.modules.follow.domain;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* The lines behind a {@link Digest}, in the order they are shown.
*/
public record DigestItems(List<DigestItem> items) {
public DigestItems plus(DigestItems other) {
List<DigestItem> all = new ArrayList<>(items);
all.addAll(other.items);
return new DigestItems(all);
}
/**
* Same rule as {@link Digest#visibleOn}.
*
* @param sections the profile parts the user shows, null = all
*/
public DigestItems visibleOn(Collection<String> sections) {
if (sections == null) {
return this;
}
boolean summary = sections.contains(Digest.SUMMARY);
boolean status = Digest.STATUS_SECTIONS.stream().anyMatch(sections::contains);
return new DigestItems(items.stream().filter(item -> switch (item.kind()) {
case DigestItem.NEW_GAME, DigestItem.ACHIEVEMENT -> summary;
case DigestItem.STATUS -> status;
default -> true;
}).toList());
}
/**
* One STATUS line per game: the first old status to the last new one, dropped when they are equal. The line keeps
* the place of the first move of its game.
*/
public DigestItems collapseStatus() {
Map<Long, String> lastTo = new HashMap<>();
for (DigestItem item : items) {
if (DigestItem.STATUS.equals(item.kind())) {
lastTo.put(item.appid(), item.toStatus());
}
}
List<DigestItem> result = new ArrayList<>();
Set<Long> seen = new HashSet<>();
for (DigestItem item : items) {
if (!DigestItem.STATUS.equals(item.kind())) {
result.add(item);
} else if (seen.add(item.appid())) {
String to = lastTo.get(item.appid());
if (!Objects.equals(item.fromStatus(), to)) {
result.add(DigestItem.status(item.appid(), item.gameName(), item.fromStatus(), to));
}
}
}
return new DigestItems(result);
}
}
@@ -2,15 +2,19 @@ package core_service.modules.follow.repo;
import core_service.modules.follow.domain.Cursor;
import core_service.modules.follow.domain.Digest;
import core_service.modules.follow.domain.DigestItem;
import core_service.modules.follow.domain.DigestItems;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.jdbc.core.namedparam.SqlParameterSource;
import org.springframework.stereotype.Repository;
import java.sql.Array;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.Arrays;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@@ -22,6 +26,8 @@ import java.util.Optional;
@RequiredArgsConstructor
public class FollowDigestRepository {
static final int MAX_ACHIEVEMENTS_PER_GAME = 20;
private final NamedParameterJdbcTemplate jdbc;
/**
@@ -83,6 +89,17 @@ public class FollowDigestRepository {
return max != null ? max : 0;
}
/**
* The newest hand change after {@code afterId}, or {@code afterId} when there is none. A hand-only digest stops
* there, so the status rows of a sync stay for the digest of that sync.
*/
public long maxHandStatusChangeId(long userId, long afterId) {
Long max = jdbc.queryForObject("select max(id) from user_game_status_change "
+ "where user_id = :userId and by_user and id > :afterId",
user(userId).addValue("afterId", afterId), Long.class);
return max != null ? max : afterId;
}
/**
* The changes the user made by hand: no old status = a game added by hand, else a status change.
*/
@@ -98,6 +115,90 @@ public class FollowDigestRepository {
(rs, i) -> new Digest(rs.getInt("new_games"), 0, rs.getInt("status_changes"), 0));
}
/**
* The lines behind {@link #countSync}, same joins and filters. Achievement names are not stored by the sync, only
* how many: the newest unlocks of the game (Steam and PlayStation together) stand for the new ones, at most
* {@link #MAX_ACHIEVEMENTS_PER_GAME} per game.
*/
public DigestItems findSyncItems(long userId) {
List<DigestItem> items = new ArrayList<>(findSyncNewGames(userId));
items.addAll(findSyncAchievements(userId));
return new DigestItems(items);
}
private List<DigestItem> findSyncNewGames(long userId) {
return jdbc.query("""
select distinct c.appid, sg.name from sync_change c
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid
left join steam_game sg on sg.appid = c.appid
where c.user_id = :userId and c.kind = 'ADDED' and ug.hidden_at is null and ug.removed_at is null
order by sg.name, c.appid""", user(userId), (rs, i) -> DigestItem.newGame(rs.getLong("appid"), rs.getString("name")));
}
private List<DigestItem> findSyncAchievements(long userId) {
return jdbc.query("""
with gained as (
select c.appid, sum(c.amount) as amount from sync_change c
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid
where c.user_id = :userId and c.kind = 'ACHIEVEMENTS' and ug.hidden_at is null and ug.removed_at is null
group by c.appid),
unlocks as (
select a.appid, coalesce(s.display_name, a.api_name) as name, a.unlocked_at as at
from user_game_achievement a
left join game_achievement_schema s on s.appid = a.appid and s.api_name = a.api_name
where a.user_id = :userId and a.unlocked
union all
select st.appid, coalesce(t.name, 'Trophy ' || t.trophy_id), ut.earned_at
from user_psn_trophy ut
join user_psn_trophy_set st on st.user_id = ut.user_id and st.np_communication_id = ut.np_communication_id
join psn_trophy t on t.np_communication_id = ut.np_communication_id and t.trophy_id = ut.trophy_id
where ut.user_id = :userId),
ranked as (
select u.appid, u.name, row_number() over (partition by u.appid order by u.at desc nulls last, u.name) as rn
from unlocks u join gained g on g.appid = u.appid)
select r.appid, sg.name as game_name, r.name from ranked r
join gained g on g.appid = r.appid
left join steam_game sg on sg.appid = r.appid
where r.rn <= least(g.amount, :cap)
order by sg.name, r.appid, r.rn""", user(userId).addValue("cap", MAX_ACHIEVEMENTS_PER_GAME),
(rs, i) -> DigestItem.achievement(rs.getLong("appid"), rs.getString("game_name"), rs.getString("name")));
}
/**
* The lines behind {@link #countHand}, same filters. With {@code withSyncMoves} the status moves of the sync are
* read from the same log, so a game's moves by hand and by sync stay in time order for the collapse.
*/
public DigestItems findHandItems(long userId, long afterId, long upToId, boolean withSyncMoves) {
return new DigestItems(jdbc.query("""
select c.appid, sg.name, c.from_status, c.to_status
from user_game_status_change c
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid
left join steam_game sg on sg.appid = c.appid
where c.user_id = :userId and c.id > :afterId and c.id <= :upToId
and (c.by_user or (:withSyncMoves and c.from_status is not null))
and ug.hidden_at is null and ug.removed_at is null
order by c.id""",
user(userId).addValue("afterId", afterId).addValue("upToId", upToId).addValue("withSyncMoves", withSyncMoves),
(rs, i) -> rs.getString("from_status") == null
? DigestItem.newGame(rs.getLong("appid"), rs.getString("name"))
: DigestItem.status(rs.getLong("appid"), rs.getString("name"), rs.getString("from_status"),
rs.getString("to_status"))));
}
/**
* The lines behind {@link #countWishlist}, same id range.
*/
public DigestItems findWishlistItems(long userId, long afterId, long upToId) {
return new DigestItems(jdbc.query("""
select c.appid, coalesce(w.name, sg.name, 'App ' || c.appid) as name from sync_change c
left join wishlist_game w on w.user_id = c.user_id and w.appid = c.appid
left join steam_game sg on sg.appid = c.appid
where c.user_id = :userId and c.kind = 'WISHLIST' and c.id > :afterId and c.id <= :upToId
order by c.id""",
user(userId).addValue("afterId", afterId).addValue("upToId", upToId),
(rs, i) -> DigestItem.wishlist(rs.getLong("appid"), rs.getString("name"))));
}
/**
* @return null when there is no row or the user did not send sections: nothing is filtered
*/
@@ -115,10 +216,10 @@ public class FollowDigestRepository {
* One note per follower, only while the followed user is public, not blocked and not deleted and the follower is
* not deleted. A name is stored as it is now.
*
* @return how many notes were written
* @return the ids of the notes written
*/
public int insertDigestNotes(long userId, Digest digest) {
return jdbc.update("""
public List<Long> insertDigestNotes(long userId, Digest digest) {
return jdbc.query("""
insert into inbox_note (user_id, kind, subject_user_id, subject_name, new_games, achievements,
status_changes, wishlist_adds)
select f.user_id, 'DIGEST', f.followed_user_id,
@@ -128,9 +229,32 @@ public class FollowDigestRepository {
where f.followed_user_id = :userId
and exists (select 1 from public_user p where p.user_id = f.followed_user_id)
and not exists (select 1 from blocked_user b where b.user_id = f.followed_user_id)
and not exists (select 1 from deleted_user d where d.user_id in (f.followed_user_id, f.user_id))""",
and not exists (select 1 from deleted_user d where d.user_id in (f.followed_user_id, f.user_id))
returning id""",
user(userId).addValue("newGames", digest.newGames()).addValue("achievements", digest.achievements())
.addValue("statusChanges", digest.statusChanges()).addValue("wishlistAdds", digest.wishlistAdds()));
.addValue("statusChanges", digest.statusChanges()).addValue("wishlistAdds", digest.wishlistAdds()),
(rs, i) -> rs.getLong("id"));
}
/**
* One copy of the lines per note: the note owns them and they go with it.
*/
public void insertDigestItems(List<Long> noteIds, DigestItems items) {
if (noteIds.isEmpty() || items.items().isEmpty()) {
return;
}
List<SqlParameterSource> rows = new ArrayList<>();
for (long noteId : noteIds) {
for (DigestItem item : items.items()) {
rows.add(new MapSqlParameterSource("noteId", noteId).addValue("kind", item.kind())
.addValue("appid", item.appid()).addValue("gameName", item.gameName())
.addValue("itemName", item.itemName()).addValue("fromStatus", item.fromStatus())
.addValue("toStatus", item.toStatus()));
}
}
jdbc.batchUpdate("insert into inbox_note_detail (note_id, kind, appid, game_name, item_name, from_status, to_status) "
+ "values (:noteId, :kind, :appid, :gameName, :itemName, :fromStatus, :toStatus)",
rows.toArray(new SqlParameterSource[0]));
}
public void moveCursor(long userId, Instant syncStartedAt, long statusChangeId, long wishlistChangeId) {
@@ -2,6 +2,7 @@ package core_service.modules.follow.service;
import core_service.modules.follow.domain.Cursor;
import core_service.modules.follow.domain.Digest;
import core_service.modules.follow.domain.DigestItems;
import core_service.modules.follow.repo.FollowDigestRepository;
import core_service.shared.event.LibraryActivityEvent;
import lombok.extern.slf4j.Slf4j;
@@ -19,7 +20,8 @@ import java.util.Optional;
/**
* One inbox note per follower and batch. A batch is a sync with its achievement run, plus the hand changes since the
* last batch. The cursor moves in every case, so what the profile hides is never reported later.
* last batch. The cursor moves in every case, so what the profile hides is never reported later. Each note also gets
* the lines behind its counts (status moves collapsed per game).
*/
@Service
@Slf4j
@@ -67,23 +69,31 @@ public class FollowDigestService {
return;
}
Cursor cursor = locked.get();
long upTo = digestRepository.maxStatusChangeId(userId);
long upTo = withSync ? digestRepository.maxStatusChangeId(userId)
: digestRepository.maxHandStatusChangeId(userId, cursor.statusChangeId());
Digest digest = digestRepository.countHand(userId, cursor.statusChangeId(), upTo);
Instant syncStartedAt = cursor.syncStartedAt();
long wishlistUpTo = cursor.wishlistChangeId();
Optional<Instant> current = withSync ? digestRepository.findSyncStartedAt(userId) : Optional.empty();
boolean newSync = current.isPresent() && (syncStartedAt == null || current.get().isAfter(syncStartedAt));
DigestItems items = digestRepository.findHandItems(userId, cursor.statusChangeId(), upTo, newSync);
if (withSync) {
Optional<Instant> current = digestRepository.findSyncStartedAt(userId);
if (current.isPresent() && (syncStartedAt == null || current.get().isAfter(syncStartedAt))) {
if (newSync) {
digest = digest.plus(digestRepository.countSync(userId));
items = items.plus(digestRepository.findSyncItems(userId));
syncStartedAt = current.get();
}
wishlistUpTo = digestRepository.maxWishlistChangeId(userId);
digest = digest.plus(new Digest(0, 0, 0,
digestRepository.countWishlist(userId, cursor.wishlistChangeId(), wishlistUpTo)));
items = items.plus(digestRepository.findWishlistItems(userId, cursor.wishlistChangeId(), wishlistUpTo));
}
digest = digest.visibleOn(digestRepository.findPublicSections(userId));
List<String> sections = digestRepository.findPublicSections(userId);
digest = digest.visibleOn(sections);
items = items.visibleOn(sections).collapseStatus();
if (!digest.isEmpty()) {
digestRepository.insertDigestNotes(userId, digest);
List<Long> noteIds = digestRepository.insertDigestNotes(userId, digest);
digestRepository.insertDigestItems(noteIds, items);
}
digestRepository.moveCursor(userId, syncStartedAt, upTo, wishlistUpTo);
});
@@ -1,13 +1,17 @@
package core_service.modules.inbox.api;
import core_service.modules.inbox.api.dto.InboxDto;
import core_service.modules.inbox.api.dto.NoteDetailsDto;
import core_service.modules.inbox.domain.Inbox;
import core_service.modules.inbox.service.InboxService;
import core_service.shared.api.ErrorResponse;
import core_service.shared.security.UserPrincipal;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -24,10 +28,21 @@ public class InboxController {
Inbox inbox = inboxService.inbox(principal.getIdAsLong());
return ResponseEntity.ok(new InboxDto(inbox.notes().stream()
.map(note -> new InboxDto.Note(note.id(), note.kind().name(), note.subjectName(), note.newGames(),
note.achievements(), note.statusChanges(), note.wishlistAdds(), note.createdAt(), note.read()))
note.achievements(), note.statusChanges(), note.wishlistAdds(), note.createdAt(), note.read(), note.details()))
.toList(), inbox.unread()));
}
@GetMapping("/{id}/details")
public ResponseEntity<?> details(@AuthenticationPrincipal UserPrincipal principal, @PathVariable("id") long id) {
return inboxService.details(principal.getIdAsLong(), id)
.<ResponseEntity<?>>map(details -> ResponseEntity.ok(new NoteDetailsDto(details.stream()
.map(detail -> new NoteDetailsDto.Detail(detail.kind().name(), detail.gameName(),
detail.itemName(), detail.fromStatus(), detail.toStatus()))
.toList())))
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("NOTE_NOT_FOUND", "No such note")));
}
@PutMapping("/read")
public ResponseEntity<Void> markAllRead(@AuthenticationPrincipal UserPrincipal principal) {
inboxService.markAllRead(principal.getIdAsLong());
@@ -11,8 +11,9 @@ public record InboxDto(List<Note> results, int unread) {
/**
* @param kind DIGEST, VISIBILITY, PSN_EXPIRING or PSN_EXPIRED (the PSN kinds have no displayName)
* @param displayName the name of the followed user when the note was written; never a user id
* @param details the note can be opened: it has detail rows and the subject is still followed and public
*/
public record Note(long id, String kind, String displayName, int newGames, int achievements, int statusChanges,
int wishlistAdds, Instant createdAt, boolean read) {
int wishlistAdds, Instant createdAt, boolean read, boolean details) {
}
}
@@ -0,0 +1,15 @@
package core_service.modules.inbox.api.dto;
import java.util.List;
public record NoteDetailsDto(List<Detail> results) {
/**
* @param kind NEW_GAME, ACHIEVEMENT, STATUS or WISHLIST
* @param itemName ACHIEVEMENT only: the achievement or trophy name
* @param fromStatus STATUS only
* @param toStatus STATUS only
*/
public record Detail(String kind, String gameName, String itemName, String fromStatus, String toStatus) {
}
}
@@ -4,7 +4,8 @@ import java.time.Instant;
/**
* @param subjectName the name of the followed user when the note was written
* @param details the note can be opened: it has detail rows and the subject is still followed and public
*/
public record InboxNote(long id, NoteKind kind, String subjectName, int newGames, int achievements,
int statusChanges, int wishlistAdds, Instant createdAt, boolean read) {
int statusChanges, int wishlistAdds, Instant createdAt, boolean read, boolean details) {
}
@@ -0,0 +1,7 @@
package core_service.modules.inbox.domain;
/**
* One line behind a digest note. Names are snapshots from when the note was written.
*/
public record NoteDetail(NoteDetailKind kind, String gameName, String itemName, String fromStatus, String toStatus) {
}
@@ -0,0 +1,29 @@
package core_service.modules.inbox.domain;
import java.util.Collection;
import java.util.List;
public enum NoteDetailKind {
NEW_GAME,
ACHIEVEMENT,
STATUS,
WISHLIST;
private static final List<String> STATUS_SECTIONS = List.of("STATUS", "PLAYING", "FINISHED");
/**
* Mirror of follow.domain.Digest.visibleOn, modules may not import each other.
*
* @param sections the profile parts the user shows, null = all
*/
public boolean visibleOn(Collection<String> sections) {
if (sections == null) {
return true;
}
return switch (this) {
case NEW_GAME, ACHIEVEMENT -> sections.contains("SUMMARY");
case STATUS -> STATUS_SECTIONS.stream().anyMatch(sections::contains);
case WISHLIST -> true;
};
}
}
@@ -1,15 +1,20 @@
package core_service.modules.inbox.repo;
import core_service.modules.inbox.domain.InboxNote;
import core_service.modules.inbox.domain.NoteDetail;
import core_service.modules.inbox.domain.NoteDetailKind;
import core_service.modules.inbox.domain.NoteKind;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository;
import java.sql.Array;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
@Repository
@RequiredArgsConstructor
@@ -19,14 +24,63 @@ public class InboxRepository {
public List<InboxNote> findNewest(long userId, int limit) {
return jdbc.query("""
select id, kind, subject_name, new_games, achievements, status_changes, wishlist_adds, created_at, read_at
from inbox_note where user_id = :userId
order by created_at desc, id desc
select n.id, n.kind, n.subject_name, n.new_games, n.achievements, n.status_changes, n.wishlist_adds,
n.created_at, n.read_at,
exists (select 1 from inbox_note_detail d where d.note_id = n.id)
and exists (select 1 from follow f where f.user_id = n.user_id and f.followed_user_id = n.subject_user_id)
and exists (select 1 from public_user p where p.user_id = n.subject_user_id) as details
from inbox_note n where n.user_id = :userId
order by n.created_at desc, n.id desc
limit :limit""", new MapSqlParameterSource("userId", userId).addValue("limit", limit),
(rs, i) -> new InboxNote(rs.getLong("id"), NoteKind.valueOf(rs.getString("kind")),
rs.getString("subject_name"), rs.getInt("new_games"), rs.getInt("achievements"),
rs.getInt("status_changes"), rs.getInt("wishlist_adds"),
rs.getTimestamp("created_at").toInstant(), rs.getTimestamp("read_at") != null));
rs.getTimestamp("created_at").toInstant(), rs.getTimestamp("read_at") != null,
rs.getBoolean("details")));
}
/**
* Ownership and the follow re-check in one statement.
*
* @return the followed user of a DIGEST note of this user, empty when the note is not theirs, they no longer
* follow the subject or the subject is no longer public
*/
public Optional<Long> findFollowedSubject(long userId, long noteId) {
return jdbc.query("""
select n.subject_user_id from inbox_note n
where n.id = :noteId and n.user_id = :userId and n.kind = 'DIGEST'
and exists (select 1 from follow f where f.user_id = n.user_id and f.followed_user_id = n.subject_user_id)
and exists (select 1 from public_user p where p.user_id = n.subject_user_id)""",
new MapSqlParameterSource("userId", userId).addValue("noteId", noteId),
(rs, i) -> rs.getLong("subject_user_id")).stream().findFirst();
}
/**
* @return null when there is no row or the user did not send sections: nothing is filtered
*/
public List<String> findPublicSections(long subjectUserId) {
return jdbc.query("select sections from public_user where user_id = :userId",
new MapSqlParameterSource("userId", subjectUserId), rs -> {
if (!rs.next()) {
return null;
}
Array sections = rs.getArray("sections");
return sections == null ? null : Arrays.asList((String[]) sections.getArray());
});
}
/**
* Without the games the subject hides now.
*/
public List<NoteDetail> findDetails(long noteId, long subjectUserId) {
return jdbc.query("""
select d.kind, d.game_name, d.item_name, d.from_status, d.to_status from inbox_note_detail d
where d.note_id = :noteId
and not exists (select 1 from user_game ug
where ug.user_id = :subjectUserId and ug.appid = d.appid and ug.hidden_at is not null)
order by d.id""", new MapSqlParameterSource("noteId", noteId).addValue("subjectUserId", subjectUserId),
(rs, i) -> new NoteDetail(NoteDetailKind.valueOf(rs.getString("kind")), rs.getString("game_name"),
rs.getString("item_name"), rs.getString("from_status"), rs.getString("to_status")));
}
public int countUnread(long userId) {
@@ -1,6 +1,7 @@
package core_service.modules.inbox.service;
import core_service.modules.inbox.domain.Inbox;
import core_service.modules.inbox.domain.NoteDetail;
import core_service.modules.inbox.repo.InboxRepository;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
@@ -13,6 +14,8 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
@Service
@Slf4j
@@ -32,6 +35,22 @@ public class InboxService {
return new Inbox(inboxRepository.findNewest(userId, LIST_SIZE), inboxRepository.countUnread(userId));
}
/**
* Checked again on every read: the caller must own the note, still follow the subject, the subject must be
* public and show the part.
*
* @return empty when the note cannot be opened
*/
@RequiresRole(Role.USER)
@Transactional(readOnly = true)
public Optional<List<NoteDetail>> details(long userId, long noteId) {
return inboxRepository.findFollowedSubject(userId, noteId).map(subject -> {
List<String> sections = inboxRepository.findPublicSections(subject);
return inboxRepository.findDetails(noteId, subject).stream()
.filter(detail -> detail.kind().visibleOn(sections)).toList();
});
}
@RequiresRole(Role.USER)
@Transactional
public void markAllRead(long userId) {
@@ -0,0 +1,12 @@
-- What a DIGEST note counted (#27 follow-up). Names are snapshots; the row goes with its note.
CREATE TABLE inbox_note_detail (
id BIGSERIAL PRIMARY KEY,
note_id BIGINT NOT NULL REFERENCES inbox_note (id) ON DELETE CASCADE,
kind VARCHAR(16) NOT NULL CHECK (kind IN ('NEW_GAME', 'ACHIEVEMENT', 'STATUS', 'WISHLIST')),
appid BIGINT NOT NULL,
game_name VARCHAR(512), -- steam_game.name / wishlist_game.name at write time
item_name VARCHAR(512), -- ACHIEVEMENT: achievement or trophy name
from_status VARCHAR(16), -- STATUS only
to_status VARCHAR(16)
);
CREATE INDEX idx_inbox_note_detail_note ON inbox_note_detail (note_id);
@@ -68,14 +68,19 @@ class LocalMocksParseTest extends AbstractUnitTest {
@Test
void everyMapping_PointsToAnExistingAnswer() throws IOException {
List<Path> mappings;
try (Stream<Path> listed = Files.list(MAPPINGS)) {
mappings = listed.sorted().toList();
}
List<Path> mappings = sortedFiles(MAPPINGS);
assertThat(mappings).isNotEmpty();
for (Path mapping : mappings) {
JsonNode response = jsonMapper.readTree(mapping.toFile()).path("response");
JsonNode root = jsonMapper.readTree(mapping.toFile());
JsonNode response = root.path("response");
// A copied achievement mapping must ask for the appid in its own name.
String name = mapping.getFileName().toString();
if (name.matches("steam-(schema|player-achievements|global-percentages)-\\d+\\.json")) {
JsonNode query = root.path("request").path("queryParameters");
String asked = query.path(query.has("appid") ? "appid" : "gameid").path("equalTo").asString();
assertThat(name).as(mapping.toString()).isEqualTo(name.replaceAll("\\d+", asked));
}
// The PlayStation authorize answer is a redirect with no body.
assertThat(response.has("bodyFileName") || response.has("jsonBody") || response.path("headers").has("Location"))
.as(mapping.toString()).isTrue();
@@ -105,10 +110,7 @@ class LocalMocksParseTest extends AbstractUnitTest {
@Test
void everyRecordedAnswer_ParsesIntoItsResponseType() throws IOException, ReflectiveOperationException {
List<Path> files;
try (Stream<Path> listed = Files.list(FILES)) {
files = listed.sorted().toList();
}
List<Path> files = sortedFiles(FILES);
assertThat(files).isNotEmpty();
for (Path file : files) {
@@ -121,4 +123,30 @@ class LocalMocksParseTest extends AbstractUnitTest {
assertFilled(name, jsonMapper.readValue(file.toFile(), type));
}
}
// A copied stub with a typo in its appid is otherwise only a silent 400 in the sync log.
@Test
void everyAchievementStub_BelongsToAnOwnedGame() throws IOException {
List<String> owned = jsonMapper.readValue(FILES.resolve("steam-owned-games.json").toFile(), SteamOwnedGamesResponse.class)
.response().games().stream()
.map(game -> game.appid().toString())
.toList();
List<Path> schemas = sortedFiles(FILES).stream()
.filter(file -> file.getFileName().toString().matches("steam-schema-\\d+\\.json"))
.toList();
assertThat(schemas).isNotEmpty();
for (Path schema : schemas) {
String appid = schema.getFileName().toString().replaceAll("\\D", "");
assertThat(owned).as(schema.toString()).contains(appid);
assertThat(FILES.resolve("steam-player-achievements-" + appid + ".json")).exists();
assertThat(FILES.resolve("steam-global-percentages-" + appid + ".json")).exists();
}
}
private static List<Path> sortedFiles(Path dir) throws IOException {
try (Stream<Path> listed = Files.list(dir)) {
return listed.sorted().toList();
}
}
}
@@ -0,0 +1,65 @@
package core_service.modules.follow.domain;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class DigestItemsTest extends AbstractUnitTest {
private static DigestItems of(DigestItem... items) {
return new DigestItems(List.of(items));
}
private static final DigestItems ALL = of(DigestItem.newGame(1, "A"), DigestItem.achievement(1, "A", "Win"),
DigestItem.status(2, "B", "BACKLOG", "PLAYING"), DigestItem.wishlist(3, "C"));
@Test
void collapseStatus_FirstFromToLastTo() {
DigestItems items = of(DigestItem.status(1, "A", "BACKLOG", "PLAYING"), DigestItem.status(1, "A", "PLAYING", "FINISHED"));
assertThat(items.collapseStatus().items()).containsExactly(DigestItem.status(1, "A", "BACKLOG", "FINISHED"));
}
@Test
void collapseStatus_DropsAGameThatEndsWhereItStarted() {
DigestItems items = of(DigestItem.status(1, "A", "BACKLOG", "PLAYING"), DigestItem.status(1, "A", "PLAYING", "BACKLOG"));
assertThat(items.collapseStatus().items()).isEmpty();
}
@Test
void collapseStatus_KeepsGamesApartAndOtherKindsAndOrder() {
DigestItems items = of(DigestItem.newGame(9, "N"), DigestItem.status(1, "A", "BACKLOG", "PLAYING"),
DigestItem.status(2, "B", "BACKLOG", "PLAYING"), DigestItem.status(1, "A", "PLAYING", "FINISHED"));
assertThat(items.collapseStatus().items()).containsExactly(DigestItem.newGame(9, "N"),
DigestItem.status(1, "A", "BACKLOG", "FINISHED"), DigestItem.status(2, "B", "BACKLOG", "PLAYING"));
}
@Test
void visibleOn_NullKeepsAll() {
assertThat(ALL.visibleOn(null)).isEqualTo(ALL);
}
@Test
void visibleOn_StatusSectionDropsNewGamesAndAchievementsAndKeepsWishlist() {
assertThat(ALL.visibleOn(Set.of("STATUS")).items()).extracting(DigestItem::kind)
.containsExactly(DigestItem.STATUS, DigestItem.WISHLIST);
}
@Test
void visibleOn_SummaryKeepsNewGamesAndAchievementsButNotStatus() {
assertThat(ALL.visibleOn(Set.of("SUMMARY")).items()).extracting(DigestItem::kind)
.containsExactly(DigestItem.NEW_GAME, DigestItem.ACHIEVEMENT, DigestItem.WISHLIST);
}
@Test
void plus_KeepsReceiverFirst() {
assertThat(of(DigestItem.wishlist(1, "A")).plus(of(DigestItem.wishlist(2, "B"))).items())
.extracting(DigestItem::appid).containsExactly(1L, 2L);
}
}
@@ -1,6 +1,8 @@
package core_service.modules.follow.repo;
import core_service.modules.follow.domain.Digest;
import core_service.modules.follow.domain.DigestItem;
import core_service.modules.follow.domain.DigestItems;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -8,6 +10,8 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@Import(FollowDigestRepository.class)
@@ -27,7 +31,7 @@ class FollowDigestRepositoryTest extends AbstractPersistenceTest {
void seed() {
jdbc.update("insert into sync_result (user_id, sync_started_at, started_by, imported_games) values (?, now(), 'AUTO', 0)", USER);
for (long appid : new long[]{LISTED, REMOVED}) {
jdbc.update("insert into steam_game (appid, name) values (?, 'Game')", appid);
jdbc.update("insert into steam_game (appid, name) values (?, ?)", appid, "Game " + appid);
jdbc.update("insert into user_game (user_id, appid, status, source, platform, status_set_by_user) "
+ "values (?, ?, 'BACKLOG', 'MANUAL', 'PC', true)", USER, appid);
jdbc.update("insert into sync_change (user_id, kind, appid, amount) values (?, 'ADDED', ?, null)", USER, appid);
@@ -49,4 +53,81 @@ class FollowDigestRepositoryTest extends AbstractPersistenceTest {
assertThat(digest.newGames()).isEqualTo(1);
}
private void unlock(long appid, String apiName, String displayName, int daysAgo) {
jdbc.update("insert into game_achievement_schema (appid, api_name, display_name) values (?, ?, ?)", appid, apiName, displayName);
jdbc.update("insert into user_game_achievement (user_id, appid, api_name, unlocked, unlocked_at) "
+ "values (?, ?, ?, true, now() - make_interval(days => ?))", USER, appid, apiName, daysAgo);
}
@Test
void findSyncItems_SkipsRemovedGamesAndNamesTheGame() {
DigestItems items = repository.findSyncItems(USER);
assertThat(items.items()).contains(DigestItem.newGame(LISTED, "Game 10"));
assertThat(items.items()).noneMatch(item -> item.appid() == REMOVED);
}
@Test
void findSyncItems_TakesTheAmountNewestUnlocks() {
unlock(LISTED, "a", "Oldest", 40);
unlock(LISTED, "b", "Third", 30);
unlock(LISTED, "c", "Second", 20);
unlock(LISTED, "d", "Newest", 10);
unlock(REMOVED, "e", "Removed one", 1);
List<DigestItem> achievements = repository.findSyncItems(USER).items().stream()
.filter(item -> DigestItem.ACHIEVEMENT.equals(item.kind())).toList();
assertThat(achievements).containsExactly(DigestItem.achievement(LISTED, "Game 10", "Newest"),
DigestItem.achievement(LISTED, "Game 10", "Second"), DigestItem.achievement(LISTED, "Game 10", "Third"));
}
@Test
void findSyncItems_PsnTrophiesCompeteWithSteamUnlocksOfTheSameGame() {
unlock(LISTED, "a", "Old steam", 40);
jdbc.update("insert into psn_trophy_set (np_communication_id, service_name, platform, name, defined_bronze, "
+ "defined_silver, defined_gold, defined_platinum) values ('NPWR1', 'trophy', 'PS4', 'T', 1, 0, 0, 0)");
jdbc.update("insert into psn_trophy (np_communication_id, trophy_id, type, name) values ('NPWR1', 1, 'BRONZE', 'Trophy one')");
jdbc.update("insert into psn_trophy (np_communication_id, trophy_id, type, name) values ('NPWR1', 2, 'BRONZE', null)");
jdbc.update("insert into user_psn_trophy_set (user_id, np_communication_id, appid, earned_total, platinum_earned) "
+ "values (?, 'NPWR1', ?, 2, false)", USER, LISTED);
jdbc.update("insert into user_psn_trophy (user_id, np_communication_id, trophy_id, earned_at) values (?, 'NPWR1', 1, now() - interval '1 day')", USER);
jdbc.update("insert into user_psn_trophy (user_id, np_communication_id, trophy_id, earned_at) values (?, 'NPWR1', 2, now() - interval '2 days')", USER);
List<String> names = repository.findSyncItems(USER).items().stream()
.filter(item -> DigestItem.ACHIEVEMENT.equals(item.kind())).map(DigestItem::itemName).toList();
assertThat(names).containsExactly("Trophy one", "Trophy 2", "Old steam");
}
@Test
void findWishlistItems_UsesTheWishlistName() {
jdbc.update("insert into wishlist_game (user_id, appid, name) values (?, 777, 'Wished')", USER);
jdbc.update("insert into sync_change (user_id, kind, appid) values (?, 'WISHLIST', 777)", USER);
assertThat(repository.findWishlistItems(USER, 0, Long.MAX_VALUE).items())
.containsExactly(DigestItem.wishlist(777, "Wished"));
}
@Test
void findHandItems_NoOldStatusIsANewGame() {
assertThat(repository.findHandItems(USER, 0, Long.MAX_VALUE, false).items())
.containsExactly(DigestItem.newGame(LISTED, "Game 10"));
}
@Test
void insertDigestNotesAndItems_ReturnIdsAndWriteRowsPerNote() {
jdbc.update("insert into public_user (user_id) values (?)", USER);
jdbc.update("insert into follow (user_id, followed_user_id) values (2, ?), (3, ?)", USER, USER);
List<Long> ids = repository.insertDigestNotes(USER, new Digest(1, 0, 0, 0));
repository.insertDigestItems(ids, new DigestItems(List.of(DigestItem.newGame(LISTED, "Game 10"),
DigestItem.wishlist(5, "W"))));
assertThat(ids).hasSize(2);
assertThat(jdbc.queryForObject("select count(*) from inbox_note_detail", Integer.class)).isEqualTo(4);
assertThat(jdbc.queryForObject("select count(*) from inbox_note_detail where note_id = ?", Integer.class, ids.get(0)))
.isEqualTo(2);
}
}
@@ -79,6 +79,13 @@ class FollowDigestServiceTest extends AbstractIntegrationTest {
jdbc.update("insert into sync_change (user_id, kind, appid, amount) values (?, ?, ?, ?)", FOLLOWED, kind, appid, amount);
}
private void moved(long appid) {
jdbc.update("insert into sync_change (user_id, kind, appid, from_status, to_status) values (?, 'STATUS', ?, 'BACKLOG', 'PLAYING')",
FOLLOWED, appid);
jdbc.update("insert into user_game_status_change (user_id, appid, from_status, to_status, by_user) values (?, ?, 'BACKLOG', 'PLAYING', false)",
FOLLOWED, appid);
}
private void added(int count) {
games(count);
sync();
@@ -131,6 +138,87 @@ class FollowDigestServiceTest extends AbstractIntegrationTest {
assertThat(jdbc.queryForObject("select subject_name from inbox_note limit 1", String.class)).isEqualTo("Followed");
}
private List<Map<String, Object>> details(long reader) {
return jdbc.queryForList("select d.kind, d.game_name, d.item_name, d.from_status, d.to_status from inbox_note_detail d "
+ "join inbox_note n on n.id = d.note_id where n.user_id = ? order by d.id", reader);
}
@Test
void syncBatch_WritesTheLinesBehindTheCountsForEachFollower() {
added(2);
moved(FIRST_APPID + 1);
change("WISHLIST", 777_001L, null);
event();
for (long reader : List.of(FOLLOWER_ONE, FOLLOWER_TWO)) {
assertThat(details(reader)).extracting(row -> row.get("kind"))
.containsExactlyInAnyOrder("NEW_GAME", "NEW_GAME", "STATUS", "WISHLIST");
assertThat(details(reader)).anySatisfy(row -> assertThat(row).containsEntry("game_name", "Game 1"));
}
}
@Test
void handChangesThereAndBack_WriteNoStatusLineButTheCountStays() {
handGame(920_001L, 30);
jdbc.update("update user_game set status = 'PLAYING' where user_id = ? and appid = ?", FOLLOWED, 920_001L);
jdbc.update("update user_game set status = 'BACKLOG' where user_id = ? and appid = ?", FOLLOWED, 920_001L);
jdbc.update("update user_game_status_change set changed_at = now() - interval '30 minutes' where user_id = ?", FOLLOWED);
service.digestHandChanges();
assertThat(notes().get(0)).containsEntry("status_changes", 2);
assertThat(details(FOLLOWER_ONE)).extracting(row -> row.get("kind")).doesNotContain("STATUS");
}
@Test
void handMoveBeforeSyncMoveGivesOneLineFromFirstToLast() {
handGame(920_001L, 30);
jdbc.update("update user_game set status = 'PLAYING' where user_id = ? and appid = ?", FOLLOWED, 920_001L);
sync();
jdbc.update("insert into sync_change (user_id, kind, appid, from_status, to_status) values (?, 'STATUS', ?, 'PLAYING', 'FINISHED')",
FOLLOWED, 920_001L);
jdbc.update("insert into user_game_status_change (user_id, appid, from_status, to_status, by_user) values (?, ?, 'PLAYING', 'FINISHED', false)",
FOLLOWED, 920_001L);
event();
assertThat(notes().get(0)).containsEntry("status_changes", 2);
assertThat(details(FOLLOWER_ONE)).filteredOn(row -> "STATUS".equals(row.get("kind")))
.extracting(row -> row.get("from_status"), row -> row.get("to_status"))
.containsExactly(org.assertj.core.groups.Tuple.tuple("BACKLOG", "FINISHED"));
}
@Test
void syncMoveBeforeHandMoveGivesOneLineFromFirstToLast() {
handGame(920_001L, 30);
jdbc.update("delete from user_game_status_change where user_id = ? and appid = ?", FOLLOWED, 920_001L);
sync();
jdbc.update("update user_game set status = 'PLAYING', status_set_by_user = false where user_id = ? and appid = ?", FOLLOWED, 920_001L);
jdbc.update("insert into sync_change (user_id, kind, appid, from_status, to_status) values (?, 'STATUS', ?, 'BACKLOG', 'PLAYING')",
FOLLOWED, 920_001L);
jdbc.update("update user_game set status = 'FINISHED', status_set_by_user = true where user_id = ? and appid = ?", FOLLOWED, 920_001L);
event();
assertThat(notes().get(0)).containsEntry("status_changes", 2);
assertThat(details(FOLLOWER_ONE)).filteredOn(row -> "STATUS".equals(row.get("kind")))
.extracting(row -> row.get("from_status"), row -> row.get("to_status"))
.containsExactly(org.assertj.core.groups.Tuple.tuple("BACKLOG", "FINISHED"));
}
@Test
void statusSectionOnly_WritesNoNewGameOrAchievementLines() {
added(2);
change("ACHIEVEMENTS", FIRST_APPID + 1, 3);
moved(FIRST_APPID + 1);
sections("STATUS");
event();
assertThat(details(FOLLOWER_ONE)).extracting(row -> row.get("kind")).containsExactly("STATUS");
}
@Test
void noActivity_WritesNoNoteButMovesTheCursor() {
sync();
@@ -351,4 +439,20 @@ class FollowDigestServiceTest extends AbstractIntegrationTest {
assertThat(jdbc.queryForObject("select count(*) from inbox_note where achievements = 7 and new_games = 2",
Integer.class)).isEqualTo(2);
}
@Test
void handJobBeforeSync_LeavesTheSyncStatusRowsForTheSyncDigest() {
handGame(920_001L, 30);
added(1);
moved(FIRST_APPID + 1);
service.digestHandChanges();
event();
assertThat(details(FOLLOWER_ONE)).extracting(row -> row.get("kind"), row -> row.get("game_name"))
.containsExactlyInAnyOrder(org.assertj.core.groups.Tuple.tuple("NEW_GAME", "Hand"),
org.assertj.core.groups.Tuple.tuple("NEW_GAME", "Game 1"),
org.assertj.core.groups.Tuple.tuple("STATUS", "Game 1"));
assertThat(notes()).extracting(note -> note.get("status_changes")).containsOnly(0, 1);
}
}
@@ -35,6 +35,23 @@ class InboxControllerTest extends AbstractIntegrationTest {
@AfterEach
void cleanUp() {
jdbc.update("delete from inbox_note");
jdbc.update("delete from follow where user_id in (?, ?)", READER, OTHER);
jdbc.update("delete from public_user where user_id = 7");
jdbc.update("delete from user_game where user_id = 7");
jdbc.update("delete from steam_game where appid = 501");
}
/** A note of READER with lines, the subject (7) public and followed. */
private long noteWithDetails() {
note(READER, "Subject", 1, 2);
long id = jdbc.queryForObject("select max(id) from inbox_note", Long.class);
jdbc.update("insert into follow (user_id, followed_user_id) values (?, 7)", READER);
jdbc.update("insert into public_user (user_id) values (7)");
jdbc.update("insert into inbox_note_detail (note_id, kind, appid, game_name, item_name, from_status, to_status) values "
+ "(?, 'NEW_GAME', 501, 'Alpha', null, null, null), "
+ "(?, 'ACHIEVEMENT', 501, 'Alpha', 'Win', null, null), "
+ "(?, 'STATUS', 502, 'Beta', null, 'BACKLOG', 'PLAYING')", id, id, id);
return id;
}
private static MockHttpServletRequestBuilder as(MockHttpServletRequestBuilder request, long userId) {
@@ -95,4 +112,62 @@ class InboxControllerTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.results[0].read").value(true));
mockMvc.perform(as(get("/api/inbox"), OTHER)).andExpect(jsonPath("$.unread").value(1));
}
@Test
void details_OfAnOwnNoteListsTheLinesWithoutIdentifiers() throws Exception {
long id = noteWithDetails();
String body = mockMvc.perform(as(get("/api/inbox/" + id + "/details"), READER)).andExpect(status().isOk())
.andExpect(jsonPath("$.results.length()").value(3))
.andExpect(jsonPath("$.results[0].kind").value("NEW_GAME"))
.andExpect(jsonPath("$.results[1].itemName").value("Win"))
.andExpect(jsonPath("$.results[2].fromStatus").value("BACKLOG"))
.andReturn().getResponse().getContentAsString();
assertThat(body).doesNotContain("appid", "subject", "userId");
mockMvc.perform(as(get("/api/inbox"), READER)).andExpect(jsonPath("$.results[0].details").value(true));
}
@Test
void details_OfAnotherReadersNoteIs404() throws Exception {
long id = noteWithDetails();
mockMvc.perform(as(get("/api/inbox/" + id + "/details"), OTHER)).andExpect(status().isNotFound())
.andExpect(jsonPath("$.error").doesNotExist());
}
@Test
void details_AfterUnfollowIs404AndTheListShowsNoDetails() throws Exception {
long id = noteWithDetails();
jdbc.update("delete from follow where user_id = ?", READER);
mockMvc.perform(as(get("/api/inbox/" + id + "/details"), READER)).andExpect(status().isNotFound());
mockMvc.perform(as(get("/api/inbox"), READER)).andExpect(jsonPath("$.results[0].details").value(false));
}
@Test
void details_OnlyKindsOfTheSectionsThatAreStillPublic() throws Exception {
long id = noteWithDetails();
jdbc.update("update public_user set sections = '{STATUS}' where user_id = 7");
mockMvc.perform(as(get("/api/inbox/" + id + "/details"), READER)).andExpect(status().isOk())
.andExpect(jsonPath("$.results.length()").value(1))
.andExpect(jsonPath("$.results[0].kind").value("STATUS"));
}
@Test
void details_LeaveOutAGameTheSubjectHidesNow() throws Exception {
long id = noteWithDetails();
jdbc.update("insert into steam_game (appid, name) values (501, 'Alpha') on conflict do nothing");
jdbc.update("insert into user_game (user_id, appid, status, hidden_at) values (7, 501, 'BACKLOG', now())");
mockMvc.perform(as(get("/api/inbox/" + id + "/details"), READER)).andExpect(status().isOk())
.andExpect(jsonPath("$.results.length()").value(1))
.andExpect(jsonPath("$.results[0].gameName").value("Beta"));
}
@Test
void details_WithoutLoginIs401() throws Exception {
mockMvc.perform(get("/api/inbox/1/details")).andExpect(status().isUnauthorized());
}
}
@@ -0,0 +1,44 @@
package core_service.modules.inbox.domain;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class NoteDetailKindTest extends AbstractUnitTest {
@Test
void visibleOn_NullSectionsShowsEverything() {
for (NoteDetailKind kind : NoteDetailKind.values()) {
assertThat(kind.visibleOn(null)).as(kind.name()).isTrue();
}
}
@Test
void visibleOn_SummaryShowsGamesAndAchievementsOnly() {
Set<String> sections = Set.of("SUMMARY");
assertThat(NoteDetailKind.NEW_GAME.visibleOn(sections)).isTrue();
assertThat(NoteDetailKind.ACHIEVEMENT.visibleOn(sections)).isTrue();
assertThat(NoteDetailKind.STATUS.visibleOn(sections)).isFalse();
assertThat(NoteDetailKind.WISHLIST.visibleOn(sections)).isTrue();
}
@Test
void visibleOn_EachStatusSectionShowsStatusButNotGames() {
for (String section : new String[]{"STATUS", "PLAYING", "FINISHED"}) {
Set<String> sections = Set.of(section);
assertThat(NoteDetailKind.STATUS.visibleOn(sections)).as(section).isTrue();
assertThat(NoteDetailKind.NEW_GAME.visibleOn(sections)).as(section).isFalse();
assertThat(NoteDetailKind.ACHIEVEMENT.visibleOn(sections)).as(section).isFalse();
}
}
@Test
void visibleOn_OnlyStatsLeavesTheWishlist() {
assertThat(NoteDetailKind.WISHLIST.visibleOn(Set.of("STATS"))).isTrue();
assertThat(NoteDetailKind.STATUS.visibleOn(Set.of("STATS"))).isFalse();
}
}
@@ -43,4 +43,32 @@ class InboxRepositoryTest extends AbstractPersistenceTest {
assertThat(deleted).isEqualTo(2);
assertThat(ids()).containsExactly(3L, 4L);
}
@Test
void deleteBefore_AlsoRemovesTheDetailRows() {
jdbc.update("delete from inbox_note");
note(1, 91, true);
jdbc.update("insert into inbox_note_detail (note_id, kind, appid) values (1, 'NEW_GAME', 5)");
repository.deleteBefore(Instant.now().minus(Duration.ofDays(90)));
assertThat(jdbc.queryForObject("select count(*) from inbox_note_detail", Integer.class)).isZero();
}
@Test
void findNewest_DetailsNeedRowsAndAFollowAndAPublicSubject() {
jdbc.update("delete from inbox_note");
note(1, 1, false);
jdbc.update("insert into inbox_note_detail (note_id, kind, appid) values (1, 'NEW_GAME', 5)");
assertThat(repository.findNewest(1, 10).get(0).details()).isFalse();
jdbc.update("insert into follow (user_id, followed_user_id) values (1, 2)");
assertThat(repository.findNewest(1, 10).get(0).details()).isFalse();
jdbc.update("insert into public_user (user_id) values (2)");
assertThat(repository.findNewest(1, 10).get(0).details()).isTrue();
jdbc.update("delete from inbox_note_detail");
assertThat(repository.findNewest(1, 10).get(0).details()).isFalse();
}
}