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).
This commit is contained in:
710
crates/lore-tui/tests/parity_tests.rs
Normal file
710
crates/lore-tui/tests/parity_tests.rs
Normal file
@@ -0,0 +1,710 @@
|
||||
//! 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:?}"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user