Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed786f087c | ||
|
|
8e22ea05ff | ||
|
|
8414657224 | ||
|
|
e25213ed1b | ||
|
|
4843b0778c | ||
|
|
f5fae98c69 | ||
|
|
6faf0a4a31 | ||
|
|
011fafc0ff | ||
|
|
8ebe74484c | ||
|
|
3eb9523103 |
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -2846,6 +2846,7 @@ dependencies = [
|
||||
"lazy_static",
|
||||
"prost",
|
||||
"prost-types",
|
||||
"rand 0.9.1",
|
||||
"regex",
|
||||
"rstest",
|
||||
"rust-stemmers",
|
||||
|
||||
@@ -42,3 +42,4 @@ path = "src/lib.rs"
|
||||
tokio = { version = "1.44", features = ["full", "test-util"] }
|
||||
rstest = "0.25.0"
|
||||
lazy_static = "1.5.0"
|
||||
rand = "0.9.1"
|
||||
|
||||
13
server/Makefile
Normal file
13
server/Makefile
Normal file
@@ -0,0 +1,13 @@
|
||||
# Makefile
|
||||
|
||||
test: reset_db run_tests
|
||||
|
||||
reset_db:
|
||||
@echo "Resetting test database..."
|
||||
@./scripts/reset_test_db.sh
|
||||
|
||||
run_tests:
|
||||
@echo "Running tests..."
|
||||
@cargo test
|
||||
|
||||
.PHONY: test
|
||||
@@ -1,4 +1,6 @@
|
||||
-- Main table definitions
|
||||
CREATE SCHEMA IF NOT EXISTS gen;
|
||||
|
||||
CREATE TABLE table_definitions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
deleted BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
-- Add migration script here
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS gen;
|
||||
9
server/scripts/reset_test_db.sh
Executable file
9
server/scripts/reset_test_db.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
# scripts/reset_test_db.sh
|
||||
|
||||
DATABASE_URL=${TEST_DATABASE_URL:-"postgres://multi_psql_dev:3@localhost:5432/multi_rust_test"}
|
||||
|
||||
echo "Reset db script"
|
||||
yes | sqlx database drop --database-url "$DATABASE_URL"
|
||||
sqlx database create --database-url "$DATABASE_URL"
|
||||
echo "Test database reset complete."
|
||||
@@ -1,34 +1,51 @@
|
||||
// src/shared/schema_qualifier.rs
|
||||
// src/shared/schema_qualifier.rs
|
||||
use sqlx::PgPool;
|
||||
use tonic::Status;
|
||||
|
||||
/// Qualifies table names with the appropriate schema
|
||||
///
|
||||
// TODO in the future, remove database query on every request and implement caching for scalable
|
||||
// solution with many data and requests
|
||||
|
||||
/// Qualifies a table name by checking for its existence in the table_definitions table.
|
||||
/// This is the robust, "source of truth" approach.
|
||||
///
|
||||
/// Rules:
|
||||
/// - Tables created via PostTableDefinition (dynamically created tables) are in 'gen' schema
|
||||
/// - System tables (like users, profiles) remain in 'public' schema
|
||||
pub fn qualify_table_name(table_name: &str) -> String {
|
||||
// Check if table matches the pattern of dynamically created tables (e.g., 2025_something)
|
||||
if table_name.starts_with(|c: char| c.is_ascii_digit()) && table_name.contains('_') {
|
||||
format!("gen.\"{}\"", table_name)
|
||||
/// - If a table is found in `table_definitions`, it is qualified with the 'gen' schema.
|
||||
/// - Otherwise, it is assumed to be a system table in the 'public' schema.
|
||||
pub async fn qualify_table_name(
|
||||
db_pool: &PgPool,
|
||||
profile_name: &str,
|
||||
table_name: &str,
|
||||
) -> Result<String, Status> {
|
||||
// Check if a definition exists for this table in the given profile.
|
||||
let definition_exists = sqlx::query!(
|
||||
r#"SELECT EXISTS (
|
||||
SELECT 1 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(db_pool)
|
||||
.await
|
||||
.map_err(|e| Status::internal(format!("Schema lookup failed: {}", e)))?
|
||||
.exists
|
||||
.unwrap_or(false);
|
||||
|
||||
if definition_exists {
|
||||
// It's a user-defined table, so it lives in 'gen'.
|
||||
Ok(format!("gen.\"{}\"", table_name))
|
||||
} else {
|
||||
format!("\"{}\"", table_name)
|
||||
// It's not a user-defined table, so it must be a system table in 'public'.
|
||||
Ok(format!("\"{}\"", table_name))
|
||||
}
|
||||
}
|
||||
|
||||
/// Qualifies table names for data operations
|
||||
pub fn qualify_table_name_for_data(table_name: &str) -> Result<String, Status> {
|
||||
Ok(qualify_table_name(table_name))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_qualify_table_name() {
|
||||
assert_eq!(qualify_table_name("2025_test_schema3"), "gen.\"2025_test_schema3\"");
|
||||
assert_eq!(qualify_table_name("users"), "\"users\"");
|
||||
assert_eq!(qualify_table_name("profiles"), "\"profiles\"");
|
||||
assert_eq!(qualify_table_name("adresar"), "\"adresar\"");
|
||||
}
|
||||
pub async fn qualify_table_name_for_data(
|
||||
db_pool: &PgPool,
|
||||
profile_name: &str,
|
||||
table_name: &str,
|
||||
) -> Result<String, Status> {
|
||||
qualify_table_name(db_pool, profile_name, table_name).await
|
||||
}
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
// src/table_definition/handlers/post_table_definition.rs
|
||||
|
||||
use tonic::Status;
|
||||
use sqlx::{PgPool, Transaction, Postgres};
|
||||
use serde_json::json;
|
||||
use time::OffsetDateTime;
|
||||
use common::proto::multieko2::table_definition::{PostTableDefinitionRequest, TableDefinitionResponse};
|
||||
|
||||
const GENERATED_SCHEMA_NAME: &str = "gen";
|
||||
|
||||
const PREDEFINED_FIELD_TYPES: &[(&str, &str)] = &[
|
||||
("text", "TEXT"),
|
||||
("psc", "TEXT"),
|
||||
("phone", "VARCHAR(15)"),
|
||||
("address", "TEXT"),
|
||||
("email", "VARCHAR(255)"),
|
||||
("string", "TEXT"),
|
||||
("boolean", "BOOLEAN"),
|
||||
("timestamp", "TIMESTAMPTZ"),
|
||||
("time", "TIMESTAMPTZ"),
|
||||
("money", "NUMERIC(14, 4)"),
|
||||
("integer", "INTEGER"),
|
||||
("date", "DATE"),
|
||||
];
|
||||
|
||||
fn is_valid_identifier(s: &str) -> bool {
|
||||
@@ -24,11 +26,9 @@ fn is_valid_identifier(s: &str) -> bool {
|
||||
}
|
||||
|
||||
fn sanitize_table_name(s: &str) -> String {
|
||||
let year = OffsetDateTime::now_utc().year();
|
||||
let cleaned = s.replace(|c: char| !c.is_ascii_alphanumeric() && c != '_', "")
|
||||
s.replace(|c: char| !c.is_ascii_alphanumeric() && c != '_', "")
|
||||
.trim()
|
||||
.to_lowercase();
|
||||
format!("{}_{}", year, cleaned)
|
||||
.to_lowercase()
|
||||
}
|
||||
|
||||
fn sanitize_identifier(s: &str) -> String {
|
||||
@@ -37,12 +37,60 @@ fn sanitize_identifier(s: &str) -> String {
|
||||
.to_lowercase()
|
||||
}
|
||||
|
||||
fn map_field_type(field_type: &str) -> Result<&str, Status> {
|
||||
fn map_field_type(field_type: &str) -> Result<String, Status> {
|
||||
let lower_field_type = field_type.to_lowercase();
|
||||
|
||||
// Special handling for "decimal(precision, scale)"
|
||||
if lower_field_type.starts_with("decimal(") && lower_field_type.ends_with(')') {
|
||||
// Extract the part inside the parentheses, e.g., "10, 2"
|
||||
let args = lower_field_type
|
||||
.strip_prefix("decimal(")
|
||||
.and_then(|s| s.strip_suffix(')'))
|
||||
.unwrap_or(""); // Should always succeed due to the checks above
|
||||
|
||||
// Split into precision and scale parts
|
||||
if let Some((p_str, s_str)) = args.split_once(',') {
|
||||
// Parse precision, returning an error if it's not a valid number
|
||||
let precision = p_str.trim().parse::<u32>().map_err(|_| {
|
||||
Status::invalid_argument("Invalid precision in decimal type")
|
||||
})?;
|
||||
|
||||
// Parse scale, returning an error if it's not a valid number
|
||||
let scale = s_str.trim().parse::<u32>().map_err(|_| {
|
||||
Status::invalid_argument("Invalid scale in decimal type")
|
||||
})?;
|
||||
|
||||
// Add validation based on PostgreSQL rules
|
||||
if precision < 1 {
|
||||
return Err(Status::invalid_argument("Precision must be at least 1"));
|
||||
}
|
||||
if scale > precision {
|
||||
return Err(Status::invalid_argument(
|
||||
"Scale cannot be greater than precision",
|
||||
));
|
||||
}
|
||||
|
||||
// If everything is valid, build and return the NUMERIC type string
|
||||
return Ok(format!("NUMERIC({}, {})", precision, scale));
|
||||
} else {
|
||||
// The format was wrong, e.g., "decimal(10)" or "decimal()"
|
||||
return Err(Status::invalid_argument(
|
||||
"Invalid decimal format. Expected: decimal(precision, scale)",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// If not a decimal, fall back to the predefined list
|
||||
PREDEFINED_FIELD_TYPES
|
||||
.iter()
|
||||
.find(|(key, _)| *key == field_type.to_lowercase().as_str())
|
||||
.map(|(_, sql_type)| *sql_type)
|
||||
.ok_or_else(|| Status::invalid_argument(format!("Invalid field type: {}", field_type)))
|
||||
.find(|(key, _)| *key == lower_field_type.as_str())
|
||||
.map(|(_, sql_type)| sql_type.to_string()) // Convert to an owned String
|
||||
.ok_or_else(|| {
|
||||
Status::invalid_argument(format!(
|
||||
"Invalid field type: {}",
|
||||
field_type
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn is_invalid_table_name(table_name: &str) -> bool {
|
||||
@@ -56,7 +104,21 @@ pub async fn post_table_definition(
|
||||
db_pool: &PgPool,
|
||||
request: PostTableDefinitionRequest,
|
||||
) -> Result<TableDefinitionResponse, Status> {
|
||||
if request.profile_name.trim().is_empty() {
|
||||
return Err(Status::invalid_argument("Profile name cannot be empty"));
|
||||
}
|
||||
|
||||
const MAX_IDENTIFIER_LENGTH: usize = 63;
|
||||
|
||||
let base_name = sanitize_table_name(&request.table_name);
|
||||
if base_name.len() > MAX_IDENTIFIER_LENGTH {
|
||||
return Err(Status::invalid_argument(format!(
|
||||
"Identifier '{}' exceeds the {} character limit.",
|
||||
base_name,
|
||||
MAX_IDENTIFIER_LENGTH
|
||||
)));
|
||||
}
|
||||
|
||||
let user_part_cleaned = request.table_name
|
||||
.replace(|c: char| !c.is_ascii_alphanumeric() && c != '_', "")
|
||||
.trim_matches('_')
|
||||
@@ -131,6 +193,9 @@ async fn execute_table_definition(
|
||||
if !is_valid_identifier(&col_def.name) {
|
||||
return Err(Status::invalid_argument("Invalid column name"));
|
||||
}
|
||||
if col_name.ends_with("_id") || col_name == "id" || col_name == "deleted" || col_name == "created_at" {
|
||||
return Err(Status::invalid_argument("Invalid column name"));
|
||||
}
|
||||
let sql_type = map_field_type(&col_def.field_type)?;
|
||||
columns.push(format!("\"{}\" {}", col_name, sql_type));
|
||||
}
|
||||
|
||||
@@ -38,7 +38,12 @@ pub async fn delete_table_data(
|
||||
}
|
||||
|
||||
// Qualify table name with schema
|
||||
let qualified_table = qualify_table_name_for_data(&request.table_name)?;
|
||||
let qualified_table = qualify_table_name_for_data(
|
||||
db_pool,
|
||||
&request.profile_name,
|
||||
&request.table_name,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Perform soft delete using qualified table name
|
||||
let query = format!(
|
||||
|
||||
@@ -88,7 +88,12 @@ pub async fn get_table_data(
|
||||
// --- END OF FIX ---
|
||||
|
||||
// Qualify table name with schema
|
||||
let qualified_table = qualify_table_name_for_data(&table_name)?;
|
||||
let qualified_table = qualify_table_name_for_data(
|
||||
db_pool,
|
||||
&profile_name,
|
||||
&table_name,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let sql = format!(
|
||||
"SELECT {} FROM {} WHERE id = $1 AND deleted = false",
|
||||
|
||||
@@ -45,7 +45,12 @@ pub async fn get_table_data_by_position(
|
||||
}
|
||||
|
||||
// Qualify table name with schema
|
||||
let qualified_table = qualify_table_name_for_data(&table_name)?;
|
||||
let qualified_table = qualify_table_name_for_data(
|
||||
db_pool,
|
||||
&profile_name,
|
||||
&table_name,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let id_result = sqlx::query_scalar(
|
||||
&format!(
|
||||
|
||||
@@ -47,7 +47,12 @@ pub async fn get_table_data_count(
|
||||
}
|
||||
|
||||
// 2. QUALIFY THE TABLE NAME using the imported function
|
||||
let qualified_table_name = qualify_table_name_for_data(&request.table_name)?;
|
||||
let qualified_table = qualify_table_name_for_data(
|
||||
db_pool,
|
||||
&request.profile_name,
|
||||
&request.table_name,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 3. USE THE QUALIFIED NAME in the SQL query
|
||||
let query_sql = format!(
|
||||
@@ -56,7 +61,7 @@ pub async fn get_table_data_count(
|
||||
FROM {}
|
||||
WHERE deleted = FALSE
|
||||
"#,
|
||||
qualified_table_name // Use the schema-qualified name here
|
||||
qualified_table
|
||||
);
|
||||
|
||||
// The rest of the logic remains largely the same, but error messages can be more specific.
|
||||
@@ -81,14 +86,14 @@ pub async fn get_table_data_count(
|
||||
// even though it was defined in table_definitions. This is an inconsistency.
|
||||
return Err(Status::internal(format!(
|
||||
"Table '{}' is defined but does not physically exist in the database as {}.",
|
||||
request.table_name, qualified_table_name
|
||||
request.table_name, qualified_table
|
||||
)));
|
||||
}
|
||||
}
|
||||
// For other errors, provide a general message.
|
||||
Err(Status::internal(format!(
|
||||
"Count query failed for table {}: {}",
|
||||
qualified_table_name, e
|
||||
qualified_table, e
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,7 @@ use chrono::{DateTime, Utc};
|
||||
use common::proto::multieko2::tables_data::{PostTableDataRequest, PostTableDataResponse};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use crate::shared::schema_qualifier::qualify_table_name_for_data;
|
||||
use prost_types::value::Kind; // NEW: Import the Kind enum
|
||||
use prost_types::value::Kind;
|
||||
|
||||
use crate::steel::server::execution::{self, Value};
|
||||
use crate::steel::server::functions::SteelContext;
|
||||
@@ -261,7 +260,12 @@ pub async fn post_table_data(
|
||||
}
|
||||
|
||||
// Qualify table name with schema
|
||||
let qualified_table = qualify_table_name_for_data(&table_name)?;
|
||||
let qualified_table = crate::shared::schema_qualifier::qualify_table_name_for_data(
|
||||
db_pool,
|
||||
&profile_name,
|
||||
&table_name,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let sql = format!(
|
||||
"INSERT INTO {} ({}) VALUES ({}) RETURNING id",
|
||||
|
||||
@@ -161,7 +161,12 @@ pub async fn put_table_data(
|
||||
params.add(record_id)
|
||||
.map_err(|e| Status::internal(format!("Failed to add record_id parameter: {}", e)))?;
|
||||
|
||||
let qualified_table = qualify_table_name_for_data(&table_name)?;
|
||||
let qualified_table = qualify_table_name_for_data(
|
||||
db_pool,
|
||||
&profile_name,
|
||||
&table_name,
|
||||
)
|
||||
.await?;
|
||||
let set_clause = set_clauses.join(", ");
|
||||
let sql = format!(
|
||||
"UPDATE {} SET {} WHERE id = ${} AND deleted = FALSE RETURNING id",
|
||||
|
||||
@@ -1,56 +1,75 @@
|
||||
// tests/common/mod.rs
|
||||
use dotenvy;
|
||||
use sqlx::{postgres::PgPoolOptions, PgPool};
|
||||
|
||||
use dotenvy::dotenv;
|
||||
// --- CHANGE 1: Add Alphanumeric to the use statement ---
|
||||
use rand::distr::Alphanumeric;
|
||||
use rand::Rng;
|
||||
use sqlx::{postgres::PgPoolOptions, Connection, Executor, PgConnection, PgPool};
|
||||
use std::env;
|
||||
use std::path::Path;
|
||||
|
||||
pub async fn setup_test_db() -> PgPool {
|
||||
// Get path to server directory
|
||||
let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR must be set");
|
||||
let env_path = Path::new(&manifest_dir).join(".env_test");
|
||||
// (The get_database_url and get_root_connection functions remain the same)
|
||||
fn get_database_url() -> String {
|
||||
dotenv().ok();
|
||||
env::var("TEST_DATABASE_URL").expect("TEST_DATABASE_URL must be set")
|
||||
}
|
||||
async fn get_root_connection() -> PgConnection {
|
||||
PgConnection::connect(&get_database_url())
|
||||
.await
|
||||
.expect("Failed to create root connection to test database")
|
||||
}
|
||||
|
||||
// Load environment variables
|
||||
dotenvy::from_path(env_path).ok();
|
||||
|
||||
// Create connection pool
|
||||
let database_url = env::var("TEST_DATABASE_URL").expect("TEST_DATABASE_URL must be set");
|
||||
/// The primary test setup function.
|
||||
/// Creates a new, unique schema and returns a connection pool that is scoped to that schema.
|
||||
/// This is the key to test isolation.
|
||||
pub async fn setup_isolated_db() -> PgPool {
|
||||
let mut root_conn = get_root_connection().await;
|
||||
|
||||
let schema_name = format!(
|
||||
"test_{}",
|
||||
rand::thread_rng()
|
||||
// --- CHANGE 2: Pass a reference to Alphanumeric directly ---
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(12)
|
||||
.map(char::from)
|
||||
.collect::<String>()
|
||||
.to_lowercase()
|
||||
);
|
||||
|
||||
root_conn
|
||||
.execute(format!("CREATE SCHEMA \"{}\"", schema_name).as_str())
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("Failed to create schema: {}", schema_name));
|
||||
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect(&database_url)
|
||||
.after_connect(move |conn, _meta| {
|
||||
let schema_name = schema_name.clone();
|
||||
Box::pin(async move {
|
||||
conn.execute(format!("SET search_path TO \"{}\"", schema_name).as_str())
|
||||
.await?;
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
.connect(&get_database_url())
|
||||
.await
|
||||
.expect("Failed to create pool");
|
||||
.expect("Failed to create isolated pool");
|
||||
|
||||
// Run migrations
|
||||
sqlx::migrate!()
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("Migrations failed");
|
||||
.expect("Migrations failed in isolated schema");
|
||||
|
||||
// Insert default profile if it doesn't exist
|
||||
let profile = sqlx::query!(
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO profiles (name)
|
||||
VALUES ('default')
|
||||
ON CONFLICT (name) DO NOTHING
|
||||
RETURNING id
|
||||
"#
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("Failed to insert test profile");
|
||||
|
||||
let profile_id = if let Some(profile) = profile {
|
||||
profile.id
|
||||
} else {
|
||||
// If the profile already exists, fetch its ID
|
||||
sqlx::query!(
|
||||
"SELECT id FROM profiles WHERE name = 'default'"
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("Failed to fetch default profile ID")
|
||||
.id
|
||||
};
|
||||
.expect("Failed to insert test profile in isolated schema");
|
||||
|
||||
pool
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// tests/mod.rs
|
||||
pub mod adresar;
|
||||
pub mod tables_data;
|
||||
// pub mod adresar;
|
||||
// pub mod tables_data;
|
||||
pub mod common;
|
||||
|
||||
pub mod table_definition;
|
||||
|
||||
3
server/tests/table_definition/mod.rs
Normal file
3
server/tests/table_definition/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
// server/tests/table_definition/mod.rs
|
||||
|
||||
pub mod post_table_definition_test;
|
||||
511
server/tests/table_definition/post_table_definition_test.rs
Normal file
511
server/tests/table_definition/post_table_definition_test.rs
Normal file
@@ -0,0 +1,511 @@
|
||||
// tests/table_definition/post_table_definition_test.rs
|
||||
|
||||
// Keep all your normal use statements
|
||||
use common::proto::multieko2::table_definition::{
|
||||
ColumnDefinition, PostTableDefinitionRequest, TableLink,
|
||||
};
|
||||
use rstest::{fixture, rstest};
|
||||
use server::table_definition::handlers::post_table_definition;
|
||||
use sqlx::{postgres::PgPoolOptions, Connection, Executor, PgConnection, PgPool, Row}; // Add PgConnection etc.
|
||||
use tonic::Code;
|
||||
// Add these two new use statements for the isolation logic
|
||||
use rand::distr::Alphanumeric;
|
||||
use rand::Rng;
|
||||
use std::env;
|
||||
use dotenvy;
|
||||
use std::path::Path;
|
||||
|
||||
// ===================================================================
|
||||
// SPECIALIZED SETUP FOR `table_definition` TESTS
|
||||
// This setup logic is now local to this file and will not affect other tests.
|
||||
// ===================================================================
|
||||
async fn setup_isolated_gen_schema_db() -> PgPool {
|
||||
// ---- ADD THIS BLOCK TO LOAD THE .env_test FILE ----
|
||||
let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR must be set");
|
||||
let env_path = Path::new(&manifest_dir).join(".env_test");
|
||||
dotenvy::from_path(env_path).ok();
|
||||
// ----------------------------------------------------
|
||||
|
||||
let database_url = env::var("TEST_DATABASE_URL").expect("TEST_DATABASE_URL must be set");
|
||||
|
||||
let unique_schema_name = format!(
|
||||
"test_{}",
|
||||
rand::thread_rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(12)
|
||||
.map(char::from)
|
||||
.collect::<String>()
|
||||
);
|
||||
|
||||
let mut root_conn = PgConnection::connect(&database_url).await.unwrap();
|
||||
root_conn
|
||||
.execute(format!("CREATE SCHEMA \"{}\"", unique_schema_name).as_str())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.after_connect(move |conn, _meta| {
|
||||
let schema = unique_schema_name.clone();
|
||||
Box::pin(async move {
|
||||
conn.execute(format!("SET search_path = '{}'", schema).as_str())
|
||||
.await?;
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
.connect(&database_url)
|
||||
.await
|
||||
.expect("Failed to create isolated pool");
|
||||
|
||||
sqlx::migrate!()
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("Migrations failed in isolated schema");
|
||||
|
||||
sqlx::query!("INSERT INTO profiles (name) VALUES ('default') ON CONFLICT (name) DO NOTHING")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("Failed to insert test profile in isolated schema");
|
||||
|
||||
pool
|
||||
}
|
||||
|
||||
// ========= Fixtures for THIS FILE ONLY =========
|
||||
|
||||
#[fixture]
|
||||
async fn pool() -> PgPool {
|
||||
// This fixture now calls the LOCAL, SPECIALIZED setup function.
|
||||
setup_isolated_gen_schema_db().await
|
||||
}
|
||||
|
||||
#[fixture]
|
||||
async fn closed_pool(#[future] pool: PgPool) -> PgPool {
|
||||
let pool = pool.await;
|
||||
pool.close().await;
|
||||
pool
|
||||
}
|
||||
|
||||
/// This fixture now works perfectly and is also isolated,
|
||||
/// because it depends on the `pool` fixture above. No changes needed here!
|
||||
#[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");
|
||||
490
server/tests/table_definition/post_table_definition_test2.rs
Normal file
490
server/tests/table_definition/post_table_definition_test2.rs
Normal file
@@ -0,0 +1,490 @@
|
||||
// ============================================================================
|
||||
// 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(),
|
||||
columns: vec![ColumnDefinition { name: "col".into(), field_type: "text".into() }], // Added this
|
||||
..Default::default()
|
||||
}).await.unwrap();
|
||||
|
||||
// Profile B: foo, bar
|
||||
post_table_definition(&pool, PostTableDefinitionRequest {
|
||||
profile_name: "B".into(),
|
||||
table_name: "foo".into(),
|
||||
columns: vec![ColumnDefinition { name: "col".into(), field_type: "text".into() }], // Added this
|
||||
..Default::default()
|
||||
}).await.unwrap();
|
||||
|
||||
post_table_definition(&pool, PostTableDefinitionRequest {
|
||||
profile_name: "B".into(),
|
||||
table_name: "bar".into(),
|
||||
columns: vec![ColumnDefinition { name: "col".into(), field_type: "text".into() }], // Added this
|
||||
..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(),
|
||||
columns: vec![ColumnDefinition { name: "col".into(), field_type: "text".into() }], // Added this
|
||||
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::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 profiles WHERE name = $1",
|
||||
prof
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(cnt, 1);
|
||||
}
|
||||
242
server/tests/table_definition/post_table_definition_test3.rs
Normal file
242
server/tests/table_definition/post_table_definition_test3.rs
Normal file
@@ -0,0 +1,242 @@
|
||||
// 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 be rejected by input validation.
|
||||
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::InvalidArgument, // Changed from Internal
|
||||
"Expected InvalidArgument error from input validation"
|
||||
);
|
||||
assert!(
|
||||
err.message().contains("Profile name cannot be empty"), // Updated message
|
||||
"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");
|
||||
}
|
||||
222
server/tests/table_definition/post_table_definition_test4.rs
Normal file
222
server/tests/table_definition/post_table_definition_test4.rs
Normal file
@@ -0,0 +1,222 @@
|
||||
// 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_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"));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_column_name_with_id_suffix_is_rejected(#[future] pool: PgPool) {
|
||||
// Test that column names ending with '_id' are properly rejected during input validation
|
||||
let pool = pool.await;
|
||||
|
||||
// Test 1: Column ending with '_id' should be rejected
|
||||
let request = PostTableDefinitionRequest {
|
||||
profile_name: "default".into(),
|
||||
table_name: "orders".into(), // Valid table name
|
||||
columns: vec![ColumnDefinition {
|
||||
name: "legacy_order_id".into(), // This should be rejected
|
||||
field_type: "integer".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Act & Assert - should fail validation
|
||||
let result = post_table_definition(&pool, request).await;
|
||||
assert!(result.is_err(), "Column names ending with '_id' should be rejected");
|
||||
if let Err(status) = result {
|
||||
assert_eq!(status.code(), tonic::Code::InvalidArgument);
|
||||
assert!(status.message().contains("Invalid column name"));
|
||||
}
|
||||
|
||||
// Test 2: Column named exactly 'id' should be rejected
|
||||
let request2 = PostTableDefinitionRequest {
|
||||
profile_name: "default".into(),
|
||||
table_name: "orders".into(),
|
||||
columns: vec![ColumnDefinition {
|
||||
name: "id".into(), // This should be rejected
|
||||
field_type: "integer".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result2 = post_table_definition(&pool, request2).await;
|
||||
assert!(result2.is_err(), "Column named 'id' should be rejected");
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_table_name_with_id_suffix_is_rejected(#[future] pool: PgPool) {
|
||||
// Test that table names ending with '_id' are properly rejected during input validation
|
||||
let pool = pool.await;
|
||||
|
||||
let request = PostTableDefinitionRequest {
|
||||
profile_name: "default".into(),
|
||||
table_name: "orders_id".into(), // This should be rejected
|
||||
columns: vec![ColumnDefinition {
|
||||
name: "customer_name".into(), // Valid column name
|
||||
field_type: "text".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Act & Assert - should fail validation
|
||||
let result = post_table_definition(&pool, request).await;
|
||||
assert!(result.is_err(), "Table names ending with '_id' should be rejected");
|
||||
if let Err(status) = result {
|
||||
assert_eq!(status.code(), tonic::Code::InvalidArgument);
|
||||
assert!(status.message().contains("Table name cannot be 'id', 'deleted', 'created_at' or end with '_id'"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user