use super::*; use crate::cli::render; use crate::core::time::now_ms; #[test] fn truncate_leaves_short_strings_alone() { assert_eq!(render::truncate("short", 10), "short"); } #[test] fn truncate_adds_ellipsis_to_long_strings() { assert_eq!( render::truncate("this is a very long title", 15), "this is a ve..." ); } #[test] fn truncate_handles_exact_length() { assert_eq!(render::truncate("exactly10!", 10), "exactly10!"); } #[test] fn relative_time_formats_correctly() { let now = now_ms(); assert_eq!(render::format_relative_time(now - 30_000), "just now"); assert_eq!(render::format_relative_time(now - 120_000), "2 min ago"); assert_eq!(render::format_relative_time(now - 7_200_000), "2 hours ago"); assert_eq!( render::format_relative_time(now - 172_800_000), "2 days ago" ); } #[test] fn format_labels_empty() { assert_eq!(render::format_labels(&[], 2), ""); } #[test] fn format_labels_single() { assert_eq!(render::format_labels(&["bug".to_string()], 2), "[bug]"); } #[test] fn format_labels_multiple() { let labels = vec!["bug".to_string(), "urgent".to_string()]; assert_eq!(render::format_labels(&labels, 2), "[bug, urgent]"); } #[test] fn format_labels_overflow() { let labels = vec![ "bug".to_string(), "urgent".to_string(), "wip".to_string(), "blocked".to_string(), ]; assert_eq!(render::format_labels(&labels, 2), "[bug, urgent +2]"); } #[test] fn format_discussions_empty() { assert_eq!(format_discussions(0, 0).text, ""); } #[test] fn format_discussions_no_unresolved() { assert_eq!(format_discussions(5, 0).text, "5"); } #[test] fn format_discussions_with_unresolved() { let cell = format_discussions(5, 2); // Text contains styled ANSI for warning-colored unresolved count assert!(cell.text.starts_with("5/"), "got: {}", cell.text); assert!(cell.text.contains("2!"), "got: {}", cell.text); } // ----------------------------------------------------------------------- // Note query layer tests // ----------------------------------------------------------------------- use std::path::Path; use crate::core::config::{ Config, EmbeddingConfig, GitLabConfig, LoggingConfig, ProjectConfig, ScoringConfig, StorageConfig, SyncConfig, }; use crate::core::db::{create_connection, run_migrations}; fn test_config(default_project: Option<&str>) -> Config { Config { gitlab: GitLabConfig { base_url: "https://gitlab.example.com".to_string(), token_env_var: "GITLAB_TOKEN".to_string(), token: None, username: None, }, projects: vec![ProjectConfig { path: "group/project".to_string(), }], default_project: default_project.map(String::from), sync: SyncConfig::default(), storage: StorageConfig::default(), embedding: EmbeddingConfig::default(), logging: LoggingConfig::default(), scoring: ScoringConfig::default(), } } fn default_note_filters() -> NoteListFilters { NoteListFilters { limit: 50, project: None, author: None, note_type: None, include_system: false, for_issue_iid: None, for_mr_iid: None, note_id: None, gitlab_note_id: None, discussion_id: None, since: None, until: None, path: None, contains: None, resolution: None, sort: "created".to_string(), order: "desc".to_string(), } } fn setup_note_test_db() -> Connection { let conn = create_connection(Path::new(":memory:")).unwrap(); run_migrations(&conn).unwrap(); conn } fn insert_test_project(conn: &Connection, id: i64, path: &str) { conn.execute( "INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url) VALUES (?1, ?2, ?3, ?4)", rusqlite::params![ id, id * 100, path, format!("https://gitlab.example.com/{path}") ], ) .unwrap(); } fn insert_test_issue(conn: &Connection, id: i64, project_id: i64, iid: i64, title: &str) { conn.execute( "INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, author_username, created_at, updated_at, last_seen_at) VALUES (?1, ?2, ?3, ?4, ?5, 'opened', 'author', 1000, 2000, 3000)", rusqlite::params![id, id * 10, project_id, iid, title], ) .unwrap(); } fn insert_test_mr(conn: &Connection, id: i64, project_id: i64, iid: i64, title: &str) { conn.execute( "INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, state, author_username, source_branch, target_branch, created_at, updated_at, last_seen_at) VALUES (?1, ?2, ?3, ?4, ?5, 'opened', 'author', 'feat', 'main', 1000, 2000, 3000)", rusqlite::params![id, id * 10, project_id, iid, title], ) .unwrap(); } #[allow(clippy::too_many_arguments)] fn insert_test_discussion( conn: &Connection, id: i64, gitlab_disc_id: &str, project_id: i64, issue_id: Option, mr_id: Option, noteable_type: &str, ) { conn.execute( "INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, merge_request_id, noteable_type, last_seen_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1000)", rusqlite::params![ id, gitlab_disc_id, project_id, issue_id, mr_id, noteable_type ], ) .unwrap(); } #[allow(clippy::too_many_arguments)] fn insert_test_note( conn: &Connection, id: i64, gitlab_id: i64, discussion_id: i64, project_id: i64, author: &str, body: &str, is_system: bool, created_at: i64, updated_at: i64, ) { conn.execute( "INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, is_system, created_at, updated_at, last_seen_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", rusqlite::params![ id, gitlab_id, discussion_id, project_id, author, body, is_system as i64, created_at, updated_at, updated_at, ], ) .unwrap(); } #[allow(clippy::too_many_arguments)] fn insert_note_with_position( conn: &Connection, id: i64, gitlab_id: i64, discussion_id: i64, project_id: i64, author: &str, body: &str, created_at: i64, note_type: Option<&str>, new_path: Option<&str>, new_line: Option, ) { conn.execute( "INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, is_system, created_at, updated_at, last_seen_at, note_type, position_new_path, position_new_line) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0, ?7, ?7, ?7, ?8, ?9, ?10)", rusqlite::params![ id, gitlab_id, discussion_id, project_id, author, body, created_at, note_type, new_path, new_line, ], ) .unwrap(); } #[allow(clippy::too_many_arguments)] fn insert_resolvable_note( conn: &Connection, id: i64, gitlab_id: i64, discussion_id: i64, project_id: i64, author: &str, body: &str, created_at: i64, resolvable: bool, resolved: bool, resolved_by: Option<&str>, ) { conn.execute( "INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, is_system, created_at, updated_at, last_seen_at, resolvable, resolved, resolved_by) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0, ?7, ?7, ?7, ?8, ?9, ?10)", rusqlite::params![ id, gitlab_id, discussion_id, project_id, author, body, created_at, resolvable as i64, resolved as i64, resolved_by, ], ) .unwrap(); } #[test] fn test_query_notes_empty_db() { let conn = setup_note_test_db(); let config = test_config(None); let filters = default_note_filters(); let result = query_notes(&conn, &filters, &config).unwrap(); assert_eq!(result.total_count, 0); assert!(result.notes.is_empty()); } #[test] fn test_query_notes_basic() { let conn = setup_note_test_db(); let config = test_config(None); insert_test_project(&conn, 1, "group/project"); insert_test_issue(&conn, 1, 1, 42, "Test Issue"); insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue"); insert_test_note( &conn, 1, 100, 1, 1, "alice", "Hello world", false, 1000, 2000, ); let filters = default_note_filters(); let result = query_notes(&conn, &filters, &config).unwrap(); assert_eq!(result.total_count, 1); assert_eq!(result.notes.len(), 1); assert_eq!(result.notes[0].author_username, "alice"); assert_eq!(result.notes[0].body.as_deref(), Some("Hello world")); assert_eq!(result.notes[0].parent_iid, Some(42)); assert_eq!(result.notes[0].parent_title.as_deref(), Some("Test Issue")); assert_eq!(result.notes[0].project_path, "group/project"); } #[test] fn test_query_notes_excludes_system_by_default() { let conn = setup_note_test_db(); let config = test_config(None); insert_test_project(&conn, 1, "group/project"); insert_test_issue(&conn, 1, 1, 1, "Issue"); insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue"); insert_test_note(&conn, 1, 100, 1, 1, "alice", "User note", false, 1000, 1000); insert_test_note(&conn, 2, 101, 1, 1, "bot", "System note", true, 2000, 2000); let filters = default_note_filters(); let result = query_notes(&conn, &filters, &config).unwrap(); assert_eq!(result.total_count, 1); assert_eq!(result.notes[0].author_username, "alice"); } #[test] fn test_query_notes_include_system() { let conn = setup_note_test_db(); let config = test_config(None); insert_test_project(&conn, 1, "group/project"); insert_test_issue(&conn, 1, 1, 1, "Issue"); insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue"); insert_test_note(&conn, 1, 100, 1, 1, "alice", "User note", false, 1000, 1000); insert_test_note(&conn, 2, 101, 1, 1, "bot", "System note", true, 2000, 2000); let mut filters = default_note_filters(); filters.include_system = true; let result = query_notes(&conn, &filters, &config).unwrap(); assert_eq!(result.total_count, 2); } #[test] fn test_query_notes_filter_author() { let conn = setup_note_test_db(); let config = test_config(None); insert_test_project(&conn, 1, "group/project"); insert_test_issue(&conn, 1, 1, 1, "Issue"); insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue"); insert_test_note(&conn, 1, 100, 1, 1, "alice", "Note 1", false, 1000, 1000); insert_test_note(&conn, 2, 101, 1, 1, "bob", "Note 2", false, 2000, 2000); let mut filters = default_note_filters(); filters.author = Some("alice".to_string()); let result = query_notes(&conn, &filters, &config).unwrap(); assert_eq!(result.total_count, 1); assert_eq!(result.notes[0].author_username, "alice"); } #[test] fn test_query_notes_filter_author_case_insensitive() { let conn = setup_note_test_db(); let config = test_config(None); insert_test_project(&conn, 1, "group/project"); insert_test_issue(&conn, 1, 1, 1, "Issue"); insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue"); insert_test_note(&conn, 1, 100, 1, 1, "Alice", "Note", false, 1000, 1000); let mut filters = default_note_filters(); filters.author = Some("ALICE".to_string()); let result = query_notes(&conn, &filters, &config).unwrap(); assert_eq!(result.total_count, 1); } #[test] fn test_query_notes_filter_author_strips_at() { let conn = setup_note_test_db(); let config = test_config(None); insert_test_project(&conn, 1, "group/project"); insert_test_issue(&conn, 1, 1, 1, "Issue"); insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue"); insert_test_note(&conn, 1, 100, 1, 1, "alice", "Note", false, 1000, 1000); let mut filters = default_note_filters(); filters.author = Some("@alice".to_string()); let result = query_notes(&conn, &filters, &config).unwrap(); assert_eq!(result.total_count, 1); } #[test] fn test_query_notes_filter_since() { let conn = setup_note_test_db(); let config = test_config(None); insert_test_project(&conn, 1, "group/project"); insert_test_issue(&conn, 1, 1, 1, "Issue"); insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue"); let ts = crate::core::time::iso_to_ms("2024-01-15T00:00:00Z").unwrap(); insert_test_note(&conn, 1, 100, 1, 1, "alice", "Old note", false, ts, ts); let ts2 = crate::core::time::iso_to_ms("2024-06-15T00:00:00Z").unwrap(); insert_test_note(&conn, 2, 101, 1, 1, "bob", "New note", false, ts2, ts2); let mut filters = default_note_filters(); filters.since = Some("2024-03-01".to_string()); let result = query_notes(&conn, &filters, &config).unwrap(); assert_eq!(result.total_count, 1); assert_eq!(result.notes[0].author_username, "bob"); } #[test] fn test_query_notes_filter_until() { let conn = setup_note_test_db(); let config = test_config(None); insert_test_project(&conn, 1, "group/project"); insert_test_issue(&conn, 1, 1, 1, "Issue"); insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue"); let ts = crate::core::time::iso_to_ms("2024-01-15T00:00:00Z").unwrap(); insert_test_note(&conn, 1, 100, 1, 1, "alice", "Old note", false, ts, ts); let ts2 = crate::core::time::iso_to_ms("2024-06-15T00:00:00Z").unwrap(); insert_test_note(&conn, 2, 101, 1, 1, "bob", "New note", false, ts2, ts2); let mut filters = default_note_filters(); filters.until = Some("2024-03-01".to_string()); let result = query_notes(&conn, &filters, &config).unwrap(); assert_eq!(result.total_count, 1); assert_eq!(result.notes[0].author_username, "alice"); } #[test] fn test_query_notes_filter_since_until_combined() { let conn = setup_note_test_db(); let config = test_config(None); insert_test_project(&conn, 1, "group/project"); insert_test_issue(&conn, 1, 1, 1, "Issue"); insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue"); let ts1 = crate::core::time::iso_to_ms("2024-01-15T00:00:00Z").unwrap(); insert_test_note(&conn, 1, 100, 1, 1, "alice", "Old", false, ts1, ts1); let ts2 = crate::core::time::iso_to_ms("2024-03-15T00:00:00Z").unwrap(); insert_test_note(&conn, 2, 101, 1, 1, "bob", "Middle", false, ts2, ts2); let ts3 = crate::core::time::iso_to_ms("2024-06-15T00:00:00Z").unwrap(); insert_test_note(&conn, 3, 102, 1, 1, "carol", "New", false, ts3, ts3); let mut filters = default_note_filters(); filters.since = Some("2024-02-01".to_string()); filters.until = Some("2024-04-01".to_string()); let result = query_notes(&conn, &filters, &config).unwrap(); assert_eq!(result.total_count, 1); assert_eq!(result.notes[0].author_username, "bob"); } #[test] fn test_query_notes_invalid_time_window_rejected() { let conn = setup_note_test_db(); let config = test_config(None); let mut filters = default_note_filters(); filters.since = Some("2024-06-01".to_string()); filters.until = Some("2024-01-01".to_string()); let result = query_notes(&conn, &filters, &config); assert!(result.is_err()); let msg = result.unwrap_err().to_string(); assert!( msg.contains("--since is after --until"), "Expected time window error, got: {msg}" ); } #[test] fn test_query_notes_until_date_uses_end_of_day() { let conn = setup_note_test_db(); let config = test_config(None); insert_test_project(&conn, 1, "group/project"); insert_test_issue(&conn, 1, 1, 1, "Issue"); insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue"); let ts = crate::core::time::iso_to_ms("2024-03-01T23:30:00Z").unwrap(); insert_test_note(&conn, 1, 100, 1, 1, "alice", "Late night", false, ts, ts); let mut filters = default_note_filters(); filters.until = Some("2024-03-01".to_string()); let result = query_notes(&conn, &filters, &config).unwrap(); assert_eq!( result.total_count, 1, "Note at 23:30 should be included when --until is the same date" ); } #[test] fn test_query_notes_filter_contains() { let conn = setup_note_test_db(); let config = test_config(None); insert_test_project(&conn, 1, "group/project"); insert_test_issue(&conn, 1, 1, 1, "Issue"); insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue"); insert_test_note( &conn, 1, 100, 1, 1, "alice", "This has a BUG in it", false, 1000, 1000, ); insert_test_note( &conn, 2, 101, 1, 1, "bob", "Everything is fine", false, 2000, 2000, ); let mut filters = default_note_filters(); filters.contains = Some("bug".to_string()); let result = query_notes(&conn, &filters, &config).unwrap(); assert_eq!(result.total_count, 1); assert_eq!(result.notes[0].author_username, "alice"); } #[test] fn test_query_notes_filter_contains_escapes_like_wildcards() { let conn = setup_note_test_db(); let config = test_config(None); insert_test_project(&conn, 1, "group/project"); insert_test_issue(&conn, 1, 1, 1, "Issue"); insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue"); insert_test_note(&conn, 1, 100, 1, 1, "alice", "100% done", false, 1000, 1000); insert_test_note(&conn, 2, 101, 1, 1, "bob", "100 things", false, 2000, 2000); let mut filters = default_note_filters(); filters.contains = Some("100%".to_string()); let result = query_notes(&conn, &filters, &config).unwrap(); assert_eq!(result.total_count, 1); assert_eq!(result.notes[0].author_username, "alice"); } #[test] fn test_query_notes_filter_path_exact() { let conn = setup_note_test_db(); let config = test_config(None); insert_test_project(&conn, 1, "group/project"); insert_test_issue(&conn, 1, 1, 1, "Issue"); insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue"); insert_note_with_position( &conn, 1, 100, 1, 1, "alice", "Change here", 1000, Some("DiffNote"), Some("src/main.rs"), Some(42), ); insert_note_with_position( &conn, 2, 101, 1, 1, "bob", "And here", 2000, Some("DiffNote"), Some("src/lib.rs"), Some(10), ); let mut filters = default_note_filters(); filters.path = Some("src/main.rs".to_string()); let result = query_notes(&conn, &filters, &config).unwrap(); assert_eq!(result.total_count, 1); assert_eq!(result.notes[0].author_username, "alice"); } #[test] fn test_query_notes_filter_path_prefix() { let conn = setup_note_test_db(); let config = test_config(None); insert_test_project(&conn, 1, "group/project"); insert_test_issue(&conn, 1, 1, 1, "Issue"); insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue"); insert_note_with_position( &conn, 1, 100, 1, 1, "alice", "In src", 1000, Some("DiffNote"), Some("src/main.rs"), Some(42), ); insert_note_with_position( &conn, 2, 101, 1, 1, "bob", "Also in src", 2000, Some("DiffNote"), Some("src/lib.rs"), Some(10), ); insert_note_with_position( &conn, 3, 102, 1, 1, "carol", "In tests", 3000, Some("DiffNote"), Some("tests/test.rs"), Some(1), ); let mut filters = default_note_filters(); filters.path = Some("src/".to_string()); let result = query_notes(&conn, &filters, &config).unwrap(); assert_eq!(result.total_count, 2); } #[test] fn test_query_notes_filter_for_issue_requires_project() { let conn = setup_note_test_db(); let config = test_config(None); let mut filters = default_note_filters(); filters.for_issue_iid = Some(42); let result = query_notes(&conn, &filters, &config); assert!(result.is_err()); let msg = result.unwrap_err().to_string(); assert!( msg.contains("project context"), "Expected project context error, got: {msg}" ); } #[test] fn test_query_notes_filter_for_mr_requires_project() { let conn = setup_note_test_db(); let config = test_config(None); let mut filters = default_note_filters(); filters.for_mr_iid = Some(10); let result = query_notes(&conn, &filters, &config); assert!(result.is_err()); let msg = result.unwrap_err().to_string(); assert!( msg.contains("project context"), "Expected project context error, got: {msg}" ); } #[test] fn test_query_notes_filter_for_issue_with_project() { let conn = setup_note_test_db(); let config = test_config(None); insert_test_project(&conn, 1, "group/project"); insert_test_issue(&conn, 1, 1, 42, "Target Issue"); insert_test_issue(&conn, 2, 1, 43, "Other Issue"); insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue"); insert_test_discussion(&conn, 2, "disc-2", 1, Some(2), None, "Issue"); insert_test_note( &conn, 1, 100, 1, 1, "alice", "On issue 42", false, 1000, 1000, ); insert_test_note(&conn, 2, 101, 2, 1, "bob", "On issue 43", false, 2000, 2000); let mut filters = default_note_filters(); filters.for_issue_iid = Some(42); filters.project = Some("group/project".to_string()); let result = query_notes(&conn, &filters, &config).unwrap(); assert_eq!(result.total_count, 1); assert_eq!(result.notes[0].author_username, "alice"); } #[test] fn test_query_notes_filter_for_issue_uses_default_project() { let conn = setup_note_test_db(); let config = test_config(Some("group/project")); insert_test_project(&conn, 1, "group/project"); insert_test_issue(&conn, 1, 1, 42, "Target Issue"); insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue"); insert_test_note( &conn, 1, 100, 1, 1, "alice", "On issue 42", false, 1000, 1000, ); let mut filters = default_note_filters(); filters.for_issue_iid = Some(42); let result = query_notes(&conn, &filters, &config).unwrap(); assert_eq!(result.total_count, 1); } #[test] fn test_query_notes_filter_for_mr_with_project() { let conn = setup_note_test_db(); let config = test_config(None); insert_test_project(&conn, 1, "group/project"); insert_test_mr(&conn, 1, 1, 10, "Target MR"); insert_test_discussion(&conn, 1, "disc-1", 1, None, Some(1), "MergeRequest"); insert_test_note(&conn, 1, 100, 1, 1, "alice", "On MR 10", false, 1000, 1000); let mut filters = default_note_filters(); filters.for_mr_iid = Some(10); filters.project = Some("group/project".to_string()); let result = query_notes(&conn, &filters, &config).unwrap(); assert_eq!(result.total_count, 1); assert_eq!(result.notes[0].author_username, "alice"); } #[test] fn test_query_notes_filter_resolution_unresolved() { let conn = setup_note_test_db(); let config = test_config(None); insert_test_project(&conn, 1, "group/project"); insert_test_issue(&conn, 1, 1, 1, "Issue"); insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue"); insert_resolvable_note( &conn, 1, 100, 1, 1, "alice", "Unresolved", 1000, true, false, None, ); insert_resolvable_note( &conn, 2, 101, 1, 1, "bob", "Resolved", 2000, true, true, Some("carol"), ); insert_test_note( &conn, 3, 102, 1, 1, "dave", "Not resolvable", false, 3000, 3000, ); let mut filters = default_note_filters(); filters.resolution = Some("unresolved".to_string()); let result = query_notes(&conn, &filters, &config).unwrap(); assert_eq!(result.total_count, 1); assert_eq!(result.notes[0].author_username, "alice"); } #[test] fn test_query_notes_filter_resolution_resolved() { let conn = setup_note_test_db(); let config = test_config(None); insert_test_project(&conn, 1, "group/project"); insert_test_issue(&conn, 1, 1, 1, "Issue"); insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue"); insert_resolvable_note( &conn, 1, 100, 1, 1, "alice", "Unresolved", 1000, true, false, None, ); insert_resolvable_note( &conn, 2, 101, 1, 1, "bob", "Resolved", 2000, true, true, Some("carol"), ); let mut filters = default_note_filters(); filters.resolution = Some("resolved".to_string()); let result = query_notes(&conn, &filters, &config).unwrap(); assert_eq!(result.total_count, 1); assert_eq!(result.notes[0].author_username, "bob"); assert!(result.notes[0].resolved); assert_eq!(result.notes[0].resolved_by.as_deref(), Some("carol")); } #[test] fn test_query_notes_sort_created_desc() { let conn = setup_note_test_db(); let config = test_config(None); insert_test_project(&conn, 1, "group/project"); insert_test_issue(&conn, 1, 1, 1, "Issue"); insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue"); insert_test_note(&conn, 1, 100, 1, 1, "alice", "First", false, 1000, 1000); insert_test_note(&conn, 2, 101, 1, 1, "bob", "Second", false, 2000, 2000); insert_test_note(&conn, 3, 102, 1, 1, "carol", "Third", false, 3000, 3000); let filters = default_note_filters(); let result = query_notes(&conn, &filters, &config).unwrap(); assert_eq!(result.notes[0].author_username, "carol"); assert_eq!(result.notes[1].author_username, "bob"); assert_eq!(result.notes[2].author_username, "alice"); } #[test] fn test_query_notes_sort_created_asc() { let conn = setup_note_test_db(); let config = test_config(None); insert_test_project(&conn, 1, "group/project"); insert_test_issue(&conn, 1, 1, 1, "Issue"); insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue"); insert_test_note(&conn, 1, 100, 1, 1, "alice", "First", false, 1000, 1000); insert_test_note(&conn, 2, 101, 1, 1, "bob", "Second", false, 2000, 2000); insert_test_note(&conn, 3, 102, 1, 1, "carol", "Third", false, 3000, 3000); let mut filters = default_note_filters(); filters.order = "asc".to_string(); let result = query_notes(&conn, &filters, &config).unwrap(); assert_eq!(result.notes[0].author_username, "alice"); assert_eq!(result.notes[1].author_username, "bob"); assert_eq!(result.notes[2].author_username, "carol"); } #[test] fn test_query_notes_deterministic_tiebreak() { let conn = setup_note_test_db(); let config = test_config(None); insert_test_project(&conn, 1, "group/project"); insert_test_issue(&conn, 1, 1, 1, "Issue"); insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue"); insert_test_note(&conn, 1, 100, 1, 1, "alice", "A", false, 1000, 1000); insert_test_note(&conn, 2, 101, 1, 1, "bob", "B", false, 1000, 1000); insert_test_note(&conn, 3, 102, 1, 1, "carol", "C", false, 1000, 1000); let filters = default_note_filters(); let result = query_notes(&conn, &filters, &config).unwrap(); assert_eq!(result.notes[0].id, 3); assert_eq!(result.notes[1].id, 2); assert_eq!(result.notes[2].id, 1); let mut filters = default_note_filters(); filters.order = "asc".to_string(); let result = query_notes(&conn, &filters, &config).unwrap(); assert_eq!(result.notes[0].id, 1); assert_eq!(result.notes[1].id, 2); assert_eq!(result.notes[2].id, 3); } #[test] fn test_query_notes_limit() { let conn = setup_note_test_db(); let config = test_config(None); insert_test_project(&conn, 1, "group/project"); insert_test_issue(&conn, 1, 1, 1, "Issue"); insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue"); for i in 1..=10 { insert_test_note( &conn, i, 100 + i, 1, 1, "alice", &format!("Note {i}"), false, i * 1000, i * 1000, ); } let mut filters = default_note_filters(); filters.limit = 3; let result = query_notes(&conn, &filters, &config).unwrap(); assert_eq!(result.total_count, 10); assert_eq!(result.notes.len(), 3); } #[test] fn test_query_notes_combined_filters() { let conn = setup_note_test_db(); let config = test_config(None); insert_test_project(&conn, 1, "group/project"); insert_test_issue(&conn, 1, 1, 1, "Issue"); insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue"); insert_test_note( &conn, 1, 100, 1, 1, "alice", "Found a bug here", false, 1000, 1000, ); insert_test_note( &conn, 2, 101, 1, 1, "alice", "Looks good", false, 2000, 2000, ); insert_test_note( &conn, 3, 102, 1, 1, "bob", "Another bug fix", false, 3000, 3000, ); let mut filters = default_note_filters(); filters.author = Some("alice".to_string()); filters.contains = Some("bug".to_string()); let result = query_notes(&conn, &filters, &config).unwrap(); assert_eq!(result.total_count, 1); assert_eq!(result.notes[0].id, 1); } #[test] fn test_query_notes_filter_note_type() { let conn = setup_note_test_db(); let config = test_config(None); insert_test_project(&conn, 1, "group/project"); insert_test_issue(&conn, 1, 1, 1, "Issue"); insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue"); insert_note_with_position( &conn, 1, 100, 1, 1, "alice", "Diff comment", 1000, Some("DiffNote"), Some("src/main.rs"), Some(10), ); insert_test_note( &conn, 2, 101, 1, 1, "bob", "Discussion note", false, 2000, 2000, ); let mut filters = default_note_filters(); filters.note_type = Some("DiffNote".to_string()); let result = query_notes(&conn, &filters, &config).unwrap(); assert_eq!(result.total_count, 1); assert_eq!(result.notes[0].author_username, "alice"); } #[test] fn test_query_notes_filter_discussion_id() { let conn = setup_note_test_db(); let config = test_config(None); insert_test_project(&conn, 1, "group/project"); insert_test_issue(&conn, 1, 1, 1, "Issue"); insert_test_discussion(&conn, 1, "disc-aaa", 1, Some(1), None, "Issue"); insert_test_discussion(&conn, 2, "disc-bbb", 1, Some(1), None, "Issue"); insert_test_note(&conn, 1, 100, 1, 1, "alice", "In disc A", false, 1000, 1000); insert_test_note(&conn, 2, 101, 2, 1, "bob", "In disc B", false, 2000, 2000); let mut filters = default_note_filters(); filters.discussion_id = Some("disc-aaa".to_string()); let result = query_notes(&conn, &filters, &config).unwrap(); assert_eq!(result.total_count, 1); assert_eq!(result.notes[0].author_username, "alice"); } #[test] fn test_query_notes_filter_note_id() { let conn = setup_note_test_db(); let config = test_config(None); insert_test_project(&conn, 1, "group/project"); insert_test_issue(&conn, 1, 1, 1, "Issue"); insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue"); insert_test_note(&conn, 1, 100, 1, 1, "alice", "First", false, 1000, 1000); insert_test_note(&conn, 2, 101, 1, 1, "bob", "Second", false, 2000, 2000); let mut filters = default_note_filters(); filters.note_id = Some(2); let result = query_notes(&conn, &filters, &config).unwrap(); assert_eq!(result.total_count, 1); assert_eq!(result.notes[0].author_username, "bob"); } #[test] fn test_query_notes_filter_gitlab_note_id() { let conn = setup_note_test_db(); let config = test_config(None); insert_test_project(&conn, 1, "group/project"); insert_test_issue(&conn, 1, 1, 1, "Issue"); insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue"); insert_test_note(&conn, 1, 100, 1, 1, "alice", "First", false, 1000, 1000); insert_test_note(&conn, 2, 200, 1, 1, "bob", "Second", false, 2000, 2000); let mut filters = default_note_filters(); filters.gitlab_note_id = Some(200); let result = query_notes(&conn, &filters, &config).unwrap(); assert_eq!(result.total_count, 1); assert_eq!(result.notes[0].author_username, "bob"); } #[test] fn test_query_notes_filter_project() { let conn = setup_note_test_db(); let config = test_config(None); insert_test_project(&conn, 1, "group/project-a"); insert_test_project(&conn, 2, "group/project-b"); insert_test_issue(&conn, 1, 1, 1, "Issue A"); insert_test_issue(&conn, 2, 2, 1, "Issue B"); insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue"); insert_test_discussion(&conn, 2, "disc-2", 2, Some(2), None, "Issue"); insert_test_note(&conn, 1, 100, 1, 1, "alice", "In A", false, 1000, 1000); insert_test_note(&conn, 2, 101, 2, 2, "bob", "In B", false, 2000, 2000); let mut filters = default_note_filters(); filters.project = Some("group/project-a".to_string()); let result = query_notes(&conn, &filters, &config).unwrap(); assert_eq!(result.total_count, 1); assert_eq!(result.notes[0].author_username, "alice"); } #[test] fn test_query_notes_mr_parent() { let conn = setup_note_test_db(); let config = test_config(None); insert_test_project(&conn, 1, "group/project"); insert_test_mr(&conn, 1, 1, 99, "My MR"); insert_test_discussion(&conn, 1, "disc-1", 1, None, Some(1), "MergeRequest"); insert_test_note( &conn, 1, 100, 1, 1, "alice", "MR comment", false, 1000, 1000, ); let filters = default_note_filters(); let result = query_notes(&conn, &filters, &config).unwrap(); assert_eq!(result.total_count, 1); assert_eq!( result.notes[0].noteable_type.as_deref(), Some("MergeRequest") ); assert_eq!(result.notes[0].parent_iid, Some(99)); assert_eq!(result.notes[0].parent_title.as_deref(), Some("My MR")); } #[test] fn test_note_list_row_json_conversion() { let row = NoteListRow { id: 1, gitlab_id: 100, author_username: "alice".to_string(), body: Some("Test body".to_string()), note_type: Some("DiffNote".to_string()), is_system: false, created_at: 1_705_315_800_000, updated_at: 1_705_315_800_000, position_new_path: Some("src/main.rs".to_string()), position_new_line: Some(42), position_old_path: None, position_old_line: None, resolvable: true, resolved: false, resolved_by: None, noteable_type: Some("Issue".to_string()), parent_iid: Some(5), parent_title: Some("Test Issue".to_string()), project_path: "group/project".to_string(), }; let json_row = NoteListRowJson::from(&row); assert_eq!(json_row.id, 1); assert_eq!(json_row.gitlab_id, 100); assert_eq!(json_row.author_username, "alice"); assert!(json_row.created_at_iso.contains("2024-01-15")); assert!(json_row.updated_at_iso.contains("2024-01-15")); assert_eq!(json_row.position_new_path.as_deref(), Some("src/main.rs")); assert_eq!(json_row.position_new_line, Some(42)); assert!(!json_row.is_system); assert!(json_row.resolvable); assert!(!json_row.resolved); } #[test] fn test_note_list_result_json_conversion() { let result = NoteListResult { notes: vec![NoteListRow { id: 1, gitlab_id: 100, author_username: "alice".to_string(), body: Some("Test".to_string()), note_type: None, is_system: false, created_at: 1000, updated_at: 2000, position_new_path: None, position_new_line: None, position_old_path: None, position_old_line: None, resolvable: false, resolved: false, resolved_by: None, noteable_type: Some("Issue".to_string()), parent_iid: Some(1), parent_title: Some("Issue".to_string()), project_path: "group/project".to_string(), }], total_count: 5, }; let json_result = NoteListResultJson::from(&result); assert_eq!(json_result.total_count, 5); assert_eq!(json_result.showing, 1); assert_eq!(json_result.notes.len(), 1); } #[test] fn test_query_notes_sort_updated() { let conn = setup_note_test_db(); let config = test_config(None); insert_test_project(&conn, 1, "group/project"); insert_test_issue(&conn, 1, 1, 1, "Issue"); insert_test_discussion(&conn, 1, "disc-1", 1, Some(1), None, "Issue"); insert_test_note(&conn, 1, 100, 1, 1, "alice", "A", false, 1000, 3000); insert_test_note(&conn, 2, 101, 1, 1, "bob", "B", false, 2000, 1000); insert_test_note(&conn, 3, 102, 1, 1, "carol", "C", false, 3000, 2000); let mut filters = default_note_filters(); filters.sort = "updated".to_string(); filters.order = "desc".to_string(); let result = query_notes(&conn, &filters, &config).unwrap(); assert_eq!(result.notes[0].author_username, "alice"); assert_eq!(result.notes[1].author_username, "carol"); assert_eq!(result.notes[2].author_username, "bob"); } #[test] fn test_note_escape_like() { assert_eq!(note_escape_like("normal/path"), "normal/path"); assert_eq!(note_escape_like("has_underscore"), "has\\_underscore"); assert_eq!(note_escape_like("has%percent"), "has\\%percent"); assert_eq!(note_escape_like("has\\backslash"), "has\\\\backslash"); } // ----------------------------------------------------------------------- // Note output formatting tests // ----------------------------------------------------------------------- #[test] fn test_truncate_note_body() { let short = "short body"; assert_eq!(truncate_body(short, 60), "short body"); let long: String = "a".repeat(200); let result = truncate_body(&long, 60); assert_eq!(result.chars().count(), 63); // 60 chars + "..." assert!(result.ends_with("...")); } #[test] fn test_jsonl_output_one_per_line() { let result = NoteListResult { notes: vec![ NoteListRow { id: 1, gitlab_id: 100, author_username: "alice".to_string(), body: Some("First note".to_string()), note_type: None, is_system: false, created_at: 1_000_000, updated_at: 2_000_000, position_new_path: None, position_new_line: None, position_old_path: None, position_old_line: None, resolvable: false, resolved: false, resolved_by: None, noteable_type: None, parent_iid: None, parent_title: None, project_path: "group/project".to_string(), }, NoteListRow { id: 2, gitlab_id: 101, author_username: "bob".to_string(), body: Some("Second note".to_string()), note_type: Some("DiffNote".to_string()), is_system: false, created_at: 3_000_000, updated_at: 4_000_000, position_new_path: None, position_new_line: None, position_old_path: None, position_old_line: None, resolvable: false, resolved: false, resolved_by: None, noteable_type: None, parent_iid: None, parent_title: None, project_path: "group/project".to_string(), }, ], total_count: 2, }; // Each note should produce valid JSON when serialized individually for note in &result.notes { let json_row = NoteListRowJson::from(note); let json_str = serde_json::to_string(&json_row).unwrap(); // Verify it parses back as valid JSON let _: serde_json::Value = serde_json::from_str(&json_str).unwrap(); // Verify no embedded newlines in the JSON line assert!(!json_str.contains('\n')); } } #[test] fn test_format_note_parent_variants() { assert_eq!(format_note_parent(Some("Issue"), Some(42)), "Issue #42"); assert_eq!(format_note_parent(Some("MergeRequest"), Some(99)), "MR !99"); assert_eq!(format_note_parent(None, None), "-"); assert_eq!(format_note_parent(Some("Issue"), None), "-"); } #[test] fn test_format_note_type_variants() { assert_eq!(format_note_type(Some("DiffNote")), "Diff"); assert_eq!(format_note_type(Some("DiscussionNote")), "Disc"); assert_eq!(format_note_type(None), "-"); assert_eq!(format_note_type(Some("Other")), "-"); }