Files
MyAiDesk/nomifun-tauri/crates/backend/nomifun-app/tests/cron_e2e.rs
T
freedak f7a720204a Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
2026-07-04 19:20:46 +08:00

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);
}