Skip to main content

whatsapp_rust/
usync.rs

1//! User device list synchronization.
2//!
3//! Device list IQ specification is defined in `wacore::iq::usync`.
4
5use crate::client::Client;
6use crate::request::IqError;
7use log::{debug, warn};
8use wacore::iq::usync::{DeviceListResponse, DeviceListSpec};
9use wacore_binary::Jid;
10
11/// An authoritative refresh retries when a newer registry mutation wins while
12/// its IQ is in flight. Bound retries so a continuously changing account never
13/// turns one send into an unbounded request loop.
14const DEVICE_REFRESH_MAX_ATTEMPTS: usize = 3;
15
16#[inline]
17fn device_response_contains_user(response: &DeviceListResponse, user: &str) -> bool {
18    response
19        .device_lists
20        .iter()
21        .any(|device_list| device_list.user.user == user)
22        || response
23            .lid_mappings
24            .iter()
25            .any(|mapping| mapping.phone_number == user || mapping.lid == user)
26}
27
28pub use wacore::iq::usync::{
29    UsyncAddressingMode, UsyncBotCommand, UsyncBotProfessionalType, UsyncBotProfileResult,
30    UsyncBotPrompt, UsyncBusinessResult, UsyncContactResult, UsyncContext, UsyncDeviceListResult,
31    UsyncDeviceResult, UsyncDeviceSyncHint, UsyncDevicesResult, UsyncDisappearingModeResult,
32    UsyncFeature, UsyncFeatureResult, UsyncKeyIndexResult, UsyncMode, UsyncOutcome, UsyncProtocol,
33    UsyncProtocolKind, UsyncProtocolResult, UsyncProtocolState, UsyncQuery, UsyncResponse,
34    UsyncStatusResult, UsyncSubprotocolError, UsyncTextStatusResult, UsyncUser, UsyncUserResult,
35    UsyncValidationError,
36};
37
38impl Client {
39    /// Executes a typed USync query.
40    ///
41    /// The client generates the protocol `sid` independently from the IQ ID,
42    /// matching WhatsApp Web. This neutral operation only returns decoded wire
43    /// data; cache and persistence effects remain in specialized client APIs.
44    pub async fn query_usync(&self, query: UsyncQuery) -> Result<UsyncResponse, IqError> {
45        let sid = self.generate_request_id();
46        let spec = wacore::iq::usync::UsyncQuerySpec::new(query, sid)
47            .map_err(|error| IqError::EncodeError(error.into()))?;
48        self.execute(spec).await
49    }
50
51    #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.usync.get_user_devices", level = "debug", skip_all, fields(users = jids.len()), err(Debug)))]
52    pub(crate) async fn get_user_devices(&self, jids: &[Jid]) -> Result<Vec<Jid>, anyhow::Error> {
53        let mut owned = Vec::with_capacity(jids.len());
54        owned.extend(jids.iter().map(Jid::to_non_ad));
55        self.get_user_devices_owned(owned).await
56    }
57
58    pub(crate) async fn get_user_devices_owned(
59        &self,
60        jids: Vec<Jid>,
61    ) -> Result<Vec<Jid>, anyhow::Error> {
62        let input_len = jids.len();
63        let mut jids_to_fetch: Vec<Jid> = Vec::with_capacity(input_len);
64        let mut all_devices = Vec::with_capacity(input_len * 2);
65
66        // Resolve the LOCAL registry scan concurrently (the network usync below is
67        // already one batched IQ) — a cold-cache large group would otherwise
68        // serialize 256+ per-user cache/DB reads. Order is irrelevant (phash sorts,
69        // encrypt fan-out is order-agnostic). A None result means an empty/corrupt
70        // record, which falls through to the network below (WA Web always keeps
71        // device 0). The stream owns each JID and is drained incrementally, so
72        // no second result Vec is materialized.
73        use futures::StreamExt;
74        // Bounded fan-out over the independent per-user registry reads.
75        const DEVICE_LIST_RESOLVE_CONCURRENCY: usize = 16;
76        let mut resolved = futures::stream::iter(jids.into_iter().map(Jid::into_non_ad))
77            .map(|jid| async move {
78                let devices = self.get_devices_from_registry(&jid).await;
79                (jid, devices)
80            })
81            .buffer_unordered(DEVICE_LIST_RESOLVE_CONCURRENCY);
82
83        while let Some((jid, devices)) = resolved.next().await {
84            match devices {
85                Some(devices) => all_devices.extend(devices),
86                None => {
87                    jids_to_fetch.push(jid);
88                }
89            }
90        }
91
92        if !jids_to_fetch.is_empty() {
93            wacore::types::jid::sort_dedup_by_user(&mut jids_to_fetch);
94            debug!(
95                "get_user_devices: Cache miss, fetching from network for {} unique users",
96                jids_to_fetch.len()
97            );
98            all_devices.extend(self.fetch_user_devices(jids_to_fetch).await?);
99        }
100
101        Ok(all_devices)
102    }
103
104    pub(crate) async fn refresh_user_devices(
105        &self,
106        mut jids: Vec<Jid>,
107    ) -> Result<Vec<Jid>, anyhow::Error> {
108        for jid in &mut jids {
109            jid.agent = 0;
110            jid.device = 0;
111        }
112        wacore::types::jid::sort_dedup_by_user(&mut jids);
113        self.fetch_user_devices_with_freshness(jids, crate::cache::Freshness::Refresh)
114            .await
115    }
116
117    async fn fetch_user_devices(&self, jids: Vec<Jid>) -> Result<Vec<Jid>, anyhow::Error> {
118        self.fetch_user_devices_with_freshness(jids, crate::cache::Freshness::CachePreferred)
119            .await
120    }
121
122    async fn fetch_user_devices_with_freshness(
123        &self,
124        mut jids: Vec<Jid>,
125        freshness: crate::cache::Freshness,
126    ) -> Result<Vec<Jid>, anyhow::Error> {
127        if jids.is_empty() {
128            return Ok(Vec::new());
129        }
130        if freshness == crate::cache::Freshness::CachePreferred {
131            let sid = self.generate_request_id();
132            let response = self.execute(DeviceListSpec::new(jids, sid)).await?;
133            return self
134                .process_device_list_response(&response, freshness)
135                .await;
136        }
137
138        for attempt in 0..DEVICE_REFRESH_MAX_ATTEMPTS {
139            let topology_generation = self.device_topology.current();
140            let sid = self.generate_request_id();
141            let response = self
142                .execute(DeviceListSpec::new(jids, sid).require_complete_response())
143                .await?;
144
145            if let Some(devices) = self
146                .try_process_refreshed_device_list_response(
147                    &response,
148                    freshness,
149                    topology_generation,
150                )
151                .await?
152            {
153                return Ok(devices);
154            }
155
156            if attempt + 1 == DEVICE_REFRESH_MAX_ATTEMPTS {
157                anyhow::bail!(
158                    "device registry kept changing while an authoritative refresh was in flight"
159                );
160            }
161
162            // The complete response contains every requested identity. Move its
163            // canonical JIDs into the next attempt only on this rare race path;
164            // the ordinary refresh performs no duplicate query-vector clone.
165            jids = response
166                .device_lists
167                .into_iter()
168                .map(|user| user.user)
169                .collect();
170        }
171
172        unreachable!("bounded device refresh loop always returns")
173    }
174
175    async fn learn_device_list_mappings_guarded(
176        &self,
177        response: &DeviceListResponse,
178        guard: &crate::lid_pn_cache::LidPnMutationGuard<'_>,
179    ) -> Result<(), anyhow::Error> {
180        // Learn LID↔PN mappings via the same batched, guarded learner query_info
181        // uses (one detached transaction, skipping already-durable pairs), so
182        // per-mapping DB writes stay off the send's critical path. Client
183        // construction always installs `self_weak`; failing the impossible
184        // upgrade keeps mapping and registry publication atomic.
185        //
186        // Ordering: the old per-mapping path AWAITED migrate_signal_sessions_on_lid_discovery.
187        // Detaching it can let a standalone usync (sync_own_device_list /
188        // flush_pending_device_sync) that is the FIRST learner of a LID for a
189        // contact with prior PN Signal state encrypt before the PN-wins migration
190        // runs — but the per-address session_lock_for both take is the real
191        // barrier (they can't interleave). The group-send path is unchanged:
192        // query_info already learns these same pairs detached upstream.
193        if response.lid_mappings.is_empty() {
194            return Ok(());
195        }
196        let client = self
197            .self_weak
198            .get()
199            .and_then(|weak| weak.upgrade())
200            .ok_or_else(|| anyhow::anyhow!("client ownership unavailable during device sync"))?;
201        let mappings: Vec<(String, String)> = response
202            .lid_mappings
203            .iter()
204            .map(|mapping| (mapping.lid.to_string(), mapping.phone_number.to_string()))
205            .collect();
206        client
207            .learn_lid_pn_mappings_batch_guarded(
208                mappings,
209                crate::lid_pn_cache::LearningSource::Usync,
210                false,
211                guard,
212            )
213            .await;
214        Ok(())
215    }
216
217    /// Apply a usync device-list response to the registry: persist LID mappings,
218    /// rebuild each returned user's `DeviceListRecord` (preserving key indices and
219    /// handling raw_id identity changes), and batch-write them. Returns the
220    /// resolved device JIDs for the users present in the response.
221    ///
222    /// Users the server OMITS — unchanged ones, when we sent a `device_hash` — are
223    /// simply absent here, so their cached records are left untouched (the
224    /// merge-safe behavior the `device_hash` optimization depends on).
225    #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.usync.process_device_list", level = "debug", skip_all, fields(users = response.device_lists.len())))]
226    async fn process_device_list_response(
227        &self,
228        response: &DeviceListResponse,
229        freshness: crate::cache::Freshness,
230    ) -> Result<Vec<Jid>, anyhow::Error> {
231        // Keep this order stable: mapping writers never acquire the registry
232        // guard while holding their lock, so mapping -> registry cannot cycle.
233        let mapping_guard = self.lid_pn_cache.lock_mutation().await;
234        let registry_guard = self.device_topology.lock_registry().await;
235        self.learn_device_list_mappings_guarded(response, &mapping_guard)
236            .await?;
237        self.process_device_list_response_guarded(response, freshness, &registry_guard)
238            .await
239    }
240
241    async fn try_process_refreshed_device_list_response(
242        &self,
243        response: &DeviceListResponse,
244        freshness: crate::cache::Freshness,
245        topology_generation: u64,
246    ) -> Result<Option<Vec<Jid>>, anyhow::Error> {
247        // Serialize both mutation classes across the CAS and publication. The
248        // response's own mappings are deliberately learned only after the CAS.
249        let mapping_guard = self.lid_pn_cache.lock_mutation().await;
250        let registry_guard = self.device_topology.lock_registry().await;
251        if !self
252            .device_topology
253            .unchanged_for(topology_generation, |user| {
254                device_response_contains_user(response, user)
255            })
256        {
257            return Ok(None);
258        }
259        self.learn_device_list_mappings_guarded(response, &mapping_guard)
260            .await?;
261        self.process_device_list_response_guarded(response, freshness, &registry_guard)
262            .await
263            .map(Some)
264    }
265
266    async fn process_device_list_response_guarded(
267        &self,
268        response: &DeviceListResponse,
269        freshness: crate::cache::Freshness,
270        guard: &crate::client::device_topology::DeviceRegistryMutationGuard<'_>,
271    ) -> Result<Vec<Jid>, anyhow::Error> {
272        let mut fetched_devices = Vec::with_capacity(response.device_lists.len());
273        let mut device_records: Vec<wacore::store::traits::DeviceListRecord> =
274            Vec::with_capacity(response.device_lists.len());
275        struct PendingIdentityReset<'a> {
276            user: &'a Jid,
277            previous: wacore::store::traits::DeviceListRecord,
278            invalidate_registry: bool,
279        }
280        // Identity changes are rare, so this stays allocation-free for the
281        // ordinary response. More importantly, deferring the destructive work
282        // lets an authoritative refresh validate every user before any prior
283        // Signal sessions or registry snapshot are discarded.
284        let mut pending_identity_resets = Vec::new();
285
286        for user_list in &response.device_lists {
287            // Update device registry (single source of truth for device lists).
288            // Preserve key_index values from existing records (set via account_sync)
289            // Use alias-aware lookup (resolves LID ↔ PN) to find
290            // existing record regardless of which key it was stored under
291            let mut existing_record = self.load_device_record(&user_list.user.user).await;
292
293            // Decode key-index-list if present (WA Web: handleKeyIndexResult)
294            let decoded_key_index = user_list
295                .key_index_bytes
296                .as_deref()
297                .and_then(wacore::adv::decode_key_index_list);
298
299            // Check raw_id mismatch for identity change detection
300            // TODO: also check advAccountType mismatch (see patch_device_add TODO)
301            let mut raw_id = decoded_key_index.as_ref().map(|d| d.raw_id);
302            let pending_identity_reset = if let Some(ref decoded) = decoded_key_index
303                && let Some(ref existing) = existing_record
304                && let Some(stored_raw_id) = existing.raw_id
305                && stored_raw_id != decoded.raw_id
306            {
307                log::info!(
308                    "raw_id mismatch for user {} in usync: stored={stored_raw_id}, received={}. Scheduling record reset.",
309                    user_list.user.user,
310                    decoded.raw_id
311                );
312                existing_record.take()
313            } else {
314                None
315            };
316
317            // Preserve raw_id from existing when usync didn't provide one
318            // (no key-index-list). An identity change takes the old record out
319            // above, so its raw_id and key indices cannot leak into the new one.
320            if raw_id.is_none() {
321                raw_id = existing_record
322                    .as_ref()
323                    .filter(|record| !record.devices.is_empty())
324                    .and_then(|record| record.raw_id);
325            }
326
327            let mut devices: Vec<wacore::store::traits::DeviceInfo> = user_list
328                .devices
329                .iter()
330                .map(|d| {
331                    // Server-returned key_index takes priority over cached
332                    let key_index = d.key_index.or_else(|| {
333                        // Accounts ordinarily have only a handful of companion
334                        // devices; a short scan avoids allocating a HashMap for
335                        // every user in a large fanout response.
336                        existing_record.as_ref().and_then(|record| {
337                            record
338                                .devices
339                                .iter()
340                                .find(|cached| cached.device_id == d.device as u32)
341                                .and_then(|cached| cached.key_index)
342                        })
343                    });
344                    wacore::store::traits::DeviceInfo::new(d.device as u32, key_index)
345                        .with_hosting(d.is_hosted)
346                })
347                .collect();
348
349            // Apply valid_indexes filtering if key-index-list was decoded
350            if let Some(ref decoded) = decoded_key_index {
351                wacore::adv::retain_devices_by_key_index(&mut devices, decoded);
352            }
353
354            // An empty device list is never valid — WA Web always keeps the primary
355            // (device 0), so a usync returning no devices for a user is transient or
356            // corrupt. Persisting it would clobber a good cached record, or store an
357            // empty one that get_user_devices then re-fetches on every send.
358            if devices.is_empty() {
359                if freshness == crate::cache::Freshness::Refresh {
360                    anyhow::bail!(
361                        "device-list refresh left no valid devices for {}",
362                        user_list.user
363                    );
364                }
365                if let Some(previous) = pending_identity_reset {
366                    pending_identity_resets.push(PendingIdentityReset {
367                        user: &user_list.user,
368                        previous,
369                        // No replacement record will be written. Keeping the old
370                        // snapshot would pair a new identity with stale devices
371                        // and suppress the next authoritative network fetch.
372                        invalidate_registry: true,
373                    });
374                }
375                continue;
376            }
377
378            if let Some(previous) = pending_identity_reset {
379                pending_identity_resets.push(PendingIdentityReset {
380                    user: &user_list.user,
381                    previous,
382                    invalidate_registry: false,
383                });
384            }
385
386            // Convert filtered DeviceInfo list back to JIDs for return
387            let user_jid = &user_list.user;
388            for d in &devices {
389                fetched_devices.push(user_jid.with_device_hosting(d.device_id as u16, d.is_hosted));
390            }
391
392            device_records.push(wacore::store::traits::DeviceListRecord {
393                user: user_list.user.user.to_string(),
394                devices,
395                timestamp: wacore::time::now_secs(),
396                phash: user_list.phash.clone(),
397                raw_id,
398            });
399        }
400
401        // All strict validation has completed. Apply identity cleanup before
402        // publishing replacement snapshots so no send can pair a new registry
403        // record with sessions established under the previous identity.
404        for reset in pending_identity_resets {
405            self.clear_device_record(
406                &reset.user.user,
407                reset.user.server.as_str(),
408                &reset.previous,
409            )
410            .await;
411            if reset.invalidate_registry {
412                self.invalidate_device_cache_guarded(&reset.user.user, guard)
413                    .await;
414            }
415        }
416
417        // One batched backend write for the whole usync response — for
418        // large groups this collapses N spawn_blocking SQLite hops into
419        // a single transaction, which dominated the per-send wall-clock.
420        if let Err(e) = self
421            .update_device_lists_guarded(device_records, guard)
422            .await
423        {
424            warn!("Failed to update device registry batch: {e}");
425        }
426
427        Ok(fetched_devices)
428    }
429
430    /// Re-sync own device list from the server. Mirrors WA Web `syncMyDeviceList`:
431    /// sends the cached per-user `device_hash` so the server answers "unchanged"
432    /// (by omitting the user) instead of returning the full list on every reconnect.
433    /// On a changed list the server returns it and we update; omitted users keep
434    /// their cache.
435    #[cfg_attr(
436        feature = "tracing",
437        tracing::instrument(
438            name = "wa.usync.sync_own_device_list",
439            level = "debug",
440            skip_all,
441            err(Debug)
442        )
443    )]
444    pub(crate) async fn sync_own_device_list(&self) -> Result<(), anyhow::Error> {
445        let device_snapshot = self.persistence_manager.get_device_snapshot();
446
447        let mut jids = Vec::with_capacity(2);
448        let mut hashes: std::collections::HashMap<Jid, (String, i64)> =
449            std::collections::HashMap::new();
450        for own in device_snapshot.pn.iter().chain(device_snapshot.lid.iter()) {
451            let bare = own.to_non_ad();
452            // Carry the cached device_hash so an unchanged list is skipped server-side.
453            if let Some(record) = self.load_device_record(&bare.user).await
454                && let Some(phash) = record.phash
455            {
456                hashes.insert(bare.clone(), (phash, record.timestamp));
457            }
458            jids.push(bare);
459        }
460
461        if jids.is_empty() {
462            return Ok(());
463        }
464
465        let sid = self.generate_request_id();
466        let spec = DeviceListSpec::with_hashes(jids, sid, hashes);
467        let response = self.execute(spec).await?;
468        // `process_device_list_response` only touches users the server actually
469        // returned, so unchanged (omitted) own devices keep their cache.
470        let devices = self
471            .process_device_list_response(&response, crate::cache::Freshness::CachePreferred)
472            .await?;
473        log::info!(
474            "Re-synced own device list: {} device(s) updated",
475            devices.len()
476        );
477        Ok(())
478    }
479
480    /// WA Web: `doPendingDeviceSync()` — flush batched unknown-device users.
481    #[cfg_attr(
482        feature = "tracing",
483        tracing::instrument(
484            name = "wa.usync.flush_pending_device_sync",
485            level = "debug",
486            skip_all
487        )
488    )]
489    pub(crate) async fn flush_pending_device_sync(&self) {
490        let pending = self.pending_device_sync.take_all().await;
491        if pending.is_empty() {
492            return;
493        }
494
495        debug!("Flushing pending device sync for {} users", pending.len());
496
497        // Invalidate stale records so get_user_devices hits the network
498        for jid in &pending {
499            self.invalidate_device_cache(&jid.user).await;
500        }
501
502        match self.get_user_devices(&pending).await {
503            Ok(devices) => {
504                debug!(
505                    "Pending device sync completed: {} devices across {} users",
506                    devices.len(),
507                    pending.len()
508                );
509            }
510            Err(e) => {
511                warn!(
512                    "Pending device sync failed, re-enqueueing {} users: {e:?}",
513                    pending.len()
514                );
515                for jid in &pending {
516                    self.pending_device_sync.add(jid).await;
517                }
518            }
519        }
520    }
521}
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526    use crate::cache::Freshness;
527    use crate::test_utils::create_test_client;
528    use wacore::libsignal::protocol::{ProtocolAddress, SessionRecord};
529    use wacore::store::traits::{DeviceInfo, DeviceListRecord};
530    use wacore::types::jid::JidExt;
531
532    fn signed_key_index_bytes(valid_indexes: Vec<u32>, current_index: u32) -> Vec<u8> {
533        let key_index = waproto::whatsapp::ADVKeyIndexList {
534            raw_id: Some(1),
535            timestamp: Some(1_700_000_000),
536            current_index: Some(current_index),
537            valid_indexes,
538            ..Default::default()
539        };
540        let signed = waproto::whatsapp::ADVSignedKeyIndexList {
541            details: Some(waproto::codec::adv_key_index_list_to_vec(&key_index)),
542            ..Default::default()
543        };
544        waproto::codec::adv_signed_key_index_list_to_vec(&signed)
545    }
546
547    async fn seed_fresh_session(client: &Client, jid: &Jid) -> ProtocolAddress {
548        let address = jid.to_protocol_address();
549        client
550            .signal_cache
551            .put_session(&address, SessionRecord::new_fresh())
552            .await;
553        address
554    }
555
556    async fn has_session(client: &Client, address: &ProtocolAddress) -> bool {
557        let snapshot = client.persistence_manager.get_device_snapshot();
558        client
559            .signal_cache
560            .has_session(address, &*snapshot.backend)
561            .await
562            .unwrap()
563    }
564
565    #[tokio::test]
566    async fn test_device_registry_hit_resolves_devices() {
567        let client = create_test_client().await;
568
569        let user_jid: Jid = "1234567890@s.whatsapp.net".parse().unwrap();
570
571        // Insert a device record into the registry (simulates prior usync/notification)
572        let record = DeviceListRecord {
573            user: "1234567890".into(),
574            devices: vec![DeviceInfo::new(0, None), DeviceInfo::new(3, Some(10))],
575            timestamp: wacore::time::now_secs(),
576            phash: None,
577            raw_id: None,
578        };
579        client.update_device_list(record).await.unwrap();
580
581        // get_user_devices should resolve from registry without network
582        let devices = client.get_user_devices(&[user_jid]).await.unwrap();
583        assert_eq!(devices.len(), 2);
584        assert!(devices.iter().any(|d| d.device == 0));
585        assert!(devices.iter().any(|d| d.device == 3));
586        assert!(devices.iter().all(|d| d.is_pn()));
587    }
588
589    #[tokio::test]
590    async fn refresh_bypasses_a_warm_registry_without_clearing_it_first() {
591        let client = create_test_client().await;
592        let user: Jid = "12025550102@s.whatsapp.net".parse().unwrap();
593        client
594            .update_device_list(DeviceListRecord {
595                user: "12025550102".into(),
596                devices: vec![DeviceInfo::new(0, None), DeviceInfo::new(8, None)],
597                timestamp: wacore::time::now_secs(),
598                phash: None,
599                raw_id: None,
600            })
601            .await
602            .unwrap();
603
604        let cached = client
605            .get_user_devices(std::slice::from_ref(&user))
606            .await
607            .unwrap();
608        assert_eq!(
609            cached.len(),
610            2,
611            "cache-preferred must use the warm snapshot"
612        );
613
614        let refresh = client.refresh_user_devices(vec![user.clone()]).await;
615        assert!(
616            refresh.is_err(),
617            "the offline fixture proves refresh consulted the source"
618        );
619
620        let preserved = client
621            .get_devices_from_registry(&user)
622            .await
623            .expect("a failed refresh must leave the previous snapshot readable");
624        assert_eq!(preserved.len(), 2);
625        assert!(preserved.iter().any(|device| device.device == 8));
626    }
627
628    #[tokio::test]
629    async fn test_device_registry_hit_for_lid_jid() {
630        let client = create_test_client().await;
631
632        let lid_jid: Jid = "100000012345678@lid".parse().unwrap();
633
634        let record = DeviceListRecord {
635            user: "100000012345678".into(),
636            devices: vec![DeviceInfo::new(0, None), DeviceInfo::new(39, Some(25))],
637            timestamp: wacore::time::now_secs(),
638            phash: None,
639            raw_id: None,
640        };
641        client.update_device_list(record).await.unwrap();
642
643        let devices = client.get_user_devices(&[lid_jid]).await.unwrap();
644        assert_eq!(devices.len(), 2);
645        assert!(devices.iter().any(|d| d.device == 0));
646        assert!(devices.iter().any(|d| d.device == 39));
647        assert!(devices.iter().all(|d| d.is_lid()));
648    }
649
650    #[tokio::test]
651    async fn test_device_registry_db_fallback() {
652        let client = create_test_client().await;
653
654        let user_jid: Jid = "9876543210@s.whatsapp.net".parse().unwrap();
655
656        // Insert into backend DB via update_device_list
657        let record = DeviceListRecord {
658            user: "9876543210".into(),
659            devices: vec![DeviceInfo::new(5, None)],
660            timestamp: wacore::time::now_secs(),
661            phash: None,
662            raw_id: None,
663        };
664        client.update_device_list(record).await.unwrap();
665
666        // Evict from registry cache to force DB path
667        client
668            .device_registry_cache
669            .raw_invalidate_for_tests("9876543210")
670            .await;
671        client.device_registry_cache.run_pending_tasks().await;
672
673        // Should still resolve from DB
674        let devices = client.get_user_devices(&[user_jid]).await.unwrap();
675        assert_eq!(devices.len(), 1);
676        assert_eq!(devices[0].device, 5);
677    }
678
679    // A present-but-empty device record is local corruption and must not be
680    // treated as authoritative: get_user_devices falls through to the network so
681    // the list can self-heal. Offline, that surfaces as an error, which proves the
682    // fetch was attempted (the old behavior returned Ok([]) with no fetch).
683    #[tokio::test]
684    async fn test_empty_device_record_falls_through_to_network() {
685        let client = create_test_client().await;
686
687        let user_jid: Jid = "5551230000@s.whatsapp.net".parse().unwrap();
688
689        let record = DeviceListRecord {
690            user: "5551230000".into(),
691            devices: vec![],
692            timestamp: wacore::time::now_secs(),
693            phash: None,
694            raw_id: None,
695        };
696        client.update_device_list(record).await.unwrap();
697
698        let result = client.get_user_devices(&[user_jid]).await;
699        assert!(
700            result.is_err(),
701            "empty record must fall through to the network, got {result:?}"
702        );
703    }
704
705    #[tokio::test]
706    async fn test_cache_size_eviction() {
707        use crate::cache::Cache;
708
709        let cache: Cache<i32, String> = Cache::builder().max_capacity(2).build();
710
711        cache.insert(1, "one".to_string()).await;
712        cache.insert(2, "two".to_string()).await;
713        cache.insert(3, "three".to_string()).await;
714
715        cache.run_pending_tasks().await;
716
717        let count = cache.entry_count();
718        assert!(
719            count <= 2,
720            "Cache should have at most 2 items, has {}",
721            count
722        );
723    }
724
725    /// #3 merge-safety: when the server omits an unchanged user (the `device_hash`
726    /// skip), `process_device_list_response` must update only the returned users
727    /// and leave the omitted user's cached devices untouched.
728    #[tokio::test]
729    async fn process_response_preserves_omitted_users() {
730        use wacore::iq::usync::DeviceListResponse;
731        use wacore::usync::{UserDeviceList, UsyncDevice};
732
733        let client = create_test_client().await;
734
735        // Seed user B in the registry (will be the "unchanged/omitted" one).
736        client
737            .update_device_list(DeviceListRecord {
738                user: "2222222222".into(),
739                devices: vec![DeviceInfo::new(0, None), DeviceInfo::new(7, None)],
740                timestamp: wacore::time::now_secs(),
741                phash: Some("2:oldB".to_string()),
742                raw_id: None,
743            })
744            .await
745            .unwrap();
746
747        // Response only contains user A — B is omitted (unchanged).
748        let response = DeviceListResponse {
749            device_lists: vec![UserDeviceList {
750                user: "1111111111@s.whatsapp.net".parse().unwrap(),
751                devices: vec![UsyncDevice::new(0, None)],
752                phash: Some("2:a".to_string()),
753                key_index_bytes: None,
754            }],
755            lid_mappings: vec![],
756        };
757
758        let fetched = client
759            .process_device_list_response(&response, Freshness::CachePreferred)
760            .await
761            .unwrap();
762        assert!(
763            fetched.iter().any(|j| j.user == "1111111111"),
764            "returned user A must be resolved"
765        );
766
767        // B's cache is preserved (still 2 devices) — not wiped by the omission.
768        let b_jid: Jid = "2222222222@s.whatsapp.net".parse().unwrap();
769        let b_devices = client
770            .get_devices_from_registry(&b_jid)
771            .await
772            .expect("omitted user B must keep its cached record");
773        assert_eq!(
774            b_devices.len(),
775            2,
776            "omitted user's devices must be preserved"
777        );
778    }
779
780    #[tokio::test]
781    async fn process_response_preserves_hosted_device_addressing() {
782        use wacore::iq::usync::DeviceListResponse;
783        use wacore::usync::{UserDeviceList, UsyncDevice};
784
785        let client = create_test_client().await;
786        let user = Jid::pn("1111111111");
787        let response = DeviceListResponse {
788            device_lists: vec![UserDeviceList {
789                user: user.clone(),
790                devices: vec![
791                    UsyncDevice::new(0, None),
792                    UsyncDevice::new(7, Some(3)).with_hosting(true),
793                ],
794                phash: Some("2:hosted".to_string()),
795                key_index_bytes: None,
796            }],
797            lid_mappings: Vec::new(),
798        };
799
800        let fetched = client
801            .process_device_list_response(&response, Freshness::CachePreferred)
802            .await
803            .unwrap();
804        assert!(
805            fetched
806                .iter()
807                .any(|jid| jid.device == 7 && jid.server == wacore_binary::Server::Hosted)
808        );
809
810        let cached = client
811            .get_devices_from_registry(&user)
812            .await
813            .expect("processed devices should be cached");
814        assert!(
815            cached
816                .iter()
817                .any(|jid| jid.device == 7 && jid.server == wacore_binary::Server::Hosted)
818        );
819    }
820
821    #[tokio::test]
822    async fn stale_refresh_does_not_overwrite_a_newer_device_notification() {
823        use wacore::usync::{UserDeviceList, UsyncDevice};
824
825        let client = create_test_client().await;
826        let user = Jid::pn("12025550102");
827        client
828            .update_device_list(DeviceListRecord {
829                user: user.user.to_string(),
830                devices: vec![DeviceInfo::new(0, None)],
831                timestamp: wacore::time::now_secs(),
832                phash: Some("1:before".to_string()),
833                raw_id: None,
834            })
835            .await
836            .unwrap();
837
838        let refresh_started_at = client.device_topology.current();
839        let stale_response = DeviceListResponse {
840            device_lists: vec![UserDeviceList {
841                user: user.clone(),
842                devices: vec![UsyncDevice::new(0, None)],
843                phash: Some("1:stale".to_string()),
844                key_index_bytes: None,
845            }],
846            lid_mappings: Vec::new(),
847        };
848
849        client
850            .patch_device_add(
851                &user.user,
852                &wacore::stanza::devices::DeviceElement {
853                    jid: user.with_device(7),
854                    key_index: None,
855                    lid: None,
856                },
857                None,
858            )
859            .await;
860
861        let published = client
862            .try_process_refreshed_device_list_response(
863                &stale_response,
864                Freshness::Refresh,
865                refresh_started_at,
866            )
867            .await
868            .unwrap();
869        assert!(published.is_none(), "the stale response must be retried");
870
871        let retained = client
872            .get_devices_from_registry(&user)
873            .await
874            .expect("notification snapshot must remain available");
875        assert!(retained.iter().any(|device| device.device == 7));
876    }
877
878    #[tokio::test]
879    async fn stale_refresh_does_not_overwrite_a_newer_lid_mapping() {
880        use wacore::usync::UsyncLidMapping;
881
882        let client = create_test_client().await;
883        let phone = "12025550110";
884        let current_lid = "100000000000110";
885        let stale_lid = "100000000000111";
886        let refresh_started_at = client.device_topology.current();
887
888        client
889            .add_lid_pn_mapping(
890                current_lid,
891                phone,
892                crate::lid_pn_cache::LearningSource::PeerPnMessage,
893            )
894            .await
895            .unwrap();
896
897        let stale_response = DeviceListResponse {
898            device_lists: Vec::new(),
899            lid_mappings: vec![UsyncLidMapping {
900                phone_number: phone.into(),
901                lid: stale_lid.into(),
902            }],
903        };
904        let published = client
905            .try_process_refreshed_device_list_response(
906                &stale_response,
907                Freshness::Refresh,
908                refresh_started_at,
909            )
910            .await
911            .unwrap();
912
913        assert!(published.is_none(), "the stale response must be retried");
914        assert_eq!(
915            client.lid_pn_cache.get_current_lid(phone).await.as_deref(),
916            Some(current_lid),
917            "the mapping learned after the refresh started must survive"
918        );
919    }
920
921    #[tokio::test]
922    async fn refresh_commits_its_own_lid_mapping_after_the_cas() {
923        use wacore::usync::UsyncLidMapping;
924
925        let client = create_test_client().await;
926        let phone = "12025550112";
927        let lid = "100000000000112";
928        let refresh_started_at = client.device_topology.current();
929        let response = DeviceListResponse {
930            device_lists: Vec::new(),
931            lid_mappings: vec![UsyncLidMapping {
932                phone_number: phone.into(),
933                lid: lid.into(),
934            }],
935        };
936
937        let published = client
938            .try_process_refreshed_device_list_response(
939                &response,
940                Freshness::Refresh,
941                refresh_started_at,
942            )
943            .await
944            .unwrap();
945
946        assert!(
947            published.is_some(),
948            "the response must not conflict with itself"
949        );
950        assert_eq!(
951            client.lid_pn_cache.get_current_lid(phone).await.as_deref(),
952            Some(lid)
953        );
954    }
955
956    #[tokio::test]
957    async fn unrelated_registry_change_does_not_restart_a_refresh() {
958        use wacore::usync::{UserDeviceList, UsyncDevice};
959
960        let client = create_test_client().await;
961        let refreshed = Jid::pn("12025550104");
962        let refresh_started_at = client.device_topology.current();
963        client
964            .update_device_list(DeviceListRecord {
965                user: "12025550105".to_string(),
966                devices: vec![DeviceInfo::new(0, None)],
967                timestamp: wacore::time::now_secs(),
968                phash: None,
969                raw_id: None,
970            })
971            .await
972            .unwrap();
973
974        let response = DeviceListResponse {
975            device_lists: vec![UserDeviceList {
976                user: refreshed,
977                devices: vec![UsyncDevice::new(0, None)],
978                phash: None,
979                key_index_bytes: None,
980            }],
981            lid_mappings: Vec::new(),
982        };
983        let published = client
984            .try_process_refreshed_device_list_response(
985                &response,
986                Freshness::Refresh,
987                refresh_started_at,
988            )
989            .await
990            .unwrap();
991        assert!(published.is_some());
992    }
993
994    #[tokio::test]
995    async fn unrelated_mapping_change_does_not_restart_a_refresh() {
996        use wacore::usync::{UserDeviceList, UsyncDevice};
997
998        let client = create_test_client().await;
999        let refreshed = Jid::pn("12025550113");
1000        let refresh_started_at = client.device_topology.current();
1001        client
1002            .add_lid_pn_mapping(
1003                "100000000000114",
1004                "12025550114",
1005                crate::lid_pn_cache::LearningSource::PeerPnMessage,
1006            )
1007            .await
1008            .unwrap();
1009
1010        let response = DeviceListResponse {
1011            device_lists: vec![UserDeviceList {
1012                user: refreshed,
1013                devices: vec![UsyncDevice::new(0, None)],
1014                phash: None,
1015                key_index_bytes: None,
1016            }],
1017            lid_mappings: Vec::new(),
1018        };
1019        let published = client
1020            .try_process_refreshed_device_list_response(
1021                &response,
1022                Freshness::Refresh,
1023                refresh_started_at,
1024            )
1025            .await
1026            .unwrap();
1027
1028        assert!(published.is_some());
1029    }
1030
1031    /// A usync that returns an empty device list for a user is transient or
1032    /// corrupt (WA Web always keeps device 0). `process_device_list_response`
1033    /// must not persist it: a good cached record stays intact instead of being
1034    /// clobbered with an empty list that `get_user_devices` then re-fetches on
1035    /// every send.
1036    #[tokio::test]
1037    async fn process_response_skips_empty_device_list() {
1038        use wacore::usync::UserDeviceList;
1039
1040        let client = create_test_client().await;
1041
1042        client
1043            .update_device_list(DeviceListRecord {
1044                user: "3333333333".into(),
1045                devices: vec![DeviceInfo::new(0, None), DeviceInfo::new(4, None)],
1046                timestamp: wacore::time::now_secs(),
1047                phash: Some("3:old".to_string()),
1048                raw_id: None,
1049            })
1050            .await
1051            .unwrap();
1052
1053        // The same user comes back from usync with no devices.
1054        let response = DeviceListResponse {
1055            device_lists: vec![UserDeviceList {
1056                user: "3333333333@s.whatsapp.net".parse().unwrap(),
1057                devices: vec![],
1058                phash: Some("3:empty".to_string()),
1059                key_index_bytes: None,
1060            }],
1061            lid_mappings: vec![],
1062        };
1063
1064        let fetched = client
1065            .process_device_list_response(&response, Freshness::CachePreferred)
1066            .await
1067            .unwrap();
1068        assert!(
1069            !fetched.iter().any(|j| j.user == "3333333333"),
1070            "an empty returned list contributes no devices"
1071        );
1072
1073        // The good cached record survives — not clobbered with an empty list.
1074        let jid: Jid = "3333333333@s.whatsapp.net".parse().unwrap();
1075        let devices = client
1076            .get_devices_from_registry(&jid)
1077            .await
1078            .expect("the good record must survive an empty usync response");
1079        assert_eq!(
1080            devices.len(),
1081            2,
1082            "empty response must not clobber the record"
1083        );
1084    }
1085
1086    #[tokio::test]
1087    async fn refresh_rejects_a_device_list_emptied_by_key_index_filtering() {
1088        use wacore::usync::{UserDeviceList, UsyncDevice};
1089
1090        let client = create_test_client().await;
1091        let user = Jid::pn("4444444444");
1092        client
1093            .update_device_list(DeviceListRecord {
1094                user: user.user.to_string(),
1095                devices: vec![DeviceInfo::new(0, None), DeviceInfo::new(7, Some(3))],
1096                timestamp: wacore::time::now_secs(),
1097                phash: Some("2:previous".to_string()),
1098                raw_id: Some(1),
1099            })
1100            .await
1101            .unwrap();
1102
1103        // The wire response is non-empty, but its only companion is outside the
1104        // signed key-index set. This exercises the post-projection completeness
1105        // check rather than the raw USync response check.
1106        let response = DeviceListResponse {
1107            device_lists: vec![UserDeviceList {
1108                user: user.clone(),
1109                devices: vec![UsyncDevice::new(7, Some(3))],
1110                phash: Some("2:incomplete".to_string()),
1111                key_index_bytes: Some(signed_key_index_bytes(Vec::new(), 10)),
1112            }],
1113            lid_mappings: Vec::new(),
1114        };
1115
1116        let error = client
1117            .process_device_list_response(&response, Freshness::Refresh)
1118            .await
1119            .expect_err("an authoritative refresh must not return an empty projection");
1120        assert!(error.to_string().contains("no valid devices"));
1121
1122        let preserved = client
1123            .get_devices_from_registry(&user)
1124            .await
1125            .expect("a rejected refresh must preserve the previous snapshot");
1126        assert_eq!(preserved.len(), 2);
1127        assert!(preserved.iter().any(|device| device.device == 7));
1128    }
1129
1130    #[tokio::test]
1131    async fn rejected_refresh_defers_identity_cleanup_for_every_user() {
1132        use wacore::usync::{UserDeviceList, UsyncDevice};
1133
1134        let client = create_test_client().await;
1135        let identity_changed = Jid::pn("4444444451");
1136        let invalid = Jid::pn("4444444452");
1137
1138        for (user, raw_id) in [(&identity_changed, 2), (&invalid, 1)] {
1139            client
1140                .update_device_list(DeviceListRecord {
1141                    user: user.user.to_string(),
1142                    devices: vec![DeviceInfo::new(0, None), DeviceInfo::new(7, Some(3))],
1143                    timestamp: wacore::time::now_secs(),
1144                    phash: Some("2:previous".to_string()),
1145                    raw_id: Some(raw_id),
1146                })
1147                .await
1148                .unwrap();
1149        }
1150
1151        let previous_session = seed_fresh_session(&client, &identity_changed.with_device(7)).await;
1152        let response = DeviceListResponse {
1153            device_lists: vec![
1154                UserDeviceList {
1155                    user: identity_changed.clone(),
1156                    // A primary device survives key-index filtering, so this
1157                    // first user schedules a valid identity replacement.
1158                    devices: vec![UsyncDevice::new(0, None)],
1159                    phash: Some("1:changed".to_string()),
1160                    key_index_bytes: Some(signed_key_index_bytes(Vec::new(), 10)),
1161                },
1162                UserDeviceList {
1163                    user: invalid,
1164                    // The second user makes the authoritative response invalid
1165                    // only after key-index projection.
1166                    devices: vec![UsyncDevice::new(7, Some(3))],
1167                    phash: Some("1:invalid".to_string()),
1168                    key_index_bytes: Some(signed_key_index_bytes(Vec::new(), 10)),
1169                },
1170            ],
1171            lid_mappings: Vec::new(),
1172        };
1173
1174        client
1175            .process_device_list_response(&response, Freshness::Refresh)
1176            .await
1177            .expect_err("the second user must reject the whole authoritative refresh");
1178
1179        assert!(
1180            has_session(&client, &previous_session).await,
1181            "validation failure must not partially clear an earlier user's sessions"
1182        );
1183        let preserved = client
1184            .get_devices_from_registry(&identity_changed)
1185            .await
1186            .expect("validation failure must preserve the earlier registry snapshot");
1187        assert!(preserved.iter().any(|device| device.device == 7));
1188    }
1189
1190    #[tokio::test]
1191    async fn filtered_identity_change_invalidates_the_stale_registry() {
1192        use wacore::usync::{UserDeviceList, UsyncDevice};
1193
1194        let client = create_test_client().await;
1195        let user = Jid::pn("4444444453");
1196        client
1197            .update_device_list(DeviceListRecord {
1198                user: user.user.to_string(),
1199                devices: vec![DeviceInfo::new(0, None), DeviceInfo::new(7, Some(3))],
1200                timestamp: wacore::time::now_secs(),
1201                phash: Some("2:previous".to_string()),
1202                raw_id: Some(2),
1203            })
1204            .await
1205            .unwrap();
1206        let previous_session = seed_fresh_session(&client, &user.with_device(7)).await;
1207
1208        let response = DeviceListResponse {
1209            device_lists: vec![UserDeviceList {
1210                user: user.clone(),
1211                devices: vec![UsyncDevice::new(7, Some(3))],
1212                phash: Some("1:changed".to_string()),
1213                key_index_bytes: Some(signed_key_index_bytes(Vec::new(), 10)),
1214            }],
1215            lid_mappings: Vec::new(),
1216        };
1217
1218        let fetched = client
1219            .process_device_list_response(&response, Freshness::CachePreferred)
1220            .await
1221            .unwrap();
1222        assert!(fetched.is_empty());
1223        assert!(
1224            !has_session(&client, &previous_session).await,
1225            "an accepted identity change must clear sessions from the old identity"
1226        );
1227        assert!(
1228            client.get_devices_from_registry(&user).await.is_none(),
1229            "without a replacement snapshot, the stale registry must be invalidated"
1230        );
1231    }
1232
1233    /// The batched LID-PN learn path warms the in-memory cache SYNCHRONOUSLY
1234    /// (the persist runs detached), so a mapping from the usync response is
1235    /// resolvable the moment `process_device_list_response` returns. Locks the
1236    /// contract that the new path doesn't defer the cache update.
1237    #[tokio::test]
1238    async fn process_response_warms_lid_pn_cache_synchronously() {
1239        use wacore::usync::UsyncLidMapping;
1240
1241        let client = create_test_client().await;
1242
1243        // Pin that we exercise the BATCHED branch, not the per-mapping fallback:
1244        // the branch is `if let Some(client) = self.self_weak...upgrade()`, so a
1245        // live self_weak upgrade means learn_lid_pn_mappings_batch is the path
1246        // taken. (Both paths warm the cache, so without this the test could pass
1247        // via the fallback.)
1248        assert!(
1249            client.self_weak.get().and_then(|w| w.upgrade()).is_some(),
1250            "fixture must populate self_weak so the batched learner is exercised"
1251        );
1252
1253        let response = DeviceListResponse {
1254            device_lists: vec![],
1255            lid_mappings: vec![UsyncLidMapping {
1256                phone_number: "559980000123".into(),
1257                lid: "100000000000123".into(),
1258            }],
1259        };
1260
1261        assert!(
1262            client
1263                .lid_pn_cache
1264                .get_current_lid("559980000123")
1265                .await
1266                .is_none()
1267        );
1268
1269        client
1270            .process_device_list_response(&response, Freshness::CachePreferred)
1271            .await
1272            .unwrap();
1273
1274        assert_eq!(
1275            client
1276                .lid_pn_cache
1277                .get_current_lid("559980000123")
1278                .await
1279                .as_deref(),
1280            Some("100000000000123"),
1281            "usync LID mapping must be in the cache synchronously after the call"
1282        );
1283    }
1284}