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": { "response": {
"game_count": 8, "game_count": 30,
"games": [ "games": [
{ {
"appid": 620, "appid": 620,
@@ -8,7 +8,8 @@
"playtime_forever": 1450, "playtime_forever": 1450,
"playtime_2weeks": null, "playtime_2weeks": null,
"img_icon_url": "", "img_icon_url": "",
"has_community_visible_stats": true "has_community_visible_stats": true,
"rtime_last_played": 1787300000
}, },
{ {
"appid": 1145360, "appid": 1145360,
@@ -16,7 +17,8 @@
"playtime_forever": 3200, "playtime_forever": 3200,
"playtime_2weeks": 240, "playtime_2weeks": 240,
"img_icon_url": "", "img_icon_url": "",
"has_community_visible_stats": true "has_community_visible_stats": true,
"rtime_last_played": 1790500000
}, },
{ {
"appid": 413150, "appid": 413150,
@@ -24,7 +26,8 @@
"playtime_forever": 5400, "playtime_forever": 5400,
"playtime_2weeks": null, "playtime_2weeks": null,
"img_icon_url": "", "img_icon_url": "",
"has_community_visible_stats": true "has_community_visible_stats": true,
"rtime_last_played": 1788900000
}, },
{ {
"appid": 427520, "appid": 427520,
@@ -32,7 +35,8 @@
"playtime_forever": 12000, "playtime_forever": 12000,
"playtime_2weeks": 600, "playtime_2weeks": 600,
"img_icon_url": "", "img_icon_url": "",
"has_community_visible_stats": true "has_community_visible_stats": true,
"rtime_last_played": 1790900000
}, },
{ {
"appid": 504230, "appid": 504230,
@@ -40,7 +44,8 @@
"playtime_forever": 800, "playtime_forever": 800,
"playtime_2weeks": null, "playtime_2weeks": null,
"img_icon_url": "", "img_icon_url": "",
"has_community_visible_stats": true "has_community_visible_stats": true,
"rtime_last_played": 1789200000
}, },
{ {
"appid": 367520, "appid": 367520,
@@ -56,7 +61,8 @@
"playtime_forever": 780, "playtime_forever": 780,
"playtime_2weeks": null, "playtime_2weeks": null,
"img_icon_url": "", "img_icon_url": "",
"has_community_visible_stats": true "has_community_visible_stats": true,
"rtime_last_played": 1786900000
}, },
{ {
"appid": 892970, "appid": 892970,
@@ -64,6 +70,198 @@
"playtime_forever": 90, "playtime_forever": 90,
"playtime_2weeks": 90, "playtime_2weeks": 90,
"img_icon_url": "", "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 "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 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`). 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 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, - **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 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 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 | | `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 | | `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 | | `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 | | `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/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 | | `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)"] 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")] 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 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 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 --> Db Inbox -->|"inbox_note, inbox_note_detail, follow, public_user, user_game"| Db
Auth -->|"service token<br/>/internal/accounts"| Account 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 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 | | `stats` | Public numbers for the landing page |
| `version` | `GET /api/version`: commit and build time of the running image | | `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` | | `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 | | `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 | | `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 | | `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) { public record Digest(int newGames, int achievements, int statusChanges, int wishlistAdds) {
private static final String SUMMARY = "SUMMARY"; static final String SUMMARY = "SUMMARY";
private static final List<String> STATUS_SECTIONS = List.of("STATUS", "PLAYING", "FINISHED"); static final List<String> STATUS_SECTIONS = List.of("STATUS", "PLAYING", "FINISHED");
public boolean isEmpty() { public boolean isEmpty() {
return newGames == 0 && achievements == 0 && statusChanges == 0 && wishlistAdds == 0; 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.Cursor;
import core_service.modules.follow.domain.Digest; 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 lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.jdbc.core.namedparam.SqlParameterSource;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.sql.Array; import java.sql.Array;
import java.sql.Timestamp; import java.sql.Timestamp;
import java.time.Instant; import java.time.Instant;
import java.util.Arrays; import java.util.Arrays;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -22,6 +26,8 @@ import java.util.Optional;
@RequiredArgsConstructor @RequiredArgsConstructor
public class FollowDigestRepository { public class FollowDigestRepository {
static final int MAX_ACHIEVEMENTS_PER_GAME = 20;
private final NamedParameterJdbcTemplate jdbc; private final NamedParameterJdbcTemplate jdbc;
/** /**
@@ -83,6 +89,17 @@ public class FollowDigestRepository {
return max != null ? max : 0; 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. * 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)); (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 * @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 * 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. * 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) { public List<Long> insertDigestNotes(long userId, Digest digest) {
return jdbc.update(""" return jdbc.query("""
insert into inbox_note (user_id, kind, subject_user_id, subject_name, new_games, achievements, insert into inbox_note (user_id, kind, subject_user_id, subject_name, new_games, achievements,
status_changes, wishlist_adds) status_changes, wishlist_adds)
select f.user_id, 'DIGEST', f.followed_user_id, select f.user_id, 'DIGEST', f.followed_user_id,
@@ -128,9 +229,32 @@ public class FollowDigestRepository {
where f.followed_user_id = :userId where f.followed_user_id = :userId
and exists (select 1 from public_user p where p.user_id = f.followed_user_id) 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 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()) 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) { 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.Cursor;
import core_service.modules.follow.domain.Digest; import core_service.modules.follow.domain.Digest;
import core_service.modules.follow.domain.DigestItems;
import core_service.modules.follow.repo.FollowDigestRepository; import core_service.modules.follow.repo.FollowDigestRepository;
import core_service.shared.event.LibraryActivityEvent; import core_service.shared.event.LibraryActivityEvent;
import lombok.extern.slf4j.Slf4j; 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 * 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 @Service
@Slf4j @Slf4j
@@ -67,23 +69,31 @@ public class FollowDigestService {
return; return;
} }
Cursor cursor = locked.get(); 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); Digest digest = digestRepository.countHand(userId, cursor.statusChangeId(), upTo);
Instant syncStartedAt = cursor.syncStartedAt(); Instant syncStartedAt = cursor.syncStartedAt();
long wishlistUpTo = cursor.wishlistChangeId(); 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) { if (withSync) {
Optional<Instant> current = digestRepository.findSyncStartedAt(userId); if (newSync) {
if (current.isPresent() && (syncStartedAt == null || current.get().isAfter(syncStartedAt))) {
digest = digest.plus(digestRepository.countSync(userId)); digest = digest.plus(digestRepository.countSync(userId));
items = items.plus(digestRepository.findSyncItems(userId));
syncStartedAt = current.get(); syncStartedAt = current.get();
} }
wishlistUpTo = digestRepository.maxWishlistChangeId(userId); wishlistUpTo = digestRepository.maxWishlistChangeId(userId);
digest = digest.plus(new Digest(0, 0, 0, digest = digest.plus(new Digest(0, 0, 0,
digestRepository.countWishlist(userId, cursor.wishlistChangeId(), wishlistUpTo))); 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()) { if (!digest.isEmpty()) {
digestRepository.insertDigestNotes(userId, digest); List<Long> noteIds = digestRepository.insertDigestNotes(userId, digest);
digestRepository.insertDigestItems(noteIds, items);
} }
digestRepository.moveCursor(userId, syncStartedAt, upTo, wishlistUpTo); digestRepository.moveCursor(userId, syncStartedAt, upTo, wishlistUpTo);
}); });
@@ -1,13 +1,17 @@
package core_service.modules.inbox.api; package core_service.modules.inbox.api;
import core_service.modules.inbox.api.dto.InboxDto; 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.domain.Inbox;
import core_service.modules.inbox.service.InboxService; import core_service.modules.inbox.service.InboxService;
import core_service.shared.api.ErrorResponse;
import core_service.shared.security.UserPrincipal; import core_service.shared.security.UserPrincipal;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping; 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.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@@ -24,10 +28,21 @@ public class InboxController {
Inbox inbox = inboxService.inbox(principal.getIdAsLong()); Inbox inbox = inboxService.inbox(principal.getIdAsLong());
return ResponseEntity.ok(new InboxDto(inbox.notes().stream() return ResponseEntity.ok(new InboxDto(inbox.notes().stream()
.map(note -> new InboxDto.Note(note.id(), note.kind().name(), note.subjectName(), note.newGames(), .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())); .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") @PutMapping("/read")
public ResponseEntity<Void> markAllRead(@AuthenticationPrincipal UserPrincipal principal) { public ResponseEntity<Void> markAllRead(@AuthenticationPrincipal UserPrincipal principal) {
inboxService.markAllRead(principal.getIdAsLong()); 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 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 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, 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 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, 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; package core_service.modules.inbox.repo;
import core_service.modules.inbox.domain.InboxNote; 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 core_service.modules.inbox.domain.NoteKind;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.sql.Array;
import java.sql.Timestamp; import java.sql.Timestamp;
import java.time.Instant; import java.time.Instant;
import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Optional;
@Repository @Repository
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -19,14 +24,63 @@ public class InboxRepository {
public List<InboxNote> findNewest(long userId, int limit) { public List<InboxNote> findNewest(long userId, int limit) {
return jdbc.query(""" return jdbc.query("""
select id, kind, subject_name, new_games, achievements, status_changes, wishlist_adds, created_at, read_at select n.id, n.kind, n.subject_name, n.new_games, n.achievements, n.status_changes, n.wishlist_adds,
from inbox_note where user_id = :userId n.created_at, n.read_at,
order by created_at desc, id desc 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), limit :limit""", new MapSqlParameterSource("userId", userId).addValue("limit", limit),
(rs, i) -> new InboxNote(rs.getLong("id"), NoteKind.valueOf(rs.getString("kind")), (rs, i) -> new InboxNote(rs.getLong("id"), NoteKind.valueOf(rs.getString("kind")),
rs.getString("subject_name"), rs.getInt("new_games"), rs.getInt("achievements"), rs.getString("subject_name"), rs.getInt("new_games"), rs.getInt("achievements"),
rs.getInt("status_changes"), rs.getInt("wishlist_adds"), 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) { public int countUnread(long userId) {
@@ -1,6 +1,7 @@
package core_service.modules.inbox.service; package core_service.modules.inbox.service;
import core_service.modules.inbox.domain.Inbox; import core_service.modules.inbox.domain.Inbox;
import core_service.modules.inbox.domain.NoteDetail;
import core_service.modules.inbox.repo.InboxRepository; import core_service.modules.inbox.repo.InboxRepository;
import core_service.shared.security.RequiresRole; import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role; import core_service.shared.security.Role;
@@ -13,6 +14,8 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.List;
import java.util.Optional;
@Service @Service
@Slf4j @Slf4j
@@ -32,6 +35,22 @@ public class InboxService {
return new Inbox(inboxRepository.findNewest(userId, LIST_SIZE), inboxRepository.countUnread(userId)); 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) @RequiresRole(Role.USER)
@Transactional @Transactional
public void markAllRead(long userId) { 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 @Test
void everyMapping_PointsToAnExistingAnswer() throws IOException { void everyMapping_PointsToAnExistingAnswer() throws IOException {
List<Path> mappings; List<Path> mappings = sortedFiles(MAPPINGS);
try (Stream<Path> listed = Files.list(MAPPINGS)) {
mappings = listed.sorted().toList();
}
assertThat(mappings).isNotEmpty(); assertThat(mappings).isNotEmpty();
for (Path mapping : mappings) { 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. // The PlayStation authorize answer is a redirect with no body.
assertThat(response.has("bodyFileName") || response.has("jsonBody") || response.path("headers").has("Location")) assertThat(response.has("bodyFileName") || response.has("jsonBody") || response.path("headers").has("Location"))
.as(mapping.toString()).isTrue(); .as(mapping.toString()).isTrue();
@@ -105,10 +110,7 @@ class LocalMocksParseTest extends AbstractUnitTest {
@Test @Test
void everyRecordedAnswer_ParsesIntoItsResponseType() throws IOException, ReflectiveOperationException { void everyRecordedAnswer_ParsesIntoItsResponseType() throws IOException, ReflectiveOperationException {
List<Path> files; List<Path> files = sortedFiles(FILES);
try (Stream<Path> listed = Files.list(FILES)) {
files = listed.sorted().toList();
}
assertThat(files).isNotEmpty(); assertThat(files).isNotEmpty();
for (Path file : files) { for (Path file : files) {
@@ -121,4 +123,30 @@ class LocalMocksParseTest extends AbstractUnitTest {
assertFilled(name, jsonMapper.readValue(file.toFile(), type)); 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; package core_service.modules.follow.repo;
import core_service.modules.follow.domain.Digest; 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 core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; 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.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@Import(FollowDigestRepository.class) @Import(FollowDigestRepository.class)
@@ -27,7 +31,7 @@ class FollowDigestRepositoryTest extends AbstractPersistenceTest {
void seed() { void seed() {
jdbc.update("insert into sync_result (user_id, sync_started_at, started_by, imported_games) values (?, now(), 'AUTO', 0)", USER); 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}) { 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) " jdbc.update("insert into user_game (user_id, appid, status, source, platform, status_set_by_user) "
+ "values (?, ?, 'BACKLOG', 'MANUAL', 'PC', true)", USER, appid); + "values (?, ?, 'BACKLOG', 'MANUAL', 'PC', true)", USER, appid);
jdbc.update("insert into sync_change (user_id, kind, appid, amount) values (?, 'ADDED', ?, null)", 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); 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); 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) { private void added(int count) {
games(count); games(count);
sync(); sync();
@@ -131,6 +138,87 @@ class FollowDigestServiceTest extends AbstractIntegrationTest {
assertThat(jdbc.queryForObject("select subject_name from inbox_note limit 1", String.class)).isEqualTo("Followed"); 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 @Test
void noActivity_WritesNoNoteButMovesTheCursor() { void noActivity_WritesNoNoteButMovesTheCursor() {
sync(); sync();
@@ -351,4 +439,20 @@ class FollowDigestServiceTest extends AbstractIntegrationTest {
assertThat(jdbc.queryForObject("select count(*) from inbox_note where achievements = 7 and new_games = 2", assertThat(jdbc.queryForObject("select count(*) from inbox_note where achievements = 7 and new_games = 2",
Integer.class)).isEqualTo(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 @AfterEach
void cleanUp() { void cleanUp() {
jdbc.update("delete from inbox_note"); 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) { private static MockHttpServletRequestBuilder as(MockHttpServletRequestBuilder request, long userId) {
@@ -95,4 +112,62 @@ class InboxControllerTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.results[0].read").value(true)); .andExpect(jsonPath("$.results[0].read").value(true));
mockMvc.perform(as(get("/api/inbox"), OTHER)).andExpect(jsonPath("$.unread").value(1)); 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(deleted).isEqualTo(2);
assertThat(ids()).containsExactly(3L, 4L); 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();
}
} }