f7a720204a
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
813 lines
31 KiB
Rust
813 lines
31 KiB
Rust
//! E2E tests for cron job HTTP endpoints.
|
|
//!
|
|
//! Covers test-plan items: CJ-1..CJ-12, SK-1..SK-6, SC-3..SC-8, AU-1..AU-2,
|
|
//! RN-1..RN-2.
|
|
//! Items requiring real AI execution (RN-1, EV-*, SR-*, OC-*, CD-*) are tested
|
|
//! at the service integration level in `nomifun-cron/tests/service_integration.rs`.
|
|
|
|
mod common;
|
|
|
|
use axum::http::StatusCode;
|
|
use serde_json::json;
|
|
use tower::ServiceExt;
|
|
|
|
use nomifun_db::{ICronRepository, SqliteCronRepository};
|
|
|
|
use common::{body_json, build_app, delete_with_token, get_request, get_with_token, json_with_token, setup_and_login};
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────
|
|
|
|
fn create_job_body(name: &str) -> serde_json::Value {
|
|
json!({
|
|
"name": name,
|
|
"schedule": { "kind": "every", "every_ms": 60000, "description": "every minute" },
|
|
"message": "test message",
|
|
"conversation_id": 1,
|
|
"conversation_title": "Test Conv",
|
|
"agent_type": "acp",
|
|
"created_by": "user"
|
|
})
|
|
}
|
|
|
|
fn create_at_job_body(name: &str, at_ms: i64) -> serde_json::Value {
|
|
json!({
|
|
"name": name,
|
|
"schedule": { "kind": "at", "at_ms": at_ms, "description": "once" },
|
|
"message": "at message",
|
|
"conversation_id": 1,
|
|
"agent_type": "acp",
|
|
"created_by": "user"
|
|
})
|
|
}
|
|
|
|
fn create_cron_job_body(name: &str, expr: &str) -> serde_json::Value {
|
|
json!({
|
|
"name": name,
|
|
"schedule": { "kind": "cron", "expr": expr },
|
|
"message": "cron message",
|
|
"conversation_id": 1,
|
|
"agent_type": "acp",
|
|
"created_by": "user"
|
|
})
|
|
}
|
|
|
|
async fn create_job(app: &mut axum::Router, token: &str, csrf: &str, body: serde_json::Value) -> serde_json::Value {
|
|
let req = json_with_token("POST", "/api/cron/jobs", body, token, csrf);
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
|
let json = body_json(resp).await;
|
|
assert_eq!(json["success"], true);
|
|
json["data"].clone()
|
|
}
|
|
|
|
/// Seed a minimal `conversations` parent row so a cron job carrying this
|
|
/// `conversation_id` satisfies the `cron_jobs.conversation_id -> conversations`
|
|
/// foreign key. `system_default_user` is already seeded by the migration.
|
|
async fn seed_conversation(services: &nomifun_app::AppServices, id: i64) {
|
|
sqlx::query(
|
|
"INSERT INTO conversations (id, user_id, name, type, created_at, updated_at) \
|
|
VALUES (?, 'system_default_user', 'Seeded Conv', 'acp', 0, 0)",
|
|
)
|
|
.bind(id)
|
|
.execute(services.database.pool())
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
// ── AU-1/AU-2: Unauthenticated requests ─────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn au1_unauthenticated_list_returns_403() {
|
|
let (app, _services) = build_app().await;
|
|
let req = get_request("/api/cron/jobs");
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert!(
|
|
resp.status() == StatusCode::UNAUTHORIZED || resp.status() == StatusCode::FORBIDDEN,
|
|
"expected 401 or 403, got {}",
|
|
resp.status()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn au2_unauthenticated_all_endpoints() {
|
|
let (app, _services) = build_app().await;
|
|
|
|
let endpoints = vec![
|
|
("GET", "/api/cron/jobs"),
|
|
("GET", "/api/cron/jobs/cron_test"),
|
|
("GET", "/api/cron/jobs/cron_test/skill"),
|
|
("DELETE", "/api/cron/jobs/cron_test/skill"),
|
|
];
|
|
|
|
for (method, uri) in endpoints {
|
|
let req = axum::http::Request::builder()
|
|
.method(method)
|
|
.uri(uri)
|
|
.body(axum::body::Body::empty())
|
|
.unwrap();
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
assert!(
|
|
resp.status() == StatusCode::UNAUTHORIZED || resp.status() == StatusCode::FORBIDDEN,
|
|
"{method} {uri} expected 401/403, got {}",
|
|
resp.status()
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── CJ-1: Create cron job ───────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn cj1_create_cron_job() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
seed_conversation(&services, 1).await;
|
|
let data = create_job(&mut app, &token, &csrf, create_job_body("Daily Report")).await;
|
|
|
|
assert!(data["id"].as_str().unwrap().starts_with("cron_"));
|
|
assert_eq!(data["name"], "Daily Report");
|
|
assert_eq!(data["enabled"], true);
|
|
assert!(data["state"]["next_run_at_ms"].as_i64().is_some());
|
|
assert_eq!(data["state"]["run_count"], 0);
|
|
assert_eq!(data["target"]["payload"]["kind"], "message");
|
|
assert_eq!(data["target"]["payload"]["text"], "test message");
|
|
assert_eq!(data["metadata"]["conversation_id"], 1);
|
|
assert_eq!(data["metadata"]["agent_type"], "acp");
|
|
assert_eq!(data["metadata"]["created_by"], "user");
|
|
}
|
|
|
|
// ── CJ-2: Create three schedule types ────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn cj2_create_three_schedule_types() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let now = nomifun_common::now_ms();
|
|
|
|
seed_conversation(&services, 1).await;
|
|
let at = create_job(&mut app, &token, &csrf, create_at_job_body("At Job", now + 3_600_000)).await;
|
|
assert_eq!(at["schedule"]["kind"], "at");
|
|
assert!(at["state"]["next_run_at_ms"].as_i64().unwrap() > now);
|
|
|
|
let every = create_job(&mut app, &token, &csrf, create_job_body("Every Job")).await;
|
|
assert_eq!(every["schedule"]["kind"], "every");
|
|
let next = every["state"]["next_run_at_ms"].as_i64().unwrap();
|
|
assert!((next - now - 60000).abs() < 3000);
|
|
|
|
let cron = create_job(
|
|
&mut app,
|
|
&token,
|
|
&csrf,
|
|
create_cron_job_body("Cron Job", "0 */5 * * * *"),
|
|
)
|
|
.await;
|
|
assert_eq!(cron["schedule"]["kind"], "cron");
|
|
assert!(cron["state"]["next_run_at_ms"].as_i64().unwrap() > now);
|
|
}
|
|
|
|
// ── CJ-3: Create parameter validation ────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn cj3_create_missing_required_fields() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let invalid_bodies = vec![
|
|
json!({"schedule": {"kind": "every", "every_ms": 60000}, "conversation_id": 1, "agent_type": "acp", "created_by": "user"}),
|
|
json!({"name": "X", "conversation_id": 1, "agent_type": "acp", "created_by": "user"}),
|
|
json!({"name": "X", "schedule": {"kind": "every", "every_ms": 60000}, "agent_type": "acp", "created_by": "user"}),
|
|
json!({"name": "X", "schedule": {"kind": "every", "every_ms": 60000}, "conversation_id": 1, "created_by": "user"}),
|
|
];
|
|
|
|
for body in invalid_bodies {
|
|
let req = json_with_token("POST", "/api/cron/jobs", body, &token, &csrf);
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
assert_eq!(
|
|
resp.status(),
|
|
StatusCode::BAD_REQUEST,
|
|
"missing field should return 400"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cj3b_create_rejects_workspace_with_edge_whitespace_segment() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let body = json!({
|
|
"name": "Whitespace Workspace",
|
|
"schedule": { "kind": "every", "every_ms": 60000, "description": "every minute" },
|
|
"message": "test message",
|
|
"conversation_id": 0,
|
|
"agent_type": "acp",
|
|
"created_by": "user",
|
|
"execution_mode": "new_conversation",
|
|
"agent_config": {
|
|
"backend": "acp",
|
|
"name": "Cron Agent",
|
|
"workspace": "/Users/zhoukai/Documents/Archive "
|
|
}
|
|
});
|
|
|
|
let req = json_with_token("POST", "/api/cron/jobs", body, &token, &csrf);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
|
|
|
let json = body_json(resp).await;
|
|
assert_eq!(json["code"], "WORKSPACE_PATH_EDGE_WHITESPACE_UNSUPPORTED");
|
|
assert!(
|
|
json["error"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains("begins or ends with whitespace")
|
|
);
|
|
}
|
|
|
|
// ── CJ-4: Get single job ────────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn cj4_get_single_job() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
seed_conversation(&services, 1).await;
|
|
let created = create_job(&mut app, &token, &csrf, create_job_body("Get Test")).await;
|
|
let job_id = created["id"].as_str().unwrap();
|
|
|
|
let req = get_with_token(&format!("/api/cron/jobs/{job_id}"), &token);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
let json = body_json(resp).await;
|
|
assert_eq!(json["data"]["id"], job_id);
|
|
assert_eq!(json["data"]["name"], "Get Test");
|
|
}
|
|
|
|
// ── CJ-5: Get nonexistent job ────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn cj5_get_nonexistent() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, _csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let req = get_with_token("/api/cron/jobs/cron_nonexistent", &token);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cj5b_run_now_legacy_workspace_uses_runtime_edge_whitespace_code() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
let cron_repo = SqliteCronRepository::new(services.database.pool().clone());
|
|
let now = nomifun_common::now_ms();
|
|
|
|
cron_repo
|
|
.insert(&nomifun_db::models::CronJobRow {
|
|
id: "cron_whitespace_workspace".into(),
|
|
name: "Legacy Workspace".into(),
|
|
enabled: true,
|
|
schedule_kind: "every".into(),
|
|
schedule_value: "60000".into(),
|
|
schedule_tz: None,
|
|
schedule_description: Some("every minute".into()),
|
|
payload_message: "test message".into(),
|
|
execution_mode: "new_conversation".into(),
|
|
agent_config: Some(
|
|
json!({
|
|
"backend": "acp",
|
|
"name": "Cron Agent",
|
|
"workspace": "/Users/zhoukai/Documents/Archive "
|
|
})
|
|
.to_string(),
|
|
),
|
|
conversation_id: None,
|
|
conversation_title: None,
|
|
agent_type: "acp".into(),
|
|
created_by: "user".into(),
|
|
skill_content: None,
|
|
description: None,
|
|
created_at: now,
|
|
updated_at: now,
|
|
next_run_at: Some(now + 60_000),
|
|
last_run_at: None,
|
|
last_status: None,
|
|
last_error: None,
|
|
run_count: 0,
|
|
retry_count: 0,
|
|
max_retries: 3,
|
|
target_kind: "agent".into(),
|
|
terminal_mode: None,
|
|
terminal_session_id: None,
|
|
terminal_command: None,
|
|
terminal_args: None,
|
|
terminal_script: None,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let req = json_with_token(
|
|
"POST",
|
|
"/api/cron/jobs/cron_whitespace_workspace/run",
|
|
json!({}),
|
|
&token,
|
|
&csrf,
|
|
);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
|
|
|
let json = body_json(resp).await;
|
|
assert_eq!(json["code"], "WORKSPACE_PATH_EDGE_WHITESPACE_RUNTIME_UNSUPPORTED");
|
|
assert!(
|
|
json["error"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains("begins or ends with whitespace")
|
|
);
|
|
assert_eq!(json["details"]["operation"], "runtime");
|
|
}
|
|
|
|
// ── CJ-6: List all jobs ─────────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn cj6_list_all_jobs() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
seed_conversation(&services, 1).await;
|
|
for i in 0..3 {
|
|
create_job(&mut app, &token, &csrf, create_job_body(&format!("Job {i}"))).await;
|
|
}
|
|
|
|
let req = get_with_token("/api/cron/jobs", &token);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
let json = body_json(resp).await;
|
|
let items = json["data"].as_array().unwrap();
|
|
assert!(items.len() >= 3);
|
|
}
|
|
|
|
// ── CJ-7: List by conversation ID ───────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn cj7_list_by_conversation() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
seed_conversation(&services, 2).await;
|
|
seed_conversation(&services, 3).await;
|
|
let mut body_a = create_job_body("Job A");
|
|
body_a["conversation_id"] = json!(2);
|
|
create_job(&mut app, &token, &csrf, body_a).await;
|
|
|
|
let mut body_b = create_job_body("Job B");
|
|
body_b["conversation_id"] = json!(2);
|
|
create_job(&mut app, &token, &csrf, body_b).await;
|
|
|
|
let mut body_c = create_job_body("Job C");
|
|
body_c["conversation_id"] = json!(3);
|
|
create_job(&mut app, &token, &csrf, body_c).await;
|
|
|
|
let req = get_with_token("/api/cron/jobs?conversation_id=2", &token);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
let json = body_json(resp).await;
|
|
let items = json["data"].as_array().unwrap();
|
|
assert_eq!(items.len(), 2);
|
|
}
|
|
|
|
// ── CJ-8: Update job ────────────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn cj8_update_job() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
seed_conversation(&services, 1).await;
|
|
let created = create_job(&mut app, &token, &csrf, create_job_body("Original")).await;
|
|
let job_id = created["id"].as_str().unwrap();
|
|
|
|
let update_body = json!({"name": "Updated Name", "enabled": false});
|
|
let req = json_with_token("PUT", &format!("/api/cron/jobs/{job_id}"), update_body, &token, &csrf);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
let json = body_json(resp).await;
|
|
assert_eq!(json["data"]["name"], "Updated Name");
|
|
assert_eq!(json["data"]["enabled"], false);
|
|
assert!(
|
|
json["data"]["metadata"]["updated_at"].as_i64().unwrap() >= created["metadata"]["created_at"].as_i64().unwrap()
|
|
);
|
|
}
|
|
|
|
// ── CJ-9: Update schedule type ──────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn cj9_update_schedule_type() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
seed_conversation(&services, 1).await;
|
|
let created = create_job(&mut app, &token, &csrf, create_job_body("Schedule Change")).await;
|
|
let job_id = created["id"].as_str().unwrap();
|
|
|
|
let update_body = json!({"schedule": {"kind": "cron", "expr": "0 */5 * * * *"}});
|
|
let req = json_with_token("PUT", &format!("/api/cron/jobs/{job_id}"), update_body, &token, &csrf);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
let json = body_json(resp).await;
|
|
assert_eq!(json["data"]["schedule"]["kind"], "cron");
|
|
assert!(json["data"]["state"]["next_run_at_ms"].as_i64().is_some());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cj9b_update_schedule_preserves_existing_timezone_when_omitted() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
seed_conversation(&services, 1).await;
|
|
let created = create_job(
|
|
&mut app,
|
|
&token,
|
|
&csrf,
|
|
json!({
|
|
"name": "Schedule Change With Timezone",
|
|
"schedule": { "kind": "cron", "expr": "0 0 9 * * *", "tz": "Asia/Shanghai" },
|
|
"message": "cron message",
|
|
"conversation_id": 1,
|
|
"agent_type": "acp",
|
|
"created_by": "user"
|
|
}),
|
|
)
|
|
.await;
|
|
let job_id = created["id"].as_str().unwrap();
|
|
|
|
let update_body = json!({"schedule": {"kind": "cron", "expr": "0 30 9 * * *"}});
|
|
let req = json_with_token("PUT", &format!("/api/cron/jobs/{job_id}"), update_body, &token, &csrf);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
let json = body_json(resp).await;
|
|
assert_eq!(json["data"]["schedule"]["kind"], "cron");
|
|
assert_eq!(json["data"]["schedule"]["expr"], "0 30 9 * * *");
|
|
assert_eq!(json["data"]["schedule"]["tz"], "Asia/Shanghai");
|
|
}
|
|
|
|
// ── CJ-10: Update nonexistent ────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn cj10_update_nonexistent() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let update_body = json!({"name": "X"});
|
|
let req = json_with_token("PUT", "/api/cron/jobs/cron_nonexistent", update_body, &token, &csrf);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
// ── CJ-11: Delete job ───────────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn cj11_delete_job() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
seed_conversation(&services, 1).await;
|
|
let created = create_job(&mut app, &token, &csrf, create_job_body("To Delete")).await;
|
|
let job_id = created["id"].as_str().unwrap();
|
|
|
|
let req = delete_with_token(&format!("/api/cron/jobs/{job_id}"), &token, &csrf);
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
let req = get_with_token(&format!("/api/cron/jobs/{job_id}"), &token);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
// ── CJ-12: Delete nonexistent ────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn cj12_delete_nonexistent() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let req = delete_with_token("/api/cron/jobs/cron_nonexistent", &token, &csrf);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
// ── RN-2: Run now nonexistent ────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn rn1_run_now_returns_conversation_id_for_new_conversation_job() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let create_conv_req = json_with_token(
|
|
"POST",
|
|
"/api/conversations",
|
|
json!({
|
|
"type": "acp",
|
|
"name": "Run Now Source",
|
|
"extra": { "workspace": "/project" }
|
|
}),
|
|
&token,
|
|
&csrf,
|
|
);
|
|
let create_conv_resp = app.clone().oneshot(create_conv_req).await.unwrap();
|
|
assert_eq!(create_conv_resp.status(), StatusCode::CREATED);
|
|
let created_conv = body_json(create_conv_resp).await;
|
|
let conversation_id = created_conv["data"]["id"].as_i64().unwrap();
|
|
|
|
let mut body = create_job_body("Run Now Job");
|
|
body["conversation_id"] = json!(conversation_id);
|
|
let created = create_job(&mut app, &token, &csrf, body).await;
|
|
let job_id = created["id"].as_str().unwrap();
|
|
|
|
let req = json_with_token(
|
|
"POST",
|
|
&format!("/api/cron/jobs/{job_id}/run"),
|
|
json!({}),
|
|
&token,
|
|
&csrf,
|
|
);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
let body = body_json(resp).await;
|
|
assert_eq!(body["data"]["conversation_id"], json!(conversation_id));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rn2_run_now_nonexistent() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let req = json_with_token("POST", "/api/cron/jobs/cron_nonexistent/run", json!({}), &token, &csrf);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
// ── SK-1: Save skill ────────────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn sk1_save_skill() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
seed_conversation(&services, 1).await;
|
|
let created = create_job(&mut app, &token, &csrf, create_job_body("Skill Job")).await;
|
|
let job_id = created["id"].as_str().unwrap();
|
|
|
|
let skill_body = json!({"content": "---\nname: test\ndescription: test skill\n---\nDo something"});
|
|
let req = json_with_token(
|
|
"POST",
|
|
&format!("/api/cron/jobs/{job_id}/skill"),
|
|
skill_body,
|
|
&token,
|
|
&csrf,
|
|
);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
}
|
|
|
|
// ── SK-2: Has skill (true) ──────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn sk2_has_skill_true() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
seed_conversation(&services, 1).await;
|
|
let created = create_job(&mut app, &token, &csrf, create_job_body("Skill Check")).await;
|
|
let job_id = created["id"].as_str().unwrap();
|
|
|
|
let skill_body = json!({"content": "---\nname: x\n---\nContent"});
|
|
let req = json_with_token(
|
|
"POST",
|
|
&format!("/api/cron/jobs/{job_id}/skill"),
|
|
skill_body,
|
|
&token,
|
|
&csrf,
|
|
);
|
|
app.clone().oneshot(req).await.unwrap();
|
|
|
|
let req = get_with_token(&format!("/api/cron/jobs/{job_id}/skill"), &token);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
let json = body_json(resp).await;
|
|
assert_eq!(json["data"]["has_skill"], true);
|
|
}
|
|
|
|
// ── SK-3: Has skill (false) ─────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn sk3_has_skill_false() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
seed_conversation(&services, 1).await;
|
|
let created = create_job(&mut app, &token, &csrf, create_job_body("No Skill")).await;
|
|
let job_id = created["id"].as_str().unwrap();
|
|
|
|
let req = get_with_token(&format!("/api/cron/jobs/{job_id}/skill"), &token);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
|
|
let json = body_json(resp).await;
|
|
assert_eq!(json["data"]["has_skill"], false);
|
|
}
|
|
|
|
// ── SK-4: Save empty skill ──────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn sk4_save_empty_skill() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
seed_conversation(&services, 1).await;
|
|
let created = create_job(&mut app, &token, &csrf, create_job_body("Empty Skill")).await;
|
|
let job_id = created["id"].as_str().unwrap();
|
|
|
|
let skill_body = json!({"content": ""});
|
|
let req = json_with_token(
|
|
"POST",
|
|
&format!("/api/cron/jobs/{job_id}/skill"),
|
|
skill_body,
|
|
&token,
|
|
&csrf,
|
|
);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
// ── SK-5: Save placeholder skill ────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn sk5_save_placeholder_skill() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
seed_conversation(&services, 1).await;
|
|
let created = create_job(&mut app, &token, &csrf, create_job_body("Placeholder Skill")).await;
|
|
let job_id = created["id"].as_str().unwrap();
|
|
|
|
let skill_body = json!({"content": "TODO: fill in later"});
|
|
let req = json_with_token(
|
|
"POST",
|
|
&format!("/api/cron/jobs/{job_id}/skill"),
|
|
skill_body,
|
|
&token,
|
|
&csrf,
|
|
);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
// ── SK-6: Save skill for nonexistent job ─────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn sk6_save_skill_nonexistent() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let skill_body = json!({"content": "---\nname: x\n---\nOk"});
|
|
let req = json_with_token(
|
|
"POST",
|
|
"/api/cron/jobs/cron_nonexistent/skill",
|
|
skill_body,
|
|
&token,
|
|
&csrf,
|
|
);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
// ── SK-7: Delete existing skill ──────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn sk7_delete_skill() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
seed_conversation(&services, 1).await;
|
|
let created = create_job(&mut app, &token, &csrf, create_job_body("Delete Skill Job")).await;
|
|
let job_id = created["id"].as_str().unwrap();
|
|
|
|
let save_req = json_with_token(
|
|
"POST",
|
|
&format!("/api/cron/jobs/{job_id}/skill"),
|
|
json!({"content": "---\nname: delete-me\n---\nContent"}),
|
|
&token,
|
|
&csrf,
|
|
);
|
|
let save_resp = app.clone().oneshot(save_req).await.unwrap();
|
|
assert_eq!(save_resp.status(), StatusCode::OK);
|
|
|
|
let delete_req = delete_with_token(&format!("/api/cron/jobs/{job_id}/skill"), &token, &csrf);
|
|
let delete_resp = app.clone().oneshot(delete_req).await.unwrap();
|
|
assert_eq!(delete_resp.status(), StatusCode::OK);
|
|
|
|
let has_req = get_with_token(&format!("/api/cron/jobs/{job_id}/skill"), &token);
|
|
let has_resp = app.oneshot(has_req).await.unwrap();
|
|
assert_eq!(has_resp.status(), StatusCode::OK);
|
|
|
|
let json = body_json(has_resp).await;
|
|
assert_eq!(json["data"]["has_skill"], false);
|
|
}
|
|
|
|
// ── SK-8: Delete skill for nonexistent job ───────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn sk8_delete_skill_nonexistent() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let req = delete_with_token("/api/cron/jobs/cron_nonexistent/skill", &token, &csrf);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
// ── SC-5: Invalid cron expression ────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn sc5_invalid_cron_expression() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let body = create_cron_job_body("Invalid Cron", "invalid cron");
|
|
let req = json_with_token("POST", "/api/cron/jobs", body, &token, &csrf);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
// ── SC-6: Cron with timezone ─────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn sc6_cron_with_timezone() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let body = json!({
|
|
"name": "Shanghai Job",
|
|
"schedule": { "kind": "cron", "expr": "0 0 9 * * *", "tz": "Asia/Shanghai" },
|
|
"message": "hello",
|
|
"conversation_id": 1,
|
|
"agent_type": "acp",
|
|
"created_by": "user"
|
|
});
|
|
|
|
seed_conversation(&services, 1).await;
|
|
let data = create_job(&mut app, &token, &csrf, body).await;
|
|
let now = nomifun_common::now_ms();
|
|
assert!(data["state"]["next_run_at_ms"].as_i64().unwrap() > now);
|
|
}
|
|
|
|
// ── SC-7: Every zero interval ────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn sc7_every_zero_interval() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let body = json!({
|
|
"name": "Zero Interval",
|
|
"schedule": { "kind": "every", "every_ms": 0 },
|
|
"message": "x",
|
|
"conversation_id": 1,
|
|
"agent_type": "acp",
|
|
"created_by": "user"
|
|
});
|
|
let req = json_with_token("POST", "/api/cron/jobs", body, &token, &csrf);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
// ── SC-8: Every negative interval ────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn sc8_every_negative_interval() {
|
|
let (mut app, services) = build_app().await;
|
|
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
|
|
|
|
let body = json!({
|
|
"name": "Negative Interval",
|
|
"schedule": { "kind": "every", "every_ms": -1000 },
|
|
"message": "x",
|
|
"conversation_id": 1,
|
|
"agent_type": "acp",
|
|
"created_by": "user"
|
|
});
|
|
let req = json_with_token("POST", "/api/cron/jobs", body, &token, &csrf);
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
|
}
|