use crate::{
http::CONTENT_TYPE_JSON,
standards::indieauth::AccessToken,
standards::micropub::{
paging,
query::{
CategoryResponse, ConfigurationResponse, MatchingPropertyValuesMap, PostTypeInfo, Query, Response,
SourceListResponse, SourceQuery, SourceResponse,
},
},
};
use http::header::CONTENT_TYPE;
use microformats::types::{Class, KnownClass};
use crate::{
algorithms::ptd::Type,
standards::micropub::{query::QueryKind, Error},
};
#[tracing_test::traced_test]
#[tokio::test]
async fn query_send() {
let mut client = crate::test::Client::new().await;
let token = AccessToken::new("a-bad-token");
let query = crate::standards::micropub::query::Query {
pagination: Default::default(),
kind: QueryKind::Configuration,
};
let endpoint_mock = client
.mock_server
.mock("get", "/micropub/auth-failure")
.match_query("q=config")
.with_status(400)
.with_header(CONTENT_TYPE.as_str(), CONTENT_TYPE_JSON)
.with_body(
serde_json::to_string(&serde_json::json!({
"error": "invalid_request",
"error_description": "This was a bad, bad request."
}))
.unwrap(),
)
.expect(1)
.create_async()
.await;
let endpoint = format!("{}/micropub/auth-failure", client.mock_server.url())
.parse()
.unwrap();
let query_result = query.send(&client, &endpoint, &token).await;
endpoint_mock.assert_async().await;
assert_eq!(
query_result,
Err(Error::bad_request("This was a bad, bad request").into()),
"reports an authorization failure"
);
}
#[test]
fn query_from_qs_str() {
crate::test::Client::default();
assert_eq!(
Ok(vec![Type::Note, Type::Read]),
serde_qs::from_str::<Query>(
"q=source&post-type[]=note&post-type[]=read&channel=jump&limit=5"
)
.map_err(|e| e.to_string())
.map(|q| q.kind)
.map(|k| match k {
QueryKind::Source(query) => query.post_type,
_ => vec![],
}),
"provides deserialization of the source query with multiple post type asked for"
);
assert_eq!(
Ok(vec![Type::Video, Type::Read]),
serde_qs::Config::new(1, false)
.deserialize_str::<Query>(
"q=source&post-type%5B0%5D=video&post-type%5B1%5D=read&limit=5"
)
.map_err(|e| e.to_string())
.map(|q| q.kind)
.map(|k| match k {
QueryKind::Source(query) => query.post_type,
_ => vec![],
}),
"provides deserialization of the source query with multiple post type asked for"
);
assert_eq!(
None,
serde_qs::from_str::<Query>("q=source&post-type=")
.err()
.map(|e| e.to_string()),
"ignores if the post type value is 'empty'"
);
assert_eq!(
None,
serde_qs::from_str::<Query>("q=source&syndicate-to=3")
.err()
.map(|e| e.to_string()),
"supports filtering by syndication targets"
);
assert_eq!(
None,
serde_qs::from_str::<Query>("q=config")
.err()
.map(|e| e.to_string()),
"provides deserialization of the config query"
);
}
#[test]
fn query_from_qs_str_with_property_filtering() {
crate::test::Client::default();
assert_eq!(
Ok(QueryKind::Source(Box::new(SourceQuery {
exists: vec!["in-reply-to".to_string()],
not_exists: vec!["like-of".to_string()],
matching_properties: MatchingPropertyValuesMap::from_iter(vec![
("byline".into(), vec!["today".into()]),
("range".into(), vec!["3".into(), "10".into()])
]),
..Default::default()
}))),
serde_qs::from_str::<Query>("q=source¬-exists=like-of&exists=in-reply-to&property-byline=today&property-range[]=3&property-range[]=10")
.map(|q| q.kind)
.map_err(|e| e.to_string()),
"provides deserialization of the config query"
);
}
#[test]
fn query_to_str() {
crate::test::Client::default();
let result1 = serde_qs::to_string(&QueryKind::Source(Box::new(SourceQuery {
post_type: vec![Type::Article],
..Default::default()
})));
assert_eq!(
result1.as_ref().err().map(|s| s.to_string()),
None,
"can query a list of articles"
);
assert_eq!(
Some("q=source&post-type=article".to_string()),
result1.ok(),
"can query a list of articles"
);
let result2 = serde_qs::to_string(&QueryKind::Source(Box::new(SourceQuery {
post_type: vec![Type::Article, Type::Note],
..Default::default()
})));
assert_eq!(
Some("q=source&post-type[0]=article&post-type[1]=note".to_string()),
result2.ok(),
"can query a list of articles and notes"
);
}
#[test]
#[cfg(feature = "experimental_channels")]
fn query_response_for_channels() {
use super::extension;
assert_eq!(
Some(Response::Channel(extension::channel::QueryResponse {
channels: vec![extension::channel::Form::Expanded {
uid: "magic".to_string(),
name: "Magic".to_string(),
properties: serde_json::json!({"grr": "bark"}).try_into().unwrap()
}],
paging: Default::default()
})),
serde_json::from_value(serde_json::json!({
"channels": [{
"uid": "magic",
"name": "Magic",
"grr": "bark"
}]
}))
.ok()
)
}
#[test]
fn query_response_for_configuration() {
assert_eq!(
Ok(Response::Configuration(ConfigurationResponse {
q: vec!["channels".to_owned()],
category: vec!["tag".into()],
media_endpoint: None,
post_types: vec![PostTypeInfo::Simple(Type::Note)],
channels: Default::default(),
syndicate_to: Default::default(),
extensions: Default::default()
})),
serde_json::from_value(serde_json::json!({
"q": ["channels"],
"post-types": ["note"],
"category": ["tag"]
}))
.map(Response::Configuration)
.map_err(crate::Error::JSON)
);
}
#[test]
#[cfg(feature = "experimental_syndication")]
fn query_response_for_configuration_with_syndication() {
use crate::standards::micropub::extension;
assert_eq!(
Ok(Response::Configuration(ConfigurationResponse {
q: vec!["channels".to_owned()],
category: vec!["tag".into()],
media_endpoint: None,
post_types: vec![PostTypeInfo::Simple(Type::Note)],
channels: Default::default(),
syndicate_to: vec![extension::syndication::Target {
uid: "magic".into(),
name: "cookie".into(),
..Default::default()
}],
extensions: Default::default()
})),
serde_json::from_value(serde_json::json!({
"q": ["channels"],
"post-types": ["note"],
"category": ["tag"],
"syndicate-to": [
{
"uid": "magic",
"name": "cookie"
}
]
}))
.map(Response::Configuration)
.map_err(crate::Error::JSON)
);
}
#[test]
#[cfg(feature = "experimental_channels")]
fn query_response_for_configuration_with_channels() {
assert_eq!(
Ok(Response::Configuration(ConfigurationResponse {
q: vec!["channels".to_owned()],
category: vec!["tag".into()],
media_endpoint: None,
post_types: vec![PostTypeInfo::Simple(Type::Note)],
channels: Default::default(),
syndicate_to: Default::default(),
extensions: Default::default()
})),
serde_json::from_value(serde_json::json!({
"q": ["channels"],
"post-types": ["note"],
"category": ["tag"]
}))
.map(Response::Configuration)
.map_err(crate::Error::JSON)
);
}
#[test]
fn query_response_for_categories() {
assert_eq!(
Ok(Response::Category(CategoryResponse {
categories: vec!["jump".into(), "kick".into(), "spin".into()],
pagination: Default::default()
})),
serde_json::from_value(serde_json::json!({
"categories": ["jump", "kick", "spin"]
}))
.map_err(|e| format!("{:#?}", e)),
"works without paging"
);
}
#[test]
fn query_response_for_categories_with_paging() {
use super::extension::Order;
assert_eq!(
Ok(CategoryResponse {
categories: vec!["jump".into(), "kick".into(), "spin".into()],
pagination: paging::Fields {
paging: paging::Query {
order: Some(Order::Descending),
..Default::default()
},
..Default::default()
}
}),
serde_json::from_value(serde_json::json!({
"categories": ["jump", "kick", "spin"], "paging": { "order": "desc" }
}))
.map_err(|e| format!("{:#?}", e)),
"works with paging"
)
}
#[test]
fn query_response_for_source() {
crate::test::Client::default();
let item = microformats::types::Item {
r#type: vec![Class::Known(KnownClass::Entry)],
..Default::default()
};
assert_eq!(
Ok(Response::Source(SourceResponse {
post_type: vec![],
item
})),
serde_json::from_value(serde_json::json!({
"type": ["h-entry"],
"properties": {}
}))
.map_err(|e| e.to_string())
);
assert_eq!(
serde_json::from_value::<Response>(serde_json::json!(
{
"post-type": [
"article"
],
"properties": {
"audience": [],
"category": [],
"channel": [
"all"
],
"content": {
"html": "<p>well-here-we-go</p>"
},
"name": "magic-omg",
"post-status": [
"published"
],
"published": [
"2022-02-12T23:22:27+00:00"
],
"slug": [
"Gzg043ii"
],
"syndication": [],
"updated": [
"2022-02-12T23:22:27+00:00"
],
"url": [
"http://localhost:3112/Gzg043ii"
],
"visibility": [
"public"
]
},
"type": [
"h-entry"
]
}
))
.map_err(|e| e.to_string())
.err(),
None,
)
}
#[test]
fn post_type_info_simple_format() {
let json = serde_json::json!(["note", "article"]);
let result: Result<Vec<PostTypeInfo>, _> = serde_json::from_value(json);
assert_eq!(
result.ok(),
Some(vec![
PostTypeInfo::Simple(Type::Note),
PostTypeInfo::Simple(Type::Article)
])
);
}
#[test]
fn post_type_info_extended_format() {
let json = serde_json::json!([
{
"type": "note",
"name": "Note",
"properties": ["content", "category", "published"]
},
{
"type": "article",
"name": "Article"
}
]);
let result: Result<Vec<PostTypeInfo>, _> = serde_json::from_value(json);
assert!(result.is_ok());
let types = result.unwrap();
assert_eq!(types.len(), 2);
match &types[0] {
PostTypeInfo::Extended { r#type, name, properties } => {
assert_eq!(r#type, &Type::Note);
assert_eq!(name, "Note");
assert_eq!(
properties.as_ref().unwrap(),
&vec!["content".to_string(), "category".to_string(), "published".to_string()]
);
}
_ => panic!("Expected Extended variant"),
}
match &types[1] {
PostTypeInfo::Extended { r#type, name, properties } => {
assert_eq!(r#type, &Type::Article);
assert_eq!(name, "Article");
assert_eq!(properties, &None);
}
_ => panic!("Expected Extended variant"),
}
}
#[test]
fn post_type_info_serialization() {
let simple = PostTypeInfo::Simple(Type::Note);
let json = serde_json::to_value(&simple).unwrap();
assert_eq!(json, serde_json::json!("note"));
let extended = PostTypeInfo::Extended {
r#type: Type::Article,
name: "Article".to_string(),
properties: Some(vec!["content".to_string(), "name".to_string()]),
};
let json = serde_json::to_value(&extended).unwrap();
assert_eq!(
json,
serde_json::json!({
"type": "article",
"name": "Article",
"properties": ["content", "name"]
})
);
}
#[test]
fn config_response_with_extended_post_types() {
let json = serde_json::json!({
"post-types": [
{
"type": "note",
"name": "Note",
"properties": ["content", "category"]
},
{
"type": "article",
"name": "Article",
"properties": ["content", "name", "summary"]
}
],
"media-endpoint": "https://example.com/media"
});
let result: Result<ConfigurationResponse, _> = serde_json::from_value(json);
assert!(result.is_ok());
let config = result.unwrap();
assert_eq!(config.post_types.len(), 2);
assert_eq!(config.media_endpoint, Some("https://example.com/media".parse().unwrap()));
}
#[test]
fn config_response_backward_compatible() {
let json = serde_json::json!({
"post-types": ["note", "article", "photo"]
});
let result: Result<ConfigurationResponse, _> = serde_json::from_value(json);
assert!(result.is_ok());
let config = result.unwrap();
assert_eq!(config.post_types.len(), 3);
for pt in &config.post_types {
assert!(matches!(pt, PostTypeInfo::Simple(_)));
}
}
#[test]
fn source_list_response_basic() {
use microformats::types::{Class, KnownClass};
let json = serde_json::json!({
"items": [
{
"type": ["h-entry"],
"properties": {
"content": ["Hello world"]
}
},
{
"type": ["h-entry"],
"properties": {
"content": ["Second post"]
}
}
]
});
let result: Result<SourceListResponse, _> = serde_json::from_value(json);
assert!(result.is_ok());
let list = result.unwrap();
assert_eq!(list.items.len(), 2);
assert_eq!(list.items[0].item.r#type, vec![Class::Known(KnownClass::Entry)]);
}
#[test]
fn source_list_response_with_paging() {
let json = serde_json::json!({
"items": [
{
"type": ["h-entry"],
"properties": {}
}
],
"paging": {
"after": "cursor_token_123"
}
});
let result: Result<SourceListResponse, _> = serde_json::from_value(json);
assert!(result.is_ok());
let list = result.unwrap();
assert_eq!(list.items.len(), 1);
assert!(list.paging.paging.after.is_some());
assert_eq!(list.paging.paging.after.unwrap(), "cursor_token_123");
}
#[test]
fn source_list_response_in_response_enum() {
use microformats::types::{Class, KnownClass};
let json = serde_json::json!({
"items": [
{
"type": ["h-entry"],
"properties": {
"content": ["Test post"]
}
}
]
});
let result: Result<Response, _> = serde_json::from_value(json);
assert!(result.is_ok(), "Failed to deserialize as Response");
match result.unwrap() {
Response::SourceList(list) => {
assert_eq!(list.items.len(), 1);
assert_eq!(list.items[0].item.r#type, vec![Class::Known(KnownClass::Entry)]);
}
other => panic!("Expected SourceList variant, got {:?}", other),
}
}
#[test]
fn source_list_empty_items() {
let json = serde_json::json!({
"items": []
});
let result: Result<SourceListResponse, _> = serde_json::from_value(json);
assert!(result.is_ok(), "Failed to deserialize empty items list");
let list = result.unwrap();
assert_eq!(list.items.len(), 0);
}
#[test]
fn source_list_response_serialization() {
use microformats::types::{Class, KnownClass, Item};
let list = SourceListResponse {
items: vec![
SourceResponse {
post_type: vec![Type::Note],
item: Item {
r#type: vec![Class::Known(KnownClass::Entry)],
properties: std::collections::BTreeMap::new(),
..Default::default()
},
}
],
paging: Default::default(),
};
let json = serde_json::to_value(&list).unwrap();
assert!(json.get("items").is_some());
assert!(json.get("items").unwrap().is_array());
assert_eq!(json.get("items").unwrap().as_array().unwrap().len(), 1);
}
#[test]
fn syndication_targets_response_basic() {
let json = serde_json::json!({
"syndicate-to": [
{
"uid": "https://twitter.com/user",
"name": "Twitter"
},
{
"uid": "https://facebook.com/user",
"name": "Facebook"
}
]
});
let result: Result<super::SyndicationTargetsResponse, _> = serde_json::from_value(json);
assert!(result.is_ok(), "Failed to deserialize syndication targets");
let response = result.unwrap();
assert_eq!(response.syndicate_to.len(), 2);
assert_eq!(response.syndicate_to[0].uid, "https://twitter.com/user");
assert_eq!(response.syndicate_to[0].name, "Twitter");
}
#[test]
#[cfg(feature = "experimental_syndication")]
fn syndication_targets_response_with_full_info() {
let json = serde_json::json!({
"syndicate-to": [
{
"uid": "https://twitter.com/user",
"name": "Twitter"
},
{
"uid": "https://facebook.com/user",
"name": "Facebook"
}
]
});
let result: Result<super::SyndicationTargetsResponse, _> = serde_json::from_value(json);
assert!(result.is_ok(), "Failed to deserialize syndication targets");
let response = result.unwrap();
assert_eq!(response.syndicate_to.len(), 2);
assert_eq!(response.syndicate_to[0].uid, "https://twitter.com/user");
assert_eq!(response.syndicate_to[0].name, "Twitter");
}
#[test]
#[cfg(feature = "experimental_syndication")]
fn syndication_targets_empty() {
let json = serde_json::json!({
"syndicate-to": []
});
let result: Result<super::SyndicationTargetsResponse, _> = serde_json::from_value(json);
assert!(result.is_ok(), "Failed to deserialize empty syndication targets");
let response = result.unwrap();
assert_eq!(response.syndicate_to.len(), 0);
}
#[test]
#[cfg(feature = "experimental_syndication")]
fn syndication_targets_response_serialization() {
use crate::standards::micropub::extension::syndication;
let response = super::SyndicationTargetsResponse {
syndicate_to: vec![
syndication::Target {
uid: "https://twitter.com/user".to_string(),
name: "Twitter".to_string(),
service: None,
user: None,
}
],
};
let json = serde_json::to_value(&response).unwrap();
assert!(json.get("syndicate-to").is_some());
assert!(json.get("syndicate-to").unwrap().is_array());
assert_eq!(json.get("syndicate-to").unwrap().as_array().unwrap().len(), 1);
}
#[test]
#[cfg(feature = "experimental_syndication")]
fn syndication_targets_in_response_enum() {
let json = serde_json::json!({
"syndicate-to": [
{
"uid": "https://twitter.com/user",
"name": "Twitter"
}
]
});
let result: Result<super::Response, _> = serde_json::from_value(json);
assert!(result.is_ok(), "Failed to deserialize as Response");
match result.unwrap() {
super::Response::SyndicateTo(targets) => {
assert_eq!(targets.syndicate_to.len(), 1);
assert_eq!(targets.syndicate_to[0].name, "Twitter");
}
other => panic!("Expected SyndicateTo variant, got {:?}", other),
}
}
#[test]
fn config_response_extensions_appear_when_present() {
let config = ConfigurationResponse {
q: vec!["source".to_string()],
category: vec![],
media_endpoint: None,
post_types: vec![],
#[cfg(feature = "experimental_channels")]
channels: vec![],
#[cfg(feature = "experimental_syndication")]
syndicate_to: vec![],
extensions: vec!["batch".to_string(), "channels".to_string()],
};
let json = serde_json::to_value(&config).unwrap();
assert_eq!(
json.get("extensions"),
Some(&serde_json::json!(["batch", "channels"])),
"extensions should appear in JSON when populated"
);
}
#[test]
fn config_response_extensions_skipped_when_empty() {
let config = ConfigurationResponse {
q: vec!["source".to_string()],
category: vec![],
media_endpoint: None,
post_types: vec![],
#[cfg(feature = "experimental_channels")]
channels: vec![],
#[cfg(feature = "experimental_syndication")]
syndicate_to: vec![],
extensions: vec![],
};
let json = serde_json::to_value(&config).unwrap();
assert_eq!(
json.get("extensions"),
None,
"extensions should be skipped when empty"
);
}
#[test]
fn config_response_extensions_deserialization() {
let json = serde_json::json!({
"q": ["source", "config"],
"extensions": ["batch", "media-query", "custom-extension"]
});
let result: Result<ConfigurationResponse, _> = serde_json::from_value(json);
assert!(result.is_ok(), "Failed to deserialize config with extensions");
let config = result.unwrap();
assert_eq!(config.extensions.len(), 3);
assert!(config.extensions.contains(&"batch".to_string()));
assert!(config.extensions.contains(&"media-query".to_string()));
assert!(config.extensions.contains(&"custom-extension".to_string()));
}
#[test]
fn config_response_extensions_roundtrip() {
let original = ConfigurationResponse {
q: vec!["source".to_string(), "config".to_string()],
category: vec!["tag".to_string()],
media_endpoint: Some("https://example.com/media".parse().unwrap()),
post_types: vec![PostTypeInfo::Simple(Type::Note)],
#[cfg(feature = "experimental_channels")]
channels: vec![],
#[cfg(feature = "experimental_syndication")]
syndicate_to: vec![],
extensions: vec!["batch".to_string(), "publish-delay".to_string()],
};
let json = serde_json::to_value(&original).unwrap();
let deserialized: ConfigurationResponse = serde_json::from_value(json).unwrap();
assert_eq!(original.extensions, deserialized.extensions);
assert_eq!(original.q, deserialized.q);
assert_eq!(original.category, deserialized.category);
}