From 8da63578e41162e66428f54b8f3aa540a21876a3 Mon Sep 17 00:00:00 2001 From: Lucrona <113807308+Lucrona@users.noreply.github.com> Date: Thu, 12 Nov 2026 06:41:55 +1100 Subject: [PATCH] Preserve WoW card metadata when Blizzard fields are blank --- apps/server/src/wow.rs | 22 +++-- apps/server/tests/wow.rs | 180 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 197 insertions(+), 5 deletions(-) diff --git a/apps/server/src/wow.rs b/apps/server/src/wow.rs index 1f516f7..cd6afbf 100644 --- a/apps/server/src/wow.rs +++ b/apps/server/src/wow.rs @@ -2210,6 +2210,8 @@ pub(crate) fn name_of(v: &Value) -> Option { 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()) } @@ -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), @@ -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(()) diff --git a/apps/server/tests/wow.rs b/apps/server/tests/wow.rs index 49483d5..8b5d0c3 100644 --- a/apps/server/tests/wow.rs +++ b/apps/server/tests/wow.rs @@ -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); + } +}