448 lines
14 KiB
Rust
448 lines
14 KiB
Rust
// tests/table_definition/post_table_definition_test.rs
|
||
use crate::common::setup_test_db;
|
||
use common::proto::multieko2::table_definition::{
|
||
ColumnDefinition, PostTableDefinitionRequest, TableLink,
|
||
};
|
||
use rstest::{fixture, rstest};
|
||
use server::table_definition::handlers::post_table_definition;
|
||
use sqlx::{PgPool, Row};
|
||
use tonic::Code;
|
||
|
||
// ========= Fixtures =========
|
||
|
||
#[fixture]
|
||
async fn pool() -> PgPool {
|
||
setup_test_db().await
|
||
}
|
||
|
||
#[fixture]
|
||
async fn closed_pool(#[future] pool: PgPool) -> PgPool {
|
||
let pool = pool.await;
|
||
pool.close().await;
|
||
pool
|
||
}
|
||
|
||
/// A fixture that creates a pre-existing 'customers' table definition,
|
||
/// so we can test linking to it.
|
||
#[fixture]
|
||
async fn pool_with_preexisting_table(#[future] pool: PgPool) -> PgPool {
|
||
let pool = pool.await;
|
||
let create_customers_req = PostTableDefinitionRequest {
|
||
profile_name: "default".into(),
|
||
table_name: "customers".into(),
|
||
columns: vec![ColumnDefinition {
|
||
name: "customer_name".into(),
|
||
field_type: "text".into(),
|
||
}],
|
||
indexes: vec!["customer_name".into()],
|
||
links: vec![],
|
||
};
|
||
post_table_definition(&pool, create_customers_req)
|
||
.await
|
||
.expect("Failed to create pre-requisite 'customers' table");
|
||
pool
|
||
}
|
||
|
||
// ========= Helper Functions =========
|
||
|
||
/// Checks the PostgreSQL information_schema to verify a table and its columns exist.
|
||
async fn assert_table_structure_is_correct(
|
||
pool: &PgPool,
|
||
table_name: &str,
|
||
expected_cols: &[(&str, &str)],
|
||
) {
|
||
let table_exists = sqlx::query_scalar::<_, bool>(
|
||
"SELECT EXISTS (
|
||
SELECT FROM information_schema.tables
|
||
WHERE table_schema = 'gen' AND table_name = $1
|
||
)",
|
||
)
|
||
.bind(table_name)
|
||
.fetch_one(pool)
|
||
.await
|
||
.unwrap();
|
||
|
||
assert!(table_exists, "Table 'gen.{}' was not created", table_name);
|
||
|
||
for (col_name, col_type) in expected_cols {
|
||
let record = sqlx::query(
|
||
"SELECT data_type FROM information_schema.columns
|
||
WHERE table_schema = 'gen' AND table_name = $1 AND column_name = $2",
|
||
)
|
||
.bind(table_name)
|
||
.bind(col_name)
|
||
.fetch_optional(pool)
|
||
.await
|
||
.unwrap();
|
||
|
||
let found_type = record.unwrap_or_else(|| panic!("Column '{}' not found in table '{}'", col_name, table_name)).get::<String, _>("data_type");
|
||
|
||
// Handle type mappings, e.g., TEXT -> character varying, NUMERIC -> numeric
|
||
let normalized_found_type = found_type.to_lowercase();
|
||
let normalized_expected_type = col_type.to_lowercase();
|
||
|
||
assert!(
|
||
normalized_found_type.contains(&normalized_expected_type),
|
||
"Column '{}' has wrong type. Expected: {}, Found: {}",
|
||
col_name,
|
||
col_type,
|
||
found_type
|
||
);
|
||
}
|
||
}
|
||
|
||
// ========= Tests =========
|
||
|
||
#[rstest]
|
||
#[tokio::test]
|
||
async fn test_create_table_success(#[future] pool: PgPool) {
|
||
// Arrange
|
||
let pool = pool.await;
|
||
let request = PostTableDefinitionRequest {
|
||
profile_name: "default".into(),
|
||
table_name: "invoices".into(),
|
||
columns: vec![
|
||
ColumnDefinition {
|
||
name: "invoice_number".into(),
|
||
field_type: "text".into(),
|
||
},
|
||
ColumnDefinition {
|
||
name: "amount".into(),
|
||
field_type: "decimal(10, 2)".into(),
|
||
},
|
||
],
|
||
indexes: vec!["invoice_number".into()],
|
||
links: vec![],
|
||
};
|
||
|
||
// Act
|
||
let response = post_table_definition(&pool, request).await.unwrap();
|
||
|
||
// Assert
|
||
assert!(response.success);
|
||
assert!(response.sql.contains("CREATE TABLE gen.\"invoices\""));
|
||
assert!(response.sql.contains("\"invoice_number\" TEXT"));
|
||
assert!(response.sql.contains("\"amount\" NUMERIC(10, 2)"));
|
||
assert!(response
|
||
.sql
|
||
.contains("CREATE INDEX \"idx_invoices_invoice_number\""));
|
||
|
||
// Verify actual DB state
|
||
assert_table_structure_is_correct(
|
||
&pool,
|
||
"invoices",
|
||
&[
|
||
("id", "bigint"),
|
||
("deleted", "boolean"),
|
||
("invoice_number", "text"),
|
||
("amount", "numeric"),
|
||
("created_at", "timestamp with time zone"),
|
||
],
|
||
)
|
||
.await;
|
||
}
|
||
|
||
#[rstest]
|
||
#[tokio::test]
|
||
async fn test_fail_on_invalid_decimal_format(#[future] pool: PgPool) {
|
||
let pool = pool.await;
|
||
let invalid_types = vec![
|
||
"decimal(0,0)", // precision too small
|
||
"decimal(5,10)", // scale > precision
|
||
"decimal(10)", // missing scale
|
||
"decimal(a,b)", // non-numeric
|
||
];
|
||
|
||
for invalid_type in invalid_types {
|
||
let request = PostTableDefinitionRequest {
|
||
profile_name: "default".into(),
|
||
table_name: format!("table_{}", invalid_type),
|
||
columns: vec![ColumnDefinition {
|
||
name: "amount".into(),
|
||
field_type: invalid_type.into(),
|
||
}],
|
||
..Default::default()
|
||
};
|
||
|
||
let result = post_table_definition(&pool, request).await;
|
||
assert_eq!(result.unwrap_err().code(), Code::InvalidArgument);
|
||
}
|
||
}
|
||
|
||
#[rstest]
|
||
#[tokio::test]
|
||
async fn test_create_table_with_link(
|
||
#[future] pool_with_preexisting_table: PgPool,
|
||
) {
|
||
// Arrange
|
||
let pool = pool_with_preexisting_table.await;
|
||
let request = PostTableDefinitionRequest {
|
||
profile_name: "default".into(),
|
||
table_name: "orders".into(),
|
||
columns: vec![],
|
||
indexes: vec![],
|
||
links: vec![TableLink { // CORRECTED
|
||
linked_table_name: "customers".into(),
|
||
required: true,
|
||
}],
|
||
};
|
||
|
||
// Act
|
||
let response = post_table_definition(&pool, request).await.unwrap();
|
||
|
||
// Assert
|
||
assert!(response.success);
|
||
assert!(response.sql.contains(
|
||
"\"customers_id\" BIGINT NOT NULL REFERENCES gen.\"customers\"(id)"
|
||
));
|
||
assert!(response
|
||
.sql
|
||
.contains("CREATE INDEX \"idx_orders_customers_fk\""));
|
||
|
||
// Verify actual DB state
|
||
assert_table_structure_is_correct(
|
||
&pool,
|
||
"orders",
|
||
&[("customers_id", "bigint")],
|
||
)
|
||
.await;
|
||
}
|
||
|
||
#[rstest]
|
||
#[tokio::test]
|
||
async fn test_fail_on_duplicate_table_name(#[future] pool: PgPool) {
|
||
// Arrange
|
||
let pool = pool.await;
|
||
let request = PostTableDefinitionRequest {
|
||
profile_name: "default".into(),
|
||
table_name: "reused_name".into(),
|
||
..Default::default()
|
||
};
|
||
// Create it once
|
||
post_table_definition(&pool, request.clone()).await.unwrap();
|
||
|
||
// Act: Try to create it again
|
||
let result = post_table_definition(&pool, request).await;
|
||
|
||
// Assert
|
||
let err = result.unwrap_err();
|
||
assert_eq!(err.code(), Code::AlreadyExists);
|
||
assert_eq!(err.message(), "Table already exists in this profile");
|
||
}
|
||
|
||
#[rstest]
|
||
#[tokio::test]
|
||
async fn test_fail_on_invalid_table_name(#[future] pool: PgPool) {
|
||
let pool = pool.await;
|
||
let mut request = PostTableDefinitionRequest {
|
||
profile_name: "default".into(),
|
||
table_name: "ends_with_id".into(), // Invalid name
|
||
..Default::default()
|
||
};
|
||
|
||
let result = post_table_definition(&pool, request.clone()).await;
|
||
assert_eq!(result.unwrap_err().code(), Code::InvalidArgument);
|
||
|
||
request.table_name = "deleted".into(); // Reserved name
|
||
let result = post_table_definition(&pool, request.clone()).await;
|
||
assert_eq!(result.unwrap_err().code(), Code::InvalidArgument);
|
||
}
|
||
|
||
#[rstest]
|
||
#[tokio::test]
|
||
async fn test_fail_on_invalid_column_type(#[future] pool: PgPool) {
|
||
// Arrange
|
||
let pool = pool.await;
|
||
let request = PostTableDefinitionRequest {
|
||
profile_name: "default".into(),
|
||
table_name: "bad_col_type".into(),
|
||
columns: vec![ColumnDefinition {
|
||
name: "some_col".into(),
|
||
field_type: "super_string_9000".into(), // Invalid type
|
||
}],
|
||
..Default::default()
|
||
};
|
||
|
||
// Act
|
||
let result = post_table_definition(&pool, request).await;
|
||
|
||
// Assert
|
||
let err = result.unwrap_err();
|
||
assert_eq!(err.code(), Code::InvalidArgument);
|
||
assert!(err.message().contains("Invalid field type"));
|
||
}
|
||
|
||
#[rstest]
|
||
#[tokio::test]
|
||
async fn test_fail_on_index_for_nonexistent_column(#[future] pool: PgPool) {
|
||
// Arrange
|
||
let pool = pool.await;
|
||
let request = PostTableDefinitionRequest {
|
||
profile_name: "default".into(),
|
||
table_name: "bad_index".into(),
|
||
columns: vec![ColumnDefinition {
|
||
name: "real_column".into(),
|
||
field_type: "text".into(),
|
||
}],
|
||
indexes: vec!["fake_column".into()], // Index on a column not in the list
|
||
..Default::default()
|
||
};
|
||
|
||
// Act
|
||
let result = post_table_definition(&pool, request).await;
|
||
|
||
// Assert
|
||
let err = result.unwrap_err();
|
||
assert_eq!(err.code(), Code::InvalidArgument);
|
||
assert!(err.message().contains("Index column fake_column not found"));
|
||
}
|
||
|
||
#[rstest]
|
||
#[tokio::test]
|
||
async fn test_fail_on_link_to_nonexistent_table(#[future] pool: PgPool) {
|
||
// Arrange
|
||
let pool = pool.await;
|
||
let request = PostTableDefinitionRequest {
|
||
profile_name: "default".into(),
|
||
table_name: "bad_link".into(),
|
||
links: vec![TableLink { // CORRECTED
|
||
linked_table_name: "i_do_not_exist".into(),
|
||
required: false,
|
||
}],
|
||
..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 i_do_not_exist not found"));
|
||
}
|
||
|
||
#[rstest]
|
||
#[tokio::test]
|
||
async fn test_database_error_on_closed_pool(
|
||
#[future] closed_pool: PgPool,
|
||
) {
|
||
// Arrange
|
||
let pool = closed_pool.await;
|
||
let request = PostTableDefinitionRequest {
|
||
profile_name: "default".into(),
|
||
table_name: "wont_be_created".into(),
|
||
..Default::default()
|
||
};
|
||
|
||
// Act
|
||
let result = post_table_definition(&pool, request).await;
|
||
|
||
// 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");
|