fix(游戏共创): 非法游标回归稳定码 THEME_INVALID_CURSOR(契约码此前在 HTTP 面不可达)
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
端到端实测(54 项里唯一红的那条):`GET /api/game-distribution/themes?cursor=abc` 回 400,但
`error.code = "BAD_REQUEST"`——技术方案 §3.4 与主题 e2e 脚本承诺的稳定码 `THEME_INVALID_CURSOR`
在 HTTP 面上拿不到,客户端只能去匹配中文文案。
根因:`map_spacetime_error` 的 `THEME_` 分支前置条件是「消息含 `THEME_`」,而模块侧游标解析只回
中文串「主题列表游标格式无效」⇒ 落兜底分支成 400 + 通用 `BAD_REQUEST`;契约常量与那条映射分支
因此是死代码。且 api-server 原有单测**明确要求**文案不含 `THEME_`(口径留了两套)。
口径二选一,选**让码可达**(模块产出前缀),删掉「要求不含 `THEME_`」的断言:
- `module-game-distribution/src/errors.rs`:新增 6 个主题码常量
`GAME_DISTRIBUTION_THEME_{NOT_FOUND,BAD_REQUEST,INVALID_CURSOR,IDEMPOTENCY_CONFLICT,MEMBER_NOT_ROOT,MEMBER_GAME_NOT_FOUND}_CODE`,
并把 `Display` 从字面量改为**复用同一批常量**(消灭「常量改了、`Display` 没改」这类只有真栈才发现的漂移)。
- `theme.rs`:`parse_game_distribution_theme_cursor` 产出 `THEME_INVALID_CURSOR: 主题列表游标格式无效`;
文档注释更新为「只保留 `FORK_` 这个排在 `THEME_` 分支之前的子串禁忌」(其余子串已抢不到)。
- `lib.rs`:导出新增的 6 个码常量。
- 测试:
· `domain.rs::theme_errors_are_prefixed_with_their_machine_code` 改为**枚举全部 6 个主题码**(含唯一
不走领域变体的游标路径——正是当初漏掉它的原因),并加「任何新增码都必须进这张表」的覆盖完整性断言;
· `theme.rs::invalid_cursor_message_stays_in_the_bad_request_bucket` 增加「必须以稳定码开头」;
· api-server 新增 `theme_error_codes_are_reachable_from_module_messages`:用**模块真实产出的消息**
逐码断言 `(状态码, 稳定码)` 可达,并比对模块常量与 `shared-contracts` 常量是同一组字符串;
· api-server `theme_list_invalid_cursor_maps_to_bad_request` 改为断言映射出 `THEME_INVALID_CURSOR`,
只把 `FORK_` 留在禁忌列表里(并写明理由)。
门禁:`cargo test -p module-game-distribution` **100 passed**;`cargo test -p api-server game_distribution`
**86 passed**;`cargo check --all-targets` 0;DTO parity 58 组 / 10 构建器;`check:encoding` 0;
`cargo fmt --all -- --check` 0;`git diff --check` 0。
This commit is contained in:
@@ -8678,33 +8678,109 @@ mod tests {
|
||||
assert_eq!(GAME_DISTRIBUTION_THEME_PAGE_LIMIT_DEFAULT, 20);
|
||||
}
|
||||
|
||||
/// 主题列表非法游标必须落 400:模块侧文案经 `map_spacetime_error` 的兜底分支,
|
||||
/// 不得被 404 / 409 的子串规则抢先命中(与 `/my-collections` 同一条纪律)。
|
||||
/// 主题列表非法游标必须落 400 **且带稳定码 `THEME_INVALID_CURSOR`**。
|
||||
///
|
||||
/// 这条测试是那次真缺陷的回归网:模块侧曾经只回中文串「主题列表游标格式无效」,不含 `THEME_`,
|
||||
/// 于是 `THEME_` 映射分支不命中,落兜底 400 + 通用 `BAD_REQUEST`——契约里的稳定码在 HTTP 面上
|
||||
/// **不可达**,客户端只能匹配中文文案。现在两头都钉住:模块产出前缀(模块侧枚举测试),
|
||||
/// 这里钉「HTTP 面真的映射出那个码」。
|
||||
///
|
||||
/// 文案不得含 `FORK_`:`FORK_` 分支排在 `THEME_` **之前**,只要文案里出现 `FORK_`,这条错误
|
||||
/// 就会被 FORK 分支抢走(其余「不存在」「状态」等子串都在 `THEME_` 之后,抢不到了)。
|
||||
#[test]
|
||||
fn theme_list_invalid_cursor_maps_to_bad_request() {
|
||||
let message = module_game_distribution::parse_game_distribution_theme_cursor("不是游标")
|
||||
.expect_err("非法游标必须报错");
|
||||
assert!(message.contains("格式无效"), "{message}");
|
||||
for forbidden in [
|
||||
"不存在",
|
||||
"已被删除",
|
||||
"状态",
|
||||
"不匹配",
|
||||
"幂等",
|
||||
"已存在",
|
||||
"FORK_",
|
||||
"THEME_",
|
||||
] {
|
||||
assert!(
|
||||
!message.contains("FORK_"),
|
||||
"游标错误文案不得含 `FORK_`(FORK 分支排在 THEME 之前,会被它抢走):{message}"
|
||||
);
|
||||
let error = map_spacetime_error(SpacetimeClientError::Procedure(message.clone()));
|
||||
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST, "{message}");
|
||||
assert_eq!(
|
||||
error.code(),
|
||||
GAME_DISTRIBUTION_THEME_INVALID_CURSOR,
|
||||
"非法游标必须映射出契约里的稳定码,否则客户端只能匹配中文文案:{message}"
|
||||
);
|
||||
}
|
||||
|
||||
/// **可达性**:6 个主题码里每一个都必须能从**模块真实产出的文案**映射出 (状态码, 稳定码)。
|
||||
///
|
||||
/// 这是那次真缺陷的根因所在的另一面:`theme_errors_map_to_documented_status_codes` 用的是
|
||||
/// `format!("{code}: 具体原因")` 这类**合成**文案,只能证明「映射表里有这条」,证明不了「模块
|
||||
/// 真的会产出这个前缀」——`THEME_INVALID_CURSOR` 就此成了死代码。这条测试改用模块真实产出的
|
||||
/// 消息(5 个领域变体的 `Display` + 游标解析的裸字符串),顺带比对模块常量与 `shared-contracts`
|
||||
/// 常量是同一组字符串(两侧改字都会红)。
|
||||
#[test]
|
||||
fn theme_error_codes_are_reachable_from_module_messages() {
|
||||
use module_game_distribution::GameDistributionError as DomainError;
|
||||
|
||||
let cases = [
|
||||
(
|
||||
DomainError::ThemeNotFound.to_string(),
|
||||
module_game_distribution::GAME_DISTRIBUTION_THEME_NOT_FOUND_CODE,
|
||||
GAME_DISTRIBUTION_THEME_NOT_FOUND,
|
||||
StatusCode::NOT_FOUND,
|
||||
),
|
||||
(
|
||||
DomainError::ThemeBadRequest {
|
||||
reason: "主题名不能为空".to_string(),
|
||||
}
|
||||
.to_string(),
|
||||
module_game_distribution::GAME_DISTRIBUTION_THEME_BAD_REQUEST_CODE,
|
||||
GAME_DISTRIBUTION_THEME_BAD_REQUEST,
|
||||
StatusCode::BAD_REQUEST,
|
||||
),
|
||||
(
|
||||
module_game_distribution::parse_game_distribution_theme_cursor("不是游标")
|
||||
.expect_err("非法游标必须报错"),
|
||||
module_game_distribution::GAME_DISTRIBUTION_THEME_INVALID_CURSOR_CODE,
|
||||
GAME_DISTRIBUTION_THEME_INVALID_CURSOR,
|
||||
StatusCode::BAD_REQUEST,
|
||||
),
|
||||
(
|
||||
DomainError::ThemeIdempotencyConflict.to_string(),
|
||||
module_game_distribution::GAME_DISTRIBUTION_THEME_IDEMPOTENCY_CONFLICT_CODE,
|
||||
GAME_DISTRIBUTION_THEME_IDEMPOTENCY_CONFLICT,
|
||||
StatusCode::CONFLICT,
|
||||
),
|
||||
(
|
||||
DomainError::ThemeMemberNotRoot {
|
||||
game_id: "game_child".to_string(),
|
||||
}
|
||||
.to_string(),
|
||||
module_game_distribution::GAME_DISTRIBUTION_THEME_MEMBER_NOT_ROOT_CODE,
|
||||
GAME_DISTRIBUTION_THEME_MEMBER_NOT_ROOT,
|
||||
StatusCode::CONFLICT,
|
||||
),
|
||||
(
|
||||
DomainError::ThemeMemberGameNotFound {
|
||||
game_id: "game_missing".to_string(),
|
||||
}
|
||||
.to_string(),
|
||||
module_game_distribution::GAME_DISTRIBUTION_THEME_MEMBER_GAME_NOT_FOUND_CODE,
|
||||
GAME_DISTRIBUTION_THEME_MEMBER_GAME_NOT_FOUND,
|
||||
StatusCode::NOT_FOUND,
|
||||
),
|
||||
];
|
||||
for (message, module_code, contract_code, expected_status) in &cases {
|
||||
assert_eq!(
|
||||
module_code, contract_code,
|
||||
"模块常量与 shared-contracts 常量必须是同一组字符串:{message}"
|
||||
);
|
||||
assert!(
|
||||
!message.contains(forbidden),
|
||||
"游标错误文案不得含「{forbidden}」:{message}"
|
||||
message.starts_with(contract_code),
|
||||
"模块产出的文案必须以契约码开头(否则 HTTP 面不可达):{message}"
|
||||
);
|
||||
let error = map_spacetime_error(SpacetimeClientError::Procedure(message.clone()));
|
||||
assert_eq!(error.status_code(), *expected_status, "{message}");
|
||||
assert_eq!(
|
||||
error.code(),
|
||||
*contract_code,
|
||||
"稳定码必须原样透传到 HTTP 面:{message}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
map_spacetime_error(SpacetimeClientError::Procedure(message.clone())).status_code(),
|
||||
StatusCode::BAD_REQUEST,
|
||||
"{message}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 主题列表响应形状:`themes` / `nextCursor` 一定发出;游标是真实值,最后一页为 `null`;
|
||||
|
||||
@@ -438,39 +438,55 @@ mod fork_authorization_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// 共创主题同理:后台写接口的错误码同样靠前缀被 HTTP 层识别,文案格式一变就静默退化成通用 4xx。
|
||||
/// 共创主题同理:错误码同样靠前缀被 HTTP 层识别,文案格式一变就静默退化成通用 4xx。
|
||||
///
|
||||
/// 这里钉的是**领域侧**的半张表(字面量),另一半(`shared-contracts` 的常量 + 状态码)由
|
||||
/// api-server 的映射测试钉;两半都对着同一组字符串,因此任何一侧改字都会红。
|
||||
/// 这条测试**枚举全部主题码**——包括唯一不走领域变体的那条(游标解析只回裸字符串,`Display`
|
||||
/// 的前缀测试天然覆盖不到它)。正因为当初漏了它,才有过「技术方案承诺 `THEME_INVALID_CURSOR`、
|
||||
/// HTTP 面实际只回通用 `BAD_REQUEST`」这个真缺陷。
|
||||
///
|
||||
/// 这里钉的是**领域侧**的半张表(本 crate 的码常量,`Display` 也复用同一批常量);另一半
|
||||
/// (`shared-contracts` 的同名常量 + 状态码)由 api-server 的
|
||||
/// `theme_error_codes_are_reachable_from_module_messages` 钉;两半都对着同一组字符串,
|
||||
/// 因此任何一侧改字都会红。
|
||||
#[test]
|
||||
fn theme_errors_are_prefixed_with_their_machine_code() {
|
||||
let cases = [
|
||||
(GameDistributionError::ThemeNotFound, "THEME_NOT_FOUND"),
|
||||
(
|
||||
GameDistributionError::ThemeNotFound.to_string(),
|
||||
crate::GAME_DISTRIBUTION_THEME_NOT_FOUND_CODE,
|
||||
),
|
||||
(
|
||||
GameDistributionError::ThemeBadRequest {
|
||||
reason: "主题名不能为空".to_string(),
|
||||
},
|
||||
"THEME_BAD_REQUEST",
|
||||
}
|
||||
.to_string(),
|
||||
crate::GAME_DISTRIBUTION_THEME_BAD_REQUEST_CODE,
|
||||
),
|
||||
(
|
||||
GameDistributionError::ThemeIdempotencyConflict,
|
||||
"THEME_IDEMPOTENCY_CONFLICT",
|
||||
GameDistributionError::ThemeIdempotencyConflict.to_string(),
|
||||
crate::GAME_DISTRIBUTION_THEME_IDEMPOTENCY_CONFLICT_CODE,
|
||||
),
|
||||
(
|
||||
GameDistributionError::ThemeMemberNotRoot {
|
||||
game_id: "game_child".to_string(),
|
||||
},
|
||||
"THEME_MEMBER_NOT_ROOT",
|
||||
}
|
||||
.to_string(),
|
||||
crate::GAME_DISTRIBUTION_THEME_MEMBER_NOT_ROOT_CODE,
|
||||
),
|
||||
(
|
||||
GameDistributionError::ThemeMemberGameNotFound {
|
||||
game_id: "game_missing".to_string(),
|
||||
},
|
||||
"THEME_MEMBER_GAME_NOT_FOUND",
|
||||
}
|
||||
.to_string(),
|
||||
crate::GAME_DISTRIBUTION_THEME_MEMBER_GAME_NOT_FOUND_CODE,
|
||||
),
|
||||
(
|
||||
crate::parse_game_distribution_theme_cursor("不是游标")
|
||||
.expect_err("非法游标必须报错"),
|
||||
crate::GAME_DISTRIBUTION_THEME_INVALID_CURSOR_CODE,
|
||||
),
|
||||
];
|
||||
for (error, code) in cases {
|
||||
let message = error.to_string();
|
||||
for (message, code) in &cases {
|
||||
assert!(
|
||||
message.starts_with(code),
|
||||
"expected {code} prefix, got {message}"
|
||||
@@ -482,6 +498,21 @@ mod fork_authorization_tests {
|
||||
"expected {code} to be followed by ':', got {message}"
|
||||
);
|
||||
}
|
||||
// 覆盖完整性:本 crate 一共只有这 6 个主题码,将来新增的码必须一并进这张表,否则
|
||||
// 新码的「HTTP 面是否可达」又会变成只有真栈才能发现的事。
|
||||
for code in [
|
||||
crate::GAME_DISTRIBUTION_THEME_NOT_FOUND_CODE,
|
||||
crate::GAME_DISTRIBUTION_THEME_BAD_REQUEST_CODE,
|
||||
crate::GAME_DISTRIBUTION_THEME_INVALID_CURSOR_CODE,
|
||||
crate::GAME_DISTRIBUTION_THEME_IDEMPOTENCY_CONFLICT_CODE,
|
||||
crate::GAME_DISTRIBUTION_THEME_MEMBER_NOT_ROOT_CODE,
|
||||
crate::GAME_DISTRIBUTION_THEME_MEMBER_GAME_NOT_FOUND_CODE,
|
||||
] {
|
||||
assert!(
|
||||
cases.iter().any(|(_, covered)| *covered == code),
|
||||
"主题码 {code} 未被这条枚举测试覆盖"
|
||||
);
|
||||
}
|
||||
// 空名原因要原样透出:400 只有一个状态码,区分「空名」与「超长」只有靠这句。
|
||||
let empty_name = GameDistributionError::ThemeBadRequest {
|
||||
reason: "主题名不能为空".to_string(),
|
||||
|
||||
@@ -2,6 +2,27 @@ use std::{error::Error, fmt};
|
||||
|
||||
use crate::{GameVersionStatus, GameVisibility};
|
||||
|
||||
/// 共创主题错误码的字面量。
|
||||
///
|
||||
/// 与 `shared-contracts` 里的同名常量是**同一组字符串的两个副本**:本 crate 不依赖
|
||||
/// `shared-contracts`(那边也不依赖本 crate),所以无法直接共用。两侧由 api-server 的一条测试
|
||||
/// 逐码比对(`theme_error_codes_are_reachable_from_module_messages`),任何一侧改字都会红。
|
||||
///
|
||||
/// 这些码必须出现在**每条**主题错误的文案开头(`"{CODE}: {中文说明}"`):HTTP 层靠「文案含
|
||||
/// `THEME_` + 码前缀」映射稳定码,少一个前缀就静默退化成通用 `BAD_REQUEST`——游标解析就是
|
||||
/// 这样漏过一次(游标错误不是领域变体,只是裸字符串,所以不在下面的 `Display` 里)。
|
||||
pub const GAME_DISTRIBUTION_THEME_NOT_FOUND_CODE: &str = "THEME_NOT_FOUND";
|
||||
/// 后台请求参数非法(空名 / 超长 / 状态不在白名单)。
|
||||
pub const GAME_DISTRIBUTION_THEME_BAD_REQUEST_CODE: &str = "THEME_BAD_REQUEST";
|
||||
/// 主题列表游标格式非法。
|
||||
pub const GAME_DISTRIBUTION_THEME_INVALID_CURSOR_CODE: &str = "THEME_INVALID_CURSOR";
|
||||
/// 同 `Idempotency-Key` 不同请求摘要。
|
||||
pub const GAME_DISTRIBUTION_THEME_IDEMPOTENCY_CONFLICT_CODE: &str = "THEME_IDEMPOTENCY_CONFLICT";
|
||||
/// 目标作品有血缘行(非根),不能作为主题成员。
|
||||
pub const GAME_DISTRIBUTION_THEME_MEMBER_NOT_ROOT_CODE: &str = "THEME_MEMBER_NOT_ROOT";
|
||||
/// 作为成员的目标作品不存在。
|
||||
pub const GAME_DISTRIBUTION_THEME_MEMBER_GAME_NOT_FOUND_CODE: &str = "THEME_MEMBER_GAME_NOT_FOUND";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum GameDistributionFieldError {
|
||||
MissingGameId,
|
||||
@@ -154,21 +175,35 @@ impl fmt::Display for GameDistributionError {
|
||||
.write_str("FORK_SOURCE_VERSION_MISMATCH: 改编来源版本不是该作品的当前公开版本"),
|
||||
Self::ForkDeclarationOnExistingGame => formatter
|
||||
.write_str("FORK_DECLARATION_ON_EXISTING_GAME: 已存在的作品不能再声明改编来源"),
|
||||
// 共创主题同理:文案以错误码开头,HTTP 层按码映射(不做子串猜测)。
|
||||
Self::ThemeNotFound => formatter.write_str("THEME_NOT_FOUND: 主题不存在"),
|
||||
// 共创主题同理:文案以错误码开头,HTTP 层按码映射(不做子串猜测);码串统一取上面的常量,
|
||||
// 避免「常量改了、Display 没改」这类只有真栈才会发现的漂移。
|
||||
Self::ThemeNotFound => write!(
|
||||
formatter,
|
||||
"{}: 主题不存在",
|
||||
GAME_DISTRIBUTION_THEME_NOT_FOUND_CODE
|
||||
),
|
||||
Self::ThemeBadRequest { reason } => {
|
||||
write!(formatter, "THEME_BAD_REQUEST: {reason}")
|
||||
write!(
|
||||
formatter,
|
||||
"{}: {reason}",
|
||||
GAME_DISTRIBUTION_THEME_BAD_REQUEST_CODE
|
||||
)
|
||||
}
|
||||
Self::ThemeIdempotencyConflict => formatter
|
||||
.write_str("THEME_IDEMPOTENCY_CONFLICT: 同一个 Idempotency-Key 对应了不同的请求"),
|
||||
Self::ThemeIdempotencyConflict => write!(
|
||||
formatter,
|
||||
"{}: 同一个 Idempotency-Key 对应了不同的请求",
|
||||
GAME_DISTRIBUTION_THEME_IDEMPOTENCY_CONFLICT_CODE
|
||||
),
|
||||
Self::ThemeMemberNotRoot { game_id } => write!(
|
||||
formatter,
|
||||
"THEME_MEMBER_NOT_ROOT: 作品 {game_id} 有血缘行(不是根作品),不能作为主题成员"
|
||||
"{}: 作品 {game_id} 有血缘行(不是根作品),不能作为主题成员",
|
||||
GAME_DISTRIBUTION_THEME_MEMBER_NOT_ROOT_CODE
|
||||
),
|
||||
Self::ThemeMemberGameNotFound { game_id } => {
|
||||
write!(
|
||||
formatter,
|
||||
"THEME_MEMBER_GAME_NOT_FOUND: 作品 {game_id} 不存在"
|
||||
"{}: 作品 {game_id} 不存在",
|
||||
GAME_DISTRIBUTION_THEME_MEMBER_GAME_NOT_FOUND_CODE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,13 @@ pub use domain::{
|
||||
counts_as_public_derivative, fork_lineage_visible_identity, generate_game_id,
|
||||
generate_game_version_id, next_generation, resolve_version_number,
|
||||
};
|
||||
pub use errors::{GameDistributionError, GameDistributionFieldError};
|
||||
pub use errors::{
|
||||
GAME_DISTRIBUTION_THEME_BAD_REQUEST_CODE, GAME_DISTRIBUTION_THEME_IDEMPOTENCY_CONFLICT_CODE,
|
||||
GAME_DISTRIBUTION_THEME_INVALID_CURSOR_CODE,
|
||||
GAME_DISTRIBUTION_THEME_MEMBER_GAME_NOT_FOUND_CODE,
|
||||
GAME_DISTRIBUTION_THEME_MEMBER_NOT_ROOT_CODE, GAME_DISTRIBUTION_THEME_NOT_FOUND_CODE,
|
||||
GameDistributionError, GameDistributionFieldError,
|
||||
};
|
||||
pub use events::GameDistributionEvent;
|
||||
pub use lineage::{
|
||||
GAME_DISTRIBUTION_LINEAGE_NODE_LIMIT, LineageGameFacts, LineageLink, LineageNodeList,
|
||||
|
||||
@@ -197,20 +197,26 @@ pub fn encode_game_distribution_theme_cursor(created_at_micros: i64, theme_id: &
|
||||
format!("{created_at_micros}:{theme_id}")
|
||||
}
|
||||
|
||||
/// 主题列表游标解析;格式非法时返回 `Err`(api-server 会把它透传成 400)。
|
||||
/// 主题列表游标解析;格式非法时返回带**稳定码**的 `Err`:`THEME_INVALID_CURSOR: …`。
|
||||
///
|
||||
/// 严格的错误文案是刻意的:它不得含「不存在」「已被删除」「状态」「不匹配」「幂等」「已存在」
|
||||
/// 「FORK_」等子串,否则会被 api-server 的既有 `map_spacetime_error` 分支抢先映射成 404 / 409;
|
||||
/// 「格式无效」只会落到兜底的 400,这正是「客户端传了坏游标」的语义。
|
||||
/// 文案必须以码开头(`"{CODE}: {中文说明}"`):api-server 的 `THEME_` 映射分支要求消息含 `THEME_`
|
||||
/// 且按冒号前的码查状态码表,少了前缀就静默退化成兜底的 400 + 通用 `BAD_REQUEST`,客户端只能去
|
||||
/// 匹配中文——这正是本项目出过一次的真缺陷(游标错误不是领域变体,只是裸字符串,`Display` 的
|
||||
/// 前缀测试覆盖不到它,所以另有一条测试专门枚举全部主题码)。
|
||||
///
|
||||
/// 仍然刻意避开的中文子串:只保留了**排在 `THEME_` 分支之前**的 `FORK_` 不能出现(否则会被
|
||||
/// `FORK_` 分支抢先映射);「不存在」「状态」等子串不再有害——`THEME_` 分支先命中,码由前缀决定。
|
||||
pub fn parse_game_distribution_theme_cursor(value: &str) -> Result<(i64, String), String> {
|
||||
let (micros, theme_id) = value
|
||||
.split_once(':')
|
||||
.ok_or_else(|| "主题列表游标格式无效".to_string())?;
|
||||
let micros = micros
|
||||
.parse::<i64>()
|
||||
.map_err(|_| "主题列表游标格式无效".to_string())?;
|
||||
let invalid = || {
|
||||
format!(
|
||||
"{}: 主题列表游标格式无效",
|
||||
crate::GAME_DISTRIBUTION_THEME_INVALID_CURSOR_CODE
|
||||
)
|
||||
};
|
||||
let (micros, theme_id) = value.split_once(':').ok_or_else(invalid)?;
|
||||
let micros = micros.parse::<i64>().map_err(|_| invalid())?;
|
||||
if theme_id.is_empty() {
|
||||
return Err("主题列表游标格式无效".to_string());
|
||||
return Err(invalid());
|
||||
}
|
||||
Ok((micros, theme_id.to_string()))
|
||||
}
|
||||
@@ -543,11 +549,19 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// 非法游标文案必须落 400:不得含会被 api-server 抢先映射成 404 / 409 的子串。
|
||||
/// 非法游标文案必须以**稳定码**开头,且不含会被 api-server 抢先映射的子串。
|
||||
///
|
||||
/// 只有排在 `THEME_` 分支**之前**的分支才会抢:`map_spacetime_error` 里 `FORK_` 在前、
|
||||
/// `THEME_` 紧随其后,`owner 不匹配` / `已被删除` / 409 的「状态」子串都在 `THEME_` **之后**,
|
||||
/// 因此不再需要回避它们(这里保留断言,是为了让「顺序一变就红」这件事仍然可见)。
|
||||
#[test]
|
||||
fn invalid_cursor_message_stays_in_the_bad_request_bucket() {
|
||||
let message =
|
||||
parse_game_distribution_theme_cursor("不是游标").expect_err("非法游标必须报错");
|
||||
assert!(
|
||||
message.starts_with(crate::GAME_DISTRIBUTION_THEME_INVALID_CURSOR_CODE),
|
||||
"游标错误必须以稳定码开头,否则 HTTP 面只能回通用 BAD_REQUEST:{message}"
|
||||
);
|
||||
assert!(message.contains("格式无效"), "{message}");
|
||||
for forbidden in [
|
||||
"不存在",
|
||||
|
||||
Reference in New Issue
Block a user