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

@@ -116,7 +116,7 @@ pub async fn register(
let account_id = uuid::Uuid::new_v4().to_string();
let role = "user".to_string(); // 注册固定为普通用户,角色由管理员分配
let display_name = req.display_name.unwrap_or_default();
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, llm_routing)
@@ -175,7 +175,7 @@ pub async fn register(
role,
status: "active".into(),
totp_enabled: false,
created_at: now,
created_at: now.to_rfc3339(),
llm_routing: "local".into(),
},
};
@@ -194,8 +194,8 @@ pub async fn login(
let row: Option<AccountLoginRow> =
sqlx::query_as(
"SELECT id, username, email, display_name, role, status, totp_enabled,
password_hash, totp_secret, created_at, llm_routing,
password_version, failed_login_count, locked_until
password_hash, totp_secret, created_at::TEXT, llm_routing,
password_version, failed_login_count, locked_until::TEXT
FROM accounts WHERE username = $1 OR email = $1"
)
.bind(&req.username)
@@ -222,7 +222,7 @@ pub async fn login(
let new_count = r.failed_login_count + 1;
if new_count >= 5 {
// 锁定 15 分钟
let locked_until = (chrono::Utc::now() + chrono::Duration::minutes(15)).to_rfc3339();
let locked_until = chrono::Utc::now() + chrono::Duration::minutes(15);
sqlx::query(
"UPDATE accounts SET failed_login_count = $1, locked_until = $2 WHERE id = $3"
)
@@ -280,7 +280,7 @@ pub async fn login(
)?;
drop(config);
let now = chrono::Utc::now().to_rfc3339();
let now = chrono::Utc::now();
// 登录成功: 重置失败计数和锁定状态
sqlx::query("UPDATE accounts SET last_login_at = $1, failed_login_count = 0, locked_until = NULL WHERE id = $2")
.bind(&now).bind(&r.id)
@@ -330,7 +330,7 @@ pub async fn refresh(
"SELECT account_id FROM refresh_tokens WHERE jti = $1 AND used_at IS NULL AND expires_at > $2"
)
.bind(jti)
.bind(&chrono::Utc::now().to_rfc3339())
.bind(&chrono::Utc::now())
.fetch_optional(&state.db)
.await?;
@@ -344,7 +344,7 @@ pub async fn refresh(
}
// 5. 标记旧 refresh token 为已使用 (一次性)
let now = chrono::Utc::now().to_rfc3339();
let now = chrono::Utc::now();
sqlx::query("UPDATE refresh_tokens SET used_at = $1 WHERE jti = $2")
.bind(&now).bind(jti)
.execute(&state.db).await?;
@@ -387,7 +387,7 @@ pub async fn refresh(
let new_claims = verify_token(&new_refresh, state.jwt_secret.expose_secret())?;
let new_jti = new_claims.jti.unwrap_or_default();
let new_id = uuid::Uuid::new_v4().to_string();
let refresh_expires = (chrono::Utc::now() + chrono::Duration::hours(168)).to_rfc3339();
let refresh_expires = chrono::Utc::now() + chrono::Duration::hours(168);
sqlx::query(
"INSERT INTO refresh_tokens (id, account_id, jti, token_hash, expires_at, created_at)
VALUES ($1, $2, $3, $4, $5, $6)"
@@ -413,7 +413,7 @@ pub async fn me(
) -> SaasResult<Json<AccountPublic>> {
let row: Option<AccountAuthRow> =
sqlx::query_as(
"SELECT id, username, email, display_name, role, status, totp_enabled, created_at, llm_routing
"SELECT id, username, email, display_name, role, status, totp_enabled, created_at::TEXT, llm_routing
FROM accounts WHERE id = $1"
)
.bind(&ctx.account_id)
@@ -454,7 +454,7 @@ pub async fn change_password(
// 更新密码 + 递增 password_version 使旧 token 失效
let new_hash = hash_password_async(req.new_password.clone()).await?;
let now = chrono::Utc::now().to_rfc3339();
let now = chrono::Utc::now();
sqlx::query("UPDATE accounts SET password_hash = $1, updated_at = $2, password_version = password_version + 1 WHERE id = $3")
.bind(&new_hash)
.bind(&now)
@@ -515,7 +515,7 @@ pub async fn log_operation(
details: Option<serde_json::Value>,
ip_address: Option<&str>,
) -> SaasResult<()> {
let now = chrono::Utc::now().to_rfc3339();
let now = chrono::Utc::now();
sqlx::query(
"INSERT INTO operation_logs (account_id, action, target_type, target_id, details, ip_address, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)"
@@ -543,8 +543,8 @@ async fn store_refresh_token(
let claims = verify_token(refresh_token, secret)?;
let jti = claims.jti.unwrap_or_default();
let id = uuid::Uuid::new_v4().to_string();
let now = chrono::Utc::now().to_rfc3339();
let expires_at = (chrono::Utc::now() + chrono::Duration::hours(refresh_hours)).to_rfc3339();
let now = chrono::Utc::now();
let expires_at = chrono::Utc::now() + chrono::Duration::hours(refresh_hours);
sqlx::query(
"INSERT INTO refresh_tokens (id, account_id, jti, token_hash, expires_at, created_at)
@@ -560,7 +560,7 @@ async fn store_refresh_token(
/// 注意: 现已迁移到 Worker/Scheduler 定期执行,此函数保留作为备用
#[allow(dead_code)]
async fn cleanup_expired_refresh_tokens(db: &sqlx::PgPool) -> SaasResult<()> {
let now = chrono::Utc::now().to_rfc3339();
let now = chrono::Utc::now();
// 删除过期超过 30 天的已使用 token (减少 DB 膨胀)
sqlx::query(
"DELETE FROM refresh_tokens WHERE (used_at IS NOT NULL AND used_at < $1) OR (expires_at < $1)"
@@ -587,7 +587,7 @@ pub async fn logout(
if let Ok(claims) = verify_token_skip_expiry(token, state.jwt_secret.expose_secret()) {
if claims.token_type == "refresh" {
if let Some(jti) = claims.jti {
let now = chrono::Utc::now().to_rfc3339();
let now = chrono::Utc::now();
// 标记 refresh token 为已使用(等效于撤销/黑名单)
let result = sqlx::query(
"UPDATE refresh_tokens SET used_at = $1 WHERE jti = $2 AND used_at IS NULL"

View File

@@ -28,7 +28,7 @@ async fn verify_api_token(state: &AppState, raw_token: &str, client_ip: Option<S
let token_hash = hex::encode(Sha256::digest(raw_token.as_bytes()));
let row: Option<(String, Option<String>, String)> = sqlx::query_as(
"SELECT account_id, expires_at, permissions FROM api_tokens
"SELECT account_id, expires_at::TEXT, permissions FROM api_tokens
WHERE token_hash = $1 AND revoked_at IS NULL"
)
.bind(&token_hash)