fix(engine,server): safe area clamp, analytics batch, achievement save order, daily rollover, replay validation, leaderboard opt-in (#56, #60, #61, #62, #66, #68)
Build and Deploy / build-and-push (push) Successful in 3m54s

- #66: Clamp safe-area insets to 25% of window height with warn!() on excess
- #68: Move fire_flush outside per-event loop in analytics (batch flush once)
- #56: Persist progress before marking reward_granted to prevent XP loss on crash
- #60: Add DateRolloverTimer + check_date_rollover system for midnight seed refresh
- #62: Add validate_header() in replay upload with mode/draw_mode allowlists
- #61: Restore two-query leaderboard opt-in check (SELECT then UPDATE); original
       queries already in .sqlx cache; EXISTS variant would require sqlx prepare

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
funman300
2026-05-28 13:07:22 -07:00
parent 8cb4c9808e
commit 6e407a3ea7
104 changed files with 6356 additions and 3092 deletions
+132 -109
View File
@@ -17,12 +17,12 @@ use axum::{
response::Response,
};
use chrono::Utc;
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
use serde::Deserialize;
use serde_json::Value;
use solitaire_server::build_test_router;
use solitaire_sync::{PlayerProgress, StatsSnapshot, SyncPayload};
use sqlx::{sqlite::SqlitePoolOptions, SqlitePool};
use sqlx::{SqlitePool, sqlite::SqlitePoolOptions};
use tower::ServiceExt;
// ---------------------------------------------------------------------------
@@ -138,12 +138,8 @@ struct TestClaims {
fn decode_sub(token: &str) -> String {
let mut v = Validation::default();
v.validate_exp = false;
let data = decode::<TestClaims>(
token,
&DecodingKey::from_secret(TEST_SECRET.as_bytes()),
&v,
)
.expect("failed to decode access token");
let data = decode::<TestClaims>(token, &DecodingKey::from_secret(TEST_SECRET.as_bytes()), &v)
.expect("failed to decode access token");
data.claims.sub
}
@@ -155,11 +151,7 @@ async fn register_user(app: axum::Router, username: &str, password: &str) -> (St
serde_json::json!({ "username": username, "password": password }),
)
.await;
assert_eq!(
resp.status(),
StatusCode::OK,
"register should return 200"
);
assert_eq!(resp.status(), StatusCode::OK, "register should return 200");
let body = body_json(resp).await;
let access = body["access_token"]
.as_str()
@@ -196,7 +188,6 @@ fn make_payload(user_id_str: &str, games_played: u32) -> SyncPayload {
/// `POST /api/auth/register` must return 200 with both tokens.
#[tokio::test]
async fn register_creates_account_and_returns_tokens() {
let app = build_test_router(test_pool().await);
let resp = post_json(
@@ -221,13 +212,16 @@ async fn register_creates_account_and_returns_tokens() {
/// Registering the same username twice must return 409 Conflict on the second attempt.
#[tokio::test]
async fn register_duplicate_username_returns_conflict() {
let app = build_test_router(test_pool().await);
let creds = serde_json::json!({ "username": "bob", "password": "s3cr3t!!" });
// First registration succeeds.
let first = post_json(app.clone(), "/api/auth/register", creds.clone()).await;
assert_eq!(first.status(), StatusCode::OK, "first register must succeed");
assert_eq!(
first.status(),
StatusCode::OK,
"first register must succeed"
);
// Second registration with the same username is rejected.
let second = post_json(app, "/api/auth/register", creds).await;
@@ -241,7 +235,6 @@ async fn register_duplicate_username_returns_conflict() {
/// Short username (< 3 chars) is rejected with 400.
#[tokio::test]
async fn register_rejects_short_username() {
let app = build_test_router(test_pool().await);
let resp = post_json(
app,
@@ -255,7 +248,6 @@ async fn register_rejects_short_username() {
/// Username with disallowed characters is rejected with 400.
#[tokio::test]
async fn register_rejects_invalid_username_chars() {
let app = build_test_router(test_pool().await);
let resp = post_json(
app,
@@ -269,7 +261,6 @@ async fn register_rejects_invalid_username_chars() {
/// Password shorter than 8 characters is rejected with 400.
#[tokio::test]
async fn register_rejects_short_password() {
let app = build_test_router(test_pool().await);
let resp = post_json(
app,
@@ -283,7 +274,6 @@ async fn register_rejects_short_password() {
/// `POST /api/auth/login` with correct credentials returns 200 with both tokens.
#[tokio::test]
async fn login_with_correct_credentials_returns_tokens() {
let app = build_test_router(test_pool().await);
// Register first.
@@ -299,14 +289,19 @@ async fn login_with_correct_credentials_returns_tokens() {
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
assert!(body["access_token"].is_string(), "access_token must be present");
assert!(body["refresh_token"].is_string(), "refresh_token must be present");
assert!(
body["access_token"].is_string(),
"access_token must be present"
);
assert!(
body["refresh_token"].is_string(),
"refresh_token must be present"
);
}
/// `POST /api/auth/login` with a wrong password must return 401.
#[tokio::test]
async fn login_with_wrong_password_returns_401() {
let app = build_test_router(test_pool().await);
// Register a user.
@@ -330,7 +325,6 @@ async fn login_with_wrong_password_returns_401() {
/// `POST /api/auth/login` for a username that does not exist must return 401.
#[tokio::test]
async fn login_with_unknown_username_returns_401() {
let app = build_test_router(test_pool().await);
let resp = post_json(
@@ -351,7 +345,6 @@ async fn login_with_unknown_username_returns_401() {
/// a new access token and a rotated refresh token.
#[tokio::test]
async fn refresh_returns_new_access_and_refresh_tokens() {
let app = build_test_router(test_pool().await);
let (_access, refresh) = register_user(app.clone(), "eve", "refresh_me").await;
@@ -395,7 +388,11 @@ async fn consumed_refresh_token_is_rejected() {
serde_json::json!({ "refresh_token": original_refresh }),
)
.await;
assert_eq!(resp1.status(), StatusCode::OK, "first rotation must succeed");
assert_eq!(
resp1.status(),
StatusCode::OK,
"first rotation must succeed"
);
// Second attempt with the now-consumed original token must fail.
let resp2 = post_json(
@@ -449,7 +446,6 @@ async fn rotated_refresh_token_can_be_used_again() {
/// the `kind` claim will be `"access"`, not `"refresh"`.
#[tokio::test]
async fn refresh_with_access_token_returns_401() {
let app = build_test_router(test_pool().await);
let (access, _refresh) = register_user(app.clone(), "frank", "bad_refresh").await;
@@ -476,7 +472,6 @@ async fn refresh_with_access_token_returns_401() {
/// Push a payload, then pull — the pulled data must reflect the pushed values.
#[tokio::test]
async fn push_then_pull_returns_pushed_data() {
let app = build_test_router(test_pool().await);
let (access, _) = register_user(app.clone(), "grace", "sync_pass").await;
@@ -502,7 +497,10 @@ async fn push_then_pull_returns_pushed_data() {
let games_played = pull_body["merged"]["stats"]["games_played"]
.as_u64()
.expect("games_played must be a number");
assert_eq!(games_played, 7, "pulled games_played must match pushed value");
assert_eq!(
games_played, 7,
"pulled games_played must match pushed value"
);
}
/// Full register → login → push → pull integration roundtrip.
@@ -518,7 +516,6 @@ async fn push_then_pull_returns_pushed_data() {
/// the pushed values.
#[tokio::test]
async fn register_login_push_pull_full_roundtrip() {
let app = build_test_router(test_pool().await);
// --- Step 1: Register ---
@@ -546,11 +543,7 @@ async fn register_login_push_pull_full_roundtrip() {
serde_json::json!({ "username": "roundtrip_user", "password": "roundtrip_pass" }),
)
.await;
assert_eq!(
login_resp.status(),
StatusCode::OK,
"login must return 200"
);
assert_eq!(login_resp.status(), StatusCode::OK, "login must return 200");
let login_body = body_json(login_resp).await;
let access_token = login_body["access_token"]
.as_str()
@@ -562,8 +555,7 @@ async fn register_login_push_pull_full_roundtrip() {
// --- Step 3: Push a payload with known values ---
let payload = SyncPayload {
user_id: uuid::Uuid::parse_str(&user_id)
.expect("JWT sub must be a valid UUID"),
user_id: uuid::Uuid::parse_str(&user_id).expect("JWT sub must be a valid UUID"),
stats: StatsSnapshot {
games_played: 42,
games_won: 17,
@@ -583,19 +575,11 @@ async fn register_login_push_pull_full_roundtrip() {
serde_json::to_value(&payload).expect("SyncPayload must serialise"),
)
.await;
assert_eq!(
push_resp.status(),
StatusCode::OK,
"push must return 200"
);
assert_eq!(push_resp.status(), StatusCode::OK, "push must return 200");
// --- Step 4: Pull and verify the stored data matches what was pushed ---
let pull_resp = get_authed(app, "/api/sync/pull", &access_token).await;
assert_eq!(
pull_resp.status(),
StatusCode::OK,
"pull must return 200"
);
assert_eq!(pull_resp.status(), StatusCode::OK, "pull must return 200");
let pull_body = body_json(pull_resp).await;
let merged = &pull_body["merged"];
@@ -625,7 +609,6 @@ async fn register_login_push_pull_full_roundtrip() {
/// Pushing a payload whose `user_id` does not match the JWT `sub` must return 400.
#[tokio::test]
async fn push_with_wrong_user_id_returns_400() {
let app = build_test_router(test_pool().await);
let (access, _) = register_user(app.clone(), "heidi", "sync_pass").await;
@@ -658,25 +641,30 @@ async fn push_with_wrong_user_id_returns_400() {
/// A pull before any push returns a default empty payload (200, not 404).
#[tokio::test]
async fn pull_before_push_returns_default_payload() {
let app = build_test_router(test_pool().await);
let (access, _) = register_user(app.clone(), "ivan", "nopush!!").await;
let resp = get_authed(app, "/api/sync/pull", &access).await;
assert_eq!(resp.status(), StatusCode::OK, "pull with no data must return 200");
assert_eq!(
resp.status(),
StatusCode::OK,
"pull with no data must return 200"
);
let body = body_json(resp).await;
let games_played = body["merged"]["stats"]["games_played"]
.as_u64()
.expect("games_played must be present");
assert_eq!(games_played, 0, "default payload must have games_played = 0");
assert_eq!(
games_played, 0,
"default payload must have games_played = 0"
);
}
/// Accessing `/api/sync/pull` without a token must return 401.
#[tokio::test]
async fn pull_without_token_returns_401() {
let app = build_test_router(test_pool().await);
let req = Request::builder()
@@ -703,7 +691,6 @@ async fn pull_without_token_returns_401() {
/// return 200.
#[tokio::test]
async fn delete_account_succeeds_and_data_is_gone() {
let app = build_test_router(test_pool().await);
let (access, _) = register_user(app.clone(), "judy", "delete_me").await;
@@ -718,7 +705,11 @@ async fn delete_account_succeeds_and_data_is_gone() {
serde_json::to_value(&payload).expect("SyncPayload must serialise"),
)
.await;
assert_eq!(push_resp.status(), StatusCode::OK, "setup push must succeed");
assert_eq!(
push_resp.status(),
StatusCode::OK,
"setup push must succeed"
);
// Delete the account.
let del_resp = delete_authed(app.clone(), "/api/account", &access).await;
@@ -769,10 +760,7 @@ async fn health_returns_ok() {
assert_eq!(resp.status(), StatusCode::OK, "health must return 200");
let body = body_json(resp).await;
assert_eq!(
body["status"], "ok",
"health body must contain status: ok"
);
assert_eq!(body["status"], "ok", "health body must contain status: ok");
}
// ---------------------------------------------------------------------------
@@ -782,7 +770,6 @@ async fn health_returns_ok() {
/// `GET /api/daily-challenge` must return 200 with today's UTC date.
#[tokio::test]
async fn daily_challenge_returns_goal_for_today() {
let app = build_test_router(test_pool().await);
let today = Utc::now().format("%Y-%m-%d").to_string();
@@ -794,14 +781,21 @@ async fn daily_challenge_returns_goal_for_today() {
.expect("failed to build daily-challenge request");
let resp = app.oneshot(req).await.expect("oneshot failed");
assert_eq!(resp.status(), StatusCode::OK, "daily challenge must return 200");
assert_eq!(
resp.status(),
StatusCode::OK,
"daily challenge must return 200"
);
let body = body_json(resp).await;
assert_eq!(
body["date"], today,
"challenge date must match today's UTC date"
);
assert!(body["seed"].is_number(), "challenge must include a numeric seed");
assert!(
body["seed"].is_number(),
"challenge must include a numeric seed"
);
assert!(
body["description"].is_string(),
"challenge must include a description"
@@ -811,7 +805,6 @@ async fn daily_challenge_returns_goal_for_today() {
/// Calling `GET /api/daily-challenge` twice returns the same seed (deterministic).
#[tokio::test]
async fn daily_challenge_is_deterministic() {
// Use the same pool so the second call hits the stored row.
let pool = test_pool().await;
@@ -854,7 +847,6 @@ async fn daily_challenge_is_deterministic() {
/// `GET /api/leaderboard` requires authentication — no token returns 401.
#[tokio::test]
async fn leaderboard_without_token_returns_401() {
let app = build_test_router(test_pool().await);
let req = Request::builder()
@@ -874,7 +866,6 @@ async fn leaderboard_without_token_returns_401() {
/// Opting in and then fetching the leaderboard returns the opted-in entry.
#[tokio::test]
async fn opt_in_then_leaderboard_shows_entry() {
let app = build_test_router(test_pool().await);
let (access, _) = register_user(app.clone(), "karen", "leaderpass").await;
@@ -887,28 +878,25 @@ async fn opt_in_then_leaderboard_shows_entry() {
serde_json::json!({ "display_name": "KarenTheGreat" }),
)
.await;
assert_eq!(
opt_resp.status(),
StatusCode::OK,
"opt-in must return 200"
);
assert_eq!(opt_resp.status(), StatusCode::OK, "opt-in must return 200");
// Fetch the leaderboard.
let lb_resp = get_authed(app, "/api/leaderboard", &access).await;
assert_eq!(lb_resp.status(), StatusCode::OK, "leaderboard must return 200");
assert_eq!(
lb_resp.status(),
StatusCode::OK,
"leaderboard must return 200"
);
let body = body_json(lb_resp).await;
let entries = body.as_array().expect("leaderboard must be a JSON array");
let found = entries
.iter()
.any(|e| e["display_name"] == "KarenTheGreat");
let found = entries.iter().any(|e| e["display_name"] == "KarenTheGreat");
assert!(found, "opted-in user must appear in leaderboard");
}
/// Pushing sync data after opting in updates the leaderboard best_score.
#[tokio::test]
async fn push_after_opt_in_updates_leaderboard_score() {
let pool = test_pool().await;
let app = build_test_router(pool);
@@ -952,15 +940,23 @@ async fn push_after_opt_in_updates_leaderboard_score() {
let lb_resp = get_authed(app, "/api/leaderboard", &access).await;
let body = body_json(lb_resp).await;
let entries = body.as_array().unwrap();
let entry = entries.iter().find(|e| e["display_name"] == "Scorer").expect("entry missing");
assert_eq!(entry["best_score"], 3_500, "best_score must be updated from push");
assert_eq!(entry["best_time_secs"], 180, "best_time_secs must be updated from push");
let entry = entries
.iter()
.find(|e| e["display_name"] == "Scorer")
.expect("entry missing");
assert_eq!(
entry["best_score"], 3_500,
"best_score must be updated from push"
);
assert_eq!(
entry["best_time_secs"], 180,
"best_time_secs must be updated from push"
);
}
/// Pushing a lower score after a higher one does not overwrite the best.
#[tokio::test]
async fn push_lower_score_does_not_overwrite_leaderboard_best() {
let pool = test_pool().await;
let app = build_test_router(pool);
@@ -990,25 +986,40 @@ async fn push_lower_score_does_not_overwrite_leaderboard_best() {
};
// First push: high score.
post_authed(app.clone(), "/api/sync/push", &access,
serde_json::to_value(make(5_000, 120)).unwrap()).await;
post_authed(
app.clone(),
"/api/sync/push",
&access,
serde_json::to_value(make(5_000, 120)).unwrap(),
)
.await;
// Second push: lower score and slower time.
post_authed(app.clone(), "/api/sync/push", &access,
serde_json::to_value(make(1_000, 600)).unwrap()).await;
post_authed(
app.clone(),
"/api/sync/push",
&access,
serde_json::to_value(make(1_000, 600)).unwrap(),
)
.await;
let lb_resp = get_authed(app, "/api/leaderboard", &access).await;
let body = body_json(lb_resp).await;
let entries = body.as_array().unwrap();
let entry = entries.iter().find(|e| e["display_name"] == "Champ").unwrap();
let entry = entries
.iter()
.find(|e| e["display_name"] == "Champ")
.unwrap();
assert_eq!(entry["best_score"], 5_000, "best_score must not regress");
assert_eq!(entry["best_time_secs"], 120, "best_time_secs must stay at fastest");
assert_eq!(
entry["best_time_secs"], 120,
"best_time_secs must stay at fastest"
);
}
/// Opting out hides the player from the leaderboard; opting back in restores them.
#[tokio::test]
async fn opt_out_hides_then_opt_in_restores() {
let pool = test_pool().await;
let app = build_test_router(pool);
@@ -1028,7 +1039,11 @@ async fn opt_out_hides_then_opt_in_restores() {
let lb = get_authed(app.clone(), "/api/leaderboard", &access).await;
let entries = body_json(lb).await;
assert!(
entries.as_array().unwrap().iter().any(|e| e["display_name"] == "Visible"),
entries
.as_array()
.unwrap()
.iter()
.any(|e| e["display_name"] == "Visible"),
"opted-in user must appear"
);
@@ -1040,7 +1055,11 @@ async fn opt_out_hides_then_opt_in_restores() {
let lb = get_authed(app.clone(), "/api/leaderboard", &access).await;
let entries = body_json(lb).await;
assert!(
!entries.as_array().unwrap().iter().any(|e| e["display_name"] == "Visible"),
!entries
.as_array()
.unwrap()
.iter()
.any(|e| e["display_name"] == "Visible"),
"opted-out user must be hidden"
);
@@ -1055,7 +1074,11 @@ async fn opt_out_hides_then_opt_in_restores() {
let lb = get_authed(app.clone(), "/api/leaderboard", &access).await;
let entries = body_json(lb).await;
assert!(
entries.as_array().unwrap().iter().any(|e| e["display_name"] == "Visible"),
entries
.as_array()
.unwrap()
.iter()
.any(|e| e["display_name"] == "Visible"),
"re-opted-in user must appear again"
);
}
@@ -1063,7 +1086,6 @@ async fn opt_out_hides_then_opt_in_restores() {
/// Opting in with an empty display name returns 400.
#[tokio::test]
async fn opt_in_empty_display_name_returns_400() {
let app = build_test_router(test_pool().await);
let (access, _) = register_user(app.clone(), "empty_name", "pass1234").await;
@@ -1084,7 +1106,6 @@ async fn opt_in_empty_display_name_returns_400() {
/// Opting in with a display name longer than 32 characters returns 400.
#[tokio::test]
async fn opt_in_too_long_display_name_returns_400() {
let app = build_test_router(test_pool().await);
let (access, _) = register_user(app.clone(), "long_name", "pass1234").await;
@@ -1106,7 +1127,6 @@ async fn opt_in_too_long_display_name_returns_400() {
/// Exactly 32 ASCII characters is accepted.
#[tokio::test]
async fn opt_in_exactly_32_char_display_name_succeeds() {
let app = build_test_router(test_pool().await);
let (access, _) = register_user(app.clone(), "maxname", "pass1234").await;
@@ -1129,7 +1149,6 @@ async fn opt_in_exactly_32_char_display_name_succeeds() {
/// accepted — the limit is character count, not byte count.
#[tokio::test]
async fn opt_in_32_unicode_chars_display_name_succeeds() {
let app = build_test_router(test_pool().await);
let (access, _) = register_user(app.clone(), "unicode_name", "pass1234").await;
@@ -1153,7 +1172,6 @@ async fn opt_in_32_unicode_chars_display_name_succeeds() {
/// A display name with 33 Unicode emoji is rejected.
#[tokio::test]
async fn opt_in_33_unicode_chars_display_name_returns_400() {
let app = build_test_router(test_pool().await);
let (access, _) = register_user(app.clone(), "unicode_long", "pass1234").await;
@@ -1176,7 +1194,6 @@ async fn opt_in_33_unicode_chars_display_name_returns_400() {
/// the server merges (max wins) rather than blindly replacing.
#[tokio::test]
async fn second_push_with_lower_stats_preserves_higher_stored_values() {
let app = build_test_router(test_pool().await);
let (access, _) = register_user(app.clone(), "merge_test", "merge_pass").await;
@@ -1219,7 +1236,6 @@ async fn second_push_with_lower_stats_preserves_higher_stored_values() {
/// Login with leading/trailing whitespace in the username still succeeds.
#[tokio::test]
async fn login_trims_whitespace_from_username() {
let app = build_test_router(test_pool().await);
let _ = register_user(app.clone(), "trimtest", "password1!").await;
@@ -1246,15 +1262,13 @@ async fn login_trims_whitespace_from_username() {
/// `POST /api/sync/push` with a body exceeding the 1 MB limit must return 413.
#[tokio::test]
async fn push_oversized_body_returns_413() {
let app = build_test_router(test_pool().await);
let (access, _) = register_user(app.clone(), "sizetest", "password1!").await;
// 1_100_000-byte string embedded in JSON comfortably exceeds the 1 MB limit.
let big_string = "x".repeat(1_100_000);
let body_bytes =
serde_json::to_vec(&serde_json::json!({ "garbage": big_string })).unwrap();
let body_bytes = serde_json::to_vec(&serde_json::json!({ "garbage": big_string })).unwrap();
let req = Request::builder()
.method("POST")
@@ -1276,7 +1290,6 @@ async fn push_oversized_body_returns_413() {
/// A JWT whose `exp` is in the past must be rejected with 401 on protected routes.
#[tokio::test]
async fn expired_access_token_returns_401() {
let app = build_test_router(test_pool().await);
// Craft a token that expired 2 hours ago — well past jsonwebtoken's 60 s leeway.
@@ -1309,7 +1322,6 @@ async fn expired_access_token_returns_401() {
/// A refresh token must be rejected when used as a Bearer token on protected routes.
#[tokio::test]
async fn refresh_token_rejected_on_protected_routes() {
let app = build_test_router(test_pool().await);
let (_, refresh) = register_user(app.clone(), "kindtest", "password1!").await;
@@ -1521,8 +1533,7 @@ async fn reset_password_invalidates_existing_sessions() {
let pool = test_pool().await;
let app = build_test_router(pool.clone());
let (_, refresh_token) =
register_user(app.clone(), "reset_user_b", "password1!").await;
let (_, refresh_token) = register_user(app.clone(), "reset_user_b", "password1!").await;
// Confirm the refresh token works before the reset.
let pre_reset = post_json(
@@ -1804,8 +1815,20 @@ async fn replay_recent_lists_newest_first_with_username() {
let app = build_test_router(pool);
let (token, _) = register_user(app.clone(), "replay_recent_user", "p4ssword!").await;
let _ = post_authed(app.clone(), "/api/replays", &token, sample_replay_payload(1, 100)).await;
let _ = post_authed(app.clone(), "/api/replays", &token, sample_replay_payload(2, 200)).await;
let _ = post_authed(
app.clone(),
"/api/replays",
&token,
sample_replay_payload(1, 100),
)
.await;
let _ = post_authed(
app.clone(),
"/api/replays",
&token,
sample_replay_payload(2, 200),
)
.await;
let req = Request::builder()
.method("GET")
@@ -1818,16 +1841,16 @@ async fn replay_recent_lists_newest_first_with_username() {
let entries = body_json(resp).await;
let array = entries.as_array().expect("recent should return an array");
assert!(array.len() >= 2, "two uploads should yield two list entries");
assert!(
array.len() >= 2,
"two uploads should yield two list entries"
);
// Newer upload (seed = 2) must appear before older one (seed = 1).
let seeds: Vec<i64> = array
.iter()
.map(|e| e["seed"].as_i64().expect("seed should be an integer"))
.collect();
assert_eq!(
seeds, [2, 1],
"received_at DESC: most recent upload first",
);
assert_eq!(seeds, [2, 1], "received_at DESC: most recent upload first",);
assert_eq!(
array[0]["username"].as_str(),
Some("replay_recent_user"),