- entry 新增 format(apps/posters/articles),老数据按根分组 view_mode 回填;seed 按分区写入 - 分组管理移除格式字段/显示,根分组只作容器 - 内容弹窗新增逐条格式选择,列表增加格式列;ItemEditor 保存时保留原格式 - 前台按 entry.format 渲染:卡片/分组页支持同组混合格式,详情按条目格式选模板 - 迁移后 live 20 应用 / 13 海报;cargo test 59 passed;Playwright 验证混合展示
687 lines
22 KiB
Rust
687 lines
22 KiB
Rust
//! 条目的读取与编辑(P2)。
|
||
//!
|
||
//! 编辑用 `updated_at` 做乐观锁:带上打开表单时的时间戳,
|
||
//! 别人已经改过就返回冲突,而不是静默覆盖。
|
||
|
||
use rusqlite::{Connection, OptionalExtension, params};
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
use crate::{db::map_error, error::StashError};
|
||
|
||
const MAX_NAME: usize = 120;
|
||
const MAX_BODY: usize = 100_000;
|
||
const MAX_TAGS: usize = 30;
|
||
const MAX_TAG_LEN: usize = 40;
|
||
|
||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||
pub struct EntryRecord {
|
||
pub key: String,
|
||
pub format: String,
|
||
pub name: String,
|
||
pub description: String,
|
||
pub icon: String,
|
||
pub href: String,
|
||
pub cover: String,
|
||
pub banner: String,
|
||
pub badge: String,
|
||
pub comment: String,
|
||
pub summary: String,
|
||
pub body: String,
|
||
pub tags: Vec<String>,
|
||
pub updated_at: i64,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EntryPatch {
|
||
pub name: String,
|
||
#[serde(default)]
|
||
pub description: String,
|
||
#[serde(default)]
|
||
pub icon: String,
|
||
#[serde(default)]
|
||
pub href: String,
|
||
#[serde(default)]
|
||
pub cover: String,
|
||
#[serde(default)]
|
||
pub banner: String,
|
||
#[serde(default)]
|
||
pub badge: String,
|
||
#[serde(default)]
|
||
pub comment: String,
|
||
#[serde(default)]
|
||
pub summary: String,
|
||
#[serde(default)]
|
||
pub body: String,
|
||
#[serde(default)]
|
||
pub tags: Vec<String>,
|
||
/// 可选:把条目移动到另一个分区 / 分组(不传则保持原位)
|
||
#[serde(default)]
|
||
pub node_key: Option<String>,
|
||
/// 条目自身的展示格式:apps / posters / articles;不传默认 apps
|
||
#[serde(default)]
|
||
pub format: Option<String>,
|
||
}
|
||
|
||
/// 新建条目的请求体:`key` 是业务键,`nodeKey` 决定挂在哪个分区 / 分组。
|
||
#[derive(Debug, Clone, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct NewEntry {
|
||
pub key: String,
|
||
pub node_key: String,
|
||
pub item: EntryPatch,
|
||
}
|
||
|
||
fn now() -> i64 {
|
||
std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.map(|duration| duration.as_secs() as i64)
|
||
.unwrap_or(0)
|
||
}
|
||
|
||
/// 规范化跳转地址:补协议、拒绝危险协议、空值统一成空串。
|
||
///
|
||
/// 用户经常直接填 `www.zhihu.com`。原样存进 `href` 会被浏览器当成
|
||
/// 相对地址(点「跳转到」会跳到 `/c/.../www.zhihu.com`),所以这里统一补
|
||
/// `https://`。`href` 会原样渲染进 `<a>`,`javascript:` / `data:` 这类
|
||
/// 协议必须直接拒绝,不能只靠前端过滤。
|
||
fn normalize_href(raw: &str) -> Result<String, StashError> {
|
||
let value = raw.trim();
|
||
if value.is_empty() || value == "#" {
|
||
return Ok(String::new());
|
||
}
|
||
if value.chars().any(char::is_whitespace) {
|
||
return Err(StashError::InvalidDirectory("跳转地址不能包含空格".into()));
|
||
}
|
||
|
||
// 站内路径(单个斜杠开头);`//host` 是协议相对地址,按外链处理
|
||
if value.starts_with('/') && !value.starts_with("//") {
|
||
return Ok(value.to_string());
|
||
}
|
||
|
||
let lower = value.to_ascii_lowercase();
|
||
if lower.starts_with("http://") || lower.starts_with("https://") {
|
||
return Ok(value.to_string());
|
||
}
|
||
if value.starts_with("//") {
|
||
return Ok(format!("https:{value}"));
|
||
}
|
||
|
||
// 带「其他协议」的地址一律拒绝(javascript: / data: / vbscript: / file: / mailto: …);
|
||
// 只放行上面已经处理过的 http(s) 与站内路径。
|
||
if let Some((scheme, _)) = lower.split_once(':') {
|
||
let looks_like_scheme = !scheme.is_empty()
|
||
&& scheme.chars().next().is_some_and(|ch| ch.is_ascii_alphabetic())
|
||
&& scheme
|
||
.chars()
|
||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '+' || ch == '-' || ch == '.');
|
||
if looks_like_scheme && !looks_like_domain(value) {
|
||
return Err(StashError::InvalidDirectory(
|
||
"跳转地址只支持 http(s)、站内路径或域名".into(),
|
||
));
|
||
}
|
||
}
|
||
|
||
if looks_like_domain(value) {
|
||
return Ok(format!("https://{value}"));
|
||
}
|
||
|
||
Err(StashError::InvalidDirectory(
|
||
"跳转地址需以 http(s):// 或 / 开头".into(),
|
||
))
|
||
}
|
||
|
||
/// 裸域名判断:`www.zhihu.com`、`example.com:8080/path` 这类没有协议头的地址。
|
||
fn looks_like_domain(value: &str) -> bool {
|
||
let host_port = value.split('/').next().unwrap_or("");
|
||
let host = host_port.split(':').next().unwrap_or("");
|
||
if !host.contains('.') {
|
||
return false;
|
||
}
|
||
|
||
let mut parts = host.split('.');
|
||
let Some(tld) = parts.next_back() else {
|
||
return false;
|
||
};
|
||
|
||
tld.len() >= 2
|
||
&& tld.chars().all(|ch| ch.is_ascii_alphabetic())
|
||
&& parts.all(|part| {
|
||
!part.is_empty()
|
||
&& part
|
||
.chars()
|
||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-')
|
||
})
|
||
}
|
||
|
||
fn validate(patch: &EntryPatch) -> Result<(), StashError> {
|
||
if let Some(format) = patch.format.as_deref() {
|
||
if !matches!(format, "apps" | "posters" | "articles") {
|
||
return Err(StashError::InvalidDirectory("格式不合法".into()));
|
||
}
|
||
}
|
||
if patch.name.trim().is_empty() {
|
||
return Err(StashError::InvalidDirectory("标题不能为空".into()));
|
||
}
|
||
if patch.name.chars().count() > MAX_NAME {
|
||
return Err(StashError::InvalidDirectory(format!(
|
||
"标题最多 {MAX_NAME} 个字"
|
||
)));
|
||
}
|
||
if patch.body.chars().count() > MAX_BODY {
|
||
return Err(StashError::InvalidDirectory("正文过长".into()));
|
||
}
|
||
if patch.tags.len() > MAX_TAGS {
|
||
return Err(StashError::InvalidDirectory(format!(
|
||
"标签最多 {MAX_TAGS} 个"
|
||
)));
|
||
}
|
||
for tag in &patch.tags {
|
||
if tag.chars().count() > MAX_TAG_LEN {
|
||
return Err(StashError::InvalidDirectory(format!(
|
||
"单个标签最多 {MAX_TAG_LEN} 个字"
|
||
)));
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// 读一条条目(编辑表单用),不存在返回 None。
|
||
pub fn load(conn: &Connection, key: &str) -> Result<Option<EntryRecord>, StashError> {
|
||
let row = conn
|
||
.query_row(
|
||
"SELECT key, COALESCE(format, 'apps'), name, COALESCE(description, ''), COALESCE(icon, ''), COALESCE(href, ''),
|
||
COALESCE(cover, ''), COALESCE(banner, ''), COALESCE(badge, ''),
|
||
COALESCE(comment, ''), COALESCE(summary, ''), COALESCE(body, ''), updated_at
|
||
FROM entry WHERE key = ?1",
|
||
[key],
|
||
|row| {
|
||
Ok(EntryRecord {
|
||
key: row.get(0)?,
|
||
format: row.get(1)?,
|
||
name: row.get(2)?,
|
||
description: row.get(3)?,
|
||
icon: row.get(4)?,
|
||
href: row.get(5)?,
|
||
cover: row.get(6)?,
|
||
banner: row.get(7)?,
|
||
badge: row.get(8)?,
|
||
comment: row.get(9)?,
|
||
summary: row.get(10)?,
|
||
body: row.get(11)?,
|
||
tags: Vec::new(),
|
||
updated_at: row.get(12)?,
|
||
})
|
||
},
|
||
)
|
||
.optional()
|
||
.map_err(map_error)?;
|
||
|
||
let Some(mut entry) = row else {
|
||
return Ok(None);
|
||
};
|
||
|
||
let mut statement = conn
|
||
.prepare("SELECT label FROM entry_tag WHERE entry_key = ?1 ORDER BY sort")
|
||
.map_err(map_error)?;
|
||
entry.tags = statement
|
||
.query_map([key], |row| row.get::<_, String>(0))
|
||
.map_err(map_error)?
|
||
.collect::<Result<Vec<_>, _>>()
|
||
.map_err(map_error)?;
|
||
|
||
Ok(Some(entry))
|
||
}
|
||
|
||
/// 条目能挂到哪:分区本身,或分区下的分组(任意类型都支持分组)。
|
||
fn validate_target(conn: &Connection, node_key: &str) -> Result<(), StashError> {
|
||
let row: Option<(String, Option<String>)> = conn
|
||
.query_row(
|
||
"SELECT n.kind, p.kind
|
||
FROM node n LEFT JOIN node p ON p.key = n.parent_key
|
||
WHERE n.key = ?1",
|
||
[node_key],
|
||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||
)
|
||
.optional()
|
||
.map_err(map_error)?;
|
||
|
||
let Some((kind, parent_kind)) = row else {
|
||
return Err(StashError::NotFound(format!("节点不存在: {node_key}")));
|
||
};
|
||
|
||
match (kind.as_str(), parent_kind.as_deref()) {
|
||
("section", _) => Ok(()),
|
||
// 多层分组:父级可以是分区或任意分组
|
||
("group", Some(_)) => Ok(()),
|
||
("group", _) => Err(StashError::InvalidDirectory(
|
||
"分组的父节点必须是分区或分组".into(),
|
||
)),
|
||
_ => Err(StashError::InvalidDirectory(
|
||
"条目只能挂在分区或分组下".into(),
|
||
)),
|
||
}
|
||
}
|
||
|
||
fn valid_key(key: &str) -> bool {
|
||
!key.is_empty()
|
||
&& key.len() <= 64
|
||
&& key
|
||
.chars()
|
||
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
|
||
}
|
||
|
||
fn write_tags(conn: &Connection, key: &str, tags: &[String]) -> Result<(), StashError> {
|
||
conn.execute("DELETE FROM entry_tag WHERE entry_key = ?1", [key])
|
||
.map_err(map_error)?;
|
||
for (index, tag) in tags.iter().enumerate() {
|
||
conn.execute(
|
||
"INSERT INTO entry_tag (entry_key, sort, label) VALUES (?1, ?2, ?3)",
|
||
params![key, index as i64, tag],
|
||
)
|
||
.map_err(map_error)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// 新建条目:用于后台「内容」页添加网址 / 海报 / 图文。
|
||
pub fn create(conn: &mut Connection, new: NewEntry) -> Result<EntryRecord, StashError> {
|
||
let key = new.key.trim().to_string();
|
||
if !valid_key(&key) {
|
||
return Err(StashError::InvalidDirectory(
|
||
"标识只能用小写字母、数字与连字符(≤64)".into(),
|
||
));
|
||
}
|
||
|
||
let patch = new.item;
|
||
validate(&patch)?;
|
||
let format = patch.format.clone().unwrap_or_else(|| "apps".into());
|
||
let href = normalize_href(&patch.href)?;
|
||
validate_target(conn, &new.node_key)?;
|
||
|
||
let exists: i64 = conn
|
||
.query_row("SELECT COUNT(*) FROM entry WHERE key = ?1", [&key], |row| {
|
||
row.get(0)
|
||
})
|
||
.map_err(map_error)?;
|
||
if exists > 0 {
|
||
return Err(StashError::Conflict(format!("条目已存在: {key}")));
|
||
}
|
||
|
||
let tags: Vec<String> = patch
|
||
.tags
|
||
.iter()
|
||
.map(|tag| tag.trim().to_string())
|
||
.filter(|tag| !tag.is_empty())
|
||
.collect();
|
||
|
||
let timestamp = now();
|
||
let tx = conn.transaction().map_err(map_error)?;
|
||
|
||
let sort: i64 = tx
|
||
.query_row(
|
||
"SELECT COALESCE(MAX(sort), 0) + 1 FROM entry WHERE node_key = ?1",
|
||
[&new.node_key],
|
||
|row| row.get(0),
|
||
)
|
||
.map_err(map_error)?;
|
||
|
||
tx.execute(
|
||
"INSERT INTO entry (key, node_key, format, name, description, icon, href, cover, banner,
|
||
badge, comment, summary, body, hot, views, likes, sort, updated_at)
|
||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, 0, 0, 0, ?14, ?15)",
|
||
params![
|
||
key,
|
||
new.node_key,
|
||
format,
|
||
patch.name.trim(),
|
||
patch.description,
|
||
patch.icon,
|
||
href,
|
||
patch.cover,
|
||
patch.banner,
|
||
patch.badge,
|
||
patch.comment,
|
||
patch.summary,
|
||
patch.body,
|
||
sort,
|
||
timestamp
|
||
],
|
||
)
|
||
.map_err(map_error)?;
|
||
|
||
write_tags(&tx, &key, &tags)?;
|
||
tx.execute(
|
||
"INSERT INTO audit (at, action, target, detail) VALUES (?1, 'item.create', ?2, ?3)",
|
||
params![timestamp, key, patch.name.trim()],
|
||
)
|
||
.map_err(map_error)?;
|
||
|
||
tx.commit().map_err(map_error)?;
|
||
load(conn, &key)?
|
||
.ok_or_else(|| StashError::NotFound(format!("条目不存在: {key}")))
|
||
}
|
||
|
||
/// 删除条目(后台内容页用)。
|
||
pub fn remove(conn: &Connection, key: &str) -> Result<(), StashError> {
|
||
let removed = conn
|
||
.execute("DELETE FROM entry WHERE key = ?1", [key])
|
||
.map_err(map_error)?;
|
||
if removed == 0 {
|
||
return Err(StashError::NotFound(format!("条目不存在: {key}")));
|
||
}
|
||
|
||
audit(conn, "item.delete", key, "")?;
|
||
Ok(())
|
||
}
|
||
|
||
/// 更新条目;`expected_updated_at` 与库里不一致时冲突。
|
||
pub fn update( conn: &mut Connection,
|
||
key: &str,
|
||
patch: EntryPatch,
|
||
expected_updated_at: i64,
|
||
) -> Result<EntryRecord, StashError> {
|
||
validate(&patch)?;
|
||
let format = patch.format.clone().unwrap_or_else(|| "apps".into());
|
||
// 跳转地址先规范化:补 https、拒绝 javascript: 等危险协议
|
||
let href = normalize_href(&patch.href)?;
|
||
if let Some(node_key) = patch.node_key.as_deref() {
|
||
validate_target(conn, node_key)?;
|
||
}
|
||
|
||
let tags: Vec<String> = patch
|
||
.tags
|
||
.iter()
|
||
.map(|tag| tag.trim().to_string())
|
||
.filter(|tag| !tag.is_empty())
|
||
.collect();
|
||
|
||
let timestamp = now();
|
||
let tx = conn.transaction().map_err(map_error)?;
|
||
|
||
let updated = tx
|
||
.execute(
|
||
"UPDATE entry SET
|
||
name = ?1, description = ?2, icon = ?3, href = ?4,
|
||
cover = ?5, banner = ?6, badge = ?7, comment = ?8,
|
||
summary = ?9, body = ?10, updated_at = ?11,
|
||
node_key = COALESCE(?14, node_key),
|
||
format = ?15
|
||
WHERE key = ?12 AND updated_at = ?13",
|
||
params![
|
||
patch.name.trim(),
|
||
patch.description,
|
||
patch.icon,
|
||
href,
|
||
patch.cover,
|
||
patch.banner,
|
||
patch.badge,
|
||
patch.comment,
|
||
patch.summary,
|
||
patch.body,
|
||
timestamp,
|
||
key,
|
||
expected_updated_at,
|
||
patch.node_key,
|
||
format
|
||
],
|
||
)
|
||
.map_err(map_error)?;
|
||
|
||
if updated == 0 {
|
||
return Err(StashError::Conflict(
|
||
"条目已被修改,请刷新后重试".into(),
|
||
));
|
||
}
|
||
|
||
tx.execute("DELETE FROM entry_tag WHERE entry_key = ?1", [key])
|
||
.map_err(map_error)?;
|
||
for (index, tag) in tags.iter().enumerate() {
|
||
tx.execute(
|
||
"INSERT INTO entry_tag (entry_key, sort, label) VALUES (?1, ?2, ?3)",
|
||
params![key, index as i64, tag],
|
||
)
|
||
.map_err(map_error)?;
|
||
}
|
||
|
||
tx.execute(
|
||
"INSERT INTO audit (at, action, target, detail) VALUES (?1, 'item.update', ?2, ?3)",
|
||
params![timestamp, key, patch.name.trim()],
|
||
)
|
||
.map_err(map_error)?;
|
||
|
||
tx.commit().map_err(map_error)?;
|
||
|
||
load(conn, key)?
|
||
.ok_or_else(|| StashError::NotFound(format!("条目不存在: {key}")))
|
||
}
|
||
|
||
/// 记录一次浏览:views +1,返回 (views, likes)。
|
||
pub fn bump_view(conn: &Connection, key: &str) -> Result<(i64, i64), StashError> {
|
||
conn.execute("UPDATE entry SET views = views + 1 WHERE key = ?1", [key])
|
||
.map_err(map_error)?;
|
||
counts(conn, key)
|
||
}
|
||
|
||
/// 点赞 / 取消点赞:likes 加一或减一(不低于 0),返回 (views, likes)。
|
||
pub fn set_like(conn: &Connection, key: &str, liked: bool) -> Result<(i64, i64), StashError> {
|
||
let delta: i64 = if liked { 1 } else { -1 };
|
||
conn.execute(
|
||
"UPDATE entry SET likes = MAX(0, likes + ?1) WHERE key = ?2",
|
||
params![delta, key],
|
||
)
|
||
.map_err(map_error)?;
|
||
counts(conn, key)
|
||
}
|
||
|
||
fn counts(conn: &Connection, key: &str) -> Result<(i64, i64), StashError> {
|
||
conn.query_row("SELECT views, likes FROM entry WHERE key = ?1", [key], |row| {
|
||
Ok((row.get(0)?, row.get(1)?))
|
||
})
|
||
.optional()
|
||
.map_err(map_error)?
|
||
.ok_or_else(|| StashError::NotFound(format!("条目不存在: {key}")))
|
||
}
|
||
|
||
/// 记录一条审计(设置保存等)。
|
||
pub fn audit(conn: &Connection, action: &str, target: &str, detail: &str) -> Result<(), StashError> {
|
||
conn.execute(
|
||
"INSERT INTO audit (at, action, target, detail) VALUES (?1, ?2, ?3, ?4)",
|
||
params![now(), action, target, detail],
|
||
)
|
||
.map_err(map_error)?;
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn db() -> Connection {
|
||
let conn = Connection::open_in_memory().expect("内存库应能打开");
|
||
conn.execute_batch(
|
||
"CREATE TABLE entry (
|
||
key TEXT PRIMARY KEY,
|
||
node_key TEXT NOT NULL,
|
||
format TEXT NOT NULL DEFAULT 'apps',
|
||
name TEXT NOT NULL,
|
||
description TEXT, icon TEXT, href TEXT, cover TEXT, banner TEXT,
|
||
badge TEXT, comment TEXT, summary TEXT, body TEXT,
|
||
hot INTEGER NOT NULL DEFAULT 0, views INTEGER NOT NULL DEFAULT 0,
|
||
likes INTEGER NOT NULL DEFAULT 0, sort INTEGER NOT NULL DEFAULT 0,
|
||
updated_at INTEGER NOT NULL DEFAULT 0
|
||
);
|
||
CREATE TABLE entry_tag (
|
||
entry_key TEXT NOT NULL,
|
||
sort INTEGER NOT NULL DEFAULT 0,
|
||
label TEXT NOT NULL,
|
||
PRIMARY KEY (entry_key, sort)
|
||
);
|
||
CREATE TABLE audit (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
at INTEGER NOT NULL,
|
||
action TEXT NOT NULL,
|
||
target TEXT,
|
||
detail TEXT
|
||
);
|
||
CREATE TABLE node (
|
||
key TEXT PRIMARY KEY,
|
||
parent_key TEXT,
|
||
title TEXT NOT NULL,
|
||
kind TEXT NOT NULL,
|
||
view_mode TEXT
|
||
);
|
||
INSERT INTO node (key, parent_key, title, kind, view_mode) VALUES
|
||
('movie', NULL, '推荐电影', 'section', 'posters'),
|
||
('apps1', NULL, '常用推荐', 'section', 'apps'),
|
||
('g1', 'apps1', '常用站点', 'group', NULL);
|
||
INSERT INTO entry (key, node_key, name, updated_at) VALUES ('movie-1', 'movie', '千与千寻', 0);",
|
||
)
|
||
.expect("应能建表");
|
||
conn
|
||
}
|
||
|
||
fn patch(name: &str) -> EntryPatch {
|
||
EntryPatch {
|
||
name: name.into(),
|
||
description: "简介".into(),
|
||
icon: String::new(),
|
||
href: "https://example.com".into(),
|
||
cover: String::new(),
|
||
banner: String::new(),
|
||
badge: "推荐".into(),
|
||
comment: "一句话".into(),
|
||
summary: "正文".into(),
|
||
body: "## 标题".into(),
|
||
tags: vec!["推荐电影".into(), "动画".into()],
|
||
node_key: None,
|
||
format: None,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn update_replaces_fields_and_tags() {
|
||
let mut conn = db();
|
||
let saved = update(&mut conn, "movie-1", patch("千与千寻 2"), 0).expect("应能更新");
|
||
assert_eq!(saved.name, "千与千寻 2");
|
||
assert_eq!(saved.tags, vec!["推荐电影".to_string(), "动画".to_string()]);
|
||
assert!(saved.updated_at > 0);
|
||
|
||
let audit: i64 = conn
|
||
.query_row("SELECT COUNT(*) FROM audit", [], |row| row.get(0))
|
||
.expect("应能查审计");
|
||
assert_eq!(audit, 1);
|
||
}
|
||
|
||
#[test]
|
||
fn stale_timestamp_conflicts() {
|
||
let mut conn = db();
|
||
let saved = update(&mut conn, "movie-1", patch("A"), 0).expect("应能更新");
|
||
let error = update(&mut conn, "movie-1", patch("B"), saved.updated_at - 1)
|
||
.expect_err("应冲突");
|
||
assert!(matches!(error, StashError::Conflict(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_empty_name() {
|
||
let mut conn = db();
|
||
assert!(update(&mut conn, "movie-1", patch(" "), 0).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn missing_entry_is_not_found() {
|
||
let mut conn = db();
|
||
let error = update(&mut conn, "none", patch("X"), 0).expect_err("应不存在");
|
||
assert!(matches!(error, StashError::Conflict(_) | StashError::NotFound(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn normalizes_links_so_jump_works() {
|
||
// 裸域名补 https,避免被浏览器当成相对地址
|
||
assert_eq!(normalize_href("www.zhihu.com").unwrap(), "https://www.zhihu.com");
|
||
assert_eq!(
|
||
normalize_href("example.com:8080/path").unwrap(),
|
||
"https://example.com:8080/path"
|
||
);
|
||
// 已带协议的保持原样;站内路径保持站内
|
||
assert_eq!(normalize_href("https://example.com/a").unwrap(), "https://example.com/a");
|
||
assert_eq!(normalize_href("/git_helper/").unwrap(), "/git_helper/");
|
||
// 空值和占位符统一成空串
|
||
assert_eq!(normalize_href(" ").unwrap(), "");
|
||
assert_eq!(normalize_href("#").unwrap(), "");
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_dangerous_or_unknown_links() {
|
||
for bad in ["javascript:alert(1)", "data:text/html,hi", "mailto:[email protected]", "not a url"] {
|
||
assert!(normalize_href(bad).is_err(), "{bad} 应被拒绝");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn update_stores_normalized_href() {
|
||
let mut conn = db();
|
||
let mut input = patch("知乎");
|
||
input.href = "www.zhihu.com".into();
|
||
let saved = update(&mut conn, "movie-1", input, 0).expect("应能更新");
|
||
assert_eq!(saved.href, "https://www.zhihu.com");
|
||
}
|
||
|
||
#[test]
|
||
fn creates_entry_under_section_or_group() {
|
||
let mut conn = db();
|
||
let saved = create(
|
||
&mut conn,
|
||
NewEntry {
|
||
key: "new-site".into(),
|
||
node_key: "g1".into(),
|
||
item: patch("新站点"),
|
||
},
|
||
)
|
||
.expect("应能创建");
|
||
assert_eq!(saved.key, "new-site");
|
||
assert_eq!(saved.name, "新站点");
|
||
|
||
let node: String = conn
|
||
.query_row("SELECT node_key FROM entry WHERE key='new-site'", [], |row| {
|
||
row.get(0)
|
||
})
|
||
.expect("应能查");
|
||
assert_eq!(node, "g1");
|
||
|
||
// 任何类型的分区都允许条目直属分区
|
||
let direct = create(
|
||
&mut conn,
|
||
NewEntry {
|
||
key: "direct-site".into(),
|
||
node_key: "apps1".into(),
|
||
item: patch("直属条目"),
|
||
},
|
||
)
|
||
.expect("应能创建");
|
||
assert_eq!(direct.key, "direct-site");
|
||
}
|
||
|
||
#[test]
|
||
fn moves_entry_between_nodes() {
|
||
let mut conn = db();
|
||
let mut input = patch("搬家");
|
||
input.node_key = Some("g1".into());
|
||
update(&mut conn, "movie-1", input, 0).expect("应能移动");
|
||
|
||
let node: String = conn
|
||
.query_row("SELECT node_key FROM entry WHERE key='movie-1'", [], |row| {
|
||
row.get(0)
|
||
})
|
||
.expect("应能查");
|
||
assert_eq!(node, "g1");
|
||
}
|
||
|
||
#[test]
|
||
fn deletes_entry() {
|
||
let conn = db();
|
||
remove(&conn, "movie-1").expect("应能删除");
|
||
assert!(remove(&conn, "movie-1").is_err(), "重复删除应失败");
|
||
}
|
||
}
|