//! CLI/TUI parity tests (bd-wrw1). //! //! Verifies that the TUI action layer and CLI query layer return consistent //! results when querying the same SQLite database. Both paths read the same //! tables with different query strategies (TUI uses keyset pagination, CLI //! uses LIMIT-based pagination), so given identical data and filters they //! must agree on entity IIDs, ordering, and counts. //! //! Uses `lore::core::db::{create_connection, run_migrations}` for a //! full-schema in-memory database with deterministic seed data. use std::path::Path; use rusqlite::Connection; use lore::cli::commands::{ListFilters, MrListFilters, query_issues, query_mrs}; use lore::core::db::{create_connection, run_migrations}; use lore_tui::action::{fetch_dashboard, fetch_issue_list, fetch_mr_list}; use lore_tui::clock::FakeClock; use lore_tui::state::issue_list::{IssueFilter, SortField, SortOrder}; use lore_tui::state::mr_list::{MrFilter, MrSortField, MrSortOrder}; use chrono::{TimeZone, Utc}; // --------------------------------------------------------------------------- // Setup: in-memory database with full schema and seed data // --------------------------------------------------------------------------- fn test_conn() -> Connection { let conn = create_connection(Path::new(":memory:")).expect("create in-memory connection"); run_migrations(&conn).expect("run migrations"); conn } fn frozen_clock() -> FakeClock { FakeClock::new(Utc.with_ymd_and_hms(2026, 1, 15, 12, 0, 0).unwrap()) } /// Insert a project and return its id. fn insert_project(conn: &Connection, id: i64, path: &str) -> i64 { conn.execute( "INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url) VALUES (?1, ?1, ?2, ?3)", rusqlite::params![id, path, format!("https://gitlab.com/{path}")], ) .expect("insert project"); id } /// Test issue row for insertion. struct TestIssue<'a> { id: i64, project_id: i64, iid: i64, title: &'a str, state: &'a str, author: &'a str, created_at: i64, updated_at: i64, } /// Insert a test issue. fn insert_issue(conn: &Connection, issue: &TestIssue<'_>) { 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, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8, NULL)", rusqlite::params![ issue.id, issue.project_id, issue.iid, issue.title, issue.state, issue.author, issue.created_at, issue.updated_at ], ) .expect("insert issue"); } /// Test MR row for insertion. struct TestMr<'a> { id: i64, project_id: i64, iid: i64, title: &'a str, state: &'a str, draft: bool, author: &'a str, source_branch: &'a str, target_branch: &'a str, created_at: i64, updated_at: i64, } /// Insert a test merge request. fn insert_mr(conn: &Connection, mr: &TestMr<'_>) { conn.execute( "INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, state, draft, author_username, source_branch, target_branch, created_at, updated_at, last_seen_at, web_url) VALUES (?1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11, NULL)", rusqlite::params![ mr.id, mr.project_id, mr.iid, mr.title, mr.state, i64::from(mr.draft), mr.author, mr.source_branch, mr.target_branch, mr.created_at, mr.updated_at ], ) .expect("insert mr"); } /// Insert a test discussion. fn insert_discussion(conn: &Connection, id: i64, project_id: i64, issue_id: i64) { conn.execute( "INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at) VALUES (?1, ?1, ?2, ?3, 'Issue', 1000)", rusqlite::params![id, project_id, issue_id], ) .expect("insert discussion"); } /// Insert a test note. fn insert_note(conn: &Connection, id: i64, discussion_id: i64, project_id: i64, is_system: bool) { conn.execute( "INSERT INTO notes (id, gitlab_id, discussion_id, project_id, is_system, author_username, body, created_at, updated_at, last_seen_at) VALUES (?1, ?1, ?2, ?3, ?4, 'author', 'body', 1000, 1000, 1000)", rusqlite::params![id, discussion_id, project_id, i64::from(is_system)], ) .expect("insert note"); } /// Seed the database with a deterministic fixture set. /// /// Creates: /// - 1 project /// - 10 issues (5 opened, 5 closed, various authors/timestamps) /// - 5 merge requests (3 opened, 1 merged, 1 closed) /// - 3 discussions + 6 notes (2 system) fn seed_fixture(conn: &Connection) { let pid = insert_project(conn, 1, "group/repo"); // Issues: iid 1..=10, alternating state, varying timestamps. let base_ts: i64 = 1_700_000_000_000; // ~Nov 2023 for i in 1..=10 { let state = if i % 2 == 0 { "closed" } else { "opened" }; let author = if i <= 5 { "alice" } else { "bob" }; let created = base_ts + i * 60_000; let updated = base_ts + i * 120_000; // Strictly increasing for deterministic sort. let title = format!("Issue {i}"); insert_issue( conn, &TestIssue { id: i, project_id: pid, iid: i, title: &title, state, author, created_at: created, updated_at: updated, }, ); } // Merge requests: iid 1..=5. for i in 1..=5 { let (state, draft) = match i { 1..=3 => ("opened", i == 2), 4 => ("merged", false), _ => ("closed", false), }; let created = base_ts + i * 60_000; let updated = base_ts + i * 120_000; let title = format!("MR {i}"); let source = format!("feature-{i}"); insert_mr( conn, &TestMr { id: 100 + i, project_id: pid, iid: i, title: &title, state, draft, author: "alice", source_branch: &source, target_branch: "main", created_at: created, updated_at: updated, }, ); } // Discussions + notes (for count parity). for d in 1..=3 { insert_discussion(conn, d, pid, d); // discussions on issues 1..3 // 2 notes per discussion. let n1 = d * 10; let n2 = d * 10 + 1; insert_note(conn, n1, d, pid, false); insert_note(conn, n2, d, pid, d == 1); // discussion 1 gets a system note } } // --------------------------------------------------------------------------- // Default CLI filters (no filtering, default sort) // --------------------------------------------------------------------------- fn default_issue_filters() -> ListFilters<'static> { ListFilters { limit: 100, project: None, state: None, author: None, assignee: None, labels: None, milestone: None, since: None, due_before: None, has_due_date: false, statuses: &[], sort: "updated", order: "desc", } } fn default_mr_filters() -> MrListFilters<'static> { MrListFilters { limit: 100, project: None, state: None, author: None, assignee: None, reviewer: None, labels: None, since: None, draft: false, no_draft: false, target_branch: None, source_branch: None, sort: "updated", order: "desc", } } // --------------------------------------------------------------------------- // Parity Tests // --------------------------------------------------------------------------- /// Count parity: TUI dashboard entity counts match direct SQL (CLI logic). /// /// The TUI fetches counts via `fetch_dashboard().counts`, while the CLI uses /// private `count_issues`/`count_mrs` with simple COUNT(*) queries. Since both /// query the same tables, counts must agree. #[test] fn test_count_parity_dashboard_vs_sql() { let conn = test_conn(); seed_fixture(&conn); // TUI path: fetch_dashboard returns EntityCounts. let clock = frozen_clock(); let dashboard = fetch_dashboard(&conn, &clock).expect("fetch_dashboard"); let counts = &dashboard.counts; // CLI-equivalent: direct SQL matching the CLI's count logic. let issues_total: i64 = conn .query_row("SELECT COUNT(*) FROM issues", [], |r| r.get(0)) .unwrap(); let issues_open: i64 = conn .query_row( "SELECT COUNT(*) FROM issues WHERE state = 'opened'", [], |r| r.get(0), ) .unwrap(); let mrs_total: i64 = conn .query_row("SELECT COUNT(*) FROM merge_requests", [], |r| r.get(0)) .unwrap(); let mrs_open: i64 = conn .query_row( "SELECT COUNT(*) FROM merge_requests WHERE state = 'opened'", [], |r| r.get(0), ) .unwrap(); let discussions: i64 = conn .query_row("SELECT COUNT(*) FROM discussions", [], |r| r.get(0)) .unwrap(); let notes_total: i64 = conn .query_row("SELECT COUNT(*) FROM notes", [], |r| r.get(0)) .unwrap(); assert_eq!(counts.issues_total, issues_total as u64, "issues_total"); assert_eq!(counts.issues_open, issues_open as u64, "issues_open"); assert_eq!(counts.mrs_total, mrs_total as u64, "mrs_total"); assert_eq!(counts.mrs_open, mrs_open as u64, "mrs_open"); assert_eq!(counts.discussions, discussions as u64, "discussions"); assert_eq!(counts.notes_total, notes_total as u64, "notes_total"); // Verify known fixture counts. assert_eq!(counts.issues_total, 10); assert_eq!(counts.issues_open, 5); // odd IIDs are opened assert_eq!(counts.mrs_total, 5); assert_eq!(counts.mrs_open, 3); // iid 1,2,3 opened assert_eq!(counts.discussions, 3); assert_eq!(counts.notes_total, 6); // 2 per discussion } /// Issue list parity: TUI and CLI return the same IIDs in the same order. /// /// TUI uses keyset pagination (`fetch_issue_list`), CLI uses LIMIT-based /// (`query_issues`). Both sorted by updated_at DESC with no filters. #[test] fn test_issue_list_parity_iids_and_order() { let conn = test_conn(); seed_fixture(&conn); // CLI path. let cli_result = query_issues(&conn, &default_issue_filters()).expect("CLI query_issues"); let cli_iids: Vec = cli_result.issues.iter().map(|r| r.iid).collect(); // TUI path: first page (no cursor, no fence — equivalent to CLI's initial query). let tui_page = fetch_issue_list( &conn, &IssueFilter::default(), SortField::UpdatedAt, SortOrder::Desc, None, None, ) .expect("TUI fetch_issue_list"); let tui_iids: Vec = tui_page.rows.iter().map(|r| r.iid).collect(); // Both should see all 10 issues in the same descending updated_at order. assert_eq!(cli_result.total_count, 10); assert_eq!(tui_page.total_count, 10); assert_eq!( cli_iids, tui_iids, "CLI and TUI issue IID order must match.\nCLI: {cli_iids:?}\nTUI: {tui_iids:?}" ); // Verify descending order (iid 10 has highest updated_at). assert_eq!(cli_iids[0], 10, "most recently updated should be iid 10"); assert_eq!( *cli_iids.last().unwrap(), 1, "oldest updated should be iid 1" ); } /// Issue list parity with state filter: both paths agree on filtered results. #[test] fn test_issue_list_parity_state_filter() { let conn = test_conn(); seed_fixture(&conn); // CLI: filter state = "opened". let mut cli_filters = default_issue_filters(); cli_filters.state = Some("opened"); let cli_result = query_issues(&conn, &cli_filters).expect("CLI opened issues"); let cli_iids: Vec = cli_result.issues.iter().map(|r| r.iid).collect(); // TUI: filter state = "opened". let tui_filter = IssueFilter { state: Some("opened".into()), ..Default::default() }; let tui_page = fetch_issue_list( &conn, &tui_filter, SortField::UpdatedAt, SortOrder::Desc, None, None, ) .expect("TUI opened issues"); let tui_iids: Vec = tui_page.rows.iter().map(|r| r.iid).collect(); assert_eq!(cli_result.total_count, 5, "CLI count for opened"); assert_eq!(tui_page.total_count, 5, "TUI count for opened"); assert_eq!( cli_iids, tui_iids, "Filtered IIDs must match.\nCLI: {cli_iids:?}\nTUI: {tui_iids:?}" ); // All returned IIDs should be odd (our fixture alternates). for iid in &cli_iids { assert!( iid % 2 == 1, "opened issues should have odd IIDs, got {iid}" ); } } /// Issue list parity with author filter. #[test] fn test_issue_list_parity_author_filter() { let conn = test_conn(); seed_fixture(&conn); // CLI: filter author = "alice" (issues 1..=5). let mut cli_filters = default_issue_filters(); cli_filters.author = Some("alice"); let cli_result = query_issues(&conn, &cli_filters).expect("CLI alice issues"); let cli_iids: Vec = cli_result.issues.iter().map(|r| r.iid).collect(); // TUI: filter author = "alice". let tui_filter = IssueFilter { author: Some("alice".into()), ..Default::default() }; let tui_page = fetch_issue_list( &conn, &tui_filter, SortField::UpdatedAt, SortOrder::Desc, None, None, ) .expect("TUI alice issues"); let tui_iids: Vec = tui_page.rows.iter().map(|r| r.iid).collect(); assert_eq!(cli_result.total_count, 5, "CLI count for alice"); assert_eq!(tui_page.total_count, 5, "TUI count for alice"); assert_eq!(cli_iids, tui_iids, "Author-filtered IIDs must match"); // All returned IIDs should be <= 5 (alice authors issues 1-5). for iid in &cli_iids { assert!(*iid <= 5, "alice issues should have IID <= 5, got {iid}"); } } /// MR list parity: TUI and CLI return the same IIDs in the same order. #[test] fn test_mr_list_parity_iids_and_order() { let conn = test_conn(); seed_fixture(&conn); // CLI path. let cli_result = query_mrs(&conn, &default_mr_filters()).expect("CLI query_mrs"); let cli_iids: Vec = cli_result.mrs.iter().map(|r| r.iid).collect(); // TUI path. let tui_page = fetch_mr_list( &conn, &MrFilter::default(), MrSortField::UpdatedAt, MrSortOrder::Desc, None, None, ) .expect("TUI fetch_mr_list"); let tui_iids: Vec = tui_page.rows.iter().map(|r| r.iid).collect(); assert_eq!(cli_result.total_count, 5, "CLI MR count"); assert_eq!(tui_page.total_count, 5, "TUI MR count"); assert_eq!( cli_iids, tui_iids, "CLI and TUI MR IID order must match.\nCLI: {cli_iids:?}\nTUI: {tui_iids:?}" ); // Verify descending order. assert_eq!(cli_iids[0], 5, "most recently updated MR should be iid 5"); } /// MR list parity with state filter. #[test] fn test_mr_list_parity_state_filter() { let conn = test_conn(); seed_fixture(&conn); // CLI: filter state = "opened". let mut cli_filters = default_mr_filters(); cli_filters.state = Some("opened"); let cli_result = query_mrs(&conn, &cli_filters).expect("CLI opened MRs"); let cli_iids: Vec = cli_result.mrs.iter().map(|r| r.iid).collect(); // TUI: filter state = "opened". let tui_filter = MrFilter { state: Some("opened".into()), ..Default::default() }; let tui_page = fetch_mr_list( &conn, &tui_filter, MrSortField::UpdatedAt, MrSortOrder::Desc, None, None, ) .expect("TUI opened MRs"); let tui_iids: Vec = tui_page.rows.iter().map(|r| r.iid).collect(); assert_eq!(cli_result.total_count, 3, "CLI opened MR count"); assert_eq!(tui_page.total_count, 3, "TUI opened MR count"); assert_eq!(cli_iids, tui_iids, "Opened MR IIDs must match"); } /// Shared field parity: verify overlapping fields agree between CLI and TUI. /// /// CLI IssueListRow has more fields (discussion_count, assignees, web_url), /// but the shared fields (iid, title, state, author) must be identical. #[test] fn test_issue_shared_fields_parity() { let conn = test_conn(); seed_fixture(&conn); let cli_result = query_issues(&conn, &default_issue_filters()).expect("CLI issues"); let tui_page = fetch_issue_list( &conn, &IssueFilter::default(), SortField::UpdatedAt, SortOrder::Desc, None, None, ) .expect("TUI issues"); assert_eq!( cli_result.issues.len(), tui_page.rows.len(), "row count must match" ); for (cli_row, tui_row) in cli_result.issues.iter().zip(tui_page.rows.iter()) { assert_eq!(cli_row.iid, tui_row.iid, "IID mismatch"); assert_eq!( cli_row.title, tui_row.title, "title mismatch for iid {}", cli_row.iid ); assert_eq!( cli_row.state, tui_row.state, "state mismatch for iid {}", cli_row.iid ); assert_eq!( cli_row.author_username, tui_row.author, "author mismatch for iid {}", cli_row.iid ); assert_eq!( cli_row.project_path, tui_row.project_path, "project_path mismatch for iid {}", cli_row.iid ); } } /// Sort order parity: ascending sort returns the same order in both paths. #[test] fn test_issue_list_parity_ascending_sort() { let conn = test_conn(); seed_fixture(&conn); // CLI: ascending by updated_at. let mut cli_filters = default_issue_filters(); cli_filters.order = "asc"; let cli_result = query_issues(&conn, &cli_filters).expect("CLI asc issues"); let cli_iids: Vec = cli_result.issues.iter().map(|r| r.iid).collect(); // TUI: ascending by updated_at. let tui_page = fetch_issue_list( &conn, &IssueFilter::default(), SortField::UpdatedAt, SortOrder::Asc, None, None, ) .expect("TUI asc issues"); let tui_iids: Vec = tui_page.rows.iter().map(|r| r.iid).collect(); assert_eq!( cli_iids, tui_iids, "Ascending sort order must match.\nCLI: {cli_iids:?}\nTUI: {tui_iids:?}" ); // Ascending: iid 1 has lowest updated_at. assert_eq!(cli_iids[0], 1); assert_eq!(*cli_iids.last().unwrap(), 10); } /// Empty database parity: both paths handle zero rows gracefully. #[test] fn test_empty_database_parity() { let conn = test_conn(); // No seed — empty DB. // Dashboard counts should all be zero. let clock = frozen_clock(); let dashboard = fetch_dashboard(&conn, &clock).expect("fetch_dashboard empty"); assert_eq!(dashboard.counts.issues_total, 0); assert_eq!(dashboard.counts.mrs_total, 0); assert_eq!(dashboard.counts.discussions, 0); assert_eq!(dashboard.counts.notes_total, 0); // Issue list: both empty. let cli_result = query_issues(&conn, &default_issue_filters()).expect("CLI empty"); let tui_page = fetch_issue_list( &conn, &IssueFilter::default(), SortField::UpdatedAt, SortOrder::Desc, None, None, ) .expect("TUI empty"); assert_eq!(cli_result.total_count, 0); assert_eq!(tui_page.total_count, 0); assert!(cli_result.issues.is_empty()); assert!(tui_page.rows.is_empty()); // MR list: both empty. let cli_mrs = query_mrs(&conn, &default_mr_filters()).expect("CLI empty MRs"); let tui_mrs = fetch_mr_list( &conn, &MrFilter::default(), MrSortField::UpdatedAt, MrSortOrder::Desc, None, None, ) .expect("TUI empty MRs"); assert_eq!(cli_mrs.total_count, 0); assert_eq!(tui_mrs.total_count, 0); } /// Sanitization: TUI safety module strips dangerous escape sequences /// while preserving safe SGR. Both paths return raw data from the DB, /// and the safety module is applied at the view layer. #[test] fn test_sanitization_dangerous_sequences_stripped() { let conn = test_conn(); insert_project(&conn, 1, "group/repo"); // Dangerous title: cursor movement (CSI 2A = move up 2) + bidi override. let dangerous_title = "normal\x1b[2Ahidden\u{202E}reversed"; insert_issue( &conn, &TestIssue { id: 1, project_id: 1, iid: 1, title: dangerous_title, state: "opened", author: "alice", created_at: 1000, updated_at: 2000, }, ); // Both CLI and TUI data layers return raw titles. let cli_result = query_issues(&conn, &default_issue_filters()).expect("CLI dangerous issue"); let tui_page = fetch_issue_list( &conn, &IssueFilter::default(), SortField::UpdatedAt, SortOrder::Desc, None, None, ) .expect("TUI dangerous issue"); // Data layer parity: both return the raw title. assert_eq!(cli_result.issues[0].title, dangerous_title); assert_eq!(tui_page.rows[0].title, dangerous_title); // Safety module strips dangerous sequences but preserves text. use lore_tui::safety::{UrlPolicy, sanitize_for_terminal}; let sanitized = sanitize_for_terminal(&tui_page.rows[0].title, UrlPolicy::Strip); // Cursor movement sequence (ESC[2A) should be stripped. assert!( !sanitized.contains('\x1b'), "sanitized should have no ESC: {sanitized:?}" ); // Bidi override (U+202E) should be stripped. assert!( !sanitized.contains('\u{202E}'), "sanitized should have no bidi overrides: {sanitized:?}" ); // Safe text should be preserved. assert!( sanitized.contains("normal"), "should preserve 'normal': {sanitized:?}" ); assert!( sanitized.contains("hidden"), "should preserve 'hidden': {sanitized:?}" ); assert!( sanitized.contains("reversed"), "should preserve 'reversed': {sanitized:?}" ); }