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

@@ -23,7 +23,7 @@ pub async fn list_accounts(
"SELECT COUNT(*) FROM accounts WHERE role = $1 AND status = $2 AND (username LIKE $3 OR email LIKE $3 OR display_name LIKE $3)"
).bind(role).bind(status).bind(&pattern).fetch_one(db).await?;
let rows = sqlx::query_as::<_, AccountRow>(
"SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at, created_at, llm_routing
"SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at::TEXT, created_at::TEXT, llm_routing
FROM accounts WHERE role = $1 AND status = $2 AND (username LIKE $3 OR email LIKE $3 OR display_name LIKE $3)
ORDER BY created_at DESC LIMIT $4 OFFSET $5"
).bind(role).bind(status).bind(&pattern).bind(page_size as i64).bind(offset as i64).fetch_all(db).await?;
@@ -35,7 +35,7 @@ pub async fn list_accounts(
"SELECT COUNT(*) FROM accounts WHERE role = $1 AND status = $2"
).bind(role).bind(status).fetch_one(db).await?;
let rows = sqlx::query_as::<_, AccountRow>(
"SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at, created_at, llm_routing
"SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at::TEXT, created_at::TEXT, llm_routing
FROM accounts WHERE role = $1 AND status = $2
ORDER BY created_at DESC LIMIT $3 OFFSET $4"
).bind(role).bind(status).bind(page_size as i64).bind(offset as i64).fetch_all(db).await?;
@@ -48,7 +48,7 @@ pub async fn list_accounts(
"SELECT COUNT(*) FROM accounts WHERE role = $1 AND (username LIKE $2 OR email LIKE $2 OR display_name LIKE $2)"
).bind(role).bind(&pattern).fetch_one(db).await?;
let rows = sqlx::query_as::<_, AccountRow>(
"SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at, created_at, llm_routing
"SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at::TEXT, created_at::TEXT, llm_routing
FROM accounts WHERE role = $1 AND (username LIKE $2 OR email LIKE $2 OR display_name LIKE $2)
ORDER BY created_at DESC LIMIT $3 OFFSET $4"
).bind(role).bind(&pattern).bind(page_size as i64).bind(offset as i64).fetch_all(db).await?;
@@ -61,7 +61,7 @@ pub async fn list_accounts(
"SELECT COUNT(*) FROM accounts WHERE status = $1 AND (username LIKE $2 OR email LIKE $2 OR display_name LIKE $2)"
).bind(status).bind(&pattern).fetch_one(db).await?;
let rows = sqlx::query_as::<_, AccountRow>(
"SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at, created_at, llm_routing
"SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at::TEXT, created_at::TEXT, llm_routing
FROM accounts WHERE status = $1 AND (username LIKE $2 OR email LIKE $2 OR display_name LIKE $2)
ORDER BY created_at DESC LIMIT $3 OFFSET $4"
).bind(status).bind(&pattern).bind(page_size as i64).bind(offset as i64).fetch_all(db).await?;
@@ -73,7 +73,7 @@ pub async fn list_accounts(
"SELECT COUNT(*) FROM accounts WHERE role = $1"
).bind(role).fetch_one(db).await?;
let rows = sqlx::query_as::<_, AccountRow>(
"SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at, created_at, llm_routing
"SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at::TEXT, created_at::TEXT, llm_routing
FROM accounts WHERE role = $1
ORDER BY created_at DESC LIMIT $2 OFFSET $3"
).bind(role).bind(page_size as i64).bind(offset as i64).fetch_all(db).await?;
@@ -85,7 +85,7 @@ pub async fn list_accounts(
"SELECT COUNT(*) FROM accounts WHERE status = $1"
).bind(status).fetch_one(db).await?;
let rows = sqlx::query_as::<_, AccountRow>(
"SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at, created_at, llm_routing
"SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at::TEXT, created_at::TEXT, llm_routing
FROM accounts WHERE status = $1
ORDER BY created_at DESC LIMIT $2 OFFSET $3"
).bind(status).bind(page_size as i64).bind(offset as i64).fetch_all(db).await?;
@@ -98,7 +98,7 @@ pub async fn list_accounts(
"SELECT COUNT(*) FROM accounts WHERE (username LIKE $1 OR email LIKE $1 OR display_name LIKE $1)"
).bind(&pattern).fetch_one(db).await?;
let rows = sqlx::query_as::<_, AccountRow>(
"SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at, created_at, llm_routing
"SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at::TEXT, created_at::TEXT, llm_routing
FROM accounts WHERE (username LIKE $1 OR email LIKE $1 OR display_name LIKE $1)
ORDER BY created_at DESC LIMIT $2 OFFSET $3"
).bind(&pattern).bind(page_size as i64).bind(offset as i64).fetch_all(db).await?;
@@ -110,7 +110,7 @@ pub async fn list_accounts(
"SELECT COUNT(*) FROM accounts"
).fetch_one(db).await?;
let rows = sqlx::query_as::<_, AccountRow>(
"SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at, created_at, llm_routing
"SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at::TEXT, created_at::TEXT, llm_routing
FROM accounts ORDER BY created_at DESC LIMIT $1 OFFSET $2"
).bind(page_size as i64).bind(offset as i64).fetch_all(db).await?;
(total, rows)
@@ -134,7 +134,7 @@ pub async fn list_accounts(
pub async fn get_account(db: &PgPool, account_id: &str) -> SaasResult<serde_json::Value> {
let row: Option<AccountRow> =
sqlx::query_as(
"SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at, created_at, llm_routing
"SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at::TEXT, created_at::TEXT, llm_routing
FROM accounts WHERE id = $1"
)
.bind(account_id)
@@ -155,7 +155,7 @@ pub async fn update_account(
account_id: &str,
req: &UpdateAccountRequest,
) -> SaasResult<serde_json::Value> {
let now = chrono::Utc::now().to_rfc3339();
let now = chrono::Utc::now();
// COALESCE pattern: all updatable fields in a single static SQL.
// NULL parameters leave the column unchanged.
@@ -190,7 +190,7 @@ pub async fn update_account_status(
if !valid.contains(&status) {
return Err(SaasError::InvalidInput(format!("无效状态: {},有效值: {:?}", status, valid)));
}
let now = chrono::Utc::now().to_rfc3339();
let now = chrono::Utc::now();
let result = sqlx::query("UPDATE accounts SET status = $1, updated_at = $2 WHERE id = $3")
.bind(status).bind(&now).bind(account_id)
.execute(db).await?;
@@ -215,9 +215,9 @@ pub async fn create_api_token(
let token_hash = hex::encode(Sha256::digest(raw_token.as_bytes()));
let token_prefix = raw_token[..8].to_string();
let now = chrono::Utc::now().to_rfc3339();
let now = chrono::Utc::now();
let expires_at = req.expires_days.map(|d| {
(chrono::Utc::now() + chrono::Duration::days(d)).to_rfc3339()
chrono::Utc::now() + chrono::Duration::days(d)
});
let permissions = serde_json::to_string(&req.permissions)?;
let token_id = uuid::Uuid::new_v4().to_string();
@@ -243,8 +243,8 @@ pub async fn create_api_token(
token_prefix,
permissions: req.permissions.clone(),
last_used_at: None,
expires_at,
created_at: now,
expires_at: expires_at.map(|dt| dt.to_rfc3339()),
created_at: now.to_rfc3339(),
token: Some(raw_token),
})
}
@@ -266,7 +266,7 @@ pub async fn list_api_tokens(
let rows: Vec<ApiTokenRow> =
sqlx::query_as(
"SELECT id, name, token_prefix, permissions, last_used_at, expires_at, created_at
"SELECT id, name, token_prefix, permissions, last_used_at::TEXT, expires_at::TEXT, created_at::TEXT
FROM api_tokens WHERE account_id = $1 AND revoked_at IS NULL ORDER BY created_at DESC LIMIT $2 OFFSET $3"
)
.bind(account_id)
@@ -300,7 +300,7 @@ pub async fn list_devices(
let rows: Vec<DeviceRow> =
sqlx::query_as(
"SELECT id, device_id, device_name, platform, app_version, last_seen_at, created_at
"SELECT id, device_id, device_name, platform, app_version, last_seen_at::TEXT, created_at::TEXT
FROM devices WHERE account_id = $1 ORDER BY last_seen_at DESC LIMIT $2 OFFSET $3"
)
.bind(account_id)
@@ -321,7 +321,7 @@ pub async fn list_devices(
}
pub async fn revoke_api_token(db: &PgPool, token_id: &str, account_id: &str) -> SaasResult<()> {
let now = chrono::Utc::now().to_rfc3339();
let now = chrono::Utc::now();
let result = sqlx::query(
"UPDATE api_tokens SET revoked_at = $1 WHERE id = $2 AND account_id = $3 AND revoked_at IS NULL"
)