Skip to main content

stormchaser_engine/handler/integrations/email/
test_report.rs

1use anyhow::Result;
2use serde_json::Value;
3use sqlx::PgPool;
4use std::sync::Arc;
5use stormchaser_tls::TlsReloader;
6
7#[cfg(feature = "email")]
8use crate::handler::{fetch_outputs, fetch_run_context, fetch_step_instance};
9#[cfg(feature = "email")]
10use stormchaser_model::dsl::{self, EmailBackend};
11#[cfg(feature = "email")]
12use stormchaser_model::workflow;
13#[cfg(feature = "email")]
14use tracing::info;
15
16#[cfg(feature = "email")]
17use super::{complete_email_step, fail_email_step, ses, smtp};
18
19#[cfg(feature = "email")]
20/// Handle test report email.
21pub async fn handle_test_report_email(
22    run_id: stormchaser_model::RunId,
23    step_id: stormchaser_model::StepInstanceId,
24    spec: Value,
25    pool: PgPool,
26    nats_client: async_nats::Client,
27    tls_reloader: Arc<TlsReloader>,
28) -> Result<()> {
29    let spec: stormchaser_model::dsl::TestReportEmailSpec = serde_json::from_value(spec)?;
30
31    info!("Sending test report email for run {}", run_id);
32
33    // 1. Mark as Running
34    let instance = fetch_step_instance(step_id, &pool).await?;
35    let machine =
36        crate::step_machine::StepMachine::<crate::step_machine::state::Pending>::from_instance(
37            instance,
38        );
39    let _ = machine
40        .start("test-report-email".to_string(), &mut *pool.acquire().await?)
41        .await?;
42
43    // 2. Fetch Test Data
44    let reports = fetch_test_reports(run_id, &spec, &pool).await?;
45
46    // 3. Prepare Context
47    let run_context: workflow::RunContext = fetch_run_context(run_id, &pool).await?;
48    let outputs: Value = fetch_outputs(run_id, &pool).await?;
49
50    let template_ctx = serde_json::json!({
51        "inputs": run_context.inputs,
52        "steps": outputs,
53        "run": {
54            "id": run_id.to_string(),
55        },
56        "reports": reports,
57    });
58
59    // 4. Render Template
60    let rendered_body = render_test_report_body(&spec, &template_ctx)?;
61
62    let backend = spec.backend.clone().unwrap_or(EmailBackend::Smtp);
63
64    match backend {
65        EmailBackend::Ses => {
66            send_test_report_via_ses(run_id, step_id, &spec, rendered_body, pool, nats_client).await
67        }
68        EmailBackend::Smtp => {
69            send_test_report_via_smtp(
70                run_id,
71                step_id,
72                &spec,
73                rendered_body,
74                pool,
75                nats_client,
76                tls_reloader,
77            )
78            .await
79        }
80    }
81}
82
83#[cfg(feature = "email")]
84async fn fetch_test_reports(
85    run_id: stormchaser_model::RunId,
86    spec: &dsl::TestReportEmailSpec,
87    pool: &PgPool,
88) -> Result<Vec<Value>> {
89    let all_summaries = crate::db::get_test_summaries_for_run(pool, run_id.into_inner()).await?;
90    let filtered_summaries = if let Some(name) = &spec.report_name {
91        all_summaries
92            .into_iter()
93            .filter(|s| &s.report_name == name)
94            .collect::<Vec<_>>()
95    } else {
96        all_summaries
97    };
98
99    let mut reports = Vec::new();
100    for summary in filtered_summaries {
101        let cases =
102            crate::db::get_test_cases_for_report(pool, run_id.into_inner(), &summary.report_name)
103                .await?;
104        reports.push(serde_json::json!({
105            "summary": summary,
106            "cases": cases
107        }));
108    }
109    Ok(reports)
110}
111
112#[cfg(feature = "email")]
113fn render_test_report_body(
114    spec: &dsl::TestReportEmailSpec,
115    template_ctx: &Value,
116) -> Result<String> {
117    use minijinja::Environment;
118    let default_template = r#"
119    <html>
120    <head>
121        <style>
122            body { font-family: sans-serif; color: #333; }
123            .report { margin-bottom: 30px; border: 1px solid #ddd; padding: 15px; border-radius: 5px; }
124            .summary { display: flex; gap: 20px; background: #f9f9f9; padding: 10px; margin-bottom: 10px; }
125            .stat { text-align: center; }
126            .stat-value { font-size: 20px; font-weight: bold; }
127            .stat-label { font-size: 12px; color: #666; }
128            .passed { color: #28a745; }
129            .failed { color: #dc3545; }
130            .skipped { color: #ffc107; }
131            .error { color: #6f42c1; }
132            table { width: 100%; border-collapse: collapse; }
133            th, td { text-align: left; padding: 8px; border-bottom: 1px solid #eee; }
134            tr.fail-row { background: #fff5f5; }
135        </style>
136    </head>
137    <body>
138        <h1>Workflow Test Report: {{ run.id }}</h1>
139        {% for report in reports %}
140            <div class="report">
141                <h2>Report: {{ report.summary.report_name }}</h2>
142                <div class="summary">
143                    <div class="stat"><div class="stat-value">{{ report.summary.total_tests }}</div><div class="stat-label">Total</div></div>
144                    <div class="stat"><div class="stat-value passed">{{ report.summary.passed }}</div><div class="stat-label">Passed</div></div>
145                    <div class="stat"><div class="stat-value failed">{{ report.summary.failed }}</div><div class="stat-label">Failed</div></div>
146                    <div class="stat"><div class="stat-value error">{{ report.summary.errors }}</div><div class="stat-label">Errors</div></div>
147                    <div class="stat"><div class="stat-value skipped">{{ report.summary.skipped }}</div><div class="stat-label">Skipped</div></div>
148                    <div class="stat"><div class="stat-value">{{ report.summary.duration_ms }}ms</div><div class="stat-label">Duration</div></div>
149                </div>
150                {% if report.summary.failed > 0 or report.summary.errors > 0 %}
151                    <h3>Failures</h3>
152                    <table>
153                        <thead>
154                            <tr><th>Suite</th><th>Case</th><th>Status</th><th>Message</th></tr>
155                        </thead>
156                        <tbody>
157                            {% for case in report.cases %}
158                                {% if case.status == 'failed' or case.status == 'error' %}
159                                    <tr class="fail-row">
160                                        <td>{{ case.test_suite or "Default" }}</td>
161                                        <td>{{ case.test_case }}</td>
162                                        <td class="{{ case.status }}">{{ case.status }}</td>
163                                        <td>{{ case.message or "" }}</td>
164                                    </tr>
165                                {% endif %}
166                            {% endfor %}
167                        </tbody>
168                    </table>
169                {% else %}
170                    <p class="passed">All tests passed!</p>
171                {% endif %}
172            </div>
173        {% endfor %}
174    </body>
175    </html>
176    "#;
177
178    let template_str = spec.template.as_deref().unwrap_or(default_template);
179    let env = Environment::new();
180    env.render_str(template_str, template_ctx)
181        .map_err(|e| anyhow::anyhow!("Failed to render test report email body: {:?}", e))
182}
183
184#[cfg(feature = "email")]
185async fn send_test_report_via_ses(
186    run_id: stormchaser_model::RunId,
187    step_id: stormchaser_model::StepInstanceId,
188    spec: &dsl::TestReportEmailSpec,
189    rendered_body: String,
190    pool: PgPool,
191    nats_client: async_nats::Client,
192) -> Result<()> {
193    #[cfg(feature = "aws-ses")]
194    {
195        match ses::send_email_ses(
196            spec.from.clone(),
197            spec.to.clone(),
198            None,
199            None,
200            spec.subject.clone(),
201            rendered_body,
202            true, // HTML
203            spec.ses_region.clone(),
204            spec.ses_role_arn.clone(),
205            spec.ses_configuration_set_name.clone(),
206            run_id,
207        )
208        .await
209        {
210            Ok(_) => {
211                info!(
212                    "Test report email sent via SES successfully for step {}",
213                    step_id
214                );
215                complete_email_step(run_id, step_id, pool, nats_client).await
216            }
217            Err(e) => {
218                let error_msg = format!("Failed to send test report email via SES: {:?}", e);
219                fail_email_step(run_id, step_id, error_msg, pool, nats_client).await
220            }
221        }
222    }
223    #[cfg(not(feature = "aws-ses"))]
224    {
225        let _ = (run_id, step_id, spec, rendered_body, pool, nats_client);
226        anyhow::bail!("SES backend requested but 'aws-ses' feature is not enabled.");
227    }
228}
229
230#[cfg(feature = "email")]
231async fn send_test_report_via_smtp(
232    run_id: stormchaser_model::RunId,
233    step_id: stormchaser_model::StepInstanceId,
234    spec: &dsl::TestReportEmailSpec,
235    rendered_body: String,
236    pool: PgPool,
237    nats_client: async_nats::Client,
238    _tls_reloader: Arc<TlsReloader>,
239) -> Result<()> {
240    use lettre::message::header::ContentType;
241    use lettre::{Message, Transport};
242
243    // 5. Build and Send Email
244    let mut builder = Message::builder()
245        .from(spec.from.parse()?)
246        .subject(spec.subject.clone())
247        .header(ContentType::TEXT_HTML);
248
249    for to in &spec.to {
250        builder = builder.to(to.parse()?);
251    }
252
253    let message = builder.body(rendered_body)?;
254
255    let smtp_params = smtp::SmtpParams {
256        server: spec.smtp_server.clone().unwrap_or_else(|| {
257            std::env::var("SMTP_SERVER").unwrap_or_else(|_| "localhost".to_string())
258        }),
259        port: spec.smtp_port.unwrap_or_else(|| {
260            std::env::var("SMTP_PORT")
261                .ok()
262                .and_then(|p| p.parse().ok())
263                .unwrap_or(25)
264        }),
265        username: spec
266            .smtp_username
267            .clone()
268            .or_else(|| std::env::var("SMTP_USERNAME").ok()),
269        password: spec
270            .smtp_password
271            .clone()
272            .or_else(|| std::env::var("SMTP_PASSWORD").ok()),
273        use_tls: spec
274            .smtp_use_tls
275            .unwrap_or_else(|| std::env::var("SMTP_USE_TLS").unwrap_or_default() == "true"),
276        use_mtls: spec
277            .smtp_use_mtls
278            .unwrap_or_else(|| std::env::var("SMTP_USE_MTLS").unwrap_or_default() == "true"),
279    };
280
281    let mailer = smtp::build_smtp_transport(smtp_params)?;
282    match mailer.send(&message) {
283        Ok(_) => {
284            info!("Test report email sent successfully for step {}", step_id);
285            complete_email_step(run_id, step_id, pool, nats_client).await
286        }
287        Err(e) => {
288            let error_msg = format!("Failed to send test report email: {:?}", e);
289            fail_email_step(run_id, step_id, error_msg, pool, nats_client).await
290        }
291    }
292}
293
294#[cfg(not(feature = "email"))]
295/// Handle test report email.
296pub async fn handle_test_report_email(
297    _run_id: stormchaser_model::RunId,
298    _step_id: stormchaser_model::StepInstanceId,
299    _spec: Value,
300    _pool: PgPool,
301    _nats_client: async_nats::Client,
302    __tls_reloader: Arc<TlsReloader>,
303) -> Result<()> {
304    anyhow::bail!("Email support is not enabled. Enable 'email' feature.")
305}
306
307#[cfg(all(test, feature = "email"))]
308mod tests {
309    use super::*;
310    use serde_json::json;
311    use stormchaser_model::dsl::specs::{EmailBackend, TestReportEmailSpec};
312    use stormchaser_model::test_report::TestCaseStatus;
313
314    #[test]
315    #[cfg(feature = "email")]
316    fn test_render_test_report_body_default() {
317        let spec = TestReportEmailSpec {
318            from: "sender@paninfracon.net".to_string(),
319            to: vec!["receiver@paninfracon.net".to_string()],
320            subject: "Test Report".to_string(),
321            template: None,
322            report_name: None,
323            backend: Some(EmailBackend::Smtp),
324            smtp_server: None,
325            smtp_port: None,
326            smtp_username: None,
327            smtp_password: None,
328            smtp_use_tls: None,
329            smtp_use_mtls: None,
330            ses_region: None,
331            ses_role_arn: None,
332            ses_configuration_set_name: None,
333        };
334
335        let template_ctx = json!({
336            "run": { "id": "test-run-id" },
337            "reports": [
338                {
339                    "summary": {
340                        "report_name": "JUnit",
341                        "total_tests": 10,
342                        "passed": 8,
343                        "failed": 1,
344                        "errors": 1,
345                        "skipped": 0,
346                        "duration_ms": 1234
347                    },
348                    "cases": [
349                        {
350                            "test_suite": "suite1",
351                            "test_case": "case1",
352                            "status": TestCaseStatus::Passed,
353                            "message": null
354                        },
355                        {
356                            "test_suite": "suite1",
357                            "test_case": "case2",
358                            "status": TestCaseStatus::Failed,
359                            "message": "Failure message"
360                        },
361                        {
362                            "test_suite": "suite2",
363                            "test_case": "case3",
364                            "status": TestCaseStatus::Error,
365                            "message": "Error message"
366                        }
367                    ]
368                }
369            ]
370        });
371
372        let rendered = render_test_report_body(&spec, &template_ctx).unwrap();
373        assert!(rendered.contains("Workflow Test Report: test-run-id"));
374        assert!(rendered.contains("Report: JUnit"));
375        assert!(rendered.contains("8")); // passed
376        assert!(rendered.contains("1")); // failed
377        assert!(rendered.contains("Failure message"));
378        assert!(rendered.contains("Error message"));
379    }
380
381    #[test]
382    #[cfg(feature = "email")]
383    fn test_render_test_report_body_custom() {
384        let spec = TestReportEmailSpec {
385            from: "sender@paninfracon.net".to_string(),
386            to: vec!["receiver@paninfracon.net".to_string()],
387            subject: "Test Report".to_string(),
388            template: Some("Custom: {{ run.id }}".to_string()),
389            report_name: None,
390            backend: Some(EmailBackend::Smtp),
391            smtp_server: None,
392            smtp_port: None,
393            smtp_username: None,
394            smtp_password: None,
395            smtp_use_tls: None,
396            smtp_use_mtls: None,
397            ses_region: None,
398            ses_role_arn: None,
399            ses_configuration_set_name: None,
400        };
401
402        let template_ctx = json!({
403            "run": { "id": "custom-run-id" },
404            "reports": []
405        });
406
407        let rendered = render_test_report_body(&spec, &template_ctx).unwrap();
408        assert_eq!(rendered.trim(), "Custom: custom-run-id");
409    }
410}