Skip to content

Commit f198dbc

Browse files
author
codeErrorSleep
committed
test: Add unit tests for AI, query and transfer modules
- Added unit tests for error handling and input validation for AI modules - Added boundary value testing for SQL execution logs - Added path validation and row data validation tests for data transfer - Added integration tests for AI providers and SQL logs for local databases
1 parent 8f8d111 commit f198dbc

4 files changed

Lines changed: 362 additions & 20 deletions

File tree

src-tauri/src/commands/ai.rs

Lines changed: 131 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,50 @@ fn normalize_provider_form(
5151
Ok(())
5252
}
5353

54+
fn map_provider_lookup_error(e: &str) -> String {
55+
if e.contains("[GET_AI_PROVIDER_ERROR]") {
56+
"Selected AI provider does not exist".to_string()
57+
} else {
58+
e.to_string()
59+
}
60+
}
61+
62+
fn map_default_provider_error(e: &str) -> String {
63+
if e.contains("[NO_ENABLED_AI_PROVIDER]") {
64+
"No enabled AI provider is configured. Please enable one in AI Provider settings."
65+
.to_string()
66+
} else {
67+
e.to_string()
68+
}
69+
}
70+
71+
fn ensure_provider_enabled(enabled: bool) -> Result<(), String> {
72+
if enabled {
73+
Ok(())
74+
} else {
75+
Err("Selected AI provider is disabled".to_string())
76+
}
77+
}
78+
79+
fn validate_conversation_requirement(
80+
conversation_id: Option<i64>,
81+
create_if_missing: bool,
82+
) -> Result<(), String> {
83+
if conversation_id.is_none() && !create_if_missing {
84+
Err("conversationId is required".to_string())
85+
} else {
86+
Ok(())
87+
}
88+
}
89+
90+
fn map_history_load_error(conversation_id: i64, e: &str) -> String {
91+
eprintln!(
92+
"[AI_HISTORY_LOAD_ERROR] Failed to load messages for conversation {}: {}",
93+
conversation_id, e
94+
);
95+
"Failed to load conversation history".to_string()
96+
}
97+
5498
async fn get_db(state: &State<'_, AppState>) -> Result<Arc<crate::db::local::LocalDb>, String> {
5599
let local_db = {
56100
let lock = state.local_db.lock().await;
@@ -170,11 +214,7 @@ async fn run_chat(
170214
match db.get_ai_provider_by_id(provider_id).await {
171215
Ok(provider) => provider,
172216
Err(e) => {
173-
let msg = if e.contains("[GET_AI_PROVIDER_ERROR]") {
174-
"Selected AI provider does not exist".to_string()
175-
} else {
176-
e
177-
};
217+
let msg = map_provider_lookup_error(&e);
178218
emit_ai_error(
179219
&app,
180220
request.request_id,
@@ -188,11 +228,7 @@ async fn run_chat(
188228
match db.get_default_ai_provider().await {
189229
Ok(provider) => provider,
190230
Err(e) => {
191-
let msg = if e.contains("[NO_ENABLED_AI_PROVIDER]") {
192-
"No enabled AI provider is configured. Please enable one in AI Provider settings.".to_string()
193-
} else {
194-
e
195-
};
231+
let msg = map_default_provider_error(&e);
196232
emit_ai_error(
197233
&app,
198234
request.request_id,
@@ -204,8 +240,7 @@ async fn run_chat(
204240
}
205241
};
206242

207-
if !provider_record.enabled {
208-
let msg = "Selected AI provider is disabled".to_string();
243+
if let Err(msg) = ensure_provider_enabled(provider_record.enabled) {
209244
emit_ai_error(
210245
&app,
211246
request.request_id,
@@ -215,6 +250,8 @@ async fn run_chat(
215250
return Err(msg);
216251
}
217252

253+
validate_conversation_requirement(request.conversation_id, create_if_missing)?;
254+
218255
let api_key = db
219256
.decrypt_ai_api_key(&provider_record.api_key)
220257
.map_err(|_| {
@@ -242,7 +279,7 @@ async fn run_chat(
242279
)
243280
.await?
244281
}
245-
None => return Err("conversationId is required".to_string()),
282+
None => unreachable!("conversation requirement should be validated before this branch"),
246283
};
247284

248285
let user_message = db
@@ -313,11 +350,7 @@ async fn run_chat(
313350
let mut existing = match db.list_ai_messages(conversation.id).await {
314351
Ok(messages) => messages,
315352
Err(e) => {
316-
eprintln!(
317-
"[AI_HISTORY_LOAD_ERROR] Failed to load messages for conversation {}: {}",
318-
conversation.id, e
319-
);
320-
let client_error = "Failed to load conversation history".to_string();
353+
let client_error = map_history_load_error(conversation.id, &e);
321354
emit_ai_error(
322355
&app,
323356
request.request_id.clone(),
@@ -439,3 +472,83 @@ pub async fn ai_delete_conversation(
439472
let db = get_db(&state).await?;
440473
db.delete_ai_conversation(conversation_id).await
441474
}
475+
476+
#[cfg(test)]
477+
mod tests {
478+
use super::{
479+
ensure_provider_enabled, map_default_provider_error, map_history_load_error,
480+
map_provider_lookup_error, normalize_provider_type, validate_conversation_requirement,
481+
};
482+
483+
#[test]
484+
fn normalize_provider_type_rejects_empty_value() {
485+
assert_eq!(
486+
normalize_provider_type(" ").unwrap_err(),
487+
"providerType is required"
488+
);
489+
}
490+
491+
#[test]
492+
fn normalize_provider_type_maps_openai_compat_to_openai() {
493+
assert_eq!(
494+
normalize_provider_type("OpenAI_Compat").unwrap(),
495+
"openai".to_string()
496+
);
497+
}
498+
499+
#[test]
500+
fn normalize_provider_type_rejects_invalid_chars() {
501+
assert_eq!(
502+
normalize_provider_type("bad type!").unwrap_err(),
503+
"providerType has invalid format"
504+
);
505+
}
506+
507+
#[test]
508+
fn normalize_provider_type_accepts_supported_chars() {
509+
assert_eq!(
510+
normalize_provider_type("x.y-z_1").unwrap(),
511+
"x.y-z_1".to_string()
512+
);
513+
}
514+
515+
#[test]
516+
fn provider_lookup_error_maps_not_found_to_user_friendly_message() {
517+
assert_eq!(
518+
map_provider_lookup_error("[GET_AI_PROVIDER_ERROR] row not found"),
519+
"Selected AI provider does not exist"
520+
);
521+
}
522+
523+
#[test]
524+
fn default_provider_error_maps_no_enabled_provider_to_user_friendly_message() {
525+
assert_eq!(
526+
map_default_provider_error("[NO_ENABLED_AI_PROVIDER] nothing configured"),
527+
"No enabled AI provider is configured. Please enable one in AI Provider settings."
528+
);
529+
}
530+
531+
#[test]
532+
fn ensure_provider_enabled_rejects_disabled_provider() {
533+
assert_eq!(
534+
ensure_provider_enabled(false).unwrap_err(),
535+
"Selected AI provider is disabled"
536+
);
537+
}
538+
539+
#[test]
540+
fn continue_requires_conversation_id() {
541+
assert_eq!(
542+
validate_conversation_requirement(None, false).unwrap_err(),
543+
"conversationId is required"
544+
);
545+
}
546+
547+
#[test]
548+
fn history_load_error_maps_to_client_message() {
549+
assert_eq!(
550+
map_history_load_error(42, "[DB_ERROR] broken"),
551+
"Failed to load conversation history"
552+
);
553+
}
554+
}

src-tauri/src/commands/query.rs

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -668,12 +668,16 @@ pub async fn execute_by_conn(
668668
result
669669
}
670670

671+
fn clamp_sql_execution_logs_limit(limit: Option<i64>) -> i64 {
672+
limit.unwrap_or(100).clamp(1, 100)
673+
}
674+
671675
#[tauri::command]
672676
pub async fn list_sql_execution_logs(
673677
state: State<'_, AppState>,
674678
limit: Option<i64>,
675679
) -> Result<Vec<SqlExecutionLog>, String> {
676-
let safe_limit = limit.unwrap_or(100).clamp(1, 100);
680+
let safe_limit = clamp_sql_execution_logs_limit(limit);
677681
let local_db = {
678682
let lock = state.local_db.lock().await;
679683
lock.clone()
@@ -688,7 +692,7 @@ pub async fn list_sql_execution_logs(
688692

689693
#[cfg(test)]
690694
mod tests {
691-
use super::maybe_apply_default_limit;
695+
use super::{clamp_sql_execution_logs_limit, maybe_apply_default_limit};
692696

693697
#[test]
694698
fn adds_limit_to_simple_select() {
@@ -817,4 +821,21 @@ mod tests {
817821
"SELECT TOP 20 * FROM t"
818822
);
819823
}
824+
825+
#[test]
826+
fn sql_logs_limit_defaults_to_100() {
827+
assert_eq!(clamp_sql_execution_logs_limit(None), 100);
828+
}
829+
830+
#[test]
831+
fn sql_logs_limit_clamps_lower_bound() {
832+
assert_eq!(clamp_sql_execution_logs_limit(Some(0)), 1);
833+
assert_eq!(clamp_sql_execution_logs_limit(Some(-5)), 1);
834+
}
835+
836+
#[test]
837+
fn sql_logs_limit_clamps_upper_bound() {
838+
assert_eq!(clamp_sql_execution_logs_limit(Some(101)), 100);
839+
assert_eq!(clamp_sql_execution_logs_limit(Some(9999)), 100);
840+
}
820841
}

src-tauri/src/commands/transfer.rs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,9 @@ fn quote_target(schema: Option<&str>, table: &str, driver: &str) -> String {
470470
#[cfg(test)]
471471
mod tests {
472472
use super::*;
473+
use std::fs;
474+
use std::path::PathBuf;
475+
use std::time::{SystemTime, UNIX_EPOCH};
473476

474477
#[test]
475478
fn csv_escape_works() {
@@ -513,4 +516,53 @@ mod tests {
513516
assert_eq!(quote_target(Some(" "), "users", "postgres"), "\"users\"");
514517
assert_eq!(quote_target(None, "users", "mysql"), "`users`");
515518
}
519+
520+
#[test]
521+
fn validate_output_path_rejects_empty_path() {
522+
assert_eq!(
523+
validate_output_path(&PathBuf::new()).unwrap_err(),
524+
"[EXPORT_ERROR] Invalid output path"
525+
);
526+
}
527+
528+
#[test]
529+
fn validate_output_path_rejects_directory_path() {
530+
let unique = SystemTime::now()
531+
.duration_since(UNIX_EPOCH)
532+
.unwrap()
533+
.as_nanos();
534+
let dir = std::env::temp_dir().join(format!("dbpaw-transfer-test-dir-{unique}"));
535+
fs::create_dir_all(&dir).unwrap();
536+
let err = validate_output_path(&dir).unwrap_err();
537+
assert_eq!(err, "[EXPORT_ERROR] Output path points to a directory");
538+
let _ = fs::remove_dir_all(dir);
539+
}
540+
541+
#[test]
542+
fn validate_output_path_rejects_path_without_filename() {
543+
let err = validate_output_path(&PathBuf::from("/")).unwrap_err();
544+
assert_eq!(err, "[EXPORT_ERROR] Output path must include a file name");
545+
}
546+
547+
#[test]
548+
fn write_rows_rejects_non_object_rows() {
549+
let unique = SystemTime::now()
550+
.duration_since(UNIX_EPOCH)
551+
.unwrap()
552+
.as_nanos();
553+
let path = std::env::temp_dir().join(format!("dbpaw-transfer-test-{unique}.json"));
554+
let mut writer = ExportWriter::new(path.clone(), ExportFormat::Json, vec!["a".to_string()])
555+
.unwrap();
556+
let err = writer
557+
.write_rows(
558+
&[Value::String("not-object".to_string())],
559+
&["a".to_string()],
560+
None,
561+
"t",
562+
"postgres",
563+
)
564+
.unwrap_err();
565+
assert_eq!(err, "[EXPORT_ERROR] row is not a JSON object");
566+
let _ = fs::remove_file(path);
567+
}
516568
}

0 commit comments

Comments
 (0)