Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions apps/server/src/wow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2210,6 +2210,8 @@ pub(crate) fn name_of(v: &Value) -> Option<String> {
v["name"]
.as_str()
.or_else(|| v["name"]["en_US"].as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.chars().take(40).collect())
}

Expand All @@ -2228,23 +2230,33 @@ pub fn profile_faction(profile: &Value) -> Option<&str> {
/// them. The owner's /connect/wow page lists every row so unverified ones can be verified.
/// Copy a successful Blizzard character profile onto the card fields
/// (`shown_level!`, class, race, spec, faction). Never writes the addon's
/// `level` claim or `played_s`.
/// `level` claim or `played_s`. Missing or blank metadata preserves the saved
/// value; guild/media, ownership and visibility are outside this write set.
pub async fn apply_blizzard_profile(
db: &sqlx::PgPool,
id: Uuid,
root: &Value,
) -> Result<(), sqlx::Error> {
let level = root["level"].as_i64().filter(|l| *l > 0).map(|l| l as i32);
let level = root["level"]
.as_i64()
.and_then(|l| i32::try_from(l).ok())
.filter(|l| *l > 0);
let last = root["last_login_timestamp"]
.as_i64()
.and_then(|ms| Utc.timestamp_millis_opt(ms).single());
let class = class_token(root);
let class = (!class.is_empty()).then_some(class);
let gender = root["gender"]["type"]
.as_str()
.map(str::trim)
.filter(|g| !g.is_empty())
.map(str::to_lowercase);
sqlx::query(
"UPDATE wow_characters SET
bnet_level = coalesce($2, bnet_level),
bnet_last_login = CASE WHEN $3::timestamptz IS NULL THEN bnet_last_login
ELSE greatest(bnet_last_login, $3) END,
class = CASE WHEN $4 <> '' THEN $4 ELSE class END,
class = coalesce($4, class),
race = coalesce($5, race),
gender = coalesce($6, gender),
bnet_spec = coalesce($7, bnet_spec),
Expand All @@ -2258,10 +2270,10 @@ pub async fn apply_blizzard_profile(
.bind(last)
.bind(&class)
.bind(name_of(&root["race"]))
.bind(root["gender"]["type"].as_str().map(|g| g.to_lowercase()))
.bind(gender)
.bind(name_of(&root["active_spec"]))
.bind(profile_faction(root))
.bind(root["id"].as_i64())
.bind(root["id"].as_i64().filter(|id| *id > 0))
.execute(db)
.await?;
Ok(())
Expand Down
180 changes: 180 additions & 0 deletions apps/server/tests/wow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1216,3 +1216,183 @@ async fn stale_blizzard_levels_update_cards_not_played(pool: PgPool) {
.unwrap();
assert_eq!(played, 9876);
}

#[sqlx::test(migrator = "fraglink_server::MIGRATOR")]
async fn retail_profile_preserves_missing_and_blank_metadata(pool: PgPool) {
profile_preserves_missing_and_blank_metadata(pool, "wow").await;
}

#[sqlx::test(migrator = "fraglink_server::MIGRATOR")]
async fn forever_profile_preserves_missing_and_blank_metadata(pool: PgPool) {
profile_preserves_missing_and_blank_metadata(pool, "wow_forever").await;
}

async fn profile_preserves_missing_and_blank_metadata(pool: PgPool, game: &str) {
let uid = uuid::Uuid::new_v4();
let id = uuid::Uuid::new_v4();
sqlx::query(
"INSERT INTO users(id, email, handle, display_name, password_hash)
VALUES ($1, 'profile-guard@example.invalid', 'profileguard', 'Profile guard', 'unused')",
)
.bind(uid)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO games(id, name) VALUES ($1, 'Profile guard test') ON CONFLICT DO NOTHING",
)
.bind(game)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO wow_characters(id, user_id, game_id, region, realm, realm_slug, name,
class, race, gender, bnet_spec, faction, level, bnet_level, bnet_last_login,
played_s, played_at, verified, ever_verified, hidden, featured, show_guild, bnet_id)
VALUES ($1, $2, $3, 'us', 'Test', 'test', 'Guard Example',
'PALADIN', 'Human', 'male', 'Protection', 'ALLIANCE', 22, 27,
'2026-01-02T00:00:00Z', 9876, '2026-01-01T00:00:00Z', $4, true, true, 1, true, 123)",
)
.bind(id)
.bind(uid)
.bind(game)
.bind(game == "wow") // Both verified and unverified ownership must stay unchanged.
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO wow_played_snaps(char_id, played_s, at) VALUES ($1, 9876, now())")
.bind(id)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO hours_entries(id, user_id, game_id, tier, source, minutes)
VALUES ($1, $2, $3, 'imported', 'wow_addon', 164)",
)
.bind(uuid::Uuid::new_v4())
.bind(uid)
.bind(game)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO sessions(id, user_id, game_id, started_at, ended_at, duration_s)
VALUES ($1, $2, $3, now() - interval '1 hour', now(), 3600)",
)
.bind(uuid::Uuid::new_v4())
.bind(uid)
.bind(game)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO wow_char_details(char_id, status, data)
VALUES ($1, 'ok', '{\"guild\":\"Saved guild\",\"avatar\":\"saved-avatar\",\"media\":\"saved-media\"}')",
)
.bind(id)
.execute(&pool)
.await
.unwrap();

