Skip to main content

whatsapp_rust/client/
lid_pn.rs

1//! LID-PN (Linked ID to Phone Number) mapping methods for Client.
2//!
3//! This module contains methods for managing the bidirectional mapping
4//! between LIDs (Linked IDs) and phone numbers.
5//!
6//! Key features:
7//! - Cache warm-up from persistent storage
8//! - Adding new LID-PN mappings with automatic migration
9//! - Resolving JIDs to their LID equivalents
10//! - Bidirectional lookup (LID to PN and PN to LID)
11
12use std::sync::Arc;
13
14use anyhow::Result;
15use log::debug;
16use wacore::iq::usync::LidQuerySpec;
17use wacore::store::traits::{LidPnMappingEntry, SignalStore};
18use wacore_binary::Jid;
19
20use super::Client;
21use crate::lid_pn_cache::{LearningSource, LidPnEntry};
22
23/// Exclusive upper bound for the device-id range we iterate when migrating
24/// PN→LID. WhatsApp's protocol caps companion devices well below this, but
25/// the conservative bound covers paired devices learned via offline syncs
26/// without unbounded looping.
27const MIGRATION_DEVICE_RANGE: u16 = 100;
28
29/// Backend `LidPnMappingEntry` → in-memory `LidPnEntry`.
30fn mapping_to_entry(m: LidPnMappingEntry) -> LidPnEntry {
31    LidPnEntry::with_timestamp(
32        m.lid,
33        m.phone_number,
34        m.created_at,
35        LearningSource::parse(&m.learning_source),
36    )
37}
38
39/// Per-mapping write policy, mirroring WhatsApp Web's `createLidPnMappings`
40/// `switch (learningSource)` (`WAWebDBCreateLidPnMappings`). The learning
41/// source is not mere provenance: it decides whether an incoming pair may
42/// overwrite what the cache already holds.
43///
44/// Inputs (WA Web `c`/`y`/`C`):
45/// - `lid_unseen`: the LID has no phone number cached yet (`c`).
46/// - `exact`: the phone already resolves to this exact LID (`y`).
47/// - derived `lid_known_mismatch = !lid_unseen && !exact` (`C`): the LID is
48///   already known but the phone currently resolves elsewhere.
49///
50/// Returns `(write, needs_usync)`:
51/// - `write` (WA Web `v`): update the cache/DB with this pair.
52/// - `needs_usync` (WA Web `b`): a conservative source hit a conflicting LID;
53///   re-resolve the phone authoritatively via a live LID query instead of
54///   trusting the observational pair. Only ever set when `write` is false.
55fn lid_pn_write_policy(source: LearningSource, lid_unseen: bool, exact: bool) -> (bool, bool) {
56    let lid_known_mismatch = !lid_unseen && !exact;
57    match source {
58        // Device-list usync: authoritative for a new LID and for correcting a
59        // known LID whose phone drifted.
60        LearningSource::Usync => (lid_unseen || lid_known_mismatch, false),
61        // Directed sources: overwrite on any difference from what's cached.
62        LearningSource::PeerPnMessage
63        | LearningSource::PeerLidMessage
64        | LearningSource::RecipientLatestLid
65        | LearningSource::MigrationSyncLatest
66        | LearningSource::MigrationSyncOld
67        | LearningSource::BlocklistActive
68        | LearningSource::BlocklistInactive => (!exact, false),
69        // Observational bulk sources (WA Web `default`, i.e. `learningSource:
70        // "other"`): only seed genuinely new LIDs; on a conflict with an
71        // already-known LID, don't clobber — request a live re-resolve. WA Web
72        // tags history sync, group/participant seeds, device- and
73        // contact-notifications, status/voip, etc. all as "other".
74        LearningSource::Other | LearningSource::Pairing | LearningSource::DeviceNotification => {
75            (lid_unseen, lid_known_mismatch)
76        }
77    }
78}
79
80/// WA Web `S`: sources carrying known-stale data get `created_at = 0` so any
81/// later live mapping for the same phone outranks them in the cache's
82/// most-recent-wins (PN→LID) resolution. Only the forward direction is
83/// timestamp-ordered; the LID→PN reverse map always takes the latest write (as
84/// in WA Web), so this does not guard the reverse lookup.
85fn is_stale_source(source: LearningSource) -> bool {
86    matches!(
87        source,
88        LearningSource::MigrationSyncOld | LearningSource::BlocklistInactive
89    )
90}
91
92/// Outcome of recording one (lid, phone) pair against current cache state.
93enum RecordOutcome {
94    /// Already durable in both directions; nothing to do.
95    Skipped,
96    /// Written to (or re-affirmed in) the cache; the caller should persist it.
97    /// `needs_migration` preserves the PN→LID device/session migration until
98    /// the mapping is durably persisted.
99    Written {
100        entry: LidPnEntry,
101        needs_migration: bool,
102    },
103    /// An observational source conflicted with a known LID; the phone should be
104    /// re-resolved via a live LID query rather than trusting this pair.
105    NeedsUsync,
106}
107
108/// Outcome of recording a batch: entries to persist (with their migration
109/// flags) plus phones that need a live LID re-query.
110struct BatchRecordOutcome {
111    entries: Vec<LidPnEntry>,
112    migration_flags: Vec<bool>,
113    usync_phones: Vec<String>,
114}
115
116impl Client {
117    /// Warm up the LID-PN cache from persistent storage.
118    /// This is called during client initialization to populate the in-memory cache
119    /// with previously learned LID-PN mappings.
120    #[cfg_attr(
121        feature = "tracing",
122        tracing::instrument(
123            name = "wa.session.warm_up_lid_pn_cache",
124            level = "debug",
125            skip_all,
126            err(Debug)
127        )
128    )]
129    pub(crate) async fn warm_up_lid_pn_cache(&self) -> Result<(), anyhow::Error> {
130        let backend = self.persistence_manager.backend();
131        let entries = backend.get_all_lid_mappings().await?;
132
133        if entries.is_empty() {
134            debug!("LID-PN cache warm-up: no entries found in storage");
135            return Ok(());
136        }
137
138        self.lid_pn_cache
139            .warm_up(entries.into_iter().map(mapping_to_entry))
140            .await;
141        Ok(())
142    }
143
144    /// Awaits the persist + any device/session migrations. Hot paths should
145    /// prefer `learn_lid_pn_mapping_fast`.
146    ///
147    /// Public so embedders can feed in pairs the library never observes
148    /// itself — e.g. app-state `ContactAction` mutations, which carry
149    /// `lidJid`/`pnJid` for the user's address-book contacts — instead of
150    /// writing the backend mapping table behind the cache's back.
151    ///
152    /// `lid` and `phone_number` are bare user parts (no `@lid` /
153    /// `@s.whatsapp.net` server, no device suffix). Pick the
154    /// [`LearningSource`] that matches where the pair came from;
155    /// [`LearningSource::Other`] covers sources without a dedicated variant.
156    #[cfg_attr(
157        feature = "tracing",
158        tracing::instrument(
159            name = "wa.session.add_lid_pn_mapping",
160            level = "debug",
161            skip_all,
162            err(Debug)
163        )
164    )]
165    pub async fn add_lid_pn_mapping(
166        &self,
167        lid: &str,
168        phone_number: &str,
169        source: LearningSource,
170    ) -> Result<()> {
171        match self
172            .record_lid_pn_in_memory(lid, phone_number, source)
173            .await
174        {
175            RecordOutcome::Skipped => Ok(()),
176            RecordOutcome::NeedsUsync => {
177                self.spawn_lid_usync_reconcile(vec![phone_number.to_string()]);
178                Ok(())
179            }
180            RecordOutcome::Written {
181                entry,
182                needs_migration,
183            } => {
184                self.persist_and_migrate_lid_pn(entry, needs_migration)
185                    .await
186            }
187        }
188    }
189
190    /// Durably add a batch of linked-identifier mappings and run the same
191    /// registry/session migrations as the single-entry path.
192    pub async fn add_lid_pn_mappings(
193        &self,
194        mappings: Vec<(String, String)>,
195        source: LearningSource,
196    ) -> Result<usize> {
197        let BatchRecordOutcome {
198            entries,
199            migration_flags,
200            usync_phones,
201        } = self.record_lid_pn_batch_in_memory(mappings, source).await;
202        self.spawn_lid_usync_reconcile(usync_phones);
203
204        let count = entries.len();
205        if !entries.is_empty() {
206            self.persist_and_migrate_lid_pn_batch(entries, migration_flags)
207                .await?;
208        }
209        Ok(count)
210    }
211
212    /// Hot-path variant: cache is updated synchronously (so a subsequent
213    /// `resolve_encryption_jid` sees the mapping), DB write + migrations run
214    /// in a detached task. Matches WA Web's `warmUpLidPnMapping` + the
215    /// deferred `lidPnCacheDirtySet` flush in `WAWebDBCreateLidPnMappings`.
216    ///
217    /// `is_offline` mirrors WA Web's `flushImmediately = msgInfo.offline == null`:
218    /// offline replays only warm the in-memory cache, so a burst of queued
219    /// messages on reconnect doesn't fan out one persist task per message.
220    /// Offline mappings are re-learned from the next live message or usync.
221    ///
222    /// Durability: if the spawned persist task fails (DB error, shutdown
223    /// mid-write), the mapping is only in-memory and will be lost on restart.
224    /// Use [`add_lid_pn_mapping`] when the caller needs a durable guarantee.
225    ///
226    /// Concurrent calls for the same phone number may both observe
227    /// `needs_migration = true` and each spawn a persist task. The downstream
228    /// work tolerates this:
229    /// - `put_lid_mapping` is an upsert
230    /// - `migrate_device_registry_on_lid_discovery` no-ops after the PN-keyed
231    ///   record is gone
232    /// - `migrate_signal_sessions_on_lid_discovery` no-ops after the sessions
233    ///   are migrated
234    #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.session.learn_lid_pn_fast", level = "trace", skip_all, fields(is_offline = is_offline)))]
235    pub(crate) async fn learn_lid_pn_mapping_fast(
236        self: &Arc<Self>,
237        lid: &str,
238        phone_number: &str,
239        source: LearningSource,
240        is_offline: bool,
241    ) {
242        let (entry, needs_migration) = match self
243            .record_lid_pn_in_memory(lid, phone_number, source)
244            .await
245        {
246            RecordOutcome::Skipped => return,
247            RecordOutcome::NeedsUsync => {
248                self.spawn_lid_usync_reconcile(vec![phone_number.to_string()]);
249                return;
250            }
251            RecordOutcome::Written {
252                entry,
253                needs_migration,
254            } => (entry, needs_migration),
255        };
256        if is_offline {
257            return;
258        }
259        let client = Arc::clone(self);
260        self.runtime
261            .spawn(Box::pin(async move {
262                if let Err(err) = client
263                    .persist_and_migrate_lid_pn(entry, needs_migration)
264                    .await
265                {
266                    log::warn!("Background LID-PN persist failed: {err}");
267                }
268            }))
269            .detach();
270    }
271
272    /// Batched variant of [`learn_lid_pn_mapping_fast`]. Updates the in-memory
273    /// cache synchronously for every entry, then fires one detached task that
274    /// persists the whole batch in a single backend transaction and runs the
275    /// device/session migrations for newly discovered PN↔LID pairs.
276    ///
277    /// Mirrors WA Web's `createLidPnMappings({ mappings, flushImmediately, learningSource })`
278    /// call shape: one backend write for N participants instead of N detached
279    /// tasks racing each other. The savings are linear in batch size and
280    /// matter most on first `query_info` of large groups.
281    ///
282    /// `is_offline` mirrors the single-entry path: skip the persist task for
283    /// offline replays; mappings are re-learned from the next live event.
284    ///
285    /// Takes owned `(lid, phone_number)` pairs; each `String` moves directly
286    /// into the `LidPnEntry` stored in the cache, then (via `into_iter`) into
287    /// the `LidPnMappingEntry` that's persisted — no clones on either step.
288    /// The `Vec` itself is consumed, so no copy of the outer container either.
289    #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.session.learn_lid_pn_batch", level = "debug", skip_all, fields(count = mappings.len(), is_offline = is_offline)))]
290    pub(crate) async fn learn_lid_pn_mappings_batch(
291        self: &Arc<Self>,
292        mappings: Vec<(String, String)>,
293        source: LearningSource,
294        is_offline: bool,
295    ) {
296        let outcome = self.record_lid_pn_batch_in_memory(mappings, source).await;
297        self.finish_lid_pn_batch_learning(outcome, is_offline);
298    }
299
300    pub(crate) async fn learn_lid_pn_mappings_batch_guarded(
301        self: &Arc<Self>,
302        mappings: Vec<(String, String)>,
303        source: LearningSource,
304        is_offline: bool,
305        guard: &crate::lid_pn_cache::LidPnMutationGuard<'_>,
306    ) {
307        let outcome = self
308            .record_lid_pn_batch_in_memory_guarded(mappings, source, guard)
309            .await;
310        self.finish_lid_pn_batch_learning(outcome, is_offline);
311    }
312
313    fn finish_lid_pn_batch_learning(
314        self: &Arc<Self>,
315        outcome: BatchRecordOutcome,
316        is_offline: bool,
317    ) {
318        let BatchRecordOutcome {
319            entries,
320            migration_flags,
321            usync_phones,
322        } = outcome;
323
324        // Conflicting observational pairs re-resolve live, independent of the
325        // flush gate (WA Web fires syncContactListJob regardless of
326        // flushImmediately).
327        self.spawn_lid_usync_reconcile(usync_phones);
328
329        // Nothing written, or an offline replay: skip the persist/migrate task.
330        if is_offline || entries.is_empty() {
331            return;
332        }
333
334        let client = Arc::clone(self);
335        self.runtime
336            .spawn(Box::pin(async move {
337                if let Err(err) = client
338                    .persist_and_migrate_lid_pn_batch(entries, migration_flags)
339                    .await
340                {
341                    log::warn!("Background LID-PN batch persist failed: {err}");
342                }
343            }))
344            .detach();
345    }
346
347    /// Fire-and-forget the WA Web `syncContactListJob({mode:"query"})` analog:
348    /// one background LID usync for phones an observational source found in
349    /// conflict with a known LID, learning the authoritative result under
350    /// `LearningSource::Usync` (which cannot itself trigger another reconcile,
351    /// so there is no query→learn→query loop). Best-effort: a failed query
352    /// leaves the existing mapping untouched.
353    fn spawn_lid_usync_reconcile(&self, phones: Vec<String>) {
354        if phones.is_empty() {
355            return;
356        }
357        let Some(client) = self.self_weak.get().and_then(|w| w.upgrade()) else {
358            return;
359        };
360        let runtime = client.runtime.clone();
361        runtime
362            .spawn(Box::pin(async move {
363                client.reconcile_lid_mappings_via_usync(phones).await;
364            }))
365            .detach();
366    }
367
368    async fn reconcile_lid_mappings_via_usync(&self, phones: Vec<String>) {
369        let jids: Vec<Jid> = phones.iter().map(|p| Jid::pn(p.as_str())).collect();
370        let sid = self.generate_request_id();
371        match self.execute(LidQuerySpec::new(jids, sid)).await {
372            Ok(resp) => {
373                for mapping in &resp.lid_mappings {
374                    if let Err(err) = self
375                        .add_lid_pn_mapping(
376                            &mapping.lid,
377                            &mapping.phone_number,
378                            LearningSource::Usync,
379                        )
380                        .await
381                    {
382                        log::warn!(
383                            "LID reconcile persist failed for {} -> {}: {err}",
384                            mapping.phone_number,
385                            mapping.lid
386                        );
387                    }
388                }
389            }
390            Err(err) => debug!("LID reconcile usync query failed: {err}"),
391        }
392    }
393
394    /// Batch cache warm-up shared by the fire-and-forget learn path and the
395    /// migration-sync handler (which awaits persistence instead). Each pair
396    /// runs through [`Self::record_lid_pn_in_memory`] under the source's write
397    /// policy. Dedups by phone_number (last lid wins) — otherwise the same
398    /// phone appearing twice in one batch requests migration for the first
399    /// (lid_A) but not the second (lid_B), so signal migration
400    /// runs for lid_A while the persisted mapping ends up pointing at lid_B.
401    /// (WA Web instead records the superseded entry with created_at=0; dropping
402    /// it is equivalent for the resolved PN→LID mapping.)
403    async fn record_lid_pn_batch_in_memory(
404        &self,
405        mappings: Vec<(String, String)>,
406        source: LearningSource,
407    ) -> BatchRecordOutcome {
408        let guard = self.lid_pn_cache.lock_mutation().await;
409        self.record_lid_pn_batch_in_memory_guarded(mappings, source, &guard)
410            .await
411    }
412
413    async fn record_lid_pn_batch_in_memory_guarded(
414        &self,
415        mappings: Vec<(String, String)>,
416        source: LearningSource,
417        guard: &crate::lid_pn_cache::LidPnMutationGuard<'_>,
418    ) -> BatchRecordOutcome {
419        let cap = mappings.len();
420        let mut deduped: std::collections::HashMap<String, String> =
421            std::collections::HashMap::with_capacity(cap);
422        for (lid, phone_number) in mappings {
423            deduped.insert(phone_number, lid);
424        }
425
426        let mut entries: Vec<LidPnEntry> = Vec::with_capacity(deduped.len());
427        let mut migration_flags: Vec<bool> = Vec::with_capacity(deduped.len());
428        let mut usync_phones: Vec<String> = Vec::new();
429        for (phone_number, lid) in deduped {
430            match self
431                .record_lid_pn_in_memory_guarded(&lid, &phone_number, source, guard)
432                .await
433            {
434                RecordOutcome::Skipped => {}
435                RecordOutcome::Written {
436                    entry,
437                    needs_migration,
438                } => {
439                    entries.push(entry);
440                    migration_flags.push(needs_migration);
441                }
442                RecordOutcome::NeedsUsync => usync_phones.push(phone_number),
443            }
444        }
445        BatchRecordOutcome {
446            entries,
447            migration_flags,
448            usync_phones,
449        }
450    }
451
452    /// Record one pair in the in-memory cache under [`lid_pn_write_policy`].
453    /// Does not persist — the caller drives persistence/migration from the
454    /// returned [`RecordOutcome`].
455    async fn record_lid_pn_in_memory(
456        &self,
457        lid: &str,
458        phone_number: &str,
459        source: LearningSource,
460    ) -> RecordOutcome {
461        let guard = self.lid_pn_cache.lock_mutation().await;
462        self.record_lid_pn_in_memory_guarded(lid, phone_number, source, &guard)
463            .await
464    }
465
466    async fn record_lid_pn_in_memory_guarded(
467        &self,
468        lid: &str,
469        phone_number: &str,
470        source: LearningSource,
471        guard: &crate::lid_pn_cache::LidPnMutationGuard<'_>,
472    ) -> RecordOutcome {
473        // Fully durable and resolvable both ways: nothing to re-add or persist.
474        if self.lid_pn_cache.can_skip_relearn(phone_number, lid).await {
475            return RecordOutcome::Skipped;
476        }
477
478        let current_lid = self.lid_pn_cache.get_current_lid(phone_number).await;
479        let reverse_pn = self.lid_pn_cache.get_phone_number(lid).await;
480        let exact = current_lid.as_deref() == Some(lid);
481
482        // Re-warm/re-affirm durability for a pair that is already the cached
483        // mapping (exact, or reverse-only after a bounded-cache PN eviction).
484        // Precedes the write/conflict branches so a self-consistent pair is
485        // not re-queried as a conflict. A still-unpersisted pair retains its
486        // pending discovery migration across retries.
487        let same_pair_forward_evicted =
488            current_lid.is_none() && reverse_pn.as_deref() == Some(phone_number);
489        if exact || same_pair_forward_evicted {
490            // The pair may only be cached because a prior batch write failed.
491            // Preserve its discovery migration until persistence succeeds.
492            let needs_migration = !self.lid_pn_cache.is_persisted(phone_number, lid).await;
493            let existing = match self.lid_pn_cache.get_entry_by_phone(phone_number).await {
494                Some(entry) => Some(entry),
495                None => self.lid_pn_cache.get_entry_by_lid(lid).await,
496            };
497            return match existing {
498                Some(entry) => {
499                    self.lid_pn_cache.add_guarded(&entry, guard).await;
500                    RecordOutcome::Written {
501                        entry,
502                        needs_migration,
503                    }
504                }
505                None => RecordOutcome::Skipped,
506            };
507        }
508
509        // Not a self-consistent pair: apply the source's write policy.
510        let lid_unseen = reverse_pn.is_none();
511        let (write, needs_usync) = lid_pn_write_policy(source, lid_unseen, exact);
512
513        if write {
514            let created_at = if is_stale_source(source) {
515                0
516            } else {
517                wacore::time::now_secs()
518            };
519            let entry = LidPnEntry::with_timestamp(lid, phone_number, created_at, source);
520            self.lid_pn_cache.add_guarded(&entry, guard).await;
521            return RecordOutcome::Written {
522                entry,
523                needs_migration: current_lid.is_none(),
524            };
525        }
526
527        // A genuine observational conflict with a different known LID: leave the
528        // live mapping in place and request an authoritative live re-resolve.
529        if needs_usync {
530            return RecordOutcome::NeedsUsync;
531        }
532        RecordOutcome::Skipped
533    }
534
535    #[cfg_attr(
536        feature = "tracing",
537        tracing::instrument(
538            name = "wa.session.persist_migrate_lid_pn",
539            level = "debug",
540            skip_all,
541            fields(needs_migration),
542            err(Debug)
543        )
544    )]
545    async fn persist_and_migrate_lid_pn(
546        &self,
547        entry: LidPnEntry,
548        needs_migration: bool,
549    ) -> Result<()> {
550        use anyhow::anyhow;
551
552        let storage_entry = LidPnMappingEntry {
553            lid: entry.lid.to_string(),
554            phone_number: entry.phone_number.to_string(),
555            created_at: entry.created_at,
556            updated_at: entry.created_at,
557            learning_source: entry.learning_source.as_str().to_string(),
558        };
559
560        self.persistence_manager
561            .backend()
562            .put_lid_mapping(&storage_entry)
563            .await
564            .map_err(|e| anyhow!("persisting LID-PN mapping: {e}"))?;
565
566        // After the write, not before: a failed persist stays un-marked so the
567        // next live message retries instead of skipping.
568        self.lid_pn_cache
569            .mark_persisted(&storage_entry.phone_number, &storage_entry.lid)
570            .await;
571
572        if needs_migration {
573            self.migrate_device_registry_on_lid_discovery(
574                &storage_entry.phone_number,
575                &storage_entry.lid,
576            )
577            .await;
578            self.migrate_signal_sessions_on_lid_discovery(
579                &storage_entry.phone_number,
580                &storage_entry.lid,
581            )
582            .await;
583        }
584
585        Ok(())
586    }
587
588    #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.session.persist_migrate_lid_pn_batch", level = "debug", skip_all, fields(count = entries.len()), err(Debug)))]
589    async fn persist_and_migrate_lid_pn_batch(
590        &self,
591        entries: Vec<LidPnEntry>,
592        migration_flags: Vec<bool>,
593    ) -> Result<()> {
594        let storage = self.persist_lid_pn_batch(entries).await?;
595        self.migrate_lid_pn_batch(storage, migration_flags).await;
596        Ok(())
597    }
598
599    /// Durable half of the batch learn: one backend transaction plus the
600    /// cache persisted-markers, no migrations.
601    async fn persist_lid_pn_batch(
602        &self,
603        entries: Vec<LidPnEntry>,
604    ) -> Result<Vec<LidPnMappingEntry>> {
605        use anyhow::anyhow;
606
607        // Consume entries so `lid`/`phone_number` move into storage rather
608        // than being cloned. Only `learning_source` is allocated, and only
609        // because `LidPnMappingEntry.learning_source` is a `String` field.
610        let storage: Vec<LidPnMappingEntry> = entries
611            .into_iter()
612            .map(|entry| LidPnMappingEntry {
613                lid: entry.lid.to_string(),
614                phone_number: entry.phone_number.to_string(),
615                created_at: entry.created_at,
616                updated_at: entry.created_at,
617                learning_source: entry.learning_source.as_str().to_string(),
618            })
619            .collect();
620
621        self.persistence_manager
622            .backend()
623            .put_lid_mappings(&storage)
624            .await
625            .map_err(|e| anyhow!("persisting LID-PN mapping batch: {e}"))?;
626
627        for entry in &storage {
628            self.lid_pn_cache
629                .mark_persisted(&entry.phone_number, &entry.lid)
630                .await;
631        }
632        Ok(storage)
633    }
634
635    /// Registry + Signal-session migrations for a persisted batch. Split from
636    /// the persist so callers on the message pipeline can await durability but
637    /// defer this part — each new mapping walks up to MIGRATION_DEVICE_RANGE
638    /// per-address locks, which must not stall the global processing permit.
639    async fn migrate_lid_pn_batch(
640        &self,
641        storage: Vec<LidPnMappingEntry>,
642        migration_flags: Vec<bool>,
643    ) {
644        for (entry, needs_migration) in storage.iter().zip(migration_flags.iter()) {
645            if *needs_migration {
646                self.migrate_device_registry_on_lid_discovery(&entry.phone_number, &entry.lid)
647                    .await;
648                self.migrate_signal_sessions_on_lid_discovery(&entry.phone_number, &entry.lid)
649                    .await;
650            }
651        }
652    }
653
654    /// Ensure phone-to-LID mappings are resolved for the given JIDs.
655    /// Matches WhatsApp Web's WAWebManagePhoneNumberMappingJob.ensurePhoneNumberToLidMapping().
656    /// Should be called before establishing new E2E sessions to avoid duplicate sessions.
657    ///
658    /// This checks the local cache for existing mappings. For JIDs without cached mappings,
659    /// the caller should consider fetching them via usync query if establishing sessions.
660    pub(crate) async fn resolve_lid_mappings(&self, jids: &[Jid]) -> Vec<Jid> {
661        let mut resolved = Vec::with_capacity(jids.len());
662
663        for jid in jids {
664            // Only resolve for user JIDs (not groups, status, etc.)
665            if !jid.is_pn() && !jid.is_lid() {
666                resolved.push(jid.clone());
667                continue;
668            }
669
670            // If it's already a LID, use as-is
671            if jid.is_lid() {
672                resolved.push(jid.clone());
673                continue;
674            }
675
676            // Try to resolve PN to LID from cache
677            if let Some(lid_user) = self.lid_pn_cache.get_current_lid(&jid.user).await {
678                resolved.push(Jid::lid_device(lid_user, jid.device));
679            } else {
680                // No cached mapping — use original JID. Mapping will be learned
681                // organically from incoming messages or usync responses.
682                resolved.push(jid.clone());
683            }
684        }
685
686        resolved
687    }
688
689    /// Mirrors WA Web `SignalAddress.toString()` (`WAWeb/Signal/Address.js`):
690    /// upgrade Pn → Lid and Hosted → HostedLid when a mapping is known, else
691    /// preserve the input.
692    ///
693    /// Session-layer addressing only: outbound DM wire jids must go through
694    /// [`Self::resolve_dm_wire_jid`] instead, which gates the namespace on
695    /// the account's 1:1-LID-migration state (an unmigrated account's LID
696    /// stanza is 400-nacked by the server).
697    pub(crate) async fn resolve_encryption_jid(&self, target: &Jid) -> Jid {
698        use wacore_binary::Server;
699        let lid_server = match target.server {
700            Server::Pn => Server::Lid,
701            Server::Hosted => Server::HostedLid,
702            _ => return target.clone(),
703        };
704        match self.lid_pn_cache.get_current_lid(&target.user).await {
705            Some(lid_user) => Jid {
706                user: lid_user,
707                server: lid_server,
708                device: target.device,
709                agent: target.agent,
710                integrator: target.integrator,
711            },
712            None => target.clone(),
713        }
714    }
715
716    /// Mirrors WA Web `Lid1X1MigrationUtils.isLidMigrated()`: the pairing- or
717    /// migration-persisted account flag, with the `lid_one_on_one_migration_enabled`
718    /// ab prop covering sessions paired before the flag existed (the prop is
719    /// what lets WA Web start the 1:1 migration on an already-linked client).
720    pub async fn is_lid_migrated(&self) -> bool {
721        if self.persistence_manager.get_device_snapshot().lid_migrated {
722            return true;
723        }
724        self.ab_props()
725            .is_enabled(wacore::iq::abprops::web::LID_ONE_ON_ONE_MIGRATION_ENABLED)
726            .await
727    }
728
729    /// One-way latch run after every props fetch: the props cache is not
730    /// persisted, so without this a prop-only-migrated account re-enters PN
731    /// wire addressing on every process start until the fetch lands, flapping
732    /// the DM namespace. Persisting the observation makes the state durable,
733    /// like WA Web's pref outliving the prop.
734    pub(crate) async fn latch_lid_migrated_from_props(&self) {
735        if !self.persistence_manager.get_device_snapshot().lid_migrated
736            && self
737                .ab_props()
738                .is_enabled(wacore::iq::abprops::web::LID_ONE_ON_ONE_MIGRATION_ENABLED)
739                .await
740        {
741            log::info!("Account is 1:1-LID-migrated (ab prop observation)");
742            self.persistence_manager
743                .process_command(crate::store::commands::DeviceCommand::SetLidMigrated(true))
744                .await;
745        }
746    }
747
748    /// Wire namespace for a 1:1 recipient. WAWebSendMsgCreateFanoutStanza
749    /// addresses the whole DM stanza from the chat wid, which is LID only once
750    /// the account is 1:1-LID-migrated (WAWebMessageDestinationChat); an
751    /// unmigrated account keeps 1:1 chats on PN even with a known mapping.
752    /// Signal session addressing is NOT gated by this — WAWebSignalAddress
753    /// upgrades PN to LID unconditionally.
754    ///
755    /// Known limit: a LID input with no cached PN mapping stays LID even on
756    /// an unmigrated account (and may 400). There is no reverse LID-to-PN
757    /// resolution to fall back on — WA Web has none either; its unmigrated
758    /// accounts simply never hold LID 1:1 chats.
759    pub(crate) async fn resolve_dm_wire_jid(&self, to: &Jid) -> Jid {
760        if self.is_lid_migrated().await {
761            return self.resolve_encryption_jid(to).await.into_non_ad();
762        }
763        let bare = to.to_non_ad();
764        if bare.is_lid() {
765            self.swap_pn_lid_namespace(&bare).await.unwrap_or(bare)
766        } else {
767            bare
768        }
769    }
770
771    /// Handle the primary's 1:1 LID-migration mapping push (WA Web
772    /// HandleMsgProcess -> `setLidMigrationMappings`). Learns the PN-LID pairs
773    /// and, once the migration ab prop allows it (WA Web's state machine only
774    /// migrates past WAITING_PROP with the prop on), persists the account as
775    /// migrated so DMs switch to LID wire addressing.
776    pub(crate) async fn handle_lid_migration_mapping_sync(
777        self: &Arc<Self>,
778        sync: &waproto::whatsapp::LIDMigrationMappingSyncMessage,
779    ) {
780        let Some(payload_bytes) = sync.encoded_mapping_payload.as_deref() else {
781            log::warn!("lid_migration_mapping_sync without payload");
782            return;
783        };
784        let payload = match waproto::codec::lid_migration_mapping_sync_payload_decode(payload_bytes)
785        {
786            Ok(p) => p,
787            Err(e) => {
788                log::warn!("Failed to decode LID migration mapping payload: {e}");
789                return;
790            }
791        };
792
793        let mappings: Vec<(String, String)> = payload
794            .pn_to_lid_mappings
795            .iter()
796            .filter_map(|mapping| {
797                // Absent (or explicit-zero) scalar fields decode as 0; a "0"
798                // user would poison the cache, and a zero latest_lid falls
799                // back to the required assigned_lid instead of dropping the
800                // whole mapping.
801                let lid = mapping
802                    .latest_lid
803                    .filter(|&l| l != 0)
804                    .unwrap_or(mapping.assigned_lid);
805                if mapping.pn == 0 || lid == 0 {
806                    log::warn!("Skipping migration mapping with zero pn/lid");
807                    return None;
808                }
809                Some((lid.to_string(), mapping.pn.to_string()))
810            })
811            .collect();
812        // The persist is awaited (unlike the fire-and-forget learn path) so
813        // the mappings are durable before the migrated flag below is; a crash
814        // in between must not leave a migrated account without its mapping
815        // rows. The per-mapping registry/session migrations are deferred to a
816        // detached task instead: this handler runs under the message
817        // pipeline's processing permit, and a large first push walking
818        // MIGRATION_DEVICE_RANGE locks per mapping would stall it.
819        let BatchRecordOutcome {
820            entries,
821            migration_flags,
822            usync_phones,
823        } = self
824            .record_lid_pn_batch_in_memory(mappings, LearningSource::MigrationSyncLatest)
825            .await;
826        self.spawn_lid_usync_reconcile(usync_phones);
827        if !entries.is_empty() {
828            match self.persist_lid_pn_batch(entries).await {
829                Ok(storage) => {
830                    // A shutdown can drop this task with the migrations unrun;
831                    // that is accepted, not retried: both halves self-heal
832                    // lazily (decrypt-side session migration via
833                    // try_pn_to_lid_migration_decrypt, and a LID registry miss
834                    // just re-warms over the network on the next send).
835                    let client = Arc::clone(self);
836                    self.runtime
837                        .spawn(Box::pin(async move {
838                            client.migrate_lid_pn_batch(storage, migration_flags).await;
839                        }))
840                        .detach();
841                }
842                Err(e) => {
843                    // Do not advance migration state on a failed save (WA
844                    // Web's setLidMigrationMappings rethrows); the primary's
845                    // push is gone, but the ab prop keeps addressing correct
846                    // until re-pair.
847                    log::warn!("Failed to persist migration mappings: {e:?}");
848                    return;
849                }
850            }
851        }
852
853        if !self.persistence_manager.get_device_snapshot().lid_migrated
854            && self
855                .ab_props()
856                .is_enabled(wacore::iq::abprops::web::LID_ONE_ON_ONE_MIGRATION_ENABLED)
857                .await
858        {
859            log::info!("Account is 1:1-LID-migrated (primary mapping sync)");
860            self.persistence_manager
861                .process_command(crate::store::commands::DeviceCommand::SetLidMigrated(true))
862                .await;
863        }
864    }
865
866    /// Swap a JID's namespace between PN and LID, preserving device/agent/integrator.
867    /// Returns `None` if no mapping exists or the JID is neither PN nor LID.
868    pub(crate) async fn swap_pn_lid_namespace(&self, jid: &Jid) -> Option<Jid> {
869        if jid.is_lid() {
870            let pn_user = self.lid_pn_cache.get_phone_number(&jid.user).await?;
871            Some(Jid {
872                user: pn_user.into(),
873                server: wacore_binary::Server::Pn,
874                device: jid.device,
875                agent: jid.agent,
876                integrator: jid.integrator,
877            })
878        } else if jid.is_pn() {
879            let lid_user = self.lid_pn_cache.get_current_lid(&jid.user).await?;
880            Some(Jid {
881                user: lid_user,
882                server: wacore_binary::Server::Lid,
883                device: jid.device,
884                agent: jid.agent,
885                integrator: jid.integrator,
886            })
887        } else {
888            None
889        }
890    }
891
892    /// Migrate Signal sessions and identity keys from PN to LID address.
893    ///
894    /// All reads/writes go through `signal_cache` to avoid reading stale data
895    /// from the backend when the cache has unflushed mutations (e.g., after
896    /// SKDM encryption ratcheted the session).
897    /// Read-modify-write of PN and LID Signal session/identity slots must
898    /// hold the same per-address locks that encrypt/decrypt take, otherwise
899    /// concurrent message_encrypt on LID can clobber the migrated session.
900    ///
901    /// Callers must NOT hold `session_lock_for(<lid_addr>)` for any device
902    /// in [0, 100) — `async_lock::Mutex` is not reentrant. The decrypt path
903    /// drops its address lock around the call (`try_pn_to_lid_migration_decrypt`).
904    ///
905    /// Returns whether anything moved into a LID slot. When `false`, decrypt
906    /// state is unchanged, so a failed decrypt retried after this call is
907    /// guaranteed to fail identically and callers can skip the retry.
908    #[cfg_attr(
909        feature = "tracing",
910        tracing::instrument(
911            name = "wa.session.migrate_signal_sessions",
912            level = "debug",
913            skip_all
914        )
915    )]
916    pub(crate) async fn migrate_signal_sessions_on_lid_discovery(
917        &self,
918        pn: &str,
919        lid: &str,
920    ) -> bool {
921        use log::warn;
922
923        let backend = self.persistence_manager.backend();
924        if let Ok(false) = self
925            .signal_cache
926            .has_state_for_user(pn, backend.as_ref())
927            .await
928        {
929            return false;
930        }
931
932        let standard = self
933            .migrate_signal_sessions_with_backend(&Jid::pn(pn), &Jid::lid(lid), backend.as_ref())
934            .await;
935        let hosted = self
936            .migrate_signal_sessions_with_backend(
937                &Jid::new(pn, wacore_binary::Server::Hosted),
938                &Jid::new(lid, wacore_binary::Server::HostedLid),
939                backend.as_ref(),
940            )
941            .await;
942        let migrated_sessions = standard.migrated != 0 || hosted.migrated != 0;
943        if (standard.has_state_changes()
944            || hosted.has_state_changes()
945            || self
946                .signal_cache
947                .has_pending_pairwise_writes_for_user(pn)
948                .await)
949            && let Err(error) = self.signal_cache.flush(backend.as_ref()).await
950        {
951            warn!("Failed to flush signal cache after migration: {error:?}");
952        }
953        migrated_sessions
954    }
955
956    pub(crate) async fn migrate_signal_sessions(
957        &self,
958        from: &Jid,
959        to: &Jid,
960    ) -> crate::features::SignalSessionMigration {
961        let backend = self.persistence_manager.backend();
962
963        // Nothing to migrate unless the PN side has Signal state. For a freshly
964        // resolved peer (e.g. every member of a large group on first send) this
965        // skips MIGRATION_DEVICE_RANGE lock+lookup iterations that would all
966        // find nothing. On a lookup error, fall through to the full scan.
967        if let Ok(false) = self
968            .signal_cache
969            .has_state_for_user(&from.user, backend.as_ref())
970            .await
971        {
972            return crate::features::SignalSessionMigration::default();
973        }
974
975        self.migrate_signal_sessions_with_backend(from, to, backend.as_ref())
976            .await
977    }
978
979    /// Migrate one matching address-family pair after the caller has established
980    /// that this user may have Signal state. Splitting the existence probe from
981    /// the scan lets LID discovery cover both regular and hosted namespaces with
982    /// one backend probe and one final flush.
983    async fn migrate_signal_sessions_with_backend(
984        &self,
985        from: &Jid,
986        to: &Jid,
987        backend: &dyn SignalStore,
988    ) -> crate::features::SignalSessionMigration {
989        use log::{info, warn};
990        use wacore::types::jid::JidExt;
991
992        let mut outcome = crate::features::SignalSessionMigration::default();
993
994        for device_id in 0..MIGRATION_DEVICE_RANGE {
995            let pn_jid = from.with_device(device_id);
996            let lid_jid = to.with_device(device_id);
997
998            let pn_proto = pn_jid.to_protocol_address();
999            let lid_proto = lid_jid.to_protocol_address();
1000
1001            // Acquire both per-address locks in stable lexicographic order to
1002            // avoid deadlock against concurrent paths that legitimately hold
1003            // only one side. (Callers never hold either lock.)
1004            let pn_lock = self.session_lock_for(pn_proto.as_str()).await;
1005            let lid_lock = self.session_lock_for(lid_proto.as_str()).await;
1006            let (_first_guard, _second_guard) = if pn_proto.as_str() <= lid_proto.as_str() {
1007                let pn_g = pn_lock.lock_arc().await;
1008                let lid_g = lid_lock.lock_arc().await;
1009                (pn_g, lid_g)
1010            } else {
1011                let lid_g = lid_lock.lock_arc().await;
1012                let pn_g = pn_lock.lock_arc().await;
1013                (lid_g, pn_g)
1014            };
1015
1016            // PN wins on conflict — mirrors whatsmeow's `MigratePNToLID`
1017            // (`ON CONFLICT DO UPDATE SET session=excluded.session`).
1018            match self.signal_cache.get_session(&pn_proto, backend).await {
1019                Ok(Some(session)) => {
1020                    outcome.total += 1;
1021                    self.signal_cache.put_session(&lid_proto, session).await;
1022                    self.signal_cache.delete_session(&pn_proto).await;
1023                    outcome.migrated += 1;
1024                    info!(
1025                        "Migrated session {} -> {} (PN wins on conflict)",
1026                        pn_proto, lid_proto
1027                    );
1028                }
1029                Ok(None) => {}
1030                Err(error) => {
1031                    outcome.total += 1;
1032                    outcome.skipped += 1;
1033                    warn!("Skipping session migration for {}: {error:?}", pn_proto);
1034                }
1035            }
1036
1037            // Identity uses LID-wins (the inverse of session). For the same
1038            // physical device the identity_key is stable across PN/LID, so
1039            // either policy yields the same bytes in the steady state. The
1040            // asymmetry only matters if the peer re-paired between our PN
1041            // and LID identity captures — in that case the fresher LID
1042            // identity is on the namespace we're migrating *to*, and PN's
1043            // stale value should not clobber it.
1044            //
1045            // Match the LID lookup result explicitly so a transient read
1046            // failure isn't collapsed with `Ok(None)` and used as license
1047            // to overwrite a potentially-valid LID identity.
1048            match self.signal_cache.get_identity(&pn_proto, backend).await {
1049                Ok(Some(identity_data)) => {
1050                    match self.signal_cache.get_identity(&lid_proto, backend).await {
1051                        Ok(None) => {
1052                            self.signal_cache
1053                                .put_identity(&lid_proto, &identity_data)
1054                                .await;
1055                            self.signal_cache.delete_identity(&pn_proto).await;
1056                            outcome.migrated_identities += 1;
1057                            info!("Migrated identity {} -> {}", pn_proto, lid_proto);
1058                        }
1059                        Ok(Some(_)) => {
1060                            // LID-wins: existing LID identity preserved; drop the PN copy.
1061                            self.signal_cache.delete_identity(&pn_proto).await;
1062                            outcome.discarded_identities += 1;
1063                        }
1064                        Err(e) => {
1065                            outcome.skipped_identities += 1;
1066                            warn!(
1067                                "Skipping identity migration {} -> {}: \
1068                             failed to read LID identity: {e:?}",
1069                                pn_proto, lid_proto
1070                            );
1071                        }
1072                    }
1073                }
1074                Ok(None) => {}
1075                Err(error) => {
1076                    outcome.skipped_identities += 1;
1077                    warn!("Skipping identity migration for {}: {error:?}", pn_proto);
1078                }
1079            }
1080        }
1081
1082        outcome
1083    }
1084
1085    /// Look up the LID↔phone mapping for a JID. Cache-aside: falls back to
1086    /// the backend on cache miss so mappings survive cache eviction and any
1087    /// backend implementation gets the fallback without warm-up.
1088    ///
1089    /// Backend errors are propagated — callers can distinguish "no mapping"
1090    /// (`Ok(None)`) from "lookup failed" (`Err(_)`).
1091    #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.session.get_lid_pn_entry", level = "trace", skip_all, fields(peer = %jid.observe()), err(Debug)))]
1092    pub async fn get_lid_pn_entry(&self, jid: &Jid) -> Result<Option<LidPnEntry>> {
1093        let is_lid = if jid.is_lid() {
1094            true
1095        } else if jid.is_pn() {
1096            false
1097        } else {
1098            return Ok(None);
1099        };
1100
1101        self.get_lid_pn_entry_by_user(&jid.user, is_lid).await
1102    }
1103
1104    async fn get_lid_pn_entry_by_user(
1105        &self,
1106        user: &str,
1107        is_lid: bool,
1108    ) -> Result<Option<LidPnEntry>> {
1109        let hit = if is_lid {
1110            self.lid_pn_cache.get_entry_by_lid(user).await
1111        } else {
1112            self.lid_pn_cache.get_entry_by_phone(user).await
1113        };
1114
1115        if let Some(entry) = hit {
1116            return Ok(Some(entry));
1117        }
1118
1119        let backend = self.persistence_manager.backend();
1120        let mapping = if is_lid {
1121            backend.get_lid_mapping(user).await?
1122        } else {
1123            backend.get_pn_mapping(user).await?
1124        };
1125
1126        let Some(mapping) = mapping else {
1127            return Ok(None);
1128        };
1129
1130        let entry = mapping_to_entry(mapping);
1131        self.lid_pn_cache.add(&entry).await;
1132        Ok(Some(entry))
1133    }
1134
1135    /// Whether two user JIDs identify the same account, ignoring device
1136    /// suffixes and resolving PN/LID aliases through the canonical mapping
1137    /// cache-aside path. Hosted namespaces belong to their corresponding PN or
1138    /// LID family; unrelated namespaces only match exactly.
1139    pub(crate) async fn jids_share_user_identity(&self, left: &Jid, right: &Jid) -> Result<bool> {
1140        if left.is_same_chat_as(right) {
1141            return Ok(true);
1142        }
1143
1144        let same_user_and_integrator =
1145            left.user == right.user && left.integrator == right.integrator;
1146        if same_user_and_integrator
1147            && ((left.server.is_pn_family() && right.server.is_pn_family())
1148                || (left.server.is_lid_family() && right.server.is_lid_family()))
1149        {
1150            return Ok(true);
1151        }
1152
1153        let (lid, pn) = if left.server.is_lid_family() && right.server.is_pn_family() {
1154            (left, right)
1155        } else if right.server.is_lid_family() && left.server.is_pn_family() {
1156            (right, left)
1157        } else {
1158            return Ok(false);
1159        };
1160        if lid.integrator != pn.integrator {
1161            return Ok(false);
1162        }
1163
1164        Ok(self
1165            .get_lid_pn_entry_by_user(&lid.user, true)
1166            .await?
1167            .is_some_and(|mapping| {
1168                &*mapping.lid == lid.user.as_str() && &*mapping.phone_number == pn.user.as_str()
1169            }))
1170    }
1171
1172    /// Resolve any user JID to its bare LID form, or `None` when no LID is
1173    /// available. Mirrors WA Web's `WAWebLidMigrationUtils.toUserLid`: LID
1174    /// passes through, PN goes through the cache-aside mapping, anything
1175    /// else and any lookup failure returns `None`.
1176    ///
1177    /// Used by `send_status_message` to replicate WA Web's
1178    /// `compactMap(list, toUserLid)` skip-on-unresolvable semantics.
1179    pub(crate) async fn resolve_recipient_to_lid(&self, jid: &Jid) -> Option<Jid> {
1180        if jid.is_lid() {
1181            return Some(jid.to_non_ad());
1182        }
1183        if !jid.is_pn() {
1184            return None;
1185        }
1186        match self.get_lid_pn_entry(jid).await {
1187            Ok(Some(entry)) => Some(Jid::new(&*entry.lid, wacore_binary::Server::Lid)),
1188            Ok(None) => None,
1189            Err(e) => {
1190                log::warn!(
1191                    "resolve_recipient_to_lid: LID lookup for {} failed: {:?}",
1192                    jid.observe(),
1193                    e
1194                );
1195                None
1196            }
1197        }
1198    }
1199}
1200
1201#[cfg(test)]
1202#[allow(clippy::disallowed_methods)]
1203mod tests {
1204    use super::*;
1205    use crate::lid_pn_cache::LearningSource;
1206    use crate::test_utils::{create_test_client, create_test_client_with_backend};
1207    use std::sync::Arc;
1208    use wacore::store::in_memory::InMemoryBackend;
1209    use wacore::store::traits::SignalStore;
1210    use wacore_binary::Server;
1211
1212    /// Fixture: test client with one cached peer LID-PN mapping.
1213    async fn client_with_peer_mapping() -> (Arc<Client>, &'static str, &'static str) {
1214        let client = create_test_client().await;
1215        let pn = "5511987650001";
1216        let lid = "111000011112222";
1217        client
1218            .add_lid_pn_mapping(lid, pn, LearningSource::PeerPnMessage)
1219            .await
1220            .unwrap();
1221        (client, pn, lid)
1222    }
1223
1224    // ── WA Web `createLidPnMappings` switch(learningSource) parity ─────────
1225
1226    /// Every non-default `LearningSource`, for the pure decision table below.
1227    const ALL_SOURCES: [LearningSource; 11] = [
1228        LearningSource::Usync,
1229        LearningSource::PeerPnMessage,
1230        LearningSource::PeerLidMessage,
1231        LearningSource::RecipientLatestLid,
1232        LearningSource::MigrationSyncLatest,
1233        LearningSource::MigrationSyncOld,
1234        LearningSource::BlocklistActive,
1235        LearningSource::BlocklistInactive,
1236        LearningSource::Pairing,
1237        LearningSource::DeviceNotification,
1238        LearningSource::Other,
1239    ];
1240
1241    /// Pure decision table for [`lid_pn_write_policy`], mirroring WA Web's
1242    /// `createLidPnMappings` `switch (learningSource)`. Columns are
1243    /// `(lid_unseen, exact)`; `exact` implies the LID is already seen, so
1244    /// `(true, true)` is unreachable.
1245    #[test]
1246    fn test_lid_pn_write_policy_switch_matrix() {
1247        // Brand-new LID: every source writes it, none needs a re-query.
1248        for src in ALL_SOURCES {
1249            assert_eq!(
1250                lid_pn_write_policy(src, true, false),
1251                (true, false),
1252                "a brand-new LID must always be written, never re-queried ({src:?})"
1253            );
1254        }
1255
1256        // Exact match already cached: no source rewrites, none re-queries.
1257        for src in ALL_SOURCES {
1258            assert_eq!(
1259                lid_pn_write_policy(src, false, true),
1260                (false, false),
1261                "an exact match is a no-op ({src:?})"
1262            );
1263        }
1264
1265        // Known-LID conflict (WA Web `C`): directed sources overwrite;
1266        // observational sources refuse and request a live re-resolve.
1267        let directed = [
1268            LearningSource::Usync,
1269            LearningSource::PeerPnMessage,
1270            LearningSource::PeerLidMessage,
1271            LearningSource::RecipientLatestLid,
1272            LearningSource::MigrationSyncLatest,
1273            LearningSource::MigrationSyncOld,
1274            LearningSource::BlocklistActive,
1275            LearningSource::BlocklistInactive,
1276        ];
1277        for src in directed {
1278            assert_eq!(
1279                lid_pn_write_policy(src, false, false),
1280                (true, false),
1281                "a directed source overwrites a conflicting known LID ({src:?})"
1282            );
1283        }
1284        for src in [
1285            LearningSource::Other,
1286            LearningSource::Pairing,
1287            LearningSource::DeviceNotification,
1288        ] {
1289            assert_eq!(
1290                lid_pn_write_policy(src, false, false),
1291                (false, true),
1292                "an observational source must not clobber; it re-queries ({src:?})"
1293            );
1294        }
1295    }
1296
1297    #[test]
1298    fn test_is_stale_source() {
1299        assert!(is_stale_source(LearningSource::MigrationSyncOld));
1300        assert!(is_stale_source(LearningSource::BlocklistInactive));
1301        // Every other source must be non-stale. Iterates ALL_SOURCES, which
1302        // `all_sources_is_exhaustive` keeps in step with the enum.
1303        for src in ALL_SOURCES {
1304            if matches!(
1305                src,
1306                LearningSource::MigrationSyncOld | LearningSource::BlocklistInactive
1307            ) {
1308                continue;
1309            }
1310            assert!(!is_stale_source(src), "{src:?} is not stale");
1311        }
1312    }
1313
1314    /// Keeps `ALL_SOURCES` (a hand-written array the policy tests iterate) in
1315    /// sync with the enum: the wildcard-free match below fails to compile when
1316    /// a `LearningSource` variant is added, and its arm count is asserted equal
1317    /// to `ALL_SOURCES.len()`, so a new variant must be appended to both.
1318    #[test]
1319    fn all_sources_is_exhaustive() {
1320        fn arm_count(s: LearningSource) -> usize {
1321            // Wildcard-free on purpose — a new variant breaks compilation here.
1322            match s {
1323                LearningSource::Usync
1324                | LearningSource::PeerPnMessage
1325                | LearningSource::PeerLidMessage
1326                | LearningSource::RecipientLatestLid
1327                | LearningSource::MigrationSyncLatest
1328                | LearningSource::MigrationSyncOld
1329                | LearningSource::BlocklistActive
1330                | LearningSource::BlocklistInactive
1331                | LearningSource::Pairing
1332                | LearningSource::DeviceNotification
1333                | LearningSource::Other => 11,
1334            }
1335        }
1336        assert_eq!(
1337            ALL_SOURCES.len(),
1338            arm_count(LearningSource::Other),
1339            "add the new LearningSource variant to ALL_SOURCES (and the match above)"
1340        );
1341        // Reject duplicates: a repeated entry could pad the length back to the
1342        // arm count while a variant is silently missing.
1343        for (idx, src) in ALL_SOURCES.iter().enumerate() {
1344            assert!(
1345                !ALL_SOURCES[..idx].contains(src),
1346                "ALL_SOURCES contains duplicate {src:?}"
1347            );
1348        }
1349    }
1350
1351    /// An observational source (`Other`, e.g. the history-sync seed) must not
1352    /// overwrite a live-learned LID for the same phone; it returns
1353    /// `NeedsUsync` and leaves the cache untouched.
1354    #[tokio::test]
1355    async fn test_record_observational_preserves_conflicting_known_lid() {
1356        let client = create_test_client().await;
1357        let phone = "5511900000001";
1358        let lid_live = "200000000000001";
1359        let lid_other = "200000000000002";
1360        client
1361            .add_lid_pn_mapping(lid_live, phone, LearningSource::Usync)
1362            .await
1363            .unwrap();
1364        // Make lid_other a *known* LID (mapped to some other phone).
1365        client
1366            .add_lid_pn_mapping(lid_other, "5511900000099", LearningSource::Usync)
1367            .await
1368            .unwrap();
1369
1370        let outcome = client
1371            .record_lid_pn_in_memory(lid_other, phone, LearningSource::Other)
1372            .await;
1373
1374        assert!(
1375            matches!(outcome, RecordOutcome::NeedsUsync),
1376            "observational conflict must request a usync, not clobber"
1377        );
1378        assert_eq!(
1379            client.lid_pn_cache.get_current_lid(phone).await.as_deref(),
1380            Some(lid_live),
1381            "the live mapping must survive an observational conflict"
1382        );
1383    }
1384
1385    /// A directed source (`PeerPnMessage`) does overwrite a conflicting known
1386    /// LID — the WA Web `!y` branch.
1387    #[tokio::test]
1388    async fn test_record_directed_overwrites_conflicting_known_lid() {
1389        let client = create_test_client().await;
1390        let phone = "5511900000010";
1391        let lid_old = "200000000000010";
1392        let lid_new = "200000000000020";
1393        client
1394            .add_lid_pn_mapping(lid_old, phone, LearningSource::Usync)
1395            .await
1396            .unwrap();
1397        client
1398            .add_lid_pn_mapping(lid_new, "5511900000098", LearningSource::Usync)
1399            .await
1400            .unwrap();
1401
1402        let outcome = client
1403            .record_lid_pn_in_memory(lid_new, phone, LearningSource::PeerPnMessage)
1404            .await;
1405
1406        assert!(matches!(
1407            outcome,
1408            RecordOutcome::Written {
1409                needs_migration: false,
1410                ..
1411            }
1412        ));
1413        assert_eq!(
1414            client.lid_pn_cache.get_current_lid(phone).await.as_deref(),
1415            Some(lid_new),
1416            "a directed source must overwrite a conflicting known LID"
1417        );
1418    }
1419
1420    /// Even an observational source seeds a *brand-new* LID for an existing
1421    /// phone (WA Web `c` is true), overwriting the prior mapping.
1422    #[tokio::test]
1423    async fn test_record_observational_seeds_new_lid_over_existing() {
1424        let client = create_test_client().await;
1425        let phone = "5511900000030";
1426        let lid_old = "200000000000030";
1427        let lid_brand_new = "200000000000031";
1428        client
1429            .add_lid_pn_mapping(lid_old, phone, LearningSource::Usync)
1430            .await
1431            .unwrap();
1432
1433        let outcome = client
1434            .record_lid_pn_in_memory(lid_brand_new, phone, LearningSource::Other)
1435            .await;
1436
1437        assert!(matches!(outcome, RecordOutcome::Written { .. }));
1438        assert_eq!(
1439            client.lid_pn_cache.get_current_lid(phone).await.as_deref(),
1440            Some(lid_brand_new),
1441            "a brand-new LID is seeded even by an observational source"
1442        );
1443    }
1444
1445    /// An exact re-learn of a not-yet-durable pair retains the migration work
1446    /// until its retry successfully persists the mapping.
1447    #[tokio::test]
1448    async fn test_record_exact_match_preserves_pending_migration() {
1449        let client = create_test_client().await;
1450        let phone = "5511900000040";
1451        let lid = "200000000000040";
1452        // Cache-only seed (never persisted), so can_skip_relearn stays false.
1453        let _ = client
1454            .record_lid_pn_in_memory(lid, phone, LearningSource::Other)
1455            .await;
1456
1457        let outcome = client
1458            .record_lid_pn_in_memory(lid, phone, LearningSource::Other)
1459            .await;
1460
1461        assert!(
1462            matches!(
1463                outcome,
1464                RecordOutcome::Written {
1465                    needs_migration: true,
1466                    ..
1467                }
1468            ),
1469            "an exact re-learn must retain its pending migration"
1470        );
1471        assert_eq!(
1472            client.lid_pn_cache.get_current_lid(phone).await.as_deref(),
1473            Some(lid)
1474        );
1475    }
1476
1477    /// A failed batch persist leaves the pair cached but not durable. Retrying
1478    /// the same batch must retain its discovery migration instead of treating
1479    /// the cached pair as old.
1480    #[tokio::test]
1481    async fn test_record_batch_retry_preserves_pending_migration() {
1482        let client = create_test_client().await;
1483        let phone = "5511900000041";
1484        let lid = "200000000000041";
1485        let mapping = || vec![(lid.to_string(), phone.to_string())];
1486
1487        let first = client
1488            .record_lid_pn_batch_in_memory(mapping(), LearningSource::Other)
1489            .await;
1490        assert_eq!(first.migration_flags, vec![true]);
1491
1492        // Do not persist `first`: this models the failed batch write.
1493        let retry = client
1494            .record_lid_pn_batch_in_memory(mapping(), LearningSource::Other)
1495            .await;
1496        assert_eq!(retry.migration_flags, vec![true]);
1497
1498        client.lid_pn_cache.mark_persisted(phone, lid).await;
1499        let durable = client
1500            .record_lid_pn_batch_in_memory(mapping(), LearningSource::Other)
1501            .await;
1502        assert!(durable.entries.is_empty());
1503        assert!(durable.migration_flags.is_empty());
1504    }
1505
1506    /// A stale source (`MigrationSyncOld`) writes, but with `created_at = 0`,
1507    /// so the cache's most-recent-wins keeps a fresher mapping for the phone.
1508    #[tokio::test]
1509    async fn test_stale_source_does_not_outrank_fresh_mapping() {
1510        let client = create_test_client().await;
1511        let phone = "5511900000050";
1512        let lid_fresh = "200000000000050";
1513        let lid_stale = "200000000000051";
1514        client
1515            .add_lid_pn_mapping(lid_fresh, phone, LearningSource::Usync)
1516            .await
1517            .unwrap();
1518
1519        let outcome = client
1520            .record_lid_pn_in_memory(lid_stale, phone, LearningSource::MigrationSyncOld)
1521            .await;
1522
1523        assert!(matches!(outcome, RecordOutcome::Written { .. }));
1524        assert_eq!(
1525            client.lid_pn_cache.get_current_lid(phone).await.as_deref(),
1526            Some(lid_fresh),
1527            "a created_at=0 stale mapping must not outrank a fresh one"
1528        );
1529        // The reverse LID→phone direction is still recorded.
1530        assert_eq!(
1531            client
1532                .lid_pn_cache
1533                .get_phone_number(lid_stale)
1534                .await
1535                .as_deref(),
1536            Some(phone)
1537        );
1538    }
1539
1540    /// A stale source still seeds into an empty cache (no fresher mapping to
1541    /// lose to).
1542    #[tokio::test]
1543    async fn test_stale_source_seeds_empty_cache() {
1544        let client = create_test_client().await;
1545        let phone = "5511900000060";
1546        let lid = "200000000000060";
1547
1548        let outcome = client
1549            .record_lid_pn_in_memory(lid, phone, LearningSource::MigrationSyncOld)
1550            .await;
1551
1552        assert!(matches!(
1553            outcome,
1554            RecordOutcome::Written {
1555                needs_migration: true,
1556                ..
1557            }
1558        ));
1559        assert_eq!(
1560            client.lid_pn_cache.get_current_lid(phone).await.as_deref(),
1561            Some(lid)
1562        );
1563    }
1564
1565    /// The batch recorder routes a conflicting observational pair to
1566    /// `usync_phones` while still writing the non-conflicting new pair.
1567    #[tokio::test]
1568    async fn test_record_batch_splits_written_and_usync() {
1569        let client = create_test_client().await;
1570        let phone_conflict = "5511900000070";
1571        let lid_live = "200000000000070";
1572        let lid_known = "200000000000071";
1573        let phone_fresh = "5511900000072";
1574        let lid_fresh = "200000000000073";
1575        client
1576            .add_lid_pn_mapping(lid_live, phone_conflict, LearningSource::Usync)
1577            .await
1578            .unwrap();
1579        client
1580            .add_lid_pn_mapping(lid_known, "5511900000079", LearningSource::Usync)
1581            .await
1582            .unwrap();
1583
1584        let outcome = client
1585            .record_lid_pn_batch_in_memory(
1586                vec![
1587                    (lid_known.to_string(), phone_conflict.to_string()),
1588                    (lid_fresh.to_string(), phone_fresh.to_string()),
1589                ],
1590                LearningSource::Other,
1591            )
1592            .await;
1593
1594        assert_eq!(outcome.usync_phones, vec![phone_conflict.to_string()]);
1595        assert_eq!(outcome.entries.len(), 1);
1596        assert_eq!(&*outcome.entries[0].phone_number, phone_fresh);
1597        assert_eq!(
1598            client
1599                .lid_pn_cache
1600                .get_current_lid(phone_conflict)
1601                .await
1602                .as_deref(),
1603            Some(lid_live),
1604            "the conflicting phone must keep its live LID"
1605        );
1606    }
1607
1608    /// End-to-end through the public batch learn: an `Other` (history-sync)
1609    /// seed must not overwrite a live-learned LID for the same phone.
1610    #[tokio::test]
1611    async fn test_learn_batch_other_preserves_live_mapping() {
1612        let client = create_test_client().await;
1613        let phone = "5511900000080";
1614        let lid_live = "200000000000080";
1615        let lid_hist = "200000000000081";
1616        client
1617            .add_lid_pn_mapping(lid_live, phone, LearningSource::Usync)
1618            .await
1619            .unwrap();
1620        client
1621            .add_lid_pn_mapping(lid_hist, "5511900000089", LearningSource::Usync)
1622            .await
1623            .unwrap();
1624
1625        client
1626            .learn_lid_pn_mappings_batch(
1627                vec![(lid_hist.to_string(), phone.to_string())],
1628                LearningSource::Other,
1629                false,
1630            )
1631            .await;
1632
1633        assert_eq!(
1634            client.lid_pn_cache.get_current_lid(phone).await.as_deref(),
1635            Some(lid_live),
1636            "a history-sync seed must not clobber the live mapping"
1637        );
1638    }
1639
1640    #[tokio::test]
1641    async fn test_latch_lid_migrated_from_props() {
1642        let client: Arc<Client> = create_test_client().await;
1643
1644        // Prop absent: nothing latched.
1645        client.latch_lid_migrated_from_props().await;
1646        assert!(
1647            !client
1648                .persistence_manager
1649                .get_device_snapshot()
1650                .lid_migrated
1651        );
1652
1653        // Prop observed on: persisted, and it outlives the prop disappearing
1654        // from a later fetch.
1655        client
1656            .ab_props()
1657            .apply_props(
1658                false,
1659                std::iter::once((
1660                    wacore::iq::abprops::web::LID_ONE_ON_ONE_MIGRATION_ENABLED.code,
1661                    "1".into(),
1662                )),
1663            )
1664            .await;
1665        client.latch_lid_migrated_from_props().await;
1666        client
1667            .ab_props()
1668            .apply_props(false, std::iter::empty())
1669            .await;
1670        assert!(
1671            client
1672                .persistence_manager
1673                .get_device_snapshot()
1674                .lid_migrated
1675        );
1676        assert!(client.is_lid_migrated().await);
1677    }
1678
1679    #[tokio::test]
1680    async fn test_resolve_encryption_jid_pn_to_lid() {
1681        let client: Arc<Client> = create_test_client().await;
1682        let pn = "55999999999";
1683        let lid = "100000012345678";
1684
1685        // Add mapping to cache
1686        client
1687            .add_lid_pn_mapping(lid, pn, LearningSource::PeerPnMessage)
1688            .await
1689            .unwrap();
1690
1691        let pn_jid = Jid::pn(pn);
1692        let resolved = client.resolve_encryption_jid(&pn_jid).await;
1693
1694        assert_eq!(resolved.user, lid);
1695        assert_eq!(resolved.server, Server::Lid);
1696    }
1697
1698    #[tokio::test]
1699    async fn test_resolve_encryption_jid_preserves_lid() {
1700        let client: Arc<Client> = create_test_client().await;
1701        let lid = "100000012345678";
1702        let lid_jid = Jid::lid(lid);
1703
1704        let resolved = client.resolve_encryption_jid(&lid_jid).await;
1705
1706        assert_eq!(resolved, lid_jid);
1707    }
1708
1709    #[tokio::test]
1710    async fn test_resolve_encryption_jid_no_mapping_returns_pn() {
1711        let client: Arc<Client> = create_test_client().await;
1712        let pn = "55999999999";
1713        let pn_jid = Jid::pn(pn);
1714
1715        let resolved = client.resolve_encryption_jid(&pn_jid).await;
1716
1717        assert_eq!(resolved, pn_jid);
1718    }
1719
1720    #[tokio::test]
1721    async fn test_resolve_dm_wire_jid_unmigrated_keeps_pn() {
1722        let (client, pn, lid) = client_with_peer_mapping().await;
1723
1724        // Unmigrated account: the wire jid stays PN even with a cached mapping.
1725        assert_eq!(client.resolve_dm_wire_jid(&Jid::pn(pn)).await, Jid::pn(pn));
1726        // A LID chat id maps back to the PN chat (WA Web keeps 1:1 chats on
1727        // PN until the account migrates).
1728        assert_eq!(
1729            client.resolve_dm_wire_jid(&Jid::lid(lid)).await,
1730            Jid::pn(pn)
1731        );
1732        // Signal session addressing is deliberately not gated.
1733        assert_eq!(client.resolve_encryption_jid(&Jid::pn(pn)).await.user, lid);
1734    }
1735
1736    #[tokio::test]
1737    async fn test_resolve_dm_wire_jid_unmigrated_unmapped_lid_stays_lid() {
1738        let client: Arc<Client> = create_test_client().await;
1739        let lid_jid = Jid::lid("111000011112222");
1740        assert_eq!(client.resolve_dm_wire_jid(&lid_jid).await, lid_jid);
1741    }
1742
1743    #[tokio::test]
1744    async fn test_resolve_dm_wire_jid_migrated_flag_upgrades_to_lid() {
1745        let (client, pn, lid) = client_with_peer_mapping().await;
1746
1747        client
1748            .persistence_manager
1749            .process_command(crate::store::commands::DeviceCommand::SetLidMigrated(true))
1750            .await;
1751
1752        assert!(client.is_lid_migrated().await);
1753        assert_eq!(
1754            client.resolve_dm_wire_jid(&Jid::pn(pn)).await,
1755            Jid::lid(lid)
1756        );
1757    }
1758
1759    #[tokio::test]
1760    async fn test_resolve_dm_wire_jid_migration_prop_upgrades_to_lid() {
1761        let (client, pn, lid) = client_with_peer_mapping().await;
1762
1763        assert!(!client.is_lid_migrated().await);
1764        client
1765            .ab_props()
1766            .apply_props(
1767                false,
1768                std::iter::once((
1769                    wacore::iq::abprops::web::LID_ONE_ON_ONE_MIGRATION_ENABLED.code,
1770                    "1".into(),
1771                )),
1772            )
1773            .await;
1774
1775        assert!(client.is_lid_migrated().await);
1776        assert_eq!(
1777            client.resolve_dm_wire_jid(&Jid::pn(pn)).await,
1778            Jid::lid(lid)
1779        );
1780    }
1781
1782    #[tokio::test]
1783    async fn test_lid_migration_mapping_sync_learns_and_migrates_with_prop() {
1784        use buffa::Message as _;
1785        use waproto::whatsapp as wa;
1786
1787        let client: Arc<Client> = create_test_client().await;
1788        let payload = wa::LIDMigrationMappingSyncPayload {
1789            pn_to_lid_mappings: vec![wa::LIDMigrationMapping {
1790                pn: 5511987650001,
1791                assigned_lid: 111000011112222,
1792                latest_lid: None,
1793            }],
1794            chat_db_migration_timestamp: None,
1795        };
1796        let sync = wa::LIDMigrationMappingSyncMessage {
1797            encoded_mapping_payload: Some(payload.encode_to_vec()),
1798        };
1799
1800        // Prop off: mappings are learned but the account stays unmigrated,
1801        // mirroring WA Web's state machine parking at WAITING_PROP.
1802        client.handle_lid_migration_mapping_sync(&sync).await;
1803        assert_eq!(
1804            client
1805                .resolve_encryption_jid(&Jid::pn("5511987650001"))
1806                .await
1807                .user,
1808            "111000011112222"
1809        );
1810        assert!(!client.is_lid_migrated().await);
1811
1812        // Prop on: the same push persists the migrated flag...
1813        client
1814            .ab_props()
1815            .apply_props(
1816                false,
1817                std::iter::once((
1818                    wacore::iq::abprops::web::LID_ONE_ON_ONE_MIGRATION_ENABLED.code,
1819                    "1".into(),
1820                )),
1821            )
1822            .await;
1823        client.handle_lid_migration_mapping_sync(&sync).await;
1824
1825        // ...which outlives the prop, like the WA Web pref.
1826        client
1827            .ab_props()
1828            .apply_props(false, std::iter::empty())
1829            .await;
1830        assert!(client.is_lid_migrated().await);
1831    }
1832
1833    #[tokio::test]
1834    async fn test_is_lid_migrated_prop_zero_or_absent_is_false() {
1835        let client: Arc<Client> = create_test_client().await;
1836        assert!(!client.is_lid_migrated().await);
1837
1838        client
1839            .ab_props()
1840            .apply_props(
1841                false,
1842                std::iter::once((
1843                    wacore::iq::abprops::web::LID_ONE_ON_ONE_MIGRATION_ENABLED.code,
1844                    "0".into(),
1845                )),
1846            )
1847            .await;
1848        assert!(!client.is_lid_migrated().await);
1849    }
1850
1851    #[tokio::test]
1852    async fn test_lid_migration_mapping_sync_missing_or_malformed_payload_is_ignored() {
1853        use waproto::whatsapp as wa;
1854
1855        let client: Arc<Client> = create_test_client().await;
1856        client
1857            .ab_props()
1858            .apply_props(
1859                false,
1860                std::iter::once((
1861                    wacore::iq::abprops::web::LID_ONE_ON_ONE_MIGRATION_ENABLED.code,
1862                    "1".into(),
1863                )),
1864            )
1865            .await;
1866
1867        // Missing payload: WA Web treats this as malformed; nothing is
1868        // learned and the account must not flip to migrated.
1869        let missing = wa::LIDMigrationMappingSyncMessage {
1870            encoded_mapping_payload: None,
1871        };
1872        client.handle_lid_migration_mapping_sync(&missing).await;
1873        assert!(
1874            !client
1875                .persistence_manager
1876                .get_device_snapshot()
1877                .lid_migrated
1878        );
1879
1880        let malformed = wa::LIDMigrationMappingSyncMessage {
1881            encoded_mapping_payload: Some(vec![0xFF, 0xFF, 0xFF]),
1882        };
1883        client.handle_lid_migration_mapping_sync(&malformed).await;
1884        assert!(
1885            !client
1886                .persistence_manager
1887                .get_device_snapshot()
1888                .lid_migrated
1889        );
1890    }
1891
1892    #[tokio::test]
1893    async fn test_lid_migration_mapping_sync_prefers_latest_lid() {
1894        use buffa::Message as _;
1895        use waproto::whatsapp as wa;
1896
1897        let client: Arc<Client> = create_test_client().await;
1898        let payload = wa::LIDMigrationMappingSyncPayload {
1899            pn_to_lid_mappings: vec![wa::LIDMigrationMapping {
1900                pn: 5511987650001,
1901                assigned_lid: 111000011112222,
1902                latest_lid: Some(999000099990000),
1903            }],
1904            chat_db_migration_timestamp: None,
1905        };
1906        let sync = wa::LIDMigrationMappingSyncMessage {
1907            encoded_mapping_payload: Some(payload.encode_to_vec()),
1908        };
1909
1910        client.handle_lid_migration_mapping_sync(&sync).await;
1911        assert_eq!(
1912            client
1913                .resolve_encryption_jid(&Jid::pn("5511987650001"))
1914                .await
1915                .user,
1916            "999000099990000"
1917        );
1918    }
1919
1920    #[tokio::test]
1921    async fn test_resolve_encryption_jid_hosted_with_lid_upgrades_to_hosted_lid() {
1922        let client: Arc<Client> = create_test_client().await;
1923        let user = "55999999999";
1924        let lid = "100000012345678";
1925
1926        client
1927            .add_lid_pn_mapping(lid, user, LearningSource::PeerPnMessage)
1928            .await
1929            .unwrap();
1930
1931        for device in [99u16, 7] {
1932            let mut hosted = Jid::new(user, Server::Hosted);
1933            hosted.device = device;
1934            hosted.agent = 0xAB;
1935            hosted.integrator = 0xBEEF;
1936            let resolved = client.resolve_encryption_jid(&hosted).await;
1937
1938            assert_eq!(resolved.user, lid);
1939            assert_eq!(resolved.server, Server::HostedLid);
1940            assert_eq!(
1941                resolved.device, device,
1942                "device must round-trip, not be coerced to 99"
1943            );
1944            assert_eq!(resolved.agent, hosted.agent);
1945            assert_eq!(resolved.integrator, hosted.integrator);
1946        }
1947    }
1948
1949    #[tokio::test]
1950    async fn test_resolve_encryption_jid_hosted_no_mapping_keeps_hosted() {
1951        let client: Arc<Client> = create_test_client().await;
1952        let mut hosted = Jid::new("55999999999", Server::Hosted);
1953        hosted.device = 99;
1954
1955        let resolved = client.resolve_encryption_jid(&hosted).await;
1956
1957        assert_eq!(resolved, hosted);
1958    }
1959
1960    #[tokio::test]
1961    async fn test_resolve_encryption_jid_preserves_hosted_lid() {
1962        let client: Arc<Client> = create_test_client().await;
1963        let mut hosted_lid = Jid::new("100000012345678", Server::HostedLid);
1964        hosted_lid.device = 99;
1965
1966        let resolved = client.resolve_encryption_jid(&hosted_lid).await;
1967
1968        assert_eq!(resolved, hosted_lid);
1969    }
1970
1971    #[tokio::test]
1972    async fn test_get_lid_pn_entry_from_pn() {
1973        let client: Arc<Client> = create_test_client().await;
1974        let pn = "55999999999";
1975        let lid = "100000012345678";
1976
1977        assert!(
1978            client
1979                .get_lid_pn_entry(&Jid::pn(pn))
1980                .await
1981                .unwrap()
1982                .is_none()
1983        );
1984
1985        client
1986            .add_lid_pn_mapping(lid, pn, LearningSource::Usync)
1987            .await
1988            .unwrap();
1989
1990        let entry = client
1991            .get_lid_pn_entry(&Jid::pn(pn))
1992            .await
1993            .unwrap()
1994            .unwrap();
1995        assert_eq!(&*entry.lid, lid);
1996        assert_eq!(&*entry.phone_number, pn);
1997    }
1998
1999    #[tokio::test]
2000    async fn test_get_lid_pn_entry_from_lid() {
2001        let client: Arc<Client> = create_test_client().await;
2002        let pn = "55999999999";
2003        let lid = "100000012345678";
2004
2005        assert!(
2006            client
2007                .get_lid_pn_entry(&Jid::lid(lid))
2008                .await
2009                .unwrap()
2010                .is_none()
2011        );
2012
2013        client
2014            .add_lid_pn_mapping(lid, pn, LearningSource::Usync)
2015            .await
2016            .unwrap();
2017
2018        let entry = client
2019            .get_lid_pn_entry(&Jid::lid(lid))
2020            .await
2021            .unwrap()
2022            .unwrap();
2023        assert_eq!(&*entry.lid, lid);
2024        assert_eq!(&*entry.phone_number, pn);
2025    }
2026
2027    /// Cache-aside fallback: if the in-memory cache is missing an entry the
2028    /// backend has, the lookup should still succeed and re-populate the cache.
2029    #[tokio::test]
2030    async fn test_get_lid_pn_entry_falls_back_to_backend() {
2031        use wacore::store::traits::LidPnMappingEntry;
2032
2033        let client: Arc<Client> = create_test_client().await;
2034        let pn = "15555550123";
2035        let lid = "100000000000123";
2036
2037        let backend = client.persistence_manager.backend();
2038        backend
2039            .put_lid_mapping(&LidPnMappingEntry {
2040                lid: lid.into(),
2041                phone_number: pn.into(),
2042                created_at: 1,
2043                updated_at: 1,
2044                learning_source: "usync".into(),
2045            })
2046            .await
2047            .unwrap();
2048
2049        // Cache was never warmed from this backend write → cache miss path.
2050        let entry = client
2051            .get_lid_pn_entry(&Jid::lid(lid))
2052            .await
2053            .unwrap()
2054            .unwrap();
2055        assert_eq!(&*entry.lid, lid);
2056        assert_eq!(&*entry.phone_number, pn);
2057
2058        // Subsequent lookup served from cache.
2059        let entry = client
2060            .get_lid_pn_entry(&Jid::pn(pn))
2061            .await
2062            .unwrap()
2063            .unwrap();
2064        assert_eq!(&*entry.lid, lid);
2065    }
2066
2067    /// `learn_lid_pn_mapping_fast` must leave the in-memory cache populated
2068    /// by the time it returns — `resolve_encryption_jid` runs immediately
2069    /// after on the decrypt hot path and needs to find the LID.
2070    #[tokio::test]
2071    async fn test_learn_lid_pn_mapping_fast_populates_cache_synchronously() {
2072        let client: Arc<Client> = create_test_client().await;
2073        let pn = "5511999998877";
2074        let lid = "200000000007788";
2075
2076        client
2077            .learn_lid_pn_mapping_fast(lid, pn, LearningSource::PeerPnMessage, false)
2078            .await;
2079
2080        let resolved = client.resolve_encryption_jid(&Jid::pn(pn)).await;
2081        assert_eq!(resolved.user, lid, "cache must have the mapping on return");
2082        assert_eq!(resolved.server, Server::Lid);
2083    }
2084
2085    /// A mapping first warmed memory-only by an offline replay must still be
2086    /// persisted on its first live message; the fast-path skip must not swallow
2087    /// it just because the cache already holds it.
2088    #[tokio::test]
2089    async fn learn_fast_offline_then_live_persists() {
2090        let client: Arc<Client> = create_test_client().await;
2091        let lid = "200000000012345";
2092        let pn = "5511988887777";
2093        let backend = client.persistence_manager.backend();
2094
2095        client
2096            .learn_lid_pn_mapping_fast(lid, pn, LearningSource::PeerPnMessage, true)
2097            .await;
2098        assert_eq!(client.resolve_encryption_jid(&Jid::pn(pn)).await.user, lid);
2099        assert!(
2100            backend.get_lid_mapping(lid).await.unwrap().is_none(),
2101            "offline learn must not persist"
2102        );
2103
2104        client
2105            .learn_lid_pn_mapping_fast(lid, pn, LearningSource::PeerPnMessage, false)
2106            .await;
2107        // Poll until persisted; tolerate the transient SQLite read/write lock
2108        // while the detached persist task is mid-write.
2109        let start = wacore::time::Instant::now();
2110        while !matches!(backend.get_lid_mapping(lid).await, Ok(Some(_))) {
2111            assert!(
2112                start.elapsed() < std::time::Duration::from_secs(5),
2113                "live learn after an offline-only learn must persist"
2114            );
2115            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2116        }
2117    }
2118
2119    /// Batched variant must populate the in-memory cache synchronously for
2120    /// every entry before returning; WA Web parity for `createLidPnMappings`.
2121    #[tokio::test]
2122    async fn test_learn_lid_pn_mappings_batch_populates_cache_synchronously() {
2123        let client: Arc<Client> = create_test_client().await;
2124        let pairs = [
2125            ("200000000000001", "5511911111111"),
2126            ("200000000000002", "5511922222222"),
2127            ("200000000000003", "5511933333333"),
2128        ];
2129
2130        let batch: Vec<(String, String)> = pairs
2131            .iter()
2132            .map(|(lid, pn)| ((*lid).to_string(), (*pn).to_string()))
2133            .collect();
2134        client
2135            .learn_lid_pn_mappings_batch(batch, LearningSource::Other, false)
2136            .await;
2137
2138        for (lid, pn) in &pairs {
2139            let resolved = client.resolve_encryption_jid(&Jid::pn(*pn)).await;
2140            assert_eq!(resolved.user, *lid, "batch entry {pn} missing from cache");
2141            assert_eq!(resolved.server, Server::Lid);
2142        }
2143    }
2144
2145    /// Empty batch is a no-op (no detached task, no panic).
2146    #[tokio::test]
2147    async fn test_learn_lid_pn_mappings_batch_empty_is_noop() {
2148        let client: Arc<Client> = create_test_client().await;
2149        client
2150            .learn_lid_pn_mappings_batch(Vec::new(), LearningSource::Other, false)
2151            .await;
2152        assert_eq!(client.lid_pn_cache.lid_count().await, 0);
2153    }
2154
2155    #[tokio::test]
2156    async fn test_add_lid_pn_mappings_deduplicates_and_is_durable_on_return() {
2157        let client: Arc<Client> = create_test_client().await;
2158        let phone = "5511900012345";
2159        let stale_lid = "200000000001234";
2160        let current_lid = "200000000001235";
2161
2162        let written = client
2163            .add_lid_pn_mappings(
2164                vec![
2165                    (stale_lid.to_owned(), phone.to_owned()),
2166                    (current_lid.to_owned(), phone.to_owned()),
2167                ],
2168                LearningSource::Other,
2169            )
2170            .await
2171            .unwrap();
2172
2173        assert_eq!(written, 1);
2174        let persisted = client
2175            .persistence_manager
2176            .backend()
2177            .get_lid_mapping(current_lid)
2178            .await
2179            .unwrap()
2180            .expect("mapping must be durable when the call returns");
2181        assert_eq!(persisted.phone_number, phone);
2182        assert_eq!(
2183            client.resolve_encryption_jid(&Jid::pn(phone)).await.user,
2184            current_lid
2185        );
2186    }
2187
2188    /// Online (`is_offline = false`) batch must persist the mapping to the
2189    /// backend AND run `migrate_device_registry_on_lid_discovery` for each
2190    /// newly learned PN. Polls until the detached task completes.
2191    #[tokio::test]
2192    async fn test_learn_lid_pn_mappings_batch_online_persists_and_migrates() {
2193        use wacore::store::traits::{DeviceInfo, DeviceListRecord};
2194        use wacore_binary::Jid;
2195
2196        let client: Arc<Client> = create_test_client().await;
2197        let lid = "200000000077777";
2198        let pn = "5511955550000";
2199        let backend = client.persistence_manager.backend();
2200
2201        // Seed a PN-keyed device registry row so the migration has something
2202        // to move when the mapping is learned. Without this, the migration
2203        // helper is a no-op and the test can't distinguish "migration ran"
2204        // from "migration never called".
2205        backend
2206            .update_device_list(DeviceListRecord {
2207                user: pn.to_string(),
2208                devices: vec![DeviceInfo::new(3, None)],
2209                timestamp: wacore::time::now_secs(),
2210                phash: None,
2211                raw_id: None,
2212            })
2213            .await
2214            .unwrap();
2215
2216        client
2217            .learn_lid_pn_mappings_batch(
2218                vec![(lid.to_string(), pn.to_string())],
2219                LearningSource::Other,
2220                false,
2221            )
2222            .await;
2223
2224        // Poll for the end-of-chain migration effect (device row moved to
2225        // LID key). That strictly happens after both `put_lid_mappings` and
2226        // `migrate_device_registry_on_lid_discovery`, so observing it
2227        // guarantees both steps ran.
2228        let start = wacore::time::Instant::now();
2229        let deadline = std::time::Duration::from_secs(5);
2230        loop {
2231            if backend.get_devices(lid).await.unwrap().is_some() {
2232                break;
2233            }
2234            assert!(
2235                start.elapsed() < deadline,
2236                "timed out waiting for batch persist + migration"
2237            );
2238            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2239        }
2240
2241        assert!(
2242            backend.get_lid_mapping(lid).await.unwrap().is_some(),
2243            "mapping must be persisted"
2244        );
2245        assert!(
2246            backend.get_devices(pn).await.unwrap().is_none(),
2247            "migration must delete the old PN-keyed device row"
2248        );
2249        let lid_row = backend.get_devices(lid).await.unwrap().unwrap();
2250        assert_eq!(lid_row.devices[0].device_id, 3);
2251        // And the mapping resolves from both directions.
2252        assert_eq!(
2253            client
2254                .get_lid_pn_entry(&Jid::pn(pn))
2255                .await
2256                .unwrap()
2257                .unwrap()
2258                .lid,
2259            lid.into()
2260        );
2261    }
2262
2263    /// Offline batch only warms the in-memory cache; the persist task never
2264    /// fires. Mirrors WA Web's `flushImmediately = false` semantics.
2265    #[tokio::test]
2266    async fn test_learn_lid_pn_mappings_batch_offline_skips_persist() {
2267        use wacore_binary::Jid;
2268
2269        let client: Arc<Client> = create_test_client().await;
2270        let lid = "200000000009999";
2271        let pn = "5511900009999";
2272
2273        client
2274            .learn_lid_pn_mappings_batch(
2275                vec![(lid.to_string(), pn.to_string())],
2276                LearningSource::Other,
2277                true,
2278            )
2279            .await;
2280
2281        let resolved = client.resolve_encryption_jid(&Jid::pn(pn)).await;
2282        assert_eq!(resolved.user, lid);
2283
2284        assert!(
2285            client
2286                .persistence_manager
2287                .backend()
2288                .get_lid_mapping(lid)
2289                .await
2290                .unwrap()
2291                .is_none(),
2292            "offline batch must not persist to DB"
2293        );
2294    }
2295
2296    /// Duplicate phone_numbers in a single batch must collapse to one
2297    /// (lid, phone) → migration entry, and that entry must use the FINAL
2298    /// lid for the phone. Otherwise migration runs against the stale lid
2299    /// while the persisted mapping resolves to the fresh one.
2300    #[tokio::test]
2301    async fn test_learn_lid_pn_mappings_batch_dedups_duplicate_phones() {
2302        use wacore_binary::Jid;
2303
2304        let client: Arc<Client> = create_test_client().await;
2305        let pn = "5511900000007";
2306        let lid_stale = "200000000007777";
2307        let lid_fresh = "200000000007999";
2308
2309        client
2310            .learn_lid_pn_mappings_batch(
2311                vec![
2312                    (lid_stale.to_string(), pn.to_string()),
2313                    (lid_fresh.to_string(), pn.to_string()),
2314                ],
2315                LearningSource::Other,
2316                true, // offline → no spawned persist, no migration races
2317            )
2318            .await;
2319
2320        // Final cache state must reflect the LAST mapping for this phone.
2321        let resolved = client.resolve_encryption_jid(&Jid::pn(pn)).await;
2322        assert_eq!(
2323            resolved.user, lid_fresh,
2324            "dedup must keep the last lid for a repeated phone_number"
2325        );
2326    }
2327
2328    /// Produce a SessionRecord blob with a distinctive remote_registration_id
2329    /// so we can tell which side of a migration won by parsing the surviving
2330    /// session, not by raw-byte comparison.
2331    fn tagged_session_blob(remote_regid: u32) -> Vec<u8> {
2332        use wacore::libsignal::protocol::{SessionRecord, SessionState};
2333        use waproto::whatsapp::SessionStructure;
2334
2335        let state = SessionState::from_session_structure(SessionStructure {
2336            session_version: Some(3),
2337            local_identity_public: None,
2338            remote_identity_public: None,
2339            root_key: None,
2340            previous_counter: Some(0),
2341            sender_chain: buffa::MessageField::none(),
2342            receiver_chains: vec![],
2343            pending_pre_key: buffa::MessageField::none(),
2344            remote_registration_id: Some(remote_regid),
2345            local_registration_id: Some(0),
2346            alice_base_key: Some(vec![]),
2347            needs_refresh: None,
2348            pending_key_exchange: buffa::MessageField::none(),
2349        });
2350        SessionRecord::new(state)
2351            .serialize()
2352            .expect("serialize session record")
2353    }
2354
2355    /// Both PN and LID slots hold a session for the same peer; the
2356    /// PN one is the working Double Ratchet state, the LID one was
2357    /// built freshly by `process_prekey_bundle` and has no link to
2358    /// the peer's outbound chain. Migration must keep the PN blob —
2359    /// silently dropping it leaves the linked device pinned to the
2360    /// fresh stub forever. Reg-id tags identify which side won.
2361    #[tokio::test]
2362    async fn migration_preserves_working_session_when_both_namespaces_present() {
2363        use wacore::libsignal::protocol::SessionRecord;
2364        use wacore::types::jid::JidExt as _;
2365
2366        let client: Arc<Client> = create_test_client().await;
2367        let pn = "5500000000000";
2368        let lid = "111111111111111";
2369
2370        client
2371            .add_lid_pn_mapping(lid, pn, LearningSource::PeerPnMessage)
2372            .await
2373            .unwrap();
2374
2375        let pn_addr = Jid::pn_device(pn.to_string(), 0).to_protocol_address();
2376        let lid_addr = Jid::lid_device(lid.to_string(), 0).to_protocol_address();
2377
2378        // The working session — what Bob's outbound chain is actually
2379        // ratcheted against — lives in the PN slot. Tag it with a
2380        // distinctive registration id so post-migration we can prove
2381        // the surviving session is the SAME blob.
2382        const WORKING_REGID: u32 = 0xDEAD_BEEF;
2383        const FRESH_REGID: u32 = 0x0BAD_F00D;
2384
2385        let backend = client.persistence_manager.backend();
2386
2387        // Seed both slots through signal_cache so the cache holds Present
2388        // entries when migrate runs. Raw backend writes alone leave the
2389        // cache cold and migrate's `get_session` then races with whatever
2390        // populated Absent markers for unknown peers during test bring-up.
2391        client
2392            .signal_cache
2393            .put_session(
2394                &pn_addr,
2395                SessionRecord::deserialize(&tagged_session_blob(WORKING_REGID))
2396                    .expect("seed PN blob deserializes"),
2397            )
2398            .await;
2399        client
2400            .signal_cache
2401            .put_session(
2402                &lid_addr,
2403                SessionRecord::deserialize(&tagged_session_blob(FRESH_REGID))
2404                    .expect("seed LID blob deserializes"),
2405            )
2406            .await;
2407        client.signal_cache.flush(backend.as_ref()).await.unwrap();
2408
2409        client
2410            .migrate_signal_sessions_on_lid_discovery(pn, lid)
2411            .await;
2412
2413        // PN must be drained — future loads route to LID once the
2414        // mapping is known.
2415        assert!(
2416            backend
2417                .get_session(pn_addr.as_str())
2418                .await
2419                .unwrap()
2420                .is_none(),
2421            "PN address must be cleared post-migration"
2422        );
2423
2424        let surviving_bytes = backend
2425            .get_session(lid_addr.as_str())
2426            .await
2427            .unwrap()
2428            .expect("LID slot must have a session after migration");
2429        let record = SessionRecord::deserialize(&surviving_bytes)
2430            .expect("surviving session blob must parse");
2431        let surviving_regid = record
2432            .remote_registration_id()
2433            .expect("surviving session must expose its remote reg id");
2434
2435        assert_eq!(
2436            surviving_regid, WORKING_REGID,
2437            "LID slot held the FRESH (regid={:#x}) blob — that's the prod \
2438             deadlock: the working PN session ({:#x}) got discarded by the \
2439             'both exist' branch, leaving us pinned to a session that has no \
2440             link to the peer's outbound chain.",
2441            surviving_regid, WORKING_REGID
2442        );
2443    }
2444
2445    #[tokio::test]
2446    async fn lid_discovery_migrates_standard_and_hosted_signal_namespaces() {
2447        use wacore::libsignal::protocol::SessionRecord;
2448        use wacore::types::jid::JidExt as _;
2449
2450        let client: Arc<Client> = create_test_client().await;
2451        let pn = "13135550100";
2452        let lid = "100000000000100";
2453        let backend = client.persistence_manager.backend();
2454        let pairs = [
2455            (Server::Pn, Server::Lid, 11),
2456            (Server::Hosted, Server::HostedLid, 12),
2457        ];
2458
2459        for (from_server, _, registration_id) in pairs {
2460            let source = Jid::new(pn, from_server).to_protocol_address();
2461            client
2462                .signal_cache
2463                .put_session(
2464                    &source,
2465                    SessionRecord::deserialize(&tagged_session_blob(registration_id)).unwrap(),
2466                )
2467                .await;
2468        }
2469        client.signal_cache.flush(backend.as_ref()).await.unwrap();
2470
2471        assert!(
2472            client
2473                .migrate_signal_sessions_on_lid_discovery(pn, lid)
2474                .await
2475        );
2476        for (from_server, to_server, _) in pairs {
2477            let source = Jid::new(pn, from_server).to_protocol_address();
2478            let destination = Jid::new(lid, to_server).to_protocol_address();
2479            assert!(
2480                backend
2481                    .get_session(source.as_str())
2482                    .await
2483                    .unwrap()
2484                    .is_none()
2485            );
2486            assert!(
2487                backend
2488                    .get_session(destination.as_str())
2489                    .await
2490                    .unwrap()
2491                    .is_some()
2492            );
2493        }
2494    }
2495
2496    /// A freshly-resolved peer (no prior PN Signal state) must short-circuit the
2497    /// per-device migration scan: nothing to move, so no LID session appears and
2498    /// the MIGRATION_DEVICE_RANGE lock/lookup loop is skipped.
2499    #[tokio::test]
2500    async fn migrate_skips_when_no_pn_signal_state() {
2501        use wacore::types::jid::JidExt as _;
2502
2503        let client: Arc<Client> = create_test_client().await;
2504        let pn = "5500000000777";
2505        let lid = "222222222222222";
2506        client
2507            .add_lid_pn_mapping(lid, pn, LearningSource::PeerPnMessage)
2508            .await
2509            .unwrap();
2510        let backend = client.persistence_manager.backend();
2511
2512        // Fresh peer: no PN session or identity anywhere, so the guard skips.
2513        assert!(
2514            !client
2515                .signal_cache
2516                .has_state_for_user(pn, backend.as_ref())
2517                .await
2518                .unwrap(),
2519            "fresh peer should have no PN Signal state"
2520        );
2521
2522        client
2523            .migrate_signal_sessions_on_lid_discovery(pn, lid)
2524            .await;
2525
2526        // No LID session was materialized (nothing was migrated).
2527        let lid_addr = Jid::lid_device(lid.to_string(), 0).to_protocol_address();
2528        assert!(
2529            client
2530                .signal_cache
2531                .get_session(&lid_addr, backend.as_ref())
2532                .await
2533                .unwrap()
2534                .is_none(),
2535            "migration of a stateless peer must not create a LID session"
2536        );
2537    }
2538
2539    /// Migration must hold the same per-address session locks that
2540    /// encrypt/decrypt take. Otherwise a concurrent `message_encrypt`
2541    /// on the LID slot can clobber the just-migrated session (or read
2542    /// mid-update state). Externally hold the LID lock, kick off
2543    /// migration, and assert it blocks until the lock is released.
2544    #[tokio::test]
2545    async fn migration_blocks_on_per_address_session_lock() {
2546        use std::time::Duration;
2547        use wacore::types::jid::JidExt as _;
2548
2549        let client: Arc<Client> = create_test_client().await;
2550        let pn = "5500000000000";
2551        let lid = "111111111111111";
2552        client
2553            .add_lid_pn_mapping(lid, pn, LearningSource::PeerPnMessage)
2554            .await
2555            .unwrap();
2556
2557        // Seed a PN session so the migration actually enters its per-device
2558        // loop. The existence guard skips when there is nothing to migrate, and
2559        // this test is about the lock the loop takes when migrating real state.
2560        let pn_addr = Jid::pn_device(pn.to_string(), 0).to_protocol_address();
2561        client
2562            .signal_cache
2563            .put_session(
2564                &pn_addr,
2565                wacore::libsignal::protocol::SessionRecord::deserialize(&tagged_session_blob(
2566                    0xDEAD_BEEF,
2567                ))
2568                .expect("seed PN blob deserializes"),
2569            )
2570            .await;
2571
2572        let lid_addr = Jid::lid_device(lid.to_string(), 0).to_protocol_address();
2573        let lid_lock = client.session_lock_for(lid_addr.as_str()).await;
2574        let held = lid_lock.lock().await;
2575
2576        let migrate_client = client.clone();
2577        let pn_s = pn.to_string();
2578        let lid_s = lid.to_string();
2579        let mut handle = tokio::spawn(async move {
2580            migrate_client
2581                .migrate_signal_sessions_on_lid_discovery(&pn_s, &lid_s)
2582                .await;
2583        });
2584
2585        let blocked = tokio::time::timeout(Duration::from_millis(200), &mut handle).await;
2586        assert!(
2587            blocked.is_err(),
2588            "migration must block while another holder owns the LID address \
2589             session lock — otherwise concurrent encrypt/decrypt races"
2590        );
2591
2592        // Release the lock; migration should now complete so the spawned task
2593        // doesn't outlive the test (and contaminate parallel test state).
2594        drop(held);
2595        tokio::time::timeout(Duration::from_secs(5), handle)
2596            .await
2597            .expect("migration must complete once the lock is released")
2598            .expect("migration task must not panic");
2599    }
2600
2601    /// Regression guard for the decrypt-path deadlock: `decrypt_message`
2602    /// holds `session_lock_for(<lid_addr>)` while invoking
2603    /// `try_pn_to_lid_migration_decrypt`, whose migration loop re-enters
2604    /// that same mutex. The fix is to drop the guard around the call.
2605    /// This test exercises the exact drop → migrate → reacquire dance the
2606    /// production code does, asserting it never deadlocks.
2607    #[tokio::test]
2608    async fn migration_lock_dance_completes_when_caller_drops_guard() {
2609        use std::time::Duration;
2610        use wacore::types::jid::JidExt as _;
2611
2612        let client: Arc<Client> = create_test_client().await;
2613        let pn = "5500000000000";
2614        let lid = "111111111111111";
2615        client
2616            .add_lid_pn_mapping(lid, pn, LearningSource::PeerPnMessage)
2617            .await
2618            .unwrap();
2619
2620        let lid_addr = Jid::lid_device(lid.to_string(), 0).to_protocol_address();
2621        let session_mutex = client.session_lock_for(lid_addr.as_str()).await;
2622        let mut session_guard: Option<async_lock::MutexGuardArc<()>> =
2623            Some(session_mutex.lock_arc().await);
2624
2625        // Exactly mirrors try_pn_to_lid_migration_decrypt: drop, migrate,
2626        // reacquire. If the migration's per-device lock loop ever re-enters
2627        // a held guard, this hangs and the timeout fires.
2628        let dance = async {
2629            session_guard = None;
2630            client
2631                .migrate_signal_sessions_on_lid_discovery(pn, lid)
2632                .await;
2633            session_guard = Some(session_mutex.lock_arc().await);
2634        };
2635        tokio::time::timeout(Duration::from_secs(5), dance)
2636            .await
2637            .expect("drop → migrate → reacquire must not deadlock");
2638
2639        assert!(
2640            session_guard.is_some(),
2641            "guard must be re-held after the dance so the next batch payload \
2642             stays serialized on the address lock"
2643        );
2644    }
2645
2646    /// `try_pn_to_lid_migration_decrypt` skips its retry decrypt when the
2647    /// migration reports nothing moved: with decrypt state unchanged, the
2648    /// retry would fail identically and log a second decrypt error for
2649    /// every redelivered copy of an undecryptable message.
2650    #[tokio::test]
2651    async fn migration_reports_whether_anything_moved() {
2652        use wacore::libsignal::protocol::SessionRecord;
2653        use wacore::types::jid::JidExt as _;
2654
2655        let client: Arc<Client> = create_test_client().await;
2656        let pn = "5500000001111";
2657        let lid = "122222222222222";
2658
2659        client
2660            .add_lid_pn_mapping(lid, pn, LearningSource::PeerPnMessage)
2661            .await
2662            .unwrap();
2663
2664        assert!(
2665            !client
2666                .migrate_signal_sessions_on_lid_discovery(pn, lid)
2667                .await,
2668            "no PN signal state, so nothing can move"
2669        );
2670
2671        let pn_addr = Jid::pn_device(pn.to_string(), 0).to_protocol_address();
2672        client
2673            .signal_cache
2674            .put_session(
2675                &pn_addr,
2676                SessionRecord::deserialize(&tagged_session_blob(7)).expect("blob deserializes"),
2677            )
2678            .await;
2679        let backend = client.persistence_manager.backend();
2680        client.signal_cache.flush(backend.as_ref()).await.unwrap();
2681
2682        assert!(
2683            client
2684                .migrate_signal_sessions_on_lid_discovery(pn, lid)
2685                .await,
2686            "a PN session moved into the LID slot"
2687        );
2688        assert!(
2689            !client
2690                .migrate_signal_sessions_on_lid_discovery(pn, lid)
2691                .await,
2692            "second call finds the PN side already drained"
2693        );
2694    }
2695
2696    /// Identity cleanup still needs a durable flush, but cannot make a failed
2697    /// session decrypt succeed and must not request a retry.
2698    #[tokio::test]
2699    async fn identity_only_migration_flushes_without_requesting_decrypt_retry() {
2700        use wacore::types::jid::JidExt as _;
2701
2702        let client: Arc<Client> = create_test_client().await;
2703        let pn = "5500000002222";
2704        let lid = "133333333333333";
2705        let pn_addr = Jid::pn_device(pn.to_string(), 0).to_protocol_address();
2706        let lid_addr = Jid::lid_device(lid.to_string(), 0).to_protocol_address();
2707        let backend = client.persistence_manager.backend();
2708
2709        client.signal_cache.put_identity(&pn_addr, &[7; 32]).await;
2710        client.signal_cache.put_identity(&lid_addr, &[8; 32]).await;
2711        client.signal_cache.flush(backend.as_ref()).await.unwrap();
2712
2713        assert!(
2714            !client
2715                .migrate_signal_sessions_on_lid_discovery(pn, lid)
2716                .await,
2717            "discarding only the stale PN identity cannot help a decrypt retry"
2718        );
2719        assert_eq!(backend.load_identity(pn_addr.as_str()).await.unwrap(), None);
2720        assert_eq!(
2721            backend.load_identity(lid_addr.as_str()).await.unwrap(),
2722            Some([8; 32]),
2723            "the destination identity must win and the cleanup must be durable"
2724        );
2725    }
2726
2727    #[tokio::test]
2728    async fn lid_discovery_retries_pending_migration_flush() {
2729        use wacore::libsignal::protocol::SessionRecord;
2730        use wacore::types::jid::JidExt as _;
2731
2732        let backend = Arc::new(InMemoryBackend::new());
2733        let client = create_test_client_with_backend(backend.clone()).await;
2734        let pn = "5500000003333";
2735        let lid = "144444444444444";
2736        let pn_addr = Jid::pn_device(pn, 0).to_protocol_address();
2737        let lid_addr = Jid::lid_device(lid, 0).to_protocol_address();
2738        client
2739            .signal_cache
2740            .put_session(
2741                &pn_addr,
2742                SessionRecord::deserialize(&tagged_session_blob(9)).unwrap(),
2743            )
2744            .await;
2745        client.signal_cache.flush(backend.as_ref()).await.unwrap();
2746
2747        backend.set_fail_session_writes(true);
2748        assert!(
2749            client
2750                .migrate_signal_sessions_on_lid_discovery(pn, lid)
2751                .await,
2752            "the first pass moved a session in memory"
2753        );
2754        backend.set_fail_session_writes(false);
2755        let attempts_before_retry = backend.session_batch_write_count();
2756
2757        assert!(
2758            !client
2759                .migrate_signal_sessions_on_lid_discovery(pn, lid)
2760                .await,
2761            "a durability retry must not request another decrypt attempt"
2762        );
2763        assert!(backend.session_batch_write_count() > attempts_before_retry);
2764        assert!(
2765            backend
2766                .get_session(pn_addr.as_str())
2767                .await
2768                .unwrap()
2769                .is_none()
2770        );
2771        assert!(
2772            backend
2773                .get_session(lid_addr.as_str())
2774                .await
2775                .unwrap()
2776                .is_some()
2777        );
2778    }
2779}