test(saas): Phase 1 integration tests — billing + scheduled_task + knowledge (68 tests)
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled

- Fix TIMESTAMPTZ decode errors: add ::TEXT cast to all SELECT queries
  where Row structs use String for TIMESTAMPTZ columns (~22 locations)
- Fix Axum 0.7 route params: {id} → :id in billing/knowledge/scheduled_task routes
- Fix JSONB bind: scheduled_task INSERT uses ::jsonb cast for input_payload
- Add billing_test.rs (14 tests): plans, subscription, usage, payments, invoices
- Add scheduled_task_test.rs (12 tests): CRUD, validation, isolation
- Add knowledge_test.rs (20 tests): categories, items, versions, search, analytics, permissions
- Fix auth test regression: 6 tests were failing due to TIMESTAMPTZ type mismatch

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
iven
2026-04-07 14:25:34 +08:00
parent a5b887051d
commit 7de486bfca
27 changed files with 1317 additions and 187 deletions

View File

@@ -313,16 +313,14 @@ fn split_sql_statements(sql: &str) -> Vec<String> {
/// Seed 角色数据
async fn seed_roles(pool: &PgPool) -> SaasResult<()> {
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
r#"INSERT INTO roles (id, name, description, permissions, is_system, created_at, updated_at)
VALUES
('super_admin', '超级管理员', '拥有所有权限', '["admin:full","account:admin","provider:manage","model:manage","relay:admin","config:write","prompt:read","prompt:write","prompt:publish","prompt:admin"]', TRUE, $1, $1),
('admin', '管理员', '管理账号和配置', '["account:read","account:admin","provider:manage","model:read","model:manage","relay:use","relay:admin","config:read","config:write","prompt:read","prompt:write","prompt:publish"]', TRUE, $1, $1),
('user', '普通用户', '基础使用权限', '["model:read","relay:use","config:read","prompt:read"]', TRUE, $1, $1)
('super_admin', '超级管理员', '拥有所有权限', '["admin:full","account:admin","provider:manage","model:manage","relay:admin","config:write","prompt:read","prompt:write","prompt:publish","prompt:admin"]', TRUE, NOW(), NOW()),
('admin', '管理员', '管理账号和配置', '["account:read","account:admin","provider:manage","model:read","model:manage","relay:use","relay:admin","config:read","config:write","prompt:read","prompt:write","prompt:publish"]', TRUE, NOW(), NOW()),
('user', '普通用户', '基础使用权限', '["model:read","relay:use","config:read","prompt:read"]', TRUE, NOW(), NOW())
ON CONFLICT (id) DO NOTHING"#
)
.bind(&now)
.execute(pool)
.await?;
Ok(())
@@ -364,7 +362,7 @@ pub async fn seed_admin_account(pool: &PgPool) -> SaasResult<()> {
if let Some((account_id,)) = existing {
// 更新现有用户的密码和角色(使用 spawn_blocking 避免阻塞 tokio 运行时)
let password_hash = crate::auth::password::hash_password_async(admin_password.clone()).await?;
let now = chrono::Utc::now().to_rfc3339();
let now = chrono::Utc::now();
sqlx::query(
"UPDATE accounts SET password_hash = $1, role = 'super_admin', updated_at = $2 WHERE id = $3"
@@ -381,7 +379,7 @@ pub async fn seed_admin_account(pool: &PgPool) -> SaasResult<()> {
let password_hash = crate::auth::password::hash_password_async(admin_password.clone()).await?;
let account_id = uuid::Uuid::new_v4().to_string();
let email = format!("{}@zclaw.local", admin_username);
let now = chrono::Utc::now().to_rfc3339();
let now = chrono::Utc::now();
sqlx::query(
"INSERT INTO accounts (id, username, email, password_hash, display_name, role, status, created_at, updated_at)
@@ -411,7 +409,7 @@ async fn seed_builtin_prompts(pool: &PgPool) -> SaasResult<()> {
return Ok(());
}
let now = chrono::Utc::now().to_rfc3339();
let now = chrono::Utc::now();
// reflection 提示词
let reflection_id = uuid::Uuid::new_v4().to_string();
@@ -490,7 +488,7 @@ async fn seed_demo_data(pool: &PgPool) -> SaasResult<()> {
("demo-local", "local-ollama", "本地 Ollama", "http://localhost:11434/v1", false, 10, 20000),
];
for (id, name, display, url, enabled, rpm, tpm) in &providers {
let ts = now.to_rfc3339();
let ts = now;
sqlx::query(
"INSERT INTO providers (id, name, display_name, base_url, api_protocol, enabled, rate_limit_rpm, rate_limit_tpm, created_at, updated_at)
VALUES ($1, $2, $3, $4, 'openai', $5, $6, $7, $8, $8) ON CONFLICT (id) DO NOTHING"
@@ -518,7 +516,7 @@ async fn seed_demo_data(pool: &PgPool) -> SaasResult<()> {
("demo-deepseek-reasoner", "demo-deepseek", "deepseek-reasoner", "DeepSeek R1", 65536, 8192, true, false, 0.00055, 0.00219),
];
for (id, pid, mid, alias, ctx, max_out, stream, vision, price_in, price_out) in &models {
let ts = now.to_rfc3339();
let ts = now;
sqlx::query(
"INSERT INTO models (id, provider_id, model_id, alias, context_window, max_output_tokens, supports_streaming, supports_vision, enabled, pricing_input, pricing_output, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, $9, $10, $11, $11) ON CONFLICT (id) DO NOTHING"
@@ -537,7 +535,7 @@ async fn seed_demo_data(pool: &PgPool) -> SaasResult<()> {
("demo-key-d1", "demo-deepseek", "DeepSeek Key 1", "sk-demo-deepseek-key-1-xxxxx", 0, 30, 50000),
];
for (id, pid, label, kv, priority, rpm, tpm) in &provider_keys {
let ts = now.to_rfc3339();
let ts = now;
sqlx::query(
"INSERT INTO provider_keys (id, provider_id, key_label, key_value, priority, max_rpm, max_tpm, is_active, total_requests, total_tokens, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, true, 0, 0, $8, $8) ON CONFLICT (id) DO NOTHING"
@@ -565,7 +563,7 @@ async fn seed_demo_data(pool: &PgPool) -> SaasResult<()> {
rng_seed = rng_seed.wrapping_mul(6364136223846793005).wrapping_add(1);
let hour = rng_seed as i32 % 24;
rng_seed = rng_seed.wrapping_mul(6364136223846793005).wrapping_add(1);
let ts = (day + chrono::Duration::hours(hour as i64) + chrono::Duration::minutes(i as i64)).to_rfc3339();
let ts = day + chrono::Duration::hours(hour as i64) + chrono::Duration::minutes(i as i64);
let input = (500 + (rng_seed % 8000)) as i32;
rng_seed = rng_seed.wrapping_mul(6364136223846793005).wrapping_add(1);
let output = (200 + (rng_seed % 4000)) as i32;
@@ -590,8 +588,8 @@ async fn seed_demo_data(pool: &PgPool) -> SaasResult<()> {
let (provider_id, model_id) = models_for_usage[i % models_for_usage.len()];
let status = relay_statuses[i % relay_statuses.len()];
let offset_hours = (20 - i) as i64;
let ts = (now - chrono::Duration::hours(offset_hours)).to_rfc3339();
let ts_completed = (now - chrono::Duration::hours(offset_hours) + chrono::Duration::seconds(3)).to_rfc3339();
let ts = now - chrono::Duration::hours(offset_hours);
let ts_completed = now - chrono::Duration::hours(offset_hours) + chrono::Duration::seconds(3);
let task_id = uuid::Uuid::new_v4().to_string();
let hash = format!("{:064x}", i);
let body = format!(r#"{{"model":"{}","messages":[{{"role":"user","content":"demo request {}"}}]}}"#, model_id, i);
@@ -609,7 +607,7 @@ async fn seed_demo_data(pool: &PgPool) -> SaasResult<()> {
).bind(&task_id).bind(&admin_id).bind(provider_id).bind(model_id)
.bind(&hash).bind(status).bind(&body)
.bind(in_tok).bind(out_tok).bind(err.as_deref())
.bind(&ts).bind(&ts).bind(if status == "queued" { None::<&str> } else { Some(ts_completed.as_str()) })
.bind(&ts).bind(&ts).bind(if status == "queued" { None::<&chrono::DateTime<chrono::Utc>> } else { Some(&ts_completed) })
.execute(pool).await?;
}
@@ -681,7 +679,7 @@ async fn seed_demo_data(pool: &PgPool) -> SaasResult<()> {
];
for (id, name, desc, cat, model, prompt, tools, caps, temp, max_tok,
soul, scenarios, welcome, quick_cmds, personality, comm_style, emoji, source_id) in &agent_templates {
let ts = now.to_rfc3339();
let ts = now;
sqlx::query(
"INSERT INTO agent_templates (id, name, description, category, source, model, system_prompt, tools, capabilities,
temperature, max_tokens, visibility, status, current_version, created_at, updated_at,
@@ -724,7 +722,7 @@ async fn seed_demo_data(pool: &PgPool) -> SaasResult<()> {
("log", "slow_query_threshold_ms", "integer", "1000", "2000", "慢查询阈值(ms)"),
];
for (cat, key, vtype, current, default, desc) in &config_items {
let ts = now.to_rfc3339();
let ts = now;
let id = format!("cfg-{}-{}", cat, key);
sqlx::query(
"INSERT INTO config_items (id, category, key_path, value_type, current_value, default_value, source, description, created_at, updated_at)
@@ -740,7 +738,7 @@ async fn seed_demo_data(pool: &PgPool) -> SaasResult<()> {
("demo-akey-3", "demo-deepseek", "sk-demo-deepseek-key-1-xxxxx", "DeepSeek API Key", "[\"relay:use\"]"),
];
for (id, provider_id, key_val, label, perms) in &account_api_keys {
let ts = now.to_rfc3339();
let ts = now;
sqlx::query(
"INSERT INTO account_api_keys (id, account_id, provider_id, key_value, key_label, permissions, enabled, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, true, $7, $7) ON CONFLICT (id) DO NOTHING"
@@ -755,7 +753,7 @@ async fn seed_demo_data(pool: &PgPool) -> SaasResult<()> {
("demo-token-3", "Testing Key", "zclaw_test_jK4lM6nO8pQ0", "[\"relay:use\"]"),
];
for (id, name, prefix, perms) in &api_tokens {
let ts = now.to_rfc3339();
let ts = now;
let hash = {
use sha2::{Sha256, Digest};
hex::encode(Sha256::digest(format!("{}-dummy-hash", id).as_bytes()))
@@ -786,7 +784,7 @@ async fn seed_demo_data(pool: &PgPool) -> SaasResult<()> {
for i in 0..50 {
let (action, target_type, _detail) = log_actions[i % log_actions.len()];
let offset_hours = (i * 3 + 1) as i64;
let ts = (now - chrono::Duration::hours(offset_hours)).to_rfc3339();
let ts = now - chrono::Duration::hours(offset_hours);
let detail = serde_json::json!({"index": i}).to_string();
sqlx::query(
"INSERT INTO operation_logs (account_id, action, target_type, target_id, details, ip_address, created_at)
@@ -801,7 +799,7 @@ async fn seed_demo_data(pool: &PgPool) -> SaasResult<()> {
for day_offset in 0i32..14 {
let day = now - chrono::Duration::days(13 - day_offset as i64);
for h in 0i32..8 {
let ts = (day + chrono::Duration::hours(h as i64 * 3)).to_rfc3339();
let ts = day + chrono::Duration::hours(h as i64 * 3);
let model = telem_models[(day_offset as usize + h as usize) % telem_models.len()];
let report_id = format!("telem-d{}-h{}", day_offset, h);
let input = 1000 + (day_offset as i64 * 100 + h as i64 * 50);
@@ -828,7 +826,7 @@ async fn seed_demo_data(pool: &PgPool) -> SaasResult<()> {
/// - 旧种子将 API Keys 写入 api_tokens 表,但 handler 读 account_api_keys 表
/// - 旧种子数据的 account_id 可能与当前 admin 不匹配
async fn fix_seed_data(pool: &PgPool) -> SaasResult<()> {
let now = chrono::Utc::now().to_rfc3339();
let now = chrono::Utc::now();
// 1. 获取所有 super_admin account_id可能有多个
let admins: Vec<(String,)> = sqlx::query_as(