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()); }