diff --git a/server/tests/table_definition/post_table_definition_test.rs b/server/tests/table_definition/post_table_definition_test.rs index 0eae3ff..46d6550 100644 --- a/server/tests/table_definition/post_table_definition_test.rs +++ b/server/tests/table_definition/post_table_definition_test.rs @@ -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"); diff --git a/server/tests/table_definition/post_table_definition_test2.rs b/server/tests/table_definition/post_table_definition_test2.rs new file mode 100644 index 0000000..3e392c9 --- /dev/null +++ b/server/tests/table_definition/post_table_definition_test2.rs @@ -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); +} diff --git a/server/tests/table_definition/post_table_definition_test3.rs b/server/tests/table_definition/post_table_definition_test3.rs new file mode 100644 index 0000000..cc52bfd --- /dev/null +++ b/server/tests/table_definition/post_table_definition_test3.rs @@ -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"); +} diff --git a/server/tests/table_definition/post_table_definition_test4.rs b/server/tests/table_definition/post_table_definition_test4.rs new file mode 100644 index 0000000..52890c0 --- /dev/null +++ b/server/tests/table_definition/post_table_definition_test4.rs @@ -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")); +}