Files
MyAiDesk/nomifun-tauri/crates/backend/nomifun-db/tests/db_lifecycle.rs
T
freedak f7a720204a Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
2026-07-04 19:20:46 +08:00

417 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use nomifun_db::{init_database, init_database_memory};
use sqlx::Row;
// -- T1.1 Initialization --
#[tokio::test]
async fn init_creates_users_table() {
let db = init_database_memory().await.unwrap();
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
.fetch_one(db.pool())
.await
.unwrap();
assert!(
count.0 >= 1,
"users table should exist and have at least the system user"
);
}
// -- T1.2 Pragma configuration --
#[tokio::test]
async fn pragma_foreign_keys_enabled() {
let db = init_database_memory().await.unwrap();
let row: (i64,) = sqlx::query_as("PRAGMA foreign_keys")
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(row.0, 1, "foreign_keys should be ON");
}
#[tokio::test]
async fn pragma_busy_timeout() {
let db = init_database_memory().await.unwrap();
let row: (i64,) = sqlx::query_as("PRAGMA busy_timeout")
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(row.0, 5000, "busy_timeout should be 5000ms");
}
#[tokio::test]
async fn pragma_journal_mode_wal_on_file() {
let dir = tempfile::tempdir().unwrap();
let db = init_database(&dir.path().join("test.db")).await.unwrap();
let row: (String,) = sqlx::query_as("PRAGMA journal_mode")
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(
row.0.to_lowercase(),
"wal",
"journal_mode should be WAL for file-backed DB"
);
db.close().await;
}
// -- T1.3 Idempotent re-initialization --
#[tokio::test]
async fn idempotent_reinit_preserves_data() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.db");
// First init + insert test data
let db = init_database(&path).await.unwrap();
sqlx::query(
"INSERT INTO users (id, username, password_hash, created_at, updated_at) \
VALUES ('u1', 'alice', 'hash123', 1000, 1000)",
)
.execute(db.pool())
.await
.unwrap();
db.close().await;
// Second init — data should persist
let db = init_database(&path).await.unwrap();
let row = sqlx::query("SELECT username FROM users WHERE id = 'u1'")
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(row.get::<String, _>("username"), "alice");
db.close().await;
}
// -- T1.4 Migrations --
#[tokio::test]
async fn migrations_applied() {
let db = init_database_memory().await.unwrap();
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM _sqlx_migrations WHERE success = 1")
.fetch_one(db.pool())
.await
.unwrap();
assert!(count.0 >= 1, "at least one migration should be applied");
}
// -- T1.5 System default user --
#[tokio::test]
async fn system_default_user_exists() {
let db = init_database_memory().await.unwrap();
let row = sqlx::query("SELECT id, username, password_hash FROM users WHERE id = 'system_default_user'")
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(row.get::<String, _>("id"), "system_default_user");
assert_eq!(row.get::<String, _>("username"), "admin");
assert_eq!(
row.get::<String, _>("password_hash"),
"",
"system user should have empty password hash"
);
}
#[tokio::test]
async fn system_user_has_valid_timestamps() {
let before = nomifun_common::now_ms();
let db = init_database_memory().await.unwrap();
let after = nomifun_common::now_ms();
let row = sqlx::query("SELECT created_at, updated_at FROM users WHERE id = 'system_default_user'")
.fetch_one(db.pool())
.await
.unwrap();
let created = row.get::<i64, _>("created_at");
let updated = row.get::<i64, _>("updated_at");
assert!(
created >= before && created <= after,
"created_at should be within test window"
);
assert!(
updated >= before && updated <= after,
"updated_at should be within test window"
);
}
// -- Schema validation --
#[tokio::test]
async fn users_table_accepts_all_columns() {
let db = init_database_memory().await.unwrap();
sqlx::query(
"INSERT INTO users \
(id, username, email, password_hash, avatar_path, jwt_secret, created_at, updated_at, last_login) \
VALUES ('u1', 'testuser', 'test@example.com', 'hash', '/avatar.png', 'secret', 1000, 2000, 3000)",
)
.execute(db.pool())
.await
.unwrap();
let row = sqlx::query("SELECT * FROM users WHERE id = 'u1'")
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(row.get::<String, _>("email"), "test@example.com");
assert_eq!(
row.get::<Option<String>, _>("avatar_path"),
Some("/avatar.png".to_string())
);
assert_eq!(row.get::<Option<String>, _>("jwt_secret"), Some("secret".to_string()));
assert_eq!(row.get::<Option<i64>, _>("last_login"), Some(3000));
}
#[tokio::test]
async fn username_unique_constraint() {
let db = init_database_memory().await.unwrap();
sqlx::query(
"INSERT INTO users (id, username, password_hash, created_at, updated_at) \
VALUES ('u1', 'duplicate', 'h', 1, 1)",
)
.execute(db.pool())
.await
.unwrap();
let result = sqlx::query(
"INSERT INTO users (id, username, password_hash, created_at, updated_at) \
VALUES ('u2', 'duplicate', 'h', 1, 1)",
)
.execute(db.pool())
.await;
assert!(result.is_err(), "duplicate username should violate unique constraint");
}
#[tokio::test]
async fn email_unique_constraint() {
let db = init_database_memory().await.unwrap();
sqlx::query(
"INSERT INTO users (id, username, email, password_hash, created_at, updated_at) \
VALUES ('u1', 'user1', 'same@example.com', 'h', 1, 1)",
)
.execute(db.pool())
.await
.unwrap();
let result = sqlx::query(
"INSERT INTO users (id, username, email, password_hash, created_at, updated_at) \
VALUES ('u2', 'user2', 'same@example.com', 'h', 1, 1)",
)
.execute(db.pool())
.await;
assert!(result.is_err(), "duplicate email should violate unique constraint");
}
// -- Corruption recovery --
#[tokio::test]
async fn corruption_recovery_creates_backup() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.db");
// Write invalid content to simulate corruption
std::fs::write(&path, b"not a valid sqlite database").unwrap();
let db = init_database(&path).await.unwrap();
// Recovered database should work
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
.fetch_one(db.pool())
.await
.unwrap();
assert!(count.0 >= 1, "recovered DB should have system user");
// Backup file should exist
let has_backup = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.any(|e| e.file_name().to_string_lossy().contains("backup"));
assert!(has_backup, "backup of corrupted file should exist");
db.close().await;
}
// -- Directory creation --
#[tokio::test]
async fn creates_parent_directories() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("sub").join("nested").join("test.db");
let db = init_database(&path).await.unwrap();
assert!(path.exists(), "database file should be created in nested directory");
db.close().await;
}
// -- Pre-baseline rebuild (pre-launch convenience; removed before release) --
//
// The 2026-06-12 clean-baseline refactor squashed migrations 001021 into a
// single 001_baseline.sql, resetting the migration chain. A dev database
// carrying the old `_sqlx_migrations` history (mismatched checksum on
// version 1, applied versions 221 missing from the resolved set) must be
// renamed to `*.pre-baseline.bak` and rebuilt empty instead of failing fast.
#[tokio::test]
async fn pre_baseline_database_is_renamed_and_rebuilt() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nomifun-backend.db");
// Build a valid database, then forge a pre-baseline migration history:
// tamper the baseline checksum and record extra applied versions.
let db = init_database(&path).await.unwrap();
sqlx::query("UPDATE _sqlx_migrations SET checksum = X'00'")
.execute(db.pool())
.await
.unwrap();
sqlx::query(
"INSERT INTO _sqlx_migrations (version, description, success, checksum, execution_time) \
VALUES (21, 'entity seq', TRUE, X'00', 0)",
)
.execute(db.pool())
.await
.unwrap();
// Marker row that must NOT survive the rebuild.
sqlx::query(
"INSERT INTO users (id, username, password_hash, created_at, updated_at) \
VALUES ('u_old', 'old_dev_user', 'h', 1, 1)",
)
.execute(db.pool())
.await
.unwrap();
db.close().await;
// Re-init: the version mismatch must trigger the rename-and-rebuild path.
let db = init_database(&path).await.unwrap();
let old_user: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users WHERE id = 'u_old'")
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(old_user.0, 0, "rebuilt DB must be empty (old data renamed aside)");
let system_user: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users WHERE id = 'system_default_user'")
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(system_user.0, 1, "rebuilt DB should have the system user");
let backup = dir.path().join("nomifun-backend.db.pre-baseline.bak");
assert!(backup.exists(), "old database should be preserved as .pre-baseline.bak");
db.close().await;
}
#[tokio::test]
async fn pre_baseline_rebuild_numbers_subsequent_backups() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nomifun-backend.db");
// Occupy the primary backup name so the rebuild has to pick a suffix.
std::fs::write(dir.path().join("nomifun-backend.db.pre-baseline.bak"), b"earlier backup").unwrap();
let db = init_database(&path).await.unwrap();
sqlx::query("UPDATE _sqlx_migrations SET checksum = X'00'")
.execute(db.pool())
.await
.unwrap();
db.close().await;
let db = init_database(&path).await.unwrap();
let numbered = dir.path().join("nomifun-backend.db.pre-baseline.bak.1");
assert!(numbered.exists(), "second backup should get a numeric suffix");
db.close().await;
}
// -- Concurrent migrator regression (ELECTRON-1KK) --
//
// Repro for the Sentry secondary symptom: two processes opening the same
// SQLite DB on first start (e.g. Electron auto-update spawning the new
// version while the old one is still finalising shutdown, or
// `nomicore doctor` racing the server) both decide to apply the same
// migration version. sqlx-sqlite's lock()/unlock() are no-ops, so without
// the advisory file lock and retry-on-UNIQUE the slower process used to
// blow up with `UNIQUE constraint failed: _sqlx_migrations.version`.
//
// We use OS threads (not tokio::spawn) so each migrator runs on its own
// runtime — this matches the real "two processes" topology more closely
// than cooperative tasks would, and avoids the `&SqlitePool: Send` lifetime
// gymnastics that block tokio::spawn on this future.
#[test]
fn concurrent_init_database_does_not_panic_on_unique_conflict() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nomifun-backend.db");
let mut handles = Vec::new();
for _ in 0..8 {
let p = path.clone();
handles.push(std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async move { init_database(&p).await })
}));
}
// Every thread must succeed — none should bubble up the UNIQUE-constraint
// error from `_sqlx_migrations`.
let mut errors = Vec::new();
for h in handles {
match h.join().expect("thread panicked") {
Ok(_db) => {}
Err(e) => errors.push(e.to_string()),
}
}
assert!(
errors.is_empty(),
"all parallel migrators should succeed, got errors: {errors:?}"
);
// All migrators converged on the same baseline schema with no duplicate
// `_sqlx_migrations` rows.
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async {
let db = init_database(&path).await.unwrap();
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM _sqlx_migrations WHERE success = 1")
.fetch_one(db.pool())
.await
.unwrap();
assert!(count.0 >= 1, "at least one migration should be recorded");
let dup: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM (SELECT version FROM _sqlx_migrations GROUP BY version HAVING COUNT(*) > 1)",
)
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(dup.0, 0, "no duplicate versions should ever exist in _sqlx_migrations");
db.close().await;
});
// Lock file is created next to the DB and is harmless to leave behind.
let lock = path.with_file_name("nomifun-backend.db.migrate.lock");
assert!(lock.exists(), "advisory lock file should be present after migrate");
}