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#[derive(Debug, Clone, PartialEq)]
26pub enum FunnelEffect {
27 Dismiss,
29 OpenUrl {
33 url: String,
35 },
36 ShowAlert {
41 title: Option<String>,
43 message: String,
45 button_label: Option<String>,
47 },
48 Custom {
50 action_id: String,
52 params_json: String,
54 },
55 Purchase {
57 package_id: String,
59 },
60 RestorePurchases,
62}
63
64#[derive(Debug, Clone)]
70pub struct FunnelStep {
71 pub screen_json: Option<String>,
73 pub effects: Vec<FunnelEffect>,
75 pub finished: bool,
77}
78
79#[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 pub amount_micros: Option<i64>,
100 pub currency: Option<String>,
102 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 pub offer: Option<FunnelPackageOffer>,
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum FunnelPeriodUnit {
114 Day,
115 Week,
116 Month,
117 Year,
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum FunnelOfferKind {
123 FreeTrial,
124 PayAsYouGo,
125 PayUpFront,
126}
127
128#[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#[derive(Debug, Clone, Default)]
198pub struct PendingAttributes(
199 pub Vec<(String, serde_json::Value)>,
201);
202
203#[derive(Debug, Default)]
207struct Emission {
208 last_screen_id: Option<String>,
209 finished_emitted: bool,
210 dismissed_emitted: bool,
211}
212
213const PREVIEW_PRESENTATION_PREFIX: &str = "preview_";
217
218#[derive(Debug, Deserialize)]
224struct StoredSession {
225 presentation_id: String,
226 state: SessionState,
227}
228
229struct 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 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 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 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 fn dismiss(&self) {
311 let mut emission = self.emission.lock().unwrap_or_else(|e| e.into_inner());
312 if emission.dismissed_emitted || emission.finished_emitted {
316 return;
317 }
318 emission.dismissed_emitted = true;
319 self.emit("funnel_dismiss", None);
320 }
321}
322
323pub struct FunnelSession {
334 inner: Mutex<FunnelMachine>,
335 pending_attributes: Mutex<PendingAttributes>,
336 presentation_id: String,
337 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#[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#[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
373fn 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 facts: PriceFacts::default(),
397 })
398 .collect(),
399 }
400}
401
402impl FunnelSession {
403 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 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 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 #[doc(hidden)]
538 pub fn test_presentation_id(&self) -> String {
539 self.presentation_id.clone()
540 }
541
542 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 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(¶ms).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 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 pub fn current(&self) -> FunnelStep {
614 let step = self.with(|m| m.current());
615 self.convert(step)
616 }
617
618 pub fn submit(&self, block_id: String, answer_json: String) -> FunnelStep {
621 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 pub fn select_package(&self, identifier: String) -> FunnelStep {
630 self.interact(|m| m.select_package(&identifier))
631 }
632
633 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 pub fn tap(&self, action_path: String) -> FunnelStep {
651 self.interact(|m| m.tap(&action_path))
652 }
653
654 pub fn back(&self) -> FunnelStep {
656 self.interact(|m| m.back())
657 }
658
659 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 pub fn dismiss(&self) {
679 if let Some(recorder) = &self.recorder {
680 recorder.dismiss();
681 }
682 }
683
684 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#[derive(Debug, Serialize)]
699struct StoredSessionRef<'a> {
700 presentation_id: &'a str,
701 state: &'a SessionState,
702}
703
704fn 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 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 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 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 #[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 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 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 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 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 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 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}