diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index ea1c057..b1073c4 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -2,7 +2,8 @@
"permissions": {
"allow": [
"Bash(find:*)",
- "Bash(cargo search:*)"
+ "Bash(cargo search:*)",
+ "Bash(cd:*)"
]
}
}
diff --git a/mocks/v1/auth/login.yaml b/mocks/v1/auth/login.yaml
deleted file mode 100644
index a24501a..0000000
--- a/mocks/v1/auth/login.yaml
+++ /dev/null
@@ -1,28 +0,0 @@
-id: "auth_login_001"
-request:
- method: "POST"
- path: "/api/v1/auth/login"
- # 必须包含此 Header 才会匹配
- headers:
- Content-Type: "application/json"
- Authorization: "111"
- host: "127.0.0.1:8080"
- body: >
- {
- "username":"user",
- "password":"123"
- }
-response:
- status: 200
- headers:
- Content-Type: "application/json"
- X-Mock-Engine: "Rust-Gemini-v1.2"
- # 直接内联 JSON 字符串
- body: >
- {
- "code": 0,
- "data": { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6" },
- "msg": "success"
- }
-settings:
- delay_ms: 2000 # 模拟真实网络延迟
\ No newline at end of file
diff --git a/mocks/v1/auth/login_out.yaml b/mocks/v1/auth/login_out.yaml
deleted file mode 100644
index 05e4930..0000000
--- a/mocks/v1/auth/login_out.yaml
+++ /dev/null
@@ -1,50 +0,0 @@
-- id: "auth_login_out_001"
- request:
- method: "POST"
- path: "/api/v1/auth/login_out"
- # 必须包含此 Header 才会匹配
- headers:
- Content-Type: "application/json"
- Authorization: "111"
- host: "127.0.0.1:8080"
- body:
- type: true
- response:
- status: 200
- headers:
- Content-Type: "application/json"
- X-Mock-Engine: "Rust-Gemini-v1.2"
- # 直接内联 JSON 字符串
- body: >
- {
- "code": 0,
- "data": "退出成功",
- "msg": "success"
- }
- settings:
- delay_ms: 200 # 模拟真实网络延迟
-- id: "auth_login_out_002"
- request:
- method: "POST"
- path: "/api/v1/auth/login_out"
- # 必须包含此 Header 才会匹配
- headers:
- Content-Type: "application/json"
- Authorization: "111"
- host: "127.0.0.1:8080"
- body:
- type: false
- response:
- status: 200
- headers:
- Content-Type: "application/json"
- X-Mock-Engine: "Rust-Gemini-v1.2"
- # 直接内联 JSON 字符串
- body: >
- {
- "code": 1,
- "data": "退出失败",
- "msg": "success"
- }
- settings:
- delay_ms: 200 # 模拟真实网络延迟
\ No newline at end of file
diff --git a/mocks/v1/auth/multiple_login.yaml b/mocks/v1/auth/multiple_login.yaml
new file mode 100644
index 0000000..00c6ce5
--- /dev/null
+++ b/mocks/v1/auth/multiple_login.yaml
@@ -0,0 +1,118 @@
+# 用户登录 - JSON 格式
+- name: "user_login_002"
+ request:
+ path: "/v1/auth/login"
+ method: "POST"
+ headers:
+ Content-Type: "application/json"
+ Authorization: "eyJhbGciOiJIUzI1NiIsInR5cCI6"
+ host: "127.0.0.1:8080"
+ body: >
+ {
+ "username": "user002",
+ "password": "password123"
+ }
+ response:
+ status: 200
+ headers:
+ Content-Type: "application/json"
+ body: |
+ {
+ "code": 0,
+ "message": "登录成功",
+ "data": {
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6",
+ "userId": 10002,
+ "username": "user002",
+ "role": "administrator"
+ }
+ }
+ settings:
+ delay_ms: 2000 # 模拟真实网络延迟
+
+- name: "user_login_003"
+ request:
+ path: "/v1/auth/login"
+ method: "POST"
+ headers:
+ Content-Type: "application/json"
+ Authorization: "eyJhbGciOiJIUzI1NiIsInR5cCI6"
+ host: "127.0.0.1:8080"
+ body: |
+ {
+ "username": "user003",
+ "password": "password123"
+ }
+ response:
+ status: 200
+ headers:
+ Content-Type: "application/json"
+ body: >
+ {
+ "code": 0,
+ "message": "登录成功",
+ "data": {
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6",
+ "userId": 10003,
+ "username": "user003",
+ "role": "administrator"
+ }
+ }
+ settings:
+ delay_ms: 2000 # 模拟真实网络延迟
+
+- name: "user_login_004"
+ request:
+ path: "/v1/auth/login"
+ method: "POST"
+ headers:
+ Content-Type: "application/json"
+ Authorization: "eyJhbGciOiJIUzI1NiIsInR5cCI6"
+ host: "127.0.0.1:8080"
+ body: |
+ {
+ "username": "user004",
+ "password": "password123"
+ }
+ response:
+ status: 200
+ headers:
+ Content-Type: "application/json"
+ body: |
+ {
+ "code": 0,
+ "message": "登录成功",
+ "data": {
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6",
+ "userId": 10004,
+ "username": "user004",
+ "role": "administrator"
+ }
+ }
+
+- name: "user_login_005"
+ request:
+ path: "/v1/auth/login"
+ method: "POST"
+ headers:
+ Content-Type: "application/json"
+ Authorization: "eyJhbGciOiJIUzI1NiIsInR5cCI6"
+ host: "127.0.0.1:8080"
+ body:
+ username: "user005"
+ password: "password123"
+ response:
+ status: 200
+ headers:
+ Content-Type: "application/json"
+ body: |
+ {
+ "code": 0,
+ "message": "登录成功",
+ "data": {
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6",
+ "userId": 10005,
+ "username": "user005",
+ "role": "administrator"
+ }
+ }
\ No newline at end of file
diff --git a/mocks/v1/auth/register.yaml b/mocks/v1/auth/register.yaml
new file mode 100644
index 0000000..af5a64e
--- /dev/null
+++ b/mocks/v1/auth/register.yaml
@@ -0,0 +1,26 @@
+# 用户注册 - JSON 格式
+name: "user_register"
+request:
+ method: "POST"
+ path: "/v1/auth/register"
+ headers:
+ Content-Type: "application/json"
+ body:
+ username: "newuser"
+ password: "newpass123"
+ email: "newuser@example.com"
+response:
+ status: 201
+ headers:
+ Content-Type: "application/json"
+ body: |
+ {
+ "code": 0,
+ "message": "注册成功",
+ "data": {
+ "userId": 10002,
+ "username": "newuser",
+ "email": "newuser@example.com",
+ "createdAt": "2026-03-27T10:00:00Z"
+ }
+ }
diff --git a/mocks/v1/auth/single_login.yaml b/mocks/v1/auth/single_login.yaml
new file mode 100644
index 0000000..19000e5
--- /dev/null
+++ b/mocks/v1/auth/single_login.yaml
@@ -0,0 +1,29 @@
+# 用户登录 - JSON 格式
+name: "user_login_001"
+request:
+ path: "/v1/auth/login"
+ method: "POST"
+ headers:
+ Content-Type: "application/json"
+ Authorization: "eyJhbGciOiJIUzI1NiIsInR5cCI6"
+ host: "127.0.0.1:8080"
+ body:
+ username: "user001"
+ password: "password123"
+response:
+ status: 200
+ headers:
+ Content-Type: "application/json"
+ body: |
+ {
+ "code": 0,
+ "message": "登录成功",
+ "data": {
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6",
+ "userId": 10001,
+ "username": "user001",
+ "role": "administrator"
+ }
+ }
+settings:
+ delay_ms: 2000 # 模拟真实网络延迟
diff --git a/mocks/v1/data/export.yaml b/mocks/v1/data/export.yaml
new file mode 100644
index 0000000..bcdc0bb
--- /dev/null
+++ b/mocks/v1/data/export.yaml
@@ -0,0 +1,30 @@
+# XML 数据导出 - XML 格式
+name: "data_export"
+request:
+ method: "POST"
+ path: "/v1/data/export"
+ headers:
+ Content-Type: "application/xml"
+ body: |
+
+
+ 10001
+ xml
+
+response:
+ status: 200
+ headers:
+ Content-Type: "application/xml"
+ body: |
+
+
+ 0
+ 导出成功
+
+
+ 10001
+ 管理员
+ admin@example.com
+
+
+
diff --git a/mocks/v1/health.yaml b/mocks/v1/health.yaml
new file mode 100644
index 0000000..688a534
--- /dev/null
+++ b/mocks/v1/health.yaml
@@ -0,0 +1,15 @@
+# 健康检查 - GET 无 Body
+name: "health_check"
+request:
+ method: "GET"
+ path: "/v1/health"
+response:
+ status: 200
+ headers:
+ Content-Type: "application/json"
+ body: |
+ {
+ "status": "healthy",
+ "version": "2.0.0",
+ "timestamp": "2026-03-27T10:00:00Z"
+ }
diff --git a/mocks/v1/products/export.yaml b/mocks/v1/products/export.yaml
index faed5af..ea6abca 100644
--- a/mocks/v1/products/export.yaml
+++ b/mocks/v1/products/export.yaml
@@ -1,12 +1,12 @@
-id: "prod_export_pdf"
+name: "prod_export_pdf"
request:
method: "GET"
- path: "/api/v1/products/report"
- body: '{"username":"user","password":"123"}'
+ path: "/v1/products/report"
+ body: '{"username":"user001","password":"password123"}'
response:
status: 200
headers:
Content-Type: "application/pdf"
Content-Disposition: "attachment; filename=report.pdf"
# 智能协议:引擎会自动识别前缀并异步读取磁盘文件
- body: "file://./storage/reports/annual_2024.pdf"
\ No newline at end of file
+ body: "file://./storage/v1/hello.pdf"
\ No newline at end of file
diff --git a/mocks/v1/system/health.yaml b/mocks/v1/system/health.yaml
deleted file mode 100644
index a1a2d4b..0000000
--- a/mocks/v1/system/health.yaml
+++ /dev/null
@@ -1,16 +0,0 @@
-# 使用 YAML 数组语法定义多个规则
-- id: "sys_ping"
- request:
- method: "GET"
- path: "/api/v1/ping"
- response:
- status: 200
- body: "pong"
-
-- id: "sys_version"
- request:
- method: "GET"
- path: "/api/v1/version"
- response:
- status: 200
- body: '{"version": "1.2.0-smart"}'
\ No newline at end of file
diff --git a/mocks/v1/upload.yaml b/mocks/v1/upload.yaml
deleted file mode 100644
index 8a69423..0000000
--- a/mocks/v1/upload.yaml
+++ /dev/null
@@ -1,23 +0,0 @@
-id: "upload_file"
-request:
- method: "POST"
- path: "/api/v1/upload"
- headers:
- Content-Type: "multipart/form-data"
-response:
- status: 200
- headers:
- Content-Type: "application/json"
- body: >
- {
- "code": 0,
- "data": {
- "filename": "example.txt",
- "path": "storage/2024-01-15/example.txt",
- "size": 1024,
- "url": "/storage/2024-01-15/example.txt"
- },
- "msg": "upload success"
- }
-settings:
- delay_ms: 100
diff --git a/mocks/v1/user/avatar.yaml b/mocks/v1/user/avatar.yaml
new file mode 100644
index 0000000..1337114
--- /dev/null
+++ b/mocks/v1/user/avatar.yaml
@@ -0,0 +1,73 @@
+# 上传头像 - Multipart 格式(数组形式,只匹配字段名)
+- name: "user_upload_avatar_001"
+ request:
+ method: "POST"
+ path: "/v1/user/avatar"
+ headers:
+ Content-Type: "multipart/form-data"
+ body:
+ - "avatar1"
+ - "description1"
+ response:
+ status: 200
+ headers:
+ Content-Type: "application/json"
+ body: |
+ {
+ "code": 0,
+ "message": "头像上传成功",
+ "data": {
+ "url": "https://cdn.example.com/v1/avatars/10001.jpg",
+ "size": 204800,
+ "filename": "avatar.jpg"
+ }
+ }
+
+- name: "user_upload_avatar_002"
+ request:
+ method: "POST"
+ path: "/v1/user/avatar"
+ headers:
+ Content-Type: "multipart/form-data"
+ body:
+ avatar2: "avatar"
+ description2: "description"
+ response:
+ status: 200
+ headers:
+ Content-Type: "application/json"
+ body: |
+ {
+ "code": 0,
+ "message": "头像上传成功",
+ "data": {
+ "url": "https://cdn.example.com/v1/avatars/10002.jpg",
+ "size": 204800,
+ "filename": "avatar.jpg"
+ }
+ }
+- name: "user_upload_avatar_003"
+ request:
+ method: "POST"
+ path: "/v1/user/avatar"
+ headers:
+ Content-Type: "multipart/form-data"
+ body: >
+ {
+ "avatar3": "avatar"
+ "description3": "description"
+ }
+ response:
+ status: 200
+ headers:
+ Content-Type: "application/json"
+ body: |
+ {
+ "code": 0,
+ "message": "头像上传成功",
+ "data": {
+ "url": "https://cdn.example.com/v1/avatars/10003.jpg",
+ "size": 204800,
+ "filename": "avatar.jpg"
+ }
+ }
\ No newline at end of file
diff --git a/mocks/v1/user/download.yaml b/mocks/v1/user/download.yaml
new file mode 100644
index 0000000..ef8a024
--- /dev/null
+++ b/mocks/v1/user/download.yaml
@@ -0,0 +1,13 @@
+# 下载用户数据文件 - file:// 协议
+name: "user_download"
+request:
+ method: "GET"
+ path: "/v1/user/download"
+ query_params:
+ format: "json"
+response:
+ status: 200
+ headers:
+ Content-Type: "application/octet-stream"
+ Content-Disposition: "attachment; filename=user_data.json"
+ body: "file://./storage/v1/user_data.json"
diff --git a/mocks/v1/user/echo.yaml b/mocks/v1/user/echo.yaml
new file mode 100644
index 0000000..a83478d
--- /dev/null
+++ b/mocks/v1/user/echo.yaml
@@ -0,0 +1,13 @@
+# 文本回显 - Text 格式
+name: "user_echo"
+request:
+ method: "POST"
+ path: "/v1/user/echo"
+ headers:
+ Content-Type: "text/plain"
+ body: "Hello V1 Mock Server"
+response:
+ status: 200
+ headers:
+ Content-Type: "text/plain"
+ body: "Echo from V1: Hello V1 Mock Server"
diff --git a/mocks/v1/user/login_form.yaml b/mocks/v1/user/login_form.yaml
new file mode 100644
index 0000000..ea9a072
--- /dev/null
+++ b/mocks/v1/user/login_form.yaml
@@ -0,0 +1,24 @@
+# 表单登录 - Form 格式
+name: "_user_login_form"
+request:
+ method: "POST"
+ path: "/v1/user/login/form"
+ headers:
+ Content-Type: "application/x-www-form-urlencoded"
+ body:
+ username: "formuser"
+ password: "formpass"
+response:
+ status: 200
+ headers:
+ Content-Type: "application/json"
+ body: |
+ {
+ "code": 0,
+ "message": "表单登录成功",
+ "data": {
+ "token": "v2_form_token_xyz",
+ "userId": 20001,
+ "username": "formuser"
+ }
+ }
diff --git a/mocks/v1/user/profile.yaml b/mocks/v1/user/profile.yaml
new file mode 100644
index 0000000..3c8277e
--- /dev/null
+++ b/mocks/v1/user/profile.yaml
@@ -0,0 +1,24 @@
+# 获取用户信息 - GET 无 Body,需要 Authorization Header
+name: "user_profile"
+request:
+ method: "GET"
+ path: "/v1/user/profile"
+ headers:
+ Authorization: "Bearer v1_test_token"
+response:
+ status: 200
+ headers:
+ Content-Type: "application/json"
+ body: |
+ {
+ "code": 0,
+ "message": "获取成功",
+ "data": {
+ "userId": 10001,
+ "username": "admin",
+ "email": "admin@example.com",
+ "nickname": "管理员",
+ "avatar": "https://example.com/avatars/admin.jpg",
+ "createdAt": "2025-01-01T00:00:00Z"
+ }
+ }
diff --git a/mocks/v1/user/search.yaml b/mocks/v1/user/search.yaml
deleted file mode 100644
index d4848fb..0000000
--- a/mocks/v1/user/search.yaml
+++ /dev/null
@@ -1,11 +0,0 @@
-id: "user_search_admin"
-request:
- method: "GET"
- path: "/api/v1/users"
- # 请求中必须包含 role=admin 且 status=active
- query_params:
- role: "admin"
- status: "active"
-response:
- status: 200
- body: '{"users": [{"id": 1, "name": "SuperAdmin"}]}'
\ No newline at end of file
diff --git a/src/handler.rs b/src/handler.rs
index 2c5d3f2..a91ea5e 100644
--- a/src/handler.rs
+++ b/src/handler.rs
@@ -8,6 +8,7 @@ use std::collections::HashMap;
use std::sync::{Arc, RwLock}; // 必须引入 RwLock
use tokio_util::io::ReaderStream;
+use crate::models::Payload;
use crate::router::MockRouter;
/// 共享的应用状态,router 现在由 RwLock 保护以支持热重载
@@ -15,6 +16,131 @@ pub struct AppState {
pub router: RwLock,
}
+/// 提取请求的 Content-Type(去掉参数部分,如 boundary)
+fn extract_content_type(headers: &HeaderMap) -> Option {
+ headers
+ .get(axum::http::header::CONTENT_TYPE)
+ .and_then(|v| v.to_str().ok())
+ .map(|s| s.split(';').next().unwrap_or(s).trim().to_lowercase())
+}
+
+/// 根据 Content-Type 解析 Body(始终以请求的 Content-Type 为准)
+fn parse_body(content_type: Option<&str>, bytes: &[u8]) -> Payload {
+ if bytes.is_empty() {
+ return Payload::None;
+ }
+
+ match content_type {
+ Some(ct) if ct.contains("application/json") => {
+ serde_json::from_slice(bytes)
+ .map(Payload::Json)
+ .unwrap_or_else(|_| {
+ // JSON 解析失败,降级为文本
+ Payload::Text(String::from_utf8_lossy(bytes).to_string())
+ })
+ }
+ Some(ct) if ct.contains("xml") => {
+ Payload::Xml(String::from_utf8_lossy(bytes).to_string())
+ }
+ Some(ct) if ct.contains("form-urlencoded") => {
+ Payload::Form(parse_urlencoded(bytes))
+ }
+ Some(ct) if ct.contains("multipart/form-data") => {
+ Payload::Multipart(extract_multipart_data(bytes))
+ }
+ _ => {
+ Payload::Text(String::from_utf8_lossy(bytes).to_string())
+ }
+ }
+}
+
+/// 解析 urlencoded 格式
+fn parse_urlencoded(bytes: &[u8]) -> HashMap {
+ let body = String::from_utf8_lossy(bytes);
+ let mut map = HashMap::new();
+ for pair in body.split('&') {
+ if let Some((key, value)) = pair.split_once('=') {
+ let decoded_key = urlencoding_decode(key);
+ let decoded_value = urlencoding_decode(value);
+ map.insert(decoded_key, decoded_value);
+ }
+ }
+ map
+}
+
+/// URL 解码(简单实现)
+fn urlencoding_decode(s: &str) -> String {
+ let mut result = String::new();
+ let mut chars = s.chars().peekable();
+ while let Some(c) = chars.next() {
+ if c == '+' {
+ result.push(' ');
+ } else if c == '%' {
+ let hex: String = chars.by_ref().take(2).collect();
+ if let Ok(byte) = u8::from_str_radix(&hex, 16) {
+ result.push(byte as char);
+ } else {
+ result.push('%');
+ result.push_str(&hex);
+ }
+ } else {
+ result.push(c);
+ }
+ }
+ result
+}
+
+/// 从 multipart body 中提取键值对
+fn extract_multipart_data(bytes: &[u8]) -> HashMap {
+ let body = String::from_utf8_lossy(bytes);
+ let mut map = HashMap::new();
+
+ // 分割 boundary
+ let lines: Vec<&str> = body.lines().collect();
+ let mut current_name: Option = None;
+ let mut current_value = String::new();
+ let mut in_value = false;
+
+ for line in &lines {
+ // 检测 Content-Disposition 行,提取 name
+ if line.contains("Content-Disposition") && line.contains("name=") {
+ // 保存上一个字段的值
+ if let Some(name) = current_name.take() {
+ map.insert(name, current_value.trim().to_string());
+ current_value.clear();
+ }
+
+ // 提取 name 属性
+ if let Some(start) = line.find("name=\"") {
+ let start = start + 6;
+ if let Some(end) = line[start..].find('"') {
+ current_name = Some(line[start..start + end].to_string());
+ in_value = false;
+ }
+ }
+ } else if line.starts_with("Content-Type") {
+ // 跳过 Content-Type 行
+ continue;
+ } else if line.is_empty() {
+ // 空行后面是值
+ in_value = true;
+ } else if in_value {
+ // 收集值内容
+ if !current_value.is_empty() {
+ current_value.push('\n');
+ }
+ current_value.push_str(line);
+ }
+ }
+
+ // 保存最后一个字段
+ if let Some(name) = current_name {
+ map.insert(name, current_value.trim().to_string());
+ }
+
+ map
+}
+
/// 全局统一请求处理函数
pub async fn mock_handler(
State(state): State>, // State 必须是第一个或靠前的参数
@@ -27,7 +153,10 @@ pub async fn mock_handler(
let path = req.uri().path().to_string();
let method_str = method.as_str().to_string();
- // 2. 读取请求 body(用于 body 字段匹配)
+ // 2. 提取请求的 Content-Type
+ let req_content_type = extract_content_type(&headers);
+
+ // 3. 读取请求 body
let body_bytes = match axum::body::to_bytes(req.into_body(), 10 * 1024 * 1024).await {
Ok(bytes) => bytes,
Err(_) => {
@@ -37,9 +166,11 @@ pub async fn mock_handler(
.unwrap();
}
};
- let incoming_json: Option = serde_json::from_slice(&body_bytes).ok();
- // 3. 将 Axum HeaderMap 转换为简单的 HashMap
+ // 4. 根据【请求的 Content-Type】解析 body
+ let parsed_body = parse_body(req_content_type.as_deref(), &body_bytes);
+
+ // 5. 将 Axum HeaderMap 转换为简单的 HashMap
let mut req_headers = HashMap::new();
for (name, value) in headers.iter() {
if let Ok(v) = value.to_str() {
@@ -47,22 +178,22 @@ pub async fn mock_handler(
}
}
- // 4. 执行匹配逻辑:先获取读锁 (Read Lock)
+ // 6. 执行匹配逻辑:先获取读锁 (Read Lock)
let maybe_rule = {
let router = state.router.read().expect("Failed to acquire read lock");
- router.match_rule(&method_str, &path, ¶ms, &req_headers, &incoming_json).cloned()
+ router.match_rule(&method_str, &path, ¶ms, &req_headers, &parsed_body).cloned()
// 此处使用 .cloned() 以便尽早释放读锁,避免阻塞热重载写锁
};
if let Some(rule) = maybe_rule {
- // 5. 处理模拟延迟
+ // 7. 处理模拟延迟
if let Some(ref settings) = rule.settings {
if let Some(delay) = settings.delay_ms {
tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
}
}
- // 6. 构建响应
+ // 8. 构建响应
let status = StatusCode::from_u16(rule.response.status).unwrap_or(StatusCode::OK);
let mut response_builder = Response::builder().status(status);
@@ -72,7 +203,7 @@ pub async fn mock_handler(
}
}
- // 7. Smart Body 逻辑
+ // 9. Smart Body 逻辑
if let Some(file_path) = rule.response.get_file_path() {
match tokio::fs::File::open(file_path).await {
Ok(file) => {
diff --git a/src/lib.rs b/src/lib.rs
index e13f385..8753663 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,5 +1,5 @@
// 声明模块并设为 pub,这样 tests/ 目录才能看到它们
-pub mod config;
+pub mod models;
pub mod loader;
pub mod router;
pub mod handler;
\ No newline at end of file
diff --git a/src/loader.rs b/src/loader.rs
index 0a68dea..6cba720 100644
--- a/src/loader.rs
+++ b/src/loader.rs
@@ -3,7 +3,7 @@ use std::fs;
use std::path::{Path, PathBuf};
use walkdir::WalkDir; // 需在 Cargo.toml 添加 walkdir 依赖
-use crate::config::{MockRule, MockSource}; // 假设 config.rs 中定义了这两个类型
+use crate::models::{MockRule, MockSource};
pub struct MockLoader;
diff --git a/src/config.rs b/src/models.rs
similarity index 50%
rename from src/config.rs
rename to src/models.rs
index b2ce262..60e1c2f 100644
--- a/src/config.rs
+++ b/src/models.rs
@@ -1,13 +1,58 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
+/// 解析后的请求 Body
+#[derive(Debug, Clone)]
+pub enum Payload {
+ /// JSON 格式
+ Json(serde_json::Value),
+ /// XML 格式
+ Xml(String),
+ /// URL 编码表单
+ Form(HashMap),
+ /// Multipart 表单(键值对)
+ Multipart(HashMap),
+ /// 纯文本
+ Text(String),
+ /// 无 Body
+ None,
+}
+
+impl Payload {
+ /// 转换为字符串(用于兜底比较)
+ pub fn to_compare_string(&self) -> String {
+ match self {
+ Payload::Json(v) => v.to_string(),
+ Payload::Xml(s) | Payload::Text(s) => s.clone(),
+ Payload::Form(map) | Payload::Multipart(map) => {
+ let mut pairs: Vec<_> = map.iter().collect();
+ pairs.sort_by_key(|(k, _)| *k);
+ pairs.iter().map(|(k, v)| format!("{}={}", k, v)).collect::>().join("&")
+ }
+ Payload::None => String::new(),
+ }
+ }
+
+ /// 获取对应的 Content-Type 名称
+ pub fn content_type_name(&self) -> &'static str {
+ match self {
+ Payload::Json(_) => "application/json",
+ Payload::Xml(_) => "application/xml",
+ Payload::Form(_) => "application/x-www-form-urlencoded",
+ Payload::Multipart(_) => "multipart/form-data",
+ Payload::Text(_) => "text/plain",
+ Payload::None => "none",
+ }
+ }
+}
+
/// 顶层包装:支持单对象或数组,自动打平
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum MockSource {
- /// 对应“一个接口一个文件”模式
+ /// 对应"一个接口一个文件"模式
Single(MockRule),
- /// 对应“一个文件多个接口”模式
+ /// 对应"一个文件多个接口"模式
Multiple(Vec),
}
@@ -22,16 +67,16 @@ impl MockSource {
}
/// 核心 Mock 规则定义
-#[derive(Debug, Deserialize,Serialize, Clone,PartialEq)]
+#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub struct MockRule {
- pub id: String,
+ pub name: String,
pub request: RequestMatcher,
pub response: MockResponse,
pub settings: Option,
}
/// 请求匹配条件
-#[derive(Debug, Deserialize,Serialize, Clone,PartialEq)]
+#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub struct RequestMatcher {
pub method: String,
pub path: String,
@@ -39,12 +84,12 @@ pub struct RequestMatcher {
pub query_params: Option>,
/// 选填:只有请求包含这些 Header 时才匹配
pub headers: Option>,
- // 修改点:从 String 改为 Option
+ /// 选填:请求体匹配条件
pub body: Option,
}
/// 响应内容定义
-#[derive(Debug, Deserialize,Serialize, Clone,PartialEq)]
+#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub struct MockResponse {
pub status: u16,
pub headers: Option>,
@@ -53,7 +98,7 @@ pub struct MockResponse {
}
/// 模拟器行为设置
-#[derive(Debug, Deserialize,Serialize, Clone,PartialEq)]
+#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub struct MockSettings {
/// 模拟网络延迟(毫秒)
pub delay_ms: Option,
diff --git a/src/router.rs b/src/router.rs
index c68dbcd..132a94c 100644
--- a/src/router.rs
+++ b/src/router.rs
@@ -1,5 +1,5 @@
use std::collections::HashMap;
-use crate::config::MockRule;
+use crate::models::{MockRule, Payload};
pub struct MockRouter {
// 索引表:Key 是路径首段(如 "api"),Value 是该段下的所有 Mock 规则
@@ -18,7 +18,7 @@ impl MockRouter {
path: &str,
queries: &HashMap,
headers: &HashMap,
- incoming_body: &Option, // 修改 1: 增加参数
+ payload: &Payload,
) -> Option<&MockRule> {
// 1. 提取请求路径的首段作为索引 Key
let key = self.extract_first_segment(path);
@@ -27,7 +27,7 @@ impl MockRouter {
if let Some(rules) = self.index.get(&key) {
// 3. 在候选集中进行线性深度匹配
for rule in rules {
- if self.is_match(rule, method, path, queries, headers,incoming_body) {
+ if self.is_match(rule, method, path, queries, headers, payload) {
return Some(rule);
}
}
@@ -44,18 +44,19 @@ impl MockRouter {
path: &str,
queries: &HashMap,
headers: &HashMap,
- incoming_body: &Option, // 修改 3: 增加参数
+ payload: &Payload,
) -> bool {
// A. 基础校验:Method 和 Path 必须完全一致 (忽略末尾斜杠)
if rule.request.method.to_uppercase() != method.to_uppercase() {
- println!("DEBUG: [ID:{}] Method Mismatch: YAML={}, Req={}", rule.id, rule.request.method, method);
+ println!("DEBUG: [NAME:{}] Method Mismatch: YAML={}, Req={}", rule.name, rule.request.method, method);
return false;
}
if rule.request.path.trim_end_matches('/') != path.trim_end_matches('/') {
- println!("DEBUG: [ID:{}] Path Mismatch: YAML='{}', Req='{}'", rule.id, rule.request.path, path);
+ println!("DEBUG: [NAME:{}] Path Mismatch: YAML='{}', Req='{}'", rule.name, rule.request.path, path);
return false;
}
- println!("DEBUG: [ID:{}] Method and Path matched! Checking headers...", rule.id);
+ println!("DEBUG: [NAME:{}] Method and Path matched! Checking headers...", rule.name);
+
// B. Query 参数校验 (子集匹配原则)
if let Some(ref required_queries) = rule.request.query_params {
for (key, val) in required_queries {
@@ -64,47 +65,72 @@ impl MockRouter {
}
}
}
-
- // C. Header 校验 (优化版)
+
+ // C. Header 校验 (大小写不敏感,Content-Type 使用前缀匹配)
if let Some(ref required_headers) = rule.request.headers {
for (key, val) in required_headers {
- println!("{}:{}",key.clone(), val.clone());
- // 方案:将 Key 统一转为小写比较,并检查请求头是否“包含”期望的值
+ let key_lower = key.to_lowercase();
let matched = headers.iter().any(|(k, v)| {
- // k.to_lowercase() == key.to_lowercase() && v.contains(val)
- k.to_lowercase() == key.to_lowercase() && v==val
+ if k.to_lowercase() != key_lower {
+ return false;
+ }
+ // Content-Type 使用前缀匹配(因为可能包含 boundary 等参数)
+ if key_lower == "content-type" {
+ v.to_lowercase().starts_with(&val.to_lowercase())
+ } else {
+ v == val
+ }
});
-
if !matched {
return false;
}
}
}
- // D. 智能 Body 全量比对逻辑
- if let Some(ref required_val) = rule.request.body {
- match incoming_body {
- Some(actual_val) => {
- // 实现你的想法:尝试将 YAML 中的 String 转换为 Object 再对比
- let final_required = if let Some(s) = required_val.as_str() {
- // 如果能解析成 JSON,就用解析后的对象,否则用原始字符串 Value
- serde_json::from_str::(s).unwrap_or_else(|_| required_val.clone())
- } else {
- required_val.clone()
- };
- // 执行全量相等比对
- if final_required != *actual_val {
- println!("DEBUG: [ID:{}] Body Mismatch", rule.id);
- return false;
- }
- }
- None => return false, // YAML 要求有 Body 但请求为空
- }
+ // D. Body 匹配(根据 Payload 类型智能比较)
+ if let Some(ref yaml_body) = rule.request.body {
+ return self.match_body(yaml_body, payload);
}
true
}
+ /// Body 匹配逻辑
+ fn match_body(&self, yaml_body: &serde_json::Value, payload: &Payload) -> bool {
+ // 将 YAML body 规范化:如果是字符串,尝试解析为 JSON
+ let normalized_body = normalize_yaml_body(yaml_body);
+
+ match payload {
+ Payload::Json(actual) => {
+ // JSON 对象比较
+ &normalized_body == actual
+ }
+ Payload::Xml(actual) => {
+ // XML 字符串比较(规范化后比较)
+ normalized_body.as_str()
+ .map(|expected| normalize_xml(expected) == normalize_xml(actual))
+ .unwrap_or(false)
+ }
+ Payload::Form(actual) => {
+ // Form 键值对比较(子集匹配)
+ compare_form_with_yaml(&normalized_body, actual)
+ }
+ Payload::Multipart(actual_data) => {
+ // Multipart 匹配:支持键值对或字段名列表
+ compare_multipart_with_yaml(&normalized_body, actual_data)
+ }
+ Payload::Text(actual) => {
+ // 字符串比较(去掉首尾空白)
+ normalized_body.as_str()
+ .map(|expected| expected.trim() == actual.trim())
+ .unwrap_or_else(|| normalized_body.to_string().trim() == actual.trim())
+ }
+ Payload::None => {
+ false // YAML 配置了 body,但请求没有 body
+ }
+ }
+ }
+
/// 与 Loader 保持一致的 Key 提取算法
fn extract_first_segment(&self, path: &str) -> String {
path.trim_start_matches('/')
@@ -113,4 +139,87 @@ impl MockRouter {
.unwrap_or("root")
.to_string()
}
-}
\ No newline at end of file
+}
+
+/// 规范化 YAML body:如果是字符串,尝试解析为 JSON
+fn normalize_yaml_body(yaml_body: &serde_json::Value) -> serde_json::Value {
+ if let Some(s) = yaml_body.as_str() {
+ // 先 trim 去掉前导/尾随空格,再尝试解析为 JSON
+ let trimmed = s.trim();
+ if let Ok(parsed) = serde_json::from_str::(trimmed) {
+ return parsed;
+ }
+ }
+ yaml_body.clone()
+}
+
+/// 规范化 XML 字符串:去掉声明、多余空白、格式化为紧凑形式
+fn normalize_xml(xml: &str) -> String {
+ let mut result = xml.to_string();
+
+ // 去掉 XML 声明
+ if let Some(pos) = result.find("?>") {
+ if result[..pos].contains(">()
+ .chunks(1)
+ .map(|c| c[0])
+ .collect::();
+
+ // 分割成行,去掉每行首尾空白,过滤空行
+ result = result
+ .lines()
+ .map(|line| line.trim())
+ .filter(|line| !line.is_empty())
+ .collect::();
+
+ result
+}
+
+/// Form 比较:YAML 中的键值对必须是请求的子集
+fn compare_form_with_yaml(yaml_body: &serde_json::Value, actual: &HashMap) -> bool {
+ let yaml_map = match yaml_body.as_object() {
+ Some(obj) => obj,
+ None => return false,
+ };
+
+ for (key, yaml_val) in yaml_map {
+ let expected = yaml_val.as_str().map(|s| s.to_string()).unwrap_or_else(|| yaml_val.to_string());
+ if actual.get(key) != Some(&expected) {
+ return false;
+ }
+ }
+ true
+}
+
+/// Multipart 比较:对象和数组形式都只匹配字段名是否存在
+fn compare_multipart_with_yaml(yaml_body: &serde_json::Value, actual: &HashMap) -> bool {
+ // 方式 1:对象形式 - 只匹配键名是否存在(忽略值)
+ if let Some(yaml_map) = yaml_body.as_object() {
+ for key in yaml_map.keys() {
+ if !actual.contains_key(key) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ // 方式 2:数组形式 - 只匹配字段名是否存在
+ if let Some(yaml_array) = yaml_body.as_array() {
+ for yaml_field in yaml_array {
+ let field_name = yaml_field.as_str().map(|s| s.to_string()).unwrap_or_else(|| yaml_field.to_string());
+ if !actual.contains_key(&field_name) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ false
+}
diff --git a/storage/v1/hello.pdf b/storage/v1/hello.pdf
new file mode 100644
index 0000000..f2260e5
Binary files /dev/null and b/storage/v1/hello.pdf differ
diff --git a/storage/v1/user_data.json b/storage/v1/user_data.json
new file mode 100644
index 0000000..673d10c
--- /dev/null
+++ b/storage/v1/user_data.json
@@ -0,0 +1,20 @@
+{
+ "exportInfo": {
+ "exportedAt": "2026-03-27T10:00:00Z",
+ "version": "2.0.0"
+ },
+ "user": {
+ "id": 10001,
+ "username": "admin",
+ "email": "admin@example.com",
+ "nickname": "管理员",
+ "role": "administrator",
+ "createdAt": "2025-01-01T00:00:00Z",
+ "lastLoginAt": "2026-03-27T09:30:00Z"
+ },
+ "preferences": {
+ "theme": "dark",
+ "language": "zh-CN",
+ "notifications": true
+ }
+}
diff --git a/tests/integration_test.rs b/tests/integration_test.rs
index 5355fae..4717950 100644
--- a/tests/integration_test.rs
+++ b/tests/integration_test.rs
@@ -1,160 +1,92 @@
use std::fs::{self, File};
use std::io::Write;
use tempfile::tempdir;
-// 确保 Cargo.toml 中有 serde_json
-use serde_json::json;
-use mock_server::config::{MockRule, MockSource};
+use mock_server::models::MockSource;
use mock_server::loader::MockLoader;
-use mock_server::router::MockRouter;
-use std::collections::HashMap;
-/// 模块一:验证 Config 反序列化逻辑
#[test]
-fn test_config_parsing_scenarios() {
- // 场景 A: 验证单接口配置 (增加 body 结构化校验)
- let yaml_single = r#"
- id: "auth_v1"
+fn test_config_deserialization() {
+ // 测试 1:验证单接口 YAML 解析
+ let single_yaml = r#"
+ name: "auth_v1"
request:
method: "POST"
path: "/api/v1/login"
- body: { "user": "admin" }
- response: { status: 200, body: "welcome" }
+ response:
+ status: 200
+ body: "inline_content"
"#;
- let source_s: MockSource = serde_yaml::from_str(yaml_single).expect("解析单接口失败");
- let rules = source_s.flatten();
- assert_eq!(rules.len(), 1);
- // 验证 body 是否被成功解析为 Value::Object
- assert!(rules[0].request.body.is_some());
- assert_eq!(rules[0].request.body.as_ref().unwrap()["user"], "admin");
+ let res: MockSource = serde_yaml::from_str(single_yaml).expect("应该成功解析单接口");
+ assert_eq!(res.flatten().len(), 1);
- // 场景 B: 验证多接口配置 (原有逻辑不变)
- let yaml_multi = r#"
- - id: "api_1"
- request: { method: "GET", path: "/health" }
- response: { status: 200, body: "ok" }
- - id: "api_2"
- request: { method: "GET", path: "/version" }
- response: { status: 200, body: "1.0" }
+ // 测试 2:验证多接口 YAML 数组解析
+ let multi_yaml = r#"
+ - name: "api_1"
+ request: { method: "GET", path: "/1" }
+ response: { status: 200, body: "b1" }
+ - name: "api_2"
+ request: { method: "GET", path: "/2" }
+ response: { status: 200, body: "b2" }
"#;
- let source_m: MockSource = serde_yaml::from_str(yaml_multi).expect("解析多接口失败");
- assert_eq!(source_m.flatten().len(), 2);
-
- // 场景 C: 验证 Smart Body 的 file:// 协议字符串解析 (原有逻辑不变)
- let yaml_file = r#"
- id: "export_api"
- request: { method: "GET", path: "/download" }
- response: { status: 200, body: "file://./storage/data.zip" }
- "#;
- let source_f: MockSource = serde_yaml::from_str(yaml_file).unwrap();
- let rule = &source_f.flatten()[0];
- assert!(rule.response.body.starts_with("file://"));
+ let res_multi: MockSource = serde_yaml::from_str(multi_yaml).expect("应该成功解析接口数组");
+ assert_eq!(res_multi.flatten().len(), 2);
}
-/// 模块二:验证 Loader 递归扫描与索引构建 (不涉及 Matcher 逻辑,基本保持不变)
#[test]
-fn test_loader_recursive_indexing() {
- let temp_root = tempdir().expect("无法创建临时目录");
- let root_path = temp_root.path();
+fn test_recursive_loading_logic() {
+ // 创建临时 Mock 目录结构
+ let root_dir = tempdir().expect("创建临时目录失败");
+ let root_path = root_dir.path();
- let auth_path = root_path.join("v1/auth");
- fs::create_dir_all(&auth_path).unwrap();
+ // 构造物理层级:mocks/v1/user/
+ let user_dir = root_path.join("v1/user");
+ fs::create_dir_all(&user_dir).unwrap();
- let mut f1 = File::create(auth_path.join("login.yaml")).unwrap();
- writeln!(f1, "id: 'l1'\nrequest: {{ method: 'POST', path: '/api/v1/login' }}\nresponse: {{ status: 200, body: 'ok' }}").unwrap();
+ // 在深层目录创建单接口文件
+ let mut file1 = File::create(user_dir.join("get_profile.yaml")).unwrap();
+ writeln!(
+ file1,
+ r#"
+ name: "user_profile"
+ request:
+ method: "GET"
+ path: "/api/v1/user/profile"
+ response:
+ status: 200
+ body: "profile_data"
+ "#
+ )
+ .unwrap();
- let mut f2 = File::create(root_path.join("sys.yaml")).unwrap();
- writeln!(f2, "- id: 's1'\n request: {{ method: 'GET', path: '/health' }}\n response: {{ status: 200, body: 'up' }}").unwrap();
+ // 在根目录创建多接口文件
+ let mut file2 = File::create(root_path.join("system.yaml")).unwrap();
+ writeln!(
+ file2,
+ r#"
+ - name: "sys_health"
+ request: {{ method: "GET", path: "/health" }}
+ response: {{ status: 200, body: "ok" }}
+ - name: "sys_version"
+ request: {{ method: "GET", path: "/version" }}
+ response: {{ status: 200, body: "1.0.0" }}
+ "#
+ )
+ .unwrap();
+ // 执行加载
let index = MockLoader::load_all_from_dir(root_path);
- assert!(index.contains_key("api"));
- assert!(index.contains_key("health"));
- let total: usize = index.values().map(|v| v.len()).sum();
- assert_eq!(total, 2);
+ // 断言结果:
+ // 1. 检查 Key 是否根据路径首段正确提取
+ assert!(index.contains_key("api"), "索引应包含 'api' 键");
+ assert!(index.contains_key("health"), "索引应包含 'health' 键");
+ assert!(index.contains_key("version"), "索引应包含 'version' 键");
+
+ // 2. 检查规则总数
+ let total_rules: usize = index.values().map(|v| v.len()).sum();
+ assert_eq!(total_rules, 3, "总规则数应为 3");
+
+ // 3. 检查深层文件是否被正确读取
+ let api_rules = &index["api"];
+ assert_eq!(api_rules[0].name, "user_profile");
}
-
-#[test]
-fn test_router_matching_logic() {
- let mut index = HashMap::new();
-
- // 1. 准备带有 Body 的规则
- let rule_auth = serde_yaml::from_str::(
- r#"
- id: "auth_v1"
- request:
- method: "POST"
- path: "/api/v1/login"
- headers: { "Content-Type": "application/json" }
- body: { "code": 123 }
- response: { status: 200, body: "token_123" }
- "#,
- )
- .unwrap()
- .flatten();
-
- index.insert("api".to_string(), vec![rule_auth[0].clone()]);
- let router = MockRouter::new(index);
-
- // 2. 测试场景 A:完全匹配 (包括 Body)
- let mut headers = HashMap::new();
- headers.insert("Content-Type".to_string(), "application/json".to_string());
-
- // 构造请求 Body
- let incoming_body = Some(json!({ "code": 123 }));
-
- let matched = router.match_rule(
- "POST",
- "/api/v1/login",
- &HashMap::new(),
- &headers,
- &incoming_body // 传入新参数
- );
- assert!(matched.is_some());
- assert_eq!(matched.unwrap().id, "auth_v1");
-
- // 3. 测试场景 B:Body 不匹配
- let wrong_body = Some(json!({ "code": 456 }));
- let matched_fail = router.match_rule(
- "POST",
- "/api/v1/login",
- &HashMap::new(),
- &headers,
- &wrong_body
- );
- assert!(matched_fail.is_none(), "Body 不一致时不应匹配成功");
-
- // 4. 测试场景 C:智能字符串转换验证 (YAML 是字符串,请求是对象)
- let rule_str_body = serde_yaml::from_str::(
- r#"
- id: "str_match"
- request:
- method: "POST"
- path: "/api/str"
- body: '{"type": "json_in_string"}' # 这里 YAML 解析为 Value::String
- response: { status: 200, body: "ok" }
- "#).unwrap().flatten();
-
- let mut index2 = HashMap::new();
- index2.insert("api".to_string(), vec![rule_str_body[0].clone()]);
- let router2 = MockRouter::new(index2);
-
- let incoming_obj = Some(json!({ "type": "json_in_string" })); // 请求是 JSON 对象
- let matched_str = router2.match_rule(
- "POST",
- "/api/str",
- &HashMap::new(),
- &HashMap::new(),
- &incoming_obj
- );
- assert!(matched_str.is_some(), "应该支持将 YAML 字符串 body 转换为对象进行匹配");
-
- // 5. 测试场景 D:末尾斜杠兼容性测试
- let matched_slash = router.match_rule(
- "POST",
- "/api/v1/login/",
- &HashMap::new(),
- &headers,
- &incoming_body
- );
- assert!(matched_slash.is_some());
-}
\ No newline at end of file
diff --git a/tests/loader_test.rs b/tests/loader_test.rs
index 64a0735..9b22b08 100644
--- a/tests/loader_test.rs
+++ b/tests/loader_test.rs
@@ -1,114 +1,425 @@
use std::fs::{self, File};
use std::io::Write;
use tempfile::tempdir;
-// 假设你的项目名在 Cargo.toml 中叫 mock_server
-use mock_server::config::MockSource;
+use serde_json::json;
+use mock_server::models::{MockRule, MockSource, Payload};
use mock_server::loader::MockLoader;
+use mock_server::router::MockRouter;
+use std::collections::HashMap;
+/// 模块一:验证 Config 反序列化逻辑
#[test]
-fn test_config_deserialization() {
- // 测试 1:验证单接口 YAML 解析
- let single_yaml = r#"
- id: "auth_v1"
+fn test_config_parsing_scenarios() {
+ // 场景 A: 验证单接口配置 (增加 body 结构化校验)
+ let yaml_single = r#"
+ name: "auth_v1"
request:
method: "POST"
path: "/api/v1/login"
- response:
- status: 200
- body: "inline_content"
+ body: { "user": "admin" }
+ response: { status: 200, body: "welcome" }
"#;
- let res: MockSource = serde_yaml::from_str(single_yaml).expect("应该成功解析单接口");
- assert_eq!(res.flatten().len(), 1);
- // assert_eq!(res.flatten()[0].id, "auth_v1");
+ let source_s: MockSource = serde_yaml::from_str(yaml_single).expect("解析单接口失败");
+ let rules = source_s.flatten();
+ assert_eq!(rules.len(), 1);
+ // 验证 body 是否被成功解析为 Value::Object
+ assert!(rules[0].request.body.is_some());
+ assert_eq!(rules[0].request.body.as_ref().unwrap()["user"], "admin");
- // 测试 2:验证多接口 YAML 数组解析
- let multi_yaml = r#"
- - id: "api_1"
- request: { method: "GET", path: "/1" }
- response: { status: 200, body: "b1" }
- - id: "api_2"
- request: { method: "GET", path: "/2" }
- response: { status: 200, body: "b2" }
+ // 场景 B: 验证多接口配置
+ let yaml_multi = r#"
+ - name: "api_1"
+ request: { method: "GET", path: "/health" }
+ response: { status: 200, body: "ok" }
+ - name: "api_2"
+ request: { method: "GET", path: "/version" }
+ response: { status: 200, body: "1.0" }
"#;
- let res_multi: MockSource = serde_yaml::from_str(multi_yaml).expect("应该成功解析接口数组");
- assert_eq!(res_multi.flatten().len(), 2);
+ let source_m: MockSource = serde_yaml::from_str(yaml_multi).expect("解析多接口失败");
+ assert_eq!(source_m.flatten().len(), 2);
+
+ // 场景 C: 验证 Smart Body 的 file:// 协议字符串解析
+ let yaml_file = r#"
+ name: "export_api"
+ request: { method: "GET", path: "/download" }
+ response: { status: 200, body: "file://./storage/data.zip" }
+ "#;
+ let source_f: MockSource = serde_yaml::from_str(yaml_file).unwrap();
+ let rule = &source_f.flatten()[0];
+ assert!(rule.response.body.starts_with("file://"));
}
+/// 模块二:验证 Loader 递归扫描与索引构建
#[test]
-fn test_recursive_loading_logic() {
- // 创建临时 Mock 目录结构
- let root_dir = tempdir().expect("创建临时目录失败");
- let root_path = root_dir.path();
+fn test_loader_recursive_indexing() {
+ let temp_root = tempdir().expect("无法创建临时目录");
+ let root_path = temp_root.path();
- // 构造物理层级:mocks/v1/user/
- let user_dir = root_path.join("v1/user");
- fs::create_dir_all(&user_dir).unwrap();
+ let auth_path = root_path.join("v1/auth");
+ fs::create_dir_all(&auth_path).unwrap();
- // 在深层目录创建单接口文件
- let mut file1 = File::create(user_dir.join("get_profile.yaml")).unwrap();
- writeln!(
- file1,
- r#"
- id: "user_profile"
- request:
- method: "GET"
- path: "/api/v1/user/profile"
- response:
- status: 200
- body: "profile_data"
- "#
- )
- .unwrap();
+ let mut f1 = File::create(auth_path.join("single_login.yaml")).unwrap();
+ writeln!(f1, "name: 'l1'\nrequest: {{ method: 'POST', path: '/api/v1/login' }}\nresponse: {{ status: 200, body: 'ok' }}").unwrap();
- // 在根目录创建多接口文件
- let mut file2 = File::create(root_path.join("system.yaml")).unwrap();
- writeln!(
- file2,
- r#"
- - id: "sys_health"
- request: {{ method: "GET", path: "/health" }}
- response: {{ status: 200, body: "ok" }}
- - id: "sys_version"
- request: {{ method: "GET", path: "/version" }}
- response: {{ status: 200, body: "1.0.0" }}
- "#
- )
- .unwrap();
-// writeln!(
-// file2,
-// r#"
-// - id: "sys_health"
-// request:
-// method: "GET"
-// path: "/health"
-// response:
-// status: 200
-// body: "ok"
-// - id: "sys_version"
-// request:
-// method: "GET"
-// path: "/version"
-// response:
-// status: 200
-// body: "1.0.0"
-// "#
-// )
-// .unwrap();
+ let mut f2 = File::create(root_path.join("sys.yaml")).unwrap();
+ writeln!(f2, "- name: 's1'\n request: {{ method: 'GET', path: '/health' }}\n response: {{ status: 200, body: 'up' }}").unwrap();
- // 执行加载
let index = MockLoader::load_all_from_dir(root_path);
- // 断言结果:
- // 1. 检查 Key 是否根据路径首段正确提取(/api/v1/... -> api, /health -> health)
- assert!(index.contains_key("api"), "索引应包含 'api' 键");
- assert!(index.contains_key("health"), "索引应包含 'health' 键");
- assert!(index.contains_key("version"), "索引应包含 'version' 键");
-
- // 2. 检查规则总数
- let total_rules: usize = index.values().map(|v| v.len()).sum();
- assert_eq!(total_rules, 3, "总规则数应为 3");
-
- // 3. 检查深层文件是否被正确读取
- let api_rules = &index["api"];
- assert_eq!(api_rules[0].id, "user_profile");
+ assert!(index.contains_key("api"));
+ assert!(index.contains_key("health"));
+ let total: usize = index.values().map(|v| v.len()).sum();
+ assert_eq!(total, 2);
+}
+
+/// 模块三:验证 Router 匹配逻辑(使用新的 Payload 类型)
+#[test]
+fn test_router_matching_logic() {
+ let mut index = HashMap::new();
+
+ // 1. 准备带有 Body 的规则
+ let rule_auth = serde_yaml::from_str::(
+ r#"
+ name: "auth_v1"
+ request:
+ method: "POST"
+ path: "/api/v1/login"
+ headers: { "Content-Type": "application/json" }
+ body: { "code": 123 }
+ response: { status: 200, body: "token_123" }
+ "#,
+ )
+ .unwrap()
+ .flatten();
+
+ index.insert("api".to_string(), vec![rule_auth[0].clone()]);
+ let router = MockRouter::new(index);
+
+ // 2. 测试场景 A:JSON 完全匹配
+ let mut headers = HashMap::new();
+ headers.insert("Content-Type".to_string(), "application/json".to_string());
+
+ let payload = Payload::Json(json!({ "code": 123 }));
+
+ let matched = router.match_rule(
+ "POST",
+ "/api/v1/login",
+ &HashMap::new(),
+ &headers,
+ &payload,
+ );
+ assert!(matched.is_some());
+ assert_eq!(matched.unwrap().name, "auth_v1");
+
+ // 3. 测试场景 B:Body 不匹配
+ let wrong_payload = Payload::Json(json!({ "code": 456 }));
+ let matched_fail = router.match_rule(
+ "POST",
+ "/api/v1/login",
+ &HashMap::new(),
+ &headers,
+ &wrong_payload,
+ );
+ assert!(matched_fail.is_none(), "Body 不一致时不应匹配成功");
+
+ // 4. 测试场景 C:末尾斜杠兼容性测试
+ let matched_slash = router.match_rule(
+ "POST",
+ "/api/v1/login/",
+ &HashMap::new(),
+ &headers,
+ &payload,
+ );
+ assert!(matched_slash.is_some());
+}
+
+/// 模块四:验证不同 Payload 类型的匹配
+#[test]
+fn test_payload_type_matching() {
+ let mut index = HashMap::new();
+
+ // XML 规则
+ let xml_rule = serde_yaml::from_str::(
+ r#"
+ name: "xml_api"
+ request:
+ method: "POST"
+ path: "/api/xml"
+ body: "test"
+ response: { status: 200, body: "ok" }
+ "#,
+ )
+ .unwrap()
+ .flatten();
+
+ // Form 规则
+ let form_rule = serde_yaml::from_str::(
+ r#"
+ name: "form_api"
+ request:
+ method: "POST"
+ path: "/api/form"
+ body: { "username": "admin", "password": "123456" }
+ response: { status: 200, body: "login_ok" }
+ "#,
+ )
+ .unwrap()
+ .flatten();
+
+ // Text 规则
+ let text_rule = serde_yaml::from_str::(
+ r#"
+ name: "text_api"
+ request:
+ method: "POST"
+ path: "/api/text"
+ body: "plain text content"
+ response: { status: 200, body: "text_ok" }
+ "#,
+ )
+ .unwrap()
+ .flatten();
+
+ index.insert("api".to_string(), vec![xml_rule[0].clone(), form_rule[0].clone(), text_rule[0].clone()]);
+ let router = MockRouter::new(index);
+
+ // 测试 XML 匹配
+ let xml_payload = Payload::Xml("test".to_string());
+ let xml_matched = router.match_rule("POST", "/api/xml", &HashMap::new(), &HashMap::new(), &xml_payload);
+ assert!(xml_matched.is_some(), "XML 应该匹配");
+ assert_eq!(xml_matched.unwrap().name, "xml_api");
+
+ // 测试 Form 匹配
+ let mut form_data = HashMap::new();
+ form_data.insert("username".to_string(), "admin".to_string());
+ form_data.insert("password".to_string(), "123456".to_string());
+ let form_payload = Payload::Form(form_data);
+ let form_matched = router.match_rule("POST", "/api/form", &HashMap::new(), &HashMap::new(), &form_payload);
+ assert!(form_matched.is_some(), "Form 应该匹配");
+ assert_eq!(form_matched.unwrap().name, "form_api");
+
+ // 测试 Text 匹配
+ let text_payload = Payload::Text("plain text content".to_string());
+ let text_matched = router.match_rule("POST", "/api/text", &HashMap::new(), &HashMap::new(), &text_payload);
+ assert!(text_matched.is_some(), "Text 应该匹配");
+ assert_eq!(text_matched.unwrap().name, "text_api");
+
+ // 测试 None 不匹配
+ let none_payload = Payload::None;
+ let none_matched = router.match_rule("POST", "/api/text", &HashMap::new(), &HashMap::new(), &none_payload);
+ assert!(none_matched.is_none(), "None 不应该匹配有 body 的规则");
+}
+
+/// 模块五:验证 Payload 方法
+#[test]
+fn test_payload_methods() {
+ // 测试 content_type_name
+ assert_eq!(Payload::Json(json!({})).content_type_name(), "application/json");
+ assert_eq!(Payload::Xml("".to_string()).content_type_name(), "application/xml");
+ assert_eq!(Payload::Form(HashMap::new()).content_type_name(), "application/x-www-form-urlencoded");
+ assert_eq!(Payload::Multipart(HashMap::new()).content_type_name(), "multipart/form-data");
+ assert_eq!(Payload::Text("".to_string()).content_type_name(), "text/plain");
+ assert_eq!(Payload::None.content_type_name(), "none");
+
+ // 测试 to_compare_string
+ let json_payload = Payload::Json(json!({"a": 1, "b": 2}));
+ assert!(json_payload.to_compare_string().contains("\"a\":1"));
+
+ let text_payload = Payload::Text("hello world".to_string());
+ assert_eq!(text_payload.to_compare_string(), "hello world");
+
+ let none_payload = Payload::None;
+ assert_eq!(none_payload.to_compare_string(), "");
+
+ // 测试 Form 排序后的字符串
+ let mut form_map = HashMap::new();
+ form_map.insert("b".to_string(), "2".to_string());
+ form_map.insert("a".to_string(), "1".to_string());
+ let form_payload = Payload::Form(form_map);
+ assert_eq!(form_payload.to_compare_string(), "a=1&b=2");
+
+ // 测试 Multipart 排序后的字符串
+ let mut multipart_map = HashMap::new();
+ multipart_map.insert("b".to_string(), "2".to_string());
+ multipart_map.insert("a".to_string(), "1".to_string());
+ let multipart_payload = Payload::Multipart(multipart_map);
+ assert_eq!(multipart_payload.to_compare_string(), "a=1&b=2");
+}
+
+/// 模块六:验证 Multipart 匹配
+#[test]
+fn test_multipart_matching() {
+ let mut index = HashMap::new();
+
+ // 数组形式:只匹配字段名
+ let array_rule = serde_yaml::from_str::(
+ r#"
+name: "upload_array"
+request:
+ method: "POST"
+ path: "/api/upload/array"
+ body: ["file", "description"]
+response: { status: 200, body: "ok" }
+"#,
+ )
+ .unwrap()
+ .flatten();
+
+ // 对象形式:匹配键值对
+ let object_rule = serde_yaml::from_str::(
+ r#"
+name: "upload_object"
+request:
+ method: "POST"
+ path: "/api/upload/object"
+ body: { "username": "admin", "role": "user" }
+response: { status: 200, body: "ok" }
+"#,
+ )
+ .unwrap()
+ .flatten();
+
+ index.insert("api".to_string(), vec![array_rule[0].clone(), object_rule[0].clone()]);
+ let router = MockRouter::new(index);
+
+ // 测试数组形式:匹配字段名
+ let mut full_data = HashMap::new();
+ full_data.insert("file".to_string(), "file_content".to_string());
+ full_data.insert("description".to_string(), "test file".to_string());
+ let full_payload = Payload::Multipart(full_data);
+ let matched = router.match_rule("POST", "/api/upload/array", &HashMap::new(), &HashMap::new(), &full_payload);
+ assert!(matched.is_some(), "包含所有字段应该匹配");
+
+ // 测试数组形式:缺少字段
+ let mut partial_data = HashMap::new();
+ partial_data.insert("file".to_string(), "file_content".to_string());
+ let partial_payload = Payload::Multipart(partial_data);
+ let not_matched = router.match_rule("POST", "/api/upload/array", &HashMap::new(), &HashMap::new(), &partial_payload);
+ assert!(not_matched.is_none(), "缺少字段不应该匹配");
+
+ // 测试对象形式:键名匹配(值被忽略)
+ let mut correct_data = HashMap::new();
+ correct_data.insert("username".to_string(), "any_value".to_string()); // 值不重要
+ correct_data.insert("role".to_string(), "any_role".to_string());
+ let correct_payload = Payload::Multipart(correct_data);
+ let object_matched = router.match_rule("POST", "/api/upload/object", &HashMap::new(), &HashMap::new(), &correct_payload);
+ assert!(object_matched.is_some(), "键名匹配应该成功");
+
+ // 测试对象形式:键名不匹配
+ let mut wrong_data = HashMap::new();
+ wrong_data.insert("username".to_string(), "admin".to_string());
+ wrong_data.insert("other_field".to_string(), "value".to_string()); // 缺少 role 字段
+ let wrong_payload = Payload::Multipart(wrong_data);
+ let wrong_matched = router.match_rule("POST", "/api/upload/object", &HashMap::new(), &HashMap::new(), &wrong_payload);
+ assert!(wrong_matched.is_none(), "缺少键名不应该成功");
+}
+
+/// 模块七:验证 YAML 块语法(折叠 `>` 和字面量 `|`)
+#[test]
+fn test_yaml_block_syntax() {
+ let mut index = HashMap::new();
+
+ // 使用折叠语法 `>` 的规则
+ let folded_rule = serde_yaml::from_str::(
+ r#"
+name: "folded_api"
+request:
+ method: "POST"
+ path: "/api/folded"
+ body: >
+ {"username":"admin","password":"123456"}
+response:
+ status: 200
+ body: "ok"
+"#,
+ )
+ .unwrap()
+ .flatten();
+
+ // 使用字面量语法 `|` 的规则
+ let literal_rule = serde_yaml::from_str::(
+ r#"
+name: "literal_api"
+request:
+ method: "POST"
+ path: "/api/literal"
+ body: |
+ {"username":"test","password":"abcdef"}
+response:
+ status: 200
+ body: "ok"
+"#,
+ )
+ .unwrap()
+ .flatten();
+
+ index.insert("api".to_string(), vec![folded_rule[0].clone(), literal_rule[0].clone()]);
+ let router = MockRouter::new(index);
+
+ // 测试折叠语法:YAML 中 `>` 会把内容合并成一行,但仍是字符串
+ // 程序应该能将字符串解析为 JSON 后匹配
+ let folded_payload = Payload::Json(json!({"username":"admin","password":"123456"}));
+ let folded_matched = router.match_rule("POST", "/api/folded", &HashMap::new(), &HashMap::new(), &folded_payload);
+ assert!(folded_matched.is_some(), "折叠语法应该匹配 JSON 请求");
+ assert_eq!(folded_matched.unwrap().name, "folded_api");
+
+ // 测试字面量语法:YAML 中 `|` 保留换行
+ let literal_payload = Payload::Json(json!({"username":"test","password":"abcdef"}));
+ let literal_matched = router.match_rule("POST", "/api/literal", &HashMap::new(), &HashMap::new(), &literal_payload);
+ assert!(literal_matched.is_some(), "字面量语法应该匹配 JSON 请求");
+ assert_eq!(literal_matched.unwrap().name, "literal_api");
+
+ // 测试不匹配的情况
+ let wrong_payload = Payload::Json(json!({"username":"wrong","password":"wrong"}));
+ let wrong_matched = router.match_rule("POST", "/api/folded", &HashMap::new(), &HashMap::new(), &wrong_payload);
+ assert!(wrong_matched.is_none(), "错误的 body 不应该匹配");
+}
+
+/// 模块八:验证 XML 格式化匹配
+#[test]
+fn test_xml_normalized_matching() {
+ let mut index = HashMap::new();
+
+ // YAML 中的 XML(带格式化)
+ let xml_rule = serde_yaml::from_str::(
+ r#"
+name: "xml_api"
+request:
+ method: "POST"
+ path: "/api/xml"
+ body: |
+
+ 1001
+ 张三
+
+response:
+ status: 200
+ body: "ok"
+"#,
+ )
+ .unwrap()
+ .flatten();
+
+ index.insert("api".to_string(), vec![xml_rule[0].clone()]);
+ let router = MockRouter::new(index);
+
+ // 测试 1:完全相同的格式化 XML
+ let formatted_xml = Payload::Xml("\n 1001\n 张三\n".to_string());
+ let matched1 = router.match_rule("POST", "/api/xml", &HashMap::new(), &HashMap::new(), &formatted_xml);
+ assert!(matched1.is_some(), "格式化 XML 应该匹配");
+
+ // 测试 2:紧凑格式的 XML
+ let compact_xml = Payload::Xml("1001张三".to_string());
+ let matched2 = router.match_rule("POST", "/api/xml", &HashMap::new(), &HashMap::new(), &compact_xml);
+ assert!(matched2.is_some(), "紧凑格式 XML 应该匹配");
+
+ // 测试 3:带 XML 声明的请求
+ let xml_with_decl = Payload::Xml("1001张三".to_string());
+ let matched3 = router.match_rule("POST", "/api/xml", &HashMap::new(), &HashMap::new(), &xml_with_decl);
+ assert!(matched3.is_some(), "带声明的 XML 应该匹配");
+
+ // 测试 4:内容不同,不应该匹配
+ let wrong_xml = Payload::Xml("1002李四".to_string());
+ let matched4 = router.match_rule("POST", "/api/xml", &HashMap::new(), &HashMap::new(), &wrong_xml);
+ assert!(matched4.is_none(), "内容不同的 XML 不应该匹配");
}
diff --git a/tests/v2_integration_test.rs b/tests/v2_integration_test.rs
new file mode 100644
index 0000000..fd4dfd1
--- /dev/null
+++ b/tests/v2_integration_test.rs
@@ -0,0 +1,238 @@
+use std::path::Path;
+use std::collections::HashMap;
+use serde_json::json;
+use mock_server::models::Payload;
+use mock_server::loader::MockLoader;
+use mock_server::router::MockRouter;
+
+/// 加载 v1 目录的 mock 规则
+fn load_v2_mocks() -> HashMap> {
+ let v2_path = Path::new("../mocks/v1");
+ MockLoader::load_all_from_dir(v2_path)
+}
+
+// ========== 模块一:验证所有 YAML 文件正确加载 ==========
+
+#[test]
+fn test_v2_load_all_mocks() {
+ let index = load_v2_mocks();
+
+ // 验证索引键存在
+ assert!(index.contains_key("v1"), "应包含 'v2' 索引键");
+
+ // 验证规则总数
+ let total: usize = index.values().map(|v| v.len()).sum();
+ assert_eq!(total, 9, "v2 目录应有 9 个 mock 规则");
+}
+
+// ========== 模块二:JSON Payload 测试 ==========
+
+#[test]
+fn test_v2_json_login() {
+ let index = load_v2_mocks();
+ let router = MockRouter::new(index);
+
+ let mut headers = HashMap::new();
+ headers.insert("Content-Type".to_string(), "application/json".to_string());
+
+ let payload = Payload::Json(json!({
+ "username": "admin",
+ "password": "password123"
+ }));
+
+ let matched = router.match_rule("POST", "/v1/auth/login", &HashMap::new(), &headers, &payload);
+
+ assert!(matched.is_some(), "JSON 登录应匹配成功");
+ let rule = matched.unwrap();
+ assert_eq!(rule.name, "v2_user_login");
+ assert_eq!(rule.response.status, 200);
+}
+
+#[test]
+fn test_v2_json_register() {
+ let index = load_v2_mocks();
+ let router = MockRouter::new(index);
+
+ let mut headers = HashMap::new();
+ headers.insert("Content-Type".to_string(), "application/json".to_string());
+
+ let payload = Payload::Json(json!({
+ "username": "newuser",
+ "password": "newpass123",
+ "email": "newuser@example.com"
+ }));
+
+ let matched = router.match_rule("POST", "/v1/auth/register", &HashMap::new(), &headers, &payload);
+
+ assert!(matched.is_some(), "JSON 注册应匹配成功");
+ let rule = matched.unwrap();
+ assert_eq!(rule.name, "v2_user_register");
+ assert_eq!(rule.response.status, 201);
+}
+
+// ========== 模块三:Form Payload 测试 ==========
+
+#[test]
+fn test_v2_form_login() {
+ let index = load_v2_mocks();
+ let router = MockRouter::new(index);
+
+ let mut headers = HashMap::new();
+ headers.insert("Content-Type".to_string(), "application/x-www-form-urlencoded".to_string());
+
+ let mut form_data = HashMap::new();
+ form_data.insert("username".to_string(), "formuser".to_string());
+ form_data.insert("password".to_string(), "formpass".to_string());
+ let payload = Payload::Form(form_data);
+
+ let matched = router.match_rule("POST", "/v1/user/login/form", &HashMap::new(), &headers, &payload);
+
+ assert!(matched.is_some(), "Form 登录应匹配成功");
+ assert_eq!(matched.unwrap().name, "v2_user_login_form");
+}
+
+// ========== 模块四:Text Payload 测试 ==========
+
+#[test]
+fn test_v2_text_echo() {
+ let index = load_v2_mocks();
+ let router = MockRouter::new(index);
+
+ let mut headers = HashMap::new();
+ headers.insert("Content-Type".to_string(), "text/plain".to_string());
+
+ let payload = Payload::Text("Hello V2 Mock Server".to_string());
+
+ let matched = router.match_rule("POST", "/v1/user/echo", &HashMap::new(), &headers, &payload);
+
+ assert!(matched.is_some(), "Text 回显应匹配成功");
+ let rule = matched.unwrap();
+ assert_eq!(rule.name, "v2_user_echo");
+ assert!(rule.response.body.contains("Echo from V2"));
+}
+
+// ========== 模块五:XML Payload 测试 ==========
+
+#[test]
+fn test_v2_xml_export() {
+ let index = load_v2_mocks();
+ let router = MockRouter::new(index);
+
+ let mut headers = HashMap::new();
+ headers.insert("Content-Type".to_string(), "application/xml".to_string());
+
+ let xml_body = r#"10001xml"#;
+ let payload = Payload::Xml(xml_body.to_string());
+
+ let matched = router.match_rule("POST", "/v1/data/export", &HashMap::new(), &headers, &payload);
+
+ assert!(matched.is_some(), "XML 导出应匹配成功");
+ assert_eq!(matched.unwrap().name, "v2_data_export");
+}
+
+// ========== 模块六:Multipart Payload 测试 ==========
+
+#[test]
+fn test_v2_multipart_upload() {
+ let index = load_v2_mocks();
+ let router = MockRouter::new(index);
+
+ let mut headers = HashMap::new();
+ headers.insert("Content-Type".to_string(), "multipart/form-data".to_string());
+
+ let mut multipart_data = HashMap::new();
+ multipart_data.insert("avatar".to_string(), "file_content".to_string());
+ multipart_data.insert("description".to_string(), "user avatar".to_string());
+ let payload = Payload::Multipart(multipart_data);
+
+ let matched = router.match_rule("POST", "/v1/user/avatar", &HashMap::new(), &headers, &payload);
+
+ assert!(matched.is_some(), "Multipart 上传应匹配成功");
+ assert_eq!(matched.unwrap().name, "v2_user_upload_avatar");
+}
+
+// ========== 模块七:None Payload 测试 (GET 无 Body) ==========
+
+#[test]
+fn test_v2_health_check() {
+ let index = load_v2_mocks();
+ let router = MockRouter::new(index);
+
+ let payload = Payload::None;
+
+ let matched = router.match_rule("GET", "/v1/health", &HashMap::new(), &HashMap::new(), &payload);
+
+ assert!(matched.is_some(), "健康检查应匹配成功");
+ let rule = matched.unwrap();
+ assert_eq!(rule.name, "v2_health_check");
+ assert_eq!(rule.response.status, 200);
+}
+
+#[test]
+fn test_v2_get_profile() {
+ let index = load_v2_mocks();
+ let router = MockRouter::new(index);
+
+ let mut headers = HashMap::new();
+ headers.insert("Authorization".to_string(), "Bearer v2_test_token".to_string());
+
+ let payload = Payload::None;
+
+ let matched = router.match_rule("GET", "/v1/user/profile", &HashMap::new(), &headers, &payload);
+
+ assert!(matched.is_some(), "获取用户信息应匹配成功");
+ assert_eq!(matched.unwrap().name, "v2_user_profile");
+}
+
+// ========== 模块八:Query Params 测试 ==========
+
+#[test]
+fn test_v2_query_params() {
+ let index = load_v2_mocks();
+ let router = MockRouter::new(index);
+
+ let mut query = HashMap::new();
+ query.insert("format".to_string(), "json".to_string());
+
+ let payload = Payload::None;
+
+ let matched = router.match_rule("GET", "/v1/user/download", &query, &HashMap::new(), &payload);
+
+ assert!(matched.is_some(), "带 query params 的下载应匹配成功");
+ let rule = matched.unwrap();
+ assert_eq!(rule.name, "v2_user_download");
+ assert!(rule.response.body.starts_with("file://"));
+}
+
+// ========== 模块九:Header 匹配测试 ==========
+
+#[test]
+fn test_v2_header_required() {
+ let index = load_v2_mocks();
+ let router = MockRouter::new(index);
+
+ // 无 Authorization header 不应匹配
+ let payload = Payload::None;
+ let no_match = router.match_rule("GET", "/v1/user/profile", &HashMap::new(), &HashMap::new(), &payload);
+ assert!(no_match.is_none(), "无 Authorization 不应匹配");
+
+ // 有正确的 Authorization header 应匹配
+ let mut headers = HashMap::new();
+ headers.insert("Authorization".to_string(), "Bearer v2_test_token".to_string());
+ let matched = router.match_rule("GET", "/v1/user/profile", &HashMap::new(), &headers, &payload);
+ assert!(matched.is_some(), "有 Authorization 应匹配");
+}
+
+// ========== 模块十:name 字段验证 ==========
+
+#[test]
+fn test_v2_all_rules_have_name() {
+ let index = load_v2_mocks();
+
+ for (key, rules) in &index {
+ for rule in rules {
+ assert!(!rule.name.is_empty(), "规则 name 不能为空");
+ assert!(rule.name.starts_with("v2_"), "v2 规则 name 应以 'v2_' 开头");
+ }
+ }
+}