gsm_testutil/
lib.rs

1use anyhow::{Context, Result, anyhow};
2use jsonschema::JSONSchema;
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_{}_CREDENTIALS", key);
74    if let Ok(raw) = std::env::var(&var) {
75        if raw.trim().is_empty() {
76            return Ok(None);
77        }
78        let json =
79            serde_json::from_str(&raw).with_context(|| format!("failed to parse {}", var))?;
80        return Ok(Some(json));
81    }
82
83    let path_var = format!("MESSAGING_{}_CREDENTIALS_PATH", key);
84    if let Ok(path) = std::env::var(&path_var) {
85        let content =
86            fs::read_to_string(&path).with_context(|| format!("failed to read {}", path))?;
87        let json =
88            serde_json::from_str(&content).with_context(|| format!("failed to parse {}", path))?;
89        return Ok(Some(json));
90    }
91
92    Ok(None)
93}
94
95fn load_secret_credentials(
96    platform: &str,
97    env: Option<&str>,
98    tenant: Option<&str>,
99    team: Option<&str>,
100) -> Result<Option<Value>> {
101    let env = match env {
102        Some(value) => value,
103        None => return Ok(None),
104    };
105    let tenant = match tenant {
106        Some(value) => value,
107        None => return Ok(None),
108    };
109    let team = team.unwrap_or("default");
110
111    let root =
112        match std::env::var("GREENTIC_SECRETS_DIR").or_else(|_| std::env::var("SECRETS_ROOT")) {
113            Ok(value) => value,
114            Err(_) => return Ok(None),
115        };
116
117    let file = Path::new(&root)
118        .join(env)
119        .join(tenant)
120        .join(team)
121        .join("messaging")
122        .join(format!("{platform}-{team}-credentials.json"));
123
124    if !file.exists() {
125        return Ok(None);
126    }
127
128    let content =
129        fs::read_to_string(&file).with_context(|| format!("failed to read {}", file.display()))?;
130    let json = serde_json::from_str(&content)
131        .with_context(|| format!("failed to parse {}", file.display()))?;
132    Ok(Some(json))
133}
134
135fn build_secret_uri(
136    env: Option<&str>,
137    tenant: Option<&str>,
138    team: Option<&str>,
139    platform: &str,
140) -> Option<String> {
141    let env = env?;
142    let tenant = tenant?;
143    let team = team.unwrap_or("default");
144    Some(format!(
145        "secret://{env}/{tenant}/{team}/messaging/{platform}-{team}-credentials.json"
146    ))
147}
148
149pub fn load_card_value(path: &str) -> Result<Value> {
150    let absolute = absolute_path(path)?;
151    let content = fs::read_to_string(&absolute)
152        .with_context(|| format!("failed to read {}", absolute.display()))?;
153    let extension = absolute
154        .extension()
155        .and_then(|ext| ext.to_str())
156        .unwrap_or_default()
157        .to_ascii_lowercase();
158
159    match extension.as_str() {
160        "json" => serde_json::from_str(&content)
161            .with_context(|| format!("failed to parse json {}", absolute.display())),
162        "yaml" | "yml" => {
163            let yaml: serde_yaml_bw::Value = serde_yaml_bw::from_str(&content)
164                .with_context(|| format!("failed to parse yaml {}", absolute.display()))?;
165            serde_json::to_value(yaml)
166                .with_context(|| format!("failed to convert yaml {}", absolute.display()))
167        }
168        other => Err(anyhow!("unsupported fixture extension: {}", other)),
169    }
170}
171
172fn absolute_path<P>(path: P) -> Result<PathBuf>
173where
174    P: AsRef<Path>,
175{
176    let relative = path.as_ref();
177    if relative.is_absolute() {
178        return Ok(relative.to_path_buf());
179    }
180
181    let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
182    let mut current = Some(manifest_dir.to_path_buf());
183    while let Some(dir) = current {
184        let candidate = dir.join(relative);
185        if candidate.exists() {
186            let canonical = candidate.canonicalize().unwrap_or(candidate);
187            return Ok(canonical);
188        }
189        current = dir.parent().map(|p| p.to_path_buf());
190    }
191
192    Ok(manifest_dir.join(relative))
193}
194
195pub fn assert_matches_schema<P>(schema_path: P, value: &Value) -> Result<()>
196where
197    P: AsRef<Path>,
198{
199    let compiled = load_compiled_schema(schema_path.as_ref())?;
200
201    if let Err(errors) = compiled.validate(value) {
202        let mut messages: Vec<String> = Vec::new();
203        for err in errors {
204            messages.push(err.to_string());
205        }
206        return Err(anyhow!("schema validation failed: {}", messages.join("; ")));
207    }
208
209    Ok(())
210}
211
212fn load_schema(path: &Path) -> Result<Value> {
213    let absolute = absolute_path(path)?;
214    let content = fs::read_to_string(&absolute)
215        .with_context(|| format!("failed to read {}", absolute.display()))?;
216    serde_json::from_str(&content)
217        .with_context(|| format!("failed to parse json {}", absolute.display()))
218}
219
220fn load_compiled_schema(path: &Path) -> Result<Arc<JSONSchema>> {
221    static CACHE: Lazy<Mutex<HashMap<PathBuf, Arc<JSONSchema>>>> =
222        Lazy::new(|| Mutex::new(HashMap::new()));
223
224    let absolute = absolute_path(path)?;
225
226    {
227        let cache = CACHE.lock().unwrap();
228        if let Some(schema) = cache.get(&absolute) {
229            return Ok(schema.clone());
230        }
231    }
232
233    let schema_value = load_schema(&absolute)?;
234    let leaked: &'static Value = Box::leak(Box::new(schema_value));
235    let compiled = JSONSchema::compile(leaked)
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}