Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user