#![cfg(feature = "sqlite")]
use autumn_web::config::DatabaseConfig;
use autumn_web::db::{RuntimeConnection, create_pool};
use autumn_web::reexports::{diesel, diesel_async};
use autumn_web::tenancy::with_tenant;
use diesel_async::RunQueryDsl as _;
use diesel_async::pooled_connection::deadpool::Pool;
type SqlitePool = Pool<RuntimeConnection>;
mod schema {
autumn_web::reexports::diesel::table! {
iso_notes (id) {
id -> Int8,
content -> Text,
tenant_id -> Text,
}
}
autumn_web::reexports::diesel::table! {
iso_lock_notes (id) {
id -> Int8,
content -> Text,
tenant_id -> Text,
lock_version -> Int8,
}
}
autumn_web::reexports::diesel::table! {
plain_lock_notes (id) {
id -> Int8,
content -> Text,
lock_version -> Int8,
}
}
}
use schema::{iso_lock_notes, iso_notes, plain_lock_notes};
#[autumn_web::model(table = "iso_notes")]
pub struct IsoNote {
#[id]
pub id: i64,
pub content: String,
#[default]
pub tenant_id: String,
}
#[autumn_web::repository(IsoNote, table = "iso_notes", tenant_scoped)]
pub trait IsoNoteRepository {}
#[autumn_web::model(table = "iso_lock_notes")]
pub struct IsoLockNote {
#[id]
pub id: i64,
pub content: String,
#[default]
pub tenant_id: String,
#[lock_version]
pub lock_version: i64,
}
#[autumn_web::repository(IsoLockNote, table = "iso_lock_notes", tenant_scoped)]
pub trait IsoLockNoteRepository {}
#[autumn_web::model(table = "plain_lock_notes")]
pub struct PlainLockNote {
#[id]
pub id: i64,
pub content: String,
#[lock_version]
pub lock_version: i64,
}
#[autumn_web::repository(PlainLockNote, table = "plain_lock_notes")]
pub trait PlainLockNoteRepository {}
async fn boot_pool(db_name: &str) -> SqlitePool {
let config = DatabaseConfig {
url: Some(format!("sqlite://file:{db_name}?mode=memory&cache=shared")),
primary_pool_size: Some(1),
..Default::default()
};
let pool: SqlitePool = create_pool(&config)
.expect("sqlite pool builds via build_sqlite_pool")
.expect("a url is configured");
{
let mut conn = pool.get().await.expect("checkout a sqlite connection");
diesel::sql_query(
"CREATE TABLE iso_notes (\
id INTEGER PRIMARY KEY AUTOINCREMENT, \
content TEXT NOT NULL, \
tenant_id TEXT NOT NULL\
)",
)
.execute(&mut *conn)
.await
.expect("create iso_notes table");
diesel::sql_query(
"CREATE TABLE iso_lock_notes (\
id INTEGER PRIMARY KEY AUTOINCREMENT, \
content TEXT NOT NULL, \
tenant_id TEXT NOT NULL, \
lock_version BIGINT NOT NULL DEFAULT 1\
)",
)
.execute(&mut *conn)
.await
.expect("create iso_lock_notes table");
diesel::sql_query(
"CREATE TABLE plain_lock_notes (\
id INTEGER PRIMARY KEY AUTOINCREMENT, \
content TEXT NOT NULL, \
lock_version BIGINT NOT NULL DEFAULT 1\
)",
)
.execute(&mut *conn)
.await
.expect("create plain_lock_notes table");
}
pool
}
#[tokio::test]
async fn tenant_scoped_upsert_many_never_leaks_cross_tenant_row_on_sqlite() {
let pool = boot_pool("iso_upsert_tenant").await;
let repo = PgIsoNoteRepository::with_pool_untracked(pool);
let record_a = with_tenant("tenant-a".to_string(), async {
repo.save(&NewIsoNote {
content: "owned-by-a".to_string(),
})
.await
.expect("save under tenant-a")
})
.await;
assert_eq!(record_a.tenant_id, "tenant-a");
let upserted = with_tenant("tenant-b".to_string(), async {
let mut hijack = record_a.clone();
hijack.content = "hijacked-by-b".to_string();
repo.upsert_many(&[hijack]).await
})
.await
.expect("cross-tenant upsert must silently filter (Ok, not Err)");
assert!(
upserted.is_empty(),
"no in-scope rows existed for tenant-b, so nothing should be upserted, got: {upserted:?}"
);
let a_after = repo
.across_tenants()
.find_by_id(record_a.id)
.await
.expect("query tenant-a row")
.expect("tenant-a row still exists");
assert_eq!(
a_after.content, "owned-by-a",
"tenant-a row content must be unchanged (no cross-tenant hijack)"
);
assert_eq!(
a_after.tenant_id, "tenant-a",
"tenant-a row must NOT be moved into tenant-b (no cross-tenant leak)"
);
let b_rows = with_tenant("tenant-b".to_string(), async {
repo.find_all().await.expect("list tenant-b rows")
})
.await;
assert!(
b_rows.is_empty(),
"tenant-b must own no rows after the filtered upsert, got: {b_rows:?}"
);
}
#[tokio::test]
async fn tenant_scoped_versioned_upsert_many_isolates_and_conflicts_on_sqlite() {
let pool = boot_pool("iso_upsert_tenant_lock").await;
let repo = PgIsoLockNoteRepository::with_pool_untracked(pool);
let record_a = with_tenant("tenant-a".to_string(), async {
repo.save(&NewIsoLockNote {
content: "a-v1".to_string(),
})
.await
.expect("save under tenant-a")
})
.await;
assert_eq!(record_a.lock_version, 1);
assert_eq!(record_a.tenant_id, "tenant-a");
let upserted = with_tenant("tenant-b".to_string(), async {
let mut hijack = record_a.clone();
hijack.content = "hijacked-by-b".to_string();
repo.upsert_many(&[hijack]).await
})
.await
.expect("cross-tenant versioned upsert must silently filter (Ok, not Err)");
assert!(
upserted.is_empty(),
"cross-tenant versioned upsert must return nothing, got: {upserted:?}"
);
let a_after = repo
.across_tenants()
.find_by_id(record_a.id)
.await
.expect("query tenant-a row")
.expect("tenant-a row still exists");
assert_eq!(
a_after.content, "a-v1",
"content untouched by cross-tenant upsert"
);
assert_eq!(a_after.tenant_id, "tenant-a", "row not moved into tenant-b");
assert_eq!(
a_after.lock_version, 1,
"lock version untouched by cross-tenant upsert"
);
let bumped = with_tenant("tenant-a".to_string(), async {
let mut valid = record_a.clone();
valid.content = "a-v2".to_string();
repo.upsert_many(&[valid])
.await
.expect("valid same-tenant upsert")
})
.await;
assert_eq!(bumped.len(), 1);
assert_eq!(bumped[0].content, "a-v2");
assert_eq!(bumped[0].lock_version, 2, "DB increments the lock version");
let stale_res = with_tenant("tenant-a".to_string(), async {
let mut stale = record_a.clone(); stale.content = "a-stale".to_string();
repo.upsert_many(&[stale]).await
})
.await;
assert!(
stale_res.is_err(),
"stale same-tenant optimistic-lock upsert must fail loudly, but it succeeded"
);
let err_str = stale_res.unwrap_err().to_string();
assert!(
err_str.contains("onflict"),
"expected a conflict error for a stale lock version, got: {err_str}"
);
let final_a = repo
.across_tenants()
.find_by_id(record_a.id)
.await
.expect("query tenant-a row")
.expect("tenant-a row still exists");
assert_eq!(final_a.content, "a-v2");
assert_eq!(final_a.lock_version, 2);
}
#[tokio::test]
async fn plain_versioned_upsert_many_succeeds_and_conflicts_on_sqlite() {
let pool = boot_pool("iso_upsert_plain_lock").await;
let repo = PgPlainLockNoteRepository::with_pool_untracked(pool);
let inserted = repo
.save_many(&[NewPlainLockNote {
content: "lock-a".to_string(),
}])
.await
.expect("save_many inserts a versioned row");
assert_eq!(inserted.len(), 1);
let original = inserted[0].clone();
assert_eq!(original.lock_version, 1);
let mut to_upsert = original.clone();
to_upsert.content = "lock-a-updated".to_string();
let upserted = repo
.upsert_many(&[to_upsert])
.await
.expect("valid versioned upsert_many must succeed on sqlite");
assert_eq!(upserted.len(), 1);
assert_eq!(upserted[0].content, "lock-a-updated");
assert_eq!(
upserted[0].lock_version, 2,
"DB increments the lock version"
);
let mut stale = original.clone();
stale.content = "lock-a-stale".to_string();
let stale_res = repo.upsert_many(&[stale]).await;
assert!(
stale_res.is_err(),
"stale optimistic-lock upsert must fail loudly on sqlite, but it succeeded"
);
let err_str = stale_res.unwrap_err().to_string();
assert!(
err_str.contains("onflict"),
"expected a conflict error for a stale lock version, got: {err_str}"
);
}