mirror of
https://github.com/pandorafuture/wx-cli.git
synced 2026-08-29 04:00:55 +00:00
feat: expose avatars and image quality metadata
This commit is contained in:
@@ -47,6 +47,7 @@ mod tests {
|
||||
last_sender_display_name: None,
|
||||
},
|
||||
display_name: "wxid_friend".to_string(),
|
||||
avatar_url: None,
|
||||
direction: None,
|
||||
detected_at: None,
|
||||
},
|
||||
@@ -70,6 +71,7 @@ mod tests {
|
||||
last_sender_display_name: None,
|
||||
},
|
||||
display_name: "wxid_friend".to_string(),
|
||||
avatar_url: None,
|
||||
direction: None,
|
||||
detected_at: Some(2),
|
||||
},
|
||||
|
||||
@@ -42,6 +42,7 @@ enum MediaPayload {
|
||||
InlineBytes {
|
||||
bytes: Vec<u8>,
|
||||
content_type: &'static str,
|
||||
quality: Option<&'static str>,
|
||||
},
|
||||
ServePath {
|
||||
path: PathBuf,
|
||||
@@ -56,11 +57,17 @@ impl MediaPayload {
|
||||
Self::InlineBytes {
|
||||
bytes,
|
||||
content_type,
|
||||
quality,
|
||||
} => {
|
||||
let mut response = bytes.into_response();
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(CONTENT_TYPE, HeaderValue::from_static(content_type));
|
||||
if let Some(quality) = quality {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("x-wechat-media-quality", HeaderValue::from_static(quality));
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
Self::ServePath {
|
||||
@@ -242,6 +249,14 @@ fn resolve_image(
|
||||
let dat_path = lookup.recommended.ok_or_else(|| {
|
||||
ServeError::NotFound(format!("no candidate image file found for md5={md5}"))
|
||||
})?;
|
||||
let quality = if dat_path
|
||||
.file_name()
|
||||
.is_some_and(|name| name.to_string_lossy().ends_with("_t.dat"))
|
||||
{
|
||||
"thumbnail"
|
||||
} else {
|
||||
"full"
|
||||
};
|
||||
let data = std::fs::read(&dat_path)
|
||||
.map_err(|e| ServeError::Internal(format!("failed to read {}: {e}", dat_path.display())))?;
|
||||
let decoded = wx_media::decrypt_dat(&data, &dat_decrypt)
|
||||
@@ -259,12 +274,14 @@ fn resolve_image(
|
||||
return Ok(MediaPayload::InlineBytes {
|
||||
bytes: transcoded.data,
|
||||
content_type: image_content_type(transcoded.ext),
|
||||
quality: Some(quality),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(MediaPayload::InlineBytes {
|
||||
bytes: decoded.data,
|
||||
content_type: image_content_type(&decoded.ext),
|
||||
quality: Some(quality),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -288,6 +305,7 @@ fn resolve_voice(
|
||||
return Ok(MediaPayload::InlineBytes {
|
||||
bytes: cached.bytes.clone(),
|
||||
content_type: cached.content_type,
|
||||
quality: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -331,6 +349,7 @@ fn resolve_voice(
|
||||
return Ok(MediaPayload::InlineBytes {
|
||||
bytes: result.data,
|
||||
content_type: result.mime,
|
||||
quality: None,
|
||||
});
|
||||
}
|
||||
Err(wx_media::MediaError::LookupMiss(_))
|
||||
|
||||
@@ -65,6 +65,7 @@ fn format_watch_line(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::items_after_test_module)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ pub struct EnrichedSession {
|
||||
pub session: Session,
|
||||
pub display_name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub avatar_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub direction: Option<Direction>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub detected_at: Option<i64>,
|
||||
@@ -61,10 +63,12 @@ pub fn enrich_session(
|
||||
detected_at: Option<i64>,
|
||||
) -> EnrichedSession {
|
||||
let display_name = resolver.display_with_id(&session.username);
|
||||
let avatar_url = resolver.avatar_url(&session.username).map(str::to_string);
|
||||
let direction = derive_session_direction(session.last_msg_sender.as_deref(), self_wxid);
|
||||
EnrichedSession {
|
||||
session,
|
||||
display_name,
|
||||
avatar_url,
|
||||
direction,
|
||||
detected_at,
|
||||
}
|
||||
@@ -76,6 +80,7 @@ pub fn enrich_session_event(
|
||||
resolver: &ContactResolver,
|
||||
) -> EnrichedSession {
|
||||
let display_name = resolver.display_with_id(&ev.username);
|
||||
let avatar_url = resolver.avatar_url(&ev.username).map(str::to_string);
|
||||
let direction = derive_session_direction(ev.last_msg_sender.as_deref(), self_wxid);
|
||||
let session = Session {
|
||||
username: ev.username,
|
||||
@@ -88,6 +93,7 @@ pub fn enrich_session_event(
|
||||
EnrichedSession {
|
||||
session,
|
||||
display_name,
|
||||
avatar_url,
|
||||
direction,
|
||||
detected_at: Some(ev.detected_at),
|
||||
}
|
||||
@@ -743,6 +749,7 @@ mod tests {
|
||||
last_sender_display_name: None,
|
||||
},
|
||||
display_name: "Spam".to_string(),
|
||||
avatar_url: None,
|
||||
direction: Some(Direction::Incoming),
|
||||
detected_at: None,
|
||||
};
|
||||
@@ -764,6 +771,7 @@ mod tests {
|
||||
last_sender_display_name: Some("Spammer".to_string()),
|
||||
},
|
||||
display_name: "Group".to_string(),
|
||||
avatar_url: None,
|
||||
direction: Some(Direction::Incoming),
|
||||
detected_at: None,
|
||||
};
|
||||
@@ -787,6 +795,7 @@ mod tests {
|
||||
last_sender_display_name: Some("Normal".to_string()),
|
||||
},
|
||||
display_name: "Group".to_string(),
|
||||
avatar_url: None,
|
||||
direction: Some(Direction::Incoming),
|
||||
detected_at: None,
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::io::{Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::path::Path;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::Mutex;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -18,6 +19,7 @@ const MSG_TABLE: &str = "Msg_29a6db07e8bbdb53f5d54cc3c309f3f1";
|
||||
const GROUP_TALKER: &str = "test@chatroom";
|
||||
const GROUP_MSG_TABLE: &str = "Msg_1d282e28b02b5c9f9522f855de32f9a8";
|
||||
const HIDDEN_SENDER: &str = "wxid_spam";
|
||||
static SERVER_START_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn bin() -> &'static str {
|
||||
env!("CARGO_BIN_EXE_wx-cli")
|
||||
@@ -150,9 +152,22 @@ fn serve_media_dispatch_image_returns_png_bytes() {
|
||||
);
|
||||
assert_eq!(response.status_code, 200, "{response:#?}");
|
||||
assert_eq!(response.header("content-type"), Some("image/png"));
|
||||
assert_eq!(response.header("x-wechat-media-quality"), Some("full"));
|
||||
assert_eq!(&response.body[..8], b"\x89PNG\r\n\x1a\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serve_media_thumbnail_only_image_reports_quality() {
|
||||
let server = spawn_test_server();
|
||||
let response = http_get(
|
||||
&server.base_url,
|
||||
"/api/v1/media?server_id=3009&talker=wxid_alice",
|
||||
);
|
||||
assert_eq!(response.status_code, 200, "{response:#?}");
|
||||
assert_eq!(response.header("content-type"), Some("image/png"));
|
||||
assert_eq!(response.header("x-wechat-media-quality"), Some("thumbnail"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serve_media_wxgf_embedded_png_returns_png_without_ffmpeg() {
|
||||
let server = spawn_test_server_with_env(&[("FFMPEG_PATH", "/definitely-missing-ffmpeg")]);
|
||||
@@ -326,6 +341,10 @@ fn spawn_test_server_with_setup(envs: &[(&str, &str)], hidden_contacts: &[&str])
|
||||
}
|
||||
let account_dir = fixture.path().join(TEST_ACCOUNT_ID);
|
||||
let runtime_root = fixture.path().join("runtime");
|
||||
// Keep ephemeral-port selection and worker binding atomic across parallel tests.
|
||||
let _start_guard = SERVER_START_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let port = find_open_port();
|
||||
let mut command = Command::new(bin());
|
||||
command
|
||||
@@ -712,6 +731,27 @@ fn create_encrypted_message_db(path: &Path, raw_key: &[u8; 32]) {
|
||||
)
|
||||
.expect("insert image ok message");
|
||||
|
||||
let image_thumb_only_info =
|
||||
encode_packed_info_for_test(Some("md5_image_thumb_only"), None);
|
||||
conn.execute(
|
||||
&format!(
|
||||
"INSERT INTO [{table}] VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
table = MSG_TABLE
|
||||
),
|
||||
params![
|
||||
301_i64,
|
||||
3009_i64,
|
||||
3_i64,
|
||||
1_i64,
|
||||
1_709_251_200_i64,
|
||||
Vec::<u8>::new(),
|
||||
image_thumb_only_info,
|
||||
0_i32,
|
||||
None::<i32>,
|
||||
],
|
||||
)
|
||||
.expect("insert thumbnail-only image message");
|
||||
|
||||
let image_wxgf_png_info = encode_packed_info_for_test(Some("md5_image_wxgf_png"), None);
|
||||
conn.execute(
|
||||
&format!(
|
||||
@@ -985,6 +1025,8 @@ fn create_image_fixture(attach_dir: &Path) {
|
||||
let encrypted = xor_bytes(&png, xor_key);
|
||||
fs::write(month_dir.join("md5_image_ok_t.dat"), &encrypted).expect("write thumb dat");
|
||||
fs::write(month_dir.join("md5_image_ok.dat"), &encrypted).expect("write image dat");
|
||||
fs::write(month_dir.join("md5_image_thumb_only_t.dat"), &encrypted)
|
||||
.expect("write thumbnail-only dat");
|
||||
|
||||
let wxgf_png = sample_wxgf_with_embedded_png();
|
||||
let wxgf_png_encrypted = xor_bytes(&wxgf_png, xor_key);
|
||||
|
||||
@@ -15,6 +15,7 @@ struct ResolvedContact {
|
||||
signature: Option<String>,
|
||||
region: Option<String>,
|
||||
labels: Vec<String>,
|
||||
avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
pub struct ContactResolver {
|
||||
@@ -56,6 +57,7 @@ impl ContactResolver {
|
||||
signature: c.signature,
|
||||
region: c.region,
|
||||
labels: c.labels,
|
||||
avatar_url: c.avatar_url,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -92,6 +94,13 @@ impl ContactResolver {
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
|
||||
/// Resolve a wxid to its preferred avatar URL.
|
||||
pub fn avatar_url(&self, wxid: &str) -> Option<&str> {
|
||||
self.contacts
|
||||
.get(wxid)
|
||||
.and_then(|contact| contact.avatar_url.as_deref())
|
||||
}
|
||||
|
||||
/// Iterate all contacts with their wxid and labels.
|
||||
/// Used by VisibilityIndex to expand ignore_tags.
|
||||
pub fn all_labels(&self) -> impl Iterator<Item = (&String, &[String])> {
|
||||
@@ -200,6 +209,7 @@ mod tests {
|
||||
signature: None,
|
||||
region: None,
|
||||
labels: Vec::new(),
|
||||
avatar_url: None,
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -235,6 +245,7 @@ mod tests {
|
||||
signature: None,
|
||||
region: None,
|
||||
labels: labels.iter().map(|s| s.to_string()).collect(),
|
||||
avatar_url: None,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
@@ -43,16 +43,19 @@ impl WechatDb {
|
||||
limit: usize,
|
||||
label_map: &HashMap<String, String>,
|
||||
) -> Result<QueryResult<Contact>, DbError> {
|
||||
let avatar_expr = self.contact_avatar_select_expr()?;
|
||||
let total_rows: usize =
|
||||
self.contact_conn
|
||||
.query_row("SELECT COUNT(*) FROM contact", [], |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})? as usize;
|
||||
|
||||
let mut stmt = self.contact_conn.prepare(
|
||||
"SELECT username, alias, remark, nick_name, description, extra_buffer \
|
||||
FROM contact ORDER BY username ASC LIMIT ?1 OFFSET ?2",
|
||||
)?;
|
||||
let sql = format!(
|
||||
"SELECT username, alias, remark, nick_name, description, extra_buffer, \
|
||||
{avatar_expr} AS avatar_url \
|
||||
FROM contact ORDER BY username ASC LIMIT ?1 OFFSET ?2"
|
||||
);
|
||||
let mut stmt = self.contact_conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(
|
||||
[
|
||||
Value::Integer(limit as i64),
|
||||
@@ -81,6 +84,7 @@ impl WechatDb {
|
||||
label_map: &HashMap<String, String>,
|
||||
) -> Result<QueryResult<Contact>, DbError> {
|
||||
let kw_lower = query.keyword.as_ref().unwrap().to_lowercase();
|
||||
let avatar_expr = self.contact_avatar_select_expr()?;
|
||||
|
||||
let total_rows: usize =
|
||||
self.contact_conn
|
||||
@@ -88,10 +92,12 @@ impl WechatDb {
|
||||
row.get::<_, i64>(0)
|
||||
})? as usize;
|
||||
|
||||
let mut stmt = self.contact_conn.prepare(
|
||||
"SELECT username, alias, remark, nick_name, description, extra_buffer \
|
||||
FROM contact ORDER BY username ASC",
|
||||
)?;
|
||||
let sql = format!(
|
||||
"SELECT username, alias, remark, nick_name, description, extra_buffer, \
|
||||
{avatar_expr} AS avatar_url \
|
||||
FROM contact ORDER BY username ASC"
|
||||
);
|
||||
let mut stmt = self.contact_conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map([], |row| self.map_contact_row(row, label_map))?;
|
||||
|
||||
let all_contacts: Vec<Contact> = rows.filter_map(|r| r.ok()).collect();
|
||||
@@ -126,6 +132,10 @@ impl WechatDb {
|
||||
let nick_name: String = row.get::<_, String>(3).unwrap_or_default();
|
||||
let memo: Option<String> = row.get::<_, Option<String>>(4).unwrap_or(None);
|
||||
let extra_buffer: Vec<u8> = row.get::<_, Vec<u8>>(5).unwrap_or_default();
|
||||
let avatar_url: Option<String> = row
|
||||
.get::<_, Option<String>>(6)
|
||||
.unwrap_or(None)
|
||||
.filter(|value| !value.is_empty());
|
||||
|
||||
let extra = contact_proto::decode_extra_buffer(&extra_buffer);
|
||||
|
||||
@@ -147,6 +157,22 @@ impl WechatDb {
|
||||
source_scene: extra.source_scene,
|
||||
phone: extra.phone,
|
||||
labels,
|
||||
avatar_url,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a compatible avatar expression for WeChat database variants.
|
||||
/// Older fixtures and database versions may not contain either column.
|
||||
fn contact_avatar_select_expr(&self) -> Result<&'static str, DbError> {
|
||||
let has_small =
|
||||
decode::check_column_exists(&self.contact_conn, "contact", "small_head_url")?;
|
||||
let has_big = decode::check_column_exists(&self.contact_conn, "contact", "big_head_url")?;
|
||||
|
||||
Ok(match (has_small, has_big) {
|
||||
(true, true) => "COALESCE(NULLIF(small_head_url, ''), NULLIF(big_head_url, ''))",
|
||||
(true, false) => "NULLIF(small_head_url, '')",
|
||||
(false, true) => "NULLIF(big_head_url, '')",
|
||||
(false, false) => "NULL",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -380,6 +380,9 @@ pub struct Contact {
|
||||
pub phone: Option<String>,
|
||||
/// Resolved label names from extra_buffer + contact_label table.
|
||||
pub labels: Vec<String>,
|
||||
/// Preferred avatar URL from `small_head_url`, falling back to `big_head_url`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
/// A WeChat chatroom (group chat) entry with its member list.
|
||||
|
||||
@@ -282,6 +282,55 @@ fn contacts_sessions_query_contacts_limit() {
|
||||
assert_eq!(result.items.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contacts_sessions_query_contacts_avatar_url() {
|
||||
let dir = create_fixture();
|
||||
let contact_path = dir.path().join("contact").join("contact.db");
|
||||
let conn = Connection::open(contact_path).unwrap();
|
||||
conn.execute_batch(
|
||||
"ALTER TABLE contact ADD COLUMN small_head_url TEXT;
|
||||
ALTER TABLE contact ADD COLUMN big_head_url TEXT;",
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"UPDATE contact SET small_head_url = ?1, big_head_url = ?2 WHERE username = ?3",
|
||||
params![
|
||||
"https://avatar.example/alice-small.jpg",
|
||||
"https://avatar.example/alice-big.jpg",
|
||||
"wxid_alice"
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"UPDATE contact SET big_head_url = ?1 WHERE username = ?2",
|
||||
params!["https://avatar.example/bob-big.jpg", "wxid_bob"],
|
||||
)
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
|
||||
let db = WechatDb::open(dir.path()).unwrap();
|
||||
let result = db.query_contacts(&ContactQuery::new()).unwrap();
|
||||
let alice = result
|
||||
.items
|
||||
.iter()
|
||||
.find(|contact| contact.user_name == "wxid_alice")
|
||||
.unwrap();
|
||||
let bob = result
|
||||
.items
|
||||
.iter()
|
||||
.find(|contact| contact.user_name == "wxid_bob")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
alice.avatar_url.as_deref(),
|
||||
Some("https://avatar.example/alice-small.jpg")
|
||||
);
|
||||
assert_eq!(
|
||||
bob.avatar_url.as_deref(),
|
||||
Some("https://avatar.example/bob-big.jpg")
|
||||
);
|
||||
}
|
||||
|
||||
// ---- sessions tests ----
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -15,21 +15,21 @@ fn make_xor_dat(key: u8) -> Vec<u8> {
|
||||
/// Header: 07 08 V1 08 07 (6B) + aes_size LE (4B) + xor_size LE (4B) + 0x01 (1B) = 15B
|
||||
/// Then AES-ECB encrypted payload with fixed key, then raw, then XOR tail.
|
||||
fn make_v1_dat() -> Vec<u8> {
|
||||
use aes::cipher::{BlockCipherEncrypt, KeyInit};
|
||||
use aes::cipher::{Array, BlockCipherEncrypt, KeyInit};
|
||||
use aes::Aes128;
|
||||
|
||||
let key = b"cfcd208495d565ef"; // md5("0")[:16]
|
||||
let cipher = Aes128::new(key.into());
|
||||
|
||||
// Plaintext: JPEG header (16 bytes = 1 AES block) with PKCS7 padding
|
||||
let mut block1 = [
|
||||
let mut block1 = Array::from([
|
||||
0xFFu8, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00,
|
||||
0x01,
|
||||
];
|
||||
let mut block2 = [16u8; 16]; // full PKCS7 padding block
|
||||
]);
|
||||
let mut block2 = Array::from([16u8; 16]); // full PKCS7 padding block
|
||||
|
||||
cipher.encrypt_block(aes::cipher::Array::from_mut_slice(&mut block1));
|
||||
cipher.encrypt_block(aes::cipher::Array::from_mut_slice(&mut block2));
|
||||
cipher.encrypt_block(&mut block1);
|
||||
cipher.encrypt_block(&mut block2);
|
||||
|
||||
let aes_size: u32 = 16; // original plaintext size
|
||||
let xor_size: u32 = 0;
|
||||
@@ -46,20 +46,20 @@ fn make_v1_dat() -> Vec<u8> {
|
||||
|
||||
/// Build a V2-encrypted `.dat` file with known AES key and XOR tail.
|
||||
fn make_v2_dat(aes_key: &[u8; 16], xor_key: u8) -> Vec<u8> {
|
||||
use aes::cipher::{BlockCipherEncrypt, KeyInit};
|
||||
use aes::cipher::{Array, BlockCipherEncrypt, KeyInit};
|
||||
use aes::Aes128;
|
||||
|
||||
let cipher = Aes128::new(aes_key.into());
|
||||
|
||||
// Plaintext: PNG header (16 bytes = 1 block)
|
||||
let mut block1 = [
|
||||
let mut block1 = Array::from([
|
||||
0x89u8, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44,
|
||||
0x52,
|
||||
];
|
||||
let mut block2 = [16u8; 16]; // PKCS7 padding block
|
||||
]);
|
||||
let mut block2 = Array::from([16u8; 16]); // PKCS7 padding block
|
||||
|
||||
cipher.encrypt_block(aes::cipher::Array::from_mut_slice(&mut block1));
|
||||
cipher.encrypt_block(aes::cipher::Array::from_mut_slice(&mut block2));
|
||||
cipher.encrypt_block(&mut block1);
|
||||
cipher.encrypt_block(&mut block2);
|
||||
|
||||
let aes_size: u32 = 16;
|
||||
// Tail: 4 bytes XOR-encrypted
|
||||
|
||||
@@ -369,21 +369,21 @@ mod tests {
|
||||
#[test]
|
||||
fn server_lock_file_under_server_state() {
|
||||
let ap = AppPaths::new().unwrap();
|
||||
assert!(ap.server_lock_file().starts_with(&ap.server_state_dir()));
|
||||
assert!(ap.server_lock_file().starts_with(ap.server_state_dir()));
|
||||
assert!(ap.server_lock_file().ends_with("manager.lock"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_config_file_under_server_state() {
|
||||
let ap = AppPaths::new().unwrap();
|
||||
assert!(ap.server_config_file().starts_with(&ap.server_state_dir()));
|
||||
assert!(ap.server_config_file().starts_with(ap.server_state_dir()));
|
||||
assert!(ap.server_config_file().ends_with("config.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_state_file_under_server_state() {
|
||||
let ap = AppPaths::new().unwrap();
|
||||
assert!(ap.server_state_file().starts_with(&ap.server_state_dir()));
|
||||
assert!(ap.server_state_file().starts_with(ap.server_state_dir()));
|
||||
assert!(ap.server_state_file().ends_with("state.json"));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user