ordering of the tests for tables data
This commit is contained in:
296
server/tests/tables_data/delete/delete_table_data_test.rs
Normal file
296
server/tests/tables_data/delete/delete_table_data_test.rs
Normal file
@@ -0,0 +1,296 @@
|
||||
// tests/tables_data/handlers/delete_table_data_test.rs
|
||||
|
||||
use rstest::{fixture, rstest};
|
||||
use sqlx::{PgPool, Row};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
use serde_json::json;
|
||||
use chrono::Utc;
|
||||
use futures::future::join_all;
|
||||
use prost_types::{value::Kind, Value};
|
||||
use rand::Rng;
|
||||
use rand::distr::Alphanumeric; // Corrected import
|
||||
|
||||
// Common imports from other modules
|
||||
use common::proto::multieko2::table_definition::{
|
||||
PostTableDefinitionRequest, ColumnDefinition as TableColumnDefinition, TableLink,
|
||||
};
|
||||
use common::proto::multieko2::tables_data::{
|
||||
DeleteTableDataRequest, DeleteTableDataResponse, PostTableDataRequest, PutTableDataRequest,
|
||||
};
|
||||
use server::indexer::IndexCommand;
|
||||
use server::table_definition::handlers::post_table_definition;
|
||||
use server::tables_data::handlers::{delete_table_data, post_table_data, put_table_data};
|
||||
use crate::common::setup_test_db;
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref TEST_MUTEX: Arc<Mutex<()>> = Arc::new(Mutex::new(()));
|
||||
}
|
||||
|
||||
// ========= Test Helpers =========
|
||||
|
||||
fn generate_unique_id() -> String {
|
||||
rand::rng() // Corrected function call
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(8)
|
||||
.map(char::from)
|
||||
.collect::<String>()
|
||||
.to_lowercase()
|
||||
}
|
||||
|
||||
fn string_to_proto_value(s: &str) -> Value {
|
||||
Value {
|
||||
kind: Some(Kind::StringValue(s.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
// ========= Fixtures =========
|
||||
|
||||
#[fixture]
|
||||
async fn pool() -> PgPool {
|
||||
setup_test_db().await
|
||||
}
|
||||
|
||||
#[fixture]
|
||||
async fn closed_pool(#[future] pool: PgPool) -> PgPool {
|
||||
let pool = pool.await;
|
||||
pool.close().await;
|
||||
pool
|
||||
}
|
||||
|
||||
#[fixture]
|
||||
async fn existing_profile(#[future] pool: PgPool) -> (PgPool, String, i64) {
|
||||
let pool = pool.await;
|
||||
let profile_name = format!("testprofile_{}", Utc::now().timestamp_nanos_opt().unwrap_or_default());
|
||||
|
||||
// FIX: The table is `schemas`, not `profiles`.
|
||||
let profile = sqlx::query!(
|
||||
"INSERT INTO schemas (name) VALUES ($1) RETURNING id",
|
||||
profile_name
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
(pool, profile_name, profile.id)
|
||||
}
|
||||
|
||||
#[fixture]
|
||||
async fn existing_table(
|
||||
#[future] existing_profile: (PgPool, String, i64),
|
||||
) -> (PgPool, String, i64, String) {
|
||||
let (pool, profile_name, schema_id) = existing_profile.await;
|
||||
let table_name = format!("test_table_{}", Utc::now().timestamp_nanos_opt().unwrap_or_default());
|
||||
|
||||
// Use post_table_definition instead of manual table creation
|
||||
let table_def_request = PostTableDefinitionRequest {
|
||||
profile_name: profile_name.clone(),
|
||||
table_name: table_name.clone(),
|
||||
columns: vec![
|
||||
TableColumnDefinition {
|
||||
name: "test_data".into(),
|
||||
field_type: "text".into(),
|
||||
}
|
||||
],
|
||||
indexes: vec![],
|
||||
links: vec![],
|
||||
};
|
||||
|
||||
post_table_definition(&pool, table_def_request).await.unwrap();
|
||||
(pool, profile_name, schema_id, table_name)
|
||||
}
|
||||
|
||||
#[fixture]
|
||||
async fn existing_record(
|
||||
#[future] existing_table: (PgPool, String, i64, String),
|
||||
) -> (PgPool, String, String, i64) {
|
||||
let (pool, profile_name, _schema_id, table_name) = existing_table.await;
|
||||
|
||||
let mut data = HashMap::new();
|
||||
data.insert("test_data".to_string(), string_to_proto_value("Test Record"));
|
||||
|
||||
let post_req = PostTableDataRequest {
|
||||
profile_name: profile_name.clone(),
|
||||
table_name: table_name.clone(),
|
||||
data,
|
||||
};
|
||||
|
||||
let (indexer_tx, _indexer_rx) = mpsc::channel(1);
|
||||
let response = post_table_data(&pool, post_req, &indexer_tx).await.unwrap();
|
||||
|
||||
(pool, profile_name, table_name, response.inserted_id)
|
||||
}
|
||||
|
||||
#[fixture]
|
||||
async fn existing_deleted_record(
|
||||
#[future] existing_table: (PgPool, String, i64, String),
|
||||
) -> (PgPool, String, String, i64) {
|
||||
let (pool, profile_name, _schema_id, table_name) = existing_table.await;
|
||||
|
||||
// First create a record
|
||||
let mut data = HashMap::new();
|
||||
data.insert("test_data".to_string(), string_to_proto_value("Test Deleted Record"));
|
||||
|
||||
let post_req = PostTableDataRequest {
|
||||
profile_name: profile_name.clone(),
|
||||
table_name: table_name.clone(),
|
||||
data,
|
||||
};
|
||||
|
||||
let (indexer_tx, _indexer_rx) = mpsc::channel(1);
|
||||
let response = post_table_data(&pool, post_req, &indexer_tx).await.unwrap();
|
||||
let record_id = response.inserted_id;
|
||||
|
||||
// Then delete it
|
||||
let delete_req = DeleteTableDataRequest {
|
||||
profile_name: profile_name.clone(),
|
||||
table_name: table_name.clone(),
|
||||
record_id,
|
||||
};
|
||||
delete_table_data(&pool, delete_req).await.unwrap();
|
||||
|
||||
(pool, profile_name, table_name, record_id)
|
||||
}
|
||||
|
||||
// New fixture for advanced tests
|
||||
#[derive(Clone)]
|
||||
struct AdvancedDeleteContext {
|
||||
pool: PgPool,
|
||||
profile_name: String,
|
||||
category_table: String,
|
||||
product_table: String,
|
||||
indexer_tx: mpsc::Sender<IndexCommand>,
|
||||
indexer_rx: Arc<tokio::sync::Mutex<mpsc::Receiver<IndexCommand>>>,
|
||||
}
|
||||
|
||||
#[fixture]
|
||||
async fn advanced_delete_context() -> AdvancedDeleteContext {
|
||||
let pool = setup_test_db().await;
|
||||
let unique_id = generate_unique_id();
|
||||
let profile_name = format!("adv_del_profile_{}", unique_id);
|
||||
let category_table = format!("categories_adv_del_{}", unique_id);
|
||||
let product_table = format!("products_adv_del_{}", unique_id);
|
||||
|
||||
let category_def = PostTableDefinitionRequest {
|
||||
profile_name: profile_name.clone(),
|
||||
table_name: category_table.clone(),
|
||||
columns: vec![TableColumnDefinition { name: "name".into(), field_type: "text".into() }],
|
||||
links: vec![], indexes: vec![],
|
||||
};
|
||||
post_table_definition(&pool, category_def).await.unwrap();
|
||||
|
||||
let product_def = PostTableDefinitionRequest {
|
||||
profile_name: profile_name.clone(),
|
||||
table_name: product_table.clone(),
|
||||
columns: vec![TableColumnDefinition { name: "name".into(), field_type: "text".into() }],
|
||||
links: vec![TableLink { linked_table_name: category_table.clone(), required: true }],
|
||||
indexes: vec![],
|
||||
};
|
||||
post_table_definition(&pool, product_def).await.unwrap();
|
||||
|
||||
let (tx, rx) = mpsc::channel(100);
|
||||
AdvancedDeleteContext {
|
||||
pool, profile_name, category_table, product_table,
|
||||
indexer_tx: tx,
|
||||
indexer_rx: Arc::new(tokio::sync::Mutex::new(rx)),
|
||||
}
|
||||
}
|
||||
|
||||
// ========= Basic Tests (from your original file) =========
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_delete_table_data_success(
|
||||
#[future] existing_record: (PgPool, String, String, i64),
|
||||
) {
|
||||
let (pool, profile_name, table_name, record_id) = existing_record.await;
|
||||
let request = DeleteTableDataRequest {
|
||||
profile_name: profile_name.clone(),
|
||||
table_name: table_name.clone(),
|
||||
record_id,
|
||||
};
|
||||
let response = delete_table_data(&pool, request).await.unwrap();
|
||||
assert!(response.success);
|
||||
|
||||
let query = format!("SELECT deleted FROM \"{}\".\"{}\" WHERE id = $1", profile_name, table_name);
|
||||
let row = sqlx::query(&query).bind(record_id).fetch_one(&pool).await.unwrap();
|
||||
assert!(row.get::<bool, _>("deleted"));
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_delete_table_data_profile_not_found(#[future] pool: PgPool) {
|
||||
let pool = pool.await;
|
||||
let request = DeleteTableDataRequest {
|
||||
profile_name: "NonExistentProfile".to_string(),
|
||||
table_name: "test_table".to_string(),
|
||||
record_id: 1,
|
||||
};
|
||||
let result = delete_table_data(&pool, request).await;
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err().code(), tonic::Code::NotFound);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_delete_table_data_table_not_found(
|
||||
#[future] existing_profile: (PgPool, String, i64),
|
||||
) {
|
||||
let (pool, profile_name, _) = existing_profile.await;
|
||||
let request = DeleteTableDataRequest {
|
||||
profile_name,
|
||||
table_name: "non_existent_table".to_string(),
|
||||
record_id: 1,
|
||||
};
|
||||
let result = delete_table_data(&pool, request).await;
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err().code(), tonic::Code::NotFound);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_delete_table_data_record_not_found(
|
||||
#[future] existing_table: (PgPool, String, i64, String),
|
||||
) {
|
||||
let (pool, profile_name, _, table_name) = existing_table.await;
|
||||
let request = DeleteTableDataRequest {
|
||||
profile_name,
|
||||
table_name: table_name.clone(),
|
||||
record_id: 9999,
|
||||
};
|
||||
let response = delete_table_data(&pool, request).await.unwrap();
|
||||
assert!(!response.success);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_delete_table_data_already_deleted(
|
||||
#[future] existing_deleted_record: (PgPool, String, String, i64),
|
||||
) {
|
||||
let (pool, profile_name, table_name, record_id) = existing_deleted_record.await;
|
||||
let request = DeleteTableDataRequest {
|
||||
profile_name,
|
||||
table_name: table_name.clone(),
|
||||
record_id,
|
||||
};
|
||||
let response = delete_table_data(&pool, request).await.unwrap();
|
||||
assert!(!response.success);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_delete_table_data_database_error(#[future] closed_pool: PgPool) {
|
||||
let closed_pool = closed_pool.await;
|
||||
let request = DeleteTableDataRequest {
|
||||
profile_name: "test".to_string(),
|
||||
table_name: "test".to_string(),
|
||||
record_id: 1,
|
||||
};
|
||||
let result = delete_table_data(&closed_pool, request).await;
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err().code(), tonic::Code::Internal);
|
||||
}
|
||||
|
||||
// Include the new, more advanced tests
|
||||
include!("delete_table_data_test2.rs");
|
||||
include!("delete_table_data_test3.rs");
|
||||
241
server/tests/tables_data/delete/delete_table_data_test2.rs
Normal file
241
server/tests/tables_data/delete/delete_table_data_test2.rs
Normal file
@@ -0,0 +1,241 @@
|
||||
// tests/tables_data/handlers/delete_table_data_test2.rs
|
||||
|
||||
// ========================================================================
|
||||
// Foreign Key Integrity Tests
|
||||
// ========================================================================
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_soft_delete_does_not_break_foreign_key_references(
|
||||
#[future] advanced_delete_context: AdvancedDeleteContext,
|
||||
) {
|
||||
// Arrange: Create a category and a product that links to it.
|
||||
let context = advanced_delete_context.await;
|
||||
let mut category_data = HashMap::new();
|
||||
category_data.insert("name".to_string(), string_to_proto_value("Electronics"));
|
||||
let category_req = PostTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.category_table.clone(),
|
||||
data: category_data,
|
||||
};
|
||||
let category_res = post_table_data(&context.pool, category_req, &context.indexer_tx).await.unwrap();
|
||||
let category_id = category_res.inserted_id;
|
||||
|
||||
let mut product_data = HashMap::new();
|
||||
product_data.insert("name".to_string(), string_to_proto_value("Laptop"));
|
||||
product_data.insert(
|
||||
format!("{}_id", context.category_table),
|
||||
Value { kind: Some(Kind::NumberValue(category_id as f64)) },
|
||||
);
|
||||
let product_req = PostTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.product_table.clone(),
|
||||
data: product_data,
|
||||
};
|
||||
let product_res = post_table_data(&context.pool, product_req, &context.indexer_tx).await.unwrap();
|
||||
let product_id = product_res.inserted_id;
|
||||
|
||||
// Act: Soft-delete the category record.
|
||||
let delete_req = DeleteTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.category_table.clone(),
|
||||
record_id: category_id,
|
||||
};
|
||||
let delete_res = delete_table_data(&context.pool, delete_req).await.unwrap();
|
||||
assert!(delete_res.success);
|
||||
|
||||
// Assert: The product record still exists and its foreign key still points to the (now soft-deleted) category ID.
|
||||
let query = format!(
|
||||
r#"SELECT "{}_id" FROM "{}"."{}" WHERE id = $1"#,
|
||||
context.category_table, context.profile_name, context.product_table
|
||||
);
|
||||
let fk_id: i64 = sqlx::query_scalar(&query)
|
||||
.bind(product_id)
|
||||
.fetch_one(&context.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(fk_id, category_id, "Foreign key reference should remain intact after soft delete.");
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Indexer Integration Tests
|
||||
// ========================================================================
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_delete_does_not_send_indexer_command(
|
||||
#[future] advanced_delete_context: AdvancedDeleteContext,
|
||||
) {
|
||||
// Arrange
|
||||
let context = advanced_delete_context.await;
|
||||
let mut category_data = HashMap::new();
|
||||
category_data.insert("name".to_string(), string_to_proto_value("Test Category"));
|
||||
let category_req = PostTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.category_table.clone(),
|
||||
data: category_data,
|
||||
};
|
||||
let category_res = post_table_data(&context.pool, category_req, &context.indexer_tx).await.unwrap();
|
||||
let category_id = category_res.inserted_id;
|
||||
|
||||
// Drain the create command from the channel
|
||||
let _ = context.indexer_rx.lock().await.recv().await;
|
||||
|
||||
// Act
|
||||
let delete_req = DeleteTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.category_table.clone(),
|
||||
record_id: category_id,
|
||||
};
|
||||
|
||||
let delete_res = delete_table_data(&context.pool, delete_req).await.unwrap();
|
||||
assert!(delete_res.success);
|
||||
|
||||
// Assert: Check that NO command was sent. This verifies current behavior.
|
||||
let recv_result = tokio::time::timeout(
|
||||
std::time::Duration::from_millis(50),
|
||||
context.indexer_rx.lock().await.recv()
|
||||
).await;
|
||||
|
||||
assert!(recv_result.is_err(), "Expected no indexer command to be sent on delete, but one was received.");
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Concurrency and State Mismatch Tests
|
||||
// ========================================================================
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_concurrent_deletes_on_same_record(
|
||||
#[future] advanced_delete_context: AdvancedDeleteContext,
|
||||
) {
|
||||
// Arrange
|
||||
let context = advanced_delete_context.await;
|
||||
let mut category_data = HashMap::new();
|
||||
category_data.insert("name".to_string(), string_to_proto_value("Concurrent Delete Test"));
|
||||
let category_req = PostTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.category_table.clone(),
|
||||
data: category_data,
|
||||
};
|
||||
let category_res = post_table_data(&context.pool, category_req, &context.indexer_tx).await.unwrap();
|
||||
let category_id = category_res.inserted_id;
|
||||
|
||||
// Act: Spawn multiple tasks to delete the same record.
|
||||
let mut tasks = vec![];
|
||||
for _ in 0..5 {
|
||||
let pool = context.pool.clone();
|
||||
let req = DeleteTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.category_table.clone(),
|
||||
record_id: category_id,
|
||||
};
|
||||
tasks.push(tokio::spawn(async move {
|
||||
delete_table_data(&pool, req).await
|
||||
}));
|
||||
}
|
||||
let results = join_all(tasks).await;
|
||||
|
||||
// Assert: Exactly one delete should succeed, the rest should fail (softly).
|
||||
let success_count = results.iter().filter(|res|
|
||||
res.is_ok() && res.as_ref().unwrap().is_ok() && res.as_ref().unwrap().as_ref().unwrap().success
|
||||
).count();
|
||||
|
||||
assert_eq!(success_count, 1, "Exactly one concurrent delete operation should succeed.");
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_delete_fails_if_physical_table_is_missing(
|
||||
#[future] advanced_delete_context: AdvancedDeleteContext,
|
||||
) {
|
||||
// Arrange: Create definitions, then manually drop the physical table to create a state mismatch.
|
||||
let context = advanced_delete_context.await;
|
||||
let qualified_table = format!("\"{}\".\"{}\"", context.profile_name, context.category_table);
|
||||
sqlx::query(&format!("DROP TABLE {} CASCADE", qualified_table))
|
||||
.execute(&context.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Act: Attempt to delete a record from the logically-defined but physically-absent table.
|
||||
let delete_req = DeleteTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.category_table.clone(),
|
||||
record_id: 1, // ID doesn't matter
|
||||
};
|
||||
let result = delete_table_data(&context.pool, delete_req).await;
|
||||
|
||||
// Assert: The operation should fail with the specific internal error for a missing relation.
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert_eq!(err.code(), tonic::Code::Internal);
|
||||
assert!(
|
||||
err.message().contains("is defined but does not physically exist"),
|
||||
"Error message should indicate a state mismatch."
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Interaction with Other Endpoints
|
||||
// ========================================================================
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_update_succeeds_on_soft_deleted_record(
|
||||
#[future] advanced_delete_context: AdvancedDeleteContext,
|
||||
) {
|
||||
// Arrange: Create and then soft-delete a record.
|
||||
let context = advanced_delete_context.await;
|
||||
let mut category_data = HashMap::new();
|
||||
category_data.insert("name".to_string(), string_to_proto_value("Original Name"));
|
||||
let category_req = PostTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.category_table.clone(),
|
||||
data: category_data,
|
||||
};
|
||||
let category_res = post_table_data(&context.pool, category_req, &context.indexer_tx).await.unwrap();
|
||||
let category_id = category_res.inserted_id;
|
||||
|
||||
let delete_req = DeleteTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.category_table.clone(),
|
||||
record_id: category_id,
|
||||
};
|
||||
delete_table_data(&context.pool, delete_req).await.unwrap();
|
||||
|
||||
// Act: Attempt to update the soft-deleted record using the PUT handler.
|
||||
let mut update_data = HashMap::new();
|
||||
update_data.insert("name".to_string(), string_to_proto_value("Updated After Delete"));
|
||||
let put_req = PutTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.category_table.clone(),
|
||||
id: category_id,
|
||||
data: update_data,
|
||||
};
|
||||
let put_result = put_table_data(&context.pool, put_req, &context.indexer_tx).await;
|
||||
|
||||
// Assert: This test is crucial as it verifies your requirement to "freeze operations".
|
||||
// Currently, the PUT handler does NOT check the deleted flag, so it will succeed.
|
||||
// This test documents that behavior. To make it fail, you would need to add a check
|
||||
// in `put_table_data` to see if the record is already deleted.
|
||||
assert!(put_result.is_ok(), "PUT should succeed on a soft-deleted record (current behavior).");
|
||||
let put_res = put_result.unwrap();
|
||||
assert!(put_res.success);
|
||||
|
||||
// Verify the name was updated, but the record remains marked as deleted.
|
||||
let row = sqlx::query(&format!(
|
||||
r#"SELECT name, deleted FROM "{}"."{}" WHERE id = $1"#,
|
||||
context.profile_name, context.category_table
|
||||
))
|
||||
.bind(category_id)
|
||||
.fetch_one(&context.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let name: String = row.get("name");
|
||||
let deleted: bool = row.get("deleted");
|
||||
|
||||
assert_eq!(name, "Updated After Delete");
|
||||
assert!(deleted, "Record should remain soft-deleted after an update.");
|
||||
}
|
||||
567
server/tests/tables_data/delete/delete_table_data_test3.rs
Normal file
567
server/tests/tables_data/delete/delete_table_data_test3.rs
Normal file
@@ -0,0 +1,567 @@
|
||||
// tests/tables_data/handlers/delete_table_data_test3.rs
|
||||
|
||||
// ========================================================================
|
||||
// Input Validation and Edge Cases
|
||||
// ========================================================================
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_delete_with_negative_record_id(
|
||||
#[future] existing_table: (PgPool, String, i64, String),
|
||||
) {
|
||||
let (pool, profile_name, _, table_name) = existing_table.await;
|
||||
let request = DeleteTableDataRequest {
|
||||
profile_name,
|
||||
table_name,
|
||||
record_id: -1,
|
||||
};
|
||||
let response = delete_table_data(&pool, request).await.unwrap();
|
||||
assert!(!response.success, "Delete with negative ID should fail gracefully");
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_delete_with_zero_record_id(
|
||||
#[future] existing_table: (PgPool, String, i64, String),
|
||||
) {
|
||||
let (pool, profile_name, _, table_name) = existing_table.await;
|
||||
let request = DeleteTableDataRequest {
|
||||
profile_name,
|
||||
table_name,
|
||||
record_id: 0,
|
||||
};
|
||||
let response = delete_table_data(&pool, request).await.unwrap();
|
||||
assert!(!response.success, "Delete with zero ID should fail gracefully");
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_delete_with_max_int64_record_id(
|
||||
#[future] existing_table: (PgPool, String, i64, String),
|
||||
) {
|
||||
let (pool, profile_name, _, table_name) = existing_table.await;
|
||||
let request = DeleteTableDataRequest {
|
||||
profile_name,
|
||||
table_name,
|
||||
record_id: i64::MAX,
|
||||
};
|
||||
let response = delete_table_data(&pool, request).await.unwrap();
|
||||
assert!(!response.success, "Delete with max int64 ID should fail gracefully");
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Malformed Input Handling
|
||||
// ========================================================================
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_delete_with_empty_profile_name(#[future] pool: PgPool) {
|
||||
let pool = pool.await;
|
||||
let request = DeleteTableDataRequest {
|
||||
profile_name: "".to_string(),
|
||||
table_name: "test_table".to_string(),
|
||||
record_id: 1,
|
||||
};
|
||||
let result = delete_table_data(&pool, request).await;
|
||||
assert!(result.is_err(), "Empty profile name should be rejected");
|
||||
assert_eq!(result.unwrap_err().code(), tonic::Code::NotFound);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_delete_with_whitespace_only_profile_name(#[future] pool: PgPool) {
|
||||
let pool = pool.await;
|
||||
let request = DeleteTableDataRequest {
|
||||
profile_name: " ".to_string(),
|
||||
table_name: "test_table".to_string(),
|
||||
record_id: 1,
|
||||
};
|
||||
let result = delete_table_data(&pool, request).await;
|
||||
assert!(result.is_err(), "Whitespace-only profile name should be rejected");
|
||||
assert_eq!(result.unwrap_err().code(), tonic::Code::NotFound);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_delete_with_empty_table_name(
|
||||
#[future] existing_profile: (PgPool, String, i64),
|
||||
) {
|
||||
let (pool, profile_name, _) = existing_profile.await;
|
||||
let request = DeleteTableDataRequest {
|
||||
profile_name,
|
||||
table_name: "".to_string(),
|
||||
record_id: 1,
|
||||
};
|
||||
let result = delete_table_data(&pool, request).await;
|
||||
assert!(result.is_err(), "Empty table name should be rejected");
|
||||
assert_eq!(result.unwrap_err().code(), tonic::Code::NotFound);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_delete_with_sql_injection_attempt(
|
||||
#[future] existing_profile: (PgPool, String, i64),
|
||||
) {
|
||||
let (pool, profile_name, _) = existing_profile.await;
|
||||
let request = DeleteTableDataRequest {
|
||||
profile_name,
|
||||
table_name: "test'; DROP TABLE users; --".to_string(),
|
||||
record_id: 1,
|
||||
};
|
||||
let result = delete_table_data(&pool, request).await;
|
||||
assert!(result.is_err(), "SQL injection attempt should be rejected");
|
||||
assert_eq!(result.unwrap_err().code(), tonic::Code::NotFound);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Data Integrity Verification Tests
|
||||
// ========================================================================
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_delete_only_affects_target_record(
|
||||
#[future] existing_table: (PgPool, String, i64, String),
|
||||
) {
|
||||
// Arrange: Create multiple records
|
||||
let (pool, profile_name, _, table_name) = existing_table.await;
|
||||
|
||||
let mut record_ids = Vec::new();
|
||||
for i in 0..5 {
|
||||
let query = format!(
|
||||
"INSERT INTO \"{}\".\"{}\" (deleted) VALUES (false) RETURNING id",
|
||||
profile_name, table_name
|
||||
);
|
||||
let row = sqlx::query(&query).fetch_one(&pool).await.unwrap();
|
||||
let id: i64 = row.get("id");
|
||||
record_ids.push(id);
|
||||
}
|
||||
|
||||
let target_id = record_ids[2]; // Delete the middle record
|
||||
|
||||
// Act: Delete one specific record
|
||||
let request = DeleteTableDataRequest {
|
||||
profile_name: profile_name.clone(),
|
||||
table_name: table_name.clone(),
|
||||
record_id: target_id,
|
||||
};
|
||||
let response = delete_table_data(&pool, request).await.unwrap();
|
||||
assert!(response.success);
|
||||
|
||||
// Assert: Verify only the target record is deleted
|
||||
for &id in &record_ids {
|
||||
let query = format!(
|
||||
"SELECT deleted FROM \"{}\".\"{}\" WHERE id = $1",
|
||||
profile_name, table_name
|
||||
);
|
||||
let row = sqlx::query(&query).bind(id).fetch_one(&pool).await.unwrap();
|
||||
let is_deleted: bool = row.get("deleted");
|
||||
|
||||
if id == target_id {
|
||||
assert!(is_deleted, "Target record should be marked as deleted");
|
||||
} else {
|
||||
assert!(!is_deleted, "Non-target records should remain undeleted");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_delete_preserves_all_other_fields(
|
||||
#[future] advanced_delete_context: AdvancedDeleteContext,
|
||||
) {
|
||||
// Arrange: Create a record with rich data
|
||||
let context = advanced_delete_context.await;
|
||||
let mut category_data = HashMap::new();
|
||||
category_data.insert("name".to_string(), string_to_proto_value("Preserve Test Category"));
|
||||
|
||||
let category_req = PostTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.category_table.clone(),
|
||||
data: category_data,
|
||||
};
|
||||
let category_res = post_table_data(&context.pool, category_req, &context.indexer_tx).await.unwrap();
|
||||
let category_id = category_res.inserted_id;
|
||||
|
||||
// Capture state before deletion
|
||||
let before_query = format!(
|
||||
"SELECT id, name, deleted, created_at FROM \"{}\".\"{}\" WHERE id = $1",
|
||||
context.profile_name, context.category_table
|
||||
);
|
||||
let before_row = sqlx::query(&before_query)
|
||||
.bind(category_id)
|
||||
.fetch_one(&context.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let before_id: i64 = before_row.get("id");
|
||||
let before_name: String = before_row.get("name");
|
||||
let before_deleted: bool = before_row.get("deleted");
|
||||
let before_created_at: chrono::DateTime<chrono::Utc> = before_row.get("created_at");
|
||||
|
||||
// Act: Delete the record
|
||||
let delete_req = DeleteTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.category_table.clone(),
|
||||
record_id: category_id,
|
||||
};
|
||||
let delete_res = delete_table_data(&context.pool, delete_req).await.unwrap();
|
||||
assert!(delete_res.success);
|
||||
|
||||
// Assert: Verify only 'deleted' field changed
|
||||
let after_query = format!(
|
||||
"SELECT id, name, deleted, created_at FROM \"{}\".\"{}\" WHERE id = $1",
|
||||
context.profile_name, context.category_table
|
||||
);
|
||||
let after_row = sqlx::query(&after_query)
|
||||
.bind(category_id)
|
||||
.fetch_one(&context.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let after_id: i64 = after_row.get("id");
|
||||
let after_name: String = after_row.get("name");
|
||||
let after_deleted: bool = after_row.get("deleted");
|
||||
let after_created_at: chrono::DateTime<chrono::Utc> = after_row.get("created_at");
|
||||
|
||||
assert_eq!(before_id, after_id, "ID should not change");
|
||||
assert_eq!(before_name, after_name, "Name should not change");
|
||||
assert_eq!(before_created_at, after_created_at, "Created timestamp should not change");
|
||||
assert_eq!(before_deleted, false, "Record should initially be not deleted");
|
||||
assert_eq!(after_deleted, true, "Record should be marked as deleted after operation");
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_delete_count_verification(
|
||||
#[future] existing_table: (PgPool, String, i64, String),
|
||||
) {
|
||||
// Arrange: Create records and count them
|
||||
let (pool, profile_name, _, table_name) = existing_table.await;
|
||||
|
||||
// Create 3 records
|
||||
let mut record_ids = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let query = format!(
|
||||
"INSERT INTO \"{}\".\"{}\" (deleted) VALUES (false) RETURNING id",
|
||||
profile_name, table_name
|
||||
);
|
||||
let row = sqlx::query(&query).fetch_one(&pool).await.unwrap();
|
||||
let id: i64 = row.get("id");
|
||||
record_ids.push(id);
|
||||
}
|
||||
|
||||
// Verify initial count
|
||||
let count_query = format!(
|
||||
"SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE deleted = false) as active FROM \"{}\".\"{}\"",
|
||||
profile_name, table_name
|
||||
);
|
||||
let count_row = sqlx::query(&count_query).fetch_one(&pool).await.unwrap();
|
||||
let initial_total: i64 = count_row.get("total");
|
||||
let initial_active: i64 = count_row.get("active");
|
||||
|
||||
// Act: Delete one record
|
||||
let request = DeleteTableDataRequest {
|
||||
profile_name: profile_name.clone(),
|
||||
table_name: table_name.clone(),
|
||||
record_id: record_ids[0],
|
||||
};
|
||||
let response = delete_table_data(&pool, request).await.unwrap();
|
||||
assert!(response.success);
|
||||
|
||||
// Assert: Verify counts after deletion
|
||||
let final_count_row = sqlx::query(&count_query).fetch_one(&pool).await.unwrap();
|
||||
let final_total: i64 = final_count_row.get("total");
|
||||
let final_active: i64 = final_count_row.get("active");
|
||||
|
||||
assert_eq!(initial_total, final_total, "Total record count should not change (soft delete)");
|
||||
assert_eq!(initial_active - 1, final_active, "Active record count should decrease by 1");
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Multiple Operations Sequence Testing
|
||||
// ========================================================================
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_delete_then_post_same_data(
|
||||
#[future] advanced_delete_context: AdvancedDeleteContext,
|
||||
) {
|
||||
// Arrange: Create and delete a record
|
||||
let context = advanced_delete_context.await;
|
||||
let mut category_data = HashMap::new();
|
||||
category_data.insert("name".to_string(), string_to_proto_value("Reusable Name"));
|
||||
|
||||
let category_req = PostTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.category_table.clone(),
|
||||
data: category_data.clone(),
|
||||
};
|
||||
let first_res = post_table_data(&context.pool, category_req, &context.indexer_tx).await.unwrap();
|
||||
let first_id = first_res.inserted_id;
|
||||
|
||||
let delete_req = DeleteTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.category_table.clone(),
|
||||
record_id: first_id,
|
||||
};
|
||||
delete_table_data(&context.pool, delete_req).await.unwrap();
|
||||
|
||||
// Act: Try to POST the same data again
|
||||
let second_req = PostTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.category_table.clone(),
|
||||
data: category_data,
|
||||
};
|
||||
let second_res = post_table_data(&context.pool, second_req, &context.indexer_tx).await.unwrap();
|
||||
|
||||
// Assert: Should succeed with a new ID
|
||||
assert!(second_res.success);
|
||||
assert_ne!(first_id, second_res.inserted_id, "New record should have different ID");
|
||||
|
||||
// Verify both records exist in database
|
||||
let count_query = format!(
|
||||
"SELECT COUNT(*) as total FROM \"{}\".\"{}\" WHERE name = 'Reusable Name'",
|
||||
context.profile_name, context.category_table
|
||||
);
|
||||
let count: i64 = sqlx::query_scalar(&count_query).fetch_one(&context.pool).await.unwrap();
|
||||
assert_eq!(count, 2, "Should have 2 records with same name (one deleted, one active)");
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_multiple_deletes_then_recreate_pattern(
|
||||
#[future] advanced_delete_context: AdvancedDeleteContext,
|
||||
) {
|
||||
// Test a realistic pattern: create, delete, recreate multiple times
|
||||
let context = advanced_delete_context.await;
|
||||
let mut all_ids = Vec::new();
|
||||
|
||||
for i in 0..3 {
|
||||
// Create
|
||||
let mut category_data = HashMap::new();
|
||||
category_data.insert("name".to_string(), string_to_proto_value(&format!("Cycle Name {}", i)));
|
||||
|
||||
let create_req = PostTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.category_table.clone(),
|
||||
data: category_data,
|
||||
};
|
||||
let create_res = post_table_data(&context.pool, create_req, &context.indexer_tx).await.unwrap();
|
||||
all_ids.push(create_res.inserted_id);
|
||||
|
||||
// Delete immediately
|
||||
let delete_req = DeleteTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.category_table.clone(),
|
||||
record_id: create_res.inserted_id,
|
||||
};
|
||||
let delete_res = delete_table_data(&context.pool, delete_req).await.unwrap();
|
||||
assert!(delete_res.success);
|
||||
}
|
||||
|
||||
// Verify all records are marked as deleted
|
||||
for &id in &all_ids {
|
||||
let query = format!(
|
||||
"SELECT deleted FROM \"{}\".\"{}\" WHERE id = $1",
|
||||
context.profile_name, context.category_table
|
||||
);
|
||||
let is_deleted: bool = sqlx::query_scalar(&query)
|
||||
.bind(id)
|
||||
.fetch_one(&context.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(is_deleted, "Record {} should be deleted", id);
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Performance and Stress Tests
|
||||
// ========================================================================
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_delete_performance_with_many_records(
|
||||
#[future] advanced_delete_context: AdvancedDeleteContext,
|
||||
) {
|
||||
// Arrange: Create many records
|
||||
let context = advanced_delete_context.await;
|
||||
let record_count = 100; // Adjust based on test environment
|
||||
let mut record_ids = Vec::new();
|
||||
|
||||
for i in 0..record_count {
|
||||
let mut category_data = HashMap::new();
|
||||
category_data.insert("name".to_string(), string_to_proto_value(&format!("Perf Test {}", i)));
|
||||
|
||||
let create_req = PostTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.category_table.clone(),
|
||||
data: category_data,
|
||||
};
|
||||
let create_res = post_table_data(&context.pool, create_req, &context.indexer_tx).await.unwrap();
|
||||
record_ids.push(create_res.inserted_id);
|
||||
}
|
||||
|
||||
// Act: Delete a record from the middle (worst case for performance)
|
||||
let target_id = record_ids[record_count / 2];
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
let delete_req = DeleteTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.category_table.clone(),
|
||||
record_id: target_id,
|
||||
};
|
||||
let delete_res = delete_table_data(&context.pool, delete_req).await.unwrap();
|
||||
|
||||
let elapsed = start_time.elapsed();
|
||||
|
||||
// Assert: Operation should succeed and be reasonably fast
|
||||
assert!(delete_res.success);
|
||||
assert!(elapsed.as_millis() < 1000, "Delete should complete within 1 second even with {} records", record_count);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_rapid_sequential_deletes(
|
||||
#[future] advanced_delete_context: AdvancedDeleteContext,
|
||||
) {
|
||||
// Arrange: Create multiple records
|
||||
let context = advanced_delete_context.await;
|
||||
let mut record_ids = Vec::new();
|
||||
|
||||
for i in 0..10 {
|
||||
let mut category_data = HashMap::new();
|
||||
category_data.insert("name".to_string(), string_to_proto_value(&format!("Rapid Delete {}", i)));
|
||||
|
||||
let create_req = PostTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.category_table.clone(),
|
||||
data: category_data,
|
||||
};
|
||||
let create_res = post_table_data(&context.pool, create_req, &context.indexer_tx).await.unwrap();
|
||||
record_ids.push(create_res.inserted_id);
|
||||
}
|
||||
|
||||
// Act: Delete all records rapidly in sequence
|
||||
let start_time = std::time::Instant::now();
|
||||
for &record_id in &record_ids {
|
||||
let delete_req = DeleteTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.category_table.clone(),
|
||||
record_id,
|
||||
};
|
||||
let delete_res = delete_table_data(&context.pool, delete_req).await.unwrap();
|
||||
assert!(delete_res.success, "Delete of record {} should succeed", record_id);
|
||||
}
|
||||
let elapsed = start_time.elapsed();
|
||||
|
||||
// Assert: All deletes should complete in reasonable time
|
||||
assert!(elapsed.as_millis() < 5000, "10 sequential deletes should complete within 5 seconds");
|
||||
|
||||
// Verify all records are deleted
|
||||
let count_query = format!(
|
||||
"SELECT COUNT(*) FILTER (WHERE deleted = true) as deleted_count FROM \"{}\".\"{}\"",
|
||||
context.profile_name, context.category_table
|
||||
);
|
||||
let deleted_count: i64 = sqlx::query_scalar(&count_query).fetch_one(&context.pool).await.unwrap();
|
||||
assert_eq!(deleted_count as usize, record_ids.len(), "All records should be marked as deleted");
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Error Message Quality and Handling Tests
|
||||
// ========================================================================
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_error_messages_are_descriptive(#[future] pool: PgPool) {
|
||||
let pool = pool.await;
|
||||
|
||||
// Test profile not found error
|
||||
let request = DeleteTableDataRequest {
|
||||
profile_name: "NonExistentProfile123".to_string(),
|
||||
table_name: "test_table".to_string(),
|
||||
record_id: 1,
|
||||
};
|
||||
let result = delete_table_data(&pool, request).await;
|
||||
assert!(result.is_err());
|
||||
let error = result.unwrap_err();
|
||||
assert_eq!(error.code(), tonic::Code::NotFound);
|
||||
assert_eq!(error.message(), "Profile not found");
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_table_not_found_error_message(
|
||||
#[future] existing_profile: (PgPool, String, i64),
|
||||
) {
|
||||
let (pool, profile_name, _) = existing_profile.await;
|
||||
let request = DeleteTableDataRequest {
|
||||
profile_name,
|
||||
table_name: "definitely_does_not_exist_12345".to_string(),
|
||||
record_id: 1,
|
||||
};
|
||||
let result = delete_table_data(&pool, request).await;
|
||||
assert!(result.is_err());
|
||||
let error = result.unwrap_err();
|
||||
assert_eq!(error.code(), tonic::Code::NotFound);
|
||||
assert_eq!(error.message(), "Table not found in profile");
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Database State Consistency Tests
|
||||
// ========================================================================
|
||||
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn test_delete_maintains_foreign_key_constraints(
|
||||
#[future] advanced_delete_context: AdvancedDeleteContext,
|
||||
) {
|
||||
// This test ensures that soft deletes don't interfere with FK constraint validation
|
||||
let context = advanced_delete_context.await;
|
||||
|
||||
// Create category
|
||||
let mut category_data = HashMap::new();
|
||||
category_data.insert("name".to_string(), string_to_proto_value("FK Test Category"));
|
||||
let category_req = PostTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.category_table.clone(),
|
||||
data: category_data,
|
||||
};
|
||||
let category_res = post_table_data(&context.pool, category_req, &context.indexer_tx).await.unwrap();
|
||||
let category_id = category_res.inserted_id;
|
||||
|
||||
// Create product referencing the category
|
||||
let mut product_data = HashMap::new();
|
||||
product_data.insert("name".to_string(), string_to_proto_value("FK Test Product"));
|
||||
product_data.insert(
|
||||
format!("{}_id", context.category_table),
|
||||
Value { kind: Some(Kind::NumberValue(category_id as f64)) },
|
||||
);
|
||||
let product_req = PostTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.product_table.clone(),
|
||||
data: product_data,
|
||||
};
|
||||
let product_res = post_table_data(&context.pool, product_req, &context.indexer_tx).await.unwrap();
|
||||
|
||||
// Soft delete the category
|
||||
let delete_req = DeleteTableDataRequest {
|
||||
profile_name: context.profile_name.clone(),
|
||||
table_name: context.category_table.clone(),
|
||||
record_id: category_id,
|
||||
};
|
||||
let delete_res = delete_table_data(&context.pool, delete_req).await.unwrap();
|
||||
assert!(delete_res.success);
|
||||
|
||||
// The product should still exist and reference the soft-deleted category
|
||||
let fk_query = format!(
|
||||
"SELECT \"{}_id\" FROM \"{}\".\"{}\" WHERE id = $1",
|
||||
context.category_table, context.profile_name, context.product_table
|
||||
);
|
||||
let fk_value: i64 = sqlx::query_scalar(&fk_query)
|
||||
.bind(product_res.inserted_id)
|
||||
.fetch_one(&context.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(fk_value, category_id, "Foreign key should still point to soft-deleted category");
|
||||
}
|
||||
3
server/tests/tables_data/delete/mod.rs
Normal file
3
server/tests/tables_data/delete/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
// tests/tables_data/delete/mod.rs
|
||||
|
||||
pub mod delete_table_data_test;
|
||||
Reference in New Issue
Block a user