gsm_testutil/
lib.rs

1use anyhow::{Context, Result, anyhow};
2use jsonschema::{Validator, validator_for};
3use once_cell::sync::Lazy;
4use serde::Serialize;
5use serde_json::Value;
6use std::collections::HashMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::sync::{Arc, Mutex};
10
11#[cfg(feature = "e2e")]
12mod assertions;
13#[cfg(feature = "e2e")]
14pub mod e2e;
15#[cfg(feature = "e2e")]
16pub mod secrets;
17#[cfg(feature = "visual")]
18pub mod visual;
19
20#[derive(Debug, Clone)]
21pub struct TestConfig {
22    pub platform: String,
23    pub env: Option<String>,
24    pub tenant: Option<String>,
25    pub team: Option<String>,
26    pub credentials: Option<Value>,
27    pub secret_uri: Option<String>,
28}
29
30impl TestConfig {
31    pub fn from_env_or_secrets(platform: &str) -> Result<Option<Self>> {
32        // Load .env files when present so local credentials become available in tests.
33        dotenvy::dotenv().ok();
34
35        let env = std::env::var("GREENTIC_ENV").ok();
36        let tenant = std::env::var("TENANT").ok();
37        let team = std::env::var("TEAM").ok();
38        let upper = platform.to_ascii_uppercase();
39
40        if let Some(credentials) = load_env_credentials(&upper)? {
41            let secret_uri =
42                build_secret_uri(env.as_deref(), tenant.as_deref(), team.as_deref(), platform);
43            return Ok(Some(Self {
44                platform: platform.to_string(),
45                env,
46                tenant,
47                team,
48                credentials: Some(credentials),
49                secret_uri,
50            }));
51        }
52
53        if let Some(credentials) =
54            load_secret_credentials(platform, env.as_deref(), tenant.as_deref(), team.as_deref())?
55        {
56            let secret_uri =
57                build_secret_uri(env.as_deref(), tenant.as_deref(), team.as_deref(), platform);
58            return Ok(Some(Self {
59                platform: platform.to_string(),
60                env,
61                tenant,
62                team,
63                credentials: Some(credentials),
64                secret_uri,
65            }));
66        }
67
68        Ok(None)
69    }
70}
71
72fn load_env_credentials(key: &str) -> Result<Option<Value>> {
73    let var = format!("MESSAGING_{key}_CREDENTIALS");
74    if let Ok(raw) = std::env::var(&var) {
75        if raw.trim().is_empty() {
76            return Ok(None);
77        }
78        let json = serde_json::from_str(&raw).with_context(|| format!("failed to parse {var}"))?;
79        return Ok(Some(json));
80    }
81
82    let path_var = format!("MESSAGING_{key}_CREDENTIALS_PATH");
83    if let Ok(path) = std::env::var(&path_var) {
84        let content =
85            fs::read_to_string(&path).with_context(|| format!("failed to read {path}"))?;
86        let json =
87            serde_json::from_str(&content).with_context(|| format!("failed to parse {path}"))?;
88        return Ok(Some(json));
89    }
90
91    Ok(None)
92}
93
94fn load_secret_credentials(
95    platform: &str,
96    env: Option<&str>,
97    tenant: Option<&str>,
98    team: Option<&str>,
99) -> Result<Option<Value>> {
100    let env = match env {
101        Some(value) => value,
102        None => return Ok(None),
103    };
104    let tenant = match tenant {
105        Some(value) => value,
106        None => return Ok(None),
107    };
108    let team = team.unwrap_or("default");
109
110    let root =
111        match std::env::var("GREENTIC_SECRETS_DIR").or_else(|_| std::env::var("SECRETS_ROOT")) {
112            Ok(value) => value,
113            Err(_) => return Ok(None),
114        };
115
116    let file = Path::new(&root)
117        .join(env)
118        .join(tenant)
119        .join(team)
120        .join("messaging")
121        .join(format!("{platform}-{team}-credentials.json"));
122
123    if !file.exists() {
124        return Ok(None);
125    }
126
127    let content =
128        fs::read_to_string(&file).with_context(|| format!("failed to read {}", file.display()))?;
129    let json = serde_json::from_str(&content)
130        .with_context(|| format!("failed to parse {}", file.display()))?;
131    Ok(Some(json))
132}
133
134fn build_secret_uri(
135    env: Option<&str>,
136    tenant: Option<&str>,
137    team: Option<&str>,
138    platform: &str,
139) -> Option<String> {
140    let env = env?;
141    let tenant = tenant?;
142    let team = team.unwrap_or("default");
143    Some(format!(
144        "secret://{env}/{tenant}/{team}/messaging/{platform}-{team}-credentials.json"
145    ))
146}
147
148pub fn load_card_value(path: &str) -> Result<Value> {
149    let absolute = absolute_path(path)?;
150    let content = fs::read_to_string(&absolute)
151        .with_context(|| format!("failed to read {}", absolute.display()))?;
152    let extension = absolute
153        .extension()
154        .and_then(|ext| ext.to_str())
155        .unwrap_or_default()
156        .to_ascii_lowercase();
157
158    match extension.as_str() {
159        "json" => serde_json::from_str(&content)
160            .with_context(|| format!("failed to parse json {}", absolute.display())),
161        "yaml" | "yml" => {
162            let yaml: serde_yaml_bw::Value = serde_yaml_bw::from_str(&content)
163                .with_context(|| format!("failed to parse yaml {}", absolute.display()))?;
164            serde_json::to_value(yaml)
165                .with_context(|| format!("failed to convert yaml {}", absolute.display()))
166        }
167        other => Err(anyhow!("unsupported fixture extension: {other}")),
168    }
169}
170
171fn absolute_path<P>(path: P) -> Result<PathBuf>
172where
173    P: AsRef<Path>,
174{
175    let relative = path.as_ref();
176    if relative.is_absolute() {
177        return Ok(relative.to_path_buf());
178    }
179
180    let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
181    let mut current = Some(manifest_dir.to_path_buf());
182    while let Some(dir) = current {
183        let candidate = dir.join(relative);
184        if candidate.exists() {
185            let canonical = candidate.canonicalize().unwrap_or(candidate);
186            return Ok(canonical);
187        }
188        current = dir.parent().map(|p| p.to_path_buf());
189    }
190
191    Ok(manifest_dir.join(relative))
192}
193
194pub fn assert_matches_schema<P>(schema_path: P, value: &Value) -> Result<()>
195where
196    P: AsRef<Path>,
197{
198    let compiled = load_compiled_schema(schema_path.as_ref())?;
199
200    let mut errors = compiled.iter_errors(value);
201    if let Some(first) = errors.next() {
202        let mut messages: Vec<String> = Vec::new();
203        messages.push(first.to_string());
204        for err in errors {
205            messages.push(err.to_string());
206        }
207        return Err(anyhow!("schema validation failed: {}", messages.join("; ")));
208    }
209
210    Ok(())
211}
212
213fn load_schema(path: &Path) -> Result<Value> {
214    let absolute = absolute_path(path)?;
215    let content = fs::read_to_string(&absolute)
216        .with_context(|| format!("failed to read {}", absolute.display()))?;
217    serde_json::from_str(&content)
218        .with_context(|| format!("failed to parse json {}", absolute.display()))
219}
220
221fn load_compiled_schema(path: &Path) -> Result<Arc<Validator>> {
222    static CACHE: Lazy<Mutex<HashMap<PathBuf, Arc<Validator>>>> =
223        Lazy::new(|| Mutex::new(HashMap::new()));
224
225    let absolute = absolute_path(path)?;
226
227    {
228        let cache = CACHE.lock().unwrap();
229        if let Some(schema) = cache.get(&absolute) {
230            return Ok(schema.clone());
231        }
232    }
233
234    let schema_value = load_schema(&absolute)?;
235    let compiled = validator_for(&schema_value)
236        .map_err(|err| anyhow!("failed to compile json schema: {err}"))?;
237    let compiled = Arc::new(compiled);
238
239    let mut cache = CACHE.lock().unwrap();
240    let entry = cache.entry(absolute).or_insert_with(|| compiled.clone());
241    Ok(entry.clone())
242}
243
244pub fn to_json_value<T>(value: &T) -> Result<Value>
245where
246    T: Serialize,
247{
248    serde_json::to_value(value).context("failed to convert to json value")
249}
250
251#[macro_export]
252macro_rules! skip_or_require {
253    ($expr:expr $(,)?) => {{
254        match $expr {
255            Ok(Some(value)) => value,
256            Ok(None) => {
257                eprintln!("skipping test: required secrets not available");
258                return;
259            }
260            Err(err) => panic!("failed to load test secrets: {err:?}"),
261        }
262    }};
263    ($expr:expr, $($msg:tt)+) => {{
264        match $expr {
265            Ok(Some(value)) => value,
266            Ok(None) => {
267                eprintln!("skipping test: {}", format!($($msg)+));
268                return;
269            }
270            Err(err) => panic!("failed to load test secrets: {err:?}"),
271        }
272    }};
273}
274
275#[macro_export]
276macro_rules! load_card {
277    ($path:expr $(,)?) => {{
278        $crate::load_card_value($path)
279            .unwrap_or_else(|err| panic!("failed to load card {}: {}", $path, err))
280    }};
281}
282
283#[macro_export]
284macro_rules! assert_snapshot_json {
285    ($name:expr, $value:expr $(,)?) => {{
286        let snapshot_value = $crate::to_json_value(&$value)
287            .unwrap_or_else(|err| panic!("failed to serialise snapshot {}: {}", $name, err));
288        insta::assert_json_snapshot!($name, snapshot_value);
289    }};
290}