// Compare complete rows, including upload timestamps and every ownership /
// visibility field; only the refresh-at timestamp may change for blank data.
let character = || async {
sqlx::query_scalar::<_, Value>(
"SELECT to_jsonb(c) - 'bnet_checked_at' FROM wow_characters c WHERE id = $1",
)
.bind(id)
.fetch_one(&pool)
.await
.unwrap()
};
let related = || async {
sqlx::query_scalar::<_, Value>(
"SELECT jsonb_build_object(
'snaps', (SELECT jsonb_agg(to_jsonb(s)) FROM wow_played_snaps s),
'hours', (SELECT jsonb_agg(to_jsonb(h)) FROM hours_entries h),
'sessions', (SELECT jsonb_agg(to_jsonb(s)) FROM sessions s),
'details', (SELECT jsonb_agg(to_jsonb(d)) FROM wow_char_details d))",
)
.fetch_one(&pool)
.await
.unwrap()
};
let before = character().await;
let related_before = related().await;
let mut blanks = vec![json!({}), json!({"level": null, "id": null})];
for blank in ["", " \t\n\r ", "\u{2003}\u{a0}"] {
for name in [json!(blank), json!({"en_US": blank})] {
blanks.push(json!({
"level": 0, "id": 999,
"playable_class": {"name": name}, "character_class": {"name": name},
"race": {"name": name}, "active_spec": {"name": name},
"gender": {"type": blank}, "faction": {"type": blank},
"guild": {"name": blank}, "avatar": blank, "media": blank,
"last_login_timestamp": null
}));
}
}
blanks.extend([
json!({"level": -1, "id": 0}),
json!({"level": 4_294_967_297i64, "id": -1}), // Must not wrap to level 1.
json!({"race": null, "gender": null, "active_spec": null, "faction": null}),
json!({"last_login_timestamp": 1_735_689_600_000i64}), // Older login.
]);
for profile in blanks {
fraglink_server::wow::apply_blizzard_profile(&pool, id, &profile)
.await
.unwrap();
assert_eq!(character().await, before, "{game}: {profile}");
assert_eq!(related().await, related_before);
}

// Valid data still updates the intended card metadata, with trimmed names.
fraglink_server::wow::apply_blizzard_profile(
&pool,
id,
&json!({"level": 28, "id": 999, "playable_class": {"name": " \t "},
"character_class": {"name": " Mage "}, "race": {"name": {"en_US": " Elf "}},
"gender": {"type": " FEMALE "}, "active_spec": {"name": " Frost "},
"faction": {"type": "HORDE"}, "last_login_timestamp": 1_767_398_400_000i64}),
)
.await
.unwrap();
let mut expected = before;
expected["bnet_level"] = json!(28);
expected["class"] = json!("MAGE");
expected["race"] = json!("Elf");
expected["gender"] = json!("female");
expected["bnet_spec"] = json!("Frost");
expected["faction"] = json!("HORDE");
expected["bnet_last_login"] =
sqlx::query_scalar::<_, Value>("SELECT to_jsonb('2026-01-03T00:00:00Z'::timestamptz)")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(character().await, expected); // Existing bnet_id remains 123, not 999.
assert_eq!(related().await, related_before);

// An absent stable ID can be filled, but never with an invalid ID.
sqlx::query("UPDATE wow_characters SET bnet_id = NULL WHERE id = $1")
.bind(id)
.execute(&pool)
.await
.unwrap();
for profile in [
json!({}),
json!({"id": 0}),
json!({"id": -1}),
json!({"id": 456}),
] {
fraglink_server::wow::apply_blizzard_profile(&pool, id, &profile)
.await
.unwrap();
expected["bnet_id"] = if profile["id"] == 456 {
json!(456)
} else {
Value::Null
};
assert_eq!(character().await, expected);
assert_eq!(related().await, related_before);
}
}
Loading