Files
gitlore/crates/lore-tui/tests/parity_tests.rs
teernisse 3e96f19a11 feat(tui): add CLI/TUI parity tests (bd-wrw1)
10 parity tests verifying TUI and CLI query paths return consistent
results from the same SQLite database:
- Dashboard count parity (issues, MRs, discussions, notes)
- Issue list parity (IID ordering, state/author filters, ascending sort)
- MR list parity (IID ordering, state filter)
- Shared field parity (title, state, author, project_path)
- Empty database handling
- Terminal safety sanitization (dangerous sequences stripped)

Uses full-schema in-memory DB via create_connection + run_migrations.
Closes bd-wrw1, bd-2o49 (Phase 5.6 epic).
2026-02-19 08:01:55 -05:00

711 lines
22 KiB
Rust

//! 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<i64> = 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<i64> = 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<i64> = 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<i64> = 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<i64> = 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<i64> = 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<i64> = 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<i64> = 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<i64> = 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<i64> = 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<i64> = 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<i64> = 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:?}"
);
}