rovenue/funnel/
session.rs

1use std::sync::{Arc, Mutex};
2
3use funnel_core::actions::FunnelEffect as CoreEffect;
4use funnel_core::answer::AnswerValue;
5use funnel_core::machine::{FunnelError, FunnelMachine, Step};
6use funnel_core::schema::{
7    Funnel, OfferKind, Offering, OfferingPackage, PackageOffer, PackagePrice, PeriodUnit,
8    PriceFacts,
9};
10use funnel_core::session::SessionState;
11use serde::{Deserialize, Serialize};
12
13use crate::error::{RovenueError, RovenueResult};
14use crate::events::{EventEnvelope, EventQueue, FunnelContext, EVENT_WIRE_VERSION};
15use crate::offerings::CoreOfferingProduct;
16use crate::placements::types::FunnelWire;
17use crate::placements::CoreFunnel;
18use crate::time::{rfc3339_millis, Clock};
19
20/// What the host must do, as the façades see it.
21///
22/// `SetAttribute` is deliberately absent. librovenue intercepts it below
23/// and routes it into the existing attributes module, so it never crosses
24/// this boundary and is never implemented three times.
25#[derive(Debug, Clone, PartialEq)]
26pub enum FunnelEffect {
27    /// Close the funnel's view. Produced by `close_funnel`.
28    Dismiss,
29    /// Open a URL. Unlike the web funnel this does not end the funnel: an
30    /// app deep-link scheme is legitimate here, and handing off to another
31    /// app leaves the session where it was.
32    OpenUrl {
33        /// The URL to open, exactly as authored.
34        url: String,
35    },
36    // No RequestPermission variant: a permission screen announces itself
37    // through the resolved screen's `permission` field, which is
38    // declarative and survives a redraw. See funnel_core::actions::FunnelEffect.
39    /// Show an alert. Already localized and interpolated.
40    ShowAlert {
41        /// Optional title.
42        title: Option<String>,
43        /// The body, never empty.
44        message: String,
45        /// Optional dismiss-button label.
46        button_label: Option<String>,
47    },
48    /// An app-defined action the funnel author named.
49    Custom {
50        /// The author's identifier for this action.
51        action_id: String,
52        /// Parameters as a JSON object string (string-not-record, as above).
53        params_json: String,
54    },
55    /// Buy the selected package through StoreKit / Play Billing.
56    Purchase {
57        /// The offering package the visitor selected.
58        package_id: String,
59    },
60    /// Restore previous purchases.
61    RestorePurchases,
62}
63
64/// One step of a funnel, as it crosses the FFI.
65///
66/// `screen_json` is a raw JSON string rather than a record: the uniffi
67/// record→HashMap mapping cannot carry arbitrary nested JSON, and it also
68/// means a new block type never forces a UDL edit.
69#[derive(Debug, Clone)]
70pub struct FunnelStep {
71    /// The resolved screen to draw, or `None` once the funnel is over.
72    pub screen_json: Option<String>,
73    /// What the host must do before or alongside drawing.
74    pub effects: Vec<FunnelEffect>,
75    /// True once the funnel has ended; `screen_json` is then `None`.
76    pub finished: bool,
77}
78
79/// A store-fetched price for one package (D13). The façade hands these in
80/// through `FunnelSession::set_package_prices` once its async StoreKit /
81/// Play Billing lookup resolves — `open` cannot wait on it, so a package
82/// row draws unpriced (but titled — see `map_offering`) until this lands.
83///
84/// The FFI twin of funnel-core's `PackagePrice`: forwarded to the machine
85/// (`FunnelMachine::set_package_prices`), so the resolver sees the price
86/// when it builds copy (`{{product.*}}`) and computes savings. Matched by
87/// `identifier` (the package SLOT identifier, the key
88/// `OfferingPackage::identifier` carries); an identifier the offering lacks
89/// is ignored. A `None` `localized_period` / `localized_title` keeps what
90/// the served offering carried; the numeric facts replace the package's
91/// wholesale.
92#[derive(Debug, Clone, PartialEq)]
93pub struct FunnelPackagePrice {
94    pub identifier: String,
95    pub localized_price: String,
96    pub localized_period: Option<String>,
97    pub localized_title: Option<String>,
98    /// The price in micros of `currency`.
99    pub amount_micros: Option<i64>,
100    /// ISO-4217, upper-case.
101    pub currency: Option<String>,
102    /// `None` for a one-time product.
103    pub period_unit: Option<FunnelPeriodUnit>,
104    pub period_count: Option<u32>,
105    pub localized_price_per_month: Option<String>,
106    pub localized_price_per_week: Option<String>,
107    /// Present ONLY when the visitor is eligible for it.
108    pub offer: Option<FunnelPackageOffer>,
109}
110
111/// funnel-core's `PeriodUnit`, as the FFI spells it.
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum FunnelPeriodUnit {
114    Day,
115    Week,
116    Month,
117    Year,
118}
119
120/// funnel-core's `OfferKind`, as the FFI spells it.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum FunnelOfferKind {
123    FreeTrial,
124    PayAsYouGo,
125    PayUpFront,
126}
127
128/// funnel-core's `PackageOffer`: an introductory offer the visitor is
129/// eligible for. `period_unit` × `period_count` is its whole duration.
130#[derive(Debug, Clone, PartialEq)]
131pub struct FunnelPackageOffer {
132    pub kind: FunnelOfferKind,
133    pub period_unit: FunnelPeriodUnit,
134    pub period_count: u32,
135    pub localized_period: String,
136    pub localized_price: Option<String>,
137}
138
139impl From<FunnelPeriodUnit> for PeriodUnit {
140    fn from(unit: FunnelPeriodUnit) -> Self {
141        match unit {
142            FunnelPeriodUnit::Day => PeriodUnit::Day,
143            FunnelPeriodUnit::Week => PeriodUnit::Week,
144            FunnelPeriodUnit::Month => PeriodUnit::Month,
145            FunnelPeriodUnit::Year => PeriodUnit::Year,
146        }
147    }
148}
149
150impl From<FunnelOfferKind> for OfferKind {
151    fn from(kind: FunnelOfferKind) -> Self {
152        match kind {
153            FunnelOfferKind::FreeTrial => OfferKind::FreeTrial,
154            FunnelOfferKind::PayAsYouGo => OfferKind::PayAsYouGo,
155            FunnelOfferKind::PayUpFront => OfferKind::PayUpFront,
156        }
157    }
158}
159
160impl From<FunnelPackageOffer> for PackageOffer {
161    fn from(offer: FunnelPackageOffer) -> Self {
162        PackageOffer {
163            kind: offer.kind.into(),
164            period_unit: offer.period_unit.into(),
165            period_count: offer.period_count,
166            localized_period: offer.localized_period,
167            localized_price: offer.localized_price,
168        }
169    }
170}
171
172impl From<FunnelPackagePrice> for PackagePrice {
173    fn from(price: FunnelPackagePrice) -> Self {
174        PackagePrice {
175            identifier: price.identifier,
176            localized_price: price.localized_price,
177            localized_period: price.localized_period,
178            localized_title: price.localized_title,
179            facts: PriceFacts {
180                amount_micros: price.amount_micros,
181                currency: price.currency,
182                period_unit: price.period_unit.map(Into::into),
183                period_count: price.period_count,
184                localized_price_per_month: price.localized_price_per_month,
185                localized_price_per_week: price.localized_price_per_week,
186                offer: price.offer.map(Into::into),
187            },
188        }
189    }
190}
191
192/// Attributes a funnel asked to write, collected as they are produced.
193///
194/// Kept on the session rather than applied inline because `funnel-core` is
195/// pure and this wrapper has no network of its own — the owning
196/// `RovenueCore` drains them through its existing attributes module.
197#[derive(Debug, Clone, Default)]
198pub struct PendingAttributes(
199    /// Name/value pairs in the order the funnel produced them.
200    pub Vec<(String, serde_json::Value)>,
201);
202
203/// What the session has already emitted, so each device event fires once:
204/// a screen view per screen ARRIVAL (not per interaction on it), one
205/// completion, one dismissal.
206#[derive(Debug, Default)]
207struct Emission {
208    last_screen_id: Option<String>,
209    finished_emitted: bool,
210    dismissed_emitted: bool,
211}
212
213/// Prefix of a preview session's presentation id. A preview never emits,
214/// so the id is never stamped on an event; the distinct prefix only makes
215/// a preview's `state_json` recognisable to whoever reads it.
216const PREVIEW_PRESENTATION_PREFIX: &str = "preview_";
217
218/// The persisted session (spec D7): the machine state plus the
219/// presentation id, so a restored session keeps its id and counts once.
220/// Façades treat it opaquely — store the string, hand it back to
221/// `open_funnel`. A pre-wave state (a bare machine state, no wrapper)
222/// still restores; it just gets a fresh id.
223#[derive(Debug, Deserialize)]
224struct StoredSession {
225    presentation_id: String,
226    state: SessionState,
227}
228
229/// Everything a session needs to emit device events, and the only code
230/// that can.
231///
232/// It exists only inside `FunnelSession::open`, which is the one
233/// constructor handed an `EventQueue`. A preview session
234/// (`FunnelSession::open_preview`) is built with `recorder: None` and is
235/// never given a queue, so no code path can reach `emit` from it: zero
236/// analytics pollution comes from the type, not from a flag someone could
237/// forget to check.
238///
239/// No `Debug`: the queue and clock behind it don't implement it.
240struct EventRecorder {
241    queue: Arc<EventQueue>,
242    rovenue_id: String,
243    context: FunnelContext,
244    clock: Arc<dyn Clock>,
245    emission: Mutex<Emission>,
246}
247
248impl EventRecorder {
249    /// Enqueue one device event. Best-effort by design: a telemetry write
250    /// must never fail the interaction that produced it. The queue
251    /// persists and drains (the server dedups a replayed drain on the
252    /// content-hash event id), so a transient failure is retried, not
253    /// lost; only a storage failure drops it, and failing the funnel UI
254    /// for that would not save it either — but the drop is logged at warn,
255    /// never swallowed.
256    fn emit(&self, event_type: &str, screen_id: Option<String>) {
257        let mut context = self.context.clone();
258        context.screen_id = screen_id;
259        let envelope = EventEnvelope {
260            version: Some(EVENT_WIRE_VERSION),
261            event_id: Some(format!("evt_{}", cuid2::create_id())),
262            event_type: event_type.to_string(),
263            occurred_at: rfc3339_millis(self.clock.now_unix_ms()),
264            subscriber_id: if self.rovenue_id.is_empty() {
265                None
266            } else {
267                Some(self.rovenue_id.clone())
268            },
269            product_id: None,
270            amount: None,
271            currency: None,
272            event_source_url: None,
273            identity_context: None,
274            funnel_context: Some(context),
275        };
276        let queued = serde_json::to_string(&envelope)
277            .map_err(|_| RovenueError::InvalidArgument())
278            .and_then(|json| self.queue.enqueue(&json));
279        if let Err(err) = queued {
280            self.queue.log_dropped(event_type, &err);
281        }
282    }
283
284    /// Record arriving on `page_id` (a `screen_view` per arrival, not per
285    /// interaction) and finishing (one `complete`).
286    fn after_step(&self, page_id: Option<String>, finished: bool) {
287        let mut emission = self.emission.lock().unwrap_or_else(|e| e.into_inner());
288        if emission.last_screen_id != page_id {
289            emission.last_screen_id = page_id.clone();
290            self.emit("funnel_screen_view", page_id);
291        }
292        if finished && !emission.finished_emitted {
293            emission.finished_emitted = true;
294            self.emit("funnel_complete", None);
295        }
296    }
297
298    /// A restore emits nothing — but the tracking must start where the
299    /// restored machine stands, or the first post-restore step would
300    /// re-emit the arrival it resumes on, and an already finished machine
301    /// would emit a completion for a funnel that ended before this wave.
302    fn resume_at(&self, page_id: Option<String>, finished: bool) {
303        let mut emission = self.emission.lock().unwrap_or_else(|e| e.into_inner());
304        emission.last_screen_id = page_id;
305        emission.finished_emitted = finished;
306    }
307
308    /// `funnel_dismiss`, once only: a close handler firing twice must not
309    /// double-count the dismissal — and never after the funnel completed.
310    fn dismiss(&self) {
311        let mut emission = self.emission.lock().unwrap_or_else(|e| e.into_inner());
312        // `dismissed` and `completed` are distinct terminal states (analytics
313        // counts a dismissal as a drop-off): closing the sheet of a funnel
314        // that already completed is not a second outcome.
315        if emission.dismissed_emitted || emission.finished_emitted {
316            return;
317        }
318        emission.dismissed_emitted = true;
319        self.emit("funnel_dismiss", None);
320    }
321}
322
323/// A running funnel, owned by the façade for the life of the presentation.
324///
325/// No `Debug`: the queue and clock behind it don't implement it, and a
326/// debug dump of a live session would be noise anyway.
327///
328/// Opened through `RovenueCore::open_funnel` (emitting) or the
329/// `open_preview_funnel` namespace function (never emitting), never
330/// constructed directly: a live session holds the durable event queue and
331/// stamps every event with the presentation id, so emission lives here
332/// once (W10) rather than in each façade.
333pub struct FunnelSession {
334    inner: Mutex<FunnelMachine>,
335    pending_attributes: Mutex<PendingAttributes>,
336    presentation_id: String,
337    /// `Some` only for a session `open` built; `None` for a preview, which
338    /// therefore has no way to emit. See `EventRecorder`.
339    recorder: Option<EventRecorder>,
340}
341
342fn map_error(err: FunnelError) -> RovenueError {
343    match err {
344        FunnelError::UnsupportedConfigVersion { .. } => RovenueError::FunnelConfigUnsupported(),
345        FunnelError::EmptyFunnel => RovenueError::FunnelInvalid(),
346    }
347}
348
349/// The platform's store product id for a package. The machine never reads
350/// it (only `localized_price` renders, and prices are the platform's job —
351/// core-rs performs no StoreKit / Play Billing I/O), but the field must
352/// name the store the build actually buys from.
353#[cfg(any(target_os = "ios", target_os = "macos"))]
354fn store_product_id(package: &CoreOfferingProduct) -> String {
355    package
356        .apple_product_id
357        .clone()
358        .or(package.google_product_id.clone())
359        .unwrap_or_default()
360}
361
362/// The platform's store product id for a package — see the iOS/macOS
363/// twin for why this exists at all.
364#[cfg(not(any(target_os = "ios", target_os = "macos")))]
365fn store_product_id(package: &CoreOfferingProduct) -> String {
366    package
367        .google_product_id
368        .clone()
369        .or(package.apple_product_id.clone())
370        .unwrap_or_default()
371}
372
373/// The served offering as machine data. No prices cross this boundary
374/// (formatting them is the platform's job and core-rs does no store I/O),
375/// so package lists draw unpriced until a façade enriches them (D13) —
376/// purchasing is unaffected, the host buys by `package_id`. The title DOES
377/// cross: it is server content, not a store price, so it draws from the
378/// first render — never empty (D7).
379fn map_offering(offering: &crate::offerings::CoreOffering) -> Offering {
380    Offering {
381        identifier: offering.identifier.clone(),
382        packages: offering
383            .packages
384            .iter()
385            .map(|p| OfferingPackage {
386                identifier: p.package_identifier.clone(),
387                product_identifier: store_product_id(p),
388                localized_price: String::new(),
389                localized_title: Some(if p.display_name.is_empty() {
390                    p.package_identifier.clone()
391                } else {
392                    p.display_name.clone()
393                }),
394                localized_period: None,
395                // The store facts arrive through `set_package_prices`.
396                facts: PriceFacts::default(),
397            })
398            .collect(),
399    }
400}
401
402impl FunnelSession {
403    /// Open a funnel presentation on `funnel`'s published document.
404    ///
405    /// The offering comes from `funnel.offering` (see `map_offering`); an
406    /// empty `funnel_id` — a cached or bundled response predating the
407    /// field — is refused loudly rather than emitted unattributable.
408    ///
409    /// A fresh open emits `funnel_view` plus `funnel_screen_view` for the
410    /// first screen. A restored open emits nothing and keeps the stored
411    /// presentation id.
412    pub fn open(
413        funnel: CoreFunnel,
414        locale: String,
415        today: Option<String>,
416        restored_state_json: Option<String>,
417        queue: Arc<EventQueue>,
418        rovenue_id: String,
419        clock: Arc<dyn Clock>,
420    ) -> RovenueResult<Self> {
421        if funnel.funnel_id.trim().is_empty() {
422            return Err(RovenueError::InvalidArgument());
423        }
424        let document: Funnel =
425            serde_json::from_str(&funnel.config_json).map_err(|_| RovenueError::FunnelInvalid())?;
426
427        // A malformed offering or a stale stored session must not stop the
428        // funnel from opening: both degrade to "absent" rather than
429        // throwing.
430        let offering: Option<Offering> = funnel.offering.as_ref().map(map_offering);
431        let (restored, presentation_id, was_restored) = split_restored(&restored_state_json);
432
433        let machine = FunnelMachine::new(
434            document,
435            locale,
436            today,
437            offering,
438            funnel.links.into(),
439            restored,
440        )
441        .map_err(map_error)?;
442
443        let (variant_id, experiment_key) = funnel
444            .presented_context
445            .map(|c| (c.variant_id, c.experiment_key))
446            .unwrap_or((None, None));
447        let recorder = EventRecorder {
448            queue,
449            rovenue_id,
450            context: FunnelContext {
451                funnel_id: funnel.funnel_id,
452                session_id: presentation_id.clone(),
453                placement_id: funnel.placement_identifier,
454                placement_revision: funnel.placement_revision,
455                revision: funnel.revision,
456                screen_id: None,
457                variant_id,
458                experiment_key,
459            },
460            clock,
461            emission: Mutex::new(Emission::default()),
462        };
463        let (page, finished) = (
464            machine.state().current_page_id.clone(),
465            machine.current().finished,
466        );
467        if was_restored {
468            recorder.resume_at(page, finished);
469        } else {
470            recorder.emit("funnel_view", None);
471            recorder.after_step(page, finished);
472        }
473        Ok(Self::from_machine(machine, presentation_id, Some(recorder)))
474    }
475
476    /// Open a NON-EMITTING preview of a funnel, for preview tools (the
477    /// operator apps' "Test on device").
478    ///
479    /// `payload_json` is exactly the object `GET /v1/placements` serves
480    /// under its `funnel` key (`identifier`, `id`, `revision`,
481    /// `configFormatVersion`, `config`, `offering`), decoded by the same
482    /// wire type the placements client uses, so a preview consumes the
483    /// production input. The dashboard's preview-document route serves the
484    /// same shape for a draft.
485    ///
486    /// The session resolves, navigates, evaluates conditions and variables
487    /// and produces effects exactly like one from `open` — it is the same
488    /// machine and the same conversion — but it is built without an
489    /// `EventQueue`, so it holds no `EventRecorder` and cannot emit: no
490    /// `funnel_*` event, no experiment exposure (no variant is drawn; the
491    /// payload is one funnel). It needs no configured core and no API key.
492    /// A preview always starts fresh: there is nothing to restore.
493    ///
494    /// An empty `id` is accepted here (nothing is attributed). A payload
495    /// that does not decode, or whose `config` is not a funnel document,
496    /// is `FunnelInvalid`; a config format newer than this build is
497    /// `FunnelConfigUnsupported`. A payload with no offering opens fine —
498    /// its package lists simply draw empty, as in production.
499    pub fn open_preview(
500        payload_json: String,
501        locale: String,
502        today: Option<String>,
503    ) -> RovenueResult<Self> {
504        let wire: FunnelWire =
505            serde_json::from_str(&payload_json).map_err(|_| RovenueError::FunnelInvalid())?;
506        let document: Funnel =
507            serde_json::from_value(wire.config).map_err(|_| RovenueError::FunnelInvalid())?;
508        let offering = wire
509            .offering
510            .map(crate::offerings::client::map_offering)
511            .as_ref()
512            .map(map_offering);
513        let machine = FunnelMachine::new(document, locale, today, offering, wire.links, None)
514            .map_err(map_error)?;
515        Ok(Self::from_machine(
516            machine,
517            format!("{PREVIEW_PRESENTATION_PREFIX}{}", cuid2::create_id()),
518            None,
519        ))
520    }
521
522    fn from_machine(
523        machine: FunnelMachine,
524        presentation_id: String,
525        recorder: Option<EventRecorder>,
526    ) -> Self {
527        Self {
528            inner: Mutex::new(machine),
529            pending_attributes: Mutex::new(PendingAttributes::default()),
530            presentation_id,
531            recorder,
532        }
533    }
534
535    /// The presentation id this session stamps. Test-only: lets emission
536    /// tests prove one id across events without parsing `state_json`.
537    #[doc(hidden)]
538    pub fn test_presentation_id(&self) -> String {
539        self.presentation_id.clone()
540    }
541
542    /// Record arrival/finish on the recorder, if this session has one.
543    fn after_step(&self, page_id: Option<String>, finished: bool) {
544        if let Some(recorder) = &self.recorder {
545            recorder.after_step(page_id, finished);
546        }
547    }
548
549    fn convert(&self, step: Step) -> FunnelStep {
550        let mut effects = Vec::new();
551        for effect in step.effects {
552            match effect {
553                // Absorbed here: recorded for the owning core to flush
554                // through its existing attributes module.
555                CoreEffect::SetAttribute { name, value } => {
556                    self.pending_attributes
557                        .lock()
558                        .unwrap_or_else(|e| e.into_inner())
559                        .0
560                        .push((name, value));
561                }
562                CoreEffect::Dismiss => effects.push(FunnelEffect::Dismiss),
563                CoreEffect::OpenUrl { url } => effects.push(FunnelEffect::OpenUrl { url }),
564                CoreEffect::ShowAlert {
565                    title,
566                    message,
567                    button_label,
568                } => effects.push(FunnelEffect::ShowAlert {
569                    title,
570                    message,
571                    button_label,
572                }),
573                CoreEffect::Custom { action_id, params } => effects.push(FunnelEffect::Custom {
574                    action_id,
575                    params_json: serde_json::to_string(&params).unwrap_or_else(|_| "{}".into()),
576                }),
577                CoreEffect::Purchase { package_id } => {
578                    effects.push(FunnelEffect::Purchase { package_id })
579                }
580                CoreEffect::RestorePurchases => effects.push(FunnelEffect::RestorePurchases),
581            }
582        }
583        FunnelStep {
584            screen_json: step
585                .screen
586                .as_ref()
587                .and_then(|s| serde_json::to_string(s).ok()),
588            effects,
589            finished: step.finished,
590        }
591    }
592
593    fn with<R>(&self, f: impl FnOnce(&mut FunnelMachine) -> R) -> R {
594        let mut guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
595        f(&mut guard)
596    }
597
598    /// One interaction: run the machine, convert, then emit for whatever
599    /// screen that landed on (and for finishing).
600    fn interact(&self, f: impl FnOnce(&mut FunnelMachine) -> Step) -> FunnelStep {
601        let (step, page, finished) = self.with(|m| {
602            let step = f(m);
603            let finished = step.finished;
604            let page = m.state().current_page_id.clone();
605            (step, page, finished)
606        });
607        let out = self.convert(step);
608        self.after_step(page, finished);
609        out
610    }
611
612    /// The screen to draw right now, with no effects.
613    pub fn current(&self) -> FunnelStep {
614        let step = self.with(|m| m.current());
615        self.convert(step)
616    }
617
618    /// Record an answer and re-resolve, so a `visibleWhen` keyed on it
619    /// takes effect on the same screen.
620    pub fn submit(&self, block_id: String, answer_json: String) -> FunnelStep {
621        // An answer that will not parse is stored as null rather than
622        // dropped: "asked and not answered" is a state the evaluator knows
623        // how to route, and a silent no-op is not.
624        let answer: AnswerValue = serde_json::from_str(&answer_json).unwrap_or(AnswerValue::Null);
625        self.interact(|m| m.submit(&block_id, answer))
626    }
627
628    /// Record which offering package a package list tap selected.
629    pub fn select_package(&self, identifier: String) -> FunnelStep {
630        self.interact(|m| m.select_package(&identifier))
631    }
632
633    /// Hand in store-fetched prices for the current offering's packages
634    /// (D13). Façades call this once their async StoreKit / Play Billing
635    /// lookup resolves — `open` cannot wait on it, so it draws first
636    /// without a price. Forwarded to the machine, which keeps them on its
637    /// offering (so every later draw carries them) and re-resolves: copy
638    /// reading `{{product.*}}` and the computed savings see the prices.
639    /// `identifier` matches the package SLOT identifier (what
640    /// `select_package` also takes); one the offering lacks is ignored.
641    /// Does not advance the session or emit anything.
642    pub fn set_package_prices(&self, prices: Vec<FunnelPackagePrice>) -> FunnelStep {
643        let step =
644            self.with(|m| m.set_package_prices(prices.into_iter().map(Into::into).collect()));
645        self.convert(step)
646    }
647
648    /// Run the action chain at `action_path` (`cta`, `el.<blockId>` or
649    /// `opt.<urlEncodedValue>`). An address nothing answers to is a no-op.
650    pub fn tap(&self, action_path: String) -> FunnelStep {
651        self.interact(|m| m.tap(&action_path))
652    }
653
654    /// Return to the previous screen. A no-op when there is no history.
655    pub fn back(&self) -> FunnelStep {
656        self.interact(|m| m.back())
657    }
658
659    /// The persisted session: machine state plus the presentation id. The
660    /// façade stores this string and hands it back to `open_funnel` on the
661    /// next launch (spec D7).
662    pub fn state_json(&self) -> String {
663        self.with(|m| {
664            let stored = StoredSessionRef {
665                presentation_id: &self.presentation_id,
666                state: m.state(),
667            };
668            serde_json::to_string(&stored).unwrap_or_else(|_| "{}".into())
669        })
670    }
671
672    /// Emit `funnel_dismiss`. The façade calls it when the host closes the
673    /// view or on a `Dismiss` effect. Once only: a close handler firing
674    /// twice must not double-count the dismissal — and never after the
675    /// funnel completed.
676    ///
677    /// A preview session has no recorder, so this is a no-op there.
678    pub fn dismiss(&self) {
679        if let Some(recorder) = &self.recorder {
680            recorder.dismiss();
681        }
682    }
683
684    /// Attributes the funnel asked to write, drained for the owning core to
685    /// flush. Not part of the FFI surface.
686    pub fn take_pending_attributes(&self) -> Vec<(String, serde_json::Value)> {
687        std::mem::take(
688            &mut self
689                .pending_attributes
690                .lock()
691                .unwrap_or_else(|e| e.into_inner())
692                .0,
693        )
694    }
695}
696
697/// `state_json` shapes, split out so `open` stays readable.
698#[derive(Debug, Serialize)]
699struct StoredSessionRef<'a> {
700    presentation_id: &'a str,
701    state: &'a SessionState,
702}
703
704/// See `FunnelSession::open`: wrapper preferred, legacy bare state
705/// accepted, garbage degrades to fresh.
706fn split_restored(restored_state_json: &Option<String>) -> (Option<SessionState>, String, bool) {
707    let fresh_id = || format!("sess_{}", cuid2::create_id());
708    match restored_state_json {
709        Some(raw) => {
710            if let Ok(stored) = serde_json::from_str::<StoredSession>(raw) {
711                (Some(stored.state), stored.presentation_id, true)
712            } else if let Ok(legacy) = serde_json::from_str::<SessionState>(raw) {
713                (Some(legacy), fresh_id(), true)
714            } else {
715                (None, fresh_id(), false)
716            }
717        }
718        None => (None, fresh_id(), false),
719    }
720}
721
722#[cfg(test)]
723mod tests {
724    use super::*;
725    use crate::cache::CacheStore;
726    use crate::events::EventsClient;
727    use crate::placements::CorePresentedContext;
728    use crate::time::SystemClock;
729    use crate::transport::http_client::HttpClient;
730
731    const MINIMAL: &str = r#"{"configFormatVersion":1,"id":"f","identifier":"t","revision":1,
732        "defaultLocale":"en","target":"native","pagesOrder":["a"],
733        "pages":[{"id":"a","type":"custom","question_id":"q",
734                  "blocks":[{"id":"b1","type":"heading","text":{"en":"Hi"}}]}]}"#;
735
736    /// Two screens: `s1` advances on a bare `cta` tap, `s2` closes.
737    const TWO_PAGE: &str = r#"{"configFormatVersion":1,"id":"f","identifier":"t","revision":1,
738        "defaultLocale":"en","target":"native","pagesOrder":["s1","s2"],
739        "pages":[{"id":"s1","type":"custom","question_id":"q",
740                  "blocks":[{"id":"b1","type":"heading","text":{"en":"Hi"}}]},
741                 {"id":"s2","type":"custom",
742                  "actions":{"next":[{"type":"close_funnel"}]}}]}"#;
743
744    fn test_env() -> (Arc<CacheStore>, Arc<EventQueue>) {
745        let store = Arc::new(CacheStore::open_in_memory().unwrap());
746        // Nothing listens here: emission persists + retains, never posts.
747        // That is exactly what these tests assert — the queued envelopes.
748        let http = Arc::new(
749            HttpClient::new("http://127.0.0.1:1".to_string(), "pk_test".to_string())
750                .with_max_attempts(1),
751        );
752        let events = Arc::new(EventsClient::new(http));
753        let queue = Arc::new(EventQueue::new(Arc::clone(&store), events));
754        (store, queue)
755    }
756
757    fn core_funnel(config_json: &str) -> CoreFunnel {
758        CoreFunnel {
759            placement_identifier: "plc_test".to_string(),
760            placement_revision: 3,
761            funnel_identifier: "t".to_string(),
762            funnel_id: "fnl_test".to_string(),
763            revision: 1,
764            config_format_version: 1,
765            config_json: config_json.to_string(),
766            offering: None,
767            presented_context: Some(CorePresentedContext {
768                placement_id: "plc_test".to_string(),
769                funnel_id: "t".to_string(),
770                variant_id: Some("var_a".to_string()),
771                experiment_key: Some("exp_1".to_string()),
772                revision: 3,
773            }),
774            served_from_fallback: false,
775            links: crate::placements::FunnelProjectLinks::default(),
776        }
777    }
778
779    fn open_session(
780        config_json: &str,
781        restored: Option<String>,
782    ) -> (FunnelSession, Arc<CacheStore>) {
783        let (store, queue) = test_env();
784        let clock: Arc<dyn Clock> = Arc::new(SystemClock);
785        let session = FunnelSession::open(
786            core_funnel(config_json),
787            "en".to_string(),
788            None,
789            restored,
790            queue,
791            "rov_test".to_string(),
792            clock,
793        )
794        .expect("session opens");
795        (session, store)
796    }
797
798    /// The queued device envelopes, oldest first, parsed (never hand-built).
799    fn queued(store: &CacheStore) -> Vec<EventEnvelope> {
800        store
801            .list_queued_events(usize::MAX)
802            .unwrap()
803            .into_iter()
804            .map(|row| serde_json::from_str::<EventEnvelope>(&row.envelope_json).unwrap())
805            .collect()
806    }
807
808    struct Collector(Arc<Mutex<Vec<crate::logging::LogRecord>>>);
809    impl crate::logging::LogSink for Collector {
810        fn on_log(&self, record: crate::logging::LogRecord) {
811            self.0.lock().unwrap().push(record);
812        }
813    }
814
815    /// Emission is best-effort, but a storage failure must be VISIBLE: a
816    /// silent `let _ =` hid a missing `event_queue` table (MIGRATION_V11
817    /// edited in place) that dropped every funnel event. How it could fail:
818    /// nothing logged; logged below warn (the default threshold hides it);
819    /// the record does not say which event was lost; the failure breaks
820    /// opening the funnel instead of being contained.
821    #[test]
822    fn a_failed_enqueue_is_logged_at_warn_and_does_not_fail_the_funnel() {
823        let store = Arc::new(CacheStore::open_in_memory().unwrap());
824        store
825            .with_conn(|c| c.execute_batch("DROP TABLE event_queue"))
826            .unwrap();
827        let logger = Arc::new(crate::logging::Logger::new(crate::logging::LogLevel::Warn));
828        let records = Arc::new(Mutex::new(Vec::new()));
829        logger.set_sink(Arc::new(Collector(Arc::clone(&records))));
830        let http = Arc::new(
831            HttpClient::new("http://127.0.0.1:1".to_string(), "pk_test".to_string())
832                .with_max_attempts(1),
833        );
834        let queue = Arc::new(
835            EventQueue::new(Arc::clone(&store), Arc::new(EventsClient::new(http)))
836                .with_logger(logger),
837        );
838
839        // Opening arrives on the first page, which emits funnel_screen_view.
840        FunnelSession::open(
841            core_funnel(TWO_PAGE),
842            "en".to_string(),
843            None,
844            None,
845            queue,
846            "rov_test".to_string(),
847            Arc::new(SystemClock),
848        )
849        .expect("a telemetry failure never fails the funnel");
850
851        let got = records.lock().unwrap();
852        let dropped: Vec<_> = got
853            .iter()
854            .filter(|r| r.message.contains("funnel_screen_view"))
855            .collect();
856        assert_eq!(dropped.len(), 1, "one warn per lost event");
857        assert_eq!(dropped[0].level, crate::logging::LogLevel::Warn);
858        assert_eq!(
859            dropped[0].fields.get("event_type").map(String::as_str),
860            Some("funnel_screen_view")
861        );
862    }
863
864    fn types_of(store: &CacheStore) -> Vec<String> {
865        queued(store).into_iter().map(|e| e.event_type).collect()
866    }
867
868    #[test]
869    fn a_fresh_open_emits_view_then_the_first_screen() {
870        let (session, store) = open_session(TWO_PAGE, None);
871
872        assert_eq!(types_of(&store), vec!["funnel_view", "funnel_screen_view"]);
873        let rows = queued(&store);
874        let first = &rows[0];
875        assert_eq!(first.funnel_context.as_ref().unwrap().screen_id, None);
876        assert_eq!(
877            rows[1].funnel_context.as_ref().unwrap().screen_id,
878            Some("s1".to_string())
879        );
880
881        // One presentation id across both, non-empty, namespaced.
882        let id = session.test_presentation_id();
883        assert!(id.starts_with("sess_"), "unexpected id {id}");
884        for e in &rows {
885            let ctx = e.funnel_context.as_ref().unwrap();
886            assert_eq!(ctx.session_id, id);
887            assert_eq!(ctx.funnel_id, "fnl_test");
888            assert_eq!(ctx.placement_id, "plc_test");
889            assert_eq!(ctx.placement_revision, 3);
890            assert_eq!(ctx.revision, 1);
891            assert_eq!(ctx.variant_id.as_deref(), Some("var_a"));
892            assert_eq!(ctx.experiment_key.as_deref(), Some("exp_1"));
893            assert_eq!(e.subscriber_id.as_deref(), Some("rov_test"));
894            assert!(e.occurred_at.contains('T') && e.occurred_at.ends_with('Z'));
895        }
896        // Distinct envelopes: the server dedups replays, not siblings.
897        assert_ne!(rows[0].event_id, rows[1].event_id);
898    }
899
900    #[test]
901    fn tap_to_screen_two_then_finish_drains_view_screen_screen_complete() {
902        let (session, store) = open_session(TWO_PAGE, None);
903
904        let step = session.tap("cta".to_string());
905        let json: serde_json::Value =
906            serde_json::from_str(&step.screen_json.expect("screen two")).unwrap();
907        assert_eq!(json["screenId"], "s2");
908
909        let end = session.tap("cta".to_string());
910        assert!(end.finished, "s2's next chain closes the funnel");
911
912        assert_eq!(
913            types_of(&store),
914            vec![
915                "funnel_view",
916                "funnel_screen_view",
917                "funnel_screen_view",
918                "funnel_complete"
919            ]
920        );
921        let rows = queued(&store);
922        let screens: Vec<Option<String>> = rows
923            .iter()
924            .map(|e| e.funnel_context.as_ref().unwrap().screen_id.clone())
925            .collect();
926        assert_eq!(
927            screens,
928            vec![None, Some("s1".to_string()), Some("s2".to_string()), None]
929        );
930        let id = session.test_presentation_id();
931        assert!(!id.is_empty());
932        for e in &rows {
933            assert_eq!(e.funnel_context.as_ref().unwrap().session_id, id);
934        }
935    }
936
937    #[test]
938    fn an_address_nothing_answers_to_emits_nothing() {
939        let (session, store) = open_session(TWO_PAGE, None);
940        session.tap("nope".to_string());
941        assert_eq!(types_of(&store), vec!["funnel_view", "funnel_screen_view"]);
942    }
943
944    #[test]
945    fn a_restore_emits_nothing_and_keeps_the_session_id() {
946        let (session, store) = open_session(TWO_PAGE, None);
947        session.tap("cta".to_string());
948        assert_eq!(types_of(&store).len(), 3);
949        let id = session.test_presentation_id();
950        let saved = session.state_json();
951        drop(session);
952
953        let clock: Arc<dyn Clock> = Arc::new(SystemClock);
954        let (store_r, queue_r) = test_env();
955        let resumed = FunnelSession::open(
956            core_funnel(TWO_PAGE),
957            "en".to_string(),
958            None,
959            Some(saved),
960            queue_r,
961            "rov_test".to_string(),
962            clock,
963        )
964        .expect("restore opens");
965        assert_eq!(resumed.test_presentation_id(), id);
966        assert_eq!(types_of(&store_r).len(), 0, "a restore emits nothing");
967        // …and resumes where it left off: one more tap finishes.
968        let end = resumed.tap("cta".to_string());
969        assert!(end.finished);
970        assert_eq!(types_of(&store_r), vec!["funnel_complete"]);
971        assert_eq!(
972            queued(&store_r)[0]
973                .funnel_context
974                .as_ref()
975                .unwrap()
976                .session_id,
977            id
978        );
979    }
980
981    #[test]
982    fn a_legacy_bare_state_restores_with_a_fresh_id_and_emits_nothing() {
983        let (session, _) = open_session(TWO_PAGE, None);
984        session.submit("b1".to_string(), "\"answered\"".to_string());
985        let saved: serde_json::Value = serde_json::from_str(&session.state_json()).unwrap();
986        let legacy = serde_json::to_string(&saved["state"]).unwrap();
987        let old_id = session.test_presentation_id();
988        drop(session);
989
990        let (store, queue) = test_env();
991        let clock: Arc<dyn Clock> = Arc::new(SystemClock);
992        let resumed = FunnelSession::open(
993            core_funnel(TWO_PAGE),
994            "en".to_string(),
995            None,
996            Some(legacy),
997            queue,
998            "rov_test".to_string(),
999            clock,
1000        )
1001        .expect("legacy state restores");
1002        assert_ne!(resumed.test_presentation_id(), old_id);
1003        assert_eq!(types_of(&store).len(), 0);
1004        // The answers survived the legacy restore.
1005        let json: serde_json::Value =
1006            serde_json::from_str(&resumed.current().screen_json.expect("a screen")).unwrap();
1007        assert_eq!(json["screenId"], "s1");
1008    }
1009
1010    #[test]
1011    fn dismiss_emits_funnel_dismiss_once() {
1012        let (session, store) = open_session(TWO_PAGE, None);
1013        session.dismiss();
1014        session.dismiss();
1015        assert_eq!(
1016            types_of(&store),
1017            vec!["funnel_view", "funnel_screen_view", "funnel_dismiss"]
1018        );
1019        let rows = queued(&store);
1020        assert_eq!(rows[2].funnel_context.as_ref().unwrap().screen_id, None);
1021        assert_eq!(
1022            rows[2].funnel_context.as_ref().unwrap().session_id,
1023            session.test_presentation_id()
1024        );
1025    }
1026
1027    #[test]
1028    fn an_empty_funnel_id_is_refused_before_anything_emits() {
1029        let (store, queue) = test_env();
1030        let clock: Arc<dyn Clock> = Arc::new(SystemClock);
1031        let mut funnel = core_funnel(TWO_PAGE);
1032        funnel.funnel_id = String::new();
1033        let err = FunnelSession::open(
1034            funnel,
1035            "en".to_string(),
1036            None,
1037            None,
1038            queue,
1039            "rov_test".to_string(),
1040            clock,
1041        )
1042        .err()
1043        .expect("empty funnel id is refused");
1044        assert_eq!(err.kind, crate::error::ErrorKind::InvalidArgument);
1045        assert_eq!(types_of(&store).len(), 0);
1046    }
1047
1048    #[test]
1049    fn a_step_carries_the_screen_as_json() {
1050        let (session, _) = open_session(MINIMAL, None);
1051        let step = session.current();
1052        let json = step.screen_json.expect("a screen");
1053        assert!(json.contains("\"screenId\":\"a\""));
1054        assert!(json.contains("\"text\":\"Hi\""));
1055        assert!(!step.finished);
1056    }
1057
1058    #[test]
1059    fn a_config_above_the_supported_version_is_refused() {
1060        let newer = MINIMAL.replace("\"configFormatVersion\":1", "\"configFormatVersion\":99");
1061        let (store, queue) = test_env();
1062        let clock: Arc<dyn Clock> = Arc::new(SystemClock);
1063        let err = FunnelSession::open(
1064            core_funnel(&newer),
1065            "en".to_string(),
1066            None,
1067            None,
1068            queue,
1069            "rov_test".to_string(),
1070            clock,
1071        )
1072        .err()
1073        .expect("newer config is refused");
1074        assert_eq!(err.kind, crate::error::ErrorKind::FunnelConfigUnsupported);
1075        assert_eq!(types_of(&store).len(), 0);
1076    }
1077
1078    #[test]
1079    fn a_config_that_does_not_parse_is_refused_as_invalid() {
1080        let (store, queue) = test_env();
1081        let clock: Arc<dyn Clock> = Arc::new(SystemClock);
1082        let err = FunnelSession::open(
1083            core_funnel("not json"),
1084            "en".to_string(),
1085            None,
1086            None,
1087            queue,
1088            "rov_test".to_string(),
1089            clock,
1090        )
1091        .err()
1092        .expect("unparseable config is refused");
1093        assert_eq!(err.kind, crate::error::ErrorKind::FunnelInvalid);
1094        assert_eq!(types_of(&store).len(), 0);
1095    }
1096
1097    #[test]
1098    fn a_malformed_offering_does_not_stop_the_funnel_opening() {
1099        // The offering now travels inside CoreFunnel (mapped, never parsed
1100        // as JSON), so "malformed" degrades to no offering — the funnel
1101        // still opens and still emits.
1102        let (session, store) = open_session(MINIMAL, None);
1103        assert!(session.current().screen_json.is_some());
1104        assert_eq!(types_of(&store).len(), 2);
1105    }
1106
1107    #[test]
1108    fn set_attribute_is_absorbed_and_never_reaches_a_facade() {
1109        let config = r#"{"configFormatVersion":1,"id":"f","identifier":"t","revision":1,
1110            "defaultLocale":"en","target":"native","pagesOrder":["a"],
1111            "pages":[{"id":"a","type":"custom","question_id":"q",
1112                      "actions":{"cta":[{"type":"set_attribute","name":"goal",
1113                                         "value":{"kind":"literal","value":"focus"}}]}}]}"#;
1114        let (session, _) = open_session(config, None);
1115        let step = session.tap("cta".into());
1116        assert!(
1117            step.effects.is_empty(),
1118            "set_attribute must not cross the FFI"
1119        );
1120        assert_eq!(
1121            session.take_pending_attributes(),
1122            vec![("goal".to_string(), serde_json::json!("focus"))]
1123        );
1124        assert!(
1125            session.take_pending_attributes().is_empty(),
1126            "draining twice must not repeat"
1127        );
1128    }
1129
1130    #[test]
1131    fn state_json_round_trips_into_a_resumed_session() {
1132        let (session, _) = open_session(MINIMAL, None);
1133        session.submit("b1".into(), "\"answered\"".into());
1134        let saved = session.state_json();
1135
1136        let (resumed, _) = open_session(MINIMAL, Some(saved));
1137        let json = resumed.current().screen_json.expect("a screen");
1138        assert!(json.contains("\"screenId\":\"a\""));
1139    }
1140
1141    #[test]
1142    fn an_unparseable_answer_becomes_null_rather_than_vanishing() {
1143        let (session, _) = open_session(MINIMAL, None);
1144        session.submit("b1".into(), "not json".into());
1145        let states_answers = session.with(|m| m.state().answers.get("q").cloned());
1146        assert_eq!(states_answers, Some(AnswerValue::Null));
1147    }
1148}