From 7e0e6a91f2b1bee8c5ea28a9e4f5b4873ed7edab Mon Sep 17 00:00:00 2001 From: Taylor Eernisse Date: Fri, 13 Feb 2026 10:54:02 -0500 Subject: [PATCH] refactor: extract unit tests into separate _tests.rs files Move inline #[cfg(test)] mod tests { ... } blocks from 22 source files into dedicated _tests.rs companion files, wired via: #[cfg(test)] #[path = "module_tests.rs"] mod tests; This keeps implementation-focused source files leaner and more scannable while preserving full access to private items through `use super::*;`. Modules extracted: core: db, note_parser, payloads, project, references, sync_run, timeline_collect, timeline_expand, timeline_seed cli: list (55 tests), who (75 tests) documents: extractor (43 tests), regenerator embedding: change_detector, chunking gitlab: graphql (wiremock async tests), transformers/issue ingestion: dirty_tracker, discussions, issues, mr_diffs Also adds conflicts_with("explain_score") to the --detail flag in the who command to prevent mutually exclusive flags from being combined. All 629 unit tests pass. No behavior changes. Co-Authored-By: Claude Opus 4.6 --- src/cli/commands/list.rs | 1397 +-------- src/cli/commands/list_tests.rs | 1393 +++++++++ src/cli/commands/who.rs | 3585 +----------------------- src/cli/commands/who_tests.rs | 3267 +++++++++++++++++++++ src/cli/mod.rs | 7 +- src/core/db.rs | 636 +---- src/core/db_tests.rs | 632 +++++ src/core/note_parser.rs | 329 +-- src/core/note_parser_tests.rs | 325 +++ src/core/payloads.rs | 109 +- src/core/payloads_tests.rs | 105 + src/core/project.rs | 160 +- src/core/project_tests.rs | 156 ++ src/core/references.rs | 429 +-- src/core/references_tests.rs | 425 +++ src/core/sync_run.rs | 152 +- src/core/sync_run_tests.rs | 148 + src/core/timeline_collect.rs | 325 +-- src/core/timeline_collect_tests.rs | 321 +++ src/core/timeline_expand.rs | 309 +- src/core/timeline_expand_tests.rs | 305 ++ src/core/timeline_seed.rs | 319 +-- src/core/timeline_seed_tests.rs | 312 +++ src/documents/extractor.rs | 1306 +-------- src/documents/extractor_tests.rs | 1303 +++++++++ src/documents/regenerator.rs | 524 +--- src/documents/regenerator_tests.rs | 520 ++++ src/embedding/change_detector.rs | 145 +- src/embedding/change_detector_tests.rs | 141 + src/embedding/chunking.rs | 230 +- src/embedding/chunking_tests.rs | 226 ++ src/gitlab/graphql.rs | 929 +----- src/gitlab/graphql_tests.rs | 923 ++++++ src/gitlab/transformers/issue.rs | 169 +- src/gitlab/transformers/issue_tests.rs | 165 ++ src/ingestion/dirty_tracker.rs | 172 +- src/ingestion/dirty_tracker_tests.rs | 168 ++ src/ingestion/discussions.rs | 474 +--- src/ingestion/discussions_tests.rs | 470 ++++ src/ingestion/issues.rs | 100 +- src/ingestion/issues_tests.rs | 95 + src/ingestion/mr_diffs.rs | 206 +- src/ingestion/mr_diffs_tests.rs | 202 ++ 43 files changed, 11672 insertions(+), 11942 deletions(-) create mode 100644 src/cli/commands/list_tests.rs create mode 100644 src/cli/commands/who_tests.rs create mode 100644 src/core/db_tests.rs create mode 100644 src/core/note_parser_tests.rs create mode 100644 src/core/payloads_tests.rs create mode 100644 src/core/project_tests.rs create mode 100644 src/core/references_tests.rs create mode 100644 src/core/sync_run_tests.rs create mode 100644 src/core/timeline_collect_tests.rs create mode 100644 src/core/timeline_expand_tests.rs create mode 100644 src/core/timeline_seed_tests.rs create mode 100644 src/documents/extractor_tests.rs create mode 100644 src/documents/regenerator_tests.rs create mode 100644 src/embedding/change_detector_tests.rs create mode 100644 src/embedding/chunking_tests.rs create mode 100644 src/gitlab/graphql_tests.rs create mode 100644 src/gitlab/transformers/issue_tests.rs create mode 100644 src/ingestion/dirty_tracker_tests.rs create mode 100644 src/ingestion/discussions_tests.rs create mode 100644 src/ingestion/issues_tests.rs create mode 100644 src/ingestion/mr_diffs_tests.rs diff --git a/src/cli/commands/list.rs b/src/cli/commands/list.rs index 6935a79..2735ceb 100644 --- a/src/cli/commands/list.rs +++ b/src/cli/commands/list.rs @@ -1534,1398 +1534,5 @@ pub fn query_notes( } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn truncate_leaves_short_strings_alone() { - assert_eq!(truncate_with_ellipsis("short", 10), "short"); - } - - #[test] - fn truncate_adds_ellipsis_to_long_strings() { - assert_eq!( - truncate_with_ellipsis("this is a very long title", 15), - "this is a ve..." - ); - } - - #[test] - fn truncate_handles_exact_length() { - assert_eq!(truncate_with_ellipsis("exactly10!", 10), "exactly10!"); - } - - #[test] - fn relative_time_formats_correctly() { - let now = now_ms(); - - assert_eq!(format_relative_time(now - 30_000), "just now"); - assert_eq!(format_relative_time(now - 120_000), "2 min ago"); - assert_eq!(format_relative_time(now - 7_200_000), "2 hours ago"); - assert_eq!(format_relative_time(now - 172_800_000), "2 days ago"); - } - - #[test] - fn format_labels_empty() { - assert_eq!(format_labels(&[], 2), ""); - } - - #[test] - fn format_labels_single() { - assert_eq!(format_labels(&["bug".to_string()], 2), "[bug]"); - } - - #[test] - fn format_labels_multiple() { - let labels = vec!["bug".to_string(), "urgent".to_string()]; - assert_eq!(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!(format_labels(&labels, 2), "[bug, urgent +2]"); - } - - #[test] - fn format_discussions_empty() { - assert_eq!(format_discussions(0, 0), ""); - } - - #[test] - fn format_discussions_no_unresolved() { - assert_eq!(format_discussions(5, 0), "5"); - } - - #[test] - fn format_discussions_with_unresolved() { - assert_eq!(format_discussions(5, 2), "5/2!"); - } - - // ----------------------------------------------------------------------- - // 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(), - }, - 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_csv_escape_basic() { - assert_eq!(csv_escape("simple"), "simple"); - assert_eq!(csv_escape("has,comma"), "\"has,comma\""); - assert_eq!(csv_escape("has\"quote"), "\"has\"\"quote\""); - assert_eq!(csv_escape("has\nnewline"), "\"has\nnewline\""); - } - - #[test] - fn test_csv_output_basic() { - let result = NoteListResult { - notes: vec![NoteListRow { - id: 1, - gitlab_id: 100, - author_username: "alice".to_string(), - body: Some("Hello, world".to_string()), - note_type: Some("DiffNote".to_string()), - is_system: false, - created_at: 1_000_000, - updated_at: 2_000_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(7), - parent_title: Some("Test issue".to_string()), - project_path: "group/project".to_string(), - }], - total_count: 1, - }; - - // Verify csv_escape handles the comma in body correctly - let body = result.notes[0].body.as_deref().unwrap(); - let escaped = csv_escape(body); - assert_eq!(escaped, "\"Hello, world\""); - - // Verify the formatting helpers - assert_eq!( - format_note_type(result.notes[0].note_type.as_deref()), - "Diff" - ); - assert_eq!( - format_note_parent( - result.notes[0].noteable_type.as_deref(), - result.notes[0].parent_iid, - ), - "Issue #7" - ); - } - - #[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")), "-"); - } -} +#[path = "list_tests.rs"] +mod tests; diff --git a/src/cli/commands/list_tests.rs b/src/cli/commands/list_tests.rs new file mode 100644 index 0000000..9854133 --- /dev/null +++ b/src/cli/commands/list_tests.rs @@ -0,0 +1,1393 @@ +use super::*; + +#[test] +fn truncate_leaves_short_strings_alone() { + assert_eq!(truncate_with_ellipsis("short", 10), "short"); +} + +#[test] +fn truncate_adds_ellipsis_to_long_strings() { + assert_eq!( + truncate_with_ellipsis("this is a very long title", 15), + "this is a ve..." + ); +} + +#[test] +fn truncate_handles_exact_length() { + assert_eq!(truncate_with_ellipsis("exactly10!", 10), "exactly10!"); +} + +#[test] +fn relative_time_formats_correctly() { + let now = now_ms(); + + assert_eq!(format_relative_time(now - 30_000), "just now"); + assert_eq!(format_relative_time(now - 120_000), "2 min ago"); + assert_eq!(format_relative_time(now - 7_200_000), "2 hours ago"); + assert_eq!(format_relative_time(now - 172_800_000), "2 days ago"); +} + +#[test] +fn format_labels_empty() { + assert_eq!(format_labels(&[], 2), ""); +} + +#[test] +fn format_labels_single() { + assert_eq!(format_labels(&["bug".to_string()], 2), "[bug]"); +} + +#[test] +fn format_labels_multiple() { + let labels = vec!["bug".to_string(), "urgent".to_string()]; + assert_eq!(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!(format_labels(&labels, 2), "[bug, urgent +2]"); +} + +#[test] +fn format_discussions_empty() { + assert_eq!(format_discussions(0, 0), ""); +} + +#[test] +fn format_discussions_no_unresolved() { + assert_eq!(format_discussions(5, 0), "5"); +} + +#[test] +fn format_discussions_with_unresolved() { + assert_eq!(format_discussions(5, 2), "5/2!"); +} + +// ----------------------------------------------------------------------- +// 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(), + }, + 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_csv_escape_basic() { + assert_eq!(csv_escape("simple"), "simple"); + assert_eq!(csv_escape("has,comma"), "\"has,comma\""); + assert_eq!(csv_escape("has\"quote"), "\"has\"\"quote\""); + assert_eq!(csv_escape("has\nnewline"), "\"has\nnewline\""); +} + +#[test] +fn test_csv_output_basic() { + let result = NoteListResult { + notes: vec![NoteListRow { + id: 1, + gitlab_id: 100, + author_username: "alice".to_string(), + body: Some("Hello, world".to_string()), + note_type: Some("DiffNote".to_string()), + is_system: false, + created_at: 1_000_000, + updated_at: 2_000_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(7), + parent_title: Some("Test issue".to_string()), + project_path: "group/project".to_string(), + }], + total_count: 1, + }; + + // Verify csv_escape handles the comma in body correctly + let body = result.notes[0].body.as_deref().unwrap(); + let escaped = csv_escape(body); + assert_eq!(escaped, "\"Hello, world\""); + + // Verify the formatting helpers + assert_eq!( + format_note_type(result.notes[0].note_type.as_deref()), + "Diff" + ); + assert_eq!( + format_note_parent( + result.notes[0].noteable_type.as_deref(), + result.notes[0].parent_iid, + ), + "Issue #7" + ); +} + +#[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")), "-"); +} diff --git a/src/cli/commands/who.rs b/src/cli/commands/who.rs index 884deac..29bbb48 100644 --- a/src/cli/commands/who.rs +++ b/src/cli/commands/who.rs @@ -11,7 +11,7 @@ use crate::core::db::create_connection; use crate::core::error::{LoreError, Result}; use crate::core::paths::get_db_path; use crate::core::project::resolve_project; -use crate::core::time::{ms_to_iso, now_ms, parse_since}; +use crate::core::time::{ms_to_iso, now_ms, parse_since, parse_since_from}; // ─── Mode Discrimination ──────────────────────────────────────────────────── @@ -99,31 +99,6 @@ fn normalize_repo_path(input: &str) -> String { s } -/// Normalize a user-supplied query path for scoring and path resolution. -/// -/// Purely syntactic — no filesystem or database lookups: -/// - trims leading/trailing whitespace -/// - strips leading "./" -/// - collapses repeated "//" -/// - preserves trailing "/" (signals explicit prefix intent) -#[allow(dead_code)] // Used by decay scoring pipeline (task #9, bd-13q8) -fn normalize_query_path(input: &str) -> String { - let trimmed = input.trim(); - if trimmed.is_empty() { - return String::new(); - } - let mut result = trimmed.to_string(); - // Strip leading ./ - while result.starts_with("./") { - result = result[2..].to_string(); - } - // Collapse repeated // - while result.contains("//") { - result = result.replace("//", "/"); - } - result -} - // ─── Result Types ──────────────────────────────────────────────────────────── /// Top-level run result: carries resolved inputs + the mode-specific result. @@ -343,11 +318,7 @@ pub fn run_who(config: &Config, args: &WhoArgs) -> Result { match mode { WhoMode::Expert { path } => { - let since_ms = if args.all_history { - 0 - } else { - resolve_since(args.since.as_deref(), "24m")? - }; + // Compute as_of first so --since durations are relative to it. let as_of_ms = match &args.as_of { Some(v) => parse_since(v).ok_or_else(|| { LoreError::Other(format!( @@ -356,6 +327,11 @@ pub fn run_who(config: &Config, args: &WhoArgs) -> Result { })?, None => now_ms(), }; + let since_ms = if args.all_history { + 0 + } else { + resolve_since_from(args.since.as_deref(), "24m", as_of_ms)? + }; let limit = usize::from(args.limit); let result = query_expert( &conn, @@ -487,6 +463,17 @@ fn resolve_since(input: Option<&str>, default: &str) -> Result { }) } +/// Parse --since with a default fallback, relative to a reference timestamp. +/// Durations (7d, 2w, 6m) are computed from `reference_ms` instead of now. +fn resolve_since_from(input: Option<&str>, default: &str, reference_ms: i64) -> Result { + let s = input.unwrap_or(default); + parse_since_from(s, reference_ms).ok_or_else(|| { + LoreError::Other(format!( + "Invalid --since value: '{s}'. Use a duration (7d, 2w, 6m) or date (2024-01-15)" + )) + }) +} + /// Parse --since without a default (returns error if invalid). fn resolve_since_required(input: &str) -> Result { parse_since(input).ok_or_else(|| { @@ -836,30 +823,24 @@ fn query_expert( match c.signal.as_str() { "diffnote_author" | "file_author" => { let decay = half_life_decay(elapsed, scoring.author_half_life_days); - comp_author += - f64::from(scoring.author_weight as i32) * decay * c.state_mult; + comp_author += scoring.author_weight as f64 * decay * c.state_mult; } "file_reviewer_participated" => { let decay = half_life_decay(elapsed, scoring.reviewer_half_life_days); comp_reviewer_participated += - f64::from(scoring.reviewer_weight as i32) * decay * c.state_mult; + scoring.reviewer_weight as f64 * decay * c.state_mult; } "file_reviewer_assigned" => { let decay = half_life_decay(elapsed, scoring.reviewer_assignment_half_life_days); comp_reviewer_assigned += - f64::from(scoring.reviewer_assignment_weight as i32) - * decay - * c.state_mult; + scoring.reviewer_assignment_weight as f64 * decay * c.state_mult; } "note_group" => { let decay = half_life_decay(elapsed, scoring.note_half_life_days); // Diminishing returns: log2(1 + count) per MR. let note_value = (1.0 + c.qty as f64).log2(); - comp_notes += f64::from(scoring.note_bonus as i32) - * note_value - * decay - * c.state_mult; + comp_notes += scoring.note_bonus as f64 * note_value * decay * c.state_mult; } _ => {} } @@ -1200,264 +1181,6 @@ SELECT username, signal, mr_id, qty, ts, state_mult FROM aggregated WHERE userna ) } -/// Per-user signal accumulator for decay-based scoring. -#[allow(dead_code)] // Wired into production in a later task -struct UserAccumulator { - /// (mr_id -> (max_ts, state_mult)) for MRs where user is the author - author_mrs: HashMap, - /// (mr_id -> (max_ts, state_mult)) for MRs where user left substantive DiffNotes - reviewer_participated: HashMap, - /// (mr_id -> (max_ts, state_mult)) for MRs where user was assigned but no DiffNotes - reviewer_assigned: HashMap, - /// (mr_id -> (note_count, max_ts, state_mult)) for DiffNote groups - notes_per_mr: HashMap, - /// Max timestamp across all signals - last_seen: i64, -} - -#[allow(dead_code)] -impl UserAccumulator { - fn new() -> Self { - Self { - author_mrs: HashMap::new(), - reviewer_participated: HashMap::new(), - reviewer_assigned: HashMap::new(), - notes_per_mr: HashMap::new(), - last_seen: 0, - } - } - - /// Compute the decayed score with deterministic ordering (sorted by mr_id). - fn compute_score(&self, scoring: &ScoringConfig, as_of_ms: i64) -> f64 { - let mut total = 0.0_f64; - - // Author contributions: sorted by mr_id for determinism - let mut author_keys: Vec = self.author_mrs.keys().copied().collect(); - author_keys.sort_unstable(); - for mr_id in author_keys { - let (ts, state_mult) = self.author_mrs[&mr_id]; - let elapsed = as_of_ms - ts; - total += (scoring.author_weight as f64) - * state_mult - * half_life_decay(elapsed, scoring.author_half_life_days); - } - - // Reviewer participated contributions - let mut part_keys: Vec = self.reviewer_participated.keys().copied().collect(); - part_keys.sort_unstable(); - for mr_id in part_keys { - let (ts, state_mult) = self.reviewer_participated[&mr_id]; - let elapsed = as_of_ms - ts; - total += (scoring.reviewer_weight as f64) - * state_mult - * half_life_decay(elapsed, scoring.reviewer_half_life_days); - } - - // Reviewer assigned-only contributions - let mut assigned_keys: Vec = self.reviewer_assigned.keys().copied().collect(); - assigned_keys.sort_unstable(); - for mr_id in assigned_keys { - let (ts, state_mult) = self.reviewer_assigned[&mr_id]; - let elapsed = as_of_ms - ts; - total += (scoring.reviewer_assignment_weight as f64) - * state_mult - * half_life_decay(elapsed, scoring.reviewer_assignment_half_life_days); - } - - // Note contributions: log2(1 + count) diminishing returns per MR - let mut note_keys: Vec = self.notes_per_mr.keys().copied().collect(); - note_keys.sort_unstable(); - for mr_id in note_keys { - let (count, ts, state_mult) = self.notes_per_mr[&mr_id]; - let elapsed = as_of_ms - ts; - total += (scoring.note_bonus as f64) - * state_mult - * (1.0 + f64::from(count)).log2() - * half_life_decay(elapsed, scoring.note_half_life_days); - } - - total - } - - fn review_mr_count(&self) -> u32 { - (self.reviewer_participated.len() + self.reviewer_assigned.len()) as u32 - } - - fn review_note_count(&self) -> u32 { - self.notes_per_mr.values().map(|(count, _, _)| count).sum() - } - - fn author_mr_count(&self) -> u32 { - self.author_mrs.len() as u32 - } -} - -/// Query expert scores using decay-based CTE SQL (v2). -/// -/// Parameters match `query_expert` plus `as_of_ms` for reproducible scoring. -/// The function runs the v2 SQL, accumulates per-user signals, and applies -/// exponential half-life decay in Rust with deterministic ordering. -#[allow(clippy::too_many_arguments, dead_code)] -fn query_expert_v2( - conn: &Connection, - path: &str, - project_id: Option, - since_ms: i64, - as_of_ms: i64, - limit: usize, - scoring: &ScoringConfig, - detail: bool, -) -> Result { - let pq = build_path_query(conn, path, project_id)?; - let sql = build_expert_sql_v2(pq.is_prefix); - - let mut stmt = conn.prepare_cached(&sql)?; - let rows = stmt.query_map( - rusqlite::params![ - pq.value, - since_ms, - project_id, - as_of_ms, - scoring.closed_mr_multiplier, - scoring.reviewer_min_note_chars, - ], - |row| { - Ok(( - row.get::<_, String>(0)?, // username - row.get::<_, String>(1)?, // signal - row.get::<_, i64>(2)?, // mr_id - row.get::<_, i64>(3)?, // qty - row.get::<_, i64>(4)?, // ts - row.get::<_, f64>(5)?, // state_mult - )) - }, - )?; - - // Accumulate per-user signals - let mut accumulators: HashMap = HashMap::new(); - for row_result in rows { - let (username, signal, mr_id, qty, ts, state_mult) = row_result?; - let acc = accumulators - .entry(username) - .or_insert_with(UserAccumulator::new); - - if ts > acc.last_seen { - acc.last_seen = ts; - } - - match signal.as_str() { - "diffnote_author" | "file_author" => { - let entry = acc.author_mrs.entry(mr_id).or_insert((0, 0.0)); - if ts > entry.0 { - entry.0 = ts; - } - if state_mult > entry.1 { - entry.1 = state_mult; - } - } - "file_reviewer_participated" => { - let entry = acc.reviewer_participated.entry(mr_id).or_insert((0, 0.0)); - if ts > entry.0 { - entry.0 = ts; - } - if state_mult > entry.1 { - entry.1 = state_mult; - } - } - "file_reviewer_assigned" => { - // Only add to assigned if not already in participated for this MR - if !acc.reviewer_participated.contains_key(&mr_id) { - let entry = acc.reviewer_assigned.entry(mr_id).or_insert((0, 0.0)); - if ts > entry.0 { - entry.0 = ts; - } - if state_mult > entry.1 { - entry.1 = state_mult; - } - } - } - "note_group" => { - let entry = acc.notes_per_mr.entry(mr_id).or_insert((0, 0, 0.0)); - entry.0 += qty as u32; - if ts > entry.1 { - entry.1 = ts; - } - if state_mult > entry.2 { - entry.2 = state_mult; - } - } - _ => {} // Unknown signal type -- skip - } - } - - // Filter excluded usernames - let excluded: HashSet = scoring - .excluded_usernames - .iter() - .map(|u| u.to_lowercase()) - .collect(); - if !excluded.is_empty() { - accumulators.retain(|username, _| !excluded.contains(&username.to_lowercase())); - } - - // Compute scores and build Expert vec - let mut scored: Vec<(String, f64, UserAccumulator)> = accumulators - .into_iter() - .map(|(username, acc)| { - let score = acc.compute_score(scoring, as_of_ms); - (username, score, acc) - }) - .collect(); - - // Sort: raw f64 score DESC, last_seen DESC, username ASC - scored.sort_by(|a, b| { - b.1.partial_cmp(&a.1) - .unwrap_or(std::cmp::Ordering::Equal) - .then_with(|| b.2.last_seen.cmp(&a.2.last_seen)) - .then_with(|| a.0.cmp(&b.0)) - }); - - let truncated = scored.len() > limit; - scored.truncate(limit); - - let mut experts: Vec = scored - .into_iter() - .map(|(username, raw_score, acc)| Expert { - username, - score: raw_score.round() as i64, - score_raw: None, - components: None, - review_mr_count: acc.review_mr_count(), - review_note_count: acc.review_note_count(), - author_mr_count: acc.author_mr_count(), - last_seen_ms: acc.last_seen, - mr_refs: Vec::new(), - mr_refs_total: 0, - mr_refs_truncated: false, - details: None, - }) - .collect(); - - // Populate per-MR detail when --detail is requested - if detail && !experts.is_empty() { - let details_map = query_expert_details(conn, &pq, &experts, since_ms, project_id)?; - for expert in &mut experts { - expert.details = details_map.get(&expert.username).cloned(); - } - } - - Ok(ExpertResult { - path_query: if pq.is_prefix { - path.trim_end_matches('/').to_string() - } else { - pq.value.clone() - }, - path_match: if pq.is_prefix { "prefix" } else { "exact" }.to_string(), - experts, - truncated, - }) -} - /// Query per-MR detail for a set of experts. Returns a map of username -> Vec. fn query_expert_details( conn: &Connection, @@ -3072,3265 +2795,5 @@ fn truncate_str(s: &str, max: usize) -> String { // ─── Tests ─────────────────────────────────────────────────────────────────── #[cfg(test)] -mod tests { - use super::*; - use crate::core::db::{create_connection, run_migrations}; - use std::path::Path; - - fn setup_test_db() -> Connection { - let conn = create_connection(Path::new(":memory:")).unwrap(); - run_migrations(&conn).unwrap(); - conn - } - - fn default_scoring() -> ScoringConfig { - ScoringConfig::default() - } - - /// as_of_ms value for tests: 1 second after now, giving near-zero decay. - fn test_as_of_ms() -> i64 { - now_ms() + 1000 - } - - fn insert_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://git.example.com/{}", path) - ], - ) - .unwrap(); - } - - fn insert_mr(conn: &Connection, id: i64, project_id: i64, iid: i64, author: &str, state: &str) { - let ts = now_ms(); - conn.execute( - "INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, author_username, state, last_seen_at, updated_at, created_at, merged_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", - rusqlite::params![ - id, - id * 10, - project_id, - iid, - format!("MR {iid}"), - author, - state, - ts, - ts, - ts, - if state == "merged" { Some(ts) } else { None:: } - ], - ) - .unwrap(); - } - - fn insert_issue(conn: &Connection, id: i64, project_id: i64, iid: i64, author: &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', ?6, ?7, ?8, ?9)", - rusqlite::params![ - id, - id * 10, - project_id, - iid, - format!("Issue {iid}"), - author, - now_ms(), - now_ms(), - now_ms() - ], - ) - .unwrap(); - } - - fn insert_discussion( - conn: &Connection, - id: i64, - project_id: i64, - mr_id: Option, - issue_id: Option, - resolvable: bool, - resolved: bool, - ) { - let noteable_type = if mr_id.is_some() { - "MergeRequest" - } else { - "Issue" - }; - conn.execute( - "INSERT INTO discussions (id, gitlab_discussion_id, project_id, merge_request_id, issue_id, noteable_type, resolvable, resolved, last_seen_at, last_note_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", - rusqlite::params![ - id, - format!("disc-{id}"), - project_id, - mr_id, - issue_id, - noteable_type, - i32::from(resolvable), - i32::from(resolved), - now_ms(), - now_ms() - ], - ) - .unwrap(); - } - - #[allow(clippy::too_many_arguments)] - fn insert_diffnote( - conn: &Connection, - id: i64, - discussion_id: i64, - project_id: i64, - author: &str, - file_path: &str, - body: &str, - ) { - conn.execute( - "INSERT INTO notes (id, gitlab_id, discussion_id, project_id, note_type, is_system, author_username, body, created_at, updated_at, last_seen_at, position_new_path) - VALUES (?1, ?2, ?3, ?4, 'DiffNote', 0, ?5, ?6, ?7, ?8, ?9, ?10)", - rusqlite::params![ - id, - id * 10, - discussion_id, - project_id, - author, - body, - now_ms(), - now_ms(), - now_ms(), - file_path - ], - ) - .unwrap(); - } - - fn insert_assignee(conn: &Connection, issue_id: i64, username: &str) { - conn.execute( - "INSERT INTO issue_assignees (issue_id, username) VALUES (?1, ?2)", - rusqlite::params![issue_id, username], - ) - .unwrap(); - } - - fn insert_reviewer(conn: &Connection, mr_id: i64, username: &str) { - conn.execute( - "INSERT INTO mr_reviewers (merge_request_id, username) VALUES (?1, ?2)", - rusqlite::params![mr_id, username], - ) - .unwrap(); - } - - fn insert_file_change( - conn: &Connection, - mr_id: i64, - project_id: i64, - new_path: &str, - change_type: &str, - ) { - conn.execute( - "INSERT INTO mr_file_changes (merge_request_id, project_id, new_path, change_type) - VALUES (?1, ?2, ?3, ?4)", - rusqlite::params![mr_id, project_id, new_path, change_type], - ) - .unwrap(); - } - - #[allow(clippy::too_many_arguments, dead_code)] - fn insert_mr_at( - conn: &Connection, - id: i64, - project_id: i64, - iid: i64, - author: &str, - state: &str, - updated_at_ms: i64, - merged_at_ms: Option, - closed_at_ms: Option, - ) { - conn.execute( - "INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, author_username, state, last_seen_at, updated_at, merged_at, closed_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", - rusqlite::params![ - id, - id * 10, - project_id, - iid, - format!("MR {iid}"), - author, - state, - now_ms(), - updated_at_ms, - merged_at_ms, - closed_at_ms - ], - ) - .unwrap(); - } - - #[allow(clippy::too_many_arguments, dead_code)] - fn insert_diffnote_at( - conn: &Connection, - id: i64, - discussion_id: i64, - project_id: i64, - author: &str, - new_path: &str, - old_path: Option<&str>, - body: &str, - created_at_ms: i64, - ) { - conn.execute( - "INSERT INTO notes (id, gitlab_id, project_id, discussion_id, author_username, note_type, is_system, position_new_path, position_old_path, body, created_at, updated_at, last_seen_at) - VALUES (?1, ?2, ?3, ?4, ?5, 'DiffNote', 0, ?6, ?7, ?8, ?9, ?9, ?9)", - rusqlite::params![ - id, - id * 10, - project_id, - discussion_id, - author, - new_path, - old_path, - body, - created_at_ms - ], - ) - .unwrap(); - } - - #[allow(dead_code)] - fn insert_file_change_with_old_path( - conn: &Connection, - mr_id: i64, - project_id: i64, - new_path: &str, - old_path: Option<&str>, - change_type: &str, - ) { - conn.execute( - "INSERT INTO mr_file_changes (merge_request_id, project_id, new_path, old_path, change_type) - VALUES (?1, ?2, ?3, ?4, ?5)", - rusqlite::params![mr_id, project_id, new_path, old_path, change_type], - ) - .unwrap(); - } - - #[test] - fn test_is_file_path_discrimination() { - // Contains '/' -> file path - assert!(matches!( - resolve_mode(&WhoArgs { - target: Some("src/auth/".to_string()), - path: None, - active: false, - overlap: None, - reviews: false, - since: None, - project: None, - limit: 20, - detail: false, - no_detail: false, - fields: None, - as_of: None, - explain_score: false, - include_bots: false, - all_history: false, - }) - .unwrap(), - WhoMode::Expert { .. } - )); - - // No '/' -> username - assert!(matches!( - resolve_mode(&WhoArgs { - target: Some("asmith".to_string()), - path: None, - active: false, - overlap: None, - reviews: false, - since: None, - project: None, - limit: 20, - detail: false, - no_detail: false, - fields: None, - as_of: None, - explain_score: false, - include_bots: false, - all_history: false, - }) - .unwrap(), - WhoMode::Workload { .. } - )); - - // With @ prefix -> username (stripped) - assert!(matches!( - resolve_mode(&WhoArgs { - target: Some("@asmith".to_string()), - path: None, - active: false, - overlap: None, - reviews: false, - since: None, - project: None, - limit: 20, - detail: false, - no_detail: false, - fields: None, - as_of: None, - explain_score: false, - include_bots: false, - all_history: false, - }) - .unwrap(), - WhoMode::Workload { .. } - )); - - // --reviews flag -> reviews mode - assert!(matches!( - resolve_mode(&WhoArgs { - target: Some("asmith".to_string()), - path: None, - active: false, - overlap: None, - reviews: true, - since: None, - project: None, - limit: 20, - detail: false, - no_detail: false, - fields: None, - as_of: None, - explain_score: false, - include_bots: false, - all_history: false, - }) - .unwrap(), - WhoMode::Reviews { .. } - )); - - // --path flag -> expert mode (handles root files) - assert!(matches!( - resolve_mode(&WhoArgs { - target: None, - path: Some("README.md".to_string()), - active: false, - overlap: None, - reviews: false, - since: None, - project: None, - limit: 20, - detail: false, - no_detail: false, - fields: None, - as_of: None, - explain_score: false, - include_bots: false, - all_history: false, - }) - .unwrap(), - WhoMode::Expert { .. } - )); - - // --path flag with dotless file -> expert mode - assert!(matches!( - resolve_mode(&WhoArgs { - target: None, - path: Some("Makefile".to_string()), - active: false, - overlap: None, - reviews: false, - since: None, - project: None, - limit: 20, - detail: false, - no_detail: false, - fields: None, - as_of: None, - explain_score: false, - include_bots: false, - all_history: false, - }) - .unwrap(), - WhoMode::Expert { .. } - )); - } - - #[test] - fn test_detail_rejected_outside_expert_mode() { - let args = WhoArgs { - target: Some("asmith".to_string()), - path: None, - active: false, - overlap: None, - reviews: false, - since: None, - project: None, - limit: 20, - detail: true, - no_detail: false, - fields: None, - as_of: None, - explain_score: false, - include_bots: false, - all_history: false, - }; - let mode = resolve_mode(&args).unwrap(); - let err = validate_mode_flags(&mode, &args).unwrap_err(); - let msg = err.to_string(); - assert!( - msg.contains("--detail is only supported in expert mode"), - "unexpected error: {msg}" - ); - } - - #[test] - fn test_detail_allowed_in_expert_mode() { - let args = WhoArgs { - target: None, - path: Some("README.md".to_string()), - active: false, - overlap: None, - reviews: false, - since: None, - project: None, - limit: 20, - detail: true, - no_detail: false, - fields: None, - as_of: None, - explain_score: false, - include_bots: false, - all_history: false, - }; - let mode = resolve_mode(&args).unwrap(); - assert!(validate_mode_flags(&mode, &args).is_ok()); - } - - #[test] - fn test_build_path_query() { - let conn = setup_test_db(); - - // Directory with trailing slash -> prefix - let pq = build_path_query(&conn, "src/auth/", None).unwrap(); - assert_eq!(pq.value, "src/auth/%"); - assert!(pq.is_prefix); - - // Directory without trailing slash (no dot in last segment) -> prefix - let pq = build_path_query(&conn, "src/auth", None).unwrap(); - assert_eq!(pq.value, "src/auth/%"); - assert!(pq.is_prefix); - - // File with extension -> exact - let pq = build_path_query(&conn, "src/auth/login.rs", None).unwrap(); - assert_eq!(pq.value, "src/auth/login.rs"); - assert!(!pq.is_prefix); - - // Root file -> exact - let pq = build_path_query(&conn, "README.md", None).unwrap(); - assert_eq!(pq.value, "README.md"); - assert!(!pq.is_prefix); - - // Directory with dots in non-leaf segment -> prefix - let pq = build_path_query(&conn, ".github/workflows/", None).unwrap(); - assert_eq!(pq.value, ".github/workflows/%"); - assert!(pq.is_prefix); - - // Versioned directory path -> prefix - let pq = build_path_query(&conn, "src/v1.2/auth/", None).unwrap(); - assert_eq!(pq.value, "src/v1.2/auth/%"); - assert!(pq.is_prefix); - - // Path with LIKE metacharacters -> prefix, escaped - let pq = build_path_query(&conn, "src/test_files/", None).unwrap(); - assert_eq!(pq.value, "src/test\\_files/%"); - assert!(pq.is_prefix); - - // Dotless root file -> exact match (root path without '/') - let pq = build_path_query(&conn, "Makefile", None).unwrap(); - assert_eq!(pq.value, "Makefile"); - assert!(!pq.is_prefix); - - let pq = build_path_query(&conn, "LICENSE", None).unwrap(); - assert_eq!(pq.value, "LICENSE"); - assert!(!pq.is_prefix); - - // Dotless root path with trailing '/' -> directory prefix (explicit override) - let pq = build_path_query(&conn, "Makefile/", None).unwrap(); - assert_eq!(pq.value, "Makefile/%"); - assert!(pq.is_prefix); - } - - #[test] - fn test_escape_like() { - assert_eq!(escape_like("normal/path"), "normal/path"); - assert_eq!(escape_like("has_underscore"), "has\\_underscore"); - assert_eq!(escape_like("has%percent"), "has\\%percent"); - assert_eq!(escape_like("has\\backslash"), "has\\\\backslash"); - } - - #[test] - fn test_build_path_query_exact_does_not_escape() { - let conn = setup_test_db(); - // '_' must NOT be escaped for exact match (=). - let pq = build_path_query(&conn, "README_with_underscore.md", None).unwrap(); - assert_eq!(pq.value, "README_with_underscore.md"); - assert!(!pq.is_prefix); - } - - #[test] - fn test_path_flag_dotless_root_file_is_exact() { - let conn = setup_test_db(); - // --path Makefile must produce an exact match, not Makefile/% - let pq = build_path_query(&conn, "Makefile", None).unwrap(); - assert_eq!(pq.value, "Makefile"); - assert!(!pq.is_prefix); - - let pq = build_path_query(&conn, "Dockerfile", None).unwrap(); - assert_eq!(pq.value, "Dockerfile"); - assert!(!pq.is_prefix); - } - - #[test] - fn test_expert_query() { - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - insert_mr(&conn, 1, 1, 100, "author_a", "merged"); - insert_discussion(&conn, 1, 1, Some(1), None, true, false); - insert_diffnote( - &conn, - 1, - 1, - 1, - "reviewer_b", - "src/auth/login.rs", - "**suggestion**: use const", - ); - insert_diffnote( - &conn, - 2, - 1, - 1, - "reviewer_b", - "src/auth/login.rs", - "**question**: why?", - ); - insert_diffnote( - &conn, - 3, - 1, - 1, - "reviewer_c", - "src/auth/session.rs", - "looks good", - ); - - let result = query_expert( - &conn, - "src/auth/", - None, - 0, - test_as_of_ms(), - 20, - &default_scoring(), - false, - false, - false, - ) - .unwrap(); - assert_eq!(result.experts.len(), 3); // author_a, reviewer_b, reviewer_c - assert_eq!(result.experts[0].username, "author_a"); // highest score (authorship dominates) - } - - #[test] - fn test_workload_query() { - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - insert_issue(&conn, 1, 1, 42, "someone_else"); - insert_assignee(&conn, 1, "dev_a"); - insert_mr(&conn, 1, 1, 100, "dev_a", "opened"); - - let result = query_workload(&conn, "dev_a", None, None, 20).unwrap(); - assert_eq!(result.assigned_issues.len(), 1); - assert_eq!(result.authored_mrs.len(), 1); - } - - #[test] - fn test_reviews_query() { - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - insert_mr(&conn, 1, 1, 100, "author_a", "merged"); - insert_discussion(&conn, 1, 1, Some(1), None, true, false); - insert_diffnote( - &conn, - 1, - 1, - 1, - "reviewer_b", - "src/foo.rs", - "**suggestion**: refactor", - ); - insert_diffnote( - &conn, - 2, - 1, - 1, - "reviewer_b", - "src/bar.rs", - "**question**: why?", - ); - insert_diffnote(&conn, 3, 1, 1, "reviewer_b", "src/baz.rs", "looks good"); - - let result = query_reviews(&conn, "reviewer_b", None, 0).unwrap(); - assert_eq!(result.total_diffnotes, 3); - assert_eq!(result.categorized_count, 2); - assert_eq!(result.categories.len(), 2); - } - - #[test] - fn test_active_query() { - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - insert_mr(&conn, 1, 1, 100, "author_a", "opened"); - insert_discussion(&conn, 1, 1, Some(1), None, true, false); - insert_diffnote(&conn, 1, 1, 1, "reviewer_b", "src/foo.rs", "needs work"); - // Second note by same participant -- note_count should be 2, participants still ["reviewer_b"] - insert_diffnote(&conn, 2, 1, 1, "reviewer_b", "src/foo.rs", "follow-up"); - - let result = query_active(&conn, None, 0, 20).unwrap(); - assert_eq!(result.total_unresolved_in_window, 1); - assert_eq!(result.discussions.len(), 1); - assert_eq!(result.discussions[0].participants, vec!["reviewer_b"]); - // This was a regression in iteration 4: note_count was counting participants, not notes - assert_eq!(result.discussions[0].note_count, 2); - assert!(result.discussions[0].discussion_id > 0); - } - - #[test] - fn test_overlap_dual_roles() { - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - // User is both author of one MR and reviewer of another at same path - insert_mr(&conn, 1, 1, 100, "dual_user", "opened"); - insert_mr(&conn, 2, 1, 200, "other_author", "opened"); - insert_discussion(&conn, 1, 1, Some(1), None, true, false); - insert_discussion(&conn, 2, 1, Some(2), None, true, false); - insert_diffnote( - &conn, - 1, - 1, - 1, - "someone", - "src/auth/login.rs", - "review of dual_user's MR", - ); - insert_diffnote( - &conn, - 2, - 2, - 1, - "dual_user", - "src/auth/login.rs", - "dual_user reviewing other MR", - ); - - let result = query_overlap(&conn, "src/auth/", None, 0, 20).unwrap(); - let dual = result - .users - .iter() - .find(|u| u.username == "dual_user") - .unwrap(); - assert!(dual.author_touch_count > 0); - assert!(dual.review_touch_count > 0); - assert_eq!(format_overlap_role(dual), "A+R"); - // MR refs should be project-qualified - assert!(dual.mr_refs.iter().any(|r| r.contains("team/backend!"))); - } - - #[test] - fn test_overlap_multi_project_mr_refs() { - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - insert_project(&conn, 2, "team/frontend"); - insert_mr(&conn, 1, 1, 100, "author_a", "opened"); - insert_mr(&conn, 2, 2, 100, "author_a", "opened"); // Same iid, different project - insert_discussion(&conn, 1, 1, Some(1), None, true, false); - insert_discussion(&conn, 2, 2, Some(2), None, true, false); - insert_diffnote(&conn, 1, 1, 1, "reviewer_x", "src/auth/login.rs", "review"); - insert_diffnote(&conn, 2, 2, 2, "reviewer_x", "src/auth/login.rs", "review"); - - let result = query_overlap(&conn, "src/auth/", None, 0, 20).unwrap(); - let reviewer = result - .users - .iter() - .find(|u| u.username == "reviewer_x") - .unwrap(); - // Should have two distinct refs despite same iid - assert!(reviewer.mr_refs.contains(&"team/backend!100".to_string())); - assert!(reviewer.mr_refs.contains(&"team/frontend!100".to_string())); - } - - #[test] - fn test_normalize_review_prefix() { - assert_eq!(normalize_review_prefix("suggestion"), "suggestion"); - assert_eq!(normalize_review_prefix("Suggestion:"), "suggestion"); - assert_eq!( - normalize_review_prefix("suggestion (non-blocking):"), - "suggestion" - ); - assert_eq!(normalize_review_prefix("Nitpick:"), "nit"); - assert_eq!(normalize_review_prefix("nit (non-blocking):"), "nit"); - assert_eq!(normalize_review_prefix("question"), "question"); - assert_eq!(normalize_review_prefix("TODO:"), "todo"); - } - - #[test] - fn test_normalize_repo_path() { - // Strips leading ./ - assert_eq!(normalize_repo_path("./src/foo/"), "src/foo/"); - // Strips leading / - assert_eq!(normalize_repo_path("/src/foo/"), "src/foo/"); - // Strips leading ./ recursively - assert_eq!(normalize_repo_path("././src/foo"), "src/foo"); - // Converts Windows backslashes when no forward slashes - assert_eq!(normalize_repo_path("src\\foo\\bar.rs"), "src/foo/bar.rs"); - // Does NOT convert backslashes when forward slashes present - assert_eq!(normalize_repo_path("src/foo\\bar"), "src/foo\\bar"); - // Collapses repeated // - assert_eq!(normalize_repo_path("src//foo//bar/"), "src/foo/bar/"); - // Trims whitespace - assert_eq!(normalize_repo_path(" src/foo/ "), "src/foo/"); - // Identity for clean paths - assert_eq!(normalize_repo_path("src/foo/bar.rs"), "src/foo/bar.rs"); - } - - #[test] - fn test_lookup_project_path() { - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - assert_eq!(lookup_project_path(&conn, 1).unwrap(), "team/backend"); - } - - #[test] - fn test_build_path_query_dotless_subdir_file_uses_db_probe() { - // Dotless file in subdirectory (src/Dockerfile) would normally be - // treated as a directory. The DB probe detects it's actually a file. - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - insert_mr(&conn, 1, 1, 100, "author_a", "opened"); - insert_discussion(&conn, 1, 1, Some(1), None, true, false); - insert_diffnote(&conn, 1, 1, 1, "reviewer_b", "src/Dockerfile", "note"); - - let pq = build_path_query(&conn, "src/Dockerfile", None).unwrap(); - assert_eq!(pq.value, "src/Dockerfile"); - assert!(!pq.is_prefix); - - // Same path without DB data -> falls through to prefix - let conn2 = setup_test_db(); - let pq2 = build_path_query(&conn2, "src/Dockerfile", None).unwrap(); - assert_eq!(pq2.value, "src/Dockerfile/%"); - assert!(pq2.is_prefix); - } - - #[test] - fn test_build_path_query_probe_is_project_scoped() { - // Path exists as a dotless file in project 1; project 2 should not - // treat it as an exact file unless it exists there too. - let conn = setup_test_db(); - insert_project(&conn, 1, "team/a"); - insert_project(&conn, 2, "team/b"); - insert_mr(&conn, 1, 1, 10, "author_a", "opened"); - insert_discussion(&conn, 1, 1, Some(1), None, true, false); - insert_diffnote(&conn, 1, 1, 1, "rev", "infra/Makefile", "note"); - - // Unscoped: finds exact match in project 1 -> exact - let pq_unscoped = build_path_query(&conn, "infra/Makefile", None).unwrap(); - assert!(!pq_unscoped.is_prefix); - - // Scoped to project 2: no data -> falls back to prefix - let pq_scoped = build_path_query(&conn, "infra/Makefile", Some(2)).unwrap(); - assert!(pq_scoped.is_prefix); - - // Scoped to project 1: finds data -> exact - let pq_scoped1 = build_path_query(&conn, "infra/Makefile", Some(1)).unwrap(); - assert!(!pq_scoped1.is_prefix); - } - - #[test] - fn test_expert_excludes_self_review_notes() { - // MR author commenting on their own diff should not be counted as reviewer - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - insert_mr(&conn, 1, 1, 100, "author_a", "opened"); - insert_discussion(&conn, 1, 1, Some(1), None, true, false); - // author_a comments on their own MR diff (clarification) - insert_diffnote( - &conn, - 1, - 1, - 1, - "author_a", - "src/auth/login.rs", - "clarification", - ); - // reviewer_b also reviews - insert_diffnote( - &conn, - 2, - 1, - 1, - "reviewer_b", - "src/auth/login.rs", - "looks good", - ); - - let result = query_expert( - &conn, - "src/auth/", - None, - 0, - test_as_of_ms(), - 20, - &default_scoring(), - false, - false, - false, - ) - .unwrap(); - // author_a should appear as author only, not as reviewer - let author = result - .experts - .iter() - .find(|e| e.username == "author_a") - .unwrap(); - assert_eq!(author.review_mr_count, 0); - assert!(author.author_mr_count > 0); - - // reviewer_b should be a reviewer - let reviewer = result - .experts - .iter() - .find(|e| e.username == "reviewer_b") - .unwrap(); - assert!(reviewer.review_mr_count > 0); - } - - #[test] - fn test_overlap_excludes_self_review_notes() { - // MR author commenting on their own diff should not inflate reviewer counts - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - insert_mr(&conn, 1, 1, 100, "author_a", "opened"); - insert_discussion(&conn, 1, 1, Some(1), None, true, false); - // author_a comments on their own MR diff (clarification) - insert_diffnote( - &conn, - 1, - 1, - 1, - "author_a", - "src/auth/login.rs", - "clarification", - ); - - let result = query_overlap(&conn, "src/auth/", None, 0, 20).unwrap(); - let u = result.users.iter().find(|u| u.username == "author_a"); - // Should NOT be credited as reviewer touch - assert!(u.map_or(0, |x| x.review_touch_count) == 0); - } - - #[test] - fn test_active_participants_sorted() { - // Participants should be sorted alphabetically for deterministic output - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - insert_mr(&conn, 1, 1, 100, "author_a", "opened"); - insert_discussion(&conn, 1, 1, Some(1), None, true, false); - insert_diffnote(&conn, 1, 1, 1, "zebra_user", "src/foo.rs", "note 1"); - insert_diffnote(&conn, 2, 1, 1, "alpha_user", "src/foo.rs", "note 2"); - - let result = query_active(&conn, None, 0, 20).unwrap(); - assert_eq!( - result.discussions[0].participants, - vec!["alpha_user", "zebra_user"] - ); - } - - #[test] - fn test_expert_truncation() { - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - // Create 3 experts - for i in 1..=3 { - insert_mr(&conn, i, 1, 100 + i, &format!("author_{i}"), "opened"); - insert_discussion(&conn, i, 1, Some(i), None, true, false); - insert_diffnote( - &conn, - i, - i, - 1, - &format!("reviewer_{i}"), - "src/auth/login.rs", - "note", - ); - } - - // limit = 2, should return truncated = true - let result = query_expert( - &conn, - "src/auth/", - None, - 0, - test_as_of_ms(), - 2, - &default_scoring(), - false, - false, - false, - ) - .unwrap(); - assert!(result.truncated); - assert_eq!(result.experts.len(), 2); - - // limit = 10, should return truncated = false - let result = query_expert( - &conn, - "src/auth/", - None, - 0, - test_as_of_ms(), - 10, - &default_scoring(), - false, - false, - false, - ) - .unwrap(); - assert!(!result.truncated); - } - - #[test] - fn test_expert_file_changes_only() { - // MR author should appear even when there are zero DiffNotes - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - insert_mr(&conn, 1, 1, 100, "file_author", "merged"); - insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified"); - - let result = query_expert( - &conn, - "src/auth/login.rs", - None, - 0, - test_as_of_ms(), - 20, - &default_scoring(), - false, - false, - false, - ) - .unwrap(); - assert_eq!(result.experts.len(), 1); - assert_eq!(result.experts[0].username, "file_author"); - assert!(result.experts[0].author_mr_count > 0); - assert_eq!(result.experts[0].review_mr_count, 0); - } - - #[test] - fn test_expert_mr_reviewer_via_file_changes() { - // A reviewer assigned via mr_reviewers should appear when that MR - // touched the queried file (via mr_file_changes) - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - insert_mr(&conn, 1, 1, 100, "author_a", "merged"); - insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified"); - insert_reviewer(&conn, 1, "assigned_reviewer"); - - let result = query_expert( - &conn, - "src/auth/login.rs", - None, - 0, - test_as_of_ms(), - 20, - &default_scoring(), - false, - false, - false, - ) - .unwrap(); - let reviewer = result - .experts - .iter() - .find(|e| e.username == "assigned_reviewer"); - assert!(reviewer.is_some(), "assigned_reviewer should appear"); - assert!(reviewer.unwrap().review_mr_count > 0); - } - - #[test] - fn test_expert_deduplicates_across_signals() { - // User who is BOTH a DiffNote reviewer AND an mr_reviewers entry for - // the same MR should be counted only once per MR - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - insert_mr(&conn, 1, 1, 100, "author_a", "merged"); - insert_discussion(&conn, 1, 1, Some(1), None, true, false); - insert_diffnote( - &conn, - 1, - 1, - 1, - "reviewer_b", - "src/auth/login.rs", - "looks good", - ); - // Same user also listed as assigned reviewer, with file change data - insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified"); - insert_reviewer(&conn, 1, "reviewer_b"); - - let result = query_expert( - &conn, - "src/auth/login.rs", - None, - 0, - test_as_of_ms(), - 20, - &default_scoring(), - false, - false, - false, - ) - .unwrap(); - let reviewer = result - .experts - .iter() - .find(|e| e.username == "reviewer_b") - .unwrap(); - // Should be 1 MR, not 2 (dedup across DiffNote + mr_reviewers) - assert_eq!(reviewer.review_mr_count, 1); - } - - #[test] - fn test_expert_combined_diffnote_and_file_changes() { - // Author with DiffNotes on path A and file_changes on path B should - // get credit for both when queried with a directory prefix - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - // MR 1: has DiffNotes on login.rs - insert_mr(&conn, 1, 1, 100, "author_a", "merged"); - insert_discussion(&conn, 1, 1, Some(1), None, true, false); - insert_diffnote(&conn, 1, 1, 1, "reviewer_b", "src/auth/login.rs", "note"); - // MR 2: has file_changes on session.rs (no DiffNotes) - insert_mr(&conn, 2, 1, 200, "author_a", "merged"); - insert_file_change(&conn, 2, 1, "src/auth/session.rs", "added"); - - let result = query_expert( - &conn, - "src/auth/", - None, - 0, - test_as_of_ms(), - 20, - &default_scoring(), - false, - false, - false, - ) - .unwrap(); - let author = result - .experts - .iter() - .find(|e| e.username == "author_a") - .unwrap(); - // Should count 2 authored MRs (one from DiffNote path, one from file changes) - assert_eq!(author.author_mr_count, 2); - } - - #[test] - fn test_expert_file_changes_prefix_match() { - // Directory prefix queries should pick up mr_file_changes under the directory - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - insert_mr(&conn, 1, 1, 100, "author_a", "merged"); - insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified"); - insert_file_change(&conn, 1, 1, "src/auth/session.rs", "added"); - - let result = query_expert( - &conn, - "src/auth/", - None, - 0, - test_as_of_ms(), - 20, - &default_scoring(), - false, - false, - false, - ) - .unwrap(); - assert_eq!(result.path_match, "prefix"); - assert_eq!(result.experts.len(), 1); - assert_eq!(result.experts[0].username, "author_a"); - } - - #[test] - fn test_overlap_file_changes_only() { - // Overlap mode should also find users via mr_file_changes - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - insert_mr(&conn, 1, 1, 100, "author_a", "merged"); - insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified"); - insert_reviewer(&conn, 1, "reviewer_x"); - - let result = query_overlap(&conn, "src/auth/", None, 0, 20).unwrap(); - assert!( - result.users.iter().any(|u| u.username == "author_a"), - "author_a should appear via file_changes" - ); - assert!( - result.users.iter().any(|u| u.username == "reviewer_x"), - "reviewer_x should appear via mr_reviewers + file_changes" - ); - } - - #[test] - fn test_build_path_query_resolves_via_file_changes() { - // DB probe should detect exact file match from mr_file_changes even - // when no DiffNotes exist for the path - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - insert_mr(&conn, 1, 1, 100, "author_a", "merged"); - insert_file_change(&conn, 1, 1, "src/Dockerfile", "modified"); - - let pq = build_path_query(&conn, "src/Dockerfile", None).unwrap(); - assert_eq!(pq.value, "src/Dockerfile"); - assert!(!pq.is_prefix); - } - - #[test] - fn test_expert_excludes_self_assigned_reviewer() { - // MR author listed in mr_reviewers for their own MR should NOT be - // counted as a reviewer (same principle as DiffNote self-review exclusion) - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - insert_mr(&conn, 1, 1, 100, "author_a", "merged"); - insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified"); - // author_a is self-assigned as reviewer - insert_reviewer(&conn, 1, "author_a"); - // real_reviewer is also assigned - insert_reviewer(&conn, 1, "real_reviewer"); - - let result = query_expert( - &conn, - "src/auth/login.rs", - None, - 0, - test_as_of_ms(), - 20, - &default_scoring(), - false, - false, - false, - ) - .unwrap(); - - // author_a should appear as author only, not reviewer - let author = result - .experts - .iter() - .find(|e| e.username == "author_a") - .unwrap(); - assert_eq!(author.review_mr_count, 0); - assert!(author.author_mr_count > 0); - - // real_reviewer should appear as reviewer - let reviewer = result - .experts - .iter() - .find(|e| e.username == "real_reviewer") - .unwrap(); - assert!(reviewer.review_mr_count > 0); - } - - #[test] - fn test_overlap_excludes_self_assigned_reviewer() { - // Same self-review exclusion for overlap mode via file changes - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - insert_mr(&conn, 1, 1, 100, "author_a", "merged"); - insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified"); - insert_reviewer(&conn, 1, "author_a"); // self-assigned - - let result = query_overlap(&conn, "src/auth/", None, 0, 20).unwrap(); - let user = result.users.iter().find(|u| u.username == "author_a"); - // Should appear (as author) but NOT have reviewer touch count - assert!(user.is_some()); - assert_eq!(user.unwrap().review_touch_count, 0); - } - - // ─── Suffix / Fuzzy Path Resolution Tests ─────────────────────────────── - - #[test] - fn test_build_path_query_suffix_resolves_bare_filename() { - // User types just "login.rs" but the DB has "src/auth/login.rs" - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - insert_mr(&conn, 1, 1, 100, "author_a", "merged"); - insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified"); - - let pq = build_path_query(&conn, "login.rs", None).unwrap(); - assert_eq!(pq.value, "src/auth/login.rs"); - assert!(!pq.is_prefix); - } - - #[test] - fn test_build_path_query_suffix_resolves_partial_path() { - // User types "auth/login.rs" but full path is "src/auth/login.rs" - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - insert_mr(&conn, 1, 1, 100, "author_a", "merged"); - insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified"); - - let pq = build_path_query(&conn, "auth/login.rs", None).unwrap(); - assert_eq!(pq.value, "src/auth/login.rs"); - assert!(!pq.is_prefix); - } - - #[test] - fn test_build_path_query_suffix_ambiguous_returns_error() { - // Two different files share the same filename -> Ambiguous error - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - insert_mr(&conn, 1, 1, 100, "author_a", "merged"); - insert_file_change(&conn, 1, 1, "src/auth/utils.rs", "modified"); - insert_file_change(&conn, 1, 1, "src/db/utils.rs", "modified"); - - let err = build_path_query(&conn, "utils.rs", None).unwrap_err(); - let msg = err.to_string(); - assert!( - msg.contains("src/auth/utils.rs"), - "should list candidates: {msg}" - ); - assert!( - msg.contains("src/db/utils.rs"), - "should list candidates: {msg}" - ); - } - - #[test] - fn test_build_path_query_suffix_scoped_to_project() { - // Two projects have the same filename; scoping to one should resolve - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - insert_project(&conn, 2, "team/frontend"); - insert_mr(&conn, 1, 1, 100, "author_a", "merged"); - insert_mr(&conn, 2, 2, 200, "author_b", "merged"); - insert_file_change(&conn, 1, 1, "src/utils.rs", "modified"); - insert_file_change(&conn, 2, 2, "lib/utils.rs", "modified"); - - // Unscoped -> ambiguous - assert!(build_path_query(&conn, "utils.rs", None).is_err()); - - // Scoped to project 1 -> resolves - let pq = build_path_query(&conn, "utils.rs", Some(1)).unwrap(); - assert_eq!(pq.value, "src/utils.rs"); - assert!(!pq.is_prefix); - } - - #[test] - fn test_build_path_query_suffix_deduplicates_across_sources() { - // Same path in both notes AND mr_file_changes -> single unique match, not ambiguous - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - insert_mr(&conn, 1, 1, 100, "author_a", "merged"); - insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified"); - insert_discussion(&conn, 1, 1, Some(1), None, true, false); - insert_diffnote( - &conn, - 1, - 1, - 1, - "reviewer_a", - "src/auth/login.rs", - "review note", - ); - - let pq = build_path_query(&conn, "login.rs", None).unwrap(); - assert_eq!(pq.value, "src/auth/login.rs"); - assert!(!pq.is_prefix); - } - - #[test] - fn test_build_path_query_exact_match_still_preferred() { - // If the exact path exists in the DB, suffix should NOT be attempted - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - insert_mr(&conn, 1, 1, 100, "author_a", "merged"); - insert_file_change(&conn, 1, 1, "README.md", "modified"); - insert_file_change(&conn, 1, 1, "docs/README.md", "modified"); - - // "README.md" exists as exact match -> use it directly, no ambiguity - let pq = build_path_query(&conn, "README.md", None).unwrap(); - assert_eq!(pq.value, "README.md"); - assert!(!pq.is_prefix); - } - - #[test] - fn test_expert_scoring_weights_are_configurable() { - // With reviewer-heavy weights, reviewer should rank above author. - // With author-heavy weights (default), author should rank above reviewer. - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - insert_mr(&conn, 1, 1, 100, "the_author", "merged"); - insert_file_change(&conn, 1, 1, "src/app.rs", "modified"); - insert_discussion(&conn, 1, 1, Some(1), None, true, false); - insert_diffnote( - &conn, - 1, - 1, - 1, - "the_reviewer", - "src/app.rs", - "lgtm -- a substantive review comment", - ); - insert_reviewer(&conn, 1, "the_reviewer"); - - // Default weights: author=25, reviewer=10 → author wins - let result = query_expert( - &conn, - "src/app.rs", - None, - 0, - test_as_of_ms(), - 20, - &default_scoring(), - false, - false, - false, - ) - .unwrap(); - assert_eq!(result.experts[0].username, "the_author"); - - // Custom weights: flip so reviewer dominates - let flipped = ScoringConfig { - author_weight: 5, - reviewer_weight: 30, - note_bonus: 1, - ..Default::default() - }; - let result = query_expert( - &conn, - "src/app.rs", - None, - 0, - test_as_of_ms(), - 20, - &flipped, - false, - false, - false, - ) - .unwrap(); - assert_eq!(result.experts[0].username, "the_reviewer"); - } - - #[test] - fn test_expert_mr_refs() { - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - insert_mr(&conn, 1, 1, 891, "author_a", "merged"); - insert_mr(&conn, 2, 1, 847, "author_a", "merged"); - insert_discussion(&conn, 1, 1, Some(1), None, true, false); - insert_discussion(&conn, 2, 1, Some(2), None, true, false); - insert_diffnote(&conn, 1, 1, 1, "reviewer_b", "src/auth/login.rs", "note1"); - insert_diffnote(&conn, 2, 2, 1, "reviewer_b", "src/auth/login.rs", "note2"); - - let result = query_expert( - &conn, - "src/auth/", - None, - 0, - test_as_of_ms(), - 20, - &default_scoring(), - false, - false, - false, - ) - .unwrap(); - - // reviewer_b should have MR refs - let reviewer = result - .experts - .iter() - .find(|e| e.username == "reviewer_b") - .unwrap(); - assert!(reviewer.mr_refs.contains(&"team/backend!847".to_string())); - assert!(reviewer.mr_refs.contains(&"team/backend!891".to_string())); - assert_eq!(reviewer.mr_refs_total, 2); - assert!(!reviewer.mr_refs_truncated); - - // author_a should also have MR refs - let author = result - .experts - .iter() - .find(|e| e.username == "author_a") - .unwrap(); - assert!(author.mr_refs.contains(&"team/backend!847".to_string())); - assert!(author.mr_refs.contains(&"team/backend!891".to_string())); - assert_eq!(author.mr_refs_total, 2); - } - - #[test] - fn test_expert_mr_refs_multi_project() { - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - insert_project(&conn, 2, "team/frontend"); - insert_mr(&conn, 1, 1, 100, "author_a", "opened"); - insert_mr(&conn, 2, 2, 100, "author_a", "opened"); // Same iid, different project - insert_discussion(&conn, 1, 1, Some(1), None, true, false); - insert_discussion(&conn, 2, 2, Some(2), None, true, false); - insert_diffnote(&conn, 1, 1, 1, "reviewer_x", "src/auth/login.rs", "review"); - insert_diffnote(&conn, 2, 2, 2, "reviewer_x", "src/auth/login.rs", "review"); - - let result = query_expert( - &conn, - "src/auth/", - None, - 0, - test_as_of_ms(), - 20, - &default_scoring(), - false, - false, - false, - ) - .unwrap(); - let reviewer = result - .experts - .iter() - .find(|e| e.username == "reviewer_x") - .unwrap(); - // Should have two distinct refs despite same iid - assert!(reviewer.mr_refs.contains(&"team/backend!100".to_string())); - assert!(reviewer.mr_refs.contains(&"team/frontend!100".to_string())); - assert_eq!(reviewer.mr_refs_total, 2); - } - - #[test] - fn test_half_life_decay_math() { - // elapsed=0 -> 1.0 (no decay) - assert!((half_life_decay(0, 180) - 1.0).abs() < f64::EPSILON); - - // elapsed=half_life -> 0.5 - let half_life_ms = 180 * 86_400_000_i64; - assert!((half_life_decay(half_life_ms, 180) - 0.5).abs() < 1e-10); - - // elapsed=2*half_life -> 0.25 - assert!((half_life_decay(2 * half_life_ms, 180) - 0.25).abs() < 1e-10); - - // half_life_days=0 -> 0.0 (guard against div-by-zero) - assert!((half_life_decay(1_000_000, 0)).abs() < f64::EPSILON); - - // negative elapsed clamped to 0 -> 1.0 - assert!((half_life_decay(-5_000_000, 180) - 1.0).abs() < f64::EPSILON); - } - - #[test] - fn test_path_normalization_handles_dot_and_double_slash() { - assert_eq!(normalize_query_path("./src//foo.rs"), "src/foo.rs"); - assert_eq!(normalize_query_path(" src/bar.rs "), "src/bar.rs"); - assert_eq!(normalize_query_path("src/foo.rs"), "src/foo.rs"); - assert_eq!(normalize_query_path(""), ""); - } - - #[test] - fn test_path_normalization_preserves_prefix_semantics() { - // Trailing slash preserved for prefix intent - assert_eq!(normalize_query_path("./src/dir/"), "src/dir/"); - // No trailing slash = file, not prefix - assert_eq!(normalize_query_path("src/dir"), "src/dir"); - } - - #[test] - fn test_expert_sql_v2_prepares_exact() { - let conn = setup_test_db(); - let sql = build_expert_sql_v2(false); - // Verify the SQL is syntactically valid and INDEXED BY references exist - conn.prepare_cached(&sql) - .expect("v2 exact SQL should parse"); - } - - #[test] - fn test_expert_sql_v2_prepares_prefix() { - let conn = setup_test_db(); - let sql = build_expert_sql_v2(true); - conn.prepare_cached(&sql) - .expect("v2 prefix SQL should parse"); - } - - #[test] - fn test_expert_sql_v2_returns_signals() { - let conn = setup_test_db(); - let now = now_ms(); - insert_project(&conn, 1, "team/backend"); - insert_mr_at(&conn, 1, 1, 100, "author_a", "merged", now, Some(now), None); - insert_discussion(&conn, 1, 1, Some(1), None, true, false); - insert_diffnote( - &conn, - 1, - 1, - 1, - "reviewer_b", - "src/auth/login.rs", - "substantive review comment here", - ); - insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified"); - insert_reviewer(&conn, 1, "reviewer_b"); - - let sql = build_expert_sql_v2(false); - let mut stmt = conn.prepare_cached(&sql).unwrap(); - let rows: Vec<(String, String, i64, i64, i64, f64)> = stmt - .query_map( - rusqlite::params![ - "src/auth/login.rs", // ?1 path - 0_i64, // ?2 since_ms - Option::::None, // ?3 project_id - now + 1000, // ?4 as_of_ms (slightly in future to include test data) - 0.5_f64, // ?5 closed_mr_multiplier - 20_i64, // ?6 reviewer_min_note_chars - ], - |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, i64>(2)?, - row.get::<_, i64>(3)?, - row.get::<_, i64>(4)?, - row.get::<_, f64>(5)?, - )) - }, - ) - .unwrap() - .collect::, _>>() - .unwrap(); - - // Should have signals for both author_a and reviewer_b - let usernames: Vec<&str> = rows.iter().map(|r| r.0.as_str()).collect(); - assert!(usernames.contains(&"author_a"), "should contain author_a"); - assert!( - usernames.contains(&"reviewer_b"), - "should contain reviewer_b" - ); - - // Verify signal types are from the expected set - let valid_signals = [ - "diffnote_author", - "file_author", - "file_reviewer_participated", - "file_reviewer_assigned", - "note_group", - ]; - for row in &rows { - assert!( - valid_signals.contains(&row.1.as_str()), - "unexpected signal type: {}", - row.1 - ); - } - - // state_mult should be 1.0 for merged MRs - for row in &rows { - assert!( - (row.5 - 1.0).abs() < f64::EPSILON, - "merged MR should have state_mult=1.0, got {}", - row.5 - ); - } - } - - #[test] - fn test_expert_sql_v2_reviewer_split() { - let conn = setup_test_db(); - let now = now_ms(); - insert_project(&conn, 1, "team/backend"); - insert_mr_at(&conn, 1, 1, 100, "author_a", "merged", now, Some(now), None); - insert_discussion(&conn, 1, 1, Some(1), None, true, false); - // reviewer_b leaves a substantive note (>= 20 chars) - insert_diffnote( - &conn, - 1, - 1, - 1, - "reviewer_b", - "src/app.rs", - "This looks correct, good refactoring work here", - ); - insert_file_change(&conn, 1, 1, "src/app.rs", "modified"); - insert_reviewer(&conn, 1, "reviewer_b"); - // reviewer_c is assigned but leaves no notes - insert_reviewer(&conn, 1, "reviewer_c"); - - let sql = build_expert_sql_v2(false); - let mut stmt = conn.prepare_cached(&sql).unwrap(); - let rows: Vec<(String, String)> = stmt - .query_map( - rusqlite::params![ - "src/app.rs", - 0_i64, - Option::::None, - now + 1000, - 0.5_f64, - 20_i64, - ], - |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), - ) - .unwrap() - .collect::, _>>() - .unwrap(); - - // reviewer_b should be "file_reviewer_participated" - let b_signals: Vec<&str> = rows - .iter() - .filter(|r| r.0 == "reviewer_b") - .map(|r| r.1.as_str()) - .collect(); - assert!( - b_signals.contains(&"file_reviewer_participated"), - "reviewer_b should be participated, got: {:?}", - b_signals - ); - assert!( - !b_signals.contains(&"file_reviewer_assigned"), - "reviewer_b should NOT be assigned-only" - ); - - // reviewer_c should be "file_reviewer_assigned" - let c_signals: Vec<&str> = rows - .iter() - .filter(|r| r.0 == "reviewer_c") - .map(|r| r.1.as_str()) - .collect(); - assert!( - c_signals.contains(&"file_reviewer_assigned"), - "reviewer_c should be assigned-only, got: {:?}", - c_signals - ); - assert!( - !c_signals.contains(&"file_reviewer_participated"), - "reviewer_c should NOT be participated" - ); - } - - #[test] - fn test_expert_sql_v2_closed_mr_multiplier() { - let conn = setup_test_db(); - let now = now_ms(); - insert_project(&conn, 1, "team/backend"); - insert_mr_at(&conn, 1, 1, 100, "author_a", "closed", now, None, Some(now)); - insert_file_change(&conn, 1, 1, "src/app.rs", "modified"); - - let sql = build_expert_sql_v2(false); - let mut stmt = conn.prepare_cached(&sql).unwrap(); - let rows: Vec<(String, String, f64)> = stmt - .query_map( - rusqlite::params![ - "src/app.rs", - 0_i64, - Option::::None, - now + 1000, - 0.5_f64, - 20_i64, - ], - |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, f64>(5)?, - )) - }, - ) - .unwrap() - .collect::, _>>() - .unwrap(); - - // All signals from closed MR should have state_mult=0.5 - for row in &rows { - assert!( - (row.2 - 0.5).abs() < f64::EPSILON, - "closed MR should have state_mult=0.5, got {} for signal {}", - row.2, - row.1 - ); - } - } - - #[test] - fn test_expert_v2_decay_scoring() { - let conn = setup_test_db(); - let now = now_ms(); - let day_ms = 86_400_000_i64; - insert_project(&conn, 1, "team/backend"); - - // Recent author: 10 days ago - insert_mr_at( - &conn, - 1, - 1, - 100, - "recent_author", - "merged", - now - 10 * day_ms, - Some(now - 10 * day_ms), - None, - ); - insert_file_change(&conn, 1, 1, "src/app.rs", "modified"); - - // Old author: 360 days ago - insert_mr_at( - &conn, - 2, - 1, - 101, - "old_author", - "merged", - now - 360 * day_ms, - Some(now - 360 * day_ms), - None, - ); - insert_file_change(&conn, 2, 1, "src/app.rs", "modified"); - - let scoring = default_scoring(); - let result = query_expert_v2( - &conn, - "src/app.rs", - None, - 0, - now + 1000, - 20, - &scoring, - false, - ) - .unwrap(); - - assert_eq!(result.experts.len(), 2); - // Recent author should rank first - assert_eq!(result.experts[0].username, "recent_author"); - assert_eq!(result.experts[1].username, "old_author"); - // Recent author score should be much higher than old author - assert!(result.experts[0].score > result.experts[1].score); - } - - #[test] - fn test_expert_v2_reviewer_split_scoring() { - let conn = setup_test_db(); - let now = now_ms(); - let day_ms = 86_400_000_i64; - insert_project(&conn, 1, "team/backend"); - - // MR from 30 days ago - insert_mr_at( - &conn, - 1, - 1, - 100, - "the_author", - "merged", - now - 30 * day_ms, - Some(now - 30 * day_ms), - None, - ); - insert_file_change(&conn, 1, 1, "src/app.rs", "modified"); - insert_discussion(&conn, 1, 1, Some(1), None, true, false); - - // Reviewer A: participated (left substantive DiffNotes) - insert_diffnote( - &conn, - 1, - 1, - 1, - "participated_reviewer", - "src/app.rs", - "Substantive review comment here about the code", - ); - insert_reviewer(&conn, 1, "participated_reviewer"); - - // Reviewer B: assigned only (no DiffNotes) - insert_reviewer(&conn, 1, "assigned_reviewer"); - - let scoring = default_scoring(); - let result = query_expert_v2( - &conn, - "src/app.rs", - None, - 0, - now + 1000, - 20, - &scoring, - false, - ) - .unwrap(); - - let participated = result - .experts - .iter() - .find(|e| e.username == "participated_reviewer"); - let assigned = result - .experts - .iter() - .find(|e| e.username == "assigned_reviewer"); - assert!( - participated.is_some(), - "participated reviewer should appear" - ); - assert!(assigned.is_some(), "assigned reviewer should appear"); - - // Participated reviewer should score higher (weight=10 vs weight=3) - assert!( - participated.unwrap().score > assigned.unwrap().score, - "participated ({}) should score higher than assigned ({})", - participated.unwrap().score, - assigned.unwrap().score - ); - } - - #[test] - fn test_expert_v2_excluded_usernames() { - let conn = setup_test_db(); - let now = now_ms(); - insert_project(&conn, 1, "team/backend"); - insert_mr_at( - &conn, - 1, - 1, - 100, - "real_user", - "merged", - now, - Some(now), - None, - ); - insert_mr_at( - &conn, - 2, - 1, - 101, - "renovate-bot", - "merged", - now, - Some(now), - None, - ); - insert_file_change(&conn, 1, 1, "src/app.rs", "modified"); - insert_file_change(&conn, 2, 1, "src/app.rs", "modified"); - - let mut scoring = default_scoring(); - scoring.excluded_usernames = vec!["renovate-bot".to_string()]; - let result = query_expert_v2( - &conn, - "src/app.rs", - None, - 0, - now + 1000, - 20, - &scoring, - false, - ) - .unwrap(); - - let usernames: Vec<&str> = result.experts.iter().map(|e| e.username.as_str()).collect(); - assert!(usernames.contains(&"real_user")); - assert!( - !usernames.contains(&"renovate-bot"), - "bot should be excluded" - ); - } - - #[test] - fn test_expert_v2_deterministic_ordering() { - let conn = setup_test_db(); - let now = now_ms(); - let day_ms = 86_400_000_i64; - insert_project(&conn, 1, "team/backend"); - - // Create 5 MRs with varied timestamps - for i in 0_i64..5 { - insert_mr_at( - &conn, - i + 1, - 1, - 100 + i, - "test_user", - "merged", - now - (i + 1) * 30 * day_ms, - Some(now - (i + 1) * 30 * day_ms), - None, - ); - insert_file_change(&conn, i + 1, 1, "src/app.rs", "modified"); - } - - let scoring = default_scoring(); - // Run 10 times and verify identical scores - let mut scores: Vec = Vec::new(); - for _ in 0..10 { - let result = query_expert_v2( - &conn, - "src/app.rs", - None, - 0, - now + 1000, - 20, - &scoring, - false, - ) - .unwrap(); - assert_eq!(result.experts.len(), 1); - scores.push(result.experts[0].score); - } - assert!( - scores.windows(2).all(|w| w[0] == w[1]), - "scores should be identical across runs: {:?}", - scores - ); - } - - #[test] - fn test_expert_detail_mode() { - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - insert_mr(&conn, 1, 1, 891, "author_a", "merged"); - insert_mr(&conn, 2, 1, 902, "author_a", "merged"); - insert_discussion(&conn, 1, 1, Some(1), None, true, false); - insert_discussion(&conn, 2, 1, Some(2), None, true, false); - insert_diffnote(&conn, 1, 1, 1, "reviewer_b", "src/auth/login.rs", "note1"); - insert_diffnote(&conn, 2, 1, 1, "reviewer_b", "src/auth/login.rs", "note2"); - insert_diffnote(&conn, 3, 2, 1, "reviewer_b", "src/auth/session.rs", "note3"); - - // Without detail: details should be None - let result = query_expert( - &conn, - "src/auth/", - None, - 0, - test_as_of_ms(), - 20, - &default_scoring(), - false, - false, - false, - ) - .unwrap(); - for expert in &result.experts { - assert!(expert.details.is_none()); - } - - // With detail: details should be populated - let result = query_expert( - &conn, - "src/auth/", - None, - 0, - test_as_of_ms(), - 20, - &default_scoring(), - true, - false, - false, - ) - .unwrap(); - let reviewer = result - .experts - .iter() - .find(|e| e.username == "reviewer_b") - .unwrap(); - let details = reviewer.details.as_ref().unwrap(); - assert!(!details.is_empty()); - - // All detail entries should have role "R" for reviewer - for d in details { - assert!( - d.role == "R" || d.role == "A+R", - "role should be R or A+R, got {}", - d.role - ); - assert!(d.mr_ref.starts_with("team/backend!")); - } - - // author_a should have detail entries with role "A" - let author = result - .experts - .iter() - .find(|e| e.username == "author_a") - .unwrap(); - let author_details = author.details.as_ref().unwrap(); - assert!(!author_details.is_empty()); - for d in author_details { - assert!( - d.role == "A" || d.role == "A+R", - "role should be A or A+R, got {}", - d.role - ); - } - } - - #[test] - fn test_old_path_probe_exact_and_prefix() { - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - insert_mr(&conn, 1, 1, 100, "alice", "merged"); - insert_file_change_with_old_path( - &conn, - 1, - 1, - "src/new/foo.rs", - Some("src/old/foo.rs"), - "renamed", - ); - insert_discussion(&conn, 1, 1, Some(1), None, true, false); - insert_diffnote_at( - &conn, - 1, - 1, - 1, - "alice", - "src/new/foo.rs", - Some("src/old/foo.rs"), - "review comment", - now_ms(), - ); - - // Exact probe by OLD path should resolve - let pq = build_path_query(&conn, "src/old/foo.rs", None).unwrap(); - assert!(!pq.is_prefix, "old exact path should resolve as exact"); - assert_eq!(pq.value, "src/old/foo.rs"); - - // Prefix probe by OLD directory should resolve - let pq = build_path_query(&conn, "src/old/", None).unwrap(); - assert!(pq.is_prefix, "old directory should resolve as prefix"); - - // New path still works - let pq = build_path_query(&conn, "src/new/foo.rs", None).unwrap(); - assert!(!pq.is_prefix, "new exact path should still resolve"); - assert_eq!(pq.value, "src/new/foo.rs"); - } - - #[test] - fn test_suffix_probe_uses_old_path_sources() { - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - insert_mr(&conn, 1, 1, 100, "alice", "merged"); - insert_file_change_with_old_path( - &conn, - 1, - 1, - "src/utils.rs", - Some("legacy/utils.rs"), - "renamed", - ); - - let result = suffix_probe(&conn, "utils.rs", None).unwrap(); - match result { - SuffixResult::Ambiguous(paths) => { - assert!( - paths.contains(&"src/utils.rs".to_string()), - "should contain new path" - ); - assert!( - paths.contains(&"legacy/utils.rs".to_string()), - "should contain old path" - ); - } - SuffixResult::Unique(p) => { - panic!("Expected Ambiguous with both paths, got Unique({p})"); - } - SuffixResult::NoMatch => panic!("Expected Ambiguous, got NoMatch"), - SuffixResult::NotAttempted => panic!("Expected Ambiguous, got NotAttempted"), - } - } - - // ─── Plan Section 8: New Tests ────────────────────────────────────────────── - - #[test] - fn test_expert_scores_decay_with_time() { - // Two authors, one recent (10 days), one old (360 days). - // With default author_half_life_days=180, recent ≈ 24.1, old ≈ 6.3. - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - - let now = now_ms(); - let day_ms: i64 = 86_400_000; - let recent_ts = now - 10 * day_ms; // 10 days ago - let old_ts = now - 360 * day_ms; // 360 days ago - - // Recent author - insert_mr_at( - &conn, - 1, - 1, - 100, - "recent_author", - "merged", - recent_ts, - Some(recent_ts), - None, - ); - insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); - - // Old author - insert_mr_at( - &conn, - 2, - 1, - 200, - "old_author", - "merged", - old_ts, - Some(old_ts), - None, - ); - insert_file_change(&conn, 2, 1, "src/lib.rs", "modified"); - - let result = query_expert( - &conn, - "src/lib.rs", - None, - 0, - now + 1000, - 20, - &default_scoring(), - false, - true, - false, - ) - .unwrap(); - - assert_eq!(result.experts.len(), 2); - assert_eq!(result.experts[0].username, "recent_author"); - assert_eq!(result.experts[1].username, "old_author"); - - // Recent author scores significantly higher - let recent_score = result.experts[0].score_raw.unwrap(); - let old_score = result.experts[1].score_raw.unwrap(); - assert!( - recent_score > old_score * 2.0, - "recent ({recent_score:.1}) should be >2x old ({old_score:.1})" - ); - } - - #[test] - fn test_expert_reviewer_decays_faster_than_author() { - // Same MR, same age (90 days). Author half-life=180d, reviewer half-life=90d. - // Author retains 2^(-90/180)=0.707 of weight, reviewer retains 2^(-90/90)=0.5. - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - - let now = now_ms(); - let day_ms: i64 = 86_400_000; - let age_ts = now - 90 * day_ms; - - insert_mr_at( - &conn, - 1, - 1, - 100, - "the_author", - "merged", - age_ts, - Some(age_ts), - None, - ); - insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); - insert_discussion(&conn, 1, 1, Some(1), None, true, false); - insert_diffnote_at( - &conn, - 1, - 1, - 1, - "the_reviewer", - "src/lib.rs", - None, - "a substantive review comment here", - age_ts, - ); - insert_reviewer(&conn, 1, "the_reviewer"); - - let result = query_expert( - &conn, - "src/lib.rs", - None, - 0, - now + 1000, - 20, - &default_scoring(), - false, - true, - false, - ) - .unwrap(); - - // Author gets file_author (25*0.707) + diffnote_author (25*0.707) ≈ 35.4 - // Reviewer gets file_reviewer_participated (10*0.5) + note_group (1*1.0*0.5) ≈ 5.5 - assert_eq!(result.experts[0].username, "the_author"); - let author_score = result.experts[0].score_raw.unwrap(); - let reviewer_score = result.experts[1].score_raw.unwrap(); - assert!( - author_score > reviewer_score * 3.0, - "author ({author_score:.1}) should dominate reviewer ({reviewer_score:.1})" - ); - } - - #[test] - fn test_reviewer_participated_vs_assigned_only() { - // Two reviewers on the same MR. One left substantive DiffNotes (participated), - // one didn't (assigned-only). Participated gets reviewer_weight, assigned-only - // gets reviewer_assignment_weight (much lower). - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - - let now = now_ms(); - insert_mr(&conn, 1, 1, 100, "the_author", "merged"); - insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); - insert_discussion(&conn, 1, 1, Some(1), None, true, false); - insert_diffnote( - &conn, - 1, - 1, - 1, - "active_reviewer", - "src/lib.rs", - "This needs refactoring because...", - ); - insert_reviewer(&conn, 1, "active_reviewer"); - insert_reviewer(&conn, 1, "passive_reviewer"); - - let result = query_expert( - &conn, - "src/lib.rs", - None, - 0, - now + 1000, - 20, - &default_scoring(), - false, - true, - false, - ) - .unwrap(); - - let active = result - .experts - .iter() - .find(|e| e.username == "active_reviewer") - .unwrap(); - let passive = result - .experts - .iter() - .find(|e| e.username == "passive_reviewer") - .unwrap(); - let active_score = active.score_raw.unwrap(); - let passive_score = passive.score_raw.unwrap(); - - // Default: reviewer_weight=10, reviewer_assignment_weight=3 - // Active: 10 * ~1.0 + note_group ≈ 11 - // Passive: 3 * ~1.0 = 3 - assert!( - active_score > passive_score * 2.0, - "active ({active_score:.1}) should be >2x passive ({passive_score:.1})" - ); - } - - #[test] - fn test_note_diminishing_returns_per_mr() { - // One reviewer with 1 note on MR-A and another with 20 notes on MR-B. - // The 20-note reviewer should score ~log2(21)/log2(2) ≈ 4.4x, NOT 20x. - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - - let now = now_ms(); - // MR 1 with 1 note - insert_mr(&conn, 1, 1, 100, "author_x", "merged"); - insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); - insert_discussion(&conn, 1, 1, Some(1), None, true, false); - insert_diffnote( - &conn, - 1, - 1, - 1, - "one_note_reviewer", - "src/lib.rs", - "a single substantive review note", - ); - insert_reviewer(&conn, 1, "one_note_reviewer"); - - // MR 2 with 20 notes from another reviewer - insert_mr(&conn, 2, 1, 200, "author_y", "merged"); - insert_file_change(&conn, 2, 1, "src/lib.rs", "modified"); - insert_discussion(&conn, 2, 1, Some(2), None, true, false); - for i in 0_i64..20 { - insert_diffnote( - &conn, - 100 + i, - 2, - 1, - "many_note_reviewer", - "src/lib.rs", - &format!("substantive review comment number {i}"), - ); - } - insert_reviewer(&conn, 2, "many_note_reviewer"); - - let scoring = ScoringConfig { - reviewer_weight: 0, - reviewer_assignment_weight: 0, - author_weight: 0, - note_bonus: 10, // High bonus to isolate note contribution - ..Default::default() - }; - let result = query_expert( - &conn, - "src/lib.rs", - None, - 0, - now + 1000, - 20, - &scoring, - false, - true, - false, - ) - .unwrap(); - - let one = result - .experts - .iter() - .find(|e| e.username == "one_note_reviewer") - .unwrap(); - let many = result - .experts - .iter() - .find(|e| e.username == "many_note_reviewer") - .unwrap(); - let one_score = one.score_raw.unwrap(); - let many_score = many.score_raw.unwrap(); - - // log2(1+1)=1.0, log2(1+20)≈4.39. Ratio should be ~4.4x, not 20x. - let ratio = many_score / one_score; - assert!( - ratio < 6.0, - "ratio ({ratio:.1}) should be ~4.4, not 20 (diminishing returns)" - ); - assert!( - ratio > 3.0, - "ratio ({ratio:.1}) should be ~4.4, reflecting log2 scaling" - ); - } - - #[test] - fn test_file_change_timestamp_uses_merged_at() { - // A merged MR should use merged_at for decay, not updated_at. - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - - let now = now_ms(); - let day_ms: i64 = 86_400_000; - let old_merged = now - 300 * day_ms; // merged 300 days ago - let recent_updated = now - day_ms; // updated yesterday - - // MR merged long ago but recently updated (e.g., label change) - insert_mr_at( - &conn, - 1, - 1, - 100, - "the_author", - "merged", - recent_updated, - Some(old_merged), - None, - ); - insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); - - let result = query_expert( - &conn, - "src/lib.rs", - None, - 0, - now + 1000, - 20, - &default_scoring(), - false, - true, - false, - ) - .unwrap(); - - assert_eq!(result.experts.len(), 1); - let score = result.experts[0].score_raw.unwrap(); - // With half_life=180d and elapsed=300d, decay = 2^(-300/180) ≈ 0.315 - // Score ≈ 25 * 0.315 ≈ 7.9 (file_author only, no diffnote_author without notes) - // If it incorrectly used updated_at (1 day), score ≈ 25 * ~1.0 = 25 - assert!( - score < 15.0, - "score ({score:.1}) should reflect old merged_at, not recent updated_at" - ); - } - - #[test] - fn test_open_mr_uses_updated_at() { - // An opened MR should use updated_at for decay, not created_at. - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - - let now = now_ms(); - let day_ms: i64 = 86_400_000; - - // MR-A: opened, recently updated (decay ≈ 1.0) - conn.execute( - "INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, author_username, state, last_seen_at, updated_at, created_at) - VALUES (1, 10, 1, 100, 'MR 100', 'recent_update', 'opened', ?1, ?2, ?3)", - rusqlite::params![now, now - 5 * day_ms, now - 200 * day_ms], - ).unwrap(); - insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); - - // MR-B: opened, old updated_at (decay significant) - conn.execute( - "INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, author_username, state, last_seen_at, updated_at, created_at) - VALUES (2, 20, 1, 200, 'MR 200', 'old_update', 'opened', ?1, ?2, ?3)", - rusqlite::params![now, now - 200 * day_ms, now - 200 * day_ms], - ).unwrap(); - insert_file_change(&conn, 2, 1, "src/lib.rs", "modified"); - - let result = query_expert( - &conn, - "src/lib.rs", - None, - 0, - now + 1000, - 20, - &default_scoring(), - false, - true, - false, - ) - .unwrap(); - - // recent_update should rank first (higher score from fresher updated_at) - assert_eq!(result.experts[0].username, "recent_update"); - let recent = result.experts[0].score_raw.unwrap(); - let old = result.experts[1].score_raw.unwrap(); - assert!( - recent > old * 2.0, - "recent ({recent:.1}) should beat old ({old:.1}) by updated_at" - ); - } - - #[test] - fn test_old_path_match_credits_expertise() { - // DiffNote with old_path should credit expertise when queried by old path. - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - - let now = now_ms(); - insert_mr(&conn, 1, 1, 100, "the_author", "merged"); - insert_file_change_with_old_path(&conn, 1, 1, "src/new.rs", Some("src/old.rs"), "renamed"); - insert_discussion(&conn, 1, 1, Some(1), None, true, false); - insert_diffnote_at( - &conn, - 1, - 1, - 1, - "reviewer_a", - "src/new.rs", - Some("src/old.rs"), - "substantive review of the renamed file", - now, - ); - - // Query by old path - let result_old = query_expert( - &conn, - "src/old.rs", - None, - 0, - now + 1000, - 20, - &default_scoring(), - false, - false, - false, - ) - .unwrap(); - - // Query by new path - let result_new = query_expert( - &conn, - "src/new.rs", - None, - 0, - now + 1000, - 20, - &default_scoring(), - false, - false, - false, - ) - .unwrap(); - - // Both queries should find the author - assert!( - result_old - .experts - .iter() - .any(|e| e.username == "the_author"), - "author should appear via old_path query" - ); - assert!( - result_new - .experts - .iter() - .any(|e| e.username == "the_author"), - "author should appear via new_path query" - ); - } - - #[test] - fn test_explain_score_components_sum_to_total() { - // With explain_score, component subtotals should sum to score_raw. - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - - let now = now_ms(); - insert_mr(&conn, 1, 1, 100, "the_author", "merged"); - insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); - insert_discussion(&conn, 1, 1, Some(1), None, true, false); - insert_diffnote( - &conn, - 1, - 1, - 1, - "the_reviewer", - "src/lib.rs", - "a substantive enough review comment", - ); - insert_reviewer(&conn, 1, "the_reviewer"); - - let result = query_expert( - &conn, - "src/lib.rs", - None, - 0, - now + 1000, - 20, - &default_scoring(), - false, - true, - false, - ) - .unwrap(); - - for expert in &result.experts { - let raw = expert.score_raw.unwrap(); - let comp = expert.components.as_ref().unwrap(); - let sum = - comp.author + comp.reviewer_participated + comp.reviewer_assigned + comp.notes; - assert!( - (raw - sum).abs() < 1e-10, - "components ({sum:.6}) should sum to score_raw ({raw:.6}) for {}", - expert.username - ); - } - } - - #[test] - fn test_as_of_produces_deterministic_results() { - // Same as_of value produces identical results across multiple runs. - // Later as_of produces lower scores (more decay). - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - - let now = now_ms(); - let day_ms: i64 = 86_400_000; - let mr_ts = now - 30 * day_ms; - - insert_mr_at( - &conn, - 1, - 1, - 100, - "the_author", - "merged", - mr_ts, - Some(mr_ts), - None, - ); - insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); - - let as_of_early = now; - let as_of_late = now + 180 * day_ms; - - let result1 = query_expert( - &conn, - "src/lib.rs", - None, - 0, - as_of_early, - 20, - &default_scoring(), - false, - true, - false, - ) - .unwrap(); - let result2 = query_expert( - &conn, - "src/lib.rs", - None, - 0, - as_of_early, - 20, - &default_scoring(), - false, - true, - false, - ) - .unwrap(); - - // Same as_of -> identical scores - assert_eq!( - result1.experts[0].score_raw.unwrap().to_bits(), - result2.experts[0].score_raw.unwrap().to_bits(), - "same as_of should produce bit-identical scores" - ); - - // Later as_of -> lower score - let result_late = query_expert( - &conn, - "src/lib.rs", - None, - 0, - as_of_late, - 20, - &default_scoring(), - false, - true, - false, - ) - .unwrap(); - assert!( - result1.experts[0].score_raw.unwrap() > result_late.experts[0].score_raw.unwrap(), - "later as_of should produce lower scores" - ); - } - - #[test] - fn test_trivial_note_does_not_count_as_participation() { - // A reviewer with only a short note ("LGTM") should be classified as - // assigned-only, not participated, when reviewer_min_note_chars = 20. - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - - let now = now_ms(); - insert_mr(&conn, 1, 1, 100, "the_author", "merged"); - insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); - insert_discussion(&conn, 1, 1, Some(1), None, true, false); - // Short note (4 chars, below threshold of 20) - insert_diffnote(&conn, 1, 1, 1, "trivial_reviewer", "src/lib.rs", "LGTM"); - insert_reviewer(&conn, 1, "trivial_reviewer"); - - // Another reviewer with substantive note - insert_diffnote( - &conn, - 2, - 1, - 1, - "substantive_reviewer", - "src/lib.rs", - "This function needs better error handling for the edge case...", - ); - insert_reviewer(&conn, 1, "substantive_reviewer"); - - let result = query_expert( - &conn, - "src/lib.rs", - None, - 0, - now + 1000, - 20, - &default_scoring(), - false, - true, - false, - ) - .unwrap(); - - let trivial = result - .experts - .iter() - .find(|e| e.username == "trivial_reviewer") - .unwrap(); - let substantive = result - .experts - .iter() - .find(|e| e.username == "substantive_reviewer") - .unwrap(); - - let trivial_comp = trivial.components.as_ref().unwrap(); - let substantive_comp = substantive.components.as_ref().unwrap(); - - // Trivial should get reviewer_assigned (3), not reviewer_participated (10) - assert!( - trivial_comp.reviewer_assigned > 0.0, - "trivial reviewer should get assigned-only signal" - ); - assert!( - trivial_comp.reviewer_participated < 0.01, - "trivial reviewer should NOT get participated signal" - ); - - // Substantive should get reviewer_participated - assert!( - substantive_comp.reviewer_participated > 0.0, - "substantive reviewer should get participated signal" - ); - } - - #[test] - fn test_closed_mr_multiplier() { - // Two MRs with the same author: one merged, one closed. - // Closed should contribute author_weight * closed_mr_multiplier * decay. - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - - let now = now_ms(); - // Merged MR - insert_mr(&conn, 1, 1, 100, "merged_author", "merged"); - insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); - - // Closed MR (same path, different author) - insert_mr(&conn, 2, 1, 200, "closed_author", "closed"); - insert_file_change(&conn, 2, 1, "src/lib.rs", "modified"); - - let result = query_expert( - &conn, - "src/lib.rs", - None, - 0, - now + 1000, - 20, - &default_scoring(), - false, - true, - false, - ) - .unwrap(); - - let merged = result - .experts - .iter() - .find(|e| e.username == "merged_author") - .unwrap(); - let closed = result - .experts - .iter() - .find(|e| e.username == "closed_author") - .unwrap(); - let merged_score = merged.score_raw.unwrap(); - let closed_score = closed.score_raw.unwrap(); - - // Default closed_mr_multiplier=0.5, so closed should be roughly half - let ratio = closed_score / merged_score; - assert!( - (ratio - 0.5).abs() < 0.1, - "closed/merged ratio ({ratio:.2}) should be ≈0.5" - ); - } - - #[test] - fn test_as_of_excludes_future_events() { - // Events after as_of should not appear in results. - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - - let now = now_ms(); - let day_ms: i64 = 86_400_000; - let past_ts = now - 30 * day_ms; - let future_ts = now + 30 * day_ms; - - // Past MR (should appear) - insert_mr_at( - &conn, - 1, - 1, - 100, - "past_author", - "merged", - past_ts, - Some(past_ts), - None, - ); - insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); - - // Future MR (should NOT appear) - insert_mr_at( - &conn, - 2, - 1, - 200, - "future_author", - "merged", - future_ts, - Some(future_ts), - None, - ); - insert_file_change(&conn, 2, 1, "src/lib.rs", "modified"); - - let result = query_expert( - &conn, - "src/lib.rs", - None, - 0, - now, - 20, - &default_scoring(), - false, - false, - false, - ) - .unwrap(); - - assert!( - result.experts.iter().any(|e| e.username == "past_author"), - "past author should appear" - ); - assert!( - !result.experts.iter().any(|e| e.username == "future_author"), - "future author should be excluded by as_of" - ); - } - - #[test] - fn test_as_of_exclusive_upper_bound() { - // An event with timestamp exactly equal to as_of should be excluded (strict <). - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - - let now = now_ms(); - let boundary_ts = now; - - insert_mr_at( - &conn, - 1, - 1, - 100, - "boundary_author", - "merged", - boundary_ts, - Some(boundary_ts), - None, - ); - insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); - - let result = query_expert( - &conn, - "src/lib.rs", - None, - 0, - boundary_ts, - 20, - &default_scoring(), - false, - false, - false, - ) - .unwrap(); - - assert!( - !result - .experts - .iter() - .any(|e| e.username == "boundary_author"), - "event at exactly as_of should be excluded (half-open interval)" - ); - } - - #[test] - fn test_excluded_usernames_filters_bots() { - // Bot users in excluded_usernames should be filtered out. - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - - let now = now_ms(); - insert_mr(&conn, 1, 1, 100, "renovate-bot", "merged"); - insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); - insert_mr(&conn, 2, 1, 200, "jsmith", "merged"); - insert_file_change(&conn, 2, 1, "src/lib.rs", "modified"); - - let scoring = ScoringConfig { - excluded_usernames: vec!["renovate-bot".to_string()], - ..Default::default() - }; - let result = query_expert( - &conn, - "src/lib.rs", - None, - 0, - now + 1000, - 20, - &scoring, - false, - false, - false, - ) - .unwrap(); - - assert_eq!(result.experts.len(), 1); - assert_eq!(result.experts[0].username, "jsmith"); - } - - #[test] - fn test_include_bots_flag_disables_filtering() { - // With include_bots=true, excluded_usernames should be ignored. - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - - let now = now_ms(); - insert_mr(&conn, 1, 1, 100, "renovate-bot", "merged"); - insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); - insert_mr(&conn, 2, 1, 200, "jsmith", "merged"); - insert_file_change(&conn, 2, 1, "src/lib.rs", "modified"); - - let scoring = ScoringConfig { - excluded_usernames: vec!["renovate-bot".to_string()], - ..Default::default() - }; - // include_bots = true - let result = query_expert( - &conn, - "src/lib.rs", - None, - 0, - now + 1000, - 20, - &scoring, - false, - false, - true, - ) - .unwrap(); - - assert_eq!(result.experts.len(), 2); - assert!(result.experts.iter().any(|e| e.username == "renovate-bot")); - assert!(result.experts.iter().any(|e| e.username == "jsmith")); - } - - #[test] - fn test_null_timestamp_fallback_to_created_at() { - // A merged MR with merged_at=NULL should fall back to created_at. - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - - let now = now_ms(); - let day_ms: i64 = 86_400_000; - let old_ts = now - 100 * day_ms; - - // Insert merged MR with merged_at=NULL, created_at set - conn.execute( - "INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, author_username, state, last_seen_at, created_at, merged_at, updated_at) - VALUES (1, 10, 1, 100, 'MR 100', 'the_author', 'merged', ?1, ?2, NULL, NULL)", - rusqlite::params![now, old_ts], - ).unwrap(); - insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); - - let result = query_expert( - &conn, - "src/lib.rs", - None, - 0, - now + 1000, - 20, - &default_scoring(), - false, - true, - false, - ) - .unwrap(); - - // Should still find the author (not panic or return empty) - assert_eq!(result.experts.len(), 1); - assert_eq!(result.experts[0].username, "the_author"); - // Score should reflect old_ts (100 days ago), not 0 or now - let score = result.experts[0].score_raw.unwrap(); - // 25 * 2^(-100/180) ≈ 17.1 - assert!( - score > 5.0 && score < 22.0, - "score ({score:.1}) should reflect created_at fallback" - ); - } - - // ─── Invariant Tests ──────────────────────────────────────────────────────── - - #[test] - fn test_score_monotonicity_by_age() { - // For any single signal, older timestamp must never produce a higher score. - for half_life in [1_u32, 7, 45, 90, 180, 365] { - let mut prev_decay = f64::MAX; - for days in 0..=730 { - let elapsed_ms = i64::from(days) * 86_400_000; - let decay = half_life_decay(elapsed_ms, half_life); - assert!( - decay <= prev_decay, - "monotonicity violated: half_life={half_life}, day={days}, decay={decay} > prev={prev_decay}" - ); - prev_decay = decay; - } - } - } - - #[test] - fn test_row_order_independence() { - // Same signals inserted in different order should produce identical results. - let conn1 = setup_test_db(); - let conn2 = setup_test_db(); - - let now = now_ms(); - let day_ms: i64 = 86_400_000; - - // Setup identical data in both DBs but in different insertion order - for conn in [&conn1, &conn2] { - insert_project(conn, 1, "team/backend"); - } - - // Forward order in conn1 - for i in 1_i64..=5 { - let ts = now - i * 30 * day_ms; - insert_mr_at( - &conn1, - i, - 1, - i * 100, - &format!("author_{i}"), - "merged", - ts, - Some(ts), - None, - ); - insert_file_change(&conn1, i, 1, "src/lib.rs", "modified"); - } - - // Reverse order in conn2 - for i in (1_i64..=5).rev() { - let ts = now - i * 30 * day_ms; - insert_mr_at( - &conn2, - i, - 1, - i * 100, - &format!("author_{i}"), - "merged", - ts, - Some(ts), - None, - ); - insert_file_change(&conn2, i, 1, "src/lib.rs", "modified"); - } - - let r1 = query_expert( - &conn1, - "src/lib.rs", - None, - 0, - now + 1000, - 20, - &default_scoring(), - false, - true, - false, - ) - .unwrap(); - let r2 = query_expert( - &conn2, - "src/lib.rs", - None, - 0, - now + 1000, - 20, - &default_scoring(), - false, - true, - false, - ) - .unwrap(); - - assert_eq!(r1.experts.len(), r2.experts.len()); - for (e1, e2) in r1.experts.iter().zip(r2.experts.iter()) { - assert_eq!(e1.username, e2.username, "ranking order differs"); - assert_eq!( - e1.score_raw.unwrap().to_bits(), - e2.score_raw.unwrap().to_bits(), - "scores differ for {}", - e1.username - ); - } - } - - #[test] - fn test_reviewer_split_is_exhaustive() { - // A reviewer on an MR must appear in exactly one of: participated or assigned-only. - // Three cases: (1) substantive notes -> participated, (2) no notes -> assigned, - // (3) trivial notes -> assigned. - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - - let now = now_ms(); - insert_mr(&conn, 1, 1, 100, "the_author", "merged"); - insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); - insert_discussion(&conn, 1, 1, Some(1), None, true, false); - - // Case 1: substantive notes - insert_diffnote( - &conn, - 1, - 1, - 1, - "reviewer_substantive", - "src/lib.rs", - "this is a long enough substantive review comment", - ); - insert_reviewer(&conn, 1, "reviewer_substantive"); - - // Case 2: no notes at all - insert_reviewer(&conn, 1, "reviewer_no_notes"); - - // Case 3: trivial notes only - insert_diffnote(&conn, 2, 1, 1, "reviewer_trivial", "src/lib.rs", "ok"); - insert_reviewer(&conn, 1, "reviewer_trivial"); - - let result = query_expert( - &conn, - "src/lib.rs", - None, - 0, - now + 1000, - 20, - &default_scoring(), - false, - true, - false, - ) - .unwrap(); - - // Check each reviewer's components - let subst = result - .experts - .iter() - .find(|e| e.username == "reviewer_substantive") - .unwrap(); - let subst_comp = subst.components.as_ref().unwrap(); - assert!( - subst_comp.reviewer_participated > 0.0, - "case 1: should be participated" - ); - assert!( - subst_comp.reviewer_assigned < 0.01, - "case 1: should NOT be assigned" - ); - - let no_notes = result - .experts - .iter() - .find(|e| e.username == "reviewer_no_notes") - .unwrap(); - let nn_comp = no_notes.components.as_ref().unwrap(); - assert!( - nn_comp.reviewer_assigned > 0.0, - "case 2: should be assigned" - ); - assert!( - nn_comp.reviewer_participated < 0.01, - "case 2: should NOT be participated" - ); - - let trivial = result - .experts - .iter() - .find(|e| e.username == "reviewer_trivial") - .unwrap(); - let tr_comp = trivial.components.as_ref().unwrap(); - assert!( - tr_comp.reviewer_assigned > 0.0, - "case 3: should be assigned" - ); - assert!( - tr_comp.reviewer_participated < 0.01, - "case 3: should NOT be participated" - ); - } - - #[test] - fn test_deterministic_accumulation_order() { - // Same data queried 50 times must produce bit-identical f64 scores. - let conn = setup_test_db(); - insert_project(&conn, 1, "team/backend"); - - let now = now_ms(); - let day_ms: i64 = 86_400_000; - - // 10 MRs at varied ages - for i in 1_i64..=10 { - let ts = now - i * 20 * day_ms; - insert_mr_at( - &conn, - i, - 1, - i * 100, - "the_author", - "merged", - ts, - Some(ts), - None, - ); - insert_file_change(&conn, i, 1, "src/lib.rs", "modified"); - } - - let as_of = now + 1000; - let first_result = query_expert( - &conn, - "src/lib.rs", - None, - 0, - as_of, - 20, - &default_scoring(), - false, - true, - false, - ) - .unwrap(); - let expected_bits = first_result.experts[0].score_raw.unwrap().to_bits(); - - for run in 1..50 { - let result = query_expert( - &conn, - "src/lib.rs", - None, - 0, - as_of, - 20, - &default_scoring(), - false, - true, - false, - ) - .unwrap(); - assert_eq!( - result.experts[0].score_raw.unwrap().to_bits(), - expected_bits, - "run {run}: score bits diverged (HashMap iteration order leaking)" - ); - } - } -} +#[path = "who_tests.rs"] +mod tests; diff --git a/src/cli/commands/who_tests.rs b/src/cli/commands/who_tests.rs new file mode 100644 index 0000000..9c267cb --- /dev/null +++ b/src/cli/commands/who_tests.rs @@ -0,0 +1,3267 @@ +use super::*; +use crate::core::db::{create_connection, run_migrations}; +use std::path::Path; + +fn setup_test_db() -> Connection { + let conn = create_connection(Path::new(":memory:")).unwrap(); + run_migrations(&conn).unwrap(); + conn +} + +fn default_scoring() -> ScoringConfig { + ScoringConfig::default() +} + +/// as_of_ms value for tests: 1 second after now, giving near-zero decay. +fn test_as_of_ms() -> i64 { + now_ms() + 1000 +} + +fn insert_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://git.example.com/{}", path) + ], + ) + .unwrap(); +} + +fn insert_mr(conn: &Connection, id: i64, project_id: i64, iid: i64, author: &str, state: &str) { + let ts = now_ms(); + conn.execute( + "INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, author_username, state, last_seen_at, updated_at, created_at, merged_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + rusqlite::params![ + id, + id * 10, + project_id, + iid, + format!("MR {iid}"), + author, + state, + ts, + ts, + ts, + if state == "merged" { Some(ts) } else { None:: } + ], + ) + .unwrap(); +} + +fn insert_issue(conn: &Connection, id: i64, project_id: i64, iid: i64, author: &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', ?6, ?7, ?8, ?9)", + rusqlite::params![ + id, + id * 10, + project_id, + iid, + format!("Issue {iid}"), + author, + now_ms(), + now_ms(), + now_ms() + ], + ) + .unwrap(); +} + +fn insert_discussion( + conn: &Connection, + id: i64, + project_id: i64, + mr_id: Option, + issue_id: Option, + resolvable: bool, + resolved: bool, +) { + let noteable_type = if mr_id.is_some() { + "MergeRequest" + } else { + "Issue" + }; + conn.execute( + "INSERT INTO discussions (id, gitlab_discussion_id, project_id, merge_request_id, issue_id, noteable_type, resolvable, resolved, last_seen_at, last_note_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + rusqlite::params![ + id, + format!("disc-{id}"), + project_id, + mr_id, + issue_id, + noteable_type, + i32::from(resolvable), + i32::from(resolved), + now_ms(), + now_ms() + ], + ) + .unwrap(); +} + +#[allow(clippy::too_many_arguments)] +fn insert_diffnote( + conn: &Connection, + id: i64, + discussion_id: i64, + project_id: i64, + author: &str, + file_path: &str, + body: &str, +) { + conn.execute( + "INSERT INTO notes (id, gitlab_id, discussion_id, project_id, note_type, is_system, author_username, body, created_at, updated_at, last_seen_at, position_new_path) + VALUES (?1, ?2, ?3, ?4, 'DiffNote', 0, ?5, ?6, ?7, ?8, ?9, ?10)", + rusqlite::params![ + id, + id * 10, + discussion_id, + project_id, + author, + body, + now_ms(), + now_ms(), + now_ms(), + file_path + ], + ) + .unwrap(); +} + +fn insert_assignee(conn: &Connection, issue_id: i64, username: &str) { + conn.execute( + "INSERT INTO issue_assignees (issue_id, username) VALUES (?1, ?2)", + rusqlite::params![issue_id, username], + ) + .unwrap(); +} + +fn insert_reviewer(conn: &Connection, mr_id: i64, username: &str) { + conn.execute( + "INSERT INTO mr_reviewers (merge_request_id, username) VALUES (?1, ?2)", + rusqlite::params![mr_id, username], + ) + .unwrap(); +} + +fn insert_file_change( + conn: &Connection, + mr_id: i64, + project_id: i64, + new_path: &str, + change_type: &str, +) { + conn.execute( + "INSERT INTO mr_file_changes (merge_request_id, project_id, new_path, change_type) + VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![mr_id, project_id, new_path, change_type], + ) + .unwrap(); +} + +#[allow(clippy::too_many_arguments, dead_code)] +fn insert_mr_at( + conn: &Connection, + id: i64, + project_id: i64, + iid: i64, + author: &str, + state: &str, + updated_at_ms: i64, + merged_at_ms: Option, + closed_at_ms: Option, +) { + conn.execute( + "INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, author_username, state, last_seen_at, updated_at, merged_at, closed_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + rusqlite::params![ + id, + id * 10, + project_id, + iid, + format!("MR {iid}"), + author, + state, + now_ms(), + updated_at_ms, + merged_at_ms, + closed_at_ms + ], + ) + .unwrap(); +} + +#[allow(clippy::too_many_arguments, dead_code)] +fn insert_diffnote_at( + conn: &Connection, + id: i64, + discussion_id: i64, + project_id: i64, + author: &str, + new_path: &str, + old_path: Option<&str>, + body: &str, + created_at_ms: i64, +) { + conn.execute( + "INSERT INTO notes (id, gitlab_id, project_id, discussion_id, author_username, note_type, is_system, position_new_path, position_old_path, body, created_at, updated_at, last_seen_at) + VALUES (?1, ?2, ?3, ?4, ?5, 'DiffNote', 0, ?6, ?7, ?8, ?9, ?9, ?9)", + rusqlite::params![ + id, + id * 10, + project_id, + discussion_id, + author, + new_path, + old_path, + body, + created_at_ms + ], + ) + .unwrap(); +} + +#[allow(dead_code)] +fn insert_file_change_with_old_path( + conn: &Connection, + mr_id: i64, + project_id: i64, + new_path: &str, + old_path: Option<&str>, + change_type: &str, +) { + conn.execute( + "INSERT INTO mr_file_changes (merge_request_id, project_id, new_path, old_path, change_type) + VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![mr_id, project_id, new_path, old_path, change_type], + ) + .unwrap(); +} + +#[test] +fn test_is_file_path_discrimination() { + // Contains '/' -> file path + assert!(matches!( + resolve_mode(&WhoArgs { + target: Some("src/auth/".to_string()), + path: None, + active: false, + overlap: None, + reviews: false, + since: None, + project: None, + limit: 20, + detail: false, + no_detail: false, + fields: None, + as_of: None, + explain_score: false, + include_bots: false, + all_history: false, + }) + .unwrap(), + WhoMode::Expert { .. } + )); + + // No '/' -> username + assert!(matches!( + resolve_mode(&WhoArgs { + target: Some("asmith".to_string()), + path: None, + active: false, + overlap: None, + reviews: false, + since: None, + project: None, + limit: 20, + detail: false, + no_detail: false, + fields: None, + as_of: None, + explain_score: false, + include_bots: false, + all_history: false, + }) + .unwrap(), + WhoMode::Workload { .. } + )); + + // With @ prefix -> username (stripped) + assert!(matches!( + resolve_mode(&WhoArgs { + target: Some("@asmith".to_string()), + path: None, + active: false, + overlap: None, + reviews: false, + since: None, + project: None, + limit: 20, + detail: false, + no_detail: false, + fields: None, + as_of: None, + explain_score: false, + include_bots: false, + all_history: false, + }) + .unwrap(), + WhoMode::Workload { .. } + )); + + // --reviews flag -> reviews mode + assert!(matches!( + resolve_mode(&WhoArgs { + target: Some("asmith".to_string()), + path: None, + active: false, + overlap: None, + reviews: true, + since: None, + project: None, + limit: 20, + detail: false, + no_detail: false, + fields: None, + as_of: None, + explain_score: false, + include_bots: false, + all_history: false, + }) + .unwrap(), + WhoMode::Reviews { .. } + )); + + // --path flag -> expert mode (handles root files) + assert!(matches!( + resolve_mode(&WhoArgs { + target: None, + path: Some("README.md".to_string()), + active: false, + overlap: None, + reviews: false, + since: None, + project: None, + limit: 20, + detail: false, + no_detail: false, + fields: None, + as_of: None, + explain_score: false, + include_bots: false, + all_history: false, + }) + .unwrap(), + WhoMode::Expert { .. } + )); + + // --path flag with dotless file -> expert mode + assert!(matches!( + resolve_mode(&WhoArgs { + target: None, + path: Some("Makefile".to_string()), + active: false, + overlap: None, + reviews: false, + since: None, + project: None, + limit: 20, + detail: false, + no_detail: false, + fields: None, + as_of: None, + explain_score: false, + include_bots: false, + all_history: false, + }) + .unwrap(), + WhoMode::Expert { .. } + )); +} + +#[test] +fn test_detail_rejected_outside_expert_mode() { + let args = WhoArgs { + target: Some("asmith".to_string()), + path: None, + active: false, + overlap: None, + reviews: false, + since: None, + project: None, + limit: 20, + detail: true, + no_detail: false, + fields: None, + as_of: None, + explain_score: false, + include_bots: false, + all_history: false, + }; + let mode = resolve_mode(&args).unwrap(); + let err = validate_mode_flags(&mode, &args).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("--detail is only supported in expert mode"), + "unexpected error: {msg}" + ); +} + +#[test] +fn test_detail_allowed_in_expert_mode() { + let args = WhoArgs { + target: None, + path: Some("README.md".to_string()), + active: false, + overlap: None, + reviews: false, + since: None, + project: None, + limit: 20, + detail: true, + no_detail: false, + fields: None, + as_of: None, + explain_score: false, + include_bots: false, + all_history: false, + }; + let mode = resolve_mode(&args).unwrap(); + assert!(validate_mode_flags(&mode, &args).is_ok()); +} + +#[test] +fn test_build_path_query() { + let conn = setup_test_db(); + + // Directory with trailing slash -> prefix + let pq = build_path_query(&conn, "src/auth/", None).unwrap(); + assert_eq!(pq.value, "src/auth/%"); + assert!(pq.is_prefix); + + // Directory without trailing slash (no dot in last segment) -> prefix + let pq = build_path_query(&conn, "src/auth", None).unwrap(); + assert_eq!(pq.value, "src/auth/%"); + assert!(pq.is_prefix); + + // File with extension -> exact + let pq = build_path_query(&conn, "src/auth/login.rs", None).unwrap(); + assert_eq!(pq.value, "src/auth/login.rs"); + assert!(!pq.is_prefix); + + // Root file -> exact + let pq = build_path_query(&conn, "README.md", None).unwrap(); + assert_eq!(pq.value, "README.md"); + assert!(!pq.is_prefix); + + // Directory with dots in non-leaf segment -> prefix + let pq = build_path_query(&conn, ".github/workflows/", None).unwrap(); + assert_eq!(pq.value, ".github/workflows/%"); + assert!(pq.is_prefix); + + // Versioned directory path -> prefix + let pq = build_path_query(&conn, "src/v1.2/auth/", None).unwrap(); + assert_eq!(pq.value, "src/v1.2/auth/%"); + assert!(pq.is_prefix); + + // Path with LIKE metacharacters -> prefix, escaped + let pq = build_path_query(&conn, "src/test_files/", None).unwrap(); + assert_eq!(pq.value, "src/test\\_files/%"); + assert!(pq.is_prefix); + + // Dotless root file -> exact match (root path without '/') + let pq = build_path_query(&conn, "Makefile", None).unwrap(); + assert_eq!(pq.value, "Makefile"); + assert!(!pq.is_prefix); + + let pq = build_path_query(&conn, "LICENSE", None).unwrap(); + assert_eq!(pq.value, "LICENSE"); + assert!(!pq.is_prefix); + + // Dotless root path with trailing '/' -> directory prefix (explicit override) + let pq = build_path_query(&conn, "Makefile/", None).unwrap(); + assert_eq!(pq.value, "Makefile/%"); + assert!(pq.is_prefix); +} + +#[test] +fn test_escape_like() { + assert_eq!(escape_like("normal/path"), "normal/path"); + assert_eq!(escape_like("has_underscore"), "has\\_underscore"); + assert_eq!(escape_like("has%percent"), "has\\%percent"); + assert_eq!(escape_like("has\\backslash"), "has\\\\backslash"); +} + +#[test] +fn test_build_path_query_exact_does_not_escape() { + let conn = setup_test_db(); + // '_' must NOT be escaped for exact match (=). + let pq = build_path_query(&conn, "README_with_underscore.md", None).unwrap(); + assert_eq!(pq.value, "README_with_underscore.md"); + assert!(!pq.is_prefix); +} + +#[test] +fn test_path_flag_dotless_root_file_is_exact() { + let conn = setup_test_db(); + // --path Makefile must produce an exact match, not Makefile/% + let pq = build_path_query(&conn, "Makefile", None).unwrap(); + assert_eq!(pq.value, "Makefile"); + assert!(!pq.is_prefix); + + let pq = build_path_query(&conn, "Dockerfile", None).unwrap(); + assert_eq!(pq.value, "Dockerfile"); + assert!(!pq.is_prefix); +} + +#[test] +fn test_expert_query() { + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + insert_mr(&conn, 1, 1, 100, "author_a", "merged"); + insert_discussion(&conn, 1, 1, Some(1), None, true, false); + insert_diffnote( + &conn, + 1, + 1, + 1, + "reviewer_b", + "src/auth/login.rs", + "**suggestion**: use const", + ); + insert_diffnote( + &conn, + 2, + 1, + 1, + "reviewer_b", + "src/auth/login.rs", + "**question**: why?", + ); + insert_diffnote( + &conn, + 3, + 1, + 1, + "reviewer_c", + "src/auth/session.rs", + "looks good", + ); + + let result = query_expert( + &conn, + "src/auth/", + None, + 0, + test_as_of_ms(), + 20, + &default_scoring(), + false, + false, + false, + ) + .unwrap(); + assert_eq!(result.experts.len(), 3); // author_a, reviewer_b, reviewer_c + assert_eq!(result.experts[0].username, "author_a"); // highest score (authorship dominates) +} + +#[test] +fn test_workload_query() { + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + insert_issue(&conn, 1, 1, 42, "someone_else"); + insert_assignee(&conn, 1, "dev_a"); + insert_mr(&conn, 1, 1, 100, "dev_a", "opened"); + + let result = query_workload(&conn, "dev_a", None, None, 20).unwrap(); + assert_eq!(result.assigned_issues.len(), 1); + assert_eq!(result.authored_mrs.len(), 1); +} + +#[test] +fn test_reviews_query() { + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + insert_mr(&conn, 1, 1, 100, "author_a", "merged"); + insert_discussion(&conn, 1, 1, Some(1), None, true, false); + insert_diffnote( + &conn, + 1, + 1, + 1, + "reviewer_b", + "src/foo.rs", + "**suggestion**: refactor", + ); + insert_diffnote( + &conn, + 2, + 1, + 1, + "reviewer_b", + "src/bar.rs", + "**question**: why?", + ); + insert_diffnote(&conn, 3, 1, 1, "reviewer_b", "src/baz.rs", "looks good"); + + let result = query_reviews(&conn, "reviewer_b", None, 0).unwrap(); + assert_eq!(result.total_diffnotes, 3); + assert_eq!(result.categorized_count, 2); + assert_eq!(result.categories.len(), 2); +} + +#[test] +fn test_active_query() { + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + insert_mr(&conn, 1, 1, 100, "author_a", "opened"); + insert_discussion(&conn, 1, 1, Some(1), None, true, false); + insert_diffnote(&conn, 1, 1, 1, "reviewer_b", "src/foo.rs", "needs work"); + // Second note by same participant -- note_count should be 2, participants still ["reviewer_b"] + insert_diffnote(&conn, 2, 1, 1, "reviewer_b", "src/foo.rs", "follow-up"); + + let result = query_active(&conn, None, 0, 20).unwrap(); + assert_eq!(result.total_unresolved_in_window, 1); + assert_eq!(result.discussions.len(), 1); + assert_eq!(result.discussions[0].participants, vec!["reviewer_b"]); + // This was a regression in iteration 4: note_count was counting participants, not notes + assert_eq!(result.discussions[0].note_count, 2); + assert!(result.discussions[0].discussion_id > 0); +} + +#[test] +fn test_overlap_dual_roles() { + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + // User is both author of one MR and reviewer of another at same path + insert_mr(&conn, 1, 1, 100, "dual_user", "opened"); + insert_mr(&conn, 2, 1, 200, "other_author", "opened"); + insert_discussion(&conn, 1, 1, Some(1), None, true, false); + insert_discussion(&conn, 2, 1, Some(2), None, true, false); + insert_diffnote( + &conn, + 1, + 1, + 1, + "someone", + "src/auth/login.rs", + "review of dual_user's MR", + ); + insert_diffnote( + &conn, + 2, + 2, + 1, + "dual_user", + "src/auth/login.rs", + "dual_user reviewing other MR", + ); + + let result = query_overlap(&conn, "src/auth/", None, 0, 20).unwrap(); + let dual = result + .users + .iter() + .find(|u| u.username == "dual_user") + .unwrap(); + assert!(dual.author_touch_count > 0); + assert!(dual.review_touch_count > 0); + assert_eq!(format_overlap_role(dual), "A+R"); + // MR refs should be project-qualified + assert!(dual.mr_refs.iter().any(|r| r.contains("team/backend!"))); +} + +#[test] +fn test_overlap_multi_project_mr_refs() { + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + insert_project(&conn, 2, "team/frontend"); + insert_mr(&conn, 1, 1, 100, "author_a", "opened"); + insert_mr(&conn, 2, 2, 100, "author_a", "opened"); // Same iid, different project + insert_discussion(&conn, 1, 1, Some(1), None, true, false); + insert_discussion(&conn, 2, 2, Some(2), None, true, false); + insert_diffnote(&conn, 1, 1, 1, "reviewer_x", "src/auth/login.rs", "review"); + insert_diffnote(&conn, 2, 2, 2, "reviewer_x", "src/auth/login.rs", "review"); + + let result = query_overlap(&conn, "src/auth/", None, 0, 20).unwrap(); + let reviewer = result + .users + .iter() + .find(|u| u.username == "reviewer_x") + .unwrap(); + // Should have two distinct refs despite same iid + assert!(reviewer.mr_refs.contains(&"team/backend!100".to_string())); + assert!(reviewer.mr_refs.contains(&"team/frontend!100".to_string())); +} + +#[test] +fn test_normalize_review_prefix() { + assert_eq!(normalize_review_prefix("suggestion"), "suggestion"); + assert_eq!(normalize_review_prefix("Suggestion:"), "suggestion"); + assert_eq!( + normalize_review_prefix("suggestion (non-blocking):"), + "suggestion" + ); + assert_eq!(normalize_review_prefix("Nitpick:"), "nit"); + assert_eq!(normalize_review_prefix("nit (non-blocking):"), "nit"); + assert_eq!(normalize_review_prefix("question"), "question"); + assert_eq!(normalize_review_prefix("TODO:"), "todo"); +} + +#[test] +fn test_normalize_repo_path() { + // Strips leading ./ + assert_eq!(normalize_repo_path("./src/foo/"), "src/foo/"); + // Strips leading / + assert_eq!(normalize_repo_path("/src/foo/"), "src/foo/"); + // Strips leading ./ recursively + assert_eq!(normalize_repo_path("././src/foo"), "src/foo"); + // Converts Windows backslashes when no forward slashes + assert_eq!(normalize_repo_path("src\\foo\\bar.rs"), "src/foo/bar.rs"); + // Does NOT convert backslashes when forward slashes present + assert_eq!(normalize_repo_path("src/foo\\bar"), "src/foo\\bar"); + // Collapses repeated // + assert_eq!(normalize_repo_path("src//foo//bar/"), "src/foo/bar/"); + // Trims whitespace + assert_eq!(normalize_repo_path(" src/foo/ "), "src/foo/"); + // Identity for clean paths + assert_eq!(normalize_repo_path("src/foo/bar.rs"), "src/foo/bar.rs"); +} + +#[test] +fn test_lookup_project_path() { + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + assert_eq!(lookup_project_path(&conn, 1).unwrap(), "team/backend"); +} + +#[test] +fn test_build_path_query_dotless_subdir_file_uses_db_probe() { + // Dotless file in subdirectory (src/Dockerfile) would normally be + // treated as a directory. The DB probe detects it's actually a file. + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + insert_mr(&conn, 1, 1, 100, "author_a", "opened"); + insert_discussion(&conn, 1, 1, Some(1), None, true, false); + insert_diffnote(&conn, 1, 1, 1, "reviewer_b", "src/Dockerfile", "note"); + + let pq = build_path_query(&conn, "src/Dockerfile", None).unwrap(); + assert_eq!(pq.value, "src/Dockerfile"); + assert!(!pq.is_prefix); + + // Same path without DB data -> falls through to prefix + let conn2 = setup_test_db(); + let pq2 = build_path_query(&conn2, "src/Dockerfile", None).unwrap(); + assert_eq!(pq2.value, "src/Dockerfile/%"); + assert!(pq2.is_prefix); +} + +#[test] +fn test_build_path_query_probe_is_project_scoped() { + // Path exists as a dotless file in project 1; project 2 should not + // treat it as an exact file unless it exists there too. + let conn = setup_test_db(); + insert_project(&conn, 1, "team/a"); + insert_project(&conn, 2, "team/b"); + insert_mr(&conn, 1, 1, 10, "author_a", "opened"); + insert_discussion(&conn, 1, 1, Some(1), None, true, false); + insert_diffnote(&conn, 1, 1, 1, "rev", "infra/Makefile", "note"); + + // Unscoped: finds exact match in project 1 -> exact + let pq_unscoped = build_path_query(&conn, "infra/Makefile", None).unwrap(); + assert!(!pq_unscoped.is_prefix); + + // Scoped to project 2: no data -> falls back to prefix + let pq_scoped = build_path_query(&conn, "infra/Makefile", Some(2)).unwrap(); + assert!(pq_scoped.is_prefix); + + // Scoped to project 1: finds data -> exact + let pq_scoped1 = build_path_query(&conn, "infra/Makefile", Some(1)).unwrap(); + assert!(!pq_scoped1.is_prefix); +} + +#[test] +fn test_expert_excludes_self_review_notes() { + // MR author commenting on their own diff should not be counted as reviewer + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + insert_mr(&conn, 1, 1, 100, "author_a", "opened"); + insert_discussion(&conn, 1, 1, Some(1), None, true, false); + // author_a comments on their own MR diff (clarification) + insert_diffnote( + &conn, + 1, + 1, + 1, + "author_a", + "src/auth/login.rs", + "clarification", + ); + // reviewer_b also reviews + insert_diffnote( + &conn, + 2, + 1, + 1, + "reviewer_b", + "src/auth/login.rs", + "looks good", + ); + + let result = query_expert( + &conn, + "src/auth/", + None, + 0, + test_as_of_ms(), + 20, + &default_scoring(), + false, + false, + false, + ) + .unwrap(); + // author_a should appear as author only, not as reviewer + let author = result + .experts + .iter() + .find(|e| e.username == "author_a") + .unwrap(); + assert_eq!(author.review_mr_count, 0); + assert!(author.author_mr_count > 0); + + // reviewer_b should be a reviewer + let reviewer = result + .experts + .iter() + .find(|e| e.username == "reviewer_b") + .unwrap(); + assert!(reviewer.review_mr_count > 0); +} + +#[test] +fn test_overlap_excludes_self_review_notes() { + // MR author commenting on their own diff should not inflate reviewer counts + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + insert_mr(&conn, 1, 1, 100, "author_a", "opened"); + insert_discussion(&conn, 1, 1, Some(1), None, true, false); + // author_a comments on their own MR diff (clarification) + insert_diffnote( + &conn, + 1, + 1, + 1, + "author_a", + "src/auth/login.rs", + "clarification", + ); + + let result = query_overlap(&conn, "src/auth/", None, 0, 20).unwrap(); + let u = result.users.iter().find(|u| u.username == "author_a"); + // Should NOT be credited as reviewer touch + assert!(u.map_or(0, |x| x.review_touch_count) == 0); +} + +#[test] +fn test_active_participants_sorted() { + // Participants should be sorted alphabetically for deterministic output + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + insert_mr(&conn, 1, 1, 100, "author_a", "opened"); + insert_discussion(&conn, 1, 1, Some(1), None, true, false); + insert_diffnote(&conn, 1, 1, 1, "zebra_user", "src/foo.rs", "note 1"); + insert_diffnote(&conn, 2, 1, 1, "alpha_user", "src/foo.rs", "note 2"); + + let result = query_active(&conn, None, 0, 20).unwrap(); + assert_eq!( + result.discussions[0].participants, + vec!["alpha_user", "zebra_user"] + ); +} + +#[test] +fn test_expert_truncation() { + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + // Create 3 experts + for i in 1..=3 { + insert_mr(&conn, i, 1, 100 + i, &format!("author_{i}"), "opened"); + insert_discussion(&conn, i, 1, Some(i), None, true, false); + insert_diffnote( + &conn, + i, + i, + 1, + &format!("reviewer_{i}"), + "src/auth/login.rs", + "note", + ); + } + + // limit = 2, should return truncated = true + let result = query_expert( + &conn, + "src/auth/", + None, + 0, + test_as_of_ms(), + 2, + &default_scoring(), + false, + false, + false, + ) + .unwrap(); + assert!(result.truncated); + assert_eq!(result.experts.len(), 2); + + // limit = 10, should return truncated = false + let result = query_expert( + &conn, + "src/auth/", + None, + 0, + test_as_of_ms(), + 10, + &default_scoring(), + false, + false, + false, + ) + .unwrap(); + assert!(!result.truncated); +} + +#[test] +fn test_expert_file_changes_only() { + // MR author should appear even when there are zero DiffNotes + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + insert_mr(&conn, 1, 1, 100, "file_author", "merged"); + insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified"); + + let result = query_expert( + &conn, + "src/auth/login.rs", + None, + 0, + test_as_of_ms(), + 20, + &default_scoring(), + false, + false, + false, + ) + .unwrap(); + assert_eq!(result.experts.len(), 1); + assert_eq!(result.experts[0].username, "file_author"); + assert!(result.experts[0].author_mr_count > 0); + assert_eq!(result.experts[0].review_mr_count, 0); +} + +#[test] +fn test_expert_mr_reviewer_via_file_changes() { + // A reviewer assigned via mr_reviewers should appear when that MR + // touched the queried file (via mr_file_changes) + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + insert_mr(&conn, 1, 1, 100, "author_a", "merged"); + insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified"); + insert_reviewer(&conn, 1, "assigned_reviewer"); + + let result = query_expert( + &conn, + "src/auth/login.rs", + None, + 0, + test_as_of_ms(), + 20, + &default_scoring(), + false, + false, + false, + ) + .unwrap(); + let reviewer = result + .experts + .iter() + .find(|e| e.username == "assigned_reviewer"); + assert!(reviewer.is_some(), "assigned_reviewer should appear"); + assert!(reviewer.unwrap().review_mr_count > 0); +} + +#[test] +fn test_expert_deduplicates_across_signals() { + // User who is BOTH a DiffNote reviewer AND an mr_reviewers entry for + // the same MR should be counted only once per MR + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + insert_mr(&conn, 1, 1, 100, "author_a", "merged"); + insert_discussion(&conn, 1, 1, Some(1), None, true, false); + insert_diffnote( + &conn, + 1, + 1, + 1, + "reviewer_b", + "src/auth/login.rs", + "looks good", + ); + // Same user also listed as assigned reviewer, with file change data + insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified"); + insert_reviewer(&conn, 1, "reviewer_b"); + + let result = query_expert( + &conn, + "src/auth/login.rs", + None, + 0, + test_as_of_ms(), + 20, + &default_scoring(), + false, + false, + false, + ) + .unwrap(); + let reviewer = result + .experts + .iter() + .find(|e| e.username == "reviewer_b") + .unwrap(); + // Should be 1 MR, not 2 (dedup across DiffNote + mr_reviewers) + assert_eq!(reviewer.review_mr_count, 1); +} + +#[test] +fn test_expert_combined_diffnote_and_file_changes() { + // Author with DiffNotes on path A and file_changes on path B should + // get credit for both when queried with a directory prefix + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + // MR 1: has DiffNotes on login.rs + insert_mr(&conn, 1, 1, 100, "author_a", "merged"); + insert_discussion(&conn, 1, 1, Some(1), None, true, false); + insert_diffnote(&conn, 1, 1, 1, "reviewer_b", "src/auth/login.rs", "note"); + // MR 2: has file_changes on session.rs (no DiffNotes) + insert_mr(&conn, 2, 1, 200, "author_a", "merged"); + insert_file_change(&conn, 2, 1, "src/auth/session.rs", "added"); + + let result = query_expert( + &conn, + "src/auth/", + None, + 0, + test_as_of_ms(), + 20, + &default_scoring(), + false, + false, + false, + ) + .unwrap(); + let author = result + .experts + .iter() + .find(|e| e.username == "author_a") + .unwrap(); + // Should count 2 authored MRs (one from DiffNote path, one from file changes) + assert_eq!(author.author_mr_count, 2); +} + +#[test] +fn test_expert_file_changes_prefix_match() { + // Directory prefix queries should pick up mr_file_changes under the directory + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + insert_mr(&conn, 1, 1, 100, "author_a", "merged"); + insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified"); + insert_file_change(&conn, 1, 1, "src/auth/session.rs", "added"); + + let result = query_expert( + &conn, + "src/auth/", + None, + 0, + test_as_of_ms(), + 20, + &default_scoring(), + false, + false, + false, + ) + .unwrap(); + assert_eq!(result.path_match, "prefix"); + assert_eq!(result.experts.len(), 1); + assert_eq!(result.experts[0].username, "author_a"); +} + +#[test] +fn test_overlap_file_changes_only() { + // Overlap mode should also find users via mr_file_changes + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + insert_mr(&conn, 1, 1, 100, "author_a", "merged"); + insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified"); + insert_reviewer(&conn, 1, "reviewer_x"); + + let result = query_overlap(&conn, "src/auth/", None, 0, 20).unwrap(); + assert!( + result.users.iter().any(|u| u.username == "author_a"), + "author_a should appear via file_changes" + ); + assert!( + result.users.iter().any(|u| u.username == "reviewer_x"), + "reviewer_x should appear via mr_reviewers + file_changes" + ); +} + +#[test] +fn test_build_path_query_resolves_via_file_changes() { + // DB probe should detect exact file match from mr_file_changes even + // when no DiffNotes exist for the path + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + insert_mr(&conn, 1, 1, 100, "author_a", "merged"); + insert_file_change(&conn, 1, 1, "src/Dockerfile", "modified"); + + let pq = build_path_query(&conn, "src/Dockerfile", None).unwrap(); + assert_eq!(pq.value, "src/Dockerfile"); + assert!(!pq.is_prefix); +} + +#[test] +fn test_expert_excludes_self_assigned_reviewer() { + // MR author listed in mr_reviewers for their own MR should NOT be + // counted as a reviewer (same principle as DiffNote self-review exclusion) + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + insert_mr(&conn, 1, 1, 100, "author_a", "merged"); + insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified"); + // author_a is self-assigned as reviewer + insert_reviewer(&conn, 1, "author_a"); + // real_reviewer is also assigned + insert_reviewer(&conn, 1, "real_reviewer"); + + let result = query_expert( + &conn, + "src/auth/login.rs", + None, + 0, + test_as_of_ms(), + 20, + &default_scoring(), + false, + false, + false, + ) + .unwrap(); + + // author_a should appear as author only, not reviewer + let author = result + .experts + .iter() + .find(|e| e.username == "author_a") + .unwrap(); + assert_eq!(author.review_mr_count, 0); + assert!(author.author_mr_count > 0); + + // real_reviewer should appear as reviewer + let reviewer = result + .experts + .iter() + .find(|e| e.username == "real_reviewer") + .unwrap(); + assert!(reviewer.review_mr_count > 0); +} + +#[test] +fn test_overlap_excludes_self_assigned_reviewer() { + // Same self-review exclusion for overlap mode via file changes + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + insert_mr(&conn, 1, 1, 100, "author_a", "merged"); + insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified"); + insert_reviewer(&conn, 1, "author_a"); // self-assigned + + let result = query_overlap(&conn, "src/auth/", None, 0, 20).unwrap(); + let user = result.users.iter().find(|u| u.username == "author_a"); + // Should appear (as author) but NOT have reviewer touch count + assert!(user.is_some()); + assert_eq!(user.unwrap().review_touch_count, 0); +} + +// ─── Suffix / Fuzzy Path Resolution Tests ─────────────────────────────── + +#[test] +fn test_build_path_query_suffix_resolves_bare_filename() { + // User types just "login.rs" but the DB has "src/auth/login.rs" + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + insert_mr(&conn, 1, 1, 100, "author_a", "merged"); + insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified"); + + let pq = build_path_query(&conn, "login.rs", None).unwrap(); + assert_eq!(pq.value, "src/auth/login.rs"); + assert!(!pq.is_prefix); +} + +#[test] +fn test_build_path_query_suffix_resolves_partial_path() { + // User types "auth/login.rs" but full path is "src/auth/login.rs" + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + insert_mr(&conn, 1, 1, 100, "author_a", "merged"); + insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified"); + + let pq = build_path_query(&conn, "auth/login.rs", None).unwrap(); + assert_eq!(pq.value, "src/auth/login.rs"); + assert!(!pq.is_prefix); +} + +#[test] +fn test_build_path_query_suffix_ambiguous_returns_error() { + // Two different files share the same filename -> Ambiguous error + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + insert_mr(&conn, 1, 1, 100, "author_a", "merged"); + insert_file_change(&conn, 1, 1, "src/auth/utils.rs", "modified"); + insert_file_change(&conn, 1, 1, "src/db/utils.rs", "modified"); + + let err = build_path_query(&conn, "utils.rs", None).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("src/auth/utils.rs"), + "should list candidates: {msg}" + ); + assert!( + msg.contains("src/db/utils.rs"), + "should list candidates: {msg}" + ); +} + +#[test] +fn test_build_path_query_suffix_scoped_to_project() { + // Two projects have the same filename; scoping to one should resolve + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + insert_project(&conn, 2, "team/frontend"); + insert_mr(&conn, 1, 1, 100, "author_a", "merged"); + insert_mr(&conn, 2, 2, 200, "author_b", "merged"); + insert_file_change(&conn, 1, 1, "src/utils.rs", "modified"); + insert_file_change(&conn, 2, 2, "lib/utils.rs", "modified"); + + // Unscoped -> ambiguous + assert!(build_path_query(&conn, "utils.rs", None).is_err()); + + // Scoped to project 1 -> resolves + let pq = build_path_query(&conn, "utils.rs", Some(1)).unwrap(); + assert_eq!(pq.value, "src/utils.rs"); + assert!(!pq.is_prefix); +} + +#[test] +fn test_build_path_query_suffix_deduplicates_across_sources() { + // Same path in both notes AND mr_file_changes -> single unique match, not ambiguous + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + insert_mr(&conn, 1, 1, 100, "author_a", "merged"); + insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified"); + insert_discussion(&conn, 1, 1, Some(1), None, true, false); + insert_diffnote( + &conn, + 1, + 1, + 1, + "reviewer_a", + "src/auth/login.rs", + "review note", + ); + + let pq = build_path_query(&conn, "login.rs", None).unwrap(); + assert_eq!(pq.value, "src/auth/login.rs"); + assert!(!pq.is_prefix); +} + +#[test] +fn test_build_path_query_exact_match_still_preferred() { + // If the exact path exists in the DB, suffix should NOT be attempted + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + insert_mr(&conn, 1, 1, 100, "author_a", "merged"); + insert_file_change(&conn, 1, 1, "README.md", "modified"); + insert_file_change(&conn, 1, 1, "docs/README.md", "modified"); + + // "README.md" exists as exact match -> use it directly, no ambiguity + let pq = build_path_query(&conn, "README.md", None).unwrap(); + assert_eq!(pq.value, "README.md"); + assert!(!pq.is_prefix); +} + +#[test] +fn test_expert_scoring_weights_are_configurable() { + // With reviewer-heavy weights, reviewer should rank above author. + // With author-heavy weights (default), author should rank above reviewer. + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + insert_mr(&conn, 1, 1, 100, "the_author", "merged"); + insert_file_change(&conn, 1, 1, "src/app.rs", "modified"); + insert_discussion(&conn, 1, 1, Some(1), None, true, false); + insert_diffnote( + &conn, + 1, + 1, + 1, + "the_reviewer", + "src/app.rs", + "lgtm -- a substantive review comment", + ); + insert_reviewer(&conn, 1, "the_reviewer"); + + // Default weights: author=25, reviewer=10 → author wins + let result = query_expert( + &conn, + "src/app.rs", + None, + 0, + test_as_of_ms(), + 20, + &default_scoring(), + false, + false, + false, + ) + .unwrap(); + assert_eq!(result.experts[0].username, "the_author"); + + // Custom weights: flip so reviewer dominates + let flipped = ScoringConfig { + author_weight: 5, + reviewer_weight: 30, + note_bonus: 1, + ..Default::default() + }; + let result = query_expert( + &conn, + "src/app.rs", + None, + 0, + test_as_of_ms(), + 20, + &flipped, + false, + false, + false, + ) + .unwrap(); + assert_eq!(result.experts[0].username, "the_reviewer"); +} + +#[test] +fn test_expert_mr_refs() { + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + insert_mr(&conn, 1, 1, 891, "author_a", "merged"); + insert_mr(&conn, 2, 1, 847, "author_a", "merged"); + insert_discussion(&conn, 1, 1, Some(1), None, true, false); + insert_discussion(&conn, 2, 1, Some(2), None, true, false); + insert_diffnote(&conn, 1, 1, 1, "reviewer_b", "src/auth/login.rs", "note1"); + insert_diffnote(&conn, 2, 2, 1, "reviewer_b", "src/auth/login.rs", "note2"); + + let result = query_expert( + &conn, + "src/auth/", + None, + 0, + test_as_of_ms(), + 20, + &default_scoring(), + false, + false, + false, + ) + .unwrap(); + + // reviewer_b should have MR refs + let reviewer = result + .experts + .iter() + .find(|e| e.username == "reviewer_b") + .unwrap(); + assert!(reviewer.mr_refs.contains(&"team/backend!847".to_string())); + assert!(reviewer.mr_refs.contains(&"team/backend!891".to_string())); + assert_eq!(reviewer.mr_refs_total, 2); + assert!(!reviewer.mr_refs_truncated); + + // author_a should also have MR refs + let author = result + .experts + .iter() + .find(|e| e.username == "author_a") + .unwrap(); + assert!(author.mr_refs.contains(&"team/backend!847".to_string())); + assert!(author.mr_refs.contains(&"team/backend!891".to_string())); + assert_eq!(author.mr_refs_total, 2); +} + +#[test] +fn test_expert_mr_refs_multi_project() { + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + insert_project(&conn, 2, "team/frontend"); + insert_mr(&conn, 1, 1, 100, "author_a", "opened"); + insert_mr(&conn, 2, 2, 100, "author_a", "opened"); // Same iid, different project + insert_discussion(&conn, 1, 1, Some(1), None, true, false); + insert_discussion(&conn, 2, 2, Some(2), None, true, false); + insert_diffnote(&conn, 1, 1, 1, "reviewer_x", "src/auth/login.rs", "review"); + insert_diffnote(&conn, 2, 2, 2, "reviewer_x", "src/auth/login.rs", "review"); + + let result = query_expert( + &conn, + "src/auth/", + None, + 0, + test_as_of_ms(), + 20, + &default_scoring(), + false, + false, + false, + ) + .unwrap(); + let reviewer = result + .experts + .iter() + .find(|e| e.username == "reviewer_x") + .unwrap(); + // Should have two distinct refs despite same iid + assert!(reviewer.mr_refs.contains(&"team/backend!100".to_string())); + assert!(reviewer.mr_refs.contains(&"team/frontend!100".to_string())); + assert_eq!(reviewer.mr_refs_total, 2); +} + +#[test] +fn test_half_life_decay_math() { + // elapsed=0 -> 1.0 (no decay) + assert!((half_life_decay(0, 180) - 1.0).abs() < f64::EPSILON); + + // elapsed=half_life -> 0.5 + let half_life_ms = 180 * 86_400_000_i64; + assert!((half_life_decay(half_life_ms, 180) - 0.5).abs() < 1e-10); + + // elapsed=2*half_life -> 0.25 + assert!((half_life_decay(2 * half_life_ms, 180) - 0.25).abs() < 1e-10); + + // half_life_days=0 -> 0.0 (guard against div-by-zero) + assert!((half_life_decay(1_000_000, 0)).abs() < f64::EPSILON); + + // negative elapsed clamped to 0 -> 1.0 + assert!((half_life_decay(-5_000_000, 180) - 1.0).abs() < f64::EPSILON); +} + +#[test] +fn test_path_normalization_handles_dot_and_double_slash() { + assert_eq!(normalize_repo_path("./src//foo.rs"), "src/foo.rs"); + assert_eq!(normalize_repo_path(" src/bar.rs "), "src/bar.rs"); + assert_eq!(normalize_repo_path("src/foo.rs"), "src/foo.rs"); + assert_eq!(normalize_repo_path(""), ""); +} + +#[test] +fn test_path_normalization_preserves_prefix_semantics() { + // Trailing slash preserved for prefix intent + assert_eq!(normalize_repo_path("./src/dir/"), "src/dir/"); + // No trailing slash = file, not prefix + assert_eq!(normalize_repo_path("src/dir"), "src/dir"); +} + +#[test] +fn test_expert_sql_v2_prepares_exact() { + let conn = setup_test_db(); + let sql = build_expert_sql_v2(false); + // Verify the SQL is syntactically valid and INDEXED BY references exist + conn.prepare_cached(&sql) + .expect("v2 exact SQL should parse"); +} + +#[test] +fn test_expert_sql_v2_prepares_prefix() { + let conn = setup_test_db(); + let sql = build_expert_sql_v2(true); + conn.prepare_cached(&sql) + .expect("v2 prefix SQL should parse"); +} + +#[test] +fn test_expert_sql_v2_returns_signals() { + let conn = setup_test_db(); + let now = now_ms(); + insert_project(&conn, 1, "team/backend"); + insert_mr_at(&conn, 1, 1, 100, "author_a", "merged", now, Some(now), None); + insert_discussion(&conn, 1, 1, Some(1), None, true, false); + insert_diffnote( + &conn, + 1, + 1, + 1, + "reviewer_b", + "src/auth/login.rs", + "substantive review comment here", + ); + insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified"); + insert_reviewer(&conn, 1, "reviewer_b"); + + let sql = build_expert_sql_v2(false); + let mut stmt = conn.prepare_cached(&sql).unwrap(); + let rows: Vec<(String, String, i64, i64, i64, f64)> = stmt + .query_map( + rusqlite::params![ + "src/auth/login.rs", // ?1 path + 0_i64, // ?2 since_ms + Option::::None, // ?3 project_id + now + 1000, // ?4 as_of_ms (slightly in future to include test data) + 0.5_f64, // ?5 closed_mr_multiplier + 20_i64, // ?6 reviewer_min_note_chars + ], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, i64>(4)?, + row.get::<_, f64>(5)?, + )) + }, + ) + .unwrap() + .collect::, _>>() + .unwrap(); + + // Should have signals for both author_a and reviewer_b + let usernames: Vec<&str> = rows.iter().map(|r| r.0.as_str()).collect(); + assert!(usernames.contains(&"author_a"), "should contain author_a"); + assert!( + usernames.contains(&"reviewer_b"), + "should contain reviewer_b" + ); + + // Verify signal types are from the expected set + let valid_signals = [ + "diffnote_author", + "file_author", + "file_reviewer_participated", + "file_reviewer_assigned", + "note_group", + ]; + for row in &rows { + assert!( + valid_signals.contains(&row.1.as_str()), + "unexpected signal type: {}", + row.1 + ); + } + + // state_mult should be 1.0 for merged MRs + for row in &rows { + assert!( + (row.5 - 1.0).abs() < f64::EPSILON, + "merged MR should have state_mult=1.0, got {}", + row.5 + ); + } +} + +#[test] +fn test_expert_sql_v2_reviewer_split() { + let conn = setup_test_db(); + let now = now_ms(); + insert_project(&conn, 1, "team/backend"); + insert_mr_at(&conn, 1, 1, 100, "author_a", "merged", now, Some(now), None); + insert_discussion(&conn, 1, 1, Some(1), None, true, false); + // reviewer_b leaves a substantive note (>= 20 chars) + insert_diffnote( + &conn, + 1, + 1, + 1, + "reviewer_b", + "src/app.rs", + "This looks correct, good refactoring work here", + ); + insert_file_change(&conn, 1, 1, "src/app.rs", "modified"); + insert_reviewer(&conn, 1, "reviewer_b"); + // reviewer_c is assigned but leaves no notes + insert_reviewer(&conn, 1, "reviewer_c"); + + let sql = build_expert_sql_v2(false); + let mut stmt = conn.prepare_cached(&sql).unwrap(); + let rows: Vec<(String, String)> = stmt + .query_map( + rusqlite::params![ + "src/app.rs", + 0_i64, + Option::::None, + now + 1000, + 0.5_f64, + 20_i64, + ], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .unwrap() + .collect::, _>>() + .unwrap(); + + // reviewer_b should be "file_reviewer_participated" + let b_signals: Vec<&str> = rows + .iter() + .filter(|r| r.0 == "reviewer_b") + .map(|r| r.1.as_str()) + .collect(); + assert!( + b_signals.contains(&"file_reviewer_participated"), + "reviewer_b should be participated, got: {:?}", + b_signals + ); + assert!( + !b_signals.contains(&"file_reviewer_assigned"), + "reviewer_b should NOT be assigned-only" + ); + + // reviewer_c should be "file_reviewer_assigned" + let c_signals: Vec<&str> = rows + .iter() + .filter(|r| r.0 == "reviewer_c") + .map(|r| r.1.as_str()) + .collect(); + assert!( + c_signals.contains(&"file_reviewer_assigned"), + "reviewer_c should be assigned-only, got: {:?}", + c_signals + ); + assert!( + !c_signals.contains(&"file_reviewer_participated"), + "reviewer_c should NOT be participated" + ); +} + +#[test] +fn test_expert_sql_v2_closed_mr_multiplier() { + let conn = setup_test_db(); + let now = now_ms(); + insert_project(&conn, 1, "team/backend"); + insert_mr_at(&conn, 1, 1, 100, "author_a", "closed", now, None, Some(now)); + insert_file_change(&conn, 1, 1, "src/app.rs", "modified"); + + let sql = build_expert_sql_v2(false); + let mut stmt = conn.prepare_cached(&sql).unwrap(); + let rows: Vec<(String, String, f64)> = stmt + .query_map( + rusqlite::params![ + "src/app.rs", + 0_i64, + Option::::None, + now + 1000, + 0.5_f64, + 20_i64, + ], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, f64>(5)?, + )) + }, + ) + .unwrap() + .collect::, _>>() + .unwrap(); + + // All signals from closed MR should have state_mult=0.5 + for row in &rows { + assert!( + (row.2 - 0.5).abs() < f64::EPSILON, + "closed MR should have state_mult=0.5, got {} for signal {}", + row.2, + row.1 + ); + } +} + +#[test] +fn test_expert_v2_decay_scoring() { + let conn = setup_test_db(); + let now = now_ms(); + let day_ms = 86_400_000_i64; + insert_project(&conn, 1, "team/backend"); + + // Recent author: 10 days ago + insert_mr_at( + &conn, + 1, + 1, + 100, + "recent_author", + "merged", + now - 10 * day_ms, + Some(now - 10 * day_ms), + None, + ); + insert_file_change(&conn, 1, 1, "src/app.rs", "modified"); + + // Old author: 360 days ago + insert_mr_at( + &conn, + 2, + 1, + 101, + "old_author", + "merged", + now - 360 * day_ms, + Some(now - 360 * day_ms), + None, + ); + insert_file_change(&conn, 2, 1, "src/app.rs", "modified"); + + let scoring = default_scoring(); + let result = query_expert( + &conn, + "src/app.rs", + None, + 0, + now + 1000, + 20, + &scoring, + false, + false, + false, + ) + .unwrap(); + + assert_eq!(result.experts.len(), 2); + // Recent author should rank first + assert_eq!(result.experts[0].username, "recent_author"); + assert_eq!(result.experts[1].username, "old_author"); + // Recent author score should be much higher than old author + assert!(result.experts[0].score > result.experts[1].score); +} + +#[test] +fn test_expert_v2_reviewer_split_scoring() { + let conn = setup_test_db(); + let now = now_ms(); + let day_ms = 86_400_000_i64; + insert_project(&conn, 1, "team/backend"); + + // MR from 30 days ago + insert_mr_at( + &conn, + 1, + 1, + 100, + "the_author", + "merged", + now - 30 * day_ms, + Some(now - 30 * day_ms), + None, + ); + insert_file_change(&conn, 1, 1, "src/app.rs", "modified"); + insert_discussion(&conn, 1, 1, Some(1), None, true, false); + + // Reviewer A: participated (left substantive DiffNotes) + insert_diffnote( + &conn, + 1, + 1, + 1, + "participated_reviewer", + "src/app.rs", + "Substantive review comment here about the code", + ); + insert_reviewer(&conn, 1, "participated_reviewer"); + + // Reviewer B: assigned only (no DiffNotes) + insert_reviewer(&conn, 1, "assigned_reviewer"); + + let scoring = default_scoring(); + let result = query_expert( + &conn, + "src/app.rs", + None, + 0, + now + 1000, + 20, + &scoring, + false, + false, + false, + ) + .unwrap(); + + let participated = result + .experts + .iter() + .find(|e| e.username == "participated_reviewer"); + let assigned = result + .experts + .iter() + .find(|e| e.username == "assigned_reviewer"); + assert!( + participated.is_some(), + "participated reviewer should appear" + ); + assert!(assigned.is_some(), "assigned reviewer should appear"); + + // Participated reviewer should score higher (weight=10 vs weight=3) + assert!( + participated.unwrap().score > assigned.unwrap().score, + "participated ({}) should score higher than assigned ({})", + participated.unwrap().score, + assigned.unwrap().score + ); +} + +#[test] +fn test_expert_v2_excluded_usernames() { + let conn = setup_test_db(); + let now = now_ms(); + insert_project(&conn, 1, "team/backend"); + insert_mr_at( + &conn, + 1, + 1, + 100, + "real_user", + "merged", + now, + Some(now), + None, + ); + insert_mr_at( + &conn, + 2, + 1, + 101, + "renovate-bot", + "merged", + now, + Some(now), + None, + ); + insert_file_change(&conn, 1, 1, "src/app.rs", "modified"); + insert_file_change(&conn, 2, 1, "src/app.rs", "modified"); + + let mut scoring = default_scoring(); + scoring.excluded_usernames = vec!["renovate-bot".to_string()]; + let result = query_expert( + &conn, + "src/app.rs", + None, + 0, + now + 1000, + 20, + &scoring, + false, + false, + false, + ) + .unwrap(); + + let usernames: Vec<&str> = result.experts.iter().map(|e| e.username.as_str()).collect(); + assert!(usernames.contains(&"real_user")); + assert!( + !usernames.contains(&"renovate-bot"), + "bot should be excluded" + ); +} + +#[test] +fn test_expert_v2_deterministic_ordering() { + let conn = setup_test_db(); + let now = now_ms(); + let day_ms = 86_400_000_i64; + insert_project(&conn, 1, "team/backend"); + + // Create 5 MRs with varied timestamps + for i in 0_i64..5 { + insert_mr_at( + &conn, + i + 1, + 1, + 100 + i, + "test_user", + "merged", + now - (i + 1) * 30 * day_ms, + Some(now - (i + 1) * 30 * day_ms), + None, + ); + insert_file_change(&conn, i + 1, 1, "src/app.rs", "modified"); + } + + let scoring = default_scoring(); + // Run 10 times and verify identical scores + let mut scores: Vec = Vec::new(); + for _ in 0..10 { + let result = query_expert( + &conn, + "src/app.rs", + None, + 0, + now + 1000, + 20, + &scoring, + false, + false, + false, + ) + .unwrap(); + assert_eq!(result.experts.len(), 1); + scores.push(result.experts[0].score); + } + assert!( + scores.windows(2).all(|w| w[0] == w[1]), + "scores should be identical across runs: {:?}", + scores + ); +} + +#[test] +fn test_expert_detail_mode() { + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + insert_mr(&conn, 1, 1, 891, "author_a", "merged"); + insert_mr(&conn, 2, 1, 902, "author_a", "merged"); + insert_discussion(&conn, 1, 1, Some(1), None, true, false); + insert_discussion(&conn, 2, 1, Some(2), None, true, false); + insert_diffnote(&conn, 1, 1, 1, "reviewer_b", "src/auth/login.rs", "note1"); + insert_diffnote(&conn, 2, 1, 1, "reviewer_b", "src/auth/login.rs", "note2"); + insert_diffnote(&conn, 3, 2, 1, "reviewer_b", "src/auth/session.rs", "note3"); + + // Without detail: details should be None + let result = query_expert( + &conn, + "src/auth/", + None, + 0, + test_as_of_ms(), + 20, + &default_scoring(), + false, + false, + false, + ) + .unwrap(); + for expert in &result.experts { + assert!(expert.details.is_none()); + } + + // With detail: details should be populated + let result = query_expert( + &conn, + "src/auth/", + None, + 0, + test_as_of_ms(), + 20, + &default_scoring(), + true, + false, + false, + ) + .unwrap(); + let reviewer = result + .experts + .iter() + .find(|e| e.username == "reviewer_b") + .unwrap(); + let details = reviewer.details.as_ref().unwrap(); + assert!(!details.is_empty()); + + // All detail entries should have role "R" for reviewer + for d in details { + assert!( + d.role == "R" || d.role == "A+R", + "role should be R or A+R, got {}", + d.role + ); + assert!(d.mr_ref.starts_with("team/backend!")); + } + + // author_a should have detail entries with role "A" + let author = result + .experts + .iter() + .find(|e| e.username == "author_a") + .unwrap(); + let author_details = author.details.as_ref().unwrap(); + assert!(!author_details.is_empty()); + for d in author_details { + assert!( + d.role == "A" || d.role == "A+R", + "role should be A or A+R, got {}", + d.role + ); + } +} + +#[test] +fn test_old_path_probe_exact_and_prefix() { + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + insert_mr(&conn, 1, 1, 100, "alice", "merged"); + insert_file_change_with_old_path( + &conn, + 1, + 1, + "src/new/foo.rs", + Some("src/old/foo.rs"), + "renamed", + ); + insert_discussion(&conn, 1, 1, Some(1), None, true, false); + insert_diffnote_at( + &conn, + 1, + 1, + 1, + "alice", + "src/new/foo.rs", + Some("src/old/foo.rs"), + "review comment", + now_ms(), + ); + + // Exact probe by OLD path should resolve + let pq = build_path_query(&conn, "src/old/foo.rs", None).unwrap(); + assert!(!pq.is_prefix, "old exact path should resolve as exact"); + assert_eq!(pq.value, "src/old/foo.rs"); + + // Prefix probe by OLD directory should resolve + let pq = build_path_query(&conn, "src/old/", None).unwrap(); + assert!(pq.is_prefix, "old directory should resolve as prefix"); + + // New path still works + let pq = build_path_query(&conn, "src/new/foo.rs", None).unwrap(); + assert!(!pq.is_prefix, "new exact path should still resolve"); + assert_eq!(pq.value, "src/new/foo.rs"); +} + +#[test] +fn test_suffix_probe_uses_old_path_sources() { + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + insert_mr(&conn, 1, 1, 100, "alice", "merged"); + insert_file_change_with_old_path( + &conn, + 1, + 1, + "src/utils.rs", + Some("legacy/utils.rs"), + "renamed", + ); + + let result = suffix_probe(&conn, "utils.rs", None).unwrap(); + match result { + SuffixResult::Ambiguous(paths) => { + assert!( + paths.contains(&"src/utils.rs".to_string()), + "should contain new path" + ); + assert!( + paths.contains(&"legacy/utils.rs".to_string()), + "should contain old path" + ); + } + SuffixResult::Unique(p) => { + panic!("Expected Ambiguous with both paths, got Unique({p})"); + } + SuffixResult::NoMatch => panic!("Expected Ambiguous, got NoMatch"), + SuffixResult::NotAttempted => panic!("Expected Ambiguous, got NotAttempted"), + } +} + +// ─── Plan Section 8: New Tests ────────────────────────────────────────────── + +#[test] +fn test_expert_scores_decay_with_time() { + // Two authors, one recent (10 days), one old (360 days). + // With default author_half_life_days=180, recent ≈ 24.1, old ≈ 6.3. + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + + let now = now_ms(); + let day_ms: i64 = 86_400_000; + let recent_ts = now - 10 * day_ms; // 10 days ago + let old_ts = now - 360 * day_ms; // 360 days ago + + // Recent author + insert_mr_at( + &conn, + 1, + 1, + 100, + "recent_author", + "merged", + recent_ts, + Some(recent_ts), + None, + ); + insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); + + // Old author + insert_mr_at( + &conn, + 2, + 1, + 200, + "old_author", + "merged", + old_ts, + Some(old_ts), + None, + ); + insert_file_change(&conn, 2, 1, "src/lib.rs", "modified"); + + let result = query_expert( + &conn, + "src/lib.rs", + None, + 0, + now + 1000, + 20, + &default_scoring(), + false, + true, + false, + ) + .unwrap(); + + assert_eq!(result.experts.len(), 2); + assert_eq!(result.experts[0].username, "recent_author"); + assert_eq!(result.experts[1].username, "old_author"); + + // Recent author scores significantly higher + let recent_score = result.experts[0].score_raw.unwrap(); + let old_score = result.experts[1].score_raw.unwrap(); + assert!( + recent_score > old_score * 2.0, + "recent ({recent_score:.1}) should be >2x old ({old_score:.1})" + ); +} + +#[test] +fn test_expert_reviewer_decays_faster_than_author() { + // Same MR, same age (90 days). Author half-life=180d, reviewer half-life=90d. + // Author retains 2^(-90/180)=0.707 of weight, reviewer retains 2^(-90/90)=0.5. + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + + let now = now_ms(); + let day_ms: i64 = 86_400_000; + let age_ts = now - 90 * day_ms; + + insert_mr_at( + &conn, + 1, + 1, + 100, + "the_author", + "merged", + age_ts, + Some(age_ts), + None, + ); + insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); + insert_discussion(&conn, 1, 1, Some(1), None, true, false); + insert_diffnote_at( + &conn, + 1, + 1, + 1, + "the_reviewer", + "src/lib.rs", + None, + "a substantive review comment here", + age_ts, + ); + insert_reviewer(&conn, 1, "the_reviewer"); + + let result = query_expert( + &conn, + "src/lib.rs", + None, + 0, + now + 1000, + 20, + &default_scoring(), + false, + true, + false, + ) + .unwrap(); + + // Author gets file_author (25*0.707) + diffnote_author (25*0.707) ≈ 35.4 + // Reviewer gets file_reviewer_participated (10*0.5) + note_group (1*1.0*0.5) ≈ 5.5 + assert_eq!(result.experts[0].username, "the_author"); + let author_score = result.experts[0].score_raw.unwrap(); + let reviewer_score = result.experts[1].score_raw.unwrap(); + assert!( + author_score > reviewer_score * 3.0, + "author ({author_score:.1}) should dominate reviewer ({reviewer_score:.1})" + ); +} + +#[test] +fn test_reviewer_participated_vs_assigned_only() { + // Two reviewers on the same MR. One left substantive DiffNotes (participated), + // one didn't (assigned-only). Participated gets reviewer_weight, assigned-only + // gets reviewer_assignment_weight (much lower). + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + + let now = now_ms(); + insert_mr(&conn, 1, 1, 100, "the_author", "merged"); + insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); + insert_discussion(&conn, 1, 1, Some(1), None, true, false); + insert_diffnote( + &conn, + 1, + 1, + 1, + "active_reviewer", + "src/lib.rs", + "This needs refactoring because...", + ); + insert_reviewer(&conn, 1, "active_reviewer"); + insert_reviewer(&conn, 1, "passive_reviewer"); + + let result = query_expert( + &conn, + "src/lib.rs", + None, + 0, + now + 1000, + 20, + &default_scoring(), + false, + true, + false, + ) + .unwrap(); + + let active = result + .experts + .iter() + .find(|e| e.username == "active_reviewer") + .unwrap(); + let passive = result + .experts + .iter() + .find(|e| e.username == "passive_reviewer") + .unwrap(); + let active_score = active.score_raw.unwrap(); + let passive_score = passive.score_raw.unwrap(); + + // Default: reviewer_weight=10, reviewer_assignment_weight=3 + // Active: 10 * ~1.0 + note_group ≈ 11 + // Passive: 3 * ~1.0 = 3 + assert!( + active_score > passive_score * 2.0, + "active ({active_score:.1}) should be >2x passive ({passive_score:.1})" + ); +} + +#[test] +fn test_note_diminishing_returns_per_mr() { + // One reviewer with 1 note on MR-A and another with 20 notes on MR-B. + // The 20-note reviewer should score ~log2(21)/log2(2) ≈ 4.4x, NOT 20x. + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + + let now = now_ms(); + // MR 1 with 1 note + insert_mr(&conn, 1, 1, 100, "author_x", "merged"); + insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); + insert_discussion(&conn, 1, 1, Some(1), None, true, false); + insert_diffnote( + &conn, + 1, + 1, + 1, + "one_note_reviewer", + "src/lib.rs", + "a single substantive review note", + ); + insert_reviewer(&conn, 1, "one_note_reviewer"); + + // MR 2 with 20 notes from another reviewer + insert_mr(&conn, 2, 1, 200, "author_y", "merged"); + insert_file_change(&conn, 2, 1, "src/lib.rs", "modified"); + insert_discussion(&conn, 2, 1, Some(2), None, true, false); + for i in 0_i64..20 { + insert_diffnote( + &conn, + 100 + i, + 2, + 1, + "many_note_reviewer", + "src/lib.rs", + &format!("substantive review comment number {i}"), + ); + } + insert_reviewer(&conn, 2, "many_note_reviewer"); + + let scoring = ScoringConfig { + reviewer_weight: 0, + reviewer_assignment_weight: 0, + author_weight: 0, + note_bonus: 10, // High bonus to isolate note contribution + ..Default::default() + }; + let result = query_expert( + &conn, + "src/lib.rs", + None, + 0, + now + 1000, + 20, + &scoring, + false, + true, + false, + ) + .unwrap(); + + let one = result + .experts + .iter() + .find(|e| e.username == "one_note_reviewer") + .unwrap(); + let many = result + .experts + .iter() + .find(|e| e.username == "many_note_reviewer") + .unwrap(); + let one_score = one.score_raw.unwrap(); + let many_score = many.score_raw.unwrap(); + + // log2(1+1)=1.0, log2(1+20)≈4.39. Ratio should be ~4.4x, not 20x. + let ratio = many_score / one_score; + assert!( + ratio < 6.0, + "ratio ({ratio:.1}) should be ~4.4, not 20 (diminishing returns)" + ); + assert!( + ratio > 3.0, + "ratio ({ratio:.1}) should be ~4.4, reflecting log2 scaling" + ); +} + +#[test] +fn test_file_change_timestamp_uses_merged_at() { + // A merged MR should use merged_at for decay, not updated_at. + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + + let now = now_ms(); + let day_ms: i64 = 86_400_000; + let old_merged = now - 300 * day_ms; // merged 300 days ago + let recent_updated = now - day_ms; // updated yesterday + + // MR merged long ago but recently updated (e.g., label change) + insert_mr_at( + &conn, + 1, + 1, + 100, + "the_author", + "merged", + recent_updated, + Some(old_merged), + None, + ); + insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); + + let result = query_expert( + &conn, + "src/lib.rs", + None, + 0, + now + 1000, + 20, + &default_scoring(), + false, + true, + false, + ) + .unwrap(); + + assert_eq!(result.experts.len(), 1); + let score = result.experts[0].score_raw.unwrap(); + // With half_life=180d and elapsed=300d, decay = 2^(-300/180) ≈ 0.315 + // Score ≈ 25 * 0.315 ≈ 7.9 (file_author only, no diffnote_author without notes) + // If it incorrectly used updated_at (1 day), score ≈ 25 * ~1.0 = 25 + assert!( + score < 15.0, + "score ({score:.1}) should reflect old merged_at, not recent updated_at" + ); +} + +#[test] +fn test_open_mr_uses_updated_at() { + // An opened MR should use updated_at for decay, not created_at. + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + + let now = now_ms(); + let day_ms: i64 = 86_400_000; + + // MR-A: opened, recently updated (decay ≈ 1.0) + conn.execute( + "INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, author_username, state, last_seen_at, updated_at, created_at) + VALUES (1, 10, 1, 100, 'MR 100', 'recent_update', 'opened', ?1, ?2, ?3)", + rusqlite::params![now, now - 5 * day_ms, now - 200 * day_ms], + ).unwrap(); + insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); + + // MR-B: opened, old updated_at (decay significant) + conn.execute( + "INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, author_username, state, last_seen_at, updated_at, created_at) + VALUES (2, 20, 1, 200, 'MR 200', 'old_update', 'opened', ?1, ?2, ?3)", + rusqlite::params![now, now - 200 * day_ms, now - 200 * day_ms], + ).unwrap(); + insert_file_change(&conn, 2, 1, "src/lib.rs", "modified"); + + let result = query_expert( + &conn, + "src/lib.rs", + None, + 0, + now + 1000, + 20, + &default_scoring(), + false, + true, + false, + ) + .unwrap(); + + // recent_update should rank first (higher score from fresher updated_at) + assert_eq!(result.experts[0].username, "recent_update"); + let recent = result.experts[0].score_raw.unwrap(); + let old = result.experts[1].score_raw.unwrap(); + assert!( + recent > old * 2.0, + "recent ({recent:.1}) should beat old ({old:.1}) by updated_at" + ); +} + +#[test] +fn test_old_path_match_credits_expertise() { + // DiffNote with old_path should credit expertise when queried by old path. + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + + let now = now_ms(); + insert_mr(&conn, 1, 1, 100, "the_author", "merged"); + insert_file_change_with_old_path(&conn, 1, 1, "src/new.rs", Some("src/old.rs"), "renamed"); + insert_discussion(&conn, 1, 1, Some(1), None, true, false); + insert_diffnote_at( + &conn, + 1, + 1, + 1, + "reviewer_a", + "src/new.rs", + Some("src/old.rs"), + "substantive review of the renamed file", + now, + ); + + // Query by old path + let result_old = query_expert( + &conn, + "src/old.rs", + None, + 0, + now + 1000, + 20, + &default_scoring(), + false, + false, + false, + ) + .unwrap(); + + // Query by new path + let result_new = query_expert( + &conn, + "src/new.rs", + None, + 0, + now + 1000, + 20, + &default_scoring(), + false, + false, + false, + ) + .unwrap(); + + // Both queries should find the author + assert!( + result_old + .experts + .iter() + .any(|e| e.username == "the_author"), + "author should appear via old_path query" + ); + assert!( + result_new + .experts + .iter() + .any(|e| e.username == "the_author"), + "author should appear via new_path query" + ); +} + +#[test] +fn test_explain_score_components_sum_to_total() { + // With explain_score, component subtotals should sum to score_raw. + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + + let now = now_ms(); + insert_mr(&conn, 1, 1, 100, "the_author", "merged"); + insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); + insert_discussion(&conn, 1, 1, Some(1), None, true, false); + insert_diffnote( + &conn, + 1, + 1, + 1, + "the_reviewer", + "src/lib.rs", + "a substantive enough review comment", + ); + insert_reviewer(&conn, 1, "the_reviewer"); + + let result = query_expert( + &conn, + "src/lib.rs", + None, + 0, + now + 1000, + 20, + &default_scoring(), + false, + true, + false, + ) + .unwrap(); + + for expert in &result.experts { + let raw = expert.score_raw.unwrap(); + let comp = expert.components.as_ref().unwrap(); + let sum = comp.author + comp.reviewer_participated + comp.reviewer_assigned + comp.notes; + assert!( + (raw - sum).abs() < 1e-10, + "components ({sum:.6}) should sum to score_raw ({raw:.6}) for {}", + expert.username + ); + } +} + +#[test] +fn test_as_of_produces_deterministic_results() { + // Same as_of value produces identical results across multiple runs. + // Later as_of produces lower scores (more decay). + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + + let now = now_ms(); + let day_ms: i64 = 86_400_000; + let mr_ts = now - 30 * day_ms; + + insert_mr_at( + &conn, + 1, + 1, + 100, + "the_author", + "merged", + mr_ts, + Some(mr_ts), + None, + ); + insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); + + let as_of_early = now; + let as_of_late = now + 180 * day_ms; + + let result1 = query_expert( + &conn, + "src/lib.rs", + None, + 0, + as_of_early, + 20, + &default_scoring(), + false, + true, + false, + ) + .unwrap(); + let result2 = query_expert( + &conn, + "src/lib.rs", + None, + 0, + as_of_early, + 20, + &default_scoring(), + false, + true, + false, + ) + .unwrap(); + + // Same as_of -> identical scores + assert_eq!( + result1.experts[0].score_raw.unwrap().to_bits(), + result2.experts[0].score_raw.unwrap().to_bits(), + "same as_of should produce bit-identical scores" + ); + + // Later as_of -> lower score + let result_late = query_expert( + &conn, + "src/lib.rs", + None, + 0, + as_of_late, + 20, + &default_scoring(), + false, + true, + false, + ) + .unwrap(); + assert!( + result1.experts[0].score_raw.unwrap() > result_late.experts[0].score_raw.unwrap(), + "later as_of should produce lower scores" + ); +} + +#[test] +fn test_trivial_note_does_not_count_as_participation() { + // A reviewer with only a short note ("LGTM") should be classified as + // assigned-only, not participated, when reviewer_min_note_chars = 20. + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + + let now = now_ms(); + insert_mr(&conn, 1, 1, 100, "the_author", "merged"); + insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); + insert_discussion(&conn, 1, 1, Some(1), None, true, false); + // Short note (4 chars, below threshold of 20) + insert_diffnote(&conn, 1, 1, 1, "trivial_reviewer", "src/lib.rs", "LGTM"); + insert_reviewer(&conn, 1, "trivial_reviewer"); + + // Another reviewer with substantive note + insert_diffnote( + &conn, + 2, + 1, + 1, + "substantive_reviewer", + "src/lib.rs", + "This function needs better error handling for the edge case...", + ); + insert_reviewer(&conn, 1, "substantive_reviewer"); + + let result = query_expert( + &conn, + "src/lib.rs", + None, + 0, + now + 1000, + 20, + &default_scoring(), + false, + true, + false, + ) + .unwrap(); + + let trivial = result + .experts + .iter() + .find(|e| e.username == "trivial_reviewer") + .unwrap(); + let substantive = result + .experts + .iter() + .find(|e| e.username == "substantive_reviewer") + .unwrap(); + + let trivial_comp = trivial.components.as_ref().unwrap(); + let substantive_comp = substantive.components.as_ref().unwrap(); + + // Trivial should get reviewer_assigned (3), not reviewer_participated (10) + assert!( + trivial_comp.reviewer_assigned > 0.0, + "trivial reviewer should get assigned-only signal" + ); + assert!( + trivial_comp.reviewer_participated < 0.01, + "trivial reviewer should NOT get participated signal" + ); + + // Substantive should get reviewer_participated + assert!( + substantive_comp.reviewer_participated > 0.0, + "substantive reviewer should get participated signal" + ); +} + +#[test] +fn test_closed_mr_multiplier() { + // Two MRs with the same author: one merged, one closed. + // Closed should contribute author_weight * closed_mr_multiplier * decay. + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + + let now = now_ms(); + // Merged MR + insert_mr(&conn, 1, 1, 100, "merged_author", "merged"); + insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); + + // Closed MR (same path, different author) + insert_mr(&conn, 2, 1, 200, "closed_author", "closed"); + insert_file_change(&conn, 2, 1, "src/lib.rs", "modified"); + + let result = query_expert( + &conn, + "src/lib.rs", + None, + 0, + now + 1000, + 20, + &default_scoring(), + false, + true, + false, + ) + .unwrap(); + + let merged = result + .experts + .iter() + .find(|e| e.username == "merged_author") + .unwrap(); + let closed = result + .experts + .iter() + .find(|e| e.username == "closed_author") + .unwrap(); + let merged_score = merged.score_raw.unwrap(); + let closed_score = closed.score_raw.unwrap(); + + // Default closed_mr_multiplier=0.5, so closed should be roughly half + let ratio = closed_score / merged_score; + assert!( + (ratio - 0.5).abs() < 0.1, + "closed/merged ratio ({ratio:.2}) should be ≈0.5" + ); +} + +#[test] +fn test_as_of_excludes_future_events() { + // Events after as_of should not appear in results. + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + + let now = now_ms(); + let day_ms: i64 = 86_400_000; + let past_ts = now - 30 * day_ms; + let future_ts = now + 30 * day_ms; + + // Past MR (should appear) + insert_mr_at( + &conn, + 1, + 1, + 100, + "past_author", + "merged", + past_ts, + Some(past_ts), + None, + ); + insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); + + // Future MR (should NOT appear) + insert_mr_at( + &conn, + 2, + 1, + 200, + "future_author", + "merged", + future_ts, + Some(future_ts), + None, + ); + insert_file_change(&conn, 2, 1, "src/lib.rs", "modified"); + + let result = query_expert( + &conn, + "src/lib.rs", + None, + 0, + now, + 20, + &default_scoring(), + false, + false, + false, + ) + .unwrap(); + + assert!( + result.experts.iter().any(|e| e.username == "past_author"), + "past author should appear" + ); + assert!( + !result.experts.iter().any(|e| e.username == "future_author"), + "future author should be excluded by as_of" + ); +} + +#[test] +fn test_as_of_exclusive_upper_bound() { + // An event with timestamp exactly equal to as_of should be excluded (strict <). + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + + let now = now_ms(); + let boundary_ts = now; + + insert_mr_at( + &conn, + 1, + 1, + 100, + "boundary_author", + "merged", + boundary_ts, + Some(boundary_ts), + None, + ); + insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); + + let result = query_expert( + &conn, + "src/lib.rs", + None, + 0, + boundary_ts, + 20, + &default_scoring(), + false, + false, + false, + ) + .unwrap(); + + assert!( + !result + .experts + .iter() + .any(|e| e.username == "boundary_author"), + "event at exactly as_of should be excluded (half-open interval)" + ); +} + +#[test] +fn test_excluded_usernames_filters_bots() { + // Bot users in excluded_usernames should be filtered out. + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + + let now = now_ms(); + insert_mr(&conn, 1, 1, 100, "renovate-bot", "merged"); + insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); + insert_mr(&conn, 2, 1, 200, "jsmith", "merged"); + insert_file_change(&conn, 2, 1, "src/lib.rs", "modified"); + + let scoring = ScoringConfig { + excluded_usernames: vec!["renovate-bot".to_string()], + ..Default::default() + }; + let result = query_expert( + &conn, + "src/lib.rs", + None, + 0, + now + 1000, + 20, + &scoring, + false, + false, + false, + ) + .unwrap(); + + assert_eq!(result.experts.len(), 1); + assert_eq!(result.experts[0].username, "jsmith"); +} + +#[test] +fn test_include_bots_flag_disables_filtering() { + // With include_bots=true, excluded_usernames should be ignored. + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + + let now = now_ms(); + insert_mr(&conn, 1, 1, 100, "renovate-bot", "merged"); + insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); + insert_mr(&conn, 2, 1, 200, "jsmith", "merged"); + insert_file_change(&conn, 2, 1, "src/lib.rs", "modified"); + + let scoring = ScoringConfig { + excluded_usernames: vec!["renovate-bot".to_string()], + ..Default::default() + }; + // include_bots = true + let result = query_expert( + &conn, + "src/lib.rs", + None, + 0, + now + 1000, + 20, + &scoring, + false, + false, + true, + ) + .unwrap(); + + assert_eq!(result.experts.len(), 2); + assert!(result.experts.iter().any(|e| e.username == "renovate-bot")); + assert!(result.experts.iter().any(|e| e.username == "jsmith")); +} + +#[test] +fn test_null_timestamp_fallback_to_created_at() { + // A merged MR with merged_at=NULL should fall back to created_at. + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + + let now = now_ms(); + let day_ms: i64 = 86_400_000; + let old_ts = now - 100 * day_ms; + + // Insert merged MR with merged_at=NULL, created_at set + conn.execute( + "INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, author_username, state, last_seen_at, created_at, merged_at, updated_at) + VALUES (1, 10, 1, 100, 'MR 100', 'the_author', 'merged', ?1, ?2, NULL, NULL)", + rusqlite::params![now, old_ts], + ).unwrap(); + insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); + + let result = query_expert( + &conn, + "src/lib.rs", + None, + 0, + now + 1000, + 20, + &default_scoring(), + false, + true, + false, + ) + .unwrap(); + + // Should still find the author (not panic or return empty) + assert_eq!(result.experts.len(), 1); + assert_eq!(result.experts[0].username, "the_author"); + // Score should reflect old_ts (100 days ago), not 0 or now + let score = result.experts[0].score_raw.unwrap(); + // 25 * 2^(-100/180) ≈ 17.1 + assert!( + score > 5.0 && score < 22.0, + "score ({score:.1}) should reflect created_at fallback" + ); +} + +// ─── Invariant Tests ──────────────────────────────────────────────────────── + +#[test] +fn test_score_monotonicity_by_age() { + // For any single signal, older timestamp must never produce a higher score. + for half_life in [1_u32, 7, 45, 90, 180, 365] { + let mut prev_decay = f64::MAX; + for days in 0..=730 { + let elapsed_ms = i64::from(days) * 86_400_000; + let decay = half_life_decay(elapsed_ms, half_life); + assert!( + decay <= prev_decay, + "monotonicity violated: half_life={half_life}, day={days}, decay={decay} > prev={prev_decay}" + ); + prev_decay = decay; + } + } +} + +#[test] +fn test_row_order_independence() { + // Same signals inserted in different order should produce identical results. + let conn1 = setup_test_db(); + let conn2 = setup_test_db(); + + let now = now_ms(); + let day_ms: i64 = 86_400_000; + + // Setup identical data in both DBs but in different insertion order + for conn in [&conn1, &conn2] { + insert_project(conn, 1, "team/backend"); + } + + // Forward order in conn1 + for i in 1_i64..=5 { + let ts = now - i * 30 * day_ms; + insert_mr_at( + &conn1, + i, + 1, + i * 100, + &format!("author_{i}"), + "merged", + ts, + Some(ts), + None, + ); + insert_file_change(&conn1, i, 1, "src/lib.rs", "modified"); + } + + // Reverse order in conn2 + for i in (1_i64..=5).rev() { + let ts = now - i * 30 * day_ms; + insert_mr_at( + &conn2, + i, + 1, + i * 100, + &format!("author_{i}"), + "merged", + ts, + Some(ts), + None, + ); + insert_file_change(&conn2, i, 1, "src/lib.rs", "modified"); + } + + let r1 = query_expert( + &conn1, + "src/lib.rs", + None, + 0, + now + 1000, + 20, + &default_scoring(), + false, + true, + false, + ) + .unwrap(); + let r2 = query_expert( + &conn2, + "src/lib.rs", + None, + 0, + now + 1000, + 20, + &default_scoring(), + false, + true, + false, + ) + .unwrap(); + + assert_eq!(r1.experts.len(), r2.experts.len()); + for (e1, e2) in r1.experts.iter().zip(r2.experts.iter()) { + assert_eq!(e1.username, e2.username, "ranking order differs"); + assert_eq!( + e1.score_raw.unwrap().to_bits(), + e2.score_raw.unwrap().to_bits(), + "scores differ for {}", + e1.username + ); + } +} + +#[test] +fn test_reviewer_split_is_exhaustive() { + // A reviewer on an MR must appear in exactly one of: participated or assigned-only. + // Three cases: (1) substantive notes -> participated, (2) no notes -> assigned, + // (3) trivial notes -> assigned. + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + + let now = now_ms(); + insert_mr(&conn, 1, 1, 100, "the_author", "merged"); + insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); + insert_discussion(&conn, 1, 1, Some(1), None, true, false); + + // Case 1: substantive notes + insert_diffnote( + &conn, + 1, + 1, + 1, + "reviewer_substantive", + "src/lib.rs", + "this is a long enough substantive review comment", + ); + insert_reviewer(&conn, 1, "reviewer_substantive"); + + // Case 2: no notes at all + insert_reviewer(&conn, 1, "reviewer_no_notes"); + + // Case 3: trivial notes only + insert_diffnote(&conn, 2, 1, 1, "reviewer_trivial", "src/lib.rs", "ok"); + insert_reviewer(&conn, 1, "reviewer_trivial"); + + let result = query_expert( + &conn, + "src/lib.rs", + None, + 0, + now + 1000, + 20, + &default_scoring(), + false, + true, + false, + ) + .unwrap(); + + // Check each reviewer's components + let subst = result + .experts + .iter() + .find(|e| e.username == "reviewer_substantive") + .unwrap(); + let subst_comp = subst.components.as_ref().unwrap(); + assert!( + subst_comp.reviewer_participated > 0.0, + "case 1: should be participated" + ); + assert!( + subst_comp.reviewer_assigned < 0.01, + "case 1: should NOT be assigned" + ); + + let no_notes = result + .experts + .iter() + .find(|e| e.username == "reviewer_no_notes") + .unwrap(); + let nn_comp = no_notes.components.as_ref().unwrap(); + assert!( + nn_comp.reviewer_assigned > 0.0, + "case 2: should be assigned" + ); + assert!( + nn_comp.reviewer_participated < 0.01, + "case 2: should NOT be participated" + ); + + let trivial = result + .experts + .iter() + .find(|e| e.username == "reviewer_trivial") + .unwrap(); + let tr_comp = trivial.components.as_ref().unwrap(); + assert!( + tr_comp.reviewer_assigned > 0.0, + "case 3: should be assigned" + ); + assert!( + tr_comp.reviewer_participated < 0.01, + "case 3: should NOT be participated" + ); +} + +#[test] +fn test_deterministic_accumulation_order() { + // Same data queried 50 times must produce bit-identical f64 scores. + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + + let now = now_ms(); + let day_ms: i64 = 86_400_000; + + // 10 MRs at varied ages + for i in 1_i64..=10 { + let ts = now - i * 20 * day_ms; + insert_mr_at( + &conn, + i, + 1, + i * 100, + "the_author", + "merged", + ts, + Some(ts), + None, + ); + insert_file_change(&conn, i, 1, "src/lib.rs", "modified"); + } + + let as_of = now + 1000; + let first_result = query_expert( + &conn, + "src/lib.rs", + None, + 0, + as_of, + 20, + &default_scoring(), + false, + true, + false, + ) + .unwrap(); + let expected_bits = first_result.experts[0].score_raw.unwrap().to_bits(); + + for run in 1..50 { + let result = query_expert( + &conn, + "src/lib.rs", + None, + 0, + as_of, + 20, + &default_scoring(), + false, + true, + false, + ) + .unwrap(); + assert_eq!( + result.experts[0].score_raw.unwrap().to_bits(), + expected_bits, + "run {run}: score bits diverged (HashMap iteration order leaking)" + ); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 1ad3e42..2647b34 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -905,7 +905,12 @@ pub struct WhoArgs { pub fields: Option>, /// Show per-MR detail breakdown (expert mode only) - #[arg(long, help_heading = "Output", overrides_with = "no_detail")] + #[arg( + long, + help_heading = "Output", + overrides_with = "no_detail", + conflicts_with = "explain_score" + )] pub detail: bool, #[arg(long = "no-detail", hide = true, overrides_with = "detail")] diff --git a/src/core/db.rs b/src/core/db.rs index 1085c7a..78af367 100644 --- a/src/core/db.rs +++ b/src/core/db.rs @@ -334,637 +334,5 @@ pub fn get_schema_version(conn: &Connection) -> i32 { } #[cfg(test)] -mod tests { - use super::*; - - fn setup_migrated_db() -> Connection { - let conn = create_connection(Path::new(":memory:")).unwrap(); - run_migrations(&conn).unwrap(); - conn - } - - fn index_exists(conn: &Connection, index_name: &str) -> bool { - conn.query_row( - "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='index' AND name=?1", - [index_name], - |row| row.get(0), - ) - .unwrap_or(false) - } - - fn column_exists(conn: &Connection, table: &str, column: &str) -> bool { - let sql = format!("PRAGMA table_info({})", table); - let mut stmt = conn.prepare(&sql).unwrap(); - let columns: Vec = stmt - .query_map([], |row| row.get::<_, String>(1)) - .unwrap() - .filter_map(|r| r.ok()) - .collect(); - columns.contains(&column.to_string()) - } - - #[test] - fn test_migration_022_indexes_exist() { - let conn = setup_migrated_db(); - - // New indexes from migration 022 - assert!( - index_exists(&conn, "idx_notes_user_created"), - "idx_notes_user_created should exist" - ); - assert!( - index_exists(&conn, "idx_notes_project_created"), - "idx_notes_project_created should exist" - ); - assert!( - index_exists(&conn, "idx_notes_author_id"), - "idx_notes_author_id should exist" - ); - - // Discussion JOIN indexes (idx_discussions_issue_id is new; - // idx_discussions_mr_id already existed from migration 006 but - // IF NOT EXISTS makes it safe) - assert!( - index_exists(&conn, "idx_discussions_issue_id"), - "idx_discussions_issue_id should exist" - ); - assert!( - index_exists(&conn, "idx_discussions_mr_id"), - "idx_discussions_mr_id should exist" - ); - - // author_id column on notes - assert!( - column_exists(&conn, "notes", "author_id"), - "notes.author_id column should exist" - ); - } - - // -- Helper: insert a minimal project for FK satisfaction -- - fn insert_test_project(conn: &Connection) -> i64 { - conn.execute( - "INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url) \ - VALUES (1000, 'test/project', 'https://example.com/test/project')", - [], - ) - .unwrap(); - conn.last_insert_rowid() - } - - // -- Helper: insert a minimal issue -- - fn insert_test_issue(conn: &Connection, project_id: i64) -> i64 { - conn.execute( - "INSERT INTO issues (gitlab_id, project_id, iid, state, author_username, \ - created_at, updated_at, last_seen_at) \ - VALUES (100, ?1, 1, 'opened', 'alice', 1000, 1000, 1000)", - [project_id], - ) - .unwrap(); - conn.last_insert_rowid() - } - - // -- Helper: insert a minimal discussion -- - fn insert_test_discussion(conn: &Connection, project_id: i64, issue_id: i64) -> i64 { - conn.execute( - "INSERT INTO discussions (gitlab_discussion_id, project_id, issue_id, \ - noteable_type, last_seen_at) \ - VALUES ('disc-001', ?1, ?2, 'Issue', 1000)", - rusqlite::params![project_id, issue_id], - ) - .unwrap(); - conn.last_insert_rowid() - } - - // -- Helper: insert a minimal non-system note -- - #[allow(clippy::too_many_arguments)] - fn insert_test_note( - conn: &Connection, - gitlab_id: i64, - discussion_id: i64, - project_id: i64, - is_system: bool, - ) -> i64 { - conn.execute( - "INSERT INTO notes (gitlab_id, discussion_id, project_id, is_system, \ - author_username, body, created_at, updated_at, last_seen_at) \ - VALUES (?1, ?2, ?3, ?4, 'alice', 'note body', 1000, 1000, 1000)", - rusqlite::params![gitlab_id, discussion_id, project_id, is_system as i32], - ) - .unwrap(); - conn.last_insert_rowid() - } - - // -- Helper: insert a document -- - fn insert_test_document( - conn: &Connection, - source_type: &str, - source_id: i64, - project_id: i64, - ) -> i64 { - conn.execute( - "INSERT INTO documents (source_type, source_id, project_id, content_text, content_hash) \ - VALUES (?1, ?2, ?3, 'test content', 'hash123')", - rusqlite::params![source_type, source_id, project_id], - ) - .unwrap(); - conn.last_insert_rowid() - } - - #[test] - fn test_migration_024_allows_note_source_type() { - let conn = setup_migrated_db(); - let pid = insert_test_project(&conn); - - // Should succeed — 'note' is now allowed - conn.execute( - "INSERT INTO documents (source_type, source_id, project_id, content_text, content_hash) \ - VALUES ('note', 1, ?1, 'note content', 'hash-note')", - [pid], - ) - .expect("INSERT with source_type='note' into documents should succeed"); - - // dirty_sources should also accept 'note' - conn.execute( - "INSERT INTO dirty_sources (source_type, source_id, queued_at) \ - VALUES ('note', 1, 1000)", - [], - ) - .expect("INSERT with source_type='note' into dirty_sources should succeed"); - } - - #[test] - fn test_migration_024_preserves_existing_data() { - // Run migrations up to 023 only, insert data, then apply 024 - // Migration 024 is at index 23 (0-based). Use hardcoded index so adding - // later migrations doesn't silently shift what this test exercises. - let conn = create_connection(Path::new(":memory:")).unwrap(); - - // Apply migrations 001-023 (indices 0..23) - run_migrations_up_to(&conn, 23); - - let pid = insert_test_project(&conn); - - // Insert a document with existing source_type - conn.execute( - "INSERT INTO documents (source_type, source_id, project_id, content_text, content_hash, title) \ - VALUES ('issue', 1, ?1, 'issue content', 'hash-issue', 'Test Issue')", - [pid], - ) - .unwrap(); - let doc_id: i64 = conn.last_insert_rowid(); - - // Insert junction data - conn.execute( - "INSERT INTO document_labels (document_id, label_name) VALUES (?1, 'bug')", - [doc_id], - ) - .unwrap(); - conn.execute( - "INSERT INTO document_paths (document_id, path) VALUES (?1, 'src/main.rs')", - [doc_id], - ) - .unwrap(); - - // Insert dirty_sources row - conn.execute( - "INSERT INTO dirty_sources (source_type, source_id, queued_at) VALUES ('issue', 1, 1000)", - [], - ) - .unwrap(); - - // Now apply migration 024 (index 23) — the table-rebuild migration - run_single_migration(&conn, 23); - - // Verify document still exists with correct data - let (st, content, title): (String, String, String) = conn - .query_row( - "SELECT source_type, content_text, title FROM documents WHERE id = ?1", - [doc_id], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), - ) - .unwrap(); - assert_eq!(st, "issue"); - assert_eq!(content, "issue content"); - assert_eq!(title, "Test Issue"); - - // Verify junction data preserved - let label_count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM document_labels WHERE document_id = ?1", - [doc_id], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(label_count, 1); - - let path_count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM document_paths WHERE document_id = ?1", - [doc_id], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(path_count, 1); - - // Verify dirty_sources preserved - let dirty_count: i64 = conn - .query_row("SELECT COUNT(*) FROM dirty_sources", [], |row| row.get(0)) - .unwrap(); - assert_eq!(dirty_count, 1); - } - - #[test] - fn test_migration_024_fts_triggers_intact() { - let conn = setup_migrated_db(); - let pid = insert_test_project(&conn); - - // Insert a document after migration — FTS trigger should fire - let doc_id = insert_test_document(&conn, "note", 1, pid); - - // Verify FTS entry exists - let fts_count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM documents_fts WHERE documents_fts MATCH 'test'", - [], - |row| row.get(0), - ) - .unwrap(); - assert!(fts_count > 0, "FTS trigger should have created an entry"); - - // Verify update trigger works - conn.execute( - "UPDATE documents SET content_text = 'updated content' WHERE id = ?1", - [doc_id], - ) - .unwrap(); - - let fts_updated: i64 = conn - .query_row( - "SELECT COUNT(*) FROM documents_fts WHERE documents_fts MATCH 'updated'", - [], - |row| row.get(0), - ) - .unwrap(); - assert!( - fts_updated > 0, - "FTS update trigger should reflect new content" - ); - - // Verify delete trigger works - conn.execute("DELETE FROM documents WHERE id = ?1", [doc_id]) - .unwrap(); - - let fts_after_delete: i64 = conn - .query_row( - "SELECT COUNT(*) FROM documents_fts WHERE documents_fts MATCH 'updated'", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!( - fts_after_delete, 0, - "FTS delete trigger should remove the entry" - ); - } - - #[test] - fn test_migration_024_row_counts_preserved() { - let conn = setup_migrated_db(); - - // After full migration, tables should exist and be queryable - let doc_count: i64 = conn - .query_row("SELECT COUNT(*) FROM documents", [], |row| row.get(0)) - .unwrap(); - assert_eq!(doc_count, 0, "Fresh DB should have 0 documents"); - - let dirty_count: i64 = conn - .query_row("SELECT COUNT(*) FROM dirty_sources", [], |row| row.get(0)) - .unwrap(); - assert_eq!(dirty_count, 0, "Fresh DB should have 0 dirty_sources"); - } - - #[test] - fn test_migration_024_integrity_checks_pass() { - let conn = setup_migrated_db(); - - // PRAGMA integrity_check - let integrity: String = conn - .query_row("PRAGMA integrity_check", [], |row| row.get(0)) - .unwrap(); - assert_eq!(integrity, "ok", "Database integrity check should pass"); - - // PRAGMA foreign_key_check (returns rows only if there are violations) - let fk_violations: i64 = conn - .query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { - row.get(0) - }) - .unwrap(); - assert_eq!(fk_violations, 0, "No foreign key violations should exist"); - } - - #[test] - fn test_migration_024_note_delete_trigger_cleans_document() { - let conn = setup_migrated_db(); - let pid = insert_test_project(&conn); - let issue_id = insert_test_issue(&conn, pid); - let disc_id = insert_test_discussion(&conn, pid, issue_id); - let note_id = insert_test_note(&conn, 200, disc_id, pid, false); - - // Create a document for this note - insert_test_document(&conn, "note", note_id, pid); - - let doc_before: i64 = conn - .query_row( - "SELECT COUNT(*) FROM documents WHERE source_type = 'note' AND source_id = ?1", - [note_id], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(doc_before, 1); - - // Delete the note — trigger should remove the document - conn.execute("DELETE FROM notes WHERE id = ?1", [note_id]) - .unwrap(); - - let doc_after: i64 = conn - .query_row( - "SELECT COUNT(*) FROM documents WHERE source_type = 'note' AND source_id = ?1", - [note_id], - |row| row.get(0), - ) - .unwrap(); - assert_eq!( - doc_after, 0, - "notes_ad_cleanup trigger should delete the document" - ); - } - - #[test] - fn test_migration_024_note_system_flip_trigger_cleans_document() { - let conn = setup_migrated_db(); - let pid = insert_test_project(&conn); - let issue_id = insert_test_issue(&conn, pid); - let disc_id = insert_test_discussion(&conn, pid, issue_id); - let note_id = insert_test_note(&conn, 201, disc_id, pid, false); - - // Create a document for this note - insert_test_document(&conn, "note", note_id, pid); - - let doc_before: i64 = conn - .query_row( - "SELECT COUNT(*) FROM documents WHERE source_type = 'note' AND source_id = ?1", - [note_id], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(doc_before, 1); - - // Flip is_system from 0 to 1 — trigger should remove the document - conn.execute("UPDATE notes SET is_system = 1 WHERE id = ?1", [note_id]) - .unwrap(); - - let doc_after: i64 = conn - .query_row( - "SELECT COUNT(*) FROM documents WHERE source_type = 'note' AND source_id = ?1", - [note_id], - |row| row.get(0), - ) - .unwrap(); - assert_eq!( - doc_after, 0, - "notes_au_system_cleanup trigger should delete the document" - ); - } - - #[test] - fn test_migration_024_system_note_delete_trigger_does_not_fire() { - let conn = setup_migrated_db(); - let pid = insert_test_project(&conn); - let issue_id = insert_test_issue(&conn, pid); - let disc_id = insert_test_discussion(&conn, pid, issue_id); - - // Insert a system note (is_system = true) - let note_id = insert_test_note(&conn, 202, disc_id, pid, true); - - // Manually insert a document (shouldn't exist for system notes in practice, - // but we test the trigger guard) - insert_test_document(&conn, "note", note_id, pid); - - let doc_before: i64 = conn - .query_row( - "SELECT COUNT(*) FROM documents WHERE source_type = 'note' AND source_id = ?1", - [note_id], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(doc_before, 1); - - // Delete system note — trigger has WHEN old.is_system = 0 so it should NOT fire - conn.execute("DELETE FROM notes WHERE id = ?1", [note_id]) - .unwrap(); - - let doc_after: i64 = conn - .query_row( - "SELECT COUNT(*) FROM documents WHERE source_type = 'note' AND source_id = ?1", - [note_id], - |row| row.get(0), - ) - .unwrap(); - assert_eq!( - doc_after, 1, - "notes_ad_cleanup trigger should NOT fire for system notes" - ); - } - - /// Run migrations only up to version `up_to` (inclusive). - fn run_migrations_up_to(conn: &Connection, up_to: usize) { - conn.execute_batch( - "CREATE TABLE IF NOT EXISTS schema_version ( \ - version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL, description TEXT);", - ) - .unwrap(); - - for (version_str, sql) in &MIGRATIONS[..up_to] { - let version: i32 = version_str.parse().unwrap(); - conn.execute_batch(sql).unwrap(); - conn.execute( - "INSERT OR REPLACE INTO schema_version (version, applied_at, description) \ - VALUES (?1, strftime('%s', 'now') * 1000, ?2)", - rusqlite::params![version, version_str], - ) - .unwrap(); - } - } - - /// Run a single migration by index (0-based). - fn run_single_migration(conn: &Connection, index: usize) { - let (version_str, sql) = MIGRATIONS[index]; - let version: i32 = version_str.parse().unwrap(); - conn.execute_batch(sql).unwrap(); - conn.execute( - "INSERT OR REPLACE INTO schema_version (version, applied_at, description) \ - VALUES (?1, strftime('%s', 'now') * 1000, ?2)", - rusqlite::params![version, version_str], - ) - .unwrap(); - } - - #[test] - fn test_migration_025_backfills_existing_notes() { - let conn = create_connection(Path::new(":memory:")).unwrap(); - // Run all migrations through 024 (index 0..24) - run_migrations_up_to(&conn, 24); - - let pid = insert_test_project(&conn); - let issue_id = insert_test_issue(&conn, pid); - let disc_id = insert_test_discussion(&conn, pid, issue_id); - - // Insert 5 non-system notes - for i in 1..=5 { - insert_test_note(&conn, 300 + i, disc_id, pid, false); - } - // Insert 2 system notes - for i in 1..=2 { - insert_test_note(&conn, 400 + i, disc_id, pid, true); - } - - // Run migration 025 - run_single_migration(&conn, 24); - - let dirty_count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM dirty_sources WHERE source_type = 'note'", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!( - dirty_count, 5, - "Migration 025 should backfill 5 non-system notes" - ); - - // Verify system notes were not backfilled - let system_note_ids: Vec = { - let mut stmt = conn - .prepare( - "SELECT source_id FROM dirty_sources WHERE source_type = 'note' ORDER BY source_id", - ) - .unwrap(); - stmt.query_map([], |row| row.get(0)) - .unwrap() - .collect::, _>>() - .unwrap() - }; - // System note ids should not appear - let all_system_note_ids: Vec = { - let mut stmt = conn - .prepare("SELECT id FROM notes WHERE is_system = 1 ORDER BY id") - .unwrap(); - stmt.query_map([], |row| row.get(0)) - .unwrap() - .collect::, _>>() - .unwrap() - }; - for sys_id in &all_system_note_ids { - assert!( - !system_note_ids.contains(sys_id), - "System note id {} should not be in dirty_sources", - sys_id - ); - } - } - - #[test] - fn test_migration_025_idempotent_with_existing_documents() { - let conn = create_connection(Path::new(":memory:")).unwrap(); - run_migrations_up_to(&conn, 24); - - let pid = insert_test_project(&conn); - let issue_id = insert_test_issue(&conn, pid); - let disc_id = insert_test_discussion(&conn, pid, issue_id); - - // Insert 3 non-system notes - let note_ids: Vec = (1..=3) - .map(|i| insert_test_note(&conn, 500 + i, disc_id, pid, false)) - .collect(); - - // Create documents for 2 of 3 notes (simulating already-generated docs) - insert_test_document(&conn, "note", note_ids[0], pid); - insert_test_document(&conn, "note", note_ids[1], pid); - - // Run migration 025 - run_single_migration(&conn, 24); - - let dirty_count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM dirty_sources WHERE source_type = 'note'", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!( - dirty_count, 1, - "Only the note without a document should be backfilled" - ); - - // Verify the correct note was queued - let queued_id: i64 = conn - .query_row( - "SELECT source_id FROM dirty_sources WHERE source_type = 'note'", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(queued_id, note_ids[2]); - } - - #[test] - fn test_migration_025_skips_notes_already_in_dirty_queue() { - let conn = create_connection(Path::new(":memory:")).unwrap(); - run_migrations_up_to(&conn, 24); - - let pid = insert_test_project(&conn); - let issue_id = insert_test_issue(&conn, pid); - let disc_id = insert_test_discussion(&conn, pid, issue_id); - - // Insert 3 non-system notes - let note_ids: Vec = (1..=3) - .map(|i| insert_test_note(&conn, 600 + i, disc_id, pid, false)) - .collect(); - - // Pre-queue one note in dirty_sources - conn.execute( - "INSERT INTO dirty_sources (source_type, source_id, queued_at) VALUES ('note', ?1, 999)", - [note_ids[0]], - ) - .unwrap(); - - // Run migration 025 - run_single_migration(&conn, 24); - - let dirty_count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM dirty_sources WHERE source_type = 'note'", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!( - dirty_count, 3, - "All 3 notes should be in dirty_sources (1 pre-existing + 2 new)" - ); - - // Verify the pre-existing entry preserved its original queued_at - let original_queued_at: i64 = conn - .query_row( - "SELECT queued_at FROM dirty_sources WHERE source_type = 'note' AND source_id = ?1", - [note_ids[0]], - |row| row.get(0), - ) - .unwrap(); - assert_eq!( - original_queued_at, 999, - "ON CONFLICT DO NOTHING should preserve the original queued_at" - ); - } -} +#[path = "db_tests.rs"] +mod tests; diff --git a/src/core/db_tests.rs b/src/core/db_tests.rs new file mode 100644 index 0000000..95b6dcf --- /dev/null +++ b/src/core/db_tests.rs @@ -0,0 +1,632 @@ +use super::*; + +fn setup_migrated_db() -> Connection { + let conn = create_connection(Path::new(":memory:")).unwrap(); + run_migrations(&conn).unwrap(); + conn +} + +fn index_exists(conn: &Connection, index_name: &str) -> bool { + conn.query_row( + "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='index' AND name=?1", + [index_name], + |row| row.get(0), + ) + .unwrap_or(false) +} + +fn column_exists(conn: &Connection, table: &str, column: &str) -> bool { + let sql = format!("PRAGMA table_info({})", table); + let mut stmt = conn.prepare(&sql).unwrap(); + let columns: Vec = stmt + .query_map([], |row| row.get::<_, String>(1)) + .unwrap() + .filter_map(|r| r.ok()) + .collect(); + columns.contains(&column.to_string()) +} + +#[test] +fn test_migration_022_indexes_exist() { + let conn = setup_migrated_db(); + + // New indexes from migration 022 + assert!( + index_exists(&conn, "idx_notes_user_created"), + "idx_notes_user_created should exist" + ); + assert!( + index_exists(&conn, "idx_notes_project_created"), + "idx_notes_project_created should exist" + ); + assert!( + index_exists(&conn, "idx_notes_author_id"), + "idx_notes_author_id should exist" + ); + + // Discussion JOIN indexes (idx_discussions_issue_id is new; + // idx_discussions_mr_id already existed from migration 006 but + // IF NOT EXISTS makes it safe) + assert!( + index_exists(&conn, "idx_discussions_issue_id"), + "idx_discussions_issue_id should exist" + ); + assert!( + index_exists(&conn, "idx_discussions_mr_id"), + "idx_discussions_mr_id should exist" + ); + + // author_id column on notes + assert!( + column_exists(&conn, "notes", "author_id"), + "notes.author_id column should exist" + ); +} + +// -- Helper: insert a minimal project for FK satisfaction -- +fn insert_test_project(conn: &Connection) -> i64 { + conn.execute( + "INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url) \ + VALUES (1000, 'test/project', 'https://example.com/test/project')", + [], + ) + .unwrap(); + conn.last_insert_rowid() +} + +// -- Helper: insert a minimal issue -- +fn insert_test_issue(conn: &Connection, project_id: i64) -> i64 { + conn.execute( + "INSERT INTO issues (gitlab_id, project_id, iid, state, author_username, \ + created_at, updated_at, last_seen_at) \ + VALUES (100, ?1, 1, 'opened', 'alice', 1000, 1000, 1000)", + [project_id], + ) + .unwrap(); + conn.last_insert_rowid() +} + +// -- Helper: insert a minimal discussion -- +fn insert_test_discussion(conn: &Connection, project_id: i64, issue_id: i64) -> i64 { + conn.execute( + "INSERT INTO discussions (gitlab_discussion_id, project_id, issue_id, \ + noteable_type, last_seen_at) \ + VALUES ('disc-001', ?1, ?2, 'Issue', 1000)", + rusqlite::params![project_id, issue_id], + ) + .unwrap(); + conn.last_insert_rowid() +} + +// -- Helper: insert a minimal non-system note -- +#[allow(clippy::too_many_arguments)] +fn insert_test_note( + conn: &Connection, + gitlab_id: i64, + discussion_id: i64, + project_id: i64, + is_system: bool, +) -> i64 { + conn.execute( + "INSERT INTO notes (gitlab_id, discussion_id, project_id, is_system, \ + author_username, body, created_at, updated_at, last_seen_at) \ + VALUES (?1, ?2, ?3, ?4, 'alice', 'note body', 1000, 1000, 1000)", + rusqlite::params![gitlab_id, discussion_id, project_id, is_system as i32], + ) + .unwrap(); + conn.last_insert_rowid() +} + +// -- Helper: insert a document -- +fn insert_test_document( + conn: &Connection, + source_type: &str, + source_id: i64, + project_id: i64, +) -> i64 { + conn.execute( + "INSERT INTO documents (source_type, source_id, project_id, content_text, content_hash) \ + VALUES (?1, ?2, ?3, 'test content', 'hash123')", + rusqlite::params![source_type, source_id, project_id], + ) + .unwrap(); + conn.last_insert_rowid() +} + +#[test] +fn test_migration_024_allows_note_source_type() { + let conn = setup_migrated_db(); + let pid = insert_test_project(&conn); + + // Should succeed -- 'note' is now allowed + conn.execute( + "INSERT INTO documents (source_type, source_id, project_id, content_text, content_hash) \ + VALUES ('note', 1, ?1, 'note content', 'hash-note')", + [pid], + ) + .expect("INSERT with source_type='note' into documents should succeed"); + + // dirty_sources should also accept 'note' + conn.execute( + "INSERT INTO dirty_sources (source_type, source_id, queued_at) \ + VALUES ('note', 1, 1000)", + [], + ) + .expect("INSERT with source_type='note' into dirty_sources should succeed"); +} + +#[test] +fn test_migration_024_preserves_existing_data() { + // Run migrations up to 023 only, insert data, then apply 024 + // Migration 024 is at index 23 (0-based). Use hardcoded index so adding + // later migrations doesn't silently shift what this test exercises. + let conn = create_connection(Path::new(":memory:")).unwrap(); + + // Apply migrations 001-023 (indices 0..23) + run_migrations_up_to(&conn, 23); + + let pid = insert_test_project(&conn); + + // Insert a document with existing source_type + conn.execute( + "INSERT INTO documents (source_type, source_id, project_id, content_text, content_hash, title) \ + VALUES ('issue', 1, ?1, 'issue content', 'hash-issue', 'Test Issue')", + [pid], + ) + .unwrap(); + let doc_id: i64 = conn.last_insert_rowid(); + + // Insert junction data + conn.execute( + "INSERT INTO document_labels (document_id, label_name) VALUES (?1, 'bug')", + [doc_id], + ) + .unwrap(); + conn.execute( + "INSERT INTO document_paths (document_id, path) VALUES (?1, 'src/main.rs')", + [doc_id], + ) + .unwrap(); + + // Insert dirty_sources row + conn.execute( + "INSERT INTO dirty_sources (source_type, source_id, queued_at) VALUES ('issue', 1, 1000)", + [], + ) + .unwrap(); + + // Now apply migration 024 (index 23) -- the table-rebuild migration + run_single_migration(&conn, 23); + + // Verify document still exists with correct data + let (st, content, title): (String, String, String) = conn + .query_row( + "SELECT source_type, content_text, title FROM documents WHERE id = ?1", + [doc_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .unwrap(); + assert_eq!(st, "issue"); + assert_eq!(content, "issue content"); + assert_eq!(title, "Test Issue"); + + // Verify junction data preserved + let label_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM document_labels WHERE document_id = ?1", + [doc_id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(label_count, 1); + + let path_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM document_paths WHERE document_id = ?1", + [doc_id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(path_count, 1); + + // Verify dirty_sources preserved + let dirty_count: i64 = conn + .query_row("SELECT COUNT(*) FROM dirty_sources", [], |row| row.get(0)) + .unwrap(); + assert_eq!(dirty_count, 1); +} + +#[test] +fn test_migration_024_fts_triggers_intact() { + let conn = setup_migrated_db(); + let pid = insert_test_project(&conn); + + // Insert a document after migration -- FTS trigger should fire + let doc_id = insert_test_document(&conn, "note", 1, pid); + + // Verify FTS entry exists + let fts_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM documents_fts WHERE documents_fts MATCH 'test'", + [], + |row| row.get(0), + ) + .unwrap(); + assert!(fts_count > 0, "FTS trigger should have created an entry"); + + // Verify update trigger works + conn.execute( + "UPDATE documents SET content_text = 'updated content' WHERE id = ?1", + [doc_id], + ) + .unwrap(); + + let fts_updated: i64 = conn + .query_row( + "SELECT COUNT(*) FROM documents_fts WHERE documents_fts MATCH 'updated'", + [], + |row| row.get(0), + ) + .unwrap(); + assert!( + fts_updated > 0, + "FTS update trigger should reflect new content" + ); + + // Verify delete trigger works + conn.execute("DELETE FROM documents WHERE id = ?1", [doc_id]) + .unwrap(); + + let fts_after_delete: i64 = conn + .query_row( + "SELECT COUNT(*) FROM documents_fts WHERE documents_fts MATCH 'updated'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + fts_after_delete, 0, + "FTS delete trigger should remove the entry" + ); +} + +#[test] +fn test_migration_024_row_counts_preserved() { + let conn = setup_migrated_db(); + + // After full migration, tables should exist and be queryable + let doc_count: i64 = conn + .query_row("SELECT COUNT(*) FROM documents", [], |row| row.get(0)) + .unwrap(); + assert_eq!(doc_count, 0, "Fresh DB should have 0 documents"); + + let dirty_count: i64 = conn + .query_row("SELECT COUNT(*) FROM dirty_sources", [], |row| row.get(0)) + .unwrap(); + assert_eq!(dirty_count, 0, "Fresh DB should have 0 dirty_sources"); +} + +#[test] +fn test_migration_024_integrity_checks_pass() { + let conn = setup_migrated_db(); + + // PRAGMA integrity_check + let integrity: String = conn + .query_row("PRAGMA integrity_check", [], |row| row.get(0)) + .unwrap(); + assert_eq!(integrity, "ok", "Database integrity check should pass"); + + // PRAGMA foreign_key_check (returns rows only if there are violations) + let fk_violations: i64 = conn + .query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(fk_violations, 0, "No foreign key violations should exist"); +} + +#[test] +fn test_migration_024_note_delete_trigger_cleans_document() { + let conn = setup_migrated_db(); + let pid = insert_test_project(&conn); + let issue_id = insert_test_issue(&conn, pid); + let disc_id = insert_test_discussion(&conn, pid, issue_id); + let note_id = insert_test_note(&conn, 200, disc_id, pid, false); + + // Create a document for this note + insert_test_document(&conn, "note", note_id, pid); + + let doc_before: i64 = conn + .query_row( + "SELECT COUNT(*) FROM documents WHERE source_type = 'note' AND source_id = ?1", + [note_id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(doc_before, 1); + + // Delete the note -- trigger should remove the document + conn.execute("DELETE FROM notes WHERE id = ?1", [note_id]) + .unwrap(); + + let doc_after: i64 = conn + .query_row( + "SELECT COUNT(*) FROM documents WHERE source_type = 'note' AND source_id = ?1", + [note_id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + doc_after, 0, + "notes_ad_cleanup trigger should delete the document" + ); +} + +#[test] +fn test_migration_024_note_system_flip_trigger_cleans_document() { + let conn = setup_migrated_db(); + let pid = insert_test_project(&conn); + let issue_id = insert_test_issue(&conn, pid); + let disc_id = insert_test_discussion(&conn, pid, issue_id); + let note_id = insert_test_note(&conn, 201, disc_id, pid, false); + + // Create a document for this note + insert_test_document(&conn, "note", note_id, pid); + + let doc_before: i64 = conn + .query_row( + "SELECT COUNT(*) FROM documents WHERE source_type = 'note' AND source_id = ?1", + [note_id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(doc_before, 1); + + // Flip is_system from 0 to 1 -- trigger should remove the document + conn.execute("UPDATE notes SET is_system = 1 WHERE id = ?1", [note_id]) + .unwrap(); + + let doc_after: i64 = conn + .query_row( + "SELECT COUNT(*) FROM documents WHERE source_type = 'note' AND source_id = ?1", + [note_id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + doc_after, 0, + "notes_au_system_cleanup trigger should delete the document" + ); +} + +#[test] +fn test_migration_024_system_note_delete_trigger_does_not_fire() { + let conn = setup_migrated_db(); + let pid = insert_test_project(&conn); + let issue_id = insert_test_issue(&conn, pid); + let disc_id = insert_test_discussion(&conn, pid, issue_id); + + // Insert a system note (is_system = true) + let note_id = insert_test_note(&conn, 202, disc_id, pid, true); + + // Manually insert a document (shouldn't exist for system notes in practice, + // but we test the trigger guard) + insert_test_document(&conn, "note", note_id, pid); + + let doc_before: i64 = conn + .query_row( + "SELECT COUNT(*) FROM documents WHERE source_type = 'note' AND source_id = ?1", + [note_id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(doc_before, 1); + + // Delete system note -- trigger has WHEN old.is_system = 0 so it should NOT fire + conn.execute("DELETE FROM notes WHERE id = ?1", [note_id]) + .unwrap(); + + let doc_after: i64 = conn + .query_row( + "SELECT COUNT(*) FROM documents WHERE source_type = 'note' AND source_id = ?1", + [note_id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + doc_after, 1, + "notes_ad_cleanup trigger should NOT fire for system notes" + ); +} + +/// Run migrations only up to version `up_to` (inclusive). +fn run_migrations_up_to(conn: &Connection, up_to: usize) { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS schema_version ( \ + version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL, description TEXT);", + ) + .unwrap(); + + for (version_str, sql) in &MIGRATIONS[..up_to] { + let version: i32 = version_str.parse().unwrap(); + conn.execute_batch(sql).unwrap(); + conn.execute( + "INSERT OR REPLACE INTO schema_version (version, applied_at, description) \ + VALUES (?1, strftime('%s', 'now') * 1000, ?2)", + rusqlite::params![version, version_str], + ) + .unwrap(); + } +} + +/// Run a single migration by index (0-based). +fn run_single_migration(conn: &Connection, index: usize) { + let (version_str, sql) = MIGRATIONS[index]; + let version: i32 = version_str.parse().unwrap(); + conn.execute_batch(sql).unwrap(); + conn.execute( + "INSERT OR REPLACE INTO schema_version (version, applied_at, description) \ + VALUES (?1, strftime('%s', 'now') * 1000, ?2)", + rusqlite::params![version, version_str], + ) + .unwrap(); +} + +#[test] +fn test_migration_025_backfills_existing_notes() { + let conn = create_connection(Path::new(":memory:")).unwrap(); + // Run all migrations through 024 (index 0..24) + run_migrations_up_to(&conn, 24); + + let pid = insert_test_project(&conn); + let issue_id = insert_test_issue(&conn, pid); + let disc_id = insert_test_discussion(&conn, pid, issue_id); + + // Insert 5 non-system notes + for i in 1..=5 { + insert_test_note(&conn, 300 + i, disc_id, pid, false); + } + // Insert 2 system notes + for i in 1..=2 { + insert_test_note(&conn, 400 + i, disc_id, pid, true); + } + + // Run migration 025 + run_single_migration(&conn, 24); + + let dirty_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM dirty_sources WHERE source_type = 'note'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + dirty_count, 5, + "Migration 025 should backfill 5 non-system notes" + ); + + // Verify system notes were not backfilled + let system_note_ids: Vec = { + let mut stmt = conn + .prepare( + "SELECT source_id FROM dirty_sources WHERE source_type = 'note' ORDER BY source_id", + ) + .unwrap(); + stmt.query_map([], |row| row.get(0)) + .unwrap() + .collect::, _>>() + .unwrap() + }; + // System note ids should not appear + let all_system_note_ids: Vec = { + let mut stmt = conn + .prepare("SELECT id FROM notes WHERE is_system = 1 ORDER BY id") + .unwrap(); + stmt.query_map([], |row| row.get(0)) + .unwrap() + .collect::, _>>() + .unwrap() + }; + for sys_id in &all_system_note_ids { + assert!( + !system_note_ids.contains(sys_id), + "System note id {} should not be in dirty_sources", + sys_id + ); + } +} + +#[test] +fn test_migration_025_idempotent_with_existing_documents() { + let conn = create_connection(Path::new(":memory:")).unwrap(); + run_migrations_up_to(&conn, 24); + + let pid = insert_test_project(&conn); + let issue_id = insert_test_issue(&conn, pid); + let disc_id = insert_test_discussion(&conn, pid, issue_id); + + // Insert 3 non-system notes + let note_ids: Vec = (1..=3) + .map(|i| insert_test_note(&conn, 500 + i, disc_id, pid, false)) + .collect(); + + // Create documents for 2 of 3 notes (simulating already-generated docs) + insert_test_document(&conn, "note", note_ids[0], pid); + insert_test_document(&conn, "note", note_ids[1], pid); + + // Run migration 025 + run_single_migration(&conn, 24); + + let dirty_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM dirty_sources WHERE source_type = 'note'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + dirty_count, 1, + "Only the note without a document should be backfilled" + ); + + // Verify the correct note was queued + let queued_id: i64 = conn + .query_row( + "SELECT source_id FROM dirty_sources WHERE source_type = 'note'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(queued_id, note_ids[2]); +} + +#[test] +fn test_migration_025_skips_notes_already_in_dirty_queue() { + let conn = create_connection(Path::new(":memory:")).unwrap(); + run_migrations_up_to(&conn, 24); + + let pid = insert_test_project(&conn); + let issue_id = insert_test_issue(&conn, pid); + let disc_id = insert_test_discussion(&conn, pid, issue_id); + + // Insert 3 non-system notes + let note_ids: Vec = (1..=3) + .map(|i| insert_test_note(&conn, 600 + i, disc_id, pid, false)) + .collect(); + + // Pre-queue one note in dirty_sources + conn.execute( + "INSERT INTO dirty_sources (source_type, source_id, queued_at) VALUES ('note', ?1, 999)", + [note_ids[0]], + ) + .unwrap(); + + // Run migration 025 + run_single_migration(&conn, 24); + + let dirty_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM dirty_sources WHERE source_type = 'note'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + dirty_count, 3, + "All 3 notes should be in dirty_sources (1 pre-existing + 2 new)" + ); + + // Verify the pre-existing entry preserved its original queued_at + let original_queued_at: i64 = conn + .query_row( + "SELECT queued_at FROM dirty_sources WHERE source_type = 'note' AND source_id = ?1", + [note_ids[0]], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + original_queued_at, 999, + "ON CONFLICT DO NOTHING should preserve the original queued_at" + ); +} diff --git a/src/core/note_parser.rs b/src/core/note_parser.rs index f7af7be..0c57dbf 100644 --- a/src/core/note_parser.rs +++ b/src/core/note_parser.rs @@ -234,330 +234,5 @@ fn resolve_cross_project_entity( } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_mentioned_in_mr() { - let refs = parse_cross_refs("mentioned in !567"); - assert_eq!(refs.len(), 1); - assert_eq!(refs[0].reference_type, "mentioned"); - assert_eq!(refs[0].target_entity_type, "merge_request"); - assert_eq!(refs[0].target_iid, 567); - assert!(refs[0].target_project_path.is_none()); - } - - #[test] - fn test_parse_mentioned_in_issue() { - let refs = parse_cross_refs("mentioned in #234"); - assert_eq!(refs.len(), 1); - assert_eq!(refs[0].reference_type, "mentioned"); - assert_eq!(refs[0].target_entity_type, "issue"); - assert_eq!(refs[0].target_iid, 234); - assert!(refs[0].target_project_path.is_none()); - } - - #[test] - fn test_parse_mentioned_cross_project() { - let refs = parse_cross_refs("mentioned in group/repo!789"); - assert_eq!(refs.len(), 1); - assert_eq!(refs[0].reference_type, "mentioned"); - assert_eq!(refs[0].target_entity_type, "merge_request"); - assert_eq!(refs[0].target_iid, 789); - assert_eq!(refs[0].target_project_path.as_deref(), Some("group/repo")); - } - - #[test] - fn test_parse_mentioned_cross_project_issue() { - let refs = parse_cross_refs("mentioned in group/repo#123"); - assert_eq!(refs.len(), 1); - assert_eq!(refs[0].reference_type, "mentioned"); - assert_eq!(refs[0].target_entity_type, "issue"); - assert_eq!(refs[0].target_iid, 123); - assert_eq!(refs[0].target_project_path.as_deref(), Some("group/repo")); - } - - #[test] - fn test_parse_closed_by_mr() { - let refs = parse_cross_refs("closed by !567"); - assert_eq!(refs.len(), 1); - assert_eq!(refs[0].reference_type, "closes"); - assert_eq!(refs[0].target_entity_type, "merge_request"); - assert_eq!(refs[0].target_iid, 567); - assert!(refs[0].target_project_path.is_none()); - } - - #[test] - fn test_parse_closed_by_cross_project() { - let refs = parse_cross_refs("closed by group/repo!789"); - assert_eq!(refs.len(), 1); - assert_eq!(refs[0].reference_type, "closes"); - assert_eq!(refs[0].target_entity_type, "merge_request"); - assert_eq!(refs[0].target_iid, 789); - assert_eq!(refs[0].target_project_path.as_deref(), Some("group/repo")); - } - - #[test] - fn test_parse_multiple_refs() { - let refs = parse_cross_refs("mentioned in !123 and mentioned in #456"); - assert_eq!(refs.len(), 2); - assert_eq!(refs[0].target_entity_type, "merge_request"); - assert_eq!(refs[0].target_iid, 123); - assert_eq!(refs[1].target_entity_type, "issue"); - assert_eq!(refs[1].target_iid, 456); - } - - #[test] - fn test_parse_no_refs() { - let refs = parse_cross_refs("Updated the description"); - assert!(refs.is_empty()); - } - - #[test] - fn test_parse_non_english_note() { - let refs = parse_cross_refs("a ajout\u{00e9} l'\u{00e9}tiquette ~bug"); - assert!(refs.is_empty()); - } - - #[test] - fn test_parse_multi_level_group_path() { - let refs = parse_cross_refs("mentioned in top/sub/project#123"); - assert_eq!(refs.len(), 1); - assert_eq!( - refs[0].target_project_path.as_deref(), - Some("top/sub/project") - ); - assert_eq!(refs[0].target_iid, 123); - } - - #[test] - fn test_parse_deeply_nested_group_path() { - let refs = parse_cross_refs("mentioned in a/b/c/d/e!42"); - assert_eq!(refs.len(), 1); - assert_eq!(refs[0].target_project_path.as_deref(), Some("a/b/c/d/e")); - assert_eq!(refs[0].target_iid, 42); - } - - #[test] - fn test_parse_hyphenated_project_path() { - let refs = parse_cross_refs("mentioned in my-group/my-project#99"); - assert_eq!(refs.len(), 1); - assert_eq!( - refs[0].target_project_path.as_deref(), - Some("my-group/my-project") - ); - } - - #[test] - fn test_parse_dotted_project_path() { - let refs = parse_cross_refs("mentioned in visiostack.io/backend#123"); - assert_eq!(refs.len(), 1); - assert_eq!( - refs[0].target_project_path.as_deref(), - Some("visiostack.io/backend") - ); - assert_eq!(refs[0].target_iid, 123); - } - - #[test] - fn test_parse_dotted_nested_project_path() { - let refs = parse_cross_refs("closed by my.org/sub.group/my.project!42"); - assert_eq!(refs.len(), 1); - assert_eq!( - refs[0].target_project_path.as_deref(), - Some("my.org/sub.group/my.project") - ); - assert_eq!(refs[0].target_entity_type, "merge_request"); - assert_eq!(refs[0].target_iid, 42); - } - - #[test] - fn test_parse_self_reference_is_valid() { - let refs = parse_cross_refs("mentioned in #123"); - assert_eq!(refs.len(), 1); - assert_eq!(refs[0].target_iid, 123); - } - - #[test] - fn test_parse_mixed_mentioned_and_closed() { - let refs = parse_cross_refs("mentioned in !10 and closed by !20"); - assert_eq!(refs.len(), 2); - assert_eq!(refs[0].reference_type, "mentioned"); - assert_eq!(refs[0].target_iid, 10); - assert_eq!(refs[1].reference_type, "closes"); - assert_eq!(refs[1].target_iid, 20); - } - - fn setup_test_db() -> Connection { - use crate::core::db::{create_connection, run_migrations}; - - let conn = create_connection(std::path::Path::new(":memory:")).unwrap(); - run_migrations(&conn).unwrap(); - conn - } - - fn seed_test_data(conn: &Connection) -> i64 { - let now = now_ms(); - - conn.execute( - "INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url, created_at, updated_at) - VALUES (1, 100, 'group/test-project', 'https://gitlab.com/group/test-project', ?1, ?1)", - [now], - ) - .unwrap(); - - conn.execute( - "INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at) - VALUES (10, 1000, 1, 123, 'Test Issue', 'opened', ?1, ?1, ?1)", - [now], - ) - .unwrap(); - - conn.execute( - "INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at) - VALUES (11, 1001, 1, 456, 'Another Issue', 'opened', ?1, ?1, ?1)", - [now], - ) - .unwrap(); - - conn.execute( - "INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, state, source_branch, target_branch, author_username, created_at, updated_at, last_seen_at) - VALUES (20, 2000, 1, 789, 'Test MR', 'opened', 'feat', 'main', 'dev', ?1, ?1, ?1)", - [now], - ) - .unwrap(); - - conn.execute( - "INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at) - VALUES (30, 'disc-aaa', 1, 10, 'Issue', ?1)", - [now], - ) - .unwrap(); - - conn.execute( - "INSERT INTO discussions (id, gitlab_discussion_id, project_id, merge_request_id, noteable_type, last_seen_at) - VALUES (31, 'disc-bbb', 1, 20, 'MergeRequest', ?1)", - [now], - ) - .unwrap(); - - conn.execute( - "INSERT INTO notes (id, gitlab_id, discussion_id, project_id, is_system, body, created_at, updated_at, last_seen_at) - VALUES (40, 4000, 30, 1, 1, 'mentioned in !789', ?1, ?1, ?1)", - [now], - ) - .unwrap(); - - conn.execute( - "INSERT INTO notes (id, gitlab_id, discussion_id, project_id, is_system, body, created_at, updated_at, last_seen_at) - VALUES (41, 4001, 31, 1, 1, 'mentioned in #456', ?1, ?1, ?1)", - [now], - ) - .unwrap(); - - conn.execute( - "INSERT INTO notes (id, gitlab_id, discussion_id, project_id, is_system, body, created_at, updated_at, last_seen_at) - VALUES (42, 4002, 30, 1, 0, 'mentioned in !999', ?1, ?1, ?1)", - [now], - ) - .unwrap(); - - conn.execute( - "INSERT INTO notes (id, gitlab_id, discussion_id, project_id, is_system, body, created_at, updated_at, last_seen_at) - VALUES (43, 4003, 30, 1, 1, 'added label ~bug', ?1, ?1, ?1)", - [now], - ) - .unwrap(); - - conn.execute( - "INSERT INTO notes (id, gitlab_id, discussion_id, project_id, is_system, body, created_at, updated_at, last_seen_at) - VALUES (44, 4004, 30, 1, 1, 'mentioned in other/project#999', ?1, ?1, ?1)", - [now], - ) - .unwrap(); - - 1 - } - - #[test] - fn test_extract_refs_from_system_notes_integration() { - let conn = setup_test_db(); - let project_id = seed_test_data(&conn); - - let result = extract_refs_from_system_notes(&conn, project_id).unwrap(); - - assert_eq!(result.inserted, 2, "Two same-project refs should resolve"); - assert_eq!( - result.skipped_unresolvable, 1, - "One cross-project ref should be unresolvable" - ); - assert_eq!( - result.parse_failures, 1, - "One system note has no cross-ref pattern" - ); - - let ref_count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM entity_references WHERE project_id = ?1 AND source_method = 'note_parse'", - [project_id], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(ref_count, 3, "Should have 3 entity_references rows total"); - - let unresolved_count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM entity_references WHERE target_entity_id IS NULL AND source_method = 'note_parse'", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!( - unresolved_count, 1, - "Should have 1 unresolved cross-project ref" - ); - - let (path, iid): (String, i64) = conn - .query_row( - "SELECT target_project_path, target_entity_iid FROM entity_references WHERE target_entity_id IS NULL", - [], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .unwrap(); - assert_eq!(path, "other/project"); - assert_eq!(iid, 999); - } - - #[test] - fn test_extract_refs_idempotent() { - let conn = setup_test_db(); - let project_id = seed_test_data(&conn); - - let result1 = extract_refs_from_system_notes(&conn, project_id).unwrap(); - let result2 = extract_refs_from_system_notes(&conn, project_id).unwrap(); - - assert_eq!(result2.inserted, 0); - assert_eq!(result2.skipped_unresolvable, 0); - - let total: i64 = conn - .query_row( - "SELECT COUNT(*) FROM entity_references WHERE source_method = 'note_parse'", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!( - total, - (result1.inserted + result1.skipped_unresolvable) as i64 - ); - } - - #[test] - fn test_extract_refs_empty_project() { - let conn = setup_test_db(); - let result = extract_refs_from_system_notes(&conn, 999).unwrap(); - assert_eq!(result.inserted, 0); - assert_eq!(result.skipped_unresolvable, 0); - assert_eq!(result.parse_failures, 0); - } -} +#[path = "note_parser_tests.rs"] +mod tests; diff --git a/src/core/note_parser_tests.rs b/src/core/note_parser_tests.rs new file mode 100644 index 0000000..0cba3b2 --- /dev/null +++ b/src/core/note_parser_tests.rs @@ -0,0 +1,325 @@ +use super::*; + +#[test] +fn test_parse_mentioned_in_mr() { + let refs = parse_cross_refs("mentioned in !567"); + assert_eq!(refs.len(), 1); + assert_eq!(refs[0].reference_type, "mentioned"); + assert_eq!(refs[0].target_entity_type, "merge_request"); + assert_eq!(refs[0].target_iid, 567); + assert!(refs[0].target_project_path.is_none()); +} + +#[test] +fn test_parse_mentioned_in_issue() { + let refs = parse_cross_refs("mentioned in #234"); + assert_eq!(refs.len(), 1); + assert_eq!(refs[0].reference_type, "mentioned"); + assert_eq!(refs[0].target_entity_type, "issue"); + assert_eq!(refs[0].target_iid, 234); + assert!(refs[0].target_project_path.is_none()); +} + +#[test] +fn test_parse_mentioned_cross_project() { + let refs = parse_cross_refs("mentioned in group/repo!789"); + assert_eq!(refs.len(), 1); + assert_eq!(refs[0].reference_type, "mentioned"); + assert_eq!(refs[0].target_entity_type, "merge_request"); + assert_eq!(refs[0].target_iid, 789); + assert_eq!(refs[0].target_project_path.as_deref(), Some("group/repo")); +} + +#[test] +fn test_parse_mentioned_cross_project_issue() { + let refs = parse_cross_refs("mentioned in group/repo#123"); + assert_eq!(refs.len(), 1); + assert_eq!(refs[0].reference_type, "mentioned"); + assert_eq!(refs[0].target_entity_type, "issue"); + assert_eq!(refs[0].target_iid, 123); + assert_eq!(refs[0].target_project_path.as_deref(), Some("group/repo")); +} + +#[test] +fn test_parse_closed_by_mr() { + let refs = parse_cross_refs("closed by !567"); + assert_eq!(refs.len(), 1); + assert_eq!(refs[0].reference_type, "closes"); + assert_eq!(refs[0].target_entity_type, "merge_request"); + assert_eq!(refs[0].target_iid, 567); + assert!(refs[0].target_project_path.is_none()); +} + +#[test] +fn test_parse_closed_by_cross_project() { + let refs = parse_cross_refs("closed by group/repo!789"); + assert_eq!(refs.len(), 1); + assert_eq!(refs[0].reference_type, "closes"); + assert_eq!(refs[0].target_entity_type, "merge_request"); + assert_eq!(refs[0].target_iid, 789); + assert_eq!(refs[0].target_project_path.as_deref(), Some("group/repo")); +} + +#[test] +fn test_parse_multiple_refs() { + let refs = parse_cross_refs("mentioned in !123 and mentioned in #456"); + assert_eq!(refs.len(), 2); + assert_eq!(refs[0].target_entity_type, "merge_request"); + assert_eq!(refs[0].target_iid, 123); + assert_eq!(refs[1].target_entity_type, "issue"); + assert_eq!(refs[1].target_iid, 456); +} + +#[test] +fn test_parse_no_refs() { + let refs = parse_cross_refs("Updated the description"); + assert!(refs.is_empty()); +} + +#[test] +fn test_parse_non_english_note() { + let refs = parse_cross_refs("a ajout\u{00e9} l'\u{00e9}tiquette ~bug"); + assert!(refs.is_empty()); +} + +#[test] +fn test_parse_multi_level_group_path() { + let refs = parse_cross_refs("mentioned in top/sub/project#123"); + assert_eq!(refs.len(), 1); + assert_eq!( + refs[0].target_project_path.as_deref(), + Some("top/sub/project") + ); + assert_eq!(refs[0].target_iid, 123); +} + +#[test] +fn test_parse_deeply_nested_group_path() { + let refs = parse_cross_refs("mentioned in a/b/c/d/e!42"); + assert_eq!(refs.len(), 1); + assert_eq!(refs[0].target_project_path.as_deref(), Some("a/b/c/d/e")); + assert_eq!(refs[0].target_iid, 42); +} + +#[test] +fn test_parse_hyphenated_project_path() { + let refs = parse_cross_refs("mentioned in my-group/my-project#99"); + assert_eq!(refs.len(), 1); + assert_eq!( + refs[0].target_project_path.as_deref(), + Some("my-group/my-project") + ); +} + +#[test] +fn test_parse_dotted_project_path() { + let refs = parse_cross_refs("mentioned in visiostack.io/backend#123"); + assert_eq!(refs.len(), 1); + assert_eq!( + refs[0].target_project_path.as_deref(), + Some("visiostack.io/backend") + ); + assert_eq!(refs[0].target_iid, 123); +} + +#[test] +fn test_parse_dotted_nested_project_path() { + let refs = parse_cross_refs("closed by my.org/sub.group/my.project!42"); + assert_eq!(refs.len(), 1); + assert_eq!( + refs[0].target_project_path.as_deref(), + Some("my.org/sub.group/my.project") + ); + assert_eq!(refs[0].target_entity_type, "merge_request"); + assert_eq!(refs[0].target_iid, 42); +} + +#[test] +fn test_parse_self_reference_is_valid() { + let refs = parse_cross_refs("mentioned in #123"); + assert_eq!(refs.len(), 1); + assert_eq!(refs[0].target_iid, 123); +} + +#[test] +fn test_parse_mixed_mentioned_and_closed() { + let refs = parse_cross_refs("mentioned in !10 and closed by !20"); + assert_eq!(refs.len(), 2); + assert_eq!(refs[0].reference_type, "mentioned"); + assert_eq!(refs[0].target_iid, 10); + assert_eq!(refs[1].reference_type, "closes"); + assert_eq!(refs[1].target_iid, 20); +} + +fn setup_test_db() -> Connection { + use crate::core::db::{create_connection, run_migrations}; + + let conn = create_connection(std::path::Path::new(":memory:")).unwrap(); + run_migrations(&conn).unwrap(); + conn +} + +fn seed_test_data(conn: &Connection) -> i64 { + let now = now_ms(); + + conn.execute( + "INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url, created_at, updated_at) + VALUES (1, 100, 'group/test-project', 'https://gitlab.com/group/test-project', ?1, ?1)", + [now], + ) + .unwrap(); + + conn.execute( + "INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at) + VALUES (10, 1000, 1, 123, 'Test Issue', 'opened', ?1, ?1, ?1)", + [now], + ) + .unwrap(); + + conn.execute( + "INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at) + VALUES (11, 1001, 1, 456, 'Another Issue', 'opened', ?1, ?1, ?1)", + [now], + ) + .unwrap(); + + conn.execute( + "INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, state, source_branch, target_branch, author_username, created_at, updated_at, last_seen_at) + VALUES (20, 2000, 1, 789, 'Test MR', 'opened', 'feat', 'main', 'dev', ?1, ?1, ?1)", + [now], + ) + .unwrap(); + + conn.execute( + "INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at) + VALUES (30, 'disc-aaa', 1, 10, 'Issue', ?1)", + [now], + ) + .unwrap(); + + conn.execute( + "INSERT INTO discussions (id, gitlab_discussion_id, project_id, merge_request_id, noteable_type, last_seen_at) + VALUES (31, 'disc-bbb', 1, 20, 'MergeRequest', ?1)", + [now], + ) + .unwrap(); + + conn.execute( + "INSERT INTO notes (id, gitlab_id, discussion_id, project_id, is_system, body, created_at, updated_at, last_seen_at) + VALUES (40, 4000, 30, 1, 1, 'mentioned in !789', ?1, ?1, ?1)", + [now], + ) + .unwrap(); + + conn.execute( + "INSERT INTO notes (id, gitlab_id, discussion_id, project_id, is_system, body, created_at, updated_at, last_seen_at) + VALUES (41, 4001, 31, 1, 1, 'mentioned in #456', ?1, ?1, ?1)", + [now], + ) + .unwrap(); + + conn.execute( + "INSERT INTO notes (id, gitlab_id, discussion_id, project_id, is_system, body, created_at, updated_at, last_seen_at) + VALUES (42, 4002, 30, 1, 0, 'mentioned in !999', ?1, ?1, ?1)", + [now], + ) + .unwrap(); + + conn.execute( + "INSERT INTO notes (id, gitlab_id, discussion_id, project_id, is_system, body, created_at, updated_at, last_seen_at) + VALUES (43, 4003, 30, 1, 1, 'added label ~bug', ?1, ?1, ?1)", + [now], + ) + .unwrap(); + + conn.execute( + "INSERT INTO notes (id, gitlab_id, discussion_id, project_id, is_system, body, created_at, updated_at, last_seen_at) + VALUES (44, 4004, 30, 1, 1, 'mentioned in other/project#999', ?1, ?1, ?1)", + [now], + ) + .unwrap(); + + 1 +} + +#[test] +fn test_extract_refs_from_system_notes_integration() { + let conn = setup_test_db(); + let project_id = seed_test_data(&conn); + + let result = extract_refs_from_system_notes(&conn, project_id).unwrap(); + + assert_eq!(result.inserted, 2, "Two same-project refs should resolve"); + assert_eq!( + result.skipped_unresolvable, 1, + "One cross-project ref should be unresolvable" + ); + assert_eq!( + result.parse_failures, 1, + "One system note has no cross-ref pattern" + ); + + let ref_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM entity_references WHERE project_id = ?1 AND source_method = 'note_parse'", + [project_id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(ref_count, 3, "Should have 3 entity_references rows total"); + + let unresolved_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM entity_references WHERE target_entity_id IS NULL AND source_method = 'note_parse'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + unresolved_count, 1, + "Should have 1 unresolved cross-project ref" + ); + + let (path, iid): (String, i64) = conn + .query_row( + "SELECT target_project_path, target_entity_iid FROM entity_references WHERE target_entity_id IS NULL", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(path, "other/project"); + assert_eq!(iid, 999); +} + +#[test] +fn test_extract_refs_idempotent() { + let conn = setup_test_db(); + let project_id = seed_test_data(&conn); + + let result1 = extract_refs_from_system_notes(&conn, project_id).unwrap(); + let result2 = extract_refs_from_system_notes(&conn, project_id).unwrap(); + + assert_eq!(result2.inserted, 0); + assert_eq!(result2.skipped_unresolvable, 0); + + let total: i64 = conn + .query_row( + "SELECT COUNT(*) FROM entity_references WHERE source_method = 'note_parse'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + total, + (result1.inserted + result1.skipped_unresolvable) as i64 + ); +} + +#[test] +fn test_extract_refs_empty_project() { + let conn = setup_test_db(); + let result = extract_refs_from_system_notes(&conn, 999).unwrap(); + assert_eq!(result.inserted, 0); + assert_eq!(result.skipped_unresolvable, 0); + assert_eq!(result.parse_failures, 0); +} diff --git a/src/core/payloads.rs b/src/core/payloads.rs index b8e6420..6963f7e 100644 --- a/src/core/payloads.rs +++ b/src/core/payloads.rs @@ -95,110 +95,5 @@ pub fn read_payload(conn: &Connection, id: i64) -> Result Connection { - let dir = tempdir().unwrap(); - let db_path = dir.path().join("test.db"); - let conn = create_connection(&db_path).unwrap(); - - conn.execute_batch( - "CREATE TABLE raw_payloads ( - id INTEGER PRIMARY KEY, - source TEXT NOT NULL, - project_id INTEGER, - resource_type TEXT NOT NULL, - gitlab_id TEXT NOT NULL, - fetched_at INTEGER NOT NULL, - content_encoding TEXT NOT NULL DEFAULT 'identity', - payload_hash TEXT NOT NULL, - payload BLOB NOT NULL - ); - CREATE UNIQUE INDEX uq_raw_payloads_dedupe - ON raw_payloads(project_id, resource_type, gitlab_id, payload_hash);", - ) - .unwrap(); - - conn - } - - #[test] - fn test_store_and_read_payload() { - let conn = setup_test_db(); - let payload = serde_json::json!({"title": "Test Issue", "id": 123}); - let json_bytes = serde_json::to_vec(&payload).unwrap(); - - let id = store_payload( - &conn, - StorePayloadOptions { - project_id: Some(1), - resource_type: "issue", - gitlab_id: "123", - json_bytes: &json_bytes, - compress: false, - }, - ) - .unwrap(); - - let result = read_payload(&conn, id).unwrap().unwrap(); - assert_eq!(result["title"], "Test Issue"); - } - - #[test] - fn test_compression_roundtrip() { - let conn = setup_test_db(); - let payload = serde_json::json!({"data": "x".repeat(1000)}); - let json_bytes = serde_json::to_vec(&payload).unwrap(); - - let id = store_payload( - &conn, - StorePayloadOptions { - project_id: Some(1), - resource_type: "issue", - gitlab_id: "456", - json_bytes: &json_bytes, - compress: true, - }, - ) - .unwrap(); - - let result = read_payload(&conn, id).unwrap().unwrap(); - assert_eq!(result["data"], "x".repeat(1000)); - } - - #[test] - fn test_deduplication() { - let conn = setup_test_db(); - let payload = serde_json::json!({"id": 789}); - let json_bytes = serde_json::to_vec(&payload).unwrap(); - - let id1 = store_payload( - &conn, - StorePayloadOptions { - project_id: Some(1), - resource_type: "issue", - gitlab_id: "789", - json_bytes: &json_bytes, - compress: false, - }, - ) - .unwrap(); - - let id2 = store_payload( - &conn, - StorePayloadOptions { - project_id: Some(1), - resource_type: "issue", - gitlab_id: "789", - json_bytes: &json_bytes, - compress: false, - }, - ) - .unwrap(); - - assert_eq!(id1, id2); - } -} +#[path = "payloads_tests.rs"] +mod tests; diff --git a/src/core/payloads_tests.rs b/src/core/payloads_tests.rs new file mode 100644 index 0000000..27801f5 --- /dev/null +++ b/src/core/payloads_tests.rs @@ -0,0 +1,105 @@ +use super::*; +use crate::core::db::create_connection; +use tempfile::tempdir; + +fn setup_test_db() -> Connection { + let dir = tempdir().unwrap(); + let db_path = dir.path().join("test.db"); + let conn = create_connection(&db_path).unwrap(); + + conn.execute_batch( + "CREATE TABLE raw_payloads ( + id INTEGER PRIMARY KEY, + source TEXT NOT NULL, + project_id INTEGER, + resource_type TEXT NOT NULL, + gitlab_id TEXT NOT NULL, + fetched_at INTEGER NOT NULL, + content_encoding TEXT NOT NULL DEFAULT 'identity', + payload_hash TEXT NOT NULL, + payload BLOB NOT NULL + ); + CREATE UNIQUE INDEX uq_raw_payloads_dedupe + ON raw_payloads(project_id, resource_type, gitlab_id, payload_hash);", + ) + .unwrap(); + + conn +} + +#[test] +fn test_store_and_read_payload() { + let conn = setup_test_db(); + let payload = serde_json::json!({"title": "Test Issue", "id": 123}); + let json_bytes = serde_json::to_vec(&payload).unwrap(); + + let id = store_payload( + &conn, + StorePayloadOptions { + project_id: Some(1), + resource_type: "issue", + gitlab_id: "123", + json_bytes: &json_bytes, + compress: false, + }, + ) + .unwrap(); + + let result = read_payload(&conn, id).unwrap().unwrap(); + assert_eq!(result["title"], "Test Issue"); +} + +#[test] +fn test_compression_roundtrip() { + let conn = setup_test_db(); + let payload = serde_json::json!({"data": "x".repeat(1000)}); + let json_bytes = serde_json::to_vec(&payload).unwrap(); + + let id = store_payload( + &conn, + StorePayloadOptions { + project_id: Some(1), + resource_type: "issue", + gitlab_id: "456", + json_bytes: &json_bytes, + compress: true, + }, + ) + .unwrap(); + + let result = read_payload(&conn, id).unwrap().unwrap(); + assert_eq!(result["data"], "x".repeat(1000)); +} + +#[test] +fn test_deduplication() { + let conn = setup_test_db(); + let payload = serde_json::json!({"id": 789}); + let json_bytes = serde_json::to_vec(&payload).unwrap(); + + let id1 = store_payload( + &conn, + StorePayloadOptions { + project_id: Some(1), + resource_type: "issue", + gitlab_id: "789", + json_bytes: &json_bytes, + compress: false, + }, + ) + .unwrap(); + + let id2 = store_payload( + &conn, + StorePayloadOptions { + project_id: Some(1), + resource_type: "issue", + gitlab_id: "789", + json_bytes: &json_bytes, + compress: false, + }, + ) + .unwrap(); + + assert_eq!(id1, id2); +} diff --git a/src/core/project.rs b/src/core/project.rs index 055e7a3..0f362aa 100644 --- a/src/core/project.rs +++ b/src/core/project.rs @@ -114,161 +114,5 @@ fn escape_like(input: &str) -> String { } #[cfg(test)] -mod tests { - use super::*; - - fn setup_db() -> Connection { - let conn = Connection::open_in_memory().unwrap(); - conn.execute_batch( - " - CREATE TABLE projects ( - id INTEGER PRIMARY KEY, - gitlab_project_id INTEGER UNIQUE NOT NULL, - path_with_namespace TEXT NOT NULL, - default_branch TEXT, - web_url TEXT, - created_at INTEGER, - updated_at INTEGER, - raw_payload_id INTEGER - ); - CREATE INDEX idx_projects_path ON projects(path_with_namespace); - ", - ) - .unwrap(); - conn - } - - fn insert_project(conn: &Connection, id: i64, path: &str) { - conn.execute( - "INSERT INTO projects (id, gitlab_project_id, path_with_namespace) VALUES (?1, ?2, ?3)", - rusqlite::params![id, id * 100, path], - ) - .unwrap(); - } - - #[test] - fn test_exact_match() { - let conn = setup_db(); - insert_project(&conn, 1, "backend/auth-service"); - let id = resolve_project(&conn, "backend/auth-service").unwrap(); - assert_eq!(id, 1); - } - - #[test] - fn test_case_insensitive() { - let conn = setup_db(); - insert_project(&conn, 1, "backend/auth-service"); - let id = resolve_project(&conn, "Backend/Auth-Service").unwrap(); - assert_eq!(id, 1); - } - - #[test] - fn test_suffix_unambiguous() { - let conn = setup_db(); - insert_project(&conn, 1, "backend/auth-service"); - insert_project(&conn, 2, "frontend/web-ui"); - let id = resolve_project(&conn, "auth-service").unwrap(); - assert_eq!(id, 1); - } - - #[test] - fn test_suffix_ambiguous() { - let conn = setup_db(); - insert_project(&conn, 1, "backend/auth-service"); - insert_project(&conn, 2, "frontend/auth-service"); - let err = resolve_project(&conn, "auth-service").unwrap_err(); - let msg = err.to_string(); - assert!( - msg.contains("ambiguous"), - "Expected ambiguous error, got: {}", - msg - ); - assert!(msg.contains("backend/auth-service")); - assert!(msg.contains("frontend/auth-service")); - } - - #[test] - fn test_substring_unambiguous() { - let conn = setup_db(); - insert_project(&conn, 1, "vs/python-code"); - insert_project(&conn, 2, "vs/typescript-code"); - let id = resolve_project(&conn, "typescript").unwrap(); - assert_eq!(id, 2); - } - - #[test] - fn test_substring_case_insensitive() { - let conn = setup_db(); - insert_project(&conn, 1, "vs/python-code"); - insert_project(&conn, 2, "vs/typescript-code"); - let id = resolve_project(&conn, "TypeScript").unwrap(); - assert_eq!(id, 2); - } - - #[test] - fn test_substring_ambiguous() { - let conn = setup_db(); - insert_project(&conn, 1, "vs/python-code"); - insert_project(&conn, 2, "vs/typescript-code"); - let err = resolve_project(&conn, "code").unwrap_err(); - let msg = err.to_string(); - assert!( - msg.contains("ambiguous"), - "Expected ambiguous error, got: {}", - msg - ); - assert!(msg.contains("vs/python-code")); - assert!(msg.contains("vs/typescript-code")); - } - - #[test] - fn test_suffix_preferred_over_substring() { - let conn = setup_db(); - insert_project(&conn, 1, "backend/auth-service"); - insert_project(&conn, 2, "backend/auth-service-v2"); - let id = resolve_project(&conn, "auth-service").unwrap(); - assert_eq!(id, 1); - } - - #[test] - fn test_no_match() { - let conn = setup_db(); - insert_project(&conn, 1, "backend/auth-service"); - let err = resolve_project(&conn, "nonexistent").unwrap_err(); - let msg = err.to_string(); - assert!( - msg.contains("not found"), - "Expected not found error, got: {}", - msg - ); - assert!(msg.contains("backend/auth-service")); - } - - #[test] - fn test_empty_projects() { - let conn = setup_db(); - let err = resolve_project(&conn, "anything").unwrap_err(); - let msg = err.to_string(); - assert!(msg.contains("No projects have been synced")); - } - - #[test] - fn test_underscore_not_wildcard() { - let conn = setup_db(); - insert_project(&conn, 1, "backend/my_project"); - insert_project(&conn, 2, "backend/my-project"); - // `_` in user input must not match `-` (LIKE wildcard behavior) - let id = resolve_project(&conn, "my_project").unwrap(); - assert_eq!(id, 1); - } - - #[test] - fn test_percent_not_wildcard() { - let conn = setup_db(); - insert_project(&conn, 1, "backend/a%b"); - insert_project(&conn, 2, "backend/axyzb"); - // `%` in user input must not match arbitrary strings - let id = resolve_project(&conn, "a%b").unwrap(); - assert_eq!(id, 1); - } -} +#[path = "project_tests.rs"] +mod tests; diff --git a/src/core/project_tests.rs b/src/core/project_tests.rs new file mode 100644 index 0000000..e28129d --- /dev/null +++ b/src/core/project_tests.rs @@ -0,0 +1,156 @@ +use super::*; + +fn setup_db() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + " + CREATE TABLE projects ( + id INTEGER PRIMARY KEY, + gitlab_project_id INTEGER UNIQUE NOT NULL, + path_with_namespace TEXT NOT NULL, + default_branch TEXT, + web_url TEXT, + created_at INTEGER, + updated_at INTEGER, + raw_payload_id INTEGER + ); + CREATE INDEX idx_projects_path ON projects(path_with_namespace); + ", + ) + .unwrap(); + conn +} + +fn insert_project(conn: &Connection, id: i64, path: &str) { + conn.execute( + "INSERT INTO projects (id, gitlab_project_id, path_with_namespace) VALUES (?1, ?2, ?3)", + rusqlite::params![id, id * 100, path], + ) + .unwrap(); +} + +#[test] +fn test_exact_match() { + let conn = setup_db(); + insert_project(&conn, 1, "backend/auth-service"); + let id = resolve_project(&conn, "backend/auth-service").unwrap(); + assert_eq!(id, 1); +} + +#[test] +fn test_case_insensitive() { + let conn = setup_db(); + insert_project(&conn, 1, "backend/auth-service"); + let id = resolve_project(&conn, "Backend/Auth-Service").unwrap(); + assert_eq!(id, 1); +} + +#[test] +fn test_suffix_unambiguous() { + let conn = setup_db(); + insert_project(&conn, 1, "backend/auth-service"); + insert_project(&conn, 2, "frontend/web-ui"); + let id = resolve_project(&conn, "auth-service").unwrap(); + assert_eq!(id, 1); +} + +#[test] +fn test_suffix_ambiguous() { + let conn = setup_db(); + insert_project(&conn, 1, "backend/auth-service"); + insert_project(&conn, 2, "frontend/auth-service"); + let err = resolve_project(&conn, "auth-service").unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("ambiguous"), + "Expected ambiguous error, got: {}", + msg + ); + assert!(msg.contains("backend/auth-service")); + assert!(msg.contains("frontend/auth-service")); +} + +#[test] +fn test_substring_unambiguous() { + let conn = setup_db(); + insert_project(&conn, 1, "vs/python-code"); + insert_project(&conn, 2, "vs/typescript-code"); + let id = resolve_project(&conn, "typescript").unwrap(); + assert_eq!(id, 2); +} + +#[test] +fn test_substring_case_insensitive() { + let conn = setup_db(); + insert_project(&conn, 1, "vs/python-code"); + insert_project(&conn, 2, "vs/typescript-code"); + let id = resolve_project(&conn, "TypeScript").unwrap(); + assert_eq!(id, 2); +} + +#[test] +fn test_substring_ambiguous() { + let conn = setup_db(); + insert_project(&conn, 1, "vs/python-code"); + insert_project(&conn, 2, "vs/typescript-code"); + let err = resolve_project(&conn, "code").unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("ambiguous"), + "Expected ambiguous error, got: {}", + msg + ); + assert!(msg.contains("vs/python-code")); + assert!(msg.contains("vs/typescript-code")); +} + +#[test] +fn test_suffix_preferred_over_substring() { + let conn = setup_db(); + insert_project(&conn, 1, "backend/auth-service"); + insert_project(&conn, 2, "backend/auth-service-v2"); + let id = resolve_project(&conn, "auth-service").unwrap(); + assert_eq!(id, 1); +} + +#[test] +fn test_no_match() { + let conn = setup_db(); + insert_project(&conn, 1, "backend/auth-service"); + let err = resolve_project(&conn, "nonexistent").unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("not found"), + "Expected not found error, got: {}", + msg + ); + assert!(msg.contains("backend/auth-service")); +} + +#[test] +fn test_empty_projects() { + let conn = setup_db(); + let err = resolve_project(&conn, "anything").unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("No projects have been synced")); +} + +#[test] +fn test_underscore_not_wildcard() { + let conn = setup_db(); + insert_project(&conn, 1, "backend/my_project"); + insert_project(&conn, 2, "backend/my-project"); + // `_` in user input must not match `-` (LIKE wildcard behavior) + let id = resolve_project(&conn, "my_project").unwrap(); + assert_eq!(id, 1); +} + +#[test] +fn test_percent_not_wildcard() { + let conn = setup_db(); + insert_project(&conn, 1, "backend/a%b"); + insert_project(&conn, 2, "backend/axyzb"); + // `%` in user input must not match arbitrary strings + let id = resolve_project(&conn, "a%b").unwrap(); + assert_eq!(id, 1); +} diff --git a/src/core/references.rs b/src/core/references.rs index bc92c61..c55039b 100644 --- a/src/core/references.rs +++ b/src/core/references.rs @@ -122,430 +122,5 @@ pub fn count_references_for_source( } #[cfg(test)] -mod tests { - use super::*; - use crate::core::db::{create_connection, run_migrations}; - use std::path::Path; - - fn setup_test_db() -> Connection { - let conn = create_connection(Path::new(":memory:")).unwrap(); - run_migrations(&conn).unwrap(); - conn - } - - fn seed_project_issue_mr(conn: &Connection) -> (i64, i64, i64) { - conn.execute( - "INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url, created_at, updated_at) - VALUES (1, 100, 'group/repo', 'https://gitlab.example.com/group/repo', 1000, 2000)", - [], - ) - .unwrap(); - - conn.execute( - "INSERT INTO issues (id, gitlab_id, iid, project_id, title, state, created_at, updated_at, last_seen_at) - VALUES (1, 200, 10, 1, 'Test issue', 'closed', 1000, 2000, 2000)", - [], - ) - .unwrap(); - - conn.execute( - "INSERT INTO merge_requests (id, gitlab_id, iid, project_id, title, state, created_at, updated_at, last_seen_at, source_branch, target_branch) - VALUES (1, 300, 5, 1, 'Test MR', 'merged', 1000, 2000, 2000, 'feature', 'main')", - [], - ) - .unwrap(); - - (1, 1, 1) - } - - #[test] - fn test_extract_refs_from_state_events_basic() { - let conn = setup_test_db(); - let (project_id, issue_id, mr_id) = seed_project_issue_mr(&conn); - - conn.execute( - "INSERT INTO resource_state_events - (gitlab_id, project_id, issue_id, merge_request_id, state, - created_at, source_merge_request_iid) - VALUES (1, ?1, ?2, NULL, 'closed', 3000, 5)", - rusqlite::params![project_id, issue_id], - ) - .unwrap(); - - let count = extract_refs_from_state_events(&conn, project_id).unwrap(); - assert_eq!(count, 1, "Should insert exactly one reference"); - - let (src_type, src_id, tgt_type, tgt_id, ref_type, method): ( - String, - i64, - String, - i64, - String, - String, - ) = conn - .query_row( - "SELECT source_entity_type, source_entity_id, - target_entity_type, target_entity_id, - reference_type, source_method - FROM entity_references WHERE project_id = ?1", - [project_id], - |row| { - Ok(( - row.get(0)?, - row.get(1)?, - row.get(2)?, - row.get(3)?, - row.get(4)?, - row.get(5)?, - )) - }, - ) - .unwrap(); - - assert_eq!(src_type, "merge_request"); - assert_eq!(src_id, mr_id, "Source should be the MR's local DB id"); - assert_eq!(tgt_type, "issue"); - assert_eq!(tgt_id, issue_id, "Target should be the issue's local DB id"); - assert_eq!(ref_type, "closes"); - assert_eq!(method, "api"); - } - - #[test] - fn test_extract_refs_dedup_with_closes_issues() { - let conn = setup_test_db(); - let (project_id, issue_id, mr_id) = seed_project_issue_mr(&conn); - - conn.execute( - "INSERT INTO entity_references - (project_id, source_entity_type, source_entity_id, - target_entity_type, target_entity_id, - reference_type, source_method, created_at) - VALUES (?1, 'merge_request', ?2, 'issue', ?3, 'closes', 'api', 3000)", - rusqlite::params![project_id, mr_id, issue_id], - ) - .unwrap(); - - conn.execute( - "INSERT INTO resource_state_events - (gitlab_id, project_id, issue_id, merge_request_id, state, - created_at, source_merge_request_iid) - VALUES (1, ?1, ?2, NULL, 'closed', 3000, 5)", - rusqlite::params![project_id, issue_id], - ) - .unwrap(); - - let count = extract_refs_from_state_events(&conn, project_id).unwrap(); - assert_eq!(count, 0, "Should not insert duplicate reference"); - - let total: i64 = conn - .query_row( - "SELECT COUNT(*) FROM entity_references WHERE project_id = ?1", - [project_id], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(total, 1, "Should still have exactly one reference"); - } - - #[test] - fn test_extract_refs_no_source_mr() { - let conn = setup_test_db(); - let (project_id, issue_id, _mr_id) = seed_project_issue_mr(&conn); - - conn.execute( - "INSERT INTO resource_state_events - (gitlab_id, project_id, issue_id, merge_request_id, state, - created_at, source_merge_request_iid) - VALUES (1, ?1, ?2, NULL, 'closed', 3000, NULL)", - rusqlite::params![project_id, issue_id], - ) - .unwrap(); - - let count = extract_refs_from_state_events(&conn, project_id).unwrap(); - assert_eq!(count, 0, "Should not create refs when no source MR"); - } - - #[test] - fn test_extract_refs_mr_not_synced() { - let conn = setup_test_db(); - let (project_id, issue_id, _mr_id) = seed_project_issue_mr(&conn); - - conn.execute( - "INSERT INTO resource_state_events - (gitlab_id, project_id, issue_id, merge_request_id, state, - created_at, source_merge_request_iid) - VALUES (2, ?1, ?2, NULL, 'closed', 3000, 999)", - rusqlite::params![project_id, issue_id], - ) - .unwrap(); - - let count = extract_refs_from_state_events(&conn, project_id).unwrap(); - assert_eq!( - count, 0, - "Should not create ref when MR is not synced locally" - ); - } - - #[test] - fn test_extract_refs_idempotent() { - let conn = setup_test_db(); - let (project_id, issue_id, _mr_id) = seed_project_issue_mr(&conn); - - conn.execute( - "INSERT INTO resource_state_events - (gitlab_id, project_id, issue_id, merge_request_id, state, - created_at, source_merge_request_iid) - VALUES (1, ?1, ?2, NULL, 'closed', 3000, 5)", - rusqlite::params![project_id, issue_id], - ) - .unwrap(); - - let count1 = extract_refs_from_state_events(&conn, project_id).unwrap(); - assert_eq!(count1, 1); - - let count2 = extract_refs_from_state_events(&conn, project_id).unwrap(); - assert_eq!(count2, 0, "Second run should insert nothing (idempotent)"); - } - - #[test] - fn test_extract_refs_multiple_events_same_mr_issue() { - let conn = setup_test_db(); - let (project_id, issue_id, _mr_id) = seed_project_issue_mr(&conn); - - conn.execute( - "INSERT INTO resource_state_events - (gitlab_id, project_id, issue_id, merge_request_id, state, - created_at, source_merge_request_iid) - VALUES (1, ?1, ?2, NULL, 'closed', 3000, 5)", - rusqlite::params![project_id, issue_id], - ) - .unwrap(); - conn.execute( - "INSERT INTO resource_state_events - (gitlab_id, project_id, issue_id, merge_request_id, state, - created_at, source_merge_request_iid) - VALUES (2, ?1, ?2, NULL, 'closed', 4000, 5)", - rusqlite::params![project_id, issue_id], - ) - .unwrap(); - - let count = extract_refs_from_state_events(&conn, project_id).unwrap(); - assert!(count <= 2, "At most 2 inserts attempted"); - - let total: i64 = conn - .query_row( - "SELECT COUNT(*) FROM entity_references WHERE project_id = ?1", - [project_id], - |row| row.get(0), - ) - .unwrap(); - assert_eq!( - total, 1, - "Only one unique reference should exist for same MR->issue pair" - ); - } - - #[test] - fn test_extract_refs_scoped_to_project() { - let conn = setup_test_db(); - seed_project_issue_mr(&conn); - - conn.execute( - "INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url, created_at, updated_at) - VALUES (2, 101, 'group/other', 'https://gitlab.example.com/group/other', 1000, 2000)", - [], - ) - .unwrap(); - conn.execute( - "INSERT INTO issues (id, gitlab_id, iid, project_id, title, state, created_at, updated_at, last_seen_at) - VALUES (2, 201, 10, 2, 'Other issue', 'closed', 1000, 2000, 2000)", - [], - ) - .unwrap(); - conn.execute( - "INSERT INTO merge_requests (id, gitlab_id, iid, project_id, title, state, created_at, updated_at, last_seen_at, source_branch, target_branch) - VALUES (2, 301, 5, 2, 'Other MR', 'merged', 1000, 2000, 2000, 'feature', 'main')", - [], - ) - .unwrap(); - - conn.execute( - "INSERT INTO resource_state_events - (gitlab_id, project_id, issue_id, merge_request_id, state, - created_at, source_merge_request_iid) - VALUES (1, 1, 1, NULL, 'closed', 3000, 5)", - [], - ) - .unwrap(); - conn.execute( - "INSERT INTO resource_state_events - (gitlab_id, project_id, issue_id, merge_request_id, state, - created_at, source_merge_request_iid) - VALUES (2, 2, 2, NULL, 'closed', 3000, 5)", - [], - ) - .unwrap(); - - let count = extract_refs_from_state_events(&conn, 1).unwrap(); - assert_eq!(count, 1); - - let total: i64 = conn - .query_row("SELECT COUNT(*) FROM entity_references", [], |row| { - row.get(0) - }) - .unwrap(); - assert_eq!(total, 1, "Only project 1 refs should be created"); - } - - #[test] - fn test_insert_entity_reference_creates_row() { - let conn = setup_test_db(); - let (project_id, issue_id, mr_id) = seed_project_issue_mr(&conn); - - let ref_ = EntityReference { - project_id, - source_entity_type: "merge_request", - source_entity_id: mr_id, - target_entity_type: "issue", - target_entity_id: Some(issue_id), - target_project_path: None, - target_entity_iid: None, - reference_type: "closes", - source_method: "api", - }; - - let inserted = insert_entity_reference(&conn, &ref_).unwrap(); - assert!(inserted); - - let count = count_references_for_source(&conn, "merge_request", mr_id).unwrap(); - assert_eq!(count, 1); - } - - #[test] - fn test_insert_entity_reference_idempotent() { - let conn = setup_test_db(); - let (project_id, issue_id, mr_id) = seed_project_issue_mr(&conn); - - let ref_ = EntityReference { - project_id, - source_entity_type: "merge_request", - source_entity_id: mr_id, - target_entity_type: "issue", - target_entity_id: Some(issue_id), - target_project_path: None, - target_entity_iid: None, - reference_type: "closes", - source_method: "api", - }; - - let first = insert_entity_reference(&conn, &ref_).unwrap(); - assert!(first); - - let second = insert_entity_reference(&conn, &ref_).unwrap(); - assert!(!second, "Duplicate insert should be ignored"); - - let count = count_references_for_source(&conn, "merge_request", mr_id).unwrap(); - assert_eq!(count, 1, "Still just one reference"); - } - - #[test] - fn test_insert_entity_reference_cross_project_unresolved() { - let conn = setup_test_db(); - let (project_id, _issue_id, mr_id) = seed_project_issue_mr(&conn); - - let ref_ = EntityReference { - project_id, - source_entity_type: "merge_request", - source_entity_id: mr_id, - target_entity_type: "issue", - target_entity_id: None, - target_project_path: Some("other-group/other-project"), - target_entity_iid: Some(99), - reference_type: "closes", - source_method: "api", - }; - - let inserted = insert_entity_reference(&conn, &ref_).unwrap(); - assert!(inserted); - - let (target_id, target_path, target_iid): (Option, Option, Option) = conn - .query_row( - "SELECT target_entity_id, target_project_path, target_entity_iid \ - FROM entity_references WHERE source_entity_id = ?1", - [mr_id], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), - ) - .unwrap(); - - assert!(target_id.is_none()); - assert_eq!(target_path, Some("other-group/other-project".to_string())); - assert_eq!(target_iid, Some(99)); - } - - #[test] - fn test_insert_multiple_closes_references() { - let conn = setup_test_db(); - let (project_id, issue_id, mr_id) = seed_project_issue_mr(&conn); - - conn.execute( - "INSERT INTO issues (id, gitlab_id, iid, project_id, title, state, created_at, updated_at, last_seen_at) - VALUES (10, 210, 11, ?1, 'Second issue', 'opened', 1000, 2000, 2000)", - rusqlite::params![project_id], - ) - .unwrap(); - let issue_id_2 = 10i64; - - for target_id in [issue_id, issue_id_2] { - let ref_ = EntityReference { - project_id, - source_entity_type: "merge_request", - source_entity_id: mr_id, - target_entity_type: "issue", - target_entity_id: Some(target_id), - target_project_path: None, - target_entity_iid: None, - reference_type: "closes", - source_method: "api", - }; - insert_entity_reference(&conn, &ref_).unwrap(); - } - - let count = count_references_for_source(&conn, "merge_request", mr_id).unwrap(); - assert_eq!(count, 2); - } - - #[test] - fn test_resolve_issue_local_id_found() { - let conn = setup_test_db(); - let (project_id, issue_id, _mr_id) = seed_project_issue_mr(&conn); - - let resolved = resolve_issue_local_id(&conn, project_id, 10).unwrap(); - assert_eq!(resolved, Some(issue_id)); - } - - #[test] - fn test_resolve_issue_local_id_not_found() { - let conn = setup_test_db(); - let (project_id, _issue_id, _mr_id) = seed_project_issue_mr(&conn); - - let resolved = resolve_issue_local_id(&conn, project_id, 999).unwrap(); - assert!(resolved.is_none()); - } - - #[test] - fn test_resolve_project_path_found() { - let conn = setup_test_db(); - seed_project_issue_mr(&conn); - - let path = resolve_project_path(&conn, 100).unwrap(); - assert_eq!(path, Some("group/repo".to_string())); - } - - #[test] - fn test_resolve_project_path_not_found() { - let conn = setup_test_db(); - - let path = resolve_project_path(&conn, 999).unwrap(); - assert!(path.is_none()); - } -} +#[path = "references_tests.rs"] +mod tests; diff --git a/src/core/references_tests.rs b/src/core/references_tests.rs new file mode 100644 index 0000000..5f6c47c --- /dev/null +++ b/src/core/references_tests.rs @@ -0,0 +1,425 @@ +use super::*; +use crate::core::db::{create_connection, run_migrations}; +use std::path::Path; + +fn setup_test_db() -> Connection { + let conn = create_connection(Path::new(":memory:")).unwrap(); + run_migrations(&conn).unwrap(); + conn +} + +fn seed_project_issue_mr(conn: &Connection) -> (i64, i64, i64) { + conn.execute( + "INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url, created_at, updated_at) + VALUES (1, 100, 'group/repo', 'https://gitlab.example.com/group/repo', 1000, 2000)", + [], + ) + .unwrap(); + + conn.execute( + "INSERT INTO issues (id, gitlab_id, iid, project_id, title, state, created_at, updated_at, last_seen_at) + VALUES (1, 200, 10, 1, 'Test issue', 'closed', 1000, 2000, 2000)", + [], + ) + .unwrap(); + + conn.execute( + "INSERT INTO merge_requests (id, gitlab_id, iid, project_id, title, state, created_at, updated_at, last_seen_at, source_branch, target_branch) + VALUES (1, 300, 5, 1, 'Test MR', 'merged', 1000, 2000, 2000, 'feature', 'main')", + [], + ) + .unwrap(); + + (1, 1, 1) +} + +#[test] +fn test_extract_refs_from_state_events_basic() { + let conn = setup_test_db(); + let (project_id, issue_id, mr_id) = seed_project_issue_mr(&conn); + + conn.execute( + "INSERT INTO resource_state_events + (gitlab_id, project_id, issue_id, merge_request_id, state, + created_at, source_merge_request_iid) + VALUES (1, ?1, ?2, NULL, 'closed', 3000, 5)", + rusqlite::params![project_id, issue_id], + ) + .unwrap(); + + let count = extract_refs_from_state_events(&conn, project_id).unwrap(); + assert_eq!(count, 1, "Should insert exactly one reference"); + + let (src_type, src_id, tgt_type, tgt_id, ref_type, method): ( + String, + i64, + String, + i64, + String, + String, + ) = conn + .query_row( + "SELECT source_entity_type, source_entity_id, + target_entity_type, target_entity_id, + reference_type, source_method + FROM entity_references WHERE project_id = ?1", + [project_id], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + )) + }, + ) + .unwrap(); + + assert_eq!(src_type, "merge_request"); + assert_eq!(src_id, mr_id, "Source should be the MR's local DB id"); + assert_eq!(tgt_type, "issue"); + assert_eq!(tgt_id, issue_id, "Target should be the issue's local DB id"); + assert_eq!(ref_type, "closes"); + assert_eq!(method, "api"); +} + +#[test] +fn test_extract_refs_dedup_with_closes_issues() { + let conn = setup_test_db(); + let (project_id, issue_id, mr_id) = seed_project_issue_mr(&conn); + + conn.execute( + "INSERT INTO entity_references + (project_id, source_entity_type, source_entity_id, + target_entity_type, target_entity_id, + reference_type, source_method, created_at) + VALUES (?1, 'merge_request', ?2, 'issue', ?3, 'closes', 'api', 3000)", + rusqlite::params![project_id, mr_id, issue_id], + ) + .unwrap(); + + conn.execute( + "INSERT INTO resource_state_events + (gitlab_id, project_id, issue_id, merge_request_id, state, + created_at, source_merge_request_iid) + VALUES (1, ?1, ?2, NULL, 'closed', 3000, 5)", + rusqlite::params![project_id, issue_id], + ) + .unwrap(); + + let count = extract_refs_from_state_events(&conn, project_id).unwrap(); + assert_eq!(count, 0, "Should not insert duplicate reference"); + + let total: i64 = conn + .query_row( + "SELECT COUNT(*) FROM entity_references WHERE project_id = ?1", + [project_id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(total, 1, "Should still have exactly one reference"); +} + +#[test] +fn test_extract_refs_no_source_mr() { + let conn = setup_test_db(); + let (project_id, issue_id, _mr_id) = seed_project_issue_mr(&conn); + + conn.execute( + "INSERT INTO resource_state_events + (gitlab_id, project_id, issue_id, merge_request_id, state, + created_at, source_merge_request_iid) + VALUES (1, ?1, ?2, NULL, 'closed', 3000, NULL)", + rusqlite::params![project_id, issue_id], + ) + .unwrap(); + + let count = extract_refs_from_state_events(&conn, project_id).unwrap(); + assert_eq!(count, 0, "Should not create refs when no source MR"); +} + +#[test] +fn test_extract_refs_mr_not_synced() { + let conn = setup_test_db(); + let (project_id, issue_id, _mr_id) = seed_project_issue_mr(&conn); + + conn.execute( + "INSERT INTO resource_state_events + (gitlab_id, project_id, issue_id, merge_request_id, state, + created_at, source_merge_request_iid) + VALUES (2, ?1, ?2, NULL, 'closed', 3000, 999)", + rusqlite::params![project_id, issue_id], + ) + .unwrap(); + + let count = extract_refs_from_state_events(&conn, project_id).unwrap(); + assert_eq!( + count, 0, + "Should not create ref when MR is not synced locally" + ); +} + +#[test] +fn test_extract_refs_idempotent() { + let conn = setup_test_db(); + let (project_id, issue_id, _mr_id) = seed_project_issue_mr(&conn); + + conn.execute( + "INSERT INTO resource_state_events + (gitlab_id, project_id, issue_id, merge_request_id, state, + created_at, source_merge_request_iid) + VALUES (1, ?1, ?2, NULL, 'closed', 3000, 5)", + rusqlite::params![project_id, issue_id], + ) + .unwrap(); + + let count1 = extract_refs_from_state_events(&conn, project_id).unwrap(); + assert_eq!(count1, 1); + + let count2 = extract_refs_from_state_events(&conn, project_id).unwrap(); + assert_eq!(count2, 0, "Second run should insert nothing (idempotent)"); +} + +#[test] +fn test_extract_refs_multiple_events_same_mr_issue() { + let conn = setup_test_db(); + let (project_id, issue_id, _mr_id) = seed_project_issue_mr(&conn); + + conn.execute( + "INSERT INTO resource_state_events + (gitlab_id, project_id, issue_id, merge_request_id, state, + created_at, source_merge_request_iid) + VALUES (1, ?1, ?2, NULL, 'closed', 3000, 5)", + rusqlite::params![project_id, issue_id], + ) + .unwrap(); + conn.execute( + "INSERT INTO resource_state_events + (gitlab_id, project_id, issue_id, merge_request_id, state, + created_at, source_merge_request_iid) + VALUES (2, ?1, ?2, NULL, 'closed', 4000, 5)", + rusqlite::params![project_id, issue_id], + ) + .unwrap(); + + let count = extract_refs_from_state_events(&conn, project_id).unwrap(); + assert!(count <= 2, "At most 2 inserts attempted"); + + let total: i64 = conn + .query_row( + "SELECT COUNT(*) FROM entity_references WHERE project_id = ?1", + [project_id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + total, 1, + "Only one unique reference should exist for same MR->issue pair" + ); +} + +#[test] +fn test_extract_refs_scoped_to_project() { + let conn = setup_test_db(); + seed_project_issue_mr(&conn); + + conn.execute( + "INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url, created_at, updated_at) + VALUES (2, 101, 'group/other', 'https://gitlab.example.com/group/other', 1000, 2000)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO issues (id, gitlab_id, iid, project_id, title, state, created_at, updated_at, last_seen_at) + VALUES (2, 201, 10, 2, 'Other issue', 'closed', 1000, 2000, 2000)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO merge_requests (id, gitlab_id, iid, project_id, title, state, created_at, updated_at, last_seen_at, source_branch, target_branch) + VALUES (2, 301, 5, 2, 'Other MR', 'merged', 1000, 2000, 2000, 'feature', 'main')", + [], + ) + .unwrap(); + + conn.execute( + "INSERT INTO resource_state_events + (gitlab_id, project_id, issue_id, merge_request_id, state, + created_at, source_merge_request_iid) + VALUES (1, 1, 1, NULL, 'closed', 3000, 5)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO resource_state_events + (gitlab_id, project_id, issue_id, merge_request_id, state, + created_at, source_merge_request_iid) + VALUES (2, 2, 2, NULL, 'closed', 3000, 5)", + [], + ) + .unwrap(); + + let count = extract_refs_from_state_events(&conn, 1).unwrap(); + assert_eq!(count, 1); + + let total: i64 = conn + .query_row("SELECT COUNT(*) FROM entity_references", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(total, 1, "Only project 1 refs should be created"); +} + +#[test] +fn test_insert_entity_reference_creates_row() { + let conn = setup_test_db(); + let (project_id, issue_id, mr_id) = seed_project_issue_mr(&conn); + + let ref_ = EntityReference { + project_id, + source_entity_type: "merge_request", + source_entity_id: mr_id, + target_entity_type: "issue", + target_entity_id: Some(issue_id), + target_project_path: None, + target_entity_iid: None, + reference_type: "closes", + source_method: "api", + }; + + let inserted = insert_entity_reference(&conn, &ref_).unwrap(); + assert!(inserted); + + let count = count_references_for_source(&conn, "merge_request", mr_id).unwrap(); + assert_eq!(count, 1); +} + +#[test] +fn test_insert_entity_reference_idempotent() { + let conn = setup_test_db(); + let (project_id, issue_id, mr_id) = seed_project_issue_mr(&conn); + + let ref_ = EntityReference { + project_id, + source_entity_type: "merge_request", + source_entity_id: mr_id, + target_entity_type: "issue", + target_entity_id: Some(issue_id), + target_project_path: None, + target_entity_iid: None, + reference_type: "closes", + source_method: "api", + }; + + let first = insert_entity_reference(&conn, &ref_).unwrap(); + assert!(first); + + let second = insert_entity_reference(&conn, &ref_).unwrap(); + assert!(!second, "Duplicate insert should be ignored"); + + let count = count_references_for_source(&conn, "merge_request", mr_id).unwrap(); + assert_eq!(count, 1, "Still just one reference"); +} + +#[test] +fn test_insert_entity_reference_cross_project_unresolved() { + let conn = setup_test_db(); + let (project_id, _issue_id, mr_id) = seed_project_issue_mr(&conn); + + let ref_ = EntityReference { + project_id, + source_entity_type: "merge_request", + source_entity_id: mr_id, + target_entity_type: "issue", + target_entity_id: None, + target_project_path: Some("other-group/other-project"), + target_entity_iid: Some(99), + reference_type: "closes", + source_method: "api", + }; + + let inserted = insert_entity_reference(&conn, &ref_).unwrap(); + assert!(inserted); + + let (target_id, target_path, target_iid): (Option, Option, Option) = conn + .query_row( + "SELECT target_entity_id, target_project_path, target_entity_iid \ + FROM entity_references WHERE source_entity_id = ?1", + [mr_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .unwrap(); + + assert!(target_id.is_none()); + assert_eq!(target_path, Some("other-group/other-project".to_string())); + assert_eq!(target_iid, Some(99)); +} + +#[test] +fn test_insert_multiple_closes_references() { + let conn = setup_test_db(); + let (project_id, issue_id, mr_id) = seed_project_issue_mr(&conn); + + conn.execute( + "INSERT INTO issues (id, gitlab_id, iid, project_id, title, state, created_at, updated_at, last_seen_at) + VALUES (10, 210, 11, ?1, 'Second issue', 'opened', 1000, 2000, 2000)", + rusqlite::params![project_id], + ) + .unwrap(); + let issue_id_2 = 10i64; + + for target_id in [issue_id, issue_id_2] { + let ref_ = EntityReference { + project_id, + source_entity_type: "merge_request", + source_entity_id: mr_id, + target_entity_type: "issue", + target_entity_id: Some(target_id), + target_project_path: None, + target_entity_iid: None, + reference_type: "closes", + source_method: "api", + }; + insert_entity_reference(&conn, &ref_).unwrap(); + } + + let count = count_references_for_source(&conn, "merge_request", mr_id).unwrap(); + assert_eq!(count, 2); +} + +#[test] +fn test_resolve_issue_local_id_found() { + let conn = setup_test_db(); + let (project_id, issue_id, _mr_id) = seed_project_issue_mr(&conn); + + let resolved = resolve_issue_local_id(&conn, project_id, 10).unwrap(); + assert_eq!(resolved, Some(issue_id)); +} + +#[test] +fn test_resolve_issue_local_id_not_found() { + let conn = setup_test_db(); + let (project_id, _issue_id, _mr_id) = seed_project_issue_mr(&conn); + + let resolved = resolve_issue_local_id(&conn, project_id, 999).unwrap(); + assert!(resolved.is_none()); +} + +#[test] +fn test_resolve_project_path_found() { + let conn = setup_test_db(); + seed_project_issue_mr(&conn); + + let path = resolve_project_path(&conn, 100).unwrap(); + assert_eq!(path, Some("group/repo".to_string())); +} + +#[test] +fn test_resolve_project_path_not_found() { + let conn = setup_test_db(); + + let path = resolve_project_path(&conn, 999).unwrap(); + assert!(path.is_none()); +} diff --git a/src/core/sync_run.rs b/src/core/sync_run.rs index 326fb4d..a07b250 100644 --- a/src/core/sync_run.rs +++ b/src/core/sync_run.rs @@ -66,153 +66,5 @@ impl SyncRunRecorder { } #[cfg(test)] -mod tests { - use super::*; - use crate::core::db::{create_connection, run_migrations}; - use std::path::Path; - - fn setup_test_db() -> Connection { - let conn = create_connection(Path::new(":memory:")).unwrap(); - run_migrations(&conn).unwrap(); - conn - } - - #[test] - fn test_sync_run_recorder_start() { - let conn = setup_test_db(); - let recorder = SyncRunRecorder::start(&conn, "sync", "abc12345").unwrap(); - assert!(recorder.row_id > 0); - - let (status, command, run_id): (String, String, String) = conn - .query_row( - "SELECT status, command, run_id FROM sync_runs WHERE id = ?1", - [recorder.row_id], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), - ) - .unwrap(); - - assert_eq!(status, "running"); - assert_eq!(command, "sync"); - assert_eq!(run_id, "abc12345"); - } - - #[test] - fn test_sync_run_recorder_succeed() { - let conn = setup_test_db(); - let recorder = SyncRunRecorder::start(&conn, "sync", "def67890").unwrap(); - let row_id = recorder.row_id; - - let metrics = vec![StageTiming { - name: "ingest".to_string(), - project: None, - elapsed_ms: 1200, - items_processed: 50, - items_skipped: 0, - errors: 2, - rate_limit_hits: 0, - retries: 0, - sub_stages: vec![], - }]; - - recorder.succeed(&conn, &metrics, 50, 2).unwrap(); - - let (status, finished_at, metrics_json, total_items, total_errors): ( - String, - Option, - Option, - i64, - i64, - ) = conn - .query_row( - "SELECT status, finished_at, metrics_json, total_items_processed, total_errors - FROM sync_runs WHERE id = ?1", - [row_id], - |row| { - Ok(( - row.get(0)?, - row.get(1)?, - row.get(2)?, - row.get(3)?, - row.get(4)?, - )) - }, - ) - .unwrap(); - - assert_eq!(status, "succeeded"); - assert!(finished_at.is_some()); - assert!(metrics_json.is_some()); - assert_eq!(total_items, 50); - assert_eq!(total_errors, 2); - - let parsed: Vec = serde_json::from_str(&metrics_json.unwrap()).unwrap(); - assert_eq!(parsed.len(), 1); - assert_eq!(parsed[0].name, "ingest"); - } - - #[test] - fn test_sync_run_recorder_fail() { - let conn = setup_test_db(); - let recorder = SyncRunRecorder::start(&conn, "ingest issues", "fail0001").unwrap(); - let row_id = recorder.row_id; - - recorder.fail(&conn, "GitLab auth failed", None).unwrap(); - - let (status, finished_at, error, metrics_json): ( - String, - Option, - Option, - Option, - ) = conn - .query_row( - "SELECT status, finished_at, error, metrics_json - FROM sync_runs WHERE id = ?1", - [row_id], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), - ) - .unwrap(); - - assert_eq!(status, "failed"); - assert!(finished_at.is_some()); - assert_eq!(error.as_deref(), Some("GitLab auth failed")); - assert!(metrics_json.is_none()); - } - - #[test] - fn test_sync_run_recorder_fail_with_partial_metrics() { - let conn = setup_test_db(); - let recorder = SyncRunRecorder::start(&conn, "sync", "part0001").unwrap(); - let row_id = recorder.row_id; - - let partial_metrics = vec![StageTiming { - name: "ingest_issues".to_string(), - project: Some("group/repo".to_string()), - elapsed_ms: 800, - items_processed: 30, - items_skipped: 0, - errors: 0, - rate_limit_hits: 1, - retries: 0, - sub_stages: vec![], - }]; - - recorder - .fail(&conn, "Embedding failed", Some(&partial_metrics)) - .unwrap(); - - let (status, metrics_json): (String, Option) = conn - .query_row( - "SELECT status, metrics_json FROM sync_runs WHERE id = ?1", - [row_id], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .unwrap(); - - assert_eq!(status, "failed"); - assert!(metrics_json.is_some()); - - let parsed: Vec = serde_json::from_str(&metrics_json.unwrap()).unwrap(); - assert_eq!(parsed.len(), 1); - assert_eq!(parsed[0].name, "ingest_issues"); - } -} +#[path = "sync_run_tests.rs"] +mod tests; diff --git a/src/core/sync_run_tests.rs b/src/core/sync_run_tests.rs new file mode 100644 index 0000000..b17c816 --- /dev/null +++ b/src/core/sync_run_tests.rs @@ -0,0 +1,148 @@ +use super::*; +use crate::core::db::{create_connection, run_migrations}; +use std::path::Path; + +fn setup_test_db() -> Connection { + let conn = create_connection(Path::new(":memory:")).unwrap(); + run_migrations(&conn).unwrap(); + conn +} + +#[test] +fn test_sync_run_recorder_start() { + let conn = setup_test_db(); + let recorder = SyncRunRecorder::start(&conn, "sync", "abc12345").unwrap(); + assert!(recorder.row_id > 0); + + let (status, command, run_id): (String, String, String) = conn + .query_row( + "SELECT status, command, run_id FROM sync_runs WHERE id = ?1", + [recorder.row_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .unwrap(); + + assert_eq!(status, "running"); + assert_eq!(command, "sync"); + assert_eq!(run_id, "abc12345"); +} + +#[test] +fn test_sync_run_recorder_succeed() { + let conn = setup_test_db(); + let recorder = SyncRunRecorder::start(&conn, "sync", "def67890").unwrap(); + let row_id = recorder.row_id; + + let metrics = vec![StageTiming { + name: "ingest".to_string(), + project: None, + elapsed_ms: 1200, + items_processed: 50, + items_skipped: 0, + errors: 2, + rate_limit_hits: 0, + retries: 0, + sub_stages: vec![], + }]; + + recorder.succeed(&conn, &metrics, 50, 2).unwrap(); + + let (status, finished_at, metrics_json, total_items, total_errors): ( + String, + Option, + Option, + i64, + i64, + ) = conn + .query_row( + "SELECT status, finished_at, metrics_json, total_items_processed, total_errors + FROM sync_runs WHERE id = ?1", + [row_id], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + )) + }, + ) + .unwrap(); + + assert_eq!(status, "succeeded"); + assert!(finished_at.is_some()); + assert!(metrics_json.is_some()); + assert_eq!(total_items, 50); + assert_eq!(total_errors, 2); + + let parsed: Vec = serde_json::from_str(&metrics_json.unwrap()).unwrap(); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].name, "ingest"); +} + +#[test] +fn test_sync_run_recorder_fail() { + let conn = setup_test_db(); + let recorder = SyncRunRecorder::start(&conn, "ingest issues", "fail0001").unwrap(); + let row_id = recorder.row_id; + + recorder.fail(&conn, "GitLab auth failed", None).unwrap(); + + let (status, finished_at, error, metrics_json): ( + String, + Option, + Option, + Option, + ) = conn + .query_row( + "SELECT status, finished_at, error, metrics_json + FROM sync_runs WHERE id = ?1", + [row_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .unwrap(); + + assert_eq!(status, "failed"); + assert!(finished_at.is_some()); + assert_eq!(error.as_deref(), Some("GitLab auth failed")); + assert!(metrics_json.is_none()); +} + +#[test] +fn test_sync_run_recorder_fail_with_partial_metrics() { + let conn = setup_test_db(); + let recorder = SyncRunRecorder::start(&conn, "sync", "part0001").unwrap(); + let row_id = recorder.row_id; + + let partial_metrics = vec![StageTiming { + name: "ingest_issues".to_string(), + project: Some("group/repo".to_string()), + elapsed_ms: 800, + items_processed: 30, + items_skipped: 0, + errors: 0, + rate_limit_hits: 1, + retries: 0, + sub_stages: vec![], + }]; + + recorder + .fail(&conn, "Embedding failed", Some(&partial_metrics)) + .unwrap(); + + let (status, metrics_json): (String, Option) = conn + .query_row( + "SELECT status, metrics_json FROM sync_runs WHERE id = ?1", + [row_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + + assert_eq!(status, "failed"); + assert!(metrics_json.is_some()); + + let parsed: Vec = serde_json::from_str(&metrics_json.unwrap()).unwrap(); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].name, "ingest_issues"); +} diff --git a/src/core/timeline_collect.rs b/src/core/timeline_collect.rs index 55557ae..58fe92f 100644 --- a/src/core/timeline_collect.rs +++ b/src/core/timeline_collect.rs @@ -370,326 +370,5 @@ fn entity_id_column(entity: &EntityRef) -> Result<(&'static str, i64)> { } #[cfg(test)] -mod tests { - use super::*; - use crate::core::db::{create_connection, run_migrations}; - use std::path::Path; - - fn setup_test_db() -> Connection { - let conn = create_connection(Path::new(":memory:")).unwrap(); - run_migrations(&conn).unwrap(); - conn - } - - fn insert_project(conn: &Connection) -> i64 { - conn.execute( - "INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url) VALUES (1, 'group/project', 'https://gitlab.com/group/project')", - [], - ) - .unwrap(); - conn.last_insert_rowid() - } - - fn insert_issue(conn: &Connection, project_id: i64, iid: i64) -> i64 { - conn.execute( - "INSERT INTO issues (gitlab_id, project_id, iid, title, state, author_username, created_at, updated_at, last_seen_at, web_url) VALUES (?1, ?2, ?3, 'Auth bug', 'opened', 'alice', 1000, 2000, 3000, 'https://gitlab.com/group/project/-/issues/1')", - rusqlite::params![iid * 100, project_id, iid], - ) - .unwrap(); - conn.last_insert_rowid() - } - - fn insert_mr(conn: &Connection, project_id: i64, iid: i64, merged_at: Option) -> i64 { - conn.execute( - "INSERT INTO merge_requests (gitlab_id, project_id, iid, title, state, author_username, created_at, updated_at, last_seen_at, merged_at, merge_user_username, web_url) VALUES (?1, ?2, ?3, 'Fix auth', 'merged', 'bob', 1000, 5000, 6000, ?4, 'charlie', 'https://gitlab.com/group/project/-/merge_requests/10')", - rusqlite::params![iid * 100, project_id, iid, merged_at], - ) - .unwrap(); - conn.last_insert_rowid() - } - - fn make_entity_ref(entity_type: &str, entity_id: i64, iid: i64) -> EntityRef { - EntityRef { - entity_type: entity_type.to_owned(), - entity_id, - entity_iid: iid, - project_path: "group/project".to_owned(), - } - } - - fn insert_state_event( - conn: &Connection, - project_id: i64, - issue_id: Option, - mr_id: Option, - state: &str, - created_at: i64, - ) { - let gitlab_id: i64 = rand::random::().into(); - conn.execute( - "INSERT INTO resource_state_events (gitlab_id, project_id, issue_id, merge_request_id, state, actor_username, created_at) VALUES (?1, ?2, ?3, ?4, ?5, 'alice', ?6)", - rusqlite::params![gitlab_id, project_id, issue_id, mr_id, state, created_at], - ) - .unwrap(); - } - - fn insert_label_event( - conn: &Connection, - project_id: i64, - issue_id: Option, - mr_id: Option, - action: &str, - label_name: Option<&str>, - created_at: i64, - ) { - let gitlab_id: i64 = rand::random::().into(); - conn.execute( - "INSERT INTO resource_label_events (gitlab_id, project_id, issue_id, merge_request_id, action, label_name, actor_username, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'alice', ?7)", - rusqlite::params![gitlab_id, project_id, issue_id, mr_id, action, label_name, created_at], - ) - .unwrap(); - } - - fn insert_milestone_event( - conn: &Connection, - project_id: i64, - issue_id: Option, - mr_id: Option, - action: &str, - milestone_title: Option<&str>, - created_at: i64, - ) { - let gitlab_id: i64 = rand::random::().into(); - conn.execute( - "INSERT INTO resource_milestone_events (gitlab_id, project_id, issue_id, merge_request_id, action, milestone_title, actor_username, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'alice', ?7)", - rusqlite::params![gitlab_id, project_id, issue_id, mr_id, action, milestone_title, created_at], - ) - .unwrap(); - } - - #[test] - fn test_collect_creation_event() { - let conn = setup_test_db(); - let project_id = insert_project(&conn); - let issue_id = insert_issue(&conn, project_id, 1); - let seeds = vec![make_entity_ref("issue", issue_id, 1)]; - - let (events, _) = collect_events(&conn, &seeds, &[], &[], None, 100).unwrap(); - assert_eq!(events.len(), 1); - assert!(matches!(events[0].event_type, TimelineEventType::Created)); - assert_eq!(events[0].timestamp, 1000); - assert_eq!(events[0].actor, Some("alice".to_owned())); - assert!(events[0].is_seed); - } - - #[test] - fn test_collect_state_events() { - let conn = setup_test_db(); - let project_id = insert_project(&conn); - let issue_id = insert_issue(&conn, project_id, 1); - - insert_state_event(&conn, project_id, Some(issue_id), None, "closed", 3000); - insert_state_event(&conn, project_id, Some(issue_id), None, "reopened", 4000); - - let seeds = vec![make_entity_ref("issue", issue_id, 1)]; - let (events, _) = collect_events(&conn, &seeds, &[], &[], None, 100).unwrap(); - - // Created + 2 state changes = 3 - assert_eq!(events.len(), 3); - assert!(matches!(events[0].event_type, TimelineEventType::Created)); - assert!(matches!( - events[1].event_type, - TimelineEventType::StateChanged { ref state } if state == "closed" - )); - assert!(matches!( - events[2].event_type, - TimelineEventType::StateChanged { ref state } if state == "reopened" - )); - } - - #[test] - fn test_collect_merged_dedup() { - let conn = setup_test_db(); - let project_id = insert_project(&conn); - let mr_id = insert_mr(&conn, project_id, 10, Some(5000)); - - // Also add a state event for 'merged' — this should NOT produce a StateChanged - insert_state_event(&conn, project_id, None, Some(mr_id), "merged", 5000); - - let seeds = vec![make_entity_ref("merge_request", mr_id, 10)]; - let (events, _) = collect_events(&conn, &seeds, &[], &[], None, 100).unwrap(); - - // Should have Created + Merged (not Created + StateChanged{merged} + Merged) - let merged_count = events - .iter() - .filter(|e| matches!(e.event_type, TimelineEventType::Merged)) - .count(); - let state_merged_count = events - .iter() - .filter(|e| matches!(&e.event_type, TimelineEventType::StateChanged { state } if state == "merged")) - .count(); - - assert_eq!(merged_count, 1); - assert_eq!(state_merged_count, 0); - } - - #[test] - fn test_collect_null_label_fallback() { - let conn = setup_test_db(); - let project_id = insert_project(&conn); - let issue_id = insert_issue(&conn, project_id, 1); - - insert_label_event(&conn, project_id, Some(issue_id), None, "add", None, 2000); - - let seeds = vec![make_entity_ref("issue", issue_id, 1)]; - let (events, _) = collect_events(&conn, &seeds, &[], &[], None, 100).unwrap(); - - let label_event = events.iter().find(|e| { - matches!(&e.event_type, TimelineEventType::LabelAdded { label } if label == "[deleted label]") - }); - assert!(label_event.is_some()); - } - - #[test] - fn test_collect_null_milestone_fallback() { - let conn = setup_test_db(); - let project_id = insert_project(&conn); - let issue_id = insert_issue(&conn, project_id, 1); - - insert_milestone_event(&conn, project_id, Some(issue_id), None, "add", None, 2000); - - let seeds = vec![make_entity_ref("issue", issue_id, 1)]; - let (events, _) = collect_events(&conn, &seeds, &[], &[], None, 100).unwrap(); - - let ms_event = events.iter().find(|e| { - matches!(&e.event_type, TimelineEventType::MilestoneSet { milestone } if milestone == "[deleted milestone]") - }); - assert!(ms_event.is_some()); - } - - #[test] - fn test_collect_since_filter() { - let conn = setup_test_db(); - let project_id = insert_project(&conn); - let issue_id = insert_issue(&conn, project_id, 1); - - insert_state_event(&conn, project_id, Some(issue_id), None, "closed", 3000); - insert_state_event(&conn, project_id, Some(issue_id), None, "reopened", 5000); - - let seeds = vec![make_entity_ref("issue", issue_id, 1)]; - - // Since 4000: should exclude Created (1000) and closed (3000) - let (events, _) = collect_events(&conn, &seeds, &[], &[], Some(4000), 100).unwrap(); - assert_eq!(events.len(), 1); - assert_eq!(events[0].timestamp, 5000); - } - - #[test] - fn test_collect_chronological_sort() { - let conn = setup_test_db(); - let project_id = insert_project(&conn); - let issue_id = insert_issue(&conn, project_id, 1); - let mr_id = insert_mr(&conn, project_id, 10, Some(4000)); - - insert_state_event(&conn, project_id, Some(issue_id), None, "closed", 3000); - insert_label_event( - &conn, - project_id, - None, - Some(mr_id), - "add", - Some("bug"), - 2000, - ); - - let seeds = vec![ - make_entity_ref("issue", issue_id, 1), - make_entity_ref("merge_request", mr_id, 10), - ]; - let (events, _) = collect_events(&conn, &seeds, &[], &[], None, 100).unwrap(); - - // Verify chronological order - for window in events.windows(2) { - assert!(window[0].timestamp <= window[1].timestamp); - } - } - - #[test] - fn test_collect_respects_limit() { - let conn = setup_test_db(); - let project_id = insert_project(&conn); - let issue_id = insert_issue(&conn, project_id, 1); - - for i in 0..20 { - insert_state_event( - &conn, - project_id, - Some(issue_id), - None, - "closed", - 3000 + i * 100, - ); - } - - let seeds = vec![make_entity_ref("issue", issue_id, 1)]; - let (events, total) = collect_events(&conn, &seeds, &[], &[], None, 5).unwrap(); - assert_eq!(events.len(), 5); - // 20 state changes + 1 created = 21 total before limit - assert_eq!(total, 21); - } - - #[test] - fn test_collect_evidence_notes_included() { - let conn = setup_test_db(); - let project_id = insert_project(&conn); - let issue_id = insert_issue(&conn, project_id, 1); - - let evidence = vec![TimelineEvent { - timestamp: 2500, - entity_type: "issue".to_owned(), - entity_id: issue_id, - entity_iid: 1, - project_path: "group/project".to_owned(), - event_type: TimelineEventType::NoteEvidence { - note_id: 42, - snippet: "relevant note".to_owned(), - discussion_id: Some(1), - }, - summary: "Note by alice".to_owned(), - actor: Some("alice".to_owned()), - url: None, - is_seed: true, - }]; - - let seeds = vec![make_entity_ref("issue", issue_id, 1)]; - let (events, _) = collect_events(&conn, &seeds, &[], &evidence, None, 100).unwrap(); - - let note_event = events.iter().find(|e| { - matches!( - &e.event_type, - TimelineEventType::NoteEvidence { note_id, .. } if *note_id == 42 - ) - }); - assert!(note_event.is_some()); - } - - #[test] - fn test_collect_merged_fallback_to_state_event() { - let conn = setup_test_db(); - let project_id = insert_project(&conn); - // MR with merged_at = NULL - let mr_id = insert_mr(&conn, project_id, 10, None); - - // But has a state event for 'merged' - insert_state_event(&conn, project_id, None, Some(mr_id), "merged", 5000); - - let seeds = vec![make_entity_ref("merge_request", mr_id, 10)]; - let (events, _) = collect_events(&conn, &seeds, &[], &[], None, 100).unwrap(); - - let merged = events - .iter() - .find(|e| matches!(e.event_type, TimelineEventType::Merged)); - assert!(merged.is_some()); - assert_eq!(merged.unwrap().timestamp, 5000); - } -} +#[path = "timeline_collect_tests.rs"] +mod tests; diff --git a/src/core/timeline_collect_tests.rs b/src/core/timeline_collect_tests.rs new file mode 100644 index 0000000..6e74bef --- /dev/null +++ b/src/core/timeline_collect_tests.rs @@ -0,0 +1,321 @@ +use super::*; +use crate::core::db::{create_connection, run_migrations}; +use std::path::Path; + +fn setup_test_db() -> Connection { + let conn = create_connection(Path::new(":memory:")).unwrap(); + run_migrations(&conn).unwrap(); + conn +} + +fn insert_project(conn: &Connection) -> i64 { + conn.execute( + "INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url) VALUES (1, 'group/project', 'https://gitlab.com/group/project')", + [], + ) + .unwrap(); + conn.last_insert_rowid() +} + +fn insert_issue(conn: &Connection, project_id: i64, iid: i64) -> i64 { + conn.execute( + "INSERT INTO issues (gitlab_id, project_id, iid, title, state, author_username, created_at, updated_at, last_seen_at, web_url) VALUES (?1, ?2, ?3, 'Auth bug', 'opened', 'alice', 1000, 2000, 3000, 'https://gitlab.com/group/project/-/issues/1')", + rusqlite::params![iid * 100, project_id, iid], + ) + .unwrap(); + conn.last_insert_rowid() +} + +fn insert_mr(conn: &Connection, project_id: i64, iid: i64, merged_at: Option) -> i64 { + conn.execute( + "INSERT INTO merge_requests (gitlab_id, project_id, iid, title, state, author_username, created_at, updated_at, last_seen_at, merged_at, merge_user_username, web_url) VALUES (?1, ?2, ?3, 'Fix auth', 'merged', 'bob', 1000, 5000, 6000, ?4, 'charlie', 'https://gitlab.com/group/project/-/merge_requests/10')", + rusqlite::params![iid * 100, project_id, iid, merged_at], + ) + .unwrap(); + conn.last_insert_rowid() +} + +fn make_entity_ref(entity_type: &str, entity_id: i64, iid: i64) -> EntityRef { + EntityRef { + entity_type: entity_type.to_owned(), + entity_id, + entity_iid: iid, + project_path: "group/project".to_owned(), + } +} + +fn insert_state_event( + conn: &Connection, + project_id: i64, + issue_id: Option, + mr_id: Option, + state: &str, + created_at: i64, +) { + let gitlab_id: i64 = rand::random::().into(); + conn.execute( + "INSERT INTO resource_state_events (gitlab_id, project_id, issue_id, merge_request_id, state, actor_username, created_at) VALUES (?1, ?2, ?3, ?4, ?5, 'alice', ?6)", + rusqlite::params![gitlab_id, project_id, issue_id, mr_id, state, created_at], + ) + .unwrap(); +} + +fn insert_label_event( + conn: &Connection, + project_id: i64, + issue_id: Option, + mr_id: Option, + action: &str, + label_name: Option<&str>, + created_at: i64, +) { + let gitlab_id: i64 = rand::random::().into(); + conn.execute( + "INSERT INTO resource_label_events (gitlab_id, project_id, issue_id, merge_request_id, action, label_name, actor_username, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'alice', ?7)", + rusqlite::params![gitlab_id, project_id, issue_id, mr_id, action, label_name, created_at], + ) + .unwrap(); +} + +fn insert_milestone_event( + conn: &Connection, + project_id: i64, + issue_id: Option, + mr_id: Option, + action: &str, + milestone_title: Option<&str>, + created_at: i64, +) { + let gitlab_id: i64 = rand::random::().into(); + conn.execute( + "INSERT INTO resource_milestone_events (gitlab_id, project_id, issue_id, merge_request_id, action, milestone_title, actor_username, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'alice', ?7)", + rusqlite::params![gitlab_id, project_id, issue_id, mr_id, action, milestone_title, created_at], + ) + .unwrap(); +} + +#[test] +fn test_collect_creation_event() { + let conn = setup_test_db(); + let project_id = insert_project(&conn); + let issue_id = insert_issue(&conn, project_id, 1); + let seeds = vec![make_entity_ref("issue", issue_id, 1)]; + + let (events, _) = collect_events(&conn, &seeds, &[], &[], None, 100).unwrap(); + assert_eq!(events.len(), 1); + assert!(matches!(events[0].event_type, TimelineEventType::Created)); + assert_eq!(events[0].timestamp, 1000); + assert_eq!(events[0].actor, Some("alice".to_owned())); + assert!(events[0].is_seed); +} + +#[test] +fn test_collect_state_events() { + let conn = setup_test_db(); + let project_id = insert_project(&conn); + let issue_id = insert_issue(&conn, project_id, 1); + + insert_state_event(&conn, project_id, Some(issue_id), None, "closed", 3000); + insert_state_event(&conn, project_id, Some(issue_id), None, "reopened", 4000); + + let seeds = vec![make_entity_ref("issue", issue_id, 1)]; + let (events, _) = collect_events(&conn, &seeds, &[], &[], None, 100).unwrap(); + + // Created + 2 state changes = 3 + assert_eq!(events.len(), 3); + assert!(matches!(events[0].event_type, TimelineEventType::Created)); + assert!(matches!( + events[1].event_type, + TimelineEventType::StateChanged { ref state } if state == "closed" + )); + assert!(matches!( + events[2].event_type, + TimelineEventType::StateChanged { ref state } if state == "reopened" + )); +} + +#[test] +fn test_collect_merged_dedup() { + let conn = setup_test_db(); + let project_id = insert_project(&conn); + let mr_id = insert_mr(&conn, project_id, 10, Some(5000)); + + // Also add a state event for 'merged' — this should NOT produce a StateChanged + insert_state_event(&conn, project_id, None, Some(mr_id), "merged", 5000); + + let seeds = vec![make_entity_ref("merge_request", mr_id, 10)]; + let (events, _) = collect_events(&conn, &seeds, &[], &[], None, 100).unwrap(); + + // Should have Created + Merged (not Created + StateChanged{merged} + Merged) + let merged_count = events + .iter() + .filter(|e| matches!(e.event_type, TimelineEventType::Merged)) + .count(); + let state_merged_count = events + .iter() + .filter(|e| matches!(&e.event_type, TimelineEventType::StateChanged { state } if state == "merged")) + .count(); + + assert_eq!(merged_count, 1); + assert_eq!(state_merged_count, 0); +} + +#[test] +fn test_collect_null_label_fallback() { + let conn = setup_test_db(); + let project_id = insert_project(&conn); + let issue_id = insert_issue(&conn, project_id, 1); + + insert_label_event(&conn, project_id, Some(issue_id), None, "add", None, 2000); + + let seeds = vec![make_entity_ref("issue", issue_id, 1)]; + let (events, _) = collect_events(&conn, &seeds, &[], &[], None, 100).unwrap(); + + let label_event = events.iter().find(|e| { + matches!(&e.event_type, TimelineEventType::LabelAdded { label } if label == "[deleted label]") + }); + assert!(label_event.is_some()); +} + +#[test] +fn test_collect_null_milestone_fallback() { + let conn = setup_test_db(); + let project_id = insert_project(&conn); + let issue_id = insert_issue(&conn, project_id, 1); + + insert_milestone_event(&conn, project_id, Some(issue_id), None, "add", None, 2000); + + let seeds = vec![make_entity_ref("issue", issue_id, 1)]; + let (events, _) = collect_events(&conn, &seeds, &[], &[], None, 100).unwrap(); + + let ms_event = events.iter().find(|e| { + matches!(&e.event_type, TimelineEventType::MilestoneSet { milestone } if milestone == "[deleted milestone]") + }); + assert!(ms_event.is_some()); +} + +#[test] +fn test_collect_since_filter() { + let conn = setup_test_db(); + let project_id = insert_project(&conn); + let issue_id = insert_issue(&conn, project_id, 1); + + insert_state_event(&conn, project_id, Some(issue_id), None, "closed", 3000); + insert_state_event(&conn, project_id, Some(issue_id), None, "reopened", 5000); + + let seeds = vec![make_entity_ref("issue", issue_id, 1)]; + + // Since 4000: should exclude Created (1000) and closed (3000) + let (events, _) = collect_events(&conn, &seeds, &[], &[], Some(4000), 100).unwrap(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].timestamp, 5000); +} + +#[test] +fn test_collect_chronological_sort() { + let conn = setup_test_db(); + let project_id = insert_project(&conn); + let issue_id = insert_issue(&conn, project_id, 1); + let mr_id = insert_mr(&conn, project_id, 10, Some(4000)); + + insert_state_event(&conn, project_id, Some(issue_id), None, "closed", 3000); + insert_label_event( + &conn, + project_id, + None, + Some(mr_id), + "add", + Some("bug"), + 2000, + ); + + let seeds = vec![ + make_entity_ref("issue", issue_id, 1), + make_entity_ref("merge_request", mr_id, 10), + ]; + let (events, _) = collect_events(&conn, &seeds, &[], &[], None, 100).unwrap(); + + // Verify chronological order + for window in events.windows(2) { + assert!(window[0].timestamp <= window[1].timestamp); + } +} + +#[test] +fn test_collect_respects_limit() { + let conn = setup_test_db(); + let project_id = insert_project(&conn); + let issue_id = insert_issue(&conn, project_id, 1); + + for i in 0..20 { + insert_state_event( + &conn, + project_id, + Some(issue_id), + None, + "closed", + 3000 + i * 100, + ); + } + + let seeds = vec![make_entity_ref("issue", issue_id, 1)]; + let (events, total) = collect_events(&conn, &seeds, &[], &[], None, 5).unwrap(); + assert_eq!(events.len(), 5); + // 20 state changes + 1 created = 21 total before limit + assert_eq!(total, 21); +} + +#[test] +fn test_collect_evidence_notes_included() { + let conn = setup_test_db(); + let project_id = insert_project(&conn); + let issue_id = insert_issue(&conn, project_id, 1); + + let evidence = vec![TimelineEvent { + timestamp: 2500, + entity_type: "issue".to_owned(), + entity_id: issue_id, + entity_iid: 1, + project_path: "group/project".to_owned(), + event_type: TimelineEventType::NoteEvidence { + note_id: 42, + snippet: "relevant note".to_owned(), + discussion_id: Some(1), + }, + summary: "Note by alice".to_owned(), + actor: Some("alice".to_owned()), + url: None, + is_seed: true, + }]; + + let seeds = vec![make_entity_ref("issue", issue_id, 1)]; + let (events, _) = collect_events(&conn, &seeds, &[], &evidence, None, 100).unwrap(); + + let note_event = events.iter().find(|e| { + matches!( + &e.event_type, + TimelineEventType::NoteEvidence { note_id, .. } if *note_id == 42 + ) + }); + assert!(note_event.is_some()); +} + +#[test] +fn test_collect_merged_fallback_to_state_event() { + let conn = setup_test_db(); + let project_id = insert_project(&conn); + // MR with merged_at = NULL + let mr_id = insert_mr(&conn, project_id, 10, None); + + // But has a state event for 'merged' + insert_state_event(&conn, project_id, None, Some(mr_id), "merged", 5000); + + let seeds = vec![make_entity_ref("merge_request", mr_id, 10)]; + let (events, _) = collect_events(&conn, &seeds, &[], &[], None, 100).unwrap(); + + let merged = events + .iter() + .find(|e| matches!(e.event_type, TimelineEventType::Merged)); + assert!(merged.is_some()); + assert_eq!(merged.unwrap().timestamp, 5000); +} diff --git a/src/core/timeline_expand.rs b/src/core/timeline_expand.rs index f22bc13..5b81d39 100644 --- a/src/core/timeline_expand.rs +++ b/src/core/timeline_expand.rs @@ -248,310 +248,5 @@ fn find_incoming( } #[cfg(test)] -mod tests { - use super::*; - use crate::core::db::{create_connection, run_migrations}; - use std::path::Path; - - fn setup_test_db() -> Connection { - let conn = create_connection(Path::new(":memory:")).unwrap(); - run_migrations(&conn).unwrap(); - conn - } - - fn insert_project(conn: &Connection) -> i64 { - conn.execute( - "INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url) VALUES (1, 'group/project', 'https://gitlab.com/group/project')", - [], - ) - .unwrap(); - conn.last_insert_rowid() - } - - fn insert_issue(conn: &Connection, project_id: i64, iid: i64) -> i64 { - conn.execute( - "INSERT INTO issues (gitlab_id, project_id, iid, title, state, author_username, created_at, updated_at, last_seen_at) VALUES (?1, ?2, ?3, 'Test', 'opened', 'alice', 1000, 2000, 3000)", - rusqlite::params![iid * 100, project_id, iid], - ) - .unwrap(); - conn.last_insert_rowid() - } - - fn insert_mr(conn: &Connection, project_id: i64, iid: i64) -> i64 { - conn.execute( - "INSERT INTO merge_requests (gitlab_id, project_id, iid, title, state, author_username, created_at, updated_at, last_seen_at) VALUES (?1, ?2, ?3, 'Test MR', 'opened', 'bob', 1000, 2000, 3000)", - rusqlite::params![iid * 100, project_id, iid], - ) - .unwrap(); - conn.last_insert_rowid() - } - - #[allow(clippy::too_many_arguments)] - fn insert_ref( - conn: &Connection, - project_id: i64, - source_type: &str, - source_id: i64, - target_type: &str, - target_id: Option, - ref_type: &str, - source_method: &str, - ) { - conn.execute( - "INSERT INTO entity_references (project_id, source_entity_type, source_entity_id, target_entity_type, target_entity_id, reference_type, source_method, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 1000)", - rusqlite::params![project_id, source_type, source_id, target_type, target_id, ref_type, source_method], - ) - .unwrap(); - } - - fn make_entity_ref(entity_type: &str, entity_id: i64, iid: i64) -> EntityRef { - EntityRef { - entity_type: entity_type.to_owned(), - entity_id, - entity_iid: iid, - project_path: "group/project".to_owned(), - } - } - - #[test] - fn test_expand_depth_zero() { - let conn = setup_test_db(); - let project_id = insert_project(&conn); - let issue_id = insert_issue(&conn, project_id, 1); - let seeds = vec![make_entity_ref("issue", issue_id, 1)]; - - let result = expand_timeline(&conn, &seeds, 0, false, 100).unwrap(); - assert!(result.expanded_entities.is_empty()); - assert!(result.unresolved_references.is_empty()); - } - - #[test] - fn test_expand_finds_linked_entity() { - let conn = setup_test_db(); - let project_id = insert_project(&conn); - let issue_id = insert_issue(&conn, project_id, 1); - let mr_id = insert_mr(&conn, project_id, 10); - - // MR closes issue - insert_ref( - &conn, - project_id, - "merge_request", - mr_id, - "issue", - Some(issue_id), - "closes", - "api", - ); - - let seeds = vec![make_entity_ref("issue", issue_id, 1)]; - let result = expand_timeline(&conn, &seeds, 1, false, 100).unwrap(); - - assert_eq!(result.expanded_entities.len(), 1); - assert_eq!( - result.expanded_entities[0].entity_ref.entity_type, - "merge_request" - ); - assert_eq!(result.expanded_entities[0].entity_ref.entity_iid, 10); - assert_eq!(result.expanded_entities[0].depth, 1); - } - - #[test] - fn test_expand_bidirectional() { - let conn = setup_test_db(); - let project_id = insert_project(&conn); - let issue_id = insert_issue(&conn, project_id, 1); - let mr_id = insert_mr(&conn, project_id, 10); - - // MR closes issue (MR is source, issue is target) - insert_ref( - &conn, - project_id, - "merge_request", - mr_id, - "issue", - Some(issue_id), - "closes", - "api", - ); - - // Starting from MR should find the issue (outgoing) - let seeds = vec![make_entity_ref("merge_request", mr_id, 10)]; - let result = expand_timeline(&conn, &seeds, 1, false, 100).unwrap(); - - assert_eq!(result.expanded_entities.len(), 1); - assert_eq!(result.expanded_entities[0].entity_ref.entity_type, "issue"); - } - - #[test] - fn test_expand_respects_max_entities() { - let conn = setup_test_db(); - let project_id = insert_project(&conn); - let issue_id = insert_issue(&conn, project_id, 1); - - // Create 10 MRs that all close this issue - for i in 2..=11 { - let mr_id = insert_mr(&conn, project_id, i); - insert_ref( - &conn, - project_id, - "merge_request", - mr_id, - "issue", - Some(issue_id), - "closes", - "api", - ); - } - - let seeds = vec![make_entity_ref("issue", issue_id, 1)]; - let result = expand_timeline(&conn, &seeds, 1, false, 3).unwrap(); - - assert!(result.expanded_entities.len() <= 3); - } - - #[test] - fn test_expand_skips_mentions_by_default() { - let conn = setup_test_db(); - let project_id = insert_project(&conn); - let issue_id = insert_issue(&conn, project_id, 1); - let mr_id = insert_mr(&conn, project_id, 10); - - // MR mentions issue (should be skipped by default) - insert_ref( - &conn, - project_id, - "merge_request", - mr_id, - "issue", - Some(issue_id), - "mentioned", - "note_parse", - ); - - let seeds = vec![make_entity_ref("issue", issue_id, 1)]; - let result = expand_timeline(&conn, &seeds, 1, false, 100).unwrap(); - assert!(result.expanded_entities.is_empty()); - } - - #[test] - fn test_expand_includes_mentions_when_flagged() { - let conn = setup_test_db(); - let project_id = insert_project(&conn); - let issue_id = insert_issue(&conn, project_id, 1); - let mr_id = insert_mr(&conn, project_id, 10); - - // MR mentions issue - insert_ref( - &conn, - project_id, - "merge_request", - mr_id, - "issue", - Some(issue_id), - "mentioned", - "note_parse", - ); - - let seeds = vec![make_entity_ref("issue", issue_id, 1)]; - let result = expand_timeline(&conn, &seeds, 1, true, 100).unwrap(); - assert_eq!(result.expanded_entities.len(), 1); - } - - #[test] - fn test_expand_collects_unresolved() { - let conn = setup_test_db(); - let project_id = insert_project(&conn); - let issue_id = insert_issue(&conn, project_id, 1); - - // Unresolved cross-project reference - conn.execute( - "INSERT INTO entity_references (project_id, source_entity_type, source_entity_id, target_entity_type, target_entity_id, target_project_path, target_entity_iid, reference_type, source_method, created_at) VALUES (?1, 'issue', ?2, 'issue', NULL, 'other/repo', 42, 'closes', 'description_parse', 1000)", - rusqlite::params![project_id, issue_id], - ) - .unwrap(); - - let seeds = vec![make_entity_ref("issue", issue_id, 1)]; - let result = expand_timeline(&conn, &seeds, 1, false, 100).unwrap(); - - assert!(result.expanded_entities.is_empty()); - assert_eq!(result.unresolved_references.len(), 1); - assert_eq!( - result.unresolved_references[0].target_project, - Some("other/repo".to_owned()) - ); - assert_eq!(result.unresolved_references[0].target_iid, Some(42)); - } - - #[test] - fn test_expand_tracks_provenance() { - let conn = setup_test_db(); - let project_id = insert_project(&conn); - let issue_id = insert_issue(&conn, project_id, 1); - let mr_id = insert_mr(&conn, project_id, 10); - - insert_ref( - &conn, - project_id, - "merge_request", - mr_id, - "issue", - Some(issue_id), - "closes", - "api", - ); - - let seeds = vec![make_entity_ref("issue", issue_id, 1)]; - let result = expand_timeline(&conn, &seeds, 1, false, 100).unwrap(); - - assert_eq!(result.expanded_entities.len(), 1); - let expanded = &result.expanded_entities[0]; - assert_eq!(expanded.via_reference_type, "closes"); - assert_eq!(expanded.via_source_method, "api"); - assert_eq!(expanded.via_from.entity_type, "issue"); - assert_eq!(expanded.via_from.entity_id, issue_id); - } - - #[test] - fn test_expand_no_duplicates() { - let conn = setup_test_db(); - let project_id = insert_project(&conn); - let issue_id = insert_issue(&conn, project_id, 1); - let mr_id = insert_mr(&conn, project_id, 10); - - // Two references from MR to same issue (different methods) - insert_ref( - &conn, - project_id, - "merge_request", - mr_id, - "issue", - Some(issue_id), - "closes", - "api", - ); - insert_ref( - &conn, - project_id, - "merge_request", - mr_id, - "issue", - Some(issue_id), - "related", - "note_parse", - ); - - let seeds = vec![make_entity_ref("merge_request", mr_id, 10)]; - let result = expand_timeline(&conn, &seeds, 1, false, 100).unwrap(); - - // Should only appear once (first-come wins) - assert_eq!(result.expanded_entities.len(), 1); - } - - #[test] - fn test_expand_empty_seeds() { - let conn = setup_test_db(); - let result = expand_timeline(&conn, &[], 1, false, 100).unwrap(); - assert!(result.expanded_entities.is_empty()); - } -} +#[path = "timeline_expand_tests.rs"] +mod tests; diff --git a/src/core/timeline_expand_tests.rs b/src/core/timeline_expand_tests.rs new file mode 100644 index 0000000..4b5f162 --- /dev/null +++ b/src/core/timeline_expand_tests.rs @@ -0,0 +1,305 @@ +use super::*; +use crate::core::db::{create_connection, run_migrations}; +use std::path::Path; + +fn setup_test_db() -> Connection { + let conn = create_connection(Path::new(":memory:")).unwrap(); + run_migrations(&conn).unwrap(); + conn +} + +fn insert_project(conn: &Connection) -> i64 { + conn.execute( + "INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url) VALUES (1, 'group/project', 'https://gitlab.com/group/project')", + [], + ) + .unwrap(); + conn.last_insert_rowid() +} + +fn insert_issue(conn: &Connection, project_id: i64, iid: i64) -> i64 { + conn.execute( + "INSERT INTO issues (gitlab_id, project_id, iid, title, state, author_username, created_at, updated_at, last_seen_at) VALUES (?1, ?2, ?3, 'Test', 'opened', 'alice', 1000, 2000, 3000)", + rusqlite::params![iid * 100, project_id, iid], + ) + .unwrap(); + conn.last_insert_rowid() +} + +fn insert_mr(conn: &Connection, project_id: i64, iid: i64) -> i64 { + conn.execute( + "INSERT INTO merge_requests (gitlab_id, project_id, iid, title, state, author_username, created_at, updated_at, last_seen_at) VALUES (?1, ?2, ?3, 'Test MR', 'opened', 'bob', 1000, 2000, 3000)", + rusqlite::params![iid * 100, project_id, iid], + ) + .unwrap(); + conn.last_insert_rowid() +} + +#[allow(clippy::too_many_arguments)] +fn insert_ref( + conn: &Connection, + project_id: i64, + source_type: &str, + source_id: i64, + target_type: &str, + target_id: Option, + ref_type: &str, + source_method: &str, +) { + conn.execute( + "INSERT INTO entity_references (project_id, source_entity_type, source_entity_id, target_entity_type, target_entity_id, reference_type, source_method, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 1000)", + rusqlite::params![project_id, source_type, source_id, target_type, target_id, ref_type, source_method], + ) + .unwrap(); +} + +fn make_entity_ref(entity_type: &str, entity_id: i64, iid: i64) -> EntityRef { + EntityRef { + entity_type: entity_type.to_owned(), + entity_id, + entity_iid: iid, + project_path: "group/project".to_owned(), + } +} + +#[test] +fn test_expand_depth_zero() { + let conn = setup_test_db(); + let project_id = insert_project(&conn); + let issue_id = insert_issue(&conn, project_id, 1); + let seeds = vec![make_entity_ref("issue", issue_id, 1)]; + + let result = expand_timeline(&conn, &seeds, 0, false, 100).unwrap(); + assert!(result.expanded_entities.is_empty()); + assert!(result.unresolved_references.is_empty()); +} + +#[test] +fn test_expand_finds_linked_entity() { + let conn = setup_test_db(); + let project_id = insert_project(&conn); + let issue_id = insert_issue(&conn, project_id, 1); + let mr_id = insert_mr(&conn, project_id, 10); + + // MR closes issue + insert_ref( + &conn, + project_id, + "merge_request", + mr_id, + "issue", + Some(issue_id), + "closes", + "api", + ); + + let seeds = vec![make_entity_ref("issue", issue_id, 1)]; + let result = expand_timeline(&conn, &seeds, 1, false, 100).unwrap(); + + assert_eq!(result.expanded_entities.len(), 1); + assert_eq!( + result.expanded_entities[0].entity_ref.entity_type, + "merge_request" + ); + assert_eq!(result.expanded_entities[0].entity_ref.entity_iid, 10); + assert_eq!(result.expanded_entities[0].depth, 1); +} + +#[test] +fn test_expand_bidirectional() { + let conn = setup_test_db(); + let project_id = insert_project(&conn); + let issue_id = insert_issue(&conn, project_id, 1); + let mr_id = insert_mr(&conn, project_id, 10); + + // MR closes issue (MR is source, issue is target) + insert_ref( + &conn, + project_id, + "merge_request", + mr_id, + "issue", + Some(issue_id), + "closes", + "api", + ); + + // Starting from MR should find the issue (outgoing) + let seeds = vec![make_entity_ref("merge_request", mr_id, 10)]; + let result = expand_timeline(&conn, &seeds, 1, false, 100).unwrap(); + + assert_eq!(result.expanded_entities.len(), 1); + assert_eq!(result.expanded_entities[0].entity_ref.entity_type, "issue"); +} + +#[test] +fn test_expand_respects_max_entities() { + let conn = setup_test_db(); + let project_id = insert_project(&conn); + let issue_id = insert_issue(&conn, project_id, 1); + + // Create 10 MRs that all close this issue + for i in 2..=11 { + let mr_id = insert_mr(&conn, project_id, i); + insert_ref( + &conn, + project_id, + "merge_request", + mr_id, + "issue", + Some(issue_id), + "closes", + "api", + ); + } + + let seeds = vec![make_entity_ref("issue", issue_id, 1)]; + let result = expand_timeline(&conn, &seeds, 1, false, 3).unwrap(); + + assert!(result.expanded_entities.len() <= 3); +} + +#[test] +fn test_expand_skips_mentions_by_default() { + let conn = setup_test_db(); + let project_id = insert_project(&conn); + let issue_id = insert_issue(&conn, project_id, 1); + let mr_id = insert_mr(&conn, project_id, 10); + + // MR mentions issue (should be skipped by default) + insert_ref( + &conn, + project_id, + "merge_request", + mr_id, + "issue", + Some(issue_id), + "mentioned", + "note_parse", + ); + + let seeds = vec![make_entity_ref("issue", issue_id, 1)]; + let result = expand_timeline(&conn, &seeds, 1, false, 100).unwrap(); + assert!(result.expanded_entities.is_empty()); +} + +#[test] +fn test_expand_includes_mentions_when_flagged() { + let conn = setup_test_db(); + let project_id = insert_project(&conn); + let issue_id = insert_issue(&conn, project_id, 1); + let mr_id = insert_mr(&conn, project_id, 10); + + // MR mentions issue + insert_ref( + &conn, + project_id, + "merge_request", + mr_id, + "issue", + Some(issue_id), + "mentioned", + "note_parse", + ); + + let seeds = vec![make_entity_ref("issue", issue_id, 1)]; + let result = expand_timeline(&conn, &seeds, 1, true, 100).unwrap(); + assert_eq!(result.expanded_entities.len(), 1); +} + +#[test] +fn test_expand_collects_unresolved() { + let conn = setup_test_db(); + let project_id = insert_project(&conn); + let issue_id = insert_issue(&conn, project_id, 1); + + // Unresolved cross-project reference + conn.execute( + "INSERT INTO entity_references (project_id, source_entity_type, source_entity_id, target_entity_type, target_entity_id, target_project_path, target_entity_iid, reference_type, source_method, created_at) VALUES (?1, 'issue', ?2, 'issue', NULL, 'other/repo', 42, 'closes', 'description_parse', 1000)", + rusqlite::params![project_id, issue_id], + ) + .unwrap(); + + let seeds = vec![make_entity_ref("issue", issue_id, 1)]; + let result = expand_timeline(&conn, &seeds, 1, false, 100).unwrap(); + + assert!(result.expanded_entities.is_empty()); + assert_eq!(result.unresolved_references.len(), 1); + assert_eq!( + result.unresolved_references[0].target_project, + Some("other/repo".to_owned()) + ); + assert_eq!(result.unresolved_references[0].target_iid, Some(42)); +} + +#[test] +fn test_expand_tracks_provenance() { + let conn = setup_test_db(); + let project_id = insert_project(&conn); + let issue_id = insert_issue(&conn, project_id, 1); + let mr_id = insert_mr(&conn, project_id, 10); + + insert_ref( + &conn, + project_id, + "merge_request", + mr_id, + "issue", + Some(issue_id), + "closes", + "api", + ); + + let seeds = vec![make_entity_ref("issue", issue_id, 1)]; + let result = expand_timeline(&conn, &seeds, 1, false, 100).unwrap(); + + assert_eq!(result.expanded_entities.len(), 1); + let expanded = &result.expanded_entities[0]; + assert_eq!(expanded.via_reference_type, "closes"); + assert_eq!(expanded.via_source_method, "api"); + assert_eq!(expanded.via_from.entity_type, "issue"); + assert_eq!(expanded.via_from.entity_id, issue_id); +} + +#[test] +fn test_expand_no_duplicates() { + let conn = setup_test_db(); + let project_id = insert_project(&conn); + let issue_id = insert_issue(&conn, project_id, 1); + let mr_id = insert_mr(&conn, project_id, 10); + + // Two references from MR to same issue (different methods) + insert_ref( + &conn, + project_id, + "merge_request", + mr_id, + "issue", + Some(issue_id), + "closes", + "api", + ); + insert_ref( + &conn, + project_id, + "merge_request", + mr_id, + "issue", + Some(issue_id), + "related", + "note_parse", + ); + + let seeds = vec![make_entity_ref("merge_request", mr_id, 10)]; + let result = expand_timeline(&conn, &seeds, 1, false, 100).unwrap(); + + // Should only appear once (first-come wins) + assert_eq!(result.expanded_entities.len(), 1); +} + +#[test] +fn test_expand_empty_seeds() { + let conn = setup_test_db(); + let result = expand_timeline(&conn, &[], 1, false, 100).unwrap(); + assert!(result.expanded_entities.is_empty()); +} diff --git a/src/core/timeline_seed.rs b/src/core/timeline_seed.rs index dd022a3..8b0702b 100644 --- a/src/core/timeline_seed.rs +++ b/src/core/timeline_seed.rs @@ -233,320 +233,5 @@ fn truncate_to_chars(s: &str, max_chars: usize) -> String { } #[cfg(test)] -mod tests { - use super::*; - use crate::core::db::{create_connection, run_migrations}; - use std::path::Path; - - fn setup_test_db() -> Connection { - let conn = create_connection(Path::new(":memory:")).unwrap(); - run_migrations(&conn).unwrap(); - conn - } - - fn insert_test_project(conn: &Connection) -> i64 { - conn.execute( - "INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url) VALUES (1, 'group/project', 'https://gitlab.com/group/project')", - [], - ) - .unwrap(); - conn.last_insert_rowid() - } - - fn insert_test_issue(conn: &Connection, project_id: i64, iid: i64) -> i64 { - conn.execute( - "INSERT INTO issues (gitlab_id, project_id, iid, title, state, author_username, created_at, updated_at, last_seen_at) VALUES (?1, ?2, ?3, 'Test issue', 'opened', 'alice', 1000, 2000, 3000)", - rusqlite::params![iid * 100, project_id, iid], - ) - .unwrap(); - conn.last_insert_rowid() - } - - fn insert_test_mr(conn: &Connection, project_id: i64, iid: i64) -> i64 { - conn.execute( - "INSERT INTO merge_requests (gitlab_id, project_id, iid, title, state, author_username, created_at, updated_at, last_seen_at) VALUES (?1, ?2, ?3, 'Test MR', 'opened', 'bob', 1000, 2000, 3000)", - rusqlite::params![iid * 100, project_id, iid], - ) - .unwrap(); - conn.last_insert_rowid() - } - - fn insert_document( - conn: &Connection, - source_type: &str, - source_id: i64, - project_id: i64, - content: &str, - ) -> i64 { - conn.execute( - "INSERT INTO documents (source_type, source_id, project_id, content_text, content_hash) VALUES (?1, ?2, ?3, ?4, ?5)", - rusqlite::params![source_type, source_id, project_id, content, format!("hash_{source_id}")], - ) - .unwrap(); - conn.last_insert_rowid() - } - - fn insert_discussion( - conn: &Connection, - project_id: i64, - issue_id: Option, - mr_id: Option, - ) -> i64 { - let noteable_type = if issue_id.is_some() { - "Issue" - } else { - "MergeRequest" - }; - conn.execute( - "INSERT INTO discussions (gitlab_discussion_id, project_id, issue_id, merge_request_id, noteable_type, last_seen_at) VALUES (?1, ?2, ?3, ?4, ?5, 0)", - rusqlite::params![format!("disc_{}", rand::random::()), project_id, issue_id, mr_id, noteable_type], - ) - .unwrap(); - conn.last_insert_rowid() - } - - fn insert_note( - conn: &Connection, - discussion_id: i64, - project_id: i64, - body: &str, - is_system: bool, - ) -> i64 { - let gitlab_id: i64 = rand::random::().into(); - conn.execute( - "INSERT INTO notes (gitlab_id, discussion_id, project_id, is_system, author_username, body, created_at, updated_at, last_seen_at) VALUES (?1, ?2, ?3, ?4, 'alice', ?5, 5000, 5000, 5000)", - rusqlite::params![gitlab_id, discussion_id, project_id, is_system as i32, body], - ) - .unwrap(); - conn.last_insert_rowid() - } - - #[test] - fn test_seed_empty_query_returns_empty() { - let conn = setup_test_db(); - let result = seed_timeline(&conn, "", None, None, 50, 10).unwrap(); - assert!(result.seed_entities.is_empty()); - assert!(result.evidence_notes.is_empty()); - } - - #[test] - fn test_seed_no_matches_returns_empty() { - let conn = setup_test_db(); - let project_id = insert_test_project(&conn); - let issue_id = insert_test_issue(&conn, project_id, 1); - insert_document( - &conn, - "issue", - issue_id, - project_id, - "unrelated content here", - ); - - let result = seed_timeline(&conn, "nonexistent_xyzzy_query", None, None, 50, 10).unwrap(); - assert!(result.seed_entities.is_empty()); - } - - #[test] - fn test_seed_finds_issue() { - let conn = setup_test_db(); - let project_id = insert_test_project(&conn); - let issue_id = insert_test_issue(&conn, project_id, 42); - insert_document( - &conn, - "issue", - issue_id, - project_id, - "authentication error in login flow", - ); - - let result = seed_timeline(&conn, "authentication", None, None, 50, 10).unwrap(); - assert_eq!(result.seed_entities.len(), 1); - assert_eq!(result.seed_entities[0].entity_type, "issue"); - assert_eq!(result.seed_entities[0].entity_iid, 42); - assert_eq!(result.seed_entities[0].project_path, "group/project"); - } - - #[test] - fn test_seed_finds_mr() { - let conn = setup_test_db(); - let project_id = insert_test_project(&conn); - let mr_id = insert_test_mr(&conn, project_id, 99); - insert_document( - &conn, - "merge_request", - mr_id, - project_id, - "fix authentication bug", - ); - - let result = seed_timeline(&conn, "authentication", None, None, 50, 10).unwrap(); - assert_eq!(result.seed_entities.len(), 1); - assert_eq!(result.seed_entities[0].entity_type, "merge_request"); - assert_eq!(result.seed_entities[0].entity_iid, 99); - } - - #[test] - fn test_seed_deduplicates_entities() { - let conn = setup_test_db(); - let project_id = insert_test_project(&conn); - let issue_id = insert_test_issue(&conn, project_id, 10); - - // Two documents referencing the same issue - insert_document( - &conn, - "issue", - issue_id, - project_id, - "authentication error first doc", - ); - let disc_id = insert_discussion(&conn, project_id, Some(issue_id), None); - insert_document( - &conn, - "discussion", - disc_id, - project_id, - "authentication error second doc", - ); - - let result = seed_timeline(&conn, "authentication", None, None, 50, 10).unwrap(); - // Should deduplicate: both map to the same issue - assert_eq!(result.seed_entities.len(), 1); - assert_eq!(result.seed_entities[0].entity_iid, 10); - } - - #[test] - fn test_seed_resolves_discussion_to_parent() { - let conn = setup_test_db(); - let project_id = insert_test_project(&conn); - let issue_id = insert_test_issue(&conn, project_id, 7); - let disc_id = insert_discussion(&conn, project_id, Some(issue_id), None); - insert_document( - &conn, - "discussion", - disc_id, - project_id, - "deployment pipeline failed", - ); - - let result = seed_timeline(&conn, "deployment", None, None, 50, 10).unwrap(); - assert_eq!(result.seed_entities.len(), 1); - assert_eq!(result.seed_entities[0].entity_type, "issue"); - assert_eq!(result.seed_entities[0].entity_iid, 7); - } - - #[test] - fn test_seed_evidence_capped() { - let conn = setup_test_db(); - let project_id = insert_test_project(&conn); - let issue_id = insert_test_issue(&conn, project_id, 1); - - // Create 15 discussion documents with notes about "deployment" - for i in 0..15 { - let disc_id = insert_discussion(&conn, project_id, Some(issue_id), None); - insert_document( - &conn, - "discussion", - disc_id, - project_id, - &format!("deployment issue number {i}"), - ); - insert_note( - &conn, - disc_id, - project_id, - &format!("deployment note {i}"), - false, - ); - } - - let result = seed_timeline(&conn, "deployment", None, None, 50, 5).unwrap(); - assert!(result.evidence_notes.len() <= 5); - } - - #[test] - fn test_seed_evidence_snippet_truncated() { - let conn = setup_test_db(); - let project_id = insert_test_project(&conn); - let issue_id = insert_test_issue(&conn, project_id, 1); - let disc_id = insert_discussion(&conn, project_id, Some(issue_id), None); - insert_document( - &conn, - "discussion", - disc_id, - project_id, - "deployment configuration", - ); - - let long_body = "x".repeat(500); - insert_note(&conn, disc_id, project_id, &long_body, false); - - let result = seed_timeline(&conn, "deployment", None, None, 50, 10).unwrap(); - assert!(!result.evidence_notes.is_empty()); - if let TimelineEventType::NoteEvidence { snippet, .. } = - &result.evidence_notes[0].event_type - { - assert!(snippet.chars().count() <= 200); - } else { - panic!("Expected NoteEvidence"); - } - } - - #[test] - fn test_seed_respects_project_filter() { - let conn = setup_test_db(); - let project_id = insert_test_project(&conn); - - // Insert a second project - conn.execute( - "INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url) VALUES (2, 'other/repo', 'https://gitlab.com/other/repo')", - [], - ) - .unwrap(); - let project2_id = conn.last_insert_rowid(); - - let issue1_id = insert_test_issue(&conn, project_id, 1); - insert_document( - &conn, - "issue", - issue1_id, - project_id, - "authentication error", - ); - - let issue2_id = insert_test_issue(&conn, project2_id, 2); - insert_document( - &conn, - "issue", - issue2_id, - project2_id, - "authentication error", - ); - - // Filter to project 1 only - let result = - seed_timeline(&conn, "authentication", Some(project_id), None, 50, 10).unwrap(); - assert_eq!(result.seed_entities.len(), 1); - assert_eq!(result.seed_entities[0].project_path, "group/project"); - } - - #[test] - fn test_truncate_to_chars_short() { - assert_eq!(truncate_to_chars("hello", 200), "hello"); - } - - #[test] - fn test_truncate_to_chars_long() { - let long = "a".repeat(300); - let result = truncate_to_chars(&long, 200); - assert_eq!(result.chars().count(), 200); - } - - #[test] - fn test_truncate_to_chars_multibyte() { - let s = "\u{1F600}".repeat(300); // emoji - let result = truncate_to_chars(&s, 200); - assert_eq!(result.chars().count(), 200); - // Verify valid UTF-8 - assert!(std::str::from_utf8(result.as_bytes()).is_ok()); - } -} +#[path = "timeline_seed_tests.rs"] +mod tests; diff --git a/src/core/timeline_seed_tests.rs b/src/core/timeline_seed_tests.rs new file mode 100644 index 0000000..0256b66 --- /dev/null +++ b/src/core/timeline_seed_tests.rs @@ -0,0 +1,312 @@ +use super::*; +use crate::core::db::{create_connection, run_migrations}; +use std::path::Path; + +fn setup_test_db() -> Connection { + let conn = create_connection(Path::new(":memory:")).unwrap(); + run_migrations(&conn).unwrap(); + conn +} + +fn insert_test_project(conn: &Connection) -> i64 { + conn.execute( + "INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url) VALUES (1, 'group/project', 'https://gitlab.com/group/project')", + [], + ) + .unwrap(); + conn.last_insert_rowid() +} + +fn insert_test_issue(conn: &Connection, project_id: i64, iid: i64) -> i64 { + conn.execute( + "INSERT INTO issues (gitlab_id, project_id, iid, title, state, author_username, created_at, updated_at, last_seen_at) VALUES (?1, ?2, ?3, 'Test issue', 'opened', 'alice', 1000, 2000, 3000)", + rusqlite::params![iid * 100, project_id, iid], + ) + .unwrap(); + conn.last_insert_rowid() +} + +fn insert_test_mr(conn: &Connection, project_id: i64, iid: i64) -> i64 { + conn.execute( + "INSERT INTO merge_requests (gitlab_id, project_id, iid, title, state, author_username, created_at, updated_at, last_seen_at) VALUES (?1, ?2, ?3, 'Test MR', 'opened', 'bob', 1000, 2000, 3000)", + rusqlite::params![iid * 100, project_id, iid], + ) + .unwrap(); + conn.last_insert_rowid() +} + +fn insert_document( + conn: &Connection, + source_type: &str, + source_id: i64, + project_id: i64, + content: &str, +) -> i64 { + conn.execute( + "INSERT INTO documents (source_type, source_id, project_id, content_text, content_hash) VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![source_type, source_id, project_id, content, format!("hash_{source_id}")], + ) + .unwrap(); + conn.last_insert_rowid() +} + +fn insert_discussion( + conn: &Connection, + project_id: i64, + issue_id: Option, + mr_id: Option, +) -> i64 { + let noteable_type = if issue_id.is_some() { + "Issue" + } else { + "MergeRequest" + }; + conn.execute( + "INSERT INTO discussions (gitlab_discussion_id, project_id, issue_id, merge_request_id, noteable_type, last_seen_at) VALUES (?1, ?2, ?3, ?4, ?5, 0)", + rusqlite::params![format!("disc_{}", rand::random::()), project_id, issue_id, mr_id, noteable_type], + ) + .unwrap(); + conn.last_insert_rowid() +} + +fn insert_note( + conn: &Connection, + discussion_id: i64, + project_id: i64, + body: &str, + is_system: bool, +) -> i64 { + let gitlab_id: i64 = rand::random::().into(); + conn.execute( + "INSERT INTO notes (gitlab_id, discussion_id, project_id, is_system, author_username, body, created_at, updated_at, last_seen_at) VALUES (?1, ?2, ?3, ?4, 'alice', ?5, 5000, 5000, 5000)", + rusqlite::params![gitlab_id, discussion_id, project_id, is_system as i32, body], + ) + .unwrap(); + conn.last_insert_rowid() +} + +#[test] +fn test_seed_empty_query_returns_empty() { + let conn = setup_test_db(); + let result = seed_timeline(&conn, "", None, None, 50, 10).unwrap(); + assert!(result.seed_entities.is_empty()); + assert!(result.evidence_notes.is_empty()); +} + +#[test] +fn test_seed_no_matches_returns_empty() { + let conn = setup_test_db(); + let project_id = insert_test_project(&conn); + let issue_id = insert_test_issue(&conn, project_id, 1); + insert_document( + &conn, + "issue", + issue_id, + project_id, + "unrelated content here", + ); + + let result = seed_timeline(&conn, "nonexistent_xyzzy_query", None, None, 50, 10).unwrap(); + assert!(result.seed_entities.is_empty()); +} + +#[test] +fn test_seed_finds_issue() { + let conn = setup_test_db(); + let project_id = insert_test_project(&conn); + let issue_id = insert_test_issue(&conn, project_id, 42); + insert_document( + &conn, + "issue", + issue_id, + project_id, + "authentication error in login flow", + ); + + let result = seed_timeline(&conn, "authentication", None, None, 50, 10).unwrap(); + assert_eq!(result.seed_entities.len(), 1); + assert_eq!(result.seed_entities[0].entity_type, "issue"); + assert_eq!(result.seed_entities[0].entity_iid, 42); + assert_eq!(result.seed_entities[0].project_path, "group/project"); +} + +#[test] +fn test_seed_finds_mr() { + let conn = setup_test_db(); + let project_id = insert_test_project(&conn); + let mr_id = insert_test_mr(&conn, project_id, 99); + insert_document( + &conn, + "merge_request", + mr_id, + project_id, + "fix authentication bug", + ); + + let result = seed_timeline(&conn, "authentication", None, None, 50, 10).unwrap(); + assert_eq!(result.seed_entities.len(), 1); + assert_eq!(result.seed_entities[0].entity_type, "merge_request"); + assert_eq!(result.seed_entities[0].entity_iid, 99); +} + +#[test] +fn test_seed_deduplicates_entities() { + let conn = setup_test_db(); + let project_id = insert_test_project(&conn); + let issue_id = insert_test_issue(&conn, project_id, 10); + + // Two documents referencing the same issue + insert_document( + &conn, + "issue", + issue_id, + project_id, + "authentication error first doc", + ); + let disc_id = insert_discussion(&conn, project_id, Some(issue_id), None); + insert_document( + &conn, + "discussion", + disc_id, + project_id, + "authentication error second doc", + ); + + let result = seed_timeline(&conn, "authentication", None, None, 50, 10).unwrap(); + // Should deduplicate: both map to the same issue + assert_eq!(result.seed_entities.len(), 1); + assert_eq!(result.seed_entities[0].entity_iid, 10); +} + +#[test] +fn test_seed_resolves_discussion_to_parent() { + let conn = setup_test_db(); + let project_id = insert_test_project(&conn); + let issue_id = insert_test_issue(&conn, project_id, 7); + let disc_id = insert_discussion(&conn, project_id, Some(issue_id), None); + insert_document( + &conn, + "discussion", + disc_id, + project_id, + "deployment pipeline failed", + ); + + let result = seed_timeline(&conn, "deployment", None, None, 50, 10).unwrap(); + assert_eq!(result.seed_entities.len(), 1); + assert_eq!(result.seed_entities[0].entity_type, "issue"); + assert_eq!(result.seed_entities[0].entity_iid, 7); +} + +#[test] +fn test_seed_evidence_capped() { + let conn = setup_test_db(); + let project_id = insert_test_project(&conn); + let issue_id = insert_test_issue(&conn, project_id, 1); + + // Create 15 discussion documents with notes about "deployment" + for i in 0..15 { + let disc_id = insert_discussion(&conn, project_id, Some(issue_id), None); + insert_document( + &conn, + "discussion", + disc_id, + project_id, + &format!("deployment issue number {i}"), + ); + insert_note( + &conn, + disc_id, + project_id, + &format!("deployment note {i}"), + false, + ); + } + + let result = seed_timeline(&conn, "deployment", None, None, 50, 5).unwrap(); + assert!(result.evidence_notes.len() <= 5); +} + +#[test] +fn test_seed_evidence_snippet_truncated() { + let conn = setup_test_db(); + let project_id = insert_test_project(&conn); + let issue_id = insert_test_issue(&conn, project_id, 1); + let disc_id = insert_discussion(&conn, project_id, Some(issue_id), None); + insert_document( + &conn, + "discussion", + disc_id, + project_id, + "deployment configuration", + ); + + let long_body = "x".repeat(500); + insert_note(&conn, disc_id, project_id, &long_body, false); + + let result = seed_timeline(&conn, "deployment", None, None, 50, 10).unwrap(); + assert!(!result.evidence_notes.is_empty()); + if let TimelineEventType::NoteEvidence { snippet, .. } = &result.evidence_notes[0].event_type { + assert!(snippet.chars().count() <= 200); + } else { + panic!("Expected NoteEvidence"); + } +} + +#[test] +fn test_seed_respects_project_filter() { + let conn = setup_test_db(); + let project_id = insert_test_project(&conn); + + // Insert a second project + conn.execute( + "INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url) VALUES (2, 'other/repo', 'https://gitlab.com/other/repo')", + [], + ) + .unwrap(); + let project2_id = conn.last_insert_rowid(); + + let issue1_id = insert_test_issue(&conn, project_id, 1); + insert_document( + &conn, + "issue", + issue1_id, + project_id, + "authentication error", + ); + + let issue2_id = insert_test_issue(&conn, project2_id, 2); + insert_document( + &conn, + "issue", + issue2_id, + project2_id, + "authentication error", + ); + + // Filter to project 1 only + let result = seed_timeline(&conn, "authentication", Some(project_id), None, 50, 10).unwrap(); + assert_eq!(result.seed_entities.len(), 1); + assert_eq!(result.seed_entities[0].project_path, "group/project"); +} + +#[test] +fn test_truncate_to_chars_short() { + assert_eq!(truncate_to_chars("hello", 200), "hello"); +} + +#[test] +fn test_truncate_to_chars_long() { + let long = "a".repeat(300); + let result = truncate_to_chars(&long, 200); + assert_eq!(result.chars().count(), 200); +} + +#[test] +fn test_truncate_to_chars_multibyte() { + let s = "\u{1F600}".repeat(300); // emoji + let result = truncate_to_chars(&s, 200); + assert_eq!(result.chars().count(), 200); + // Verify valid UTF-8 + assert!(std::str::from_utf8(result.as_bytes()).is_ok()); +} diff --git a/src/documents/extractor.rs b/src/documents/extractor.rs index add611d..3903cad 100644 --- a/src/documents/extractor.rs +++ b/src/documents/extractor.rs @@ -1035,1307 +1035,5 @@ fn build_note_document( } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_source_type_parse_aliases() { - assert_eq!(SourceType::parse("issue"), Some(SourceType::Issue)); - assert_eq!(SourceType::parse("issues"), Some(SourceType::Issue)); - assert_eq!(SourceType::parse("mr"), Some(SourceType::MergeRequest)); - assert_eq!(SourceType::parse("mrs"), Some(SourceType::MergeRequest)); - assert_eq!( - SourceType::parse("merge_request"), - Some(SourceType::MergeRequest) - ); - assert_eq!( - SourceType::parse("merge_requests"), - Some(SourceType::MergeRequest) - ); - assert_eq!( - SourceType::parse("discussion"), - Some(SourceType::Discussion) - ); - assert_eq!( - SourceType::parse("discussions"), - Some(SourceType::Discussion) - ); - assert_eq!(SourceType::parse("invalid"), None); - assert_eq!(SourceType::parse("ISSUE"), Some(SourceType::Issue)); - } - - #[test] - fn test_source_type_parse_note() { - assert_eq!(SourceType::parse("note"), Some(SourceType::Note)); - } - - #[test] - fn test_source_type_note_as_str() { - assert_eq!(SourceType::Note.as_str(), "note"); - } - - #[test] - fn test_source_type_note_display() { - assert_eq!(format!("{}", SourceType::Note), "note"); - } - - #[test] - fn test_source_type_parse_notes_alias() { - assert_eq!(SourceType::parse("notes"), Some(SourceType::Note)); - } - - #[test] - fn test_source_type_as_str() { - assert_eq!(SourceType::Issue.as_str(), "issue"); - assert_eq!(SourceType::MergeRequest.as_str(), "merge_request"); - assert_eq!(SourceType::Discussion.as_str(), "discussion"); - } - - #[test] - fn test_source_type_display() { - assert_eq!(format!("{}", SourceType::Issue), "issue"); - assert_eq!(format!("{}", SourceType::MergeRequest), "merge_request"); - assert_eq!(format!("{}", SourceType::Discussion), "discussion"); - } - - #[test] - fn test_content_hash_deterministic() { - let hash1 = compute_content_hash("hello"); - let hash2 = compute_content_hash("hello"); - assert_eq!(hash1, hash2); - assert!(!hash1.is_empty()); - assert_eq!(hash1.len(), 64); - } - - #[test] - fn test_content_hash_different_inputs() { - let hash1 = compute_content_hash("hello"); - let hash2 = compute_content_hash("world"); - assert_ne!(hash1, hash2); - } - - #[test] - fn test_content_hash_empty() { - let hash = compute_content_hash(""); - assert_eq!(hash.len(), 64); - } - - #[test] - fn test_list_hash_order_independent() { - let hash1 = compute_list_hash(&["b".to_string(), "a".to_string()]); - let hash2 = compute_list_hash(&["a".to_string(), "b".to_string()]); - assert_eq!(hash1, hash2); - } - - #[test] - fn test_list_hash_empty() { - let hash = compute_list_hash(&[]); - assert_eq!(hash.len(), 64); - let hash2 = compute_list_hash(&[]); - assert_eq!(hash, hash2); - } - - fn setup_test_db() -> Connection { - let conn = Connection::open_in_memory().unwrap(); - conn.execute_batch( - " - CREATE TABLE projects ( - id INTEGER PRIMARY KEY, - gitlab_project_id INTEGER UNIQUE NOT NULL, - path_with_namespace TEXT NOT NULL, - default_branch TEXT, - web_url TEXT, - created_at INTEGER, - updated_at INTEGER, - raw_payload_id INTEGER - ); - CREATE TABLE issues ( - id INTEGER PRIMARY KEY, - gitlab_id INTEGER UNIQUE NOT NULL, - project_id INTEGER NOT NULL REFERENCES projects(id), - iid INTEGER NOT NULL, - title TEXT, - description TEXT, - state TEXT NOT NULL, - author_username TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - last_seen_at INTEGER NOT NULL, - discussions_synced_for_updated_at INTEGER, - resource_events_synced_for_updated_at INTEGER, - web_url TEXT, - raw_payload_id INTEGER - ); - CREATE TABLE labels ( - id INTEGER PRIMARY KEY, - gitlab_id INTEGER, - project_id INTEGER NOT NULL REFERENCES projects(id), - name TEXT NOT NULL, - color TEXT, - description TEXT - ); - CREATE TABLE issue_labels ( - issue_id INTEGER NOT NULL REFERENCES issues(id), - label_id INTEGER NOT NULL REFERENCES labels(id), - PRIMARY KEY(issue_id, label_id) - ); - ", - ) - .unwrap(); - - conn.execute( - "INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url) VALUES (1, 100, 'group/project-one', 'https://gitlab.example.com/group/project-one')", - [], - ).unwrap(); - - conn - } - - #[allow(clippy::too_many_arguments)] - fn insert_issue( - conn: &Connection, - id: i64, - iid: i64, - title: Option<&str>, - description: Option<&str>, - state: &str, - author: Option<&str>, - web_url: Option<&str>, - ) { - conn.execute( - "INSERT INTO issues (id, gitlab_id, project_id, iid, title, description, state, author_username, created_at, updated_at, last_seen_at, web_url) VALUES (?1, ?2, 1, ?3, ?4, ?5, ?6, ?7, 1000, 2000, 3000, ?8)", - rusqlite::params![id, id * 10, iid, title, description, state, author, web_url], - ).unwrap(); - } - - fn insert_label(conn: &Connection, id: i64, name: &str) { - conn.execute( - "INSERT INTO labels (id, project_id, name) VALUES (?1, 1, ?2)", - rusqlite::params![id, name], - ) - .unwrap(); - } - - fn link_issue_label(conn: &Connection, issue_id: i64, label_id: i64) { - conn.execute( - "INSERT INTO issue_labels (issue_id, label_id) VALUES (?1, ?2)", - rusqlite::params![issue_id, label_id], - ) - .unwrap(); - } - - #[test] - fn test_issue_document_format() { - let conn = setup_test_db(); - insert_issue( - &conn, - 1, - 234, - Some("Authentication redesign"), - Some("We need to modernize our authentication system..."), - "opened", - Some("johndoe"), - Some("https://gitlab.example.com/group/project-one/-/issues/234"), - ); - insert_label(&conn, 1, "auth"); - insert_label(&conn, 2, "bug"); - link_issue_label(&conn, 1, 1); - link_issue_label(&conn, 1, 2); - - let doc = extract_issue_document(&conn, 1).unwrap().unwrap(); - assert_eq!(doc.source_type, SourceType::Issue); - assert_eq!(doc.source_id, 1); - assert_eq!(doc.project_id, 1); - assert_eq!(doc.author_username, Some("johndoe".to_string())); - assert!( - doc.content_text - .starts_with("[[Issue]] #234: Authentication redesign\n") - ); - assert!(doc.content_text.contains("Project: group/project-one\n")); - assert!( - doc.content_text - .contains("URL: https://gitlab.example.com/group/project-one/-/issues/234\n") - ); - assert!(doc.content_text.contains("Labels: [\"auth\",\"bug\"]\n")); - assert!(doc.content_text.contains("State: opened\n")); - assert!(doc.content_text.contains("Author: @johndoe\n")); - assert!( - doc.content_text.contains( - "--- Description ---\n\nWe need to modernize our authentication system..." - ) - ); - assert!(!doc.is_truncated); - assert!(doc.paths.is_empty()); - } - - #[test] - fn test_issue_not_found() { - let conn = setup_test_db(); - let result = extract_issue_document(&conn, 999).unwrap(); - assert!(result.is_none()); - } - - #[test] - fn test_issue_no_description() { - let conn = setup_test_db(); - insert_issue( - &conn, - 1, - 10, - Some("Quick fix"), - None, - "opened", - Some("alice"), - None, - ); - - let doc = extract_issue_document(&conn, 1).unwrap().unwrap(); - assert!(!doc.content_text.contains("--- Description ---")); - assert!(doc.content_text.contains("[[Issue]] #10: Quick fix\n")); - } - - #[test] - fn test_issue_labels_sorted() { - let conn = setup_test_db(); - insert_issue( - &conn, - 1, - 10, - Some("Test"), - Some("Body"), - "opened", - Some("bob"), - None, - ); - insert_label(&conn, 1, "zeta"); - insert_label(&conn, 2, "alpha"); - insert_label(&conn, 3, "middle"); - link_issue_label(&conn, 1, 1); - link_issue_label(&conn, 1, 2); - link_issue_label(&conn, 1, 3); - - let doc = extract_issue_document(&conn, 1).unwrap().unwrap(); - assert_eq!(doc.labels, vec!["alpha", "middle", "zeta"]); - assert!( - doc.content_text - .contains("Labels: [\"alpha\",\"middle\",\"zeta\"]") - ); - } - - #[test] - fn test_issue_no_labels() { - let conn = setup_test_db(); - insert_issue( - &conn, - 1, - 10, - Some("Test"), - Some("Body"), - "opened", - None, - None, - ); - - let doc = extract_issue_document(&conn, 1).unwrap().unwrap(); - assert!(doc.labels.is_empty()); - assert!(doc.content_text.contains("Labels: []\n")); - } - - #[test] - fn test_issue_hash_deterministic() { - let conn = setup_test_db(); - insert_issue( - &conn, - 1, - 10, - Some("Test"), - Some("Body"), - "opened", - Some("alice"), - None, - ); - - let doc1 = extract_issue_document(&conn, 1).unwrap().unwrap(); - let doc2 = extract_issue_document(&conn, 1).unwrap().unwrap(); - assert_eq!(doc1.content_hash, doc2.content_hash); - assert_eq!(doc1.labels_hash, doc2.labels_hash); - assert_eq!(doc1.content_hash.len(), 64); - } - - #[test] - fn test_issue_empty_description() { - let conn = setup_test_db(); - insert_issue(&conn, 1, 10, Some("Test"), Some(""), "opened", None, None); - - let doc = extract_issue_document(&conn, 1).unwrap().unwrap(); - assert!(doc.content_text.contains("--- Description ---\n\n")); - } - - fn setup_mr_test_db() -> Connection { - let conn = setup_test_db(); - conn.execute_batch( - " - CREATE TABLE merge_requests ( - id INTEGER PRIMARY KEY, - gitlab_id INTEGER UNIQUE NOT NULL, - project_id INTEGER NOT NULL REFERENCES projects(id), - iid INTEGER NOT NULL, - title TEXT, - description TEXT, - state TEXT, - draft INTEGER NOT NULL DEFAULT 0, - author_username TEXT, - source_branch TEXT, - target_branch TEXT, - head_sha TEXT, - references_short TEXT, - references_full TEXT, - detailed_merge_status TEXT, - merge_user_username TEXT, - created_at INTEGER, - updated_at INTEGER, - merged_at INTEGER, - closed_at INTEGER, - last_seen_at INTEGER NOT NULL, - discussions_synced_for_updated_at INTEGER, - discussions_sync_last_attempt_at INTEGER, - discussions_sync_attempts INTEGER DEFAULT 0, - discussions_sync_last_error TEXT, - resource_events_synced_for_updated_at INTEGER, - web_url TEXT, - raw_payload_id INTEGER - ); - CREATE TABLE mr_labels ( - merge_request_id INTEGER REFERENCES merge_requests(id), - label_id INTEGER REFERENCES labels(id), - PRIMARY KEY(merge_request_id, label_id) - ); - ", - ) - .unwrap(); - conn - } - - #[allow(clippy::too_many_arguments)] - fn insert_mr( - conn: &Connection, - id: i64, - iid: i64, - title: Option<&str>, - description: Option<&str>, - state: Option<&str>, - author: Option<&str>, - source_branch: Option<&str>, - target_branch: Option<&str>, - web_url: Option<&str>, - ) { - conn.execute( - "INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, description, state, author_username, source_branch, target_branch, created_at, updated_at, last_seen_at, web_url) VALUES (?1, ?2, 1, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 1000, 2000, 3000, ?10)", - rusqlite::params![id, id * 10, iid, title, description, state, author, source_branch, target_branch, web_url], - ).unwrap(); - } - - fn link_mr_label(conn: &Connection, mr_id: i64, label_id: i64) { - conn.execute( - "INSERT INTO mr_labels (merge_request_id, label_id) VALUES (?1, ?2)", - rusqlite::params![mr_id, label_id], - ) - .unwrap(); - } - - #[test] - fn test_mr_document_format() { - let conn = setup_mr_test_db(); - insert_mr( - &conn, - 1, - 456, - Some("Implement JWT authentication"), - Some("This MR implements JWT-based authentication..."), - Some("opened"), - Some("johndoe"), - Some("feature/jwt-auth"), - Some("main"), - Some("https://gitlab.example.com/group/project-one/-/merge_requests/456"), - ); - insert_label(&conn, 1, "auth"); - insert_label(&conn, 2, "feature"); - link_mr_label(&conn, 1, 1); - link_mr_label(&conn, 1, 2); - - let doc = extract_mr_document(&conn, 1).unwrap().unwrap(); - assert_eq!(doc.source_type, SourceType::MergeRequest); - assert_eq!(doc.source_id, 1); - assert!( - doc.content_text - .starts_with("[[MergeRequest]] !456: Implement JWT authentication\n") - ); - assert!(doc.content_text.contains("Project: group/project-one\n")); - assert!( - doc.content_text - .contains("Labels: [\"auth\",\"feature\"]\n") - ); - assert!(doc.content_text.contains("State: opened\n")); - assert!(doc.content_text.contains("Author: @johndoe\n")); - assert!( - doc.content_text - .contains("Source: feature/jwt-auth -> main\n") - ); - assert!( - doc.content_text - .contains("--- Description ---\n\nThis MR implements JWT-based authentication...") - ); - } - - #[test] - fn test_mr_not_found() { - let conn = setup_mr_test_db(); - let result = extract_mr_document(&conn, 999).unwrap(); - assert!(result.is_none()); - } - - #[test] - fn test_mr_no_description() { - let conn = setup_mr_test_db(); - insert_mr( - &conn, - 1, - 10, - Some("Quick fix"), - None, - Some("merged"), - Some("alice"), - Some("fix/bug"), - Some("main"), - None, - ); - - let doc = extract_mr_document(&conn, 1).unwrap().unwrap(); - assert!(!doc.content_text.contains("--- Description ---")); - assert!( - doc.content_text - .contains("[[MergeRequest]] !10: Quick fix\n") - ); - } - - #[test] - fn test_mr_branch_info() { - let conn = setup_mr_test_db(); - insert_mr( - &conn, - 1, - 10, - Some("Test"), - Some("Body"), - Some("opened"), - None, - Some("feature/foo"), - Some("develop"), - None, - ); - - let doc = extract_mr_document(&conn, 1).unwrap().unwrap(); - assert!( - doc.content_text - .contains("Source: feature/foo -> develop\n") - ); - } - - #[test] - fn test_mr_no_branches() { - let conn = setup_mr_test_db(); - insert_mr( - &conn, - 1, - 10, - Some("Test"), - None, - Some("opened"), - None, - None, - None, - None, - ); - - let doc = extract_mr_document(&conn, 1).unwrap().unwrap(); - assert!(!doc.content_text.contains("Source:")); - } - - fn setup_discussion_test_db() -> Connection { - let conn = setup_mr_test_db(); - conn.execute_batch( - " - CREATE TABLE discussions ( - id INTEGER PRIMARY KEY, - gitlab_discussion_id TEXT NOT NULL, - project_id INTEGER NOT NULL REFERENCES projects(id), - issue_id INTEGER REFERENCES issues(id), - merge_request_id INTEGER, - noteable_type TEXT NOT NULL, - individual_note INTEGER NOT NULL DEFAULT 0, - first_note_at INTEGER, - last_note_at INTEGER, - last_seen_at INTEGER NOT NULL, - resolvable INTEGER NOT NULL DEFAULT 0, - resolved INTEGER NOT NULL DEFAULT 0 - ); - CREATE TABLE notes ( - id INTEGER PRIMARY KEY, - gitlab_id INTEGER UNIQUE NOT NULL, - discussion_id INTEGER NOT NULL REFERENCES discussions(id), - project_id INTEGER NOT NULL REFERENCES projects(id), - note_type TEXT, - is_system INTEGER NOT NULL DEFAULT 0, - author_username TEXT, - body TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - last_seen_at INTEGER NOT NULL, - position INTEGER, - resolvable INTEGER NOT NULL DEFAULT 0, - resolved INTEGER NOT NULL DEFAULT 0, - resolved_by TEXT, - resolved_at INTEGER, - position_old_path TEXT, - position_new_path TEXT, - position_old_line INTEGER, - position_new_line INTEGER, - raw_payload_id INTEGER - ); - ", - ) - .unwrap(); - conn - } - - fn insert_discussion( - conn: &Connection, - id: i64, - noteable_type: &str, - issue_id: Option, - mr_id: Option, - ) { - conn.execute( - "INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, merge_request_id, noteable_type, last_seen_at) VALUES (?1, ?2, 1, ?3, ?4, ?5, 3000)", - rusqlite::params![id, format!("disc_{}", id), issue_id, mr_id, noteable_type], - ).unwrap(); - } - - #[allow(clippy::too_many_arguments)] - fn insert_note( - conn: &Connection, - id: i64, - gitlab_id: i64, - discussion_id: i64, - author: Option<&str>, - body: Option<&str>, - created_at: i64, - is_system: bool, - old_path: Option<&str>, - new_path: Option<&str>, - ) { - conn.execute( - "INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system, position_old_path, position_new_path) VALUES (?1, ?2, ?3, 1, ?4, ?5, ?6, ?6, ?6, ?7, ?8, ?9)", - rusqlite::params![id, gitlab_id, discussion_id, author, body, created_at, is_system as i32, old_path, new_path], - ).unwrap(); - } - - #[test] - fn test_discussion_document_format() { - let conn = setup_discussion_test_db(); - insert_issue( - &conn, - 1, - 234, - Some("Authentication redesign"), - Some("desc"), - "opened", - Some("johndoe"), - Some("https://gitlab.example.com/group/project-one/-/issues/234"), - ); - insert_label(&conn, 1, "auth"); - insert_label(&conn, 2, "bug"); - link_issue_label(&conn, 1, 1); - link_issue_label(&conn, 1, 2); - insert_discussion(&conn, 1, "Issue", Some(1), None); - insert_note( - &conn, - 1, - 12345, - 1, - Some("johndoe"), - Some("I think we should move to JWT-based auth..."), - 1710460800000, - false, - None, - None, - ); - insert_note( - &conn, - 2, - 12346, - 1, - Some("janedoe"), - Some("Agreed. What about refresh token strategy?"), - 1710460800000, - false, - None, - None, - ); - - let doc = extract_discussion_document(&conn, 1).unwrap().unwrap(); - assert_eq!(doc.source_type, SourceType::Discussion); - assert!( - doc.content_text - .starts_with("[[Discussion]] Issue #234: Authentication redesign\n") - ); - assert!(doc.content_text.contains("Project: group/project-one\n")); - assert!(doc.content_text.contains( - "URL: https://gitlab.example.com/group/project-one/-/issues/234#note_12345\n" - )); - assert!(doc.content_text.contains("Labels: [\"auth\",\"bug\"]\n")); - assert!(doc.content_text.contains("--- Thread ---")); - assert!( - doc.content_text - .contains("@johndoe (2024-03-15):\nI think we should move to JWT-based auth...") - ); - assert!( - doc.content_text - .contains("@janedoe (2024-03-15):\nAgreed. What about refresh token strategy?") - ); - assert_eq!(doc.author_username, Some("johndoe".to_string())); - assert!(doc.title.is_none()); - } - - #[test] - fn test_discussion_not_found() { - let conn = setup_discussion_test_db(); - let result = extract_discussion_document(&conn, 999).unwrap(); - assert!(result.is_none()); - } - - #[test] - fn test_discussion_parent_deleted() { - let conn = setup_discussion_test_db(); - insert_issue( - &conn, - 99, - 10, - Some("To be deleted"), - None, - "opened", - None, - None, - ); - insert_discussion(&conn, 1, "Issue", Some(99), None); - insert_note( - &conn, - 1, - 100, - 1, - Some("alice"), - Some("Hello"), - 1000, - false, - None, - None, - ); - conn.execute("PRAGMA foreign_keys = OFF", []).unwrap(); - conn.execute("DELETE FROM issues WHERE id = 99", []) - .unwrap(); - conn.execute("PRAGMA foreign_keys = ON", []).unwrap(); - - let result = extract_discussion_document(&conn, 1).unwrap(); - assert!(result.is_none()); - } - - #[test] - fn test_discussion_system_notes_excluded() { - let conn = setup_discussion_test_db(); - insert_issue( - &conn, - 1, - 10, - Some("Test"), - Some("desc"), - "opened", - Some("alice"), - None, - ); - insert_discussion(&conn, 1, "Issue", Some(1), None); - insert_note( - &conn, - 1, - 100, - 1, - Some("alice"), - Some("Real comment"), - 1000, - false, - None, - None, - ); - insert_note( - &conn, - 2, - 101, - 1, - Some("bot"), - Some("assigned to @alice"), - 2000, - true, - None, - None, - ); - insert_note( - &conn, - 3, - 102, - 1, - Some("bob"), - Some("Follow-up"), - 3000, - false, - None, - None, - ); - - let doc = extract_discussion_document(&conn, 1).unwrap().unwrap(); - assert!(doc.content_text.contains("@alice")); - assert!(doc.content_text.contains("@bob")); - assert!(!doc.content_text.contains("assigned to")); - } - - #[test] - fn test_discussion_diffnote_paths() { - let conn = setup_discussion_test_db(); - insert_issue( - &conn, - 1, - 10, - Some("Test"), - Some("desc"), - "opened", - None, - None, - ); - insert_discussion(&conn, 1, "Issue", Some(1), None); - insert_note( - &conn, - 1, - 100, - 1, - Some("alice"), - Some("Comment on code"), - 1000, - false, - Some("src/old.rs"), - Some("src/new.rs"), - ); - insert_note( - &conn, - 2, - 101, - 1, - Some("bob"), - Some("Reply"), - 2000, - false, - Some("src/old.rs"), - Some("src/new.rs"), - ); - - let doc = extract_discussion_document(&conn, 1).unwrap().unwrap(); - assert_eq!(doc.paths, vec!["src/new.rs", "src/old.rs"]); - assert!( - doc.content_text - .contains("Files: [\"src/new.rs\",\"src/old.rs\"]") - ); - } - - #[test] - fn test_discussion_url_construction() { - let conn = setup_discussion_test_db(); - insert_issue( - &conn, - 1, - 10, - Some("Test"), - Some("desc"), - "opened", - None, - Some("https://gitlab.example.com/group/project-one/-/issues/10"), - ); - insert_discussion(&conn, 1, "Issue", Some(1), None); - insert_note( - &conn, - 1, - 54321, - 1, - Some("alice"), - Some("Hello"), - 1000, - false, - None, - None, - ); - - let doc = extract_discussion_document(&conn, 1).unwrap().unwrap(); - assert_eq!( - doc.url, - Some("https://gitlab.example.com/group/project-one/-/issues/10#note_54321".to_string()) - ); - } - - #[test] - fn test_discussion_uses_parent_labels() { - let conn = setup_discussion_test_db(); - insert_issue( - &conn, - 1, - 10, - Some("Test"), - Some("desc"), - "opened", - None, - None, - ); - insert_label(&conn, 1, "backend"); - insert_label(&conn, 2, "api"); - link_issue_label(&conn, 1, 1); - link_issue_label(&conn, 1, 2); - insert_discussion(&conn, 1, "Issue", Some(1), None); - insert_note( - &conn, - 1, - 100, - 1, - Some("alice"), - Some("Comment"), - 1000, - false, - None, - None, - ); - - let doc = extract_discussion_document(&conn, 1).unwrap().unwrap(); - assert_eq!(doc.labels, vec!["api", "backend"]); - } - - #[test] - fn test_discussion_on_mr() { - let conn = setup_discussion_test_db(); - insert_mr( - &conn, - 1, - 456, - Some("JWT Auth"), - Some("desc"), - Some("opened"), - Some("johndoe"), - Some("feature/jwt"), - Some("main"), - Some("https://gitlab.example.com/group/project-one/-/merge_requests/456"), - ); - insert_discussion(&conn, 1, "MergeRequest", None, Some(1)); - insert_note( - &conn, - 1, - 100, - 1, - Some("alice"), - Some("LGTM"), - 1000, - false, - None, - None, - ); - - let doc = extract_discussion_document(&conn, 1).unwrap().unwrap(); - assert!( - doc.content_text - .contains("[[Discussion]] MR !456: JWT Auth\n") - ); - } - - #[test] - fn test_discussion_all_system_notes() { - let conn = setup_discussion_test_db(); - insert_issue( - &conn, - 1, - 10, - Some("Test"), - Some("desc"), - "opened", - None, - None, - ); - insert_discussion(&conn, 1, "Issue", Some(1), None); - insert_note( - &conn, - 1, - 100, - 1, - Some("bot"), - Some("assigned to @alice"), - 1000, - true, - None, - None, - ); - - let result = extract_discussion_document(&conn, 1).unwrap(); - assert!(result.is_none()); - } - - #[allow(clippy::too_many_arguments)] - fn insert_note_with_type( - conn: &Connection, - id: i64, - gitlab_id: i64, - discussion_id: i64, - author: Option<&str>, - body: Option<&str>, - created_at: i64, - is_system: bool, - old_path: Option<&str>, - new_path: Option<&str>, - old_line: Option, - new_line: Option, - note_type: Option<&str>, - resolvable: bool, - resolved: bool, - ) { - conn.execute( - "INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system, position_old_path, position_new_path, position_old_line, position_new_line, note_type, resolvable, resolved) VALUES (?1, ?2, ?3, 1, ?4, ?5, ?6, ?6, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", - rusqlite::params![id, gitlab_id, discussion_id, author, body, created_at, is_system as i32, old_path, new_path, old_line, new_line, note_type, resolvable as i32, resolved as i32], - ).unwrap(); - } - - #[test] - fn test_note_document_basic_format() { - let conn = setup_discussion_test_db(); - insert_issue( - &conn, - 1, - 42, - Some("Fix login bug"), - Some("desc"), - "opened", - Some("johndoe"), - Some("https://gitlab.example.com/group/project-one/-/issues/42"), - ); - insert_discussion(&conn, 1, "Issue", Some(1), None); - insert_note( - &conn, - 1, - 12345, - 1, - Some("alice"), - Some("This looks like a race condition"), - 1710460800000, - false, - None, - None, - ); - - let doc = extract_note_document(&conn, 1).unwrap().unwrap(); - assert_eq!(doc.source_type, SourceType::Note); - assert_eq!(doc.source_id, 1); - assert_eq!(doc.project_id, 1); - assert_eq!(doc.author_username, Some("alice".to_string())); - assert!(doc.content_text.contains("[[Note]]")); - assert!(doc.content_text.contains("source_type: note")); - assert!(doc.content_text.contains("note_gitlab_id: 12345")); - assert!(doc.content_text.contains("project: group/project-one")); - assert!(doc.content_text.contains("parent_type: Issue")); - assert!(doc.content_text.contains("parent_iid: 42")); - assert!(doc.content_text.contains("parent_title: Fix login bug")); - assert!(doc.content_text.contains("author: @alice")); - assert!(doc.content_text.contains("--- Body ---")); - assert!( - doc.content_text - .contains("This looks like a race condition") - ); - assert_eq!( - doc.title, - Some("Note by @alice on Issue #42: Fix login bug".to_string()) - ); - assert_eq!( - doc.url, - Some("https://gitlab.example.com/group/project-one/-/issues/42#note_12345".to_string()) - ); - } - - #[test] - fn test_note_document_diffnote_with_path() { - let conn = setup_discussion_test_db(); - insert_issue( - &conn, - 1, - 10, - Some("Refactor auth"), - Some("desc"), - "opened", - None, - Some("https://gitlab.example.com/group/project-one/-/issues/10"), - ); - insert_discussion(&conn, 1, "Issue", Some(1), None); - insert_note_with_type( - &conn, - 1, - 555, - 1, - Some("bob"), - Some("Unused variable here"), - 1000, - false, - Some("src/old_auth.rs"), - Some("src/auth.rs"), - Some(10), - Some(25), - Some("DiffNote"), - true, - false, - ); - - let doc = extract_note_document(&conn, 1).unwrap().unwrap(); - assert!(doc.content_text.contains("note_type: DiffNote")); - assert!(doc.content_text.contains("path: src/auth.rs:25")); - assert!(doc.content_text.contains("resolved: false")); - assert_eq!(doc.paths, vec!["src/auth.rs", "src/old_auth.rs"]); - } - - #[test] - fn test_note_document_inherits_parent_labels() { - let conn = setup_discussion_test_db(); - insert_issue( - &conn, - 1, - 10, - Some("Test"), - Some("desc"), - "opened", - None, - None, - ); - insert_label(&conn, 1, "backend"); - insert_label(&conn, 2, "api"); - link_issue_label(&conn, 1, 1); - link_issue_label(&conn, 1, 2); - insert_discussion(&conn, 1, "Issue", Some(1), None); - insert_note( - &conn, - 1, - 100, - 1, - Some("alice"), - Some("Note body"), - 1000, - false, - None, - None, - ); - - let doc = extract_note_document(&conn, 1).unwrap().unwrap(); - assert_eq!(doc.labels, vec!["api", "backend"]); - assert!(doc.content_text.contains("labels: api, backend")); - } - - #[test] - fn test_note_document_mr_parent() { - let conn = setup_discussion_test_db(); - insert_mr( - &conn, - 1, - 456, - Some("JWT Auth"), - Some("desc"), - Some("opened"), - Some("johndoe"), - Some("feature/jwt"), - Some("main"), - Some("https://gitlab.example.com/group/project-one/-/merge_requests/456"), - ); - insert_discussion(&conn, 1, "MergeRequest", None, Some(1)); - insert_note( - &conn, - 1, - 200, - 1, - Some("reviewer"), - Some("Needs tests"), - 1000, - false, - None, - None, - ); - - let doc = extract_note_document(&conn, 1).unwrap().unwrap(); - assert!(doc.content_text.contains("parent_type: MergeRequest")); - assert!(doc.content_text.contains("parent_iid: 456")); - assert_eq!( - doc.title, - Some("Note by @reviewer on MR !456: JWT Auth".to_string()) - ); - } - - #[test] - fn test_note_document_system_note_returns_none() { - let conn = setup_discussion_test_db(); - insert_issue( - &conn, - 1, - 10, - Some("Test"), - Some("desc"), - "opened", - None, - None, - ); - insert_discussion(&conn, 1, "Issue", Some(1), None); - insert_note( - &conn, - 1, - 100, - 1, - Some("bot"), - Some("assigned to @alice"), - 1000, - true, - None, - None, - ); - - let result = extract_note_document(&conn, 1).unwrap(); - assert!(result.is_none()); - } - - #[test] - fn test_note_document_not_found() { - let conn = setup_discussion_test_db(); - let result = extract_note_document(&conn, 999).unwrap(); - assert!(result.is_none()); - } - - #[test] - fn test_note_document_orphaned_discussion() { - let conn = setup_discussion_test_db(); - insert_discussion(&conn, 1, "Issue", None, None); - insert_note( - &conn, - 1, - 100, - 1, - Some("alice"), - Some("Comment"), - 1000, - false, - None, - None, - ); - - let result = extract_note_document(&conn, 1).unwrap(); - assert!(result.is_none()); - } - - #[test] - fn test_note_document_hash_deterministic() { - let conn = setup_discussion_test_db(); - insert_issue( - &conn, - 1, - 10, - Some("Test"), - Some("desc"), - "opened", - None, - None, - ); - insert_discussion(&conn, 1, "Issue", Some(1), None); - insert_note( - &conn, - 1, - 100, - 1, - Some("alice"), - Some("Comment"), - 1000, - false, - None, - None, - ); - - let doc1 = extract_note_document(&conn, 1).unwrap().unwrap(); - let doc2 = extract_note_document(&conn, 1).unwrap().unwrap(); - assert_eq!(doc1.content_hash, doc2.content_hash); - assert_eq!(doc1.labels_hash, doc2.labels_hash); - assert_eq!(doc1.paths_hash, doc2.paths_hash); - assert_eq!(doc1.content_hash.len(), 64); - } - - #[test] - fn test_note_document_empty_body() { - let conn = setup_discussion_test_db(); - insert_issue( - &conn, - 1, - 10, - Some("Test"), - Some("desc"), - "opened", - None, - None, - ); - insert_discussion(&conn, 1, "Issue", Some(1), None); - insert_note( - &conn, - 1, - 100, - 1, - Some("alice"), - Some(""), - 1000, - false, - None, - None, - ); - - let doc = extract_note_document(&conn, 1).unwrap().unwrap(); - assert!(doc.content_text.contains("--- Body ---\n\n")); - assert!(!doc.is_truncated); - } - - #[test] - fn test_note_document_null_body() { - let conn = setup_discussion_test_db(); - insert_issue( - &conn, - 1, - 10, - Some("Test"), - Some("desc"), - "opened", - None, - None, - ); - insert_discussion(&conn, 1, "Issue", Some(1), None); - insert_note( - &conn, - 1, - 100, - 1, - Some("alice"), - None, - 1000, - false, - None, - None, - ); - - let doc = extract_note_document(&conn, 1).unwrap().unwrap(); - assert!(doc.content_text.contains("--- Body ---\n\n")); - assert!(doc.content_text.ends_with("--- Body ---\n\n")); - } -} +#[path = "extractor_tests.rs"] +mod tests; diff --git a/src/documents/extractor_tests.rs b/src/documents/extractor_tests.rs new file mode 100644 index 0000000..e818847 --- /dev/null +++ b/src/documents/extractor_tests.rs @@ -0,0 +1,1303 @@ +use super::*; + +#[test] +fn test_source_type_parse_aliases() { + assert_eq!(SourceType::parse("issue"), Some(SourceType::Issue)); + assert_eq!(SourceType::parse("issues"), Some(SourceType::Issue)); + assert_eq!(SourceType::parse("mr"), Some(SourceType::MergeRequest)); + assert_eq!(SourceType::parse("mrs"), Some(SourceType::MergeRequest)); + assert_eq!( + SourceType::parse("merge_request"), + Some(SourceType::MergeRequest) + ); + assert_eq!( + SourceType::parse("merge_requests"), + Some(SourceType::MergeRequest) + ); + assert_eq!( + SourceType::parse("discussion"), + Some(SourceType::Discussion) + ); + assert_eq!( + SourceType::parse("discussions"), + Some(SourceType::Discussion) + ); + assert_eq!(SourceType::parse("invalid"), None); + assert_eq!(SourceType::parse("ISSUE"), Some(SourceType::Issue)); +} + +#[test] +fn test_source_type_parse_note() { + assert_eq!(SourceType::parse("note"), Some(SourceType::Note)); +} + +#[test] +fn test_source_type_note_as_str() { + assert_eq!(SourceType::Note.as_str(), "note"); +} + +#[test] +fn test_source_type_note_display() { + assert_eq!(format!("{}", SourceType::Note), "note"); +} + +#[test] +fn test_source_type_parse_notes_alias() { + assert_eq!(SourceType::parse("notes"), Some(SourceType::Note)); +} + +#[test] +fn test_source_type_as_str() { + assert_eq!(SourceType::Issue.as_str(), "issue"); + assert_eq!(SourceType::MergeRequest.as_str(), "merge_request"); + assert_eq!(SourceType::Discussion.as_str(), "discussion"); +} + +#[test] +fn test_source_type_display() { + assert_eq!(format!("{}", SourceType::Issue), "issue"); + assert_eq!(format!("{}", SourceType::MergeRequest), "merge_request"); + assert_eq!(format!("{}", SourceType::Discussion), "discussion"); +} + +#[test] +fn test_content_hash_deterministic() { + let hash1 = compute_content_hash("hello"); + let hash2 = compute_content_hash("hello"); + assert_eq!(hash1, hash2); + assert!(!hash1.is_empty()); + assert_eq!(hash1.len(), 64); +} + +#[test] +fn test_content_hash_different_inputs() { + let hash1 = compute_content_hash("hello"); + let hash2 = compute_content_hash("world"); + assert_ne!(hash1, hash2); +} + +#[test] +fn test_content_hash_empty() { + let hash = compute_content_hash(""); + assert_eq!(hash.len(), 64); +} + +#[test] +fn test_list_hash_order_independent() { + let hash1 = compute_list_hash(&["b".to_string(), "a".to_string()]); + let hash2 = compute_list_hash(&["a".to_string(), "b".to_string()]); + assert_eq!(hash1, hash2); +} + +#[test] +fn test_list_hash_empty() { + let hash = compute_list_hash(&[]); + assert_eq!(hash.len(), 64); + let hash2 = compute_list_hash(&[]); + assert_eq!(hash, hash2); +} + +fn setup_test_db() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + " + CREATE TABLE projects ( + id INTEGER PRIMARY KEY, + gitlab_project_id INTEGER UNIQUE NOT NULL, + path_with_namespace TEXT NOT NULL, + default_branch TEXT, + web_url TEXT, + created_at INTEGER, + updated_at INTEGER, + raw_payload_id INTEGER + ); + CREATE TABLE issues ( + id INTEGER PRIMARY KEY, + gitlab_id INTEGER UNIQUE NOT NULL, + project_id INTEGER NOT NULL REFERENCES projects(id), + iid INTEGER NOT NULL, + title TEXT, + description TEXT, + state TEXT NOT NULL, + author_username TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL, + discussions_synced_for_updated_at INTEGER, + resource_events_synced_for_updated_at INTEGER, + web_url TEXT, + raw_payload_id INTEGER + ); + CREATE TABLE labels ( + id INTEGER PRIMARY KEY, + gitlab_id INTEGER, + project_id INTEGER NOT NULL REFERENCES projects(id), + name TEXT NOT NULL, + color TEXT, + description TEXT + ); + CREATE TABLE issue_labels ( + issue_id INTEGER NOT NULL REFERENCES issues(id), + label_id INTEGER NOT NULL REFERENCES labels(id), + PRIMARY KEY(issue_id, label_id) + ); + ", + ) + .unwrap(); + + conn.execute( + "INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url) VALUES (1, 100, 'group/project-one', 'https://gitlab.example.com/group/project-one')", + [], + ).unwrap(); + + conn +} + +#[allow(clippy::too_many_arguments)] +fn insert_issue( + conn: &Connection, + id: i64, + iid: i64, + title: Option<&str>, + description: Option<&str>, + state: &str, + author: Option<&str>, + web_url: Option<&str>, +) { + conn.execute( + "INSERT INTO issues (id, gitlab_id, project_id, iid, title, description, state, author_username, created_at, updated_at, last_seen_at, web_url) VALUES (?1, ?2, 1, ?3, ?4, ?5, ?6, ?7, 1000, 2000, 3000, ?8)", + rusqlite::params![id, id * 10, iid, title, description, state, author, web_url], + ).unwrap(); +} + +fn insert_label(conn: &Connection, id: i64, name: &str) { + conn.execute( + "INSERT INTO labels (id, project_id, name) VALUES (?1, 1, ?2)", + rusqlite::params![id, name], + ) + .unwrap(); +} + +fn link_issue_label(conn: &Connection, issue_id: i64, label_id: i64) { + conn.execute( + "INSERT INTO issue_labels (issue_id, label_id) VALUES (?1, ?2)", + rusqlite::params![issue_id, label_id], + ) + .unwrap(); +} + +#[test] +fn test_issue_document_format() { + let conn = setup_test_db(); + insert_issue( + &conn, + 1, + 234, + Some("Authentication redesign"), + Some("We need to modernize our authentication system..."), + "opened", + Some("johndoe"), + Some("https://gitlab.example.com/group/project-one/-/issues/234"), + ); + insert_label(&conn, 1, "auth"); + insert_label(&conn, 2, "bug"); + link_issue_label(&conn, 1, 1); + link_issue_label(&conn, 1, 2); + + let doc = extract_issue_document(&conn, 1).unwrap().unwrap(); + assert_eq!(doc.source_type, SourceType::Issue); + assert_eq!(doc.source_id, 1); + assert_eq!(doc.project_id, 1); + assert_eq!(doc.author_username, Some("johndoe".to_string())); + assert!( + doc.content_text + .starts_with("[[Issue]] #234: Authentication redesign\n") + ); + assert!(doc.content_text.contains("Project: group/project-one\n")); + assert!( + doc.content_text + .contains("URL: https://gitlab.example.com/group/project-one/-/issues/234\n") + ); + assert!(doc.content_text.contains("Labels: [\"auth\",\"bug\"]\n")); + assert!(doc.content_text.contains("State: opened\n")); + assert!(doc.content_text.contains("Author: @johndoe\n")); + assert!( + doc.content_text + .contains("--- Description ---\n\nWe need to modernize our authentication system...") + ); + assert!(!doc.is_truncated); + assert!(doc.paths.is_empty()); +} + +#[test] +fn test_issue_not_found() { + let conn = setup_test_db(); + let result = extract_issue_document(&conn, 999).unwrap(); + assert!(result.is_none()); +} + +#[test] +fn test_issue_no_description() { + let conn = setup_test_db(); + insert_issue( + &conn, + 1, + 10, + Some("Quick fix"), + None, + "opened", + Some("alice"), + None, + ); + + let doc = extract_issue_document(&conn, 1).unwrap().unwrap(); + assert!(!doc.content_text.contains("--- Description ---")); + assert!(doc.content_text.contains("[[Issue]] #10: Quick fix\n")); +} + +#[test] +fn test_issue_labels_sorted() { + let conn = setup_test_db(); + insert_issue( + &conn, + 1, + 10, + Some("Test"), + Some("Body"), + "opened", + Some("bob"), + None, + ); + insert_label(&conn, 1, "zeta"); + insert_label(&conn, 2, "alpha"); + insert_label(&conn, 3, "middle"); + link_issue_label(&conn, 1, 1); + link_issue_label(&conn, 1, 2); + link_issue_label(&conn, 1, 3); + + let doc = extract_issue_document(&conn, 1).unwrap().unwrap(); + assert_eq!(doc.labels, vec!["alpha", "middle", "zeta"]); + assert!( + doc.content_text + .contains("Labels: [\"alpha\",\"middle\",\"zeta\"]") + ); +} + +#[test] +fn test_issue_no_labels() { + let conn = setup_test_db(); + insert_issue( + &conn, + 1, + 10, + Some("Test"), + Some("Body"), + "opened", + None, + None, + ); + + let doc = extract_issue_document(&conn, 1).unwrap().unwrap(); + assert!(doc.labels.is_empty()); + assert!(doc.content_text.contains("Labels: []\n")); +} + +#[test] +fn test_issue_hash_deterministic() { + let conn = setup_test_db(); + insert_issue( + &conn, + 1, + 10, + Some("Test"), + Some("Body"), + "opened", + Some("alice"), + None, + ); + + let doc1 = extract_issue_document(&conn, 1).unwrap().unwrap(); + let doc2 = extract_issue_document(&conn, 1).unwrap().unwrap(); + assert_eq!(doc1.content_hash, doc2.content_hash); + assert_eq!(doc1.labels_hash, doc2.labels_hash); + assert_eq!(doc1.content_hash.len(), 64); +} + +#[test] +fn test_issue_empty_description() { + let conn = setup_test_db(); + insert_issue(&conn, 1, 10, Some("Test"), Some(""), "opened", None, None); + + let doc = extract_issue_document(&conn, 1).unwrap().unwrap(); + assert!(doc.content_text.contains("--- Description ---\n\n")); +} + +fn setup_mr_test_db() -> Connection { + let conn = setup_test_db(); + conn.execute_batch( + " + CREATE TABLE merge_requests ( + id INTEGER PRIMARY KEY, + gitlab_id INTEGER UNIQUE NOT NULL, + project_id INTEGER NOT NULL REFERENCES projects(id), + iid INTEGER NOT NULL, + title TEXT, + description TEXT, + state TEXT, + draft INTEGER NOT NULL DEFAULT 0, + author_username TEXT, + source_branch TEXT, + target_branch TEXT, + head_sha TEXT, + references_short TEXT, + references_full TEXT, + detailed_merge_status TEXT, + merge_user_username TEXT, + created_at INTEGER, + updated_at INTEGER, + merged_at INTEGER, + closed_at INTEGER, + last_seen_at INTEGER NOT NULL, + discussions_synced_for_updated_at INTEGER, + discussions_sync_last_attempt_at INTEGER, + discussions_sync_attempts INTEGER DEFAULT 0, + discussions_sync_last_error TEXT, + resource_events_synced_for_updated_at INTEGER, + web_url TEXT, + raw_payload_id INTEGER + ); + CREATE TABLE mr_labels ( + merge_request_id INTEGER REFERENCES merge_requests(id), + label_id INTEGER REFERENCES labels(id), + PRIMARY KEY(merge_request_id, label_id) + ); + ", + ) + .unwrap(); + conn +} + +#[allow(clippy::too_many_arguments)] +fn insert_mr( + conn: &Connection, + id: i64, + iid: i64, + title: Option<&str>, + description: Option<&str>, + state: Option<&str>, + author: Option<&str>, + source_branch: Option<&str>, + target_branch: Option<&str>, + web_url: Option<&str>, +) { + conn.execute( + "INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, description, state, author_username, source_branch, target_branch, created_at, updated_at, last_seen_at, web_url) VALUES (?1, ?2, 1, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 1000, 2000, 3000, ?10)", + rusqlite::params![id, id * 10, iid, title, description, state, author, source_branch, target_branch, web_url], + ).unwrap(); +} + +fn link_mr_label(conn: &Connection, mr_id: i64, label_id: i64) { + conn.execute( + "INSERT INTO mr_labels (merge_request_id, label_id) VALUES (?1, ?2)", + rusqlite::params![mr_id, label_id], + ) + .unwrap(); +} + +#[test] +fn test_mr_document_format() { + let conn = setup_mr_test_db(); + insert_mr( + &conn, + 1, + 456, + Some("Implement JWT authentication"), + Some("This MR implements JWT-based authentication..."), + Some("opened"), + Some("johndoe"), + Some("feature/jwt-auth"), + Some("main"), + Some("https://gitlab.example.com/group/project-one/-/merge_requests/456"), + ); + insert_label(&conn, 1, "auth"); + insert_label(&conn, 2, "feature"); + link_mr_label(&conn, 1, 1); + link_mr_label(&conn, 1, 2); + + let doc = extract_mr_document(&conn, 1).unwrap().unwrap(); + assert_eq!(doc.source_type, SourceType::MergeRequest); + assert_eq!(doc.source_id, 1); + assert!( + doc.content_text + .starts_with("[[MergeRequest]] !456: Implement JWT authentication\n") + ); + assert!(doc.content_text.contains("Project: group/project-one\n")); + assert!( + doc.content_text + .contains("Labels: [\"auth\",\"feature\"]\n") + ); + assert!(doc.content_text.contains("State: opened\n")); + assert!(doc.content_text.contains("Author: @johndoe\n")); + assert!( + doc.content_text + .contains("Source: feature/jwt-auth -> main\n") + ); + assert!( + doc.content_text + .contains("--- Description ---\n\nThis MR implements JWT-based authentication...") + ); +} + +#[test] +fn test_mr_not_found() { + let conn = setup_mr_test_db(); + let result = extract_mr_document(&conn, 999).unwrap(); + assert!(result.is_none()); +} + +#[test] +fn test_mr_no_description() { + let conn = setup_mr_test_db(); + insert_mr( + &conn, + 1, + 10, + Some("Quick fix"), + None, + Some("merged"), + Some("alice"), + Some("fix/bug"), + Some("main"), + None, + ); + + let doc = extract_mr_document(&conn, 1).unwrap().unwrap(); + assert!(!doc.content_text.contains("--- Description ---")); + assert!( + doc.content_text + .contains("[[MergeRequest]] !10: Quick fix\n") + ); +} + +#[test] +fn test_mr_branch_info() { + let conn = setup_mr_test_db(); + insert_mr( + &conn, + 1, + 10, + Some("Test"), + Some("Body"), + Some("opened"), + None, + Some("feature/foo"), + Some("develop"), + None, + ); + + let doc = extract_mr_document(&conn, 1).unwrap().unwrap(); + assert!( + doc.content_text + .contains("Source: feature/foo -> develop\n") + ); +} + +#[test] +fn test_mr_no_branches() { + let conn = setup_mr_test_db(); + insert_mr( + &conn, + 1, + 10, + Some("Test"), + None, + Some("opened"), + None, + None, + None, + None, + ); + + let doc = extract_mr_document(&conn, 1).unwrap().unwrap(); + assert!(!doc.content_text.contains("Source:")); +} + +fn setup_discussion_test_db() -> Connection { + let conn = setup_mr_test_db(); + conn.execute_batch( + " + CREATE TABLE discussions ( + id INTEGER PRIMARY KEY, + gitlab_discussion_id TEXT NOT NULL, + project_id INTEGER NOT NULL REFERENCES projects(id), + issue_id INTEGER REFERENCES issues(id), + merge_request_id INTEGER, + noteable_type TEXT NOT NULL, + individual_note INTEGER NOT NULL DEFAULT 0, + first_note_at INTEGER, + last_note_at INTEGER, + last_seen_at INTEGER NOT NULL, + resolvable INTEGER NOT NULL DEFAULT 0, + resolved INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE notes ( + id INTEGER PRIMARY KEY, + gitlab_id INTEGER UNIQUE NOT NULL, + discussion_id INTEGER NOT NULL REFERENCES discussions(id), + project_id INTEGER NOT NULL REFERENCES projects(id), + note_type TEXT, + is_system INTEGER NOT NULL DEFAULT 0, + author_username TEXT, + body TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL, + position INTEGER, + resolvable INTEGER NOT NULL DEFAULT 0, + resolved INTEGER NOT NULL DEFAULT 0, + resolved_by TEXT, + resolved_at INTEGER, + position_old_path TEXT, + position_new_path TEXT, + position_old_line INTEGER, + position_new_line INTEGER, + raw_payload_id INTEGER + ); + ", + ) + .unwrap(); + conn +} + +fn insert_discussion( + conn: &Connection, + id: i64, + noteable_type: &str, + issue_id: Option, + mr_id: Option, +) { + conn.execute( + "INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, merge_request_id, noteable_type, last_seen_at) VALUES (?1, ?2, 1, ?3, ?4, ?5, 3000)", + rusqlite::params![id, format!("disc_{}", id), issue_id, mr_id, noteable_type], + ).unwrap(); +} + +#[allow(clippy::too_many_arguments)] +fn insert_note( + conn: &Connection, + id: i64, + gitlab_id: i64, + discussion_id: i64, + author: Option<&str>, + body: Option<&str>, + created_at: i64, + is_system: bool, + old_path: Option<&str>, + new_path: Option<&str>, +) { + conn.execute( + "INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system, position_old_path, position_new_path) VALUES (?1, ?2, ?3, 1, ?4, ?5, ?6, ?6, ?6, ?7, ?8, ?9)", + rusqlite::params![id, gitlab_id, discussion_id, author, body, created_at, is_system as i32, old_path, new_path], + ).unwrap(); +} + +#[test] +fn test_discussion_document_format() { + let conn = setup_discussion_test_db(); + insert_issue( + &conn, + 1, + 234, + Some("Authentication redesign"), + Some("desc"), + "opened", + Some("johndoe"), + Some("https://gitlab.example.com/group/project-one/-/issues/234"), + ); + insert_label(&conn, 1, "auth"); + insert_label(&conn, 2, "bug"); + link_issue_label(&conn, 1, 1); + link_issue_label(&conn, 1, 2); + insert_discussion(&conn, 1, "Issue", Some(1), None); + insert_note( + &conn, + 1, + 12345, + 1, + Some("johndoe"), + Some("I think we should move to JWT-based auth..."), + 1710460800000, + false, + None, + None, + ); + insert_note( + &conn, + 2, + 12346, + 1, + Some("janedoe"), + Some("Agreed. What about refresh token strategy?"), + 1710460800000, + false, + None, + None, + ); + + let doc = extract_discussion_document(&conn, 1).unwrap().unwrap(); + assert_eq!(doc.source_type, SourceType::Discussion); + assert!( + doc.content_text + .starts_with("[[Discussion]] Issue #234: Authentication redesign\n") + ); + assert!(doc.content_text.contains("Project: group/project-one\n")); + assert!( + doc.content_text.contains( + "URL: https://gitlab.example.com/group/project-one/-/issues/234#note_12345\n" + ) + ); + assert!(doc.content_text.contains("Labels: [\"auth\",\"bug\"]\n")); + assert!(doc.content_text.contains("--- Thread ---")); + assert!( + doc.content_text + .contains("@johndoe (2024-03-15):\nI think we should move to JWT-based auth...") + ); + assert!( + doc.content_text + .contains("@janedoe (2024-03-15):\nAgreed. What about refresh token strategy?") + ); + assert_eq!(doc.author_username, Some("johndoe".to_string())); + assert!(doc.title.is_none()); +} + +#[test] +fn test_discussion_not_found() { + let conn = setup_discussion_test_db(); + let result = extract_discussion_document(&conn, 999).unwrap(); + assert!(result.is_none()); +} + +#[test] +fn test_discussion_parent_deleted() { + let conn = setup_discussion_test_db(); + insert_issue( + &conn, + 99, + 10, + Some("To be deleted"), + None, + "opened", + None, + None, + ); + insert_discussion(&conn, 1, "Issue", Some(99), None); + insert_note( + &conn, + 1, + 100, + 1, + Some("alice"), + Some("Hello"), + 1000, + false, + None, + None, + ); + conn.execute("PRAGMA foreign_keys = OFF", []).unwrap(); + conn.execute("DELETE FROM issues WHERE id = 99", []) + .unwrap(); + conn.execute("PRAGMA foreign_keys = ON", []).unwrap(); + + let result = extract_discussion_document(&conn, 1).unwrap(); + assert!(result.is_none()); +} + +#[test] +fn test_discussion_system_notes_excluded() { + let conn = setup_discussion_test_db(); + insert_issue( + &conn, + 1, + 10, + Some("Test"), + Some("desc"), + "opened", + Some("alice"), + None, + ); + insert_discussion(&conn, 1, "Issue", Some(1), None); + insert_note( + &conn, + 1, + 100, + 1, + Some("alice"), + Some("Real comment"), + 1000, + false, + None, + None, + ); + insert_note( + &conn, + 2, + 101, + 1, + Some("bot"), + Some("assigned to @alice"), + 2000, + true, + None, + None, + ); + insert_note( + &conn, + 3, + 102, + 1, + Some("bob"), + Some("Follow-up"), + 3000, + false, + None, + None, + ); + + let doc = extract_discussion_document(&conn, 1).unwrap().unwrap(); + assert!(doc.content_text.contains("@alice")); + assert!(doc.content_text.contains("@bob")); + assert!(!doc.content_text.contains("assigned to")); +} + +#[test] +fn test_discussion_diffnote_paths() { + let conn = setup_discussion_test_db(); + insert_issue( + &conn, + 1, + 10, + Some("Test"), + Some("desc"), + "opened", + None, + None, + ); + insert_discussion(&conn, 1, "Issue", Some(1), None); + insert_note( + &conn, + 1, + 100, + 1, + Some("alice"), + Some("Comment on code"), + 1000, + false, + Some("src/old.rs"), + Some("src/new.rs"), + ); + insert_note( + &conn, + 2, + 101, + 1, + Some("bob"), + Some("Reply"), + 2000, + false, + Some("src/old.rs"), + Some("src/new.rs"), + ); + + let doc = extract_discussion_document(&conn, 1).unwrap().unwrap(); + assert_eq!(doc.paths, vec!["src/new.rs", "src/old.rs"]); + assert!( + doc.content_text + .contains("Files: [\"src/new.rs\",\"src/old.rs\"]") + ); +} + +#[test] +fn test_discussion_url_construction() { + let conn = setup_discussion_test_db(); + insert_issue( + &conn, + 1, + 10, + Some("Test"), + Some("desc"), + "opened", + None, + Some("https://gitlab.example.com/group/project-one/-/issues/10"), + ); + insert_discussion(&conn, 1, "Issue", Some(1), None); + insert_note( + &conn, + 1, + 54321, + 1, + Some("alice"), + Some("Hello"), + 1000, + false, + None, + None, + ); + + let doc = extract_discussion_document(&conn, 1).unwrap().unwrap(); + assert_eq!( + doc.url, + Some("https://gitlab.example.com/group/project-one/-/issues/10#note_54321".to_string()) + ); +} + +#[test] +fn test_discussion_uses_parent_labels() { + let conn = setup_discussion_test_db(); + insert_issue( + &conn, + 1, + 10, + Some("Test"), + Some("desc"), + "opened", + None, + None, + ); + insert_label(&conn, 1, "backend"); + insert_label(&conn, 2, "api"); + link_issue_label(&conn, 1, 1); + link_issue_label(&conn, 1, 2); + insert_discussion(&conn, 1, "Issue", Some(1), None); + insert_note( + &conn, + 1, + 100, + 1, + Some("alice"), + Some("Comment"), + 1000, + false, + None, + None, + ); + + let doc = extract_discussion_document(&conn, 1).unwrap().unwrap(); + assert_eq!(doc.labels, vec!["api", "backend"]); +} + +#[test] +fn test_discussion_on_mr() { + let conn = setup_discussion_test_db(); + insert_mr( + &conn, + 1, + 456, + Some("JWT Auth"), + Some("desc"), + Some("opened"), + Some("johndoe"), + Some("feature/jwt"), + Some("main"), + Some("https://gitlab.example.com/group/project-one/-/merge_requests/456"), + ); + insert_discussion(&conn, 1, "MergeRequest", None, Some(1)); + insert_note( + &conn, + 1, + 100, + 1, + Some("alice"), + Some("LGTM"), + 1000, + false, + None, + None, + ); + + let doc = extract_discussion_document(&conn, 1).unwrap().unwrap(); + assert!( + doc.content_text + .contains("[[Discussion]] MR !456: JWT Auth\n") + ); +} + +#[test] +fn test_discussion_all_system_notes() { + let conn = setup_discussion_test_db(); + insert_issue( + &conn, + 1, + 10, + Some("Test"), + Some("desc"), + "opened", + None, + None, + ); + insert_discussion(&conn, 1, "Issue", Some(1), None); + insert_note( + &conn, + 1, + 100, + 1, + Some("bot"), + Some("assigned to @alice"), + 1000, + true, + None, + None, + ); + + let result = extract_discussion_document(&conn, 1).unwrap(); + assert!(result.is_none()); +} + +#[allow(clippy::too_many_arguments)] +fn insert_note_with_type( + conn: &Connection, + id: i64, + gitlab_id: i64, + discussion_id: i64, + author: Option<&str>, + body: Option<&str>, + created_at: i64, + is_system: bool, + old_path: Option<&str>, + new_path: Option<&str>, + old_line: Option, + new_line: Option, + note_type: Option<&str>, + resolvable: bool, + resolved: bool, +) { + conn.execute( + "INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system, position_old_path, position_new_path, position_old_line, position_new_line, note_type, resolvable, resolved) VALUES (?1, ?2, ?3, 1, ?4, ?5, ?6, ?6, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", + rusqlite::params![id, gitlab_id, discussion_id, author, body, created_at, is_system as i32, old_path, new_path, old_line, new_line, note_type, resolvable as i32, resolved as i32], + ).unwrap(); +} + +#[test] +fn test_note_document_basic_format() { + let conn = setup_discussion_test_db(); + insert_issue( + &conn, + 1, + 42, + Some("Fix login bug"), + Some("desc"), + "opened", + Some("johndoe"), + Some("https://gitlab.example.com/group/project-one/-/issues/42"), + ); + insert_discussion(&conn, 1, "Issue", Some(1), None); + insert_note( + &conn, + 1, + 12345, + 1, + Some("alice"), + Some("This looks like a race condition"), + 1710460800000, + false, + None, + None, + ); + + let doc = extract_note_document(&conn, 1).unwrap().unwrap(); + assert_eq!(doc.source_type, SourceType::Note); + assert_eq!(doc.source_id, 1); + assert_eq!(doc.project_id, 1); + assert_eq!(doc.author_username, Some("alice".to_string())); + assert!(doc.content_text.contains("[[Note]]")); + assert!(doc.content_text.contains("source_type: note")); + assert!(doc.content_text.contains("note_gitlab_id: 12345")); + assert!(doc.content_text.contains("project: group/project-one")); + assert!(doc.content_text.contains("parent_type: Issue")); + assert!(doc.content_text.contains("parent_iid: 42")); + assert!(doc.content_text.contains("parent_title: Fix login bug")); + assert!(doc.content_text.contains("author: @alice")); + assert!(doc.content_text.contains("--- Body ---")); + assert!( + doc.content_text + .contains("This looks like a race condition") + ); + assert_eq!( + doc.title, + Some("Note by @alice on Issue #42: Fix login bug".to_string()) + ); + assert_eq!( + doc.url, + Some("https://gitlab.example.com/group/project-one/-/issues/42#note_12345".to_string()) + ); +} + +#[test] +fn test_note_document_diffnote_with_path() { + let conn = setup_discussion_test_db(); + insert_issue( + &conn, + 1, + 10, + Some("Refactor auth"), + Some("desc"), + "opened", + None, + Some("https://gitlab.example.com/group/project-one/-/issues/10"), + ); + insert_discussion(&conn, 1, "Issue", Some(1), None); + insert_note_with_type( + &conn, + 1, + 555, + 1, + Some("bob"), + Some("Unused variable here"), + 1000, + false, + Some("src/old_auth.rs"), + Some("src/auth.rs"), + Some(10), + Some(25), + Some("DiffNote"), + true, + false, + ); + + let doc = extract_note_document(&conn, 1).unwrap().unwrap(); + assert!(doc.content_text.contains("note_type: DiffNote")); + assert!(doc.content_text.contains("path: src/auth.rs:25")); + assert!(doc.content_text.contains("resolved: false")); + assert_eq!(doc.paths, vec!["src/auth.rs", "src/old_auth.rs"]); +} + +#[test] +fn test_note_document_inherits_parent_labels() { + let conn = setup_discussion_test_db(); + insert_issue( + &conn, + 1, + 10, + Some("Test"), + Some("desc"), + "opened", + None, + None, + ); + insert_label(&conn, 1, "backend"); + insert_label(&conn, 2, "api"); + link_issue_label(&conn, 1, 1); + link_issue_label(&conn, 1, 2); + insert_discussion(&conn, 1, "Issue", Some(1), None); + insert_note( + &conn, + 1, + 100, + 1, + Some("alice"), + Some("Note body"), + 1000, + false, + None, + None, + ); + + let doc = extract_note_document(&conn, 1).unwrap().unwrap(); + assert_eq!(doc.labels, vec!["api", "backend"]); + assert!(doc.content_text.contains("labels: api, backend")); +} + +#[test] +fn test_note_document_mr_parent() { + let conn = setup_discussion_test_db(); + insert_mr( + &conn, + 1, + 456, + Some("JWT Auth"), + Some("desc"), + Some("opened"), + Some("johndoe"), + Some("feature/jwt"), + Some("main"), + Some("https://gitlab.example.com/group/project-one/-/merge_requests/456"), + ); + insert_discussion(&conn, 1, "MergeRequest", None, Some(1)); + insert_note( + &conn, + 1, + 200, + 1, + Some("reviewer"), + Some("Needs tests"), + 1000, + false, + None, + None, + ); + + let doc = extract_note_document(&conn, 1).unwrap().unwrap(); + assert!(doc.content_text.contains("parent_type: MergeRequest")); + assert!(doc.content_text.contains("parent_iid: 456")); + assert_eq!( + doc.title, + Some("Note by @reviewer on MR !456: JWT Auth".to_string()) + ); +} + +#[test] +fn test_note_document_system_note_returns_none() { + let conn = setup_discussion_test_db(); + insert_issue( + &conn, + 1, + 10, + Some("Test"), + Some("desc"), + "opened", + None, + None, + ); + insert_discussion(&conn, 1, "Issue", Some(1), None); + insert_note( + &conn, + 1, + 100, + 1, + Some("bot"), + Some("assigned to @alice"), + 1000, + true, + None, + None, + ); + + let result = extract_note_document(&conn, 1).unwrap(); + assert!(result.is_none()); +} + +#[test] +fn test_note_document_not_found() { + let conn = setup_discussion_test_db(); + let result = extract_note_document(&conn, 999).unwrap(); + assert!(result.is_none()); +} + +#[test] +fn test_note_document_orphaned_discussion() { + let conn = setup_discussion_test_db(); + insert_discussion(&conn, 1, "Issue", None, None); + insert_note( + &conn, + 1, + 100, + 1, + Some("alice"), + Some("Comment"), + 1000, + false, + None, + None, + ); + + let result = extract_note_document(&conn, 1).unwrap(); + assert!(result.is_none()); +} + +#[test] +fn test_note_document_hash_deterministic() { + let conn = setup_discussion_test_db(); + insert_issue( + &conn, + 1, + 10, + Some("Test"), + Some("desc"), + "opened", + None, + None, + ); + insert_discussion(&conn, 1, "Issue", Some(1), None); + insert_note( + &conn, + 1, + 100, + 1, + Some("alice"), + Some("Comment"), + 1000, + false, + None, + None, + ); + + let doc1 = extract_note_document(&conn, 1).unwrap().unwrap(); + let doc2 = extract_note_document(&conn, 1).unwrap().unwrap(); + assert_eq!(doc1.content_hash, doc2.content_hash); + assert_eq!(doc1.labels_hash, doc2.labels_hash); + assert_eq!(doc1.paths_hash, doc2.paths_hash); + assert_eq!(doc1.content_hash.len(), 64); +} + +#[test] +fn test_note_document_empty_body() { + let conn = setup_discussion_test_db(); + insert_issue( + &conn, + 1, + 10, + Some("Test"), + Some("desc"), + "opened", + None, + None, + ); + insert_discussion(&conn, 1, "Issue", Some(1), None); + insert_note( + &conn, + 1, + 100, + 1, + Some("alice"), + Some(""), + 1000, + false, + None, + None, + ); + + let doc = extract_note_document(&conn, 1).unwrap().unwrap(); + assert!(doc.content_text.contains("--- Body ---\n\n")); + assert!(!doc.is_truncated); +} + +#[test] +fn test_note_document_null_body() { + let conn = setup_discussion_test_db(); + insert_issue( + &conn, + 1, + 10, + Some("Test"), + Some("desc"), + "opened", + None, + None, + ); + insert_discussion(&conn, 1, "Issue", Some(1), None); + insert_note( + &conn, + 1, + 100, + 1, + Some("alice"), + None, + 1000, + false, + None, + None, + ); + + let doc = extract_note_document(&conn, 1).unwrap().unwrap(); + assert!(doc.content_text.contains("--- Body ---\n\n")); + assert!(doc.content_text.ends_with("--- Body ---\n\n")); +} diff --git a/src/documents/regenerator.rs b/src/documents/regenerator.rs index 7f91198..baaadb3 100644 --- a/src/documents/regenerator.rs +++ b/src/documents/regenerator.rs @@ -269,525 +269,5 @@ fn get_document_id(conn: &Connection, source_type: SourceType, source_id: i64) - } #[cfg(test)] -mod tests { - use super::*; - use crate::ingestion::dirty_tracker::mark_dirty; - - fn setup_db() -> Connection { - let conn = Connection::open_in_memory().unwrap(); - conn.execute_batch(" - CREATE TABLE projects ( - id INTEGER PRIMARY KEY, - gitlab_project_id INTEGER UNIQUE NOT NULL, - path_with_namespace TEXT NOT NULL, - default_branch TEXT, - web_url TEXT, - created_at INTEGER, - updated_at INTEGER, - raw_payload_id INTEGER - ); - INSERT INTO projects (id, gitlab_project_id, path_with_namespace) VALUES (1, 100, 'group/project'); - - CREATE TABLE issues ( - id INTEGER PRIMARY KEY, - gitlab_id INTEGER UNIQUE NOT NULL, - project_id INTEGER NOT NULL REFERENCES projects(id), - iid INTEGER NOT NULL, - title TEXT, - description TEXT, - state TEXT NOT NULL, - author_username TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - last_seen_at INTEGER NOT NULL, - discussions_synced_for_updated_at INTEGER, - resource_events_synced_for_updated_at INTEGER, - web_url TEXT, - raw_payload_id INTEGER - ); - CREATE TABLE labels ( - id INTEGER PRIMARY KEY, - gitlab_id INTEGER, - project_id INTEGER NOT NULL REFERENCES projects(id), - name TEXT NOT NULL, - color TEXT, - description TEXT - ); - CREATE TABLE issue_labels ( - issue_id INTEGER NOT NULL REFERENCES issues(id), - label_id INTEGER NOT NULL REFERENCES labels(id), - PRIMARY KEY(issue_id, label_id) - ); - - CREATE TABLE documents ( - id INTEGER PRIMARY KEY, - source_type TEXT NOT NULL, - source_id INTEGER NOT NULL, - project_id INTEGER NOT NULL, - author_username TEXT, - label_names TEXT, - created_at INTEGER, - updated_at INTEGER, - url TEXT, - title TEXT, - content_text TEXT NOT NULL, - content_hash TEXT NOT NULL, - labels_hash TEXT NOT NULL DEFAULT '', - paths_hash TEXT NOT NULL DEFAULT '', - is_truncated INTEGER NOT NULL DEFAULT 0, - truncated_reason TEXT, - UNIQUE(source_type, source_id) - ); - CREATE TABLE document_labels ( - document_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE, - label_name TEXT NOT NULL, - PRIMARY KEY(document_id, label_name) - ); - CREATE TABLE document_paths ( - document_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE, - path TEXT NOT NULL, - PRIMARY KEY(document_id, path) - ); - CREATE TABLE dirty_sources ( - source_type TEXT NOT NULL, - source_id INTEGER NOT NULL, - queued_at INTEGER NOT NULL, - attempt_count INTEGER NOT NULL DEFAULT 0, - last_attempt_at INTEGER, - last_error TEXT, - next_attempt_at INTEGER, - PRIMARY KEY(source_type, source_id) - ); - CREATE INDEX idx_dirty_sources_next_attempt ON dirty_sources(next_attempt_at); - ").unwrap(); - conn - } - - #[test] - fn test_regenerate_creates_document() { - let conn = setup_db(); - conn.execute( - "INSERT INTO issues (id, gitlab_id, project_id, iid, title, description, state, author_username, created_at, updated_at, last_seen_at) VALUES (1, 10, 1, 42, 'Test Issue', 'Description here', 'opened', 'alice', 1000, 2000, 3000)", - [], - ).unwrap(); - mark_dirty(&conn, SourceType::Issue, 1).unwrap(); - - let result = regenerate_dirty_documents(&conn, None).unwrap(); - assert_eq!(result.regenerated, 1); - assert_eq!(result.unchanged, 0); - assert_eq!(result.errored, 0); - - let count: i64 = conn - .query_row("SELECT COUNT(*) FROM documents", [], |r| r.get(0)) - .unwrap(); - assert_eq!(count, 1); - - let content: String = conn - .query_row("SELECT content_text FROM documents", [], |r| r.get(0)) - .unwrap(); - assert!(content.contains("[[Issue]] #42: Test Issue")); - } - - #[test] - fn test_regenerate_unchanged() { - let conn = setup_db(); - conn.execute( - "INSERT INTO issues (id, gitlab_id, project_id, iid, title, description, state, author_username, created_at, updated_at, last_seen_at) VALUES (1, 10, 1, 42, 'Test', 'Desc', 'opened', 'alice', 1000, 2000, 3000)", - [], - ).unwrap(); - - mark_dirty(&conn, SourceType::Issue, 1).unwrap(); - let r1 = regenerate_dirty_documents(&conn, None).unwrap(); - assert_eq!(r1.regenerated, 1); - - mark_dirty(&conn, SourceType::Issue, 1).unwrap(); - let r2 = regenerate_dirty_documents(&conn, None).unwrap(); - assert_eq!(r2.unchanged, 1); - assert_eq!(r2.regenerated, 0); - } - - #[test] - fn test_regenerate_deleted_source() { - let conn = setup_db(); - conn.execute( - "INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at) VALUES (1, 10, 1, 42, 'Test', 'opened', 1000, 2000, 3000)", - [], - ).unwrap(); - mark_dirty(&conn, SourceType::Issue, 1).unwrap(); - regenerate_dirty_documents(&conn, None).unwrap(); - - conn.execute("PRAGMA foreign_keys = OFF", []).unwrap(); - conn.execute("DELETE FROM issues WHERE id = 1", []).unwrap(); - conn.execute("PRAGMA foreign_keys = ON", []).unwrap(); - mark_dirty(&conn, SourceType::Issue, 1).unwrap(); - - let result = regenerate_dirty_documents(&conn, None).unwrap(); - assert_eq!(result.regenerated, 1); - - let count: i64 = conn - .query_row("SELECT COUNT(*) FROM documents", [], |r| r.get(0)) - .unwrap(); - assert_eq!(count, 0); - } - - #[test] - fn test_regenerate_drains_queue() { - let conn = setup_db(); - for i in 1..=10 { - conn.execute( - "INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at) VALUES (?1, ?2, 1, ?1, 'Test', 'opened', 1000, 2000, 3000)", - rusqlite::params![i, i * 10], - ).unwrap(); - mark_dirty(&conn, SourceType::Issue, i).unwrap(); - } - - let result = regenerate_dirty_documents(&conn, None).unwrap(); - assert_eq!(result.regenerated, 10); - - let dirty = get_dirty_sources(&conn).unwrap(); - assert!(dirty.is_empty()); - } - - #[test] - fn test_triple_hash_fast_path() { - let conn = setup_db(); - conn.execute( - "INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at) VALUES (1, 10, 1, 42, 'Test', 'opened', 1000, 2000, 3000)", - [], - ).unwrap(); - conn.execute( - "INSERT INTO labels (id, project_id, name) VALUES (1, 1, 'bug')", - [], - ) - .unwrap(); - conn.execute( - "INSERT INTO issue_labels (issue_id, label_id) VALUES (1, 1)", - [], - ) - .unwrap(); - - mark_dirty(&conn, SourceType::Issue, 1).unwrap(); - regenerate_dirty_documents(&conn, None).unwrap(); - - mark_dirty(&conn, SourceType::Issue, 1).unwrap(); - let result = regenerate_dirty_documents(&conn, None).unwrap(); - assert_eq!(result.unchanged, 1); - - let label_count: i64 = conn - .query_row("SELECT COUNT(*) FROM document_labels", [], |r| r.get(0)) - .unwrap(); - assert_eq!(label_count, 1); - } - - fn setup_note_db() -> Connection { - let conn = setup_db(); - conn.execute_batch( - " - CREATE TABLE merge_requests ( - id INTEGER PRIMARY KEY, - gitlab_id INTEGER UNIQUE NOT NULL, - project_id INTEGER NOT NULL REFERENCES projects(id), - iid INTEGER NOT NULL, - title TEXT, - description TEXT, - state TEXT, - draft INTEGER NOT NULL DEFAULT 0, - author_username TEXT, - source_branch TEXT, - target_branch TEXT, - head_sha TEXT, - references_short TEXT, - references_full TEXT, - detailed_merge_status TEXT, - merge_user_username TEXT, - created_at INTEGER, - updated_at INTEGER, - merged_at INTEGER, - closed_at INTEGER, - last_seen_at INTEGER NOT NULL, - discussions_synced_for_updated_at INTEGER, - discussions_sync_last_attempt_at INTEGER, - discussions_sync_attempts INTEGER DEFAULT 0, - discussions_sync_last_error TEXT, - resource_events_synced_for_updated_at INTEGER, - web_url TEXT, - raw_payload_id INTEGER - ); - CREATE TABLE mr_labels ( - merge_request_id INTEGER REFERENCES merge_requests(id), - label_id INTEGER REFERENCES labels(id), - PRIMARY KEY(merge_request_id, label_id) - ); - CREATE TABLE discussions ( - id INTEGER PRIMARY KEY, - gitlab_discussion_id TEXT NOT NULL, - project_id INTEGER NOT NULL REFERENCES projects(id), - issue_id INTEGER REFERENCES issues(id), - merge_request_id INTEGER, - noteable_type TEXT NOT NULL, - individual_note INTEGER NOT NULL DEFAULT 0, - first_note_at INTEGER, - last_note_at INTEGER, - last_seen_at INTEGER NOT NULL, - resolvable INTEGER NOT NULL DEFAULT 0, - resolved INTEGER NOT NULL DEFAULT 0 - ); - CREATE TABLE notes ( - id INTEGER PRIMARY KEY, - gitlab_id INTEGER UNIQUE NOT NULL, - discussion_id INTEGER NOT NULL REFERENCES discussions(id), - project_id INTEGER NOT NULL REFERENCES projects(id), - note_type TEXT, - is_system INTEGER NOT NULL DEFAULT 0, - author_username TEXT, - body TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - last_seen_at INTEGER NOT NULL, - position INTEGER, - resolvable INTEGER NOT NULL DEFAULT 0, - resolved INTEGER NOT NULL DEFAULT 0, - resolved_by TEXT, - resolved_at INTEGER, - position_old_path TEXT, - position_new_path TEXT, - position_old_line INTEGER, - position_new_line INTEGER, - raw_payload_id INTEGER - ); - ", - ) - .unwrap(); - conn - } - - #[test] - fn test_regenerate_note_document() { - let conn = setup_note_db(); - conn.execute( - "INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, author_username, created_at, updated_at, last_seen_at, web_url) VALUES (1, 10, 1, 42, 'Test Issue', 'opened', 'alice', 1000, 2000, 3000, 'https://example.com/issues/42')", - [], - ).unwrap(); - conn.execute( - "INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at) VALUES (1, 'disc_1', 1, 1, 'Issue', 3000)", - [], - ).unwrap(); - conn.execute( - "INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system) VALUES (1, 100, 1, 1, 'bob', 'This is a note', 1000, 2000, 3000, 0)", - [], - ).unwrap(); - - mark_dirty(&conn, SourceType::Note, 1).unwrap(); - let result = regenerate_dirty_documents(&conn, None).unwrap(); - assert_eq!(result.regenerated, 1); - assert_eq!(result.unchanged, 0); - assert_eq!(result.errored, 0); - - let (source_type, content): (String, String) = conn - .query_row( - "SELECT source_type, content_text FROM documents WHERE source_id = 1", - [], - |r| Ok((r.get(0)?, r.get(1)?)), - ) - .unwrap(); - assert_eq!(source_type, "note"); - assert!(content.contains("[[Note]]")); - assert!(content.contains("author: @bob")); - } - - #[test] - fn test_regenerate_note_system_note_deletes() { - let conn = setup_note_db(); - conn.execute( - "INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at) VALUES (1, 10, 1, 42, 'Test', 'opened', 1000, 2000, 3000)", - [], - ).unwrap(); - conn.execute( - "INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at) VALUES (1, 'disc_1', 1, 1, 'Issue', 3000)", - [], - ).unwrap(); - conn.execute( - "INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system) VALUES (1, 100, 1, 1, 'bot', 'assigned to @alice', 1000, 2000, 3000, 1)", - [], - ).unwrap(); - - // Pre-insert a document for this note (simulating a previously-generated doc) - conn.execute( - "INSERT INTO documents (source_type, source_id, project_id, content_text, content_hash) VALUES ('note', 1, 1, 'old content', 'oldhash')", - [], - ).unwrap(); - - mark_dirty(&conn, SourceType::Note, 1).unwrap(); - let result = regenerate_dirty_documents(&conn, None).unwrap(); - assert_eq!(result.regenerated, 1); - - let count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM documents WHERE source_type = 'note'", - [], - |r| r.get(0), - ) - .unwrap(); - assert_eq!(count, 0); - } - - #[test] - fn test_regenerate_note_unchanged() { - let conn = setup_note_db(); - conn.execute( - "INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at, web_url) VALUES (1, 10, 1, 42, 'Test', 'opened', 1000, 2000, 3000, 'https://example.com/issues/42')", - [], - ).unwrap(); - conn.execute( - "INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at) VALUES (1, 'disc_1', 1, 1, 'Issue', 3000)", - [], - ).unwrap(); - conn.execute( - "INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system) VALUES (1, 100, 1, 1, 'bob', 'Some note', 1000, 2000, 3000, 0)", - [], - ).unwrap(); - - mark_dirty(&conn, SourceType::Note, 1).unwrap(); - let r1 = regenerate_dirty_documents(&conn, None).unwrap(); - assert_eq!(r1.regenerated, 1); - - mark_dirty(&conn, SourceType::Note, 1).unwrap(); - let r2 = regenerate_dirty_documents(&conn, None).unwrap(); - assert_eq!(r2.unchanged, 1); - assert_eq!(r2.regenerated, 0); - } - - #[test] - fn test_note_regeneration_batch_uses_cache() { - let conn = setup_note_db(); - conn.execute( - "INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, author_username, created_at, updated_at, last_seen_at, web_url) VALUES (1, 10, 1, 42, 'Shared Issue', 'opened', 'alice', 1000, 2000, 3000, 'https://example.com/issues/42')", - [], - ).unwrap(); - conn.execute( - "INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at) VALUES (1, 'disc_1', 1, 1, 'Issue', 3000)", - [], - ).unwrap(); - - for i in 1..=10 { - conn.execute( - "INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system) VALUES (?1, ?2, 1, 1, 'bob', ?3, 1000, 2000, 3000, 0)", - rusqlite::params![i, i * 100, format!("Note body {}", i)], - ).unwrap(); - mark_dirty(&conn, SourceType::Note, i).unwrap(); - } - - let result = regenerate_dirty_documents(&conn, None).unwrap(); - assert_eq!(result.regenerated, 10); - assert_eq!(result.errored, 0); - - let count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM documents WHERE source_type = 'note'", - [], - |r| r.get(0), - ) - .unwrap(); - assert_eq!(count, 10); - } - - #[test] - fn test_note_regeneration_cache_consistent_with_direct_extraction() { - let conn = setup_note_db(); - conn.execute( - "INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, author_username, created_at, updated_at, last_seen_at, web_url) VALUES (1, 10, 1, 42, 'Consistency Check', 'opened', 'alice', 1000, 2000, 3000, 'https://example.com/issues/42')", - [], - ).unwrap(); - conn.execute( - "INSERT INTO labels (id, project_id, name) VALUES (1, 1, 'backend')", - [], - ) - .unwrap(); - conn.execute( - "INSERT INTO issue_labels (issue_id, label_id) VALUES (1, 1)", - [], - ) - .unwrap(); - conn.execute( - "INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at) VALUES (1, 'disc_1', 1, 1, 'Issue', 3000)", - [], - ).unwrap(); - conn.execute( - "INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system) VALUES (1, 100, 1, 1, 'bob', 'Some content', 1000, 2000, 3000, 0)", - [], - ).unwrap(); - - use crate::documents::extract_note_document; - let direct = extract_note_document(&conn, 1).unwrap().unwrap(); - - let mut cache = ParentMetadataCache::new(); - let cached = extract_note_document_cached(&conn, 1, &mut cache) - .unwrap() - .unwrap(); - - assert_eq!(direct.content_text, cached.content_text); - assert_eq!(direct.content_hash, cached.content_hash); - assert_eq!(direct.labels, cached.labels); - assert_eq!(direct.labels_hash, cached.labels_hash); - assert_eq!(direct.paths_hash, cached.paths_hash); - assert_eq!(direct.title, cached.title); - assert_eq!(direct.url, cached.url); - assert_eq!(direct.author_username, cached.author_username); - } - - #[test] - fn test_note_regeneration_cache_invalidates_across_parents() { - let conn = setup_note_db(); - conn.execute( - "INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at, web_url) VALUES (1, 10, 1, 42, 'Issue Alpha', 'opened', 1000, 2000, 3000, 'https://example.com/issues/42')", - [], - ).unwrap(); - conn.execute( - "INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at, web_url) VALUES (2, 20, 1, 99, 'Issue Beta', 'opened', 1000, 2000, 3000, 'https://example.com/issues/99')", - [], - ).unwrap(); - conn.execute( - "INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at) VALUES (1, 'disc_1', 1, 1, 'Issue', 3000)", - [], - ).unwrap(); - conn.execute( - "INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at) VALUES (2, 'disc_2', 1, 2, 'Issue', 3000)", - [], - ).unwrap(); - conn.execute( - "INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system) VALUES (1, 100, 1, 1, 'bob', 'Alpha note', 1000, 2000, 3000, 0)", - [], - ).unwrap(); - conn.execute( - "INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system) VALUES (2, 200, 2, 1, 'alice', 'Beta note', 1000, 2000, 3000, 0)", - [], - ).unwrap(); - - mark_dirty(&conn, SourceType::Note, 1).unwrap(); - mark_dirty(&conn, SourceType::Note, 2).unwrap(); - - let result = regenerate_dirty_documents(&conn, None).unwrap(); - assert_eq!(result.regenerated, 2); - assert_eq!(result.errored, 0); - - let alpha_content: String = conn - .query_row( - "SELECT content_text FROM documents WHERE source_type = 'note' AND source_id = 1", - [], - |r| r.get(0), - ) - .unwrap(); - let beta_content: String = conn - .query_row( - "SELECT content_text FROM documents WHERE source_type = 'note' AND source_id = 2", - [], - |r| r.get(0), - ) - .unwrap(); - - assert!(alpha_content.contains("parent_iid: 42")); - assert!(alpha_content.contains("parent_title: Issue Alpha")); - assert!(beta_content.contains("parent_iid: 99")); - assert!(beta_content.contains("parent_title: Issue Beta")); - } -} +#[path = "regenerator_tests.rs"] +mod tests; diff --git a/src/documents/regenerator_tests.rs b/src/documents/regenerator_tests.rs new file mode 100644 index 0000000..04bde8c --- /dev/null +++ b/src/documents/regenerator_tests.rs @@ -0,0 +1,520 @@ +use super::*; +use crate::ingestion::dirty_tracker::mark_dirty; + +fn setup_db() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch(" + CREATE TABLE projects ( + id INTEGER PRIMARY KEY, + gitlab_project_id INTEGER UNIQUE NOT NULL, + path_with_namespace TEXT NOT NULL, + default_branch TEXT, + web_url TEXT, + created_at INTEGER, + updated_at INTEGER, + raw_payload_id INTEGER + ); + INSERT INTO projects (id, gitlab_project_id, path_with_namespace) VALUES (1, 100, 'group/project'); + + CREATE TABLE issues ( + id INTEGER PRIMARY KEY, + gitlab_id INTEGER UNIQUE NOT NULL, + project_id INTEGER NOT NULL REFERENCES projects(id), + iid INTEGER NOT NULL, + title TEXT, + description TEXT, + state TEXT NOT NULL, + author_username TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL, + discussions_synced_for_updated_at INTEGER, + resource_events_synced_for_updated_at INTEGER, + web_url TEXT, + raw_payload_id INTEGER + ); + CREATE TABLE labels ( + id INTEGER PRIMARY KEY, + gitlab_id INTEGER, + project_id INTEGER NOT NULL REFERENCES projects(id), + name TEXT NOT NULL, + color TEXT, + description TEXT + ); + CREATE TABLE issue_labels ( + issue_id INTEGER NOT NULL REFERENCES issues(id), + label_id INTEGER NOT NULL REFERENCES labels(id), + PRIMARY KEY(issue_id, label_id) + ); + + CREATE TABLE documents ( + id INTEGER PRIMARY KEY, + source_type TEXT NOT NULL, + source_id INTEGER NOT NULL, + project_id INTEGER NOT NULL, + author_username TEXT, + label_names TEXT, + created_at INTEGER, + updated_at INTEGER, + url TEXT, + title TEXT, + content_text TEXT NOT NULL, + content_hash TEXT NOT NULL, + labels_hash TEXT NOT NULL DEFAULT '', + paths_hash TEXT NOT NULL DEFAULT '', + is_truncated INTEGER NOT NULL DEFAULT 0, + truncated_reason TEXT, + UNIQUE(source_type, source_id) + ); + CREATE TABLE document_labels ( + document_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + label_name TEXT NOT NULL, + PRIMARY KEY(document_id, label_name) + ); + CREATE TABLE document_paths ( + document_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + path TEXT NOT NULL, + PRIMARY KEY(document_id, path) + ); + CREATE TABLE dirty_sources ( + source_type TEXT NOT NULL, + source_id INTEGER NOT NULL, + queued_at INTEGER NOT NULL, + attempt_count INTEGER NOT NULL DEFAULT 0, + last_attempt_at INTEGER, + last_error TEXT, + next_attempt_at INTEGER, + PRIMARY KEY(source_type, source_id) + ); + CREATE INDEX idx_dirty_sources_next_attempt ON dirty_sources(next_attempt_at); + ").unwrap(); + conn +} + +#[test] +fn test_regenerate_creates_document() { + let conn = setup_db(); + conn.execute( + "INSERT INTO issues (id, gitlab_id, project_id, iid, title, description, state, author_username, created_at, updated_at, last_seen_at) VALUES (1, 10, 1, 42, 'Test Issue', 'Description here', 'opened', 'alice', 1000, 2000, 3000)", + [], + ).unwrap(); + mark_dirty(&conn, SourceType::Issue, 1).unwrap(); + + let result = regenerate_dirty_documents(&conn, None).unwrap(); + assert_eq!(result.regenerated, 1); + assert_eq!(result.unchanged, 0); + assert_eq!(result.errored, 0); + + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM documents", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 1); + + let content: String = conn + .query_row("SELECT content_text FROM documents", [], |r| r.get(0)) + .unwrap(); + assert!(content.contains("[[Issue]] #42: Test Issue")); +} + +#[test] +fn test_regenerate_unchanged() { + let conn = setup_db(); + conn.execute( + "INSERT INTO issues (id, gitlab_id, project_id, iid, title, description, state, author_username, created_at, updated_at, last_seen_at) VALUES (1, 10, 1, 42, 'Test', 'Desc', 'opened', 'alice', 1000, 2000, 3000)", + [], + ).unwrap(); + + mark_dirty(&conn, SourceType::Issue, 1).unwrap(); + let r1 = regenerate_dirty_documents(&conn, None).unwrap(); + assert_eq!(r1.regenerated, 1); + + mark_dirty(&conn, SourceType::Issue, 1).unwrap(); + let r2 = regenerate_dirty_documents(&conn, None).unwrap(); + assert_eq!(r2.unchanged, 1); + assert_eq!(r2.regenerated, 0); +} + +#[test] +fn test_regenerate_deleted_source() { + let conn = setup_db(); + conn.execute( + "INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at) VALUES (1, 10, 1, 42, 'Test', 'opened', 1000, 2000, 3000)", + [], + ).unwrap(); + mark_dirty(&conn, SourceType::Issue, 1).unwrap(); + regenerate_dirty_documents(&conn, None).unwrap(); + + conn.execute("PRAGMA foreign_keys = OFF", []).unwrap(); + conn.execute("DELETE FROM issues WHERE id = 1", []).unwrap(); + conn.execute("PRAGMA foreign_keys = ON", []).unwrap(); + mark_dirty(&conn, SourceType::Issue, 1).unwrap(); + + let result = regenerate_dirty_documents(&conn, None).unwrap(); + assert_eq!(result.regenerated, 1); + + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM documents", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 0); +} + +#[test] +fn test_regenerate_drains_queue() { + let conn = setup_db(); + for i in 1..=10 { + conn.execute( + "INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at) VALUES (?1, ?2, 1, ?1, 'Test', 'opened', 1000, 2000, 3000)", + rusqlite::params![i, i * 10], + ).unwrap(); + mark_dirty(&conn, SourceType::Issue, i).unwrap(); + } + + let result = regenerate_dirty_documents(&conn, None).unwrap(); + assert_eq!(result.regenerated, 10); + + let dirty = get_dirty_sources(&conn).unwrap(); + assert!(dirty.is_empty()); +} + +#[test] +fn test_triple_hash_fast_path() { + let conn = setup_db(); + conn.execute( + "INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at) VALUES (1, 10, 1, 42, 'Test', 'opened', 1000, 2000, 3000)", + [], + ).unwrap(); + conn.execute( + "INSERT INTO labels (id, project_id, name) VALUES (1, 1, 'bug')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO issue_labels (issue_id, label_id) VALUES (1, 1)", + [], + ) + .unwrap(); + + mark_dirty(&conn, SourceType::Issue, 1).unwrap(); + regenerate_dirty_documents(&conn, None).unwrap(); + + mark_dirty(&conn, SourceType::Issue, 1).unwrap(); + let result = regenerate_dirty_documents(&conn, None).unwrap(); + assert_eq!(result.unchanged, 1); + + let label_count: i64 = conn + .query_row("SELECT COUNT(*) FROM document_labels", [], |r| r.get(0)) + .unwrap(); + assert_eq!(label_count, 1); +} + +fn setup_note_db() -> Connection { + let conn = setup_db(); + conn.execute_batch( + " + CREATE TABLE merge_requests ( + id INTEGER PRIMARY KEY, + gitlab_id INTEGER UNIQUE NOT NULL, + project_id INTEGER NOT NULL REFERENCES projects(id), + iid INTEGER NOT NULL, + title TEXT, + description TEXT, + state TEXT, + draft INTEGER NOT NULL DEFAULT 0, + author_username TEXT, + source_branch TEXT, + target_branch TEXT, + head_sha TEXT, + references_short TEXT, + references_full TEXT, + detailed_merge_status TEXT, + merge_user_username TEXT, + created_at INTEGER, + updated_at INTEGER, + merged_at INTEGER, + closed_at INTEGER, + last_seen_at INTEGER NOT NULL, + discussions_synced_for_updated_at INTEGER, + discussions_sync_last_attempt_at INTEGER, + discussions_sync_attempts INTEGER DEFAULT 0, + discussions_sync_last_error TEXT, + resource_events_synced_for_updated_at INTEGER, + web_url TEXT, + raw_payload_id INTEGER + ); + CREATE TABLE mr_labels ( + merge_request_id INTEGER REFERENCES merge_requests(id), + label_id INTEGER REFERENCES labels(id), + PRIMARY KEY(merge_request_id, label_id) + ); + CREATE TABLE discussions ( + id INTEGER PRIMARY KEY, + gitlab_discussion_id TEXT NOT NULL, + project_id INTEGER NOT NULL REFERENCES projects(id), + issue_id INTEGER REFERENCES issues(id), + merge_request_id INTEGER, + noteable_type TEXT NOT NULL, + individual_note INTEGER NOT NULL DEFAULT 0, + first_note_at INTEGER, + last_note_at INTEGER, + last_seen_at INTEGER NOT NULL, + resolvable INTEGER NOT NULL DEFAULT 0, + resolved INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE notes ( + id INTEGER PRIMARY KEY, + gitlab_id INTEGER UNIQUE NOT NULL, + discussion_id INTEGER NOT NULL REFERENCES discussions(id), + project_id INTEGER NOT NULL REFERENCES projects(id), + note_type TEXT, + is_system INTEGER NOT NULL DEFAULT 0, + author_username TEXT, + body TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL, + position INTEGER, + resolvable INTEGER NOT NULL DEFAULT 0, + resolved INTEGER NOT NULL DEFAULT 0, + resolved_by TEXT, + resolved_at INTEGER, + position_old_path TEXT, + position_new_path TEXT, + position_old_line INTEGER, + position_new_line INTEGER, + raw_payload_id INTEGER + ); + ", + ) + .unwrap(); + conn +} + +#[test] +fn test_regenerate_note_document() { + let conn = setup_note_db(); + conn.execute( + "INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, author_username, created_at, updated_at, last_seen_at, web_url) VALUES (1, 10, 1, 42, 'Test Issue', 'opened', 'alice', 1000, 2000, 3000, 'https://example.com/issues/42')", + [], + ).unwrap(); + conn.execute( + "INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at) VALUES (1, 'disc_1', 1, 1, 'Issue', 3000)", + [], + ).unwrap(); + conn.execute( + "INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system) VALUES (1, 100, 1, 1, 'bob', 'This is a note', 1000, 2000, 3000, 0)", + [], + ).unwrap(); + + mark_dirty(&conn, SourceType::Note, 1).unwrap(); + let result = regenerate_dirty_documents(&conn, None).unwrap(); + assert_eq!(result.regenerated, 1); + assert_eq!(result.unchanged, 0); + assert_eq!(result.errored, 0); + + let (source_type, content): (String, String) = conn + .query_row( + "SELECT source_type, content_text FROM documents WHERE source_id = 1", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(source_type, "note"); + assert!(content.contains("[[Note]]")); + assert!(content.contains("author: @bob")); +} + +#[test] +fn test_regenerate_note_system_note_deletes() { + let conn = setup_note_db(); + conn.execute( + "INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at) VALUES (1, 10, 1, 42, 'Test', 'opened', 1000, 2000, 3000)", + [], + ).unwrap(); + conn.execute( + "INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at) VALUES (1, 'disc_1', 1, 1, 'Issue', 3000)", + [], + ).unwrap(); + conn.execute( + "INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system) VALUES (1, 100, 1, 1, 'bot', 'assigned to @alice', 1000, 2000, 3000, 1)", + [], + ).unwrap(); + + // Pre-insert a document for this note (simulating a previously-generated doc) + conn.execute( + "INSERT INTO documents (source_type, source_id, project_id, content_text, content_hash) VALUES ('note', 1, 1, 'old content', 'oldhash')", + [], + ).unwrap(); + + mark_dirty(&conn, SourceType::Note, 1).unwrap(); + let result = regenerate_dirty_documents(&conn, None).unwrap(); + assert_eq!(result.regenerated, 1); + + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM documents WHERE source_type = 'note'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 0); +} + +#[test] +fn test_regenerate_note_unchanged() { + let conn = setup_note_db(); + conn.execute( + "INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at, web_url) VALUES (1, 10, 1, 42, 'Test', 'opened', 1000, 2000, 3000, 'https://example.com/issues/42')", + [], + ).unwrap(); + conn.execute( + "INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at) VALUES (1, 'disc_1', 1, 1, 'Issue', 3000)", + [], + ).unwrap(); + conn.execute( + "INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system) VALUES (1, 100, 1, 1, 'bob', 'Some note', 1000, 2000, 3000, 0)", + [], + ).unwrap(); + + mark_dirty(&conn, SourceType::Note, 1).unwrap(); + let r1 = regenerate_dirty_documents(&conn, None).unwrap(); + assert_eq!(r1.regenerated, 1); + + mark_dirty(&conn, SourceType::Note, 1).unwrap(); + let r2 = regenerate_dirty_documents(&conn, None).unwrap(); + assert_eq!(r2.unchanged, 1); + assert_eq!(r2.regenerated, 0); +} + +#[test] +fn test_note_regeneration_batch_uses_cache() { + let conn = setup_note_db(); + conn.execute( + "INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, author_username, created_at, updated_at, last_seen_at, web_url) VALUES (1, 10, 1, 42, 'Shared Issue', 'opened', 'alice', 1000, 2000, 3000, 'https://example.com/issues/42')", + [], + ).unwrap(); + conn.execute( + "INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at) VALUES (1, 'disc_1', 1, 1, 'Issue', 3000)", + [], + ).unwrap(); + + for i in 1..=10 { + conn.execute( + "INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system) VALUES (?1, ?2, 1, 1, 'bob', ?3, 1000, 2000, 3000, 0)", + rusqlite::params![i, i * 100, format!("Note body {}", i)], + ).unwrap(); + mark_dirty(&conn, SourceType::Note, i).unwrap(); + } + + let result = regenerate_dirty_documents(&conn, None).unwrap(); + assert_eq!(result.regenerated, 10); + assert_eq!(result.errored, 0); + + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM documents WHERE source_type = 'note'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 10); +} + +#[test] +fn test_note_regeneration_cache_consistent_with_direct_extraction() { + let conn = setup_note_db(); + conn.execute( + "INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, author_username, created_at, updated_at, last_seen_at, web_url) VALUES (1, 10, 1, 42, 'Consistency Check', 'opened', 'alice', 1000, 2000, 3000, 'https://example.com/issues/42')", + [], + ).unwrap(); + conn.execute( + "INSERT INTO labels (id, project_id, name) VALUES (1, 1, 'backend')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO issue_labels (issue_id, label_id) VALUES (1, 1)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at) VALUES (1, 'disc_1', 1, 1, 'Issue', 3000)", + [], + ).unwrap(); + conn.execute( + "INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system) VALUES (1, 100, 1, 1, 'bob', 'Some content', 1000, 2000, 3000, 0)", + [], + ).unwrap(); + + use crate::documents::extract_note_document; + let direct = extract_note_document(&conn, 1).unwrap().unwrap(); + + let mut cache = ParentMetadataCache::new(); + let cached = extract_note_document_cached(&conn, 1, &mut cache) + .unwrap() + .unwrap(); + + assert_eq!(direct.content_text, cached.content_text); + assert_eq!(direct.content_hash, cached.content_hash); + assert_eq!(direct.labels, cached.labels); + assert_eq!(direct.labels_hash, cached.labels_hash); + assert_eq!(direct.paths_hash, cached.paths_hash); + assert_eq!(direct.title, cached.title); + assert_eq!(direct.url, cached.url); + assert_eq!(direct.author_username, cached.author_username); +} + +#[test] +fn test_note_regeneration_cache_invalidates_across_parents() { + let conn = setup_note_db(); + conn.execute( + "INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at, web_url) VALUES (1, 10, 1, 42, 'Issue Alpha', 'opened', 1000, 2000, 3000, 'https://example.com/issues/42')", + [], + ).unwrap(); + conn.execute( + "INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at, web_url) VALUES (2, 20, 1, 99, 'Issue Beta', 'opened', 1000, 2000, 3000, 'https://example.com/issues/99')", + [], + ).unwrap(); + conn.execute( + "INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at) VALUES (1, 'disc_1', 1, 1, 'Issue', 3000)", + [], + ).unwrap(); + conn.execute( + "INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at) VALUES (2, 'disc_2', 1, 2, 'Issue', 3000)", + [], + ).unwrap(); + conn.execute( + "INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system) VALUES (1, 100, 1, 1, 'bob', 'Alpha note', 1000, 2000, 3000, 0)", + [], + ).unwrap(); + conn.execute( + "INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system) VALUES (2, 200, 2, 1, 'alice', 'Beta note', 1000, 2000, 3000, 0)", + [], + ).unwrap(); + + mark_dirty(&conn, SourceType::Note, 1).unwrap(); + mark_dirty(&conn, SourceType::Note, 2).unwrap(); + + let result = regenerate_dirty_documents(&conn, None).unwrap(); + assert_eq!(result.regenerated, 2); + assert_eq!(result.errored, 0); + + let alpha_content: String = conn + .query_row( + "SELECT content_text FROM documents WHERE source_type = 'note' AND source_id = 1", + [], + |r| r.get(0), + ) + .unwrap(); + let beta_content: String = conn + .query_row( + "SELECT content_text FROM documents WHERE source_type = 'note' AND source_id = 2", + [], + |r| r.get(0), + ) + .unwrap(); + + assert!(alpha_content.contains("parent_iid: 42")); + assert!(alpha_content.contains("parent_title: Issue Alpha")); + assert!(beta_content.contains("parent_iid: 99")); + assert!(beta_content.contains("parent_title: Issue Beta")); +} diff --git a/src/embedding/change_detector.rs b/src/embedding/change_detector.rs index 1cccc6c..fc7eb5c 100644 --- a/src/embedding/change_detector.rs +++ b/src/embedding/change_detector.rs @@ -85,146 +85,5 @@ pub fn count_pending_documents(conn: &Connection, model_name: &str) -> Result Connection { - let conn = create_connection(Path::new(":memory:")).unwrap(); - run_migrations(&conn).unwrap(); - conn - } - - fn insert_test_project(conn: &Connection) -> i64 { - conn.execute( - "INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url) - VALUES (1, 'group/test', 'https://gitlab.example.com/group/test')", - [], - ) - .unwrap(); - conn.last_insert_rowid() - } - - fn insert_test_document(conn: &Connection, project_id: i64, content: &str) -> i64 { - conn.execute( - "INSERT INTO documents (source_type, source_id, project_id, content_text, content_hash) - VALUES ('issue', 1, ?1, ?2, 'hash123')", - rusqlite::params![project_id, content], - ) - .unwrap(); - conn.last_insert_rowid() - } - - #[test] - fn retry_failed_delete_makes_doc_pending_again() { - let conn = setup_db(); - let proj_id = insert_test_project(&conn); - let doc_id = insert_test_document(&conn, proj_id, "some text content"); - - // Doc starts as pending - let pending = find_pending_documents(&conn, 100, 0, MODEL).unwrap(); - assert_eq!(pending.len(), 1, "Doc should be pending initially"); - - // Record an error — doc should no longer be pending - record_embedding_error( - &conn, - doc_id, - 0, - "hash123", - "chunkhash", - MODEL, - "test error", - ) - .unwrap(); - let pending = find_pending_documents(&conn, 100, 0, MODEL).unwrap(); - assert!( - pending.is_empty(), - "Doc with error metadata should not be pending" - ); - - // DELETE error rows (mimicking --retry-failed) — doc should become pending again - conn.execute_batch( - "DELETE FROM embeddings WHERE rowid / 1000 IN ( - SELECT DISTINCT document_id FROM embedding_metadata - WHERE last_error IS NOT NULL - ); - DELETE FROM embedding_metadata WHERE last_error IS NOT NULL;", - ) - .unwrap(); - let pending = find_pending_documents(&conn, 100, 0, MODEL).unwrap(); - assert_eq!(pending.len(), 1, "Doc should be pending again after DELETE"); - assert_eq!(pending[0].document_id, doc_id); - } - - #[test] - fn empty_doc_with_error_not_pending() { - let conn = setup_db(); - let proj_id = insert_test_project(&conn); - let doc_id = insert_test_document(&conn, proj_id, ""); - - // Empty doc starts as pending - let pending = find_pending_documents(&conn, 100, 0, MODEL).unwrap(); - assert_eq!(pending.len(), 1, "Empty doc should be pending initially"); - - // Record an error for the empty doc - record_embedding_error( - &conn, - doc_id, - 0, - "hash123", - "empty", - MODEL, - "Document has empty content", - ) - .unwrap(); - - // Should no longer be pending - let pending = find_pending_documents(&conn, 100, 0, MODEL).unwrap(); - assert!( - pending.is_empty(), - "Empty doc with error metadata should not be pending" - ); - } - - #[test] - fn old_update_approach_leaves_doc_invisible() { - // This test demonstrates WHY we use DELETE instead of UPDATE. - // UPDATE clears last_error but the row still matches config params, - // so the doc stays "not pending" — permanently invisible. - let conn = setup_db(); - let proj_id = insert_test_project(&conn); - let doc_id = insert_test_document(&conn, proj_id, "some text content"); - - // Record an error - record_embedding_error( - &conn, - doc_id, - 0, - "hash123", - "chunkhash", - MODEL, - "test error", - ) - .unwrap(); - - // Old approach: UPDATE to clear error - conn.execute( - "UPDATE embedding_metadata SET last_error = NULL, attempt_count = 0 - WHERE last_error IS NOT NULL", - [], - ) - .unwrap(); - - // Doc is NOT pending — it's permanently invisible! This is the bug. - let pending = find_pending_documents(&conn, 100, 0, MODEL).unwrap(); - assert!( - pending.is_empty(), - "UPDATE approach leaves doc invisible (this proves the bug)" - ); - } -} +#[path = "change_detector_tests.rs"] +mod tests; diff --git a/src/embedding/change_detector_tests.rs b/src/embedding/change_detector_tests.rs new file mode 100644 index 0000000..12c8660 --- /dev/null +++ b/src/embedding/change_detector_tests.rs @@ -0,0 +1,141 @@ +use std::path::Path; + +use super::*; +use crate::core::db::{create_connection, run_migrations}; +use crate::embedding::pipeline::record_embedding_error; + +const MODEL: &str = "nomic-embed-text"; + +fn setup_db() -> Connection { + let conn = create_connection(Path::new(":memory:")).unwrap(); + run_migrations(&conn).unwrap(); + conn +} + +fn insert_test_project(conn: &Connection) -> i64 { + conn.execute( + "INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url) + VALUES (1, 'group/test', 'https://gitlab.example.com/group/test')", + [], + ) + .unwrap(); + conn.last_insert_rowid() +} + +fn insert_test_document(conn: &Connection, project_id: i64, content: &str) -> i64 { + conn.execute( + "INSERT INTO documents (source_type, source_id, project_id, content_text, content_hash) + VALUES ('issue', 1, ?1, ?2, 'hash123')", + rusqlite::params![project_id, content], + ) + .unwrap(); + conn.last_insert_rowid() +} + +#[test] +fn retry_failed_delete_makes_doc_pending_again() { + let conn = setup_db(); + let proj_id = insert_test_project(&conn); + let doc_id = insert_test_document(&conn, proj_id, "some text content"); + + // Doc starts as pending + let pending = find_pending_documents(&conn, 100, 0, MODEL).unwrap(); + assert_eq!(pending.len(), 1, "Doc should be pending initially"); + + // Record an error — doc should no longer be pending + record_embedding_error( + &conn, + doc_id, + 0, + "hash123", + "chunkhash", + MODEL, + "test error", + ) + .unwrap(); + let pending = find_pending_documents(&conn, 100, 0, MODEL).unwrap(); + assert!( + pending.is_empty(), + "Doc with error metadata should not be pending" + ); + + // DELETE error rows (mimicking --retry-failed) — doc should become pending again + conn.execute_batch( + "DELETE FROM embeddings WHERE rowid / 1000 IN ( + SELECT DISTINCT document_id FROM embedding_metadata + WHERE last_error IS NOT NULL + ); + DELETE FROM embedding_metadata WHERE last_error IS NOT NULL;", + ) + .unwrap(); + let pending = find_pending_documents(&conn, 100, 0, MODEL).unwrap(); + assert_eq!(pending.len(), 1, "Doc should be pending again after DELETE"); + assert_eq!(pending[0].document_id, doc_id); +} + +#[test] +fn empty_doc_with_error_not_pending() { + let conn = setup_db(); + let proj_id = insert_test_project(&conn); + let doc_id = insert_test_document(&conn, proj_id, ""); + + // Empty doc starts as pending + let pending = find_pending_documents(&conn, 100, 0, MODEL).unwrap(); + assert_eq!(pending.len(), 1, "Empty doc should be pending initially"); + + // Record an error for the empty doc + record_embedding_error( + &conn, + doc_id, + 0, + "hash123", + "empty", + MODEL, + "Document has empty content", + ) + .unwrap(); + + // Should no longer be pending + let pending = find_pending_documents(&conn, 100, 0, MODEL).unwrap(); + assert!( + pending.is_empty(), + "Empty doc with error metadata should not be pending" + ); +} + +#[test] +fn old_update_approach_leaves_doc_invisible() { + // This test demonstrates WHY we use DELETE instead of UPDATE. + // UPDATE clears last_error but the row still matches config params, + // so the doc stays "not pending" — permanently invisible. + let conn = setup_db(); + let proj_id = insert_test_project(&conn); + let doc_id = insert_test_document(&conn, proj_id, "some text content"); + + // Record an error + record_embedding_error( + &conn, + doc_id, + 0, + "hash123", + "chunkhash", + MODEL, + "test error", + ) + .unwrap(); + + // Old approach: UPDATE to clear error + conn.execute( + "UPDATE embedding_metadata SET last_error = NULL, attempt_count = 0 + WHERE last_error IS NOT NULL", + [], + ) + .unwrap(); + + // Doc is NOT pending — it's permanently invisible! This is the bug. + let pending = find_pending_documents(&conn, 100, 0, MODEL).unwrap(); + assert!( + pending.is_empty(), + "UPDATE approach leaves doc invisible (this proves the bug)" + ); +} diff --git a/src/embedding/chunking.rs b/src/embedding/chunking.rs index 4dc4562..f28da03 100644 --- a/src/embedding/chunking.rs +++ b/src/embedding/chunking.rs @@ -103,231 +103,5 @@ fn floor_char_boundary(s: &str, idx: usize) -> usize { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_empty_content() { - let chunks = split_into_chunks(""); - assert!(chunks.is_empty()); - } - - #[test] - fn test_short_document_single_chunk() { - let content = "Short document content."; - let chunks = split_into_chunks(content); - assert_eq!(chunks.len(), 1); - assert_eq!(chunks[0].0, 0); - assert_eq!(chunks[0].1, content); - } - - #[test] - fn test_exactly_max_chars() { - let content = "a".repeat(CHUNK_MAX_BYTES); - let chunks = split_into_chunks(&content); - assert_eq!(chunks.len(), 1); - } - - #[test] - fn test_long_document_multiple_chunks() { - let paragraph = "This is a paragraph of text.\n\n"; - let mut content = String::new(); - while content.len() < CHUNK_MAX_BYTES * 2 { - content.push_str(paragraph); - } - - let chunks = split_into_chunks(&content); - assert!( - chunks.len() >= 2, - "Expected multiple chunks, got {}", - chunks.len() - ); - - for (i, (idx, _)) in chunks.iter().enumerate() { - assert_eq!(*idx, i); - } - - assert!(!chunks.last().unwrap().1.is_empty()); - } - - #[test] - fn test_chunk_overlap() { - let paragraph = "This is paragraph content for testing chunk overlap behavior.\n\n"; - let mut content = String::new(); - while content.len() < CHUNK_MAX_BYTES + CHUNK_OVERLAP_CHARS + 1000 { - content.push_str(paragraph); - } - - let chunks = split_into_chunks(&content); - assert!(chunks.len() >= 2); - - if chunks.len() >= 2 { - let end_of_first = &chunks[0].1; - let start_of_second = &chunks[1].1; - let overlap_region = - &end_of_first[end_of_first.len().saturating_sub(CHUNK_OVERLAP_CHARS)..]; - assert!( - start_of_second.starts_with(overlap_region) - || overlap_region.contains(&start_of_second[..100.min(start_of_second.len())]), - "Expected overlap between chunks" - ); - } - } - - #[test] - fn test_no_paragraph_boundary() { - let content = "word ".repeat(CHUNK_MAX_BYTES / 5 * 3); - let chunks = split_into_chunks(&content); - assert!(chunks.len() >= 2); - for (_, chunk) in &chunks { - assert!(!chunk.is_empty()); - } - } - - #[test] - fn test_chunk_indices_sequential() { - let content = "a ".repeat(CHUNK_MAX_BYTES); - let chunks = split_into_chunks(&content); - for (i, (idx, _)) in chunks.iter().enumerate() { - assert_eq!(*idx, i, "Chunk index mismatch at position {}", i); - } - } - - #[test] - fn test_multibyte_characters_no_panic() { - // Build content with multi-byte UTF-8 chars (smart quotes, emoji, CJK) - // placed at positions likely to hit len()*2/3 and len()/2 boundaries - let segment = "We\u{2019}ve gradually ar\u{2014}ranged the components. "; - let mut content = String::new(); - while content.len() < CHUNK_MAX_BYTES * 3 { - content.push_str(segment); - } - // Should not panic on multi-byte boundary - let chunks = split_into_chunks(&content); - assert!(chunks.len() >= 2); - for (_, chunk) in &chunks { - assert!(!chunk.is_empty()); - } - } - - #[test] - fn test_nbsp_at_overlap_boundary() { - // Reproduce the exact crash: \u{a0} (non-breaking space, 2-byte UTF-8) - // placed so that split_at - CHUNK_OVERLAP_CHARS lands mid-character - let mut content = String::new(); - // Fill with ASCII up to near CHUNK_MAX_BYTES, then place \u{a0} - // near where the overlap subtraction would land - let target = CHUNK_MAX_BYTES - CHUNK_OVERLAP_CHARS; - while content.len() < target - 2 { - content.push('a'); - } - content.push('\u{a0}'); // 2-byte char right at the overlap boundary - while content.len() < CHUNK_MAX_BYTES * 3 { - content.push('b'); - } - // Should not panic - let chunks = split_into_chunks(&content); - assert!(chunks.len() >= 2); - } - - #[test] - fn test_box_drawing_heavy_content() { - // Simulates a document with many box-drawing characters (3-byte UTF-8) - // like the ─ (U+2500) character found in markdown tables - let mut content = String::new(); - // Normal text header - content.push_str("# Title\n\nSome description text.\n\n"); - // Table header with box drawing - content.push('┌'); - for _ in 0..200 { - content.push('─'); - } - content.push('┬'); - for _ in 0..200 { - content.push('─'); - } - content.push_str("┐\n"); // clippy: push_str is correct here (multi-char) - // Table rows - for row in 0..50 { - content.push_str(&format!("│ row {:<194}│ data {:<193}│\n", row, row)); - content.push('├'); - for _ in 0..200 { - content.push('─'); - } - content.push('┼'); - for _ in 0..200 { - content.push('─'); - } - content.push_str("┤\n"); // push_str for multi-char - } - content.push('└'); - for _ in 0..200 { - content.push('─'); - } - content.push('┴'); - for _ in 0..200 { - content.push('─'); - } - content.push_str("┘\n"); // push_str for multi-char - - eprintln!( - "Content size: {} bytes, {} chars", - content.len(), - content.chars().count() - ); - let start = std::time::Instant::now(); - let chunks = split_into_chunks(&content); - let elapsed = start.elapsed(); - eprintln!( - "Chunking took {:?}, produced {} chunks", - elapsed, - chunks.len() - ); - - // Should complete in reasonable time - assert!( - elapsed.as_secs() < 5, - "Chunking took too long: {:?}", - elapsed - ); - assert!(!chunks.is_empty()); - } - - #[test] - fn test_real_doc_18526_pattern() { - // Reproduce exact pattern: long lines of ─ (3 bytes each, no spaces) - // followed by newlines, creating a pattern where chunk windows - // land in spaceless regions - let mut content = String::new(); - content.push_str("Header text with spaces\n\n"); - // Create a very long line of ─ chars (2000+ bytes, exceeding CHUNK_MAX_BYTES) - for _ in 0..800 { - content.push('─'); // 3 bytes each = 2400 bytes - } - content.push('\n'); - content.push_str("Some more text.\n\n"); - // Another long run - for _ in 0..800 { - content.push('─'); - } - content.push('\n'); - content.push_str("End text.\n"); - - eprintln!("Content size: {} bytes", content.len()); - let start = std::time::Instant::now(); - let chunks = split_into_chunks(&content); - let elapsed = start.elapsed(); - eprintln!( - "Chunking took {:?}, produced {} chunks", - elapsed, - chunks.len() - ); - - assert!( - elapsed.as_secs() < 2, - "Chunking took too long: {:?}", - elapsed - ); - assert!(!chunks.is_empty()); - } -} +#[path = "chunking_tests.rs"] +mod tests; diff --git a/src/embedding/chunking_tests.rs b/src/embedding/chunking_tests.rs new file mode 100644 index 0000000..375afb9 --- /dev/null +++ b/src/embedding/chunking_tests.rs @@ -0,0 +1,226 @@ +use super::*; + +#[test] +fn test_empty_content() { + let chunks = split_into_chunks(""); + assert!(chunks.is_empty()); +} + +#[test] +fn test_short_document_single_chunk() { + let content = "Short document content."; + let chunks = split_into_chunks(content); + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0].0, 0); + assert_eq!(chunks[0].1, content); +} + +#[test] +fn test_exactly_max_chars() { + let content = "a".repeat(CHUNK_MAX_BYTES); + let chunks = split_into_chunks(&content); + assert_eq!(chunks.len(), 1); +} + +#[test] +fn test_long_document_multiple_chunks() { + let paragraph = "This is a paragraph of text.\n\n"; + let mut content = String::new(); + while content.len() < CHUNK_MAX_BYTES * 2 { + content.push_str(paragraph); + } + + let chunks = split_into_chunks(&content); + assert!( + chunks.len() >= 2, + "Expected multiple chunks, got {}", + chunks.len() + ); + + for (i, (idx, _)) in chunks.iter().enumerate() { + assert_eq!(*idx, i); + } + + assert!(!chunks.last().unwrap().1.is_empty()); +} + +#[test] +fn test_chunk_overlap() { + let paragraph = "This is paragraph content for testing chunk overlap behavior.\n\n"; + let mut content = String::new(); + while content.len() < CHUNK_MAX_BYTES + CHUNK_OVERLAP_CHARS + 1000 { + content.push_str(paragraph); + } + + let chunks = split_into_chunks(&content); + assert!(chunks.len() >= 2); + + if chunks.len() >= 2 { + let end_of_first = &chunks[0].1; + let start_of_second = &chunks[1].1; + let overlap_region = + &end_of_first[end_of_first.len().saturating_sub(CHUNK_OVERLAP_CHARS)..]; + assert!( + start_of_second.starts_with(overlap_region) + || overlap_region.contains(&start_of_second[..100.min(start_of_second.len())]), + "Expected overlap between chunks" + ); + } +} + +#[test] +fn test_no_paragraph_boundary() { + let content = "word ".repeat(CHUNK_MAX_BYTES / 5 * 3); + let chunks = split_into_chunks(&content); + assert!(chunks.len() >= 2); + for (_, chunk) in &chunks { + assert!(!chunk.is_empty()); + } +} + +#[test] +fn test_chunk_indices_sequential() { + let content = "a ".repeat(CHUNK_MAX_BYTES); + let chunks = split_into_chunks(&content); + for (i, (idx, _)) in chunks.iter().enumerate() { + assert_eq!(*idx, i, "Chunk index mismatch at position {}", i); + } +} + +#[test] +fn test_multibyte_characters_no_panic() { + // Build content with multi-byte UTF-8 chars (smart quotes, emoji, CJK) + // placed at positions likely to hit len()*2/3 and len()/2 boundaries + let segment = "We\u{2019}ve gradually ar\u{2014}ranged the components. "; + let mut content = String::new(); + while content.len() < CHUNK_MAX_BYTES * 3 { + content.push_str(segment); + } + // Should not panic on multi-byte boundary + let chunks = split_into_chunks(&content); + assert!(chunks.len() >= 2); + for (_, chunk) in &chunks { + assert!(!chunk.is_empty()); + } +} + +#[test] +fn test_nbsp_at_overlap_boundary() { + // Reproduce the exact crash: \u{a0} (non-breaking space, 2-byte UTF-8) + // placed so that split_at - CHUNK_OVERLAP_CHARS lands mid-character + let mut content = String::new(); + // Fill with ASCII up to near CHUNK_MAX_BYTES, then place \u{a0} + // near where the overlap subtraction would land + let target = CHUNK_MAX_BYTES - CHUNK_OVERLAP_CHARS; + while content.len() < target - 2 { + content.push('a'); + } + content.push('\u{a0}'); // 2-byte char right at the overlap boundary + while content.len() < CHUNK_MAX_BYTES * 3 { + content.push('b'); + } + // Should not panic + let chunks = split_into_chunks(&content); + assert!(chunks.len() >= 2); +} + +#[test] +fn test_box_drawing_heavy_content() { + // Simulates a document with many box-drawing characters (3-byte UTF-8) + // like the ─ (U+2500) character found in markdown tables + let mut content = String::new(); + // Normal text header + content.push_str("# Title\n\nSome description text.\n\n"); + // Table header with box drawing + content.push('┌'); + for _ in 0..200 { + content.push('─'); + } + content.push('┬'); + for _ in 0..200 { + content.push('─'); + } + content.push_str("┐\n"); // clippy: push_str is correct here (multi-char) + // Table rows + for row in 0..50 { + content.push_str(&format!("│ row {:<194}│ data {:<193}│\n", row, row)); + content.push('├'); + for _ in 0..200 { + content.push('─'); + } + content.push('┼'); + for _ in 0..200 { + content.push('─'); + } + content.push_str("┤\n"); // push_str for multi-char + } + content.push('└'); + for _ in 0..200 { + content.push('─'); + } + content.push('┴'); + for _ in 0..200 { + content.push('─'); + } + content.push_str("┘\n"); // push_str for multi-char + + eprintln!( + "Content size: {} bytes, {} chars", + content.len(), + content.chars().count() + ); + let start = std::time::Instant::now(); + let chunks = split_into_chunks(&content); + let elapsed = start.elapsed(); + eprintln!( + "Chunking took {:?}, produced {} chunks", + elapsed, + chunks.len() + ); + + // Should complete in reasonable time + assert!( + elapsed.as_secs() < 5, + "Chunking took too long: {:?}", + elapsed + ); + assert!(!chunks.is_empty()); +} + +#[test] +fn test_real_doc_18526_pattern() { + // Reproduce exact pattern: long lines of ─ (3 bytes each, no spaces) + // followed by newlines, creating a pattern where chunk windows + // land in spaceless regions + let mut content = String::new(); + content.push_str("Header text with spaces\n\n"); + // Create a very long line of ─ chars (2000+ bytes, exceeding CHUNK_MAX_BYTES) + for _ in 0..800 { + content.push('─'); // 3 bytes each = 2400 bytes + } + content.push('\n'); + content.push_str("Some more text.\n\n"); + // Another long run + for _ in 0..800 { + content.push('─'); + } + content.push('\n'); + content.push_str("End text.\n"); + + eprintln!("Content size: {} bytes", content.len()); + let start = std::time::Instant::now(); + let chunks = split_into_chunks(&content); + let elapsed = start.elapsed(); + eprintln!( + "Chunking took {:?}, produced {} chunks", + elapsed, + chunks.len() + ); + + assert!( + elapsed.as_secs() < 2, + "Chunking took too long: {:?}", + elapsed + ); + assert!(!chunks.is_empty()); +} diff --git a/src/gitlab/graphql.rs b/src/gitlab/graphql.rs index 5c1fd41..332d722 100644 --- a/src/gitlab/graphql.rs +++ b/src/gitlab/graphql.rs @@ -364,930 +364,5 @@ pub async fn fetch_issue_statuses_with_progress( } #[cfg(test)] -mod tests { - use super::*; - use crate::core::error::LoreError; - use wiremock::matchers::{body_json, header, method, path}; - use wiremock::{Mock, MockServer, ResponseTemplate}; - - // ═══════════════════════════════════════════════════════════════════════ - // AC-1: GraphQL Client - // ═══════════════════════════════════════════════════════════════════════ - - #[tokio::test] - async fn test_graphql_query_success() { - let server = MockServer::start().await; - let response_body = serde_json::json!({ - "data": { "project": { "id": "1" } } - }); - - Mock::given(method("POST")) - .and(path("/api/graphql")) - .respond_with(ResponseTemplate::new(200).set_body_json(&response_body)) - .mount(&server) - .await; - - let client = GraphqlClient::new(&server.uri(), "test-token"); - let result = client - .query("{ project { id } }", serde_json::json!({})) - .await - .unwrap(); - - assert_eq!(result.data["project"]["id"], "1"); - assert!(!result.had_partial_errors); - assert!(result.first_partial_error.is_none()); - } - - #[tokio::test] - async fn test_graphql_query_with_errors_no_data() { - let server = MockServer::start().await; - let response_body = serde_json::json!({ - "errors": [{ "message": "Field 'foo' not found" }] - }); - - Mock::given(method("POST")) - .and(path("/api/graphql")) - .respond_with(ResponseTemplate::new(200).set_body_json(&response_body)) - .mount(&server) - .await; - - let client = GraphqlClient::new(&server.uri(), "test-token"); - let err = client - .query("{ foo }", serde_json::json!({})) - .await - .unwrap_err(); - - match err { - LoreError::Other(msg) => { - assert!(msg.contains("Field 'foo' not found"), "got: {msg}"); - } - other => panic!("Expected LoreError::Other, got: {other:?}"), - } - } - - #[tokio::test] - async fn test_graphql_auth_uses_bearer() { - let server = MockServer::start().await; - - Mock::given(method("POST")) - .and(path("/api/graphql")) - .and(header("Authorization", "Bearer my-secret-token")) - .respond_with( - ResponseTemplate::new(200).set_body_json(serde_json::json!({"data": {"ok": true}})), - ) - .mount(&server) - .await; - - let client = GraphqlClient::new(&server.uri(), "my-secret-token"); - let result = client.query("{ ok }", serde_json::json!({})).await; - assert!(result.is_ok()); - } - - #[tokio::test] - async fn test_graphql_401_maps_to_auth_failed() { - let server = MockServer::start().await; - - Mock::given(method("POST")) - .and(path("/api/graphql")) - .respond_with(ResponseTemplate::new(401)) - .mount(&server) - .await; - - let client = GraphqlClient::new(&server.uri(), "bad-token"); - let err = client - .query("{ me }", serde_json::json!({})) - .await - .unwrap_err(); - - assert!(matches!(err, LoreError::GitLabAuthFailed)); - } - - #[tokio::test] - async fn test_graphql_403_maps_to_auth_failed() { - let server = MockServer::start().await; - - Mock::given(method("POST")) - .and(path("/api/graphql")) - .respond_with(ResponseTemplate::new(403)) - .mount(&server) - .await; - - let client = GraphqlClient::new(&server.uri(), "forbidden-token"); - let err = client - .query("{ admin }", serde_json::json!({})) - .await - .unwrap_err(); - - assert!(matches!(err, LoreError::GitLabAuthFailed)); - } - - #[tokio::test] - async fn test_graphql_404_maps_to_not_found() { - let server = MockServer::start().await; - - Mock::given(method("POST")) - .and(path("/api/graphql")) - .respond_with(ResponseTemplate::new(404)) - .mount(&server) - .await; - - let client = GraphqlClient::new(&server.uri(), "token"); - let err = client - .query("{ x }", serde_json::json!({})) - .await - .unwrap_err(); - - match err { - LoreError::GitLabNotFound { resource } => { - assert_eq!(resource, "GraphQL endpoint"); - } - other => panic!("Expected GitLabNotFound, got: {other:?}"), - } - } - - #[tokio::test] - async fn test_graphql_partial_data_with_errors_returns_data() { - let server = MockServer::start().await; - let response_body = serde_json::json!({ - "data": { "project": { "name": "test" } }, - "errors": [{ "message": "Some field failed" }] - }); - - Mock::given(method("POST")) - .and(path("/api/graphql")) - .respond_with(ResponseTemplate::new(200).set_body_json(&response_body)) - .mount(&server) - .await; - - let client = GraphqlClient::new(&server.uri(), "token"); - let result = client - .query("{ project { name } }", serde_json::json!({})) - .await - .unwrap(); - - assert_eq!(result.data["project"]["name"], "test"); - assert!(result.had_partial_errors); - assert_eq!( - result.first_partial_error.as_deref(), - Some("Some field failed") - ); - } - - #[tokio::test] - async fn test_retry_after_delta_seconds() { - let server = MockServer::start().await; - - Mock::given(method("POST")) - .and(path("/api/graphql")) - .respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "120")) - .mount(&server) - .await; - - let client = GraphqlClient::new(&server.uri(), "token"); - let err = client - .query("{ x }", serde_json::json!({})) - .await - .unwrap_err(); - - match err { - LoreError::GitLabRateLimited { retry_after } => { - assert_eq!(retry_after, 120); - } - other => panic!("Expected GitLabRateLimited, got: {other:?}"), - } - } - - #[tokio::test] - async fn test_retry_after_http_date_format() { - let server = MockServer::start().await; - - let future = SystemTime::now() + Duration::from_secs(90); - let date_str = httpdate::fmt_http_date(future); - - Mock::given(method("POST")) - .and(path("/api/graphql")) - .respond_with(ResponseTemplate::new(429).insert_header("Retry-After", date_str)) - .mount(&server) - .await; - - let client = GraphqlClient::new(&server.uri(), "token"); - let err = client - .query("{ x }", serde_json::json!({})) - .await - .unwrap_err(); - - match err { - LoreError::GitLabRateLimited { retry_after } => { - assert!( - (85..=95).contains(&retry_after), - "retry_after={retry_after}" - ); - } - other => panic!("Expected GitLabRateLimited, got: {other:?}"), - } - } - - #[tokio::test] - async fn test_retry_after_invalid_falls_back_to_60() { - let server = MockServer::start().await; - - Mock::given(method("POST")) - .and(path("/api/graphql")) - .respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "garbage")) - .mount(&server) - .await; - - let client = GraphqlClient::new(&server.uri(), "token"); - let err = client - .query("{ x }", serde_json::json!({})) - .await - .unwrap_err(); - - match err { - LoreError::GitLabRateLimited { retry_after } => { - assert_eq!(retry_after, 60); - } - other => panic!("Expected GitLabRateLimited, got: {other:?}"), - } - } - - #[tokio::test] - async fn test_graphql_network_error() { - let client = GraphqlClient::new("http://127.0.0.1:1", "token"); - let err = client - .query("{ x }", serde_json::json!({})) - .await - .unwrap_err(); - - assert!( - matches!(err, LoreError::GitLabNetworkError { .. }), - "Expected GitLabNetworkError, got: {err:?}" - ); - } - - #[tokio::test] - async fn test_graphql_request_body_format() { - let server = MockServer::start().await; - - let expected_body = serde_json::json!({ - "query": "{ project(fullPath: $path) { id } }", - "variables": { "path": "group/repo" } - }); - - Mock::given(method("POST")) - .and(path("/api/graphql")) - .and(body_json(&expected_body)) - .respond_with( - ResponseTemplate::new(200) - .set_body_json(serde_json::json!({"data": {"project": {"id": "1"}}})), - ) - .mount(&server) - .await; - - let client = GraphqlClient::new(&server.uri(), "token"); - let result = client - .query( - "{ project(fullPath: $path) { id } }", - serde_json::json!({"path": "group/repo"}), - ) - .await; - - assert!(result.is_ok(), "Body format mismatch: {result:?}"); - } - - #[tokio::test] - async fn test_graphql_base_url_trailing_slash() { - let server = MockServer::start().await; - - Mock::given(method("POST")) - .and(path("/api/graphql")) - .respond_with( - ResponseTemplate::new(200).set_body_json(serde_json::json!({"data": {"ok": true}})), - ) - .mount(&server) - .await; - - let url_with_slash = format!("{}/", server.uri()); - let client = GraphqlClient::new(&url_with_slash, "token"); - let result = client.query("{ ok }", serde_json::json!({})).await; - - assert!(result.is_ok()); - } - - #[tokio::test] - async fn test_graphql_data_null_no_errors() { - let server = MockServer::start().await; - - Mock::given(method("POST")) - .and(path("/api/graphql")) - .respond_with( - ResponseTemplate::new(200).set_body_json(serde_json::json!({"data": null})), - ) - .mount(&server) - .await; - - let client = GraphqlClient::new(&server.uri(), "token"); - let err = client - .query("{ x }", serde_json::json!({})) - .await - .unwrap_err(); - - match err { - LoreError::Other(msg) => { - assert!(msg.contains("missing 'data' field"), "got: {msg}"); - } - other => panic!("Expected LoreError::Other, got: {other:?}"), - } - } - - // ═══════════════════════════════════════════════════════════════════════ - // AC-3: Status Fetcher - // ═══════════════════════════════════════════════════════════════════════ - - /// Helper: build a GraphQL work-items response page with given issues. - fn make_work_items_page( - items: &[(i64, Option<&str>)], - has_next_page: bool, - end_cursor: Option<&str>, - ) -> serde_json::Value { - let nodes: Vec = items - .iter() - .map(|(iid, status_name)| { - let mut widgets = - vec![serde_json::json!({"__typename": "WorkItemWidgetDescription"})]; - if let Some(name) = status_name { - widgets.push(serde_json::json!({ - "__typename": "WorkItemWidgetStatus", - "status": { - "name": name, - "category": "IN_PROGRESS", - "color": "#1f75cb", - "iconName": "status-in-progress" - } - })); - } - serde_json::json!({ - "iid": iid.to_string(), - "widgets": widgets, - }) - }) - .collect(); - - serde_json::json!({ - "data": { - "project": { - "workItems": { - "nodes": nodes, - "pageInfo": { - "endCursor": end_cursor, - "hasNextPage": has_next_page, - } - } - } - } - }) - } - - /// Helper: build a page where issue has status widget but status is null. - fn make_null_status_widget_page(iid: i64) -> serde_json::Value { - serde_json::json!({ - "data": { - "project": { - "workItems": { - "nodes": [{ - "iid": iid.to_string(), - "widgets": [ - {"__typename": "WorkItemWidgetStatus", "status": null} - ] - }], - "pageInfo": { - "endCursor": null, - "hasNextPage": false, - } - } - } - } - }) - } - - #[tokio::test] - async fn test_fetch_statuses_pagination() { - let server = MockServer::start().await; - - // Page 1: returns cursor "cursor_page2" - Mock::given(method("POST")) - .and(path("/api/graphql")) - .respond_with({ - ResponseTemplate::new(200).set_body_json(make_work_items_page( - &[(1, Some("In progress")), (2, Some("To do"))], - true, - Some("cursor_page2"), - )) - }) - .up_to_n_times(1) - .expect(1) - .mount(&server) - .await; - - // Page 2: no more pages - Mock::given(method("POST")) - .and(path("/api/graphql")) - .respond_with( - ResponseTemplate::new(200).set_body_json(make_work_items_page( - &[(3, Some("Done"))], - false, - None, - )), - ) - .expect(1) - .mount(&server) - .await; - - let client = GraphqlClient::new(&server.uri(), "tok123"); - let result = fetch_issue_statuses(&client, "group/project") - .await - .unwrap(); - - assert_eq!(result.statuses.len(), 3); - assert!(result.statuses.contains_key(&1)); - assert!(result.statuses.contains_key(&2)); - assert!(result.statuses.contains_key(&3)); - assert_eq!(result.all_fetched_iids.len(), 3); - assert!(result.unsupported_reason.is_none()); - } - - #[tokio::test] - async fn test_fetch_statuses_no_status_widget() { - let server = MockServer::start().await; - - Mock::given(method("POST")) - .and(path("/api/graphql")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "data": { - "project": { - "workItems": { - "nodes": [{ - "iid": "42", - "widgets": [ - {"__typename": "WorkItemWidgetDescription"}, - {"__typename": "WorkItemWidgetLabels"} - ] - }], - "pageInfo": {"endCursor": null, "hasNextPage": false} - } - } - } - }))) - .mount(&server) - .await; - - let client = GraphqlClient::new(&server.uri(), "tok123"); - let result = fetch_issue_statuses(&client, "group/project") - .await - .unwrap(); - - assert!(result.statuses.is_empty(), "No status widget → no statuses"); - assert!( - result.all_fetched_iids.contains(&42), - "IID 42 should still be in all_fetched_iids" - ); - } - - #[tokio::test] - async fn test_fetch_statuses_404_graceful() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/api/graphql")) - .respond_with(ResponseTemplate::new(404)) - .mount(&server) - .await; - - let client = GraphqlClient::new(&server.uri(), "tok123"); - let result = fetch_issue_statuses(&client, "group/project") - .await - .unwrap(); - - assert!(result.statuses.is_empty()); - assert!(result.all_fetched_iids.is_empty()); - assert!(matches!( - result.unsupported_reason, - Some(UnsupportedReason::GraphqlEndpointMissing) - )); - } - - #[tokio::test] - async fn test_fetch_statuses_403_graceful() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/api/graphql")) - .respond_with(ResponseTemplate::new(403)) - .mount(&server) - .await; - - let client = GraphqlClient::new(&server.uri(), "tok123"); - let result = fetch_issue_statuses(&client, "group/project") - .await - .unwrap(); - - assert!(result.statuses.is_empty()); - assert!(result.all_fetched_iids.is_empty()); - assert!(matches!( - result.unsupported_reason, - Some(UnsupportedReason::AuthForbidden) - )); - } - - #[tokio::test] - async fn test_fetch_statuses_unsupported_reason_none_on_success() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/api/graphql")) - .respond_with( - ResponseTemplate::new(200).set_body_json(make_work_items_page( - &[(1, Some("To do"))], - false, - None, - )), - ) - .mount(&server) - .await; - - let client = GraphqlClient::new(&server.uri(), "tok123"); - let result = fetch_issue_statuses(&client, "group/project") - .await - .unwrap(); - - assert!(result.unsupported_reason.is_none()); - } - - #[tokio::test] - async fn test_typename_matching_ignores_non_status_widgets() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/api/graphql")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "data": { - "project": { - "workItems": { - "nodes": [{ - "iid": "10", - "widgets": [ - {"__typename": "WorkItemWidgetDescription"}, - {"__typename": "WorkItemWidgetLabels"}, - {"__typename": "WorkItemWidgetAssignees"}, - { - "__typename": "WorkItemWidgetStatus", - "status": { - "name": "In progress", - "category": "IN_PROGRESS" - } - } - ] - }], - "pageInfo": {"endCursor": null, "hasNextPage": false} - } - } - } - }))) - .mount(&server) - .await; - - let client = GraphqlClient::new(&server.uri(), "tok123"); - let result = fetch_issue_statuses(&client, "group/project") - .await - .unwrap(); - - assert_eq!(result.statuses.len(), 1); - assert_eq!(result.statuses[&10].name, "In progress"); - } - - #[tokio::test] - async fn test_fetch_statuses_cursor_stall_aborts() { - let server = MockServer::start().await; - - let stall_response = serde_json::json!({ - "data": { - "project": { - "workItems": { - "nodes": [{"iid": "1", "widgets": []}], - "pageInfo": {"endCursor": "same_cursor", "hasNextPage": true} - } - } - } - }); - - Mock::given(method("POST")) - .and(path("/api/graphql")) - .respond_with(ResponseTemplate::new(200).set_body_json(stall_response)) - .mount(&server) - .await; - - let client = GraphqlClient::new(&server.uri(), "tok123"); - let result = fetch_issue_statuses(&client, "group/project") - .await - .unwrap(); - - assert!( - result.all_fetched_iids.contains(&1), - "Should contain the one IID fetched before stall" - ); - } - - #[tokio::test] - async fn test_fetch_statuses_complexity_error_reduces_page_size() { - let server = MockServer::start().await; - let call_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let call_count_clone = call_count.clone(); - - Mock::given(method("POST")) - .and(path("/api/graphql")) - .respond_with(move |_req: &wiremock::Request| { - let n = - call_count_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - if n == 0 { - ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "errors": [{"message": "Query has complexity of 300, which exceeds max complexity of 250"}] - })) - } else { - ResponseTemplate::new(200).set_body_json(make_work_items_page( - &[(1, Some("In progress"))], - false, - None, - )) - } - }) - .mount(&server) - .await; - - let client = GraphqlClient::new(&server.uri(), "tok123"); - let result = fetch_issue_statuses(&client, "group/project") - .await - .unwrap(); - - assert_eq!(result.statuses.len(), 1); - assert_eq!(result.statuses[&1].name, "In progress"); - assert_eq!(call_count.load(std::sync::atomic::Ordering::SeqCst), 2); - } - - #[tokio::test] - async fn test_fetch_statuses_timeout_error_reduces_page_size() { - let server = MockServer::start().await; - let call_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let call_count_clone = call_count.clone(); - - Mock::given(method("POST")) - .and(path("/api/graphql")) - .respond_with(move |_req: &wiremock::Request| { - let n = call_count_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - if n == 0 { - ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "errors": [{"message": "Query timeout after 30000ms"}] - })) - } else { - ResponseTemplate::new(200).set_body_json(make_work_items_page( - &[(5, Some("Done"))], - false, - None, - )) - } - }) - .mount(&server) - .await; - - let client = GraphqlClient::new(&server.uri(), "tok123"); - let result = fetch_issue_statuses(&client, "group/project") - .await - .unwrap(); - - assert_eq!(result.statuses.len(), 1); - assert!(call_count.load(std::sync::atomic::Ordering::SeqCst) >= 2); - } - - #[tokio::test] - async fn test_fetch_statuses_smallest_page_still_fails() { - let server = MockServer::start().await; - - Mock::given(method("POST")) - .and(path("/api/graphql")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "errors": [{"message": "Query has complexity of 9999"}] - }))) - .mount(&server) - .await; - - let client = GraphqlClient::new(&server.uri(), "tok123"); - let err = fetch_issue_statuses(&client, "group/project") - .await - .unwrap_err(); - - assert!( - matches!(err, LoreError::Other(_)), - "Expected error after exhausting all page sizes, got: {err:?}" - ); - } - - #[tokio::test] - async fn test_fetch_statuses_page_size_resets_after_success() { - let server = MockServer::start().await; - let call_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let call_count_clone = call_count.clone(); - - Mock::given(method("POST")) - .and(path("/api/graphql")) - .respond_with(move |_req: &wiremock::Request| { - let n = call_count_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - match n { - 0 => { - // Page 1 at size 100: success, has next page - ResponseTemplate::new(200).set_body_json(make_work_items_page( - &[(1, Some("To do"))], - true, - Some("cursor_p2"), - )) - } - 1 => { - // Page 2 at size 100 (reset): complexity error - ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "errors": [{"message": "Query has complexity of 300"}] - })) - } - 2 => { - // Page 2 retry at size 50: success - ResponseTemplate::new(200).set_body_json(make_work_items_page( - &[(2, Some("Done"))], - false, - None, - )) - } - _ => ResponseTemplate::new(500), - } - }) - .mount(&server) - .await; - - let client = GraphqlClient::new(&server.uri(), "tok123"); - let result = fetch_issue_statuses(&client, "group/project") - .await - .unwrap(); - - assert_eq!(result.statuses.len(), 2); - assert!(result.statuses.contains_key(&1)); - assert!(result.statuses.contains_key(&2)); - assert_eq!(call_count.load(std::sync::atomic::Ordering::SeqCst), 3); - } - - #[tokio::test] - async fn test_fetch_statuses_partial_errors_tracked() { - let server = MockServer::start().await; - - Mock::given(method("POST")) - .and(path("/api/graphql")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "data": { - "project": { - "workItems": { - "nodes": [{"iid": "1", "widgets": [ - {"__typename": "WorkItemWidgetStatus", "status": {"name": "To do"}} - ]}], - "pageInfo": {"endCursor": null, "hasNextPage": false} - } - } - }, - "errors": [{"message": "Rate limit warning: approaching limit"}] - }))) - .mount(&server) - .await; - - let client = GraphqlClient::new(&server.uri(), "tok123"); - let result = fetch_issue_statuses(&client, "group/project") - .await - .unwrap(); - - assert_eq!(result.partial_error_count, 1); - assert_eq!( - result.first_partial_error.as_deref(), - Some("Rate limit warning: approaching limit") - ); - assert_eq!(result.statuses.len(), 1); - } - - #[tokio::test] - async fn test_fetch_statuses_empty_project() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/api/graphql")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "data": { - "project": { - "workItems": { - "nodes": [], - "pageInfo": {"endCursor": null, "hasNextPage": false} - } - } - } - }))) - .mount(&server) - .await; - - let client = GraphqlClient::new(&server.uri(), "tok123"); - let result = fetch_issue_statuses(&client, "group/project") - .await - .unwrap(); - - assert!(result.statuses.is_empty()); - assert!(result.all_fetched_iids.is_empty()); - assert!(result.unsupported_reason.is_none()); - assert_eq!(result.partial_error_count, 0); - } - - #[tokio::test] - async fn test_fetch_statuses_null_status_in_widget() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/api/graphql")) - .respond_with( - ResponseTemplate::new(200).set_body_json(make_null_status_widget_page(42)), - ) - .mount(&server) - .await; - - let client = GraphqlClient::new(&server.uri(), "tok123"); - let result = fetch_issue_statuses(&client, "group/project") - .await - .unwrap(); - - assert!( - result.statuses.is_empty(), - "Null status should not be in map" - ); - assert!( - result.all_fetched_iids.contains(&42), - "IID should still be tracked in all_fetched_iids" - ); - } - - #[tokio::test] - async fn test_fetch_statuses_non_numeric_iid_skipped() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/api/graphql")) - .respond_with( - ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "data": { - "project": { - "workItems": { - "nodes": [ - { - "iid": "not_a_number", - "widgets": [{"__typename": "WorkItemWidgetStatus", "status": {"name": "To do"}}] - }, - { - "iid": "7", - "widgets": [{"__typename": "WorkItemWidgetStatus", "status": {"name": "Done"}}] - } - ], - "pageInfo": {"endCursor": null, "hasNextPage": false} - } - } - } - })), - ) - .mount(&server) - .await; - - let client = GraphqlClient::new(&server.uri(), "tok123"); - let result = fetch_issue_statuses(&client, "group/project") - .await - .unwrap(); - - assert_eq!(result.statuses.len(), 1); - assert!(result.statuses.contains_key(&7)); - assert_eq!(result.all_fetched_iids.len(), 1); - } - - #[tokio::test] - async fn test_fetch_statuses_null_cursor_with_has_next_aborts() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/api/graphql")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "data": { - "project": { - "workItems": { - "nodes": [{"iid": "1", "widgets": []}], - "pageInfo": {"endCursor": null, "hasNextPage": true} - } - } - } - }))) - .mount(&server) - .await; - - let client = GraphqlClient::new(&server.uri(), "tok123"); - let result = fetch_issue_statuses(&client, "group/project") - .await - .unwrap(); - - assert_eq!(result.all_fetched_iids.len(), 1); - } -} +#[path = "graphql_tests.rs"] +mod tests; diff --git a/src/gitlab/graphql_tests.rs b/src/gitlab/graphql_tests.rs new file mode 100644 index 0000000..21e963f --- /dev/null +++ b/src/gitlab/graphql_tests.rs @@ -0,0 +1,923 @@ +use super::*; +use crate::core::error::LoreError; +use wiremock::matchers::{body_json, header, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +// ═══════════════════════════════════════════════════════════════════════ +// AC-1: GraphQL Client +// ═══════════════════════════════════════════════════════════════════════ + +#[tokio::test] +async fn test_graphql_query_success() { + let server = MockServer::start().await; + let response_body = serde_json::json!({ + "data": { "project": { "id": "1" } } + }); + + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(ResponseTemplate::new(200).set_body_json(&response_body)) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "test-token"); + let result = client + .query("{ project { id } }", serde_json::json!({})) + .await + .unwrap(); + + assert_eq!(result.data["project"]["id"], "1"); + assert!(!result.had_partial_errors); + assert!(result.first_partial_error.is_none()); +} + +#[tokio::test] +async fn test_graphql_query_with_errors_no_data() { + let server = MockServer::start().await; + let response_body = serde_json::json!({ + "errors": [{ "message": "Field 'foo' not found" }] + }); + + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(ResponseTemplate::new(200).set_body_json(&response_body)) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "test-token"); + let err = client + .query("{ foo }", serde_json::json!({})) + .await + .unwrap_err(); + + match err { + LoreError::Other(msg) => { + assert!(msg.contains("Field 'foo' not found"), "got: {msg}"); + } + other => panic!("Expected LoreError::Other, got: {other:?}"), + } +} + +#[tokio::test] +async fn test_graphql_auth_uses_bearer() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/graphql")) + .and(header("Authorization", "Bearer my-secret-token")) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({"data": {"ok": true}})), + ) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "my-secret-token"); + let result = client.query("{ ok }", serde_json::json!({})).await; + assert!(result.is_ok()); +} + +#[tokio::test] +async fn test_graphql_401_maps_to_auth_failed() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(ResponseTemplate::new(401)) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "bad-token"); + let err = client + .query("{ me }", serde_json::json!({})) + .await + .unwrap_err(); + + assert!(matches!(err, LoreError::GitLabAuthFailed)); +} + +#[tokio::test] +async fn test_graphql_403_maps_to_auth_failed() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(ResponseTemplate::new(403)) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "forbidden-token"); + let err = client + .query("{ admin }", serde_json::json!({})) + .await + .unwrap_err(); + + assert!(matches!(err, LoreError::GitLabAuthFailed)); +} + +#[tokio::test] +async fn test_graphql_404_maps_to_not_found() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(ResponseTemplate::new(404)) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "token"); + let err = client + .query("{ x }", serde_json::json!({})) + .await + .unwrap_err(); + + match err { + LoreError::GitLabNotFound { resource } => { + assert_eq!(resource, "GraphQL endpoint"); + } + other => panic!("Expected GitLabNotFound, got: {other:?}"), + } +} + +#[tokio::test] +async fn test_graphql_partial_data_with_errors_returns_data() { + let server = MockServer::start().await; + let response_body = serde_json::json!({ + "data": { "project": { "name": "test" } }, + "errors": [{ "message": "Some field failed" }] + }); + + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(ResponseTemplate::new(200).set_body_json(&response_body)) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "token"); + let result = client + .query("{ project { name } }", serde_json::json!({})) + .await + .unwrap(); + + assert_eq!(result.data["project"]["name"], "test"); + assert!(result.had_partial_errors); + assert_eq!( + result.first_partial_error.as_deref(), + Some("Some field failed") + ); +} + +#[tokio::test] +async fn test_retry_after_delta_seconds() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "120")) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "token"); + let err = client + .query("{ x }", serde_json::json!({})) + .await + .unwrap_err(); + + match err { + LoreError::GitLabRateLimited { retry_after } => { + assert_eq!(retry_after, 120); + } + other => panic!("Expected GitLabRateLimited, got: {other:?}"), + } +} + +#[tokio::test] +async fn test_retry_after_http_date_format() { + let server = MockServer::start().await; + + let future = SystemTime::now() + Duration::from_secs(90); + let date_str = httpdate::fmt_http_date(future); + + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(ResponseTemplate::new(429).insert_header("Retry-After", date_str)) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "token"); + let err = client + .query("{ x }", serde_json::json!({})) + .await + .unwrap_err(); + + match err { + LoreError::GitLabRateLimited { retry_after } => { + assert!( + (85..=95).contains(&retry_after), + "retry_after={retry_after}" + ); + } + other => panic!("Expected GitLabRateLimited, got: {other:?}"), + } +} + +#[tokio::test] +async fn test_retry_after_invalid_falls_back_to_60() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "garbage")) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "token"); + let err = client + .query("{ x }", serde_json::json!({})) + .await + .unwrap_err(); + + match err { + LoreError::GitLabRateLimited { retry_after } => { + assert_eq!(retry_after, 60); + } + other => panic!("Expected GitLabRateLimited, got: {other:?}"), + } +} + +#[tokio::test] +async fn test_graphql_network_error() { + let client = GraphqlClient::new("http://127.0.0.1:1", "token"); + let err = client + .query("{ x }", serde_json::json!({})) + .await + .unwrap_err(); + + assert!( + matches!(err, LoreError::GitLabNetworkError { .. }), + "Expected GitLabNetworkError, got: {err:?}" + ); +} + +#[tokio::test] +async fn test_graphql_request_body_format() { + let server = MockServer::start().await; + + let expected_body = serde_json::json!({ + "query": "{ project(fullPath: $path) { id } }", + "variables": { "path": "group/repo" } + }); + + Mock::given(method("POST")) + .and(path("/api/graphql")) + .and(body_json(&expected_body)) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({"data": {"project": {"id": "1"}}})), + ) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "token"); + let result = client + .query( + "{ project(fullPath: $path) { id } }", + serde_json::json!({"path": "group/repo"}), + ) + .await; + + assert!(result.is_ok(), "Body format mismatch: {result:?}"); +} + +#[tokio::test] +async fn test_graphql_base_url_trailing_slash() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({"data": {"ok": true}})), + ) + .mount(&server) + .await; + + let url_with_slash = format!("{}/", server.uri()); + let client = GraphqlClient::new(&url_with_slash, "token"); + let result = client.query("{ ok }", serde_json::json!({})).await; + + assert!(result.is_ok()); +} + +#[tokio::test] +async fn test_graphql_data_null_no_errors() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"data": null}))) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "token"); + let err = client + .query("{ x }", serde_json::json!({})) + .await + .unwrap_err(); + + match err { + LoreError::Other(msg) => { + assert!(msg.contains("missing 'data' field"), "got: {msg}"); + } + other => panic!("Expected LoreError::Other, got: {other:?}"), + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// AC-3: Status Fetcher +// ═══════════════════════════════════════════════════════════════════════ + +/// Helper: build a GraphQL work-items response page with given issues. +fn make_work_items_page( + items: &[(i64, Option<&str>)], + has_next_page: bool, + end_cursor: Option<&str>, +) -> serde_json::Value { + let nodes: Vec = items + .iter() + .map(|(iid, status_name)| { + let mut widgets = vec![serde_json::json!({"__typename": "WorkItemWidgetDescription"})]; + if let Some(name) = status_name { + widgets.push(serde_json::json!({ + "__typename": "WorkItemWidgetStatus", + "status": { + "name": name, + "category": "IN_PROGRESS", + "color": "#1f75cb", + "iconName": "status-in-progress" + } + })); + } + serde_json::json!({ + "iid": iid.to_string(), + "widgets": widgets, + }) + }) + .collect(); + + serde_json::json!({ + "data": { + "project": { + "workItems": { + "nodes": nodes, + "pageInfo": { + "endCursor": end_cursor, + "hasNextPage": has_next_page, + } + } + } + } + }) +} + +/// Helper: build a page where issue has status widget but status is null. +fn make_null_status_widget_page(iid: i64) -> serde_json::Value { + serde_json::json!({ + "data": { + "project": { + "workItems": { + "nodes": [{ + "iid": iid.to_string(), + "widgets": [ + {"__typename": "WorkItemWidgetStatus", "status": null} + ] + }], + "pageInfo": { + "endCursor": null, + "hasNextPage": false, + } + } + } + } + }) +} + +#[tokio::test] +async fn test_fetch_statuses_pagination() { + let server = MockServer::start().await; + + // Page 1: returns cursor "cursor_page2" + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with({ + ResponseTemplate::new(200).set_body_json(make_work_items_page( + &[(1, Some("In progress")), (2, Some("To do"))], + true, + Some("cursor_page2"), + )) + }) + .up_to_n_times(1) + .expect(1) + .mount(&server) + .await; + + // Page 2: no more pages + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with( + ResponseTemplate::new(200).set_body_json(make_work_items_page( + &[(3, Some("Done"))], + false, + None, + )), + ) + .expect(1) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "tok123"); + let result = fetch_issue_statuses(&client, "group/project") + .await + .unwrap(); + + assert_eq!(result.statuses.len(), 3); + assert!(result.statuses.contains_key(&1)); + assert!(result.statuses.contains_key(&2)); + assert!(result.statuses.contains_key(&3)); + assert_eq!(result.all_fetched_iids.len(), 3); + assert!(result.unsupported_reason.is_none()); +} + +#[tokio::test] +async fn test_fetch_statuses_no_status_widget() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "project": { + "workItems": { + "nodes": [{ + "iid": "42", + "widgets": [ + {"__typename": "WorkItemWidgetDescription"}, + {"__typename": "WorkItemWidgetLabels"} + ] + }], + "pageInfo": {"endCursor": null, "hasNextPage": false} + } + } + } + }))) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "tok123"); + let result = fetch_issue_statuses(&client, "group/project") + .await + .unwrap(); + + assert!( + result.statuses.is_empty(), + "No status widget -> no statuses" + ); + assert!( + result.all_fetched_iids.contains(&42), + "IID 42 should still be in all_fetched_iids" + ); +} + +#[tokio::test] +async fn test_fetch_statuses_404_graceful() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(ResponseTemplate::new(404)) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "tok123"); + let result = fetch_issue_statuses(&client, "group/project") + .await + .unwrap(); + + assert!(result.statuses.is_empty()); + assert!(result.all_fetched_iids.is_empty()); + assert!(matches!( + result.unsupported_reason, + Some(UnsupportedReason::GraphqlEndpointMissing) + )); +} + +#[tokio::test] +async fn test_fetch_statuses_403_graceful() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(ResponseTemplate::new(403)) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "tok123"); + let result = fetch_issue_statuses(&client, "group/project") + .await + .unwrap(); + + assert!(result.statuses.is_empty()); + assert!(result.all_fetched_iids.is_empty()); + assert!(matches!( + result.unsupported_reason, + Some(UnsupportedReason::AuthForbidden) + )); +} + +#[tokio::test] +async fn test_fetch_statuses_unsupported_reason_none_on_success() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with( + ResponseTemplate::new(200).set_body_json(make_work_items_page( + &[(1, Some("To do"))], + false, + None, + )), + ) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "tok123"); + let result = fetch_issue_statuses(&client, "group/project") + .await + .unwrap(); + + assert!(result.unsupported_reason.is_none()); +} + +#[tokio::test] +async fn test_typename_matching_ignores_non_status_widgets() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "project": { + "workItems": { + "nodes": [{ + "iid": "10", + "widgets": [ + {"__typename": "WorkItemWidgetDescription"}, + {"__typename": "WorkItemWidgetLabels"}, + {"__typename": "WorkItemWidgetAssignees"}, + { + "__typename": "WorkItemWidgetStatus", + "status": { + "name": "In progress", + "category": "IN_PROGRESS" + } + } + ] + }], + "pageInfo": {"endCursor": null, "hasNextPage": false} + } + } + } + }))) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "tok123"); + let result = fetch_issue_statuses(&client, "group/project") + .await + .unwrap(); + + assert_eq!(result.statuses.len(), 1); + assert_eq!(result.statuses[&10].name, "In progress"); +} + +#[tokio::test] +async fn test_fetch_statuses_cursor_stall_aborts() { + let server = MockServer::start().await; + + let stall_response = serde_json::json!({ + "data": { + "project": { + "workItems": { + "nodes": [{"iid": "1", "widgets": []}], + "pageInfo": {"endCursor": "same_cursor", "hasNextPage": true} + } + } + } + }); + + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(ResponseTemplate::new(200).set_body_json(stall_response)) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "tok123"); + let result = fetch_issue_statuses(&client, "group/project") + .await + .unwrap(); + + assert!( + result.all_fetched_iids.contains(&1), + "Should contain the one IID fetched before stall" + ); +} + +#[tokio::test] +async fn test_fetch_statuses_complexity_error_reduces_page_size() { + let server = MockServer::start().await; + let call_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let call_count_clone = call_count.clone(); + + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(move |_req: &wiremock::Request| { + let n = + call_count_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if n == 0 { + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "errors": [{"message": "Query has complexity of 300, which exceeds max complexity of 250"}] + })) + } else { + ResponseTemplate::new(200).set_body_json(make_work_items_page( + &[(1, Some("In progress"))], + false, + None, + )) + } + }) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "tok123"); + let result = fetch_issue_statuses(&client, "group/project") + .await + .unwrap(); + + assert_eq!(result.statuses.len(), 1); + assert_eq!(result.statuses[&1].name, "In progress"); + assert_eq!(call_count.load(std::sync::atomic::Ordering::SeqCst), 2); +} + +#[tokio::test] +async fn test_fetch_statuses_timeout_error_reduces_page_size() { + let server = MockServer::start().await; + let call_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let call_count_clone = call_count.clone(); + + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(move |_req: &wiremock::Request| { + let n = call_count_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if n == 0 { + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "errors": [{"message": "Query timeout after 30000ms"}] + })) + } else { + ResponseTemplate::new(200).set_body_json(make_work_items_page( + &[(5, Some("Done"))], + false, + None, + )) + } + }) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "tok123"); + let result = fetch_issue_statuses(&client, "group/project") + .await + .unwrap(); + + assert_eq!(result.statuses.len(), 1); + assert!(call_count.load(std::sync::atomic::Ordering::SeqCst) >= 2); +} + +#[tokio::test] +async fn test_fetch_statuses_smallest_page_still_fails() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "errors": [{"message": "Query has complexity of 9999"}] + }))) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "tok123"); + let err = fetch_issue_statuses(&client, "group/project") + .await + .unwrap_err(); + + assert!( + matches!(err, LoreError::Other(_)), + "Expected error after exhausting all page sizes, got: {err:?}" + ); +} + +#[tokio::test] +async fn test_fetch_statuses_page_size_resets_after_success() { + let server = MockServer::start().await; + let call_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let call_count_clone = call_count.clone(); + + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(move |_req: &wiremock::Request| { + let n = call_count_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + match n { + 0 => { + // Page 1 at size 100: success, has next page + ResponseTemplate::new(200).set_body_json(make_work_items_page( + &[(1, Some("To do"))], + true, + Some("cursor_p2"), + )) + } + 1 => { + // Page 2 at size 100 (reset): complexity error + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "errors": [{"message": "Query has complexity of 300"}] + })) + } + 2 => { + // Page 2 retry at size 50: success + ResponseTemplate::new(200).set_body_json(make_work_items_page( + &[(2, Some("Done"))], + false, + None, + )) + } + _ => ResponseTemplate::new(500), + } + }) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "tok123"); + let result = fetch_issue_statuses(&client, "group/project") + .await + .unwrap(); + + assert_eq!(result.statuses.len(), 2); + assert!(result.statuses.contains_key(&1)); + assert!(result.statuses.contains_key(&2)); + assert_eq!(call_count.load(std::sync::atomic::Ordering::SeqCst), 3); +} + +#[tokio::test] +async fn test_fetch_statuses_partial_errors_tracked() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "project": { + "workItems": { + "nodes": [{"iid": "1", "widgets": [ + {"__typename": "WorkItemWidgetStatus", "status": {"name": "To do"}} + ]}], + "pageInfo": {"endCursor": null, "hasNextPage": false} + } + } + }, + "errors": [{"message": "Rate limit warning: approaching limit"}] + }))) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "tok123"); + let result = fetch_issue_statuses(&client, "group/project") + .await + .unwrap(); + + assert_eq!(result.partial_error_count, 1); + assert_eq!( + result.first_partial_error.as_deref(), + Some("Rate limit warning: approaching limit") + ); + assert_eq!(result.statuses.len(), 1); +} + +#[tokio::test] +async fn test_fetch_statuses_empty_project() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "project": { + "workItems": { + "nodes": [], + "pageInfo": {"endCursor": null, "hasNextPage": false} + } + } + } + }))) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "tok123"); + let result = fetch_issue_statuses(&client, "group/project") + .await + .unwrap(); + + assert!(result.statuses.is_empty()); + assert!(result.all_fetched_iids.is_empty()); + assert!(result.unsupported_reason.is_none()); + assert_eq!(result.partial_error_count, 0); +} + +#[tokio::test] +async fn test_fetch_statuses_null_status_in_widget() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(ResponseTemplate::new(200).set_body_json(make_null_status_widget_page(42))) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "tok123"); + let result = fetch_issue_statuses(&client, "group/project") + .await + .unwrap(); + + assert!( + result.statuses.is_empty(), + "Null status should not be in map" + ); + assert!( + result.all_fetched_iids.contains(&42), + "IID should still be tracked in all_fetched_iids" + ); +} + +#[tokio::test] +async fn test_fetch_statuses_non_numeric_iid_skipped() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "project": { + "workItems": { + "nodes": [ + { + "iid": "not_a_number", + "widgets": [{"__typename": "WorkItemWidgetStatus", "status": {"name": "To do"}}] + }, + { + "iid": "7", + "widgets": [{"__typename": "WorkItemWidgetStatus", "status": {"name": "Done"}}] + } + ], + "pageInfo": {"endCursor": null, "hasNextPage": false} + } + } + } + })), + ) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "tok123"); + let result = fetch_issue_statuses(&client, "group/project") + .await + .unwrap(); + + assert_eq!(result.statuses.len(), 1); + assert!(result.statuses.contains_key(&7)); + assert_eq!(result.all_fetched_iids.len(), 1); +} + +#[tokio::test] +async fn test_fetch_statuses_null_cursor_with_has_next_aborts() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/api/graphql")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": { + "project": { + "workItems": { + "nodes": [{"iid": "1", "widgets": []}], + "pageInfo": {"endCursor": null, "hasNextPage": true} + } + } + } + }))) + .mount(&server) + .await; + + let client = GraphqlClient::new(&server.uri(), "tok123"); + let result = fetch_issue_statuses(&client, "group/project") + .await + .unwrap(); + + assert_eq!(result.all_fetched_iids.len(), 1); +} diff --git a/src/gitlab/transformers/issue.rs b/src/gitlab/transformers/issue.rs index fc6d28d..ec4d940 100644 --- a/src/gitlab/transformers/issue.rs +++ b/src/gitlab/transformers/issue.rs @@ -93,170 +93,5 @@ pub fn transform_issue(issue: &GitLabIssue) -> Result GitLabIssue { - GitLabIssue { - id: 12345, - iid: 42, - project_id: 100, - title: "Test issue".to_string(), - description: Some("Description here".to_string()), - state: "opened".to_string(), - created_at: "2024-01-15T10:00:00.000Z".to_string(), - updated_at: "2024-01-20T15:30:00.000Z".to_string(), - closed_at: None, - author: GitLabAuthor { - id: 1, - username: "testuser".to_string(), - name: "Test User".to_string(), - }, - assignees: vec![], - labels: vec!["bug".to_string(), "priority::high".to_string()], - milestone: None, - due_date: None, - web_url: "https://gitlab.example.com/group/project/-/issues/42".to_string(), - } - } - - #[test] - fn transforms_issue_with_all_fields() { - let issue = make_test_issue(); - let result = transform_issue(&issue).unwrap(); - - assert_eq!(result.issue.gitlab_id, 12345); - assert_eq!(result.issue.iid, 42); - assert_eq!(result.issue.project_id, 100); - assert_eq!(result.issue.title, "Test issue"); - assert_eq!( - result.issue.description, - Some("Description here".to_string()) - ); - assert_eq!(result.issue.state, "opened"); - assert_eq!(result.issue.author_username, "testuser"); - assert_eq!( - result.issue.web_url, - "https://gitlab.example.com/group/project/-/issues/42" - ); - } - - #[test] - fn handles_missing_description() { - let mut issue = make_test_issue(); - issue.description = None; - - let result = transform_issue(&issue).unwrap(); - assert!(result.issue.description.is_none()); - } - - #[test] - fn extracts_label_names() { - let issue = make_test_issue(); - let result = transform_issue(&issue).unwrap(); - - assert_eq!(result.label_names.len(), 2); - assert_eq!(result.label_names[0], "bug"); - assert_eq!(result.label_names[1], "priority::high"); - } - - #[test] - fn handles_empty_labels() { - let mut issue = make_test_issue(); - issue.labels = vec![]; - - let result = transform_issue(&issue).unwrap(); - assert!(result.label_names.is_empty()); - } - - #[test] - fn parses_timestamps_to_ms_epoch() { - let issue = make_test_issue(); - let result = transform_issue(&issue).unwrap(); - - assert_eq!(result.issue.created_at, 1705312800000); - assert_eq!(result.issue.updated_at, 1705764600000); - } - - #[test] - fn handles_timezone_offset_timestamps() { - let mut issue = make_test_issue(); - issue.created_at = "2024-01-15T05:00:00-05:00".to_string(); - - let result = transform_issue(&issue).unwrap(); - assert_eq!(result.issue.created_at, 1705312800000); - } - - #[test] - fn extracts_assignee_usernames() { - let mut issue = make_test_issue(); - issue.assignees = vec![ - GitLabAuthor { - id: 2, - username: "alice".to_string(), - name: "Alice".to_string(), - }, - GitLabAuthor { - id: 3, - username: "bob".to_string(), - name: "Bob".to_string(), - }, - ]; - - let result = transform_issue(&issue).unwrap(); - assert_eq!(result.assignee_usernames.len(), 2); - assert_eq!(result.assignee_usernames[0], "alice"); - assert_eq!(result.assignee_usernames[1], "bob"); - } - - #[test] - fn extracts_milestone_info() { - let mut issue = make_test_issue(); - issue.milestone = Some(GitLabMilestone { - id: 500, - iid: 5, - project_id: Some(100), - title: "v1.0".to_string(), - description: Some("First release".to_string()), - state: Some("active".to_string()), - due_date: Some("2024-02-01".to_string()), - web_url: Some("https://gitlab.example.com/-/milestones/5".to_string()), - }); - - let result = transform_issue(&issue).unwrap(); - - assert_eq!(result.issue.milestone_title, Some("v1.0".to_string())); - - let milestone = result.milestone.expect("should have milestone"); - assert_eq!(milestone.gitlab_id, 500); - assert_eq!(milestone.iid, 5); - assert_eq!(milestone.project_id, 100); - assert_eq!(milestone.title, "v1.0"); - assert_eq!(milestone.description, Some("First release".to_string())); - assert_eq!(milestone.state, Some("active".to_string())); - assert_eq!(milestone.due_date, Some("2024-02-01".to_string())); - assert_eq!( - milestone.web_url, - Some("https://gitlab.example.com/-/milestones/5".to_string()) - ); - } - - #[test] - fn handles_missing_milestone() { - let issue = make_test_issue(); - let result = transform_issue(&issue).unwrap(); - - assert!(result.issue.milestone_title.is_none()); - assert!(result.milestone.is_none()); - } - - #[test] - fn extracts_due_date() { - let mut issue = make_test_issue(); - issue.due_date = Some("2024-02-15".to_string()); - - let result = transform_issue(&issue).unwrap(); - assert_eq!(result.issue.due_date, Some("2024-02-15".to_string())); - } -} +#[path = "issue_tests.rs"] +mod tests; diff --git a/src/gitlab/transformers/issue_tests.rs b/src/gitlab/transformers/issue_tests.rs new file mode 100644 index 0000000..138271f --- /dev/null +++ b/src/gitlab/transformers/issue_tests.rs @@ -0,0 +1,165 @@ +use super::*; +use crate::gitlab::types::{GitLabAuthor, GitLabMilestone}; + +fn make_test_issue() -> GitLabIssue { + GitLabIssue { + id: 12345, + iid: 42, + project_id: 100, + title: "Test issue".to_string(), + description: Some("Description here".to_string()), + state: "opened".to_string(), + created_at: "2024-01-15T10:00:00.000Z".to_string(), + updated_at: "2024-01-20T15:30:00.000Z".to_string(), + closed_at: None, + author: GitLabAuthor { + id: 1, + username: "testuser".to_string(), + name: "Test User".to_string(), + }, + assignees: vec![], + labels: vec!["bug".to_string(), "priority::high".to_string()], + milestone: None, + due_date: None, + web_url: "https://gitlab.example.com/group/project/-/issues/42".to_string(), + } +} + +#[test] +fn transforms_issue_with_all_fields() { + let issue = make_test_issue(); + let result = transform_issue(&issue).unwrap(); + + assert_eq!(result.issue.gitlab_id, 12345); + assert_eq!(result.issue.iid, 42); + assert_eq!(result.issue.project_id, 100); + assert_eq!(result.issue.title, "Test issue"); + assert_eq!( + result.issue.description, + Some("Description here".to_string()) + ); + assert_eq!(result.issue.state, "opened"); + assert_eq!(result.issue.author_username, "testuser"); + assert_eq!( + result.issue.web_url, + "https://gitlab.example.com/group/project/-/issues/42" + ); +} + +#[test] +fn handles_missing_description() { + let mut issue = make_test_issue(); + issue.description = None; + + let result = transform_issue(&issue).unwrap(); + assert!(result.issue.description.is_none()); +} + +#[test] +fn extracts_label_names() { + let issue = make_test_issue(); + let result = transform_issue(&issue).unwrap(); + + assert_eq!(result.label_names.len(), 2); + assert_eq!(result.label_names[0], "bug"); + assert_eq!(result.label_names[1], "priority::high"); +} + +#[test] +fn handles_empty_labels() { + let mut issue = make_test_issue(); + issue.labels = vec![]; + + let result = transform_issue(&issue).unwrap(); + assert!(result.label_names.is_empty()); +} + +#[test] +fn parses_timestamps_to_ms_epoch() { + let issue = make_test_issue(); + let result = transform_issue(&issue).unwrap(); + + assert_eq!(result.issue.created_at, 1705312800000); + assert_eq!(result.issue.updated_at, 1705764600000); +} + +#[test] +fn handles_timezone_offset_timestamps() { + let mut issue = make_test_issue(); + issue.created_at = "2024-01-15T05:00:00-05:00".to_string(); + + let result = transform_issue(&issue).unwrap(); + assert_eq!(result.issue.created_at, 1705312800000); +} + +#[test] +fn extracts_assignee_usernames() { + let mut issue = make_test_issue(); + issue.assignees = vec![ + GitLabAuthor { + id: 2, + username: "alice".to_string(), + name: "Alice".to_string(), + }, + GitLabAuthor { + id: 3, + username: "bob".to_string(), + name: "Bob".to_string(), + }, + ]; + + let result = transform_issue(&issue).unwrap(); + assert_eq!(result.assignee_usernames.len(), 2); + assert_eq!(result.assignee_usernames[0], "alice"); + assert_eq!(result.assignee_usernames[1], "bob"); +} + +#[test] +fn extracts_milestone_info() { + let mut issue = make_test_issue(); + issue.milestone = Some(GitLabMilestone { + id: 500, + iid: 5, + project_id: Some(100), + title: "v1.0".to_string(), + description: Some("First release".to_string()), + state: Some("active".to_string()), + due_date: Some("2024-02-01".to_string()), + web_url: Some("https://gitlab.example.com/-/milestones/5".to_string()), + }); + + let result = transform_issue(&issue).unwrap(); + + assert_eq!(result.issue.milestone_title, Some("v1.0".to_string())); + + let milestone = result.milestone.expect("should have milestone"); + assert_eq!(milestone.gitlab_id, 500); + assert_eq!(milestone.iid, 5); + assert_eq!(milestone.project_id, 100); + assert_eq!(milestone.title, "v1.0"); + assert_eq!(milestone.description, Some("First release".to_string())); + assert_eq!(milestone.state, Some("active".to_string())); + assert_eq!(milestone.due_date, Some("2024-02-01".to_string())); + assert_eq!( + milestone.web_url, + Some("https://gitlab.example.com/-/milestones/5".to_string()) + ); +} + +#[test] +fn handles_missing_milestone() { + let issue = make_test_issue(); + let result = transform_issue(&issue).unwrap(); + + assert!(result.issue.milestone_title.is_none()); + assert!(result.milestone.is_none()); +} + +#[test] +fn extracts_due_date() { + let mut issue = make_test_issue(); + issue.due_date = Some("2024-02-15".to_string()); + + let result = transform_issue(&issue).unwrap(); + assert_eq!(result.issue.due_date, Some("2024-02-15".to_string())); +} diff --git a/src/ingestion/dirty_tracker.rs b/src/ingestion/dirty_tracker.rs index 3a9331a..8c2c9f9 100644 --- a/src/ingestion/dirty_tracker.rs +++ b/src/ingestion/dirty_tracker.rs @@ -124,173 +124,5 @@ pub fn record_dirty_error( } #[cfg(test)] -mod tests { - use super::*; - - fn setup_db() -> Connection { - let conn = Connection::open_in_memory().unwrap(); - conn.execute_batch(" - CREATE TABLE dirty_sources ( - source_type TEXT NOT NULL CHECK (source_type IN ('issue','merge_request','discussion','note')), - source_id INTEGER NOT NULL, - queued_at INTEGER NOT NULL, - attempt_count INTEGER NOT NULL DEFAULT 0, - last_attempt_at INTEGER, - last_error TEXT, - next_attempt_at INTEGER, - PRIMARY KEY(source_type, source_id) - ); - CREATE INDEX idx_dirty_sources_next_attempt ON dirty_sources(next_attempt_at); - ").unwrap(); - conn - } - - #[test] - fn test_mark_dirty_inserts() { - let conn = setup_db(); - mark_dirty(&conn, SourceType::Issue, 1).unwrap(); - - let count: i64 = conn - .query_row("SELECT COUNT(*) FROM dirty_sources", [], |r| r.get(0)) - .unwrap(); - assert_eq!(count, 1); - } - - #[test] - fn test_mark_dirty_tx_inserts() { - let mut conn = setup_db(); - { - let tx = conn.transaction().unwrap(); - mark_dirty_tx(&tx, SourceType::Issue, 1).unwrap(); - tx.commit().unwrap(); - } - let count: i64 = conn - .query_row("SELECT COUNT(*) FROM dirty_sources", [], |r| r.get(0)) - .unwrap(); - assert_eq!(count, 1); - } - - #[test] - fn test_requeue_resets_backoff() { - let conn = setup_db(); - mark_dirty(&conn, SourceType::Issue, 1).unwrap(); - record_dirty_error(&conn, SourceType::Issue, 1, "test error").unwrap(); - - let attempt: i64 = conn - .query_row( - "SELECT attempt_count FROM dirty_sources WHERE source_id = 1", - [], - |r| r.get(0), - ) - .unwrap(); - assert_eq!(attempt, 1); - - mark_dirty(&conn, SourceType::Issue, 1).unwrap(); - let attempt: i64 = conn - .query_row( - "SELECT attempt_count FROM dirty_sources WHERE source_id = 1", - [], - |r| r.get(0), - ) - .unwrap(); - assert_eq!(attempt, 0); - - let next_at: Option = conn - .query_row( - "SELECT next_attempt_at FROM dirty_sources WHERE source_id = 1", - [], - |r| r.get(0), - ) - .unwrap(); - assert!(next_at.is_none()); - } - - #[test] - fn test_get_respects_backoff() { - let conn = setup_db(); - mark_dirty(&conn, SourceType::Issue, 1).unwrap(); - conn.execute( - "UPDATE dirty_sources SET next_attempt_at = 9999999999999 WHERE source_id = 1", - [], - ) - .unwrap(); - - let results = get_dirty_sources(&conn).unwrap(); - assert!(results.is_empty()); - } - - #[test] - fn test_get_orders_by_attempt_count() { - let conn = setup_db(); - mark_dirty(&conn, SourceType::Issue, 1).unwrap(); - conn.execute( - "UPDATE dirty_sources SET attempt_count = 2 WHERE source_id = 1", - [], - ) - .unwrap(); - mark_dirty(&conn, SourceType::Issue, 2).unwrap(); - - let results = get_dirty_sources(&conn).unwrap(); - assert_eq!(results.len(), 2); - assert_eq!(results[0].1, 2); - assert_eq!(results[1].1, 1); - } - - #[test] - fn test_batch_size_500() { - let conn = setup_db(); - for i in 0..600 { - mark_dirty(&conn, SourceType::Issue, i).unwrap(); - } - let results = get_dirty_sources(&conn).unwrap(); - assert_eq!(results.len(), 500); - } - - #[test] - fn test_clear_removes() { - let conn = setup_db(); - mark_dirty(&conn, SourceType::Issue, 1).unwrap(); - clear_dirty(&conn, SourceType::Issue, 1).unwrap(); - - let count: i64 = conn - .query_row("SELECT COUNT(*) FROM dirty_sources", [], |r| r.get(0)) - .unwrap(); - assert_eq!(count, 0); - } - - #[test] - fn test_mark_dirty_note_type() { - let conn = setup_db(); - mark_dirty(&conn, SourceType::Note, 42).unwrap(); - - let results = get_dirty_sources(&conn).unwrap(); - assert_eq!(results.len(), 1); - assert_eq!(results[0].0, SourceType::Note); - assert_eq!(results[0].1, 42); - - clear_dirty(&conn, SourceType::Note, 42).unwrap(); - let results = get_dirty_sources(&conn).unwrap(); - assert!(results.is_empty()); - } - - #[test] - fn test_drain_loop() { - let conn = setup_db(); - for i in 0..1200 { - mark_dirty(&conn, SourceType::Issue, i).unwrap(); - } - - let mut total = 0; - loop { - let batch = get_dirty_sources(&conn).unwrap(); - if batch.is_empty() { - break; - } - for (st, id) in &batch { - clear_dirty(&conn, *st, *id).unwrap(); - } - total += batch.len(); - } - assert_eq!(total, 1200); - } -} +#[path = "dirty_tracker_tests.rs"] +mod tests; diff --git a/src/ingestion/dirty_tracker_tests.rs b/src/ingestion/dirty_tracker_tests.rs new file mode 100644 index 0000000..ef4e449 --- /dev/null +++ b/src/ingestion/dirty_tracker_tests.rs @@ -0,0 +1,168 @@ +use super::*; + +fn setup_db() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch(" + CREATE TABLE dirty_sources ( + source_type TEXT NOT NULL CHECK (source_type IN ('issue','merge_request','discussion','note')), + source_id INTEGER NOT NULL, + queued_at INTEGER NOT NULL, + attempt_count INTEGER NOT NULL DEFAULT 0, + last_attempt_at INTEGER, + last_error TEXT, + next_attempt_at INTEGER, + PRIMARY KEY(source_type, source_id) + ); + CREATE INDEX idx_dirty_sources_next_attempt ON dirty_sources(next_attempt_at); + ").unwrap(); + conn +} + +#[test] +fn test_mark_dirty_inserts() { + let conn = setup_db(); + mark_dirty(&conn, SourceType::Issue, 1).unwrap(); + + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM dirty_sources", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 1); +} + +#[test] +fn test_mark_dirty_tx_inserts() { + let mut conn = setup_db(); + { + let tx = conn.transaction().unwrap(); + mark_dirty_tx(&tx, SourceType::Issue, 1).unwrap(); + tx.commit().unwrap(); + } + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM dirty_sources", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 1); +} + +#[test] +fn test_requeue_resets_backoff() { + let conn = setup_db(); + mark_dirty(&conn, SourceType::Issue, 1).unwrap(); + record_dirty_error(&conn, SourceType::Issue, 1, "test error").unwrap(); + + let attempt: i64 = conn + .query_row( + "SELECT attempt_count FROM dirty_sources WHERE source_id = 1", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(attempt, 1); + + mark_dirty(&conn, SourceType::Issue, 1).unwrap(); + let attempt: i64 = conn + .query_row( + "SELECT attempt_count FROM dirty_sources WHERE source_id = 1", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(attempt, 0); + + let next_at: Option = conn + .query_row( + "SELECT next_attempt_at FROM dirty_sources WHERE source_id = 1", + [], + |r| r.get(0), + ) + .unwrap(); + assert!(next_at.is_none()); +} + +#[test] +fn test_get_respects_backoff() { + let conn = setup_db(); + mark_dirty(&conn, SourceType::Issue, 1).unwrap(); + conn.execute( + "UPDATE dirty_sources SET next_attempt_at = 9999999999999 WHERE source_id = 1", + [], + ) + .unwrap(); + + let results = get_dirty_sources(&conn).unwrap(); + assert!(results.is_empty()); +} + +#[test] +fn test_get_orders_by_attempt_count() { + let conn = setup_db(); + mark_dirty(&conn, SourceType::Issue, 1).unwrap(); + conn.execute( + "UPDATE dirty_sources SET attempt_count = 2 WHERE source_id = 1", + [], + ) + .unwrap(); + mark_dirty(&conn, SourceType::Issue, 2).unwrap(); + + let results = get_dirty_sources(&conn).unwrap(); + assert_eq!(results.len(), 2); + assert_eq!(results[0].1, 2); + assert_eq!(results[1].1, 1); +} + +#[test] +fn test_batch_size_500() { + let conn = setup_db(); + for i in 0..600 { + mark_dirty(&conn, SourceType::Issue, i).unwrap(); + } + let results = get_dirty_sources(&conn).unwrap(); + assert_eq!(results.len(), 500); +} + +#[test] +fn test_clear_removes() { + let conn = setup_db(); + mark_dirty(&conn, SourceType::Issue, 1).unwrap(); + clear_dirty(&conn, SourceType::Issue, 1).unwrap(); + + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM dirty_sources", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 0); +} + +#[test] +fn test_mark_dirty_note_type() { + let conn = setup_db(); + mark_dirty(&conn, SourceType::Note, 42).unwrap(); + + let results = get_dirty_sources(&conn).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].0, SourceType::Note); + assert_eq!(results[0].1, 42); + + clear_dirty(&conn, SourceType::Note, 42).unwrap(); + let results = get_dirty_sources(&conn).unwrap(); + assert!(results.is_empty()); +} + +#[test] +fn test_drain_loop() { + let conn = setup_db(); + for i in 0..1200 { + mark_dirty(&conn, SourceType::Issue, i).unwrap(); + } + + let mut total = 0; + loop { + let batch = get_dirty_sources(&conn).unwrap(); + if batch.is_empty() { + break; + } + for (st, id) in &batch { + clear_dirty(&conn, *st, *id).unwrap(); + } + total += batch.len(); + } + assert_eq!(total, 1200); +} diff --git a/src/ingestion/discussions.rs b/src/ingestion/discussions.rs index b55ea74..40d13dd 100644 --- a/src/ingestion/discussions.rs +++ b/src/ingestion/discussions.rs @@ -467,475 +467,5 @@ fn update_issue_sync_timestamp(conn: &Connection, issue_id: i64, updated_at: i64 } #[cfg(test)] -mod tests { - use super::*; - use crate::core::db::{create_connection, run_migrations}; - use crate::gitlab::transformers::NormalizedNote; - use std::path::Path; - - #[test] - fn result_default_has_zero_counts() { - let result = IngestDiscussionsResult::default(); - assert_eq!(result.discussions_fetched, 0); - assert_eq!(result.discussions_upserted, 0); - assert_eq!(result.notes_upserted, 0); - } - - fn setup() -> Connection { - let conn = create_connection(Path::new(":memory:")).unwrap(); - run_migrations(&conn).unwrap(); - - conn.execute( - "INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url) \ - VALUES (1, 'group/repo', 'https://gitlab.com/group/repo')", - [], - ) - .unwrap(); - - conn.execute( - "INSERT INTO issues (gitlab_id, iid, project_id, title, state, author_username, created_at, updated_at, last_seen_at) \ - VALUES (100, 1, 1, 'Test Issue', 'opened', 'testuser', 1000, 2000, 3000)", - [], - ) - .unwrap(); - - conn.execute( - "INSERT INTO discussions (gitlab_discussion_id, project_id, issue_id, noteable_type, individual_note, last_seen_at, resolvable, resolved) \ - VALUES ('disc-1', 1, 1, 'Issue', 0, 3000, 0, 0)", - [], - ) - .unwrap(); - - conn - } - - fn get_discussion_id(conn: &Connection) -> i64 { - conn.query_row("SELECT id FROM discussions LIMIT 1", [], |row| row.get(0)) - .unwrap() - } - - #[allow(clippy::too_many_arguments)] - fn make_note( - gitlab_id: i64, - project_id: i64, - body: &str, - note_type: Option<&str>, - created_at: i64, - updated_at: i64, - resolved: bool, - resolved_by: Option<&str>, - ) -> NormalizedNote { - NormalizedNote { - gitlab_id, - project_id, - note_type: note_type.map(String::from), - is_system: false, - author_id: None, - author_username: "testuser".to_string(), - body: body.to_string(), - created_at, - updated_at, - last_seen_at: updated_at, - position: 0, - resolvable: false, - resolved, - resolved_by: resolved_by.map(String::from), - resolved_at: None, - position_old_path: None, - position_new_path: None, - position_old_line: None, - position_new_line: None, - position_type: None, - position_line_range_start: None, - position_line_range_end: None, - position_base_sha: None, - position_start_sha: None, - position_head_sha: None, - } - } - - #[test] - fn test_issue_note_upsert_stable_id() { - let conn = setup(); - let disc_id = get_discussion_id(&conn); - let last_seen_at = 5000; - - let note1 = make_note(1001, 1, "First note", None, 1000, 2000, false, None); - let note2 = make_note(1002, 1, "Second note", None, 1000, 2000, false, None); - - let out1 = upsert_note_for_issue(&conn, disc_id, ¬e1, last_seen_at, None).unwrap(); - let out2 = upsert_note_for_issue(&conn, disc_id, ¬e2, last_seen_at, None).unwrap(); - let id1 = out1.local_note_id; - let id2 = out2.local_note_id; - - // Re-sync same gitlab_ids - let out1b = upsert_note_for_issue(&conn, disc_id, ¬e1, last_seen_at + 1, None).unwrap(); - let out2b = upsert_note_for_issue(&conn, disc_id, ¬e2, last_seen_at + 1, None).unwrap(); - - assert_eq!(id1, out1b.local_note_id); - assert_eq!(id2, out2b.local_note_id); - } - - #[test] - fn test_issue_note_upsert_detects_body_change() { - let conn = setup(); - let disc_id = get_discussion_id(&conn); - - let note = make_note(2001, 1, "Original body", None, 1000, 2000, false, None); - upsert_note_for_issue(&conn, disc_id, ¬e, 5000, None).unwrap(); - - let mut changed = make_note(2001, 1, "Updated body", None, 1000, 3000, false, None); - changed.updated_at = 3000; - let outcome = upsert_note_for_issue(&conn, disc_id, &changed, 5001, None).unwrap(); - assert!(outcome.changed_semantics); - } - - #[test] - fn test_issue_note_upsert_unchanged_returns_false() { - let conn = setup(); - let disc_id = get_discussion_id(&conn); - - let note = make_note(3001, 1, "Same body", None, 1000, 2000, false, None); - upsert_note_for_issue(&conn, disc_id, ¬e, 5000, None).unwrap(); - - // Re-sync identical note - let outcome = upsert_note_for_issue(&conn, disc_id, ¬e, 5001, None).unwrap(); - assert!(!outcome.changed_semantics); - } - - #[test] - fn test_issue_note_upsert_updated_at_only_does_not_mark_semantic_change() { - let conn = setup(); - let disc_id = get_discussion_id(&conn); - - let note = make_note(4001, 1, "Body stays", None, 1000, 2000, false, None); - upsert_note_for_issue(&conn, disc_id, ¬e, 5000, None).unwrap(); - - // Only change updated_at (non-semantic field) - let mut same = make_note(4001, 1, "Body stays", None, 1000, 9999, false, None); - same.updated_at = 9999; - let outcome = upsert_note_for_issue(&conn, disc_id, &same, 5001, None).unwrap(); - assert!(!outcome.changed_semantics); - } - - #[test] - fn test_issue_note_sweep_removes_stale() { - let conn = setup(); - let disc_id = get_discussion_id(&conn); - - let note1 = make_note(5001, 1, "Keep me", None, 1000, 2000, false, None); - let note2 = make_note(5002, 1, "Stale me", None, 1000, 2000, false, None); - - upsert_note_for_issue(&conn, disc_id, ¬e1, 5000, None).unwrap(); - upsert_note_for_issue(&conn, disc_id, ¬e2, 5000, None).unwrap(); - - // Re-sync only note1 with newer timestamp - upsert_note_for_issue(&conn, disc_id, ¬e1, 6000, None).unwrap(); - - // Sweep should remove note2 (last_seen_at=5000 < 6000) - let swept = sweep_stale_issue_notes(&conn, disc_id, 6000).unwrap(); - assert_eq!(swept, 1); - - let count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM notes WHERE discussion_id = ?", - [disc_id], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(count, 1); - } - - #[test] - fn test_issue_note_upsert_returns_local_id() { - let conn = setup(); - let disc_id = get_discussion_id(&conn); - - let note = make_note(6001, 1, "Check my ID", None, 1000, 2000, false, None); - let outcome = upsert_note_for_issue(&conn, disc_id, ¬e, 5000, None).unwrap(); - - // Verify the local_note_id matches what's in the DB - let db_id: i64 = conn - .query_row( - "SELECT id FROM notes WHERE gitlab_id = ?", - [6001_i64], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(outcome.local_note_id, db_id); - } - - #[test] - fn test_issue_note_upsert_captures_author_id() { - let conn = setup(); - let disc_id = get_discussion_id(&conn); - - let mut note = make_note(7001, 1, "With author", None, 1000, 2000, false, None); - note.author_id = Some(12345); - - upsert_note_for_issue(&conn, disc_id, ¬e, 5000, None).unwrap(); - - let stored: Option = conn - .query_row( - "SELECT author_id FROM notes WHERE gitlab_id = ?", - [7001_i64], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(stored, Some(12345)); - } - - #[test] - fn test_note_upsert_author_id_nullable() { - let conn = setup(); - let disc_id = get_discussion_id(&conn); - - let note = make_note(7002, 1, "No author id", None, 1000, 2000, false, None); - // author_id defaults to None in make_note - - upsert_note_for_issue(&conn, disc_id, ¬e, 5000, None).unwrap(); - - let stored: Option = conn - .query_row( - "SELECT author_id FROM notes WHERE gitlab_id = ?", - [7002_i64], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(stored, None); - } - - #[test] - fn test_note_author_id_survives_username_change() { - let conn = setup(); - let disc_id = get_discussion_id(&conn); - - let mut note = make_note(7003, 1, "Original body", None, 1000, 2000, false, None); - note.author_id = Some(99999); - note.author_username = "oldname".to_string(); - - upsert_note_for_issue(&conn, disc_id, ¬e, 5000, None).unwrap(); - - // Re-sync with changed username, changed body, same author_id - let mut updated = make_note(7003, 1, "Updated body", None, 1000, 3000, false, None); - updated.author_id = Some(99999); - updated.author_username = "newname".to_string(); - - upsert_note_for_issue(&conn, disc_id, &updated, 5001, None).unwrap(); - - // author_id must survive the re-sync intact - let stored_id: Option = conn - .query_row( - "SELECT author_id FROM notes WHERE gitlab_id = ?", - [7003_i64], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(stored_id, Some(99999)); - } - - fn insert_note_document(conn: &Connection, note_local_id: i64) { - conn.execute( - "INSERT INTO documents (source_type, source_id, project_id, content_text, content_hash) \ - VALUES ('note', ?1, 1, 'note content', 'hash123')", - [note_local_id], - ) - .unwrap(); - } - - fn insert_note_dirty_source(conn: &Connection, note_local_id: i64) { - conn.execute( - "INSERT INTO dirty_sources (source_type, source_id, queued_at) \ - VALUES ('note', ?1, 1000)", - [note_local_id], - ) - .unwrap(); - } - - fn count_note_documents(conn: &Connection, note_local_id: i64) -> i64 { - conn.query_row( - "SELECT COUNT(*) FROM documents WHERE source_type = 'note' AND source_id = ?", - [note_local_id], - |row| row.get(0), - ) - .unwrap() - } - - fn count_note_dirty_sources(conn: &Connection, note_local_id: i64) -> i64 { - conn.query_row( - "SELECT COUNT(*) FROM dirty_sources WHERE source_type = 'note' AND source_id = ?", - [note_local_id], - |row| row.get(0), - ) - .unwrap() - } - - #[test] - fn test_issue_note_sweep_deletes_note_documents_immediately() { - let conn = setup(); - let disc_id = get_discussion_id(&conn); - - // Insert 3 notes - let note1 = make_note(9001, 1, "Keep me", None, 1000, 2000, false, None); - let note2 = make_note(9002, 1, "Keep me too", None, 1000, 2000, false, None); - let note3 = make_note(9003, 1, "Stale me", None, 1000, 2000, false, None); - - let out1 = upsert_note_for_issue(&conn, disc_id, ¬e1, 5000, None).unwrap(); - let out2 = upsert_note_for_issue(&conn, disc_id, ¬e2, 5000, None).unwrap(); - let out3 = upsert_note_for_issue(&conn, disc_id, ¬e3, 5000, None).unwrap(); - - // Add documents for all 3 - insert_note_document(&conn, out1.local_note_id); - insert_note_document(&conn, out2.local_note_id); - insert_note_document(&conn, out3.local_note_id); - - // Add dirty_sources for note3 - insert_note_dirty_source(&conn, out3.local_note_id); - - // Re-sync only notes 1 and 2 with newer timestamp - upsert_note_for_issue(&conn, disc_id, ¬e1, 6000, None).unwrap(); - upsert_note_for_issue(&conn, disc_id, ¬e2, 6000, None).unwrap(); - - // Sweep should remove note3 and its document + dirty_source - sweep_stale_issue_notes(&conn, disc_id, 6000).unwrap(); - - // Stale note's document should be gone - assert_eq!(count_note_documents(&conn, out3.local_note_id), 0); - assert_eq!(count_note_dirty_sources(&conn, out3.local_note_id), 0); - - // Kept notes' documents should survive - assert_eq!(count_note_documents(&conn, out1.local_note_id), 1); - assert_eq!(count_note_documents(&conn, out2.local_note_id), 1); - } - - #[test] - fn test_sweep_deletion_handles_note_without_document() { - let conn = setup(); - let disc_id = get_discussion_id(&conn); - - let note = make_note(9004, 1, "No doc", None, 1000, 2000, false, None); - upsert_note_for_issue(&conn, disc_id, ¬e, 5000, None).unwrap(); - - // Don't insert any document -- sweep should still work without error - let swept = sweep_stale_issue_notes(&conn, disc_id, 6000).unwrap(); - assert_eq!(swept, 1); - } - - #[test] - fn test_set_based_deletion_atomicity() { - let conn = setup(); - let disc_id = get_discussion_id(&conn); - - // Insert a stale note with both document and dirty_source - let note = make_note(9005, 1, "Stale with deps", None, 1000, 2000, false, None); - let out = upsert_note_for_issue(&conn, disc_id, ¬e, 5000, None).unwrap(); - insert_note_document(&conn, out.local_note_id); - insert_note_dirty_source(&conn, out.local_note_id); - - // Verify they exist before sweep - assert_eq!(count_note_documents(&conn, out.local_note_id), 1); - assert_eq!(count_note_dirty_sources(&conn, out.local_note_id), 1); - - // The sweep function already runs inside a transaction (called from - // ingest_discussions_for_issue's tx). Simulate by wrapping in a transaction. - let tx = conn.unchecked_transaction().unwrap(); - sweep_stale_issue_notes(&tx, disc_id, 6000).unwrap(); - tx.commit().unwrap(); - - // All three DELETEs must have happened - assert_eq!(count_note_documents(&conn, out.local_note_id), 0); - assert_eq!(count_note_dirty_sources(&conn, out.local_note_id), 0); - - let note_count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM notes WHERE gitlab_id = ?", - [9005_i64], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(note_count, 0); - } - - fn count_dirty_notes(conn: &Connection) -> i64 { - conn.query_row( - "SELECT COUNT(*) FROM dirty_sources WHERE source_type = 'note'", - [], - |row| row.get(0), - ) - .unwrap() - } - - #[test] - fn test_parent_title_change_marks_notes_dirty() { - let conn = setup(); - let disc_id = get_discussion_id(&conn); - - // Insert two user notes and one system note - let note1 = make_note(10001, 1, "User note 1", None, 1000, 2000, false, None); - let note2 = make_note(10002, 1, "User note 2", None, 1000, 2000, false, None); - let mut sys_note = make_note(10003, 1, "System note", None, 1000, 2000, false, None); - sys_note.is_system = true; - - let out1 = upsert_note_for_issue(&conn, disc_id, ¬e1, 5000, None).unwrap(); - let out2 = upsert_note_for_issue(&conn, disc_id, ¬e2, 5000, None).unwrap(); - upsert_note_for_issue(&conn, disc_id, &sys_note, 5000, None).unwrap(); - - // Clear any dirty_sources from individual note upserts - conn.execute("DELETE FROM dirty_sources WHERE source_type = 'note'", []) - .unwrap(); - assert_eq!(count_dirty_notes(&conn), 0); - - // Simulate parent title change triggering discussion re-ingest: - // update the issue title, then run the propagation SQL - conn.execute("UPDATE issues SET title = 'Changed Title' WHERE id = 1", []) - .unwrap(); - - // Run the propagation query (same as in ingestion code) - conn.execute( - "INSERT INTO dirty_sources (source_type, source_id, queued_at) - SELECT 'note', n.id, ?1 - FROM notes n - WHERE n.discussion_id = ?2 AND n.is_system = 0 - ON CONFLICT(source_type, source_id) DO UPDATE SET queued_at = excluded.queued_at, attempt_count = 0", - params![now_ms(), disc_id], - ) - .unwrap(); - - // Both user notes should be dirty, system note should not - assert_eq!(count_dirty_notes(&conn), 2); - assert_eq!(count_note_dirty_sources(&conn, out1.local_note_id), 1); - assert_eq!(count_note_dirty_sources(&conn, out2.local_note_id), 1); - } - - #[test] - fn test_parent_label_change_marks_notes_dirty() { - let conn = setup(); - let disc_id = get_discussion_id(&conn); - - // Insert one user note - let note = make_note(11001, 1, "User note", None, 1000, 2000, false, None); - let out = upsert_note_for_issue(&conn, disc_id, ¬e, 5000, None).unwrap(); - - // Clear dirty_sources - conn.execute("DELETE FROM dirty_sources WHERE source_type = 'note'", []) - .unwrap(); - - // Simulate label change on parent issue (labels are part of issue metadata) - conn.execute("UPDATE issues SET updated_at = 9999 WHERE id = 1", []) - .unwrap(); - - // Run propagation query - conn.execute( - "INSERT INTO dirty_sources (source_type, source_id, queued_at) - SELECT 'note', n.id, ?1 - FROM notes n - WHERE n.discussion_id = ?2 AND n.is_system = 0 - ON CONFLICT(source_type, source_id) DO UPDATE SET queued_at = excluded.queued_at, attempt_count = 0", - params![now_ms(), disc_id], - ) - .unwrap(); - - assert_eq!(count_dirty_notes(&conn), 1); - assert_eq!(count_note_dirty_sources(&conn, out.local_note_id), 1); - } -} +#[path = "discussions_tests.rs"] +mod tests; diff --git a/src/ingestion/discussions_tests.rs b/src/ingestion/discussions_tests.rs new file mode 100644 index 0000000..35cbac8 --- /dev/null +++ b/src/ingestion/discussions_tests.rs @@ -0,0 +1,470 @@ +use super::*; +use crate::core::db::{create_connection, run_migrations}; +use crate::gitlab::transformers::NormalizedNote; +use std::path::Path; + +#[test] +fn result_default_has_zero_counts() { + let result = IngestDiscussionsResult::default(); + assert_eq!(result.discussions_fetched, 0); + assert_eq!(result.discussions_upserted, 0); + assert_eq!(result.notes_upserted, 0); +} + +fn setup() -> Connection { + let conn = create_connection(Path::new(":memory:")).unwrap(); + run_migrations(&conn).unwrap(); + + conn.execute( + "INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url) \ + VALUES (1, 'group/repo', 'https://gitlab.com/group/repo')", + [], + ) + .unwrap(); + + conn.execute( + "INSERT INTO issues (gitlab_id, iid, project_id, title, state, author_username, created_at, updated_at, last_seen_at) \ + VALUES (100, 1, 1, 'Test Issue', 'opened', 'testuser', 1000, 2000, 3000)", + [], + ) + .unwrap(); + + conn.execute( + "INSERT INTO discussions (gitlab_discussion_id, project_id, issue_id, noteable_type, individual_note, last_seen_at, resolvable, resolved) \ + VALUES ('disc-1', 1, 1, 'Issue', 0, 3000, 0, 0)", + [], + ) + .unwrap(); + + conn +} + +fn get_discussion_id(conn: &Connection) -> i64 { + conn.query_row("SELECT id FROM discussions LIMIT 1", [], |row| row.get(0)) + .unwrap() +} + +#[allow(clippy::too_many_arguments)] +fn make_note( + gitlab_id: i64, + project_id: i64, + body: &str, + note_type: Option<&str>, + created_at: i64, + updated_at: i64, + resolved: bool, + resolved_by: Option<&str>, +) -> NormalizedNote { + NormalizedNote { + gitlab_id, + project_id, + note_type: note_type.map(String::from), + is_system: false, + author_id: None, + author_username: "testuser".to_string(), + body: body.to_string(), + created_at, + updated_at, + last_seen_at: updated_at, + position: 0, + resolvable: false, + resolved, + resolved_by: resolved_by.map(String::from), + resolved_at: None, + position_old_path: None, + position_new_path: None, + position_old_line: None, + position_new_line: None, + position_type: None, + position_line_range_start: None, + position_line_range_end: None, + position_base_sha: None, + position_start_sha: None, + position_head_sha: None, + } +} + +#[test] +fn test_issue_note_upsert_stable_id() { + let conn = setup(); + let disc_id = get_discussion_id(&conn); + let last_seen_at = 5000; + + let note1 = make_note(1001, 1, "First note", None, 1000, 2000, false, None); + let note2 = make_note(1002, 1, "Second note", None, 1000, 2000, false, None); + + let out1 = upsert_note_for_issue(&conn, disc_id, ¬e1, last_seen_at, None).unwrap(); + let out2 = upsert_note_for_issue(&conn, disc_id, ¬e2, last_seen_at, None).unwrap(); + let id1 = out1.local_note_id; + let id2 = out2.local_note_id; + + // Re-sync same gitlab_ids + let out1b = upsert_note_for_issue(&conn, disc_id, ¬e1, last_seen_at + 1, None).unwrap(); + let out2b = upsert_note_for_issue(&conn, disc_id, ¬e2, last_seen_at + 1, None).unwrap(); + + assert_eq!(id1, out1b.local_note_id); + assert_eq!(id2, out2b.local_note_id); +} + +#[test] +fn test_issue_note_upsert_detects_body_change() { + let conn = setup(); + let disc_id = get_discussion_id(&conn); + + let note = make_note(2001, 1, "Original body", None, 1000, 2000, false, None); + upsert_note_for_issue(&conn, disc_id, ¬e, 5000, None).unwrap(); + + let mut changed = make_note(2001, 1, "Updated body", None, 1000, 3000, false, None); + changed.updated_at = 3000; + let outcome = upsert_note_for_issue(&conn, disc_id, &changed, 5001, None).unwrap(); + assert!(outcome.changed_semantics); +} + +#[test] +fn test_issue_note_upsert_unchanged_returns_false() { + let conn = setup(); + let disc_id = get_discussion_id(&conn); + + let note = make_note(3001, 1, "Same body", None, 1000, 2000, false, None); + upsert_note_for_issue(&conn, disc_id, ¬e, 5000, None).unwrap(); + + // Re-sync identical note + let outcome = upsert_note_for_issue(&conn, disc_id, ¬e, 5001, None).unwrap(); + assert!(!outcome.changed_semantics); +} + +#[test] +fn test_issue_note_upsert_updated_at_only_does_not_mark_semantic_change() { + let conn = setup(); + let disc_id = get_discussion_id(&conn); + + let note = make_note(4001, 1, "Body stays", None, 1000, 2000, false, None); + upsert_note_for_issue(&conn, disc_id, ¬e, 5000, None).unwrap(); + + // Only change updated_at (non-semantic field) + let mut same = make_note(4001, 1, "Body stays", None, 1000, 9999, false, None); + same.updated_at = 9999; + let outcome = upsert_note_for_issue(&conn, disc_id, &same, 5001, None).unwrap(); + assert!(!outcome.changed_semantics); +} + +#[test] +fn test_issue_note_sweep_removes_stale() { + let conn = setup(); + let disc_id = get_discussion_id(&conn); + + let note1 = make_note(5001, 1, "Keep me", None, 1000, 2000, false, None); + let note2 = make_note(5002, 1, "Stale me", None, 1000, 2000, false, None); + + upsert_note_for_issue(&conn, disc_id, ¬e1, 5000, None).unwrap(); + upsert_note_for_issue(&conn, disc_id, ¬e2, 5000, None).unwrap(); + + // Re-sync only note1 with newer timestamp + upsert_note_for_issue(&conn, disc_id, ¬e1, 6000, None).unwrap(); + + // Sweep should remove note2 (last_seen_at=5000 < 6000) + let swept = sweep_stale_issue_notes(&conn, disc_id, 6000).unwrap(); + assert_eq!(swept, 1); + + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM notes WHERE discussion_id = ?", + [disc_id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(count, 1); +} + +#[test] +fn test_issue_note_upsert_returns_local_id() { + let conn = setup(); + let disc_id = get_discussion_id(&conn); + + let note = make_note(6001, 1, "Check my ID", None, 1000, 2000, false, None); + let outcome = upsert_note_for_issue(&conn, disc_id, ¬e, 5000, None).unwrap(); + + // Verify the local_note_id matches what's in the DB + let db_id: i64 = conn + .query_row( + "SELECT id FROM notes WHERE gitlab_id = ?", + [6001_i64], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(outcome.local_note_id, db_id); +} + +#[test] +fn test_issue_note_upsert_captures_author_id() { + let conn = setup(); + let disc_id = get_discussion_id(&conn); + + let mut note = make_note(7001, 1, "With author", None, 1000, 2000, false, None); + note.author_id = Some(12345); + + upsert_note_for_issue(&conn, disc_id, ¬e, 5000, None).unwrap(); + + let stored: Option = conn + .query_row( + "SELECT author_id FROM notes WHERE gitlab_id = ?", + [7001_i64], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(stored, Some(12345)); +} + +#[test] +fn test_note_upsert_author_id_nullable() { + let conn = setup(); + let disc_id = get_discussion_id(&conn); + + let note = make_note(7002, 1, "No author id", None, 1000, 2000, false, None); + // author_id defaults to None in make_note + + upsert_note_for_issue(&conn, disc_id, ¬e, 5000, None).unwrap(); + + let stored: Option = conn + .query_row( + "SELECT author_id FROM notes WHERE gitlab_id = ?", + [7002_i64], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(stored, None); +} + +#[test] +fn test_note_author_id_survives_username_change() { + let conn = setup(); + let disc_id = get_discussion_id(&conn); + + let mut note = make_note(7003, 1, "Original body", None, 1000, 2000, false, None); + note.author_id = Some(99999); + note.author_username = "oldname".to_string(); + + upsert_note_for_issue(&conn, disc_id, ¬e, 5000, None).unwrap(); + + // Re-sync with changed username, changed body, same author_id + let mut updated = make_note(7003, 1, "Updated body", None, 1000, 3000, false, None); + updated.author_id = Some(99999); + updated.author_username = "newname".to_string(); + + upsert_note_for_issue(&conn, disc_id, &updated, 5001, None).unwrap(); + + // author_id must survive the re-sync intact + let stored_id: Option = conn + .query_row( + "SELECT author_id FROM notes WHERE gitlab_id = ?", + [7003_i64], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(stored_id, Some(99999)); +} + +fn insert_note_document(conn: &Connection, note_local_id: i64) { + conn.execute( + "INSERT INTO documents (source_type, source_id, project_id, content_text, content_hash) \ + VALUES ('note', ?1, 1, 'note content', 'hash123')", + [note_local_id], + ) + .unwrap(); +} + +fn insert_note_dirty_source(conn: &Connection, note_local_id: i64) { + conn.execute( + "INSERT INTO dirty_sources (source_type, source_id, queued_at) \ + VALUES ('note', ?1, 1000)", + [note_local_id], + ) + .unwrap(); +} + +fn count_note_documents(conn: &Connection, note_local_id: i64) -> i64 { + conn.query_row( + "SELECT COUNT(*) FROM documents WHERE source_type = 'note' AND source_id = ?", + [note_local_id], + |row| row.get(0), + ) + .unwrap() +} + +fn count_note_dirty_sources(conn: &Connection, note_local_id: i64) -> i64 { + conn.query_row( + "SELECT COUNT(*) FROM dirty_sources WHERE source_type = 'note' AND source_id = ?", + [note_local_id], + |row| row.get(0), + ) + .unwrap() +} + +#[test] +fn test_issue_note_sweep_deletes_note_documents_immediately() { + let conn = setup(); + let disc_id = get_discussion_id(&conn); + + // Insert 3 notes + let note1 = make_note(9001, 1, "Keep me", None, 1000, 2000, false, None); + let note2 = make_note(9002, 1, "Keep me too", None, 1000, 2000, false, None); + let note3 = make_note(9003, 1, "Stale me", None, 1000, 2000, false, None); + + let out1 = upsert_note_for_issue(&conn, disc_id, ¬e1, 5000, None).unwrap(); + let out2 = upsert_note_for_issue(&conn, disc_id, ¬e2, 5000, None).unwrap(); + let out3 = upsert_note_for_issue(&conn, disc_id, ¬e3, 5000, None).unwrap(); + + // Add documents for all 3 + insert_note_document(&conn, out1.local_note_id); + insert_note_document(&conn, out2.local_note_id); + insert_note_document(&conn, out3.local_note_id); + + // Add dirty_sources for note3 + insert_note_dirty_source(&conn, out3.local_note_id); + + // Re-sync only notes 1 and 2 with newer timestamp + upsert_note_for_issue(&conn, disc_id, ¬e1, 6000, None).unwrap(); + upsert_note_for_issue(&conn, disc_id, ¬e2, 6000, None).unwrap(); + + // Sweep should remove note3 and its document + dirty_source + sweep_stale_issue_notes(&conn, disc_id, 6000).unwrap(); + + // Stale note's document should be gone + assert_eq!(count_note_documents(&conn, out3.local_note_id), 0); + assert_eq!(count_note_dirty_sources(&conn, out3.local_note_id), 0); + + // Kept notes' documents should survive + assert_eq!(count_note_documents(&conn, out1.local_note_id), 1); + assert_eq!(count_note_documents(&conn, out2.local_note_id), 1); +} + +#[test] +fn test_sweep_deletion_handles_note_without_document() { + let conn = setup(); + let disc_id = get_discussion_id(&conn); + + let note = make_note(9004, 1, "No doc", None, 1000, 2000, false, None); + upsert_note_for_issue(&conn, disc_id, ¬e, 5000, None).unwrap(); + + // Don't insert any document -- sweep should still work without error + let swept = sweep_stale_issue_notes(&conn, disc_id, 6000).unwrap(); + assert_eq!(swept, 1); +} + +#[test] +fn test_set_based_deletion_atomicity() { + let conn = setup(); + let disc_id = get_discussion_id(&conn); + + // Insert a stale note with both document and dirty_source + let note = make_note(9005, 1, "Stale with deps", None, 1000, 2000, false, None); + let out = upsert_note_for_issue(&conn, disc_id, ¬e, 5000, None).unwrap(); + insert_note_document(&conn, out.local_note_id); + insert_note_dirty_source(&conn, out.local_note_id); + + // Verify they exist before sweep + assert_eq!(count_note_documents(&conn, out.local_note_id), 1); + assert_eq!(count_note_dirty_sources(&conn, out.local_note_id), 1); + + // The sweep function already runs inside a transaction (called from + // ingest_discussions_for_issue's tx). Simulate by wrapping in a transaction. + let tx = conn.unchecked_transaction().unwrap(); + sweep_stale_issue_notes(&tx, disc_id, 6000).unwrap(); + tx.commit().unwrap(); + + // All three DELETEs must have happened + assert_eq!(count_note_documents(&conn, out.local_note_id), 0); + assert_eq!(count_note_dirty_sources(&conn, out.local_note_id), 0); + + let note_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM notes WHERE gitlab_id = ?", + [9005_i64], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(note_count, 0); +} + +fn count_dirty_notes(conn: &Connection) -> i64 { + conn.query_row( + "SELECT COUNT(*) FROM dirty_sources WHERE source_type = 'note'", + [], + |row| row.get(0), + ) + .unwrap() +} + +#[test] +fn test_parent_title_change_marks_notes_dirty() { + let conn = setup(); + let disc_id = get_discussion_id(&conn); + + // Insert two user notes and one system note + let note1 = make_note(10001, 1, "User note 1", None, 1000, 2000, false, None); + let note2 = make_note(10002, 1, "User note 2", None, 1000, 2000, false, None); + let mut sys_note = make_note(10003, 1, "System note", None, 1000, 2000, false, None); + sys_note.is_system = true; + + let out1 = upsert_note_for_issue(&conn, disc_id, ¬e1, 5000, None).unwrap(); + let out2 = upsert_note_for_issue(&conn, disc_id, ¬e2, 5000, None).unwrap(); + upsert_note_for_issue(&conn, disc_id, &sys_note, 5000, None).unwrap(); + + // Clear any dirty_sources from individual note upserts + conn.execute("DELETE FROM dirty_sources WHERE source_type = 'note'", []) + .unwrap(); + assert_eq!(count_dirty_notes(&conn), 0); + + // Simulate parent title change triggering discussion re-ingest: + // update the issue title, then run the propagation SQL + conn.execute("UPDATE issues SET title = 'Changed Title' WHERE id = 1", []) + .unwrap(); + + // Run the propagation query (same as in ingestion code) + conn.execute( + "INSERT INTO dirty_sources (source_type, source_id, queued_at) + SELECT 'note', n.id, ?1 + FROM notes n + WHERE n.discussion_id = ?2 AND n.is_system = 0 + ON CONFLICT(source_type, source_id) DO UPDATE SET queued_at = excluded.queued_at, attempt_count = 0", + params![now_ms(), disc_id], + ) + .unwrap(); + + // Both user notes should be dirty, system note should not + assert_eq!(count_dirty_notes(&conn), 2); + assert_eq!(count_note_dirty_sources(&conn, out1.local_note_id), 1); + assert_eq!(count_note_dirty_sources(&conn, out2.local_note_id), 1); +} + +#[test] +fn test_parent_label_change_marks_notes_dirty() { + let conn = setup(); + let disc_id = get_discussion_id(&conn); + + // Insert one user note + let note = make_note(11001, 1, "User note", None, 1000, 2000, false, None); + let out = upsert_note_for_issue(&conn, disc_id, ¬e, 5000, None).unwrap(); + + // Clear dirty_sources + conn.execute("DELETE FROM dirty_sources WHERE source_type = 'note'", []) + .unwrap(); + + // Simulate label change on parent issue (labels are part of issue metadata) + conn.execute("UPDATE issues SET updated_at = 9999 WHERE id = 1", []) + .unwrap(); + + // Run propagation query + conn.execute( + "INSERT INTO dirty_sources (source_type, source_id, queued_at) + SELECT 'note', n.id, ?1 + FROM notes n + WHERE n.discussion_id = ?2 AND n.is_system = 0 + ON CONFLICT(source_type, source_id) DO UPDATE SET queued_at = excluded.queued_at, attempt_count = 0", + params![now_ms(), disc_id], + ) + .unwrap(); + + assert_eq!(count_dirty_notes(&conn), 1); + assert_eq!(count_note_dirty_sources(&conn, out.local_note_id), 1); +} diff --git a/src/ingestion/issues.rs b/src/ingestion/issues.rs index 0e7a22c..a43a7e7 100644 --- a/src/ingestion/issues.rs +++ b/src/ingestion/issues.rs @@ -138,29 +138,6 @@ fn passes_cursor_filter_with_ts(gitlab_id: i64, issue_ts: i64, cursor: &SyncCurs true } -#[cfg(test)] -fn passes_cursor_filter(issue: &GitLabIssue, cursor: &SyncCursor) -> Result { - let Some(cursor_ts) = cursor.updated_at_cursor else { - return Ok(true); - }; - - let issue_ts = parse_timestamp(&issue.updated_at)?; - - if issue_ts < cursor_ts { - return Ok(false); - } - - if issue_ts == cursor_ts - && cursor - .tie_breaker_id - .is_some_and(|cursor_id| issue.id <= cursor_id) - { - return Ok(false); - } - - Ok(true) -} - fn process_single_issue( conn: &Connection, config: &Config, @@ -423,78 +400,5 @@ fn parse_timestamp(ts: &str) -> Result { } #[cfg(test)] -mod tests { - use super::*; - use crate::gitlab::types::GitLabAuthor; - - fn make_test_issue(id: i64, updated_at: &str) -> GitLabIssue { - GitLabIssue { - id, - iid: id, - project_id: 100, - title: format!("Issue {}", id), - description: None, - state: "opened".to_string(), - created_at: "2024-01-01T00:00:00.000Z".to_string(), - updated_at: updated_at.to_string(), - closed_at: None, - author: GitLabAuthor { - id: 1, - username: "test".to_string(), - name: "Test".to_string(), - }, - assignees: vec![], - labels: vec![], - milestone: None, - due_date: None, - web_url: "https://example.com".to_string(), - } - } - - #[test] - fn cursor_filter_allows_newer_issues() { - let cursor = SyncCursor { - updated_at_cursor: Some(1705312800000), - tie_breaker_id: Some(100), - }; - - let issue = make_test_issue(101, "2024-01-16T10:00:00.000Z"); - assert!(passes_cursor_filter(&issue, &cursor).unwrap_or(false)); - } - - #[test] - fn cursor_filter_blocks_older_issues() { - let cursor = SyncCursor { - updated_at_cursor: Some(1705312800000), - tie_breaker_id: Some(100), - }; - - let issue = make_test_issue(99, "2024-01-14T10:00:00.000Z"); - assert!(!passes_cursor_filter(&issue, &cursor).unwrap_or(true)); - } - - #[test] - fn cursor_filter_uses_tie_breaker_for_same_timestamp() { - let cursor = SyncCursor { - updated_at_cursor: Some(1705312800000), - tie_breaker_id: Some(100), - }; - - let issue1 = make_test_issue(101, "2024-01-15T10:00:00.000Z"); - assert!(passes_cursor_filter(&issue1, &cursor).unwrap_or(false)); - - let issue2 = make_test_issue(100, "2024-01-15T10:00:00.000Z"); - assert!(!passes_cursor_filter(&issue2, &cursor).unwrap_or(true)); - - let issue3 = make_test_issue(99, "2024-01-15T10:00:00.000Z"); - assert!(!passes_cursor_filter(&issue3, &cursor).unwrap_or(true)); - } - - #[test] - fn cursor_filter_allows_all_when_no_cursor() { - let cursor = SyncCursor::default(); - - let issue = make_test_issue(1, "2020-01-01T00:00:00.000Z"); - assert!(passes_cursor_filter(&issue, &cursor).unwrap_or(false)); - } -} +#[path = "issues_tests.rs"] +mod tests; diff --git a/src/ingestion/issues_tests.rs b/src/ingestion/issues_tests.rs new file mode 100644 index 0000000..59b6b71 --- /dev/null +++ b/src/ingestion/issues_tests.rs @@ -0,0 +1,95 @@ +use super::*; +use crate::gitlab::types::GitLabAuthor; + +fn passes_cursor_filter(issue: &GitLabIssue, cursor: &SyncCursor) -> Result { + let Some(cursor_ts) = cursor.updated_at_cursor else { + return Ok(true); + }; + + let issue_ts = parse_timestamp(&issue.updated_at)?; + + if issue_ts < cursor_ts { + return Ok(false); + } + + if issue_ts == cursor_ts + && cursor + .tie_breaker_id + .is_some_and(|cursor_id| issue.id <= cursor_id) + { + return Ok(false); + } + + Ok(true) +} + +fn make_test_issue(id: i64, updated_at: &str) -> GitLabIssue { + GitLabIssue { + id, + iid: id, + project_id: 100, + title: format!("Issue {}", id), + description: None, + state: "opened".to_string(), + created_at: "2024-01-01T00:00:00.000Z".to_string(), + updated_at: updated_at.to_string(), + closed_at: None, + author: GitLabAuthor { + id: 1, + username: "test".to_string(), + name: "Test".to_string(), + }, + assignees: vec![], + labels: vec![], + milestone: None, + due_date: None, + web_url: "https://example.com".to_string(), + } +} + +#[test] +fn cursor_filter_allows_newer_issues() { + let cursor = SyncCursor { + updated_at_cursor: Some(1705312800000), + tie_breaker_id: Some(100), + }; + + let issue = make_test_issue(101, "2024-01-16T10:00:00.000Z"); + assert!(passes_cursor_filter(&issue, &cursor).unwrap_or(false)); +} + +#[test] +fn cursor_filter_blocks_older_issues() { + let cursor = SyncCursor { + updated_at_cursor: Some(1705312800000), + tie_breaker_id: Some(100), + }; + + let issue = make_test_issue(99, "2024-01-14T10:00:00.000Z"); + assert!(!passes_cursor_filter(&issue, &cursor).unwrap_or(true)); +} + +#[test] +fn cursor_filter_uses_tie_breaker_for_same_timestamp() { + let cursor = SyncCursor { + updated_at_cursor: Some(1705312800000), + tie_breaker_id: Some(100), + }; + + let issue1 = make_test_issue(101, "2024-01-15T10:00:00.000Z"); + assert!(passes_cursor_filter(&issue1, &cursor).unwrap_or(false)); + + let issue2 = make_test_issue(100, "2024-01-15T10:00:00.000Z"); + assert!(!passes_cursor_filter(&issue2, &cursor).unwrap_or(true)); + + let issue3 = make_test_issue(99, "2024-01-15T10:00:00.000Z"); + assert!(!passes_cursor_filter(&issue3, &cursor).unwrap_or(true)); +} + +#[test] +fn cursor_filter_allows_all_when_no_cursor() { + let cursor = SyncCursor::default(); + + let issue = make_test_issue(1, "2020-01-01T00:00:00.000Z"); + assert!(passes_cursor_filter(&issue, &cursor).unwrap_or(false)); +} diff --git a/src/ingestion/mr_diffs.rs b/src/ingestion/mr_diffs.rs index 7643b42..a199eed 100644 --- a/src/ingestion/mr_diffs.rs +++ b/src/ingestion/mr_diffs.rs @@ -66,207 +66,5 @@ pub fn upsert_mr_file_changes( } #[cfg(test)] -mod tests { - use super::*; - use crate::core::db::{create_connection, run_migrations}; - use std::path::Path; - - fn setup() -> Connection { - let conn = create_connection(Path::new(":memory:")).unwrap(); - run_migrations(&conn).unwrap(); - - // Insert a test project - conn.execute( - "INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url) VALUES (1, 'group/repo', 'https://gitlab.com/group/repo')", - [], - ).unwrap(); - - // Insert a test MR - conn.execute( - "INSERT INTO merge_requests (gitlab_id, iid, project_id, title, state, draft, source_branch, target_branch, author_username, created_at, updated_at, last_seen_at) \ - VALUES (100, 1, 1, 'Test MR', 'merged', 0, 'feature', 'main', 'testuser', 1000, 2000, 3000)", - [], - ).unwrap(); - - conn - } - - #[test] - fn test_derive_change_type_added() { - let diff = GitLabMrDiff { - old_path: String::new(), - new_path: "src/new.rs".to_string(), - new_file: true, - renamed_file: false, - deleted_file: false, - }; - assert_eq!(derive_change_type(&diff), "added"); - } - - #[test] - fn test_derive_change_type_renamed() { - let diff = GitLabMrDiff { - old_path: "src/old.rs".to_string(), - new_path: "src/new.rs".to_string(), - new_file: false, - renamed_file: true, - deleted_file: false, - }; - assert_eq!(derive_change_type(&diff), "renamed"); - } - - #[test] - fn test_derive_change_type_deleted() { - let diff = GitLabMrDiff { - old_path: "src/gone.rs".to_string(), - new_path: "src/gone.rs".to_string(), - new_file: false, - renamed_file: false, - deleted_file: true, - }; - assert_eq!(derive_change_type(&diff), "deleted"); - } - - #[test] - fn test_derive_change_type_modified() { - let diff = GitLabMrDiff { - old_path: "src/lib.rs".to_string(), - new_path: "src/lib.rs".to_string(), - new_file: false, - renamed_file: false, - deleted_file: false, - }; - assert_eq!(derive_change_type(&diff), "modified"); - } - - #[test] - fn test_upsert_inserts_file_changes() { - let conn = setup(); - let diffs = [ - GitLabMrDiff { - old_path: String::new(), - new_path: "src/new.rs".to_string(), - new_file: true, - renamed_file: false, - deleted_file: false, - }, - GitLabMrDiff { - old_path: "src/lib.rs".to_string(), - new_path: "src/lib.rs".to_string(), - new_file: false, - renamed_file: false, - deleted_file: false, - }, - ]; - - let inserted = upsert_mr_file_changes(&conn, 1, 1, &diffs).unwrap(); - assert_eq!(inserted, 2); - - let count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM mr_file_changes WHERE merge_request_id = 1", - [], - |r| r.get(0), - ) - .unwrap(); - assert_eq!(count, 2); - } - - #[test] - fn test_upsert_replaces_existing() { - let conn = setup(); - let diffs_v1 = [GitLabMrDiff { - old_path: String::new(), - new_path: "src/old.rs".to_string(), - new_file: true, - renamed_file: false, - deleted_file: false, - }]; - upsert_mr_file_changes(&conn, 1, 1, &diffs_v1).unwrap(); - - let diffs_v2 = [ - GitLabMrDiff { - old_path: "src/a.rs".to_string(), - new_path: "src/a.rs".to_string(), - new_file: false, - renamed_file: false, - deleted_file: false, - }, - GitLabMrDiff { - old_path: "src/b.rs".to_string(), - new_path: "src/b.rs".to_string(), - new_file: false, - renamed_file: false, - deleted_file: false, - }, - ]; - let inserted = upsert_mr_file_changes(&conn, 1, 1, &diffs_v2).unwrap(); - assert_eq!(inserted, 2); - - let count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM mr_file_changes WHERE merge_request_id = 1", - [], - |r| r.get(0), - ) - .unwrap(); - assert_eq!(count, 2); - - // The old "src/old.rs" should be gone - let old_count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM mr_file_changes WHERE new_path = 'src/old.rs'", - [], - |r| r.get(0), - ) - .unwrap(); - assert_eq!(old_count, 0); - } - - #[test] - fn test_renamed_stores_old_path() { - let conn = setup(); - let diffs = [GitLabMrDiff { - old_path: "src/old_name.rs".to_string(), - new_path: "src/new_name.rs".to_string(), - new_file: false, - renamed_file: true, - deleted_file: false, - }]; - - upsert_mr_file_changes(&conn, 1, 1, &diffs).unwrap(); - - let (old_path, change_type): (Option, String) = conn - .query_row( - "SELECT old_path, change_type FROM mr_file_changes WHERE new_path = 'src/new_name.rs'", - [], - |r| Ok((r.get(0)?, r.get(1)?)), - ) - .unwrap(); - assert_eq!(old_path.as_deref(), Some("src/old_name.rs")); - assert_eq!(change_type, "renamed"); - } - - #[test] - fn test_non_renamed_has_null_old_path() { - let conn = setup(); - let diffs = [GitLabMrDiff { - old_path: "src/lib.rs".to_string(), - new_path: "src/lib.rs".to_string(), - new_file: false, - renamed_file: false, - deleted_file: false, - }]; - - upsert_mr_file_changes(&conn, 1, 1, &diffs).unwrap(); - - let old_path: Option = conn - .query_row( - "SELECT old_path FROM mr_file_changes WHERE new_path = 'src/lib.rs'", - [], - |r| r.get(0), - ) - .unwrap(); - assert!(old_path.is_none()); - } -} +#[path = "mr_diffs_tests.rs"] +mod tests; diff --git a/src/ingestion/mr_diffs_tests.rs b/src/ingestion/mr_diffs_tests.rs new file mode 100644 index 0000000..1657fee --- /dev/null +++ b/src/ingestion/mr_diffs_tests.rs @@ -0,0 +1,202 @@ +use super::*; +use crate::core::db::{create_connection, run_migrations}; +use std::path::Path; + +fn setup() -> Connection { + let conn = create_connection(Path::new(":memory:")).unwrap(); + run_migrations(&conn).unwrap(); + + // Insert a test project + conn.execute( + "INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url) VALUES (1, 'group/repo', 'https://gitlab.com/group/repo')", + [], + ).unwrap(); + + // Insert a test MR + conn.execute( + "INSERT INTO merge_requests (gitlab_id, iid, project_id, title, state, draft, source_branch, target_branch, author_username, created_at, updated_at, last_seen_at) \ + VALUES (100, 1, 1, 'Test MR', 'merged', 0, 'feature', 'main', 'testuser', 1000, 2000, 3000)", + [], + ).unwrap(); + + conn +} + +#[test] +fn test_derive_change_type_added() { + let diff = GitLabMrDiff { + old_path: String::new(), + new_path: "src/new.rs".to_string(), + new_file: true, + renamed_file: false, + deleted_file: false, + }; + assert_eq!(derive_change_type(&diff), "added"); +} + +#[test] +fn test_derive_change_type_renamed() { + let diff = GitLabMrDiff { + old_path: "src/old.rs".to_string(), + new_path: "src/new.rs".to_string(), + new_file: false, + renamed_file: true, + deleted_file: false, + }; + assert_eq!(derive_change_type(&diff), "renamed"); +} + +#[test] +fn test_derive_change_type_deleted() { + let diff = GitLabMrDiff { + old_path: "src/gone.rs".to_string(), + new_path: "src/gone.rs".to_string(), + new_file: false, + renamed_file: false, + deleted_file: true, + }; + assert_eq!(derive_change_type(&diff), "deleted"); +} + +#[test] +fn test_derive_change_type_modified() { + let diff = GitLabMrDiff { + old_path: "src/lib.rs".to_string(), + new_path: "src/lib.rs".to_string(), + new_file: false, + renamed_file: false, + deleted_file: false, + }; + assert_eq!(derive_change_type(&diff), "modified"); +} + +#[test] +fn test_upsert_inserts_file_changes() { + let conn = setup(); + let diffs = [ + GitLabMrDiff { + old_path: String::new(), + new_path: "src/new.rs".to_string(), + new_file: true, + renamed_file: false, + deleted_file: false, + }, + GitLabMrDiff { + old_path: "src/lib.rs".to_string(), + new_path: "src/lib.rs".to_string(), + new_file: false, + renamed_file: false, + deleted_file: false, + }, + ]; + + let inserted = upsert_mr_file_changes(&conn, 1, 1, &diffs).unwrap(); + assert_eq!(inserted, 2); + + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM mr_file_changes WHERE merge_request_id = 1", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 2); +} + +#[test] +fn test_upsert_replaces_existing() { + let conn = setup(); + let diffs_v1 = [GitLabMrDiff { + old_path: String::new(), + new_path: "src/old.rs".to_string(), + new_file: true, + renamed_file: false, + deleted_file: false, + }]; + upsert_mr_file_changes(&conn, 1, 1, &diffs_v1).unwrap(); + + let diffs_v2 = [ + GitLabMrDiff { + old_path: "src/a.rs".to_string(), + new_path: "src/a.rs".to_string(), + new_file: false, + renamed_file: false, + deleted_file: false, + }, + GitLabMrDiff { + old_path: "src/b.rs".to_string(), + new_path: "src/b.rs".to_string(), + new_file: false, + renamed_file: false, + deleted_file: false, + }, + ]; + let inserted = upsert_mr_file_changes(&conn, 1, 1, &diffs_v2).unwrap(); + assert_eq!(inserted, 2); + + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM mr_file_changes WHERE merge_request_id = 1", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 2); + + // The old "src/old.rs" should be gone + let old_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM mr_file_changes WHERE new_path = 'src/old.rs'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(old_count, 0); +} + +#[test] +fn test_renamed_stores_old_path() { + let conn = setup(); + let diffs = [GitLabMrDiff { + old_path: "src/old_name.rs".to_string(), + new_path: "src/new_name.rs".to_string(), + new_file: false, + renamed_file: true, + deleted_file: false, + }]; + + upsert_mr_file_changes(&conn, 1, 1, &diffs).unwrap(); + + let (old_path, change_type): (Option, String) = conn + .query_row( + "SELECT old_path, change_type FROM mr_file_changes WHERE new_path = 'src/new_name.rs'", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(old_path.as_deref(), Some("src/old_name.rs")); + assert_eq!(change_type, "renamed"); +} + +#[test] +fn test_non_renamed_has_null_old_path() { + let conn = setup(); + let diffs = [GitLabMrDiff { + old_path: "src/lib.rs".to_string(), + new_path: "src/lib.rs".to_string(), + new_file: false, + renamed_file: false, + deleted_file: false, + }]; + + upsert_mr_file_changes(&conn, 1, 1, &diffs).unwrap(); + + let old_path: Option = conn + .query_row( + "SELECT old_path FROM mr_file_changes WHERE new_path = 'src/lib.rs'", + [], + |r| r.get(0), + ) + .unwrap(); + assert!(old_path.is_none()); +}