Phase 7: Email sending via Resend + Markdown/PDF export
Backend: - Synthesis email sending via Resend API with HTML template (inline CSS, tables-based for email client compatibility) + plain-text fallback - XSS prevention via html_escape() on all user content in email templates - Markdown export: clean format with headers, links, summaries - PDF export: printpdf with built-in Helvetica fonts, indigo color scheme, automatic page breaks, word wrapping - 3 new endpoints: send-email, export/markdown, export/pdf - All endpoints enforce ownership checks - Email validation using email_address crate - 24 new unit tests, 13 integration tests Frontend: - Email section on SynthesisDetail: input pre-filled with user email, send button with loading state, success/error feedback - Export buttons: Markdown + PDF with per-button loading states - File download via Blob + temporary anchor with Content-Disposition parsing - 6 new export tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>master
parent
631bd43b9f
commit
1f9f7f39d7
@ -0,0 +1,456 @@
|
||||
//! Export service for generating Markdown and PDF from a synthesis.
|
||||
//!
|
||||
//! Provides functions to convert a synthesis (with its sections and items)
|
||||
//! into downloadable Markdown or PDF format.
|
||||
|
||||
use printpdf::*;
|
||||
use std::io::BufWriter;
|
||||
|
||||
use crate::errors::AppError;
|
||||
use crate::models::synthesis::NewsSection;
|
||||
|
||||
/// Generate a Markdown representation of the synthesis.
|
||||
///
|
||||
/// Output format:
|
||||
/// ```text
|
||||
/// # Synthese de la Semaine 2026-W12
|
||||
///
|
||||
/// Generee le 2026-03-21
|
||||
///
|
||||
/// ## Section Title
|
||||
///
|
||||
/// ### Article Title
|
||||
///
|
||||
/// [Lien](https://example.com/article)
|
||||
///
|
||||
/// Summary text here.
|
||||
/// ```
|
||||
pub fn generate_markdown(week: &str, date: &str, sections: &[NewsSection]) -> String {
|
||||
let mut md = String::new();
|
||||
|
||||
md.push_str(&format!("# Synthese de la Semaine {}\n\n", week));
|
||||
md.push_str(&format!("Generee le {}\n", date));
|
||||
|
||||
for section in sections {
|
||||
md.push_str(&format!("\n## {}\n", section.title));
|
||||
|
||||
for item in §ion.items {
|
||||
md.push_str(&format!("\n### {}\n\n", item.title));
|
||||
md.push_str(&format!("[Lien]({})\n\n", item.url));
|
||||
md.push_str(&format!("{}\n", item.summary));
|
||||
}
|
||||
}
|
||||
|
||||
md
|
||||
}
|
||||
|
||||
/// Page dimensions (A4 in mm).
|
||||
const PAGE_WIDTH_MM: f32 = 210.0;
|
||||
const PAGE_HEIGHT_MM: f32 = 297.0;
|
||||
|
||||
/// Margins in mm.
|
||||
const MARGIN_LEFT: f32 = 25.0;
|
||||
const MARGIN_TOP: f32 = 25.0;
|
||||
const MARGIN_RIGHT: f32 = 25.0;
|
||||
const MARGIN_BOTTOM: f32 = 25.0;
|
||||
|
||||
/// Maximum text width in mm.
|
||||
const TEXT_WIDTH: f32 = PAGE_WIDTH_MM - MARGIN_LEFT - MARGIN_RIGHT;
|
||||
|
||||
/// Font sizes in pt.
|
||||
const TITLE_SIZE: f32 = 18.0;
|
||||
const DATE_SIZE: f32 = 10.0;
|
||||
const SECTION_SIZE: f32 = 14.0;
|
||||
const ITEM_TITLE_SIZE: f32 = 11.0;
|
||||
const URL_SIZE: f32 = 8.0;
|
||||
const BODY_SIZE: f32 = 10.0;
|
||||
const FOOTER_SIZE: f32 = 8.0;
|
||||
|
||||
/// Approximate width of a character in mm for a given font size.
|
||||
/// This is a rough estimate for Helvetica (average char width ~0.5 * size in pt).
|
||||
/// 1 pt = 0.3528 mm.
|
||||
fn approx_char_width_mm(font_size: f32) -> f32 {
|
||||
font_size * 0.3528 * 0.5
|
||||
}
|
||||
|
||||
/// Wrap text into lines that fit within the given width in mm.
|
||||
fn wrap_text(text: &str, font_size: f32, max_width_mm: f32) -> Vec<String> {
|
||||
let char_width = approx_char_width_mm(font_size);
|
||||
let max_chars = (max_width_mm / char_width).floor() as usize;
|
||||
if max_chars == 0 {
|
||||
return vec![text.to_string()];
|
||||
}
|
||||
|
||||
let mut lines = Vec::new();
|
||||
for paragraph in text.split('\n') {
|
||||
if paragraph.is_empty() {
|
||||
lines.push(String::new());
|
||||
continue;
|
||||
}
|
||||
|
||||
let words: Vec<&str> = paragraph.split_whitespace().collect();
|
||||
if words.is_empty() {
|
||||
lines.push(String::new());
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut current_line = String::new();
|
||||
for word in words {
|
||||
if current_line.is_empty() {
|
||||
current_line = word.to_string();
|
||||
} else if current_line.len() + 1 + word.len() <= max_chars {
|
||||
current_line.push(' ');
|
||||
current_line.push_str(word);
|
||||
} else {
|
||||
lines.push(current_line);
|
||||
current_line = word.to_string();
|
||||
}
|
||||
}
|
||||
if !current_line.is_empty() {
|
||||
lines.push(current_line);
|
||||
}
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
/// PDF writer state: tracks current Y position and manages page creation.
|
||||
struct PdfWriter {
|
||||
doc: PdfDocumentReference,
|
||||
current_page: PdfPageIndex,
|
||||
current_layer: PdfLayerIndex,
|
||||
y_pos: f32,
|
||||
font_regular: IndirectFontRef,
|
||||
font_bold: IndirectFontRef,
|
||||
font_italic: IndirectFontRef,
|
||||
}
|
||||
|
||||
impl PdfWriter {
|
||||
fn new(
|
||||
doc: PdfDocumentReference,
|
||||
page: PdfPageIndex,
|
||||
layer: PdfLayerIndex,
|
||||
font_regular: IndirectFontRef,
|
||||
font_bold: IndirectFontRef,
|
||||
font_italic: IndirectFontRef,
|
||||
) -> Self {
|
||||
Self {
|
||||
doc,
|
||||
current_page: page,
|
||||
current_layer: layer,
|
||||
y_pos: PAGE_HEIGHT_MM - MARGIN_TOP,
|
||||
font_regular,
|
||||
font_bold,
|
||||
font_italic,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure there is enough vertical space; if not, add a new page.
|
||||
fn ensure_space(&mut self, needed_mm: f32) {
|
||||
if self.y_pos - needed_mm < MARGIN_BOTTOM {
|
||||
let (page, layer) = self.doc.add_page(
|
||||
Mm(PAGE_WIDTH_MM),
|
||||
Mm(PAGE_HEIGHT_MM),
|
||||
"Page",
|
||||
);
|
||||
self.current_page = page;
|
||||
self.current_layer = layer;
|
||||
self.y_pos = PAGE_HEIGHT_MM - MARGIN_TOP;
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a single line of text at the current position.
|
||||
fn write_line(&mut self, text: &str, font: &IndirectFontRef, size: f32, color: (f32, f32, f32)) {
|
||||
let line_height_mm = size * 0.3528 * 1.4;
|
||||
self.ensure_space(line_height_mm);
|
||||
|
||||
let layer = self.doc.get_page(self.current_page).get_layer(self.current_layer);
|
||||
layer.set_fill_color(Color::Rgb(Rgb::new(color.0, color.1, color.2, None)));
|
||||
layer.use_text(text, size, Mm(MARGIN_LEFT), Mm(self.y_pos), font);
|
||||
|
||||
self.y_pos -= line_height_mm;
|
||||
}
|
||||
|
||||
/// Write wrapped text (may span multiple lines).
|
||||
fn write_wrapped(
|
||||
&mut self,
|
||||
text: &str,
|
||||
font: &IndirectFontRef,
|
||||
size: f32,
|
||||
color: (f32, f32, f32),
|
||||
) {
|
||||
let lines = wrap_text(text, size, TEXT_WIDTH);
|
||||
for line in &lines {
|
||||
self.write_line(line, font, size, color);
|
||||
}
|
||||
}
|
||||
|
||||
/// Add vertical spacing in mm.
|
||||
fn space(&mut self, mm: f32) {
|
||||
self.y_pos -= mm;
|
||||
}
|
||||
}
|
||||
|
||||
/// Indigo color (R, G, B) normalized to 0.0-1.0 for PDF.
|
||||
const COLOR_INDIGO: (f32, f32, f32) = (0.263, 0.275, 0.898); // #4338CA
|
||||
const COLOR_DARK: (f32, f32, f32) = (0.133, 0.133, 0.133); // #222222
|
||||
const COLOR_GRAY: (f32, f32, f32) = (0.4, 0.4, 0.4); // #666666
|
||||
const COLOR_LIGHT_GRAY: (f32, f32, f32) = (0.6, 0.6, 0.6); // #999999
|
||||
|
||||
/// Generate a PDF document from the synthesis, returned as raw bytes.
|
||||
///
|
||||
/// Uses the `printpdf` crate with built-in PDF fonts (Helvetica family).
|
||||
/// No external font files are required.
|
||||
///
|
||||
/// The PDF contains:
|
||||
/// - A title with "AI Weekly Synth - Semaine {week}"
|
||||
/// - The generation date
|
||||
/// - Section headers and items with titles, URLs, and summaries
|
||||
/// - Automatic page breaks when content exceeds page height
|
||||
pub fn generate_pdf(
|
||||
week: &str,
|
||||
date: &str,
|
||||
sections: &[NewsSection],
|
||||
) -> Result<Vec<u8>, AppError> {
|
||||
let (doc, page, layer) = PdfDocument::new(
|
||||
format!("AI Weekly Synth - Semaine {}", week),
|
||||
Mm(PAGE_WIDTH_MM),
|
||||
Mm(PAGE_HEIGHT_MM),
|
||||
"Page 1",
|
||||
);
|
||||
|
||||
let font_regular = doc.add_builtin_font(BuiltinFont::Helvetica).map_err(|e| {
|
||||
AppError::Internal(anyhow::anyhow!("Failed to load Helvetica font: {}", e))
|
||||
})?;
|
||||
let font_bold = doc
|
||||
.add_builtin_font(BuiltinFont::HelveticaBold)
|
||||
.map_err(|e| {
|
||||
AppError::Internal(anyhow::anyhow!("Failed to load Helvetica-Bold font: {}", e))
|
||||
})?;
|
||||
let font_italic = doc
|
||||
.add_builtin_font(BuiltinFont::HelveticaOblique)
|
||||
.map_err(|e| {
|
||||
AppError::Internal(anyhow::anyhow!(
|
||||
"Failed to load Helvetica-Oblique font: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
let mut writer = PdfWriter::new(doc, page, layer, font_regular, font_bold, font_italic);
|
||||
|
||||
// Title
|
||||
let title = format!("AI Weekly Synth - Semaine {}", week);
|
||||
writer.write_line(&title, &writer.font_bold.clone(), TITLE_SIZE, COLOR_INDIGO);
|
||||
writer.space(3.0);
|
||||
|
||||
// Date
|
||||
let date_line = format!("Generee le {}", date);
|
||||
writer.write_line(
|
||||
&date_line,
|
||||
&writer.font_italic.clone(),
|
||||
DATE_SIZE,
|
||||
COLOR_GRAY,
|
||||
);
|
||||
writer.space(8.0);
|
||||
|
||||
// Sections
|
||||
for section in sections {
|
||||
// Section title
|
||||
writer.write_line(
|
||||
§ion.title,
|
||||
&writer.font_bold.clone(),
|
||||
SECTION_SIZE,
|
||||
COLOR_INDIGO,
|
||||
);
|
||||
writer.space(3.0);
|
||||
|
||||
for item in §ion.items {
|
||||
// Item title (bold)
|
||||
writer.write_wrapped(
|
||||
&item.title,
|
||||
&writer.font_bold.clone(),
|
||||
ITEM_TITLE_SIZE,
|
||||
COLOR_DARK,
|
||||
);
|
||||
|
||||
// URL (italic, gray)
|
||||
writer.write_wrapped(
|
||||
&item.url,
|
||||
&writer.font_italic.clone(),
|
||||
URL_SIZE,
|
||||
COLOR_LIGHT_GRAY,
|
||||
);
|
||||
writer.space(1.0);
|
||||
|
||||
// Summary
|
||||
writer.write_wrapped(
|
||||
&item.summary,
|
||||
&writer.font_regular.clone(),
|
||||
BODY_SIZE,
|
||||
COLOR_DARK,
|
||||
);
|
||||
writer.space(4.0);
|
||||
}
|
||||
|
||||
writer.space(4.0);
|
||||
}
|
||||
|
||||
// Footer
|
||||
writer.space(8.0);
|
||||
writer.write_line(
|
||||
"Genere par AI Weekly Synth",
|
||||
&writer.font_italic.clone(),
|
||||
FOOTER_SIZE,
|
||||
COLOR_LIGHT_GRAY,
|
||||
);
|
||||
|
||||
// Render to bytes
|
||||
let buf = Vec::new();
|
||||
let mut buf_writer = BufWriter::new(buf);
|
||||
writer
|
||||
.doc
|
||||
.save(&mut buf_writer)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to render PDF: {}", e)))?;
|
||||
|
||||
let bytes = buf_writer
|
||||
.into_inner()
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to flush PDF buffer: {}", e)))?;
|
||||
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::synthesis::{NewsItem, NewsSection};
|
||||
|
||||
fn sample_sections() -> Vec<NewsSection> {
|
||||
vec![
|
||||
NewsSection {
|
||||
title: "Annonces Majeures".into(),
|
||||
items: vec![
|
||||
NewsItem {
|
||||
title: "OpenAI lance GPT-5".into(),
|
||||
url: "https://openai.com/gpt5".into(),
|
||||
summary:
|
||||
"OpenAI a annonce la sortie de GPT-5 avec des capacites ameliorees."
|
||||
.into(),
|
||||
},
|
||||
NewsItem {
|
||||
title: "Google DeepMind publie Gemini 3".into(),
|
||||
url: "https://deepmind.google/gemini3".into(),
|
||||
summary:
|
||||
"DeepMind presente Gemini 3, son nouveau modele multimodal.".into(),
|
||||
},
|
||||
],
|
||||
},
|
||||
NewsSection {
|
||||
title: "Recherche".into(),
|
||||
items: vec![NewsItem {
|
||||
title: "Nouveau papier sur le RLHF".into(),
|
||||
url: "https://arxiv.org/abs/2026.12345".into(),
|
||||
summary: "Une nouvelle approche du RLHF prometteuse.".into(),
|
||||
}],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// --- Markdown tests ---
|
||||
|
||||
#[test]
|
||||
fn markdown_contains_title() {
|
||||
let md = generate_markdown("2026-W12", "2026-03-21", &sample_sections());
|
||||
assert!(md.starts_with("# Synthese de la Semaine 2026-W12\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_contains_date() {
|
||||
let md = generate_markdown("2026-W12", "2026-03-21", &sample_sections());
|
||||
assert!(md.contains("Generee le 2026-03-21"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_contains_section_titles() {
|
||||
let md = generate_markdown("2026-W12", "2026-03-21", &sample_sections());
|
||||
assert!(md.contains("## Annonces Majeures"));
|
||||
assert!(md.contains("## Recherche"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_contains_item_titles_and_links() {
|
||||
let md = generate_markdown("2026-W12", "2026-03-21", &sample_sections());
|
||||
assert!(md.contains("### OpenAI lance GPT-5"));
|
||||
assert!(md.contains("[Lien](https://openai.com/gpt5)"));
|
||||
assert!(md.contains("### Nouveau papier sur le RLHF"));
|
||||
assert!(md.contains("[Lien](https://arxiv.org/abs/2026.12345)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_contains_summaries() {
|
||||
let md = generate_markdown("2026-W12", "2026-03-21", &sample_sections());
|
||||
assert!(md.contains("OpenAI a annonce la sortie de GPT-5"));
|
||||
assert!(md.contains("Une nouvelle approche du RLHF prometteuse."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_empty_sections_produces_header_only() {
|
||||
let md = generate_markdown("2026-W12", "2026-03-21", &[]);
|
||||
assert!(md.contains("# Synthese de la Semaine 2026-W12"));
|
||||
assert!(md.contains("Generee le 2026-03-21"));
|
||||
// No section headers
|
||||
assert!(!md.contains("##"));
|
||||
}
|
||||
|
||||
// --- PDF tests ---
|
||||
|
||||
#[test]
|
||||
fn pdf_output_is_nonempty_and_starts_with_pdf_header() {
|
||||
let bytes = generate_pdf("2026-W12", "2026-03-21", &sample_sections()).unwrap();
|
||||
assert!(!bytes.is_empty(), "PDF output should not be empty");
|
||||
assert!(
|
||||
bytes.starts_with(b"%PDF"),
|
||||
"PDF output should start with %PDF magic bytes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_empty_sections_still_produces_valid_pdf() {
|
||||
let bytes = generate_pdf("2026-W12", "2026-03-21", &[]).unwrap();
|
||||
assert!(!bytes.is_empty());
|
||||
assert!(bytes.starts_with(b"%PDF"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_contains_title_text() {
|
||||
let bytes = generate_pdf("2026-W12", "2026-03-21", &sample_sections()).unwrap();
|
||||
// The PDF binary content should contain the title string somewhere
|
||||
let content = String::from_utf8_lossy(&bytes);
|
||||
assert!(
|
||||
content.contains("AI Weekly Synth"),
|
||||
"PDF should contain the application name"
|
||||
);
|
||||
}
|
||||
|
||||
// --- Text wrapping tests ---
|
||||
|
||||
#[test]
|
||||
fn wrap_text_short_line_fits() {
|
||||
let lines = wrap_text("Hello world", 10.0, 160.0);
|
||||
assert_eq!(lines.len(), 1);
|
||||
assert_eq!(lines[0], "Hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_text_long_line_wraps() {
|
||||
// Very narrow width to force wrapping
|
||||
let lines = wrap_text("Hello world from the test", 10.0, 20.0);
|
||||
assert!(lines.len() > 1, "Long text should wrap into multiple lines");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_text_empty_input() {
|
||||
let lines = wrap_text("", 10.0, 160.0);
|
||||
assert_eq!(lines.len(), 1);
|
||||
assert!(lines[0].is_empty());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,574 @@
|
||||
//! Integration tests for Phase 7: Email + Export endpoints.
|
||||
//!
|
||||
//! Tests:
|
||||
//! - POST /api/v1/syntheses/:id/send-email — send synthesis by email
|
||||
//! - GET /api/v1/syntheses/:id/export/markdown — export as Markdown
|
||||
//! - GET /api/v1/syntheses/:id/export/pdf — export as PDF
|
||||
//!
|
||||
//! Covers authentication, validation, ownership isolation, content-type
|
||||
//! headers, and response body content for all three endpoints.
|
||||
//!
|
||||
//! Requires a running Postgres instance. Set `TEST_DATABASE_URL` to run.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Method, Request, StatusCode};
|
||||
use ai_synth_backend::services::auth::SESSION_COOKIE_NAME;
|
||||
|
||||
fn require_test_db() -> bool {
|
||||
std::env::var("TEST_DATABASE_URL").is_ok()
|
||||
}
|
||||
|
||||
/// Helper: build a sample sections JSON value with two sections and three items.
|
||||
fn sample_sections() -> serde_json::Value {
|
||||
serde_json::json!([
|
||||
{
|
||||
"title": "Annonces Majeures",
|
||||
"items": [
|
||||
{
|
||||
"title": "OpenAI lance GPT-5",
|
||||
"url": "https://openai.com/gpt5",
|
||||
"summary": "OpenAI a annonce GPT-5 avec des capacites ameliorees."
|
||||
},
|
||||
{
|
||||
"title": "Google DeepMind publie Gemini 3",
|
||||
"url": "https://deepmind.google/gemini3",
|
||||
"summary": "DeepMind presente Gemini 3, son nouveau modele multimodal."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Recherche",
|
||||
"items": [
|
||||
{
|
||||
"title": "Nouveau papier sur le RLHF",
|
||||
"url": "https://arxiv.org/abs/2026.12345",
|
||||
"summary": "Une nouvelle approche du RLHF prometteuse."
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
/// Build a GET request with a session cookie.
|
||||
fn authed_get(uri: &str, session: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(uri)
|
||||
.header("Cookie", format!("{}={}", SESSION_COOKIE_NAME, session))
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Build a GET request without a session cookie.
|
||||
fn unauthed_get(uri: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(uri)
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Email Endpoint — POST /api/v1/syntheses/:id/send-email (5 tests)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_email_without_auth_returns_401() {
|
||||
if !require_test_db() {
|
||||
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
}
|
||||
|
||||
let app = common::TestApp::new().await;
|
||||
let fake_id = uuid::Uuid::new_v4();
|
||||
let body = serde_json::json!({ "email": "test@example.com" });
|
||||
|
||||
let (status, resp) = app
|
||||
.post(&format!("/api/v1/syntheses/{}/send-email", fake_id), &body)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"POST /syntheses/:id/send-email without auth should return 401"
|
||||
);
|
||||
assert_eq!(resp["error"], "unauthorized");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_email_with_valid_email_returns_200() {
|
||||
if !require_test_db() {
|
||||
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
}
|
||||
|
||||
let app = common::TestApp::new().await;
|
||||
let (user_id, session) = app
|
||||
.create_authenticated_user("email-ok@example.com")
|
||||
.await;
|
||||
|
||||
let sections = sample_sections();
|
||||
let synth_id = app
|
||||
.insert_test_synthesis(user_id, "2026-W12", §ions)
|
||||
.await;
|
||||
|
||||
let body = serde_json::json!({ "email": "recipient@example.com" });
|
||||
let (status, resp) = app
|
||||
.post_with_session(
|
||||
&format!("/api/v1/syntheses/{}/send-email", synth_id),
|
||||
&body,
|
||||
&session,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::OK,
|
||||
"POST /syntheses/:id/send-email with valid email should return 200"
|
||||
);
|
||||
assert!(
|
||||
resp["message"].as_str().is_some(),
|
||||
"Response should contain a success message"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_email_with_invalid_email_returns_422() {
|
||||
if !require_test_db() {
|
||||
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
}
|
||||
|
||||
let app = common::TestApp::new().await;
|
||||
let (user_id, session) = app
|
||||
.create_authenticated_user("email-invalid@example.com")
|
||||
.await;
|
||||
|
||||
let sections = sample_sections();
|
||||
let synth_id = app
|
||||
.insert_test_synthesis(user_id, "2026-W12", §ions)
|
||||
.await;
|
||||
|
||||
let body = serde_json::json!({ "email": "not-an-email" });
|
||||
let (status, resp) = app
|
||||
.post_with_session(
|
||||
&format!("/api/v1/syntheses/{}/send-email", synth_id),
|
||||
&body,
|
||||
&session,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"POST /syntheses/:id/send-email with invalid email should return 422"
|
||||
);
|
||||
assert_eq!(resp["error"], "validation_error");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_email_for_non_owned_synthesis_returns_404() {
|
||||
if !require_test_db() {
|
||||
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
}
|
||||
|
||||
let app = common::TestApp::new().await;
|
||||
let (user_a_id, _session_a) = app
|
||||
.create_authenticated_user("email-owner-a@example.com")
|
||||
.await;
|
||||
let (_user_b_id, session_b) = app
|
||||
.create_authenticated_user("email-owner-b@example.com")
|
||||
.await;
|
||||
|
||||
// Insert a synthesis for User A
|
||||
let sections = sample_sections();
|
||||
let synth_id = app
|
||||
.insert_test_synthesis(user_a_id, "2026-W12", §ions)
|
||||
.await;
|
||||
|
||||
// User B tries to send email for User A's synthesis
|
||||
let body = serde_json::json!({ "email": "recipient@example.com" });
|
||||
let (status, resp) = app
|
||||
.post_with_session(
|
||||
&format!("/api/v1/syntheses/{}/send-email", synth_id),
|
||||
&body,
|
||||
&session_b,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::NOT_FOUND,
|
||||
"User B should not be able to send email for User A's synthesis"
|
||||
);
|
||||
assert_eq!(resp["error"], "not_found");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_email_for_nonexistent_synthesis_returns_404() {
|
||||
if !require_test_db() {
|
||||
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
}
|
||||
|
||||
let app = common::TestApp::new().await;
|
||||
let (_user_id, session) = app
|
||||
.create_authenticated_user("email-404@example.com")
|
||||
.await;
|
||||
|
||||
let fake_id = uuid::Uuid::new_v4();
|
||||
let body = serde_json::json!({ "email": "recipient@example.com" });
|
||||
let (status, resp) = app
|
||||
.post_with_session(
|
||||
&format!("/api/v1/syntheses/{}/send-email", fake_id),
|
||||
&body,
|
||||
&session,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::NOT_FOUND,
|
||||
"POST /syntheses/:id/send-email for non-existent synthesis should return 404"
|
||||
);
|
||||
assert_eq!(resp["error"], "not_found");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Markdown Export — GET /api/v1/syntheses/:id/export/markdown (4 tests)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn export_markdown_without_auth_returns_401() {
|
||||
if !require_test_db() {
|
||||
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
}
|
||||
|
||||
let app = common::TestApp::new().await;
|
||||
let fake_id = uuid::Uuid::new_v4();
|
||||
|
||||
let req = unauthed_get(&format!("/api/v1/syntheses/{}/export/markdown", fake_id));
|
||||
let response = app.raw_request(req).await;
|
||||
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"GET /syntheses/:id/export/markdown without auth should return 401"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn export_markdown_returns_200_with_correct_content_type() {
|
||||
if !require_test_db() {
|
||||
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
}
|
||||
|
||||
let app = common::TestApp::new().await;
|
||||
let (user_id, session) = app
|
||||
.create_authenticated_user("md-export@example.com")
|
||||
.await;
|
||||
|
||||
let sections = sample_sections();
|
||||
let synth_id = app
|
||||
.insert_test_synthesis(user_id, "2026-W12", §ions)
|
||||
.await;
|
||||
|
||||
let req = authed_get(
|
||||
&format!("/api/v1/syntheses/{}/export/markdown", synth_id),
|
||||
&session,
|
||||
);
|
||||
let (status, _text, headers) = app.raw_request_text(req).await;
|
||||
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::OK,
|
||||
"GET /syntheses/:id/export/markdown should return 200"
|
||||
);
|
||||
|
||||
let content_type = headers
|
||||
.get("content-type")
|
||||
.expect("Should have Content-Type header")
|
||||
.to_str()
|
||||
.unwrap();
|
||||
assert!(
|
||||
content_type.contains("text/markdown"),
|
||||
"Content-Type should be text/markdown, got: {}",
|
||||
content_type
|
||||
);
|
||||
|
||||
let content_disposition = headers
|
||||
.get("content-disposition")
|
||||
.expect("Should have Content-Disposition header")
|
||||
.to_str()
|
||||
.unwrap();
|
||||
assert!(
|
||||
content_disposition.contains("attachment"),
|
||||
"Content-Disposition should indicate attachment download"
|
||||
);
|
||||
assert!(
|
||||
content_disposition.contains(".md"),
|
||||
"Content-Disposition filename should end with .md"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn export_markdown_body_contains_sections_and_items() {
|
||||
if !require_test_db() {
|
||||
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
}
|
||||
|
||||
let app = common::TestApp::new().await;
|
||||
let (user_id, session) = app
|
||||
.create_authenticated_user("md-content@example.com")
|
||||
.await;
|
||||
|
||||
let sections = sample_sections();
|
||||
let synth_id = app
|
||||
.insert_test_synthesis(user_id, "2026-W12", §ions)
|
||||
.await;
|
||||
|
||||
let req = authed_get(
|
||||
&format!("/api/v1/syntheses/{}/export/markdown", synth_id),
|
||||
&session,
|
||||
);
|
||||
let (status, text, _headers) = app.raw_request_text(req).await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
|
||||
// Check title header
|
||||
assert!(
|
||||
text.contains("# Synthese de la Semaine 2026-W12"),
|
||||
"Markdown should contain the synthesis title"
|
||||
);
|
||||
|
||||
// Check section titles
|
||||
assert!(
|
||||
text.contains("## Annonces Majeures"),
|
||||
"Markdown should contain section title 'Annonces Majeures'"
|
||||
);
|
||||
assert!(
|
||||
text.contains("## Recherche"),
|
||||
"Markdown should contain section title 'Recherche'"
|
||||
);
|
||||
|
||||
// Check item titles
|
||||
assert!(
|
||||
text.contains("### OpenAI lance GPT-5"),
|
||||
"Markdown should contain item title 'OpenAI lance GPT-5'"
|
||||
);
|
||||
assert!(
|
||||
text.contains("### Google DeepMind publie Gemini 3"),
|
||||
"Markdown should contain item title 'Google DeepMind publie Gemini 3'"
|
||||
);
|
||||
assert!(
|
||||
text.contains("### Nouveau papier sur le RLHF"),
|
||||
"Markdown should contain item title 'Nouveau papier sur le RLHF'"
|
||||
);
|
||||
|
||||
// Check URLs are present as links
|
||||
assert!(
|
||||
text.contains("https://openai.com/gpt5"),
|
||||
"Markdown should contain item URL"
|
||||
);
|
||||
assert!(
|
||||
text.contains("https://arxiv.org/abs/2026.12345"),
|
||||
"Markdown should contain item URL"
|
||||
);
|
||||
|
||||
// Check summaries
|
||||
assert!(
|
||||
text.contains("OpenAI a annonce GPT-5"),
|
||||
"Markdown should contain item summary"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn export_markdown_for_non_owned_synthesis_returns_404() {
|
||||
if !require_test_db() {
|
||||
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
}
|
||||
|
||||
let app = common::TestApp::new().await;
|
||||
let (user_a_id, _session_a) = app
|
||||
.create_authenticated_user("md-owner-a@example.com")
|
||||
.await;
|
||||
let (_user_b_id, session_b) = app
|
||||
.create_authenticated_user("md-owner-b@example.com")
|
||||
.await;
|
||||
|
||||
// Insert a synthesis for User A
|
||||
let sections = sample_sections();
|
||||
let synth_id = app
|
||||
.insert_test_synthesis(user_a_id, "2026-W12", §ions)
|
||||
.await;
|
||||
|
||||
// User B tries to export User A's synthesis
|
||||
let req = authed_get(
|
||||
&format!("/api/v1/syntheses/{}/export/markdown", synth_id),
|
||||
&session_b,
|
||||
);
|
||||
let (status, _text, _headers) = app.raw_request_text(req).await;
|
||||
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::NOT_FOUND,
|
||||
"User B should not be able to export User A's synthesis as Markdown"
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// PDF Export — GET /api/v1/syntheses/:id/export/pdf (4 tests)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn export_pdf_without_auth_returns_401() {
|
||||
if !require_test_db() {
|
||||
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
}
|
||||
|
||||
let app = common::TestApp::new().await;
|
||||
let fake_id = uuid::Uuid::new_v4();
|
||||
|
||||
let req = unauthed_get(&format!("/api/v1/syntheses/{}/export/pdf", fake_id));
|
||||
let response = app.raw_request(req).await;
|
||||
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"GET /syntheses/:id/export/pdf without auth should return 401"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn export_pdf_returns_200_with_correct_content_type() {
|
||||
if !require_test_db() {
|
||||
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
}
|
||||
|
||||
let app = common::TestApp::new().await;
|
||||
let (user_id, session) = app
|
||||
.create_authenticated_user("pdf-export@example.com")
|
||||
.await;
|
||||
|
||||
let sections = sample_sections();
|
||||
let synth_id = app
|
||||
.insert_test_synthesis(user_id, "2026-W12", §ions)
|
||||
.await;
|
||||
|
||||
let req = authed_get(
|
||||
&format!("/api/v1/syntheses/{}/export/pdf", synth_id),
|
||||
&session,
|
||||
);
|
||||
let (status, bytes, headers) = app.raw_request_bytes(req).await;
|
||||
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::OK,
|
||||
"GET /syntheses/:id/export/pdf should return 200"
|
||||
);
|
||||
|
||||
let content_type = headers
|
||||
.get("content-type")
|
||||
.expect("Should have Content-Type header")
|
||||
.to_str()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
content_type, "application/pdf",
|
||||
"Content-Type should be application/pdf"
|
||||
);
|
||||
|
||||
let content_disposition = headers
|
||||
.get("content-disposition")
|
||||
.expect("Should have Content-Disposition header")
|
||||
.to_str()
|
||||
.unwrap();
|
||||
assert!(
|
||||
content_disposition.contains("attachment"),
|
||||
"Content-Disposition should indicate attachment download"
|
||||
);
|
||||
assert!(
|
||||
content_disposition.contains(".pdf"),
|
||||
"Content-Disposition filename should end with .pdf"
|
||||
);
|
||||
|
||||
// Also verify PDF is non-empty
|
||||
assert!(
|
||||
!bytes.is_empty(),
|
||||
"PDF response body should not be empty"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn export_pdf_body_starts_with_pdf_magic_bytes() {
|
||||
if !require_test_db() {
|
||||
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
}
|
||||
|
||||
let app = common::TestApp::new().await;
|
||||
let (user_id, session) = app
|
||||
.create_authenticated_user("pdf-magic@example.com")
|
||||
.await;
|
||||
|
||||
let sections = sample_sections();
|
||||
let synth_id = app
|
||||
.insert_test_synthesis(user_id, "2026-W12", §ions)
|
||||
.await;
|
||||
|
||||
let req = authed_get(
|
||||
&format!("/api/v1/syntheses/{}/export/pdf", synth_id),
|
||||
&session,
|
||||
);
|
||||
let (status, bytes, _headers) = app.raw_request_bytes(req).await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(
|
||||
bytes.starts_with(b"%PDF"),
|
||||
"PDF body should start with %PDF magic bytes, got first 4 bytes: {:?}",
|
||||
&bytes[..std::cmp::min(4, bytes.len())]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn export_pdf_for_non_owned_synthesis_returns_404() {
|
||||
if !require_test_db() {
|
||||
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
|
||||
return;
|
||||
}
|
||||
|
||||
let app = common::TestApp::new().await;
|
||||
let (user_a_id, _session_a) = app
|
||||
.create_authenticated_user("pdf-owner-a@example.com")
|
||||
.await;
|
||||
let (_user_b_id, session_b) = app
|
||||
.create_authenticated_user("pdf-owner-b@example.com")
|
||||
.await;
|
||||
|
||||
// Insert a synthesis for User A
|
||||
let sections = sample_sections();
|
||||
let synth_id = app
|
||||
.insert_test_synthesis(user_a_id, "2026-W12", §ions)
|
||||
.await;
|
||||
|
||||
// User B tries to export User A's synthesis as PDF
|
||||
let req = authed_get(
|
||||
&format!("/api/v1/syntheses/{}/export/pdf", synth_id),
|
||||
&session_b,
|
||||
);
|
||||
let (status, _bytes, _headers) = app.raw_request_bytes(req).await;
|
||||
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::NOT_FOUND,
|
||||
"User B should not be able to export User A's synthesis as PDF"
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,194 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
describe('Synthesis Export - File Download', () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalLocation = window.location;
|
||||
let createObjectURLMock: ReturnType<typeof vi.fn>;
|
||||
let revokeObjectURLMock: ReturnType<typeof vi.fn>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let appendChildSpy: any;
|
||||
let clickedLinks: { href: string; download: string }[];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
|
||||
clickedLinks = [];
|
||||
|
||||
// Mock URL.createObjectURL / revokeObjectURL
|
||||
createObjectURLMock = vi.fn().mockReturnValue('blob:http://localhost/fake-blob-url');
|
||||
revokeObjectURLMock = vi.fn();
|
||||
globalThis.URL.createObjectURL = createObjectURLMock;
|
||||
globalThis.URL.revokeObjectURL = revokeObjectURLMock;
|
||||
|
||||
// Mock document.body.appendChild to capture the anchor click
|
||||
appendChildSpy = vi.spyOn(document.body, 'appendChild').mockImplementation((node) => {
|
||||
if (node instanceof HTMLAnchorElement) {
|
||||
// Override click to just record it
|
||||
node.click = () => {
|
||||
clickedLinks.push({ href: node.href, download: node.download });
|
||||
};
|
||||
}
|
||||
return node;
|
||||
});
|
||||
|
||||
// Mock window.location for 401 redirect
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { href: '' },
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
appendChildSpy.mockRestore();
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: originalLocation,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('exportMarkdown triggers a file download with correct filename', async () => {
|
||||
const markdownContent = '# Synthese\n\nContenu test';
|
||||
const blob = new Blob([markdownContent], { type: 'text/markdown' });
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({
|
||||
'Content-Disposition': 'attachment; filename="synthese-semaine-12.md"',
|
||||
}),
|
||||
blob: () => Promise.resolve(blob),
|
||||
});
|
||||
|
||||
const { synthesesApi } = await import('~/api/syntheses');
|
||||
await synthesesApi.exportMarkdown('test-id');
|
||||
|
||||
// fetch was called with correct URL
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
'/api/v1/syntheses/test-id/export/markdown',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
}),
|
||||
);
|
||||
|
||||
// Blob was turned into an object URL
|
||||
expect(createObjectURLMock).toHaveBeenCalledWith(blob);
|
||||
|
||||
// An anchor was clicked to trigger download
|
||||
expect(clickedLinks).toHaveLength(1);
|
||||
expect(clickedLinks[0].download).toBe('synthese-semaine-12.md');
|
||||
|
||||
// Object URL was revoked
|
||||
expect(revokeObjectURLMock).toHaveBeenCalledWith('blob:http://localhost/fake-blob-url');
|
||||
});
|
||||
|
||||
it('exportPdf triggers a file download with correct filename', async () => {
|
||||
const pdfContent = new Uint8Array([0x25, 0x50, 0x44, 0x46]); // %PDF header
|
||||
const blob = new Blob([pdfContent], { type: 'application/pdf' });
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({
|
||||
'Content-Disposition': 'attachment; filename="synthese-semaine-12.pdf"',
|
||||
}),
|
||||
blob: () => Promise.resolve(blob),
|
||||
});
|
||||
|
||||
const { synthesesApi } = await import('~/api/syntheses');
|
||||
await synthesesApi.exportPdf('test-id');
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
'/api/v1/syntheses/test-id/export/pdf',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(createObjectURLMock).toHaveBeenCalledWith(blob);
|
||||
expect(clickedLinks).toHaveLength(1);
|
||||
expect(clickedLinks[0].download).toBe('synthese-semaine-12.pdf');
|
||||
expect(revokeObjectURLMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses fallback filename when Content-Disposition header is absent', async () => {
|
||||
const blob = new Blob(['content'], { type: 'text/markdown' });
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({}),
|
||||
blob: () => Promise.resolve(blob),
|
||||
});
|
||||
|
||||
const { synthesesApi } = await import('~/api/syntheses');
|
||||
await synthesesApi.exportMarkdown('abc-123');
|
||||
|
||||
expect(clickedLinks).toHaveLength(1);
|
||||
expect(clickedLinks[0].download).toBe('synthese-abc-123.md');
|
||||
});
|
||||
|
||||
it('throws an error on non-OK response', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
headers: new Headers({}),
|
||||
json: () => Promise.resolve({ error: 'Internal Server Error' }),
|
||||
});
|
||||
|
||||
const { synthesesApi } = await import('~/api/syntheses');
|
||||
|
||||
await expect(synthesesApi.exportMarkdown('test-id')).rejects.toEqual(
|
||||
expect.objectContaining({
|
||||
status: 500,
|
||||
message: 'Internal Server Error',
|
||||
}),
|
||||
);
|
||||
|
||||
// No download should have been triggered
|
||||
expect(createObjectURLMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('redirects to /login on 401 response', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
headers: new Headers({}),
|
||||
json: () => Promise.resolve({ error: 'Unauthorized' }),
|
||||
});
|
||||
|
||||
const { synthesesApi } = await import('~/api/syntheses');
|
||||
|
||||
await expect(synthesesApi.exportPdf('test-id')).rejects.toEqual(
|
||||
expect.objectContaining({
|
||||
status: 401,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(window.location.href).toBe('/login');
|
||||
});
|
||||
|
||||
it('sendEmail calls POST with correct payload', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 204,
|
||||
json: () => Promise.reject(new Error('No content')),
|
||||
});
|
||||
|
||||
const { synthesesApi } = await import('~/api/syntheses');
|
||||
await synthesesApi.sendEmail('synth-123', 'user@example.com');
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
'/api/v1/syntheses/synth-123/send-email',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email: 'user@example.com' }),
|
||||
headers: expect.objectContaining({
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue