use backbone_orm::generic_repository::{GenericCrudRepository, HardDelete, SoftDelete};
use serde::{Deserialize, Serialize};
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use uuid::Uuid;
fn admin_dsn() -> Option<String> {
std::env::var("BACKBONE_ORM_RLS_DSN").ok()
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, sqlx::FromRow)]
struct Ticket {
id: Uuid,
notes: String,
status: String,
metadata: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, sqlx::FromRow)]
struct Tag {
id: Uuid,
notes: String,
status: String,
}
async fn setup(pool: &PgPool) -> String {
let schema = format!("orm_upd_{}", &Uuid::new_v4().simple().to_string()[..8]);
sqlx::raw_sql(&format!(
"CREATE SCHEMA {schema};
CREATE TABLE {schema}.tickets (
id uuid PRIMARY KEY,
notes text NOT NULL,
status text NOT NULL,
metadata jsonb NOT NULL DEFAULT '{{}}'::jsonb
);
CREATE TABLE {schema}.tags (
id uuid PRIMARY KEY,
notes text NOT NULL,
status text NOT NULL
);"
))
.execute(pool)
.await
.unwrap();
schema
}
async fn teardown(pool: &PgPool, schema: &str) {
sqlx::raw_sql(&format!("DROP SCHEMA IF EXISTS {schema} CASCADE"))
.execute(pool)
.await
.unwrap();
}
async fn pool(dsn: &str) -> PgPool {
PgPoolOptions::new()
.max_connections(2)
.connect(dsn)
.await
.expect("connect")
}
#[tokio::test]
async fn a_patch_never_writes_back_a_column_it_did_not_change() {
let Some(dsn) = admin_dsn() else {
eprintln!("skipping: BACKBONE_ORM_RLS_DSN not set");
return;
};
let pool = pool(&dsn).await;
let schema = setup(&pool).await;
let repo: GenericCrudRepository<Ticket, SoftDelete> =
GenericCrudRepository::new(pool.clone(), &format!("{schema}.tickets"));
let id = Uuid::new_v4();
let stored = repo
.create(&Ticket {
id,
notes: "first".into(),
status: "draft".into(),
metadata: serde_json::json!({}),
})
.await
.unwrap();
let before = repo.find_by_id(&id.to_string()).await.unwrap().unwrap();
assert_eq!(before, stored);
sqlx::query(&format!(
"UPDATE {schema}.tickets SET status = 'posted' WHERE id = $1"
))
.bind(id)
.execute(&pool)
.await
.unwrap();
let mut after = before.clone();
after.notes = "edited".into();
let saved = repo
.update_changed(&id.to_string(), &before, &after)
.await
.unwrap()
.unwrap();
assert_eq!(saved.notes, "edited");
assert_eq!(
saved.status, "posted",
"the PATCH wrote back the status it read, undoing the verb"
);
teardown(&pool, &schema).await;
}
#[tokio::test]
async fn a_patch_racing_a_delete_does_not_revive_the_row() {
let Some(dsn) = admin_dsn() else {
eprintln!("skipping: BACKBONE_ORM_RLS_DSN not set");
return;
};
let pool = pool(&dsn).await;
let schema = setup(&pool).await;
let repo: GenericCrudRepository<Ticket, SoftDelete> =
GenericCrudRepository::new(pool.clone(), &format!("{schema}.tickets"));
let id = Uuid::new_v4();
repo.create(&Ticket {
id,
notes: "first".into(),
status: "draft".into(),
metadata: serde_json::json!({}),
})
.await
.unwrap();
let before = repo.find_by_id(&id.to_string()).await.unwrap().unwrap();
assert!(repo.soft_delete(&id.to_string()).await.unwrap());
let mut after = before.clone();
after.notes = "edited".into();
assert!(repo
.update_changed(&id.to_string(), &before, &after)
.await
.unwrap()
.is_none());
assert!(repo
.update(&id.to_string(), &before)
.await
.unwrap()
.is_none());
let mut stale_meta = before.clone();
stale_meta.metadata = serde_json::json!({ "note": "stale" });
assert!(repo
.update_changed(&id.to_string(), &before, &stale_meta)
.await
.unwrap()
.is_none());
assert!(
repo.find_by_id(&id.to_string()).await.unwrap().is_none(),
"the delete was undone"
);
let trashed = repo
.find_deleted_by_id(&id.to_string())
.await
.unwrap()
.unwrap();
assert_eq!(trashed.notes, "first");
teardown(&pool, &schema).await;
}
#[tokio::test]
async fn a_hard_delete_entity_patches_only_its_changes_and_reports_a_gone_row() {
let Some(dsn) = admin_dsn() else {
eprintln!("skipping: BACKBONE_ORM_RLS_DSN not set");
return;
};
let pool = pool(&dsn).await;
let schema = setup(&pool).await;
let repo: GenericCrudRepository<Tag, HardDelete> =
GenericCrudRepository::new(pool.clone(), &format!("{schema}.tags"));
let id = Uuid::new_v4();
repo.create(&Tag {
id,
notes: "first".into(),
status: "draft".into(),
})
.await
.unwrap();
let before = repo.find_by_id(&id.to_string()).await.unwrap().unwrap();
sqlx::query(&format!(
"UPDATE {schema}.tags SET status = 'posted' WHERE id = $1"
))
.bind(id)
.execute(&pool)
.await
.unwrap();
let mut after = before.clone();
after.notes = "edited".into();
let saved = repo
.update_changed(&id.to_string(), &before, &after)
.await
.unwrap()
.unwrap();
assert_eq!(
(saved.notes.as_str(), saved.status.as_str()),
("edited", "posted")
);
let same = repo
.update_changed(&id.to_string(), &saved, &saved)
.await
.unwrap()
.unwrap();
assert_eq!(same, saved);
assert!(repo.delete(&id.to_string()).await.unwrap());
assert!(repo
.update_changed(&id.to_string(), &before, &after)
.await
.unwrap()
.is_none());
teardown(&pool, &schema).await;
}