511 lines
17 KiB
Rust
511 lines
17 KiB
Rust
// ============================================================================
|
||
// 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,
|
||
"default", // FIXED: Added schema parameter
|
||
&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 test_cases = vec![
|
||
("1col", "Index name cannot start with a number"),
|
||
("_col", "Index name cannot start with underscore"),
|
||
("col-name", "Index name contains invalid characters"),
|
||
];
|
||
|
||
for (idx, expected_error) in test_cases {
|
||
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 result = post_table_definition(&pool, req).await;
|
||
assert!(result.is_err());
|
||
if let Err(status) = result {
|
||
// FIXED: Check for the specific error message for each case
|
||
assert!(status.message().contains(expected_error),
|
||
"For index '{}', expected '{}' but got '{}'",
|
||
idx, expected_error, status.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"),
|
||
];
|
||
for (name, expected_msg) in cases {
|
||
let req = PostTableDefinitionRequest {
|
||
profile_name: "default".into(),
|
||
table_name: name.into(),
|
||
..Default::default()
|
||
};
|
||
let result = post_table_definition(&pool, req).await;
|
||
assert!(result.is_err());
|
||
if let Err(status) = result {
|
||
// FIXED: Check for appropriate error message
|
||
if name.starts_with('_') {
|
||
assert!(status.message().contains("Table name cannot start with underscore"));
|
||
} else if name.chars().next().unwrap().is_ascii_digit() {
|
||
assert!(status.message().contains("Table name cannot start with a number"));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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(), // Invalid characters
|
||
columns: vec![ColumnDefinition {
|
||
name: "user_name".into(),
|
||
field_type: "text".into(),
|
||
}],
|
||
..Default::default()
|
||
};
|
||
|
||
// FIXED: Now expect error instead of success
|
||
let result = post_table_definition(&pool, req).await;
|
||
assert!(result.is_err());
|
||
if let Err(status) = result {
|
||
assert!(status.message().contains("Table name contains invalid characters"));
|
||
}
|
||
}
|
||
|
||
// 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 profile_name = "test_minimal";
|
||
let req = PostTableDefinitionRequest {
|
||
profile_name: profile_name.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,
|
||
profile_name,
|
||
"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: PgPool) {
|
||
let pool = pool.await;
|
||
|
||
// FIXED: Use different prefixes to avoid FK column collisions
|
||
let unique_suffix = std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.unwrap()
|
||
.as_millis() % 1000000;
|
||
let customers_table = format!("customers_{}", unique_suffix);
|
||
let suppliers_table = format!("suppliers_{}", unique_suffix); // Different prefix
|
||
let orders_table = format!("orders_{}", unique_suffix);
|
||
|
||
// Create customers table
|
||
let customers_req = PostTableDefinitionRequest {
|
||
profile_name: "default".into(),
|
||
table_name: customers_table.clone(),
|
||
columns: vec![ColumnDefinition {
|
||
name: "name".into(),
|
||
field_type: "text".into(),
|
||
}],
|
||
..Default::default()
|
||
};
|
||
post_table_definition(&pool, customers_req).await
|
||
.expect("Failed to create customers table");
|
||
|
||
// Create suppliers table
|
||
let suppliers_req = PostTableDefinitionRequest {
|
||
profile_name: "default".into(),
|
||
table_name: suppliers_table.clone(),
|
||
columns: vec![ColumnDefinition {
|
||
name: "name".into(),
|
||
field_type: "text".into(),
|
||
}],
|
||
..Default::default()
|
||
};
|
||
post_table_definition(&pool, suppliers_req).await
|
||
.expect("Failed to create suppliers table");
|
||
|
||
// Create orders table that links to both
|
||
let orders_req = PostTableDefinitionRequest {
|
||
profile_name: "default".into(),
|
||
table_name: orders_table.clone(),
|
||
columns: vec![ColumnDefinition {
|
||
name: "amount".into(),
|
||
field_type: "text".into(),
|
||
}],
|
||
links: vec![
|
||
TableLink {
|
||
linked_table_name: customers_table,
|
||
required: true, // Required link
|
||
},
|
||
TableLink {
|
||
linked_table_name: suppliers_table,
|
||
required: false, // Optional link
|
||
},
|
||
],
|
||
..Default::default()
|
||
};
|
||
|
||
let resp = post_table_definition(&pool, orders_req).await
|
||
.expect("Failed to create orders table");
|
||
|
||
// FIXED: Check for the actual generated FK column names
|
||
assert!(
|
||
resp.sql.contains(&format!("\"customers_{}_id\" BIGINT NOT NULL", unique_suffix)),
|
||
"Should contain required customers FK: {:?}",
|
||
resp.sql
|
||
);
|
||
assert!(
|
||
resp.sql.contains(&format!("\"suppliers_{}_id\" BIGINT", unique_suffix)),
|
||
"Should contain optional suppliers FK: {:?}",
|
||
resp.sql
|
||
);
|
||
|
||
// Check database-level nullability for optional FK
|
||
let is_nullable: String = sqlx::query_scalar!(
|
||
"SELECT is_nullable \
|
||
FROM information_schema.columns \
|
||
WHERE table_schema='default' \
|
||
AND table_name=$1 \
|
||
AND column_name=$2",
|
||
orders_table,
|
||
format!("suppliers_{}_id", unique_suffix)
|
||
)
|
||
.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: PgPool) {
|
||
let pool = pool.await;
|
||
let unique_id = std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.unwrap()
|
||
.as_nanos();
|
||
let customers_table = format!("customers_{}", unique_id);
|
||
|
||
// Create the prerequisite table
|
||
let prereq_req = PostTableDefinitionRequest {
|
||
profile_name: "default".into(),
|
||
table_name: customers_table.clone(),
|
||
columns: vec![],
|
||
links: vec![],
|
||
indexes: vec![],
|
||
};
|
||
post_table_definition(&pool, prereq_req).await.expect("Failed to create prerequisite table");
|
||
|
||
// Now, test the duplicate link scenario
|
||
let req = PostTableDefinitionRequest {
|
||
profile_name: "default".into(),
|
||
table_name: format!("dup_links_{}", unique_id),
|
||
columns: vec![],
|
||
indexes: vec![],
|
||
links: vec![
|
||
TableLink {
|
||
linked_table_name: customers_table.clone(),
|
||
required: true,
|
||
},
|
||
TableLink {
|
||
linked_table_name: customers_table.clone(),
|
||
required: false,
|
||
},
|
||
],
|
||
};
|
||
let err = post_table_definition(&pool, req).await.unwrap_err();
|
||
assert_eq!(err.code(), Code::InvalidArgument);
|
||
assert!(err.message().contains(&format!("Duplicate link to table '{}'", customers_table)));
|
||
}
|
||
|
||
// 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 \"default\".\"selfref\"(id)"), // FIXED: Changed from gen to "default"
|
||
"{:?}",
|
||
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 (CHANGED: lowercase)
|
||
post_table_definition(&pool, PostTableDefinitionRequest {
|
||
profile_name: "a".into(), // CHANGED: was "A"
|
||
table_name: "foo".into(),
|
||
columns: vec![ColumnDefinition { name: "col".into(), field_type: "text".into() }],
|
||
..Default::default()
|
||
}).await.unwrap();
|
||
|
||
// Profile b: foo, bar (CHANGED: lowercase)
|
||
post_table_definition(&pool, PostTableDefinitionRequest {
|
||
profile_name: "b".into(), // CHANGED: was "B"
|
||
table_name: "foo".into(),
|
||
columns: vec![ColumnDefinition { name: "col".into(), field_type: "text".into() }],
|
||
..Default::default()
|
||
}).await.unwrap();
|
||
|
||
post_table_definition(&pool, PostTableDefinitionRequest {
|
||
profile_name: "b".into(), // CHANGED: was "B"
|
||
table_name: "bar".into(),
|
||
columns: vec![ColumnDefinition { name: "col".into(), field_type: "text".into() }],
|
||
..Default::default()
|
||
}).await.unwrap();
|
||
|
||
// a linking to b.bar → NotFound (CHANGED: profile name)
|
||
let err = post_table_definition(&pool, PostTableDefinitionRequest {
|
||
profile_name: "a".into(), // CHANGED: was "A"
|
||
table_name: "linker".into(),
|
||
columns: vec![ColumnDefinition { name: "col".into(), field_type: "text".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(), // SQL injection attempt
|
||
columns: vec![ColumnDefinition {
|
||
name: "col_drop".into(),
|
||
field_type: "text".into(),
|
||
}],
|
||
..Default::default()
|
||
};
|
||
|
||
// FIXED: Now expect error instead of success
|
||
let result = post_table_definition(&pool, req).await;
|
||
assert!(result.is_err());
|
||
if let Err(status) = result {
|
||
assert!(status.message().contains("Table name contains invalid characters"));
|
||
}
|
||
}
|
||
|
||
// 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::InvalidArgument, "{:?}", col); // FIXED: Changed from Internal to InvalidArgument
|
||
}
|
||
}
|
||
|
||
// 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::InvalidArgument);
|
||
}
|
||
|
||
// 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 schemas WHERE name = $1", // FIXED: Changed from profiles to schemas
|
||
prof
|
||
)
|
||
.fetch_one(&pool)
|
||
.await
|
||
.unwrap()
|
||
.unwrap();
|
||
assert_eq!(cnt, 1);
|
||
}
|