Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -0,0 +1,102 @@
//! Integration tests for ConnectionTestService.
//!
//! Tests validate input checking, service construction, and error paths.
//! Real AWS calls are tested only with fake credentials to verify
//! proper error handling (no real accounts needed).
use nomifun_api_types::{BedrockAuthMethod, BedrockConfig};
use nomifun_system::ConnectionTestService;
fn make_service() -> ConnectionTestService {
ConnectionTestService::new(reqwest::Client::new())
}
// ── Bedrock validation ──────────────────────────────────────────────
#[tokio::test]
async fn bedrock_rejects_empty_region() {
let svc = make_service();
let config = BedrockConfig {
auth_method: BedrockAuthMethod::AccessKey,
region: "".into(),
access_key_id: Some("AKIA".into()),
secret_access_key: Some("secret".into()),
profile: None,
};
let err = svc.test_bedrock_connection(config).await.unwrap_err();
assert!(err.to_string().contains("region"));
}
#[tokio::test]
async fn bedrock_rejects_missing_access_key_id() {
let svc = make_service();
let config = BedrockConfig {
auth_method: BedrockAuthMethod::AccessKey,
region: "us-east-1".into(),
access_key_id: None,
secret_access_key: Some("secret".into()),
profile: None,
};
let err = svc.test_bedrock_connection(config).await.unwrap_err();
assert!(err.to_string().contains("accessKeyId"));
}
#[tokio::test]
async fn bedrock_rejects_missing_secret_access_key() {
let svc = make_service();
let config = BedrockConfig {
auth_method: BedrockAuthMethod::AccessKey,
region: "us-east-1".into(),
access_key_id: Some("AKIA".into()),
secret_access_key: None,
profile: None,
};
let err = svc.test_bedrock_connection(config).await.unwrap_err();
assert!(err.to_string().contains("secretAccessKey"));
}
#[tokio::test]
async fn bedrock_rejects_empty_profile() {
let svc = make_service();
let config = BedrockConfig {
auth_method: BedrockAuthMethod::Profile,
region: "us-east-1".into(),
access_key_id: None,
secret_access_key: None,
profile: Some("".into()),
};
let err = svc.test_bedrock_connection(config).await.unwrap_err();
assert!(err.to_string().contains("profile"));
}
#[tokio::test]
async fn bedrock_rejects_none_profile() {
let svc = make_service();
let config = BedrockConfig {
auth_method: BedrockAuthMethod::Profile,
region: "us-east-1".into(),
access_key_id: None,
secret_access_key: None,
profile: None,
};
let err = svc.test_bedrock_connection(config).await.unwrap_err();
assert!(err.to_string().contains("profile"));
}
#[tokio::test]
async fn bedrock_fake_credentials_error() {
let svc = make_service();
let config = BedrockConfig {
auth_method: BedrockAuthMethod::AccessKey,
region: "us-east-1".into(),
access_key_id: Some("AKIAFAKEKEY1234567890".into()),
secret_access_key: Some("fakesecretkey1234567890abcdefgh".into()),
profile: None,
};
// Should fail with credential error, not panic
let err = svc.test_bedrock_connection(config).await.unwrap_err();
assert!(
err.to_string().contains("Bedrock credentials invalid"),
"Expected credential error, got: {err}"
);
}
@@ -0,0 +1,492 @@
//! Black-box integration tests for model fetch endpoint.
//!
//! Uses `wiremock` to mock remote API responses and tests the full
//! HTTP flow: request -> handler -> remote API call -> response.
use std::sync::Arc;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use serde_json::json;
use tower::ServiceExt;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use nomifun_common::encrypt_string;
use nomifun_db::{
CreateProviderParams, IProviderRepository, SqliteBrandingConfigRepository,
SqliteClientPreferenceRepository, SqliteProviderRepository, SqliteSettingsRepository,
SqliteSystemConfigRepository, init_database_memory,
};
use nomifun_system::{
ClientPrefService, ModelFetchService, ProtocolDetectionService, ProviderService, SettingsService,
SystemRouterState, VersionCheckService, system_routes,
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const TEST_KEY: [u8; 32] = [0x42; 32];
fn build_state(db: &nomifun_db::Database) -> SystemRouterState {
let provider_repo = Arc::new(SqliteProviderRepository::new(db.pool().clone()));
let http_client = reqwest::Client::new();
let system_config_repo = Arc::new(SqliteSystemConfigRepository::new(db.pool().clone()));
let branding_config_repo = Arc::new(SqliteBrandingConfigRepository::new(db.pool().clone()));
SystemRouterState {
settings_service: SettingsService::new(Arc::new(SqliteSettingsRepository::new(db.pool().clone()))),
client_pref_service: ClientPrefService::new(Arc::new(SqliteClientPreferenceRepository::new(db.pool().clone()))),
provider_service: ProviderService::new(provider_repo.clone(), TEST_KEY),
model_fetch_service: ModelFetchService::new(provider_repo, TEST_KEY, http_client.clone()),
protocol_detection_service: ProtocolDetectionService::new(http_client.clone()),
version_check_service: VersionCheckService::new(http_client, "0.1.0".to_owned()),
data_dir: std::env::temp_dir(),
system_config_repo,
branding_config_repo,
}
}
async fn setup() -> (axum::Router, nomifun_db::Database) {
let db = init_database_memory().await.unwrap();
let state = build_state(&db);
(system_routes(state), db)
}
async fn create_provider(db: &nomifun_db::Database, platform: &str, base_url: &str, api_key: &str) -> String {
let repo = SqliteProviderRepository::new(db.pool().clone());
let encrypted = encrypt_string(api_key, &TEST_KEY).unwrap();
let row = repo
.create(CreateProviderParams {
id: None,
platform,
name: "Test Provider",
base_url,
api_key_encrypted: &encrypted,
models: "[]",
enabled: true,
capabilities: "[]",
context_limit: None,
model_protocols: None,
model_enabled: None,
model_health: None,
bedrock_config: None,
is_full_url: false,
})
.await
.unwrap();
row.id
}
async fn body_json(resp: axum::response::Response) -> serde_json::Value {
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
serde_json::from_slice(&bytes).unwrap()
}
fn post_request(uri: &str, body: serde_json::Value) -> Request<Body> {
Request::builder()
.method("POST")
.uri(uri)
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap()
}
// ---------------------------------------------------------------------------
// Tests: basic flow
// ---------------------------------------------------------------------------
#[tokio::test]
async fn fetch_models_nonexistent_provider() {
let (router, _db) = setup().await;
let req = post_request("/api/providers/nonexistent/models", json!({"try_fix": false}));
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn fetch_models_vertex_ai_hardcoded() {
let (router, db) = setup().await;
let id = create_provider(&db, "vertex-ai", "https://unused", "fake-key").await;
let req = post_request(&format!("/api/providers/{id}/models"), json!({"try_fix": false}));
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["success"], true);
let models = json["data"]["models"].as_array().unwrap();
assert_eq!(models.len(), 2);
assert_eq!(models[0], "gemini-2.5-pro");
assert_eq!(models[1], "gemini-2.5-flash");
assert!(json["data"].get("fixed_base_url").is_none());
}
#[tokio::test]
async fn fetch_models_minimax_hardcoded() {
let (router, db) = setup().await;
let id = create_provider(&db, "minimax", "https://unused", "fake-key").await;
let req = post_request(&format!("/api/providers/{id}/models"), json!({}));
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"]["models"].as_array().unwrap().len(), 3);
}
// ---------------------------------------------------------------------------
// Tests: OpenAI-compatible with mock
// ---------------------------------------------------------------------------
#[tokio::test]
async fn fetch_models_openai_compatible_success() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/models"))
.and(header("Authorization", "Bearer test-api-key"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [
{"id": "gpt-4o", "object": "model"},
{"id": "gpt-4o-mini", "object": "model"}
]
})))
.mount(&mock_server)
.await;
let (router, db) = setup().await;
let id = create_provider(&db, "openai", &mock_server.uri(), "test-api-key").await;
let req = post_request(&format!("/api/providers/{id}/models"), json!({"try_fix": false}));
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
let models = json["data"]["models"].as_array().unwrap();
assert_eq!(models.len(), 2);
assert_eq!(models[0], "gpt-4o");
assert_eq!(models[1], "gpt-4o-mini");
}
#[tokio::test]
async fn fetch_models_openai_remote_error() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/models"))
.respond_with(ResponseTemplate::new(500))
.mount(&mock_server)
.await;
let (router, db) = setup().await;
let id = create_provider(&db, "openai", &mock_server.uri(), "test-key").await;
let req = post_request(&format!("/api/providers/{id}/models"), json!({"try_fix": false}));
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_GATEWAY);
}
// ---------------------------------------------------------------------------
// Tests: Anthropic with mock
// ---------------------------------------------------------------------------
#[tokio::test]
async fn fetch_models_anthropic_success() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.and(header("x-api-key", "sk-ant-test"))
.and(header("anthropic-version", "2023-06-01"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [
{"id": "claude-sonnet-4-20250514", "type": "model"},
{"id": "claude-opus-4-20250514", "type": "model"}
],
"has_more": false
})))
.mount(&mock_server)
.await;
let (router, db) = setup().await;
let id = create_provider(&db, "anthropic", &mock_server.uri(), "sk-ant-test").await;
let req = post_request(&format!("/api/providers/{id}/models"), json!({}));
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
let models = json["data"]["models"].as_array().unwrap();
assert_eq!(models.len(), 2);
assert_eq!(models[0], "claude-sonnet-4-20250514");
}
#[tokio::test]
async fn fetch_models_anthropic_fallback_on_error() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.respond_with(ResponseTemplate::new(401))
.mount(&mock_server)
.await;
let (router, db) = setup().await;
let id = create_provider(&db, "anthropic", &mock_server.uri(), "bad-key").await;
let req = post_request(&format!("/api/providers/{id}/models"), json!({}));
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
let models = json["data"]["models"].as_array().unwrap();
// Should return fallback models
assert!(!models.is_empty());
}
// ---------------------------------------------------------------------------
// Tests: Gemini with mock
// ---------------------------------------------------------------------------
#[tokio::test]
async fn fetch_models_gemini_success() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1beta/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"models": [
{"name": "models/gemini-2.5-pro", "displayName": "Gemini 2.5 Pro"},
{"name": "models/gemini-2.5-flash", "displayName": "Gemini 2.5 Flash"}
]
})))
.mount(&mock_server)
.await;
let (router, db) = setup().await;
let id = create_provider(&db, "gemini", &mock_server.uri(), "gemini-key").await;
let req = post_request(&format!("/api/providers/{id}/models"), json!({}));
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
let models = json["data"]["models"].as_array().unwrap();
assert_eq!(models.len(), 2);
// models/ prefix should be stripped
assert_eq!(models[0], "gemini-2.5-pro");
assert_eq!(models[1], "gemini-2.5-flash");
}
#[tokio::test]
async fn fetch_models_gemini_fallback_on_error() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1beta/models"))
.respond_with(ResponseTemplate::new(403))
.mount(&mock_server)
.await;
let (router, db) = setup().await;
let id = create_provider(&db, "gemini", &mock_server.uri(), "bad-key").await;
let req = post_request(&format!("/api/providers/{id}/models"), json!({}));
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
let models = json["data"]["models"].as_array().unwrap();
assert!(!models.is_empty());
}
// ---------------------------------------------------------------------------
// Tests: new-api (OpenAI with /v1 enforcement)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn fetch_models_new_api_adds_v1() {
let mock_server = MockServer::start().await;
// new-api should ensure /v1 is in the path
Mock::given(method("GET"))
.and(path("/v1/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [{"id": "model-a"}]
})))
.mount(&mock_server)
.await;
let (router, db) = setup().await;
// base_url without /v1
let id = create_provider(&db, "new-api", &mock_server.uri(), "test-key").await;
let req = post_request(&format!("/api/providers/{id}/models"), json!({}));
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
let models = json["data"]["models"].as_array().unwrap();
assert_eq!(models.len(), 1);
assert_eq!(models[0], "model-a");
}
// ---------------------------------------------------------------------------
// Tests: URL auto-fix
// ---------------------------------------------------------------------------
#[tokio::test]
async fn fetch_models_url_auto_fix_success() {
let mock_server = MockServer::start().await;
// Original /models should fail
Mock::given(method("GET"))
.and(path("/models"))
.respond_with(ResponseTemplate::new(404))
.mount(&mock_server)
.await;
// /v1/models should succeed
Mock::given(method("GET"))
.and(path("/v1/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [{"id": "fixed-model"}]
})))
.mount(&mock_server)
.await;
let (router, db) = setup().await;
let id = create_provider(&db, "openai", &mock_server.uri(), "test-key").await;
let req = post_request(&format!("/api/providers/{id}/models"), json!({"try_fix": true}));
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
let models = json["data"]["models"].as_array().unwrap();
assert_eq!(models.len(), 1);
assert_eq!(models[0], "fixed-model");
// fixedBaseUrl should be present
assert!(json["data"]["fixed_base_url"].as_str().unwrap().contains("/v1"));
}
#[tokio::test]
async fn fetch_models_url_auto_fix_not_triggered_when_success() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [{"id": "original-model"}]
})))
.mount(&mock_server)
.await;
let (router, db) = setup().await;
let id = create_provider(&db, "openai", &mock_server.uri(), "test-key").await;
let req = post_request(&format!("/api/providers/{id}/models"), json!({"try_fix": true}));
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
let models = json["data"]["models"].as_array().unwrap();
assert_eq!(models[0], "original-model");
// fixedBaseUrl should NOT be present since original URL worked
assert!(json["data"].get("fixed_base_url").is_none());
}
#[tokio::test]
async fn fetch_models_url_auto_fix_not_for_anthropic() {
let mock_server = MockServer::start().await;
// Anthropic API fails
Mock::given(method("GET"))
.and(path("/v1/models"))
.respond_with(ResponseTemplate::new(401))
.mount(&mock_server)
.await;
let (router, db) = setup().await;
let id = create_provider(&db, "anthropic", &mock_server.uri(), "bad-key").await;
// Even with tryFix=true, Anthropic should use fallback, not URL fix
let req = post_request(&format!("/api/providers/{id}/models"), json!({"try_fix": true}));
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
// Should be fallback models, no fixedBaseUrl
assert!(json["data"].get("fixed_base_url").is_none());
}
// ---------------------------------------------------------------------------
// Tests: anonymous fetch-models (T1b)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn fetch_models_anonymous_returns_models_for_valid_input() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/models"))
.and(header("Authorization", "Bearer sk-anon"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [{"id": "gpt-4o"}, {"id": "gpt-4o-mini"}]
})))
.mount(&mock_server)
.await;
let (router, _db) = setup().await;
let req = post_request(
"/api/providers/fetch-models",
json!({
"platform": "openai",
"base_url": mock_server.uri(),
"api_key": "sk-anon"
}),
);
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
let models = json["data"]["models"].as_array().unwrap();
assert_eq!(models.len(), 2);
assert_eq!(models[0], "gpt-4o");
}
#[tokio::test]
async fn fetch_models_anonymous_rejects_empty_api_key() {
let (router, _db) = setup().await;
let req = post_request(
"/api/providers/fetch-models",
json!({
"platform": "openai",
"base_url": "https://api.openai.com",
"api_key": " "
}),
);
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn fetch_models_anonymous_minimax_hardcoded() {
// Hardcoded-list platforms work without hitting any remote endpoint.
let (router, _db) = setup().await;
let req = post_request(
"/api/providers/fetch-models",
json!({
"platform": "minimax",
"base_url": "https://unused",
"api_key": "fake"
}),
);
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"]["models"].as_array().unwrap().len(), 3);
}
#[tokio::test]
async fn fetch_models_route_literal_segment_beats_id_shadowing() {
// Regression guard for axum route ordering: POST /api/providers/fetch-models
// must NOT be matched as /api/providers/{id}/models with id="fetch-models".
// If shadowing occurred we'd either hit the by-id handler (→ 404 provider
// not found) or get a routing error. Hitting the anonymous handler returns
// 400 for missing required fields, which is the right signature.
let (router, _db) = setup().await;
let req = post_request("/api/providers/fetch-models", json!({}));
let resp = router.oneshot(req).await.unwrap();
// Missing "platform" / "base_url" / "api_key" — anonymous handler
// rejects with 400 via JSON deserialization failure, not 404 from the
// by-id handler.
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
@@ -0,0 +1,603 @@
//! Black-box integration tests for protocol detection endpoint.
//!
//! Uses `wiremock` to mock remote API responses and tests the full
//! HTTP flow: request -> handler -> remote API probe -> response.
use std::sync::Arc;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use serde_json::json;
use tower::ServiceExt;
use wiremock::matchers::{header, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
use nomifun_db::{
SqliteBrandingConfigRepository, SqliteClientPreferenceRepository, SqliteProviderRepository,
SqliteSettingsRepository, SqliteSystemConfigRepository, init_database_memory,
};
use nomifun_system::{
ClientPrefService, ModelFetchService, ProtocolDetectionService, ProviderService, SettingsService,
SystemRouterState, VersionCheckService, system_routes,
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const TEST_KEY: [u8; 32] = [0x42; 32];
fn build_state(db: &nomifun_db::Database) -> SystemRouterState {
let provider_repo = Arc::new(SqliteProviderRepository::new(db.pool().clone()));
let http_client = reqwest::Client::new();
let system_config_repo = Arc::new(SqliteSystemConfigRepository::new(db.pool().clone()));
let branding_config_repo = Arc::new(SqliteBrandingConfigRepository::new(db.pool().clone()));
SystemRouterState {
settings_service: SettingsService::new(Arc::new(SqliteSettingsRepository::new(db.pool().clone()))),
client_pref_service: ClientPrefService::new(Arc::new(SqliteClientPreferenceRepository::new(db.pool().clone()))),
provider_service: ProviderService::new(provider_repo.clone(), TEST_KEY),
model_fetch_service: ModelFetchService::new(provider_repo, TEST_KEY, http_client.clone()),
protocol_detection_service: ProtocolDetectionService::new(http_client.clone()),
version_check_service: VersionCheckService::new(http_client, "0.1.0".to_owned()),
data_dir: std::env::temp_dir(),
system_config_repo,
branding_config_repo,
}
}
async fn setup() -> axum::Router {
let db = init_database_memory().await.unwrap();
let state = build_state(&db);
system_routes(state)
}
async fn detect(router: &axum::Router, body: serde_json::Value) -> (StatusCode, serde_json::Value) {
let req = Request::builder()
.method("POST")
.uri("/api/providers/detect-protocol")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
let status = resp.status();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
(status, json)
}
// ---------------------------------------------------------------------------
// Input validation
// ---------------------------------------------------------------------------
#[tokio::test]
async fn detect_protocol_missing_base_url() {
let router = setup().await;
let (status, json) = detect(&router, json!({"api_key": "sk-xxx"})).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(!json["success"].as_bool().unwrap());
}
#[tokio::test]
async fn detect_protocol_missing_api_key() {
let router = setup().await;
let (status, json) = detect(&router, json!({"base_url": "https://example.com"})).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(!json["success"].as_bool().unwrap());
}
#[tokio::test]
async fn detect_protocol_empty_base_url() {
let router = setup().await;
let (status, _) = detect(&router, json!({"base_url": " ", "api_key": "sk-test"})).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn detect_protocol_empty_api_key() {
let router = setup().await;
let (status, _) = detect(&router, json!({"base_url": "https://example.com", "api_key": " "})).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
// ---------------------------------------------------------------------------
// OpenAI detection with mock server
// ---------------------------------------------------------------------------
#[tokio::test]
async fn detect_openai_protocol_success() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/models"))
.and(header("Authorization", "Bearer sk-test-key"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [
{"id": "gpt-4"},
{"id": "gpt-3.5-turbo"}
]
})))
.mount(&mock_server)
.await;
let router = setup().await;
let (status, json) = detect(
&router,
json!({
"base_url": mock_server.uri(),
"api_key": "sk-test-key"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert!(json["success"].as_bool().unwrap());
let data = &json["data"];
assert_eq!(data["protocol"], "openai");
assert!(data["confidence"].as_u64().unwrap() > 0);
let models = data["models"].as_array().unwrap();
assert!(models.contains(&json!("gpt-4")));
assert!(models.contains(&json!("gpt-3.5-turbo")));
assert_eq!(data["suggestion"]["type"], "none");
}
// ---------------------------------------------------------------------------
// Anthropic detection with mock server
// ---------------------------------------------------------------------------
#[tokio::test]
async fn detect_anthropic_protocol_success() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.and(header("x-api-key", "sk-ant-test-key"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [
{"id": "claude-sonnet-4-20250514"},
{"id": "claude-opus-4-20250514"}
]
})))
.mount(&mock_server)
.await;
let router = setup().await;
let (status, json) = detect(
&router,
json!({
"base_url": mock_server.uri(),
"api_key": "sk-ant-test-key",
"preferred_protocol": "anthropic"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
let data = &json["data"];
assert_eq!(data["protocol"], "anthropic");
assert!(data["confidence"].as_u64().unwrap() >= 90);
let models = data["models"].as_array().unwrap();
assert!(models.contains(&json!("claude-sonnet-4-20250514")));
}
// ---------------------------------------------------------------------------
// Gemini detection with mock server
// ---------------------------------------------------------------------------
#[tokio::test]
async fn detect_gemini_protocol_success() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1beta/models"))
.and(query_param("key", "AIzaSyBtest"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"models": [
{"name": "models/gemini-2.5-pro"},
{"name": "models/gemini-2.5-flash"}
]
})))
.mount(&mock_server)
.await;
let router = setup().await;
let (status, json) = detect(
&router,
json!({
"base_url": mock_server.uri(),
"api_key": "AIzaSyBtest",
"preferred_protocol": "gemini"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
let data = &json["data"];
assert_eq!(data["protocol"], "gemini");
assert!(data["confidence"].as_u64().unwrap() >= 80);
let models = data["models"].as_array().unwrap();
// Prefix stripped
assert!(models.contains(&json!("gemini-2.5-pro")));
assert!(models.contains(&json!("gemini-2.5-flash")));
}
// ---------------------------------------------------------------------------
// All protocols fail → unknown
// ---------------------------------------------------------------------------
#[tokio::test]
async fn detect_protocol_all_fail_returns_unknown() {
let mock_server = MockServer::start().await;
// All endpoints return 404
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(404))
.mount(&mock_server)
.await;
let router = setup().await;
let (status, json) = detect(
&router,
json!({
"base_url": mock_server.uri(),
"api_key": "sk-unknown-key"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
let data = &json["data"];
assert_eq!(data["protocol"], "unknown");
assert_eq!(data["confidence"], 0);
assert_eq!(data["suggestion"]["type"], "check_key");
}
// ---------------------------------------------------------------------------
// Auth failure detection (401)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn detect_protocol_auth_failure_returns_check_key() {
let mock_server = MockServer::start().await;
// OpenAI endpoint returns 401
Mock::given(method("GET"))
.and(path("/models"))
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
"error": {"message": "Invalid API key"}
})))
.mount(&mock_server)
.await;
// /v1/models also returns 401
Mock::given(method("GET"))
.and(path("/v1/models"))
.respond_with(ResponseTemplate::new(401))
.mount(&mock_server)
.await;
let router = setup().await;
let (status, json) = detect(
&router,
json!({
"base_url": mock_server.uri(),
"api_key": "invalid-key"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
let data = &json["data"];
// Should detect a protocol (OpenAI likely) with check_key suggestion
assert!(data["confidence"].as_u64().unwrap() > 0);
assert_eq!(data["suggestion"]["type"], "check_key");
}
// ---------------------------------------------------------------------------
// URL fix variant detection
// ---------------------------------------------------------------------------
#[tokio::test]
async fn detect_openai_via_v1_variant() {
let mock_server = MockServer::start().await;
// /models returns 404
Mock::given(method("GET"))
.and(path("/models"))
.respond_with(ResponseTemplate::new(404))
.mount(&mock_server)
.await;
// /v1/models returns success
Mock::given(method("GET"))
.and(path("/v1/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [{"id": "gpt-4"}]
})))
.mount(&mock_server)
.await;
let router = setup().await;
let (status, json) = detect(
&router,
json!({
"base_url": mock_server.uri(),
"api_key": "sk-test"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
let data = &json["data"];
assert_eq!(data["protocol"], "openai");
// fixed_base_url should be set when using /v1 variant
assert!(data["fixed_base_url"].is_string());
assert!(data["fixed_base_url"].as_str().unwrap().ends_with("/v1"));
}
// ---------------------------------------------------------------------------
// Multi-key testing
// ---------------------------------------------------------------------------
#[tokio::test]
async fn detect_with_multi_key_test() {
let mock_server = MockServer::start().await;
// /models returns success for any key
Mock::given(method("GET"))
.and(path("/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [{"id": "gpt-4"}]
})))
.mount(&mock_server)
.await;
let router = setup().await;
let (status, json) = detect(
&router,
json!({
"base_url": mock_server.uri(),
"api_key": "key1,key2,key3",
"test_all_keys": true
}),
)
.await;
assert_eq!(status, StatusCode::OK);
let data = &json["data"];
assert_eq!(data["protocol"], "openai");
let mkr = &data["multi_key_result"];
assert_eq!(mkr["total"], 3);
assert_eq!(mkr["details"].as_array().unwrap().len(), 3);
// All keys should be valid (mock returns 200 for any key)
assert_eq!(mkr["valid"], 3);
assert_eq!(mkr["invalid"], 0);
}
// ---------------------------------------------------------------------------
// Multi-key partial validity
// ---------------------------------------------------------------------------
#[tokio::test]
async fn detect_multi_key_partial_validity() {
let mock_server = MockServer::start().await;
// /models returns success only for "good-key"
Mock::given(method("GET"))
.and(path("/models"))
.and(header("Authorization", "Bearer good-key"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [{"id": "gpt-4"}]
})))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/models"))
.and(header("Authorization", "Bearer bad-key"))
.respond_with(ResponseTemplate::new(401))
.mount(&mock_server)
.await;
let router = setup().await;
let (status, json) = detect(
&router,
json!({
"base_url": mock_server.uri(),
"api_key": "good-key,bad-key",
"test_all_keys": true
}),
)
.await;
assert_eq!(status, StatusCode::OK);
let data = &json["data"];
let mkr = &data["multi_key_result"];
assert_eq!(mkr["total"], 2);
assert_eq!(mkr["valid"], 1);
assert_eq!(mkr["invalid"], 1);
// Verify details are sorted by index
let details = mkr["details"].as_array().unwrap();
assert_eq!(details[0]["index"], 0);
assert!(details[0]["valid"].as_bool().unwrap());
assert_eq!(details[1]["index"], 1);
assert!(!details[1]["valid"].as_bool().unwrap());
}
// ---------------------------------------------------------------------------
// Preferred protocol takes priority
// ---------------------------------------------------------------------------
#[tokio::test]
async fn preferred_protocol_tested_first() {
let mock_server = MockServer::start().await;
// Only Anthropic endpoint works
Mock::given(method("GET"))
.and(path("/v1/models"))
.and(header("x-api-key", "test-key"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [{"id": "claude-3"}]
})))
.mount(&mock_server)
.await;
let router = setup().await;
let (status, json) = detect(
&router,
json!({
"base_url": mock_server.uri(),
"api_key": "test-key",
"preferred_protocol": "anthropic"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
let data = &json["data"];
assert_eq!(data["protocol"], "anthropic");
}
// ---------------------------------------------------------------------------
// Single key → no multiKeyResult
// ---------------------------------------------------------------------------
#[tokio::test]
async fn single_key_no_multi_key_result() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [{"id": "gpt-4"}]
})))
.mount(&mock_server)
.await;
let router = setup().await;
let (status, json) = detect(
&router,
json!({
"base_url": mock_server.uri(),
"api_key": "sk-single-key",
"test_all_keys": true
}),
)
.await;
assert_eq!(status, StatusCode::OK);
let data = &json["data"];
// Single key → multi_key_result should be null
assert!(data["multi_key_result"].is_null());
}
// ---------------------------------------------------------------------------
// Timeout configuration
// ---------------------------------------------------------------------------
#[tokio::test]
async fn detect_protocol_with_custom_timeout() {
let mock_server = MockServer::start().await;
// Return success after a small delay (well within timeout)
Mock::given(method("GET"))
.and(path("/models"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(json!({"data": [{"id": "gpt-4"}]}))
.set_delay(std::time::Duration::from_millis(50)),
)
.mount(&mock_server)
.await;
let router = setup().await;
let (status, json) = detect(
&router,
json!({
"base_url": mock_server.uri(),
"api_key": "sk-test",
"timeout": 5000
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["data"]["protocol"], "openai");
}
// ---------------------------------------------------------------------------
// Multi-protocol detection (detectedProtocols)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn detect_multiple_protocols_reports_all_successes() {
let mock_server = MockServer::start().await;
// OpenAI probe: GET /models with Authorization header
Mock::given(method("GET"))
.and(path("/models"))
.and(header("Authorization", "Bearer multi-test-key"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [
{"id": "gpt-4"},
{"id": "gpt-3.5-turbo"}
]
})))
.mount(&mock_server)
.await;
// Anthropic probe: GET /v1/models with x-api-key header
Mock::given(method("GET"))
.and(path("/v1/models"))
.and(header("x-api-key", "multi-test-key"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [
{"id": "claude-sonnet-4-20250514"},
{"id": "claude-opus-4-20250514"}
]
})))
.mount(&mock_server)
.await;
let router = setup().await;
let (status, json) = detect(
&router,
json!({
"base_url": mock_server.uri(),
"api_key": "multi-test-key"
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert!(json["success"].as_bool().unwrap());
let data = &json["data"];
// Primary result is still the first successful probe in priority order (OpenAI)
assert_eq!(data["protocol"], "openai");
assert_eq!(data["success"], true);
// detectedProtocols contains both OpenAI and Anthropic
let detected = data["detectedProtocols"].as_array().unwrap();
assert!(
detected.len() >= 2,
"Expected at least 2 detected protocols, got {}",
detected.len()
);
let protocols: Vec<&str> = detected.iter().map(|d| d["protocol"].as_str().unwrap()).collect();
assert!(protocols.contains(&"openai"), "Expected openai in detectedProtocols");
assert!(
protocols.contains(&"anthropic"),
"Expected anthropic in detectedProtocols"
);
// Each entry should have confidence > 0
for entry in detected {
assert!(entry["confidence"].as_u64().unwrap() > 0);
}
}
@@ -0,0 +1,473 @@
//! Black-box integration tests for provider CRUD routes.
//!
//! Tests exercise the HTTP layer (request -> handler -> response) via
//! `tower::ServiceExt::oneshot`, without authentication middleware.
//! Auth protection is verified at the app-level E2E tests (task 3.9).
use std::sync::Arc;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use serde_json::json;
use tower::ServiceExt;
use nomifun_db::{
SqliteBrandingConfigRepository, SqliteClientPreferenceRepository, SqliteProviderRepository,
SqliteSettingsRepository, SqliteSystemConfigRepository, init_database_memory,
};
use nomifun_system::{
ClientPrefService, ModelFetchService, ProtocolDetectionService, ProviderService, SettingsService,
SystemRouterState, VersionCheckService, system_routes,
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const TEST_ENCRYPTION_KEY: [u8; 32] = [0x42; 32];
fn build_state(db: &nomifun_db::Database) -> SystemRouterState {
let provider_repo = Arc::new(SqliteProviderRepository::new(db.pool().clone()));
let http_client = reqwest::Client::new();
let system_config_repo = Arc::new(SqliteSystemConfigRepository::new(db.pool().clone()));
let branding_config_repo = Arc::new(SqliteBrandingConfigRepository::new(db.pool().clone()));
SystemRouterState {
settings_service: SettingsService::new(Arc::new(SqliteSettingsRepository::new(db.pool().clone()))),
client_pref_service: ClientPrefService::new(Arc::new(SqliteClientPreferenceRepository::new(db.pool().clone()))),
provider_service: ProviderService::new(provider_repo.clone(), TEST_ENCRYPTION_KEY),
model_fetch_service: ModelFetchService::new(provider_repo, TEST_ENCRYPTION_KEY, http_client.clone()),
protocol_detection_service: ProtocolDetectionService::new(http_client.clone()),
version_check_service: VersionCheckService::new(http_client, "0.1.0".to_owned()),
data_dir: std::env::temp_dir(),
system_config_repo,
branding_config_repo,
}
}
async fn setup() -> (axum::Router, nomifun_db::Database) {
let db = init_database_memory().await.unwrap();
let state = build_state(&db);
(system_routes(state), db)
}
async fn body_json(resp: axum::response::Response) -> serde_json::Value {
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
serde_json::from_slice(&bytes).unwrap()
}
fn get_request(uri: &str) -> Request<Body> {
Request::builder().method("GET").uri(uri).body(Body::empty()).unwrap()
}
fn json_request(method: &str, uri: &str, body: serde_json::Value) -> Request<Body> {
Request::builder()
.method(method)
.uri(uri)
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap()
}
fn delete_request(uri: &str) -> Request<Body> {
Request::builder()
.method("DELETE")
.uri(uri)
.body(Body::empty())
.unwrap()
}
fn sample_create_body() -> serde_json::Value {
json!({
"platform": "anthropic",
"name": "Anthropic",
"base_url": "https://api.anthropic.com",
"api_key": "sk-ant-api03-test1234"
})
}
/// Create a provider and return (response_json, provider_id, fresh_router).
async fn create_one(db: &nomifun_db::Database) -> (serde_json::Value, String) {
let app = system_routes(build_state(db));
let resp = app
.oneshot(json_request("POST", "/api/providers", sample_create_body()))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let json = body_json(resp).await;
let id = json["data"]["id"].as_str().unwrap().to_string();
(json, id)
}
// ===========================================================================
// GET /api/providers — list
// ===========================================================================
#[tokio::test]
async fn list_providers_empty() {
let (app, _db) = setup().await;
let resp = app.oneshot(get_request("/api/providers")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["success"], true);
assert_eq!(json["data"], json!([]));
}
#[tokio::test]
async fn list_providers_returns_plaintext_api_key() {
let (_app, db) = setup().await;
create_one(&db).await;
let app2 = system_routes(build_state(&db));
let resp = app2.oneshot(get_request("/api/providers")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
let providers = json["data"].as_array().unwrap();
assert_eq!(providers.len(), 1);
let api_key = providers[0]["api_key"].as_str().unwrap();
// Pre-launch: api_key is returned plaintext on the wire (encrypted at rest).
assert_eq!(api_key, "sk-ant-api03-test1234");
assert!(!api_key.contains("***"));
}
// ===========================================================================
// POST /api/providers — create
// ===========================================================================
#[tokio::test]
async fn create_provider_success() {
let (app, _db) = setup().await;
let resp = app
.oneshot(json_request("POST", "/api/providers", sample_create_body()))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let json = body_json(resp).await;
assert_eq!(json["success"], true);
let data = &json["data"];
assert!(data["id"].as_str().unwrap().starts_with("prov_"));
assert_eq!(data["platform"], "anthropic");
assert_eq!(data["name"], "Anthropic");
assert_eq!(data["base_url"], "https://api.anthropic.com");
assert_eq!(data["api_key"], "sk-ant-api03-test1234");
assert!(data["enabled"].as_bool().unwrap());
assert!(data["models"].as_array().unwrap().is_empty());
assert!(data["created_at"].as_i64().unwrap() > 0);
assert!(data["updated_at"].as_i64().unwrap() > 0);
}
#[tokio::test]
async fn create_provider_with_supplied_id() {
let (app, _db) = setup().await;
let body = json!({
"id": "caller-id-123",
"platform": "openai",
"name": "OpenAI",
"base_url": "https://api.openai.com",
"api_key": "sk-test",
"model_enabled": {"gpt-4": true, "gpt-3.5": false}
});
let resp = app.oneshot(json_request("POST", "/api/providers", body)).await.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let json = body_json(resp).await;
let data = &json["data"];
assert_eq!(data["id"], "caller-id-123");
assert_eq!(data["api_key"], "sk-test");
assert_eq!(data["model_enabled"]["gpt-4"], true);
assert_eq!(data["model_enabled"]["gpt-3.5"], false);
}
#[tokio::test]
async fn create_provider_with_duplicate_id_returns_conflict() {
let (_app, db) = setup().await;
let body = json!({
"id": "dup-id",
"platform": "openai",
"name": "OpenAI",
"base_url": "https://api.openai.com",
"api_key": "sk-test"
});
let app1 = system_routes(build_state(&db));
let resp = app1
.oneshot(json_request("POST", "/api/providers", body.clone()))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let app2 = system_routes(build_state(&db));
let resp = app2
.oneshot(json_request("POST", "/api/providers", body))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CONFLICT);
}
#[tokio::test]
async fn create_provider_with_invalid_id_rejected() {
let (app, _db) = setup().await;
let body = json!({
"id": "bad/slash",
"platform": "openai",
"name": "OpenAI",
"base_url": "https://api.openai.com",
"api_key": "sk-test"
});
let resp = app.oneshot(json_request("POST", "/api/providers", body)).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn create_provider_with_optional_fields() {
let (app, _db) = setup().await;
let body = json!({
"platform": "bedrock",
"name": "AWS Bedrock",
"base_url": "https://bedrock.us-east-1.amazonaws.com",
"api_key": "test-key-abcd",
"models": ["anthropic.claude-3-sonnet"],
"enabled": false,
"capabilities": [{"type": "text"}, {"type": "vision", "is_user_selected": true}],
"context_limit": 200000,
"bedrock_config": {
"auth_method": "accessKey",
"region": "us-east-1",
"access_key_id": "AKIA...",
"secret_access_key": "secret"
}
});
let resp = app.oneshot(json_request("POST", "/api/providers", body)).await.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let json = body_json(resp).await;
let data = &json["data"];
assert!(!data["enabled"].as_bool().unwrap());
assert_eq!(data["models"].as_array().unwrap().len(), 1);
assert_eq!(data["capabilities"].as_array().unwrap().len(), 2);
assert_eq!(data["context_limit"], 200000);
assert_eq!(data["bedrock_config"]["auth_method"], "accessKey");
assert_eq!(data["bedrock_config"]["region"], "us-east-1");
}
#[tokio::test]
async fn create_provider_missing_platform() {
let (app, _db) = setup().await;
let body = json!({
"name": "Test",
"base_url": "https://api.example.com",
"api_key": "sk-test"
});
let resp = app.oneshot(json_request("POST", "/api/providers", body)).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn create_provider_missing_name() {
let (app, _db) = setup().await;
let body = json!({
"platform": "openai",
"base_url": "https://api.example.com",
"api_key": "sk-test"
});
let resp = app.oneshot(json_request("POST", "/api/providers", body)).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn create_provider_missing_base_url() {
let (app, _db) = setup().await;
let body = json!({
"platform": "openai",
"name": "Test",
"api_key": "sk-test"
});
let resp = app.oneshot(json_request("POST", "/api/providers", body)).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn create_provider_missing_api_key() {
let (app, _db) = setup().await;
let body = json!({
"platform": "openai",
"name": "Test",
"base_url": "https://api.example.com"
});
let resp = app.oneshot(json_request("POST", "/api/providers", body)).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn create_provider_invalid_url() {
let (app, _db) = setup().await;
let body = json!({
"platform": "openai",
"name": "Test",
"base_url": "not-a-url",
"api_key": "sk-test"
});
let resp = app.oneshot(json_request("POST", "/api/providers", body)).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
// ===========================================================================
// PUT /api/providers/{id} — update
// ===========================================================================
#[tokio::test]
async fn update_provider_name() {
let (_app, db) = setup().await;
let (_, id) = create_one(&db).await;
let app2 = system_routes(build_state(&db));
let resp = app2
.oneshot(json_request(
"PUT",
&format!("/api/providers/{id}"),
json!({"name": "New Name"}),
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"]["name"], "New Name");
assert_eq!(json["data"]["platform"], "anthropic");
}
#[tokio::test]
async fn update_provider_api_key_returns_plaintext() {
let (_app, db) = setup().await;
let (_, id) = create_one(&db).await;
let app2 = system_routes(build_state(&db));
let resp = app2
.oneshot(json_request(
"PUT",
&format!("/api/providers/{id}"),
json!({"api_key": "new-key-abcdefgh"}),
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
let api_key = json["data"]["api_key"].as_str().unwrap();
assert_eq!(api_key, "new-key-abcdefgh");
}
#[tokio::test]
async fn update_provider_nonexistent() {
let (app, _db) = setup().await;
let resp = app
.oneshot(json_request("PUT", "/api/providers/nonexistent", json!({"name": "X"})))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
// ===========================================================================
// DELETE /api/providers/{id}
// ===========================================================================
#[tokio::test]
async fn delete_provider_success() {
let (_app, db) = setup().await;
let (_, id) = create_one(&db).await;
let app2 = system_routes(build_state(&db));
let resp = app2
.oneshot(delete_request(&format!("/api/providers/{id}")))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["success"], true);
}
#[tokio::test]
async fn delete_provider_then_list_excludes_deleted() {
let (_app, db) = setup().await;
let (_, id) = create_one(&db).await;
let app2 = system_routes(build_state(&db));
let resp = app2
.oneshot(delete_request(&format!("/api/providers/{id}")))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let app3 = system_routes(build_state(&db));
let resp = app3.oneshot(get_request("/api/providers")).await.unwrap();
let json = body_json(resp).await;
assert_eq!(json["data"], json!([]));
}
#[tokio::test]
async fn delete_provider_nonexistent() {
let (app, _db) = setup().await;
let resp = app.oneshot(delete_request("/api/providers/nonexistent")).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
// ===========================================================================
// Full CRUD flow
// ===========================================================================
#[tokio::test]
async fn full_crud_flow() {
let (_app, db) = setup().await;
// 1. Create
let (create_json, id) = create_one(&db).await;
assert_eq!(create_json["data"]["platform"], "anthropic");
// 2. List — should contain one
let app2 = system_routes(build_state(&db));
let resp = app2.oneshot(get_request("/api/providers")).await.unwrap();
let list_json = body_json(resp).await;
assert_eq!(list_json["data"].as_array().unwrap().len(), 1);
// 3. Update
let app3 = system_routes(build_state(&db));
let resp = app3
.oneshot(json_request(
"PUT",
&format!("/api/providers/{id}"),
json!({"name": "Updated", "enabled": false}),
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let update_json = body_json(resp).await;
assert_eq!(update_json["data"]["name"], "Updated");
assert!(!update_json["data"]["enabled"].as_bool().unwrap());
// 4. Verify update via list
let app4 = system_routes(build_state(&db));
let resp = app4.oneshot(get_request("/api/providers")).await.unwrap();
let list_json = body_json(resp).await;
assert_eq!(list_json["data"][0]["name"], "Updated");
// 5. Delete
let app5 = system_routes(build_state(&db));
let resp = app5
.oneshot(delete_request(&format!("/api/providers/{id}")))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// 6. Verify deleted
let app6 = system_routes(build_state(&db));
let resp = app6.oneshot(get_request("/api/providers")).await.unwrap();
let list_json = body_json(resp).await;
assert_eq!(list_json["data"], json!([]));
}
@@ -0,0 +1,357 @@
//! Black-box integration tests for system settings routes.
//!
//! Tests exercise the HTTP layer (request → handler → response) via
//! `tower::ServiceExt::oneshot`, without authentication middleware.
//! Auth protection is verified at the app-level E2E tests (task 3.9).
use std::sync::Arc;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
use nomifun_db::{
SqliteBrandingConfigRepository, SqliteClientPreferenceRepository, SqliteProviderRepository,
SqliteSettingsRepository, SqliteSystemConfigRepository, init_database_memory,
};
use nomifun_system::{
ClientPrefService, ModelFetchService, ProtocolDetectionService, ProviderService, SettingsService,
SystemRouterState, VersionCheckService, settings_routes,
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const TEST_ENCRYPTION_KEY: [u8; 32] = [0x42; 32];
fn build_state(db: &nomifun_db::Database) -> SystemRouterState {
let provider_repo = Arc::new(SqliteProviderRepository::new(db.pool().clone()));
let http_client = reqwest::Client::new();
let system_config_repo = Arc::new(SqliteSystemConfigRepository::new(db.pool().clone()));
let branding_config_repo = Arc::new(SqliteBrandingConfigRepository::new(db.pool().clone()));
SystemRouterState {
settings_service: SettingsService::new(Arc::new(SqliteSettingsRepository::new(db.pool().clone()))),
client_pref_service: ClientPrefService::new(Arc::new(SqliteClientPreferenceRepository::new(db.pool().clone()))),
provider_service: ProviderService::new(provider_repo.clone(), TEST_ENCRYPTION_KEY),
model_fetch_service: ModelFetchService::new(provider_repo, TEST_ENCRYPTION_KEY, http_client.clone()),
protocol_detection_service: ProtocolDetectionService::new(http_client.clone()),
version_check_service: VersionCheckService::new(http_client, "0.1.0".to_owned()),
data_dir: std::env::temp_dir(),
system_config_repo,
branding_config_repo,
}
}
async fn setup() -> (axum::Router, nomifun_db::Database) {
let db = init_database_memory().await.unwrap();
let state = build_state(&db);
(settings_routes(state), db)
}
async fn body_json(resp: axum::response::Response) -> serde_json::Value {
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
serde_json::from_slice(&bytes).unwrap()
}
fn get_request(uri: &str) -> Request<Body> {
Request::builder().method("GET").uri(uri).body(Body::empty()).unwrap()
}
fn json_request(method: &str, uri: &str, body: serde_json::Value) -> Request<Body> {
Request::builder()
.method(method)
.uri(uri)
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap()
}
// ===========================================================================
// System Settings (GET/PATCH /api/settings)
// ===========================================================================
#[tokio::test]
async fn get_settings_default_values() {
let (app, _db) = setup().await;
let resp = app.oneshot(get_request("/api/settings")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["success"], true);
assert_eq!(json["data"]["language"], "en-US");
assert_eq!(json["data"]["notification_enabled"], true);
assert_eq!(json["data"]["cron_notification_enabled"], false);
assert_eq!(json["data"]["command_queue_enabled"], false);
assert_eq!(json["data"]["save_upload_to_workspace"], false);
}
#[tokio::test]
async fn patch_settings_single_field() {
let (app, _db) = setup().await;
let req = json_request("PATCH", "/api/settings", serde_json::json!({"language": "zh-CN"}));
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["success"], true);
assert_eq!(json["data"]["language"], "zh-CN");
// Others remain default
assert_eq!(json["data"]["notification_enabled"], true);
assert_eq!(json["data"]["cron_notification_enabled"], false);
}
#[tokio::test]
async fn patch_settings_multiple_fields() {
let (app, _db) = setup().await;
let req = json_request(
"PATCH",
"/api/settings",
serde_json::json!({
"notification_enabled": false,
"command_queue_enabled": true
}),
);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"]["notification_enabled"], false);
assert_eq!(json["data"]["command_queue_enabled"], true);
assert_eq!(json["data"]["language"], "en-US");
}
#[tokio::test]
async fn patch_settings_empty_body() {
let (app, _db) = setup().await;
let req = json_request("PATCH", "/api/settings", serde_json::json!({}));
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"]["language"], "en-US");
}
#[tokio::test]
async fn patch_settings_unsupported_language_rejected() {
let (app, _db) = setup().await;
let req = json_request(
"PATCH",
"/api/settings",
serde_json::json!({"language": "invalid-lang"}),
);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn patch_settings_type_error_rejected() {
let (app, _db) = setup().await;
let req = json_request(
"PATCH",
"/api/settings",
serde_json::json!({"notification_enabled": "yes"}),
);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn patch_settings_unknown_field_ignored() {
let (app, _db) = setup().await;
let req = json_request("PATCH", "/api/settings", serde_json::json!({"unknown_field": 123}));
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"]["language"], "en-US");
}
#[tokio::test]
async fn patch_then_get_reflects_changes() {
let (app, db) = setup().await;
// First PATCH to update
let req = json_request(
"PATCH",
"/api/settings",
serde_json::json!({"language": "zh-CN", "save_upload_to_workspace": true}),
);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// Build a fresh router with the same DB to GET
let app2 = settings_routes(build_state(&db));
let resp = app2.oneshot(get_request("/api/settings")).await.unwrap();
let json = body_json(resp).await;
assert_eq!(json["data"]["language"], "zh-CN");
assert_eq!(json["data"]["save_upload_to_workspace"], true);
}
// ===========================================================================
// Client Preferences (GET/PUT /api/settings/client)
// ===========================================================================
#[tokio::test]
async fn get_client_prefs_empty() {
let (app, _db) = setup().await;
let resp = app.oneshot(get_request("/api/settings/client")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["success"], true);
assert_eq!(json["data"], serde_json::json!({}));
}
#[tokio::test]
async fn put_and_get_boolean_value() {
let (app, db) = setup().await;
let req = json_request(
"PUT",
"/api/settings/client",
serde_json::json!({"system.closeToTray": true}),
);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let app2 = settings_routes(build_state(&db));
let resp = app2.oneshot(get_request("/api/settings/client")).await.unwrap();
let json = body_json(resp).await;
assert_eq!(json["data"]["system.closeToTray"], true);
}
#[tokio::test]
async fn put_and_get_number_value() {
let (app, db) = setup().await;
let req = json_request("PUT", "/api/settings/client", serde_json::json!({"companion.size": 360}));
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let app2 = settings_routes(build_state(&db));
let resp = app2.oneshot(get_request("/api/settings/client")).await.unwrap();
let json = body_json(resp).await;
assert_eq!(json["data"]["companion.size"], 360);
}
#[tokio::test]
async fn put_and_get_string_value() {
let (app, db) = setup().await;
let req = json_request("PUT", "/api/settings/client", serde_json::json!({"theme": "dark"}));
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let app2 = settings_routes(build_state(&db));
let resp = app2.oneshot(get_request("/api/settings/client")).await.unwrap();
let json = body_json(resp).await;
assert_eq!(json["data"]["theme"], "dark");
}
#[tokio::test]
async fn put_null_deletes_key() {
let (app, db) = setup().await;
// First write a value
let req = json_request("PUT", "/api/settings/client", serde_json::json!({"theme": "dark"}));
app.oneshot(req).await.unwrap();
// Then delete it with null
let app2 = settings_routes(build_state(&db));
let req = json_request("PUT", "/api/settings/client", serde_json::json!({"theme": null}));
app2.oneshot(req).await.unwrap();
// Verify it's gone
let app3 = settings_routes(build_state(&db));
let resp = app3.oneshot(get_request("/api/settings/client")).await.unwrap();
let json = body_json(resp).await;
assert_eq!(json["data"], serde_json::json!({}));
}
#[tokio::test]
async fn put_batch_write() {
let (app, db) = setup().await;
let req = json_request(
"PUT",
"/api/settings/client",
serde_json::json!({"a": 1, "b": "x", "c": true}),
);
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let app2 = settings_routes(build_state(&db));
let resp = app2.oneshot(get_request("/api/settings/client")).await.unwrap();
let json = body_json(resp).await;
assert_eq!(json["data"]["a"], 1);
assert_eq!(json["data"]["b"], "x");
assert_eq!(json["data"]["c"], true);
}
#[tokio::test]
async fn get_client_prefs_with_keys_filter() {
let (app, db) = setup().await;
// Write several values
let req = json_request(
"PUT",
"/api/settings/client",
serde_json::json!({"a": 1, "b": 2, "c": 3}),
);
app.oneshot(req).await.unwrap();
// Fetch with key filter
let app2 = settings_routes(build_state(&db));
let resp = app2
.oneshot(get_request("/api/settings/client?keys=a,c"))
.await
.unwrap();
let json = body_json(resp).await;
let data = json["data"].as_object().unwrap();
assert_eq!(data.len(), 2);
assert_eq!(data["a"], 1);
assert_eq!(data["c"], 3);
}
#[tokio::test]
async fn put_overwrite_existing_value() {
let (app, db) = setup().await;
let req = json_request("PUT", "/api/settings/client", serde_json::json!({"k": "v1"}));
app.oneshot(req).await.unwrap();
let app2 = settings_routes(build_state(&db));
let req = json_request("PUT", "/api/settings/client", serde_json::json!({"k": "v2"}));
app2.oneshot(req).await.unwrap();
let app3 = settings_routes(build_state(&db));
let resp = app3.oneshot(get_request("/api/settings/client")).await.unwrap();
let json = body_json(resp).await;
assert_eq!(json["data"]["k"], "v2");
}
#[tokio::test]
async fn put_empty_key_rejected() {
let (app, _db) = setup().await;
let req = json_request("PUT", "/api/settings/client", serde_json::json!({"": true}));
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn put_long_key_rejected() {
let (app, _db) = setup().await;
let long_key = "x".repeat(256);
let req = json_request("PUT", "/api/settings/client", serde_json::json!({long_key: true}));
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
@@ -0,0 +1,469 @@
//! Black-box integration tests for system info and version check routes.
//!
//! System info tests verify the GET /api/system/info endpoint returns
//! correct platform/arch values and non-empty directory paths.
//!
//! Version check tests use `wiremock` to mock the GitHub Releases API
//! and verify the POST /api/system/check-update endpoint.
use std::sync::Arc;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use serde_json::json;
use tower::ServiceExt;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use nomifun_db::{
SqliteBrandingConfigRepository, SqliteClientPreferenceRepository, SqliteProviderRepository,
SqliteSettingsRepository, SqliteSystemConfigRepository, init_database_memory,
};
use nomifun_system::{
ClientPrefService, ModelFetchService, ProtocolDetectionService, ProviderService, SettingsService,
SystemRouterState, VersionCheckService, system_routes,
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const TEST_KEY: [u8; 32] = [0x42; 32];
fn build_state(db: &nomifun_db::Database, version_check_service: VersionCheckService) -> SystemRouterState {
let provider_repo = Arc::new(SqliteProviderRepository::new(db.pool().clone()));
let http_client = reqwest::Client::new();
let system_config_repo = Arc::new(SqliteSystemConfigRepository::new(db.pool().clone()));
let branding_config_repo = Arc::new(SqliteBrandingConfigRepository::new(db.pool().clone()));
SystemRouterState {
settings_service: SettingsService::new(Arc::new(SqliteSettingsRepository::new(db.pool().clone()))),
client_pref_service: ClientPrefService::new(Arc::new(SqliteClientPreferenceRepository::new(db.pool().clone()))),
provider_service: ProviderService::new(provider_repo.clone(), TEST_KEY),
model_fetch_service: ModelFetchService::new(provider_repo, TEST_KEY, http_client.clone()),
protocol_detection_service: ProtocolDetectionService::new(http_client),
version_check_service,
data_dir: std::env::temp_dir(),
system_config_repo,
branding_config_repo,
}
}
async fn setup() -> axum::Router {
let db = init_database_memory().await.unwrap();
let http_client = reqwest::Client::new();
let vcs = VersionCheckService::new(http_client, "1.0.0".to_owned());
let state = build_state(&db, vcs);
system_routes(state)
}
async fn setup_with_mock(current_version: &str, mock_server: &MockServer) -> axum::Router {
let db = init_database_memory().await.unwrap();
let http_client = reqwest::Client::new();
let vcs = VersionCheckService::with_api_base(http_client, current_version.to_owned(), mock_server.uri());
let state = build_state(&db, vcs);
system_routes(state)
}
async fn body_json(resp: axum::response::Response) -> serde_json::Value {
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
serde_json::from_slice(&bytes).unwrap()
}
fn get_request(uri: &str) -> Request<Body> {
Request::builder().method("GET").uri(uri).body(Body::empty()).unwrap()
}
fn json_request(method_str: &str, uri: &str, body: serde_json::Value) -> Request<Body> {
Request::builder()
.method(method_str)
.uri(uri)
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap()
}
fn make_github_release(tag: &str, draft: bool, prerelease: bool, assets: Vec<serde_json::Value>) -> serde_json::Value {
json!({
"tag_name": tag,
"name": format!("Release {tag}"),
"body": "Release notes",
"html_url": format!("https://github.com/nomifun/nomifun-app/releases/tag/{tag}"),
"published_at": "2026-04-01T00:00:00Z",
"prerelease": prerelease,
"draft": draft,
"assets": assets,
})
}
fn make_github_asset(name: &str, size: u64) -> serde_json::Value {
json!({
"name": name,
"browser_download_url": format!("https://github.com/download/{name}"),
"size": size,
"content_type": "application/octet-stream",
})
}
// ===========================================================================
// GET /api/system/info
// ===========================================================================
#[tokio::test]
async fn test_system_info_returns_all_fields() {
let app = setup().await;
let resp = app.oneshot(get_request("/api/system/info")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["success"], true);
let data = &json["data"];
assert!(data["cache_dir"].as_str().is_some_and(|s| !s.is_empty()));
assert!(data["work_dir"].as_str().is_some_and(|s| !s.is_empty()));
assert!(data["log_dir"].as_str().is_some_and(|s| !s.is_empty()));
assert!(data["platform"].as_str().is_some_and(|s| !s.is_empty()));
assert!(data["arch"].as_str().is_some_and(|s| !s.is_empty()));
}
#[tokio::test]
async fn test_system_info_platform_is_known() {
let app = setup().await;
let resp = app.oneshot(get_request("/api/system/info")).await.unwrap();
let json = body_json(resp).await;
let platform = json["data"]["platform"].as_str().unwrap();
assert!(
["darwin", "win32", "linux"].contains(&platform),
"unexpected platform: {platform}"
);
}
#[tokio::test]
async fn test_system_info_arch_is_known() {
let app = setup().await;
let resp = app.oneshot(get_request("/api/system/info")).await.unwrap();
let json = body_json(resp).await;
let arch = json["data"]["arch"].as_str().unwrap();
assert!(["x64", "arm64"].contains(&arch), "unexpected arch: {arch}");
}
#[tokio::test]
async fn test_system_info_snake_case_keys() {
let app = setup().await;
let resp = app.oneshot(get_request("/api/system/info")).await.unwrap();
let json = body_json(resp).await;
let data = &json["data"];
assert!(data.get("cache_dir").is_some());
assert!(data.get("work_dir").is_some());
assert!(data.get("log_dir").is_some());
assert!(data.get("cacheDir").is_none());
assert!(data.get("workDir").is_none());
assert!(data.get("logDir").is_none());
}
// ===========================================================================
// POST /api/system/check-update — with wiremock
// ===========================================================================
#[tokio::test]
async fn test_check_update_has_new_version() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/nomifun/nomifun-app/releases"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
make_github_release(
"v2.0.0",
false,
false,
vec![
make_github_asset("app-2.0.0-darwin-arm64.dmg", 80_000_000),
make_github_asset("app-2.0.0-linux-x64.deb", 60_000_000),
]
),
make_github_release("v1.5.0", false, false, vec![]),
])))
.mount(&mock_server)
.await;
let app = setup_with_mock("1.0.0", &mock_server).await;
let resp = app
.oneshot(json_request("POST", "/api/system/check-update", json!({})))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["success"], true);
assert_eq!(json["data"]["current_version"], "1.0.0");
assert_eq!(json["data"]["update_available"], true);
let latest = &json["data"]["latest"];
assert_eq!(latest["tag_name"], "v2.0.0");
assert_eq!(latest["version"], "2.0.0");
assert!(!latest["assets"].as_array().unwrap().is_empty());
}
#[tokio::test]
async fn test_check_update_no_update_available() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/nomifun/nomifun-app/releases"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
make_github_release("v1.0.0", false, false, vec![]),
make_github_release("v0.9.0", false, false, vec![]),
])))
.mount(&mock_server)
.await;
let app = setup_with_mock("1.0.0", &mock_server).await;
let resp = app
.oneshot(json_request("POST", "/api/system/check-update", json!({})))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"]["update_available"], false);
assert!(json["data"].get("latest").is_none());
}
#[tokio::test]
async fn test_check_update_skips_draft() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/nomifun/nomifun-app/releases"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
make_github_release("v5.0.0", true, false, vec![]), // draft — skip
make_github_release("v2.0.0", false, false, vec![]),
])))
.mount(&mock_server)
.await;
let app = setup_with_mock("1.0.0", &mock_server).await;
let resp = app
.oneshot(json_request("POST", "/api/system/check-update", json!({})))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"]["update_available"], true);
assert_eq!(json["data"]["latest"]["version"], "2.0.0");
}
#[tokio::test]
async fn test_check_update_skips_prerelease_by_default() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/nomifun/nomifun-app/releases"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
make_github_release("v3.0.0-beta.1", false, true, vec![]),
make_github_release("v2.0.0", false, false, vec![]),
])))
.mount(&mock_server)
.await;
let app = setup_with_mock("1.0.0", &mock_server).await;
let resp = app
.oneshot(json_request(
"POST",
"/api/system/check-update",
json!({"include_prerelease": false}),
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"]["latest"]["version"], "2.0.0");
}
#[tokio::test]
async fn test_check_update_includes_prerelease_when_requested() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/nomifun/nomifun-app/releases"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
make_github_release("v3.0.0-beta.1", false, true, vec![]),
make_github_release("v2.0.0", false, false, vec![]),
])))
.mount(&mock_server)
.await;
let app = setup_with_mock("1.0.0", &mock_server).await;
let resp = app
.oneshot(json_request(
"POST",
"/api/system/check-update",
json!({"include_prerelease": true}),
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"]["latest"]["version"], "3.0.0-beta.1");
}
#[tokio::test]
async fn test_check_update_recommended_asset_matches_platform() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/nomifun/nomifun-app/releases"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([make_github_release(
"v2.0.0",
false,
false,
vec![
make_github_asset("app-2.0.0-win-x64.exe", 50_000_000),
make_github_asset("app-2.0.0-darwin-arm64.dmg", 80_000_000),
make_github_asset("app-2.0.0-linux-amd64.deb", 60_000_000),
]
),])))
.mount(&mock_server)
.await;
let app = setup_with_mock("1.0.0", &mock_server).await;
let resp = app
.oneshot(json_request("POST", "/api/system/check-update", json!({})))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
let recommended = &json["data"]["latest"]["recommended_asset"];
// On the CI runner's actual platform, the recommended asset should match
if recommended.is_object() {
let name = recommended["name"].as_str().unwrap();
// Verify it's one of the known assets
assert!(
name.contains("darwin") || name.contains("linux") || name.contains("win"),
"recommended asset should contain platform keyword: {name}"
);
}
}
#[tokio::test]
async fn test_check_update_github_api_error() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/nomifun/nomifun-app/releases"))
.respond_with(ResponseTemplate::new(500).set_body_string("Internal Server Error"))
.mount(&mock_server)
.await;
let app = setup_with_mock("1.0.0", &mock_server).await;
let resp = app
.oneshot(json_request("POST", "/api/system/check-update", json!({})))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_GATEWAY);
let json = body_json(resp).await;
assert_eq!(json["success"], false);
}
#[tokio::test]
async fn test_check_update_empty_releases() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/nomifun/nomifun-app/releases"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
.mount(&mock_server)
.await;
let app = setup_with_mock("1.0.0", &mock_server).await;
let resp = app
.oneshot(json_request("POST", "/api/system/check-update", json!({})))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"]["update_available"], false);
}
#[tokio::test]
async fn test_check_update_custom_repo() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/custom-org/custom-repo/releases"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([make_github_release(
"v3.0.0",
false,
false,
vec![]
),])))
.mount(&mock_server)
.await;
let app = setup_with_mock("1.0.0", &mock_server).await;
let resp = app
.oneshot(json_request(
"POST",
"/api/system/check-update",
json!({"repo": "custom-org/custom-repo"}),
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"]["update_available"], true);
assert_eq!(json["data"]["latest"]["version"], "3.0.0");
}
#[tokio::test]
async fn test_check_update_invalid_tag_ignored() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/nomifun/nomifun-app/releases"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
make_github_release("not-semver", false, false, vec![]),
make_github_release("v2.0.0", false, false, vec![]),
])))
.mount(&mock_server)
.await;
let app = setup_with_mock("1.0.0", &mock_server).await;
let resp = app
.oneshot(json_request("POST", "/api/system/check-update", json!({})))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let json = body_json(resp).await;
assert_eq!(json["data"]["latest"]["version"], "2.0.0");
}
#[tokio::test]
async fn test_check_update_response_format() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/repos/nomifun/nomifun-app/releases"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([make_github_release(
"v2.0.0",
false,
false,
vec![make_github_asset("app.dmg", 100_000),]
),])))
.mount(&mock_server)
.await;
let app = setup_with_mock("1.0.0", &mock_server).await;
let resp = app
.oneshot(json_request("POST", "/api/system/check-update", json!({})))
.await
.unwrap();
let json = body_json(resp).await;
let latest = &json["data"]["latest"];
// Verify snake_case serialization
assert!(latest.get("tag_name").is_some());
assert!(latest.get("html_url").is_some());
assert!(latest.get("published_at").is_some());
// Verify camelCase is NOT used
assert!(latest.get("tagName").is_none());
assert!(latest.get("htmlUrl").is_none());
}