mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
1312 lines
47 KiB
Rust
1312 lines
47 KiB
Rust
//! 验证运行设置的读写、数据库约束与快照发布
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
use chrono::{DateTime, TimeDelta, Utc};
|
|
use gateway_store::postgres::{
|
|
PgRuntimeSettingsRepository, RuntimeSettingsRepository, RuntimeSettingsUpdate,
|
|
};
|
|
|
|
use super::TestDatabase;
|
|
|
|
fn enabled_protection() -> gateway_core::account::AccountErrorProtection {
|
|
serde_json::from_value(serde_json::json!({"enabled":true,"rules":[{"id":"custom","name":"自定义错误","conditions":[{"field":"code","operator":"equals","values":["custom_rejection"],"ignoreCase":false}]}],"threshold":12,"windowSeconds":600,"durationSeconds":7200,"probeEnabled":true,"probeModel":null})).unwrap()
|
|
}
|
|
|
|
pub(super) fn settings_with_margin(refresh_margin_seconds: u64) -> RuntimeSettingsUpdate {
|
|
RuntimeSettingsUpdate {
|
|
request_profile_updates: BTreeMap::new(),
|
|
rotation_strategy: "smart".to_owned(),
|
|
model_mappings: BTreeMap::from([
|
|
("gpt-5.4".to_owned(), "gpt-5.5".to_owned()),
|
|
("grok-latest".to_owned(), "grok-4.5".to_owned()),
|
|
]),
|
|
values: gateway_admin::model::settings::RuntimeSettingsValues {
|
|
codex_privacy_policy: Default::default(),
|
|
request_location_enabled: false,
|
|
request_location: Default::default(),
|
|
refresh_margin_seconds,
|
|
refresh_concurrency: 2,
|
|
max_concurrent_per_account: 3,
|
|
max_waiting_per_key: 0,
|
|
max_waiting_per_account: 0,
|
|
key_queue_timeout_seconds: 30,
|
|
account_queue_timeout_seconds: 30,
|
|
switch_account_on_queue_failure: false,
|
|
max_retry_after_seconds: 240,
|
|
openai_guardian_reserved_concurrency: 0,
|
|
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
|
|
max_account_rotations: 3,
|
|
openai_session_affinity_ttl_hours: 24,
|
|
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
|
|
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
|
|
min_codex_desktop_version: None,
|
|
min_codex_cli_version: None,
|
|
usage_retention_days: 31,
|
|
ops_event_retention_days: 30,
|
|
audit_retention_days: 90,
|
|
account_error_protection: enabled_protection(),
|
|
account_warmup_enabled: false,
|
|
account_warmup_schedule_time: "08:00".to_owned(),
|
|
account_warmup_model: None,
|
|
reset_credit_lead_minutes: 10,
|
|
reset_credit_max_weekly_remaining_percent: None,
|
|
},
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_settings_keep_account_rotation_global() {
|
|
let settings = settings_with_margin(3_600);
|
|
assert!(settings.validate().is_ok());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn privacy_policy_defaults_off_and_round_trips_in_snapshot() {
|
|
use gateway_core::settings::privacy::*;
|
|
use gateway_store::postgres::{PgRuntimeSnapshotRepository, RuntimeSnapshotRepository};
|
|
let Some(database) = TestDatabase::create("privacy_policy").await else {
|
|
return;
|
|
};
|
|
let repository = PgRuntimeSettingsRepository::new(database.pool.clone());
|
|
let before = repository.load_runtime_settings().await.unwrap();
|
|
assert_eq!(
|
|
before.values.codex_privacy_policy,
|
|
CodexPrivacyPolicy::default()
|
|
);
|
|
let mut update = settings_with_margin(300);
|
|
update.values.codex_privacy_policy = CodexPrivacyPolicy {
|
|
enabled: true,
|
|
on_error: PrivacyFailureMode::RejectRequest,
|
|
rules: vec![PrivacyRule {
|
|
id: "remove-auth".into(),
|
|
name: "配置者控制".into(),
|
|
enabled: true,
|
|
steps: vec![PrivacyStep {
|
|
scope: PrivacyScope::RequestHeader,
|
|
selector: "authorization".into(),
|
|
action: PrivacyAction::RemoveField,
|
|
pattern: None,
|
|
replacement: String::new(),
|
|
value: serde_json::Value::Null,
|
|
replace_all: true,
|
|
case_insensitive: false,
|
|
multi_line: false,
|
|
}],
|
|
}],
|
|
};
|
|
let expected = update.values.codex_privacy_policy.clone();
|
|
repository.update_runtime_settings(update).await.unwrap();
|
|
let after = repository.load_runtime_settings().await.unwrap();
|
|
assert_eq!(after.values.codex_privacy_policy, expected);
|
|
assert!(after.config_revision > before.config_revision);
|
|
let snapshot = PgRuntimeSnapshotRepository::new(database.pool.clone())
|
|
.load_runtime_snapshot()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
serde_json::to_value(snapshot.settings).unwrap()["codex_privacy_policy"],
|
|
serde_json::to_value(expected).unwrap()
|
|
);
|
|
database.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn smart_settings_round_trip_publish_and_restore_database_defaults() {
|
|
use gateway_core::account::SmartSchedulingConfig;
|
|
use gateway_store::postgres::{PgRuntimeSnapshotRepository, RuntimeSnapshotRepository};
|
|
let Some(database) = TestDatabase::create("smart_config").await else {
|
|
return;
|
|
};
|
|
let repository = PgRuntimeSettingsRepository::new(database.pool.clone());
|
|
let before = repository.load_runtime_settings().await.unwrap();
|
|
assert_eq!(
|
|
before.values.smart_scheduling,
|
|
SmartSchedulingConfig::default()
|
|
);
|
|
let mut update = settings_with_margin(3600);
|
|
update.values.smart_scheduling =
|
|
SmartSchedulingConfig::new([0.0, 2.0, 1.0, 0.5, 1.2, 2.3]).unwrap();
|
|
let expected = update.values.smart_scheduling;
|
|
repository.update_runtime_settings(update).await.unwrap();
|
|
let reloaded = repository.load_runtime_settings().await.unwrap();
|
|
assert_eq!(reloaded.values.smart_scheduling, expected);
|
|
let snapshot = PgRuntimeSnapshotRepository::new(database.pool.clone())
|
|
.load_runtime_snapshot()
|
|
.await
|
|
.unwrap();
|
|
let snapshot_values = serde_json::to_value(&snapshot.settings).unwrap();
|
|
assert_eq!(
|
|
snapshot_values["smart_scheduling"],
|
|
serde_json::to_value(expected).unwrap()
|
|
);
|
|
assert!(snapshot.config_revision > before.config_revision);
|
|
sqlx::query("update runtime_settings set smart_scheduling_json = default where id = 1")
|
|
.execute(&database.pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
repository
|
|
.load_runtime_settings()
|
|
.await
|
|
.unwrap()
|
|
.values
|
|
.smart_scheduling,
|
|
SmartSchedulingConfig::default()
|
|
);
|
|
database.close().await;
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_settings_require_model_when_warmup_is_enabled() {
|
|
let settings = {
|
|
let runtime_settings_base = settings_with_margin(3_600);
|
|
RuntimeSettingsUpdate {
|
|
values: gateway_admin::model::settings::RuntimeSettingsValues {
|
|
account_warmup_enabled: true,
|
|
..runtime_settings_base.values
|
|
},
|
|
..runtime_settings_base
|
|
}
|
|
};
|
|
assert!(settings.validate().is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn warmup_settings_round_trip_with_database_constraint() {
|
|
let Some(database) = TestDatabase::create("warmup_settings").await else {
|
|
return;
|
|
};
|
|
let repository = PgRuntimeSettingsRepository::new(database.pool.clone());
|
|
let mut update = settings_with_margin(3_600);
|
|
update.values.account_warmup_enabled = true;
|
|
update.values.account_warmup_schedule_time = "08:00,13:00".to_owned();
|
|
update.values.account_warmup_model = Some("test-model".to_owned());
|
|
repository
|
|
.update_runtime_settings(update)
|
|
.await
|
|
.expect("save warmup settings");
|
|
|
|
let settings = repository
|
|
.load_runtime_settings()
|
|
.await
|
|
.expect("load warmup settings");
|
|
assert!(settings.values.account_warmup_enabled);
|
|
assert_eq!(settings.values.account_warmup_schedule_time, "08:00,13:00");
|
|
assert_eq!(
|
|
settings.values.account_warmup_model.as_deref(),
|
|
Some("test-model")
|
|
);
|
|
|
|
let error = sqlx::query("update runtime_settings set account_warmup_model = null where id = 1")
|
|
.execute(&database.pool)
|
|
.await
|
|
.expect_err("enabled warmup requires a model");
|
|
assert_eq!(
|
|
error
|
|
.as_database_error()
|
|
.and_then(|error| error.code())
|
|
.as_deref(),
|
|
Some("23514")
|
|
);
|
|
database.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unlimited_default_account_concurrency_round_trips_without_relaxing_other_limits() {
|
|
let Some(database) = TestDatabase::create("unlimited_default_concurrency").await else {
|
|
return;
|
|
};
|
|
let repository = PgRuntimeSettingsRepository::new(database.pool.clone());
|
|
let mut update = settings_with_margin(3_600);
|
|
update.values.max_concurrent_per_account = 0;
|
|
repository
|
|
.update_runtime_settings(update)
|
|
.await
|
|
.expect("persist unlimited default");
|
|
let settings = repository
|
|
.load_runtime_settings()
|
|
.await
|
|
.expect("read unlimited default");
|
|
assert_eq!(settings.values.max_concurrent_per_account, 0);
|
|
for statement in [
|
|
"update runtime_settings set max_concurrent_per_account = -1 where id = 1",
|
|
"update runtime_settings set refresh_concurrency = 0 where id = 1",
|
|
"update runtime_settings set refresh_margin_seconds = 0 where id = 1",
|
|
] {
|
|
let error = sqlx::query(statement)
|
|
.execute(&database.pool)
|
|
.await
|
|
.expect_err("constraint must reject invalid setting");
|
|
assert_eq!(
|
|
error
|
|
.as_database_error()
|
|
.and_then(|error| error.code())
|
|
.as_deref(),
|
|
Some("23514"),
|
|
"{statement}"
|
|
);
|
|
}
|
|
database.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refresh_margin_defaults_apply_to_initial_settings_and_database_resets() {
|
|
let Some(database) = TestDatabase::create("refresh_margin_default").await else {
|
|
return;
|
|
};
|
|
let repository = PgRuntimeSettingsRepository::new(database.pool.clone());
|
|
let settings = repository
|
|
.load_runtime_settings()
|
|
.await
|
|
.expect("load settings");
|
|
assert_eq!(settings.values.refresh_margin_seconds, 300);
|
|
sqlx::query("update runtime_settings set refresh_margin_seconds = 1800 where id = 1")
|
|
.execute(&database.pool)
|
|
.await
|
|
.unwrap();
|
|
sqlx::query("update runtime_settings set refresh_margin_seconds = default where id = 1")
|
|
.execute(&database.pool)
|
|
.await
|
|
.expect("reset refresh margin to the database default");
|
|
assert_eq!(
|
|
repository
|
|
.load_runtime_settings()
|
|
.await
|
|
.unwrap()
|
|
.values
|
|
.refresh_margin_seconds,
|
|
300
|
|
);
|
|
database.close().await;
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_settings_reject_invalid_model_mapping() {
|
|
let settings = RuntimeSettingsUpdate {
|
|
model_mappings: BTreeMap::from([("".to_owned(), "gpt-5.5".to_owned())]),
|
|
..settings_with_margin(3_600)
|
|
};
|
|
|
|
assert!(settings.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_settings_reject_out_of_range_error_protection() {
|
|
for update in [
|
|
{
|
|
let runtime_settings_base = settings_with_margin(3_600);
|
|
RuntimeSettingsUpdate {
|
|
values: gateway_admin::model::settings::RuntimeSettingsValues {
|
|
account_error_protection: gateway_core::account::AccountErrorProtection {
|
|
threshold: 1,
|
|
..enabled_protection()
|
|
},
|
|
..runtime_settings_base.values
|
|
},
|
|
..runtime_settings_base
|
|
}
|
|
},
|
|
{
|
|
let runtime_settings_base = settings_with_margin(3_600);
|
|
RuntimeSettingsUpdate {
|
|
values: gateway_admin::model::settings::RuntimeSettingsValues {
|
|
account_error_protection: gateway_core::account::AccountErrorProtection {
|
|
window_seconds: 59,
|
|
..enabled_protection()
|
|
},
|
|
..runtime_settings_base.values
|
|
},
|
|
..runtime_settings_base
|
|
}
|
|
},
|
|
{
|
|
let runtime_settings_base = settings_with_margin(3_600);
|
|
RuntimeSettingsUpdate {
|
|
values: gateway_admin::model::settings::RuntimeSettingsValues {
|
|
account_error_protection: gateway_core::account::AccountErrorProtection {
|
|
duration_seconds: 299,
|
|
..enabled_protection()
|
|
},
|
|
..runtime_settings_base.values
|
|
},
|
|
..runtime_settings_base
|
|
}
|
|
},
|
|
{
|
|
let runtime_settings_base = settings_with_margin(3_600);
|
|
RuntimeSettingsUpdate {
|
|
values: gateway_admin::model::settings::RuntimeSettingsValues {
|
|
account_error_protection: gateway_core::account::AccountErrorProtection {
|
|
probe_model: Some(" pad ".to_owned()),
|
|
..enabled_protection()
|
|
},
|
|
..runtime_settings_base.values
|
|
},
|
|
..runtime_settings_base
|
|
}
|
|
},
|
|
] {
|
|
assert!(update.validate().is_err());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_settings_reject_non_semver_client_min() {
|
|
let settings = {
|
|
let runtime_settings_base = settings_with_margin(3_600);
|
|
RuntimeSettingsUpdate {
|
|
values: gateway_admin::model::settings::RuntimeSettingsValues {
|
|
min_codex_cli_version: Some("v0.40.0".to_owned()),
|
|
..runtime_settings_base.values
|
|
},
|
|
..runtime_settings_base
|
|
}
|
|
};
|
|
|
|
assert!(settings.validate().is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refresh_margin_change_should_preserve_existing_account_refresh_facts() {
|
|
let Some(database) = TestDatabase::create("refresh_margin_reschedule").await else {
|
|
return;
|
|
};
|
|
let expires_at = timestamp_micros(Utc::now() + TimeDelta::hours(2));
|
|
insert_refreshable_account(
|
|
&database.pool,
|
|
"acct_refresh_margin_changed",
|
|
expires_at,
|
|
expires_at - TimeDelta::hours(1),
|
|
)
|
|
.await;
|
|
let repository = PgRuntimeSettingsRepository::new(database.pool.clone());
|
|
let before = account_refresh_facts(&database.pool, "acct_refresh_margin_changed").await;
|
|
|
|
repository
|
|
.update_runtime_settings(settings_with_margin(1_800))
|
|
.await
|
|
.expect("update refresh margin");
|
|
|
|
assert_eq!(
|
|
account_refresh_facts(&database.pool, "acct_refresh_margin_changed").await,
|
|
before
|
|
);
|
|
database.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn client_min_versions_should_round_trip_as_nullable_settings() {
|
|
let Some(database) = TestDatabase::create("client_min_versions").await else {
|
|
return;
|
|
};
|
|
let repository = PgRuntimeSettingsRepository::new(database.pool.clone());
|
|
let mut update = settings_with_margin(3_600);
|
|
update.values.min_codex_desktop_version = Some("26.825.6671".to_owned());
|
|
update.values.min_codex_cli_version = Some("0.40.0".to_owned());
|
|
|
|
repository
|
|
.update_runtime_settings(update)
|
|
.await
|
|
.expect("update client min versions");
|
|
let settings = repository
|
|
.load_runtime_settings()
|
|
.await
|
|
.expect("load client min versions");
|
|
|
|
assert_eq!(
|
|
settings.values.min_codex_desktop_version.as_deref(),
|
|
Some("26.825.6671")
|
|
);
|
|
assert_eq!(
|
|
settings.values.min_codex_cli_version.as_deref(),
|
|
Some("0.40.0")
|
|
);
|
|
database.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unchanged_refresh_margin_should_preserve_existing_account_refresh_facts() {
|
|
let Some(database) = TestDatabase::create("refresh_margin_unchanged").await else {
|
|
return;
|
|
};
|
|
let expires_at = timestamp_micros(Utc::now() + TimeDelta::hours(2));
|
|
let retry_at = timestamp_micros(Utc::now() + TimeDelta::minutes(5));
|
|
insert_refreshable_account(
|
|
&database.pool,
|
|
"acct_refresh_margin_unchanged",
|
|
expires_at,
|
|
retry_at,
|
|
)
|
|
.await;
|
|
let repository = PgRuntimeSettingsRepository::new(database.pool.clone());
|
|
let before = account_refresh_facts(&database.pool, "acct_refresh_margin_unchanged").await;
|
|
|
|
repository
|
|
.update_runtime_settings(settings_with_margin(3_600))
|
|
.await
|
|
.expect("update unrelated runtime settings");
|
|
|
|
assert_eq!(
|
|
account_refresh_facts(&database.pool, "acct_refresh_margin_unchanged").await,
|
|
before
|
|
);
|
|
database.close().await;
|
|
}
|
|
|
|
async fn insert_refreshable_account(
|
|
pool: &sqlx::PgPool,
|
|
account_id: &str,
|
|
expires_at: DateTime<Utc>,
|
|
next_refresh_at: DateTime<Utc>,
|
|
) {
|
|
sqlx::query(
|
|
"insert into provider_accounts (
|
|
id, provider_kind, name, upstream_user_id, authentication_kind,
|
|
provider_credentials_json, has_refresh_token, access_token_expires_at,
|
|
next_refresh_at, credential_state, credential_observed_at, created_at, updated_at
|
|
) values ($1, 'openai', $1, $1, 'oauth', '{}'::jsonb, true, $2, $3,
|
|
'ready', now(), now(), now())",
|
|
)
|
|
.bind(account_id)
|
|
.bind(expires_at)
|
|
.bind(next_refresh_at)
|
|
.execute(pool)
|
|
.await
|
|
.expect("insert refreshable account");
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
struct AccountRefreshFacts {
|
|
access_token_expires_at: DateTime<Utc>,
|
|
next_refresh_at: DateTime<Utc>,
|
|
updated_at: DateTime<Utc>,
|
|
credential_revision: i64,
|
|
}
|
|
|
|
async fn account_refresh_facts(pool: &sqlx::PgPool, account_id: &str) -> AccountRefreshFacts {
|
|
let (access_token_expires_at, next_refresh_at, updated_at, credential_revision) =
|
|
sqlx::query_as(
|
|
"select access_token_expires_at, next_refresh_at, updated_at, credential_revision
|
|
from provider_accounts
|
|
where id = $1",
|
|
)
|
|
.bind(account_id)
|
|
.fetch_one(pool)
|
|
.await
|
|
.expect("load account refresh facts");
|
|
AccountRefreshFacts {
|
|
access_token_expires_at,
|
|
next_refresh_at,
|
|
updated_at,
|
|
credential_revision,
|
|
}
|
|
}
|
|
|
|
fn timestamp_micros(value: DateTime<Utc>) -> DateTime<Utc> {
|
|
DateTime::from_timestamp_micros(value.timestamp_micros()).expect("valid test timestamp")
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn retry_after_limit_defaults_and_updates_reach_the_runtime_snapshot() {
|
|
use gateway_store::postgres::{PgRuntimeSnapshotRepository, RuntimeSnapshotRepository};
|
|
let Some(database) = TestDatabase::create("retry_after_limit").await else {
|
|
return;
|
|
};
|
|
let repository = PgRuntimeSettingsRepository::new(database.pool.clone());
|
|
assert_eq!(
|
|
repository
|
|
.load_runtime_settings()
|
|
.await
|
|
.unwrap()
|
|
.values
|
|
.max_retry_after_seconds,
|
|
240
|
|
);
|
|
for limit in [60, 0, u32::MAX] {
|
|
let mut update = settings_with_margin(300);
|
|
update.values.max_retry_after_seconds = limit;
|
|
repository.update_runtime_settings(update).await.unwrap();
|
|
assert_eq!(
|
|
repository
|
|
.load_runtime_settings()
|
|
.await
|
|
.unwrap()
|
|
.values
|
|
.max_retry_after_seconds,
|
|
limit
|
|
);
|
|
let snapshot = PgRuntimeSnapshotRepository::new(database.pool.clone())
|
|
.load_runtime_snapshot()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
serde_json::to_value(snapshot.settings).unwrap()["max_retry_after_seconds"],
|
|
limit
|
|
);
|
|
}
|
|
for invalid in [-1_i64, i64::from(u32::MAX) + 1] {
|
|
assert!(
|
|
sqlx::query("update runtime_settings set max_retry_after_seconds = $1 where id = 1")
|
|
.bind(invalid)
|
|
.execute(&database.pool)
|
|
.await
|
|
.is_err()
|
|
);
|
|
}
|
|
database.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn concurrency_queue_settings_round_trip_into_the_runtime_snapshot() {
|
|
use gateway_store::postgres::{PgRuntimeSnapshotRepository, RuntimeSnapshotRepository};
|
|
let Some(database) = TestDatabase::create("queue_settings").await else {
|
|
return;
|
|
};
|
|
let repository = PgRuntimeSettingsRepository::new(database.pool.clone());
|
|
let before = repository.load_runtime_settings().await.unwrap();
|
|
assert_eq!(
|
|
(
|
|
before.values.max_concurrent_per_account,
|
|
before.values.max_waiting_per_key,
|
|
before.values.max_waiting_per_account,
|
|
before.values.key_queue_timeout_seconds,
|
|
before.values.account_queue_timeout_seconds,
|
|
before.values.switch_account_on_queue_failure,
|
|
before.values.openai_guardian_reserved_concurrency,
|
|
),
|
|
(3, 10, 10, 30, 30, false, 5)
|
|
);
|
|
let mut update = settings_with_margin(1234);
|
|
update.values.max_concurrent_per_account = 7;
|
|
update.values.refresh_concurrency = 12;
|
|
update.values.max_waiting_per_key = 5;
|
|
update.values.max_waiting_per_account = 7;
|
|
update.values.key_queue_timeout_seconds = 12;
|
|
update.values.account_queue_timeout_seconds = 5;
|
|
update.values.switch_account_on_queue_failure = true;
|
|
update.values.openai_guardian_reserved_concurrency = 1;
|
|
repository.update_runtime_settings(update).await.unwrap();
|
|
let settings = repository.load_runtime_settings().await.unwrap();
|
|
assert_eq!(
|
|
(
|
|
settings.values.max_concurrent_per_account,
|
|
settings.values.refresh_concurrency,
|
|
settings.values.refresh_margin_seconds,
|
|
settings.values.max_waiting_per_key,
|
|
settings.values.max_waiting_per_account,
|
|
settings.values.key_queue_timeout_seconds,
|
|
settings.values.account_queue_timeout_seconds,
|
|
settings.values.switch_account_on_queue_failure,
|
|
settings.values.openai_guardian_reserved_concurrency,
|
|
),
|
|
(7, 12, 1234, 5, 7, 12, 5, true, 1)
|
|
);
|
|
let snapshot = PgRuntimeSnapshotRepository::new(database.pool.clone())
|
|
.load_runtime_snapshot()
|
|
.await
|
|
.unwrap();
|
|
let snapshot_values = serde_json::to_value(&snapshot.settings).unwrap();
|
|
assert_eq!(
|
|
(
|
|
snapshot_values["max_concurrent_per_account"]
|
|
.as_u64()
|
|
.unwrap(),
|
|
snapshot_values["max_waiting_per_key"].as_u64().unwrap(),
|
|
snapshot_values["max_waiting_per_account"].as_u64().unwrap(),
|
|
snapshot_values["key_queue_timeout_seconds"]
|
|
.as_u64()
|
|
.unwrap(),
|
|
snapshot_values["switch_account_on_queue_failure"]
|
|
.as_bool()
|
|
.unwrap(),
|
|
snapshot_values["account_queue_timeout_seconds"]
|
|
.as_u64()
|
|
.unwrap(),
|
|
snapshot_values["openai_guardian_reserved_concurrency"]
|
|
.as_u64()
|
|
.unwrap(),
|
|
),
|
|
(7, 5, 7, 12, true, 5, 1)
|
|
);
|
|
assert!(snapshot.config_revision > before.config_revision);
|
|
database.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn request_location_defaults_and_updates_reach_the_runtime_snapshot() {
|
|
use gateway_core::account::RequestLocation;
|
|
use gateway_store::postgres::{PgRuntimeSnapshotRepository, RuntimeSnapshotRepository};
|
|
let Some(database) = TestDatabase::create("global_request_location").await else {
|
|
return;
|
|
};
|
|
let repository = PgRuntimeSettingsRepository::new(database.pool.clone());
|
|
let before = repository.load_runtime_settings().await.unwrap();
|
|
assert_eq!(before.values.request_location, RequestLocation::default());
|
|
assert!(!before.values.request_location_enabled);
|
|
let mut update = settings_with_margin(3600);
|
|
update.values.request_location = serde_json::from_value(serde_json::json!({"country":"JP", "region":" Tokyo ", "city":" Tokyo ", "timezone":"Asia/Tokyo"})).unwrap();
|
|
update.values.request_location_enabled = true;
|
|
update.values.account_error_protection.threshold = 17;
|
|
update.values.account_error_protection.window_seconds = 900;
|
|
update.values.account_error_protection.duration_seconds = 3_600;
|
|
update.values.account_error_protection.probe_model = Some("gpt-5.5".to_owned());
|
|
let expected = update.values.request_location.clone().normalized().unwrap();
|
|
let mut disabled = update.clone();
|
|
disabled.values.request_location = expected.clone();
|
|
disabled.values.request_location_enabled = false;
|
|
repository.update_runtime_settings(update).await.unwrap();
|
|
let settings = repository.load_runtime_settings().await.unwrap();
|
|
let snapshot = PgRuntimeSnapshotRepository::new(database.pool.clone())
|
|
.load_runtime_snapshot()
|
|
.await
|
|
.unwrap();
|
|
let snapshot_values = serde_json::to_value(&snapshot.settings).unwrap();
|
|
assert_eq!(settings.values.request_location, expected);
|
|
assert_eq!(
|
|
snapshot_values["request_location"],
|
|
serde_json::to_value(&expected).unwrap()
|
|
);
|
|
assert!(settings.values.request_location_enabled);
|
|
assert!(
|
|
snapshot_values["request_location_enabled"]
|
|
.as_bool()
|
|
.unwrap()
|
|
);
|
|
assert!(snapshot.config_revision > before.config_revision);
|
|
repository.update_runtime_settings(disabled).await.unwrap();
|
|
let disabled_settings = repository.load_runtime_settings().await.unwrap();
|
|
assert!(!disabled_settings.values.request_location_enabled);
|
|
assert_eq!(disabled_settings.values.request_location, expected);
|
|
let disabled_snapshot = PgRuntimeSnapshotRepository::new(database.pool.clone())
|
|
.load_runtime_snapshot()
|
|
.await
|
|
.unwrap();
|
|
let disabled_snapshot_values = serde_json::to_value(&disabled_snapshot.settings).unwrap();
|
|
assert!(
|
|
!disabled_snapshot_values["request_location_enabled"]
|
|
.as_bool()
|
|
.unwrap()
|
|
);
|
|
assert_eq!(
|
|
disabled_snapshot_values["request_location"],
|
|
serde_json::to_value(&expected).unwrap()
|
|
);
|
|
// 位置开关与自动冻结共用设置写入,切换位置不能覆盖冻结参数
|
|
for saved in [&settings, &disabled_settings] {
|
|
assert!(saved.values.account_error_protection.enabled);
|
|
assert_eq!(saved.values.account_error_protection.threshold, 17);
|
|
assert_eq!(saved.values.account_error_protection.window_seconds, 900);
|
|
assert_eq!(
|
|
saved.values.account_error_protection.duration_seconds,
|
|
3_600
|
|
);
|
|
assert!(saved.values.account_error_protection.probe_enabled);
|
|
assert_eq!(
|
|
saved.values.account_error_protection.probe_model.as_deref(),
|
|
Some("gpt-5.5")
|
|
);
|
|
}
|
|
assert!(disabled_snapshot.config_revision > snapshot.config_revision);
|
|
for invalid in [
|
|
serde_json::json!(null),
|
|
serde_json::json!({}),
|
|
serde_json::json!({"country":"US", "region":"Ohio", "city":null, "timezone":"America/New_York"}),
|
|
] {
|
|
assert!(
|
|
sqlx::query("update runtime_settings set request_location_json = $1 where id = 1")
|
|
.bind(sqlx::types::Json(invalid))
|
|
.execute(&database.pool)
|
|
.await
|
|
.is_err()
|
|
);
|
|
}
|
|
assert_eq!(
|
|
repository
|
|
.load_runtime_settings()
|
|
.await
|
|
.unwrap()
|
|
.values
|
|
.request_location,
|
|
expected
|
|
);
|
|
database.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn error_protection_defaults_off_and_explicit_opt_in_round_trips() {
|
|
let Some(database) = TestDatabase::create("freeze_opt_in").await else {
|
|
return;
|
|
};
|
|
let repository = PgRuntimeSettingsRepository::new(database.pool.clone());
|
|
assert!(
|
|
!repository
|
|
.load_runtime_settings()
|
|
.await
|
|
.expect("default settings")
|
|
.values
|
|
.account_error_protection
|
|
.enabled
|
|
);
|
|
repository
|
|
.update_runtime_settings(settings_with_margin(3_600))
|
|
.await
|
|
.expect("explicit opt-in");
|
|
assert!(
|
|
repository
|
|
.load_runtime_settings()
|
|
.await
|
|
.expect("settings")
|
|
.values
|
|
.account_error_protection
|
|
.enabled
|
|
);
|
|
database.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn decompression_setting_should_persist_and_reach_snapshot_facts() {
|
|
use gateway_store::postgres::{PgRuntimeSnapshotRepository, RuntimeSnapshotRepository};
|
|
let Some(database) = TestDatabase::create("decompression_settings").await else {
|
|
return;
|
|
};
|
|
let repository = PgRuntimeSettingsRepository::new(database.pool.clone());
|
|
let before = repository.load_runtime_settings().await.unwrap();
|
|
assert_eq!(
|
|
before.values.responses_max_decompressed_body_bytes,
|
|
64 * 1024 * 1024
|
|
);
|
|
let mut update = settings_with_margin(3600);
|
|
update.values.responses_max_decompressed_body_bytes = 128 * 1024 * 1024;
|
|
repository.update_runtime_settings(update).await.unwrap();
|
|
let reloaded = PgRuntimeSettingsRepository::new(database.pool.clone())
|
|
.load_runtime_settings()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
reloaded.values.responses_max_decompressed_body_bytes,
|
|
128 * 1024 * 1024
|
|
);
|
|
let snapshot = PgRuntimeSnapshotRepository::new(database.pool.clone())
|
|
.load_runtime_snapshot()
|
|
.await
|
|
.unwrap();
|
|
let snapshot_values = serde_json::to_value(&snapshot.settings).unwrap();
|
|
assert_eq!(
|
|
snapshot_values["responses_max_decompressed_body_bytes"],
|
|
reloaded.values.responses_max_decompressed_body_bytes
|
|
);
|
|
assert!(snapshot.config_revision > before.config_revision);
|
|
for invalid in [0, u64::MAX] {
|
|
let mut update = settings_with_margin(3600);
|
|
update.values.responses_max_decompressed_body_bytes = invalid;
|
|
assert!(repository.update_runtime_settings(update).await.is_err());
|
|
assert_eq!(
|
|
repository
|
|
.load_runtime_settings()
|
|
.await
|
|
.unwrap()
|
|
.config_revision,
|
|
reloaded.config_revision
|
|
);
|
|
}
|
|
database.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn request_profile_initialization_is_idempotent_and_old_updates_preserve_it() {
|
|
use gateway_core::{
|
|
account::OpaqueProviderData, provider_ports::ProviderRuntimePolicyPort,
|
|
routing::ProviderKind,
|
|
};
|
|
let Some(database) = TestDatabase::create("request_profiles").await else {
|
|
return;
|
|
};
|
|
let repository = PgRuntimeSettingsRepository::new(database.pool.clone());
|
|
let provider = ProviderKind::new("openai").unwrap();
|
|
let document = |name| {
|
|
OpaqueProviderData::new(
|
|
serde_json::json!({"marker":name})
|
|
.as_object()
|
|
.unwrap()
|
|
.clone(),
|
|
)
|
|
};
|
|
let initial = document("imported");
|
|
assert_eq!(
|
|
repository
|
|
.initialize_request_profile(&provider, initial.clone())
|
|
.await
|
|
.unwrap(),
|
|
initial
|
|
);
|
|
let revision = repository
|
|
.load_runtime_settings()
|
|
.await
|
|
.unwrap()
|
|
.config_revision;
|
|
assert_eq!(
|
|
repository
|
|
.initialize_request_profile(&provider, document("ignored"))
|
|
.await
|
|
.unwrap(),
|
|
initial
|
|
);
|
|
assert_eq!(
|
|
repository
|
|
.load_runtime_settings()
|
|
.await
|
|
.unwrap()
|
|
.config_revision,
|
|
revision
|
|
);
|
|
repository
|
|
.update_runtime_settings(settings_with_margin(3600))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
repository
|
|
.load_runtime_settings()
|
|
.await
|
|
.unwrap()
|
|
.request_profiles
|
|
.get(&provider),
|
|
Some(&initial)
|
|
);
|
|
let mut update = settings_with_margin(3600);
|
|
update
|
|
.request_profile_updates
|
|
.insert(provider.clone(), Some(document("edited")));
|
|
repository.update_runtime_settings(update).await.unwrap();
|
|
assert_eq!(
|
|
repository
|
|
.initialize_request_profile(&provider, document("old-yaml"))
|
|
.await
|
|
.unwrap(),
|
|
document("edited")
|
|
);
|
|
database.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn request_profile_deletion_is_explicit_and_preserves_other_profiles() {
|
|
use gateway_core::{
|
|
account::OpaqueProviderData, provider_ports::ProviderRuntimePolicyPort,
|
|
routing::ProviderKind,
|
|
};
|
|
let Some(database) = TestDatabase::create("request_profile_deletion").await else {
|
|
return;
|
|
};
|
|
let repository = PgRuntimeSettingsRepository::new(database.pool.clone());
|
|
let kept = ProviderKind::new("provider.kept").unwrap();
|
|
let removed = ProviderKind::new("provider.removed").unwrap();
|
|
let profile = |marker: &str| {
|
|
OpaqueProviderData::new(
|
|
serde_json::json!({"marker":marker})
|
|
.as_object()
|
|
.unwrap()
|
|
.clone(),
|
|
)
|
|
};
|
|
repository
|
|
.initialize_request_profile(&kept, profile("kept"))
|
|
.await
|
|
.unwrap();
|
|
repository
|
|
.initialize_request_profile(&removed, profile("removed"))
|
|
.await
|
|
.unwrap();
|
|
|
|
let mut update = settings_with_margin(3_600);
|
|
update.request_profile_updates.insert(removed.clone(), None);
|
|
repository.update_runtime_settings(update).await.unwrap();
|
|
let settings = repository.load_runtime_settings().await.unwrap();
|
|
|
|
assert_eq!(
|
|
(
|
|
settings.request_profiles.get(&kept),
|
|
settings.request_profiles.get(&removed),
|
|
),
|
|
(Some(&profile("kept")), None),
|
|
);
|
|
database.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn xai_profile_initialization_and_updates_preserve_other_providers() {
|
|
use gateway_core::{
|
|
account::OpaqueProviderData, provider_ports::ProviderRuntimePolicyPort,
|
|
routing::ProviderKind,
|
|
};
|
|
let Some(database) = TestDatabase::create("xai_profiles").await else {
|
|
return;
|
|
};
|
|
let repository = PgRuntimeSettingsRepository::new(database.pool.clone());
|
|
let document = |version: &str| {
|
|
OpaqueProviderData::new(
|
|
serde_json::json!({"clientVersion":version})
|
|
.as_object()
|
|
.unwrap()
|
|
.clone(),
|
|
)
|
|
};
|
|
let provider = ProviderKind::new("xai").unwrap();
|
|
repository
|
|
.initialize_request_profile(&provider, document("initial"))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
repository
|
|
.initialize_request_profile(&provider, document("ignored"))
|
|
.await
|
|
.unwrap(),
|
|
document("initial")
|
|
);
|
|
let mut update = settings_with_margin(3600);
|
|
update.request_profile_updates.insert(
|
|
ProviderKind::new("openai").unwrap(),
|
|
Some(document("openai")),
|
|
);
|
|
update
|
|
.request_profile_updates
|
|
.insert(provider.clone(), Some(document("xai")));
|
|
repository.update_runtime_settings(update).await.unwrap();
|
|
let mut update = settings_with_margin(3600);
|
|
update
|
|
.request_profile_updates
|
|
.insert(provider.clone(), Some(document("edited")));
|
|
repository.update_runtime_settings(update).await.unwrap();
|
|
let settings = repository.load_runtime_settings().await.unwrap();
|
|
assert_eq!(
|
|
settings
|
|
.request_profiles
|
|
.get(&ProviderKind::new("openai").unwrap()),
|
|
Some(&document("openai"))
|
|
);
|
|
assert_eq!(
|
|
settings.request_profiles.get(&provider),
|
|
Some(&document("edited"))
|
|
);
|
|
assert_eq!(
|
|
repository
|
|
.initialize_request_profile(&provider, document("old-yaml"))
|
|
.await
|
|
.unwrap(),
|
|
document("edited")
|
|
);
|
|
database.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn request_profile_projection_is_revision_consistent_and_includes_key_overrides() {
|
|
use gateway_core::{
|
|
account::OpaqueProviderData,
|
|
provider_ports::{ProviderRuntimePolicyPort, ProviderStoreErrorKind},
|
|
routing::ProviderKind,
|
|
};
|
|
let Some(database) = TestDatabase::create("request_profile_projection").await else {
|
|
return;
|
|
};
|
|
let repository = PgRuntimeSettingsRepository::new(database.pool.clone());
|
|
let provider = ProviderKind::new("xai").unwrap();
|
|
let document = |marker: &str| {
|
|
OpaqueProviderData::new(
|
|
serde_json::json!({"marker":marker})
|
|
.as_object()
|
|
.unwrap()
|
|
.clone(),
|
|
)
|
|
};
|
|
let mut update = settings_with_margin(3600);
|
|
update
|
|
.request_profile_updates
|
|
.insert(provider.clone(), Some(document("global")));
|
|
repository.update_runtime_settings(update).await.unwrap();
|
|
sqlx::query(
|
|
"insert into client_api_keys (
|
|
id, name, key, enabled, max_concurrency, requests_per_minute,
|
|
provider_request_profiles_json, created_at, updated_at
|
|
) values ($1, $2, $3, true, 0, 0, $4::jsonb, now(), now())",
|
|
)
|
|
.bind("key_profile_projection")
|
|
.bind("profile projection")
|
|
.bind("synthetic-profile-projection-secret")
|
|
.bind(sqlx::types::Json(serde_json::json!({
|
|
"xai":{"marker":"key"}
|
|
})))
|
|
.execute(&database.pool)
|
|
.await
|
|
.unwrap();
|
|
let revision = repository
|
|
.load_runtime_settings()
|
|
.await
|
|
.unwrap()
|
|
.config_revision;
|
|
let revision = gateway_core::routing::ConfigRevision::new(revision.get()).unwrap();
|
|
let profiles = repository
|
|
.load_request_profile_configurations(revision, &provider)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(profiles.len(), 2);
|
|
assert!(profiles.contains(&document("global")));
|
|
assert!(profiles.contains(&document("key")));
|
|
|
|
repository
|
|
.update_runtime_settings(settings_with_margin(7200))
|
|
.await
|
|
.unwrap();
|
|
let error = repository
|
|
.load_request_profile_configurations(revision, &provider)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(error.kind(), ProviderStoreErrorKind::Conflict);
|
|
database.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn warmup_cursor_survives_restart_and_settings_updates_without_rewinding() {
|
|
use gateway_core::{provider_ports::ProviderRuntimePolicyPort, time::DeploymentTimeZone};
|
|
let Some(database) = TestDatabase::create("warmup_cursor").await else {
|
|
return;
|
|
};
|
|
let zone = DeploymentTimeZone::default();
|
|
let slot = zone
|
|
.local(Utc::now())
|
|
.date_naive()
|
|
.and_hms_opt(8, 0, 0)
|
|
.unwrap();
|
|
let first = PgRuntimeSettingsRepository::new(database.pool.clone());
|
|
let second = PgRuntimeSettingsRepository::new(database.pool.clone());
|
|
let before = first.load_runtime_settings().await.unwrap();
|
|
let (a, b) = tokio::join!(
|
|
first.claim_warmup_slot(zone, slot),
|
|
second.claim_warmup_slot(zone, slot)
|
|
);
|
|
assert_ne!(a.unwrap(), b.unwrap(), "the local minute is claimed once");
|
|
let after = first.load_runtime_settings().await.unwrap();
|
|
assert_eq!(after.config_revision, before.config_revision);
|
|
assert_eq!(after.updated_at, before.updated_at);
|
|
let restarted = PgRuntimeSettingsRepository::new(database.pool.clone());
|
|
assert!(!restarted.claim_warmup_slot(zone, slot).await.unwrap());
|
|
assert!(
|
|
restarted
|
|
.claim_warmup_slot(zone, slot + TimeDelta::hours(5))
|
|
.await
|
|
.unwrap()
|
|
);
|
|
assert!(
|
|
!restarted.claim_warmup_slot(zone, slot).await.unwrap(),
|
|
"another slot cannot erase prior deduplication"
|
|
);
|
|
restarted
|
|
.update_runtime_settings(settings_with_margin(3600))
|
|
.await
|
|
.unwrap();
|
|
assert!(!restarted.claim_warmup_slot(zone, slot).await.unwrap());
|
|
assert!(
|
|
!restarted
|
|
.claim_warmup_slot(zone, slot - TimeDelta::hours(1))
|
|
.await
|
|
.unwrap(),
|
|
"clock rollback cannot replay an earlier time slot"
|
|
);
|
|
assert!(
|
|
restarted
|
|
.claim_warmup_slot(zone, slot + TimeDelta::days(1))
|
|
.await
|
|
.unwrap()
|
|
);
|
|
database.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn warmup_cursor_resolves_dst_and_deduplicates_across_timezones() {
|
|
use gateway_core::{provider_ports::ProviderRuntimePolicyPort, time::DeploymentTimeZone};
|
|
let Some(database) = TestDatabase::create("warmup_cursor_dst").await else {
|
|
return;
|
|
};
|
|
let repository = PgRuntimeSettingsRepository::new(database.pool.clone());
|
|
let zone: DeploymentTimeZone = "America/New_York".parse().unwrap();
|
|
let slot = chrono::NaiveDate::from_ymd_opt(2026, 11, 1)
|
|
.unwrap()
|
|
.and_hms_opt(1, 30, 0)
|
|
.unwrap();
|
|
assert!(repository.claim_warmup_slot(zone, slot).await.unwrap());
|
|
let cursor: DateTime<Utc> =
|
|
sqlx::query_scalar("select account_warmup_cursor from runtime_settings where id = 1")
|
|
.fetch_one(&database.pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
cursor,
|
|
"2026-11-01T05:30:00Z".parse::<DateTime<Utc>>().unwrap()
|
|
);
|
|
assert!(!repository.claim_warmup_slot(zone, slot).await.unwrap());
|
|
// 回拨中的重复本地时刻取较早一次,不能误挡后一分的正常调度
|
|
let next = slot + TimeDelta::minutes(1);
|
|
assert!(repository.claim_warmup_slot(zone, next).await.unwrap());
|
|
let utc: DeploymentTimeZone = "UTC".parse().unwrap();
|
|
assert!(
|
|
!repository
|
|
.claim_warmup_slot(utc, zone.resolve_local(next).unwrap().naive_utc())
|
|
.await
|
|
.unwrap(),
|
|
"changing the deployment timezone cannot reclaim the same instant"
|
|
);
|
|
let missing = chrono::NaiveDate::from_ymd_opt(2026, 3, 8)
|
|
.unwrap()
|
|
.and_hms_opt(2, 30, 0)
|
|
.unwrap();
|
|
assert!(repository.claim_warmup_slot(zone, missing).await.is_err());
|
|
database.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn account_affinity_defaults_and_updates_reach_the_snapshot() {
|
|
use gateway_core::account::AccountAffinity;
|
|
use gateway_store::postgres::{PgRuntimeSnapshotRepository, RuntimeSnapshotRepository};
|
|
let Some(database) = TestDatabase::create("account_affinity").await else {
|
|
return;
|
|
};
|
|
let repository = PgRuntimeSettingsRepository::new(database.pool.clone());
|
|
let before = repository.load_runtime_settings().await.unwrap();
|
|
assert_eq!(
|
|
before.values.openai_account_affinity,
|
|
AccountAffinity::Strict
|
|
);
|
|
assert_eq!(before.values.max_account_rotations, 3);
|
|
assert_eq!(before.values.openai_session_affinity_ttl_hours, 24);
|
|
for (mode, budget, ttl) in [
|
|
(AccountAffinity::Preferred, 3, 24),
|
|
(AccountAffinity::Strict, 31, 168),
|
|
(AccountAffinity::Relaxed, 0, 720),
|
|
] {
|
|
let mut update = settings_with_margin(3600);
|
|
update.values.openai_account_affinity = mode;
|
|
update.values.max_account_rotations = budget;
|
|
update.values.openai_session_affinity_ttl_hours = ttl;
|
|
repository.update_runtime_settings(update).await.unwrap();
|
|
let loaded = repository.load_runtime_settings().await.unwrap();
|
|
assert_eq!(
|
|
(
|
|
loaded.values.openai_account_affinity,
|
|
loaded.values.max_account_rotations
|
|
),
|
|
(mode, budget)
|
|
);
|
|
let snapshot = PgRuntimeSnapshotRepository::new(database.pool.clone())
|
|
.load_runtime_snapshot()
|
|
.await
|
|
.unwrap();
|
|
let snapshot_values = serde_json::to_value(&snapshot.settings).unwrap();
|
|
assert_eq!(
|
|
(
|
|
serde_json::from_value::<AccountAffinity>(
|
|
snapshot_values["openai_account_affinity"].clone()
|
|
)
|
|
.unwrap(),
|
|
snapshot_values["max_account_rotations"].as_u64().unwrap()
|
|
),
|
|
(mode, u64::from(budget))
|
|
);
|
|
assert!(snapshot.config_revision > before.config_revision);
|
|
assert_eq!(loaded.values.openai_session_affinity_ttl_hours, ttl);
|
|
assert_eq!(snapshot_values["openai_session_affinity_ttl_hours"], ttl);
|
|
}
|
|
let mut invalid = settings_with_margin(3600);
|
|
invalid.values.max_account_rotations = 32;
|
|
assert!(repository.update_runtime_settings(invalid).await.is_err());
|
|
for sql in [
|
|
"update runtime_settings set openai_session_affinity_ttl_hours = 0 where id = 1",
|
|
"update runtime_settings set openai_session_affinity_ttl_hours = 721 where id = 1",
|
|
"update runtime_settings set max_account_rotations = 32 where id = 1",
|
|
"update runtime_settings set max_account_rotations = -1 where id = 1",
|
|
"update runtime_settings set openai_account_affinity = 'unknown' where id = 1",
|
|
] {
|
|
assert!(sqlx::query(sql).execute(&database.pool).await.is_err());
|
|
}
|
|
assert_eq!(
|
|
repository
|
|
.load_runtime_settings()
|
|
.await
|
|
.unwrap()
|
|
.values
|
|
.max_account_rotations,
|
|
0
|
|
);
|
|
database.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn reset_credit_weekly_threshold_defaults_unset_and_round_trips() {
|
|
let Some(database) = TestDatabase::create("reset_weekly_threshold").await else {
|
|
return;
|
|
};
|
|
let repository = PgRuntimeSettingsRepository::new(database.pool.clone());
|
|
let settings = repository.load_runtime_settings().await.unwrap();
|
|
assert_eq!(
|
|
settings.values.reset_credit_max_weekly_remaining_percent,
|
|
None
|
|
);
|
|
for threshold in [Some(70), Some(0), Some(100), None] {
|
|
let mut update = settings_with_margin(600);
|
|
update.values.reset_credit_max_weekly_remaining_percent = threshold;
|
|
repository.update_runtime_settings(update).await.unwrap();
|
|
assert_eq!(
|
|
repository
|
|
.load_runtime_settings()
|
|
.await
|
|
.unwrap()
|
|
.values
|
|
.reset_credit_max_weekly_remaining_percent,
|
|
threshold
|
|
);
|
|
}
|
|
for threshold in [-1, 101] {
|
|
assert!(sqlx::query("update runtime_settings set reset_credit_max_weekly_remaining_percent = $1 where id = 1")
|
|
.bind(threshold).execute(&database.pool).await.is_err());
|
|
}
|
|
database.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn protection_revision_tracks_only_policy_changes_and_survives_return_to_previous_rules() {
|
|
let Some(database) = TestDatabase::create("protection_revision").await else {
|
|
return;
|
|
};
|
|
let repository = PgRuntimeSettingsRepository::new(database.pool.clone());
|
|
let initial = repository.load_runtime_settings().await.unwrap();
|
|
assert!(!initial.values.account_error_protection.enabled);
|
|
assert!(initial.values.account_error_protection.rules.is_empty());
|
|
let a = settings_with_margin(300);
|
|
repository.update_runtime_settings(a.clone()).await.unwrap();
|
|
let first = repository.load_runtime_settings().await.unwrap();
|
|
assert_eq!(
|
|
first.account_error_protection_revision,
|
|
first.config_revision
|
|
);
|
|
let mut unrelated = a.clone();
|
|
unrelated.values.max_concurrent_per_account = 17;
|
|
repository.update_runtime_settings(unrelated).await.unwrap();
|
|
let same = repository.load_runtime_settings().await.unwrap();
|
|
assert_eq!(
|
|
same.account_error_protection_revision,
|
|
first.account_error_protection_revision
|
|
);
|
|
assert!(same.config_revision > first.config_revision);
|
|
let mut b = a.clone();
|
|
b.values.account_error_protection.rules[0].conditions[0].values =
|
|
vec![Some("different_code".into())];
|
|
repository.update_runtime_settings(b).await.unwrap();
|
|
repository.update_runtime_settings(a).await.unwrap();
|
|
let final_settings = repository.load_runtime_settings().await.unwrap();
|
|
assert_eq!(
|
|
final_settings.values.account_error_protection,
|
|
first.values.account_error_protection
|
|
);
|
|
assert!(
|
|
final_settings.account_error_protection_revision > same.account_error_protection_revision
|
|
);
|
|
database.close().await;
|
|
}
|