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
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:
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user