refactor: extract unit tests into separate _tests.rs files
Move inline #[cfg(test)] mod tests { ... } blocks from 22 source files
into dedicated _tests.rs companion files, wired via:
#[cfg(test)]
#[path = "module_tests.rs"]
mod tests;
This keeps implementation-focused source files leaner and more scannable
while preserving full access to private items through `use super::*;`.
Modules extracted:
core: db, note_parser, payloads, project, references, sync_run,
timeline_collect, timeline_expand, timeline_seed
cli: list (55 tests), who (75 tests)
documents: extractor (43 tests), regenerator
embedding: change_detector, chunking
gitlab: graphql (wiremock async tests), transformers/issue
ingestion: dirty_tracker, discussions, issues, mr_diffs
Also adds conflicts_with("explain_score") to the --detail flag in the
who command to prevent mutually exclusive flags from being combined.
All 629 unit tests pass. No behavior changes.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
321
src/core/timeline_collect_tests.rs
Normal file
321
src/core/timeline_collect_tests.rs
Normal file
@@ -0,0 +1,321 @@
|
||||
use super::*;
|
||||
use crate::core::db::{create_connection, run_migrations};
|
||||
use std::path::Path;
|
||||
|
||||
fn setup_test_db() -> Connection {
|
||||
let conn = create_connection(Path::new(":memory:")).unwrap();
|
||||
run_migrations(&conn).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
fn insert_project(conn: &Connection) -> i64 {
|
||||
conn.execute(
|
||||
"INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url) VALUES (1, 'group/project', 'https://gitlab.com/group/project')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.last_insert_rowid()
|
||||
}
|
||||
|
||||
fn insert_issue(conn: &Connection, project_id: i64, iid: i64) -> i64 {
|
||||
conn.execute(
|
||||
"INSERT INTO issues (gitlab_id, project_id, iid, title, state, author_username, created_at, updated_at, last_seen_at, web_url) VALUES (?1, ?2, ?3, 'Auth bug', 'opened', 'alice', 1000, 2000, 3000, 'https://gitlab.com/group/project/-/issues/1')",
|
||||
rusqlite::params![iid * 100, project_id, iid],
|
||||
)
|
||||
.unwrap();
|
||||
conn.last_insert_rowid()
|
||||
}
|
||||
|
||||
fn insert_mr(conn: &Connection, project_id: i64, iid: i64, merged_at: Option<i64>) -> i64 {
|
||||
conn.execute(
|
||||
"INSERT INTO merge_requests (gitlab_id, project_id, iid, title, state, author_username, created_at, updated_at, last_seen_at, merged_at, merge_user_username, web_url) VALUES (?1, ?2, ?3, 'Fix auth', 'merged', 'bob', 1000, 5000, 6000, ?4, 'charlie', 'https://gitlab.com/group/project/-/merge_requests/10')",
|
||||
rusqlite::params![iid * 100, project_id, iid, merged_at],
|
||||
)
|
||||
.unwrap();
|
||||
conn.last_insert_rowid()
|
||||
}
|
||||
|
||||
fn make_entity_ref(entity_type: &str, entity_id: i64, iid: i64) -> EntityRef {
|
||||
EntityRef {
|
||||
entity_type: entity_type.to_owned(),
|
||||
entity_id,
|
||||
entity_iid: iid,
|
||||
project_path: "group/project".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_state_event(
|
||||
conn: &Connection,
|
||||
project_id: i64,
|
||||
issue_id: Option<i64>,
|
||||
mr_id: Option<i64>,
|
||||
state: &str,
|
||||
created_at: i64,
|
||||
) {
|
||||
let gitlab_id: i64 = rand::random::<u32>().into();
|
||||
conn.execute(
|
||||
"INSERT INTO resource_state_events (gitlab_id, project_id, issue_id, merge_request_id, state, actor_username, created_at) VALUES (?1, ?2, ?3, ?4, ?5, 'alice', ?6)",
|
||||
rusqlite::params![gitlab_id, project_id, issue_id, mr_id, state, created_at],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn insert_label_event(
|
||||
conn: &Connection,
|
||||
project_id: i64,
|
||||
issue_id: Option<i64>,
|
||||
mr_id: Option<i64>,
|
||||
action: &str,
|
||||
label_name: Option<&str>,
|
||||
created_at: i64,
|
||||
) {
|
||||
let gitlab_id: i64 = rand::random::<u32>().into();
|
||||
conn.execute(
|
||||
"INSERT INTO resource_label_events (gitlab_id, project_id, issue_id, merge_request_id, action, label_name, actor_username, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'alice', ?7)",
|
||||
rusqlite::params![gitlab_id, project_id, issue_id, mr_id, action, label_name, created_at],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn insert_milestone_event(
|
||||
conn: &Connection,
|
||||
project_id: i64,
|
||||
issue_id: Option<i64>,
|
||||
mr_id: Option<i64>,
|
||||
action: &str,
|
||||
milestone_title: Option<&str>,
|
||||
created_at: i64,
|
||||
) {
|
||||
let gitlab_id: i64 = rand::random::<u32>().into();
|
||||
conn.execute(
|
||||
"INSERT INTO resource_milestone_events (gitlab_id, project_id, issue_id, merge_request_id, action, milestone_title, actor_username, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'alice', ?7)",
|
||||
rusqlite::params![gitlab_id, project_id, issue_id, mr_id, action, milestone_title, created_at],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_creation_event() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = insert_project(&conn);
|
||||
let issue_id = insert_issue(&conn, project_id, 1);
|
||||
let seeds = vec![make_entity_ref("issue", issue_id, 1)];
|
||||
|
||||
let (events, _) = collect_events(&conn, &seeds, &[], &[], None, 100).unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(matches!(events[0].event_type, TimelineEventType::Created));
|
||||
assert_eq!(events[0].timestamp, 1000);
|
||||
assert_eq!(events[0].actor, Some("alice".to_owned()));
|
||||
assert!(events[0].is_seed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_state_events() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = insert_project(&conn);
|
||||
let issue_id = insert_issue(&conn, project_id, 1);
|
||||
|
||||
insert_state_event(&conn, project_id, Some(issue_id), None, "closed", 3000);
|
||||
insert_state_event(&conn, project_id, Some(issue_id), None, "reopened", 4000);
|
||||
|
||||
let seeds = vec![make_entity_ref("issue", issue_id, 1)];
|
||||
let (events, _) = collect_events(&conn, &seeds, &[], &[], None, 100).unwrap();
|
||||
|
||||
// Created + 2 state changes = 3
|
||||
assert_eq!(events.len(), 3);
|
||||
assert!(matches!(events[0].event_type, TimelineEventType::Created));
|
||||
assert!(matches!(
|
||||
events[1].event_type,
|
||||
TimelineEventType::StateChanged { ref state } if state == "closed"
|
||||
));
|
||||
assert!(matches!(
|
||||
events[2].event_type,
|
||||
TimelineEventType::StateChanged { ref state } if state == "reopened"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_merged_dedup() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = insert_project(&conn);
|
||||
let mr_id = insert_mr(&conn, project_id, 10, Some(5000));
|
||||
|
||||
// Also add a state event for 'merged' — this should NOT produce a StateChanged
|
||||
insert_state_event(&conn, project_id, None, Some(mr_id), "merged", 5000);
|
||||
|
||||
let seeds = vec![make_entity_ref("merge_request", mr_id, 10)];
|
||||
let (events, _) = collect_events(&conn, &seeds, &[], &[], None, 100).unwrap();
|
||||
|
||||
// Should have Created + Merged (not Created + StateChanged{merged} + Merged)
|
||||
let merged_count = events
|
||||
.iter()
|
||||
.filter(|e| matches!(e.event_type, TimelineEventType::Merged))
|
||||
.count();
|
||||
let state_merged_count = events
|
||||
.iter()
|
||||
.filter(|e| matches!(&e.event_type, TimelineEventType::StateChanged { state } if state == "merged"))
|
||||
.count();
|
||||
|
||||
assert_eq!(merged_count, 1);
|
||||
assert_eq!(state_merged_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_null_label_fallback() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = insert_project(&conn);
|
||||
let issue_id = insert_issue(&conn, project_id, 1);
|
||||
|
||||
insert_label_event(&conn, project_id, Some(issue_id), None, "add", None, 2000);
|
||||
|
||||
let seeds = vec![make_entity_ref("issue", issue_id, 1)];
|
||||
let (events, _) = collect_events(&conn, &seeds, &[], &[], None, 100).unwrap();
|
||||
|
||||
let label_event = events.iter().find(|e| {
|
||||
matches!(&e.event_type, TimelineEventType::LabelAdded { label } if label == "[deleted label]")
|
||||
});
|
||||
assert!(label_event.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_null_milestone_fallback() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = insert_project(&conn);
|
||||
let issue_id = insert_issue(&conn, project_id, 1);
|
||||
|
||||
insert_milestone_event(&conn, project_id, Some(issue_id), None, "add", None, 2000);
|
||||
|
||||
let seeds = vec![make_entity_ref("issue", issue_id, 1)];
|
||||
let (events, _) = collect_events(&conn, &seeds, &[], &[], None, 100).unwrap();
|
||||
|
||||
let ms_event = events.iter().find(|e| {
|
||||
matches!(&e.event_type, TimelineEventType::MilestoneSet { milestone } if milestone == "[deleted milestone]")
|
||||
});
|
||||
assert!(ms_event.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_since_filter() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = insert_project(&conn);
|
||||
let issue_id = insert_issue(&conn, project_id, 1);
|
||||
|
||||
insert_state_event(&conn, project_id, Some(issue_id), None, "closed", 3000);
|
||||
insert_state_event(&conn, project_id, Some(issue_id), None, "reopened", 5000);
|
||||
|
||||
let seeds = vec![make_entity_ref("issue", issue_id, 1)];
|
||||
|
||||
// Since 4000: should exclude Created (1000) and closed (3000)
|
||||
let (events, _) = collect_events(&conn, &seeds, &[], &[], Some(4000), 100).unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].timestamp, 5000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_chronological_sort() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = insert_project(&conn);
|
||||
let issue_id = insert_issue(&conn, project_id, 1);
|
||||
let mr_id = insert_mr(&conn, project_id, 10, Some(4000));
|
||||
|
||||
insert_state_event(&conn, project_id, Some(issue_id), None, "closed", 3000);
|
||||
insert_label_event(
|
||||
&conn,
|
||||
project_id,
|
||||
None,
|
||||
Some(mr_id),
|
||||
"add",
|
||||
Some("bug"),
|
||||
2000,
|
||||
);
|
||||
|
||||
let seeds = vec![
|
||||
make_entity_ref("issue", issue_id, 1),
|
||||
make_entity_ref("merge_request", mr_id, 10),
|
||||
];
|
||||
let (events, _) = collect_events(&conn, &seeds, &[], &[], None, 100).unwrap();
|
||||
|
||||
// Verify chronological order
|
||||
for window in events.windows(2) {
|
||||
assert!(window[0].timestamp <= window[1].timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_respects_limit() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = insert_project(&conn);
|
||||
let issue_id = insert_issue(&conn, project_id, 1);
|
||||
|
||||
for i in 0..20 {
|
||||
insert_state_event(
|
||||
&conn,
|
||||
project_id,
|
||||
Some(issue_id),
|
||||
None,
|
||||
"closed",
|
||||
3000 + i * 100,
|
||||
);
|
||||
}
|
||||
|
||||
let seeds = vec![make_entity_ref("issue", issue_id, 1)];
|
||||
let (events, total) = collect_events(&conn, &seeds, &[], &[], None, 5).unwrap();
|
||||
assert_eq!(events.len(), 5);
|
||||
// 20 state changes + 1 created = 21 total before limit
|
||||
assert_eq!(total, 21);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_evidence_notes_included() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = insert_project(&conn);
|
||||
let issue_id = insert_issue(&conn, project_id, 1);
|
||||
|
||||
let evidence = vec![TimelineEvent {
|
||||
timestamp: 2500,
|
||||
entity_type: "issue".to_owned(),
|
||||
entity_id: issue_id,
|
||||
entity_iid: 1,
|
||||
project_path: "group/project".to_owned(),
|
||||
event_type: TimelineEventType::NoteEvidence {
|
||||
note_id: 42,
|
||||
snippet: "relevant note".to_owned(),
|
||||
discussion_id: Some(1),
|
||||
},
|
||||
summary: "Note by alice".to_owned(),
|
||||
actor: Some("alice".to_owned()),
|
||||
url: None,
|
||||
is_seed: true,
|
||||
}];
|
||||
|
||||
let seeds = vec![make_entity_ref("issue", issue_id, 1)];
|
||||
let (events, _) = collect_events(&conn, &seeds, &[], &evidence, None, 100).unwrap();
|
||||
|
||||
let note_event = events.iter().find(|e| {
|
||||
matches!(
|
||||
&e.event_type,
|
||||
TimelineEventType::NoteEvidence { note_id, .. } if *note_id == 42
|
||||
)
|
||||
});
|
||||
assert!(note_event.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_merged_fallback_to_state_event() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = insert_project(&conn);
|
||||
// MR with merged_at = NULL
|
||||
let mr_id = insert_mr(&conn, project_id, 10, None);
|
||||
|
||||
// But has a state event for 'merged'
|
||||
insert_state_event(&conn, project_id, None, Some(mr_id), "merged", 5000);
|
||||
|
||||
let seeds = vec![make_entity_ref("merge_request", mr_id, 10)];
|
||||
let (events, _) = collect_events(&conn, &seeds, &[], &[], None, 100).unwrap();
|
||||
|
||||
let merged = events
|
||||
.iter()
|
||||
.find(|e| matches!(e.event_type, TimelineEventType::Merged));
|
||||
assert!(merged.is_some());
|
||||
assert_eq!(merged.unwrap().timestamp, 5000);
|
||||
}
|
||||
Reference in New Issue
Block a user