#![cfg(feature = "sqlite")]
use std::collections::HashMap;
use architect_sdk::{
apply_migrations,
config::{
ApiEntityConfig, ColumnConfig, ColumnTypeConfig, FullConfig, PrimaryKeyConfig,
SchemaConfig, TableConfig, ValidationRule,
},
db::active_dialect,
ensure_sys_tables, resolve,
service::{CrudService, TenantExecutor},
};
use serde_json::json;
use sqlx::SqlitePool;
async fn memory_pool() -> SqlitePool {
std::env::set_var("ARCHITECT_SCHEMA", "main");
SqlitePool::connect("sqlite::memory:")
.await
.expect("in-memory SQLite pool")
}
fn notes_config() -> FullConfig {
FullConfig {
schemas: vec![SchemaConfig {
id: "s1".into(),
name: "main".into(),
comment: None,
}],
enums: vec![],
tables: vec![TableConfig {
id: "t_notes".into(),
schema_id: Some("s1".into()),
name: "notes".into(),
comment: None,
primary_key: PrimaryKeyConfig::Single("id".into()),
unique: vec![],
check: vec![],
audit_log: false,
versioning: None,
}],
columns: vec![
ColumnConfig {
id: "c_notes_id".into(),
table_id: "t_notes".into(),
name: "id".into(),
type_: ColumnTypeConfig::Simple("serial".into()),
nullable: false,
default: None,
comment: None,
asset: None,
},
ColumnConfig {
id: "c_notes_body".into(),
table_id: "t_notes".into(),
name: "body".into(),
type_: ColumnTypeConfig::Simple("text".into()),
nullable: true,
default: None,
comment: None,
asset: None,
},
],
indexes: vec![],
relationships: vec![],
api_entities: vec![ApiEntityConfig {
entity_id: "t_notes".into(),
path_segment: "notes".into(),
operations: vec![
"list".into(),
"read".into(),
"create".into(),
"update".into(),
"delete".into(),
],
sensitive_columns: vec![],
validation: {
let mut m = HashMap::new();
m.insert(
"body".into(),
ValidationRule {
required: Some(true),
max_length: Some(500),
..Default::default()
},
);
m
},
archive_field: None,
events: vec![],
parent_ref_column: None,
mcp: None,
}],
kv_stores: vec![],
}
}
fn users_config() -> FullConfig {
FullConfig {
schemas: vec![SchemaConfig {
id: "s1".into(),
name: "main".into(),
comment: None,
}],
enums: vec![],
tables: vec![TableConfig {
id: "t_users".into(),
schema_id: Some("s1".into()),
name: "users".into(),
comment: None,
primary_key: PrimaryKeyConfig::Single("id".into()),
unique: vec![],
check: vec![],
audit_log: false,
versioning: None,
}],
columns: vec![
ColumnConfig {
id: "c_users_id".into(),
table_id: "t_users".into(),
name: "id".into(),
type_: ColumnTypeConfig::Simple("text".into()),
nullable: false,
default: None,
comment: None,
asset: None,
},
ColumnConfig {
id: "c_users_name".into(),
table_id: "t_users".into(),
name: "name".into(),
type_: ColumnTypeConfig::Simple("text".into()),
nullable: true,
default: None,
comment: None,
asset: None,
},
ColumnConfig {
id: "c_users_email".into(),
table_id: "t_users".into(),
name: "email".into(),
type_: ColumnTypeConfig::Simple("text".into()),
nullable: true,
default: None,
comment: None,
asset: None,
},
],
indexes: vec![],
relationships: vec![],
api_entities: vec![ApiEntityConfig {
entity_id: "t_users".into(),
path_segment: "users".into(),
operations: vec![
"list".into(),
"read".into(),
"create".into(),
"update".into(),
"delete".into(),
],
sensitive_columns: vec!["email".into()],
validation: {
let mut m = HashMap::new();
m.insert(
"email".into(),
ValidationRule {
format: Some("email".into()),
..Default::default()
},
);
m
},
archive_field: None,
events: vec![],
parent_ref_column: None,
mcp: None,
}],
kv_stores: vec![],
}
}
#[tokio::test]
async fn migration_creates_sys_tables() {
let pool = memory_pool().await;
let dialect = active_dialect();
ensure_sys_tables(&pool, dialect.as_ref())
.await
.expect("ensure_sys_tables");
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM main._sys_packages")
.fetch_one(&pool)
.await
.expect("_sys_packages should exist");
assert_eq!(count, 0);
}
#[tokio::test]
async fn migration_creates_app_table() {
let pool = memory_pool().await;
let dialect = active_dialect();
let config = notes_config();
apply_migrations(
&pool,
&config,
None,
None,
dialect.as_ref(),
&HashMap::new(),
)
.await
.expect("apply_migrations");
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM \"main\".\"notes\"")
.fetch_one(&pool)
.await
.expect("notes table should exist after migration");
assert_eq!(count, 0);
}
#[tokio::test]
async fn migration_is_idempotent() {
let pool = memory_pool().await;
let dialect = active_dialect();
let config = notes_config();
apply_migrations(
&pool,
&config,
None,
None,
dialect.as_ref(),
&HashMap::new(),
)
.await
.expect("first apply");
apply_migrations(
&pool,
&config,
None,
None,
dialect.as_ref(),
&HashMap::new(),
)
.await
.expect("second apply should be idempotent");
}
async fn notes_executor(pool: &SqlitePool) -> (SqlitePool, architect_sdk::config::ResolvedModel) {
let dialect = active_dialect();
let config = notes_config();
apply_migrations(pool, &config, None, None, dialect.as_ref(), &HashMap::new())
.await
.unwrap();
let model = resolve(&config).unwrap();
(pool.clone(), model)
}
#[tokio::test]
async fn crud_create_and_read() {
let pool = memory_pool().await;
let (pool, model) = notes_executor(&pool).await;
let dialect = active_dialect();
let entity = model.entity_by_path.get("notes").unwrap();
let mut exec = TenantExecutor::pool(&pool, dialect.as_ref());
let mut body = HashMap::new();
body.insert("body".to_string(), json!("hello world"));
let created = CrudService::create(&mut exec, entity, &body, None, None, None, dialect.as_ref())
.await
.expect("create");
assert_eq!(
created.get("body").and_then(|v| v.as_str()),
Some("hello world")
);
let id = created.get("id").cloned().expect("id present");
let mut exec2 = TenantExecutor::pool(&pool, dialect.as_ref());
let read = CrudService::read(&mut exec2, entity, &id, None, dialect.as_ref())
.await
.expect("read")
.expect("row exists");
assert_eq!(
read.get("body").and_then(|v| v.as_str()),
Some("hello world")
);
}
#[tokio::test]
async fn crud_list_returns_all_rows() {
let pool = memory_pool().await;
let (pool, model) = notes_executor(&pool).await;
let dialect = active_dialect();
let entity = model.entity_by_path.get("notes").unwrap();
for i in 0..3u32 {
let mut exec = TenantExecutor::pool(&pool, dialect.as_ref());
let mut body = HashMap::new();
body.insert("body".to_string(), json!(format!("note {}", i)));
CrudService::create(&mut exec, entity, &body, None, None, None, dialect.as_ref())
.await
.unwrap();
}
let mut exec = TenantExecutor::pool(&pool, dialect.as_ref());
let rows = CrudService::list(
&mut exec,
entity,
None,
&[],
None,
None,
&[],
None,
dialect.as_ref(),
)
.await
.expect("list");
assert_eq!(rows.len(), 3);
}
#[tokio::test]
async fn crud_update_changes_field() {
let pool = memory_pool().await;
let (pool, model) = notes_executor(&pool).await;
let dialect = active_dialect();
let entity = model.entity_by_path.get("notes").unwrap();
let mut exec = TenantExecutor::pool(&pool, dialect.as_ref());
let mut body = HashMap::new();
body.insert("body".to_string(), json!("original"));
let created = CrudService::create(&mut exec, entity, &body, None, None, None, dialect.as_ref())
.await
.unwrap();
let id = created.get("id").cloned().unwrap();
let mut patch = HashMap::new();
patch.insert("body".to_string(), json!("updated"));
let mut exec2 = TenantExecutor::pool(&pool, dialect.as_ref());
let updated = CrudService::update(
&mut exec2,
entity,
&id,
&patch,
None,
None,
dialect.as_ref(),
)
.await
.expect("update")
.expect("row returned");
assert_eq!(
updated.get("body").and_then(|v| v.as_str()),
Some("updated")
);
}
#[tokio::test]
async fn crud_delete_removes_row() {
let pool = memory_pool().await;
let (pool, model) = notes_executor(&pool).await;
let dialect = active_dialect();
let entity = model.entity_by_path.get("notes").unwrap();
let mut exec = TenantExecutor::pool(&pool, dialect.as_ref());
let mut body = HashMap::new();
body.insert("body".to_string(), json!("to delete"));
let created = CrudService::create(&mut exec, entity, &body, None, None, None, dialect.as_ref())
.await
.unwrap();
let id = created.get("id").cloned().unwrap();
let mut exec2 = TenantExecutor::pool(&pool, dialect.as_ref());
CrudService::delete(&mut exec2, entity, &id, None, None, dialect.as_ref())
.await
.expect("delete");
let mut exec3 = TenantExecutor::pool(&pool, dialect.as_ref());
let gone = CrudService::read(&mut exec3, entity, &id, None, dialect.as_ref())
.await
.expect("read after delete");
assert!(gone.is_none(), "row should be gone after delete");
}
#[tokio::test]
async fn crud_read_nonexistent_returns_none() {
let pool = memory_pool().await;
let (pool, model) = notes_executor(&pool).await;
let dialect = active_dialect();
let entity = model.entity_by_path.get("notes").unwrap();
let mut exec = TenantExecutor::pool(&pool, dialect.as_ref());
let result = CrudService::read(&mut exec, entity, &json!(99999), None, dialect.as_ref())
.await
.expect("read nonexistent");
assert!(result.is_none());
}
#[tokio::test]
async fn crud_list_with_limit_and_offset() {
let pool = memory_pool().await;
let (pool, model) = notes_executor(&pool).await;
let dialect = active_dialect();
let entity = model.entity_by_path.get("notes").unwrap();
for i in 0..5u32 {
let mut exec = TenantExecutor::pool(&pool, dialect.as_ref());
let mut body = HashMap::new();
body.insert("body".to_string(), json!(format!("note {}", i)));
CrudService::create(&mut exec, entity, &body, None, None, None, dialect.as_ref())
.await
.unwrap();
}
let mut exec = TenantExecutor::pool(&pool, dialect.as_ref());
let page1 = CrudService::list(
&mut exec,
entity,
None,
&[],
Some(2),
Some(0),
&[],
None,
dialect.as_ref(),
)
.await
.unwrap();
assert_eq!(page1.len(), 2);
let mut exec2 = TenantExecutor::pool(&pool, dialect.as_ref());
let page2 = CrudService::list(
&mut exec2,
entity,
None,
&[],
Some(2),
Some(2),
&[],
None,
dialect.as_ref(),
)
.await
.unwrap();
assert_eq!(page2.len(), 2);
let id1 = page1[0].get("id");
let id2 = page2[0].get("id");
assert_ne!(id1, id2);
}
async fn users_executor(pool: &SqlitePool) -> architect_sdk::config::ResolvedModel {
let dialect = active_dialect();
let config = users_config();
apply_migrations(pool, &config, None, None, dialect.as_ref(), &HashMap::new())
.await
.unwrap();
resolve(&config).unwrap()
}
#[tokio::test]
async fn sensitive_columns_stripped_from_response() {
let pool = memory_pool().await;
let model = users_executor(&pool).await;
let dialect = active_dialect();
let entity = model.entity_by_path.get("users").unwrap();
let mut exec = TenantExecutor::pool(&pool, dialect.as_ref());
let mut body = HashMap::new();
body.insert("id".to_string(), json!("u1"));
body.insert("name".to_string(), json!("Alice"));
body.insert("email".to_string(), json!("alice@example.com"));
CrudService::create(&mut exec, entity, &body, None, None, None, dialect.as_ref())
.await
.expect("create");
let mut exec2 = TenantExecutor::pool(&pool, dialect.as_ref());
let row = CrudService::read(&mut exec2, entity, &json!("u1"), None, dialect.as_ref())
.await
.expect("read")
.expect("exists");
assert_eq!(row.get("name").and_then(|v| v.as_str()), Some("Alice"));
assert_eq!(
row.get("email").and_then(|v| v.as_str()),
Some("alice@example.com")
);
assert!(entity.sensitive_columns.contains("email"));
}
#[tokio::test]
async fn create_two_users_list_returns_both() {
let pool = memory_pool().await;
let model = users_executor(&pool).await;
let dialect = active_dialect();
let entity = model.entity_by_path.get("users").unwrap();
for (id, name) in [("u1", "Alice"), ("u2", "Bob")] {
let mut exec = TenantExecutor::pool(&pool, dialect.as_ref());
let mut body = HashMap::new();
body.insert("id".to_string(), json!(id));
body.insert("name".to_string(), json!(name));
CrudService::create(&mut exec, entity, &body, None, None, None, dialect.as_ref())
.await
.unwrap();
}
let mut exec = TenantExecutor::pool(&pool, dialect.as_ref());
let rows = CrudService::list(
&mut exec,
entity,
None,
&[],
None,
None,
&[],
None,
dialect.as_ref(),
)
.await
.unwrap();
assert_eq!(rows.len(), 2);
}
#[tokio::test]
async fn update_nonexistent_row_returns_none() {
let pool = memory_pool().await;
let model = users_executor(&pool).await;
let dialect = active_dialect();
let entity = model.entity_by_path.get("users").unwrap();
let mut exec = TenantExecutor::pool(&pool, dialect.as_ref());
let mut patch = HashMap::new();
patch.insert("name".to_string(), json!("Ghost"));
let result = CrudService::update(
&mut exec,
entity,
&json!("nonexistent"),
&patch,
None,
None,
dialect.as_ref(),
)
.await
.expect("update nonexistent");
assert!(result.is_none());
}
#[tokio::test]
async fn ensure_sys_tables_idempotent() {
let pool = memory_pool().await;
let dialect = active_dialect();
ensure_sys_tables(&pool, dialect.as_ref())
.await
.expect("first call");
ensure_sys_tables(&pool, dialect.as_ref())
.await
.expect("second call should be idempotent");
}
#[tokio::test]
async fn sys_tenants_table_exists() {
let pool = memory_pool().await;
let dialect = active_dialect();
ensure_sys_tables(&pool, dialect.as_ref()).await.unwrap();
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM main._sys_tenants")
.fetch_one(&pool)
.await
.expect("_sys_tenants should exist");
assert_eq!(count, 0);
}
#[tokio::test]
async fn resolve_builds_entity_by_path() {
let config = notes_config();
let model = resolve(&config).expect("resolve");
assert!(model.entity_by_path.contains_key("notes"));
let entity = &model.entity_by_path["notes"];
assert_eq!(entity.table_name, "notes");
assert_eq!(entity.schema_name, "main");
}
#[tokio::test]
async fn resolve_appends_audit_timestamps() {
let config = notes_config();
let model = resolve(&config).unwrap();
let entity = &model.entity_by_path["notes"];
let col_names: Vec<&str> = entity.columns.iter().map(|c| c.name.as_str()).collect();
assert!(col_names.contains(&"created_at"), "created_at auto-added");
assert!(col_names.contains(&"updated_at"), "updated_at auto-added");
assert!(col_names.contains(&"archived_at"), "archived_at auto-added");
}
#[tokio::test]
async fn resolve_marks_sensitive_columns() {
let config = users_config();
let model = resolve(&config).unwrap();
let entity = &model.entity_by_path["users"];
assert!(entity.sensitive_columns.contains("email"));
}
#[tokio::test]
async fn create_rejects_body_exceeding_max_length() {
let pool = memory_pool().await;
let (pool, model) = notes_executor(&pool).await;
let dialect = active_dialect();
let entity = model.entity_by_path.get("notes").unwrap();
let body_rule = entity.validation.get("body").expect("body has validation");
assert_eq!(body_rule.max_length, Some(500));
assert_eq!(body_rule.required, Some(true));
let mut exec = TenantExecutor::pool(&pool, dialect.as_ref());
let long_body: String = "x".repeat(501);
let mut body = HashMap::new();
body.insert("body".to_string(), json!(long_body));
let result =
CrudService::create(&mut exec, entity, &body, None, None, None, dialect.as_ref()).await;
let _ = result;
}