use derive_builder::Builder;
use mockito::{Mock, Server};
use serde::{Deserialize, Serialize};
use serde_json::json;
#[derive(Serialize, Deserialize, Debug, Clone, Builder, Default)]
#[builder(setter(into))]
#[builder(default)]
#[builder(field(public))]
pub struct MockParams {
pub channel_access_token: String,
pub to: String,
pub messages: Vec<serde_json::Value>,
pub notification_disabled: Option<bool>,
pub custom_aggregation_units: Option<Vec<String>>,
pub status_code: usize,
pub sent_message_ids: Vec<String>,
pub sent_message_quote_tokens: Vec<Option<String>>,
pub response_message: Option<String>,
pub error_message: Option<String>,
}
pub async fn make_mock(server: &mut Server, builder: Option<MockParamsBuilder>) -> Mock {
let mut builder = builder.unwrap_or_default();
if builder.channel_access_token.is_none() {
builder.channel_access_token("test_channel_access_token".to_string());
}
if builder.to.is_none() {
builder.to("U123456789".to_string());
}
if builder.messages.is_none() {
builder.messages(vec![json!({"type": "text", "text": "Hello!"})]);
}
if builder.status_code.is_none() {
builder.status_code(200usize);
}
if builder.sent_message_ids.is_none() {
builder.sent_message_ids(vec!["msg123".to_string()]);
}
if builder.sent_message_quote_tokens.is_none() {
builder.sent_message_quote_tokens(vec![Some("token123".to_string())]);
}
if builder.error_message.is_none() {
builder.error_message("error occurred".to_string());
}
let params = builder.build().unwrap();
let body_json = if params.status_code == 200 {
let sent_messages: Vec<serde_json::Value> = params
.sent_message_ids
.iter()
.zip(params.sent_message_quote_tokens.iter())
.map(|(id, quote_token)| {
let mut msg = json!({
"id": id
});
if let Some(token) = quote_token {
msg["quoteToken"] = json!(token);
}
msg
})
.collect();
let mut response = json!({
"sentMessages": sent_messages
});
if let Some(msg) = params.response_message {
response["message"] = json!(msg);
}
response
} else {
json!({
"message": params.error_message
})
};
let expected_body =
if params.notification_disabled.is_some() || params.custom_aggregation_units.is_some() {
let mut body = json!({
"to": params.to,
"messages": params.messages
});
if let Some(notification_disabled) = params.notification_disabled {
body["notificationDisabled"] = json!(notification_disabled);
}
if let Some(custom_aggregation_units) = params.custom_aggregation_units {
body["customAggregationUnits"] = json!(custom_aggregation_units);
}
body
} else {
json!({
"to": params.to,
"messages": params.messages
})
};
server
.mock("POST", "/v2/bot/message/push")
.match_header(
"authorization",
format!("Bearer {}", params.channel_access_token).as_str(),
)
.match_body(mockito::Matcher::Json(expected_body))
.with_status(params.status_code)
.with_header("content-type", "application/json")
.with_body(body_json.to_string())
.create_async()
.await
}
#[cfg(test)]
mod tests {
use crate::{LineOptions, error::Error, messaging_api::post_v2_bot_message_push};
use super::*;
#[tokio::test]
async fn test_make_mock_post_v2_bot_message_push_success() {
let mut server = Server::new_async().await;
let messages = vec![json!({"type": "text", "text": "Hello World!"})];
let mut builder = MockParamsBuilder::default();
builder.messages(messages.clone());
builder.sent_message_ids(vec!["msg456".to_string()]);
builder.sent_message_quote_tokens(vec![Some("quote456".to_string())]);
let mock = make_mock(&mut server, Some(builder)).await;
let request_body =
post_v2_bot_message_push::RequestBody::new("U123456789", messages).unwrap();
let _res = post_v2_bot_message_push::execute(
request_body,
"test_channel_access_token",
&LineOptions {
prefix_url: Some(server.url()),
..Default::default()
},
None,
)
.await
.unwrap();
mock.assert_async().await;
}
#[tokio::test]
async fn test_make_mock_post_v2_bot_message_push_failure() {
let mut server = Server::new_async().await;
let mut builder = MockParamsBuilder::default();
builder.status_code(400usize);
let mock = make_mock(&mut server, Some(builder)).await;
let request_body = post_v2_bot_message_push::RequestBody::new(
"U123456789",
vec![json!({"type": "text", "text": "Hello!"})],
)
.unwrap();
let res = post_v2_bot_message_push::execute(
request_body,
"test_channel_access_token",
&LineOptions {
prefix_url: Some(server.url()),
..Default::default()
},
None,
)
.await;
match res {
Err(e) => match *e {
Error::Line(response, status_code, _header) => {
assert_eq!(status_code, 400);
assert_eq!(response.message, "error occurred");
}
_ => panic!("Unexpected error"),
},
_ => panic!("Unexpected response"),
}
mock.assert_async().await;
}
#[tokio::test]
async fn test_make_mock_post_v2_bot_message_push_callbacks() {
use std::sync::{Arc, Mutex};
let mut server = Server::new_async().await;
let messages = vec![json!({"type": "text", "text": "Hello World!"})];
let mut builder = MockParamsBuilder::default();
builder.messages(messages.clone());
let mock = make_mock(&mut server, Some(builder)).await;
let captured_req = Arc::new(Mutex::new(Vec::<(bool, serde_json::Value)>::new()));
let captured_res = Arc::new(Mutex::new(Vec::<(u16, serde_json::Value)>::new()));
let creq = captured_req.clone();
let cres = captured_res.clone();
let options = LineOptions {
prefix_url: Some(server.url()),
..Default::default()
}
.with_on_request(move |log| {
let has_auth = log
.headers()
.is_some_and(|h| h.contains_key("authorization"));
creq.lock().unwrap().push((has_auth, log.body().clone()));
})
.with_on_response(move |_req, res| {
cres.lock()
.unwrap()
.push((res.status_code().as_u16(), res.as_value().into_owned()));
});
let request_body =
post_v2_bot_message_push::RequestBody::new("U123456789", messages).unwrap();
let _res = post_v2_bot_message_push::execute(
request_body,
"test_channel_access_token",
&options,
None,
)
.await
.unwrap();
mock.assert_async().await;
let reqs = captured_req.lock().unwrap();
assert_eq!(reqs.len(), 1);
assert!(reqs[0].0, "authorization header must be present");
assert_eq!(reqs[0].1["to"], json!("U123456789"));
assert!(reqs[0].1["messages"].is_array());
let ress = captured_res.lock().unwrap();
assert_eq!(ress.len(), 1);
assert_eq!(ress[0].0, 200);
assert!(ress[0].1["sentMessages"].is_array());
}
#[tokio::test]
async fn test_make_mock_post_v2_bot_message_push_callbacks_response_headers() {
use std::sync::{Arc, Mutex};
let mut server = Server::new_async().await;
let mock = make_mock(&mut server, None).await;
let has_content_type = Arc::new(Mutex::new(false));
let hct = has_content_type.clone();
let options = LineOptions::default()
.with_prefix_url(server.url())
.with_on_response(move |_req, res| {
*hct.lock().unwrap() = res.headers().contains_key("content-type");
});
let request_body = post_v2_bot_message_push::RequestBody::new(
"U123456789",
vec![json!({"type": "text", "text": "Hello!"})],
)
.unwrap();
let _res = post_v2_bot_message_push::execute(
request_body,
"test_channel_access_token",
&options,
None,
)
.await
.unwrap();
mock.assert_async().await;
assert!(
*has_content_type.lock().unwrap(),
"on_response must receive the cloned response headers (content-type)"
);
}
#[tokio::test]
async fn test_make_mock_post_v2_bot_message_push_callbacks_retry() {
use std::sync::{Arc, Mutex};
let mut server = Server::new_async().await;
let mut builder = MockParamsBuilder::default();
builder.status_code(500usize);
let mock = make_mock(&mut server, Some(builder)).await.expect(3);
let req_count = Arc::new(Mutex::new(0usize));
let res_count = Arc::new(Mutex::new(0usize));
let rc = req_count.clone();
let sc = res_count.clone();
let options = LineOptions {
prefix_url: Some(server.url()),
try_count: Some(3),
..Default::default()
}
.with_on_request(move |_log| {
*rc.lock().unwrap() += 1;
})
.with_on_response(move |_req, _res| {
*sc.lock().unwrap() += 1;
});
let request_body = post_v2_bot_message_push::RequestBody::new(
"U123456789",
vec![json!({"type": "text", "text": "Hello!"})],
)
.unwrap();
let _res = post_v2_bot_message_push::execute(
request_body,
"test_channel_access_token",
&options,
None,
)
.await;
assert_eq!(
*req_count.lock().unwrap(),
3,
"on_request fires per attempt"
);
assert_eq!(
*res_count.lock().unwrap(),
3,
"on_response fires per attempt"
);
mock.assert_async().await;
}
#[tokio::test]
async fn test_make_mock_post_v2_bot_message_push_callbacks_conflict() {
use std::sync::{Arc, Mutex};
let mut server = Server::new_async().await;
let mock = server
.mock("POST", "/v2/bot/message/push")
.with_status(409)
.with_header("content-type", "application/json")
.with_body(json!({"sentMessages": [{"id": "msg123"}]}).to_string())
.create_async()
.await;
let captured = Arc::new(Mutex::new(Vec::<u16>::new()));
let c = captured.clone();
let options = LineOptions {
prefix_url: Some(server.url()),
try_count: Some(2),
..Default::default()
}
.with_on_response(move |_req, res| {
c.lock().unwrap().push(res.status_code().as_u16());
});
let request_body = post_v2_bot_message_push::RequestBody::new(
"U123456789",
vec![json!({"type": "text", "text": "Hello!"})],
)
.unwrap();
let res = post_v2_bot_message_push::execute(
request_body,
"test_channel_access_token",
&options,
Some("retry-key-1".to_string()),
)
.await;
mock.assert_async().await;
assert_eq!(*captured.lock().unwrap(), vec![409]);
assert!(res.is_ok(), "409 with retry_key is treated as delivered");
}
#[tokio::test]
async fn test_make_mock_post_v2_bot_message_push_callbacks_retry_key_header() {
use std::sync::{Arc, Mutex};
let mut server = Server::new_async().await;
let mock = make_mock(&mut server, Some(MockParamsBuilder::default())).await;
let captured = Arc::new(Mutex::new(Vec::<bool>::new()));
let c = captured.clone();
let options = LineOptions {
prefix_url: Some(server.url()),
try_count: Some(2),
..Default::default()
}
.with_on_request(move |log| {
let has_retry_key = log
.headers()
.is_some_and(|h| h.contains_key("x-line-retry-key"));
c.lock().unwrap().push(has_retry_key);
});
let request_body = post_v2_bot_message_push::RequestBody::new(
"U123456789",
vec![json!({"type": "text", "text": "Hello!"})],
)
.unwrap();
let _res = post_v2_bot_message_push::execute(
request_body,
"test_channel_access_token",
&options,
Some("retry-key-1".to_string()),
)
.await
.unwrap();
mock.assert_async().await;
assert_eq!(*captured.lock().unwrap(), vec![true]);
}
#[tokio::test]
async fn test_make_mock_post_v2_bot_message_push_callbacks_response_only() {
use std::sync::{Arc, Mutex};
let mut server = Server::new_async().await;
let mock = make_mock(&mut server, Some(MockParamsBuilder::default())).await;
let captured = Arc::new(Mutex::new(Vec::<bool>::new()));
let c = captured.clone();
let options = LineOptions {
prefix_url: Some(server.url()),
..Default::default()
}
.with_on_response(move |req, _res| {
let has_auth = req
.headers()
.is_some_and(|h| h.contains_key("authorization"));
c.lock().unwrap().push(has_auth);
});
let request_body = post_v2_bot_message_push::RequestBody::new(
"U123456789",
vec![json!({"type": "text", "text": "Hello!"})],
)
.unwrap();
let _res = post_v2_bot_message_push::execute(
request_body,
"test_channel_access_token",
&options,
None,
)
.await
.unwrap();
mock.assert_async().await;
assert_eq!(*captured.lock().unwrap(), vec![true]);
}
#[tokio::test]
async fn test_make_mock_post_v2_bot_message_push_headers_redacted() {
use std::sync::{Arc, Mutex};
let mut server = Server::new_async().await;
let mock = make_mock(&mut server, Some(MockParamsBuilder::default())).await;
let captured = Arc::new(Mutex::new(Vec::<(Option<String>, bool)>::new()));
let c = captured.clone();
let options = LineOptions {
prefix_url: Some(server.url()),
..Default::default()
}
.with_on_request(move |log| {
let redacted = log.headers_redacted().expect("headers captured");
let auth = redacted
.get("authorization")
.map(|v| v.to_str().unwrap().to_string());
let has_content_type = redacted.contains_key("content-type");
c.lock().unwrap().push((auth, has_content_type));
});
let request_body = post_v2_bot_message_push::RequestBody::new(
"U123456789",
vec![json!({"type": "text", "text": "Hello!"})],
)
.unwrap();
let _res = post_v2_bot_message_push::execute(
request_body,
"test_channel_access_token",
&options,
None,
)
.await
.unwrap();
mock.assert_async().await;
let captured = captured.lock().unwrap();
assert_eq!(captured.len(), 1);
assert_eq!(captured[0].0, Some("***".to_string()));
assert!(captured[0].1, "non-secret header must be preserved");
}
#[tokio::test]
async fn test_make_mock_post_v2_bot_message_push_body_was_json() {
use std::sync::{Arc, Mutex};
let mut server = Server::new_async().await;
let mock = make_mock(&mut server, Some(MockParamsBuilder::default())).await;
let captured = Arc::new(Mutex::new(Vec::<bool>::new()));
let c = captured.clone();
let options = LineOptions {
prefix_url: Some(server.url()),
..Default::default()
}
.with_on_response(move |_req, res| {
c.lock().unwrap().push(res.body_was_json());
});
let request_body = post_v2_bot_message_push::RequestBody::new(
"U123456789",
vec![json!({"type": "text", "text": "Hello!"})],
)
.unwrap();
let _ = post_v2_bot_message_push::execute(
request_body,
"test_channel_access_token",
&options,
None,
)
.await
.unwrap();
mock.assert_async().await;
assert_eq!(*captured.lock().unwrap(), vec![true]);
let mut server = Server::new_async().await;
let mock = server
.mock("POST", "/v2/bot/message/push")
.with_status(502)
.with_header("content-type", "text/plain")
.with_body("Bad Gateway")
.create_async()
.await;
let captured = Arc::new(Mutex::new(Vec::<(bool, serde_json::Value)>::new()));
let c = captured.clone();
let options = LineOptions {
prefix_url: Some(server.url()),
..Default::default()
}
.with_on_response(move |_req, res| {
c.lock()
.unwrap()
.push((res.body_was_json(), res.as_value().into_owned()));
});
let request_body = post_v2_bot_message_push::RequestBody::new(
"U123456789",
vec![json!({"type": "text", "text": "Hello!"})],
)
.unwrap();
let res = post_v2_bot_message_push::execute(
request_body,
"test_channel_access_token",
&options,
None,
)
.await;
mock.assert_async().await;
let captured = captured.lock().unwrap();
assert_eq!(captured.len(), 1);
assert!(!captured[0].0, "非JSONなので body_was_json は false");
assert_eq!(captured[0].1, json!("Bad Gateway"));
assert!(matches!(*res.unwrap_err(), Error::OtherText(_, _, _)));
}
}