feat: 左侧导航改版与 Python 镜像管理
- 应用外壳换成 stash-ui Layout:左侧导航(任务调度 / Python 镜像)+ 页面标题区,窄屏折叠为顶部导航 - 新增 python_mirrors 表与 CRUD/设默认接口,内置清华、阿里云、中科大、PyPI 官方 - Python 脚本任务运行时注入默认镜像的 PIP_INDEX_URL / UV_INDEX_URL,任务自身环境变量优先 - 新增镜像管理页与表单,支持新增、编辑、启用/停用、设默认、删除 - 重新生成 OpenAPI 与 TS 类型;补齐后端镜像测试、前端单测与 e2e,修复窄屏页面被宽表格撑开
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Method, Request, StatusCode, header},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use serde_json::{Value, json};
|
||||
use tempfile::TempDir;
|
||||
use tower::ServiceExt;
|
||||
|
||||
async fn test_app() -> (axum::Router, TempDir) {
|
||||
let temp_dir = TempDir::new().expect("temporary directory should be created");
|
||||
let pool = task_pilot_backend::database::connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("test database should initialize");
|
||||
let app = task_pilot_backend::app(pool, temp_dir.path().join("logs"))
|
||||
.await
|
||||
.expect("test app should start");
|
||||
(app, temp_dir)
|
||||
}
|
||||
|
||||
async fn auth_token(app: &axum::Router) -> String {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/session")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("router should respond");
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should be readable")
|
||||
.to_bytes();
|
||||
let json: Value = serde_json::from_slice(&body).expect("session should be JSON");
|
||||
json["token"]
|
||||
.as_str()
|
||||
.expect("token should be a string")
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
async fn send_json(
|
||||
app: &axum::Router,
|
||||
method: Method,
|
||||
uri: &str,
|
||||
token: Option<&str>,
|
||||
body: Option<Value>,
|
||||
) -> (StatusCode, Value) {
|
||||
let mut builder = Request::builder().method(method).uri(uri);
|
||||
if let Some(token) = token {
|
||||
builder = builder.header("x-taskpilot-token", token);
|
||||
}
|
||||
let request = if let Some(body) = body {
|
||||
builder
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&body).expect("body should serialize"),
|
||||
))
|
||||
.expect("request should build")
|
||||
} else {
|
||||
builder.body(Body::empty()).expect("request should build")
|
||||
};
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(request)
|
||||
.await
|
||||
.expect("router should respond");
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("body should be readable")
|
||||
.to_bytes();
|
||||
let json = serde_json::from_slice(&body).unwrap_or(Value::Null);
|
||||
(status, json)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn seeds_python_mirrors_with_one_default() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
|
||||
let (status, body) = send_json(&app, Method::GET, "/api/v1/python/mirrors", None, None).await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let mirrors = body.as_array().expect("mirrors should be an array");
|
||||
assert_eq!(mirrors.len(), 4);
|
||||
let defaults: Vec<&Value> = mirrors
|
||||
.iter()
|
||||
.filter(|mirror| mirror["is_default"] == true)
|
||||
.collect();
|
||||
assert_eq!(defaults.len(), 1);
|
||||
assert_eq!(defaults[0]["id"], "tsinghua");
|
||||
assert!(mirrors.iter().all(|mirror| mirror["enabled"] == true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mirrors_crud_and_default_switch() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
let token = auth_token(&app).await;
|
||||
|
||||
let (status, created) = send_json(
|
||||
&app,
|
||||
Method::POST,
|
||||
"/api/v1/python/mirrors",
|
||||
Some(&token),
|
||||
Some(json!({
|
||||
"name": "内网镜像",
|
||||
"index_url": "https://pypi.example.local/simple",
|
||||
"trusted_host": "pypi.example.local",
|
||||
"enabled": true
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::CREATED);
|
||||
let id = created["id"]
|
||||
.as_str()
|
||||
.expect("created mirror should have an id")
|
||||
.to_owned();
|
||||
assert_eq!(created["is_default"], false);
|
||||
assert_eq!(created["trusted_host"], "pypi.example.local");
|
||||
|
||||
let (status, mirror) = send_json(
|
||||
&app,
|
||||
Method::POST,
|
||||
&format!("/api/v1/python/mirrors/{id}/default"),
|
||||
Some(&token),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(mirror["is_default"], true);
|
||||
|
||||
let (status, list) = send_json(&app, Method::GET, "/api/v1/python/mirrors", None, None).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let defaults = list
|
||||
.as_array()
|
||||
.expect("mirrors should be an array")
|
||||
.iter()
|
||||
.filter(|mirror| mirror["is_default"] == true)
|
||||
.count();
|
||||
assert_eq!(defaults, 1);
|
||||
|
||||
let (status, updated) = send_json(
|
||||
&app,
|
||||
Method::PUT,
|
||||
&format!("/api/v1/python/mirrors/{id}"),
|
||||
Some(&token),
|
||||
Some(json!({
|
||||
"name": "内网镜像(停用)",
|
||||
"index_url": "https://pypi.example.local/simple",
|
||||
"trusted_host": null,
|
||||
"enabled": false
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(updated["name"], "内网镜像(停用)");
|
||||
assert_eq!(updated["enabled"], false);
|
||||
// 停用默认镜像时连带清掉默认标记,避免执行器继续引用一个停用的源
|
||||
assert_eq!(updated["is_default"], false);
|
||||
|
||||
let (status, _) = send_json(
|
||||
&app,
|
||||
Method::DELETE,
|
||||
&format!("/api/v1/python/mirrors/{id}"),
|
||||
Some(&token),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NO_CONTENT);
|
||||
|
||||
let (status, body) = send_json(
|
||||
&app,
|
||||
Method::GET,
|
||||
&format!("/api/v1/python/mirrors/{id}"),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
assert_eq!(body["code"], "MIRROR_NOT_FOUND");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_invalid_mirror_url_and_duplicate_index() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
let token = auth_token(&app).await;
|
||||
|
||||
let (status, body) = send_json(
|
||||
&app,
|
||||
Method::POST,
|
||||
"/api/v1/python/mirrors",
|
||||
Some(&token),
|
||||
Some(json!({
|
||||
"name": "坏地址",
|
||||
"index_url": "ftp://example.com/simple",
|
||||
"enabled": true
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body["field"], "index_url");
|
||||
|
||||
let (status, body) = send_json(
|
||||
&app,
|
||||
Method::POST,
|
||||
"/api/v1/python/mirrors",
|
||||
Some(&token),
|
||||
Some(json!({
|
||||
"name": "重复镜像",
|
||||
"index_url": "https://pypi.org/simple",
|
||||
"enabled": true
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::CONFLICT);
|
||||
assert_eq!(body["code"], "MIRROR_CONFLICT");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mirror_mutations_require_token() {
|
||||
let (app, _temp_dir) = test_app().await;
|
||||
|
||||
let (status, body) = send_json(
|
||||
&app,
|
||||
Method::POST,
|
||||
"/api/v1/python/mirrors",
|
||||
None,
|
||||
Some(json!({
|
||||
"name": "未授权",
|
||||
"index_url": "https://example.com/simple",
|
||||
"enabled": true
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
assert_eq!(body["code"], "FORBIDDEN");
|
||||
}
|
||||
Reference in New Issue
Block a user