use super::*; use crate::cli::commands::me::types::{ActivityEventType, AttentionState}; use crate::core::time::now_ms; use crate::test_support::{insert_project, setup_test_db}; use rusqlite::Connection; // ─── Helpers ──────────────────────────────────────────────────────────────── fn insert_issue(conn: &Connection, id: i64, project_id: i64, iid: i64, author: &str) { insert_issue_with_status( conn, id, project_id, iid, author, "opened", Some("In Progress"), ); } fn insert_issue_with_state( conn: &Connection, id: i64, project_id: i64, iid: i64, author: &str, state: &str, ) { // For closed issues, don't set status_name (they won't appear in dashboard anyway) let status_name = if state == "opened" { Some("In Progress") } else { None }; insert_issue_with_status(conn, id, project_id, iid, author, state, status_name); } #[allow(clippy::too_many_arguments)] fn insert_issue_with_status( conn: &Connection, id: i64, project_id: i64, iid: i64, author: &str, state: &str, status_name: Option<&str>, ) { let ts = now_ms(); conn.execute( "INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, status_name, author_username, created_at, updated_at, last_seen_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", rusqlite::params![ id, id * 10, project_id, iid, format!("Issue {iid}"), state, status_name, author, ts, ts, ts ], ) .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(); } #[allow(clippy::too_many_arguments)] fn insert_mr( conn: &Connection, id: i64, project_id: i64, iid: i64, author: &str, state: &str, draft: bool, ) { let ts = now_ms(); conn.execute( "INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, author_username, state, draft, last_seen_at, updated_at, created_at, merged_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", rusqlite::params![ id, id * 10, project_id, iid, format!("MR {iid}"), author, state, i32::from(draft), ts, ts, ts, if state == "merged" { Some(ts) } else { None:: } ], ) .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_discussion( conn: &Connection, id: i64, project_id: i64, mr_id: Option, issue_id: Option, ) { let noteable_type = if mr_id.is_some() { "MergeRequest" } else { "Issue" }; let ts = now_ms(); 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, 0, 0, ?7, ?8)", rusqlite::params![ id, format!("disc-{id}"), project_id, mr_id, issue_id, noteable_type, ts, ts ], ) .unwrap(); } #[allow(clippy::too_many_arguments)] fn insert_note_at( conn: &Connection, id: i64, discussion_id: i64, project_id: i64, author: &str, is_system: bool, body: &str, created_at: i64, ) { 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) VALUES (?1, ?2, ?3, ?4, 'DiscussionNote', ?5, ?6, ?7, ?8, ?9, ?10)", rusqlite::params![ id, id * 10, discussion_id, project_id, i32::from(is_system), author, body, created_at, created_at, now_ms() ], ) .unwrap(); } #[allow(clippy::too_many_arguments)] fn insert_state_event( conn: &Connection, id: i64, project_id: i64, issue_id: Option, mr_id: Option, state: &str, actor: &str, created_at: i64, ) { conn.execute( "INSERT INTO resource_state_events (id, gitlab_id, project_id, issue_id, merge_request_id, state, actor_username, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", rusqlite::params![id, id * 10, project_id, issue_id, mr_id, state, actor, created_at], ) .unwrap(); } #[allow(clippy::too_many_arguments)] fn insert_label_event( conn: &Connection, id: i64, project_id: i64, issue_id: Option, mr_id: Option, action: &str, label_name: &str, actor: &str, created_at: i64, ) { conn.execute( "INSERT INTO resource_label_events (id, gitlab_id, project_id, issue_id, merge_request_id, action, label_name, actor_username, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", rusqlite::params![ id, id * 10, project_id, issue_id, mr_id, action, label_name, actor, created_at ], ) .unwrap(); } // ─── Open Issues Tests (Task #7) ─────────────────────────────────────────── #[test] fn open_issues_returns_assigned_only() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_issue(&conn, 10, 1, 42, "someone"); insert_issue(&conn, 11, 1, 43, "someone"); // Only assign issue 42 to alice insert_assignee(&conn, 10, "alice"); let results = query_open_issues(&conn, "alice", &[]).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].iid, 42); } #[test] fn open_issues_excludes_closed() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_issue(&conn, 10, 1, 42, "someone"); insert_issue_with_state(&conn, 11, 1, 43, "someone", "closed"); insert_assignee(&conn, 10, "alice"); insert_assignee(&conn, 11, "alice"); let results = query_open_issues(&conn, "alice", &[]).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].iid, 42); } #[test] fn open_issues_project_filter() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo-a"); insert_project(&conn, 2, "group/repo-b"); insert_issue(&conn, 10, 1, 42, "someone"); insert_issue(&conn, 11, 2, 43, "someone"); insert_assignee(&conn, 10, "alice"); insert_assignee(&conn, 11, "alice"); // Filter to project 1 only let results = query_open_issues(&conn, "alice", &[1]).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].project_path, "group/repo-a"); } #[test] fn open_issues_empty_when_unassigned() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_issue(&conn, 10, 1, 42, "alice"); // alice authored but is NOT assigned let results = query_open_issues(&conn, "alice", &[]).unwrap(); assert!(results.is_empty()); } // ─── Attention State Tests (Task #10) ────────────────────────────────────── #[test] fn attention_state_not_started_no_notes() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_issue(&conn, 10, 1, 42, "someone"); insert_assignee(&conn, 10, "alice"); let results = query_open_issues(&conn, "alice", &[]).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].attention_state, AttentionState::NotStarted); } #[test] fn attention_state_needs_attention_others_replied() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_issue(&conn, 10, 1, 42, "someone"); insert_assignee(&conn, 10, "alice"); // alice comments first, then bob replies after let disc_id = 100; insert_discussion(&conn, disc_id, 1, None, Some(10)); let t1 = now_ms() - 5000; let t2 = now_ms() - 1000; insert_note_at(&conn, 200, disc_id, 1, "alice", false, "my comment", t1); insert_note_at(&conn, 201, disc_id, 1, "bob", false, "reply", t2); let results = query_open_issues(&conn, "alice", &[]).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].attention_state, AttentionState::NeedsAttention); } #[test] fn attention_state_awaiting_response() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_issue(&conn, 10, 1, 42, "someone"); insert_assignee(&conn, 10, "alice"); let disc_id = 100; insert_discussion(&conn, disc_id, 1, None, Some(10)); let t1 = now_ms() - 5000; let t2 = now_ms() - 1000; // bob first, then alice replies (alice's latest >= others' latest) insert_note_at(&conn, 200, disc_id, 1, "bob", false, "question", t1); insert_note_at(&conn, 201, disc_id, 1, "alice", false, "my reply", t2); let results = query_open_issues(&conn, "alice", &[]).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].attention_state, AttentionState::AwaitingResponse); } // ─── Authored MRs Tests (Task #8) ───────────────────────────────────────── #[test] fn authored_mrs_returns_own_only() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_mr(&conn, 10, 1, 99, "alice", "opened", false); insert_mr(&conn, 11, 1, 100, "bob", "opened", false); let results = query_authored_mrs(&conn, "alice", &[]).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].iid, 99); } #[test] fn authored_mrs_excludes_merged() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_mr(&conn, 10, 1, 99, "alice", "opened", false); insert_mr(&conn, 11, 1, 100, "alice", "merged", false); let results = query_authored_mrs(&conn, "alice", &[]).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].iid, 99); } #[test] fn authored_mrs_project_filter() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo-a"); insert_project(&conn, 2, "group/repo-b"); insert_mr(&conn, 10, 1, 99, "alice", "opened", false); insert_mr(&conn, 11, 2, 100, "alice", "opened", false); let results = query_authored_mrs(&conn, "alice", &[2]).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].project_path, "group/repo-b"); } #[test] fn authored_mr_not_ready_when_draft_no_reviewers() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_mr(&conn, 10, 1, 99, "alice", "opened", true); // No reviewers added let results = query_authored_mrs(&conn, "alice", &[]).unwrap(); assert_eq!(results.len(), 1); assert!(results[0].draft); assert_eq!(results[0].attention_state, AttentionState::NotReady); } #[test] fn authored_mr_not_ready_overridden_when_has_reviewers() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_mr(&conn, 10, 1, 99, "alice", "opened", true); insert_reviewer(&conn, 10, "bob"); let results = query_authored_mrs(&conn, "alice", &[]).unwrap(); assert_eq!(results.len(), 1); // Draft with reviewers -> not_started (not not_ready), since no one has commented assert_eq!(results[0].attention_state, AttentionState::NotStarted); } // ─── Reviewing MRs Tests (Task #9) ──────────────────────────────────────── #[test] fn reviewing_mrs_returns_reviewer_items() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_mr(&conn, 10, 1, 99, "bob", "opened", false); insert_mr(&conn, 11, 1, 100, "charlie", "opened", false); insert_reviewer(&conn, 10, "alice"); // alice is NOT a reviewer of MR 100 let results = query_reviewing_mrs(&conn, "alice", &[]).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].iid, 99); } #[test] fn reviewing_mrs_includes_author_username() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_mr(&conn, 10, 1, 99, "bob", "opened", false); insert_reviewer(&conn, 10, "alice"); let results = query_reviewing_mrs(&conn, "alice", &[]).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].author_username, Some("bob".to_string())); } #[test] fn reviewing_mrs_project_filter() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo-a"); insert_project(&conn, 2, "group/repo-b"); insert_mr(&conn, 10, 1, 99, "bob", "opened", false); insert_mr(&conn, 11, 2, 100, "bob", "opened", false); insert_reviewer(&conn, 10, "alice"); insert_reviewer(&conn, 11, "alice"); let results = query_reviewing_mrs(&conn, "alice", &[1]).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].project_path, "group/repo-a"); } // ─── Activity Feed Tests (Tasks #11-13) ──────────────────────────────────── #[test] fn activity_note_on_assigned_issue() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_issue(&conn, 10, 1, 42, "someone"); insert_assignee(&conn, 10, "alice"); let disc_id = 100; insert_discussion(&conn, disc_id, 1, None, Some(10)); let t = now_ms() - 1000; insert_note_at(&conn, 200, disc_id, 1, "bob", false, "a comment", t); let results = query_activity(&conn, "alice", &[], 0).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].event_type, ActivityEventType::Note); assert_eq!(results[0].entity_iid, 42); assert_eq!(results[0].entity_type, "issue"); assert!(!results[0].is_own); } #[test] fn activity_note_on_authored_mr() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_mr(&conn, 10, 1, 99, "alice", "opened", false); let disc_id = 100; insert_discussion(&conn, disc_id, 1, Some(10), None); let t = now_ms() - 1000; insert_note_at(&conn, 200, disc_id, 1, "bob", false, "nice work", t); let results = query_activity(&conn, "alice", &[], 0).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].event_type, ActivityEventType::Note); assert_eq!(results[0].entity_type, "mr"); assert_eq!(results[0].entity_iid, 99); } #[test] fn activity_state_event_on_my_issue() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_issue(&conn, 10, 1, 42, "someone"); insert_assignee(&conn, 10, "alice"); let t = now_ms() - 1000; insert_state_event(&conn, 300, 1, Some(10), None, "closed", "bob", t); let results = query_activity(&conn, "alice", &[], 0).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].event_type, ActivityEventType::StatusChange); assert_eq!(results[0].summary, "closed"); } #[test] fn activity_label_event_on_my_issue() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_issue(&conn, 10, 1, 42, "someone"); insert_assignee(&conn, 10, "alice"); let t = now_ms() - 1000; insert_label_event(&conn, 400, 1, Some(10), None, "add", "bug", "bob", t); let results = query_activity(&conn, "alice", &[], 0).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].event_type, ActivityEventType::LabelChange); assert!(results[0].summary.contains("bug")); } #[test] fn activity_excludes_unassociated_items() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); // Issue NOT assigned to alice insert_issue(&conn, 10, 1, 42, "someone"); let disc_id = 100; insert_discussion(&conn, disc_id, 1, None, Some(10)); let t = now_ms() - 1000; insert_note_at(&conn, 200, disc_id, 1, "bob", false, "a comment", t); let results = query_activity(&conn, "alice", &[], 0).unwrap(); assert!( results.is_empty(), "should not see activity on unassigned issues" ); } #[test] fn activity_since_filter() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_issue(&conn, 10, 1, 42, "someone"); insert_assignee(&conn, 10, "alice"); let disc_id = 100; insert_discussion(&conn, disc_id, 1, None, Some(10)); let old_t = now_ms() - 100_000_000; // ~1 day ago let recent_t = now_ms() - 1000; insert_note_at(&conn, 200, disc_id, 1, "bob", false, "old comment", old_t); insert_note_at( &conn, 201, disc_id, 1, "bob", false, "new comment", recent_t, ); // since = 50 seconds ago, should only get the recent note let since = now_ms() - 50_000; let results = query_activity(&conn, "alice", &[], since).unwrap(); assert_eq!(results.len(), 1); // Notes no longer duplicate body into body_preview (summary carries the content) assert_eq!(results[0].body_preview, None); assert_eq!(results[0].summary, "new comment"); } #[test] fn activity_project_filter() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo-a"); insert_project(&conn, 2, "group/repo-b"); insert_issue(&conn, 10, 1, 42, "someone"); insert_issue(&conn, 11, 2, 43, "someone"); insert_assignee(&conn, 10, "alice"); insert_assignee(&conn, 11, "alice"); let disc_a = 100; let disc_b = 101; insert_discussion(&conn, disc_a, 1, None, Some(10)); insert_discussion(&conn, disc_b, 2, None, Some(11)); let t = now_ms() - 1000; insert_note_at(&conn, 200, disc_a, 1, "bob", false, "comment a", t); insert_note_at(&conn, 201, disc_b, 2, "bob", false, "comment b", t); // Filter to project 1 only let results = query_activity(&conn, "alice", &[1], 0).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].project_path, "group/repo-a"); } #[test] fn activity_sorted_newest_first() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_issue(&conn, 10, 1, 42, "someone"); insert_assignee(&conn, 10, "alice"); let disc_id = 100; insert_discussion(&conn, disc_id, 1, None, Some(10)); let t1 = now_ms() - 5000; let t2 = now_ms() - 1000; insert_note_at(&conn, 200, disc_id, 1, "bob", false, "first", t1); insert_note_at(&conn, 201, disc_id, 1, "charlie", false, "second", t2); let results = query_activity(&conn, "alice", &[], 0).unwrap(); assert_eq!(results.len(), 2); assert!( results[0].timestamp >= results[1].timestamp, "should be sorted newest first" ); } #[test] fn activity_is_own_flag() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_issue(&conn, 10, 1, 42, "someone"); insert_assignee(&conn, 10, "alice"); let disc_id = 100; insert_discussion(&conn, disc_id, 1, None, Some(10)); let t = now_ms() - 1000; insert_note_at(&conn, 200, disc_id, 1, "alice", false, "my comment", t); let results = query_activity(&conn, "alice", &[], 0).unwrap(); assert_eq!(results.len(), 1); assert!(results[0].is_own); } // ─── Activity on Closed/Merged Items ───────────────────────────────────────── #[test] fn activity_note_on_merged_authored_mr() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_mr(&conn, 10, 1, 99, "alice", "merged", false); let disc_id = 100; insert_discussion(&conn, disc_id, 1, Some(10), None); let t = now_ms() - 1000; insert_note_at( &conn, 200, disc_id, 1, "bob", false, "follow-up question", t, ); let results = query_activity(&conn, "alice", &[], 0).unwrap(); assert_eq!( results.len(), 1, "should see activity on merged MR authored by user" ); assert_eq!(results[0].entity_iid, 99); assert_eq!(results[0].entity_type, "mr"); } #[test] fn activity_note_on_closed_mr_as_reviewer() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_mr(&conn, 10, 1, 99, "bob", "closed", false); insert_reviewer(&conn, 10, "alice"); let disc_id = 100; insert_discussion(&conn, disc_id, 1, Some(10), None); let t = now_ms() - 1000; insert_note_at(&conn, 200, disc_id, 1, "bob", false, "can you re-check?", t); let results = query_activity(&conn, "alice", &[], 0).unwrap(); assert_eq!( results.len(), 1, "should see activity on closed MR where user is reviewer" ); } #[test] fn activity_note_on_closed_assigned_issue() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_issue_with_state(&conn, 10, 1, 42, "someone", "closed"); insert_assignee(&conn, 10, "alice"); let disc_id = 100; insert_discussion(&conn, disc_id, 1, None, Some(10)); let t = now_ms() - 1000; insert_note_at( &conn, 200, disc_id, 1, "bob", false, "reopening discussion", t, ); let results = query_activity(&conn, "alice", &[], 0).unwrap(); assert_eq!( results.len(), 1, "should see activity on closed issue assigned to user" ); } #[test] fn since_last_check_includes_comment_on_merged_mr() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_mr(&conn, 10, 1, 99, "alice", "merged", false); let disc_id = 100; insert_discussion(&conn, disc_id, 1, Some(10), None); let t = now_ms() - 1000; insert_note_at( &conn, 200, disc_id, 1, "bob", false, "post-merge question", t, ); let groups = query_since_last_check(&conn, "alice", 0).unwrap(); let total_events: usize = groups.iter().map(|g| g.events.len()).sum(); assert_eq!( total_events, 1, "should see others' comments on merged MR in inbox" ); } // ─── Assignment Detection Tests (Task #12) ───────────────────────────────── #[test] fn activity_assignment_system_note() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_issue(&conn, 10, 1, 42, "someone"); insert_assignee(&conn, 10, "alice"); let disc_id = 100; insert_discussion(&conn, disc_id, 1, None, Some(10)); let t = now_ms() - 1000; insert_note_at(&conn, 200, disc_id, 1, "bob", true, "assigned to @alice", t); let results = query_activity(&conn, "alice", &[], 0).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].event_type, ActivityEventType::Assign); } #[test] fn activity_unassignment_system_note() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_issue(&conn, 10, 1, 42, "someone"); insert_assignee(&conn, 10, "alice"); let disc_id = 100; insert_discussion(&conn, disc_id, 1, None, Some(10)); let t = now_ms() - 1000; insert_note_at(&conn, 200, disc_id, 1, "bob", true, "unassigned @alice", t); let results = query_activity(&conn, "alice", &[], 0).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].event_type, ActivityEventType::Unassign); } #[test] fn activity_review_request_system_note() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_mr(&conn, 10, 1, 99, "bob", "opened", false); insert_reviewer(&conn, 10, "alice"); let disc_id = 100; insert_discussion(&conn, disc_id, 1, Some(10), None); let t = now_ms() - 1000; insert_note_at( &conn, 200, disc_id, 1, "bob", true, "requested review from @alice", t, ); let results = query_activity(&conn, "alice", &[], 0).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].event_type, ActivityEventType::ReviewRequest); } // ─── Since-Last-Check Mention Tests ───────────────────────────────────────── #[test] fn since_last_check_detects_mention_with_trailing_comma() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_issue(&conn, 10, 1, 42, "someone"); let disc_id = 100; insert_discussion(&conn, disc_id, 1, None, Some(10)); let t = now_ms() - 1000; insert_note_at( &conn, 200, disc_id, 1, "bob", false, "please review this @alice, thanks", t, ); let groups = query_since_last_check(&conn, "alice", 0).unwrap(); let total_events: usize = groups.iter().map(|g| g.events.len()).sum(); assert_eq!(total_events, 1, "expected mention with comma to match"); } #[test] fn since_last_check_ignores_email_like_text() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_issue(&conn, 10, 1, 42, "someone"); let disc_id = 100; insert_discussion(&conn, disc_id, 1, None, Some(10)); let t = now_ms() - 1000; insert_note_at( &conn, 200, disc_id, 1, "bob", false, "contact alice at foo@alice.com", t, ); let groups = query_since_last_check(&conn, "alice", 0).unwrap(); let total_events: usize = groups.iter().map(|g| g.events.len()).sum(); assert_eq!(total_events, 0, "email text should not count as mention"); } #[test] fn since_last_check_detects_mention_with_trailing_period() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_issue(&conn, 10, 1, 42, "someone"); let disc_id = 100; insert_discussion(&conn, disc_id, 1, None, Some(10)); let t = now_ms() - 1000; insert_note_at( &conn, 200, disc_id, 1, "bob", false, "please review this @alice.", t, ); let groups = query_since_last_check(&conn, "alice", 0).unwrap(); let total_events: usize = groups.iter().map(|g| g.events.len()).sum(); assert_eq!(total_events, 1, "expected mention with period to match"); } #[test] fn since_last_check_detects_mention_inside_parentheses() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_issue(&conn, 10, 1, 42, "someone"); let disc_id = 100; insert_discussion(&conn, disc_id, 1, None, Some(10)); let t = now_ms() - 1000; insert_note_at( &conn, 200, disc_id, 1, "bob", false, "thanks (@alice) for the update", t, ); let groups = query_since_last_check(&conn, "alice", 0).unwrap(); let total_events: usize = groups.iter().map(|g| g.events.len()).sum(); assert_eq!(total_events, 1, "expected parenthesized mention to match"); } #[test] fn since_last_check_ignores_domain_like_text() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_issue(&conn, 10, 1, 42, "someone"); let disc_id = 100; insert_discussion(&conn, disc_id, 1, None, Some(10)); let t = now_ms() - 1000; insert_note_at( &conn, 200, disc_id, 1, "bob", false, "@alice.com is the old hostname", t, ); let groups = query_since_last_check(&conn, "alice", 0).unwrap(); let total_events: usize = groups.iter().map(|g| g.events.len()).sum(); assert_eq!( total_events, 0, "domain-like text should not count as mention" ); } // ─── Mentioned In Tests ───────────────────────────────────────────────────── #[test] fn mentioned_in_finds_mention_on_unassigned_issue() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_issue(&conn, 10, 1, 42, "someone"); // alice is NOT assigned to issue 42 let disc_id = 100; insert_discussion(&conn, disc_id, 1, None, Some(10)); let t = now_ms() - 1000; insert_note_at( &conn, 200, disc_id, 1, "bob", false, "hey @alice can you look?", t, ); let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000; let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff, 0).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].entity_type, "issue"); assert_eq!(results[0].iid, 42); } #[test] fn mentioned_in_excludes_assigned_issue() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_issue(&conn, 10, 1, 42, "someone"); insert_assignee(&conn, 10, "alice"); // alice IS assigned let disc_id = 100; insert_discussion(&conn, disc_id, 1, None, Some(10)); let t = now_ms() - 1000; insert_note_at(&conn, 200, disc_id, 1, "bob", false, "hey @alice", t); let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000; let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff, 0).unwrap(); assert!(results.is_empty(), "should exclude assigned issues"); } #[test] fn mentioned_in_excludes_authored_issue() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_issue(&conn, 10, 1, 42, "alice"); // alice IS author let disc_id = 100; insert_discussion(&conn, disc_id, 1, None, Some(10)); let t = now_ms() - 1000; insert_note_at(&conn, 200, disc_id, 1, "bob", false, "hey @alice", t); let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000; let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff, 0).unwrap(); assert!(results.is_empty(), "should exclude authored issues"); } #[test] fn mentioned_in_finds_mention_on_non_authored_mr() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_mr(&conn, 10, 1, 99, "bob", "opened", false); // alice is NOT author or reviewer let disc_id = 100; insert_discussion(&conn, disc_id, 1, Some(10), None); let t = now_ms() - 1000; insert_note_at(&conn, 200, disc_id, 1, "bob", false, "cc @alice", t); let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000; let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff, 0).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].entity_type, "mr"); assert_eq!(results[0].iid, 99); } #[test] fn mentioned_in_excludes_authored_mr() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_mr(&conn, 10, 1, 99, "alice", "opened", false); // alice IS author let disc_id = 100; insert_discussion(&conn, disc_id, 1, Some(10), None); let t = now_ms() - 1000; insert_note_at(&conn, 200, disc_id, 1, "bob", false, "@alice thoughts?", t); let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000; let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff, 0).unwrap(); assert!(results.is_empty(), "should exclude authored MRs"); } #[test] fn mentioned_in_excludes_reviewer_mr() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_mr(&conn, 10, 1, 99, "bob", "opened", false); insert_reviewer(&conn, 10, "alice"); // alice IS reviewer let disc_id = 100; insert_discussion(&conn, disc_id, 1, Some(10), None); let t = now_ms() - 1000; insert_note_at(&conn, 200, disc_id, 1, "charlie", false, "@alice fyi", t); let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000; let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff, 0).unwrap(); assert!( results.is_empty(), "should exclude MRs where user is reviewer" ); } #[test] fn mentioned_in_includes_recently_closed_issue() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_issue_with_state(&conn, 10, 1, 42, "someone", "closed"); // Update updated_at to recent (within 7-day window) conn.execute( "UPDATE issues SET updated_at = ?1 WHERE id = 10", rusqlite::params![now_ms() - 2 * 24 * 3600 * 1000], // 2 days ago ) .unwrap(); let disc_id = 100; insert_discussion(&conn, disc_id, 1, None, Some(10)); let t = now_ms() - 1000; insert_note_at(&conn, 200, disc_id, 1, "bob", false, "hey @alice", t); let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000; let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff, 0).unwrap(); assert_eq!(results.len(), 1, "recently closed issue should be included"); assert_eq!(results[0].state, "closed"); } #[test] fn mentioned_in_excludes_old_closed_issue() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_issue_with_state(&conn, 10, 1, 42, "someone", "closed"); // Update updated_at to old (outside 7-day window) conn.execute( "UPDATE issues SET updated_at = ?1 WHERE id = 10", rusqlite::params![now_ms() - 30 * 24 * 3600 * 1000], // 30 days ago ) .unwrap(); let disc_id = 100; insert_discussion(&conn, disc_id, 1, None, Some(10)); let t = now_ms() - 1000; insert_note_at(&conn, 200, disc_id, 1, "bob", false, "hey @alice", t); let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000; let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff, 0).unwrap(); assert!(results.is_empty(), "old closed issue should be excluded"); } #[test] fn mentioned_in_attention_needs_attention_when_unreplied() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_issue(&conn, 10, 1, 42, "someone"); let disc_id = 100; insert_discussion(&conn, disc_id, 1, None, Some(10)); let t = now_ms() - 1000; insert_note_at( &conn, 200, disc_id, 1, "bob", false, "@alice please review", t, ); // alice has NOT replied let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000; let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff, 0).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].attention_state, AttentionState::NeedsAttention); } #[test] fn mentioned_in_attention_awaiting_when_replied() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_issue(&conn, 10, 1, 42, "someone"); let disc_id = 100; insert_discussion(&conn, disc_id, 1, None, Some(10)); let t1 = now_ms() - 5000; let t2 = now_ms() - 1000; insert_note_at( &conn, 200, disc_id, 1, "bob", false, "@alice please review", t1, ); insert_note_at(&conn, 201, disc_id, 1, "alice", false, "looks good", t2); let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000; let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff, 0).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].attention_state, AttentionState::AwaitingResponse); } #[test] fn mentioned_in_project_filter() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo-a"); insert_project(&conn, 2, "group/repo-b"); insert_issue(&conn, 10, 1, 42, "someone"); insert_issue(&conn, 11, 2, 43, "someone"); let disc_a = 100; let disc_b = 101; insert_discussion(&conn, disc_a, 1, None, Some(10)); insert_discussion(&conn, disc_b, 2, None, Some(11)); let t = now_ms() - 1000; insert_note_at(&conn, 200, disc_a, 1, "bob", false, "@alice", t); insert_note_at(&conn, 201, disc_b, 2, "bob", false, "@alice", t); let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000; let results = query_mentioned_in(&conn, "alice", &[1], recency_cutoff, 0).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].project_path, "group/repo-a"); } #[test] fn mentioned_in_deduplicates_multiple_mentions_same_entity() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_issue(&conn, 10, 1, 42, "someone"); let disc_id = 100; insert_discussion(&conn, disc_id, 1, None, Some(10)); let t1 = now_ms() - 5000; let t2 = now_ms() - 1000; // Two different people mention alice on the same issue insert_note_at(&conn, 200, disc_id, 1, "bob", false, "@alice thoughts?", t1); insert_note_at(&conn, 201, disc_id, 1, "charlie", false, "@alice +1", t2); let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000; let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff, 0).unwrap(); assert_eq!(results.len(), 1, "should deduplicate to one entity"); } #[test] fn mentioned_in_rejects_false_positive_email() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_issue(&conn, 10, 1, 42, "someone"); let disc_id = 100; insert_discussion(&conn, disc_id, 1, None, Some(10)); let t = now_ms() - 1000; insert_note_at( &conn, 200, disc_id, 1, "bob", false, "email foo@alice.com", t, ); let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000; let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff, 0).unwrap(); assert!(results.is_empty(), "email-like text should not match"); } #[test] fn mentioned_in_excludes_old_mention_on_open_issue() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_issue(&conn, 10, 1, 42, "someone"); let disc_id = 100; insert_discussion(&conn, disc_id, 1, None, Some(10)); // Mention from 45 days ago — outside 30-day mention window let t = now_ms() - 45 * 24 * 3600 * 1000; insert_note_at(&conn, 200, disc_id, 1, "bob", false, "hey @alice", t); let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000; let mention_cutoff = now_ms() - 30 * 24 * 3600 * 1000; let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff, mention_cutoff).unwrap(); assert!( results.is_empty(), "mentions older than 30 days should be excluded" ); } #[test] fn mentioned_in_includes_recent_mention_on_open_issue() { let conn = setup_test_db(); insert_project(&conn, 1, "group/repo"); insert_issue(&conn, 10, 1, 42, "someone"); let disc_id = 100; insert_discussion(&conn, disc_id, 1, None, Some(10)); // Mention from 5 days ago — within 30-day window let t = now_ms() - 5 * 24 * 3600 * 1000; insert_note_at(&conn, 200, disc_id, 1, "bob", false, "hey @alice", t); let recency_cutoff = now_ms() - 7 * 24 * 3600 * 1000; let mention_cutoff = now_ms() - 30 * 24 * 3600 * 1000; let results = query_mentioned_in(&conn, "alice", &[], recency_cutoff, mention_cutoff).unwrap(); assert_eq!(results.len(), 1, "recent mentions should be included"); } // ─── Helper Tests ────────────────────────────────────────────────────────── #[test] fn mentioned_in_sql_materializes_core_ctes() { let sql = build_mentioned_in_sql(""); assert!( sql.contains("candidate_issues AS MATERIALIZED"), "candidate_issues should be materialized" ); assert!( sql.contains("candidate_mrs AS MATERIALIZED"), "candidate_mrs should be materialized" ); assert!( sql.contains("note_ts_issue AS MATERIALIZED"), "note_ts_issue should be materialized" ); assert!( sql.contains("note_ts_mr AS MATERIALIZED"), "note_ts_mr should be materialized" ); } #[test] fn parse_attention_state_all_variants() { assert_eq!( parse_attention_state("needs_attention"), AttentionState::NeedsAttention ); assert_eq!( parse_attention_state("not_started"), AttentionState::NotStarted ); assert_eq!( parse_attention_state("awaiting_response"), AttentionState::AwaitingResponse ); assert_eq!(parse_attention_state("stale"), AttentionState::Stale); assert_eq!(parse_attention_state("not_ready"), AttentionState::NotReady); assert_eq!(parse_attention_state("unknown"), AttentionState::NotStarted); } #[test] fn format_attention_reason_not_started() { let reason = format_attention_reason(&AttentionState::NotStarted, None, None, None); assert_eq!(reason, "No discussion yet"); } #[test] fn format_attention_reason_not_ready() { let reason = format_attention_reason(&AttentionState::NotReady, None, None, None); assert_eq!(reason, "Draft with no reviewers assigned"); } #[test] fn format_attention_reason_stale_with_timestamp() { let stale_ts = now_ms() - 35 * 24 * 3600 * 1000; // 35 days ago let reason = format_attention_reason(&AttentionState::Stale, None, None, Some(stale_ts)); assert!(reason.starts_with("No activity for"), "got: {reason}"); // 35 days = 1 month in our duration bucketing assert!(reason.contains("1 month"), "got: {reason}"); } #[test] fn format_attention_reason_needs_attention_both_timestamps() { let my_ts = now_ms() - 2 * 86_400_000; // 2 days ago let others_ts = now_ms() - 3_600_000; // 1 hour ago let reason = format_attention_reason( &AttentionState::NeedsAttention, Some(my_ts), Some(others_ts), Some(others_ts), ); assert!(reason.contains("Others replied"), "got: {reason}"); assert!(reason.contains("you last commented"), "got: {reason}"); } #[test] fn format_attention_reason_needs_attention_no_self_comment() { let others_ts = now_ms() - 3_600_000; // 1 hour ago let reason = format_attention_reason( &AttentionState::NeedsAttention, None, Some(others_ts), Some(others_ts), ); assert!(reason.contains("Others commented"), "got: {reason}"); assert!(reason.contains("you haven't replied"), "got: {reason}"); } #[test] fn format_attention_reason_awaiting_response() { let my_ts = now_ms() - 7_200_000; // 2 hours ago let reason = format_attention_reason( &AttentionState::AwaitingResponse, Some(my_ts), None, Some(my_ts), ); assert!(reason.contains("You replied"), "got: {reason}"); assert!(reason.contains("awaiting others"), "got: {reason}"); } #[test] fn parse_event_type_all_variants() { assert_eq!(parse_event_type("note"), ActivityEventType::Note); assert_eq!(parse_event_type("mention_note"), ActivityEventType::Note); assert_eq!( parse_event_type("status_change"), ActivityEventType::StatusChange ); assert_eq!( parse_event_type("label_change"), ActivityEventType::LabelChange ); assert_eq!(parse_event_type("assign"), ActivityEventType::Assign); assert_eq!(parse_event_type("unassign"), ActivityEventType::Unassign); assert_eq!( parse_event_type("review_request"), ActivityEventType::ReviewRequest ); assert_eq!( parse_event_type("milestone_change"), ActivityEventType::MilestoneChange ); assert_eq!(parse_event_type("unknown"), ActivityEventType::Note); } #[test] fn build_project_clause_empty() { assert_eq!(build_project_clause("i.project_id", &[]), ""); } #[test] fn build_project_clause_single() { let clause = build_project_clause("i.project_id", &[1]); assert_eq!(clause, "AND i.project_id = ?2"); } #[test] fn build_project_clause_multiple() { let clause = build_project_clause("i.project_id", &[1, 2, 3]); assert_eq!(clause, "AND i.project_id IN (?2,?3,?4)"); } #[test] fn build_project_clause_at_custom_start() { let clause = build_project_clause_at("p.id", &[1, 2], 3); assert_eq!(clause, "AND p.id IN (?3,?4)"); }