Skip to main content

relay_quotas/
quota.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::fmt::{self, Write};
3use std::hash::{Hash, Hasher};
4use std::str::FromStr;
5use std::sync::Arc;
6
7use relay_base_schema::metrics::MetricNamespace;
8use relay_base_schema::organization::OrganizationId;
9use relay_base_schema::project::{ProjectId, ProjectKey};
10use serde::ser::SerializeSeq;
11use serde::{Deserialize, Serialize};
12use smallvec::SmallVec;
13
14#[doc(inline)]
15pub use relay_base_schema::data_category::{CategoryUnit, DataCategory};
16
17/// Data scoping information for rate limiting and quota enforcement.
18///
19/// [`Scoping`] holds all the identifiers needed to attribute data to specific
20/// organizations, projects, and keys. This allows the rate limiting and quota
21/// systems to enforce limits at the appropriate scope levels.
22#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
23pub struct Scoping {
24    /// The organization id.
25    pub organization_id: OrganizationId,
26
27    /// The project id.
28    pub project_id: ProjectId,
29
30    /// The DSN public key.
31    pub project_key: ProjectKey,
32
33    /// The public key's internal id.
34    pub key_id: Option<u64>,
35}
36
37impl Scoping {
38    /// Creates an [`ItemScoping`] for a specific data category in this scope.
39    ///
40    /// The returned item scoping contains a reference to this scope and the provided
41    /// data category. This is a cheap operation that allows for efficient rate limiting
42    /// of individual items.
43    pub fn item(&self, category: DataCategory) -> ItemScoping {
44        ItemScoping {
45            category,
46            scoping: *self,
47            namespace: MetricNamespaceScoping::None,
48            dimensions: DimensionMap::default(),
49        }
50    }
51
52    /// Creates an [`ItemScoping`] for a specific data category, along with specified dimensions
53    pub fn item_with_dimensions(
54        &self,
55        category: DataCategory,
56        dimensions: DimensionMap,
57    ) -> ItemScoping {
58        ItemScoping {
59            category,
60            scoping: *self,
61            namespace: MetricNamespaceScoping::None,
62            dimensions,
63        }
64    }
65
66    /// Creates an [`ItemScoping`] specifically for metric buckets in this scope.
67    ///
68    /// The returned item scoping contains a reference to this scope, the
69    /// [`DataCategory::MetricBucket`] category, and the provided metric namespace.
70    /// This is specialized for handling metrics with namespaces.
71    pub fn metric_bucket(&self, namespace: MetricNamespace) -> ItemScoping {
72        ItemScoping {
73            category: DataCategory::MetricBucket,
74            scoping: *self,
75            namespace: MetricNamespaceScoping::Some(namespace),
76            dimensions: DimensionMap::default(),
77        }
78    }
79}
80
81/// Describes the metric namespace scoping of an item.
82///
83/// This enum is used within [`ItemScoping`] to represent the metric namespace of an item.
84/// It handles the different cases: no namespace, a specific namespace, or any namespace.
85#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash, PartialOrd)]
86pub enum MetricNamespaceScoping {
87    /// The item does not contain metrics of any namespace.
88    ///
89    /// This should only be used for non-metric items.
90    #[default]
91    None,
92
93    /// The item contains metrics of a specific namespace.
94    Some(MetricNamespace),
95
96    /// The item contains metrics of any namespace.
97    ///
98    /// The specific namespace is not known or relevant. This can be used to check rate
99    /// limits or quotas that should apply to any namespace.
100    Any,
101}
102
103impl MetricNamespaceScoping {
104    /// Checks if the given namespace matches this namespace scoping.
105    ///
106    /// Returns `true` in the following cases:
107    /// - If `self` is [`MetricNamespaceScoping::Some`] with the same namespace
108    /// - If `self` is [`MetricNamespaceScoping::Any`], matching any namespace
109    pub fn matches(&self, namespace: MetricNamespace) -> bool {
110        match self {
111            Self::None => false,
112            Self::Some(ns) => *ns == namespace,
113            Self::Any => true,
114        }
115    }
116}
117
118impl From<MetricNamespace> for MetricNamespaceScoping {
119    fn from(namespace: MetricNamespace) -> Self {
120        Self::Some(namespace)
121    }
122}
123
124/// Data categorization and scoping information for a single item.
125///
126/// [`ItemScoping`] combines a data category, scoping information, and optional
127/// metric namespace to fully define an item for rate limiting purposes.
128#[derive(Debug, Clone, Eq, PartialEq)]
129pub struct ItemScoping {
130    /// The data category of the item.
131    pub category: DataCategory,
132
133    /// Scoping of the data.
134    pub scoping: Scoping,
135
136    /// Namespace for metric items, requiring [`DataCategory::MetricBucket`].
137    pub namespace: MetricNamespaceScoping,
138
139    /// Dimensions this quota will be matched on.
140    pub dimensions: DimensionMap,
141}
142
143/// A map of Dimension -> String
144#[derive(Default, Debug, Clone, Eq, PartialEq)]
145pub struct DimensionMap(Arc<BTreeMap<Dimension, String>>);
146
147impl<const N: usize> From<[(Dimension, String); N]> for DimensionMap {
148    fn from(arr: [(Dimension, String); N]) -> Self {
149        Self(Arc::new(BTreeMap::from(arr)))
150    }
151}
152
153impl FromIterator<(Dimension, String)> for DimensionMap {
154    fn from_iter<T: IntoIterator<Item = (Dimension, String)>>(iter: T) -> DimensionMap {
155        Self(Arc::new(BTreeMap::from_iter(iter)))
156    }
157}
158
159impl DimensionMap {
160    /// Converts the dimensions of this item scoping, for the supplied quota, into a string of the
161    /// dimension name and hashed value.  This looks like ':key1:hash1:key2:hash2'.
162    /// This function assumes that the quota passed to it already matches this ItemScoping.
163    /// If the
164    pub fn to_string(&self, quota: &Quota) -> Option<String> {
165        let mut result = String::new();
166
167        let Some(group_by) = &quota.group_by else {
168            return None;
169        };
170
171        for dim in group_by.dimensions.iter() {
172            if let Some((key, val)) = self.0.get_key_value(dim) {
173                let mut hasher = fnv::FnvHasher::with_key(1);
174                val.hash(&mut hasher);
175                let h = &hasher.finish();
176
177                write!(&mut result, ":{key}:{h}").expect("should be infallible");
178            }
179        }
180
181        if result.is_empty() {
182            return None;
183        }
184
185        result.into()
186    }
187}
188
189impl std::ops::Deref for ItemScoping {
190    type Target = Scoping;
191
192    fn deref(&self) -> &Self::Target {
193        &self.scoping
194    }
195}
196
197impl ItemScoping {
198    /// Returns the identifier for the given quota scope.
199    ///
200    /// Maps the quota scope type to the corresponding identifier from this scoping,
201    /// or `None` if the scope type doesn't have an applicable identifier.
202    pub fn scope_id(&self, scope: QuotaScope) -> Option<u64> {
203        match scope {
204            QuotaScope::Organization => Some(self.organization_id.value()),
205            QuotaScope::Project => Some(self.project_id.value()),
206            QuotaScope::Key => self.key_id,
207            QuotaScope::Unknown => None,
208        }
209    }
210
211    /// Checks whether the category matches any of the quota's categories.
212    pub(crate) fn matches_categories(&self, categories: DataCategories) -> bool {
213        // An empty list of categories means that this quota matches all categories. Note that we
214        // skip `Unknown` categories silently. If the list of categories only contains `Unknown`s,
215        // we do **not** match, since apparently the quota is meant for some data this Relay does
216        // not support yet.
217        categories.is_empty() || categories.contains(&self.category)
218    }
219
220    /// Checks wether this item matches all of the supplied quota's dimensions.
221    pub(crate) fn matches_dimensions(&self, group_by: &Option<GroupBy>) -> bool {
222        let Some(gb) = group_by else { return true };
223
224        gb.dimensions
225            .iter()
226            .all(|dim| self.dimensions.0.contains_key(dim))
227    }
228
229    /// Returns `true` if the rate limit namespace matches the namespace of the item.
230    ///
231    /// Matching behavior depends on the passed namespaces and the namespace of the scoping:
232    ///  - If the list of namespaces is empty, this check always returns `true`.
233    ///  - If the list of namespaces contains at least one namespace, a namespace on the scoping is
234    ///    required. [`MetricNamespaceScoping::None`] will not match.
235    ///  - If the namespace of this scoping is [`MetricNamespaceScoping::Any`], this check will
236    ///    always return true.
237    ///  - Otherwise, an exact match of the scoping's namespace must be found in the list.
238    ///
239    /// `namespace` can be either a slice, an iterator, or a reference to an
240    /// `Option<MetricNamespace>`. In case of `None`, this method behaves like an empty list and
241    /// permits any namespace.
242    pub(crate) fn matches_namespaces<'a, I>(&self, namespaces: I) -> bool
243    where
244        I: IntoIterator<Item = &'a MetricNamespace>,
245    {
246        let mut iter = namespaces.into_iter().peekable();
247        iter.peek().is_none() || iter.any(|ns| self.namespace.matches(*ns))
248    }
249}
250
251/// An efficient container for data categories that avoids allocations.
252///
253/// It is a read only and has set like properties, allowing for fast comparisons.
254#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, Hash)]
255pub struct DataCategories(u64);
256
257impl Serialize for DataCategories {
258    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
259    where
260        S: serde::Serializer,
261    {
262        let mut ser = serializer.serialize_seq(Some(self.len()))?;
263        for category in self.iter() {
264            ser.serialize_element(&category)?;
265        }
266        ser.end()
267    }
268}
269
270impl DataCategories {
271    fn category_to_mask(category: DataCategory) -> u64 {
272        if matches!(category, DataCategory::Unknown) {
273            // Handle Unknown by throwing it into the last bit.
274            1u64 << 63
275        } else {
276            1u64 << (category as u8)
277        }
278    }
279
280    fn bit_number_to_category(bit_number: u8) -> DataCategory {
281        bit_number.try_into().unwrap_or(DataCategory::Unknown)
282    }
283
284    /// Creates new and empty [`DataCategories`].
285    pub fn new() -> Self {
286        Default::default()
287    }
288
289    /// Creates a new [`DataCategories`] from the supplied slice of [`DataCategory`] values.
290    pub fn from_slice(slice: &[DataCategory]) -> Self {
291        let mut categories = 0;
292        for category in slice {
293            categories |= DataCategories::category_to_mask(*category);
294        }
295
296        Self(categories)
297    }
298
299    /// Adds a data category to [`Self`].
300    ///
301    /// Returns `None` if the category was already contained, otherwise creates a new [`Self`] with
302    /// the `category` added.
303    pub fn add(&self, category: DataCategory) -> Option<Self> {
304        let category_mask = DataCategories::category_to_mask(category);
305
306        if (self.0 & category_mask) != 0 {
307            return None;
308        }
309
310        Some(Self(self.0 | category_mask))
311    }
312
313    /// Returns true iff the category is contained.
314    pub fn contains(&self, category: &DataCategory) -> bool {
315        let category_mask = DataCategories::category_to_mask(*category);
316        (self.0 & category_mask) != 0
317    }
318
319    /// Returns an iterator over this [`DataCategories`] container.
320    pub fn iter(&self) -> DataCategoryIterator {
321        // We start our iteration from the lsb, so the number of trailings zeroes is our 0-based
322        // starting index.
323        let bit_start = self.0.trailing_zeros();
324
325        // Shift our bitfield so that the first bit is in the 0th place.
326        let (categories, _) = self.0.overflowing_shr(bit_start);
327        DataCategoryIterator {
328            categories,
329            current_bit: bit_start as u8,
330        }
331    }
332
333    /// Returns the number of categories in this container.
334    pub fn len(&self) -> usize {
335        self.0.count_ones() as usize
336    }
337
338    /// Returns true iff this container contains no categories.
339    pub fn is_empty(&self) -> bool {
340        self.0 == 0
341    }
342}
343
344/// An iterator over a [`DataCategories`] container.
345pub struct DataCategoryIterator {
346    categories: u64,
347    current_bit: u8,
348}
349
350impl Iterator for DataCategoryIterator {
351    type Item = DataCategory;
352
353    fn next(&mut self) -> Option<Self::Item> {
354        if self.categories == 0 {
355            return None;
356        }
357        let category = DataCategories::bit_number_to_category(self.current_bit);
358
359        // Consume the last bit
360        self.categories &= u64::MAX - 1;
361
362        // Find the next set bit
363        let trailing_zeroes = self.categories.trailing_zeros() as u8;
364
365        // Put that bit in the 0th (to-consume) position
366        (self.categories, _) = self.categories.overflowing_shr(trailing_zeroes as u32);
367
368        // Update the current bit state
369        self.current_bit += trailing_zeroes;
370
371        Some(category)
372    }
373}
374
375impl<'de> Deserialize<'de> for DataCategories {
376    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
377    where
378        D: serde::Deserializer<'de>,
379    {
380        SmallVec::<[DataCategory; 12]>::deserialize(deserializer).map(Self::from)
381    }
382}
383
384impl<const N: usize> From<SmallVec<[DataCategory; N]>> for DataCategories {
385    fn from(categories: SmallVec<[DataCategory; N]>) -> Self {
386        Self::from_slice(&categories)
387    }
388}
389
390impl<const N: usize> From<[DataCategory; N]> for DataCategories {
391    fn from(categories: [DataCategory; N]) -> Self {
392        Self::from_slice(&categories)
393    }
394}
395
396impl FromIterator<DataCategory> for DataCategories {
397    fn from_iter<T: IntoIterator<Item = DataCategory>>(iter: T) -> Self {
398        let v: SmallVec<[DataCategory; 12]> = iter.into_iter().collect();
399        Self::from_slice(&v)
400    }
401}
402
403/// The scope at which a quota is applied.
404///
405/// Defines the granularity at which quotas are enforced, from organizations
406/// down to individual project keys. This enum only defines the type of scope,
407/// not the specific instance.
408///
409/// This type is directly related to [`crate::rate_limit::RateLimitScope`], which
410/// includes the specific scope identifiers.
411#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Deserialize, Serialize)]
412#[serde(rename_all = "lowercase")]
413pub enum QuotaScope {
414    /// The organization level.
415    ///
416    /// This is the top-level scope.
417    Organization,
418
419    /// The project level.
420    ///
421    /// Projects are contained within organizations.
422    Project,
423
424    /// The project key level (corresponds to a DSN).
425    ///
426    /// This is the most specific scope level and is contained within projects.
427    Key,
428
429    /// Any scope type not recognized by this Relay.
430    #[serde(other)]
431    Unknown,
432}
433
434impl QuotaScope {
435    /// Returns the quota scope corresponding to the given name string.
436    ///
437    /// If the string doesn't match any known scope, returns [`QuotaScope::Unknown`].
438    pub fn from_name(string: &str) -> Self {
439        match string {
440            "organization" => Self::Organization,
441            "project" => Self::Project,
442            "key" => Self::Key,
443            _ => Self::Unknown,
444        }
445    }
446
447    /// Returns the canonical string name of this scope.
448    ///
449    /// This is the lowercase string representation used in serialization.
450    pub fn name(self) -> &'static str {
451        match self {
452            Self::Key => "key",
453            Self::Project => "project",
454            Self::Organization => "organization",
455            Self::Unknown => "unknown",
456        }
457    }
458}
459
460impl fmt::Display for QuotaScope {
461    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
462        write!(f, "{}", self.name())
463    }
464}
465
466impl FromStr for QuotaScope {
467    type Err = ();
468
469    fn from_str(string: &str) -> Result<Self, Self::Err> {
470        Ok(Self::from_name(string))
471    }
472}
473
474fn default_scope() -> QuotaScope {
475    QuotaScope::Organization
476}
477
478/// A machine-readable reason code for rate limits.
479///
480/// Reason codes provide a standardized way to communicate why a particular
481/// item was rate limited.
482#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Hash)]
483pub struct ReasonCode(Arc<str>);
484
485impl ReasonCode {
486    /// Creates a new reason code from a string.
487    ///
488    /// This method is primarily intended for testing. In production, reason codes
489    /// should typically be deserialized from quota configurations rather than
490    /// constructed manually.
491    pub fn new<S: Into<Arc<str>>>(code: S) -> Self {
492        Self(code.into())
493    }
494
495    /// Returns the string representation of this reason code.
496    pub fn as_str(&self) -> &str {
497        &self.0
498    }
499}
500
501impl fmt::Display for ReasonCode {
502    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
503        self.0.fmt(f)
504    }
505}
506
507/// Configuration for a data ingestion quota.
508///
509/// A quota defines restrictions on data ingestion based on data categories, scopes,
510/// and time windows. The system applies multiple quotas to incoming data, and items
511/// are counted against all matching quotas based on their categories.
512///
513/// Quotas can either:
514/// - Reject all data (`limit` = 0)
515/// - Limit data to a specific quantity per time window (`limit` > 0)
516/// - Count data without limiting it (`limit` = None)
517///
518/// Different quotas may apply at different scope levels (organization, project, key).
519#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
520#[serde(rename_all = "camelCase")]
521pub struct Quota {
522    /// The unique identifier for counting this quota.
523    ///
524    /// Required for all quotas except those with `limit` = 0, which are statically enforced.
525    #[serde(default)]
526    pub id: Option<Arc<str>>,
527
528    /// Data categories this quota applies to.
529    ///
530    /// If missing or empty, this quota applies to all data categories.
531    #[serde(default)]
532    pub categories: DataCategories,
533
534    /// The scope level at which this quota is enforced.
535    ///
536    /// The quota is enforced separately within each instance of this scope
537    /// (e.g., for each project key separately). Defaults to [`QuotaScope::Organization`].
538    #[serde(default = "default_scope")]
539    pub scope: QuotaScope,
540
541    /// Specific scope instance identifier this quota applies to.
542    ///
543    /// If set, this quota only applies to the specified scope instance
544    /// (e.g., a specific project key). Requires `scope` to be set explicitly.
545    #[serde(default, skip_serializing_if = "Option::is_none")]
546    pub scope_id: Option<Arc<str>>,
547
548    /// Maximum number of events allowed within the time window.
549    ///
550    /// Possible values:
551    /// - `Some(0)`: Reject all matching events
552    /// - `Some(n)`: Allow up to n events per time window
553    /// - `None`: Unlimited quota (counts but doesn't limit)
554    ///
555    /// Requires `window` to be set if the limit is not 0.
556    #[serde(default)]
557    pub limit: Option<u64>,
558
559    /// The time window in seconds for quota enforcement.
560    ///
561    /// Required in all cases except `limit` = 0, since those quotas
562    /// are not measured over time.
563    #[serde(default, skip_serializing_if = "Option::is_none")]
564    pub window: Option<u64>,
565
566    /// The metric namespace this quota applies to.
567    ///
568    /// If `None`, it matches any namespace.
569    pub namespace: Option<MetricNamespace>,
570
571    /// A machine-readable reason code returned when this quota is exceeded.
572    ///
573    /// Required for all quotas except those with `limit` = None, since
574    /// unlimited quotas can never be exceeded.
575    #[serde(default, skip_serializing_if = "Option::is_none")]
576    pub reason_code: Option<ReasonCode>,
577
578    /// The optional list of additional dimensions that this quota will be counted by.
579    #[serde(default, skip_serializing_if = "Option::is_none")]
580    pub group_by: Option<GroupBy>,
581}
582
583/// The dimensions that a quota can key on--this is for things like monitors, that require even
584/// more fine-grained rate-limiting.
585#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
586#[serde(rename_all = "camelCase")]
587pub struct GroupBy {
588    /// The maximum number combinations of dimensions on this quota to allow.
589    pub max_cardinality: u32,
590
591    /// The list of dimensions this quota will use.
592    pub dimensions: Arc<BTreeSet<Dimension>>,
593}
594
595/// The kinds of dimensions that can be applied to a given quota.
596#[derive(Copy, Clone, Debug, Deserialize, Serialize, Eq, PartialEq, PartialOrd, Ord, Hash)]
597#[serde(rename_all = "camelCase")]
598pub enum Dimension {
599    /// The environment used for an monitor check-in.
600    CheckInEnvironment,
601
602    /// The slug used in a monitor check-in.
603    CheckInSlug,
604
605    /// An unknown dimension.
606    ///
607    /// Quotas with unknown dimensions are not applied.
608    #[serde(other)]
609    Unknown,
610}
611
612impl fmt::Display for Dimension {
613    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
614        match self {
615            Dimension::CheckInEnvironment => f.write_str("check_in.environment"),
616            Dimension::CheckInSlug => f.write_str("check_in.slug"),
617            Dimension::Unknown => f.write_str("unknown"),
618        }
619    }
620}
621
622impl Quota {
623    /// Returns whether this quota is valid for tracking.
624    ///
625    /// A quota is considered invalid if any of the following conditions are true:
626    ///  - The quota only applies to [`DataCategory::Unknown`] data categories.
627    ///  - The quota is counted (not limit `0`) but specifies categories with different units.
628    ///  - The quota references an unsupported namespace.
629    ///  - The dimensions contain Unknown, or are empty.
630    pub fn is_valid(&self) -> bool {
631        if self.namespace == Some(MetricNamespace::Unsupported) {
632            return false;
633        }
634
635        if let Some(group_by) = &self.group_by {
636            if group_by.dimensions.is_empty() {
637                return false;
638            }
639
640            for dim in group_by.dimensions.iter() {
641                if *dim == Dimension::Unknown {
642                    return false;
643                }
644            }
645        }
646
647        let mut units = self
648            .categories
649            .iter()
650            .filter_map(CategoryUnit::from_category);
651
652        match units.next() {
653            // There are only unknown categories, which is always invalid
654            None if !self.categories.is_empty() => false,
655            // This is a reject all quota, which is always valid
656            _ if self.limit == Some(0) => true,
657            // Applies to all categories, which implies multiple units
658            None => false,
659            // There are multiple categories, which must all have the same units
660            Some(unit) => units.all(|u| u == unit),
661        }
662    }
663
664    /// Checks whether this quota's scope matches the given item scoping.
665    ///
666    /// This quota matches, if:
667    ///  - there is no `scope_id` constraint
668    ///  - the `scope_id` constraint is not numeric
669    ///  - the scope identifier matches the one from ascoping and the scope is known
670    fn matches_scope(&self, scoping: &ItemScoping) -> bool {
671        // Check for a scope identifier constraint. If there is no constraint, this means that the
672        // quota matches any scope. In case the scope is unknown, it will be coerced to the most
673        // specific scope later.
674        let Some(scope_id) = self.scope_id.as_ref() else {
675            return true;
676        };
677
678        // Check if the scope identifier in the quota is parseable. If not, this means we cannot
679        // fulfill the constraint, so the quota does not match.
680        let Ok(parsed) = scope_id.parse::<u64>() else {
681            return false;
682        };
683
684        // At this stage, require that the scope is known since we have to fulfill the constraint.
685        scoping.scope_id(self.scope) == Some(parsed)
686    }
687
688    /// Checks whether the quota's constraints match the current item.
689    ///
690    /// This method determines if this quota should be applied to a given item
691    /// based on its scope, categories, and namespace.
692    pub fn matches(&self, scoping: &ItemScoping) -> bool {
693        self.matches_scope(scoping)
694            && scoping.matches_categories(self.categories)
695            && scoping.matches_namespaces(&self.namespace)
696            && scoping.matches_dimensions(&self.group_by)
697    }
698}
699
700#[cfg(test)]
701mod tests {
702    use relay_base_schema::data_category::UnknownDataCategory;
703
704    use super::*;
705
706    #[test]
707    fn test_parse_quota_reject_all() {
708        let json = r#"{
709            "limit": 0,
710            "reasonCode": "not_yet"
711        }"#;
712
713        let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
714
715        insta::assert_ron_snapshot!(quota, @r###"
716        Quota(
717          id: None,
718          categories: [],
719          scope: organization,
720          limit: Some(0),
721          namespace: None,
722          reasonCode: Some(ReasonCode("not_yet")),
723        )
724        "###);
725    }
726
727    #[test]
728    fn test_parse_quota_reject_transactions() {
729        let json = r#"{
730            "limit": 0,
731            "categories": ["transaction"],
732            "reasonCode": "not_yet"
733        }"#;
734
735        let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
736
737        insta::assert_ron_snapshot!(quota, @r#"
738        Quota(
739          id: None,
740          categories: [
741            "transaction",
742          ],
743          scope: organization,
744          limit: Some(0),
745          namespace: None,
746          reasonCode: Some(ReasonCode("not_yet")),
747        )
748        "#);
749    }
750
751    #[test]
752    fn test_parse_quota_limited() {
753        let json = r#"{
754            "id": "o",
755            "limit": 4711,
756            "window": 42,
757            "reasonCode": "not_so_fast"
758        }"#;
759
760        let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
761
762        insta::assert_ron_snapshot!(quota, @r###"
763        Quota(
764          id: Some("o"),
765          categories: [],
766          scope: organization,
767          limit: Some(4711),
768          window: Some(42),
769          namespace: None,
770          reasonCode: Some(ReasonCode("not_so_fast")),
771        )
772        "###);
773    }
774
775    #[test]
776    fn test_parse_quota_project() {
777        let json = r#"{
778            "id": "p",
779            "scope": "project",
780            "scopeId": "1",
781            "limit": 4711,
782            "window": 42,
783            "reasonCode": "not_so_fast"
784        }"#;
785
786        let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
787
788        insta::assert_ron_snapshot!(quota, @r###"
789        Quota(
790          id: Some("p"),
791          categories: [],
792          scope: project,
793          scopeId: Some("1"),
794          limit: Some(4711),
795          window: Some(42),
796          namespace: None,
797          reasonCode: Some(ReasonCode("not_so_fast")),
798        )
799        "###);
800    }
801
802    #[test]
803    fn test_parse_quota_project_large() {
804        let json = r#"{
805            "id": "p",
806            "scope": "project",
807            "scopeId": "1",
808            "limit": 4294967296,
809            "window": 42,
810            "reasonCode": "not_so_fast"
811        }"#;
812
813        let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
814
815        insta::assert_ron_snapshot!(quota, @r###"
816        Quota(
817          id: Some("p"),
818          categories: [],
819          scope: project,
820          scopeId: Some("1"),
821          limit: Some(4294967296),
822          window: Some(42),
823          namespace: None,
824          reasonCode: Some(ReasonCode("not_so_fast")),
825        )
826        "###);
827    }
828
829    #[test]
830    fn test_parse_quota_key() {
831        let json = r#"{
832            "id": "k",
833            "scope": "key",
834            "scopeId": "1",
835            "limit": 4711,
836            "window": 42,
837            "reasonCode": "not_so_fast"
838        }"#;
839
840        let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
841
842        insta::assert_ron_snapshot!(quota, @r###"
843        Quota(
844          id: Some("k"),
845          categories: [],
846          scope: key,
847          scopeId: Some("1"),
848          limit: Some(4711),
849          window: Some(42),
850          namespace: None,
851          reasonCode: Some(ReasonCode("not_so_fast")),
852        )
853        "###);
854    }
855
856    #[test]
857    fn test_parse_quota_unknown_variants() {
858        let json = r#"{
859            "id": "f",
860            "categories": ["future"],
861            "scope": "future",
862            "scopeId": "1",
863            "limit": 4711,
864            "window": 42,
865            "reasonCode": "not_so_fast"
866        }"#;
867
868        let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
869
870        insta::assert_ron_snapshot!(quota, @r#"
871        Quota(
872          id: Some("f"),
873          categories: [
874            "unknown",
875          ],
876          scope: unknown,
877          scopeId: Some("1"),
878          limit: Some(4711),
879          window: Some(42),
880          namespace: None,
881          reasonCode: Some(ReasonCode("not_so_fast")),
882        )
883        "#);
884    }
885
886    #[test]
887    fn test_parse_quota_unlimited() {
888        let json = r#"{
889            "id": "o",
890            "window": 42
891        }"#;
892
893        let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
894
895        insta::assert_ron_snapshot!(quota, @r###"
896        Quota(
897          id: Some("o"),
898          categories: [],
899          scope: organization,
900          limit: None,
901          window: Some(42),
902          namespace: None,
903        )
904        "###);
905    }
906
907    #[test]
908    fn test_quota_valid_reject_all() {
909        let quota = Quota {
910            id: None,
911            categories: Default::default(),
912            scope: QuotaScope::Organization,
913            scope_id: None,
914            limit: Some(0),
915            window: None,
916            reason_code: None,
917            namespace: None,
918            group_by: None,
919        };
920
921        assert!(quota.is_valid());
922    }
923
924    #[test]
925    fn test_quota_invalid_only_unknown() {
926        let quota = Quota {
927            id: None,
928            categories: [DataCategory::Unknown, DataCategory::Unknown].into(),
929            scope: QuotaScope::Organization,
930            scope_id: None,
931            limit: Some(0),
932            window: None,
933            reason_code: None,
934            namespace: None,
935            group_by: None,
936        };
937
938        assert!(!quota.is_valid());
939    }
940
941    #[test]
942    fn test_quota_valid_reject_all_mixed() {
943        let quota = Quota {
944            id: None,
945            categories: [DataCategory::Error, DataCategory::Attachment].into(),
946            scope: QuotaScope::Organization,
947            scope_id: None,
948            limit: Some(0),
949            window: None,
950            reason_code: None,
951            namespace: None,
952            group_by: None,
953        };
954
955        assert!(quota.is_valid());
956    }
957
958    #[test]
959    fn test_quota_invalid_limited_mixed() {
960        let quota = Quota {
961            id: None,
962            categories: [DataCategory::Error, DataCategory::Attachment].into(),
963            scope: QuotaScope::Organization,
964            scope_id: None,
965            limit: Some(1000),
966            window: None,
967            reason_code: None,
968            namespace: None,
969            group_by: None,
970        };
971
972        // This category is limited and counted, but has multiple units.
973        assert!(!quota.is_valid());
974    }
975
976    #[test]
977    fn test_quota_invalid_unlimited_mixed() {
978        let quota = Quota {
979            id: None,
980            categories: [DataCategory::Error, DataCategory::Attachment].into(),
981            scope: QuotaScope::Organization,
982            scope_id: None,
983            limit: None,
984            window: None,
985            reason_code: None,
986            namespace: None,
987            group_by: None,
988        };
989
990        // This category is unlimited and counted, but has multiple units.
991        assert!(!quota.is_valid());
992    }
993
994    #[test]
995    fn test_quota_matches_no_categories() {
996        let quota = Quota {
997            id: None,
998            categories: Default::default(),
999            scope: QuotaScope::Organization,
1000            scope_id: None,
1001            limit: None,
1002            window: None,
1003            reason_code: None,
1004            namespace: None,
1005            group_by: None,
1006        };
1007
1008        assert!(quota.matches(&ItemScoping {
1009            category: DataCategory::Error,
1010            scoping: Scoping {
1011                organization_id: OrganizationId::new(42),
1012                project_id: ProjectId::new(21),
1013                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1014                key_id: Some(17),
1015            },
1016            namespace: MetricNamespaceScoping::None,
1017            dimensions: DimensionMap::default(),
1018        }));
1019    }
1020
1021    #[test]
1022    fn test_quota_matches_unknown_category() {
1023        let quota = Quota {
1024            id: None,
1025            categories: [DataCategory::Unknown].into(),
1026            scope: QuotaScope::Organization,
1027            scope_id: None,
1028            limit: None,
1029            window: None,
1030            reason_code: None,
1031            namespace: None,
1032            group_by: None,
1033        };
1034
1035        assert!(!quota.matches(&ItemScoping {
1036            category: DataCategory::Error,
1037            scoping: Scoping {
1038                organization_id: OrganizationId::new(42),
1039                project_id: ProjectId::new(21),
1040                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1041                key_id: Some(17),
1042            },
1043            namespace: MetricNamespaceScoping::None,
1044            dimensions: DimensionMap::default(),
1045        }));
1046    }
1047
1048    #[test]
1049    fn test_quota_matches_multiple_categores() {
1050        let quota = Quota {
1051            id: None,
1052            categories: [DataCategory::Unknown, DataCategory::Error].into(),
1053            scope: QuotaScope::Organization,
1054            scope_id: None,
1055            limit: None,
1056            window: None,
1057            reason_code: None,
1058            namespace: None,
1059            group_by: None,
1060        };
1061
1062        assert!(quota.matches(&ItemScoping {
1063            category: DataCategory::Error,
1064            scoping: Scoping {
1065                organization_id: OrganizationId::new(42),
1066                project_id: ProjectId::new(21),
1067                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1068                key_id: Some(17),
1069            },
1070            namespace: MetricNamespaceScoping::None,
1071            dimensions: DimensionMap::default(),
1072        }));
1073
1074        assert!(!quota.matches(&ItemScoping {
1075            category: DataCategory::Transaction,
1076            scoping: Scoping {
1077                organization_id: OrganizationId::new(42),
1078                project_id: ProjectId::new(21),
1079                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1080                key_id: Some(17),
1081            },
1082            namespace: MetricNamespaceScoping::None,
1083            dimensions: DimensionMap::default(),
1084        }));
1085    }
1086
1087    #[test]
1088    fn test_quota_matches_no_invalid_scope() {
1089        let quota = Quota {
1090            id: None,
1091            categories: Default::default(),
1092            scope: QuotaScope::Organization,
1093            scope_id: Some("not_a_number".into()),
1094            limit: None,
1095            window: None,
1096            reason_code: None,
1097            namespace: None,
1098            group_by: None,
1099        };
1100
1101        assert!(!quota.matches(&ItemScoping {
1102            category: DataCategory::Error,
1103            scoping: Scoping {
1104                organization_id: OrganizationId::new(42),
1105                project_id: ProjectId::new(21),
1106                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1107                key_id: Some(17),
1108            },
1109            namespace: MetricNamespaceScoping::None,
1110            dimensions: DimensionMap::default(),
1111        }));
1112    }
1113
1114    #[test]
1115    fn test_quota_matches_organization_scope() {
1116        let quota = Quota {
1117            id: None,
1118            categories: Default::default(),
1119            scope: QuotaScope::Organization,
1120            scope_id: Some("42".into()),
1121            limit: None,
1122            window: None,
1123            reason_code: None,
1124            namespace: None,
1125            group_by: None,
1126        };
1127
1128        assert!(quota.matches(&ItemScoping {
1129            category: DataCategory::Error,
1130            scoping: Scoping {
1131                organization_id: OrganizationId::new(42),
1132                project_id: ProjectId::new(21),
1133                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1134                key_id: Some(17),
1135            },
1136            namespace: MetricNamespaceScoping::None,
1137            dimensions: DimensionMap::default(),
1138        }));
1139
1140        assert!(!quota.matches(&ItemScoping {
1141            category: DataCategory::Error,
1142            scoping: Scoping {
1143                organization_id: OrganizationId::new(0),
1144                project_id: ProjectId::new(21),
1145                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1146                key_id: Some(17),
1147            },
1148            namespace: MetricNamespaceScoping::None,
1149            dimensions: DimensionMap::default(),
1150        }));
1151    }
1152
1153    #[test]
1154    fn test_quota_matches_project_scope() {
1155        let quota = Quota {
1156            id: None,
1157            categories: Default::default(),
1158            scope: QuotaScope::Project,
1159            scope_id: Some("21".into()),
1160            limit: None,
1161            window: None,
1162            reason_code: None,
1163            namespace: None,
1164            group_by: None,
1165        };
1166
1167        assert!(quota.matches(&ItemScoping {
1168            category: DataCategory::Error,
1169            scoping: Scoping {
1170                organization_id: OrganizationId::new(42),
1171                project_id: ProjectId::new(21),
1172                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1173                key_id: Some(17),
1174            },
1175            namespace: MetricNamespaceScoping::None,
1176            dimensions: DimensionMap::default(),
1177        }));
1178
1179        assert!(!quota.matches(&ItemScoping {
1180            category: DataCategory::Error,
1181            scoping: Scoping {
1182                organization_id: OrganizationId::new(42),
1183                project_id: ProjectId::new(0),
1184                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1185                key_id: Some(17),
1186            },
1187            namespace: MetricNamespaceScoping::None,
1188            dimensions: DimensionMap::default(),
1189        }));
1190    }
1191
1192    #[test]
1193    fn test_quota_matches_key_scope() {
1194        let quota = Quota {
1195            id: None,
1196            categories: Default::default(),
1197            scope: QuotaScope::Key,
1198            scope_id: Some("17".into()),
1199            limit: None,
1200            window: None,
1201            reason_code: None,
1202            namespace: None,
1203            group_by: None,
1204        };
1205
1206        assert!(quota.matches(&ItemScoping {
1207            category: DataCategory::Error,
1208            scoping: Scoping {
1209                organization_id: OrganizationId::new(42),
1210                project_id: ProjectId::new(21),
1211                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1212                key_id: Some(17),
1213            },
1214            namespace: MetricNamespaceScoping::None,
1215            dimensions: DimensionMap::default(),
1216        }));
1217
1218        assert!(!quota.matches(&ItemScoping {
1219            category: DataCategory::Error,
1220            scoping: Scoping {
1221                organization_id: OrganizationId::new(42),
1222                project_id: ProjectId::new(21),
1223                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1224                key_id: Some(0),
1225            },
1226            namespace: MetricNamespaceScoping::None,
1227            dimensions: DimensionMap::default(),
1228        }));
1229
1230        assert!(!quota.matches(&ItemScoping {
1231            category: DataCategory::Error,
1232            scoping: Scoping {
1233                organization_id: OrganizationId::new(42),
1234                project_id: ProjectId::new(21),
1235                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1236                key_id: None,
1237            },
1238            namespace: MetricNamespaceScoping::None,
1239            dimensions: DimensionMap::default(),
1240        }));
1241    }
1242
1243    /// Builds a monitor quota with the passed dimensions.
1244    fn dimensioned_quota(dimensions: Option<GroupBy>) -> Quota {
1245        Quota {
1246            id: Some("q".into()),
1247            categories: [DataCategory::Monitor].into(),
1248            scope: QuotaScope::Project,
1249            scope_id: None,
1250            limit: Some(10),
1251            window: Some(60),
1252            reason_code: None,
1253            namespace: None,
1254            group_by: dimensions,
1255        }
1256    }
1257
1258    /// Builds a monitor item scoping with the passed dimensions.
1259    fn dimensioned_scoping(dimensions: Option<&[(Dimension, &str)]>) -> ItemScoping {
1260        let scoping = Scoping {
1261            organization_id: OrganizationId::new(42),
1262            project_id: ProjectId::new(21),
1263            project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1264            key_id: Some(17),
1265        };
1266
1267        let owned_dims: Option<Vec<_>> =
1268            dimensions.map(|dims| dims.iter().map(|(d, v)| (*d, (*v).to_owned())).collect());
1269
1270        match owned_dims {
1271            None => scoping.item(DataCategory::Monitor),
1272            Some(mut dims) => scoping.item_with_dimensions(
1273                DataCategory::Monitor,
1274                DimensionMap(BTreeMap::from_iter(dims.drain(..)).into()),
1275            ),
1276        }
1277    }
1278
1279    #[test]
1280    fn test_quota_valid_dimensions() {
1281        let quota = dimensioned_quota(
1282            GroupBy {
1283                max_cardinality: 100,
1284                dimensions: BTreeSet::from([Dimension::CheckInSlug, Dimension::CheckInEnvironment])
1285                    .into(),
1286            }
1287            .into(),
1288        );
1289
1290        assert!(quota.is_valid());
1291    }
1292
1293    #[test]
1294    fn test_quota_invalid_unknown_dimension() {
1295        let quota = dimensioned_quota(
1296            GroupBy {
1297                max_cardinality: 999,
1298                dimensions: BTreeSet::from([Dimension::CheckInSlug, Dimension::Unknown]).into(),
1299            }
1300            .into(),
1301        );
1302
1303        assert!(!quota.is_valid());
1304    }
1305
1306    #[test]
1307    fn test_quota_empty_dimension() {
1308        let quota = dimensioned_quota(
1309            GroupBy {
1310                max_cardinality: 999,
1311                dimensions: BTreeSet::default().into(),
1312            }
1313            .into(),
1314        );
1315
1316        assert!(!quota.is_valid());
1317    }
1318
1319    #[test]
1320    fn test_quota_matches_dimensions() {
1321        let quota = dimensioned_quota(
1322            GroupBy {
1323                max_cardinality: 999,
1324                dimensions: BTreeSet::from([Dimension::CheckInEnvironment, Dimension::CheckInSlug])
1325                    .into(),
1326            }
1327            .into(),
1328        );
1329
1330        // Exactly the required dimensions, in either order.
1331        assert!(quota.matches(&dimensioned_scoping(Some(&[
1332            (Dimension::CheckInEnvironment, "prod"),
1333            (Dimension::CheckInSlug, "cron1"),
1334        ]))));
1335        assert!(quota.matches(&dimensioned_scoping(Some(&[
1336            (Dimension::CheckInSlug, "cron1"),
1337            (Dimension::CheckInEnvironment, "prod"),
1338        ]))));
1339
1340        // A subset of the required dimensions does not match.
1341        assert!(!quota.matches(&dimensioned_scoping(Some(&[(
1342            Dimension::CheckInSlug,
1343            "cron1"
1344        )]))));
1345
1346        // Neither does an empty or absent dimension set.
1347        assert!(!quota.matches(&dimensioned_scoping(Some(&[]))));
1348        assert!(!quota.matches(&dimensioned_scoping(None)));
1349    }
1350
1351    #[test]
1352    fn test_quota_without_dimensions_matches_any_item() {
1353        let quota = dimensioned_quota(None);
1354
1355        // A quota without dimensions applies to every item, dimensioned or not.
1356        assert!(quota.matches(&dimensioned_scoping(None)));
1357        assert!(quota.matches(&dimensioned_scoping(Some(&[
1358            (Dimension::CheckInEnvironment, "prod"),
1359            (Dimension::CheckInSlug, "cron1"),
1360        ]))));
1361    }
1362
1363    #[test]
1364    fn test_dimensions_as_string() {
1365        let quota = dimensioned_quota(
1366            GroupBy {
1367                max_cardinality: 999,
1368                dimensions: BTreeSet::from([Dimension::CheckInEnvironment, Dimension::CheckInSlug])
1369                    .into(),
1370            }
1371            .into(),
1372        );
1373
1374        let key = dimensioned_scoping(Some(&[
1375            (Dimension::CheckInEnvironment, "prod"),
1376            (Dimension::CheckInSlug, "cron1"),
1377        ]))
1378        .dimensions
1379        .to_string(&quota)
1380        .unwrap();
1381
1382        // `:<dimension>:<hash>` per dimension, ordered by the quota's dimensions.
1383        let parts = key.split(':').collect::<Vec<_>>();
1384        assert_eq!(parts.len(), 5);
1385        assert_eq!(parts[0], "");
1386        assert_eq!(parts[1], Dimension::CheckInEnvironment.to_string());
1387        assert_eq!(parts[3], Dimension::CheckInSlug.to_string());
1388        assert!(parts[2].parse::<u64>().is_ok());
1389        assert!(parts[4].parse::<u64>().is_ok());
1390    }
1391
1392    #[test]
1393    fn test_dimensions_as_string_ignores_extra_dimensions() {
1394        let quota = dimensioned_quota(
1395            GroupBy {
1396                max_cardinality: 999,
1397                dimensions: BTreeSet::from([Dimension::CheckInSlug]).into(),
1398            }
1399            .into(),
1400        );
1401
1402        let expected = dimensioned_scoping(Some(&[(Dimension::CheckInSlug, "cron1")]))
1403            .dimensions
1404            .to_string(&quota);
1405
1406        assert_eq!(
1407            dimensioned_scoping(Some(&[
1408                (Dimension::CheckInSlug, "cron1"),
1409                (Dimension::CheckInEnvironment, "prod"),
1410            ]))
1411            .dimensions
1412            .to_string(&quota),
1413            expected
1414        );
1415    }
1416
1417    #[test]
1418    fn test_dimensions_as_string_without_dimensions() {
1419        let dimensions = GroupBy {
1420            max_cardinality: 999,
1421            dimensions: BTreeSet::from([Dimension::CheckInSlug]).into(),
1422        };
1423
1424        let dimensioned_item =
1425            dimensioned_scoping(Some(&[(Dimension::CheckInEnvironment, "prod")]));
1426
1427        // Neither side having dimensions, or only one side having them, collapses to the
1428        // single "no dimensions" bucket.
1429        assert!(
1430            dimensioned_scoping(None)
1431                .dimensions
1432                .to_string(&dimensioned_quota(None))
1433                .is_none()
1434        );
1435        assert!(
1436            dimensioned_scoping(None)
1437                .dimensions
1438                .to_string(&dimensioned_quota(dimensions.clone().into()))
1439                .is_none(),
1440        );
1441        assert!(
1442            dimensioned_item
1443                .dimensions
1444                .to_string(&dimensioned_quota(None))
1445                .is_none()
1446        );
1447
1448        // So does an item which shares no dimension with the quota.
1449        assert!(
1450            dimensioned_item
1451                .dimensions
1452                .to_string(&dimensioned_quota(dimensions.into()))
1453                .is_none()
1454        );
1455    }
1456
1457    #[test]
1458    fn test_parse_quota_dimensions() {
1459        let json = r#"{
1460            "id": "o",
1461            "categories": ["monitor"],
1462            "limit": 4711,
1463            "window": 42,
1464            "groupBy": {
1465                "maxCardinality": 100,
1466                "dimensions": ["checkInSlug", "checkInEnvironment"]
1467            }
1468        }"#;
1469
1470        let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
1471
1472        insta::assert_ron_snapshot!(quota, @r#"
1473        Quota(
1474          id: Some("o"),
1475          categories: [
1476            "monitor",
1477          ],
1478          scope: organization,
1479          limit: Some(4711),
1480          window: Some(42),
1481          namespace: None,
1482          groupBy: Some(GroupBy(
1483            maxCardinality: 100,
1484            dimensions: [
1485              checkInEnvironment,
1486              checkInSlug,
1487            ],
1488          )),
1489        )
1490        "#);
1491
1492        assert!(quota.is_valid());
1493    }
1494
1495    #[test]
1496    fn test_parse_quota_dimensions_unknown() {
1497        let json = r#"{
1498            "id": "o",
1499            "limit": 4711,
1500            "window": 42,
1501            "groupBy": {
1502                "maxCardinality": 999,
1503                "dimensions": ["checkInSlug", "somethingNew"]
1504            }
1505        }"#;
1506
1507        let quota = serde_json::from_str::<Quota>(json).expect("parse quota");
1508
1509        // Unknown dimensions parse, but invalidate the quota, since we cannot bucket by a
1510        // dimension we do not understand.
1511        insta::assert_ron_snapshot!(quota, @r#"
1512        Quota(
1513          id: Some("o"),
1514          categories: [],
1515          scope: organization,
1516          limit: Some(4711),
1517          window: Some(42),
1518          namespace: None,
1519          groupBy: Some(GroupBy(
1520            maxCardinality: 999,
1521            dimensions: [
1522              checkInSlug,
1523              unknown,
1524            ],
1525          )),
1526        )
1527        "#);
1528
1529        assert!(!quota.is_valid());
1530    }
1531
1532    #[test]
1533    fn test_data_categories_sorted_deduplicated() {
1534        let a = DataCategories::from([
1535            DataCategory::Transaction,
1536            DataCategory::Span,
1537            DataCategory::Transaction,
1538        ]);
1539        let b = DataCategories::from([
1540            DataCategory::Span,
1541            DataCategory::Transaction,
1542            DataCategory::Span,
1543        ]);
1544        let c = DataCategories::from([DataCategory::Span, DataCategory::Transaction]);
1545
1546        assert_eq!(a, b);
1547        assert_eq!(b, c);
1548        assert_eq!(a, c);
1549    }
1550
1551    #[test]
1552    fn test_data_categories_serde() {
1553        let s: DataCategories = serde_json::from_str(r#"["span", "transaction", "span"]"#).unwrap();
1554        insta::assert_json_snapshot!(s, @r#"
1555        [
1556          "transaction",
1557          "span"
1558        ]
1559        "#);
1560    }
1561
1562    #[test]
1563    fn test_data_categories_add() {
1564        let c = DataCategories::new();
1565        let c = c.add(DataCategory::Span).unwrap();
1566        assert!(c.add(DataCategory::Span).is_none());
1567        let c = c.add(DataCategory::Transaction).unwrap();
1568        assert_eq!(c, [DataCategory::Span, DataCategory::Transaction].into());
1569    }
1570
1571    #[test]
1572    fn test_reserve_last_bit_data_category() {
1573        // Ensure we don't accidentally use 63 as a valid DataCategory.
1574        let r: Result<DataCategory, UnknownDataCategory> = 63u32.try_into();
1575        assert!(r.is_err());
1576    }
1577
1578    #[test]
1579    fn test_bitmapping_identity() {
1580        assert_eq!(
1581            DataCategories::category_to_mask(DataCategory::Unknown),
1582            1u64 << 63
1583        );
1584        for i in 0..63u32 {
1585            let r: Result<DataCategory, UnknownDataCategory> = i.try_into();
1586            if let Ok(dc) = r {
1587                assert_eq!(DataCategories::bit_number_to_category(i as u8), dc);
1588                assert_eq!(DataCategories::category_to_mask(dc), 1 << i);
1589            } else {
1590                break;
1591            }
1592        }
1593    }
1594}