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 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}