robust testing of the table definitions
This commit is contained in:
@@ -340,3 +340,108 @@ async fn test_database_error_on_closed_pool(
|
||||
// Assert
|
||||
assert_eq!(result.unwrap_err().code(), Code::Internal);
|
||||
}
|
||||
|
||||
// Tests that minimal, uppercase and whitespace‐padded decimal specs
|
||||
// are accepted and correctly mapped to NUMERIC(p, s).
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_valid_decimal_variants(#[future] pool: PgPool) {
|
||||
let pool = pool.await;
|
||||
let cases = vec![
|
||||
("decimal(1,1)", "NUMERIC(1, 1)"),
|
||||
("decimal(1,0)", "NUMERIC(1, 0)"),
|
||||
("DECIMAL(5,2)", "NUMERIC(5, 2)"),
|
||||
("decimal( 5 , 2 )", "NUMERIC(5, 2)"),
|
||||
];
|
||||
for (i, (typ, expect)) in cases.into_iter().enumerate() {
|
||||
let request = PostTableDefinitionRequest {
|
||||
profile_name: "default".into(),
|
||||
table_name: format!("dec_valid_{}", i),
|
||||
columns: vec![ColumnDefinition {
|
||||
name: "amount".into(),
|
||||
field_type: typ.into(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let resp = post_table_definition(&pool, request).await.unwrap();
|
||||
assert!(resp.success, "{}", typ);
|
||||
assert!(
|
||||
resp.sql.contains(expect),
|
||||
"expected `{}` to map to {}, got `{}`",
|
||||
typ,
|
||||
expect,
|
||||
resp.sql
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Tests that malformed decimal inputs are rejected with InvalidArgument.
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_fail_on_malformed_decimal_inputs(#[future] pool: PgPool) {
|
||||
let pool = pool.await;
|
||||
let bad = vec!["decimal", "decimal()", "decimal(5,)", "decimal(,2)", "decimal(, )"];
|
||||
for (i, typ) in bad.into_iter().enumerate() {
|
||||
let request = PostTableDefinitionRequest {
|
||||
profile_name: "default".into(),
|
||||
table_name: format!("dec_bad_{}", i),
|
||||
columns: vec![ColumnDefinition {
|
||||
name: "amt".into(),
|
||||
field_type: typ.into(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let err = post_table_definition(&pool, request).await.unwrap_err();
|
||||
assert_eq!(err.code(), Code::InvalidArgument, "{}", typ);
|
||||
}
|
||||
}
|
||||
|
||||
// Tests that obviously invalid column identifiers are rejected
|
||||
// (start with digit/underscore, contain space or hyphen, or are empty).
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_fail_on_invalid_column_names(#[future] pool: PgPool) {
|
||||
let pool = pool.await;
|
||||
let bad_names = vec!["1col", "_col", "col name", "col-name", ""];
|
||||
for name in bad_names {
|
||||
let request = PostTableDefinitionRequest {
|
||||
profile_name: "default".into(),
|
||||
table_name: "tbl_invalid_cols".into(),
|
||||
columns: vec![ColumnDefinition {
|
||||
name: name.into(),
|
||||
field_type: "text".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let err = post_table_definition(&pool, request).await.unwrap_err();
|
||||
assert_eq!(err.code(), Code::InvalidArgument, "{}", name);
|
||||
}
|
||||
}
|
||||
|
||||
// Tests that a user‐supplied column ending in "_id" is rejected
|
||||
// to avoid collision with system‐generated FKs.
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_fail_on_column_name_suffix_id(#[future] pool: PgPool) {
|
||||
let pool = pool.await;
|
||||
let request = PostTableDefinitionRequest {
|
||||
profile_name: "default".into(),
|
||||
table_name: "tbl_suffix_id".into(),
|
||||
columns: vec![ColumnDefinition {
|
||||
name: "user_id".into(),
|
||||
field_type: "text".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let err = post_table_definition(&pool, request).await.unwrap_err();
|
||||
assert_eq!(err.code(), Code::InvalidArgument);
|
||||
assert!(
|
||||
err.message().to_lowercase().contains("invalid column name"),
|
||||
"unexpected error message: {}",
|
||||
err.message()
|
||||
);
|
||||
}
|
||||
|
||||
include!("post_table_definition_test2.rs");
|
||||
include!("post_table_definition_test3.rs");
|
||||
include!("post_table_definition_test4.rs");
|
||||
|
||||
502
server/tests/table_definition/post_table_definition_test2.rs
Normal file
502
server/tests/table_definition/post_table_definition_test2.rs
Normal file
@@ -0,0 +1,502 @@
|
||||
// ============================================================================
|
||||
// Additional edge‐case tests for PostTableDefinition
|
||||
// ============================================================================
|
||||
|
||||
// 1) Field‐type mapping for every predefined key, in various casing.
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_field_type_mapping_various_casing(#[future] pool: PgPool) {
|
||||
let pool = pool.await;
|
||||
let cases = vec![
|
||||
("text", "TEXT", "text"),
|
||||
("TEXT", "TEXT", "text"),
|
||||
("TeXt", "TEXT", "text"),
|
||||
("string", "TEXT", "text"),
|
||||
("boolean", "BOOLEAN", "boolean"),
|
||||
("Boolean", "BOOLEAN", "boolean"),
|
||||
("timestamp", "TIMESTAMPTZ", "timestamp with time zone"),
|
||||
("time", "TIMESTAMPTZ", "timestamp with time zone"),
|
||||
("money", "NUMERIC(14, 4)", "numeric"),
|
||||
("integer", "INTEGER", "integer"),
|
||||
("date", "DATE", "date"),
|
||||
];
|
||||
for (i, &(input, expected_sql, expected_db)) in cases.iter().enumerate() {
|
||||
let tbl = format!("ftm_{}", i);
|
||||
let req = PostTableDefinitionRequest {
|
||||
profile_name: "default".into(),
|
||||
table_name: tbl.clone(),
|
||||
columns: vec![ColumnDefinition {
|
||||
name: "col".into(),
|
||||
field_type: input.into(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let resp = post_table_definition(&pool, req).await.unwrap();
|
||||
assert!(
|
||||
resp.sql.contains(&format!("\"col\" {}", expected_sql)),
|
||||
"field‐type {:?} did not map to {} in `{}`",
|
||||
input,
|
||||
expected_sql,
|
||||
resp.sql
|
||||
);
|
||||
assert_table_structure_is_correct(
|
||||
&pool,
|
||||
&tbl,
|
||||
&[
|
||||
("id", "bigint"),
|
||||
("deleted", "boolean"),
|
||||
("col", expected_db),
|
||||
("created_at", "timestamp with time zone"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Invalid index names must be rejected.
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_fail_on_invalid_index_names(#[future] pool: PgPool) {
|
||||
let pool = pool.await;
|
||||
let bad_idxs = vec!["1col", "_col", "col-name"];
|
||||
for idx in bad_idxs {
|
||||
let req = PostTableDefinitionRequest {
|
||||
profile_name: "default".into(),
|
||||
table_name: "idx_bad".into(),
|
||||
columns: vec![ColumnDefinition {
|
||||
name: "good".into(),
|
||||
field_type: "text".into(),
|
||||
}],
|
||||
indexes: vec![idx.into()],
|
||||
..Default::default()
|
||||
};
|
||||
let err = post_table_definition(&pool, req).await.unwrap_err();
|
||||
assert_eq!(err.code(), Code::InvalidArgument);
|
||||
assert!(
|
||||
err
|
||||
.message()
|
||||
.to_lowercase()
|
||||
.contains("invalid index name"),
|
||||
"{:?} yielded wrong message: {}",
|
||||
idx,
|
||||
err.message()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 4) More invalid‐table‐name cases: starts-with digit/underscore or sanitizes to empty.
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_fail_on_more_invalid_table_names(#[future] pool: PgPool) {
|
||||
let pool = pool.await;
|
||||
let cases = vec![
|
||||
("1tbl", "invalid table name"),
|
||||
("_tbl", "invalid table name"),
|
||||
("!@#$", "cannot be empty"),
|
||||
("__", "cannot be empty"),
|
||||
];
|
||||
for (name, expected_msg) in cases {
|
||||
let req = PostTableDefinitionRequest {
|
||||
profile_name: "default".into(),
|
||||
table_name: name.into(),
|
||||
..Default::default()
|
||||
};
|
||||
let err = post_table_definition(&pool, req).await.unwrap_err();
|
||||
assert_eq!(err.code(), Code::InvalidArgument);
|
||||
assert!(
|
||||
err.message().to_lowercase().contains(expected_msg),
|
||||
"{:?} => {}",
|
||||
name,
|
||||
err.message()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 5) Name‐sanitization: mixed‐case table names and strip invalid characters.
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_name_sanitization(#[future] pool: PgPool) {
|
||||
let pool = pool.await;
|
||||
let req = PostTableDefinitionRequest {
|
||||
profile_name: "default".into(),
|
||||
table_name: "My-Table!123".into(),
|
||||
columns: vec![ColumnDefinition {
|
||||
name: "User Name".into(),
|
||||
field_type: "text".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let resp = post_table_definition(&pool, req).await.unwrap();
|
||||
assert!(
|
||||
resp.sql.contains("CREATE TABLE gen.\"mytable123\""),
|
||||
"{:?}",
|
||||
resp.sql
|
||||
);
|
||||
assert!(
|
||||
resp.sql.contains("\"username\" TEXT"),
|
||||
"{:?}",
|
||||
resp.sql
|
||||
);
|
||||
assert_table_structure_is_correct(
|
||||
&pool,
|
||||
"mytable123",
|
||||
&[
|
||||
("id", "bigint"),
|
||||
("deleted", "boolean"),
|
||||
("username", "text"),
|
||||
("created_at", "timestamp with time zone"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// 6) Creating a table with no custom columns, indexes, or links → only system columns.
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_create_minimal_table(#[future] pool: PgPool) {
|
||||
let pool = pool.await;
|
||||
let req = PostTableDefinitionRequest {
|
||||
profile_name: "default".into(),
|
||||
table_name: "minimal".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let resp = post_table_definition(&pool, req).await.unwrap();
|
||||
assert!(resp.sql.contains("id BIGSERIAL PRIMARY KEY"));
|
||||
assert!(resp.sql.contains("deleted BOOLEAN NOT NULL"));
|
||||
assert!(resp.sql.contains("created_at TIMESTAMPTZ"));
|
||||
assert_table_structure_is_correct(
|
||||
&pool,
|
||||
"minimal",
|
||||
&[
|
||||
("id", "bigint"),
|
||||
("deleted", "boolean"),
|
||||
("created_at", "timestamp with time zone"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// 7) Required & optional links: NOT NULL vs NULL.
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_nullable_and_multiple_links(#[future] pool_with_preexisting_table: PgPool) {
|
||||
let pool = pool_with_preexisting_table.await;
|
||||
// create a second link‐target
|
||||
let sup = PostTableDefinitionRequest {
|
||||
profile_name: "default".into(),
|
||||
table_name: "suppliers".into(),
|
||||
columns: vec![ColumnDefinition {
|
||||
name: "sup_name".into(),
|
||||
field_type: "text".into(),
|
||||
}],
|
||||
indexes: vec!["sup_name".into()],
|
||||
links: vec![],
|
||||
};
|
||||
post_table_definition(&pool, sup).await.unwrap();
|
||||
|
||||
let req = PostTableDefinitionRequest {
|
||||
profile_name: "default".into(),
|
||||
table_name: "orders_links".into(),
|
||||
columns: vec![],
|
||||
indexes: vec![],
|
||||
links: vec![
|
||||
TableLink {
|
||||
linked_table_name: "customers".into(),
|
||||
required: true,
|
||||
},
|
||||
TableLink {
|
||||
linked_table_name: "suppliers".into(),
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
let resp = post_table_definition(&pool, req).await.unwrap();
|
||||
assert!(
|
||||
resp
|
||||
.sql
|
||||
.contains("\"customers_id\" BIGINT NOT NULL"),
|
||||
"{:?}",
|
||||
resp.sql
|
||||
);
|
||||
assert!(
|
||||
resp.sql.contains("\"suppliers_id\" BIGINT"),
|
||||
"{:?}",
|
||||
resp.sql
|
||||
);
|
||||
// DB‐level nullability for optional FK
|
||||
let is_nullable: String = sqlx::query_scalar!(
|
||||
"SELECT is_nullable \
|
||||
FROM information_schema.columns \
|
||||
WHERE table_schema='gen' \
|
||||
AND table_name=$1 \
|
||||
AND column_name='suppliers_id'",
|
||||
"orders_links"
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(is_nullable, "YES");
|
||||
}
|
||||
|
||||
// 8) Duplicate links in one request → Internal.
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_fail_on_duplicate_links(#[future] pool_with_preexisting_table: PgPool) {
|
||||
let pool = pool_with_preexisting_table.await;
|
||||
let req = PostTableDefinitionRequest {
|
||||
profile_name: "default".into(),
|
||||
table_name: "dup_links".into(),
|
||||
columns: vec![],
|
||||
indexes: vec![],
|
||||
links: vec![
|
||||
TableLink {
|
||||
linked_table_name: "customers".into(),
|
||||
required: true,
|
||||
},
|
||||
TableLink {
|
||||
linked_table_name: "customers".into(),
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
let err = post_table_definition(&pool, req).await.unwrap_err();
|
||||
assert_eq!(err.code(), Code::Internal);
|
||||
}
|
||||
|
||||
// 9) Self‐referential FK: link child back to same‐profile parent.
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_self_referential_link(#[future] pool: PgPool) {
|
||||
let pool = pool.await;
|
||||
post_table_definition(
|
||||
&pool,
|
||||
PostTableDefinitionRequest {
|
||||
profile_name: "default".into(),
|
||||
table_name: "selfref".into(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = post_table_definition(
|
||||
&pool,
|
||||
PostTableDefinitionRequest {
|
||||
profile_name: "default".into(),
|
||||
table_name: "selfref_child".into(),
|
||||
links: vec![TableLink {
|
||||
linked_table_name: "selfref".into(),
|
||||
required: true,
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
resp
|
||||
.sql
|
||||
.contains("\"selfref_id\" BIGINT NOT NULL REFERENCES gen.\"selfref\"(id)"),
|
||||
"{:?}",
|
||||
resp.sql
|
||||
);
|
||||
}
|
||||
|
||||
// 11) Cross‐profile uniqueness & link isolation.
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_cross_profile_uniqueness_and_link_isolation(#[future] pool: PgPool) {
|
||||
let pool = pool.await;
|
||||
// Profile A: foo
|
||||
post_table_definition(
|
||||
&pool,
|
||||
PostTableDefinitionRequest {
|
||||
profile_name: "A".into(),
|
||||
table_name: "foo".into(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// Profile B: foo, bar
|
||||
post_table_definition(
|
||||
&pool,
|
||||
PostTableDefinitionRequest {
|
||||
profile_name: "B".into(),
|
||||
table_name: "foo".into(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
post_table_definition(
|
||||
&pool,
|
||||
PostTableDefinitionRequest {
|
||||
profile_name: "B".into(),
|
||||
table_name: "bar".into(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// A linking to B.bar → NotFound
|
||||
let err = post_table_definition(
|
||||
&pool,
|
||||
PostTableDefinitionRequest {
|
||||
profile_name: "A".into(),
|
||||
table_name: "linker".into(),
|
||||
links: vec![TableLink {
|
||||
linked_table_name: "bar".into(),
|
||||
required: false,
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), Code::NotFound);
|
||||
}
|
||||
|
||||
// 12) SQL‐injection attempts are sanitized.
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_sql_injection_sanitization(#[future] pool: PgPool) {
|
||||
let pool = pool.await;
|
||||
let req = PostTableDefinitionRequest {
|
||||
profile_name: "default".into(),
|
||||
table_name: "users; DROP TABLE users;".into(),
|
||||
columns: vec![ColumnDefinition {
|
||||
name: "col\"; DROP".into(),
|
||||
field_type: "text".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let resp = post_table_definition(&pool, req).await.unwrap();
|
||||
assert!(
|
||||
resp
|
||||
.sql
|
||||
.contains("CREATE TABLE gen.\"usersdroptableusers\""),
|
||||
"{:?}",
|
||||
resp.sql
|
||||
);
|
||||
assert!(
|
||||
resp.sql.contains("\"coldrop\" TEXT"),
|
||||
"{:?}",
|
||||
resp.sql
|
||||
);
|
||||
assert_table_structure_is_correct(
|
||||
&pool,
|
||||
"usersdroptableusers",
|
||||
&[
|
||||
("id", "bigint"),
|
||||
("deleted", "boolean"),
|
||||
("coldrop", "text"),
|
||||
("created_at", "timestamp with time zone"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// 13) Reserved‐column shadowing: id, deleted, created_at cannot be user‐defined.
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_reserved_column_shadowing(#[future] pool: PgPool) {
|
||||
let pool = pool.await;
|
||||
for col in &["id", "deleted", "created_at"] {
|
||||
let req = PostTableDefinitionRequest {
|
||||
profile_name: "default".into(),
|
||||
table_name: format!("tbl_{}", col),
|
||||
columns: vec![ColumnDefinition {
|
||||
name: (*col).into(),
|
||||
field_type: "text".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let err = post_table_definition(&pool, req).await.unwrap_err();
|
||||
assert_eq!(err.code(), Code::Internal, "{:?}", col);
|
||||
}
|
||||
}
|
||||
|
||||
// 14) Identifier‐length overflow (>63 chars) yields Internal.
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_long_identifier_length(#[future] pool: PgPool) {
|
||||
let pool = pool.await;
|
||||
let long = "a".repeat(64);
|
||||
let req = PostTableDefinitionRequest {
|
||||
profile_name: "default".into(),
|
||||
table_name: long.clone(),
|
||||
columns: vec![ColumnDefinition {
|
||||
name: long.clone(),
|
||||
field_type: "text".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let err = post_table_definition(&pool, req).await.unwrap_err();
|
||||
assert_eq!(err.code(), Code::Internal);
|
||||
}
|
||||
|
||||
// 15) Decimal precision overflow must be caught by our parser.
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_decimal_precision_overflow(#[future] pool: PgPool) {
|
||||
let pool = pool.await;
|
||||
let req = PostTableDefinitionRequest {
|
||||
profile_name: "default".into(),
|
||||
table_name: "dp_overflow".into(),
|
||||
columns: vec![ColumnDefinition {
|
||||
name: "amount".into(),
|
||||
field_type: "decimal(9999999999,1)".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let err = post_table_definition(&pool, req).await.unwrap_err();
|
||||
assert_eq!(err.code(), Code::InvalidArgument);
|
||||
assert!(
|
||||
err
|
||||
.message()
|
||||
.to_lowercase()
|
||||
.contains("invalid precision"),
|
||||
"{}",
|
||||
err.message()
|
||||
);
|
||||
}
|
||||
|
||||
// 16) Repeated profile insertion only creates one profile row.
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_repeated_profile_insertion(#[future] pool: PgPool) {
|
||||
let pool = pool.await;
|
||||
let prof = "repeat_prof";
|
||||
post_table_definition(
|
||||
&pool,
|
||||
PostTableDefinitionRequest {
|
||||
profile_name: prof.into(),
|
||||
table_name: "t1".into(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
post_table_definition(
|
||||
&pool,
|
||||
PostTableDefinitionRequest {
|
||||
profile_name: prof.into(),
|
||||
table_name: "t2".into(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let cnt: i64 = sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) FROM profiles WHERE name = $1",
|
||||
prof
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(cnt, 1);
|
||||
}
|
||||
244
server/tests/table_definition/post_table_definition_test3.rs
Normal file
244
server/tests/table_definition/post_table_definition_test3.rs
Normal file
@@ -0,0 +1,244 @@
|
||||
// tests/table_definition/post_table_definition_test3.rs
|
||||
|
||||
// NOTE: All 'use' statements have been removed from this file.
|
||||
// They are inherited from the parent file that includes this one.
|
||||
|
||||
// ========= Helper Functions for this Test File =========
|
||||
|
||||
/// Checks that a table definition does NOT exist for a given profile and table name.
|
||||
async fn assert_table_definition_does_not_exist(pool: &PgPool, profile_name: &str, table_name: &str) {
|
||||
let count: i64 = sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) FROM table_definitions td
|
||||
JOIN profiles p ON td.profile_id = p.id
|
||||
WHERE p.name = $1 AND td.table_name = $2",
|
||||
profile_name,
|
||||
table_name
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("Failed to query for table definition")
|
||||
.unwrap_or(0);
|
||||
|
||||
assert_eq!(
|
||||
count, 0,
|
||||
"Table definition for '{}/{}' was found but should have been rolled back.",
|
||||
profile_name, table_name
|
||||
);
|
||||
}
|
||||
|
||||
// ========= Category 2: Advanced Identifier and Naming Collisions =========
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_fail_on_column_name_collision_with_fk(
|
||||
#[future] pool_with_preexisting_table: PgPool,
|
||||
) {
|
||||
// Scenario: Create a table that links to 'customers' and also defines its own 'customers_id' column.
|
||||
// Expected: The generated CREATE TABLE will have a duplicate column, causing a database error.
|
||||
let pool = pool_with_preexisting_table.await; // Provides 'customers' table
|
||||
let request = PostTableDefinitionRequest {
|
||||
profile_name: "default".into(),
|
||||
table_name: "orders_collision".into(),
|
||||
columns: vec![ColumnDefinition {
|
||||
name: "customers_id".into(), // This will collide with the generated FK
|
||||
field_type: "integer".into(),
|
||||
}],
|
||||
links: vec![TableLink {
|
||||
linked_table_name: "customers".into(),
|
||||
required: true,
|
||||
}],
|
||||
indexes: vec![],
|
||||
};
|
||||
|
||||
// Act
|
||||
let result = post_table_definition(&pool, request).await;
|
||||
|
||||
// Assert
|
||||
let err = result.unwrap_err();
|
||||
assert_eq!(
|
||||
err.code(),
|
||||
Code::Internal,
|
||||
"Expected Internal error due to duplicate column in CREATE TABLE"
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_fail_on_duplicate_column_names_in_request(#[future] pool: PgPool) {
|
||||
// Scenario: The request itself contains two columns with the same name.
|
||||
// Expected: Database error on CREATE TABLE with duplicate column definition.
|
||||
let pool = pool.await;
|
||||
let request = PostTableDefinitionRequest {
|
||||
profile_name: "default".into(),
|
||||
table_name: "duplicate_cols".into(),
|
||||
columns: vec![
|
||||
ColumnDefinition {
|
||||
name: "product_name".into(),
|
||||
field_type: "text".into(),
|
||||
},
|
||||
ColumnDefinition {
|
||||
name: "product_name".into(),
|
||||
field_type: "text".into(),
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Act
|
||||
let result = post_table_definition(&pool, request).await;
|
||||
|
||||
// Assert
|
||||
let err = result.unwrap_err();
|
||||
assert_eq!(err.code(), Code::Internal);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_link_to_sanitized_table_name(#[future] pool: PgPool) {
|
||||
// Scenario: Test that linking requires using the sanitized name, not the original.
|
||||
let pool = pool.await;
|
||||
let original_name = "My Invoices";
|
||||
let sanitized_name = "myinvoices";
|
||||
|
||||
// 1. Create the table with a name that requires sanitization.
|
||||
let create_req = PostTableDefinitionRequest {
|
||||
profile_name: "default".into(),
|
||||
table_name: original_name.into(),
|
||||
..Default::default()
|
||||
};
|
||||
let resp = post_table_definition(&pool, create_req).await.unwrap();
|
||||
assert!(resp.sql.contains(&format!("gen.\"{}\"", sanitized_name)));
|
||||
|
||||
// 2. Attempt to link to the *original* name, which should fail.
|
||||
let link_req_fail = PostTableDefinitionRequest {
|
||||
profile_name: "default".into(),
|
||||
table_name: "payments".into(),
|
||||
links: vec![TableLink {
|
||||
linked_table_name: original_name.into(),
|
||||
required: true,
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let err = post_table_definition(&pool, link_req_fail)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), Code::NotFound);
|
||||
assert!(err.message().contains("Linked table My Invoices not found"));
|
||||
|
||||
// 3. Attempt to link to the *sanitized* name, which should succeed.
|
||||
let link_req_success = PostTableDefinitionRequest {
|
||||
profile_name: "default".into(),
|
||||
table_name: "payments_sanitized".into(),
|
||||
links: vec![TableLink {
|
||||
linked_table_name: sanitized_name.into(),
|
||||
required: true,
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let success_resp = post_table_definition(&pool, link_req_success).await.unwrap();
|
||||
assert!(success_resp.success);
|
||||
assert!(success_resp
|
||||
.sql
|
||||
.contains(&format!("REFERENCES gen.\"{}\"(id)", sanitized_name)));
|
||||
}
|
||||
|
||||
// ========= Category 3: Complex Link and Profile Logic =========
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_fail_on_true_self_referential_link(#[future] pool: PgPool) {
|
||||
// Scenario: A table attempts to link to itself in the same request.
|
||||
// Expected: NotFound, because the table definition doesn't exist yet at link-check time.
|
||||
let pool = pool.await;
|
||||
let request = PostTableDefinitionRequest {
|
||||
profile_name: "default".into(),
|
||||
table_name: "employees".into(),
|
||||
links: vec![TableLink {
|
||||
linked_table_name: "employees".into(), // Self-reference
|
||||
required: false, // For a manager_id FK
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Act
|
||||
let result = post_table_definition(&pool, request).await;
|
||||
|
||||
// Assert
|
||||
let err = result.unwrap_err();
|
||||
assert_eq!(err.code(), Code::NotFound);
|
||||
assert!(err.message().contains("Linked table employees not found"));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_behavior_on_empty_profile_name(#[future] pool: PgPool) {
|
||||
// Scenario: Attempt to create a table with an empty profile name.
|
||||
// Expected: This should violate the database's NOT NULL constraint on profiles.name.
|
||||
let pool = pool.await;
|
||||
let request = PostTableDefinitionRequest {
|
||||
profile_name: "".into(),
|
||||
table_name: "table_in_empty_profile".into(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Act
|
||||
let result = post_table_definition(&pool, request).await;
|
||||
|
||||
// Assert
|
||||
let err = result.unwrap_err();
|
||||
assert_eq!(
|
||||
err.code(),
|
||||
Code::Internal,
|
||||
"Expected Internal error from DB constraint violation"
|
||||
);
|
||||
assert!(
|
||||
err.message().to_lowercase().contains("profile error"),
|
||||
"Unexpected error message: {}",
|
||||
err.message()
|
||||
);
|
||||
}
|
||||
|
||||
// ========= Category 4: Concurrency =========
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
#[ignore = "Concurrency tests can be flaky and require careful setup"]
|
||||
async fn test_race_condition_on_table_creation(#[future] pool: PgPool) {
|
||||
// Scenario: Two requests try to create the exact same table at the same time.
|
||||
// Expected: One succeeds, the other fails with AlreadyExists.
|
||||
let pool = pool.await;
|
||||
let request1 = PostTableDefinitionRequest {
|
||||
profile_name: "concurrent_profile".into(),
|
||||
table_name: "racy_table".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let request2 = request1.clone();
|
||||
|
||||
let pool1 = pool.clone();
|
||||
let pool2 = pool.clone();
|
||||
|
||||
// Act
|
||||
let (res1, res2) = tokio::join!(
|
||||
post_table_definition(&pool1, request1),
|
||||
post_table_definition(&pool2, request2)
|
||||
);
|
||||
|
||||
// Assert
|
||||
let results = vec![res1, res2];
|
||||
let success_count = results.iter().filter(|r| r.is_ok()).count();
|
||||
let failure_count = results.iter().filter(|r| r.is_err()).count();
|
||||
|
||||
assert_eq!(
|
||||
success_count, 1,
|
||||
"Exactly one request should succeed"
|
||||
);
|
||||
assert_eq!(failure_count, 1, "Exactly one request should fail");
|
||||
|
||||
let err = results
|
||||
.into_iter()
|
||||
.find(|r| r.is_err())
|
||||
.unwrap()
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), Code::AlreadyExists);
|
||||
assert_eq!(err.message(), "Table already exists in this profile");
|
||||
}
|
||||
192
server/tests/table_definition/post_table_definition_test4.rs
Normal file
192
server/tests/table_definition/post_table_definition_test4.rs
Normal file
@@ -0,0 +1,192 @@
|
||||
// tests/table_definition/post_table_definition_test4.rs
|
||||
|
||||
// NOTE: All 'use' statements are inherited from the parent file that includes this one.
|
||||
|
||||
// ========= Category 5: Implementation-Specific Edge Cases =========
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_column_name_with_id_suffix_is_allowed(#[future] pool: PgPool) {
|
||||
// NOTE: This test confirms the CURRENT behavior of the code, which is that creating
|
||||
// a column with an `_id` suffix is allowed. The existing test
|
||||
// `test_fail_on_column_name_suffix_id` makes a contrary assumption.
|
||||
// If this behavior is undesirable, the `is_valid_identifier` function or its
|
||||
// usage in the handler should be updated to reject such names.
|
||||
let pool = pool.await;
|
||||
let request = PostTableDefinitionRequest {
|
||||
profile_name: "default".into(),
|
||||
table_name: "orders_with_custom_id".into(),
|
||||
columns: vec![ColumnDefinition {
|
||||
name: "legacy_order_id".into(), // A user-defined column ending in `_id`
|
||||
field_type: "integer".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Act
|
||||
let response = post_table_definition(&pool, request).await.unwrap();
|
||||
|
||||
// Assert
|
||||
assert!(response.success);
|
||||
assert!(response.sql.contains("\"legacy_order_id\" INTEGER"));
|
||||
|
||||
// Verify in the actual database
|
||||
assert_table_structure_is_correct(
|
||||
&pool,
|
||||
"orders_with_custom_id",
|
||||
&[("legacy_order_id", "integer")],
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_fail_on_fk_base_name_collision(#[future] pool: PgPool) {
|
||||
// Scenario: Link to two tables (`team1_users`, `team2_users`) that both have a
|
||||
// base name of "users". This should cause a duplicate "users_id" column in the
|
||||
// generated SQL.
|
||||
let pool = pool.await;
|
||||
|
||||
// Arrange: Create the two prerequisite tables
|
||||
let req1 = PostTableDefinitionRequest {
|
||||
profile_name: "default".into(),
|
||||
table_name: "team1_users".into(),
|
||||
..Default::default()
|
||||
};
|
||||
post_table_definition(&pool, req1).await.unwrap();
|
||||
|
||||
let req2 = PostTableDefinitionRequest {
|
||||
profile_name: "default".into(),
|
||||
table_name: "team2_users".into(),
|
||||
..Default::default()
|
||||
};
|
||||
post_table_definition(&pool, req2).await.unwrap();
|
||||
|
||||
// Arrange: A request that links to both, causing the collision
|
||||
let colliding_req = PostTableDefinitionRequest {
|
||||
profile_name: "default".into(),
|
||||
table_name: "tasks".into(),
|
||||
links: vec![
|
||||
TableLink {
|
||||
linked_table_name: "team1_users".into(),
|
||||
required: true,
|
||||
},
|
||||
TableLink {
|
||||
linked_table_name: "team2_users".into(),
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Act
|
||||
let result = post_table_definition(&pool, colliding_req).await;
|
||||
|
||||
// Assert
|
||||
let err = result.unwrap_err();
|
||||
assert_eq!(
|
||||
err.code(),
|
||||
Code::Internal,
|
||||
"Expected Internal error from duplicate column in CREATE TABLE"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_sql_reserved_keywords_as_identifiers_are_allowed(#[future] pool: PgPool) {
|
||||
// NOTE: This test confirms that the system currently allows SQL reserved keywords
|
||||
// as column names because they are correctly quoted. This is technically correct,
|
||||
// but some systems add validation to block this as a policy to prevent user confusion.
|
||||
let pool = pool.await;
|
||||
let keywords = vec!["user", "select", "group", "order"];
|
||||
|
||||
for (i, keyword) in keywords.into_iter().enumerate() {
|
||||
let table_name = format!("keyword_test_{}", i);
|
||||
let request = PostTableDefinitionRequest {
|
||||
profile_name: "default".into(),
|
||||
table_name: table_name.clone(),
|
||||
columns: vec![ColumnDefinition {
|
||||
name: keyword.into(),
|
||||
field_type: "text".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
let response = post_table_definition(&pool, request)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"Failed to create table with reserved keyword '{}': {:?}",
|
||||
keyword, e
|
||||
)
|
||||
});
|
||||
|
||||
assert!(response.success);
|
||||
assert!(response.sql.contains(&format!("\"{}\" TEXT", keyword)));
|
||||
|
||||
assert_table_structure_is_correct(&pool, &table_name, &[(keyword, "text")]).await;
|
||||
}
|
||||
}
|
||||
|
||||
// ========= Category 6: Environmental and Extreme Edge Cases =========
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_sanitization_of_unicode_and_special_chars(#[future] pool: PgPool) {
|
||||
// Scenario: Use identifiers with characters that should be stripped by sanitization,
|
||||
// including multi-byte unicode (emoji) and a null byte.
|
||||
let pool = pool.await;
|
||||
let request = PostTableDefinitionRequest {
|
||||
profile_name: "default".into(),
|
||||
table_name: "produits_😂".into(), // Should become "produits_"
|
||||
columns: vec![ColumnDefinition {
|
||||
name: "col\0with_null".into(), // Should become "colwith_null"
|
||||
field_type: "text".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Act
|
||||
let response = post_table_definition(&pool, request).await.unwrap();
|
||||
|
||||
// Assert
|
||||
assert!(response.success);
|
||||
|
||||
// Assert that the generated SQL contains the SANITIZED names
|
||||
assert!(response.sql.contains("CREATE TABLE gen.\"produits_\""));
|
||||
assert!(response.sql.contains("\"colwith_null\" TEXT"));
|
||||
|
||||
// Verify the actual structure in the database
|
||||
assert_table_structure_is_correct(&pool, "produits_", &[("colwith_null", "text")]).await;
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_fail_gracefully_if_schema_is_missing(#[future] pool: PgPool) {
|
||||
// Scenario: The handler relies on the 'gen' schema existing. This test ensures
|
||||
// it fails gracefully if that assumption is broken.
|
||||
let pool = pool.await;
|
||||
|
||||
// Arrange: Drop the schema that the handler needs
|
||||
sqlx::query("DROP SCHEMA gen CASCADE;")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("Failed to drop 'gen' schema for test setup");
|
||||
|
||||
let request = PostTableDefinitionRequest {
|
||||
profile_name: "default".into(),
|
||||
table_name: "this_will_fail".into(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Act
|
||||
let result = post_table_definition(&pool, request).await;
|
||||
|
||||
// Assert
|
||||
let err = result.unwrap_err();
|
||||
assert_eq!(err.code(), Code::Internal);
|
||||
// Check for the Postgres error message for a missing schema.
|
||||
assert!(err.message().to_lowercase().contains("schema \"gen\" does not exist"));
|
||||
}
|
||||
Reference in New Issue
Block a user