4863c8c066
- shared-contracts 新增导入 manifest、逐条与结果 DTO(camelCase,拒绝未知字段) - module-assets 新增 prepare_template_import:新 ID 默认上架,已存在条目就地更新并保留上架状态与未知扩展字段;同一 ID 同版本字节不同时按 CLI 同一句文案拒绝,字节一致时按内容复用既有对象 - platform-oss 为 application/zip 放宽单对象上限到 64 MiB(图片与元数据仍 5 MiB),测试替身同步该口径 - api-server 新增 POST /admin/api/agc-templates/import:multipart(manifest + zip_N/cover_N),逐项校验归档安全性、entry 存在性与封面(按字节嗅探格式,仅 PNG/JPEG/WebP)后才取发布锁 - 锁内流程:CAS 校验 revision → 内容寻址写入 zip/封面/元数据并回读 → 一次提交清单 → 释放锁;断连由独立任务持有,不确定结果保留锁并返回 503 - 路由挂 require_admin_auth 与 200 MiB body 上限,页签权限矩阵与路由契约测试同步 - 新增用例:清单与文件字段校验、归档越界与符号链接拒绝、内容寻址键与封面嗅探、zip 内容类型存储上限 - 新增「后台模板上传」里程碑与实施计划文档 - 验证:cargo test(module-assets 11 项、platform-oss 12 项、api-server 定向 4 项)、cargo fmt --check、check:doc-index、check:encoding、git diff --check
1153 lines
42 KiB
Rust
1153 lines
42 KiB
Rust
//! 模板库的纯领域投影与编辑规则;OSS 读写、图片解码和发布锁由适配器承担。
|
||
|
||
use std::{collections::BTreeSet, error::Error, fmt};
|
||
|
||
use serde_json::{Map, Value, json};
|
||
|
||
const GROUPS: [(&str, bool); 2] = [("templates", true), ("inactiveTemplates", false)];
|
||
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub enum TemplateDomainError {
|
||
InvalidIndex,
|
||
InvalidEdit(String),
|
||
NotFound,
|
||
InvalidMetadata,
|
||
}
|
||
|
||
impl fmt::Display for TemplateDomainError {
|
||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
match self {
|
||
Self::InvalidIndex => formatter.write_str("模板库清单格式无效"),
|
||
Self::InvalidEdit(message) => formatter.write_str(message),
|
||
Self::NotFound => formatter.write_str("模板不存在"),
|
||
Self::InvalidMetadata => formatter.write_str("模板元数据无效或与清单不一致"),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Error for TemplateDomainError {}
|
||
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub struct ManagedTemplate {
|
||
pub id: String,
|
||
pub title: String,
|
||
pub summary: String,
|
||
pub runtime: String,
|
||
pub engine: String,
|
||
pub engine_version: String,
|
||
pub template_version: String,
|
||
pub zip_key: String,
|
||
pub zip_sha256: String,
|
||
pub cover_key: String,
|
||
pub cover_sha256: String,
|
||
pub metadata_key: String,
|
||
pub tags: Vec<String>,
|
||
pub enabled: bool,
|
||
pub zip_size_bytes: u64,
|
||
pub cover_width: u32,
|
||
pub cover_height: u32,
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub struct TemplateEdit {
|
||
pub title: String,
|
||
pub summary: String,
|
||
pub tags: Vec<String>,
|
||
pub enabled: bool,
|
||
}
|
||
|
||
/// 已由图片适配器验证原始格式的封面引用。
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub struct TemplateCover {
|
||
pub key: String,
|
||
pub sha256: String,
|
||
pub width: u32,
|
||
pub height: u32,
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub struct PreparedTemplateEdit {
|
||
pub index: Value,
|
||
pub metadata: Value,
|
||
}
|
||
|
||
/// 单批导入的模板数量上限:更大的批量请走 CLI(`--only` 定向发布)。
|
||
pub const MAX_TEMPLATE_IMPORT_BATCH: usize = 20;
|
||
/// 后台上传允许的运行时(与 CLI 的 `RUNTIMES` 保持一致)。
|
||
pub const TEMPLATE_IMPORT_RUNTIMES: [&str; 4] = ["html", "unity", "godot", "cocos"];
|
||
|
||
/// 后台导入的单条模板:内容对象键与字节摘要由存储适配器按上传字节算出。
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub struct TemplateImport {
|
||
pub id: String,
|
||
pub title: String,
|
||
pub summary: String,
|
||
pub tags: Vec<String>,
|
||
pub runtime: String,
|
||
pub engine: String,
|
||
pub engine_version: String,
|
||
pub template_version: String,
|
||
pub entry: String,
|
||
pub zip_key: String,
|
||
pub zip_sha256: String,
|
||
pub zip_size_bytes: u64,
|
||
pub cover_key: String,
|
||
pub cover_sha256: String,
|
||
pub cover_width: u32,
|
||
pub cover_height: u32,
|
||
}
|
||
|
||
/// 一批导入的合并结果:待提交清单、每个模板的元数据字节,以及按内容复用旧对象的 ID。
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub struct PreparedTemplateImport {
|
||
pub index: Value,
|
||
pub metadata: Vec<(String, Value)>,
|
||
pub reused: Vec<String>,
|
||
}
|
||
|
||
fn string_field<'a>(entry: &'a Value, field: &str) -> Result<&'a str, TemplateDomainError> {
|
||
entry
|
||
.get(field)
|
||
.and_then(Value::as_str)
|
||
.ok_or(TemplateDomainError::InvalidIndex)
|
||
}
|
||
|
||
fn optional_string(entry: &Value, field: &str) -> Result<String, TemplateDomainError> {
|
||
match entry.get(field) {
|
||
None => Ok(String::new()),
|
||
Some(value) => value
|
||
.as_str()
|
||
.map(str::to_owned)
|
||
.ok_or(TemplateDomainError::InvalidIndex),
|
||
}
|
||
}
|
||
|
||
fn optional_dimension(entry: &Value, field: &str) -> Result<u32, TemplateDomainError> {
|
||
match entry.get(field) {
|
||
None => Ok(0),
|
||
Some(value) => value
|
||
.as_u64()
|
||
.and_then(|value| u32::try_from(value).ok())
|
||
.ok_or(TemplateDomainError::InvalidIndex),
|
||
}
|
||
}
|
||
|
||
fn valid_identifier(value: &str, maximum: usize) -> bool {
|
||
!value.is_empty()
|
||
&& value.len() <= maximum
|
||
&& value.as_bytes()[0].is_ascii_alphanumeric()
|
||
&& value.bytes().all(|byte| {
|
||
byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-')
|
||
})
|
||
&& !value.contains("..")
|
||
}
|
||
|
||
fn valid_hash(value: &str) -> bool {
|
||
value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||
}
|
||
|
||
fn valid_object_key(key: &str) -> bool {
|
||
key.starts_with("templates/")
|
||
&& !key.contains("..")
|
||
&& !key.chars().any(|value| {
|
||
value.is_control() || value.is_whitespace() || matches!(value, '\\' | '?' | '#' | '%')
|
||
})
|
||
&& key.split('/').all(|part| !part.is_empty() && part != ".")
|
||
}
|
||
|
||
fn parse_entry(entry: &Value, enabled: bool) -> Result<ManagedTemplate, TemplateDomainError> {
|
||
let id = string_field(entry, "id")?;
|
||
let title = string_field(entry, "title")?;
|
||
let template_version = string_field(entry, "templateVersion")?;
|
||
let runtime = string_field(entry, "runtime")?;
|
||
let zip_key = string_field(entry, "zipKey")?;
|
||
let zip_sha256 = string_field(entry, "zipSha256")?;
|
||
let cover_key = string_field(entry, "coverKey")?;
|
||
let cover_sha256 = optional_string(entry, "coverSha256")?;
|
||
let metadata_key = optional_string(entry, "metadataKey")?;
|
||
let zip_size_bytes = entry
|
||
.get("zipSizeBytes")
|
||
.and_then(Value::as_u64)
|
||
.ok_or(TemplateDomainError::InvalidIndex)?;
|
||
let tags = match entry.get("tags") {
|
||
None => Vec::new(),
|
||
Some(value) => value
|
||
.as_array()
|
||
.ok_or(TemplateDomainError::InvalidIndex)?
|
||
.iter()
|
||
.map(|tag| {
|
||
tag.as_str()
|
||
.map(str::to_owned)
|
||
.ok_or(TemplateDomainError::InvalidIndex)
|
||
})
|
||
.collect::<Result<Vec<_>, _>>()?,
|
||
};
|
||
if !valid_identifier(id, 64)
|
||
|| !valid_identifier(template_version, 32)
|
||
|| title.trim().is_empty()
|
||
|| title.chars().count() > 120
|
||
|| !matches!(runtime, "html" | "unity" | "godot" | "cocos")
|
||
|| !valid_object_key(zip_key)
|
||
|| !valid_object_key(cover_key)
|
||
|| (!metadata_key.is_empty() && !valid_object_key(&metadata_key))
|
||
|| !valid_hash(zip_sha256)
|
||
|| (!cover_sha256.is_empty() && !valid_hash(&cover_sha256))
|
||
|| zip_size_bytes == 0
|
||
|| zip_size_bytes > 512 * 1024 * 1024
|
||
|| tags.len() > 32
|
||
|| tags.iter().any(|tag| tag.trim().is_empty())
|
||
{
|
||
return Err(TemplateDomainError::InvalidIndex);
|
||
}
|
||
Ok(ManagedTemplate {
|
||
id: id.to_owned(),
|
||
title: title.to_owned(),
|
||
template_version: template_version.to_owned(),
|
||
runtime: runtime.to_owned(),
|
||
zip_key: zip_key.to_owned(),
|
||
zip_sha256: zip_sha256.to_owned(),
|
||
cover_key: cover_key.to_owned(),
|
||
cover_sha256,
|
||
metadata_key,
|
||
tags,
|
||
enabled,
|
||
zip_size_bytes,
|
||
summary: optional_string(entry, "summary")?,
|
||
engine: optional_string(entry, "engine")?,
|
||
engine_version: optional_string(entry, "engineVersion")?,
|
||
cover_width: optional_dimension(entry, "coverWidth")?,
|
||
cover_height: optional_dimension(entry, "coverHeight")?,
|
||
})
|
||
}
|
||
|
||
/// 两组共享同一 ID 命名空间;下架条目仍保留在公开清单中。
|
||
pub fn list_templates(index: &Value) -> Result<Vec<ManagedTemplate>, TemplateDomainError> {
|
||
if index.get("schemaVersion").and_then(Value::as_str) != Some("agc-template-library.v1")
|
||
|| index.get("library").and_then(Value::as_str) != Some("agc-game-templates")
|
||
|| index.get("libraryVersion").and_then(Value::as_u64) != Some(1)
|
||
{
|
||
return Err(TemplateDomainError::InvalidIndex);
|
||
}
|
||
let mut ids = BTreeSet::new();
|
||
let mut templates = Vec::new();
|
||
for (group, enabled) in GROUPS {
|
||
let entries = match index.get(group) {
|
||
None if !enabled => continue,
|
||
Some(value) => value.as_array().ok_or(TemplateDomainError::InvalidIndex)?,
|
||
None => return Err(TemplateDomainError::InvalidIndex),
|
||
};
|
||
for entry in entries {
|
||
let template = parse_entry(entry, enabled)?;
|
||
if !ids.insert(template.id.clone()) {
|
||
return Err(TemplateDomainError::InvalidIndex);
|
||
}
|
||
templates.push(template);
|
||
}
|
||
}
|
||
templates.sort_by(|left, right| left.id.cmp(&right.id));
|
||
Ok(templates)
|
||
}
|
||
|
||
fn normalize_edit(edit: TemplateEdit) -> Result<TemplateEdit, TemplateDomainError> {
|
||
let title = edit.title.trim().to_owned();
|
||
let summary = edit.summary.trim().to_owned();
|
||
if title.is_empty() || title.chars().count() > 80 {
|
||
return Err(TemplateDomainError::InvalidEdit(
|
||
"模板名称须为 1–80 个字符".to_owned(),
|
||
));
|
||
}
|
||
if summary.chars().count() > 1000 {
|
||
return Err(TemplateDomainError::InvalidEdit(
|
||
"模板简介不能超过 1000 个字符".to_owned(),
|
||
));
|
||
}
|
||
let mut seen = BTreeSet::new();
|
||
let mut tags = Vec::new();
|
||
for tag in edit.tags {
|
||
let tag = tag.trim().to_owned();
|
||
if tag.is_empty() || tag.chars().count() > 32 {
|
||
return Err(TemplateDomainError::InvalidEdit(
|
||
"每个标签须为 1–32 个字符".to_owned(),
|
||
));
|
||
}
|
||
if seen.insert(tag.clone()) {
|
||
tags.push(tag);
|
||
}
|
||
}
|
||
if tags.len() > 16 {
|
||
return Err(TemplateDomainError::InvalidEdit(
|
||
"模板标签不能超过 16 个".to_owned(),
|
||
));
|
||
}
|
||
Ok(TemplateEdit {
|
||
title,
|
||
summary,
|
||
tags,
|
||
enabled: edit.enabled,
|
||
})
|
||
}
|
||
|
||
fn validate_metadata(
|
||
metadata: &Value,
|
||
template: &ManagedTemplate,
|
||
) -> Result<(), TemplateDomainError> {
|
||
if metadata.get("schemaVersion").and_then(Value::as_str) != Some("agc-template.v1")
|
||
|| metadata.get("id").and_then(Value::as_str) != Some(template.id.as_str())
|
||
|| metadata.get("templateVersion").and_then(Value::as_str)
|
||
!= Some(template.template_version.as_str())
|
||
|| metadata.pointer("/zip/key").and_then(Value::as_str) != Some(template.zip_key.as_str())
|
||
|| metadata.pointer("/zip/sizeBytes").and_then(Value::as_u64)
|
||
!= Some(template.zip_size_bytes)
|
||
|| !metadata
|
||
.pointer("/zip/sha256")
|
||
.and_then(Value::as_str)
|
||
.is_some_and(|hash| hash.eq_ignore_ascii_case(&template.zip_sha256))
|
||
{
|
||
return Err(TemplateDomainError::InvalidMetadata);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn set_display_fields(object: &mut Map<String, Value>, edit: &TemplateEdit, updated_at: &str) {
|
||
object.insert("title".to_owned(), json!(edit.title));
|
||
object.insert("summary".to_owned(), json!(edit.summary));
|
||
object.insert("tags".to_owned(), json!(edit.tags));
|
||
object.insert("updatedAt".to_owned(), json!(updated_at));
|
||
}
|
||
|
||
/// 只产生待提交快照,保留未编辑条目、ZIP 合同与未知扩展字段。
|
||
pub fn prepare_template_edit(
|
||
mut index: Value,
|
||
mut metadata: Value,
|
||
id: &str,
|
||
edit: TemplateEdit,
|
||
cover: Option<TemplateCover>,
|
||
updated_at: &str,
|
||
) -> Result<PreparedTemplateEdit, TemplateDomainError> {
|
||
let template = list_templates(&index)?
|
||
.into_iter()
|
||
.find(|entry| entry.id == id)
|
||
.ok_or(TemplateDomainError::NotFound)?;
|
||
let edit = normalize_edit(edit)?;
|
||
validate_metadata(&metadata, &template)?;
|
||
if updated_at.trim().is_empty() {
|
||
return Err(TemplateDomainError::InvalidEdit(
|
||
"模板更新时间无效".to_owned(),
|
||
));
|
||
}
|
||
if let Some(cover) = &cover {
|
||
if !valid_object_key(&cover.key)
|
||
|| !valid_hash(&cover.sha256)
|
||
|| cover.width == 0
|
||
|| cover.height == 0
|
||
|| cover.width > 4096
|
||
|| cover.height > 4096
|
||
|| u64::from(cover.width) * u64::from(cover.height) > 16_000_000
|
||
{
|
||
return Err(TemplateDomainError::InvalidEdit(
|
||
"模板封面引用或尺寸无效".to_owned(),
|
||
));
|
||
}
|
||
}
|
||
let source = if template.enabled {
|
||
"templates"
|
||
} else {
|
||
"inactiveTemplates"
|
||
};
|
||
let position = index[source]
|
||
.as_array()
|
||
.ok_or(TemplateDomainError::InvalidIndex)?
|
||
.iter()
|
||
.position(|entry| entry.get("id").and_then(Value::as_str) == Some(id))
|
||
.ok_or(TemplateDomainError::NotFound)?;
|
||
let mut entry = index[source][position].clone();
|
||
let entry_object = entry
|
||
.as_object_mut()
|
||
.ok_or(TemplateDomainError::InvalidIndex)?;
|
||
let metadata_object = metadata
|
||
.as_object_mut()
|
||
.ok_or(TemplateDomainError::InvalidMetadata)?;
|
||
set_display_fields(entry_object, &edit, updated_at);
|
||
set_display_fields(metadata_object, &edit, updated_at);
|
||
if let Some(cover) = cover {
|
||
entry_object.insert("coverKey".to_owned(), json!(cover.key));
|
||
entry_object.insert("coverSha256".to_owned(), json!(cover.sha256));
|
||
entry_object.insert("coverWidth".to_owned(), json!(cover.width));
|
||
entry_object.insert("coverHeight".to_owned(), json!(cover.height));
|
||
let metadata_cover = metadata_object
|
||
.entry("cover")
|
||
.or_insert_with(|| json!({}))
|
||
.as_object_mut()
|
||
.ok_or(TemplateDomainError::InvalidMetadata)?;
|
||
metadata_cover.insert("key".to_owned(), json!(cover.key));
|
||
metadata_cover.insert("sha256".to_owned(), json!(cover.sha256));
|
||
metadata_cover.insert("width".to_owned(), json!(cover.width));
|
||
metadata_cover.insert("height".to_owned(), json!(cover.height));
|
||
}
|
||
if template.enabled == edit.enabled {
|
||
index[source][position] = entry;
|
||
} else {
|
||
index[source]
|
||
.as_array_mut()
|
||
.ok_or(TemplateDomainError::InvalidIndex)?
|
||
.remove(position);
|
||
let destination = if edit.enabled {
|
||
"templates"
|
||
} else {
|
||
"inactiveTemplates"
|
||
};
|
||
index
|
||
.as_object_mut()
|
||
.ok_or(TemplateDomainError::InvalidIndex)?
|
||
.entry(destination)
|
||
.or_insert_with(|| json!([]))
|
||
.as_array_mut()
|
||
.ok_or(TemplateDomainError::InvalidIndex)?
|
||
.push(entry);
|
||
}
|
||
index["updatedAt"] = json!(updated_at);
|
||
Ok(PreparedTemplateEdit { index, metadata })
|
||
}
|
||
|
||
fn valid_import_entry_path(value: &str) -> bool {
|
||
let trimmed = value.trim();
|
||
!trimmed.is_empty()
|
||
&& trimmed.len() <= 256
|
||
&& !trimmed.starts_with(['/', '\\'])
|
||
&& !trimmed.contains('\\')
|
||
&& !trimmed.contains(':')
|
||
&& !trimmed.contains("..")
|
||
&& !trimmed
|
||
.chars()
|
||
.any(|value| value.is_control() || value.is_whitespace())
|
||
&& trimmed
|
||
.split('/')
|
||
.all(|part| !part.is_empty() && part != ".")
|
||
}
|
||
|
||
fn valid_import_object_key(key: &str, id: &str, sha256: &str, suffix: &str) -> bool {
|
||
valid_object_key(key)
|
||
&& valid_hash(sha256)
|
||
&& key.starts_with(&format!("templates/v1/{id}/sha256/"))
|
||
&& key.ends_with(&format!("/{suffix}"))
|
||
}
|
||
|
||
fn validate_import(import: &TemplateImport) -> Result<(), TemplateDomainError> {
|
||
if !valid_identifier(&import.id, 64) {
|
||
return Err(TemplateDomainError::InvalidEdit(format!(
|
||
"模板 ID 无效:{}",
|
||
import.id
|
||
)));
|
||
}
|
||
if !valid_identifier(&import.template_version, 32) {
|
||
return Err(TemplateDomainError::InvalidEdit(format!(
|
||
"{} 的 templateVersion 无效",
|
||
import.id
|
||
)));
|
||
}
|
||
if !TEMPLATE_IMPORT_RUNTIMES.contains(&import.runtime.as_str()) {
|
||
return Err(TemplateDomainError::InvalidEdit(format!(
|
||
"{} 的 runtime 不在允许列表内",
|
||
import.id
|
||
)));
|
||
}
|
||
if !valid_import_entry_path(&import.entry) {
|
||
return Err(TemplateDomainError::InvalidEdit(format!(
|
||
"{} 的 entry 不是库内相对路径",
|
||
import.id
|
||
)));
|
||
}
|
||
if import.zip_size_bytes == 0
|
||
|| !valid_import_object_key(
|
||
&import.zip_key,
|
||
&import.id,
|
||
&import.zip_sha256,
|
||
"template.zip",
|
||
)
|
||
{
|
||
return Err(TemplateDomainError::InvalidEdit(format!(
|
||
"{} 的 ZIP 对象键或摘要无效",
|
||
import.id
|
||
)));
|
||
}
|
||
let cover_extension = import
|
||
.cover_key
|
||
.rsplit('/')
|
||
.next()
|
||
.and_then(|name| name.rsplit_once('.'))
|
||
.map(|(_, extension)| extension.to_ascii_lowercase())
|
||
.unwrap_or_default();
|
||
if !matches!(
|
||
cover_extension.as_str(),
|
||
"png" | "jpg" | "jpeg" | "webp" | "svg"
|
||
) || !valid_import_object_key(
|
||
&import.cover_key,
|
||
&import.id,
|
||
&import.cover_sha256,
|
||
&format!("cover.{cover_extension}"),
|
||
) || import.cover_width == 0
|
||
|| import.cover_height == 0
|
||
|| import.cover_width > 4096
|
||
|| import.cover_height > 4096
|
||
|| u64::from(import.cover_width) * u64::from(import.cover_height) > 16_000_000
|
||
{
|
||
return Err(TemplateDomainError::InvalidEdit(format!(
|
||
"{} 的封面引用或尺寸无效",
|
||
import.id
|
||
)));
|
||
}
|
||
// 展示字段沿用编辑路径的同一套上限与去重规则。
|
||
normalize_edit(TemplateEdit {
|
||
title: import.title.clone(),
|
||
summary: import.summary.clone(),
|
||
tags: import.tags.clone(),
|
||
enabled: true,
|
||
})?;
|
||
Ok(())
|
||
}
|
||
|
||
fn import_entry_json(import: &TemplateImport, updated_at: &str) -> Value {
|
||
json!({
|
||
"id": import.id,
|
||
"title": import.title.trim(),
|
||
"summary": import.summary.trim(),
|
||
"tags": import.tags,
|
||
"runtime": import.runtime,
|
||
"engine": import.engine.trim(),
|
||
"engineVersion": import.engine_version.trim(),
|
||
"templateVersion": import.template_version,
|
||
"updatedAt": updated_at,
|
||
"entry": import.entry.trim(),
|
||
"zipKey": import.zip_key,
|
||
"zipSizeBytes": import.zip_size_bytes,
|
||
"zipSha256": import.zip_sha256,
|
||
"coverKey": import.cover_key,
|
||
"coverWidth": import.cover_width,
|
||
"coverHeight": import.cover_height,
|
||
"coverSha256": import.cover_sha256,
|
||
})
|
||
}
|
||
|
||
fn import_metadata_json(import: &TemplateImport, updated_at: &str) -> Value {
|
||
json!({
|
||
"schemaVersion": "agc-template.v1",
|
||
"id": import.id,
|
||
"title": import.title.trim(),
|
||
"summary": import.summary.trim(),
|
||
"tags": import.tags,
|
||
"runtime": import.runtime,
|
||
"engine": import.engine.trim(),
|
||
"engineVersion": import.engine_version.trim(),
|
||
"templateVersion": import.template_version,
|
||
"updatedAt": updated_at,
|
||
"entry": import.entry.trim(),
|
||
"zip": {
|
||
"key": import.zip_key,
|
||
"sizeBytes": import.zip_size_bytes,
|
||
"sha256": import.zip_sha256,
|
||
},
|
||
"cover": {
|
||
"key": import.cover_key,
|
||
"width": import.cover_width,
|
||
"height": import.cover_height,
|
||
"sha256": import.cover_sha256,
|
||
},
|
||
})
|
||
}
|
||
|
||
/// 合并一批后台导入:新增 ID 默认上架,已存在条目就地更新并保留上架状态与未知扩展字段。
|
||
///
|
||
/// 同一 ID、同一 `templateVersion` 的 ZIP 字节不同时拒绝(与 CLI 发布门禁同一句文案);
|
||
/// 字节完全一致时按内容复用既有对象,只更新展示字段与元数据。整批在同一把发布锁内提交,
|
||
/// 因此本函数只产出待提交快照,不做任何写入。
|
||
pub fn prepare_template_import(
|
||
mut index: Value,
|
||
imports: Vec<TemplateImport>,
|
||
updated_at: &str,
|
||
) -> Result<PreparedTemplateImport, TemplateDomainError> {
|
||
if imports.is_empty() {
|
||
return Err(TemplateDomainError::InvalidEdit(
|
||
"导入批次不能为空".to_owned(),
|
||
));
|
||
}
|
||
if imports.len() > MAX_TEMPLATE_IMPORT_BATCH {
|
||
return Err(TemplateDomainError::InvalidEdit(format!(
|
||
"单批最多导入 {MAX_TEMPLATE_IMPORT_BATCH} 个模板"
|
||
)));
|
||
}
|
||
if updated_at.trim().is_empty() {
|
||
return Err(TemplateDomainError::InvalidEdit(
|
||
"模板更新时间无效".to_owned(),
|
||
));
|
||
}
|
||
let mut batch_ids = BTreeSet::new();
|
||
for import in &imports {
|
||
validate_import(import)?;
|
||
if !batch_ids.insert(import.id.clone()) {
|
||
return Err(TemplateDomainError::InvalidEdit(format!(
|
||
"同一批次出现重复模板 ID:{}",
|
||
import.id
|
||
)));
|
||
}
|
||
}
|
||
|
||
let current = list_templates(&index)?;
|
||
let mut reused = Vec::new();
|
||
let mut metadata = Vec::with_capacity(imports.len());
|
||
for mut import in imports {
|
||
// 展示字段统一走编辑路径的同一套归一化(trim + 标签去重),
|
||
// 清单与 template.json 必须写入归一化后的值,否则两端会不一致。
|
||
let normalized = normalize_edit(TemplateEdit {
|
||
title: import.title.clone(),
|
||
summary: import.summary.clone(),
|
||
tags: import.tags.clone(),
|
||
enabled: true,
|
||
})?;
|
||
import.title = normalized.title;
|
||
import.summary = normalized.summary;
|
||
import.tags = normalized.tags;
|
||
let existing = current.iter().find(|entry| entry.id == import.id);
|
||
let mut position = None;
|
||
for (group, _) in GROUPS {
|
||
let Some(entries) = index.get(group).and_then(Value::as_array) else {
|
||
continue;
|
||
};
|
||
if let Some(index_in_group) = entries.iter().position(|entry| {
|
||
entry.get("id").and_then(Value::as_str) == Some(import.id.as_str())
|
||
}) {
|
||
position = Some((group, index_in_group));
|
||
break;
|
||
}
|
||
}
|
||
|
||
match (existing, position) {
|
||
(Some(existing), Some((group, index_in_group))) => {
|
||
if existing.template_version == import.template_version {
|
||
let same_bytes = existing.zip_size_bytes == import.zip_size_bytes
|
||
&& existing.zip_sha256.eq_ignore_ascii_case(&import.zip_sha256);
|
||
if !same_bytes {
|
||
return Err(TemplateDomainError::InvalidEdit(format!(
|
||
"{}@{} 同版本 ZIP 内容或尺寸变化,请递增 templateVersion",
|
||
import.id, import.template_version
|
||
)));
|
||
}
|
||
reused.push(import.id.clone());
|
||
}
|
||
// 以既有条目为底:未知扩展字段与 enabled 分组原样保留。
|
||
let mut entry = index[group][index_in_group].clone();
|
||
let object = entry
|
||
.as_object_mut()
|
||
.ok_or(TemplateDomainError::InvalidIndex)?;
|
||
for (field, value) in import_entry_json(&import, updated_at)
|
||
.as_object()
|
||
.ok_or(TemplateDomainError::InvalidIndex)?
|
||
.iter()
|
||
{
|
||
object.insert(field.clone(), value.clone());
|
||
}
|
||
index[group][index_in_group] = entry;
|
||
}
|
||
(None, None) => {
|
||
index
|
||
.as_object_mut()
|
||
.ok_or(TemplateDomainError::InvalidIndex)?
|
||
.entry("templates")
|
||
.or_insert_with(|| json!([]))
|
||
.as_array_mut()
|
||
.ok_or(TemplateDomainError::InvalidIndex)?
|
||
.push(import_entry_json(&import, updated_at));
|
||
}
|
||
_ => return Err(TemplateDomainError::InvalidIndex),
|
||
}
|
||
metadata.push((import.id.clone(), import_metadata_json(&import, updated_at)));
|
||
}
|
||
index["updatedAt"] = json!(updated_at);
|
||
Ok(PreparedTemplateImport {
|
||
index,
|
||
metadata,
|
||
reused,
|
||
})
|
||
}
|
||
|
||
impl PreparedTemplateImport {
|
||
/// 元数据字节摘要由存储适配器计算后,回填每个导入条目的 `metadataKey`。
|
||
pub fn set_metadata_key(&mut self, id: &str, key: &str) -> Result<(), TemplateDomainError> {
|
||
if !valid_object_key(key) {
|
||
return Err(TemplateDomainError::InvalidMetadata);
|
||
}
|
||
let mut found = false;
|
||
for (group, _) in GROUPS {
|
||
let Some(entries) = self.index.get_mut(group).and_then(Value::as_array_mut) else {
|
||
continue;
|
||
};
|
||
for entry in entries.iter_mut() {
|
||
if entry.get("id").and_then(Value::as_str) != Some(id) {
|
||
continue;
|
||
}
|
||
entry
|
||
.as_object_mut()
|
||
.ok_or(TemplateDomainError::InvalidIndex)?
|
||
.insert("metadataKey".to_owned(), json!(key));
|
||
found = true;
|
||
}
|
||
}
|
||
if !found {
|
||
return Err(TemplateDomainError::NotFound);
|
||
}
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
impl PreparedTemplateEdit {
|
||
/// 元数据字节摘要由存储适配器计算后,回填唯一被编辑条目的对象键。
|
||
pub fn set_metadata_key(&mut self, id: &str, key: &str) -> Result<(), TemplateDomainError> {
|
||
let template = list_templates(&self.index)?
|
||
.into_iter()
|
||
.find(|entry| entry.id == id)
|
||
.ok_or(TemplateDomainError::NotFound)?;
|
||
validate_metadata(&self.metadata, &template)?;
|
||
if !valid_object_key(key) {
|
||
return Err(TemplateDomainError::InvalidMetadata);
|
||
}
|
||
for (group, _) in GROUPS {
|
||
let Some(entries) = self.index.get_mut(group).and_then(Value::as_array_mut) else {
|
||
continue;
|
||
};
|
||
if let Some(entry) = entries
|
||
.iter_mut()
|
||
.find(|entry| entry.get("id").and_then(Value::as_str) == Some(id))
|
||
{
|
||
entry["metadataKey"] = json!(key);
|
||
return Ok(());
|
||
}
|
||
}
|
||
Err(TemplateDomainError::NotFound)
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn entry(id: &str) -> Value {
|
||
json!({
|
||
"id": id, "title": "模板", "summary": "简介", "tags": ["cocos"],
|
||
"runtime": "cocos", "engine": "cocos-creator", "engineVersion": "3.8.8",
|
||
"templateVersion": "0.1.0", "entry": "package.json", "updatedAt": "old",
|
||
"zipKey": format!("templates/v1/{id}/template.zip"), "zipSizeBytes": 10,
|
||
"zipSha256": "a".repeat(64), "coverKey": format!("templates/v1/{id}/cover.svg"),
|
||
"coverSha256": "b".repeat(64), "coverWidth": 960, "coverHeight": 540,
|
||
"metadataKey": format!("templates/v1/{id}/template.json"), "extension": {"keep": true}
|
||
})
|
||
}
|
||
|
||
fn index() -> Value {
|
||
json!({"schemaVersion": "agc-template-library.v1", "library": "agc-game-templates",
|
||
"libraryVersion": 1, "updatedAt": "old", "extension": [1, 2],
|
||
"templates": [entry("b"), entry("a")], "inactiveTemplates": [entry("c")]})
|
||
}
|
||
|
||
fn metadata() -> Value {
|
||
json!({"schemaVersion": "agc-template.v1", "id": "a", "templateVersion": "0.1.0",
|
||
"title": "模板", "summary": "简介", "tags": ["cocos"], "runtime": "cocos",
|
||
"engine": "cocos-creator", "engineVersion": "3.8.8", "entry": "package.json",
|
||
"zip": {"key": "templates/v1/a/template.zip", "sizeBytes": 10, "sha256": "a".repeat(64), "extra": 1},
|
||
"cover": {"key": "templates/v1/a/cover.svg", "sha256": "b".repeat(64), "width": 960, "height": 540, "extra": 2},
|
||
"files": [{"path": "package.json", "sizeBytes": 10, "sha256": "c".repeat(64)}], "unknown": {"keep": true}})
|
||
}
|
||
|
||
fn import(id: &str, version: &str, zip_sha: &str, size: u64) -> TemplateImport {
|
||
TemplateImport {
|
||
id: id.to_owned(),
|
||
title: " 新模板 ".to_owned(),
|
||
summary: " 新简介 ".to_owned(),
|
||
tags: vec![" cocos ".to_owned(), "cocos".to_owned(), "二维".to_owned()],
|
||
runtime: "cocos".to_owned(),
|
||
engine: "cocos-creator".to_owned(),
|
||
engine_version: "3.8.8".to_owned(),
|
||
template_version: version.to_owned(),
|
||
entry: "package.json".to_owned(),
|
||
zip_key: format!("templates/v1/{id}/sha256/{zip_sha}/template.zip"),
|
||
zip_sha256: zip_sha.to_owned(),
|
||
zip_size_bytes: size,
|
||
cover_key: format!("templates/v1/{id}/sha256/{}/cover.svg", "b".repeat(64)),
|
||
cover_sha256: "b".repeat(64),
|
||
cover_width: 960,
|
||
cover_height: 540,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn template_import_appends_new_entries_with_metadata() {
|
||
let prepared = prepare_template_import(
|
||
index(),
|
||
vec![import("d", "0.1.0", &"d".repeat(64), 42)],
|
||
"2026-09-21T00:00:00Z",
|
||
)
|
||
.expect("import new template");
|
||
|
||
let ids = prepared.index["templates"]
|
||
.as_array()
|
||
.expect("templates")
|
||
.iter()
|
||
.map(|entry| entry["id"].as_str().expect("id").to_owned())
|
||
.collect::<Vec<_>>();
|
||
assert_eq!(ids, vec!["b", "a", "d"]);
|
||
assert!(prepared.reused.is_empty());
|
||
assert_eq!(
|
||
prepared.index["inactiveTemplates"].as_array().map(Vec::len),
|
||
Some(1)
|
||
);
|
||
assert_eq!(prepared.index["updatedAt"], json!("2026-09-21T00:00:00Z"));
|
||
|
||
let imported = prepared.index["templates"]
|
||
.as_array()
|
||
.expect("templates")
|
||
.iter()
|
||
.find(|entry| entry["id"] == json!("d"))
|
||
.expect("imported entry");
|
||
assert_eq!(imported["title"], json!("新模板"));
|
||
assert_eq!(imported["tags"], json!(["cocos", "二维"]));
|
||
assert_eq!(imported["zipSizeBytes"], json!(42));
|
||
assert_eq!(
|
||
imported["zipKey"],
|
||
json!(format!(
|
||
"templates/v1/d/sha256/{}/template.zip",
|
||
"d".repeat(64)
|
||
))
|
||
);
|
||
|
||
let (id, metadata) = prepared.metadata.first().expect("metadata");
|
||
assert_eq!(id, "d");
|
||
assert_eq!(metadata["schemaVersion"], json!("agc-template.v1"));
|
||
assert_eq!(metadata["templateVersion"], json!("0.1.0"));
|
||
assert_eq!(metadata["zip"]["sizeBytes"], json!(42));
|
||
assert_eq!(metadata["zip"]["sha256"], json!("d".repeat(64)));
|
||
assert_eq!(metadata["cover"]["width"], json!(960));
|
||
}
|
||
|
||
#[test]
|
||
fn template_import_keeps_inactive_group_and_unknown_fields() {
|
||
let prepared = prepare_template_import(
|
||
index(),
|
||
vec![import("c", "0.2.0", &"e".repeat(64), 7)],
|
||
"2026-09-21T00:00:00Z",
|
||
)
|
||
.expect("import new version of an inactive template");
|
||
|
||
assert_eq!(
|
||
prepared.index["inactiveTemplates"].as_array().map(Vec::len),
|
||
Some(1)
|
||
);
|
||
let entry = &prepared.index["inactiveTemplates"][0];
|
||
assert_eq!(entry["id"], json!("c"));
|
||
assert_eq!(entry["templateVersion"], json!("0.2.0"));
|
||
assert_eq!(entry["extension"], json!({"keep": true}));
|
||
assert_eq!(entry["metadataKey"], json!("templates/v1/c/template.json"));
|
||
}
|
||
|
||
#[test]
|
||
fn template_import_rejects_same_version_with_different_bytes() {
|
||
let error = prepare_template_import(
|
||
index(),
|
||
vec![import("a", "0.1.0", &"f".repeat(64), 10)],
|
||
"2026-09-21T00:00:00Z",
|
||
)
|
||
.expect_err("same version must keep identical bytes");
|
||
assert!(
|
||
error
|
||
.to_string()
|
||
.contains("同版本 ZIP 内容或尺寸变化,请递增 templateVersion"),
|
||
"{error}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn template_import_reuses_identical_bytes_for_the_same_version() {
|
||
let prepared = prepare_template_import(
|
||
index(),
|
||
vec![import("a", "0.1.0", &"a".repeat(64), 10)],
|
||
"2026-09-21T00:00:00Z",
|
||
)
|
||
.expect("identical bytes are idempotent");
|
||
|
||
assert_eq!(prepared.reused, vec!["a".to_owned()]);
|
||
let entry = prepared.index["templates"]
|
||
.as_array()
|
||
.expect("templates")
|
||
.iter()
|
||
.find(|entry| entry["id"] == json!("a"))
|
||
.expect("entry");
|
||
assert_eq!(entry["title"], json!("新模板"));
|
||
assert_eq!(entry["extension"], json!({"keep": true}));
|
||
}
|
||
|
||
#[test]
|
||
fn template_import_rejects_invalid_batches_before_any_write() {
|
||
let cases: Vec<(Vec<TemplateImport>, &str)> = vec![
|
||
(vec![], "导入批次不能为空"),
|
||
(
|
||
(0..=MAX_TEMPLATE_IMPORT_BATCH)
|
||
.map(|index| {
|
||
import(&format!("t{index}"), "0.1.0", &format!("{:0>64}", index), 1)
|
||
})
|
||
.collect(),
|
||
"单批最多导入",
|
||
),
|
||
(
|
||
vec![
|
||
import("dup", "0.1.0", &"1".repeat(64), 1),
|
||
import("dup", "0.1.1", &"2".repeat(64), 1),
|
||
],
|
||
"重复模板 ID",
|
||
),
|
||
];
|
||
for (imports, expected) in cases {
|
||
let error = prepare_template_import(index(), imports, "2026-09-21T00:00:00Z")
|
||
.expect_err("invalid batch rejected");
|
||
assert!(error.to_string().contains(expected), "{error}");
|
||
}
|
||
|
||
let mut bad_runtime = import("x", "0.1.0", &"3".repeat(64), 1);
|
||
bad_runtime.runtime = "unity-native".to_owned();
|
||
assert!(
|
||
prepare_template_import(index(), vec![bad_runtime], "now")
|
||
.expect_err("runtime allowlist")
|
||
.to_string()
|
||
.contains("runtime 不在允许列表内")
|
||
);
|
||
|
||
let mut bad_entry = import("x", "0.1.0", &"3".repeat(64), 1);
|
||
bad_entry.entry = "../escape.json".to_owned();
|
||
assert!(
|
||
prepare_template_import(index(), vec![bad_entry], "now")
|
||
.expect_err("entry must stay inside the library")
|
||
.to_string()
|
||
.contains("entry 不是库内相对路径")
|
||
);
|
||
|
||
let mut bad_id = import("x", "0.1.0", &"3".repeat(64), 1);
|
||
bad_id.id = "Bad.Id".to_owned();
|
||
assert!(
|
||
prepare_template_import(index(), vec![bad_id], "now")
|
||
.expect_err("identifier whitelist")
|
||
.to_string()
|
||
.contains("模板 ID 无效")
|
||
);
|
||
|
||
let mut empty_zip = import("x", "0.1.0", &"3".repeat(64), 1);
|
||
empty_zip.zip_size_bytes = 0;
|
||
assert!(
|
||
prepare_template_import(index(), vec![empty_zip], "now")
|
||
.expect_err("empty zip rejected")
|
||
.to_string()
|
||
.contains("ZIP 对象键或摘要无效")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn template_import_backfills_metadata_key_after_hashing_metadata() {
|
||
let mut prepared = prepare_template_import(
|
||
index(),
|
||
vec![import("d", "0.1.0", &"d".repeat(64), 42)],
|
||
"2026-09-21T00:00:00Z",
|
||
)
|
||
.expect("import");
|
||
let key = format!("templates/v1/d/sha256/{}/template.json", "9".repeat(64));
|
||
prepared
|
||
.set_metadata_key("d", &key)
|
||
.expect("set metadata key");
|
||
|
||
let entry = prepared.index["templates"]
|
||
.as_array()
|
||
.expect("templates")
|
||
.iter()
|
||
.find(|entry| entry["id"] == json!("d"))
|
||
.expect("entry");
|
||
assert_eq!(entry["metadataKey"], json!(key));
|
||
assert!(
|
||
prepared
|
||
.set_metadata_key("missing", &key)
|
||
.is_err_and(|error| error == TemplateDomainError::NotFound)
|
||
);
|
||
}
|
||
|
||
fn edit(enabled: bool) -> TemplateEdit {
|
||
TemplateEdit {
|
||
title: " 新名称 ".to_owned(),
|
||
summary: " 新简介 ".to_owned(),
|
||
tags: vec![" cocos ".to_owned(), "cocos".to_owned(), "二维".to_owned()],
|
||
enabled,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn template_library_edits_round_trip_active_and_inactive_without_losing_fields() {
|
||
let original = index();
|
||
let old_metadata = metadata();
|
||
let mut off = prepare_template_edit(
|
||
original.clone(),
|
||
old_metadata.clone(),
|
||
"a",
|
||
edit(false),
|
||
None,
|
||
"new",
|
||
)
|
||
.unwrap();
|
||
assert_eq!(off.index["templates"], json!([entry("b")]));
|
||
let listed = list_templates(&off.index).unwrap();
|
||
assert_eq!(
|
||
listed
|
||
.iter()
|
||
.map(|item| item.id.as_str())
|
||
.collect::<Vec<_>>(),
|
||
["a", "b", "c"]
|
||
);
|
||
assert!(!listed[0].enabled);
|
||
assert_eq!(listed[0].tags, ["cocos", "二维"]);
|
||
assert_eq!(off.index["extension"], original["extension"]);
|
||
for field in [
|
||
"zip",
|
||
"files",
|
||
"runtime",
|
||
"engine",
|
||
"engineVersion",
|
||
"templateVersion",
|
||
"entry",
|
||
"unknown",
|
||
] {
|
||
assert_eq!(off.metadata[field], old_metadata[field], "{field}");
|
||
}
|
||
let key = format!("templates/v1/a/sha256/{}/template.json", "d".repeat(64));
|
||
assert_eq!(
|
||
off.set_metadata_key("b", &key).unwrap_err(),
|
||
TemplateDomainError::InvalidMetadata
|
||
);
|
||
off.set_metadata_key("a", &key).unwrap();
|
||
assert_eq!(list_templates(&off.index).unwrap()[0].metadata_key, key);
|
||
let on =
|
||
prepare_template_edit(off.index, off.metadata, "a", edit(true), None, "newer").unwrap();
|
||
assert_eq!(on.index["inactiveTemplates"], json!([entry("c")]));
|
||
let template = list_templates(&on.index).unwrap().remove(0);
|
||
assert!(template.enabled);
|
||
assert_eq!(template.title, "新名称");
|
||
assert_eq!(template.zip_key, "templates/v1/a/template.zip");
|
||
assert_eq!(template.zip_sha256, "a".repeat(64));
|
||
assert_eq!(template.template_version, "0.1.0");
|
||
}
|
||
|
||
#[test]
|
||
fn template_library_cover_edit_preserves_nested_metadata_extensions() {
|
||
let key = format!("templates/v1/a/sha256/{}/cover.png", "e".repeat(64));
|
||
let prepared = prepare_template_edit(
|
||
index(),
|
||
metadata(),
|
||
"a",
|
||
edit(true),
|
||
Some(TemplateCover {
|
||
key: key.clone(),
|
||
sha256: "e".repeat(64),
|
||
width: 1024,
|
||
height: 576,
|
||
}),
|
||
"new",
|
||
)
|
||
.unwrap();
|
||
let template = list_templates(&prepared.index).unwrap().remove(0);
|
||
assert_eq!(template.cover_key, key);
|
||
assert_eq!((template.cover_width, template.cover_height), (1024, 576));
|
||
assert_eq!(prepared.metadata["cover"]["extra"], 2);
|
||
assert_eq!(prepared.metadata["cover"]["key"], key);
|
||
assert_eq!(prepared.metadata["zip"], metadata()["zip"]);
|
||
}
|
||
|
||
#[test]
|
||
fn template_library_rejects_metadata_from_another_template_or_package() {
|
||
for (pointer, value) in [
|
||
("/id", json!("b")),
|
||
("/templateVersion", json!("0.2.0")),
|
||
("/zip/key", json!("templates/v1/b/template.zip")),
|
||
("/zip/sizeBytes", json!(11)),
|
||
("/zip/sha256", json!("f".repeat(64))),
|
||
("/schemaVersion", json!("unknown")),
|
||
] {
|
||
let mut wrong = metadata();
|
||
*wrong.pointer_mut(pointer).unwrap() = value;
|
||
assert_eq!(
|
||
prepare_template_edit(index(), wrong, "a", edit(true), None, "new").unwrap_err(),
|
||
TemplateDomainError::InvalidMetadata
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn template_library_rejects_duplicate_ids_and_unsafe_references() {
|
||
let mut duplicate = index();
|
||
duplicate["inactiveTemplates"] = json!([entry("a")]);
|
||
assert_eq!(
|
||
list_templates(&duplicate).unwrap_err(),
|
||
TemplateDomainError::InvalidIndex
|
||
);
|
||
for key in [
|
||
"other/v1/a/template.zip",
|
||
"templates/../private",
|
||
"templates/%2e%2e/private",
|
||
"templates/v1/a/file?token=secret",
|
||
] {
|
||
let mut invalid = index();
|
||
invalid["templates"][0]["zipKey"] = json!(key);
|
||
assert_eq!(
|
||
list_templates(&invalid).unwrap_err(),
|
||
TemplateDomainError::InvalidIndex
|
||
);
|
||
}
|
||
let mut legacy = index();
|
||
legacy.as_object_mut().unwrap().remove("inactiveTemplates");
|
||
assert_eq!(list_templates(&legacy).unwrap().len(), 2);
|
||
legacy["inactiveTemplates"] = Value::Null;
|
||
assert_eq!(
|
||
list_templates(&legacy).unwrap_err(),
|
||
TemplateDomainError::InvalidIndex
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn template_library_edit_limits_count_unicode_characters_and_unique_tags() {
|
||
let mut maximum = edit(true);
|
||
maximum.title = "名".repeat(80);
|
||
maximum.summary = "介".repeat(1000);
|
||
maximum.tags = (0..16).map(|number| format!("标签{number}")).collect();
|
||
prepare_template_edit(index(), metadata(), "a", maximum.clone(), None, "new").unwrap();
|
||
let mut invalid_edits = Vec::new();
|
||
let mut invalid = maximum.clone();
|
||
invalid.title.push('名');
|
||
invalid_edits.push(invalid);
|
||
let mut invalid = maximum.clone();
|
||
invalid.title = " ".to_owned();
|
||
invalid_edits.push(invalid);
|
||
let mut invalid = maximum.clone();
|
||
invalid.summary.push('介');
|
||
invalid_edits.push(invalid);
|
||
let mut invalid = maximum.clone();
|
||
invalid.tags.push("第十七个".to_owned());
|
||
invalid_edits.push(invalid);
|
||
let mut invalid = maximum.clone();
|
||
invalid.tags = vec!["标".repeat(33)];
|
||
invalid_edits.push(invalid);
|
||
let mut invalid = maximum;
|
||
invalid.tags = vec![" ".to_owned()];
|
||
invalid_edits.push(invalid);
|
||
for invalid in invalid_edits {
|
||
assert!(matches!(
|
||
prepare_template_edit(index(), metadata(), "a", invalid, None, "new"),
|
||
Err(TemplateDomainError::InvalidEdit(_))
|
||
));
|
||
}
|
||
assert_eq!(
|
||
prepare_template_edit(index(), metadata(), "missing", edit(true), None, "new")
|
||
.unwrap_err(),
|
||
TemplateDomainError::NotFound
|
||
);
|
||
}
|
||
}
|