Files
fluxer/fluxer_push/src/payload.rs
T

794 lines
26 KiB
Rust

// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::job::{ClearJob, MessageJob, RingJob};
use crate::metrics::PayloadShrink;
use crate::unix_seconds;
use serde_json::{Map, Value, json};
const WEB_PUSH_MARKER: u64 = 8030;
const CLEAR_TYPE: &str = "notification_clear";
const CLEAR_ACTION: &str = "clear_channel";
const RING_TYPE: &str = "call_ring";
const FALLBACK_TAG: &str = "fluxer-message";
const FALLBACK_TITLE: &str = "Fluxer";
const APNS_CATEGORY: &str = "fluxer_message";
const FCM_CLICK_ACTION: &str = "FLUXER_MESSAGE";
const APNS_SOUND: &str = "default";
const APNS_ALERT_EXPIRATION_SECONDS: i64 = 86_400;
const APNS_BACKGROUND_EXPIRATION_SECONDS: i64 = 86_400;
const APNS_COLLAPSE_ID_MAX_BYTES: usize = 64;
const SHRUNK_BODY_MAX_BYTES: usize = 40;
const MINIMAL_TITLE_MAX_BYTES: usize = 120;
const MEDIA_KEYS: [&str; 2] = ["image_url", "image"];
const ICON_KEYS: [&str; 3] = ["icon", "badge", "author_avatar_url"];
const RING_CALLER_ID_KEY: &str = "caller_id";
const RING_CALLER_NAME_KEY: &str = "caller_name";
const RING_CALLER_AVATAR_KEY: &str = "caller_avatar_url";
const RING_AVATAR_KEYS: [&str; 1] = [RING_CALLER_AVATAR_KEY];
const RING_IDENTITY_KEYS: [&str; 2] = [RING_CALLER_ID_KEY, RING_CALLER_NAME_KEY];
const THREAD_DATA_KEYS: [&str; 3] = ["parent_id", "parent_name", "channel_name"];
const NOTIF_TYPE_KEY: &str = "notif_type_id";
const FORUM_THREAD_CREATED_NOTIF_TYPE: u64 = 9;
const FCM_DEFAULT_CHANNEL_ID: &str = "fluxer_default_push";
const FCM_FORUM_THREAD_CREATED_CHANNEL_ID: &str = "fluxer_forum_thread_created";
const MINIMAL_DATA_KEYS: [&str; 11] = [
"type",
"action",
"channel_id",
"parent_id",
NOTIF_TYPE_KEY,
"message_id",
"guild_id",
"target_user_id",
"notification_tag",
"url",
"badge_count",
];
pub fn web_push_message(job: &MessageJob, target_user_id: &str, badge_count: u32) -> Value {
let fields = &job.notification;
let image_url = fields.image_url.as_deref();
let guild_id = if job.is_direct_message() {
Value::Null
} else {
Value::String(job.guild_id.clone())
};
let mut data = json!({
"channel_id": job.channel_id,
"author_avatar_url": fields.icon,
"message_id": job.message_id,
"notification_tag": fields.notification_tag,
"guild_id": guild_id,
"url": fields.url,
"badge_count": badge_count,
"target_user_id": target_user_id,
"has_media": image_url.is_some(),
});
merge_thread_fields(&mut data, job);
let notification = json!({
"title": fields.title,
"body": fields.body,
"icon": fields.icon,
"badge": fields.badge,
"tag": fields.tag,
"navigate": fields.url,
"app_badge": badge_count.to_string(),
"data": data,
});
let mut envelope = json!({
"web_push": WEB_PUSH_MARKER,
"notification": notification,
"title": fields.title,
"body": fields.body,
"icon": fields.icon,
"badge": fields.badge,
"tag": fields.tag,
"data": data,
});
merge_image_fields(&mut envelope, image_url);
envelope
}
fn merge_thread_fields(data: &mut Value, job: &MessageJob) {
let Some(object) = data.as_object_mut() else {
return;
};
let fields = [
job.parent_id.as_deref(),
job.parent_name.as_deref(),
job.channel_name.as_deref(),
];
for (key, value) in THREAD_DATA_KEYS.into_iter().zip(fields) {
if let Some(value) = value.filter(|value| !value.is_empty()) {
object.insert(key.to_owned(), value.into());
}
}
if job.is_forum_thread_created() {
object.insert(
NOTIF_TYPE_KEY.to_owned(),
FORUM_THREAD_CREATED_NOTIF_TYPE.into(),
);
}
}
pub fn web_push_clear(job: &ClearJob, badge_count: u32) -> Value {
let tag = format!("channel:{}", job.channel_id);
let data = json!({
"type": CLEAR_TYPE,
"action": CLEAR_ACTION,
"channel_id": job.channel_id,
"message_id": job.message_id,
"target_user_id": job.user_id,
"notification_tag": tag,
"tag": tag,
"badge_count": badge_count,
});
json!({
"type": CLEAR_TYPE,
"action": CLEAR_ACTION,
"silent": true,
"tag": tag,
"notification_tag": tag,
"data": data,
"badge_count": badge_count,
"web_push": WEB_PUSH_MARKER,
"notification": {
"tag": tag,
"data": data,
"silent": true,
"close": true,
},
})
}
pub fn web_push_call_ring(job: &RingJob) -> Value {
let mut data = json!({
"type": RING_TYPE,
"channel_id": job.channel_id,
"message_id": job.message_id,
"target_user_id": job.user_id,
"started_at_ms": job.started_at_ms,
"expires_at_ms": job.expires_at_ms,
});
put_caller(&mut data, job);
json!({
"web_push": WEB_PUSH_MARKER,
"type": RING_TYPE,
"data": data,
})
}
fn put_caller(data: &mut Value, job: &RingJob) {
let Some(object) = data.as_object_mut() else {
return;
};
let caller = [
(RING_CALLER_ID_KEY, job.caller_id.as_deref()),
(RING_CALLER_NAME_KEY, job.caller_name.as_deref()),
(RING_CALLER_AVATAR_KEY, job.caller_avatar_url.as_deref()),
];
for (key, value) in caller {
if let Some(value) = value.filter(|value| !value.is_empty()) {
object.insert(key.to_owned(), value.into());
}
}
}
pub fn fcm_message(device_token: &str, envelope: &Value) -> Value {
if is_clear(envelope) {
return fcm_clear_message(device_token, envelope);
}
fcm_notification_message(device_token, envelope)
}
fn fcm_clear_message(device_token: &str, envelope: &Value) -> Value {
let tag = match envelope.get("notification_tag") {
Some(value) => value.as_str().unwrap_or(FALLBACK_TAG),
None => envelope
.get("tag")
.and_then(Value::as_str)
.unwrap_or(FALLBACK_TAG),
};
let mut data = stringified_data(envelope.get("data"));
data.insert("type".to_owned(), CLEAR_TYPE.into());
data.insert("action".to_owned(), CLEAR_ACTION.into());
data.insert("notification_tag".to_owned(), tag.into());
json!({
"message": {
"token": device_token,
"data": data,
"android": {
"priority": "NORMAL",
"ttl": "86400s",
"collapse_key": format!("clear:{tag}"),
},
"fcm_options": {"analytics_label": CLEAR_TYPE},
},
})
}
fn fcm_notification_message(device_token: &str, envelope: &Value) -> Value {
let notification = envelope.get("notification");
let title = sanitized_text(fcm_text(notification, envelope, "title", FALLBACK_TITLE));
let body = sanitized_text(fcm_text(notification, envelope, "body", ""));
let tag = envelope
.get("tag")
.and_then(Value::as_str)
.unwrap_or(FALLBACK_TAG);
let image_url = first_non_empty([
field(envelope, "image_url"),
notification.and_then(|value| field(value, "image")),
notification.and_then(|value| field(value, "image_url")),
]);
let mut notification_body = json!({"title": title, "body": body});
put_image(&mut notification_body, image_url);
let mut data = stringified_data(envelope.get("data"));
data.insert("title".to_owned(), title.as_str().into());
data.insert("body".to_owned(), body.as_str().into());
data.insert("tag".to_owned(), tag.into());
if let Some(url) = image_url {
data.insert("image_url".to_owned(), url.into());
}
let mut android_notification = json!({
"channel_id": fcm_channel_id(envelope),
"tag": tag,
"click_action": FCM_CLICK_ACTION,
});
put_image(&mut android_notification, image_url);
json!({
"message": {
"token": device_token,
"notification": notification_body,
"data": data,
"android": {
"priority": "HIGH",
"ttl": "86400s",
"notification": android_notification,
},
"fcm_options": {"analytics_label": "message_create"},
},
})
}
fn fcm_channel_id(envelope: &Value) -> &'static str {
let notif_type = envelope
.get("data")
.and_then(|data| data.get(NOTIF_TYPE_KEY))
.and_then(Value::as_u64);
if notif_type == Some(FORUM_THREAD_CREATED_NOTIF_TYPE) {
FCM_FORUM_THREAD_CREATED_CHANNEL_ID
} else {
FCM_DEFAULT_CHANNEL_ID
}
}
pub fn apns_payload(envelope: &Value) -> Value {
let data = envelope.get("data");
let mut payload = data.and_then(Value::as_object).cloned().unwrap_or_default();
if is_clear(envelope) {
payload.insert("type".to_owned(), CLEAR_TYPE.into());
payload.insert("action".to_owned(), CLEAR_ACTION.into());
payload.insert("aps".to_owned(), json!({"content-available": 1}));
return Value::Object(payload);
}
let notification = envelope.get("notification");
let title = notification
.and_then(|value| field(value, "title"))
.or_else(|| field(envelope, "title"))
.unwrap_or(FALLBACK_TITLE);
let body = notification
.and_then(|value| field(value, "body"))
.or_else(|| field(envelope, "body"))
.unwrap_or("");
let url = data
.and_then(|data| field(data, "url"))
.or_else(|| notification.and_then(|value| field(value, "navigate")));
let image_url = first_non_empty([
field(envelope, "image_url"),
notification.and_then(|value| field(value, "image")),
]);
let thread_id = data
.and_then(|data| field(data, "notification_tag"))
.map(str::to_owned)
.or_else(|| {
data.and_then(|data| field(data, "channel_id"))
.map(|channel_id| format!("channel:{channel_id}"))
})
.unwrap_or_else(|| FALLBACK_TAG.to_owned());
let mut aps = Map::new();
aps.insert("alert".to_owned(), json!({"title": title, "body": body}));
aps.insert("sound".to_owned(), APNS_SOUND.into());
aps.insert("thread-id".to_owned(), thread_id.into());
aps.insert("category".to_owned(), APNS_CATEGORY.into());
aps.insert("interruption-level".to_owned(), "active".into());
aps.insert("relevance-score".to_owned(), Value::from(0.5));
if let Some(badge) = badge_number(data.and_then(|data| data.get("badge_count"))) {
aps.insert("badge".to_owned(), badge.into());
}
aps.insert("mutable-content".to_owned(), 1.into());
payload.insert("title".to_owned(), title.into());
payload.insert("body".to_owned(), body.into());
payload.remove("url");
if let Some(url) = url {
payload.insert("url".to_owned(), url.into());
}
payload.remove("image_url");
if let Some(image_url) = image_url {
payload.insert("image_url".to_owned(), image_url.into());
}
payload.insert("aps".to_owned(), Value::Object(aps));
Value::Object(payload)
}
pub fn apns_delivery_headers(envelope: &Value) -> Vec<(String, String)> {
let clear = is_clear(envelope);
let expiration = unix_seconds()
+ if clear {
APNS_BACKGROUND_EXPIRATION_SECONDS
} else {
APNS_ALERT_EXPIRATION_SECONDS
};
let mut headers = vec![
(
"apns-push-type".to_owned(),
if clear { "background" } else { "alert" }.to_owned(),
),
(
"apns-priority".to_owned(),
if clear { "5" } else { "10" }.to_owned(),
),
("apns-expiration".to_owned(), expiration.to_string()),
("content-type".to_owned(), "application/json".to_owned()),
];
let collapse_id = field(envelope, "tag").or_else(|| {
envelope
.get("data")
.and_then(|data| field(data, "message_id"))
});
if let Some(collapse_id) = collapse_id.filter(|id| id.len() <= APNS_COLLAPSE_ID_MAX_BYTES) {
headers.push(("apns-collapse-id".to_owned(), collapse_id.to_owned()));
}
headers
}
fn put_image(target: &mut Value, image_url: Option<&str>) {
let (Some(image_url), Some(object)) = (image_url, target.as_object_mut()) else {
return;
};
object.insert("image".to_owned(), image_url.into());
}
fn fcm_text<'a>(
notification: Option<&'a Value>,
envelope: &'a Value,
key: &str,
fallback: &'a str,
) -> &'a str {
notification
.and_then(|value| value.get(key))
.or_else(|| envelope.get(key))
.and_then(Value::as_str)
.unwrap_or(fallback)
}
fn stringified_data(data: Option<&Value>) -> Map<String, Value> {
data.and_then(Value::as_object)
.map(|data| {
data.iter()
.map(|(key, value)| (key.clone(), stringified(value)))
.collect()
})
.unwrap_or_default()
}
fn stringified(value: &Value) -> Value {
match value {
Value::String(text) => Value::String(text.clone()),
Value::Null => Value::String("null".to_owned()),
Value::Bool(flag) => Value::String(flag.to_string()),
Value::Number(number) => Value::String(number.to_string()),
other => Value::String(other.to_string()),
}
}
fn sanitized_text(value: &str) -> String {
value
.chars()
.filter(|character| displayable(*character))
.collect()
}
fn displayable(character: char) -> bool {
match character {
'\n' | '\t' => true,
'\u{0}'..='\u{1f}'
| '\u{7f}'..='\u{9f}'
| '\u{200e}'..='\u{200f}'
| '\u{202a}'..='\u{202e}'
| '\u{2066}'..='\u{2069}' => false,
_ => true,
}
}
fn field<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
value.get(key).and_then(non_empty)
}
fn first_non_empty<const N: usize>(candidates: [Option<&str>; N]) -> Option<&str> {
candidates.into_iter().flatten().next()
}
fn badge_number(value: Option<&Value>) -> Option<u64> {
match value? {
Value::Number(number) => {
let badge = number.as_f64()?;
badge.is_finite().then(|| badge.max(0.0).trunc() as u64)
}
Value::String(text) => text
.parse::<i64>()
.ok()
.map(|badge| badge.max(0).unsigned_abs()),
_ => None,
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RecordKind {
Message,
Clear,
Ring,
}
pub fn record_kind(envelope: &Value) -> RecordKind {
let kind = envelope.get("type").and_then(Value::as_str);
if kind == Some(RING_TYPE) {
return RecordKind::Ring;
}
if kind == Some(CLEAR_TYPE)
|| envelope.get("action").and_then(Value::as_str) == Some(CLEAR_ACTION)
{
return RecordKind::Clear;
}
RecordKind::Message
}
pub fn is_clear(envelope: &Value) -> bool {
matches!(record_kind(envelope), RecordKind::Clear)
}
pub fn fit(envelope: &Value, budget: usize) -> (Vec<u8>, Option<PayloadShrink>) {
let serialized = serialize(envelope);
if serialized.len() <= budget {
return (serialized, None);
}
if matches!(record_kind(envelope), RecordKind::Ring) {
return fit_ring(envelope, budget);
}
let mut working = envelope.clone();
for step in PayloadShrink::ALL {
working = shrink(&working, step, budget);
let serialized = serialize(&working);
if serialized.len() <= budget {
return (serialized, Some(step));
}
}
(serialize(&working), Some(PayloadShrink::Minimal))
}
fn fit_ring(envelope: &Value, budget: usize) -> (Vec<u8>, Option<PayloadShrink>) {
let mut working = envelope.clone();
drop_keys(&mut working, &RING_AVATAR_KEYS);
let serialized = serialize(&working);
if serialized.len() <= budget {
return (serialized, Some(PayloadShrink::Icons));
}
drop_keys(&mut working, &RING_IDENTITY_KEYS);
(serialize(&working), Some(PayloadShrink::Minimal))
}
fn shrink(envelope: &Value, step: PayloadShrink, budget: usize) -> Value {
let mut working = envelope.clone();
match step {
PayloadShrink::Media => drop_keys(&mut working, &MEDIA_KEYS),
PayloadShrink::Icons => drop_keys(&mut working, &ICON_KEYS),
PayloadShrink::Body => truncate_body(&mut working),
PayloadShrink::Minimal => working = minimal(envelope, budget),
}
working
}
fn minimal(envelope: &Value, budget: usize) -> Value {
let title = truncate_bytes(
first_text(envelope, "title").unwrap_or(FALLBACK_TITLE),
MINIMAL_TITLE_MAX_BYTES,
);
let tag = first_text(envelope, "tag")
.unwrap_or(FALLBACK_TAG)
.to_owned();
let url = first_text(envelope, "navigate")
.or_else(|| {
envelope
.get("data")
.and_then(|data| data.get("url"))
.and_then(non_empty)
})
.unwrap_or_default()
.to_owned();
let data = minimal_data(envelope.get("data"));
let candidates = [
minimal_envelope(&title, &tag, &url, data.clone()),
minimal_envelope(&title, &tag, "", data),
minimal_envelope(&title, &tag, "", Map::new()),
minimal_envelope(&title, "", "", Map::new()),
];
for candidate in candidates {
if serialize(&candidate).len() <= budget {
return candidate;
}
}
title_only(&title, budget)
}
fn minimal_envelope(title: &str, tag: &str, url: &str, data: Map<String, Value>) -> Value {
json!({
"web_push": WEB_PUSH_MARKER,
"title": title,
"tag": tag,
"data": data,
"notification": {
"title": title,
"tag": tag,
"navigate": url,
"data": data,
},
})
}
fn title_only(title: &str, budget: usize) -> Value {
let mut allowance = title.len();
loop {
let candidate = json!({
"web_push": WEB_PUSH_MARKER,
"title": truncate_bytes(title, allowance),
});
if allowance == 0 || serialize(&candidate).len() <= budget {
return candidate;
}
allowance -= 1;
}
}
fn minimal_data(data: Option<&Value>) -> Map<String, Value> {
let Some(data) = data.and_then(Value::as_object) else {
return Map::new();
};
MINIMAL_DATA_KEYS
.into_iter()
.filter_map(|key| data.get(key).map(|value| (key.to_owned(), value.clone())))
.collect()
}
fn drop_keys(envelope: &mut Value, keys: &[&str]) {
for_each_block(envelope, &mut |block| {
for key in keys {
block.remove(*key);
}
});
}
fn truncate_body(envelope: &mut Value) {
for_each_block(envelope, &mut |block| {
let Some(body) = block.get("body").and_then(Value::as_str) else {
return;
};
let shortened = truncate_bytes(body, SHRUNK_BODY_MAX_BYTES);
block.insert("body".to_owned(), shortened.into());
});
}
fn for_each_block(envelope: &mut Value, apply: &mut impl FnMut(&mut Map<String, Value>)) {
let Some(root) = envelope.as_object_mut() else {
return;
};
for key in ["notification", "data"] {
if let Some(nested) = root.get_mut(key) {
for_each_block(nested, apply);
}
}
apply(root);
}
fn truncate_bytes(text: &str, max_bytes: usize) -> String {
if text.len() <= max_bytes {
return text.to_owned();
}
let mut end = max_bytes;
while end > 0 && !text.is_char_boundary(end) {
end -= 1;
}
text[..end].to_owned()
}
fn first_text<'a>(envelope: &'a Value, key: &str) -> Option<&'a str> {
envelope
.get(key)
.and_then(non_empty)
.or_else(|| envelope.get("notification")?.get(key).and_then(non_empty))
}
fn merge_image_fields(target: &mut Value, image_url: Option<&str>) {
let (Some(image_url), Some(object)) = (image_url, target.as_object_mut()) else {
return;
};
object.insert("image_url".to_owned(), image_url.into());
}
fn non_empty(value: &Value) -> Option<&str> {
value.as_str().filter(|text| !text.is_empty())
}
fn serialize(value: &Value) -> Vec<u8> {
serde_json::to_vec(value).expect("a json value serialises")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::job::{ClearJob, MessageJob, NotificationFields};
const CHANNEL_ID: &str = "9876543210987654321";
const MESSAGE_ID: &str = "1122334455667788990";
const USER_ID: &str = "1234567890123456789";
fn message_job(image_url: Option<&str>) -> MessageJob {
MessageJob {
v: 1,
guild_id: "0".to_owned(),
channel_id: CHANNEL_ID.to_owned(),
message_id: MESSAGE_ID.to_owned(),
notification: NotificationFields {
title: "Elias".to_owned(),
body: "see you tomorrow".to_owned(),
icon: "https://media.fluxer.app/avatars/1/a.webp".to_owned(),
badge: "https://media.fluxer.app/badge.png".to_owned(),
tag: format!("channel:{CHANNEL_ID}:{MESSAGE_ID}"),
notification_tag: format!("channel:{CHANNEL_ID}"),
url: format!("/channels/@me/{CHANNEL_ID}"),
image_url: image_url.map(str::to_owned),
},
user_ids: vec![USER_ID.to_owned()],
kind: None,
parent_id: None,
parent_name: None,
channel_name: None,
}
}
fn thread_job() -> MessageJob {
MessageJob {
guild_id: "4455".to_owned(),
kind: Some("thread".to_owned()),
parent_id: Some("7788".to_owned()),
parent_name: Some("general".to_owned()),
channel_name: Some("release plans".to_owned()),
..message_job(None)
}
}
fn clear_job() -> ClearJob {
ClearJob {
v: 1,
user_id: USER_ID.to_owned(),
channel_id: CHANNEL_ID.to_owned(),
message_id: MESSAGE_ID.to_owned(),
after_message_id: None,
}
}
fn apns_for(image_url: Option<&str>) -> Value {
apns_payload(&web_push_message(&message_job(image_url), USER_ID, 3))
}
#[test]
fn a_message_without_an_image_still_runs_the_notification_service_extension() {
assert_eq!(apns_for(None)["aps"]["mutable-content"], json!(1));
}
#[test]
fn a_message_with_an_image_still_runs_the_notification_service_extension() {
assert_eq!(
apns_for(Some("https://media.fluxer.app/proxy/attachment.webp"))["aps"]["mutable-content"],
json!(1)
);
}
#[test]
fn a_background_clear_never_runs_the_notification_service_extension() {
let payload = apns_payload(&web_push_clear(&clear_job(), 3));
assert_eq!(payload["aps"], json!({"content-available": 1}));
}
#[test]
fn the_apns_category_is_the_identifier_the_app_registers() {
assert_eq!(apns_for(None)["aps"]["category"], json!("fluxer_message"));
}
#[test]
fn the_android_click_action_keeps_its_own_identifier() {
let message = fcm_message(
"device-token",
&web_push_message(&message_job(None), USER_ID, 3),
);
assert_eq!(
message["message"]["android"]["notification"]["click_action"],
json!("FLUXER_MESSAGE")
);
}
#[test]
fn a_message_outside_a_thread_carries_no_thread_keys() {
let envelope = web_push_message(&message_job(None), USER_ID, 3);
for key in THREAD_DATA_KEYS {
assert!(envelope["data"].get(key).is_none(), "{key} leaked");
}
}
#[test]
fn a_thread_message_carries_its_parent_and_names() {
let envelope = web_push_message(&thread_job(), USER_ID, 3);
assert_eq!(envelope["data"]["parent_id"], json!("7788"));
assert_eq!(envelope["data"]["parent_name"], json!("general"));
assert_eq!(envelope["data"]["channel_name"], json!("release plans"));
assert_eq!(apns_payload(&envelope)["parent_id"], json!("7788"));
}
#[test]
fn only_a_forum_thread_created_message_carries_its_notification_type() {
let thread = web_push_message(&thread_job(), USER_ID, 3);
assert!(thread["data"].get(NOTIF_TYPE_KEY).is_none());
let forum = MessageJob {
kind: Some("forum_thread_created".to_owned()),
..thread_job()
};
let envelope = web_push_message(&forum, USER_ID, 3);
assert_eq!(envelope["data"][NOTIF_TYPE_KEY], json!(9));
assert_eq!(envelope["data"]["parent_id"], json!("7788"));
let message = fcm_message("device-token", &envelope);
assert_eq!(message["message"]["data"][NOTIF_TYPE_KEY], json!("9"));
assert_eq!(
message["message"]["android"]["notification"]["channel_id"],
json!(FCM_FORUM_THREAD_CREATED_CHANNEL_ID)
);
assert_eq!(
fcm_message("device-token", &thread)["message"]["android"]["notification"]["channel_id"],
json!(FCM_DEFAULT_CHANNEL_ID)
);
let budget = serialize(&minimal(&envelope, usize::MAX)).len();
let (bytes, _step) = fit(&envelope, budget);
let shrunk: Value = serde_json::from_slice(&bytes).expect("the shrunk payload is json");
assert_eq!(shrunk["data"][NOTIF_TYPE_KEY], json!(9));
}
#[test]
fn a_shrunk_thread_message_keeps_its_parent_id() {
let envelope = web_push_message(&thread_job(), USER_ID, 3);
let budget = serialize(&minimal(&envelope, usize::MAX)).len();
let (bytes, step) = fit(&envelope, budget);
assert_eq!(step, Some(PayloadShrink::Minimal));
let shrunk: Value = serde_json::from_slice(&bytes).expect("the shrunk payload is json");
assert_eq!(shrunk["data"]["parent_id"], json!("7788"));
}
#[test]
fn a_shrunk_clear_still_tells_the_client_it_is_a_clear() {
let envelope = web_push_clear(&clear_job(), 3);
let budget = serialize(&envelope).len() - 1;
let (bytes, step) = fit(&envelope, budget);
assert_eq!(step, Some(PayloadShrink::Minimal));
let shrunk: Value = serde_json::from_slice(&bytes).expect("the shrunk payload is json");
assert_eq!(shrunk["data"]["type"], json!(CLEAR_TYPE));
assert_eq!(shrunk["data"]["action"], json!(CLEAR_ACTION));
}
}