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