unleash_api_client/
strategy.rs

1// Copyright 2020 Cognite AS
2//! <https://docs.getunleash.io/user_guide/activation_strategy>
3use std::hash::BuildHasher;
4use std::io::Cursor;
5use std::net::IpAddr;
6use std::{collections::hash_map::HashMap, str::FromStr};
7use std::{collections::hash_set::HashSet, fmt::Display};
8
9use chrono::{DateTime, Utc};
10use ipnet::IpNet;
11use log::{trace, warn};
12use murmur3::murmur3_32;
13use rand::Rng;
14use semver::Version;
15
16use crate::api::{Constraint, ConstraintExpression};
17use crate::context::Context;
18
19/// Memoise feature state for a strategy.
20pub type Strategy =
21    Box<dyn Fn(Option<HashMap<String, String>>) -> Evaluate + Sync + Send + 'static>;
22/// Apply memoised state to a context.
23pub trait Evaluator: Fn(&Context) -> bool {
24    fn clone_boxed(&self) -> Box<dyn Evaluator + Send + Sync + 'static>;
25}
26pub type Evaluate = Box<dyn Evaluator + Send + Sync + 'static>;
27
28impl<T> Evaluator for T
29where
30    T: 'static + Clone + Sync + Send + Fn(&Context) -> bool,
31{
32    fn clone_boxed(&self) -> Box<dyn Evaluator + Send + Sync + 'static> {
33        Box::new(T::clone(self))
34    }
35}
36
37impl Clone for Box<dyn Evaluator + Send + Sync + 'static> {
38    fn clone(&self) -> Self {
39        self.as_ref().clone_boxed()
40    }
41}
42
43/// <https://docs.getunleash.io/user_guide/activation_strategy#standard>
44pub fn default<S: BuildHasher>(_: Option<HashMap<String, String, S>>) -> Evaluate {
45    Box::new(|_: &Context| -> bool { true })
46}
47
48/// <https://docs.getunleash.io/user_guide/activation_strategy#userids>
49/// userIds: user,ids,to,match
50pub fn user_with_id<S: BuildHasher>(parameters: Option<HashMap<String, String, S>>) -> Evaluate {
51    let mut uids: HashSet<String> = HashSet::new();
52    if let Some(parameters) = parameters {
53        if let Some(uids_list) = parameters.get("userIds") {
54            for uid in uids_list.split(',') {
55                uids.insert(uid.trim().into());
56            }
57        }
58    }
59    Box::new(move |context: &Context| -> bool {
60        context
61            .user_id
62            .as_ref()
63            .map(|uid| uids.contains(uid))
64            .unwrap_or(false)
65    })
66}
67
68/// Get the group and rollout parameters for gradual rollouts.
69///
70/// When no group is supplied, group is set to "".
71///
72/// Checks the following parameter keys:
73/// `groupId`: defines the hash group, used to either correlate or prevent correlation across toggles.
74/// rollout_key: supplied by the caller, this keys value is the percent of the hashed results to enable.
75pub fn group_and_rollout<S: BuildHasher>(
76    parameters: &Option<HashMap<String, String, S>>,
77    rollout_key: &str,
78) -> (String, u32) {
79    let parameters = if let Some(parameters) = parameters {
80        parameters
81    } else {
82        return ("".into(), 0);
83    };
84    let group = if let Some(group) = parameters.get("groupId") {
85        group.to_string()
86    } else {
87        "".into()
88    };
89
90    let mut rollout = 0;
91    if let Some(rollout_str) = parameters.get(rollout_key) {
92        if let Ok(percent) = rollout_str.parse::<u32>() {
93            rollout = percent
94        }
95    }
96    (group, rollout)
97}
98
99/// Implement partial rollout given a group a variable part and a rollout amount
100pub fn partial_rollout(group: &str, variable: Option<&String>, rollout: u32) -> bool {
101    let variable = if let Some(variable) = variable {
102        variable
103    } else {
104        return false;
105    };
106    match rollout {
107        // No need to hash when set to 0 or 100
108        0 => false,
109        100 => true,
110        rollout => {
111            if let Ok(normalised) = normalised_hash(group, variable, 100) {
112                rollout >= normalised
113            } else {
114                false
115            }
116        }
117    }
118}
119
120/// Calculates a hash in the standard way expected for Unleash clients. Not
121/// required for extension strategies, but reusing this is probably a good idea
122/// for consistency across implementations.
123pub fn normalised_hash(group: &str, identifier: &str, modulus: u32) -> std::io::Result<u32> {
124    normalised_hash_internal(group, identifier, modulus, 0)
125}
126
127const VARIANT_NORMALIZATION_SEED: u32 = 86028157;
128
129/// Calculates a hash for **variant distribution** in the standard way
130/// expected for Unleash clients. This differs from the
131/// [`normalised_hash`] function in that it uses a different seed to
132///  ensure a fair distribution.
133pub fn normalised_variant_hash(
134    group: &str,
135    identifier: &str,
136    modulus: u32,
137) -> std::io::Result<u32> {
138    normalised_hash_internal(group, identifier, modulus, VARIANT_NORMALIZATION_SEED)
139}
140
141fn normalised_hash_internal(
142    group: &str,
143    identifier: &str,
144    modulus: u32,
145    seed: u32,
146) -> std::io::Result<u32> {
147    // See https://github.com/stusmall/murmur3/pull/16 : .chain may avoid
148    // copying in the general case, and may be faster (though perhaps
149    // benchmarking would be useful - small datasizes here could make the best
150    // path non-obvious) - but until murmur3 is fixed, we need to provide it
151    // with a single string no matter what.
152    let mut reader = Cursor::new(format!("{}:{}", &group, &identifier));
153    murmur3_32(&mut reader, seed).map(|hash_result| hash_result % modulus + 1)
154}
155
156// Build a closure to handle session id rollouts, parameterised by groupId and a
157// metaparameter of the percentage taken from rollout_key.
158fn _session_id<S: BuildHasher>(
159    parameters: Option<HashMap<String, String, S>>,
160    rollout_key: &str,
161) -> Evaluate {
162    let (group, rollout) = group_and_rollout(&parameters, rollout_key);
163    Box::new(move |context: &Context| -> bool {
164        partial_rollout(&group, context.session_id.as_ref(), rollout)
165    })
166}
167
168// Build a closure to handle user id rollouts, parameterised by groupId and a
169// metaparameter of the percentage taken from rollout_key.
170fn _user_id<S: BuildHasher>(
171    parameters: Option<HashMap<String, String, S>>,
172    rollout_key: &str,
173) -> Evaluate {
174    let (group, rollout) = group_and_rollout(&parameters, rollout_key);
175    Box::new(move |context: &Context| -> bool {
176        partial_rollout(&group, context.user_id.as_ref(), rollout)
177    })
178}
179
180/// <https://docs.getunleash.io/user_guide/activation_strategy#gradual-rollout>
181/// stickiness: [default|userId|sessionId|random]
182/// groupId: hash key
183/// rollout: percentage
184pub fn flexible_rollout<S: BuildHasher>(
185    parameters: Option<HashMap<String, String, S>>,
186) -> Evaluate {
187    let unwrapped_parameters = if let Some(parameters) = &parameters {
188        parameters
189    } else {
190        return Box::new(|_| false);
191    };
192    match if let Some(stickiness) = unwrapped_parameters.get("stickiness") {
193        stickiness.as_str()
194    } else {
195        return Box::new(|_| false);
196    } {
197        "default" => {
198            // user, session, random in that order.
199            let (group, rollout) = group_and_rollout(&parameters, "rollout");
200            Box::new(move |context: &Context| -> bool {
201                if context.user_id.is_some() {
202                    partial_rollout(&group, context.user_id.as_ref(), rollout)
203                } else if context.session_id.is_some() {
204                    partial_rollout(&group, context.session_id.as_ref(), rollout)
205                } else {
206                    pick_random(rollout as u8)
207                }
208            })
209        }
210        "userId" => _user_id(parameters, "rollout"),
211        "sessionId" => _session_id(parameters, "rollout"),
212        "random" => _random(parameters, "rollout"),
213        _ => Box::new(|_| false),
214    }
215}
216
217/// <https://docs.getunleash.io/user_guide/activation_strategy#gradualrolloutuserid-deprecated-from-v4---use-gradual-rollout-instead>
218/// percentage: 0-100
219/// groupId: hash key
220pub fn user_id<S: BuildHasher>(parameters: Option<HashMap<String, String, S>>) -> Evaluate {
221    _user_id(parameters, "percentage")
222}
223
224/// <https://docs.getunleash.io/user_guide/activation_strategy#gradualrolloutsessionid-deprecated-from-v4---use-gradual-rollout-instead>
225/// percentage: 0-100
226/// groupId: hash key
227pub fn session_id<S: BuildHasher>(parameters: Option<HashMap<String, String, S>>) -> Evaluate {
228    _session_id(parameters, "percentage")
229}
230
231/// Perform the is-enabled check for a random rollout of pct.
232fn pick_random(pct: u8) -> bool {
233    match pct {
234        0 => false,
235        100 => true,
236        pct => {
237            let mut rng = rand::rng();
238            // generates 0's but not 100's.
239            let picked = rng.random_range(0..100);
240            pct > picked
241        }
242    }
243}
244
245// Build a closure to handle random rollouts, parameterised by a
246// metaparameter of the percentage taken from rollout_key.
247pub fn _random<S: BuildHasher>(
248    parameters: Option<HashMap<String, String, S>>,
249    rollout_key: &str,
250) -> Evaluate {
251    let mut pct = 0;
252    if let Some(parameters) = parameters {
253        if let Some(pct_str) = parameters.get(rollout_key) {
254            if let Ok(percent) = pct_str.parse::<u8>() {
255                pct = percent
256            }
257        }
258    }
259    Box::new(move |_: &Context| -> bool { pick_random(pct) })
260}
261
262/// <https://docs.getunleash.io/user_guide/activation_strategy#gradualrolloutrandom-deprecated-from-v4---use-gradual-rollout-instead>
263/// percentage: percentage 0-100
264pub fn random<S: BuildHasher>(parameters: Option<HashMap<String, String, S>>) -> Evaluate {
265    _random(parameters, "percentage")
266}
267
268/// <https://docs.getunleash.io/user_guide/activation_strategy#ips>
269/// IPs: 1.2.3.4,AB::CD::::EF,1.2/8
270pub fn remote_address<S: BuildHasher>(parameters: Option<HashMap<String, String, S>>) -> Evaluate {
271    // TODO: this could be optimised given the inherent radix structure, but its
272    // not exactly hot-path.
273    let mut ips: Vec<IpNet> = Vec::new();
274
275    if let Some(parameters) = parameters {
276        if let Some(ips_str) = parameters.get("IPs") {
277            for ip_str in ips_str.split(',') {
278                let ip_parsed = _parse_ip(ip_str.trim());
279                if let Ok(ip) = ip_parsed {
280                    ips.push(ip)
281                }
282            }
283        }
284    }
285
286    Box::new(move |context: &Context| -> bool {
287        if let Some(remote_address) = &context.remote_address {
288            for ip in &ips {
289                if ip.contains(&remote_address.0) {
290                    return true;
291                }
292            }
293        }
294        false
295    })
296}
297
298/// <https://docs.getunleash.io/user_guide/activation_strategy#hostnames>
299/// hostNames: names,of,hosts
300pub fn hostname<S: BuildHasher>(parameters: Option<HashMap<String, String, S>>) -> Evaluate {
301    let mut result = false;
302    hostname::get().ok().and_then(|this_hostname| {
303        parameters.map(|parameters| {
304            parameters.get("hostNames").map(|hostnames: &String| {
305                for hostname in hostnames.split(',') {
306                    if this_hostname == hostname.trim() {
307                        result = true;
308                    }
309                }
310                false
311            })
312        })
313    });
314
315    Box::new(move |_: &Context| -> bool { result })
316}
317
318fn lower_case_if<S: Display>(case_insensitive: bool) -> impl Fn(S) -> String {
319    move |s| {
320        if case_insensitive {
321            s.to_string().to_lowercase()
322        } else {
323            s.to_string()
324        }
325    }
326}
327
328fn handle_parsable_op<T, C, F>(getter: F, compare_fn: C) -> Evaluate
329where
330    T: FromStr,
331    C: Fn(T) -> bool + Clone + Sync + Send + 'static,
332    F: Fn(&Context) -> Option<&String> + Clone + Sync + Send + 'static,
333{
334    Box::new(move |context: &Context| {
335        getter(context)
336            .and_then(|v| v.parse::<T>().ok())
337            .map(&compare_fn)
338            .unwrap_or(false)
339    })
340}
341
342fn handle_str_op<T, C, F>(
343    values: Vec<String>,
344    getter: F,
345    case_insensitive: bool,
346    compare_fn: C,
347) -> Evaluate
348where
349    T: Display,
350    C: Fn(&String, &String) -> bool + Clone + Sync + Send + 'static,
351    F: Fn(&Context) -> Option<&T> + Clone + Sync + Send + 'static,
352{
353    let as_vec: Vec<String> = values.iter().map(lower_case_if(case_insensitive)).collect();
354    Box::new(move |context: &Context| {
355        getter(context)
356            .map(lower_case_if(case_insensitive))
357            .map(|v| as_vec.iter().any(|entry| compare_fn(&v, entry)))
358            .unwrap_or(false)
359    })
360}
361
362/// returns true if the strategy should be delegated to, false to disable
363fn _compile_constraint_string<F, B>(
364    expression: ConstraintExpression,
365    apply_invert: B,
366    case_insensitive: bool,
367    getter: F,
368) -> Evaluate
369where
370    F: Fn(&Context) -> Option<&String> + Clone + Sync + Send + 'static,
371    B: Fn(bool) -> bool + Sync + Send + Clone + 'static,
372{
373    let compiled_fn: Box<dyn Evaluator + Send + Sync + 'static> = match expression {
374        ConstraintExpression::In { values } => {
375            let as_set: HashSet<String> = values.iter().cloned().collect();
376
377            Box::new(move |context: &Context| {
378                getter(context).map(|v| as_set.contains(v)).unwrap_or(false)
379            })
380        }
381        ConstraintExpression::NotIn { values } => {
382            if values.is_empty() {
383                Box::new(|_| true)
384            } else {
385                let as_set: HashSet<String> = values.iter().cloned().collect();
386                Box::new(move |context: &Context| {
387                    getter(context).map(|v| !as_set.contains(v)).unwrap_or(true)
388                })
389            }
390        }
391        ConstraintExpression::StrContains { values } => {
392            handle_str_op(values, getter, case_insensitive, |v, entry| {
393                v.contains(entry)
394            })
395        }
396        ConstraintExpression::StrStartsWith { values } => {
397            handle_str_op(values, getter, case_insensitive, |v, entry| {
398                v.starts_with(entry)
399            })
400        }
401        ConstraintExpression::StrEndsWith { values } => {
402            handle_str_op(values, getter, case_insensitive, |v, entry| {
403                v.ends_with(entry)
404            })
405        }
406        ConstraintExpression::NumEq { value } => {
407            handle_parsable_op(getter, move |v: f64| v == value)
408        }
409        ConstraintExpression::NumGT { value } => {
410            handle_parsable_op(getter, move |v: f64| v > value)
411        }
412        ConstraintExpression::NumGTE { value } => {
413            handle_parsable_op(getter, move |v: f64| v >= value)
414        }
415        ConstraintExpression::NumLT { value } => {
416            handle_parsable_op(getter, move |v: f64| v < value)
417        }
418        ConstraintExpression::NumLTE { value } => {
419            handle_parsable_op(getter, move |v: f64| v <= value)
420        }
421        ConstraintExpression::SemverEq { value } => {
422            handle_parsable_op(getter, move |v: Version| v == value)
423        }
424        ConstraintExpression::SemverGT { value } => {
425            handle_parsable_op(getter, move |v: Version| v > value)
426        }
427        ConstraintExpression::SemverLT { value } => {
428            handle_parsable_op(getter, move |v: Version| v < value)
429        }
430        _ => Box::new(|_| false),
431    };
432
433    Box::new(move |context: &Context| apply_invert(compiled_fn(context)))
434}
435
436/// returns true if the strategy should be delegated to, false to disable
437fn _compile_constraint_date<F, B>(
438    expression: ConstraintExpression,
439    apply_invert: B,
440    getter: F,
441) -> Evaluate
442where
443    F: Fn(&Context) -> Option<&DateTime<Utc>> + Clone + Sync + Send + 'static,
444    B: Fn(bool) -> bool + Sync + Send + Clone + 'static,
445{
446    let compiled_fn: Box<dyn Evaluator + Send + Sync + 'static> = match expression {
447        ConstraintExpression::DateAfter { value } => {
448            Box::new(move |context: &Context| getter(context).map(|v| *v > value).unwrap_or(false))
449        }
450        ConstraintExpression::DateBefore { value } => {
451            Box::new(move |context: &Context| getter(context).map(|v| *v < value).unwrap_or(false))
452        }
453        _ => Box::new(|_| false),
454    };
455    Box::new(move |context: &Context| apply_invert(compiled_fn(context)))
456}
457
458fn _ip_to_vec(ips: &[String]) -> Vec<IpNet> {
459    let mut result = Vec::new();
460    for ip_str in ips {
461        let ip_parsed = _parse_ip(ip_str.trim());
462        if let Ok(ip) = ip_parsed {
463            result.push(ip);
464        } else {
465            warn!("Could not parse IP address {ip_str:?}");
466        }
467    }
468    result
469}
470
471/// returns true if the strategy should be delegated to, false to disable
472fn _compile_constraint_host<F, B>(
473    expression: ConstraintExpression,
474    apply_invert: B,
475    case_insensitive: bool,
476    getter: F,
477) -> Evaluate
478where
479    F: Fn(&Context) -> Option<&crate::context::IPAddress> + Clone + Sync + Send + 'static,
480    B: Fn(bool) -> bool + Sync + Send + Clone + 'static,
481{
482    let compiled_fn: Box<dyn Evaluator + Send + Sync + 'static> = match expression {
483        ConstraintExpression::In { values } => {
484            let ips = _ip_to_vec(&values[..]);
485            Box::new(move |context: &Context| {
486                getter(context)
487                    .map(|remote_address| {
488                        for ip in &ips {
489                            if ip.contains(&remote_address.0) {
490                                return true;
491                            }
492                        }
493                        false
494                    })
495                    .unwrap_or(false)
496            })
497        }
498        ConstraintExpression::NotIn { values } => {
499            if values.is_empty() {
500                Box::new(|_| false)
501            } else {
502                let ips = _ip_to_vec(&values[..]);
503                Box::new(move |context: &Context| {
504                    getter(context)
505                        .map(|remote_address| {
506                            if ips.is_empty() {
507                                return false;
508                            }
509                            for ip in &ips {
510                                if ip.contains(&remote_address.0) {
511                                    return false;
512                                }
513                            }
514                            true
515                        })
516                        .unwrap_or(true)
517                })
518            }
519        }
520        ConstraintExpression::StrContains { values } => handle_str_op(
521            values,
522            move |ctx: &Context| getter(ctx).map(|v| &v.0),
523            case_insensitive,
524            |v, entry| v.contains(entry),
525        ),
526        ConstraintExpression::StrStartsWith { values } => handle_str_op(
527            values,
528            move |ctx: &Context| getter(ctx).map(|v| &v.0),
529            case_insensitive,
530            |v, entry| v.starts_with(entry),
531        ),
532        ConstraintExpression::StrEndsWith { values } => handle_str_op(
533            values,
534            move |ctx: &Context| getter(ctx).map(|v| &v.0),
535            case_insensitive,
536            |v, entry| v.ends_with(entry),
537        ),
538        _ => Box::new(|_| false),
539    };
540    Box::new(move |context: &Context| apply_invert(compiled_fn(context)))
541}
542
543fn _apply_invert(inverted: bool) -> impl Fn(bool) -> bool + Clone {
544    move |state| {
545        if inverted {
546            !state
547        } else {
548            state
549        }
550    }
551}
552
553fn _compile_constraints(constraints: Vec<Constraint>) -> Vec<Evaluate> {
554    constraints
555        .into_iter()
556        .map(|constraint| {
557            let (context_name, expression, inverted, case_insensitive) = (
558                constraint.context_name,
559                constraint.expression,
560                constraint.inverted,
561                constraint.case_insensitive,
562            );
563            let apply_invert = _apply_invert(inverted);
564
565            match context_name.as_str() {
566                "appName" => _compile_constraint_string(
567                    expression,
568                    apply_invert,
569                    case_insensitive,
570                    |context| Some(&context.app_name),
571                ),
572                "environment" => _compile_constraint_string(
573                    expression,
574                    apply_invert,
575                    case_insensitive,
576                    |context| Some(&context.environment),
577                ),
578                "remoteAddress" => _compile_constraint_host(
579                    expression,
580                    apply_invert,
581                    case_insensitive,
582                    |context| context.remote_address.as_ref(),
583                ),
584                "sessionId" => _compile_constraint_string(
585                    expression,
586                    apply_invert,
587                    case_insensitive,
588                    |context| context.session_id.as_ref(),
589                ),
590                "userId" => _compile_constraint_string(
591                    expression,
592                    apply_invert,
593                    case_insensitive,
594                    |context| context.user_id.as_ref(),
595                ),
596                "currentTime" => _compile_constraint_date(expression, apply_invert, |context| {
597                    context.current_time.as_ref()
598                }),
599                _ => _compile_constraint_string(
600                    expression,
601                    apply_invert,
602                    case_insensitive,
603                    move |context| context.properties.get(&context_name),
604                ),
605            }
606        })
607        .collect()
608}
609
610/// This function is a strategy decorator which compiles to nothing when
611/// there are no constraints, or to a constraint evaluating test if there are.
612pub fn constrain<S: Fn(Option<HashMap<String, String>>) -> Evaluate + Sync + Send + 'static>(
613    constraints: Option<Vec<Constraint>>,
614    strategy: &S,
615    parameters: Option<HashMap<String, String>>,
616) -> Evaluate {
617    let compiled_strategy = strategy(parameters);
618    match constraints {
619        None => {
620            trace!("constrain: no constraints, bypassing");
621            compiled_strategy
622        }
623        Some(constraints) => {
624            if constraints.is_empty() {
625                trace!("constrain: empty constraints list, bypassing");
626                compiled_strategy
627            } else {
628                trace!("constrain: compiling constraints list {constraints:?}");
629                let constraints = _compile_constraints(constraints);
630                // Create a closure that will evaluate against the context.
631                Box::new(move |context| {
632                    // Check every constraint; if all match, permit
633                    for constraint in &constraints {
634                        if !constraint(context) {
635                            return false;
636                        }
637                    }
638                    compiled_strategy(context)
639                })
640            }
641        }
642    }
643}
644
645fn _parse_ip(ip: &str) -> Result<IpNet, std::net::AddrParseError> {
646    ip.parse::<IpNet>()
647        .or_else(|_| ip.parse::<IpAddr>().map(|addr| addr.into()))
648}
649
650#[cfg(test)]
651mod tests {
652    use std::default::Default;
653    use std::{collections::hash_map::HashMap, str::FromStr};
654
655    use chrono::{DateTime, FixedOffset, TimeDelta, Utc};
656    use maplit::hashmap;
657    use semver::Version;
658
659    use crate::api::{Constraint, ConstraintExpression};
660    use crate::context::{Context, IPAddress};
661
662    fn parse_ip(addr: &str) -> Option<IPAddress> {
663        Some(IPAddress(addr.parse().unwrap()))
664    }
665
666    fn default_constraint() -> Constraint {
667        Constraint {
668            context_name: "".into(),
669            case_insensitive: false,
670            inverted: false,
671            expression: ConstraintExpression::In { values: vec![] },
672        }
673    }
674
675    #[test]
676    fn test_constrain_general() {
677        // Without constraints, things should just pass through
678        let context = Context::default();
679        assert!(super::constrain(None, &super::default, None)(&context));
680
681        // An empty constraint list acts like a missing one
682        let context = Context::default();
683        assert!(super::constrain(Some(vec![]), &super::default, None)(
684            &context
685        ));
686    }
687
688    #[test]
689    fn test_constrain_with_in_constraints() {
690        // An empty constraint gets disabled
691        let context = Context {
692            environment: "development".into(),
693            ..Default::default()
694        };
695        assert!(!super::constrain(
696            Some(vec![Constraint {
697                context_name: "".into(),
698                expression: ConstraintExpression::In { values: vec![] },
699                ..default_constraint()
700            }]),
701            &super::default,
702            None
703        )(&context));
704
705        // A missing field in context for NotIn delegates
706        let context = Context::default();
707        assert!(super::constrain(
708            Some(vec![Constraint {
709                context_name: "customFieldMissing".into(),
710                expression: ConstraintExpression::NotIn {
711                    values: vec!["s1".into()]
712                },
713                ..default_constraint()
714            }]),
715            &super::default,
716            None
717        )(&context));
718
719        // A mismatched constraint acts like an empty constraint
720        let context = Context {
721            environment: "production".into(),
722            ..Default::default()
723        };
724        assert!(!super::constrain(
725            Some(vec![Constraint {
726                context_name: "environment".into(),
727                expression: ConstraintExpression::In {
728                    values: vec!["development".into()]
729                },
730                ..default_constraint()
731            }]),
732            &super::default,
733            None
734        )(&context));
735
736        // a matched Not In acts like an empty constraint
737        let context = Context {
738            environment: "development".into(),
739            ..Default::default()
740        };
741        assert!(!super::constrain(
742            Some(vec![Constraint {
743                context_name: "environment".into(),
744                expression: ConstraintExpression::NotIn {
745                    values: vec!["development".into()]
746                },
747                ..default_constraint()
748            }]),
749            &super::default,
750            None
751        )(&context));
752
753        // a matched In in either first or second (etc) places delegates
754        let context = Context {
755            environment: "development".into(),
756            ..Default::default()
757        };
758        assert!(super::constrain(
759            Some(vec![Constraint {
760                context_name: "environment".into(),
761                expression: ConstraintExpression::In {
762                    values: vec!["development".into()]
763                },
764                ..default_constraint()
765            }]),
766            &super::default,
767            None
768        )(&context));
769
770        // inverted
771        assert!(!super::constrain(
772            Some(vec![Constraint {
773                context_name: "environment".into(),
774                expression: ConstraintExpression::In {
775                    values: vec!["development".into()]
776                },
777                inverted: true,
778                ..default_constraint()
779            }]),
780            &super::default,
781            None
782        )(&context));
783
784        // second place
785        let context = Context {
786            environment: "development".into(),
787            ..Default::default()
788        };
789        assert!(super::constrain(
790            Some(vec![Constraint {
791                context_name: "environment".into(),
792                expression: ConstraintExpression::In {
793                    values: vec!["staging".into(), "development".into()]
794                },
795                ..default_constraint()
796            }]),
797            &super::default,
798            None
799        )(&context));
800
801        // a not matched Not In across 1st and second etc delegates
802        let context = Context {
803            environment: "production".into(),
804            ..Default::default()
805        };
806        assert!(super::constrain(
807            Some(vec![Constraint {
808                context_name: "environment".into(),
809                expression: ConstraintExpression::NotIn {
810                    values: vec!["staging".into(), "development".into()]
811                },
812                ..default_constraint()
813            }]),
814            &super::default,
815            None
816        )(&context));
817
818        // inverted
819        assert!(!super::constrain(
820            Some(vec![Constraint {
821                context_name: "environment".into(),
822                expression: ConstraintExpression::NotIn {
823                    values: vec!["staging".into(), "development".into()]
824                },
825                inverted: true,
826                ..default_constraint()
827            }]),
828            &super::default,
829            None
830        )(&context));
831
832        // Context keys can be chosen by the context_name field:
833        // .environment is used above.
834        // .user_id
835        let context = Context {
836            user_id: Some("fred".into()),
837            ..Default::default()
838        };
839        assert!(super::constrain(
840            Some(vec![Constraint {
841                context_name: "userId".into(),
842                expression: ConstraintExpression::In {
843                    values: vec!["fred".into()]
844                },
845                ..default_constraint()
846            }]),
847            &super::default,
848            None
849        )(&context));
850
851        // .session_id
852        let context = Context {
853            session_id: Some("qwerty".into()),
854            ..Default::default()
855        };
856        assert!(super::constrain(
857            Some(vec![Constraint {
858                context_name: "sessionId".into(),
859                expression: ConstraintExpression::In {
860                    values: vec!["qwerty".into()]
861                },
862                ..default_constraint()
863            }]),
864            &super::default,
865            None
866        )(&context));
867
868        // .remote_address
869        let context = Context {
870            remote_address: parse_ip("10.20.30.40"),
871            ..Default::default()
872        };
873        assert!(super::constrain(
874            Some(vec![Constraint {
875                context_name: "remoteAddress".into(),
876                expression: ConstraintExpression::In {
877                    values: vec!["10.0.0.0/8".into()]
878                },
879                ..default_constraint()
880            }]),
881            &super::default,
882            None
883        )(&context));
884        let context = Context {
885            remote_address: parse_ip("1.2.3.4"),
886            ..Default::default()
887        };
888        assert!(super::constrain(
889            Some(vec![Constraint {
890                context_name: "remoteAddress".into(),
891                expression: ConstraintExpression::NotIn {
892                    values: vec!["10.0.0.0/8".into()]
893                },
894                ..default_constraint()
895            }]),
896            &super::default,
897            None
898        )(&context));
899
900        // multiple constraints are ANDed together
901        let context = Context {
902            environment: "development".into(),
903            ..Default::default()
904        };
905        // true ^ true => true
906        assert!(super::constrain(
907            Some(vec![
908                Constraint {
909                    context_name: "environment".into(),
910                    expression: ConstraintExpression::In {
911                        values: vec!["development".into()]
912                    },
913                    ..default_constraint()
914                },
915                Constraint {
916                    context_name: "environment".into(),
917                    expression: ConstraintExpression::In {
918                        values: vec!["development".into()]
919                    },
920                    ..default_constraint()
921                },
922            ]),
923            &super::default,
924            None
925        )(&context));
926
927        assert!(!super::constrain(
928            Some(vec![
929                Constraint {
930                    context_name: "environment".into(),
931                    expression: ConstraintExpression::In {
932                        values: vec!["development".into()]
933                    },
934                    ..default_constraint()
935                },
936                Constraint {
937                    context_name: "environment".into(),
938                    expression: ConstraintExpression::In { values: vec![] },
939                    ..default_constraint()
940                }
941            ]),
942            &super::default,
943            None
944        )(&context));
945    }
946
947    #[test]
948    fn test_constrain_with_date_constraints() {
949        let now = Utc::now();
950        let context = Context {
951            current_time: Some(now),
952            ..Default::default()
953        };
954        assert!(!super::constrain(
955            Some(vec![Constraint {
956                context_name: "currentTime".into(),
957                expression: ConstraintExpression::DateBefore {
958                    value: Utc::now() - TimeDelta::seconds(30)
959                },
960                ..default_constraint()
961            }]),
962            &super::default,
963            None
964        )(&context));
965
966        assert!(super::constrain(
967            Some(vec![Constraint {
968                context_name: "currentTime".into(),
969                expression: ConstraintExpression::DateAfter {
970                    value: Utc::now() - TimeDelta::seconds(30)
971                },
972                ..default_constraint()
973            }]),
974            &super::default,
975            None
976        )(&context));
977
978        // inverted
979        assert!(!super::constrain(
980            Some(vec![Constraint {
981                context_name: "currentTime".into(),
982                expression: ConstraintExpression::DateAfter {
983                    value: Utc::now() - TimeDelta::seconds(30)
984                },
985                inverted: true,
986                ..default_constraint()
987            }]),
988            &super::default,
989            None
990        )(&context));
991
992        let context = Context {
993            current_time: DateTime::<FixedOffset>::parse_from_rfc3339("2024-07-18T17:18:25.844Z")
994                .ok()
995                .map(|date| date.to_utc()),
996            ..Default::default()
997        };
998
999        assert!(super::constrain(
1000            Some(vec![Constraint {
1001                context_name: "currentTime".into(),
1002                expression: ConstraintExpression::DateBefore { value: Utc::now() },
1003                ..default_constraint()
1004            }]),
1005            &super::default,
1006            None
1007        )(&context));
1008
1009        // inverted
1010        assert!(!super::constrain(
1011            Some(vec![Constraint {
1012                context_name: "currentTime".into(),
1013                expression: ConstraintExpression::DateBefore { value: Utc::now() },
1014                inverted: true,
1015                ..default_constraint()
1016            }]),
1017            &super::default,
1018            None
1019        )(&context));
1020
1021        assert!(!super::constrain(
1022            Some(vec![Constraint {
1023                context_name: "currentTime".into(),
1024                expression: ConstraintExpression::DateAfter { value: Utc::now() },
1025                ..default_constraint()
1026            }]),
1027            &super::default,
1028            None
1029        )(&context));
1030
1031        // date comparison only works for currentTime
1032        assert!(!super::constrain(
1033            Some(vec![Constraint {
1034                context_name: "environment".into(),
1035                expression: ConstraintExpression::DateBefore { value: Utc::now() },
1036                ..default_constraint()
1037            }]),
1038            &super::default,
1039            None
1040        )(&context));
1041    }
1042
1043    #[test]
1044    fn test_constrain_with_semver_constraints() {
1045        let context = Context {
1046            properties: hashmap! {
1047                "version".into() => "1.2.3-rc.2".into()
1048            },
1049            ..Default::default()
1050        };
1051        assert!(super::constrain(
1052            Some(vec![Constraint {
1053                context_name: "version".into(),
1054                expression: ConstraintExpression::SemverLT {
1055                    value: Version::from_str("1.2.3").unwrap()
1056                },
1057                ..default_constraint()
1058            }]),
1059            &super::default,
1060            None
1061        )(&context));
1062
1063        assert!(super::constrain(
1064            Some(vec![Constraint {
1065                context_name: "version".into(),
1066                expression: ConstraintExpression::SemverGT {
1067                    value: Version::from_str("1.2.2").unwrap()
1068                },
1069                ..default_constraint()
1070            }]),
1071            &super::default,
1072            None
1073        )(&context));
1074
1075        assert!(super::constrain(
1076            Some(vec![Constraint {
1077                context_name: "version".into(),
1078                expression: ConstraintExpression::SemverEq {
1079                    value: Version::from_str("1.2.3-rc.2").unwrap()
1080                },
1081                ..default_constraint()
1082            }]),
1083            &super::default,
1084            None
1085        )(&context));
1086
1087        let context = Context {
1088            properties: hashmap! {
1089                "app_version".into() => "1.0.0-alpha.1".into()
1090            },
1091            ..Default::default()
1092        };
1093
1094        assert!(!super::constrain(
1095            Some(vec![Constraint {
1096                context_name: "app_version".into(),
1097                expression: ConstraintExpression::SemverLT {
1098                    value: Version::from_str("0.155.0").unwrap()
1099                },
1100                ..default_constraint()
1101            }]),
1102            &super::default,
1103            None
1104        )(&context));
1105
1106        // inverted
1107        assert!(super::constrain(
1108            Some(vec![Constraint {
1109                context_name: "app_version".into(),
1110                expression: ConstraintExpression::SemverLT {
1111                    value: Version::from_str("0.155.0").unwrap()
1112                },
1113                inverted: true,
1114                ..default_constraint()
1115            }]),
1116            &super::default,
1117            None
1118        )(&context));
1119
1120        assert!(!super::constrain(
1121            Some(vec![Constraint {
1122                context_name: "app_version".into(),
1123                expression: ConstraintExpression::SemverGT {
1124                    value: Version::from_str("1.0.0-beta.1").unwrap()
1125                },
1126                ..default_constraint()
1127            }]),
1128            &super::default,
1129            None
1130        )(&context));
1131
1132        // inverted
1133        assert!(super::constrain(
1134            Some(vec![Constraint {
1135                context_name: "app_version".into(),
1136                expression: ConstraintExpression::SemverGT {
1137                    value: Version::from_str("1.0.0-beta.1").unwrap()
1138                },
1139                inverted: true,
1140                ..default_constraint()
1141            }]),
1142            &super::default,
1143            None
1144        )(&context));
1145
1146        assert!(!super::constrain(
1147            Some(vec![Constraint {
1148                context_name: "app_version".into(),
1149                expression: ConstraintExpression::SemverEq {
1150                    value: Version::from_str("1.0.0-beta.10").unwrap()
1151                },
1152                ..default_constraint()
1153            }]),
1154            &super::default,
1155            None
1156        )(&context));
1157
1158        // inverted
1159        assert!(super::constrain(
1160            Some(vec![Constraint {
1161                context_name: "app_version".into(),
1162                expression: ConstraintExpression::SemverEq {
1163                    value: Version::from_str("1.0.0-beta.10").unwrap()
1164                },
1165                inverted: true,
1166                ..default_constraint()
1167            }]),
1168            &super::default,
1169            None
1170        )(&context));
1171    }
1172
1173    #[test]
1174    fn test_constrain_with_str_constraints() {
1175        let context = Context {
1176            app_name: "gondola".into(),
1177            ..Default::default()
1178        };
1179
1180        // contains
1181        assert!(super::constrain(
1182            Some(vec![Constraint {
1183                context_name: "appName".into(),
1184                expression: ConstraintExpression::StrContains {
1185                    values: vec!["ondo".into(), "gigabad".into()]
1186                },
1187                ..default_constraint()
1188            }]),
1189            &super::default,
1190            None
1191        )(&context));
1192
1193        assert!(!super::constrain(
1194            Some(vec![Constraint {
1195                context_name: "appName".into(),
1196                expression: ConstraintExpression::StrContains {
1197                    values: vec!["Ondo".into(), "gigabad".into()]
1198                },
1199                ..default_constraint()
1200            }]),
1201            &super::default,
1202            None
1203        )(&context));
1204
1205        // inverted
1206        assert!(super::constrain(
1207            Some(vec![Constraint {
1208                context_name: "appName".into(),
1209                expression: ConstraintExpression::StrContains {
1210                    values: vec!["Ondo".into(), "gigabad".into()]
1211                },
1212                inverted: true,
1213                ..default_constraint()
1214            }]),
1215            &super::default,
1216            None
1217        )(&context));
1218
1219        // case insensitive
1220        assert!(super::constrain(
1221            Some(vec![Constraint {
1222                context_name: "appName".into(),
1223                expression: ConstraintExpression::StrContains {
1224                    values: vec!["Ondo".into(), "gigabad".into()]
1225                },
1226                case_insensitive: true,
1227                ..default_constraint()
1228            }]),
1229            &super::default,
1230            None
1231        )(&context));
1232
1233        assert!(super::constrain(
1234            Some(vec![Constraint {
1235                context_name: "appName".into(),
1236                expression: ConstraintExpression::StrStartsWith {
1237                    values: vec!["and".into(), "gon".into()]
1238                },
1239                ..default_constraint()
1240            }]),
1241            &super::default,
1242            None
1243        )(&context));
1244
1245        assert!(!super::constrain(
1246            Some(vec![Constraint {
1247                context_name: "appName".into(),
1248                expression: ConstraintExpression::StrStartsWith {
1249                    values: vec!["and".into(), "Gon".into()]
1250                },
1251                ..default_constraint()
1252            }]),
1253            &super::default,
1254            None
1255        )(&context));
1256
1257        // inverted
1258        assert!(super::constrain(
1259            Some(vec![Constraint {
1260                context_name: "appName".into(),
1261                expression: ConstraintExpression::StrStartsWith {
1262                    values: vec!["and".into(), "Gon".into()]
1263                },
1264                inverted: true,
1265                ..default_constraint()
1266            }]),
1267            &super::default,
1268            None
1269        )(&context));
1270
1271        // case insensitive
1272        assert!(super::constrain(
1273            Some(vec![Constraint {
1274                context_name: "appName".into(),
1275                expression: ConstraintExpression::StrStartsWith {
1276                    values: vec!["and".into(), "Gon".into()]
1277                },
1278                case_insensitive: true,
1279                ..default_constraint()
1280            }]),
1281            &super::default,
1282            None
1283        )(&context));
1284
1285        assert!(super::constrain(
1286            Some(vec![Constraint {
1287                context_name: "appName".into(),
1288                expression: ConstraintExpression::StrEndsWith {
1289                    values: vec!["ola".into(), "oga".into()]
1290                },
1291                ..default_constraint()
1292            }]),
1293            &super::default,
1294            None
1295        )(&context));
1296
1297        assert!(!super::constrain(
1298            Some(vec![Constraint {
1299                context_name: "appName".into(),
1300                expression: ConstraintExpression::StrEndsWith {
1301                    values: vec!["Ola".into(), "Oga".into()]
1302                },
1303                ..default_constraint()
1304            }]),
1305            &super::default,
1306            None
1307        )(&context));
1308
1309        // inverted
1310        assert!(super::constrain(
1311            Some(vec![Constraint {
1312                context_name: "appName".into(),
1313                expression: ConstraintExpression::StrEndsWith {
1314                    values: vec!["Ola".into(), "Oga".into()]
1315                },
1316                inverted: true,
1317                ..default_constraint()
1318            }]),
1319            &super::default,
1320            None
1321        )(&context));
1322
1323        // case insensitive
1324        assert!(super::constrain(
1325            Some(vec![Constraint {
1326                context_name: "appName".into(),
1327                expression: ConstraintExpression::StrEndsWith {
1328                    values: vec!["Ola".into(), "Oga".into()]
1329                },
1330                case_insensitive: true,
1331                ..default_constraint()
1332            }]),
1333            &super::default,
1334            None
1335        )(&context));
1336    }
1337
1338    #[test]
1339    fn test_constrain_with_num_constraints() {
1340        let context = Context {
1341            properties: hashmap! {
1342                "times".into() => "30".into()
1343            },
1344            ..Default::default()
1345        };
1346
1347        assert!(super::constrain(
1348            Some(vec![Constraint {
1349                context_name: "times".into(),
1350                expression: ConstraintExpression::NumEq { value: 30.0 },
1351                ..default_constraint()
1352            }]),
1353            &super::default,
1354            None
1355        )(&context));
1356
1357        assert!(super::constrain(
1358            Some(vec![Constraint {
1359                context_name: "times".into(),
1360                expression: ConstraintExpression::NumLT { value: 31.0 },
1361                ..default_constraint()
1362            }]),
1363            &super::default,
1364            None
1365        )(&context));
1366
1367        assert!(super::constrain(
1368            Some(vec![Constraint {
1369                context_name: "times".into(),
1370                expression: ConstraintExpression::NumLTE { value: 40.0 },
1371                ..default_constraint()
1372            }]),
1373            &super::default,
1374            None
1375        )(&context));
1376
1377        assert!(super::constrain(
1378            Some(vec![Constraint {
1379                context_name: "times".into(),
1380                expression: ConstraintExpression::NumGT { value: 29.0 },
1381                ..default_constraint()
1382            }]),
1383            &super::default,
1384            None
1385        )(&context));
1386
1387        assert!(super::constrain(
1388            Some(vec![Constraint {
1389                context_name: "times".into(),
1390                expression: ConstraintExpression::NumGTE { value: 30.0 },
1391                ..default_constraint()
1392            }]),
1393            &super::default,
1394            None
1395        )(&context));
1396
1397        // inverted
1398        assert!(!super::constrain(
1399            Some(vec![Constraint {
1400                context_name: "times".into(),
1401                expression: ConstraintExpression::NumEq { value: 30.0 },
1402                inverted: true,
1403                ..default_constraint()
1404            }]),
1405            &super::default,
1406            None
1407        )(&context));
1408
1409        assert!(!super::constrain(
1410            Some(vec![Constraint {
1411                context_name: "times".into(),
1412                expression: ConstraintExpression::NumLT { value: 31.0 },
1413                inverted: true,
1414                ..default_constraint()
1415            }]),
1416            &super::default,
1417            None
1418        )(&context));
1419
1420        assert!(!super::constrain(
1421            Some(vec![Constraint {
1422                context_name: "times".into(),
1423                expression: ConstraintExpression::NumLTE { value: 40.0 },
1424                inverted: true,
1425                ..default_constraint()
1426            }]),
1427            &super::default,
1428            None
1429        )(&context));
1430
1431        assert!(!super::constrain(
1432            Some(vec![Constraint {
1433                context_name: "times".into(),
1434                expression: ConstraintExpression::NumGT { value: 29.0 },
1435                inverted: true,
1436                ..default_constraint()
1437            }]),
1438            &super::default,
1439            None
1440        )(&context));
1441
1442        assert!(!super::constrain(
1443            Some(vec![Constraint {
1444                context_name: "times".into(),
1445                expression: ConstraintExpression::NumGTE { value: 30.0 },
1446                inverted: true,
1447                ..default_constraint()
1448            }]),
1449            &super::default,
1450            None
1451        )(&context));
1452    }
1453
1454    #[test]
1455    fn test_user_with_id() {
1456        let params: HashMap<String, String> = hashmap! {
1457            "userIds".into() => "fred,barney".into(),
1458        };
1459        assert!(super::user_with_id(Some(params.clone()))(&Context {
1460            user_id: Some("fred".into()),
1461            ..Default::default()
1462        }));
1463        assert!(super::user_with_id(Some(params.clone()))(&Context {
1464            user_id: Some("barney".into()),
1465            ..Default::default()
1466        }));
1467        assert!(!super::user_with_id(Some(params))(&Context {
1468            user_id: Some("betty".into()),
1469            ..Default::default()
1470        }));
1471    }
1472
1473    #[test]
1474    fn test_flexible_rollout() {
1475        // random
1476        let params: HashMap<String, String> = hashmap! {
1477            "stickiness".into() => "random".into(),
1478            "rollout".into() => "0".into(),
1479        };
1480        let c: Context = Default::default();
1481        assert!(!super::flexible_rollout(Some(params))(&c));
1482
1483        let params: HashMap<String, String> = hashmap! {
1484            "stickiness".into() => "random".into(),
1485            "rollout".into() => "100".into(),
1486        };
1487        let c: Context = Default::default();
1488        assert!(super::flexible_rollout(Some(params))(&c));
1489        let params: HashMap<String, String> = hashmap! {
1490            "stickiness".into() => "random".into(),
1491            "rollout".into() => "0".into(),
1492        };
1493        let c: Context = Default::default();
1494        assert!(!super::flexible_rollout(Some(params))(&c));
1495
1496        // Could parameterise this by SESSION and USER, but its barely long
1497        // enough to bother and the explicitness in failures has merit.
1498        // sessionId
1499        let params: HashMap<String, String> = hashmap! {
1500            "stickiness".into() => "sessionId".into(),
1501            "groupId".into() => "group1".into(),
1502            "rollout".into() => "0".into(),
1503        };
1504        let c: Context = Context {
1505            session_id: Some("session1".into()),
1506            ..Default::default()
1507        };
1508        assert!(!super::flexible_rollout(Some(params))(&c));
1509        let params: HashMap<String, String> = hashmap! {
1510            "stickiness".into() => "sessionId".into(),
1511            "groupId".into() => "group1".into(),
1512            "rollout".into() => "100".into(),
1513        };
1514        let c: Context = Context {
1515            session_id: Some("session1".into()),
1516            ..Default::default()
1517        };
1518        assert!(super::flexible_rollout(Some(params))(&c));
1519        // Check rollout works
1520        let params: HashMap<String, String> = hashmap! {
1521            "stickiness".into() => "sessionId".into(),
1522            "groupId".into() => "group1".into(),
1523            "rollout".into() => "50".into(),
1524        };
1525        let c: Context = Context {
1526            session_id: Some("session1".into()),
1527            ..Default::default()
1528        };
1529        assert!(super::flexible_rollout(Some(params.clone()))(&c));
1530        let c: Context = Context {
1531            session_id: Some("session2".into()),
1532            ..Default::default()
1533        };
1534        assert!(!super::flexible_rollout(Some(params))(&c));
1535        // Check groupId modifies the hash order
1536        let params: HashMap<String, String> = hashmap! {
1537            "stickiness".into() => "sessionId".into(),
1538            "groupId".into() => "group3".into(),
1539            "rollout".into() => "50".into(),
1540        };
1541        let c: Context = Context {
1542            session_id: Some("session1".into()),
1543            ..Default::default()
1544        };
1545        assert!(!super::flexible_rollout(Some(params.clone()))(&c));
1546        let c: Context = Context {
1547            session_id: Some("session2".into()),
1548            ..Default::default()
1549        };
1550        assert!(super::flexible_rollout(Some(params))(&c));
1551
1552        // userId
1553        let params: HashMap<String, String> = hashmap! {
1554            "stickiness".into() => "userId".into(),
1555            "groupId".into() => "group1".into(),
1556            "rollout".into() => "0".into(),
1557        };
1558        let c: Context = Context {
1559            user_id: Some("user1".into()),
1560            ..Default::default()
1561        };
1562        assert!(!super::flexible_rollout(Some(params))(&c));
1563        let params: HashMap<String, String> = hashmap! {
1564            "stickiness".into() => "userId".into(),
1565            "groupId".into() => "group1".into(),
1566            "rollout".into() => "100".into(),
1567        };
1568        let c: Context = Context {
1569            user_id: Some("user1".into()),
1570            ..Default::default()
1571        };
1572        assert!(super::flexible_rollout(Some(params))(&c));
1573        // Check rollout works
1574        let params: HashMap<String, String> = hashmap! {
1575            "stickiness".into() => "userId".into(),
1576            "groupId".into() => "group1".into(),
1577            "rollout".into() => "50".into(),
1578        };
1579        let c: Context = Context {
1580            user_id: Some("user1".into()),
1581            ..Default::default()
1582        };
1583        assert!(super::flexible_rollout(Some(params.clone()))(&c));
1584        let c: Context = Context {
1585            user_id: Some("user3".into()),
1586            ..Default::default()
1587        };
1588        assert!(!super::flexible_rollout(Some(params))(&c));
1589        // Check groupId modifies the hash order
1590        let params: HashMap<String, String> = hashmap! {
1591            "stickiness".into() => "userId".into(),
1592            "groupId".into() => "group2".into(),
1593            "rollout".into() => "50".into(),
1594        };
1595        let c: Context = Context {
1596            user_id: Some("user3".into()),
1597            ..Default::default()
1598        };
1599        assert!(!super::flexible_rollout(Some(params.clone()))(&c));
1600        let c: Context = Context {
1601            user_id: Some("user1".into()),
1602            ..Default::default()
1603        };
1604        assert!(super::flexible_rollout(Some(params))(&c));
1605    }
1606
1607    #[test]
1608    fn test_random() {
1609        let params: HashMap<String, String> = hashmap! {
1610            "percentage".into() => "0".into()
1611        };
1612        let c: Context = Default::default();
1613        assert!(!super::random(Some(params))(&c));
1614        let params: HashMap<String, String> = hashmap! {
1615            "percentage".into() => "100".into()
1616        };
1617        let c: Context = Default::default();
1618        assert!(super::random(Some(params))(&c));
1619    }
1620
1621    #[test]
1622    fn test_remote_address() {
1623        let params: HashMap<String, String> = hashmap! {
1624            "IPs".into() => "1.2.0.0/8,2.3.4.5,2222:FF:0:1234::/64".into()
1625        };
1626        let c: Context = Context {
1627            remote_address: parse_ip("1.2.3.4"),
1628            ..Default::default()
1629        };
1630        assert!(super::remote_address(Some(params.clone()))(&c));
1631        let c: Context = Context {
1632            remote_address: parse_ip("2.3.4.5"),
1633            ..Default::default()
1634        };
1635        assert!(super::remote_address(Some(params.clone()))(&c));
1636        let c: Context = Context {
1637            remote_address: parse_ip("2222:FF:0:1234::FDEC"),
1638            ..Default::default()
1639        };
1640        assert!(super::remote_address(Some(params.clone()))(&c));
1641        let c: Context = Context {
1642            remote_address: parse_ip("2.3.4.4"),
1643            ..Default::default()
1644        };
1645        assert!(!super::remote_address(Some(params))(&c));
1646    }
1647
1648    #[test]
1649    fn test_hostname() {
1650        let c: Context = Default::default();
1651        let this_hostname = hostname::get().unwrap().into_string().unwrap();
1652        let params: HashMap<String, String> = hashmap! {
1653            "hostNames".into() => format!("foo,{},bar", this_hostname)
1654        };
1655        assert!(super::hostname(Some(params))(&c));
1656        let params: HashMap<String, String> = hashmap! {
1657            "hostNames".into() => "foo,bar".into()
1658        };
1659        assert!(!super::hostname(Some(params))(&c));
1660    }
1661
1662    #[test]
1663    fn normalised_hash() {
1664        assert!(50 > super::normalised_hash("AB12A", "122", 100).unwrap());
1665    }
1666
1667    #[test]
1668    fn test_normalized_hash() {
1669        assert_eq!(73, super::normalised_hash("gr1", "123", 100).unwrap());
1670        assert_eq!(25, super::normalised_hash("groupX", "999", 100).unwrap());
1671    }
1672
1673    #[test]
1674    fn test_normalised_variant_hash() {
1675        assert_eq!(
1676            96,
1677            super::normalised_variant_hash("gr1", "123", 100).unwrap()
1678        );
1679        assert_eq!(
1680            60,
1681            super::normalised_variant_hash("groupX", "999", 100).unwrap()
1682        );
1683    }
1684}