Skip to main content

posthog_rs/
event.rs

1use std::collections::HashMap;
2
3use chrono::{DateTime, Duration, NaiveDateTime, TimeZone, Utc};
4use semver::Version;
5use serde::Serialize;
6use tracing::warn;
7use uuid::Uuid;
8
9use crate::client::CRATE_VERSION;
10use crate::feature_flag_evaluations::FeatureFlagEvaluations;
11use crate::Error;
12
13/// The only properties retained on a minimized `$feature_flag_called` event.
14///
15/// A fresh allowlist (rather than a denylist) so nothing added upstream — super
16/// properties, context, user-supplied properties, SDK metadata, system context —
17/// can leak past it. Applied as the final capture-pipeline step, after all
18/// enrichment, so the resulting event carries exactly this set intersected with
19/// what the full event would have had. Beyond the evaluation properties it keeps
20/// cheap, static, low-cardinality identity useful for platform/runtime
21/// breakdowns (`$lib`, `$lib_version`, `$os`, `$os_version`), processing-control
22/// sentinels this SDK sets (`$geoip_disable`, `$process_person_profile`,
23/// `$is_server`), correctness-required `$groups`, and linkage identifiers.
24pub(crate) const MINIMAL_FLAG_CALLED_EVENT_PROPERTIES: &[&str] = &[
25    // Identity
26    "$feature_flag",
27    "$feature_flag_response",
28    "$feature_flag_has_experiment",
29    // Evaluation debug
30    "$feature_flag_id",
31    "$feature_flag_version",
32    "$feature_flag_reason",
33    "$feature_flag_request_id",
34    "$feature_flag_evaluated_at",
35    "$feature_flag_error",
36    "locally_evaluated",
37    // Correctness-required / processing-control
38    "$groups",
39    "$process_person_profile",
40    "$geoip_disable",
41    // Linkage / SDK identity
42    "$session_id",
43    "$window_id",
44    "$device_id",
45    "$lib",
46    "$lib_version",
47    "$is_server",
48    // Static platform/runtime identity
49    "$os",
50    "$os_version",
51];
52
53/// Whether `key` survives minimization to [`MINIMAL_FLAG_CALLED_EVENT_PROPERTIES`].
54/// Shared by the V0 (`Event::apply_minimal_flag_called_allowlist`) and V1
55/// (`v1_capture::build_events_at`) capture pipelines so the allowlist check
56/// itself has one implementation even though each pipeline applies it to a
57/// different properties representation.
58pub(crate) fn is_minimal_flag_called_property(key: &str) -> bool {
59    MINIMAL_FLAG_CALLED_EVENT_PROPERTIES.contains(&key)
60}
61
62/// An [`Event`] represents an interaction a user has with your app or
63/// website. Examples include button clicks, pageviews, query completions, and signups.
64/// See the [PostHog documentation](https://posthog.com/docs/data/events)
65/// for a detailed explanation of PostHog Events.
66#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
67pub struct Event {
68    event: String,
69    distinct_id: String,
70    properties: HashMap<String, serde_json::Value>,
71    groups: HashMap<String, String>,
72    timestamp: Option<NaiveDateTime>,
73    uuid: Uuid,
74    /// When set, the capture pipeline trims this event's properties to
75    /// [`MINIMAL_FLAG_CALLED_EVENT_PROPERTIES`] as its final enrichment step.
76    /// Set only for minimized `$feature_flag_called` events; never serialized.
77    #[serde(skip)]
78    minimal_flag_called: bool,
79}
80
81impl Event {
82    /// Create a new identified [`Event`]. Unless you have a distinct ID you can
83    /// associate with a user, you probably want to use [`Event::new_anon`]
84    /// instead.
85    ///
86    /// # Parameters
87    ///
88    /// - `event`: Event name, such as `"user_signed_up"`.
89    /// - `distinct_id`: Stable user or account identifier. For backend events,
90    ///   use the same distinct ID your frontend passes to `posthog.identify()`.
91    pub fn new<S: Into<String>>(event: S, distinct_id: S) -> Self {
92        Self {
93            event: event.into(),
94            distinct_id: distinct_id.into(),
95            properties: HashMap::new(),
96            groups: HashMap::new(),
97            timestamp: None,
98            uuid: Uuid::now_v7(),
99            minimal_flag_called: false,
100        }
101    }
102
103    /// Create a new anonymous event.
104    ///
105    /// See <https://posthog.com/docs/data/anonymous-vs-identified-events#how-to-capture-anonymous-events>.
106    ///
107    /// # Parameters
108    ///
109    /// - `event`: Event name.
110    ///
111    /// # Remarks
112    ///
113    /// Generates a random distinct ID and sets `$process_person_profile` to
114    /// `false` so PostHog does not create a person profile for the event.
115    pub fn new_anon<S: Into<String>>(event: S) -> Self {
116        let mut properties = HashMap::new();
117        properties.insert(
118            crate::constants::PROCESS_PERSON_PROFILE_PROP.into(),
119            serde_json::Value::Bool(false),
120        );
121        Self {
122            event: event.into(),
123            distinct_id: Uuid::now_v7().to_string(),
124            properties,
125            groups: HashMap::new(),
126            timestamp: None,
127            uuid: Uuid::now_v7(),
128            minimal_flag_called: false,
129        }
130    }
131
132    /// Add a property to the event.
133    ///
134    /// # Parameters
135    ///
136    /// - `key`: Property name.
137    /// - `prop`: Any value that can be serialized to JSON.
138    ///
139    /// # Errors
140    ///
141    /// Returns [`Error::Serialization`] if `prop` cannot be serialized.
142    pub fn insert_prop<K: Into<String>, P: Serialize>(
143        &mut self,
144        key: K,
145        prop: P,
146    ) -> Result<(), Error> {
147        let as_json =
148            serde_json::to_value(prop).map_err(|e| Error::Serialization(e.to_string()))?;
149        let _ = self.properties.insert(key.into(), as_json);
150        Ok(())
151    }
152
153    /// Remove a property from the event and return its previous value, if any.
154    pub fn remove_prop(&mut self, key: &str) -> Option<serde_json::Value> {
155        self.properties.remove(key)
156    }
157
158    /// Capture this as a group event.
159    ///
160    /// See <https://posthog.com/docs/product-analytics/group-analytics#how-to-capture-group-events>.
161    ///
162    /// # Parameters
163    ///
164    /// - `group_name`: Group type, such as `"company"`.
165    /// - `group_id`: Stable identifier for the group.
166    ///
167    /// # Remarks
168    ///
169    /// Group events cannot be personless, and will be automatically upgraded to
170    /// include person profile processing if they were anonymous. This might lead
171    /// to "empty" person profiles being created.
172    pub fn add_group(&mut self, group_name: &str, group_id: &str) {
173        self.properties.insert(
174            crate::constants::PROCESS_PERSON_PROFILE_PROP.into(),
175            serde_json::Value::Bool(true),
176        );
177        self.groups.insert(group_name.into(), group_id.into());
178    }
179
180    /// Set the event timestamp, for events that happened in the past.
181    ///
182    /// # Parameters
183    ///
184    /// - `timestamp`: Timestamp to send with the event. UTC input is preferred;
185    ///   non-UTC input is converted to the equivalent UTC instant before serialization.
186    ///
187    /// # Errors
188    ///
189    /// Returns [`Error::InvalidTimestamp`] if the timestamp is in the future.
190    pub fn set_timestamp<Tz>(&mut self, timestamp: DateTime<Tz>) -> Result<(), Error>
191    where
192        Tz: TimeZone,
193    {
194        if timestamp > Utc::now() + Duration::seconds(1) {
195            return Err(Error::InvalidTimestamp(String::from(
196                "Events cannot occur in the future",
197            )));
198        }
199        self.timestamp = Some(timestamp.naive_utc());
200        Ok(())
201    }
202
203    /// Build the `$create_alias` event backing [`crate::Client::alias`].
204    ///
205    /// The event is attributed to `previous_id`, which is also mirrored into
206    /// `properties.distinct_id` alongside the merge target in `properties.alias`
207    /// — the shape posthog-python, posthog-js-lite, and posthog-php all send.
208    ///
209    /// Returns `None` when either ID is blank. A merge needs both sides, so a
210    /// blank one can only produce a malformed event; the SDKs above drop it with
211    /// a warning rather than sending it, and so do we.
212    ///
213    /// Properties are built directly rather than through `insert_prop` so
214    /// construction is infallible: both values are strings, which cannot fail to
215    /// serialize, leaving no always-`Ok` `Result` for callers to discard.
216    pub(crate) fn alias(previous_id: String, distinct_id: String) -> Option<Self> {
217        if previous_id.trim().is_empty() || distinct_id.trim().is_empty() {
218            warn!("alias() called with a blank id, dropping the $create_alias event");
219            return None;
220        }
221
222        let mut properties = HashMap::new();
223        properties.insert(
224            "distinct_id".to_string(),
225            serde_json::Value::String(previous_id.clone()),
226        );
227        properties.insert("alias".to_string(), serde_json::Value::String(distinct_id));
228
229        Some(Self {
230            event: "$create_alias".to_string(),
231            distinct_id: previous_id,
232            properties,
233            groups: HashMap::new(),
234            timestamp: None,
235            uuid: Uuid::now_v7(),
236            minimal_flag_called: false,
237        })
238    }
239
240    /// Build the `$groupidentify` event backing [`crate::Client::group_identify`].
241    ///
242    /// Sets `$group_type`, `$group_key`, and `$group_set` in `properties`.
243    /// The event is attributed to `format!("${group_type}_{group_key}")`.
244    ///
245    /// Returns `Ok(None)` when either `group_type` or `group_key` is blank (a
246    /// warning is logged and the event is dropped).
247    ///
248    /// Returns [`Error::Serialization`] if `properties` fails to serialize to
249    /// JSON, or if it does not serialize to a JSON object — PostHog ingestion
250    /// requires `$group_set` to be an object and silently drops the event
251    /// otherwise.
252    pub(crate) fn group_identify<P: Serialize>(
253        group_type: String,
254        group_key: String,
255        properties: P,
256    ) -> Result<Option<Self>, Error> {
257        if group_type.trim().is_empty() {
258            warn!("group_identify() called with a blank group_type, dropping the $groupidentify event");
259            return Ok(None);
260        }
261        if group_key.trim().is_empty() {
262            warn!(
263                "group_identify() called with a blank group_key, dropping the $groupidentify event"
264            );
265            return Ok(None);
266        }
267
268        let group_set =
269            serde_json::to_value(properties).map_err(|e| Error::Serialization(e.to_string()))?;
270        if !group_set.is_object() {
271            return Err(Error::Serialization(format!(
272                "group_identify() properties must serialize to a JSON object, got {group_set}"
273            )));
274        }
275
276        let distinct_id = format!("${group_type}_{group_key}");
277        let mut props = HashMap::new();
278        props.insert(
279            "$group_type".to_string(),
280            serde_json::Value::String(group_type),
281        );
282        props.insert(
283            "$group_key".to_string(),
284            serde_json::Value::String(group_key),
285        );
286        props.insert("$group_set".to_string(), group_set);
287
288        Ok(Some(Self {
289            event: "$groupidentify".to_string(),
290            distinct_id,
291            properties: props,
292            groups: HashMap::new(),
293            timestamp: None,
294            uuid: Uuid::now_v7(),
295            minimal_flag_called: false,
296        }))
297    }
298
299    /// Stamp the capture (enqueue) time when the caller hasn't set an explicit
300    /// timestamp. Done on the producer side before the event is queued, so a
301    /// batched or retried event records when it *occurred*, not when the worker
302    /// finally sent it.
303    pub(crate) fn ensure_timestamp(&mut self, now: DateTime<Utc>) {
304        if self.timestamp.is_none() {
305            self.timestamp = Some(now.naive_utc());
306        }
307    }
308
309    /// Override the auto-generated UUID for this event.
310    ///
311    /// Useful for deduplication when re-importing historical data.
312    pub fn set_uuid(&mut self, uuid: Uuid) {
313        self.uuid = uuid;
314    }
315
316    /// Attach the flag state captured by a [`FeatureFlagEvaluations`] snapshot
317    /// to this event.
318    ///
319    /// Adds `$feature/<key>` for every evaluated flag plus a sorted
320    /// `$active_feature_flags` list of enabled keys, mirroring what
321    /// `send_feature_flags` would otherwise fetch — but without making an
322    /// extra `/flags` request.
323    ///
324    /// # Returns
325    ///
326    /// Returns `self` so calls can be chained before capture.
327    pub fn with_flags(&mut self, flags: &FeatureFlagEvaluations) -> &mut Self {
328        for (key, value) in flags.event_properties() {
329            self.properties.insert(key, value);
330        }
331        self
332    }
333
334    /// Return the event name.
335    #[cfg_attr(not(feature = "capture-v1"), allow(dead_code))]
336    pub fn event_name(&self) -> &str {
337        &self.event
338    }
339
340    /// Return the event distinct ID.
341    #[cfg_attr(not(feature = "capture-v1"), allow(dead_code))]
342    pub fn distinct_id(&self) -> &str {
343        &self.distinct_id
344    }
345
346    #[cfg_attr(not(feature = "capture-v1"), allow(dead_code))]
347    pub(crate) fn uuid(&self) -> Uuid {
348        self.uuid
349    }
350
351    #[cfg_attr(not(feature = "capture-v1"), allow(dead_code))]
352    pub(crate) fn timestamp(&self) -> Option<NaiveDateTime> {
353        self.timestamp
354    }
355
356    /// Return the event properties.
357    #[cfg_attr(not(feature = "capture-v1"), allow(dead_code))]
358    pub fn properties(&self) -> &HashMap<String, serde_json::Value> {
359        &self.properties
360    }
361
362    /// Insert a default property only if the caller hasn't already set it.
363    ///
364    /// This gives caller-wins semantics: SDK-level defaults (like `$is_server`)
365    /// are injected without overriding an explicit value the user placed on the
366    /// event before calling `capture()`.
367    pub(crate) fn insert_prop_default<K: Into<String>>(
368        &mut self,
369        key: K,
370        value: serde_json::Value,
371    ) {
372        self.properties.entry(key.into()).or_insert(value);
373    }
374
375    #[cfg_attr(not(feature = "capture-v1"), allow(dead_code))]
376    pub(crate) fn groups(&self) -> &HashMap<String, String> {
377        &self.groups
378    }
379
380    /// Mark this event as a minimized `$feature_flag_called` event. The capture
381    /// pipeline then trims its properties to
382    /// [`MINIMAL_FLAG_CALLED_EVENT_PROPERTIES`] after all enrichment.
383    pub(crate) fn mark_minimal_flag_called(&mut self) {
384        self.minimal_flag_called = true;
385    }
386
387    /// Whether this event is a minimized `$feature_flag_called` event.
388    #[cfg_attr(not(feature = "capture-v1"), allow(dead_code))]
389    pub(crate) fn is_minimal_flag_called(&self) -> bool {
390        self.minimal_flag_called
391    }
392
393    /// Trim this event's properties to [`MINIMAL_FLAG_CALLED_EVENT_PROPERTIES`]
394    /// when it is a minimized `$feature_flag_called` event; a no-op otherwise.
395    /// Called as the final capture step so no property added upstream survives
396    /// outside the allowlist.
397    #[cfg_attr(feature = "capture-v1", allow(dead_code))]
398    pub(crate) fn apply_minimal_flag_called_allowlist(&mut self) {
399        if self.minimal_flag_called {
400            self.properties
401                .retain(|key, _| is_minimal_flag_called_property(key));
402        }
403    }
404
405    /// Inject SDK metadata and `$groups` into V0 properties.
406    /// Call before constructing [`InnerEvent`] so that the wire payload matches
407    /// what the V0 `/capture` and `/batch` endpoints expect.
408    ///
409    /// `$process_person_profile` is already in `properties` when set by
410    /// constructors (`new_anon`, `add_group`) or explicit `insert_prop`.
411    #[cfg_attr(feature = "capture-v1", allow(dead_code))]
412    pub(crate) fn prepare_for_v0(&mut self) {
413        if !self.properties.contains_key("$lib") {
414            self.properties.insert(
415                "$lib".into(),
416                serde_json::Value::String("posthog-rs".into()),
417            );
418        }
419
420        let version_str = CRATE_VERSION;
421        if !self.properties.contains_key("$lib_version") {
422            self.properties.insert(
423                "$lib_version".into(),
424                serde_json::Value::String(version_str.into()),
425            );
426        }
427
428        if !self.properties.contains_key("$lib_version__major") {
429            if let Ok(version) = version_str.parse::<Version>() {
430                self.properties.insert(
431                    "$lib_version__major".into(),
432                    serde_json::Value::Number(version.major.into()),
433                );
434                self.properties.insert(
435                    "$lib_version__minor".into(),
436                    serde_json::Value::Number(version.minor.into()),
437                );
438                self.properties.insert(
439                    "$lib_version__patch".into(),
440                    serde_json::Value::Number(version.patch.into()),
441                );
442            }
443        }
444
445        if !self.groups.is_empty() {
446            self.properties.insert(
447                "$groups".into(),
448                serde_json::Value::Object(
449                    self.groups
450                        .iter()
451                        .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
452                        .collect(),
453                ),
454            );
455        }
456    }
457}
458
459/// Wrapper for the `/batch/` endpoint that includes the API key and options
460/// alongside the event array.
461#[cfg(not(feature = "capture-v1"))]
462#[derive(Serialize)]
463pub struct BatchRequest {
464    pub api_key: String,
465    pub historical_migration: bool,
466    /// Time the batch left the client, for server-side clock-skew correction.
467    pub sent_at: String,
468    pub batch: Vec<InnerEvent>,
469}
470
471// With `capture-v1` enabled nothing outside tests builds the V0 wire format.
472#[cfg_attr(feature = "capture-v1", allow(dead_code))]
473#[derive(Serialize)]
474pub struct InnerEvent {
475    #[serde(skip_serializing_if = "Option::is_none")]
476    api_key: Option<String>,
477    uuid: Uuid,
478    event: String,
479    distinct_id: String,
480    properties: HashMap<String, serde_json::Value>,
481    timestamp: Option<DateTime<Utc>>,
482}
483
484impl InnerEvent {
485    /// Construct a V0 single-event wire event. Expects that
486    /// [`Event::prepare_for_v0`] has already been called so properties are fully
487    /// decorated.
488    #[cfg(test)]
489    pub fn new(event: Event, api_key: String) -> Self {
490        Self::from_event(event, Some(api_key))
491    }
492
493    /// Construct a V0 batch wire event. The `/batch/` root `api_key` has
494    /// precedence on the backend, so per-event keys are intentionally omitted.
495    #[cfg(not(feature = "capture-v1"))]
496    pub(crate) fn new_for_batch(event: Event) -> Self {
497        Self::from_event(event, None)
498    }
499
500    #[cfg_attr(feature = "capture-v1", allow(dead_code))]
501    fn from_event(event: Event, api_key: Option<String>) -> Self {
502        Self {
503            api_key,
504            uuid: event.uuid,
505            event: event.event,
506            distinct_id: event.distinct_id,
507            properties: event.properties,
508            timestamp: event.timestamp.map(|timestamp| timestamp.and_utc()),
509        }
510    }
511}
512
513#[cfg(test)]
514pub mod tests {
515    use uuid::Uuid;
516
517    use crate::{event::InnerEvent, Error, Event};
518
519    /// Helper: prepares an event for V0 and constructs the InnerEvent.
520    fn build_v0(mut event: Event) -> InnerEvent {
521        event.prepare_for_v0();
522        InnerEvent::new(event, "test_api_key".to_string())
523    }
524
525    #[cfg(not(feature = "capture-v1"))]
526    fn build_v0_batch_event(mut event: Event) -> InnerEvent {
527        event.prepare_for_v0();
528        InnerEvent::new_for_batch(event)
529    }
530
531    #[test]
532    fn v0_adds_lib_properties() {
533        let mut event = Event::new("unit test event", "1234");
534        event.insert_prop("key1", "value1").unwrap();
535
536        let inner = build_v0(event);
537        assert_eq!(
538            inner.properties.get("$lib"),
539            Some(&serde_json::Value::String("posthog-rs".to_string()))
540        );
541    }
542
543    #[test]
544    fn v0_serializes_distinct_id_at_root() {
545        let inner = build_v0(Event::new("test", "user1"));
546        let json = serde_json::to_value(&inner).unwrap();
547
548        // Canonical field at the event root; the legacy `$distinct_id` spelling
549        // (only tolerated by capture via a serde alias) must not be emitted.
550        assert_eq!(json["distinct_id"], "user1");
551        assert!(json.get("$distinct_id").is_none());
552    }
553
554    #[cfg(not(feature = "capture-v1"))]
555    #[test]
556    fn v0_batch_serializes_distinct_id_at_root() {
557        use crate::event::BatchRequest;
558
559        let batch = BatchRequest {
560            api_key: "test_api_key".to_string(),
561            historical_migration: false,
562            sent_at: "2026-01-01T00:00:00Z".to_string(),
563            batch: vec![
564                build_v0_batch_event(Event::new("e1", "user1")),
565                build_v0_batch_event(Event::new("e2", "user2")),
566            ],
567        };
568        let json = serde_json::to_value(&batch).unwrap();
569
570        assert_eq!(json["api_key"], "test_api_key");
571
572        let events = json["batch"].as_array().expect("batch is an array");
573        for (event, expected_id) in events.iter().zip(["user1", "user2"]) {
574            assert_eq!(event["distinct_id"], expected_id);
575            assert!(event.get("$distinct_id").is_none());
576            assert!(event.get("api_key").is_none());
577        }
578    }
579
580    #[test]
581    fn v0_serializes_non_utc_timestamp_as_equivalent_utc_instant() {
582        let mut event = Event::new("test", "user1");
583        event
584            .set_timestamp(
585                chrono::DateTime::parse_from_rfc3339("2023-01-01T10:00:00.123+03:00").unwrap(),
586            )
587            .unwrap();
588
589        let json = serde_json::to_value(build_v0(event)).unwrap();
590        assert_eq!(json["timestamp"], "2023-01-01T07:00:00.123Z");
591    }
592
593    #[test]
594    fn v0_includes_auto_generated_uuid() {
595        let event = Event::new("test", "user1");
596        let inner = build_v0(event);
597        let json = serde_json::to_value(&inner).unwrap();
598
599        let uuid_str = json["uuid"].as_str().expect("uuid should be present");
600        Uuid::parse_str(uuid_str).expect("uuid should be valid");
601    }
602
603    #[test]
604    fn v0_preserves_overridden_uuid() {
605        let uuid = Uuid::now_v7();
606        let mut event = Event::new("test", "user1");
607        event.set_uuid(uuid);
608
609        let inner = build_v0(event);
610        let json = serde_json::to_value(&inner).unwrap();
611        assert_eq!(json["uuid"], uuid.to_string());
612    }
613
614    #[test]
615    fn v0_preserves_existing_lib_properties() {
616        let mut event = Event::new("forwarded event", "user1");
617        event.insert_prop("$lib", "posthog-js").unwrap();
618        event.insert_prop("$lib_version", "1.42.0").unwrap();
619        event.insert_prop("$lib_version__major", 1u64).unwrap();
620
621        let inner = build_v0(event);
622        let props = &inner.properties;
623
624        assert_eq!(
625            props.get("$lib"),
626            Some(&serde_json::Value::String("posthog-js".to_string()))
627        );
628        assert_eq!(
629            props.get("$lib_version"),
630            Some(&serde_json::Value::String("1.42.0".to_string()))
631        );
632        assert_eq!(
633            props.get("$lib_version__major"),
634            Some(&serde_json::Value::Number(1u64.into()))
635        );
636    }
637
638    #[test]
639    fn v0_injects_process_person_profile_for_anon() {
640        let event = Event::new_anon("anon_test");
641        let inner = build_v0(event);
642        assert_eq!(
643            inner.properties.get("$process_person_profile"),
644            Some(&serde_json::Value::Bool(false))
645        );
646    }
647
648    #[test]
649    fn v0_injects_process_person_profile_for_group() {
650        let mut event = Event::new("test", "user1");
651        event.add_group("company", "acme");
652        let inner = build_v0(event);
653        assert_eq!(
654            inner.properties.get("$process_person_profile"),
655            Some(&serde_json::Value::Bool(true))
656        );
657    }
658
659    #[test]
660    fn v0_no_process_person_profile_when_unset() {
661        let event = Event::new("test", "user1");
662        let inner = build_v0(event);
663        assert!(!inner.properties.contains_key("$process_person_profile"));
664    }
665
666    #[test]
667    fn v0_user_property_wins_over_constructor_default() {
668        let mut event = Event::new_anon("test");
669        // new_anon sets $process_person_profile=false; explicit insert overwrites.
670        event.insert_prop("$process_person_profile", true).unwrap();
671        let inner = build_v0(event);
672        assert_eq!(
673            inner.properties.get("$process_person_profile"),
674            Some(&serde_json::Value::Bool(true)),
675        );
676    }
677
678    #[test]
679    fn v0_identified_event_with_explicit_personless() {
680        let mut event = Event::new("test", "user1");
681        event.insert_prop("$process_person_profile", false).unwrap();
682        let inner = build_v0(event);
683        assert_eq!(
684            inner.properties.get("$process_person_profile"),
685            Some(&serde_json::Value::Bool(false)),
686        );
687    }
688
689    #[test]
690    fn v0_add_group_overrides_anon_person_profile() {
691        let mut event = Event::new_anon("test");
692        // new_anon sets $process_person_profile=false; add_group forces true.
693        event.add_group("company", "acme");
694        let inner = build_v0(event);
695        assert_eq!(
696            inner.properties.get("$process_person_profile"),
697            Some(&serde_json::Value::Bool(true)),
698        );
699        let groups = inner
700            .properties
701            .get("$groups")
702            .unwrap()
703            .as_object()
704            .unwrap();
705        assert_eq!(groups.get("company").unwrap().as_str().unwrap(), "acme");
706    }
707
708    #[test]
709    fn v0_group_identify_payload() {
710        let event = Event::group_identify(
711            "company".to_string(),
712            "acme_123".to_string(),
713            serde_json::json!({ "name": "Acme Inc.", "employees": 42 }),
714        )
715        .expect("group_identify should succeed")
716        .expect("group_identify should not be dropped");
717
718        let inner = build_v0(event);
719        let json = serde_json::to_value(&inner).unwrap();
720
721        assert_eq!(json["event"], "$groupidentify");
722        assert_eq!(json["distinct_id"], "$company_acme_123");
723        assert_eq!(json["properties"]["$group_type"], "company");
724        assert_eq!(json["properties"]["$group_key"], "acme_123");
725        assert_eq!(json["properties"]["$group_set"]["name"], "Acme Inc.");
726        assert_eq!(json["properties"]["$group_set"]["employees"], 42);
727        assert!(!inner.properties.contains_key("$process_person_profile"));
728    }
729
730    #[test]
731    fn group_identify_rejects_blank_keys() {
732        assert!(
733            Event::group_identify("".to_string(), "k".to_string(), serde_json::json!({}))
734                .unwrap()
735                .is_none()
736        );
737        assert!(
738            Event::group_identify("   ".to_string(), "k".to_string(), serde_json::json!({}))
739                .unwrap()
740                .is_none()
741        );
742        assert!(
743            Event::group_identify("t".to_string(), "".to_string(), serde_json::json!({}))
744                .unwrap()
745                .is_none()
746        );
747        assert!(
748            Event::group_identify("t".to_string(), "   ".to_string(), serde_json::json!({}))
749                .unwrap()
750                .is_none()
751        );
752    }
753
754    #[test]
755    fn group_identify_rejects_non_object_properties() {
756        let err = Event::group_identify("company".to_string(), "acme_123".to_string(), 42)
757            .expect_err("non-object properties should be rejected");
758        assert!(matches!(err, Error::Serialization(_)));
759
760        let err = Event::group_identify(
761            "company".to_string(),
762            "acme_123".to_string(),
763            serde_json::json!(["a", "b"]),
764        )
765        .expect_err("array properties should be rejected");
766        assert!(matches!(err, Error::Serialization(_)));
767
768        let err = Event::group_identify(
769            "company".to_string(),
770            "acme_123".to_string(),
771            serde_json::Value::Null,
772        )
773        .expect_err("null properties should be rejected");
774        assert!(matches!(err, Error::Serialization(_)));
775    }
776}
777
778#[cfg(test)]
779mod test {
780    use std::time::Duration;
781
782    use chrono::{DateTime, Utc};
783
784    use super::Event;
785
786    #[test]
787    fn test_timestamp_is_correctly_set() {
788        let mut event = Event::new_anon("test");
789        let ts = DateTime::parse_from_rfc3339("2023-01-01T10:00:00+03:00").unwrap();
790        event.set_timestamp(ts).expect("Date is not in the future");
791        let expected = DateTime::parse_from_rfc3339("2023-01-01T07:00:00Z").unwrap();
792        assert_eq!(event.timestamp.unwrap(), expected.naive_utc())
793    }
794
795    #[test]
796    fn test_timestamp_is_correctly_set_with_future_date() {
797        let mut event = Event::new_anon("test");
798        let ts = Utc::now() + Duration::from_secs(60);
799        event
800            .set_timestamp(ts)
801            .expect_err("Date is in the future, should be rejected");
802
803        assert!(event.timestamp.is_none())
804    }
805
806    #[test]
807    fn ensure_timestamp_stamps_only_when_unset() {
808        let now = DateTime::parse_from_rfc3339("2026-06-17T12:00:00Z")
809            .unwrap()
810            .with_timezone(&Utc);
811
812        // Unset -> stamped with the provided capture time.
813        let mut event = Event::new("test", "user1");
814        event.ensure_timestamp(now);
815        assert_eq!(event.timestamp, Some(now.naive_utc()));
816
817        // Caller's explicit timestamp wins; ensure is a no-op.
818        let mut event = Event::new("test", "user1");
819        let caller = DateTime::parse_from_rfc3339("2020-01-01T00:00:00Z")
820            .unwrap()
821            .with_timezone(&Utc);
822        event.set_timestamp(caller).unwrap();
823        event.ensure_timestamp(now);
824        assert_eq!(event.timestamp, Some(caller.naive_utc()));
825    }
826}