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

1108 lines
37 KiB
Rust

//! HTTP integration tests for `/api/assistants/*` plus the source-dispatched
//! `/api/skills/assistant-rule/*` and `/api/skills/assistant-skill/*` trio.
//!
//! Each test exercises the router end-to-end via `tower::ServiceExt::oneshot`
//! against a real `nomifun_app::create_router_with_states` instance backed by
//! an in-memory SQLite database. The assistant module state is re-built with
//! a temp-dir built-in manifest, a temp user-data dir, and (where needed) an
//! extension registry initialized from a fixture manifest that contributes an
//! assistant — giving coverage of the three-source dispatch (builtin / user /
//! extension) without touching `~/.nomifun/`.
mod common;
use std::sync::Arc;
use axum::http::StatusCode;
use nomifun_app::{AppConfig, AppServices, ModuleStates, build_module_states, create_router_with_states};
use nomifun_assistant::{AssistantRouterState, AssistantService, BuiltinAssistantRegistry};
use nomifun_db::{
IAssistantOverrideRepository, IAssistantRepository, IAssistantTagRepository, IProviderRepository,
SqliteAssistantOverrideRepository, SqliteAssistantRepository, SqliteAssistantTagRepository, SqliteProviderRepository,
init_database_memory,
};
use nomifun_extension::{
AssistantRuleDispatcher, ExtensionRegistry, ExtensionRouterState, ExtensionSource, ExtensionStateStore,
ExternalPathsManager, HubIndexManager, HubInstaller, HubRouterState, ScanPath, SkillPaths, SkillRouterState,
};
use serde_json::{Value, json};
use tempfile::TempDir;
use tower::ServiceExt;
use common::{body_json, delete_with_token, get_with_token, json_with_token, setup_and_login};
// ---------------------------------------------------------------------------
// Fixture — router + temp dirs + services
// ---------------------------------------------------------------------------
/// Hold onto the temp dirs for the lifetime of the fixture so on-disk
/// fixtures survive until the test returns.
#[allow(dead_code)]
struct Fixture {
app: axum::Router,
services: AppServices,
token: String,
csrf: String,
// user-data root containing assistant-rules / assistant-skills / assistant-avatars
user_data_dir: std::path::PathBuf,
// dir holding assistants.json manifest + per-file rule/skill/avatar assets
builtin_assets_dir: std::path::PathBuf,
_user_tmp: TempDir,
_builtin_tmp: TempDir,
_ext_tmp: TempDir,
}
/// Build the whole app with:
/// - a manifest at `{builtin_tmp}/assets/assistants.json` registering two
/// built-ins (`builtin-office` with rule/skill/avatar files on disk, and
/// `builtin-bare` with nothing referenced)
/// - a temp user-data dir that `AssistantService` uses for user rule/skill/
/// avatar storage
/// - an extension registry initialized from a fixture manifest that
/// contributes an assistant with id `ext-helper`
///
/// Also logs in `admin` and hands back the session + CSRF tokens so tests
/// can issue authenticated mutating requests.
async fn fixture() -> Fixture {
let user_tmp = TempDir::new().unwrap();
let builtin_tmp = TempDir::new().unwrap();
let ext_tmp = TempDir::new().unwrap();
let user_data_dir = user_tmp.path().to_path_buf();
let builtin_assets_dir = builtin_tmp.path().join("assets");
std::fs::create_dir_all(&builtin_assets_dir).unwrap();
// Builtin manifest: office has rule/skill/avatar on disk, bare has nothing.
std::fs::create_dir_all(builtin_assets_dir.join("rules")).unwrap();
std::fs::create_dir_all(builtin_assets_dir.join("skills")).unwrap();
std::fs::write(builtin_assets_dir.join("rules/office.en-US.md"), "office rule body").unwrap();
std::fs::write(builtin_assets_dir.join("skills/office.en-US.md"), "office skill body").unwrap();
// Tiny PNG-ish placeholder — content_type logic only inspects extension.
std::fs::write(builtin_assets_dir.join("office.png"), b"not-a-real-png").unwrap();
let manifest = json!({
"version": "1.0.0",
"assistants": [
{
"id": "builtin-office",
"name": "Office",
"preset_agent_type": "gemini",
"rule_file": "rules/office.{locale}.md",
"skill_file": "skills/office.{locale}.md",
"avatar": "office.png",
},
{
"id": "builtin-bare",
"name": "Bare",
"preset_agent_type": "gemini",
}
]
});
std::fs::write(
builtin_assets_dir.join("assistants.json"),
serde_json::to_vec_pretty(&manifest).unwrap(),
)
.unwrap();
// Extension fixture: a single extension directory containing a manifest
// with `contributes.assistants = [{ id: "ext-helper", ... }]`.
let ext_root = ext_tmp.path().join("extensions");
let ext_dir = ext_root.join("fixture-ext");
std::fs::create_dir_all(&ext_dir).unwrap();
let ext_manifest = json!({
"name": "fixture-ext",
"version": "1.0.0",
"display_name": "Fixture Extension",
"contributes": {
"assistants": [{
"id": "ext-helper",
"name": "Helper",
"description": "Contributed by fixture-ext",
"system_prompt": "You are helpful.",
"context": "Extension context body",
}]
}
});
std::fs::write(
ext_dir.join("nomi-extension.json"),
serde_json::to_vec_pretty(&ext_manifest).unwrap(),
)
.unwrap();
// Bring up in-memory DB + services + default module states.
let db = init_database_memory().await.unwrap();
let services = AppServices::from_config(db, &AppConfig::default()).await.unwrap();
let (mut states, _): (ModuleStates, _) = build_module_states(&services).await;
// Replace the extension + hub + skill states with freshly-constructed
// ones rooted at our temp dirs. The defaults built by
// `build_module_states` point at `~/.nomifun/` for the state store and
// external-paths file, which can hold arbitrary contents on a dev box
// and poison the test. Building from scratch gives us a pristine
// registry we can initialize with our fixture extension.
let ext_data_dir = ext_tmp.path().join("ext-data");
std::fs::create_dir_all(&ext_data_dir).unwrap();
let state_store = ExtensionStateStore::new(ext_data_dir.join("extension-states.json"));
let registry = ExtensionRegistry::new(state_store, services.event_bus.clone(), "1.0.0".to_string());
registry
.initialize_with_scan_paths(vec![ScanPath {
path: ext_root.clone(),
source: ExtensionSource::Env,
}])
.await
.unwrap();
states.extension = ExtensionRouterState {
registry: registry.clone(),
};
let hub_dir = ext_data_dir.join("extensions");
let index_manager = HubIndexManager::new(hub_dir, registry.clone());
let installer = HubInstaller::new(index_manager.clone(), registry.clone());
states.hub = HubRouterState {
index_manager,
installer,
};
let ext_paths_mgr = Arc::new(ExternalPathsManager::with_file(ext_data_dir.join("paths.json")).await);
let skill_paths = SkillPaths {
data_dir: ext_data_dir.clone(),
user_skills_dir: ext_data_dir.join("skills"),
cron_skills_dir: ext_data_dir.join("cron").join("skills"),
builtin_skills_dir: ext_data_dir.join("builtin-skills"),
builtin_rules_dir: ext_data_dir.join("builtin-rules"),
assistant_rules_dir: user_data_dir.join("assistant-rules"),
assistant_skills_dir: user_data_dir.join("assistant-skills"),
};
states.skill = SkillRouterState {
skill_paths,
external_paths_manager: ext_paths_mgr,
assistant_dispatcher: None, // wired below once service is constructed
skill_tag_repo: std::sync::Arc::new(nomifun_db::SqliteSkillTagRepository::new(
services.database.pool().clone(),
)),
builtin_skill_tags: std::sync::Arc::new(std::collections::HashMap::new()),
};
// Rebuild AssistantService pointing at our temp built-in manifest + temp
// user-data dir. `build_module_states` loads the default built-in
// registry (pointing at $exe_dir/assets or dev fallback) and uses
// `~/.nomifun/` for user data — neither is appropriate for tests.
let pool = services.database.pool().clone();
let repo: Arc<dyn IAssistantRepository> = Arc::new(SqliteAssistantRepository::new(pool.clone()));
let override_repo: Arc<dyn IAssistantOverrideRepository> =
Arc::new(SqliteAssistantOverrideRepository::new(pool.clone()));
let tag_repo: Arc<dyn IAssistantTagRepository> = Arc::new(SqliteAssistantTagRepository::new(pool.clone()));
let provider_repo: Arc<dyn IProviderRepository> = Arc::new(SqliteProviderRepository::new(pool));
// Seed an OpenAI-compatible provider so create / import calls without
// an explicit `preset_agent_type` resolve to `"nomi"` instead of
// erroring out — mirroring a configured production setup.
provider_repo
.create(nomifun_db::CreateProviderParams {
id: None,
platform: "openai",
name: "Test OpenAI",
base_url: "https://example.invalid",
api_key_encrypted: "stub",
models: "[]",
enabled: true,
capabilities: "[]",
context_limit: None,
model_protocols: None,
model_enabled: None,
model_health: None,
bedrock_config: None,
is_full_url: false,
})
.await
.expect("seed provider");
let builtin = Arc::new(BuiltinAssistantRegistry::load_from_dir(builtin_assets_dir.clone()));
let service = Arc::new(AssistantService::new(
repo,
override_repo,
tag_repo,
provider_repo,
builtin,
registry,
user_data_dir.clone(),
));
states.assistant = AssistantRouterState {
service: service.clone(),
};
// Rewire the skill-router dispatcher so assistant-rule / assistant-skill
// endpoints route through the test-configured service.
let dispatcher: Arc<dyn AssistantRuleDispatcher> = service;
states.skill.assistant_dispatcher = Some(dispatcher);
let mut app = create_router_with_states(&services, states);
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
Fixture {
app,
services,
token,
csrf,
user_data_dir,
builtin_assets_dir,
_user_tmp: user_tmp,
_builtin_tmp: builtin_tmp,
_ext_tmp: ext_tmp,
}
}
// ===========================================================================
// GET /api/assistants
// ===========================================================================
#[tokio::test]
async fn list_populated_returns_builtins_and_extension() {
let fx = fixture().await;
let resp = fx
.app
.clone()
.oneshot(get_with_token("/api/assistants", &fx.token))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["success"], true);
let list = json["data"].as_array().unwrap();
// 2 builtins + 1 extension
assert_eq!(list.len(), 3, "body = {json}");
let ids: Vec<&str> = list.iter().map(|a| a["id"].as_str().unwrap()).collect();
assert!(ids.contains(&"builtin-office"));
assert!(ids.contains(&"builtin-bare"));
assert!(ids.contains(&"ext-helper"));
let sources: Vec<&str> = list.iter().map(|a| a["source"].as_str().unwrap()).collect();
assert!(sources.contains(&"builtin"));
assert!(sources.contains(&"extension"));
}
#[tokio::test]
async fn list_requires_auth() {
let fx = fixture().await;
let req = axum::http::Request::builder()
.method("GET")
.uri("/api/assistants")
.body(axum::body::Body::empty())
.unwrap();
let resp = fx.app.clone().oneshot(req).await.unwrap();
// auth_middleware returns 403 Forbidden for any authentication failure
// (see `nomifun_auth::middleware::auth_middleware`).
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
// ===========================================================================
// POST /api/assistants
// ===========================================================================
#[tokio::test]
async fn create_happy_path_returns_201() {
let fx = fixture().await;
let req = json_with_token(
"POST",
"/api/assistants",
json!({ "id": "u1", "name": "Mine", "description": "hello" }),
&fx.token,
&fx.csrf,
);
let resp = fx.app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let json = body_json(resp).await;
assert_eq!(json["data"]["id"], "u1");
assert_eq!(json["data"]["source"], "user");
assert_eq!(json["data"]["name"], "Mine");
assert_eq!(json["data"]["description"], "hello");
}
#[tokio::test]
async fn create_rejects_empty_name_with_400() {
let fx = fixture().await;
let req = json_with_token("POST", "/api/assistants", json!({ "name": " " }), &fx.token, &fx.csrf);
let resp = fx.app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn create_rejects_builtin_id_collision_with_400() {
let fx = fixture().await;
let req = json_with_token(
"POST",
"/api/assistants",
json!({ "id": "builtin-office", "name": "spoof" }),
&fx.token,
&fx.csrf,
);
let resp = fx.app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn create_rejects_extension_id_collision_with_400() {
let fx = fixture().await;
let req = json_with_token(
"POST",
"/api/assistants",
json!({ "id": "ext-helper", "name": "spoof" }),
&fx.token,
&fx.csrf,
);
let resp = fx.app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn create_rejects_duplicate_user_id_with_409() {
let fx = fixture().await;
let req = json_with_token(
"POST",
"/api/assistants",
json!({ "id": "u1", "name": "A" }),
&fx.token,
&fx.csrf,
);
let resp = fx.app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let req = json_with_token(
"POST",
"/api/assistants",
json!({ "id": "u1", "name": "B" }),
&fx.token,
&fx.csrf,
);
let resp = fx.app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::CONFLICT);
}
// ===========================================================================
// PUT /api/assistants/{id}
// ===========================================================================
#[tokio::test]
async fn update_happy_path_returns_200() {
let fx = fixture().await;
create_user(&fx, "u1", "original").await;
let req = json_with_token(
"PUT",
"/api/assistants/u1",
json!({ "name": "renamed" }),
&fx.token,
&fx.csrf,
);
let resp = fx.app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"]["name"], "renamed");
}
#[tokio::test]
async fn update_missing_user_returns_404() {
let fx = fixture().await;
let req = json_with_token(
"PUT",
"/api/assistants/ghost",
json!({ "name": "renamed" }),
&fx.token,
&fx.csrf,
);
let resp = fx.app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn update_builtin_is_forbidden() {
let fx = fixture().await;
let req = json_with_token(
"PUT",
"/api/assistants/builtin-office",
json!({ "name": "hijack" }),
&fx.token,
&fx.csrf,
);
let resp = fx.app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn update_extension_is_forbidden() {
let fx = fixture().await;
let req = json_with_token(
"PUT",
"/api/assistants/ext-helper",
json!({ "name": "hijack" }),
&fx.token,
&fx.csrf,
);
let resp = fx.app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
// ===========================================================================
// DELETE /api/assistants/{id}
// ===========================================================================
#[tokio::test]
async fn delete_happy_path_removes_row_and_user_assets() {
let fx = fixture().await;
create_user(&fx, "u1", "A").await;
// Drop a rule, skill, and avatar on disk so the fs-cleanup branch has
// something to remove.
let rules_dir = fx.user_data_dir.join("assistant-rules");
let skills_dir = fx.user_data_dir.join("assistant-skills");
let avatars_dir = fx.user_data_dir.join("assistant-avatars");
std::fs::create_dir_all(&rules_dir).unwrap();
std::fs::create_dir_all(&skills_dir).unwrap();
std::fs::create_dir_all(&avatars_dir).unwrap();
std::fs::write(rules_dir.join("u1.md"), "rule").unwrap();
std::fs::write(skills_dir.join("u1.md"), "skill").unwrap();
std::fs::write(avatars_dir.join("u1.png"), b"avatar").unwrap();
let resp = fx
.app
.clone()
.oneshot(delete_with_token("/api/assistants/u1", &fx.token, &fx.csrf))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// Row is gone (list no longer contains u1).
let resp = fx
.app
.clone()
.oneshot(get_with_token("/api/assistants", &fx.token))
.await
.unwrap();
let json = body_json(resp).await;
let ids: Vec<&str> = json["data"]
.as_array()
.unwrap()
.iter()
.map(|a| a["id"].as_str().unwrap())
.collect();
assert!(!ids.contains(&"u1"));
// Fs cleanup ran.
assert!(!rules_dir.join("u1.md").exists());
assert!(!skills_dir.join("u1.md").exists());
assert!(!avatars_dir.join("u1.png").exists());
}
#[tokio::test]
async fn delete_builtin_is_forbidden() {
let fx = fixture().await;
let resp = fx
.app
.clone()
.oneshot(delete_with_token("/api/assistants/builtin-office", &fx.token, &fx.csrf))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn delete_extension_is_forbidden() {
let fx = fixture().await;
let resp = fx
.app
.clone()
.oneshot(delete_with_token("/api/assistants/ext-helper", &fx.token, &fx.csrf))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
// ===========================================================================
// PATCH /api/assistants/{id}/state
// ===========================================================================
#[tokio::test]
async fn set_state_inserts_override_for_builtin() {
let fx = fixture().await;
let req = json_with_token(
"PATCH",
"/api/assistants/builtin-office/state",
json!({ "enabled": false, "sort_order": 9 }),
&fx.token,
&fx.csrf,
);
let resp = fx.app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"]["enabled"], false);
assert_eq!(json["data"]["sort_order"], 9);
assert_eq!(json["data"]["source"], "builtin");
}
#[tokio::test]
async fn set_state_updates_existing_override_for_user() {
let fx = fixture().await;
create_user(&fx, "u1", "A").await;
// First call inserts.
let req = json_with_token(
"PATCH",
"/api/assistants/u1/state",
json!({ "enabled": false, "sort_order": 3 }),
&fx.token,
&fx.csrf,
);
let resp = fx.app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// Second call updates sort_order and preserves enabled when omitted.
let req = json_with_token(
"PATCH",
"/api/assistants/u1/state",
json!({ "sort_order": 7 }),
&fx.token,
&fx.csrf,
);
let resp = fx.app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"]["enabled"], false);
assert_eq!(json["data"]["sort_order"], 7);
}
#[tokio::test]
async fn set_state_extension_is_400() {
let fx = fixture().await;
let req = json_with_token(
"PATCH",
"/api/assistants/ext-helper/state",
json!({ "enabled": false }),
&fx.token,
&fx.csrf,
);
let resp = fx.app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn set_state_unknown_user_returns_404() {
let fx = fixture().await;
let req = json_with_token(
"PATCH",
"/api/assistants/ghost/state",
json!({ "enabled": true }),
&fx.token,
&fx.csrf,
);
let resp = fx.app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
// ===========================================================================
// POST /api/assistants/import
// ===========================================================================
#[tokio::test]
async fn import_happy_path_inserts_new_rows() {
let fx = fixture().await;
let body = json!({
"assistants": [
{ "id": "u1", "name": "A" },
{ "id": "u2", "name": "B" },
]
});
let req = json_with_token("POST", "/api/assistants/import", body, &fx.token, &fx.csrf);
let resp = fx.app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"]["imported"], 2);
assert_eq!(json["data"]["skipped"], 0);
assert_eq!(json["data"]["failed"], 0);
}
#[tokio::test]
async fn import_skips_builtin_collision() {
let fx = fixture().await;
let body = json!({
"assistants": [
{ "id": "builtin-office", "name": "spoof" }
]
});
let req = json_with_token("POST", "/api/assistants/import", body, &fx.token, &fx.csrf);
let resp = fx.app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"]["imported"], 0);
assert_eq!(json["data"]["skipped"], 1);
}
#[tokio::test]
async fn import_skips_extension_collision() {
let fx = fixture().await;
let body = json!({
"assistants": [
{ "id": "ext-helper", "name": "spoof" }
]
});
let req = json_with_token("POST", "/api/assistants/import", body, &fx.token, &fx.csrf);
let resp = fx.app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"]["imported"], 0);
assert_eq!(json["data"]["skipped"], 1);
}
#[tokio::test]
async fn import_skips_already_imported_user_row() {
let fx = fixture().await;
create_user(&fx, "u1", "A").await;
let body = json!({
"assistants": [
{ "id": "u1", "name": "A-updated" }
]
});
let req = json_with_token("POST", "/api/assistants/import", body, &fx.token, &fx.csrf);
let resp = fx.app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"]["imported"], 0);
assert_eq!(json["data"]["skipped"], 1);
// Verify we did NOT overwrite the original name.
let resp = fx
.app
.clone()
.oneshot(get_with_token("/api/assistants", &fx.token))
.await
.unwrap();
let json = body_json(resp).await;
let entry = find_id(&json["data"], "u1").unwrap();
assert_eq!(entry["name"], "A");
}
#[tokio::test]
async fn import_retry_is_idempotent() {
let fx = fixture().await;
let body = json!({
"assistants": [
{ "id": "u1", "name": "A" }
]
});
// First attempt — imported.
let req = json_with_token("POST", "/api/assistants/import", body.clone(), &fx.token, &fx.csrf);
let resp = fx.app.clone().oneshot(req).await.unwrap();
let first = body_json(resp).await;
assert_eq!(first["data"]["imported"], 1);
// Second attempt — same payload, now skipped.
let req = json_with_token("POST", "/api/assistants/import", body, &fx.token, &fx.csrf);
let resp = fx.app.clone().oneshot(req).await.unwrap();
let second = body_json(resp).await;
assert_eq!(second["data"]["imported"], 0);
assert_eq!(second["data"]["skipped"], 1);
}
// ===========================================================================
// GET /api/assistants/{id}/avatar
// ===========================================================================
#[tokio::test]
async fn avatar_builtin_returns_bytes_with_content_type() {
let fx = fixture().await;
let resp = fx
.app
.clone()
.oneshot(get_with_token("/api/assistants/builtin-office/avatar", &fx.token))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers().get("content-type").and_then(|v| v.to_str().ok()),
Some("image/png")
);
let bytes = http_body_util::BodyExt::collect(resp.into_body())
.await
.unwrap()
.to_bytes();
assert_eq!(&bytes[..], b"not-a-real-png");
}
#[tokio::test]
async fn avatar_user_returns_bytes_after_file_planted() {
let fx = fixture().await;
create_user(&fx, "u1", "A").await;
let avatars_dir = fx.user_data_dir.join("assistant-avatars");
std::fs::create_dir_all(&avatars_dir).unwrap();
std::fs::write(avatars_dir.join("u1.svg"), b"<svg></svg>").unwrap();
let resp = fx
.app
.clone()
.oneshot(get_with_token("/api/assistants/u1/avatar", &fx.token))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers().get("content-type").and_then(|v| v.to_str().ok()),
Some("image/svg+xml")
);
}
#[tokio::test]
async fn avatar_missing_returns_404() {
let fx = fixture().await;
// builtin-bare declared no avatar in the manifest; lookup should 404.
let resp = fx
.app
.clone()
.oneshot(get_with_token("/api/assistants/builtin-bare/avatar", &fx.token))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
// ===========================================================================
// POST /api/skills/assistant-rule/read
// ===========================================================================
#[tokio::test]
async fn read_rule_builtin_returns_manifest_file_contents() {
let fx = fixture().await;
let req = json_with_token(
"POST",
"/api/skills/assistant-rule/read",
json!({ "assistant_id": "builtin-office", "locale": "en-US" }),
&fx.token,
&fx.csrf,
);
let resp = fx.app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"], "office rule body");
}
#[tokio::test]
async fn read_rule_extension_returns_empty_string() {
let fx = fixture().await;
let req = json_with_token(
"POST",
"/api/skills/assistant-rule/read",
json!({ "assistant_id": "ext-helper", "locale": "en-US" }),
&fx.token,
&fx.csrf,
);
let resp = fx.app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"], "");
}
#[tokio::test]
async fn read_rule_user_round_trip_through_write() {
let fx = fixture().await;
create_user(&fx, "u1", "A").await;
let req = json_with_token(
"POST",
"/api/skills/assistant-rule/write",
json!({ "assistant_id": "u1", "content": "my rule", "locale": "en-US" }),
&fx.token,
&fx.csrf,
);
let resp = fx.app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let req = json_with_token(
"POST",
"/api/skills/assistant-rule/read",
json!({ "assistant_id": "u1", "locale": "en-US" }),
&fx.token,
&fx.csrf,
);
let resp = fx.app.clone().oneshot(req).await.unwrap();
let json = body_json(resp).await;
assert_eq!(json["data"], "my rule");
}
// ===========================================================================
// POST /api/skills/assistant-rule/write
// ===========================================================================
#[tokio::test]
async fn write_rule_user_happy_path() {
let fx = fixture().await;
create_user(&fx, "u1", "A").await;
let req = json_with_token(
"POST",
"/api/skills/assistant-rule/write",
json!({ "assistant_id": "u1", "content": "rule body" }),
&fx.token,
&fx.csrf,
);
let resp = fx.app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// File was actually written.
let file = fx.user_data_dir.join("assistant-rules/u1.md");
assert_eq!(std::fs::read_to_string(file).unwrap(), "rule body");
}
#[tokio::test]
async fn write_rule_builtin_returns_400() {
let fx = fixture().await;
let req = json_with_token(
"POST",
"/api/skills/assistant-rule/write",
json!({ "assistant_id": "builtin-office", "content": "nope" }),
&fx.token,
&fx.csrf,
);
let resp = fx.app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn write_rule_extension_returns_400() {
let fx = fixture().await;
let req = json_with_token(
"POST",
"/api/skills/assistant-rule/write",
json!({ "assistant_id": "ext-helper", "content": "nope" }),
&fx.token,
&fx.csrf,
);
let resp = fx.app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
// ===========================================================================
// DELETE /api/skills/assistant-rule/{id}
// ===========================================================================
#[tokio::test]
async fn delete_rule_user_removes_file() {
let fx = fixture().await;
create_user(&fx, "u1", "A").await;
let rules_dir = fx.user_data_dir.join("assistant-rules");
std::fs::create_dir_all(&rules_dir).unwrap();
std::fs::write(rules_dir.join("u1.md"), "body").unwrap();
let resp = fx
.app
.clone()
.oneshot(delete_with_token("/api/skills/assistant-rule/u1", &fx.token, &fx.csrf))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert!(!rules_dir.join("u1.md").exists());
}
#[tokio::test]
async fn delete_rule_builtin_returns_400() {
let fx = fixture().await;
let resp = fx
.app
.clone()
.oneshot(delete_with_token(
"/api/skills/assistant-rule/builtin-office",
&fx.token,
&fx.csrf,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn delete_rule_extension_returns_400() {
let fx = fixture().await;
let resp = fx
.app
.clone()
.oneshot(delete_with_token(
"/api/skills/assistant-rule/ext-helper",
&fx.token,
&fx.csrf,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
// ===========================================================================
// POST /api/skills/assistant-skill/read
// ===========================================================================
#[tokio::test]
async fn read_skill_builtin_returns_manifest_file_contents() {
let fx = fixture().await;
let req = json_with_token(
"POST",
"/api/skills/assistant-skill/read",
json!({ "assistant_id": "builtin-office", "locale": "en-US" }),
&fx.token,
&fx.csrf,
);
let resp = fx.app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"], "office skill body");
}
#[tokio::test]
async fn read_skill_extension_returns_empty_string() {
let fx = fixture().await;
let req = json_with_token(
"POST",
"/api/skills/assistant-skill/read",
json!({ "assistant_id": "ext-helper", "locale": "en-US" }),
&fx.token,
&fx.csrf,
);
let resp = fx.app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"], "");
}
#[tokio::test]
async fn read_skill_user_round_trip_through_write() {
let fx = fixture().await;
create_user(&fx, "u1", "A").await;
let req = json_with_token(
"POST",
"/api/skills/assistant-skill/write",
json!({ "assistant_id": "u1", "content": "my skill", "locale": "zh-CN" }),
&fx.token,
&fx.csrf,
);
let resp = fx.app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let req = json_with_token(
"POST",
"/api/skills/assistant-skill/read",
json!({ "assistant_id": "u1", "locale": "zh-CN" }),
&fx.token,
&fx.csrf,
);
let resp = fx.app.clone().oneshot(req).await.unwrap();
let json = body_json(resp).await;
assert_eq!(json["data"], "my skill");
}
// ===========================================================================
// POST /api/skills/assistant-skill/write
// ===========================================================================
#[tokio::test]
async fn write_skill_user_happy_path() {
let fx = fixture().await;
create_user(&fx, "u1", "A").await;
let req = json_with_token(
"POST",
"/api/skills/assistant-skill/write",
json!({ "assistant_id": "u1", "content": "skill body" }),
&fx.token,
&fx.csrf,
);
let resp = fx.app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let file = fx.user_data_dir.join("assistant-skills/u1.md");
assert_eq!(std::fs::read_to_string(file).unwrap(), "skill body");
}
#[tokio::test]
async fn write_skill_builtin_returns_400() {
let fx = fixture().await;
let req = json_with_token(
"POST",
"/api/skills/assistant-skill/write",
json!({ "assistant_id": "builtin-office", "content": "nope" }),
&fx.token,
&fx.csrf,
);
let resp = fx.app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn write_skill_extension_returns_400() {
let fx = fixture().await;
let req = json_with_token(
"POST",
"/api/skills/assistant-skill/write",
json!({ "assistant_id": "ext-helper", "content": "nope" }),
&fx.token,
&fx.csrf,
);
let resp = fx.app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
// ===========================================================================
// DELETE /api/skills/assistant-skill/{id}
// ===========================================================================
#[tokio::test]
async fn delete_skill_user_removes_file() {
let fx = fixture().await;
create_user(&fx, "u1", "A").await;
let skills_dir = fx.user_data_dir.join("assistant-skills");
std::fs::create_dir_all(&skills_dir).unwrap();
std::fs::write(skills_dir.join("u1.md"), "body").unwrap();
let resp = fx
.app
.clone()
.oneshot(delete_with_token("/api/skills/assistant-skill/u1", &fx.token, &fx.csrf))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert!(!skills_dir.join("u1.md").exists());
}
#[tokio::test]
async fn delete_skill_builtin_returns_400() {
let fx = fixture().await;
let resp = fx
.app
.clone()
.oneshot(delete_with_token(
"/api/skills/assistant-skill/builtin-office",
&fx.token,
&fx.csrf,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn delete_skill_extension_returns_400() {
let fx = fixture().await;
let resp = fx
.app
.clone()
.oneshot(delete_with_token(
"/api/skills/assistant-skill/ext-helper",
&fx.token,
&fx.csrf,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
// ===========================================================================
// Helpers local to this test module
// ===========================================================================
async fn create_user(fx: &Fixture, id: &str, name: &str) {
let req = json_with_token(
"POST",
"/api/assistants",
json!({ "id": id, "name": name }),
&fx.token,
&fx.csrf,
);
let resp = fx.app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED, "create {id} failed");
}
fn find_id<'a>(list: &'a Value, id: &str) -> Option<&'a Value> {
list.as_array()?.iter().find(|a| a["id"].as_str() == Some(id))
}