use serde_json::{json, Map, Value};
use std::collections::HashSet;
fn pick(row: &Value, keys: &[&str]) -> Value {
keys.iter()
.find_map(|key| row.get(*key).cloned())
.unwrap_or(Value::Null)
}
fn json_field(row: &Value, keys: &[&str], fallback: Value) -> Value {
match pick(row, keys) {
Value::String(raw) => serde_json::from_str::<Value>(&raw)
.ok()
.filter(|value| !value.is_null())
.unwrap_or(fallback),
Value::Null => fallback,
value => value,
}
}
fn json_field_or_value(row: &Value, keys: &[&str], fallback: Value) -> Value {
match pick(row, keys) {
Value::String(raw) if raw.is_empty() => fallback,
Value::String(raw) => {
serde_json::from_str::<Value>(&raw).unwrap_or_else(|_| Value::String(raw))
}
Value::Null => fallback,
value => value,
}
}
fn has_any_field(row: &Value, keys: &[&str]) -> bool {
keys.iter().any(|key| row.get(*key).is_some())
}
fn roster_member_id(value: &Value) -> Option<String> {
let object = value.as_object()?;
["userId", "user_id", "id"]
.iter()
.find_map(|key| object.get(*key).and_then(Value::as_str))
.map(str::trim)
.filter(|id| !id.is_empty())
.map(ToOwned::to_owned)
}
fn roster_member_count(row: &Value) -> Option<usize> {
match pick(row, &["member_count", "memberCount"]) {
Value::Number(value) => value
.as_u64()
.map(|count| count as usize)
.filter(|count| *count > 0),
Value::String(value) => value.parse::<usize>().ok().filter(|count| *count > 0),
_ => None,
}
}
fn complete_roster_projection(row: &Value) -> Option<(Value, Value, Value, Value, usize)> {
let member_keys = ["members"];
let admin_keys = ["admin_users", "adminUsers"];
let boss_keys = ["boss"];
let owner_keys = ["owner"];
if !has_any_field(row, &member_keys)
|| !has_any_field(row, &admin_keys)
|| !has_any_field(row, &boss_keys)
|| !has_any_field(row, &owner_keys)
{
return None;
}
let members = json_field(row, &member_keys, Value::Null);
let admins = json_field(row, &admin_keys, Value::Null);
let bosses = json_field(row, &boss_keys, Value::Null);
let owner = json_field(row, &owner_keys, Value::Null);
if !members.is_array() || !admins.is_array() || !bosses.is_array() {
return None;
}
if !owner.is_null() && roster_member_id(&owner).is_none() {
return None;
}
let mut ids = HashSet::new();
let owner_iter = (!owner.is_null()).then_some(&owner);
for member in members
.as_array()
.into_iter()
.flatten()
.chain(admins.as_array().into_iter().flatten())
.chain(bosses.as_array().into_iter().flatten())
.chain(owner_iter)
{
let id = roster_member_id(member)?;
if !ids.insert(id) {
return None;
}
}
let count = roster_member_count(row)?;
if ids.len() != count {
return None;
}
Some((members, admins, bosses, owner, count))
}
fn shape_last_post(value: Value) -> Value {
match value {
Value::Object(_) => super::core::shape_row(&value, ""),
_ => json!({}),
}
}
fn insert_alias(output: &mut Map<String, Value>, row: &Value, name: &str, keys: &[&str]) {
let value = pick(row, keys);
if !value.is_null() {
output.insert(name.to_string(), value);
}
}
fn non_negative_count(value: Value) -> Value {
match &value {
Value::Number(number) if number.as_f64().is_some_and(|value| value < 0.0) => Value::from(0),
Value::String(raw) if raw.trim().parse::<f64>().is_ok_and(|value| value < 0.0) => {
Value::from(0)
}
_ => value,
}
}
fn insert_non_negative_count_alias(
output: &mut Map<String, Value>,
row: &Value,
name: &str,
keys: &[&str],
) {
let value = non_negative_count(pick(row, keys));
if value.is_null() {
return;
}
output.insert(name.to_string(), value.clone());
for key in keys {
if output.contains_key(*key) {
output.insert((*key).to_string(), value.clone());
}
}
}
fn insert_bool_alias(output: &mut Map<String, Value>, row: &Value, name: &str, keys: &[&str]) {
let value = match pick(row, keys) {
Value::Bool(value) => Some(value),
Value::Number(value) => value.as_i64().map(|value| value != 0),
_ => None,
};
if let Some(value) = value {
output.insert(name.to_string(), Value::Bool(value));
}
}
fn persisted_bool(row: &Value, keys: &[&str]) -> Option<bool> {
let mut parsed = None;
for key in keys {
let Some(value) = row.get(*key) else {
continue;
};
let Some(current) = (match value {
Value::Bool(value) => Some(*value),
Value::Number(value) => match value.as_i64() {
Some(0) => Some(false),
Some(1) => Some(true),
_ => None,
},
_ => None,
}) else {
return None;
};
if parsed.is_some_and(|previous| previous != current) {
return None;
}
parsed = Some(current);
}
parsed
}
fn persisted_role(row: &Value) -> Option<String> {
row.get("role")
.and_then(Value::as_str)
.map(str::trim)
.map(str::to_ascii_uppercase)
.and_then(|role| {
let canonical = match role.as_str() {
"OWNER" | "CREATOR" => "CREATOR",
"ADMIN" | "MANAGER" | "MANGER" => "MANAGER",
"MEMBER" => "MEMBER",
"BOSS" => "BOSS",
_ => return None,
};
Some(canonical.to_string())
})
}
fn persisted_role_value(row: &Value, keys: &[&str]) -> Option<String> {
let mut parsed = None;
for key in keys {
let Some(value) = row.get(*key).and_then(Value::as_str) else {
if row.get(*key).is_some() {
return None;
}
continue;
};
let normalized = value.trim().to_ascii_uppercase();
if !matches!(
normalized.as_str(),
"CREATOR" | "MANAGER" | "MEMBER" | "BOSS"
) {
return None;
}
if parsed
.as_deref()
.is_some_and(|previous| previous != normalized)
{
return None;
}
parsed = Some(normalized);
}
parsed
}
fn role_at_or_above(role: Option<&str>, required: Option<&str>) -> bool {
let role_level = match role {
Some("BOSS") => 0,
Some("CREATOR") => 1,
Some("MANAGER") => 2,
Some("MEMBER") => 3,
_ => return false,
};
let required_level = match required {
Some("BOSS") => 0,
Some("CREATOR") => 1,
Some("MANAGER") => 2,
Some("MEMBER") => 3,
_ => return false,
};
role_level <= required_level
}
fn derived_threshold_capability(row: &Value, role: Option<&str>, keys: &[&str]) -> bool {
let required = persisted_role_value(row, keys);
if required.is_some() {
return role_at_or_above(role, required.as_deref());
}
!keys.iter().any(|key| row.get(*key).is_some()) && role == Some("CREATOR")
}
fn insert_viewer_capabilities(output: &mut Map<String, Value>, row: &Value) {
let role = persisted_role(row);
let is_open = matches!(
pick(row, &["type"]),
Value::String(ref value) if value.eq_ignore_ascii_case("O")
);
let admin_role = matches!(role.as_deref(), Some("CREATOR" | "MANAGER"))
|| (is_open && matches!(role.as_deref(), Some("BOSS")));
let change_role = matches!(role.as_deref(), Some("CREATOR"))
|| (is_open && matches!(role.as_deref(), Some("BOSS")));
let derived_settings = admin_role;
let derived_members = admin_role;
let derived_mention = derived_threshold_capability(
row,
role.as_deref(),
&["mention_permission", "mentionPermission"],
);
let derived_notice = derived_threshold_capability(
row,
role.as_deref(),
&["notice_permission", "noticePermission"],
);
let derived_top =
derived_threshold_capability(row, role.as_deref(), &["top_permission", "topPermission"]);
let can_edit = persisted_bool(
row,
&["can_edit_channel_settings", "canEditChannelSettings"],
)
.unwrap_or(derived_settings);
let can_manage_settings =
persisted_bool(row, &["can_manage_settings", "canManageSettings"]).unwrap_or(can_edit);
let can_manage_members =
persisted_bool(row, &["can_manage_members", "canManageMembers"]).unwrap_or(derived_members);
let can_mention_all =
persisted_bool(row, &["can_mention_all", "canMentionAll"]).unwrap_or(derived_mention);
let can_publish_notice =
persisted_bool(row, &["can_publish_notice", "canPublishNotice"]).unwrap_or(derived_notice);
let can_change_permissions =
persisted_bool(row, &["can_change_permissions", "canChangePermissions"])
.unwrap_or(change_role);
let can_pin_message = pick(row, &["type"]).as_str() != Some("D")
&& persisted_bool(row, &["can_pin_message", "canPinMessage"]).unwrap_or(derived_top);
output.insert("canEditChannelSettings".to_string(), Value::Bool(can_edit));
output.insert(
"canManageSettings".to_string(),
Value::Bool(can_manage_settings),
);
output.insert(
"canManageMembers".to_string(),
Value::Bool(can_manage_members),
);
output.insert("canMentionAll".to_string(), Value::Bool(can_mention_all));
output.insert(
"canPublishNotice".to_string(),
Value::Bool(can_publish_notice),
);
output.insert(
"canChangePermissions".to_string(),
Value::Bool(can_change_permissions),
);
output.insert("canPinMessage".to_string(), Value::Bool(can_pin_message));
}
pub(crate) fn shape_channel_row(row: &Value) -> Value {
let Some(object) = row.as_object() else {
return json!({});
};
let mut output = object.clone();
insert_alias(&mut output, row, "id", &["id"]);
insert_bool_alias(&mut output, row, "close", &["is_closed", "close"]);
insert_alias(
&mut output,
row,
"channelId",
&["channel_id", "channelId", "id"],
);
insert_alias(
&mut output,
row,
"displayName",
&["display_name", "displayName"],
);
insert_alias(&mut output, row, "type", &["type"]);
insert_alias(&mut output, row, "teamId", &["team_id", "teamId"]);
insert_alias(
&mut output,
row,
"companyId",
&["company_id", "companyId", "team_id", "teamId"],
);
insert_alias(
&mut output,
row,
"settingVersion",
&[
"setting_version",
"settingVersion",
"updated_at",
"updateAt",
],
);
insert_alias(
&mut output,
row,
"permissionRevision",
&[
"permission_revision",
"permissionRevision",
"updated_at",
"updateAt",
],
);
insert_alias(&mut output, row, "userId", &["user_id", "userId"]);
insert_alias(&mut output, row, "isActive", &["is_active", "isActive"]);
insert_alias(
&mut output,
row,
"unreadCount",
&["unread_count", "unreadCount"],
);
insert_alias(
&mut output,
row,
"unreadPostId",
&["unread_post_id", "unreadPostId", "unReadPostId"],
);
insert_alias(
&mut output,
row,
"mentionCount",
&["mention_count", "mentionCount"],
);
insert_alias(
&mut output,
row,
"mentionCountRoot",
&["mention_count_root", "mentionCountRoot"],
);
insert_alias(
&mut output,
row,
"mentionUser",
&["mention_user", "mentionUser"],
);
insert_non_negative_count_alias(
&mut output,
row,
"urgentCount",
&["urgent_count", "urgentCount"],
);
insert_alias(
&mut output,
row,
"lastPostAt",
&["last_post_at", "lastPostAt"],
);
insert_alias(
&mut output,
row,
"lastRootPostAt",
&["last_root_post_at", "lastRootPostAt"],
);
insert_alias(
&mut output,
row,
"lastEventSeq",
&["last_event_seq", "lastEventSeq"],
);
insert_alias(
&mut output,
row,
"lastReadSeq",
&["last_read_seq", "lastReadSeq"],
);
insert_alias(
&mut output,
row,
"projectionRevision",
&["projection_revision", "projectionRevision"],
);
insert_alias(&mut output, row, "topCount", &["top_count", "topCount"]);
insert_alias(&mut output, row, "isRemove", &["is_remove", "isRemove"]);
insert_alias(&mut output, row, "deleteAt", &["delete_at", "deleteAt"]);
insert_alias(&mut output, row, "role", &["role"]);
insert_viewer_capabilities(&mut output, row);
insert_alias(&mut output, row, "orient", &["orient"]);
insert_alias(&mut output, row, "purpose", &["purpose"]);
insert_alias(
&mut output,
row,
"pictureType",
&["picture_type", "pictureType"],
);
insert_alias(&mut output, row, "rootId", &["root_id", "rootId"]);
insert_alias(
&mut output,
row,
"rootPostId",
&["root_post_id", "rootPostId"],
);
insert_alias(
&mut output,
row,
"threadCount",
&["thread_count", "threadCount"],
);
insert_alias(
&mut output,
row,
"topicMsgCount",
&["topic_msg_count", "topicMsgCount"],
);
insert_alias(
&mut output,
row,
"adminMaxCount",
&["admin_max_count", "adminMaxCount"],
);
insert_bool_alias(
&mut output,
row,
"hasUrgentPost",
&["has_urgent_post", "hasUrgentPost"],
);
insert_alias(
&mut output,
row,
"hasSchedulePost",
&["has_schedule_post", "hasSchedulePost"],
);
insert_alias(&mut output, row, "createBy", &["create_by", "createBy"]);
insert_alias(&mut output, row, "updateBy", &["update_by", "updateBy"]);
insert_alias(
&mut output,
row,
"urgentCurrentName",
&["urgent_current_name", "urgentCurrentName"],
);
insert_alias(
&mut output,
row,
"mentionPermission",
&["mention_permission", "mentionPermission"],
);
insert_alias(
&mut output,
row,
"noticePermission",
&["notice_permission", "noticePermission"],
);
insert_alias(
&mut output,
row,
"topPermission",
&["top_permission", "topPermission"],
);
insert_alias(
&mut output,
row,
"postMapDaily",
&["post_map_daily", "postMapDaily"],
);
insert_alias(
&mut output,
row,
"subtopicsLoadedAt",
&["subtopics_loaded_at", "subtopicsLoadedAt"],
);
insert_alias(
&mut output,
row,
"scheduleId",
&["schedule_id", "scheduleId"],
);
insert_alias(
&mut output,
row,
"scheduleOwnerUserId",
&["schedule_owner_user_id", "scheduleOwnerUserId"],
);
insert_alias(
&mut output,
row,
"scheduleMessage",
&["schedule_message", "scheduleMessage"],
);
insert_alias(
&mut output,
row,
"scheduleMessagePreview",
&["schedule_message_preview", "scheduleMessagePreview"],
);
insert_alias(
&mut output,
row,
"scheduleSendAt",
&["schedule_send_at", "scheduleSendAt"],
);
insert_alias(
&mut output,
row,
"scheduleStatus",
&["schedule_status", "scheduleStatus"],
);
insert_alias(
&mut output,
row,
"scheduleRevision",
&["schedule_revision", "scheduleRevision"],
);
insert_bool_alias(
&mut output,
row,
"channelIsTop",
&["is_top", "channelIsTop"],
);
insert_alias(&mut output, row, "createAt", &["created_at", "createAt"]);
insert_alias(&mut output, row, "updateAt", &["updated_at", "updateAt"]);
output.insert(
"lastPost".to_string(),
shape_last_post(json_field(row, &["last_post", "lastPost"], json!({}))),
);
output.insert(
"mentionList".to_string(),
json_field(row, &["mention_list", "mentionList"], json!([])),
);
output.insert(
"urgentPostList".to_string(),
json_field(row, &["urgent_post_list", "urgentPostList"], json!([])),
);
for key in [
"members",
"admin_users",
"adminUsers",
"boss",
"owner",
"member_count",
"memberCount",
] {
output.remove(key);
}
if let Some((members, admins, bosses, owner, member_count)) = complete_roster_projection(row) {
output.insert("members".to_string(), members);
output.insert("adminUsers".to_string(), admins);
output.insert("boss".to_string(), bosses);
output.insert("owner".to_string(), owner);
output.insert("memberCount".to_string(), Value::from(member_count as u64));
}
output.insert(
"notifyProps".to_string(),
json_field_or_value(row, &["notify_props", "notifyProps"], json!({})),
);
output.insert("props".to_string(), json_field(row, &["props"], json!({})));
output.insert(
"source".to_string(),
json_field_or_value(row, &["source"], json!({})),
);
output.insert(
"picture".to_string(),
json_field(row, &["picture"], json!({})),
);
output.insert(
"targetUsers".to_string(),
json_field(row, &["target_users", "targetUsers"], json!([])),
);
output.insert(
"draft".to_string(),
json_field(row, &["draft"], Value::Null),
);
Value::Object(output)
}
pub(crate) fn shape_channel_rows(rows: &Value) -> Value {
Value::Array(
rows.as_array()
.into_iter()
.flatten()
.filter(|row| row.is_object())
.map(shape_channel_row)
.collect(),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shapes_snake_channel_rows_without_dropping_empty_channels() {
let rows = json!([
{
"id": "channel-1",
"display_name": "测试群",
"type": "P",
"unread_count": 2,
"mention_count": 1,
"urgent_count": 3,
"has_urgent_post": 1,
"last_event_seq": 151,
"last_read_seq": 9,
"projection_revision": 12,
"is_top": 1,
"picture_type": "USER",
"picture": "{\"userIds\":[\"444\"]}",
"notify_props": "IGNORE",
"source": "sync_events",
"last_post": "{\"id\":\"post-1\",\"temporary_id\":\"tmp-1\",\"channel_id\":\"channel-1\",\"user_id\":\"444\",\"user_snapshot\":\"{\\\"userName\\\":\\\"破坏者\\\"}\",\"message\":\"回复消息\",\"type\":\"TEXT\"}"
},
{ "id": "channel-2", "display_name": "空群" }
]);
let shaped = shape_channel_rows(&rows);
assert_eq!(shaped[0]["displayName"], "测试群");
assert_eq!(shaped[0]["unreadCount"], 2);
assert_eq!(shaped[0]["urgentCount"], 3);
assert_eq!(shaped[0]["hasUrgentPost"], true);
assert_eq!(shaped[0]["lastEventSeq"], 151);
assert_eq!(shaped[0]["lastReadSeq"], 9);
assert_eq!(shaped[0]["projectionRevision"], 12);
assert_eq!(shaped[0]["channelIsTop"], true);
assert_eq!(shaped[0]["picture"]["userIds"][0], "444");
assert_eq!(shaped[0]["notifyProps"], "IGNORE");
assert_eq!(shaped[0]["source"], "sync_events");
assert_eq!(shaped[0]["lastPost"]["message"], "回复消息");
assert_eq!(shaped[1]["displayName"], "空群");
assert_eq!(shaped.as_array().map(Vec::len), Some(2));
}
#[test]
fn omits_split_roster_for_channel_only_rows() {
let shaped = shape_channel_row(&json!({
"id": "channel-only",
"members": "[]",
"admin_users": "[]",
"boss": "[]",
"owner": "{}",
"member_count": 0
}));
assert!(!shaped.as_object().unwrap().contains_key("members"));
assert!(!shaped.as_object().unwrap().contains_key("adminUsers"));
assert!(!shaped.as_object().unwrap().contains_key("boss"));
assert!(!shaped.as_object().unwrap().contains_key("owner"));
assert!(!shaped.as_object().unwrap().contains_key("memberCount"));
}
#[test]
fn keeps_complete_split_roster_and_total_member_count() {
let shaped = shape_channel_row(&json!({
"id": "channel-complete",
"members": "[{\"userId\":\"member-1\"}]",
"admin_users": "[{\"userId\":\"admin-1\"}]",
"boss": "[]",
"owner": "{\"userId\":\"owner-1\"}",
"member_count": 3
}));
assert_eq!(shaped["members"][0]["userId"], "member-1");
assert_eq!(shaped["adminUsers"][0]["userId"], "admin-1");
assert_eq!(shaped["boss"], json!([]));
assert_eq!(shaped["owner"]["userId"], "owner-1");
assert_eq!(shaped["memberCount"], 3);
}
#[test]
fn clamps_negative_urgent_count_for_snake_and_camel_rows() {
let rows = json!([
{
"id": "channel-negative-snake",
"urgent_count": -2,
"urgent_post_list": "[\"post-1\"]"
},
{
"id": "channel-negative-camel",
"urgentCount": -3,
"urgentPostList": ["post-2"]
}
]);
let shaped = shape_channel_rows(&rows);
assert_eq!(shaped[0]["urgentCount"], 0);
assert_eq!(shaped[0]["urgent_count"], 0);
assert_eq!(shaped[0]["urgentPostList"], json!(["post-1"]));
assert_eq!(shaped[1]["urgentCount"], 0);
assert_eq!(shaped[1]["urgentPostList"], json!(["post-2"]));
}
#[test]
fn keeps_twenty_row_page_renderable_with_one_negative_urgent_count() {
let rows = Value::Array(
(0..20)
.map(|index| {
json!({
"id": format!("channel-{index}"),
"urgent_count": if index == 7 { -1 } else { index },
})
})
.collect(),
);
let shaped = shape_channel_rows(&rows);
assert_eq!(shaped.as_array().map(Vec::len), Some(20));
assert_eq!(shaped[7]["urgentCount"], 0);
assert_eq!(shaped[6]["urgentCount"], 6);
assert_eq!(shaped[19]["urgentCount"], 19);
}
#[test]
fn projects_viewer_capabilities_from_authoritative_role() {
let rows = json!([
{ "id": "creator", "type": "P", "role": "CREATOR", "mentionPermission": "MANAGER", "noticePermission": "MANAGER", "topPermission": "MANAGER" },
{ "id": "manager", "type": "P", "role": "MANAGER", "mentionPermission": "MANAGER", "noticePermission": "MANAGER", "topPermission": "MANAGER" },
{ "id": "boss", "type": "O", "role": "BOSS", "mentionPermission": "MANAGER", "noticePermission": "MANAGER", "topPermission": "MANAGER" },
{ "id": "member", "type": "P", "role": "MEMBER", "noticePermission": "MANAGER", "topPermission": "MANAGER" },
{ "id": "invalid", "type": "P", "role": "UNKNOWN", "noticePermission": "MANAGER", "topPermission": "MANAGER" },
{ "id": "missing", "type": "P" }
]);
let shaped = shape_channel_rows(&rows);
for id in ["creator", "manager", "boss"] {
let row = shaped
.as_array()
.unwrap()
.iter()
.find(|row| row["id"] == id)
.unwrap();
assert_eq!(row["canManageSettings"], true, "{id}");
assert_eq!(row["canManageMembers"], true, "{id}");
assert_eq!(row["canEditChannelSettings"], true, "{id}");
assert_eq!(row["canChangePermissions"], id != "manager", "{id}");
assert_eq!(row["canMentionAll"], true, "{id}");
assert_eq!(row["canPublishNotice"], true, "{id}");
assert_eq!(row["canPinMessage"], true, "{id}");
}
for id in ["member", "invalid", "missing"] {
let row = shaped
.as_array()
.unwrap()
.iter()
.find(|row| row["id"] == id)
.unwrap();
assert_eq!(row["canManageSettings"], false, "{id}");
assert_eq!(row["canManageMembers"], false, "{id}");
assert_eq!(row["canEditChannelSettings"], false, "{id}");
assert_eq!(row["canMentionAll"], false, "{id}");
assert_eq!(row["canPublishNotice"], false, "{id}");
assert_eq!(row["canChangePermissions"], false, "{id}");
assert_eq!(row["canPinMessage"], false, "{id}");
}
}
#[test]
fn fails_closed_for_invalid_policy_and_boolean_values() {
let shaped = shape_channel_row(&json!({
"id": "invalid-policy",
"type": "O",
"role": "BOSS",
"noticePermission": "UNKNOWN",
"topPermission": "UNKNOWN",
"can_publish_notice": "true",
"can_pin_message": 2
}));
assert_eq!(shaped["canPublishNotice"], false);
assert_eq!(shaped["canPinMessage"], false);
assert_eq!(shaped["canManageMembers"], true);
}
#[test]
fn preserves_persisted_viewer_capabilities_without_role_inference() {
let shaped = shape_channel_row(&json!({
"id": "channel-1",
"role": "MEMBER",
"can_manage_settings": 1,
"canManageMembers": false,
}));
assert_eq!(shaped["canManageSettings"], true);
assert_eq!(shaped["canManageMembers"], false);
}
#[test]
fn normalizes_owner_and_admin_aliases_for_role_readback() {
let owner = shape_channel_row(&json!({
"id": "owner-readback",
"role": "OWNER",
}));
assert_eq!(owner["canEditChannelSettings"], true);
assert_eq!(owner["canManageMembers"], true);
assert_eq!(owner["canMentionAll"], true);
assert_eq!(owner["canPublishNotice"], true);
assert_eq!(owner["canChangePermissions"], true);
assert_eq!(owner["canPinMessage"], true);
let admin = shape_channel_row(&json!({
"id": "admin-readback",
"role": "ADMIN",
"mentionPermission": "MANAGER",
"noticePermission": "MANAGER",
"topPermission": "MANAGER",
}));
assert_eq!(admin["canEditChannelSettings"], true);
assert_eq!(admin["canManageMembers"], true);
assert_eq!(admin["canMentionAll"], true);
assert_eq!(admin["canPublishNotice"], true);
assert_eq!(admin["canChangePermissions"], false);
assert_eq!(admin["canPinMessage"], true);
}
#[test]
fn restores_cas_versions_from_legacy_updated_at_rows() {
let shaped = shape_channel_row(&json!({
"id": "channel-cas-version",
"type": "P",
"role": "CREATOR",
"updated_at": 1_786_001_262_956_i64,
}));
assert_eq!(shaped["settingVersion"], 1_786_001_262_956_i64);
assert_eq!(shaped["permissionRevision"], 1_786_001_262_956_i64);
let explicit = shape_channel_row(&json!({
"id": "channel-explicit-version",
"updated_at": 10_i64,
"setting_version": 11_i64,
"permission_revision": 12_i64,
}));
assert_eq!(explicit["settingVersion"], 11_i64);
assert_eq!(explicit["permissionRevision"], 12_i64);
}
#[test]
fn direct_channels_never_grant_pin_capability() {
for role in ["CREATOR", "MANAGER", "MEMBER"] {
for key in ["canPinMessage", "can_pin_message"] {
let mut row = json!({"id":"direct","type":"D","role":role,"topPermission":"MEMBER"});
row[key] = json!(true);
assert_eq!(shape_channel_row(&row)["canPinMessage"], false);
}
}
}
}