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:
File diff suppressed because it is too large
Load Diff
1393
src/cli/commands/list_tests.rs
Normal file
1393
src/cli/commands/list_tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
3267
src/cli/commands/who_tests.rs
Normal file
3267
src/cli/commands/who_tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -905,7 +905,12 @@ pub struct WhoArgs {
|
||||
pub fields: Option<Vec<String>>,
|
||||
|
||||
/// Show per-MR detail breakdown (expert mode only)
|
||||
#[arg(long, help_heading = "Output", overrides_with = "no_detail")]
|
||||
#[arg(
|
||||
long,
|
||||
help_heading = "Output",
|
||||
overrides_with = "no_detail",
|
||||
conflicts_with = "explain_score"
|
||||
)]
|
||||
pub detail: bool,
|
||||
|
||||
#[arg(long = "no-detail", hide = true, overrides_with = "detail")]
|
||||
|
||||
636
src/core/db.rs
636
src/core/db.rs
@@ -334,637 +334,5 @@ pub fn get_schema_version(conn: &Connection) -> i32 {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn setup_migrated_db() -> Connection {
|
||||
let conn = create_connection(Path::new(":memory:")).unwrap();
|
||||
run_migrations(&conn).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
fn index_exists(conn: &Connection, index_name: &str) -> bool {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='index' AND name=?1",
|
||||
[index_name],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn column_exists(conn: &Connection, table: &str, column: &str) -> bool {
|
||||
let sql = format!("PRAGMA table_info({})", table);
|
||||
let mut stmt = conn.prepare(&sql).unwrap();
|
||||
let columns: Vec<String> = stmt
|
||||
.query_map([], |row| row.get::<_, String>(1))
|
||||
.unwrap()
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
columns.contains(&column.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_022_indexes_exist() {
|
||||
let conn = setup_migrated_db();
|
||||
|
||||
// New indexes from migration 022
|
||||
assert!(
|
||||
index_exists(&conn, "idx_notes_user_created"),
|
||||
"idx_notes_user_created should exist"
|
||||
);
|
||||
assert!(
|
||||
index_exists(&conn, "idx_notes_project_created"),
|
||||
"idx_notes_project_created should exist"
|
||||
);
|
||||
assert!(
|
||||
index_exists(&conn, "idx_notes_author_id"),
|
||||
"idx_notes_author_id should exist"
|
||||
);
|
||||
|
||||
// Discussion JOIN indexes (idx_discussions_issue_id is new;
|
||||
// idx_discussions_mr_id already existed from migration 006 but
|
||||
// IF NOT EXISTS makes it safe)
|
||||
assert!(
|
||||
index_exists(&conn, "idx_discussions_issue_id"),
|
||||
"idx_discussions_issue_id should exist"
|
||||
);
|
||||
assert!(
|
||||
index_exists(&conn, "idx_discussions_mr_id"),
|
||||
"idx_discussions_mr_id should exist"
|
||||
);
|
||||
|
||||
// author_id column on notes
|
||||
assert!(
|
||||
column_exists(&conn, "notes", "author_id"),
|
||||
"notes.author_id column should exist"
|
||||
);
|
||||
}
|
||||
|
||||
// -- Helper: insert a minimal project for FK satisfaction --
|
||||
fn insert_test_project(conn: &Connection) -> i64 {
|
||||
conn.execute(
|
||||
"INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url) \
|
||||
VALUES (1000, 'test/project', 'https://example.com/test/project')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.last_insert_rowid()
|
||||
}
|
||||
|
||||
// -- Helper: insert a minimal issue --
|
||||
fn insert_test_issue(conn: &Connection, project_id: i64) -> i64 {
|
||||
conn.execute(
|
||||
"INSERT INTO issues (gitlab_id, project_id, iid, state, author_username, \
|
||||
created_at, updated_at, last_seen_at) \
|
||||
VALUES (100, ?1, 1, 'opened', 'alice', 1000, 1000, 1000)",
|
||||
[project_id],
|
||||
)
|
||||
.unwrap();
|
||||
conn.last_insert_rowid()
|
||||
}
|
||||
|
||||
// -- Helper: insert a minimal discussion --
|
||||
fn insert_test_discussion(conn: &Connection, project_id: i64, issue_id: i64) -> i64 {
|
||||
conn.execute(
|
||||
"INSERT INTO discussions (gitlab_discussion_id, project_id, issue_id, \
|
||||
noteable_type, last_seen_at) \
|
||||
VALUES ('disc-001', ?1, ?2, 'Issue', 1000)",
|
||||
rusqlite::params![project_id, issue_id],
|
||||
)
|
||||
.unwrap();
|
||||
conn.last_insert_rowid()
|
||||
}
|
||||
|
||||
// -- Helper: insert a minimal non-system note --
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn insert_test_note(
|
||||
conn: &Connection,
|
||||
gitlab_id: i64,
|
||||
discussion_id: i64,
|
||||
project_id: i64,
|
||||
is_system: bool,
|
||||
) -> i64 {
|
||||
conn.execute(
|
||||
"INSERT INTO notes (gitlab_id, discussion_id, project_id, is_system, \
|
||||
author_username, body, created_at, updated_at, last_seen_at) \
|
||||
VALUES (?1, ?2, ?3, ?4, 'alice', 'note body', 1000, 1000, 1000)",
|
||||
rusqlite::params![gitlab_id, discussion_id, project_id, is_system as i32],
|
||||
)
|
||||
.unwrap();
|
||||
conn.last_insert_rowid()
|
||||
}
|
||||
|
||||
// -- Helper: insert a document --
|
||||
fn insert_test_document(
|
||||
conn: &Connection,
|
||||
source_type: &str,
|
||||
source_id: i64,
|
||||
project_id: i64,
|
||||
) -> i64 {
|
||||
conn.execute(
|
||||
"INSERT INTO documents (source_type, source_id, project_id, content_text, content_hash) \
|
||||
VALUES (?1, ?2, ?3, 'test content', 'hash123')",
|
||||
rusqlite::params![source_type, source_id, project_id],
|
||||
)
|
||||
.unwrap();
|
||||
conn.last_insert_rowid()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_024_allows_note_source_type() {
|
||||
let conn = setup_migrated_db();
|
||||
let pid = insert_test_project(&conn);
|
||||
|
||||
// Should succeed — 'note' is now allowed
|
||||
conn.execute(
|
||||
"INSERT INTO documents (source_type, source_id, project_id, content_text, content_hash) \
|
||||
VALUES ('note', 1, ?1, 'note content', 'hash-note')",
|
||||
[pid],
|
||||
)
|
||||
.expect("INSERT with source_type='note' into documents should succeed");
|
||||
|
||||
// dirty_sources should also accept 'note'
|
||||
conn.execute(
|
||||
"INSERT INTO dirty_sources (source_type, source_id, queued_at) \
|
||||
VALUES ('note', 1, 1000)",
|
||||
[],
|
||||
)
|
||||
.expect("INSERT with source_type='note' into dirty_sources should succeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_024_preserves_existing_data() {
|
||||
// Run migrations up to 023 only, insert data, then apply 024
|
||||
// Migration 024 is at index 23 (0-based). Use hardcoded index so adding
|
||||
// later migrations doesn't silently shift what this test exercises.
|
||||
let conn = create_connection(Path::new(":memory:")).unwrap();
|
||||
|
||||
// Apply migrations 001-023 (indices 0..23)
|
||||
run_migrations_up_to(&conn, 23);
|
||||
|
||||
let pid = insert_test_project(&conn);
|
||||
|
||||
// Insert a document with existing source_type
|
||||
conn.execute(
|
||||
"INSERT INTO documents (source_type, source_id, project_id, content_text, content_hash, title) \
|
||||
VALUES ('issue', 1, ?1, 'issue content', 'hash-issue', 'Test Issue')",
|
||||
[pid],
|
||||
)
|
||||
.unwrap();
|
||||
let doc_id: i64 = conn.last_insert_rowid();
|
||||
|
||||
// Insert junction data
|
||||
conn.execute(
|
||||
"INSERT INTO document_labels (document_id, label_name) VALUES (?1, 'bug')",
|
||||
[doc_id],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO document_paths (document_id, path) VALUES (?1, 'src/main.rs')",
|
||||
[doc_id],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Insert dirty_sources row
|
||||
conn.execute(
|
||||
"INSERT INTO dirty_sources (source_type, source_id, queued_at) VALUES ('issue', 1, 1000)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Now apply migration 024 (index 23) — the table-rebuild migration
|
||||
run_single_migration(&conn, 23);
|
||||
|
||||
// Verify document still exists with correct data
|
||||
let (st, content, title): (String, String, String) = conn
|
||||
.query_row(
|
||||
"SELECT source_type, content_text, title FROM documents WHERE id = ?1",
|
||||
[doc_id],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(st, "issue");
|
||||
assert_eq!(content, "issue content");
|
||||
assert_eq!(title, "Test Issue");
|
||||
|
||||
// Verify junction data preserved
|
||||
let label_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM document_labels WHERE document_id = ?1",
|
||||
[doc_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(label_count, 1);
|
||||
|
||||
let path_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM document_paths WHERE document_id = ?1",
|
||||
[doc_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(path_count, 1);
|
||||
|
||||
// Verify dirty_sources preserved
|
||||
let dirty_count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM dirty_sources", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(dirty_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_024_fts_triggers_intact() {
|
||||
let conn = setup_migrated_db();
|
||||
let pid = insert_test_project(&conn);
|
||||
|
||||
// Insert a document after migration — FTS trigger should fire
|
||||
let doc_id = insert_test_document(&conn, "note", 1, pid);
|
||||
|
||||
// Verify FTS entry exists
|
||||
let fts_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM documents_fts WHERE documents_fts MATCH 'test'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(fts_count > 0, "FTS trigger should have created an entry");
|
||||
|
||||
// Verify update trigger works
|
||||
conn.execute(
|
||||
"UPDATE documents SET content_text = 'updated content' WHERE id = ?1",
|
||||
[doc_id],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let fts_updated: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM documents_fts WHERE documents_fts MATCH 'updated'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
fts_updated > 0,
|
||||
"FTS update trigger should reflect new content"
|
||||
);
|
||||
|
||||
// Verify delete trigger works
|
||||
conn.execute("DELETE FROM documents WHERE id = ?1", [doc_id])
|
||||
.unwrap();
|
||||
|
||||
let fts_after_delete: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM documents_fts WHERE documents_fts MATCH 'updated'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
fts_after_delete, 0,
|
||||
"FTS delete trigger should remove the entry"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_024_row_counts_preserved() {
|
||||
let conn = setup_migrated_db();
|
||||
|
||||
// After full migration, tables should exist and be queryable
|
||||
let doc_count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM documents", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(doc_count, 0, "Fresh DB should have 0 documents");
|
||||
|
||||
let dirty_count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM dirty_sources", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(dirty_count, 0, "Fresh DB should have 0 dirty_sources");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_024_integrity_checks_pass() {
|
||||
let conn = setup_migrated_db();
|
||||
|
||||
// PRAGMA integrity_check
|
||||
let integrity: String = conn
|
||||
.query_row("PRAGMA integrity_check", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(integrity, "ok", "Database integrity check should pass");
|
||||
|
||||
// PRAGMA foreign_key_check (returns rows only if there are violations)
|
||||
let fk_violations: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(fk_violations, 0, "No foreign key violations should exist");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_024_note_delete_trigger_cleans_document() {
|
||||
let conn = setup_migrated_db();
|
||||
let pid = insert_test_project(&conn);
|
||||
let issue_id = insert_test_issue(&conn, pid);
|
||||
let disc_id = insert_test_discussion(&conn, pid, issue_id);
|
||||
let note_id = insert_test_note(&conn, 200, disc_id, pid, false);
|
||||
|
||||
// Create a document for this note
|
||||
insert_test_document(&conn, "note", note_id, pid);
|
||||
|
||||
let doc_before: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM documents WHERE source_type = 'note' AND source_id = ?1",
|
||||
[note_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(doc_before, 1);
|
||||
|
||||
// Delete the note — trigger should remove the document
|
||||
conn.execute("DELETE FROM notes WHERE id = ?1", [note_id])
|
||||
.unwrap();
|
||||
|
||||
let doc_after: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM documents WHERE source_type = 'note' AND source_id = ?1",
|
||||
[note_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
doc_after, 0,
|
||||
"notes_ad_cleanup trigger should delete the document"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_024_note_system_flip_trigger_cleans_document() {
|
||||
let conn = setup_migrated_db();
|
||||
let pid = insert_test_project(&conn);
|
||||
let issue_id = insert_test_issue(&conn, pid);
|
||||
let disc_id = insert_test_discussion(&conn, pid, issue_id);
|
||||
let note_id = insert_test_note(&conn, 201, disc_id, pid, false);
|
||||
|
||||
// Create a document for this note
|
||||
insert_test_document(&conn, "note", note_id, pid);
|
||||
|
||||
let doc_before: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM documents WHERE source_type = 'note' AND source_id = ?1",
|
||||
[note_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(doc_before, 1);
|
||||
|
||||
// Flip is_system from 0 to 1 — trigger should remove the document
|
||||
conn.execute("UPDATE notes SET is_system = 1 WHERE id = ?1", [note_id])
|
||||
.unwrap();
|
||||
|
||||
let doc_after: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM documents WHERE source_type = 'note' AND source_id = ?1",
|
||||
[note_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
doc_after, 0,
|
||||
"notes_au_system_cleanup trigger should delete the document"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_024_system_note_delete_trigger_does_not_fire() {
|
||||
let conn = setup_migrated_db();
|
||||
let pid = insert_test_project(&conn);
|
||||
let issue_id = insert_test_issue(&conn, pid);
|
||||
let disc_id = insert_test_discussion(&conn, pid, issue_id);
|
||||
|
||||
// Insert a system note (is_system = true)
|
||||
let note_id = insert_test_note(&conn, 202, disc_id, pid, true);
|
||||
|
||||
// Manually insert a document (shouldn't exist for system notes in practice,
|
||||
// but we test the trigger guard)
|
||||
insert_test_document(&conn, "note", note_id, pid);
|
||||
|
||||
let doc_before: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM documents WHERE source_type = 'note' AND source_id = ?1",
|
||||
[note_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(doc_before, 1);
|
||||
|
||||
// Delete system note — trigger has WHEN old.is_system = 0 so it should NOT fire
|
||||
conn.execute("DELETE FROM notes WHERE id = ?1", [note_id])
|
||||
.unwrap();
|
||||
|
||||
let doc_after: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM documents WHERE source_type = 'note' AND source_id = ?1",
|
||||
[note_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
doc_after, 1,
|
||||
"notes_ad_cleanup trigger should NOT fire for system notes"
|
||||
);
|
||||
}
|
||||
|
||||
/// Run migrations only up to version `up_to` (inclusive).
|
||||
fn run_migrations_up_to(conn: &Connection, up_to: usize) {
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS schema_version ( \
|
||||
version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL, description TEXT);",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
for (version_str, sql) in &MIGRATIONS[..up_to] {
|
||||
let version: i32 = version_str.parse().unwrap();
|
||||
conn.execute_batch(sql).unwrap();
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO schema_version (version, applied_at, description) \
|
||||
VALUES (?1, strftime('%s', 'now') * 1000, ?2)",
|
||||
rusqlite::params![version, version_str],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a single migration by index (0-based).
|
||||
fn run_single_migration(conn: &Connection, index: usize) {
|
||||
let (version_str, sql) = MIGRATIONS[index];
|
||||
let version: i32 = version_str.parse().unwrap();
|
||||
conn.execute_batch(sql).unwrap();
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO schema_version (version, applied_at, description) \
|
||||
VALUES (?1, strftime('%s', 'now') * 1000, ?2)",
|
||||
rusqlite::params![version, version_str],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_025_backfills_existing_notes() {
|
||||
let conn = create_connection(Path::new(":memory:")).unwrap();
|
||||
// Run all migrations through 024 (index 0..24)
|
||||
run_migrations_up_to(&conn, 24);
|
||||
|
||||
let pid = insert_test_project(&conn);
|
||||
let issue_id = insert_test_issue(&conn, pid);
|
||||
let disc_id = insert_test_discussion(&conn, pid, issue_id);
|
||||
|
||||
// Insert 5 non-system notes
|
||||
for i in 1..=5 {
|
||||
insert_test_note(&conn, 300 + i, disc_id, pid, false);
|
||||
}
|
||||
// Insert 2 system notes
|
||||
for i in 1..=2 {
|
||||
insert_test_note(&conn, 400 + i, disc_id, pid, true);
|
||||
}
|
||||
|
||||
// Run migration 025
|
||||
run_single_migration(&conn, 24);
|
||||
|
||||
let dirty_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM dirty_sources WHERE source_type = 'note'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
dirty_count, 5,
|
||||
"Migration 025 should backfill 5 non-system notes"
|
||||
);
|
||||
|
||||
// Verify system notes were not backfilled
|
||||
let system_note_ids: Vec<i64> = {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT source_id FROM dirty_sources WHERE source_type = 'note' ORDER BY source_id",
|
||||
)
|
||||
.unwrap();
|
||||
stmt.query_map([], |row| row.get(0))
|
||||
.unwrap()
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.unwrap()
|
||||
};
|
||||
// System note ids should not appear
|
||||
let all_system_note_ids: Vec<i64> = {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id FROM notes WHERE is_system = 1 ORDER BY id")
|
||||
.unwrap();
|
||||
stmt.query_map([], |row| row.get(0))
|
||||
.unwrap()
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.unwrap()
|
||||
};
|
||||
for sys_id in &all_system_note_ids {
|
||||
assert!(
|
||||
!system_note_ids.contains(sys_id),
|
||||
"System note id {} should not be in dirty_sources",
|
||||
sys_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_025_idempotent_with_existing_documents() {
|
||||
let conn = create_connection(Path::new(":memory:")).unwrap();
|
||||
run_migrations_up_to(&conn, 24);
|
||||
|
||||
let pid = insert_test_project(&conn);
|
||||
let issue_id = insert_test_issue(&conn, pid);
|
||||
let disc_id = insert_test_discussion(&conn, pid, issue_id);
|
||||
|
||||
// Insert 3 non-system notes
|
||||
let note_ids: Vec<i64> = (1..=3)
|
||||
.map(|i| insert_test_note(&conn, 500 + i, disc_id, pid, false))
|
||||
.collect();
|
||||
|
||||
// Create documents for 2 of 3 notes (simulating already-generated docs)
|
||||
insert_test_document(&conn, "note", note_ids[0], pid);
|
||||
insert_test_document(&conn, "note", note_ids[1], pid);
|
||||
|
||||
// Run migration 025
|
||||
run_single_migration(&conn, 24);
|
||||
|
||||
let dirty_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM dirty_sources WHERE source_type = 'note'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
dirty_count, 1,
|
||||
"Only the note without a document should be backfilled"
|
||||
);
|
||||
|
||||
// Verify the correct note was queued
|
||||
let queued_id: i64 = conn
|
||||
.query_row(
|
||||
"SELECT source_id FROM dirty_sources WHERE source_type = 'note'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(queued_id, note_ids[2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_025_skips_notes_already_in_dirty_queue() {
|
||||
let conn = create_connection(Path::new(":memory:")).unwrap();
|
||||
run_migrations_up_to(&conn, 24);
|
||||
|
||||
let pid = insert_test_project(&conn);
|
||||
let issue_id = insert_test_issue(&conn, pid);
|
||||
let disc_id = insert_test_discussion(&conn, pid, issue_id);
|
||||
|
||||
// Insert 3 non-system notes
|
||||
let note_ids: Vec<i64> = (1..=3)
|
||||
.map(|i| insert_test_note(&conn, 600 + i, disc_id, pid, false))
|
||||
.collect();
|
||||
|
||||
// Pre-queue one note in dirty_sources
|
||||
conn.execute(
|
||||
"INSERT INTO dirty_sources (source_type, source_id, queued_at) VALUES ('note', ?1, 999)",
|
||||
[note_ids[0]],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Run migration 025
|
||||
run_single_migration(&conn, 24);
|
||||
|
||||
let dirty_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM dirty_sources WHERE source_type = 'note'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
dirty_count, 3,
|
||||
"All 3 notes should be in dirty_sources (1 pre-existing + 2 new)"
|
||||
);
|
||||
|
||||
// Verify the pre-existing entry preserved its original queued_at
|
||||
let original_queued_at: i64 = conn
|
||||
.query_row(
|
||||
"SELECT queued_at FROM dirty_sources WHERE source_type = 'note' AND source_id = ?1",
|
||||
[note_ids[0]],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
original_queued_at, 999,
|
||||
"ON CONFLICT DO NOTHING should preserve the original queued_at"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[path = "db_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
632
src/core/db_tests.rs
Normal file
632
src/core/db_tests.rs
Normal file
@@ -0,0 +1,632 @@
|
||||
use super::*;
|
||||
|
||||
fn setup_migrated_db() -> Connection {
|
||||
let conn = create_connection(Path::new(":memory:")).unwrap();
|
||||
run_migrations(&conn).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
fn index_exists(conn: &Connection, index_name: &str) -> bool {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='index' AND name=?1",
|
||||
[index_name],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn column_exists(conn: &Connection, table: &str, column: &str) -> bool {
|
||||
let sql = format!("PRAGMA table_info({})", table);
|
||||
let mut stmt = conn.prepare(&sql).unwrap();
|
||||
let columns: Vec<String> = stmt
|
||||
.query_map([], |row| row.get::<_, String>(1))
|
||||
.unwrap()
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
columns.contains(&column.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_022_indexes_exist() {
|
||||
let conn = setup_migrated_db();
|
||||
|
||||
// New indexes from migration 022
|
||||
assert!(
|
||||
index_exists(&conn, "idx_notes_user_created"),
|
||||
"idx_notes_user_created should exist"
|
||||
);
|
||||
assert!(
|
||||
index_exists(&conn, "idx_notes_project_created"),
|
||||
"idx_notes_project_created should exist"
|
||||
);
|
||||
assert!(
|
||||
index_exists(&conn, "idx_notes_author_id"),
|
||||
"idx_notes_author_id should exist"
|
||||
);
|
||||
|
||||
// Discussion JOIN indexes (idx_discussions_issue_id is new;
|
||||
// idx_discussions_mr_id already existed from migration 006 but
|
||||
// IF NOT EXISTS makes it safe)
|
||||
assert!(
|
||||
index_exists(&conn, "idx_discussions_issue_id"),
|
||||
"idx_discussions_issue_id should exist"
|
||||
);
|
||||
assert!(
|
||||
index_exists(&conn, "idx_discussions_mr_id"),
|
||||
"idx_discussions_mr_id should exist"
|
||||
);
|
||||
|
||||
// author_id column on notes
|
||||
assert!(
|
||||
column_exists(&conn, "notes", "author_id"),
|
||||
"notes.author_id column should exist"
|
||||
);
|
||||
}
|
||||
|
||||
// -- Helper: insert a minimal project for FK satisfaction --
|
||||
fn insert_test_project(conn: &Connection) -> i64 {
|
||||
conn.execute(
|
||||
"INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url) \
|
||||
VALUES (1000, 'test/project', 'https://example.com/test/project')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.last_insert_rowid()
|
||||
}
|
||||
|
||||
// -- Helper: insert a minimal issue --
|
||||
fn insert_test_issue(conn: &Connection, project_id: i64) -> i64 {
|
||||
conn.execute(
|
||||
"INSERT INTO issues (gitlab_id, project_id, iid, state, author_username, \
|
||||
created_at, updated_at, last_seen_at) \
|
||||
VALUES (100, ?1, 1, 'opened', 'alice', 1000, 1000, 1000)",
|
||||
[project_id],
|
||||
)
|
||||
.unwrap();
|
||||
conn.last_insert_rowid()
|
||||
}
|
||||
|
||||
// -- Helper: insert a minimal discussion --
|
||||
fn insert_test_discussion(conn: &Connection, project_id: i64, issue_id: i64) -> i64 {
|
||||
conn.execute(
|
||||
"INSERT INTO discussions (gitlab_discussion_id, project_id, issue_id, \
|
||||
noteable_type, last_seen_at) \
|
||||
VALUES ('disc-001', ?1, ?2, 'Issue', 1000)",
|
||||
rusqlite::params![project_id, issue_id],
|
||||
)
|
||||
.unwrap();
|
||||
conn.last_insert_rowid()
|
||||
}
|
||||
|
||||
// -- Helper: insert a minimal non-system note --
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn insert_test_note(
|
||||
conn: &Connection,
|
||||
gitlab_id: i64,
|
||||
discussion_id: i64,
|
||||
project_id: i64,
|
||||
is_system: bool,
|
||||
) -> i64 {
|
||||
conn.execute(
|
||||
"INSERT INTO notes (gitlab_id, discussion_id, project_id, is_system, \
|
||||
author_username, body, created_at, updated_at, last_seen_at) \
|
||||
VALUES (?1, ?2, ?3, ?4, 'alice', 'note body', 1000, 1000, 1000)",
|
||||
rusqlite::params![gitlab_id, discussion_id, project_id, is_system as i32],
|
||||
)
|
||||
.unwrap();
|
||||
conn.last_insert_rowid()
|
||||
}
|
||||
|
||||
// -- Helper: insert a document --
|
||||
fn insert_test_document(
|
||||
conn: &Connection,
|
||||
source_type: &str,
|
||||
source_id: i64,
|
||||
project_id: i64,
|
||||
) -> i64 {
|
||||
conn.execute(
|
||||
"INSERT INTO documents (source_type, source_id, project_id, content_text, content_hash) \
|
||||
VALUES (?1, ?2, ?3, 'test content', 'hash123')",
|
||||
rusqlite::params![source_type, source_id, project_id],
|
||||
)
|
||||
.unwrap();
|
||||
conn.last_insert_rowid()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_024_allows_note_source_type() {
|
||||
let conn = setup_migrated_db();
|
||||
let pid = insert_test_project(&conn);
|
||||
|
||||
// Should succeed -- 'note' is now allowed
|
||||
conn.execute(
|
||||
"INSERT INTO documents (source_type, source_id, project_id, content_text, content_hash) \
|
||||
VALUES ('note', 1, ?1, 'note content', 'hash-note')",
|
||||
[pid],
|
||||
)
|
||||
.expect("INSERT with source_type='note' into documents should succeed");
|
||||
|
||||
// dirty_sources should also accept 'note'
|
||||
conn.execute(
|
||||
"INSERT INTO dirty_sources (source_type, source_id, queued_at) \
|
||||
VALUES ('note', 1, 1000)",
|
||||
[],
|
||||
)
|
||||
.expect("INSERT with source_type='note' into dirty_sources should succeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_024_preserves_existing_data() {
|
||||
// Run migrations up to 023 only, insert data, then apply 024
|
||||
// Migration 024 is at index 23 (0-based). Use hardcoded index so adding
|
||||
// later migrations doesn't silently shift what this test exercises.
|
||||
let conn = create_connection(Path::new(":memory:")).unwrap();
|
||||
|
||||
// Apply migrations 001-023 (indices 0..23)
|
||||
run_migrations_up_to(&conn, 23);
|
||||
|
||||
let pid = insert_test_project(&conn);
|
||||
|
||||
// Insert a document with existing source_type
|
||||
conn.execute(
|
||||
"INSERT INTO documents (source_type, source_id, project_id, content_text, content_hash, title) \
|
||||
VALUES ('issue', 1, ?1, 'issue content', 'hash-issue', 'Test Issue')",
|
||||
[pid],
|
||||
)
|
||||
.unwrap();
|
||||
let doc_id: i64 = conn.last_insert_rowid();
|
||||
|
||||
// Insert junction data
|
||||
conn.execute(
|
||||
"INSERT INTO document_labels (document_id, label_name) VALUES (?1, 'bug')",
|
||||
[doc_id],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO document_paths (document_id, path) VALUES (?1, 'src/main.rs')",
|
||||
[doc_id],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Insert dirty_sources row
|
||||
conn.execute(
|
||||
"INSERT INTO dirty_sources (source_type, source_id, queued_at) VALUES ('issue', 1, 1000)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Now apply migration 024 (index 23) -- the table-rebuild migration
|
||||
run_single_migration(&conn, 23);
|
||||
|
||||
// Verify document still exists with correct data
|
||||
let (st, content, title): (String, String, String) = conn
|
||||
.query_row(
|
||||
"SELECT source_type, content_text, title FROM documents WHERE id = ?1",
|
||||
[doc_id],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(st, "issue");
|
||||
assert_eq!(content, "issue content");
|
||||
assert_eq!(title, "Test Issue");
|
||||
|
||||
// Verify junction data preserved
|
||||
let label_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM document_labels WHERE document_id = ?1",
|
||||
[doc_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(label_count, 1);
|
||||
|
||||
let path_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM document_paths WHERE document_id = ?1",
|
||||
[doc_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(path_count, 1);
|
||||
|
||||
// Verify dirty_sources preserved
|
||||
let dirty_count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM dirty_sources", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(dirty_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_024_fts_triggers_intact() {
|
||||
let conn = setup_migrated_db();
|
||||
let pid = insert_test_project(&conn);
|
||||
|
||||
// Insert a document after migration -- FTS trigger should fire
|
||||
let doc_id = insert_test_document(&conn, "note", 1, pid);
|
||||
|
||||
// Verify FTS entry exists
|
||||
let fts_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM documents_fts WHERE documents_fts MATCH 'test'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(fts_count > 0, "FTS trigger should have created an entry");
|
||||
|
||||
// Verify update trigger works
|
||||
conn.execute(
|
||||
"UPDATE documents SET content_text = 'updated content' WHERE id = ?1",
|
||||
[doc_id],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let fts_updated: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM documents_fts WHERE documents_fts MATCH 'updated'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
fts_updated > 0,
|
||||
"FTS update trigger should reflect new content"
|
||||
);
|
||||
|
||||
// Verify delete trigger works
|
||||
conn.execute("DELETE FROM documents WHERE id = ?1", [doc_id])
|
||||
.unwrap();
|
||||
|
||||
let fts_after_delete: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM documents_fts WHERE documents_fts MATCH 'updated'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
fts_after_delete, 0,
|
||||
"FTS delete trigger should remove the entry"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_024_row_counts_preserved() {
|
||||
let conn = setup_migrated_db();
|
||||
|
||||
// After full migration, tables should exist and be queryable
|
||||
let doc_count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM documents", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(doc_count, 0, "Fresh DB should have 0 documents");
|
||||
|
||||
let dirty_count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM dirty_sources", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(dirty_count, 0, "Fresh DB should have 0 dirty_sources");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_024_integrity_checks_pass() {
|
||||
let conn = setup_migrated_db();
|
||||
|
||||
// PRAGMA integrity_check
|
||||
let integrity: String = conn
|
||||
.query_row("PRAGMA integrity_check", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(integrity, "ok", "Database integrity check should pass");
|
||||
|
||||
// PRAGMA foreign_key_check (returns rows only if there are violations)
|
||||
let fk_violations: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(fk_violations, 0, "No foreign key violations should exist");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_024_note_delete_trigger_cleans_document() {
|
||||
let conn = setup_migrated_db();
|
||||
let pid = insert_test_project(&conn);
|
||||
let issue_id = insert_test_issue(&conn, pid);
|
||||
let disc_id = insert_test_discussion(&conn, pid, issue_id);
|
||||
let note_id = insert_test_note(&conn, 200, disc_id, pid, false);
|
||||
|
||||
// Create a document for this note
|
||||
insert_test_document(&conn, "note", note_id, pid);
|
||||
|
||||
let doc_before: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM documents WHERE source_type = 'note' AND source_id = ?1",
|
||||
[note_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(doc_before, 1);
|
||||
|
||||
// Delete the note -- trigger should remove the document
|
||||
conn.execute("DELETE FROM notes WHERE id = ?1", [note_id])
|
||||
.unwrap();
|
||||
|
||||
let doc_after: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM documents WHERE source_type = 'note' AND source_id = ?1",
|
||||
[note_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
doc_after, 0,
|
||||
"notes_ad_cleanup trigger should delete the document"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_024_note_system_flip_trigger_cleans_document() {
|
||||
let conn = setup_migrated_db();
|
||||
let pid = insert_test_project(&conn);
|
||||
let issue_id = insert_test_issue(&conn, pid);
|
||||
let disc_id = insert_test_discussion(&conn, pid, issue_id);
|
||||
let note_id = insert_test_note(&conn, 201, disc_id, pid, false);
|
||||
|
||||
// Create a document for this note
|
||||
insert_test_document(&conn, "note", note_id, pid);
|
||||
|
||||
let doc_before: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM documents WHERE source_type = 'note' AND source_id = ?1",
|
||||
[note_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(doc_before, 1);
|
||||
|
||||
// Flip is_system from 0 to 1 -- trigger should remove the document
|
||||
conn.execute("UPDATE notes SET is_system = 1 WHERE id = ?1", [note_id])
|
||||
.unwrap();
|
||||
|
||||
let doc_after: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM documents WHERE source_type = 'note' AND source_id = ?1",
|
||||
[note_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
doc_after, 0,
|
||||
"notes_au_system_cleanup trigger should delete the document"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_024_system_note_delete_trigger_does_not_fire() {
|
||||
let conn = setup_migrated_db();
|
||||
let pid = insert_test_project(&conn);
|
||||
let issue_id = insert_test_issue(&conn, pid);
|
||||
let disc_id = insert_test_discussion(&conn, pid, issue_id);
|
||||
|
||||
// Insert a system note (is_system = true)
|
||||
let note_id = insert_test_note(&conn, 202, disc_id, pid, true);
|
||||
|
||||
// Manually insert a document (shouldn't exist for system notes in practice,
|
||||
// but we test the trigger guard)
|
||||
insert_test_document(&conn, "note", note_id, pid);
|
||||
|
||||
let doc_before: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM documents WHERE source_type = 'note' AND source_id = ?1",
|
||||
[note_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(doc_before, 1);
|
||||
|
||||
// Delete system note -- trigger has WHEN old.is_system = 0 so it should NOT fire
|
||||
conn.execute("DELETE FROM notes WHERE id = ?1", [note_id])
|
||||
.unwrap();
|
||||
|
||||
let doc_after: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM documents WHERE source_type = 'note' AND source_id = ?1",
|
||||
[note_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
doc_after, 1,
|
||||
"notes_ad_cleanup trigger should NOT fire for system notes"
|
||||
);
|
||||
}
|
||||
|
||||
/// Run migrations only up to version `up_to` (inclusive).
|
||||
fn run_migrations_up_to(conn: &Connection, up_to: usize) {
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS schema_version ( \
|
||||
version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL, description TEXT);",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
for (version_str, sql) in &MIGRATIONS[..up_to] {
|
||||
let version: i32 = version_str.parse().unwrap();
|
||||
conn.execute_batch(sql).unwrap();
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO schema_version (version, applied_at, description) \
|
||||
VALUES (?1, strftime('%s', 'now') * 1000, ?2)",
|
||||
rusqlite::params![version, version_str],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a single migration by index (0-based).
|
||||
fn run_single_migration(conn: &Connection, index: usize) {
|
||||
let (version_str, sql) = MIGRATIONS[index];
|
||||
let version: i32 = version_str.parse().unwrap();
|
||||
conn.execute_batch(sql).unwrap();
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO schema_version (version, applied_at, description) \
|
||||
VALUES (?1, strftime('%s', 'now') * 1000, ?2)",
|
||||
rusqlite::params![version, version_str],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_025_backfills_existing_notes() {
|
||||
let conn = create_connection(Path::new(":memory:")).unwrap();
|
||||
// Run all migrations through 024 (index 0..24)
|
||||
run_migrations_up_to(&conn, 24);
|
||||
|
||||
let pid = insert_test_project(&conn);
|
||||
let issue_id = insert_test_issue(&conn, pid);
|
||||
let disc_id = insert_test_discussion(&conn, pid, issue_id);
|
||||
|
||||
// Insert 5 non-system notes
|
||||
for i in 1..=5 {
|
||||
insert_test_note(&conn, 300 + i, disc_id, pid, false);
|
||||
}
|
||||
// Insert 2 system notes
|
||||
for i in 1..=2 {
|
||||
insert_test_note(&conn, 400 + i, disc_id, pid, true);
|
||||
}
|
||||
|
||||
// Run migration 025
|
||||
run_single_migration(&conn, 24);
|
||||
|
||||
let dirty_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM dirty_sources WHERE source_type = 'note'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
dirty_count, 5,
|
||||
"Migration 025 should backfill 5 non-system notes"
|
||||
);
|
||||
|
||||
// Verify system notes were not backfilled
|
||||
let system_note_ids: Vec<i64> = {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT source_id FROM dirty_sources WHERE source_type = 'note' ORDER BY source_id",
|
||||
)
|
||||
.unwrap();
|
||||
stmt.query_map([], |row| row.get(0))
|
||||
.unwrap()
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.unwrap()
|
||||
};
|
||||
// System note ids should not appear
|
||||
let all_system_note_ids: Vec<i64> = {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id FROM notes WHERE is_system = 1 ORDER BY id")
|
||||
.unwrap();
|
||||
stmt.query_map([], |row| row.get(0))
|
||||
.unwrap()
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.unwrap()
|
||||
};
|
||||
for sys_id in &all_system_note_ids {
|
||||
assert!(
|
||||
!system_note_ids.contains(sys_id),
|
||||
"System note id {} should not be in dirty_sources",
|
||||
sys_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_025_idempotent_with_existing_documents() {
|
||||
let conn = create_connection(Path::new(":memory:")).unwrap();
|
||||
run_migrations_up_to(&conn, 24);
|
||||
|
||||
let pid = insert_test_project(&conn);
|
||||
let issue_id = insert_test_issue(&conn, pid);
|
||||
let disc_id = insert_test_discussion(&conn, pid, issue_id);
|
||||
|
||||
// Insert 3 non-system notes
|
||||
let note_ids: Vec<i64> = (1..=3)
|
||||
.map(|i| insert_test_note(&conn, 500 + i, disc_id, pid, false))
|
||||
.collect();
|
||||
|
||||
// Create documents for 2 of 3 notes (simulating already-generated docs)
|
||||
insert_test_document(&conn, "note", note_ids[0], pid);
|
||||
insert_test_document(&conn, "note", note_ids[1], pid);
|
||||
|
||||
// Run migration 025
|
||||
run_single_migration(&conn, 24);
|
||||
|
||||
let dirty_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM dirty_sources WHERE source_type = 'note'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
dirty_count, 1,
|
||||
"Only the note without a document should be backfilled"
|
||||
);
|
||||
|
||||
// Verify the correct note was queued
|
||||
let queued_id: i64 = conn
|
||||
.query_row(
|
||||
"SELECT source_id FROM dirty_sources WHERE source_type = 'note'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(queued_id, note_ids[2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_025_skips_notes_already_in_dirty_queue() {
|
||||
let conn = create_connection(Path::new(":memory:")).unwrap();
|
||||
run_migrations_up_to(&conn, 24);
|
||||
|
||||
let pid = insert_test_project(&conn);
|
||||
let issue_id = insert_test_issue(&conn, pid);
|
||||
let disc_id = insert_test_discussion(&conn, pid, issue_id);
|
||||
|
||||
// Insert 3 non-system notes
|
||||
let note_ids: Vec<i64> = (1..=3)
|
||||
.map(|i| insert_test_note(&conn, 600 + i, disc_id, pid, false))
|
||||
.collect();
|
||||
|
||||
// Pre-queue one note in dirty_sources
|
||||
conn.execute(
|
||||
"INSERT INTO dirty_sources (source_type, source_id, queued_at) VALUES ('note', ?1, 999)",
|
||||
[note_ids[0]],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Run migration 025
|
||||
run_single_migration(&conn, 24);
|
||||
|
||||
let dirty_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM dirty_sources WHERE source_type = 'note'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
dirty_count, 3,
|
||||
"All 3 notes should be in dirty_sources (1 pre-existing + 2 new)"
|
||||
);
|
||||
|
||||
// Verify the pre-existing entry preserved its original queued_at
|
||||
let original_queued_at: i64 = conn
|
||||
.query_row(
|
||||
"SELECT queued_at FROM dirty_sources WHERE source_type = 'note' AND source_id = ?1",
|
||||
[note_ids[0]],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
original_queued_at, 999,
|
||||
"ON CONFLICT DO NOTHING should preserve the original queued_at"
|
||||
);
|
||||
}
|
||||
@@ -234,330 +234,5 @@ fn resolve_cross_project_entity(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_mentioned_in_mr() {
|
||||
let refs = parse_cross_refs("mentioned in !567");
|
||||
assert_eq!(refs.len(), 1);
|
||||
assert_eq!(refs[0].reference_type, "mentioned");
|
||||
assert_eq!(refs[0].target_entity_type, "merge_request");
|
||||
assert_eq!(refs[0].target_iid, 567);
|
||||
assert!(refs[0].target_project_path.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mentioned_in_issue() {
|
||||
let refs = parse_cross_refs("mentioned in #234");
|
||||
assert_eq!(refs.len(), 1);
|
||||
assert_eq!(refs[0].reference_type, "mentioned");
|
||||
assert_eq!(refs[0].target_entity_type, "issue");
|
||||
assert_eq!(refs[0].target_iid, 234);
|
||||
assert!(refs[0].target_project_path.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mentioned_cross_project() {
|
||||
let refs = parse_cross_refs("mentioned in group/repo!789");
|
||||
assert_eq!(refs.len(), 1);
|
||||
assert_eq!(refs[0].reference_type, "mentioned");
|
||||
assert_eq!(refs[0].target_entity_type, "merge_request");
|
||||
assert_eq!(refs[0].target_iid, 789);
|
||||
assert_eq!(refs[0].target_project_path.as_deref(), Some("group/repo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mentioned_cross_project_issue() {
|
||||
let refs = parse_cross_refs("mentioned in group/repo#123");
|
||||
assert_eq!(refs.len(), 1);
|
||||
assert_eq!(refs[0].reference_type, "mentioned");
|
||||
assert_eq!(refs[0].target_entity_type, "issue");
|
||||
assert_eq!(refs[0].target_iid, 123);
|
||||
assert_eq!(refs[0].target_project_path.as_deref(), Some("group/repo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_closed_by_mr() {
|
||||
let refs = parse_cross_refs("closed by !567");
|
||||
assert_eq!(refs.len(), 1);
|
||||
assert_eq!(refs[0].reference_type, "closes");
|
||||
assert_eq!(refs[0].target_entity_type, "merge_request");
|
||||
assert_eq!(refs[0].target_iid, 567);
|
||||
assert!(refs[0].target_project_path.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_closed_by_cross_project() {
|
||||
let refs = parse_cross_refs("closed by group/repo!789");
|
||||
assert_eq!(refs.len(), 1);
|
||||
assert_eq!(refs[0].reference_type, "closes");
|
||||
assert_eq!(refs[0].target_entity_type, "merge_request");
|
||||
assert_eq!(refs[0].target_iid, 789);
|
||||
assert_eq!(refs[0].target_project_path.as_deref(), Some("group/repo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_multiple_refs() {
|
||||
let refs = parse_cross_refs("mentioned in !123 and mentioned in #456");
|
||||
assert_eq!(refs.len(), 2);
|
||||
assert_eq!(refs[0].target_entity_type, "merge_request");
|
||||
assert_eq!(refs[0].target_iid, 123);
|
||||
assert_eq!(refs[1].target_entity_type, "issue");
|
||||
assert_eq!(refs[1].target_iid, 456);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_no_refs() {
|
||||
let refs = parse_cross_refs("Updated the description");
|
||||
assert!(refs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_non_english_note() {
|
||||
let refs = parse_cross_refs("a ajout\u{00e9} l'\u{00e9}tiquette ~bug");
|
||||
assert!(refs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_multi_level_group_path() {
|
||||
let refs = parse_cross_refs("mentioned in top/sub/project#123");
|
||||
assert_eq!(refs.len(), 1);
|
||||
assert_eq!(
|
||||
refs[0].target_project_path.as_deref(),
|
||||
Some("top/sub/project")
|
||||
);
|
||||
assert_eq!(refs[0].target_iid, 123);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_deeply_nested_group_path() {
|
||||
let refs = parse_cross_refs("mentioned in a/b/c/d/e!42");
|
||||
assert_eq!(refs.len(), 1);
|
||||
assert_eq!(refs[0].target_project_path.as_deref(), Some("a/b/c/d/e"));
|
||||
assert_eq!(refs[0].target_iid, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_hyphenated_project_path() {
|
||||
let refs = parse_cross_refs("mentioned in my-group/my-project#99");
|
||||
assert_eq!(refs.len(), 1);
|
||||
assert_eq!(
|
||||
refs[0].target_project_path.as_deref(),
|
||||
Some("my-group/my-project")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dotted_project_path() {
|
||||
let refs = parse_cross_refs("mentioned in visiostack.io/backend#123");
|
||||
assert_eq!(refs.len(), 1);
|
||||
assert_eq!(
|
||||
refs[0].target_project_path.as_deref(),
|
||||
Some("visiostack.io/backend")
|
||||
);
|
||||
assert_eq!(refs[0].target_iid, 123);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dotted_nested_project_path() {
|
||||
let refs = parse_cross_refs("closed by my.org/sub.group/my.project!42");
|
||||
assert_eq!(refs.len(), 1);
|
||||
assert_eq!(
|
||||
refs[0].target_project_path.as_deref(),
|
||||
Some("my.org/sub.group/my.project")
|
||||
);
|
||||
assert_eq!(refs[0].target_entity_type, "merge_request");
|
||||
assert_eq!(refs[0].target_iid, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_self_reference_is_valid() {
|
||||
let refs = parse_cross_refs("mentioned in #123");
|
||||
assert_eq!(refs.len(), 1);
|
||||
assert_eq!(refs[0].target_iid, 123);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mixed_mentioned_and_closed() {
|
||||
let refs = parse_cross_refs("mentioned in !10 and closed by !20");
|
||||
assert_eq!(refs.len(), 2);
|
||||
assert_eq!(refs[0].reference_type, "mentioned");
|
||||
assert_eq!(refs[0].target_iid, 10);
|
||||
assert_eq!(refs[1].reference_type, "closes");
|
||||
assert_eq!(refs[1].target_iid, 20);
|
||||
}
|
||||
|
||||
fn setup_test_db() -> Connection {
|
||||
use crate::core::db::{create_connection, run_migrations};
|
||||
|
||||
let conn = create_connection(std::path::Path::new(":memory:")).unwrap();
|
||||
run_migrations(&conn).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
fn seed_test_data(conn: &Connection) -> i64 {
|
||||
let now = now_ms();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url, created_at, updated_at)
|
||||
VALUES (1, 100, 'group/test-project', 'https://gitlab.com/group/test-project', ?1, ?1)",
|
||||
[now],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at)
|
||||
VALUES (10, 1000, 1, 123, 'Test Issue', 'opened', ?1, ?1, ?1)",
|
||||
[now],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at)
|
||||
VALUES (11, 1001, 1, 456, 'Another Issue', 'opened', ?1, ?1, ?1)",
|
||||
[now],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, state, source_branch, target_branch, author_username, created_at, updated_at, last_seen_at)
|
||||
VALUES (20, 2000, 1, 789, 'Test MR', 'opened', 'feat', 'main', 'dev', ?1, ?1, ?1)",
|
||||
[now],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at)
|
||||
VALUES (30, 'disc-aaa', 1, 10, 'Issue', ?1)",
|
||||
[now],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, merge_request_id, noteable_type, last_seen_at)
|
||||
VALUES (31, 'disc-bbb', 1, 20, 'MergeRequest', ?1)",
|
||||
[now],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, is_system, body, created_at, updated_at, last_seen_at)
|
||||
VALUES (40, 4000, 30, 1, 1, 'mentioned in !789', ?1, ?1, ?1)",
|
||||
[now],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, is_system, body, created_at, updated_at, last_seen_at)
|
||||
VALUES (41, 4001, 31, 1, 1, 'mentioned in #456', ?1, ?1, ?1)",
|
||||
[now],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, is_system, body, created_at, updated_at, last_seen_at)
|
||||
VALUES (42, 4002, 30, 1, 0, 'mentioned in !999', ?1, ?1, ?1)",
|
||||
[now],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, is_system, body, created_at, updated_at, last_seen_at)
|
||||
VALUES (43, 4003, 30, 1, 1, 'added label ~bug', ?1, ?1, ?1)",
|
||||
[now],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, is_system, body, created_at, updated_at, last_seen_at)
|
||||
VALUES (44, 4004, 30, 1, 1, 'mentioned in other/project#999', ?1, ?1, ?1)",
|
||||
[now],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_refs_from_system_notes_integration() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = seed_test_data(&conn);
|
||||
|
||||
let result = extract_refs_from_system_notes(&conn, project_id).unwrap();
|
||||
|
||||
assert_eq!(result.inserted, 2, "Two same-project refs should resolve");
|
||||
assert_eq!(
|
||||
result.skipped_unresolvable, 1,
|
||||
"One cross-project ref should be unresolvable"
|
||||
);
|
||||
assert_eq!(
|
||||
result.parse_failures, 1,
|
||||
"One system note has no cross-ref pattern"
|
||||
);
|
||||
|
||||
let ref_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM entity_references WHERE project_id = ?1 AND source_method = 'note_parse'",
|
||||
[project_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ref_count, 3, "Should have 3 entity_references rows total");
|
||||
|
||||
let unresolved_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM entity_references WHERE target_entity_id IS NULL AND source_method = 'note_parse'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
unresolved_count, 1,
|
||||
"Should have 1 unresolved cross-project ref"
|
||||
);
|
||||
|
||||
let (path, iid): (String, i64) = conn
|
||||
.query_row(
|
||||
"SELECT target_project_path, target_entity_iid FROM entity_references WHERE target_entity_id IS NULL",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(path, "other/project");
|
||||
assert_eq!(iid, 999);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_refs_idempotent() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = seed_test_data(&conn);
|
||||
|
||||
let result1 = extract_refs_from_system_notes(&conn, project_id).unwrap();
|
||||
let result2 = extract_refs_from_system_notes(&conn, project_id).unwrap();
|
||||
|
||||
assert_eq!(result2.inserted, 0);
|
||||
assert_eq!(result2.skipped_unresolvable, 0);
|
||||
|
||||
let total: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM entity_references WHERE source_method = 'note_parse'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
total,
|
||||
(result1.inserted + result1.skipped_unresolvable) as i64
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_refs_empty_project() {
|
||||
let conn = setup_test_db();
|
||||
let result = extract_refs_from_system_notes(&conn, 999).unwrap();
|
||||
assert_eq!(result.inserted, 0);
|
||||
assert_eq!(result.skipped_unresolvable, 0);
|
||||
assert_eq!(result.parse_failures, 0);
|
||||
}
|
||||
}
|
||||
#[path = "note_parser_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
325
src/core/note_parser_tests.rs
Normal file
325
src/core/note_parser_tests.rs
Normal file
@@ -0,0 +1,325 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_mentioned_in_mr() {
|
||||
let refs = parse_cross_refs("mentioned in !567");
|
||||
assert_eq!(refs.len(), 1);
|
||||
assert_eq!(refs[0].reference_type, "mentioned");
|
||||
assert_eq!(refs[0].target_entity_type, "merge_request");
|
||||
assert_eq!(refs[0].target_iid, 567);
|
||||
assert!(refs[0].target_project_path.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mentioned_in_issue() {
|
||||
let refs = parse_cross_refs("mentioned in #234");
|
||||
assert_eq!(refs.len(), 1);
|
||||
assert_eq!(refs[0].reference_type, "mentioned");
|
||||
assert_eq!(refs[0].target_entity_type, "issue");
|
||||
assert_eq!(refs[0].target_iid, 234);
|
||||
assert!(refs[0].target_project_path.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mentioned_cross_project() {
|
||||
let refs = parse_cross_refs("mentioned in group/repo!789");
|
||||
assert_eq!(refs.len(), 1);
|
||||
assert_eq!(refs[0].reference_type, "mentioned");
|
||||
assert_eq!(refs[0].target_entity_type, "merge_request");
|
||||
assert_eq!(refs[0].target_iid, 789);
|
||||
assert_eq!(refs[0].target_project_path.as_deref(), Some("group/repo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mentioned_cross_project_issue() {
|
||||
let refs = parse_cross_refs("mentioned in group/repo#123");
|
||||
assert_eq!(refs.len(), 1);
|
||||
assert_eq!(refs[0].reference_type, "mentioned");
|
||||
assert_eq!(refs[0].target_entity_type, "issue");
|
||||
assert_eq!(refs[0].target_iid, 123);
|
||||
assert_eq!(refs[0].target_project_path.as_deref(), Some("group/repo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_closed_by_mr() {
|
||||
let refs = parse_cross_refs("closed by !567");
|
||||
assert_eq!(refs.len(), 1);
|
||||
assert_eq!(refs[0].reference_type, "closes");
|
||||
assert_eq!(refs[0].target_entity_type, "merge_request");
|
||||
assert_eq!(refs[0].target_iid, 567);
|
||||
assert!(refs[0].target_project_path.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_closed_by_cross_project() {
|
||||
let refs = parse_cross_refs("closed by group/repo!789");
|
||||
assert_eq!(refs.len(), 1);
|
||||
assert_eq!(refs[0].reference_type, "closes");
|
||||
assert_eq!(refs[0].target_entity_type, "merge_request");
|
||||
assert_eq!(refs[0].target_iid, 789);
|
||||
assert_eq!(refs[0].target_project_path.as_deref(), Some("group/repo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_multiple_refs() {
|
||||
let refs = parse_cross_refs("mentioned in !123 and mentioned in #456");
|
||||
assert_eq!(refs.len(), 2);
|
||||
assert_eq!(refs[0].target_entity_type, "merge_request");
|
||||
assert_eq!(refs[0].target_iid, 123);
|
||||
assert_eq!(refs[1].target_entity_type, "issue");
|
||||
assert_eq!(refs[1].target_iid, 456);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_no_refs() {
|
||||
let refs = parse_cross_refs("Updated the description");
|
||||
assert!(refs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_non_english_note() {
|
||||
let refs = parse_cross_refs("a ajout\u{00e9} l'\u{00e9}tiquette ~bug");
|
||||
assert!(refs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_multi_level_group_path() {
|
||||
let refs = parse_cross_refs("mentioned in top/sub/project#123");
|
||||
assert_eq!(refs.len(), 1);
|
||||
assert_eq!(
|
||||
refs[0].target_project_path.as_deref(),
|
||||
Some("top/sub/project")
|
||||
);
|
||||
assert_eq!(refs[0].target_iid, 123);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_deeply_nested_group_path() {
|
||||
let refs = parse_cross_refs("mentioned in a/b/c/d/e!42");
|
||||
assert_eq!(refs.len(), 1);
|
||||
assert_eq!(refs[0].target_project_path.as_deref(), Some("a/b/c/d/e"));
|
||||
assert_eq!(refs[0].target_iid, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_hyphenated_project_path() {
|
||||
let refs = parse_cross_refs("mentioned in my-group/my-project#99");
|
||||
assert_eq!(refs.len(), 1);
|
||||
assert_eq!(
|
||||
refs[0].target_project_path.as_deref(),
|
||||
Some("my-group/my-project")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dotted_project_path() {
|
||||
let refs = parse_cross_refs("mentioned in visiostack.io/backend#123");
|
||||
assert_eq!(refs.len(), 1);
|
||||
assert_eq!(
|
||||
refs[0].target_project_path.as_deref(),
|
||||
Some("visiostack.io/backend")
|
||||
);
|
||||
assert_eq!(refs[0].target_iid, 123);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dotted_nested_project_path() {
|
||||
let refs = parse_cross_refs("closed by my.org/sub.group/my.project!42");
|
||||
assert_eq!(refs.len(), 1);
|
||||
assert_eq!(
|
||||
refs[0].target_project_path.as_deref(),
|
||||
Some("my.org/sub.group/my.project")
|
||||
);
|
||||
assert_eq!(refs[0].target_entity_type, "merge_request");
|
||||
assert_eq!(refs[0].target_iid, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_self_reference_is_valid() {
|
||||
let refs = parse_cross_refs("mentioned in #123");
|
||||
assert_eq!(refs.len(), 1);
|
||||
assert_eq!(refs[0].target_iid, 123);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mixed_mentioned_and_closed() {
|
||||
let refs = parse_cross_refs("mentioned in !10 and closed by !20");
|
||||
assert_eq!(refs.len(), 2);
|
||||
assert_eq!(refs[0].reference_type, "mentioned");
|
||||
assert_eq!(refs[0].target_iid, 10);
|
||||
assert_eq!(refs[1].reference_type, "closes");
|
||||
assert_eq!(refs[1].target_iid, 20);
|
||||
}
|
||||
|
||||
fn setup_test_db() -> Connection {
|
||||
use crate::core::db::{create_connection, run_migrations};
|
||||
|
||||
let conn = create_connection(std::path::Path::new(":memory:")).unwrap();
|
||||
run_migrations(&conn).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
fn seed_test_data(conn: &Connection) -> i64 {
|
||||
let now = now_ms();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url, created_at, updated_at)
|
||||
VALUES (1, 100, 'group/test-project', 'https://gitlab.com/group/test-project', ?1, ?1)",
|
||||
[now],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at)
|
||||
VALUES (10, 1000, 1, 123, 'Test Issue', 'opened', ?1, ?1, ?1)",
|
||||
[now],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at)
|
||||
VALUES (11, 1001, 1, 456, 'Another Issue', 'opened', ?1, ?1, ?1)",
|
||||
[now],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, state, source_branch, target_branch, author_username, created_at, updated_at, last_seen_at)
|
||||
VALUES (20, 2000, 1, 789, 'Test MR', 'opened', 'feat', 'main', 'dev', ?1, ?1, ?1)",
|
||||
[now],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at)
|
||||
VALUES (30, 'disc-aaa', 1, 10, 'Issue', ?1)",
|
||||
[now],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, merge_request_id, noteable_type, last_seen_at)
|
||||
VALUES (31, 'disc-bbb', 1, 20, 'MergeRequest', ?1)",
|
||||
[now],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, is_system, body, created_at, updated_at, last_seen_at)
|
||||
VALUES (40, 4000, 30, 1, 1, 'mentioned in !789', ?1, ?1, ?1)",
|
||||
[now],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, is_system, body, created_at, updated_at, last_seen_at)
|
||||
VALUES (41, 4001, 31, 1, 1, 'mentioned in #456', ?1, ?1, ?1)",
|
||||
[now],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, is_system, body, created_at, updated_at, last_seen_at)
|
||||
VALUES (42, 4002, 30, 1, 0, 'mentioned in !999', ?1, ?1, ?1)",
|
||||
[now],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, is_system, body, created_at, updated_at, last_seen_at)
|
||||
VALUES (43, 4003, 30, 1, 1, 'added label ~bug', ?1, ?1, ?1)",
|
||||
[now],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, is_system, body, created_at, updated_at, last_seen_at)
|
||||
VALUES (44, 4004, 30, 1, 1, 'mentioned in other/project#999', ?1, ?1, ?1)",
|
||||
[now],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_refs_from_system_notes_integration() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = seed_test_data(&conn);
|
||||
|
||||
let result = extract_refs_from_system_notes(&conn, project_id).unwrap();
|
||||
|
||||
assert_eq!(result.inserted, 2, "Two same-project refs should resolve");
|
||||
assert_eq!(
|
||||
result.skipped_unresolvable, 1,
|
||||
"One cross-project ref should be unresolvable"
|
||||
);
|
||||
assert_eq!(
|
||||
result.parse_failures, 1,
|
||||
"One system note has no cross-ref pattern"
|
||||
);
|
||||
|
||||
let ref_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM entity_references WHERE project_id = ?1 AND source_method = 'note_parse'",
|
||||
[project_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ref_count, 3, "Should have 3 entity_references rows total");
|
||||
|
||||
let unresolved_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM entity_references WHERE target_entity_id IS NULL AND source_method = 'note_parse'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
unresolved_count, 1,
|
||||
"Should have 1 unresolved cross-project ref"
|
||||
);
|
||||
|
||||
let (path, iid): (String, i64) = conn
|
||||
.query_row(
|
||||
"SELECT target_project_path, target_entity_iid FROM entity_references WHERE target_entity_id IS NULL",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(path, "other/project");
|
||||
assert_eq!(iid, 999);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_refs_idempotent() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = seed_test_data(&conn);
|
||||
|
||||
let result1 = extract_refs_from_system_notes(&conn, project_id).unwrap();
|
||||
let result2 = extract_refs_from_system_notes(&conn, project_id).unwrap();
|
||||
|
||||
assert_eq!(result2.inserted, 0);
|
||||
assert_eq!(result2.skipped_unresolvable, 0);
|
||||
|
||||
let total: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM entity_references WHERE source_method = 'note_parse'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
total,
|
||||
(result1.inserted + result1.skipped_unresolvable) as i64
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_refs_empty_project() {
|
||||
let conn = setup_test_db();
|
||||
let result = extract_refs_from_system_notes(&conn, 999).unwrap();
|
||||
assert_eq!(result.inserted, 0);
|
||||
assert_eq!(result.skipped_unresolvable, 0);
|
||||
assert_eq!(result.parse_failures, 0);
|
||||
}
|
||||
@@ -95,110 +95,5 @@ pub fn read_payload(conn: &Connection, id: i64) -> Result<Option<serde_json::Val
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::core::db::create_connection;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn setup_test_db() -> Connection {
|
||||
let dir = tempdir().unwrap();
|
||||
let db_path = dir.path().join("test.db");
|
||||
let conn = create_connection(&db_path).unwrap();
|
||||
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE raw_payloads (
|
||||
id INTEGER PRIMARY KEY,
|
||||
source TEXT NOT NULL,
|
||||
project_id INTEGER,
|
||||
resource_type TEXT NOT NULL,
|
||||
gitlab_id TEXT NOT NULL,
|
||||
fetched_at INTEGER NOT NULL,
|
||||
content_encoding TEXT NOT NULL DEFAULT 'identity',
|
||||
payload_hash TEXT NOT NULL,
|
||||
payload BLOB NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX uq_raw_payloads_dedupe
|
||||
ON raw_payloads(project_id, resource_type, gitlab_id, payload_hash);",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_store_and_read_payload() {
|
||||
let conn = setup_test_db();
|
||||
let payload = serde_json::json!({"title": "Test Issue", "id": 123});
|
||||
let json_bytes = serde_json::to_vec(&payload).unwrap();
|
||||
|
||||
let id = store_payload(
|
||||
&conn,
|
||||
StorePayloadOptions {
|
||||
project_id: Some(1),
|
||||
resource_type: "issue",
|
||||
gitlab_id: "123",
|
||||
json_bytes: &json_bytes,
|
||||
compress: false,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = read_payload(&conn, id).unwrap().unwrap();
|
||||
assert_eq!(result["title"], "Test Issue");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compression_roundtrip() {
|
||||
let conn = setup_test_db();
|
||||
let payload = serde_json::json!({"data": "x".repeat(1000)});
|
||||
let json_bytes = serde_json::to_vec(&payload).unwrap();
|
||||
|
||||
let id = store_payload(
|
||||
&conn,
|
||||
StorePayloadOptions {
|
||||
project_id: Some(1),
|
||||
resource_type: "issue",
|
||||
gitlab_id: "456",
|
||||
json_bytes: &json_bytes,
|
||||
compress: true,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = read_payload(&conn, id).unwrap().unwrap();
|
||||
assert_eq!(result["data"], "x".repeat(1000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deduplication() {
|
||||
let conn = setup_test_db();
|
||||
let payload = serde_json::json!({"id": 789});
|
||||
let json_bytes = serde_json::to_vec(&payload).unwrap();
|
||||
|
||||
let id1 = store_payload(
|
||||
&conn,
|
||||
StorePayloadOptions {
|
||||
project_id: Some(1),
|
||||
resource_type: "issue",
|
||||
gitlab_id: "789",
|
||||
json_bytes: &json_bytes,
|
||||
compress: false,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let id2 = store_payload(
|
||||
&conn,
|
||||
StorePayloadOptions {
|
||||
project_id: Some(1),
|
||||
resource_type: "issue",
|
||||
gitlab_id: "789",
|
||||
json_bytes: &json_bytes,
|
||||
compress: false,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(id1, id2);
|
||||
}
|
||||
}
|
||||
#[path = "payloads_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
105
src/core/payloads_tests.rs
Normal file
105
src/core/payloads_tests.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
use super::*;
|
||||
use crate::core::db::create_connection;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn setup_test_db() -> Connection {
|
||||
let dir = tempdir().unwrap();
|
||||
let db_path = dir.path().join("test.db");
|
||||
let conn = create_connection(&db_path).unwrap();
|
||||
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE raw_payloads (
|
||||
id INTEGER PRIMARY KEY,
|
||||
source TEXT NOT NULL,
|
||||
project_id INTEGER,
|
||||
resource_type TEXT NOT NULL,
|
||||
gitlab_id TEXT NOT NULL,
|
||||
fetched_at INTEGER NOT NULL,
|
||||
content_encoding TEXT NOT NULL DEFAULT 'identity',
|
||||
payload_hash TEXT NOT NULL,
|
||||
payload BLOB NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX uq_raw_payloads_dedupe
|
||||
ON raw_payloads(project_id, resource_type, gitlab_id, payload_hash);",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_store_and_read_payload() {
|
||||
let conn = setup_test_db();
|
||||
let payload = serde_json::json!({"title": "Test Issue", "id": 123});
|
||||
let json_bytes = serde_json::to_vec(&payload).unwrap();
|
||||
|
||||
let id = store_payload(
|
||||
&conn,
|
||||
StorePayloadOptions {
|
||||
project_id: Some(1),
|
||||
resource_type: "issue",
|
||||
gitlab_id: "123",
|
||||
json_bytes: &json_bytes,
|
||||
compress: false,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = read_payload(&conn, id).unwrap().unwrap();
|
||||
assert_eq!(result["title"], "Test Issue");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compression_roundtrip() {
|
||||
let conn = setup_test_db();
|
||||
let payload = serde_json::json!({"data": "x".repeat(1000)});
|
||||
let json_bytes = serde_json::to_vec(&payload).unwrap();
|
||||
|
||||
let id = store_payload(
|
||||
&conn,
|
||||
StorePayloadOptions {
|
||||
project_id: Some(1),
|
||||
resource_type: "issue",
|
||||
gitlab_id: "456",
|
||||
json_bytes: &json_bytes,
|
||||
compress: true,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = read_payload(&conn, id).unwrap().unwrap();
|
||||
assert_eq!(result["data"], "x".repeat(1000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deduplication() {
|
||||
let conn = setup_test_db();
|
||||
let payload = serde_json::json!({"id": 789});
|
||||
let json_bytes = serde_json::to_vec(&payload).unwrap();
|
||||
|
||||
let id1 = store_payload(
|
||||
&conn,
|
||||
StorePayloadOptions {
|
||||
project_id: Some(1),
|
||||
resource_type: "issue",
|
||||
gitlab_id: "789",
|
||||
json_bytes: &json_bytes,
|
||||
compress: false,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let id2 = store_payload(
|
||||
&conn,
|
||||
StorePayloadOptions {
|
||||
project_id: Some(1),
|
||||
resource_type: "issue",
|
||||
gitlab_id: "789",
|
||||
json_bytes: &json_bytes,
|
||||
compress: false,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(id1, id2);
|
||||
}
|
||||
@@ -114,161 +114,5 @@ fn escape_like(input: &str) -> String {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn setup_db() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch(
|
||||
"
|
||||
CREATE TABLE projects (
|
||||
id INTEGER PRIMARY KEY,
|
||||
gitlab_project_id INTEGER UNIQUE NOT NULL,
|
||||
path_with_namespace TEXT NOT NULL,
|
||||
default_branch TEXT,
|
||||
web_url TEXT,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER,
|
||||
raw_payload_id INTEGER
|
||||
);
|
||||
CREATE INDEX idx_projects_path ON projects(path_with_namespace);
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
fn insert_project(conn: &Connection, id: i64, path: &str) {
|
||||
conn.execute(
|
||||
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace) VALUES (?1, ?2, ?3)",
|
||||
rusqlite::params![id, id * 100, path],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exact_match() {
|
||||
let conn = setup_db();
|
||||
insert_project(&conn, 1, "backend/auth-service");
|
||||
let id = resolve_project(&conn, "backend/auth-service").unwrap();
|
||||
assert_eq!(id, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_case_insensitive() {
|
||||
let conn = setup_db();
|
||||
insert_project(&conn, 1, "backend/auth-service");
|
||||
let id = resolve_project(&conn, "Backend/Auth-Service").unwrap();
|
||||
assert_eq!(id, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_suffix_unambiguous() {
|
||||
let conn = setup_db();
|
||||
insert_project(&conn, 1, "backend/auth-service");
|
||||
insert_project(&conn, 2, "frontend/web-ui");
|
||||
let id = resolve_project(&conn, "auth-service").unwrap();
|
||||
assert_eq!(id, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_suffix_ambiguous() {
|
||||
let conn = setup_db();
|
||||
insert_project(&conn, 1, "backend/auth-service");
|
||||
insert_project(&conn, 2, "frontend/auth-service");
|
||||
let err = resolve_project(&conn, "auth-service").unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("ambiguous"),
|
||||
"Expected ambiguous error, got: {}",
|
||||
msg
|
||||
);
|
||||
assert!(msg.contains("backend/auth-service"));
|
||||
assert!(msg.contains("frontend/auth-service"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_substring_unambiguous() {
|
||||
let conn = setup_db();
|
||||
insert_project(&conn, 1, "vs/python-code");
|
||||
insert_project(&conn, 2, "vs/typescript-code");
|
||||
let id = resolve_project(&conn, "typescript").unwrap();
|
||||
assert_eq!(id, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_substring_case_insensitive() {
|
||||
let conn = setup_db();
|
||||
insert_project(&conn, 1, "vs/python-code");
|
||||
insert_project(&conn, 2, "vs/typescript-code");
|
||||
let id = resolve_project(&conn, "TypeScript").unwrap();
|
||||
assert_eq!(id, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_substring_ambiguous() {
|
||||
let conn = setup_db();
|
||||
insert_project(&conn, 1, "vs/python-code");
|
||||
insert_project(&conn, 2, "vs/typescript-code");
|
||||
let err = resolve_project(&conn, "code").unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("ambiguous"),
|
||||
"Expected ambiguous error, got: {}",
|
||||
msg
|
||||
);
|
||||
assert!(msg.contains("vs/python-code"));
|
||||
assert!(msg.contains("vs/typescript-code"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_suffix_preferred_over_substring() {
|
||||
let conn = setup_db();
|
||||
insert_project(&conn, 1, "backend/auth-service");
|
||||
insert_project(&conn, 2, "backend/auth-service-v2");
|
||||
let id = resolve_project(&conn, "auth-service").unwrap();
|
||||
assert_eq!(id, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_match() {
|
||||
let conn = setup_db();
|
||||
insert_project(&conn, 1, "backend/auth-service");
|
||||
let err = resolve_project(&conn, "nonexistent").unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("not found"),
|
||||
"Expected not found error, got: {}",
|
||||
msg
|
||||
);
|
||||
assert!(msg.contains("backend/auth-service"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_projects() {
|
||||
let conn = setup_db();
|
||||
let err = resolve_project(&conn, "anything").unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("No projects have been synced"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_underscore_not_wildcard() {
|
||||
let conn = setup_db();
|
||||
insert_project(&conn, 1, "backend/my_project");
|
||||
insert_project(&conn, 2, "backend/my-project");
|
||||
// `_` in user input must not match `-` (LIKE wildcard behavior)
|
||||
let id = resolve_project(&conn, "my_project").unwrap();
|
||||
assert_eq!(id, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_percent_not_wildcard() {
|
||||
let conn = setup_db();
|
||||
insert_project(&conn, 1, "backend/a%b");
|
||||
insert_project(&conn, 2, "backend/axyzb");
|
||||
// `%` in user input must not match arbitrary strings
|
||||
let id = resolve_project(&conn, "a%b").unwrap();
|
||||
assert_eq!(id, 1);
|
||||
}
|
||||
}
|
||||
#[path = "project_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
156
src/core/project_tests.rs
Normal file
156
src/core/project_tests.rs
Normal file
@@ -0,0 +1,156 @@
|
||||
use super::*;
|
||||
|
||||
fn setup_db() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch(
|
||||
"
|
||||
CREATE TABLE projects (
|
||||
id INTEGER PRIMARY KEY,
|
||||
gitlab_project_id INTEGER UNIQUE NOT NULL,
|
||||
path_with_namespace TEXT NOT NULL,
|
||||
default_branch TEXT,
|
||||
web_url TEXT,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER,
|
||||
raw_payload_id INTEGER
|
||||
);
|
||||
CREATE INDEX idx_projects_path ON projects(path_with_namespace);
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
fn insert_project(conn: &Connection, id: i64, path: &str) {
|
||||
conn.execute(
|
||||
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace) VALUES (?1, ?2, ?3)",
|
||||
rusqlite::params![id, id * 100, path],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exact_match() {
|
||||
let conn = setup_db();
|
||||
insert_project(&conn, 1, "backend/auth-service");
|
||||
let id = resolve_project(&conn, "backend/auth-service").unwrap();
|
||||
assert_eq!(id, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_case_insensitive() {
|
||||
let conn = setup_db();
|
||||
insert_project(&conn, 1, "backend/auth-service");
|
||||
let id = resolve_project(&conn, "Backend/Auth-Service").unwrap();
|
||||
assert_eq!(id, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_suffix_unambiguous() {
|
||||
let conn = setup_db();
|
||||
insert_project(&conn, 1, "backend/auth-service");
|
||||
insert_project(&conn, 2, "frontend/web-ui");
|
||||
let id = resolve_project(&conn, "auth-service").unwrap();
|
||||
assert_eq!(id, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_suffix_ambiguous() {
|
||||
let conn = setup_db();
|
||||
insert_project(&conn, 1, "backend/auth-service");
|
||||
insert_project(&conn, 2, "frontend/auth-service");
|
||||
let err = resolve_project(&conn, "auth-service").unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("ambiguous"),
|
||||
"Expected ambiguous error, got: {}",
|
||||
msg
|
||||
);
|
||||
assert!(msg.contains("backend/auth-service"));
|
||||
assert!(msg.contains("frontend/auth-service"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_substring_unambiguous() {
|
||||
let conn = setup_db();
|
||||
insert_project(&conn, 1, "vs/python-code");
|
||||
insert_project(&conn, 2, "vs/typescript-code");
|
||||
let id = resolve_project(&conn, "typescript").unwrap();
|
||||
assert_eq!(id, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_substring_case_insensitive() {
|
||||
let conn = setup_db();
|
||||
insert_project(&conn, 1, "vs/python-code");
|
||||
insert_project(&conn, 2, "vs/typescript-code");
|
||||
let id = resolve_project(&conn, "TypeScript").unwrap();
|
||||
assert_eq!(id, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_substring_ambiguous() {
|
||||
let conn = setup_db();
|
||||
insert_project(&conn, 1, "vs/python-code");
|
||||
insert_project(&conn, 2, "vs/typescript-code");
|
||||
let err = resolve_project(&conn, "code").unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("ambiguous"),
|
||||
"Expected ambiguous error, got: {}",
|
||||
msg
|
||||
);
|
||||
assert!(msg.contains("vs/python-code"));
|
||||
assert!(msg.contains("vs/typescript-code"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_suffix_preferred_over_substring() {
|
||||
let conn = setup_db();
|
||||
insert_project(&conn, 1, "backend/auth-service");
|
||||
insert_project(&conn, 2, "backend/auth-service-v2");
|
||||
let id = resolve_project(&conn, "auth-service").unwrap();
|
||||
assert_eq!(id, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_match() {
|
||||
let conn = setup_db();
|
||||
insert_project(&conn, 1, "backend/auth-service");
|
||||
let err = resolve_project(&conn, "nonexistent").unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("not found"),
|
||||
"Expected not found error, got: {}",
|
||||
msg
|
||||
);
|
||||
assert!(msg.contains("backend/auth-service"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_projects() {
|
||||
let conn = setup_db();
|
||||
let err = resolve_project(&conn, "anything").unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("No projects have been synced"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_underscore_not_wildcard() {
|
||||
let conn = setup_db();
|
||||
insert_project(&conn, 1, "backend/my_project");
|
||||
insert_project(&conn, 2, "backend/my-project");
|
||||
// `_` in user input must not match `-` (LIKE wildcard behavior)
|
||||
let id = resolve_project(&conn, "my_project").unwrap();
|
||||
assert_eq!(id, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_percent_not_wildcard() {
|
||||
let conn = setup_db();
|
||||
insert_project(&conn, 1, "backend/a%b");
|
||||
insert_project(&conn, 2, "backend/axyzb");
|
||||
// `%` in user input must not match arbitrary strings
|
||||
let id = resolve_project(&conn, "a%b").unwrap();
|
||||
assert_eq!(id, 1);
|
||||
}
|
||||
@@ -122,430 +122,5 @@ pub fn count_references_for_source(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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 seed_project_issue_mr(conn: &Connection) -> (i64, i64, i64) {
|
||||
conn.execute(
|
||||
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url, created_at, updated_at)
|
||||
VALUES (1, 100, 'group/repo', 'https://gitlab.example.com/group/repo', 1000, 2000)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, gitlab_id, iid, project_id, title, state, created_at, updated_at, last_seen_at)
|
||||
VALUES (1, 200, 10, 1, 'Test issue', 'closed', 1000, 2000, 2000)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO merge_requests (id, gitlab_id, iid, project_id, title, state, created_at, updated_at, last_seen_at, source_branch, target_branch)
|
||||
VALUES (1, 300, 5, 1, 'Test MR', 'merged', 1000, 2000, 2000, 'feature', 'main')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
(1, 1, 1)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_refs_from_state_events_basic() {
|
||||
let conn = setup_test_db();
|
||||
let (project_id, issue_id, mr_id) = seed_project_issue_mr(&conn);
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO resource_state_events
|
||||
(gitlab_id, project_id, issue_id, merge_request_id, state,
|
||||
created_at, source_merge_request_iid)
|
||||
VALUES (1, ?1, ?2, NULL, 'closed', 3000, 5)",
|
||||
rusqlite::params![project_id, issue_id],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let count = extract_refs_from_state_events(&conn, project_id).unwrap();
|
||||
assert_eq!(count, 1, "Should insert exactly one reference");
|
||||
|
||||
let (src_type, src_id, tgt_type, tgt_id, ref_type, method): (
|
||||
String,
|
||||
i64,
|
||||
String,
|
||||
i64,
|
||||
String,
|
||||
String,
|
||||
) = conn
|
||||
.query_row(
|
||||
"SELECT source_entity_type, source_entity_id,
|
||||
target_entity_type, target_entity_id,
|
||||
reference_type, source_method
|
||||
FROM entity_references WHERE project_id = ?1",
|
||||
[project_id],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get(0)?,
|
||||
row.get(1)?,
|
||||
row.get(2)?,
|
||||
row.get(3)?,
|
||||
row.get(4)?,
|
||||
row.get(5)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(src_type, "merge_request");
|
||||
assert_eq!(src_id, mr_id, "Source should be the MR's local DB id");
|
||||
assert_eq!(tgt_type, "issue");
|
||||
assert_eq!(tgt_id, issue_id, "Target should be the issue's local DB id");
|
||||
assert_eq!(ref_type, "closes");
|
||||
assert_eq!(method, "api");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_refs_dedup_with_closes_issues() {
|
||||
let conn = setup_test_db();
|
||||
let (project_id, issue_id, mr_id) = seed_project_issue_mr(&conn);
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO entity_references
|
||||
(project_id, source_entity_type, source_entity_id,
|
||||
target_entity_type, target_entity_id,
|
||||
reference_type, source_method, created_at)
|
||||
VALUES (?1, 'merge_request', ?2, 'issue', ?3, 'closes', 'api', 3000)",
|
||||
rusqlite::params![project_id, mr_id, issue_id],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO resource_state_events
|
||||
(gitlab_id, project_id, issue_id, merge_request_id, state,
|
||||
created_at, source_merge_request_iid)
|
||||
VALUES (1, ?1, ?2, NULL, 'closed', 3000, 5)",
|
||||
rusqlite::params![project_id, issue_id],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let count = extract_refs_from_state_events(&conn, project_id).unwrap();
|
||||
assert_eq!(count, 0, "Should not insert duplicate reference");
|
||||
|
||||
let total: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM entity_references WHERE project_id = ?1",
|
||||
[project_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(total, 1, "Should still have exactly one reference");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_refs_no_source_mr() {
|
||||
let conn = setup_test_db();
|
||||
let (project_id, issue_id, _mr_id) = seed_project_issue_mr(&conn);
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO resource_state_events
|
||||
(gitlab_id, project_id, issue_id, merge_request_id, state,
|
||||
created_at, source_merge_request_iid)
|
||||
VALUES (1, ?1, ?2, NULL, 'closed', 3000, NULL)",
|
||||
rusqlite::params![project_id, issue_id],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let count = extract_refs_from_state_events(&conn, project_id).unwrap();
|
||||
assert_eq!(count, 0, "Should not create refs when no source MR");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_refs_mr_not_synced() {
|
||||
let conn = setup_test_db();
|
||||
let (project_id, issue_id, _mr_id) = seed_project_issue_mr(&conn);
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO resource_state_events
|
||||
(gitlab_id, project_id, issue_id, merge_request_id, state,
|
||||
created_at, source_merge_request_iid)
|
||||
VALUES (2, ?1, ?2, NULL, 'closed', 3000, 999)",
|
||||
rusqlite::params![project_id, issue_id],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let count = extract_refs_from_state_events(&conn, project_id).unwrap();
|
||||
assert_eq!(
|
||||
count, 0,
|
||||
"Should not create ref when MR is not synced locally"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_refs_idempotent() {
|
||||
let conn = setup_test_db();
|
||||
let (project_id, issue_id, _mr_id) = seed_project_issue_mr(&conn);
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO resource_state_events
|
||||
(gitlab_id, project_id, issue_id, merge_request_id, state,
|
||||
created_at, source_merge_request_iid)
|
||||
VALUES (1, ?1, ?2, NULL, 'closed', 3000, 5)",
|
||||
rusqlite::params![project_id, issue_id],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let count1 = extract_refs_from_state_events(&conn, project_id).unwrap();
|
||||
assert_eq!(count1, 1);
|
||||
|
||||
let count2 = extract_refs_from_state_events(&conn, project_id).unwrap();
|
||||
assert_eq!(count2, 0, "Second run should insert nothing (idempotent)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_refs_multiple_events_same_mr_issue() {
|
||||
let conn = setup_test_db();
|
||||
let (project_id, issue_id, _mr_id) = seed_project_issue_mr(&conn);
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO resource_state_events
|
||||
(gitlab_id, project_id, issue_id, merge_request_id, state,
|
||||
created_at, source_merge_request_iid)
|
||||
VALUES (1, ?1, ?2, NULL, 'closed', 3000, 5)",
|
||||
rusqlite::params![project_id, issue_id],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO resource_state_events
|
||||
(gitlab_id, project_id, issue_id, merge_request_id, state,
|
||||
created_at, source_merge_request_iid)
|
||||
VALUES (2, ?1, ?2, NULL, 'closed', 4000, 5)",
|
||||
rusqlite::params![project_id, issue_id],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let count = extract_refs_from_state_events(&conn, project_id).unwrap();
|
||||
assert!(count <= 2, "At most 2 inserts attempted");
|
||||
|
||||
let total: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM entity_references WHERE project_id = ?1",
|
||||
[project_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
total, 1,
|
||||
"Only one unique reference should exist for same MR->issue pair"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_refs_scoped_to_project() {
|
||||
let conn = setup_test_db();
|
||||
seed_project_issue_mr(&conn);
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url, created_at, updated_at)
|
||||
VALUES (2, 101, 'group/other', 'https://gitlab.example.com/group/other', 1000, 2000)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, gitlab_id, iid, project_id, title, state, created_at, updated_at, last_seen_at)
|
||||
VALUES (2, 201, 10, 2, 'Other issue', 'closed', 1000, 2000, 2000)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO merge_requests (id, gitlab_id, iid, project_id, title, state, created_at, updated_at, last_seen_at, source_branch, target_branch)
|
||||
VALUES (2, 301, 5, 2, 'Other MR', 'merged', 1000, 2000, 2000, 'feature', 'main')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO resource_state_events
|
||||
(gitlab_id, project_id, issue_id, merge_request_id, state,
|
||||
created_at, source_merge_request_iid)
|
||||
VALUES (1, 1, 1, NULL, 'closed', 3000, 5)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO resource_state_events
|
||||
(gitlab_id, project_id, issue_id, merge_request_id, state,
|
||||
created_at, source_merge_request_iid)
|
||||
VALUES (2, 2, 2, NULL, 'closed', 3000, 5)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let count = extract_refs_from_state_events(&conn, 1).unwrap();
|
||||
assert_eq!(count, 1);
|
||||
|
||||
let total: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM entity_references", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(total, 1, "Only project 1 refs should be created");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_entity_reference_creates_row() {
|
||||
let conn = setup_test_db();
|
||||
let (project_id, issue_id, mr_id) = seed_project_issue_mr(&conn);
|
||||
|
||||
let ref_ = EntityReference {
|
||||
project_id,
|
||||
source_entity_type: "merge_request",
|
||||
source_entity_id: mr_id,
|
||||
target_entity_type: "issue",
|
||||
target_entity_id: Some(issue_id),
|
||||
target_project_path: None,
|
||||
target_entity_iid: None,
|
||||
reference_type: "closes",
|
||||
source_method: "api",
|
||||
};
|
||||
|
||||
let inserted = insert_entity_reference(&conn, &ref_).unwrap();
|
||||
assert!(inserted);
|
||||
|
||||
let count = count_references_for_source(&conn, "merge_request", mr_id).unwrap();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_entity_reference_idempotent() {
|
||||
let conn = setup_test_db();
|
||||
let (project_id, issue_id, mr_id) = seed_project_issue_mr(&conn);
|
||||
|
||||
let ref_ = EntityReference {
|
||||
project_id,
|
||||
source_entity_type: "merge_request",
|
||||
source_entity_id: mr_id,
|
||||
target_entity_type: "issue",
|
||||
target_entity_id: Some(issue_id),
|
||||
target_project_path: None,
|
||||
target_entity_iid: None,
|
||||
reference_type: "closes",
|
||||
source_method: "api",
|
||||
};
|
||||
|
||||
let first = insert_entity_reference(&conn, &ref_).unwrap();
|
||||
assert!(first);
|
||||
|
||||
let second = insert_entity_reference(&conn, &ref_).unwrap();
|
||||
assert!(!second, "Duplicate insert should be ignored");
|
||||
|
||||
let count = count_references_for_source(&conn, "merge_request", mr_id).unwrap();
|
||||
assert_eq!(count, 1, "Still just one reference");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_entity_reference_cross_project_unresolved() {
|
||||
let conn = setup_test_db();
|
||||
let (project_id, _issue_id, mr_id) = seed_project_issue_mr(&conn);
|
||||
|
||||
let ref_ = EntityReference {
|
||||
project_id,
|
||||
source_entity_type: "merge_request",
|
||||
source_entity_id: mr_id,
|
||||
target_entity_type: "issue",
|
||||
target_entity_id: None,
|
||||
target_project_path: Some("other-group/other-project"),
|
||||
target_entity_iid: Some(99),
|
||||
reference_type: "closes",
|
||||
source_method: "api",
|
||||
};
|
||||
|
||||
let inserted = insert_entity_reference(&conn, &ref_).unwrap();
|
||||
assert!(inserted);
|
||||
|
||||
let (target_id, target_path, target_iid): (Option<i64>, Option<String>, Option<i64>) = conn
|
||||
.query_row(
|
||||
"SELECT target_entity_id, target_project_path, target_entity_iid \
|
||||
FROM entity_references WHERE source_entity_id = ?1",
|
||||
[mr_id],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(target_id.is_none());
|
||||
assert_eq!(target_path, Some("other-group/other-project".to_string()));
|
||||
assert_eq!(target_iid, Some(99));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_multiple_closes_references() {
|
||||
let conn = setup_test_db();
|
||||
let (project_id, issue_id, mr_id) = seed_project_issue_mr(&conn);
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, gitlab_id, iid, project_id, title, state, created_at, updated_at, last_seen_at)
|
||||
VALUES (10, 210, 11, ?1, 'Second issue', 'opened', 1000, 2000, 2000)",
|
||||
rusqlite::params![project_id],
|
||||
)
|
||||
.unwrap();
|
||||
let issue_id_2 = 10i64;
|
||||
|
||||
for target_id in [issue_id, issue_id_2] {
|
||||
let ref_ = EntityReference {
|
||||
project_id,
|
||||
source_entity_type: "merge_request",
|
||||
source_entity_id: mr_id,
|
||||
target_entity_type: "issue",
|
||||
target_entity_id: Some(target_id),
|
||||
target_project_path: None,
|
||||
target_entity_iid: None,
|
||||
reference_type: "closes",
|
||||
source_method: "api",
|
||||
};
|
||||
insert_entity_reference(&conn, &ref_).unwrap();
|
||||
}
|
||||
|
||||
let count = count_references_for_source(&conn, "merge_request", mr_id).unwrap();
|
||||
assert_eq!(count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_issue_local_id_found() {
|
||||
let conn = setup_test_db();
|
||||
let (project_id, issue_id, _mr_id) = seed_project_issue_mr(&conn);
|
||||
|
||||
let resolved = resolve_issue_local_id(&conn, project_id, 10).unwrap();
|
||||
assert_eq!(resolved, Some(issue_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_issue_local_id_not_found() {
|
||||
let conn = setup_test_db();
|
||||
let (project_id, _issue_id, _mr_id) = seed_project_issue_mr(&conn);
|
||||
|
||||
let resolved = resolve_issue_local_id(&conn, project_id, 999).unwrap();
|
||||
assert!(resolved.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_project_path_found() {
|
||||
let conn = setup_test_db();
|
||||
seed_project_issue_mr(&conn);
|
||||
|
||||
let path = resolve_project_path(&conn, 100).unwrap();
|
||||
assert_eq!(path, Some("group/repo".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_project_path_not_found() {
|
||||
let conn = setup_test_db();
|
||||
|
||||
let path = resolve_project_path(&conn, 999).unwrap();
|
||||
assert!(path.is_none());
|
||||
}
|
||||
}
|
||||
#[path = "references_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
425
src/core/references_tests.rs
Normal file
425
src/core/references_tests.rs
Normal file
@@ -0,0 +1,425 @@
|
||||
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 seed_project_issue_mr(conn: &Connection) -> (i64, i64, i64) {
|
||||
conn.execute(
|
||||
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url, created_at, updated_at)
|
||||
VALUES (1, 100, 'group/repo', 'https://gitlab.example.com/group/repo', 1000, 2000)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, gitlab_id, iid, project_id, title, state, created_at, updated_at, last_seen_at)
|
||||
VALUES (1, 200, 10, 1, 'Test issue', 'closed', 1000, 2000, 2000)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO merge_requests (id, gitlab_id, iid, project_id, title, state, created_at, updated_at, last_seen_at, source_branch, target_branch)
|
||||
VALUES (1, 300, 5, 1, 'Test MR', 'merged', 1000, 2000, 2000, 'feature', 'main')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
(1, 1, 1)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_refs_from_state_events_basic() {
|
||||
let conn = setup_test_db();
|
||||
let (project_id, issue_id, mr_id) = seed_project_issue_mr(&conn);
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO resource_state_events
|
||||
(gitlab_id, project_id, issue_id, merge_request_id, state,
|
||||
created_at, source_merge_request_iid)
|
||||
VALUES (1, ?1, ?2, NULL, 'closed', 3000, 5)",
|
||||
rusqlite::params![project_id, issue_id],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let count = extract_refs_from_state_events(&conn, project_id).unwrap();
|
||||
assert_eq!(count, 1, "Should insert exactly one reference");
|
||||
|
||||
let (src_type, src_id, tgt_type, tgt_id, ref_type, method): (
|
||||
String,
|
||||
i64,
|
||||
String,
|
||||
i64,
|
||||
String,
|
||||
String,
|
||||
) = conn
|
||||
.query_row(
|
||||
"SELECT source_entity_type, source_entity_id,
|
||||
target_entity_type, target_entity_id,
|
||||
reference_type, source_method
|
||||
FROM entity_references WHERE project_id = ?1",
|
||||
[project_id],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get(0)?,
|
||||
row.get(1)?,
|
||||
row.get(2)?,
|
||||
row.get(3)?,
|
||||
row.get(4)?,
|
||||
row.get(5)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(src_type, "merge_request");
|
||||
assert_eq!(src_id, mr_id, "Source should be the MR's local DB id");
|
||||
assert_eq!(tgt_type, "issue");
|
||||
assert_eq!(tgt_id, issue_id, "Target should be the issue's local DB id");
|
||||
assert_eq!(ref_type, "closes");
|
||||
assert_eq!(method, "api");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_refs_dedup_with_closes_issues() {
|
||||
let conn = setup_test_db();
|
||||
let (project_id, issue_id, mr_id) = seed_project_issue_mr(&conn);
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO entity_references
|
||||
(project_id, source_entity_type, source_entity_id,
|
||||
target_entity_type, target_entity_id,
|
||||
reference_type, source_method, created_at)
|
||||
VALUES (?1, 'merge_request', ?2, 'issue', ?3, 'closes', 'api', 3000)",
|
||||
rusqlite::params![project_id, mr_id, issue_id],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO resource_state_events
|
||||
(gitlab_id, project_id, issue_id, merge_request_id, state,
|
||||
created_at, source_merge_request_iid)
|
||||
VALUES (1, ?1, ?2, NULL, 'closed', 3000, 5)",
|
||||
rusqlite::params![project_id, issue_id],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let count = extract_refs_from_state_events(&conn, project_id).unwrap();
|
||||
assert_eq!(count, 0, "Should not insert duplicate reference");
|
||||
|
||||
let total: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM entity_references WHERE project_id = ?1",
|
||||
[project_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(total, 1, "Should still have exactly one reference");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_refs_no_source_mr() {
|
||||
let conn = setup_test_db();
|
||||
let (project_id, issue_id, _mr_id) = seed_project_issue_mr(&conn);
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO resource_state_events
|
||||
(gitlab_id, project_id, issue_id, merge_request_id, state,
|
||||
created_at, source_merge_request_iid)
|
||||
VALUES (1, ?1, ?2, NULL, 'closed', 3000, NULL)",
|
||||
rusqlite::params![project_id, issue_id],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let count = extract_refs_from_state_events(&conn, project_id).unwrap();
|
||||
assert_eq!(count, 0, "Should not create refs when no source MR");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_refs_mr_not_synced() {
|
||||
let conn = setup_test_db();
|
||||
let (project_id, issue_id, _mr_id) = seed_project_issue_mr(&conn);
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO resource_state_events
|
||||
(gitlab_id, project_id, issue_id, merge_request_id, state,
|
||||
created_at, source_merge_request_iid)
|
||||
VALUES (2, ?1, ?2, NULL, 'closed', 3000, 999)",
|
||||
rusqlite::params![project_id, issue_id],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let count = extract_refs_from_state_events(&conn, project_id).unwrap();
|
||||
assert_eq!(
|
||||
count, 0,
|
||||
"Should not create ref when MR is not synced locally"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_refs_idempotent() {
|
||||
let conn = setup_test_db();
|
||||
let (project_id, issue_id, _mr_id) = seed_project_issue_mr(&conn);
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO resource_state_events
|
||||
(gitlab_id, project_id, issue_id, merge_request_id, state,
|
||||
created_at, source_merge_request_iid)
|
||||
VALUES (1, ?1, ?2, NULL, 'closed', 3000, 5)",
|
||||
rusqlite::params![project_id, issue_id],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let count1 = extract_refs_from_state_events(&conn, project_id).unwrap();
|
||||
assert_eq!(count1, 1);
|
||||
|
||||
let count2 = extract_refs_from_state_events(&conn, project_id).unwrap();
|
||||
assert_eq!(count2, 0, "Second run should insert nothing (idempotent)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_refs_multiple_events_same_mr_issue() {
|
||||
let conn = setup_test_db();
|
||||
let (project_id, issue_id, _mr_id) = seed_project_issue_mr(&conn);
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO resource_state_events
|
||||
(gitlab_id, project_id, issue_id, merge_request_id, state,
|
||||
created_at, source_merge_request_iid)
|
||||
VALUES (1, ?1, ?2, NULL, 'closed', 3000, 5)",
|
||||
rusqlite::params![project_id, issue_id],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO resource_state_events
|
||||
(gitlab_id, project_id, issue_id, merge_request_id, state,
|
||||
created_at, source_merge_request_iid)
|
||||
VALUES (2, ?1, ?2, NULL, 'closed', 4000, 5)",
|
||||
rusqlite::params![project_id, issue_id],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let count = extract_refs_from_state_events(&conn, project_id).unwrap();
|
||||
assert!(count <= 2, "At most 2 inserts attempted");
|
||||
|
||||
let total: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM entity_references WHERE project_id = ?1",
|
||||
[project_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
total, 1,
|
||||
"Only one unique reference should exist for same MR->issue pair"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_refs_scoped_to_project() {
|
||||
let conn = setup_test_db();
|
||||
seed_project_issue_mr(&conn);
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url, created_at, updated_at)
|
||||
VALUES (2, 101, 'group/other', 'https://gitlab.example.com/group/other', 1000, 2000)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, gitlab_id, iid, project_id, title, state, created_at, updated_at, last_seen_at)
|
||||
VALUES (2, 201, 10, 2, 'Other issue', 'closed', 1000, 2000, 2000)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO merge_requests (id, gitlab_id, iid, project_id, title, state, created_at, updated_at, last_seen_at, source_branch, target_branch)
|
||||
VALUES (2, 301, 5, 2, 'Other MR', 'merged', 1000, 2000, 2000, 'feature', 'main')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO resource_state_events
|
||||
(gitlab_id, project_id, issue_id, merge_request_id, state,
|
||||
created_at, source_merge_request_iid)
|
||||
VALUES (1, 1, 1, NULL, 'closed', 3000, 5)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO resource_state_events
|
||||
(gitlab_id, project_id, issue_id, merge_request_id, state,
|
||||
created_at, source_merge_request_iid)
|
||||
VALUES (2, 2, 2, NULL, 'closed', 3000, 5)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let count = extract_refs_from_state_events(&conn, 1).unwrap();
|
||||
assert_eq!(count, 1);
|
||||
|
||||
let total: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM entity_references", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(total, 1, "Only project 1 refs should be created");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_entity_reference_creates_row() {
|
||||
let conn = setup_test_db();
|
||||
let (project_id, issue_id, mr_id) = seed_project_issue_mr(&conn);
|
||||
|
||||
let ref_ = EntityReference {
|
||||
project_id,
|
||||
source_entity_type: "merge_request",
|
||||
source_entity_id: mr_id,
|
||||
target_entity_type: "issue",
|
||||
target_entity_id: Some(issue_id),
|
||||
target_project_path: None,
|
||||
target_entity_iid: None,
|
||||
reference_type: "closes",
|
||||
source_method: "api",
|
||||
};
|
||||
|
||||
let inserted = insert_entity_reference(&conn, &ref_).unwrap();
|
||||
assert!(inserted);
|
||||
|
||||
let count = count_references_for_source(&conn, "merge_request", mr_id).unwrap();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_entity_reference_idempotent() {
|
||||
let conn = setup_test_db();
|
||||
let (project_id, issue_id, mr_id) = seed_project_issue_mr(&conn);
|
||||
|
||||
let ref_ = EntityReference {
|
||||
project_id,
|
||||
source_entity_type: "merge_request",
|
||||
source_entity_id: mr_id,
|
||||
target_entity_type: "issue",
|
||||
target_entity_id: Some(issue_id),
|
||||
target_project_path: None,
|
||||
target_entity_iid: None,
|
||||
reference_type: "closes",
|
||||
source_method: "api",
|
||||
};
|
||||
|
||||
let first = insert_entity_reference(&conn, &ref_).unwrap();
|
||||
assert!(first);
|
||||
|
||||
let second = insert_entity_reference(&conn, &ref_).unwrap();
|
||||
assert!(!second, "Duplicate insert should be ignored");
|
||||
|
||||
let count = count_references_for_source(&conn, "merge_request", mr_id).unwrap();
|
||||
assert_eq!(count, 1, "Still just one reference");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_entity_reference_cross_project_unresolved() {
|
||||
let conn = setup_test_db();
|
||||
let (project_id, _issue_id, mr_id) = seed_project_issue_mr(&conn);
|
||||
|
||||
let ref_ = EntityReference {
|
||||
project_id,
|
||||
source_entity_type: "merge_request",
|
||||
source_entity_id: mr_id,
|
||||
target_entity_type: "issue",
|
||||
target_entity_id: None,
|
||||
target_project_path: Some("other-group/other-project"),
|
||||
target_entity_iid: Some(99),
|
||||
reference_type: "closes",
|
||||
source_method: "api",
|
||||
};
|
||||
|
||||
let inserted = insert_entity_reference(&conn, &ref_).unwrap();
|
||||
assert!(inserted);
|
||||
|
||||
let (target_id, target_path, target_iid): (Option<i64>, Option<String>, Option<i64>) = conn
|
||||
.query_row(
|
||||
"SELECT target_entity_id, target_project_path, target_entity_iid \
|
||||
FROM entity_references WHERE source_entity_id = ?1",
|
||||
[mr_id],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(target_id.is_none());
|
||||
assert_eq!(target_path, Some("other-group/other-project".to_string()));
|
||||
assert_eq!(target_iid, Some(99));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_multiple_closes_references() {
|
||||
let conn = setup_test_db();
|
||||
let (project_id, issue_id, mr_id) = seed_project_issue_mr(&conn);
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, gitlab_id, iid, project_id, title, state, created_at, updated_at, last_seen_at)
|
||||
VALUES (10, 210, 11, ?1, 'Second issue', 'opened', 1000, 2000, 2000)",
|
||||
rusqlite::params![project_id],
|
||||
)
|
||||
.unwrap();
|
||||
let issue_id_2 = 10i64;
|
||||
|
||||
for target_id in [issue_id, issue_id_2] {
|
||||
let ref_ = EntityReference {
|
||||
project_id,
|
||||
source_entity_type: "merge_request",
|
||||
source_entity_id: mr_id,
|
||||
target_entity_type: "issue",
|
||||
target_entity_id: Some(target_id),
|
||||
target_project_path: None,
|
||||
target_entity_iid: None,
|
||||
reference_type: "closes",
|
||||
source_method: "api",
|
||||
};
|
||||
insert_entity_reference(&conn, &ref_).unwrap();
|
||||
}
|
||||
|
||||
let count = count_references_for_source(&conn, "merge_request", mr_id).unwrap();
|
||||
assert_eq!(count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_issue_local_id_found() {
|
||||
let conn = setup_test_db();
|
||||
let (project_id, issue_id, _mr_id) = seed_project_issue_mr(&conn);
|
||||
|
||||
let resolved = resolve_issue_local_id(&conn, project_id, 10).unwrap();
|
||||
assert_eq!(resolved, Some(issue_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_issue_local_id_not_found() {
|
||||
let conn = setup_test_db();
|
||||
let (project_id, _issue_id, _mr_id) = seed_project_issue_mr(&conn);
|
||||
|
||||
let resolved = resolve_issue_local_id(&conn, project_id, 999).unwrap();
|
||||
assert!(resolved.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_project_path_found() {
|
||||
let conn = setup_test_db();
|
||||
seed_project_issue_mr(&conn);
|
||||
|
||||
let path = resolve_project_path(&conn, 100).unwrap();
|
||||
assert_eq!(path, Some("group/repo".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_project_path_not_found() {
|
||||
let conn = setup_test_db();
|
||||
|
||||
let path = resolve_project_path(&conn, 999).unwrap();
|
||||
assert!(path.is_none());
|
||||
}
|
||||
@@ -66,153 +66,5 @@ impl SyncRunRecorder {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_run_recorder_start() {
|
||||
let conn = setup_test_db();
|
||||
let recorder = SyncRunRecorder::start(&conn, "sync", "abc12345").unwrap();
|
||||
assert!(recorder.row_id > 0);
|
||||
|
||||
let (status, command, run_id): (String, String, String) = conn
|
||||
.query_row(
|
||||
"SELECT status, command, run_id FROM sync_runs WHERE id = ?1",
|
||||
[recorder.row_id],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(status, "running");
|
||||
assert_eq!(command, "sync");
|
||||
assert_eq!(run_id, "abc12345");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_run_recorder_succeed() {
|
||||
let conn = setup_test_db();
|
||||
let recorder = SyncRunRecorder::start(&conn, "sync", "def67890").unwrap();
|
||||
let row_id = recorder.row_id;
|
||||
|
||||
let metrics = vec![StageTiming {
|
||||
name: "ingest".to_string(),
|
||||
project: None,
|
||||
elapsed_ms: 1200,
|
||||
items_processed: 50,
|
||||
items_skipped: 0,
|
||||
errors: 2,
|
||||
rate_limit_hits: 0,
|
||||
retries: 0,
|
||||
sub_stages: vec![],
|
||||
}];
|
||||
|
||||
recorder.succeed(&conn, &metrics, 50, 2).unwrap();
|
||||
|
||||
let (status, finished_at, metrics_json, total_items, total_errors): (
|
||||
String,
|
||||
Option<i64>,
|
||||
Option<String>,
|
||||
i64,
|
||||
i64,
|
||||
) = conn
|
||||
.query_row(
|
||||
"SELECT status, finished_at, metrics_json, total_items_processed, total_errors
|
||||
FROM sync_runs WHERE id = ?1",
|
||||
[row_id],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get(0)?,
|
||||
row.get(1)?,
|
||||
row.get(2)?,
|
||||
row.get(3)?,
|
||||
row.get(4)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(status, "succeeded");
|
||||
assert!(finished_at.is_some());
|
||||
assert!(metrics_json.is_some());
|
||||
assert_eq!(total_items, 50);
|
||||
assert_eq!(total_errors, 2);
|
||||
|
||||
let parsed: Vec<StageTiming> = serde_json::from_str(&metrics_json.unwrap()).unwrap();
|
||||
assert_eq!(parsed.len(), 1);
|
||||
assert_eq!(parsed[0].name, "ingest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_run_recorder_fail() {
|
||||
let conn = setup_test_db();
|
||||
let recorder = SyncRunRecorder::start(&conn, "ingest issues", "fail0001").unwrap();
|
||||
let row_id = recorder.row_id;
|
||||
|
||||
recorder.fail(&conn, "GitLab auth failed", None).unwrap();
|
||||
|
||||
let (status, finished_at, error, metrics_json): (
|
||||
String,
|
||||
Option<i64>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
) = conn
|
||||
.query_row(
|
||||
"SELECT status, finished_at, error, metrics_json
|
||||
FROM sync_runs WHERE id = ?1",
|
||||
[row_id],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(status, "failed");
|
||||
assert!(finished_at.is_some());
|
||||
assert_eq!(error.as_deref(), Some("GitLab auth failed"));
|
||||
assert!(metrics_json.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_run_recorder_fail_with_partial_metrics() {
|
||||
let conn = setup_test_db();
|
||||
let recorder = SyncRunRecorder::start(&conn, "sync", "part0001").unwrap();
|
||||
let row_id = recorder.row_id;
|
||||
|
||||
let partial_metrics = vec![StageTiming {
|
||||
name: "ingest_issues".to_string(),
|
||||
project: Some("group/repo".to_string()),
|
||||
elapsed_ms: 800,
|
||||
items_processed: 30,
|
||||
items_skipped: 0,
|
||||
errors: 0,
|
||||
rate_limit_hits: 1,
|
||||
retries: 0,
|
||||
sub_stages: vec![],
|
||||
}];
|
||||
|
||||
recorder
|
||||
.fail(&conn, "Embedding failed", Some(&partial_metrics))
|
||||
.unwrap();
|
||||
|
||||
let (status, metrics_json): (String, Option<String>) = conn
|
||||
.query_row(
|
||||
"SELECT status, metrics_json FROM sync_runs WHERE id = ?1",
|
||||
[row_id],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(status, "failed");
|
||||
assert!(metrics_json.is_some());
|
||||
|
||||
let parsed: Vec<StageTiming> = serde_json::from_str(&metrics_json.unwrap()).unwrap();
|
||||
assert_eq!(parsed.len(), 1);
|
||||
assert_eq!(parsed[0].name, "ingest_issues");
|
||||
}
|
||||
}
|
||||
#[path = "sync_run_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
148
src/core/sync_run_tests.rs
Normal file
148
src/core/sync_run_tests.rs
Normal file
@@ -0,0 +1,148 @@
|
||||
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
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_run_recorder_start() {
|
||||
let conn = setup_test_db();
|
||||
let recorder = SyncRunRecorder::start(&conn, "sync", "abc12345").unwrap();
|
||||
assert!(recorder.row_id > 0);
|
||||
|
||||
let (status, command, run_id): (String, String, String) = conn
|
||||
.query_row(
|
||||
"SELECT status, command, run_id FROM sync_runs WHERE id = ?1",
|
||||
[recorder.row_id],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(status, "running");
|
||||
assert_eq!(command, "sync");
|
||||
assert_eq!(run_id, "abc12345");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_run_recorder_succeed() {
|
||||
let conn = setup_test_db();
|
||||
let recorder = SyncRunRecorder::start(&conn, "sync", "def67890").unwrap();
|
||||
let row_id = recorder.row_id;
|
||||
|
||||
let metrics = vec![StageTiming {
|
||||
name: "ingest".to_string(),
|
||||
project: None,
|
||||
elapsed_ms: 1200,
|
||||
items_processed: 50,
|
||||
items_skipped: 0,
|
||||
errors: 2,
|
||||
rate_limit_hits: 0,
|
||||
retries: 0,
|
||||
sub_stages: vec![],
|
||||
}];
|
||||
|
||||
recorder.succeed(&conn, &metrics, 50, 2).unwrap();
|
||||
|
||||
let (status, finished_at, metrics_json, total_items, total_errors): (
|
||||
String,
|
||||
Option<i64>,
|
||||
Option<String>,
|
||||
i64,
|
||||
i64,
|
||||
) = conn
|
||||
.query_row(
|
||||
"SELECT status, finished_at, metrics_json, total_items_processed, total_errors
|
||||
FROM sync_runs WHERE id = ?1",
|
||||
[row_id],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get(0)?,
|
||||
row.get(1)?,
|
||||
row.get(2)?,
|
||||
row.get(3)?,
|
||||
row.get(4)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(status, "succeeded");
|
||||
assert!(finished_at.is_some());
|
||||
assert!(metrics_json.is_some());
|
||||
assert_eq!(total_items, 50);
|
||||
assert_eq!(total_errors, 2);
|
||||
|
||||
let parsed: Vec<StageTiming> = serde_json::from_str(&metrics_json.unwrap()).unwrap();
|
||||
assert_eq!(parsed.len(), 1);
|
||||
assert_eq!(parsed[0].name, "ingest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_run_recorder_fail() {
|
||||
let conn = setup_test_db();
|
||||
let recorder = SyncRunRecorder::start(&conn, "ingest issues", "fail0001").unwrap();
|
||||
let row_id = recorder.row_id;
|
||||
|
||||
recorder.fail(&conn, "GitLab auth failed", None).unwrap();
|
||||
|
||||
let (status, finished_at, error, metrics_json): (
|
||||
String,
|
||||
Option<i64>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
) = conn
|
||||
.query_row(
|
||||
"SELECT status, finished_at, error, metrics_json
|
||||
FROM sync_runs WHERE id = ?1",
|
||||
[row_id],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(status, "failed");
|
||||
assert!(finished_at.is_some());
|
||||
assert_eq!(error.as_deref(), Some("GitLab auth failed"));
|
||||
assert!(metrics_json.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_run_recorder_fail_with_partial_metrics() {
|
||||
let conn = setup_test_db();
|
||||
let recorder = SyncRunRecorder::start(&conn, "sync", "part0001").unwrap();
|
||||
let row_id = recorder.row_id;
|
||||
|
||||
let partial_metrics = vec![StageTiming {
|
||||
name: "ingest_issues".to_string(),
|
||||
project: Some("group/repo".to_string()),
|
||||
elapsed_ms: 800,
|
||||
items_processed: 30,
|
||||
items_skipped: 0,
|
||||
errors: 0,
|
||||
rate_limit_hits: 1,
|
||||
retries: 0,
|
||||
sub_stages: vec![],
|
||||
}];
|
||||
|
||||
recorder
|
||||
.fail(&conn, "Embedding failed", Some(&partial_metrics))
|
||||
.unwrap();
|
||||
|
||||
let (status, metrics_json): (String, Option<String>) = conn
|
||||
.query_row(
|
||||
"SELECT status, metrics_json FROM sync_runs WHERE id = ?1",
|
||||
[row_id],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(status, "failed");
|
||||
assert!(metrics_json.is_some());
|
||||
|
||||
let parsed: Vec<StageTiming> = serde_json::from_str(&metrics_json.unwrap()).unwrap();
|
||||
assert_eq!(parsed.len(), 1);
|
||||
assert_eq!(parsed[0].name, "ingest_issues");
|
||||
}
|
||||
@@ -370,326 +370,5 @@ fn entity_id_column(entity: &EntityRef) -> Result<(&'static str, i64)> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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);
|
||||
}
|
||||
}
|
||||
#[path = "timeline_collect_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -248,310 +248,5 @@ fn find_incoming(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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) VALUES (?1, ?2, ?3, 'Test', 'opened', 'alice', 1000, 2000, 3000)",
|
||||
rusqlite::params![iid * 100, project_id, iid],
|
||||
)
|
||||
.unwrap();
|
||||
conn.last_insert_rowid()
|
||||
}
|
||||
|
||||
fn insert_mr(conn: &Connection, project_id: i64, iid: i64) -> i64 {
|
||||
conn.execute(
|
||||
"INSERT INTO merge_requests (gitlab_id, project_id, iid, title, state, author_username, created_at, updated_at, last_seen_at) VALUES (?1, ?2, ?3, 'Test MR', 'opened', 'bob', 1000, 2000, 3000)",
|
||||
rusqlite::params![iid * 100, project_id, iid],
|
||||
)
|
||||
.unwrap();
|
||||
conn.last_insert_rowid()
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn insert_ref(
|
||||
conn: &Connection,
|
||||
project_id: i64,
|
||||
source_type: &str,
|
||||
source_id: i64,
|
||||
target_type: &str,
|
||||
target_id: Option<i64>,
|
||||
ref_type: &str,
|
||||
source_method: &str,
|
||||
) {
|
||||
conn.execute(
|
||||
"INSERT INTO entity_references (project_id, source_entity_type, source_entity_id, target_entity_type, target_entity_id, reference_type, source_method, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 1000)",
|
||||
rusqlite::params![project_id, source_type, source_id, target_type, target_id, ref_type, source_method],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_depth_zero() {
|
||||
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 result = expand_timeline(&conn, &seeds, 0, false, 100).unwrap();
|
||||
assert!(result.expanded_entities.is_empty());
|
||||
assert!(result.unresolved_references.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_finds_linked_entity() {
|
||||
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);
|
||||
|
||||
// MR closes issue
|
||||
insert_ref(
|
||||
&conn,
|
||||
project_id,
|
||||
"merge_request",
|
||||
mr_id,
|
||||
"issue",
|
||||
Some(issue_id),
|
||||
"closes",
|
||||
"api",
|
||||
);
|
||||
|
||||
let seeds = vec![make_entity_ref("issue", issue_id, 1)];
|
||||
let result = expand_timeline(&conn, &seeds, 1, false, 100).unwrap();
|
||||
|
||||
assert_eq!(result.expanded_entities.len(), 1);
|
||||
assert_eq!(
|
||||
result.expanded_entities[0].entity_ref.entity_type,
|
||||
"merge_request"
|
||||
);
|
||||
assert_eq!(result.expanded_entities[0].entity_ref.entity_iid, 10);
|
||||
assert_eq!(result.expanded_entities[0].depth, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_bidirectional() {
|
||||
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);
|
||||
|
||||
// MR closes issue (MR is source, issue is target)
|
||||
insert_ref(
|
||||
&conn,
|
||||
project_id,
|
||||
"merge_request",
|
||||
mr_id,
|
||||
"issue",
|
||||
Some(issue_id),
|
||||
"closes",
|
||||
"api",
|
||||
);
|
||||
|
||||
// Starting from MR should find the issue (outgoing)
|
||||
let seeds = vec![make_entity_ref("merge_request", mr_id, 10)];
|
||||
let result = expand_timeline(&conn, &seeds, 1, false, 100).unwrap();
|
||||
|
||||
assert_eq!(result.expanded_entities.len(), 1);
|
||||
assert_eq!(result.expanded_entities[0].entity_ref.entity_type, "issue");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_respects_max_entities() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = insert_project(&conn);
|
||||
let issue_id = insert_issue(&conn, project_id, 1);
|
||||
|
||||
// Create 10 MRs that all close this issue
|
||||
for i in 2..=11 {
|
||||
let mr_id = insert_mr(&conn, project_id, i);
|
||||
insert_ref(
|
||||
&conn,
|
||||
project_id,
|
||||
"merge_request",
|
||||
mr_id,
|
||||
"issue",
|
||||
Some(issue_id),
|
||||
"closes",
|
||||
"api",
|
||||
);
|
||||
}
|
||||
|
||||
let seeds = vec![make_entity_ref("issue", issue_id, 1)];
|
||||
let result = expand_timeline(&conn, &seeds, 1, false, 3).unwrap();
|
||||
|
||||
assert!(result.expanded_entities.len() <= 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_skips_mentions_by_default() {
|
||||
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);
|
||||
|
||||
// MR mentions issue (should be skipped by default)
|
||||
insert_ref(
|
||||
&conn,
|
||||
project_id,
|
||||
"merge_request",
|
||||
mr_id,
|
||||
"issue",
|
||||
Some(issue_id),
|
||||
"mentioned",
|
||||
"note_parse",
|
||||
);
|
||||
|
||||
let seeds = vec![make_entity_ref("issue", issue_id, 1)];
|
||||
let result = expand_timeline(&conn, &seeds, 1, false, 100).unwrap();
|
||||
assert!(result.expanded_entities.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_includes_mentions_when_flagged() {
|
||||
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);
|
||||
|
||||
// MR mentions issue
|
||||
insert_ref(
|
||||
&conn,
|
||||
project_id,
|
||||
"merge_request",
|
||||
mr_id,
|
||||
"issue",
|
||||
Some(issue_id),
|
||||
"mentioned",
|
||||
"note_parse",
|
||||
);
|
||||
|
||||
let seeds = vec![make_entity_ref("issue", issue_id, 1)];
|
||||
let result = expand_timeline(&conn, &seeds, 1, true, 100).unwrap();
|
||||
assert_eq!(result.expanded_entities.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_collects_unresolved() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = insert_project(&conn);
|
||||
let issue_id = insert_issue(&conn, project_id, 1);
|
||||
|
||||
// Unresolved cross-project reference
|
||||
conn.execute(
|
||||
"INSERT INTO entity_references (project_id, source_entity_type, source_entity_id, target_entity_type, target_entity_id, target_project_path, target_entity_iid, reference_type, source_method, created_at) VALUES (?1, 'issue', ?2, 'issue', NULL, 'other/repo', 42, 'closes', 'description_parse', 1000)",
|
||||
rusqlite::params![project_id, issue_id],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let seeds = vec![make_entity_ref("issue", issue_id, 1)];
|
||||
let result = expand_timeline(&conn, &seeds, 1, false, 100).unwrap();
|
||||
|
||||
assert!(result.expanded_entities.is_empty());
|
||||
assert_eq!(result.unresolved_references.len(), 1);
|
||||
assert_eq!(
|
||||
result.unresolved_references[0].target_project,
|
||||
Some("other/repo".to_owned())
|
||||
);
|
||||
assert_eq!(result.unresolved_references[0].target_iid, Some(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_tracks_provenance() {
|
||||
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);
|
||||
|
||||
insert_ref(
|
||||
&conn,
|
||||
project_id,
|
||||
"merge_request",
|
||||
mr_id,
|
||||
"issue",
|
||||
Some(issue_id),
|
||||
"closes",
|
||||
"api",
|
||||
);
|
||||
|
||||
let seeds = vec![make_entity_ref("issue", issue_id, 1)];
|
||||
let result = expand_timeline(&conn, &seeds, 1, false, 100).unwrap();
|
||||
|
||||
assert_eq!(result.expanded_entities.len(), 1);
|
||||
let expanded = &result.expanded_entities[0];
|
||||
assert_eq!(expanded.via_reference_type, "closes");
|
||||
assert_eq!(expanded.via_source_method, "api");
|
||||
assert_eq!(expanded.via_from.entity_type, "issue");
|
||||
assert_eq!(expanded.via_from.entity_id, issue_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_no_duplicates() {
|
||||
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);
|
||||
|
||||
// Two references from MR to same issue (different methods)
|
||||
insert_ref(
|
||||
&conn,
|
||||
project_id,
|
||||
"merge_request",
|
||||
mr_id,
|
||||
"issue",
|
||||
Some(issue_id),
|
||||
"closes",
|
||||
"api",
|
||||
);
|
||||
insert_ref(
|
||||
&conn,
|
||||
project_id,
|
||||
"merge_request",
|
||||
mr_id,
|
||||
"issue",
|
||||
Some(issue_id),
|
||||
"related",
|
||||
"note_parse",
|
||||
);
|
||||
|
||||
let seeds = vec![make_entity_ref("merge_request", mr_id, 10)];
|
||||
let result = expand_timeline(&conn, &seeds, 1, false, 100).unwrap();
|
||||
|
||||
// Should only appear once (first-come wins)
|
||||
assert_eq!(result.expanded_entities.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_empty_seeds() {
|
||||
let conn = setup_test_db();
|
||||
let result = expand_timeline(&conn, &[], 1, false, 100).unwrap();
|
||||
assert!(result.expanded_entities.is_empty());
|
||||
}
|
||||
}
|
||||
#[path = "timeline_expand_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
305
src/core/timeline_expand_tests.rs
Normal file
305
src/core/timeline_expand_tests.rs
Normal file
@@ -0,0 +1,305 @@
|
||||
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) VALUES (?1, ?2, ?3, 'Test', 'opened', 'alice', 1000, 2000, 3000)",
|
||||
rusqlite::params![iid * 100, project_id, iid],
|
||||
)
|
||||
.unwrap();
|
||||
conn.last_insert_rowid()
|
||||
}
|
||||
|
||||
fn insert_mr(conn: &Connection, project_id: i64, iid: i64) -> i64 {
|
||||
conn.execute(
|
||||
"INSERT INTO merge_requests (gitlab_id, project_id, iid, title, state, author_username, created_at, updated_at, last_seen_at) VALUES (?1, ?2, ?3, 'Test MR', 'opened', 'bob', 1000, 2000, 3000)",
|
||||
rusqlite::params![iid * 100, project_id, iid],
|
||||
)
|
||||
.unwrap();
|
||||
conn.last_insert_rowid()
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn insert_ref(
|
||||
conn: &Connection,
|
||||
project_id: i64,
|
||||
source_type: &str,
|
||||
source_id: i64,
|
||||
target_type: &str,
|
||||
target_id: Option<i64>,
|
||||
ref_type: &str,
|
||||
source_method: &str,
|
||||
) {
|
||||
conn.execute(
|
||||
"INSERT INTO entity_references (project_id, source_entity_type, source_entity_id, target_entity_type, target_entity_id, reference_type, source_method, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 1000)",
|
||||
rusqlite::params![project_id, source_type, source_id, target_type, target_id, ref_type, source_method],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_depth_zero() {
|
||||
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 result = expand_timeline(&conn, &seeds, 0, false, 100).unwrap();
|
||||
assert!(result.expanded_entities.is_empty());
|
||||
assert!(result.unresolved_references.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_finds_linked_entity() {
|
||||
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);
|
||||
|
||||
// MR closes issue
|
||||
insert_ref(
|
||||
&conn,
|
||||
project_id,
|
||||
"merge_request",
|
||||
mr_id,
|
||||
"issue",
|
||||
Some(issue_id),
|
||||
"closes",
|
||||
"api",
|
||||
);
|
||||
|
||||
let seeds = vec![make_entity_ref("issue", issue_id, 1)];
|
||||
let result = expand_timeline(&conn, &seeds, 1, false, 100).unwrap();
|
||||
|
||||
assert_eq!(result.expanded_entities.len(), 1);
|
||||
assert_eq!(
|
||||
result.expanded_entities[0].entity_ref.entity_type,
|
||||
"merge_request"
|
||||
);
|
||||
assert_eq!(result.expanded_entities[0].entity_ref.entity_iid, 10);
|
||||
assert_eq!(result.expanded_entities[0].depth, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_bidirectional() {
|
||||
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);
|
||||
|
||||
// MR closes issue (MR is source, issue is target)
|
||||
insert_ref(
|
||||
&conn,
|
||||
project_id,
|
||||
"merge_request",
|
||||
mr_id,
|
||||
"issue",
|
||||
Some(issue_id),
|
||||
"closes",
|
||||
"api",
|
||||
);
|
||||
|
||||
// Starting from MR should find the issue (outgoing)
|
||||
let seeds = vec![make_entity_ref("merge_request", mr_id, 10)];
|
||||
let result = expand_timeline(&conn, &seeds, 1, false, 100).unwrap();
|
||||
|
||||
assert_eq!(result.expanded_entities.len(), 1);
|
||||
assert_eq!(result.expanded_entities[0].entity_ref.entity_type, "issue");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_respects_max_entities() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = insert_project(&conn);
|
||||
let issue_id = insert_issue(&conn, project_id, 1);
|
||||
|
||||
// Create 10 MRs that all close this issue
|
||||
for i in 2..=11 {
|
||||
let mr_id = insert_mr(&conn, project_id, i);
|
||||
insert_ref(
|
||||
&conn,
|
||||
project_id,
|
||||
"merge_request",
|
||||
mr_id,
|
||||
"issue",
|
||||
Some(issue_id),
|
||||
"closes",
|
||||
"api",
|
||||
);
|
||||
}
|
||||
|
||||
let seeds = vec![make_entity_ref("issue", issue_id, 1)];
|
||||
let result = expand_timeline(&conn, &seeds, 1, false, 3).unwrap();
|
||||
|
||||
assert!(result.expanded_entities.len() <= 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_skips_mentions_by_default() {
|
||||
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);
|
||||
|
||||
// MR mentions issue (should be skipped by default)
|
||||
insert_ref(
|
||||
&conn,
|
||||
project_id,
|
||||
"merge_request",
|
||||
mr_id,
|
||||
"issue",
|
||||
Some(issue_id),
|
||||
"mentioned",
|
||||
"note_parse",
|
||||
);
|
||||
|
||||
let seeds = vec![make_entity_ref("issue", issue_id, 1)];
|
||||
let result = expand_timeline(&conn, &seeds, 1, false, 100).unwrap();
|
||||
assert!(result.expanded_entities.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_includes_mentions_when_flagged() {
|
||||
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);
|
||||
|
||||
// MR mentions issue
|
||||
insert_ref(
|
||||
&conn,
|
||||
project_id,
|
||||
"merge_request",
|
||||
mr_id,
|
||||
"issue",
|
||||
Some(issue_id),
|
||||
"mentioned",
|
||||
"note_parse",
|
||||
);
|
||||
|
||||
let seeds = vec![make_entity_ref("issue", issue_id, 1)];
|
||||
let result = expand_timeline(&conn, &seeds, 1, true, 100).unwrap();
|
||||
assert_eq!(result.expanded_entities.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_collects_unresolved() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = insert_project(&conn);
|
||||
let issue_id = insert_issue(&conn, project_id, 1);
|
||||
|
||||
// Unresolved cross-project reference
|
||||
conn.execute(
|
||||
"INSERT INTO entity_references (project_id, source_entity_type, source_entity_id, target_entity_type, target_entity_id, target_project_path, target_entity_iid, reference_type, source_method, created_at) VALUES (?1, 'issue', ?2, 'issue', NULL, 'other/repo', 42, 'closes', 'description_parse', 1000)",
|
||||
rusqlite::params![project_id, issue_id],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let seeds = vec![make_entity_ref("issue", issue_id, 1)];
|
||||
let result = expand_timeline(&conn, &seeds, 1, false, 100).unwrap();
|
||||
|
||||
assert!(result.expanded_entities.is_empty());
|
||||
assert_eq!(result.unresolved_references.len(), 1);
|
||||
assert_eq!(
|
||||
result.unresolved_references[0].target_project,
|
||||
Some("other/repo".to_owned())
|
||||
);
|
||||
assert_eq!(result.unresolved_references[0].target_iid, Some(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_tracks_provenance() {
|
||||
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);
|
||||
|
||||
insert_ref(
|
||||
&conn,
|
||||
project_id,
|
||||
"merge_request",
|
||||
mr_id,
|
||||
"issue",
|
||||
Some(issue_id),
|
||||
"closes",
|
||||
"api",
|
||||
);
|
||||
|
||||
let seeds = vec![make_entity_ref("issue", issue_id, 1)];
|
||||
let result = expand_timeline(&conn, &seeds, 1, false, 100).unwrap();
|
||||
|
||||
assert_eq!(result.expanded_entities.len(), 1);
|
||||
let expanded = &result.expanded_entities[0];
|
||||
assert_eq!(expanded.via_reference_type, "closes");
|
||||
assert_eq!(expanded.via_source_method, "api");
|
||||
assert_eq!(expanded.via_from.entity_type, "issue");
|
||||
assert_eq!(expanded.via_from.entity_id, issue_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_no_duplicates() {
|
||||
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);
|
||||
|
||||
// Two references from MR to same issue (different methods)
|
||||
insert_ref(
|
||||
&conn,
|
||||
project_id,
|
||||
"merge_request",
|
||||
mr_id,
|
||||
"issue",
|
||||
Some(issue_id),
|
||||
"closes",
|
||||
"api",
|
||||
);
|
||||
insert_ref(
|
||||
&conn,
|
||||
project_id,
|
||||
"merge_request",
|
||||
mr_id,
|
||||
"issue",
|
||||
Some(issue_id),
|
||||
"related",
|
||||
"note_parse",
|
||||
);
|
||||
|
||||
let seeds = vec![make_entity_ref("merge_request", mr_id, 10)];
|
||||
let result = expand_timeline(&conn, &seeds, 1, false, 100).unwrap();
|
||||
|
||||
// Should only appear once (first-come wins)
|
||||
assert_eq!(result.expanded_entities.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_empty_seeds() {
|
||||
let conn = setup_test_db();
|
||||
let result = expand_timeline(&conn, &[], 1, false, 100).unwrap();
|
||||
assert!(result.expanded_entities.is_empty());
|
||||
}
|
||||
@@ -233,320 +233,5 @@ fn truncate_to_chars(s: &str, max_chars: usize) -> String {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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_test_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_test_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) VALUES (?1, ?2, ?3, 'Test issue', 'opened', 'alice', 1000, 2000, 3000)",
|
||||
rusqlite::params![iid * 100, project_id, iid],
|
||||
)
|
||||
.unwrap();
|
||||
conn.last_insert_rowid()
|
||||
}
|
||||
|
||||
fn insert_test_mr(conn: &Connection, project_id: i64, iid: i64) -> i64 {
|
||||
conn.execute(
|
||||
"INSERT INTO merge_requests (gitlab_id, project_id, iid, title, state, author_username, created_at, updated_at, last_seen_at) VALUES (?1, ?2, ?3, 'Test MR', 'opened', 'bob', 1000, 2000, 3000)",
|
||||
rusqlite::params![iid * 100, project_id, iid],
|
||||
)
|
||||
.unwrap();
|
||||
conn.last_insert_rowid()
|
||||
}
|
||||
|
||||
fn insert_document(
|
||||
conn: &Connection,
|
||||
source_type: &str,
|
||||
source_id: i64,
|
||||
project_id: i64,
|
||||
content: &str,
|
||||
) -> i64 {
|
||||
conn.execute(
|
||||
"INSERT INTO documents (source_type, source_id, project_id, content_text, content_hash) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
rusqlite::params![source_type, source_id, project_id, content, format!("hash_{source_id}")],
|
||||
)
|
||||
.unwrap();
|
||||
conn.last_insert_rowid()
|
||||
}
|
||||
|
||||
fn insert_discussion(
|
||||
conn: &Connection,
|
||||
project_id: i64,
|
||||
issue_id: Option<i64>,
|
||||
mr_id: Option<i64>,
|
||||
) -> i64 {
|
||||
let noteable_type = if issue_id.is_some() {
|
||||
"Issue"
|
||||
} else {
|
||||
"MergeRequest"
|
||||
};
|
||||
conn.execute(
|
||||
"INSERT INTO discussions (gitlab_discussion_id, project_id, issue_id, merge_request_id, noteable_type, last_seen_at) VALUES (?1, ?2, ?3, ?4, ?5, 0)",
|
||||
rusqlite::params![format!("disc_{}", rand::random::<u32>()), project_id, issue_id, mr_id, noteable_type],
|
||||
)
|
||||
.unwrap();
|
||||
conn.last_insert_rowid()
|
||||
}
|
||||
|
||||
fn insert_note(
|
||||
conn: &Connection,
|
||||
discussion_id: i64,
|
||||
project_id: i64,
|
||||
body: &str,
|
||||
is_system: bool,
|
||||
) -> i64 {
|
||||
let gitlab_id: i64 = rand::random::<u32>().into();
|
||||
conn.execute(
|
||||
"INSERT INTO notes (gitlab_id, discussion_id, project_id, is_system, author_username, body, created_at, updated_at, last_seen_at) VALUES (?1, ?2, ?3, ?4, 'alice', ?5, 5000, 5000, 5000)",
|
||||
rusqlite::params![gitlab_id, discussion_id, project_id, is_system as i32, body],
|
||||
)
|
||||
.unwrap();
|
||||
conn.last_insert_rowid()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_empty_query_returns_empty() {
|
||||
let conn = setup_test_db();
|
||||
let result = seed_timeline(&conn, "", None, None, 50, 10).unwrap();
|
||||
assert!(result.seed_entities.is_empty());
|
||||
assert!(result.evidence_notes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_no_matches_returns_empty() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = insert_test_project(&conn);
|
||||
let issue_id = insert_test_issue(&conn, project_id, 1);
|
||||
insert_document(
|
||||
&conn,
|
||||
"issue",
|
||||
issue_id,
|
||||
project_id,
|
||||
"unrelated content here",
|
||||
);
|
||||
|
||||
let result = seed_timeline(&conn, "nonexistent_xyzzy_query", None, None, 50, 10).unwrap();
|
||||
assert!(result.seed_entities.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_finds_issue() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = insert_test_project(&conn);
|
||||
let issue_id = insert_test_issue(&conn, project_id, 42);
|
||||
insert_document(
|
||||
&conn,
|
||||
"issue",
|
||||
issue_id,
|
||||
project_id,
|
||||
"authentication error in login flow",
|
||||
);
|
||||
|
||||
let result = seed_timeline(&conn, "authentication", None, None, 50, 10).unwrap();
|
||||
assert_eq!(result.seed_entities.len(), 1);
|
||||
assert_eq!(result.seed_entities[0].entity_type, "issue");
|
||||
assert_eq!(result.seed_entities[0].entity_iid, 42);
|
||||
assert_eq!(result.seed_entities[0].project_path, "group/project");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_finds_mr() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = insert_test_project(&conn);
|
||||
let mr_id = insert_test_mr(&conn, project_id, 99);
|
||||
insert_document(
|
||||
&conn,
|
||||
"merge_request",
|
||||
mr_id,
|
||||
project_id,
|
||||
"fix authentication bug",
|
||||
);
|
||||
|
||||
let result = seed_timeline(&conn, "authentication", None, None, 50, 10).unwrap();
|
||||
assert_eq!(result.seed_entities.len(), 1);
|
||||
assert_eq!(result.seed_entities[0].entity_type, "merge_request");
|
||||
assert_eq!(result.seed_entities[0].entity_iid, 99);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_deduplicates_entities() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = insert_test_project(&conn);
|
||||
let issue_id = insert_test_issue(&conn, project_id, 10);
|
||||
|
||||
// Two documents referencing the same issue
|
||||
insert_document(
|
||||
&conn,
|
||||
"issue",
|
||||
issue_id,
|
||||
project_id,
|
||||
"authentication error first doc",
|
||||
);
|
||||
let disc_id = insert_discussion(&conn, project_id, Some(issue_id), None);
|
||||
insert_document(
|
||||
&conn,
|
||||
"discussion",
|
||||
disc_id,
|
||||
project_id,
|
||||
"authentication error second doc",
|
||||
);
|
||||
|
||||
let result = seed_timeline(&conn, "authentication", None, None, 50, 10).unwrap();
|
||||
// Should deduplicate: both map to the same issue
|
||||
assert_eq!(result.seed_entities.len(), 1);
|
||||
assert_eq!(result.seed_entities[0].entity_iid, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_resolves_discussion_to_parent() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = insert_test_project(&conn);
|
||||
let issue_id = insert_test_issue(&conn, project_id, 7);
|
||||
let disc_id = insert_discussion(&conn, project_id, Some(issue_id), None);
|
||||
insert_document(
|
||||
&conn,
|
||||
"discussion",
|
||||
disc_id,
|
||||
project_id,
|
||||
"deployment pipeline failed",
|
||||
);
|
||||
|
||||
let result = seed_timeline(&conn, "deployment", None, None, 50, 10).unwrap();
|
||||
assert_eq!(result.seed_entities.len(), 1);
|
||||
assert_eq!(result.seed_entities[0].entity_type, "issue");
|
||||
assert_eq!(result.seed_entities[0].entity_iid, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_evidence_capped() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = insert_test_project(&conn);
|
||||
let issue_id = insert_test_issue(&conn, project_id, 1);
|
||||
|
||||
// Create 15 discussion documents with notes about "deployment"
|
||||
for i in 0..15 {
|
||||
let disc_id = insert_discussion(&conn, project_id, Some(issue_id), None);
|
||||
insert_document(
|
||||
&conn,
|
||||
"discussion",
|
||||
disc_id,
|
||||
project_id,
|
||||
&format!("deployment issue number {i}"),
|
||||
);
|
||||
insert_note(
|
||||
&conn,
|
||||
disc_id,
|
||||
project_id,
|
||||
&format!("deployment note {i}"),
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
let result = seed_timeline(&conn, "deployment", None, None, 50, 5).unwrap();
|
||||
assert!(result.evidence_notes.len() <= 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_evidence_snippet_truncated() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = insert_test_project(&conn);
|
||||
let issue_id = insert_test_issue(&conn, project_id, 1);
|
||||
let disc_id = insert_discussion(&conn, project_id, Some(issue_id), None);
|
||||
insert_document(
|
||||
&conn,
|
||||
"discussion",
|
||||
disc_id,
|
||||
project_id,
|
||||
"deployment configuration",
|
||||
);
|
||||
|
||||
let long_body = "x".repeat(500);
|
||||
insert_note(&conn, disc_id, project_id, &long_body, false);
|
||||
|
||||
let result = seed_timeline(&conn, "deployment", None, None, 50, 10).unwrap();
|
||||
assert!(!result.evidence_notes.is_empty());
|
||||
if let TimelineEventType::NoteEvidence { snippet, .. } =
|
||||
&result.evidence_notes[0].event_type
|
||||
{
|
||||
assert!(snippet.chars().count() <= 200);
|
||||
} else {
|
||||
panic!("Expected NoteEvidence");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_respects_project_filter() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = insert_test_project(&conn);
|
||||
|
||||
// Insert a second project
|
||||
conn.execute(
|
||||
"INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url) VALUES (2, 'other/repo', 'https://gitlab.com/other/repo')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let project2_id = conn.last_insert_rowid();
|
||||
|
||||
let issue1_id = insert_test_issue(&conn, project_id, 1);
|
||||
insert_document(
|
||||
&conn,
|
||||
"issue",
|
||||
issue1_id,
|
||||
project_id,
|
||||
"authentication error",
|
||||
);
|
||||
|
||||
let issue2_id = insert_test_issue(&conn, project2_id, 2);
|
||||
insert_document(
|
||||
&conn,
|
||||
"issue",
|
||||
issue2_id,
|
||||
project2_id,
|
||||
"authentication error",
|
||||
);
|
||||
|
||||
// Filter to project 1 only
|
||||
let result =
|
||||
seed_timeline(&conn, "authentication", Some(project_id), None, 50, 10).unwrap();
|
||||
assert_eq!(result.seed_entities.len(), 1);
|
||||
assert_eq!(result.seed_entities[0].project_path, "group/project");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_to_chars_short() {
|
||||
assert_eq!(truncate_to_chars("hello", 200), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_to_chars_long() {
|
||||
let long = "a".repeat(300);
|
||||
let result = truncate_to_chars(&long, 200);
|
||||
assert_eq!(result.chars().count(), 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_to_chars_multibyte() {
|
||||
let s = "\u{1F600}".repeat(300); // emoji
|
||||
let result = truncate_to_chars(&s, 200);
|
||||
assert_eq!(result.chars().count(), 200);
|
||||
// Verify valid UTF-8
|
||||
assert!(std::str::from_utf8(result.as_bytes()).is_ok());
|
||||
}
|
||||
}
|
||||
#[path = "timeline_seed_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
312
src/core/timeline_seed_tests.rs
Normal file
312
src/core/timeline_seed_tests.rs
Normal file
@@ -0,0 +1,312 @@
|
||||
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_test_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_test_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) VALUES (?1, ?2, ?3, 'Test issue', 'opened', 'alice', 1000, 2000, 3000)",
|
||||
rusqlite::params![iid * 100, project_id, iid],
|
||||
)
|
||||
.unwrap();
|
||||
conn.last_insert_rowid()
|
||||
}
|
||||
|
||||
fn insert_test_mr(conn: &Connection, project_id: i64, iid: i64) -> i64 {
|
||||
conn.execute(
|
||||
"INSERT INTO merge_requests (gitlab_id, project_id, iid, title, state, author_username, created_at, updated_at, last_seen_at) VALUES (?1, ?2, ?3, 'Test MR', 'opened', 'bob', 1000, 2000, 3000)",
|
||||
rusqlite::params![iid * 100, project_id, iid],
|
||||
)
|
||||
.unwrap();
|
||||
conn.last_insert_rowid()
|
||||
}
|
||||
|
||||
fn insert_document(
|
||||
conn: &Connection,
|
||||
source_type: &str,
|
||||
source_id: i64,
|
||||
project_id: i64,
|
||||
content: &str,
|
||||
) -> i64 {
|
||||
conn.execute(
|
||||
"INSERT INTO documents (source_type, source_id, project_id, content_text, content_hash) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
rusqlite::params![source_type, source_id, project_id, content, format!("hash_{source_id}")],
|
||||
)
|
||||
.unwrap();
|
||||
conn.last_insert_rowid()
|
||||
}
|
||||
|
||||
fn insert_discussion(
|
||||
conn: &Connection,
|
||||
project_id: i64,
|
||||
issue_id: Option<i64>,
|
||||
mr_id: Option<i64>,
|
||||
) -> i64 {
|
||||
let noteable_type = if issue_id.is_some() {
|
||||
"Issue"
|
||||
} else {
|
||||
"MergeRequest"
|
||||
};
|
||||
conn.execute(
|
||||
"INSERT INTO discussions (gitlab_discussion_id, project_id, issue_id, merge_request_id, noteable_type, last_seen_at) VALUES (?1, ?2, ?3, ?4, ?5, 0)",
|
||||
rusqlite::params![format!("disc_{}", rand::random::<u32>()), project_id, issue_id, mr_id, noteable_type],
|
||||
)
|
||||
.unwrap();
|
||||
conn.last_insert_rowid()
|
||||
}
|
||||
|
||||
fn insert_note(
|
||||
conn: &Connection,
|
||||
discussion_id: i64,
|
||||
project_id: i64,
|
||||
body: &str,
|
||||
is_system: bool,
|
||||
) -> i64 {
|
||||
let gitlab_id: i64 = rand::random::<u32>().into();
|
||||
conn.execute(
|
||||
"INSERT INTO notes (gitlab_id, discussion_id, project_id, is_system, author_username, body, created_at, updated_at, last_seen_at) VALUES (?1, ?2, ?3, ?4, 'alice', ?5, 5000, 5000, 5000)",
|
||||
rusqlite::params![gitlab_id, discussion_id, project_id, is_system as i32, body],
|
||||
)
|
||||
.unwrap();
|
||||
conn.last_insert_rowid()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_empty_query_returns_empty() {
|
||||
let conn = setup_test_db();
|
||||
let result = seed_timeline(&conn, "", None, None, 50, 10).unwrap();
|
||||
assert!(result.seed_entities.is_empty());
|
||||
assert!(result.evidence_notes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_no_matches_returns_empty() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = insert_test_project(&conn);
|
||||
let issue_id = insert_test_issue(&conn, project_id, 1);
|
||||
insert_document(
|
||||
&conn,
|
||||
"issue",
|
||||
issue_id,
|
||||
project_id,
|
||||
"unrelated content here",
|
||||
);
|
||||
|
||||
let result = seed_timeline(&conn, "nonexistent_xyzzy_query", None, None, 50, 10).unwrap();
|
||||
assert!(result.seed_entities.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_finds_issue() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = insert_test_project(&conn);
|
||||
let issue_id = insert_test_issue(&conn, project_id, 42);
|
||||
insert_document(
|
||||
&conn,
|
||||
"issue",
|
||||
issue_id,
|
||||
project_id,
|
||||
"authentication error in login flow",
|
||||
);
|
||||
|
||||
let result = seed_timeline(&conn, "authentication", None, None, 50, 10).unwrap();
|
||||
assert_eq!(result.seed_entities.len(), 1);
|
||||
assert_eq!(result.seed_entities[0].entity_type, "issue");
|
||||
assert_eq!(result.seed_entities[0].entity_iid, 42);
|
||||
assert_eq!(result.seed_entities[0].project_path, "group/project");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_finds_mr() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = insert_test_project(&conn);
|
||||
let mr_id = insert_test_mr(&conn, project_id, 99);
|
||||
insert_document(
|
||||
&conn,
|
||||
"merge_request",
|
||||
mr_id,
|
||||
project_id,
|
||||
"fix authentication bug",
|
||||
);
|
||||
|
||||
let result = seed_timeline(&conn, "authentication", None, None, 50, 10).unwrap();
|
||||
assert_eq!(result.seed_entities.len(), 1);
|
||||
assert_eq!(result.seed_entities[0].entity_type, "merge_request");
|
||||
assert_eq!(result.seed_entities[0].entity_iid, 99);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_deduplicates_entities() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = insert_test_project(&conn);
|
||||
let issue_id = insert_test_issue(&conn, project_id, 10);
|
||||
|
||||
// Two documents referencing the same issue
|
||||
insert_document(
|
||||
&conn,
|
||||
"issue",
|
||||
issue_id,
|
||||
project_id,
|
||||
"authentication error first doc",
|
||||
);
|
||||
let disc_id = insert_discussion(&conn, project_id, Some(issue_id), None);
|
||||
insert_document(
|
||||
&conn,
|
||||
"discussion",
|
||||
disc_id,
|
||||
project_id,
|
||||
"authentication error second doc",
|
||||
);
|
||||
|
||||
let result = seed_timeline(&conn, "authentication", None, None, 50, 10).unwrap();
|
||||
// Should deduplicate: both map to the same issue
|
||||
assert_eq!(result.seed_entities.len(), 1);
|
||||
assert_eq!(result.seed_entities[0].entity_iid, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_resolves_discussion_to_parent() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = insert_test_project(&conn);
|
||||
let issue_id = insert_test_issue(&conn, project_id, 7);
|
||||
let disc_id = insert_discussion(&conn, project_id, Some(issue_id), None);
|
||||
insert_document(
|
||||
&conn,
|
||||
"discussion",
|
||||
disc_id,
|
||||
project_id,
|
||||
"deployment pipeline failed",
|
||||
);
|
||||
|
||||
let result = seed_timeline(&conn, "deployment", None, None, 50, 10).unwrap();
|
||||
assert_eq!(result.seed_entities.len(), 1);
|
||||
assert_eq!(result.seed_entities[0].entity_type, "issue");
|
||||
assert_eq!(result.seed_entities[0].entity_iid, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_evidence_capped() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = insert_test_project(&conn);
|
||||
let issue_id = insert_test_issue(&conn, project_id, 1);
|
||||
|
||||
// Create 15 discussion documents with notes about "deployment"
|
||||
for i in 0..15 {
|
||||
let disc_id = insert_discussion(&conn, project_id, Some(issue_id), None);
|
||||
insert_document(
|
||||
&conn,
|
||||
"discussion",
|
||||
disc_id,
|
||||
project_id,
|
||||
&format!("deployment issue number {i}"),
|
||||
);
|
||||
insert_note(
|
||||
&conn,
|
||||
disc_id,
|
||||
project_id,
|
||||
&format!("deployment note {i}"),
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
let result = seed_timeline(&conn, "deployment", None, None, 50, 5).unwrap();
|
||||
assert!(result.evidence_notes.len() <= 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_evidence_snippet_truncated() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = insert_test_project(&conn);
|
||||
let issue_id = insert_test_issue(&conn, project_id, 1);
|
||||
let disc_id = insert_discussion(&conn, project_id, Some(issue_id), None);
|
||||
insert_document(
|
||||
&conn,
|
||||
"discussion",
|
||||
disc_id,
|
||||
project_id,
|
||||
"deployment configuration",
|
||||
);
|
||||
|
||||
let long_body = "x".repeat(500);
|
||||
insert_note(&conn, disc_id, project_id, &long_body, false);
|
||||
|
||||
let result = seed_timeline(&conn, "deployment", None, None, 50, 10).unwrap();
|
||||
assert!(!result.evidence_notes.is_empty());
|
||||
if let TimelineEventType::NoteEvidence { snippet, .. } = &result.evidence_notes[0].event_type {
|
||||
assert!(snippet.chars().count() <= 200);
|
||||
} else {
|
||||
panic!("Expected NoteEvidence");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_respects_project_filter() {
|
||||
let conn = setup_test_db();
|
||||
let project_id = insert_test_project(&conn);
|
||||
|
||||
// Insert a second project
|
||||
conn.execute(
|
||||
"INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url) VALUES (2, 'other/repo', 'https://gitlab.com/other/repo')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let project2_id = conn.last_insert_rowid();
|
||||
|
||||
let issue1_id = insert_test_issue(&conn, project_id, 1);
|
||||
insert_document(
|
||||
&conn,
|
||||
"issue",
|
||||
issue1_id,
|
||||
project_id,
|
||||
"authentication error",
|
||||
);
|
||||
|
||||
let issue2_id = insert_test_issue(&conn, project2_id, 2);
|
||||
insert_document(
|
||||
&conn,
|
||||
"issue",
|
||||
issue2_id,
|
||||
project2_id,
|
||||
"authentication error",
|
||||
);
|
||||
|
||||
// Filter to project 1 only
|
||||
let result = seed_timeline(&conn, "authentication", Some(project_id), None, 50, 10).unwrap();
|
||||
assert_eq!(result.seed_entities.len(), 1);
|
||||
assert_eq!(result.seed_entities[0].project_path, "group/project");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_to_chars_short() {
|
||||
assert_eq!(truncate_to_chars("hello", 200), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_to_chars_long() {
|
||||
let long = "a".repeat(300);
|
||||
let result = truncate_to_chars(&long, 200);
|
||||
assert_eq!(result.chars().count(), 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_to_chars_multibyte() {
|
||||
let s = "\u{1F600}".repeat(300); // emoji
|
||||
let result = truncate_to_chars(&s, 200);
|
||||
assert_eq!(result.chars().count(), 200);
|
||||
// Verify valid UTF-8
|
||||
assert!(std::str::from_utf8(result.as_bytes()).is_ok());
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
1303
src/documents/extractor_tests.rs
Normal file
1303
src/documents/extractor_tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -269,525 +269,5 @@ fn get_document_id(conn: &Connection, source_type: SourceType, source_id: i64) -
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ingestion::dirty_tracker::mark_dirty;
|
||||
|
||||
fn setup_db() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch("
|
||||
CREATE TABLE projects (
|
||||
id INTEGER PRIMARY KEY,
|
||||
gitlab_project_id INTEGER UNIQUE NOT NULL,
|
||||
path_with_namespace TEXT NOT NULL,
|
||||
default_branch TEXT,
|
||||
web_url TEXT,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER,
|
||||
raw_payload_id INTEGER
|
||||
);
|
||||
INSERT INTO projects (id, gitlab_project_id, path_with_namespace) VALUES (1, 100, 'group/project');
|
||||
|
||||
CREATE TABLE issues (
|
||||
id INTEGER PRIMARY KEY,
|
||||
gitlab_id INTEGER UNIQUE NOT NULL,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id),
|
||||
iid INTEGER NOT NULL,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
state TEXT NOT NULL,
|
||||
author_username TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
last_seen_at INTEGER NOT NULL,
|
||||
discussions_synced_for_updated_at INTEGER,
|
||||
resource_events_synced_for_updated_at INTEGER,
|
||||
web_url TEXT,
|
||||
raw_payload_id INTEGER
|
||||
);
|
||||
CREATE TABLE labels (
|
||||
id INTEGER PRIMARY KEY,
|
||||
gitlab_id INTEGER,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id),
|
||||
name TEXT NOT NULL,
|
||||
color TEXT,
|
||||
description TEXT
|
||||
);
|
||||
CREATE TABLE issue_labels (
|
||||
issue_id INTEGER NOT NULL REFERENCES issues(id),
|
||||
label_id INTEGER NOT NULL REFERENCES labels(id),
|
||||
PRIMARY KEY(issue_id, label_id)
|
||||
);
|
||||
|
||||
CREATE TABLE documents (
|
||||
id INTEGER PRIMARY KEY,
|
||||
source_type TEXT NOT NULL,
|
||||
source_id INTEGER NOT NULL,
|
||||
project_id INTEGER NOT NULL,
|
||||
author_username TEXT,
|
||||
label_names TEXT,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER,
|
||||
url TEXT,
|
||||
title TEXT,
|
||||
content_text TEXT NOT NULL,
|
||||
content_hash TEXT NOT NULL,
|
||||
labels_hash TEXT NOT NULL DEFAULT '',
|
||||
paths_hash TEXT NOT NULL DEFAULT '',
|
||||
is_truncated INTEGER NOT NULL DEFAULT 0,
|
||||
truncated_reason TEXT,
|
||||
UNIQUE(source_type, source_id)
|
||||
);
|
||||
CREATE TABLE document_labels (
|
||||
document_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
label_name TEXT NOT NULL,
|
||||
PRIMARY KEY(document_id, label_name)
|
||||
);
|
||||
CREATE TABLE document_paths (
|
||||
document_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
path TEXT NOT NULL,
|
||||
PRIMARY KEY(document_id, path)
|
||||
);
|
||||
CREATE TABLE dirty_sources (
|
||||
source_type TEXT NOT NULL,
|
||||
source_id INTEGER NOT NULL,
|
||||
queued_at INTEGER NOT NULL,
|
||||
attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_attempt_at INTEGER,
|
||||
last_error TEXT,
|
||||
next_attempt_at INTEGER,
|
||||
PRIMARY KEY(source_type, source_id)
|
||||
);
|
||||
CREATE INDEX idx_dirty_sources_next_attempt ON dirty_sources(next_attempt_at);
|
||||
").unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_regenerate_creates_document() {
|
||||
let conn = setup_db();
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, description, state, author_username, created_at, updated_at, last_seen_at) VALUES (1, 10, 1, 42, 'Test Issue', 'Description here', 'opened', 'alice', 1000, 2000, 3000)",
|
||||
[],
|
||||
).unwrap();
|
||||
mark_dirty(&conn, SourceType::Issue, 1).unwrap();
|
||||
|
||||
let result = regenerate_dirty_documents(&conn, None).unwrap();
|
||||
assert_eq!(result.regenerated, 1);
|
||||
assert_eq!(result.unchanged, 0);
|
||||
assert_eq!(result.errored, 0);
|
||||
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM documents", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
|
||||
let content: String = conn
|
||||
.query_row("SELECT content_text FROM documents", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert!(content.contains("[[Issue]] #42: Test Issue"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_regenerate_unchanged() {
|
||||
let conn = setup_db();
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, description, state, author_username, created_at, updated_at, last_seen_at) VALUES (1, 10, 1, 42, 'Test', 'Desc', 'opened', 'alice', 1000, 2000, 3000)",
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
mark_dirty(&conn, SourceType::Issue, 1).unwrap();
|
||||
let r1 = regenerate_dirty_documents(&conn, None).unwrap();
|
||||
assert_eq!(r1.regenerated, 1);
|
||||
|
||||
mark_dirty(&conn, SourceType::Issue, 1).unwrap();
|
||||
let r2 = regenerate_dirty_documents(&conn, None).unwrap();
|
||||
assert_eq!(r2.unchanged, 1);
|
||||
assert_eq!(r2.regenerated, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_regenerate_deleted_source() {
|
||||
let conn = setup_db();
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at) VALUES (1, 10, 1, 42, 'Test', 'opened', 1000, 2000, 3000)",
|
||||
[],
|
||||
).unwrap();
|
||||
mark_dirty(&conn, SourceType::Issue, 1).unwrap();
|
||||
regenerate_dirty_documents(&conn, None).unwrap();
|
||||
|
||||
conn.execute("PRAGMA foreign_keys = OFF", []).unwrap();
|
||||
conn.execute("DELETE FROM issues WHERE id = 1", []).unwrap();
|
||||
conn.execute("PRAGMA foreign_keys = ON", []).unwrap();
|
||||
mark_dirty(&conn, SourceType::Issue, 1).unwrap();
|
||||
|
||||
let result = regenerate_dirty_documents(&conn, None).unwrap();
|
||||
assert_eq!(result.regenerated, 1);
|
||||
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM documents", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_regenerate_drains_queue() {
|
||||
let conn = setup_db();
|
||||
for i in 1..=10 {
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at) VALUES (?1, ?2, 1, ?1, 'Test', 'opened', 1000, 2000, 3000)",
|
||||
rusqlite::params![i, i * 10],
|
||||
).unwrap();
|
||||
mark_dirty(&conn, SourceType::Issue, i).unwrap();
|
||||
}
|
||||
|
||||
let result = regenerate_dirty_documents(&conn, None).unwrap();
|
||||
assert_eq!(result.regenerated, 10);
|
||||
|
||||
let dirty = get_dirty_sources(&conn).unwrap();
|
||||
assert!(dirty.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_triple_hash_fast_path() {
|
||||
let conn = setup_db();
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at) VALUES (1, 10, 1, 42, 'Test', 'opened', 1000, 2000, 3000)",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO labels (id, project_id, name) VALUES (1, 1, 'bug')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO issue_labels (issue_id, label_id) VALUES (1, 1)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
mark_dirty(&conn, SourceType::Issue, 1).unwrap();
|
||||
regenerate_dirty_documents(&conn, None).unwrap();
|
||||
|
||||
mark_dirty(&conn, SourceType::Issue, 1).unwrap();
|
||||
let result = regenerate_dirty_documents(&conn, None).unwrap();
|
||||
assert_eq!(result.unchanged, 1);
|
||||
|
||||
let label_count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM document_labels", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(label_count, 1);
|
||||
}
|
||||
|
||||
fn setup_note_db() -> Connection {
|
||||
let conn = setup_db();
|
||||
conn.execute_batch(
|
||||
"
|
||||
CREATE TABLE merge_requests (
|
||||
id INTEGER PRIMARY KEY,
|
||||
gitlab_id INTEGER UNIQUE NOT NULL,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id),
|
||||
iid INTEGER NOT NULL,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
state TEXT,
|
||||
draft INTEGER NOT NULL DEFAULT 0,
|
||||
author_username TEXT,
|
||||
source_branch TEXT,
|
||||
target_branch TEXT,
|
||||
head_sha TEXT,
|
||||
references_short TEXT,
|
||||
references_full TEXT,
|
||||
detailed_merge_status TEXT,
|
||||
merge_user_username TEXT,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER,
|
||||
merged_at INTEGER,
|
||||
closed_at INTEGER,
|
||||
last_seen_at INTEGER NOT NULL,
|
||||
discussions_synced_for_updated_at INTEGER,
|
||||
discussions_sync_last_attempt_at INTEGER,
|
||||
discussions_sync_attempts INTEGER DEFAULT 0,
|
||||
discussions_sync_last_error TEXT,
|
||||
resource_events_synced_for_updated_at INTEGER,
|
||||
web_url TEXT,
|
||||
raw_payload_id INTEGER
|
||||
);
|
||||
CREATE TABLE mr_labels (
|
||||
merge_request_id INTEGER REFERENCES merge_requests(id),
|
||||
label_id INTEGER REFERENCES labels(id),
|
||||
PRIMARY KEY(merge_request_id, label_id)
|
||||
);
|
||||
CREATE TABLE discussions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
gitlab_discussion_id TEXT NOT NULL,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id),
|
||||
issue_id INTEGER REFERENCES issues(id),
|
||||
merge_request_id INTEGER,
|
||||
noteable_type TEXT NOT NULL,
|
||||
individual_note INTEGER NOT NULL DEFAULT 0,
|
||||
first_note_at INTEGER,
|
||||
last_note_at INTEGER,
|
||||
last_seen_at INTEGER NOT NULL,
|
||||
resolvable INTEGER NOT NULL DEFAULT 0,
|
||||
resolved INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE notes (
|
||||
id INTEGER PRIMARY KEY,
|
||||
gitlab_id INTEGER UNIQUE NOT NULL,
|
||||
discussion_id INTEGER NOT NULL REFERENCES discussions(id),
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id),
|
||||
note_type TEXT,
|
||||
is_system INTEGER NOT NULL DEFAULT 0,
|
||||
author_username TEXT,
|
||||
body TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
last_seen_at INTEGER NOT NULL,
|
||||
position INTEGER,
|
||||
resolvable INTEGER NOT NULL DEFAULT 0,
|
||||
resolved INTEGER NOT NULL DEFAULT 0,
|
||||
resolved_by TEXT,
|
||||
resolved_at INTEGER,
|
||||
position_old_path TEXT,
|
||||
position_new_path TEXT,
|
||||
position_old_line INTEGER,
|
||||
position_new_line INTEGER,
|
||||
raw_payload_id INTEGER
|
||||
);
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_regenerate_note_document() {
|
||||
let conn = setup_note_db();
|
||||
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, 10, 1, 42, 'Test Issue', 'opened', 'alice', 1000, 2000, 3000, 'https://example.com/issues/42')",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at) VALUES (1, 'disc_1', 1, 1, 'Issue', 3000)",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system) VALUES (1, 100, 1, 1, 'bob', 'This is a note', 1000, 2000, 3000, 0)",
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
mark_dirty(&conn, SourceType::Note, 1).unwrap();
|
||||
let result = regenerate_dirty_documents(&conn, None).unwrap();
|
||||
assert_eq!(result.regenerated, 1);
|
||||
assert_eq!(result.unchanged, 0);
|
||||
assert_eq!(result.errored, 0);
|
||||
|
||||
let (source_type, content): (String, String) = conn
|
||||
.query_row(
|
||||
"SELECT source_type, content_text FROM documents WHERE source_id = 1",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(source_type, "note");
|
||||
assert!(content.contains("[[Note]]"));
|
||||
assert!(content.contains("author: @bob"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_regenerate_note_system_note_deletes() {
|
||||
let conn = setup_note_db();
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at) VALUES (1, 10, 1, 42, 'Test', 'opened', 1000, 2000, 3000)",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at) VALUES (1, 'disc_1', 1, 1, 'Issue', 3000)",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system) VALUES (1, 100, 1, 1, 'bot', 'assigned to @alice', 1000, 2000, 3000, 1)",
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
// Pre-insert a document for this note (simulating a previously-generated doc)
|
||||
conn.execute(
|
||||
"INSERT INTO documents (source_type, source_id, project_id, content_text, content_hash) VALUES ('note', 1, 1, 'old content', 'oldhash')",
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
mark_dirty(&conn, SourceType::Note, 1).unwrap();
|
||||
let result = regenerate_dirty_documents(&conn, None).unwrap();
|
||||
assert_eq!(result.regenerated, 1);
|
||||
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM documents WHERE source_type = 'note'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_regenerate_note_unchanged() {
|
||||
let conn = setup_note_db();
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at, web_url) VALUES (1, 10, 1, 42, 'Test', 'opened', 1000, 2000, 3000, 'https://example.com/issues/42')",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at) VALUES (1, 'disc_1', 1, 1, 'Issue', 3000)",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system) VALUES (1, 100, 1, 1, 'bob', 'Some note', 1000, 2000, 3000, 0)",
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
mark_dirty(&conn, SourceType::Note, 1).unwrap();
|
||||
let r1 = regenerate_dirty_documents(&conn, None).unwrap();
|
||||
assert_eq!(r1.regenerated, 1);
|
||||
|
||||
mark_dirty(&conn, SourceType::Note, 1).unwrap();
|
||||
let r2 = regenerate_dirty_documents(&conn, None).unwrap();
|
||||
assert_eq!(r2.unchanged, 1);
|
||||
assert_eq!(r2.regenerated, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_note_regeneration_batch_uses_cache() {
|
||||
let conn = setup_note_db();
|
||||
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, 10, 1, 42, 'Shared Issue', 'opened', 'alice', 1000, 2000, 3000, 'https://example.com/issues/42')",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at) VALUES (1, 'disc_1', 1, 1, 'Issue', 3000)",
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
for i in 1..=10 {
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system) VALUES (?1, ?2, 1, 1, 'bob', ?3, 1000, 2000, 3000, 0)",
|
||||
rusqlite::params![i, i * 100, format!("Note body {}", i)],
|
||||
).unwrap();
|
||||
mark_dirty(&conn, SourceType::Note, i).unwrap();
|
||||
}
|
||||
|
||||
let result = regenerate_dirty_documents(&conn, None).unwrap();
|
||||
assert_eq!(result.regenerated, 10);
|
||||
assert_eq!(result.errored, 0);
|
||||
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM documents WHERE source_type = 'note'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(count, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_note_regeneration_cache_consistent_with_direct_extraction() {
|
||||
let conn = setup_note_db();
|
||||
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, 10, 1, 42, 'Consistency Check', 'opened', 'alice', 1000, 2000, 3000, 'https://example.com/issues/42')",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO labels (id, project_id, name) VALUES (1, 1, 'backend')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO issue_labels (issue_id, label_id) VALUES (1, 1)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at) VALUES (1, 'disc_1', 1, 1, 'Issue', 3000)",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system) VALUES (1, 100, 1, 1, 'bob', 'Some content', 1000, 2000, 3000, 0)",
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
use crate::documents::extract_note_document;
|
||||
let direct = extract_note_document(&conn, 1).unwrap().unwrap();
|
||||
|
||||
let mut cache = ParentMetadataCache::new();
|
||||
let cached = extract_note_document_cached(&conn, 1, &mut cache)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(direct.content_text, cached.content_text);
|
||||
assert_eq!(direct.content_hash, cached.content_hash);
|
||||
assert_eq!(direct.labels, cached.labels);
|
||||
assert_eq!(direct.labels_hash, cached.labels_hash);
|
||||
assert_eq!(direct.paths_hash, cached.paths_hash);
|
||||
assert_eq!(direct.title, cached.title);
|
||||
assert_eq!(direct.url, cached.url);
|
||||
assert_eq!(direct.author_username, cached.author_username);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_note_regeneration_cache_invalidates_across_parents() {
|
||||
let conn = setup_note_db();
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at, web_url) VALUES (1, 10, 1, 42, 'Issue Alpha', 'opened', 1000, 2000, 3000, 'https://example.com/issues/42')",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at, web_url) VALUES (2, 20, 1, 99, 'Issue Beta', 'opened', 1000, 2000, 3000, 'https://example.com/issues/99')",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at) VALUES (1, 'disc_1', 1, 1, 'Issue', 3000)",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at) VALUES (2, 'disc_2', 1, 2, 'Issue', 3000)",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system) VALUES (1, 100, 1, 1, 'bob', 'Alpha note', 1000, 2000, 3000, 0)",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system) VALUES (2, 200, 2, 1, 'alice', 'Beta note', 1000, 2000, 3000, 0)",
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
mark_dirty(&conn, SourceType::Note, 1).unwrap();
|
||||
mark_dirty(&conn, SourceType::Note, 2).unwrap();
|
||||
|
||||
let result = regenerate_dirty_documents(&conn, None).unwrap();
|
||||
assert_eq!(result.regenerated, 2);
|
||||
assert_eq!(result.errored, 0);
|
||||
|
||||
let alpha_content: String = conn
|
||||
.query_row(
|
||||
"SELECT content_text FROM documents WHERE source_type = 'note' AND source_id = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
let beta_content: String = conn
|
||||
.query_row(
|
||||
"SELECT content_text FROM documents WHERE source_type = 'note' AND source_id = 2",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(alpha_content.contains("parent_iid: 42"));
|
||||
assert!(alpha_content.contains("parent_title: Issue Alpha"));
|
||||
assert!(beta_content.contains("parent_iid: 99"));
|
||||
assert!(beta_content.contains("parent_title: Issue Beta"));
|
||||
}
|
||||
}
|
||||
#[path = "regenerator_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
520
src/documents/regenerator_tests.rs
Normal file
520
src/documents/regenerator_tests.rs
Normal file
@@ -0,0 +1,520 @@
|
||||
use super::*;
|
||||
use crate::ingestion::dirty_tracker::mark_dirty;
|
||||
|
||||
fn setup_db() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch("
|
||||
CREATE TABLE projects (
|
||||
id INTEGER PRIMARY KEY,
|
||||
gitlab_project_id INTEGER UNIQUE NOT NULL,
|
||||
path_with_namespace TEXT NOT NULL,
|
||||
default_branch TEXT,
|
||||
web_url TEXT,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER,
|
||||
raw_payload_id INTEGER
|
||||
);
|
||||
INSERT INTO projects (id, gitlab_project_id, path_with_namespace) VALUES (1, 100, 'group/project');
|
||||
|
||||
CREATE TABLE issues (
|
||||
id INTEGER PRIMARY KEY,
|
||||
gitlab_id INTEGER UNIQUE NOT NULL,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id),
|
||||
iid INTEGER NOT NULL,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
state TEXT NOT NULL,
|
||||
author_username TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
last_seen_at INTEGER NOT NULL,
|
||||
discussions_synced_for_updated_at INTEGER,
|
||||
resource_events_synced_for_updated_at INTEGER,
|
||||
web_url TEXT,
|
||||
raw_payload_id INTEGER
|
||||
);
|
||||
CREATE TABLE labels (
|
||||
id INTEGER PRIMARY KEY,
|
||||
gitlab_id INTEGER,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id),
|
||||
name TEXT NOT NULL,
|
||||
color TEXT,
|
||||
description TEXT
|
||||
);
|
||||
CREATE TABLE issue_labels (
|
||||
issue_id INTEGER NOT NULL REFERENCES issues(id),
|
||||
label_id INTEGER NOT NULL REFERENCES labels(id),
|
||||
PRIMARY KEY(issue_id, label_id)
|
||||
);
|
||||
|
||||
CREATE TABLE documents (
|
||||
id INTEGER PRIMARY KEY,
|
||||
source_type TEXT NOT NULL,
|
||||
source_id INTEGER NOT NULL,
|
||||
project_id INTEGER NOT NULL,
|
||||
author_username TEXT,
|
||||
label_names TEXT,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER,
|
||||
url TEXT,
|
||||
title TEXT,
|
||||
content_text TEXT NOT NULL,
|
||||
content_hash TEXT NOT NULL,
|
||||
labels_hash TEXT NOT NULL DEFAULT '',
|
||||
paths_hash TEXT NOT NULL DEFAULT '',
|
||||
is_truncated INTEGER NOT NULL DEFAULT 0,
|
||||
truncated_reason TEXT,
|
||||
UNIQUE(source_type, source_id)
|
||||
);
|
||||
CREATE TABLE document_labels (
|
||||
document_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
label_name TEXT NOT NULL,
|
||||
PRIMARY KEY(document_id, label_name)
|
||||
);
|
||||
CREATE TABLE document_paths (
|
||||
document_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
path TEXT NOT NULL,
|
||||
PRIMARY KEY(document_id, path)
|
||||
);
|
||||
CREATE TABLE dirty_sources (
|
||||
source_type TEXT NOT NULL,
|
||||
source_id INTEGER NOT NULL,
|
||||
queued_at INTEGER NOT NULL,
|
||||
attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_attempt_at INTEGER,
|
||||
last_error TEXT,
|
||||
next_attempt_at INTEGER,
|
||||
PRIMARY KEY(source_type, source_id)
|
||||
);
|
||||
CREATE INDEX idx_dirty_sources_next_attempt ON dirty_sources(next_attempt_at);
|
||||
").unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_regenerate_creates_document() {
|
||||
let conn = setup_db();
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, description, state, author_username, created_at, updated_at, last_seen_at) VALUES (1, 10, 1, 42, 'Test Issue', 'Description here', 'opened', 'alice', 1000, 2000, 3000)",
|
||||
[],
|
||||
).unwrap();
|
||||
mark_dirty(&conn, SourceType::Issue, 1).unwrap();
|
||||
|
||||
let result = regenerate_dirty_documents(&conn, None).unwrap();
|
||||
assert_eq!(result.regenerated, 1);
|
||||
assert_eq!(result.unchanged, 0);
|
||||
assert_eq!(result.errored, 0);
|
||||
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM documents", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
|
||||
let content: String = conn
|
||||
.query_row("SELECT content_text FROM documents", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert!(content.contains("[[Issue]] #42: Test Issue"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_regenerate_unchanged() {
|
||||
let conn = setup_db();
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, description, state, author_username, created_at, updated_at, last_seen_at) VALUES (1, 10, 1, 42, 'Test', 'Desc', 'opened', 'alice', 1000, 2000, 3000)",
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
mark_dirty(&conn, SourceType::Issue, 1).unwrap();
|
||||
let r1 = regenerate_dirty_documents(&conn, None).unwrap();
|
||||
assert_eq!(r1.regenerated, 1);
|
||||
|
||||
mark_dirty(&conn, SourceType::Issue, 1).unwrap();
|
||||
let r2 = regenerate_dirty_documents(&conn, None).unwrap();
|
||||
assert_eq!(r2.unchanged, 1);
|
||||
assert_eq!(r2.regenerated, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_regenerate_deleted_source() {
|
||||
let conn = setup_db();
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at) VALUES (1, 10, 1, 42, 'Test', 'opened', 1000, 2000, 3000)",
|
||||
[],
|
||||
).unwrap();
|
||||
mark_dirty(&conn, SourceType::Issue, 1).unwrap();
|
||||
regenerate_dirty_documents(&conn, None).unwrap();
|
||||
|
||||
conn.execute("PRAGMA foreign_keys = OFF", []).unwrap();
|
||||
conn.execute("DELETE FROM issues WHERE id = 1", []).unwrap();
|
||||
conn.execute("PRAGMA foreign_keys = ON", []).unwrap();
|
||||
mark_dirty(&conn, SourceType::Issue, 1).unwrap();
|
||||
|
||||
let result = regenerate_dirty_documents(&conn, None).unwrap();
|
||||
assert_eq!(result.regenerated, 1);
|
||||
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM documents", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_regenerate_drains_queue() {
|
||||
let conn = setup_db();
|
||||
for i in 1..=10 {
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at) VALUES (?1, ?2, 1, ?1, 'Test', 'opened', 1000, 2000, 3000)",
|
||||
rusqlite::params![i, i * 10],
|
||||
).unwrap();
|
||||
mark_dirty(&conn, SourceType::Issue, i).unwrap();
|
||||
}
|
||||
|
||||
let result = regenerate_dirty_documents(&conn, None).unwrap();
|
||||
assert_eq!(result.regenerated, 10);
|
||||
|
||||
let dirty = get_dirty_sources(&conn).unwrap();
|
||||
assert!(dirty.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_triple_hash_fast_path() {
|
||||
let conn = setup_db();
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at) VALUES (1, 10, 1, 42, 'Test', 'opened', 1000, 2000, 3000)",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO labels (id, project_id, name) VALUES (1, 1, 'bug')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO issue_labels (issue_id, label_id) VALUES (1, 1)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
mark_dirty(&conn, SourceType::Issue, 1).unwrap();
|
||||
regenerate_dirty_documents(&conn, None).unwrap();
|
||||
|
||||
mark_dirty(&conn, SourceType::Issue, 1).unwrap();
|
||||
let result = regenerate_dirty_documents(&conn, None).unwrap();
|
||||
assert_eq!(result.unchanged, 1);
|
||||
|
||||
let label_count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM document_labels", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(label_count, 1);
|
||||
}
|
||||
|
||||
fn setup_note_db() -> Connection {
|
||||
let conn = setup_db();
|
||||
conn.execute_batch(
|
||||
"
|
||||
CREATE TABLE merge_requests (
|
||||
id INTEGER PRIMARY KEY,
|
||||
gitlab_id INTEGER UNIQUE NOT NULL,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id),
|
||||
iid INTEGER NOT NULL,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
state TEXT,
|
||||
draft INTEGER NOT NULL DEFAULT 0,
|
||||
author_username TEXT,
|
||||
source_branch TEXT,
|
||||
target_branch TEXT,
|
||||
head_sha TEXT,
|
||||
references_short TEXT,
|
||||
references_full TEXT,
|
||||
detailed_merge_status TEXT,
|
||||
merge_user_username TEXT,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER,
|
||||
merged_at INTEGER,
|
||||
closed_at INTEGER,
|
||||
last_seen_at INTEGER NOT NULL,
|
||||
discussions_synced_for_updated_at INTEGER,
|
||||
discussions_sync_last_attempt_at INTEGER,
|
||||
discussions_sync_attempts INTEGER DEFAULT 0,
|
||||
discussions_sync_last_error TEXT,
|
||||
resource_events_synced_for_updated_at INTEGER,
|
||||
web_url TEXT,
|
||||
raw_payload_id INTEGER
|
||||
);
|
||||
CREATE TABLE mr_labels (
|
||||
merge_request_id INTEGER REFERENCES merge_requests(id),
|
||||
label_id INTEGER REFERENCES labels(id),
|
||||
PRIMARY KEY(merge_request_id, label_id)
|
||||
);
|
||||
CREATE TABLE discussions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
gitlab_discussion_id TEXT NOT NULL,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id),
|
||||
issue_id INTEGER REFERENCES issues(id),
|
||||
merge_request_id INTEGER,
|
||||
noteable_type TEXT NOT NULL,
|
||||
individual_note INTEGER NOT NULL DEFAULT 0,
|
||||
first_note_at INTEGER,
|
||||
last_note_at INTEGER,
|
||||
last_seen_at INTEGER NOT NULL,
|
||||
resolvable INTEGER NOT NULL DEFAULT 0,
|
||||
resolved INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE notes (
|
||||
id INTEGER PRIMARY KEY,
|
||||
gitlab_id INTEGER UNIQUE NOT NULL,
|
||||
discussion_id INTEGER NOT NULL REFERENCES discussions(id),
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id),
|
||||
note_type TEXT,
|
||||
is_system INTEGER NOT NULL DEFAULT 0,
|
||||
author_username TEXT,
|
||||
body TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
last_seen_at INTEGER NOT NULL,
|
||||
position INTEGER,
|
||||
resolvable INTEGER NOT NULL DEFAULT 0,
|
||||
resolved INTEGER NOT NULL DEFAULT 0,
|
||||
resolved_by TEXT,
|
||||
resolved_at INTEGER,
|
||||
position_old_path TEXT,
|
||||
position_new_path TEXT,
|
||||
position_old_line INTEGER,
|
||||
position_new_line INTEGER,
|
||||
raw_payload_id INTEGER
|
||||
);
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_regenerate_note_document() {
|
||||
let conn = setup_note_db();
|
||||
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, 10, 1, 42, 'Test Issue', 'opened', 'alice', 1000, 2000, 3000, 'https://example.com/issues/42')",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at) VALUES (1, 'disc_1', 1, 1, 'Issue', 3000)",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system) VALUES (1, 100, 1, 1, 'bob', 'This is a note', 1000, 2000, 3000, 0)",
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
mark_dirty(&conn, SourceType::Note, 1).unwrap();
|
||||
let result = regenerate_dirty_documents(&conn, None).unwrap();
|
||||
assert_eq!(result.regenerated, 1);
|
||||
assert_eq!(result.unchanged, 0);
|
||||
assert_eq!(result.errored, 0);
|
||||
|
||||
let (source_type, content): (String, String) = conn
|
||||
.query_row(
|
||||
"SELECT source_type, content_text FROM documents WHERE source_id = 1",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(source_type, "note");
|
||||
assert!(content.contains("[[Note]]"));
|
||||
assert!(content.contains("author: @bob"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_regenerate_note_system_note_deletes() {
|
||||
let conn = setup_note_db();
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at) VALUES (1, 10, 1, 42, 'Test', 'opened', 1000, 2000, 3000)",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at) VALUES (1, 'disc_1', 1, 1, 'Issue', 3000)",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system) VALUES (1, 100, 1, 1, 'bot', 'assigned to @alice', 1000, 2000, 3000, 1)",
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
// Pre-insert a document for this note (simulating a previously-generated doc)
|
||||
conn.execute(
|
||||
"INSERT INTO documents (source_type, source_id, project_id, content_text, content_hash) VALUES ('note', 1, 1, 'old content', 'oldhash')",
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
mark_dirty(&conn, SourceType::Note, 1).unwrap();
|
||||
let result = regenerate_dirty_documents(&conn, None).unwrap();
|
||||
assert_eq!(result.regenerated, 1);
|
||||
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM documents WHERE source_type = 'note'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_regenerate_note_unchanged() {
|
||||
let conn = setup_note_db();
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at, web_url) VALUES (1, 10, 1, 42, 'Test', 'opened', 1000, 2000, 3000, 'https://example.com/issues/42')",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at) VALUES (1, 'disc_1', 1, 1, 'Issue', 3000)",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system) VALUES (1, 100, 1, 1, 'bob', 'Some note', 1000, 2000, 3000, 0)",
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
mark_dirty(&conn, SourceType::Note, 1).unwrap();
|
||||
let r1 = regenerate_dirty_documents(&conn, None).unwrap();
|
||||
assert_eq!(r1.regenerated, 1);
|
||||
|
||||
mark_dirty(&conn, SourceType::Note, 1).unwrap();
|
||||
let r2 = regenerate_dirty_documents(&conn, None).unwrap();
|
||||
assert_eq!(r2.unchanged, 1);
|
||||
assert_eq!(r2.regenerated, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_note_regeneration_batch_uses_cache() {
|
||||
let conn = setup_note_db();
|
||||
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, 10, 1, 42, 'Shared Issue', 'opened', 'alice', 1000, 2000, 3000, 'https://example.com/issues/42')",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at) VALUES (1, 'disc_1', 1, 1, 'Issue', 3000)",
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
for i in 1..=10 {
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system) VALUES (?1, ?2, 1, 1, 'bob', ?3, 1000, 2000, 3000, 0)",
|
||||
rusqlite::params![i, i * 100, format!("Note body {}", i)],
|
||||
).unwrap();
|
||||
mark_dirty(&conn, SourceType::Note, i).unwrap();
|
||||
}
|
||||
|
||||
let result = regenerate_dirty_documents(&conn, None).unwrap();
|
||||
assert_eq!(result.regenerated, 10);
|
||||
assert_eq!(result.errored, 0);
|
||||
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM documents WHERE source_type = 'note'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(count, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_note_regeneration_cache_consistent_with_direct_extraction() {
|
||||
let conn = setup_note_db();
|
||||
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, 10, 1, 42, 'Consistency Check', 'opened', 'alice', 1000, 2000, 3000, 'https://example.com/issues/42')",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO labels (id, project_id, name) VALUES (1, 1, 'backend')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO issue_labels (issue_id, label_id) VALUES (1, 1)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at) VALUES (1, 'disc_1', 1, 1, 'Issue', 3000)",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system) VALUES (1, 100, 1, 1, 'bob', 'Some content', 1000, 2000, 3000, 0)",
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
use crate::documents::extract_note_document;
|
||||
let direct = extract_note_document(&conn, 1).unwrap().unwrap();
|
||||
|
||||
let mut cache = ParentMetadataCache::new();
|
||||
let cached = extract_note_document_cached(&conn, 1, &mut cache)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(direct.content_text, cached.content_text);
|
||||
assert_eq!(direct.content_hash, cached.content_hash);
|
||||
assert_eq!(direct.labels, cached.labels);
|
||||
assert_eq!(direct.labels_hash, cached.labels_hash);
|
||||
assert_eq!(direct.paths_hash, cached.paths_hash);
|
||||
assert_eq!(direct.title, cached.title);
|
||||
assert_eq!(direct.url, cached.url);
|
||||
assert_eq!(direct.author_username, cached.author_username);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_note_regeneration_cache_invalidates_across_parents() {
|
||||
let conn = setup_note_db();
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at, web_url) VALUES (1, 10, 1, 42, 'Issue Alpha', 'opened', 1000, 2000, 3000, 'https://example.com/issues/42')",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at, web_url) VALUES (2, 20, 1, 99, 'Issue Beta', 'opened', 1000, 2000, 3000, 'https://example.com/issues/99')",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at) VALUES (1, 'disc_1', 1, 1, 'Issue', 3000)",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at) VALUES (2, 'disc_2', 1, 2, 'Issue', 3000)",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system) VALUES (1, 100, 1, 1, 'bob', 'Alpha note', 1000, 2000, 3000, 0)",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system) VALUES (2, 200, 2, 1, 'alice', 'Beta note', 1000, 2000, 3000, 0)",
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
mark_dirty(&conn, SourceType::Note, 1).unwrap();
|
||||
mark_dirty(&conn, SourceType::Note, 2).unwrap();
|
||||
|
||||
let result = regenerate_dirty_documents(&conn, None).unwrap();
|
||||
assert_eq!(result.regenerated, 2);
|
||||
assert_eq!(result.errored, 0);
|
||||
|
||||
let alpha_content: String = conn
|
||||
.query_row(
|
||||
"SELECT content_text FROM documents WHERE source_type = 'note' AND source_id = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
let beta_content: String = conn
|
||||
.query_row(
|
||||
"SELECT content_text FROM documents WHERE source_type = 'note' AND source_id = 2",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(alpha_content.contains("parent_iid: 42"));
|
||||
assert!(alpha_content.contains("parent_title: Issue Alpha"));
|
||||
assert!(beta_content.contains("parent_iid: 99"));
|
||||
assert!(beta_content.contains("parent_title: Issue Beta"));
|
||||
}
|
||||
@@ -85,146 +85,5 @@ pub fn count_pending_documents(conn: &Connection, model_name: &str) -> Result<i6
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::Path;
|
||||
|
||||
use super::*;
|
||||
use crate::core::db::{create_connection, run_migrations};
|
||||
use crate::embedding::pipeline::record_embedding_error;
|
||||
|
||||
const MODEL: &str = "nomic-embed-text";
|
||||
|
||||
fn setup_db() -> Connection {
|
||||
let conn = create_connection(Path::new(":memory:")).unwrap();
|
||||
run_migrations(&conn).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
fn insert_test_project(conn: &Connection) -> i64 {
|
||||
conn.execute(
|
||||
"INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url)
|
||||
VALUES (1, 'group/test', 'https://gitlab.example.com/group/test')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.last_insert_rowid()
|
||||
}
|
||||
|
||||
fn insert_test_document(conn: &Connection, project_id: i64, content: &str) -> i64 {
|
||||
conn.execute(
|
||||
"INSERT INTO documents (source_type, source_id, project_id, content_text, content_hash)
|
||||
VALUES ('issue', 1, ?1, ?2, 'hash123')",
|
||||
rusqlite::params![project_id, content],
|
||||
)
|
||||
.unwrap();
|
||||
conn.last_insert_rowid()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_failed_delete_makes_doc_pending_again() {
|
||||
let conn = setup_db();
|
||||
let proj_id = insert_test_project(&conn);
|
||||
let doc_id = insert_test_document(&conn, proj_id, "some text content");
|
||||
|
||||
// Doc starts as pending
|
||||
let pending = find_pending_documents(&conn, 100, 0, MODEL).unwrap();
|
||||
assert_eq!(pending.len(), 1, "Doc should be pending initially");
|
||||
|
||||
// Record an error — doc should no longer be pending
|
||||
record_embedding_error(
|
||||
&conn,
|
||||
doc_id,
|
||||
0,
|
||||
"hash123",
|
||||
"chunkhash",
|
||||
MODEL,
|
||||
"test error",
|
||||
)
|
||||
.unwrap();
|
||||
let pending = find_pending_documents(&conn, 100, 0, MODEL).unwrap();
|
||||
assert!(
|
||||
pending.is_empty(),
|
||||
"Doc with error metadata should not be pending"
|
||||
);
|
||||
|
||||
// DELETE error rows (mimicking --retry-failed) — doc should become pending again
|
||||
conn.execute_batch(
|
||||
"DELETE FROM embeddings WHERE rowid / 1000 IN (
|
||||
SELECT DISTINCT document_id FROM embedding_metadata
|
||||
WHERE last_error IS NOT NULL
|
||||
);
|
||||
DELETE FROM embedding_metadata WHERE last_error IS NOT NULL;",
|
||||
)
|
||||
.unwrap();
|
||||
let pending = find_pending_documents(&conn, 100, 0, MODEL).unwrap();
|
||||
assert_eq!(pending.len(), 1, "Doc should be pending again after DELETE");
|
||||
assert_eq!(pending[0].document_id, doc_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_doc_with_error_not_pending() {
|
||||
let conn = setup_db();
|
||||
let proj_id = insert_test_project(&conn);
|
||||
let doc_id = insert_test_document(&conn, proj_id, "");
|
||||
|
||||
// Empty doc starts as pending
|
||||
let pending = find_pending_documents(&conn, 100, 0, MODEL).unwrap();
|
||||
assert_eq!(pending.len(), 1, "Empty doc should be pending initially");
|
||||
|
||||
// Record an error for the empty doc
|
||||
record_embedding_error(
|
||||
&conn,
|
||||
doc_id,
|
||||
0,
|
||||
"hash123",
|
||||
"empty",
|
||||
MODEL,
|
||||
"Document has empty content",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Should no longer be pending
|
||||
let pending = find_pending_documents(&conn, 100, 0, MODEL).unwrap();
|
||||
assert!(
|
||||
pending.is_empty(),
|
||||
"Empty doc with error metadata should not be pending"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_update_approach_leaves_doc_invisible() {
|
||||
// This test demonstrates WHY we use DELETE instead of UPDATE.
|
||||
// UPDATE clears last_error but the row still matches config params,
|
||||
// so the doc stays "not pending" — permanently invisible.
|
||||
let conn = setup_db();
|
||||
let proj_id = insert_test_project(&conn);
|
||||
let doc_id = insert_test_document(&conn, proj_id, "some text content");
|
||||
|
||||
// Record an error
|
||||
record_embedding_error(
|
||||
&conn,
|
||||
doc_id,
|
||||
0,
|
||||
"hash123",
|
||||
"chunkhash",
|
||||
MODEL,
|
||||
"test error",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Old approach: UPDATE to clear error
|
||||
conn.execute(
|
||||
"UPDATE embedding_metadata SET last_error = NULL, attempt_count = 0
|
||||
WHERE last_error IS NOT NULL",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Doc is NOT pending — it's permanently invisible! This is the bug.
|
||||
let pending = find_pending_documents(&conn, 100, 0, MODEL).unwrap();
|
||||
assert!(
|
||||
pending.is_empty(),
|
||||
"UPDATE approach leaves doc invisible (this proves the bug)"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[path = "change_detector_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
141
src/embedding/change_detector_tests.rs
Normal file
141
src/embedding/change_detector_tests.rs
Normal file
@@ -0,0 +1,141 @@
|
||||
use std::path::Path;
|
||||
|
||||
use super::*;
|
||||
use crate::core::db::{create_connection, run_migrations};
|
||||
use crate::embedding::pipeline::record_embedding_error;
|
||||
|
||||
const MODEL: &str = "nomic-embed-text";
|
||||
|
||||
fn setup_db() -> Connection {
|
||||
let conn = create_connection(Path::new(":memory:")).unwrap();
|
||||
run_migrations(&conn).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
fn insert_test_project(conn: &Connection) -> i64 {
|
||||
conn.execute(
|
||||
"INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url)
|
||||
VALUES (1, 'group/test', 'https://gitlab.example.com/group/test')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.last_insert_rowid()
|
||||
}
|
||||
|
||||
fn insert_test_document(conn: &Connection, project_id: i64, content: &str) -> i64 {
|
||||
conn.execute(
|
||||
"INSERT INTO documents (source_type, source_id, project_id, content_text, content_hash)
|
||||
VALUES ('issue', 1, ?1, ?2, 'hash123')",
|
||||
rusqlite::params![project_id, content],
|
||||
)
|
||||
.unwrap();
|
||||
conn.last_insert_rowid()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_failed_delete_makes_doc_pending_again() {
|
||||
let conn = setup_db();
|
||||
let proj_id = insert_test_project(&conn);
|
||||
let doc_id = insert_test_document(&conn, proj_id, "some text content");
|
||||
|
||||
// Doc starts as pending
|
||||
let pending = find_pending_documents(&conn, 100, 0, MODEL).unwrap();
|
||||
assert_eq!(pending.len(), 1, "Doc should be pending initially");
|
||||
|
||||
// Record an error — doc should no longer be pending
|
||||
record_embedding_error(
|
||||
&conn,
|
||||
doc_id,
|
||||
0,
|
||||
"hash123",
|
||||
"chunkhash",
|
||||
MODEL,
|
||||
"test error",
|
||||
)
|
||||
.unwrap();
|
||||
let pending = find_pending_documents(&conn, 100, 0, MODEL).unwrap();
|
||||
assert!(
|
||||
pending.is_empty(),
|
||||
"Doc with error metadata should not be pending"
|
||||
);
|
||||
|
||||
// DELETE error rows (mimicking --retry-failed) — doc should become pending again
|
||||
conn.execute_batch(
|
||||
"DELETE FROM embeddings WHERE rowid / 1000 IN (
|
||||
SELECT DISTINCT document_id FROM embedding_metadata
|
||||
WHERE last_error IS NOT NULL
|
||||
);
|
||||
DELETE FROM embedding_metadata WHERE last_error IS NOT NULL;",
|
||||
)
|
||||
.unwrap();
|
||||
let pending = find_pending_documents(&conn, 100, 0, MODEL).unwrap();
|
||||
assert_eq!(pending.len(), 1, "Doc should be pending again after DELETE");
|
||||
assert_eq!(pending[0].document_id, doc_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_doc_with_error_not_pending() {
|
||||
let conn = setup_db();
|
||||
let proj_id = insert_test_project(&conn);
|
||||
let doc_id = insert_test_document(&conn, proj_id, "");
|
||||
|
||||
// Empty doc starts as pending
|
||||
let pending = find_pending_documents(&conn, 100, 0, MODEL).unwrap();
|
||||
assert_eq!(pending.len(), 1, "Empty doc should be pending initially");
|
||||
|
||||
// Record an error for the empty doc
|
||||
record_embedding_error(
|
||||
&conn,
|
||||
doc_id,
|
||||
0,
|
||||
"hash123",
|
||||
"empty",
|
||||
MODEL,
|
||||
"Document has empty content",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Should no longer be pending
|
||||
let pending = find_pending_documents(&conn, 100, 0, MODEL).unwrap();
|
||||
assert!(
|
||||
pending.is_empty(),
|
||||
"Empty doc with error metadata should not be pending"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_update_approach_leaves_doc_invisible() {
|
||||
// This test demonstrates WHY we use DELETE instead of UPDATE.
|
||||
// UPDATE clears last_error but the row still matches config params,
|
||||
// so the doc stays "not pending" — permanently invisible.
|
||||
let conn = setup_db();
|
||||
let proj_id = insert_test_project(&conn);
|
||||
let doc_id = insert_test_document(&conn, proj_id, "some text content");
|
||||
|
||||
// Record an error
|
||||
record_embedding_error(
|
||||
&conn,
|
||||
doc_id,
|
||||
0,
|
||||
"hash123",
|
||||
"chunkhash",
|
||||
MODEL,
|
||||
"test error",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Old approach: UPDATE to clear error
|
||||
conn.execute(
|
||||
"UPDATE embedding_metadata SET last_error = NULL, attempt_count = 0
|
||||
WHERE last_error IS NOT NULL",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Doc is NOT pending — it's permanently invisible! This is the bug.
|
||||
let pending = find_pending_documents(&conn, 100, 0, MODEL).unwrap();
|
||||
assert!(
|
||||
pending.is_empty(),
|
||||
"UPDATE approach leaves doc invisible (this proves the bug)"
|
||||
);
|
||||
}
|
||||
@@ -103,231 +103,5 @@ fn floor_char_boundary(s: &str, idx: usize) -> usize {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_empty_content() {
|
||||
let chunks = split_into_chunks("");
|
||||
assert!(chunks.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_short_document_single_chunk() {
|
||||
let content = "Short document content.";
|
||||
let chunks = split_into_chunks(content);
|
||||
assert_eq!(chunks.len(), 1);
|
||||
assert_eq!(chunks[0].0, 0);
|
||||
assert_eq!(chunks[0].1, content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exactly_max_chars() {
|
||||
let content = "a".repeat(CHUNK_MAX_BYTES);
|
||||
let chunks = split_into_chunks(&content);
|
||||
assert_eq!(chunks.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_long_document_multiple_chunks() {
|
||||
let paragraph = "This is a paragraph of text.\n\n";
|
||||
let mut content = String::new();
|
||||
while content.len() < CHUNK_MAX_BYTES * 2 {
|
||||
content.push_str(paragraph);
|
||||
}
|
||||
|
||||
let chunks = split_into_chunks(&content);
|
||||
assert!(
|
||||
chunks.len() >= 2,
|
||||
"Expected multiple chunks, got {}",
|
||||
chunks.len()
|
||||
);
|
||||
|
||||
for (i, (idx, _)) in chunks.iter().enumerate() {
|
||||
assert_eq!(*idx, i);
|
||||
}
|
||||
|
||||
assert!(!chunks.last().unwrap().1.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chunk_overlap() {
|
||||
let paragraph = "This is paragraph content for testing chunk overlap behavior.\n\n";
|
||||
let mut content = String::new();
|
||||
while content.len() < CHUNK_MAX_BYTES + CHUNK_OVERLAP_CHARS + 1000 {
|
||||
content.push_str(paragraph);
|
||||
}
|
||||
|
||||
let chunks = split_into_chunks(&content);
|
||||
assert!(chunks.len() >= 2);
|
||||
|
||||
if chunks.len() >= 2 {
|
||||
let end_of_first = &chunks[0].1;
|
||||
let start_of_second = &chunks[1].1;
|
||||
let overlap_region =
|
||||
&end_of_first[end_of_first.len().saturating_sub(CHUNK_OVERLAP_CHARS)..];
|
||||
assert!(
|
||||
start_of_second.starts_with(overlap_region)
|
||||
|| overlap_region.contains(&start_of_second[..100.min(start_of_second.len())]),
|
||||
"Expected overlap between chunks"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_paragraph_boundary() {
|
||||
let content = "word ".repeat(CHUNK_MAX_BYTES / 5 * 3);
|
||||
let chunks = split_into_chunks(&content);
|
||||
assert!(chunks.len() >= 2);
|
||||
for (_, chunk) in &chunks {
|
||||
assert!(!chunk.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chunk_indices_sequential() {
|
||||
let content = "a ".repeat(CHUNK_MAX_BYTES);
|
||||
let chunks = split_into_chunks(&content);
|
||||
for (i, (idx, _)) in chunks.iter().enumerate() {
|
||||
assert_eq!(*idx, i, "Chunk index mismatch at position {}", i);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multibyte_characters_no_panic() {
|
||||
// Build content with multi-byte UTF-8 chars (smart quotes, emoji, CJK)
|
||||
// placed at positions likely to hit len()*2/3 and len()/2 boundaries
|
||||
let segment = "We\u{2019}ve gradually ar\u{2014}ranged the components. ";
|
||||
let mut content = String::new();
|
||||
while content.len() < CHUNK_MAX_BYTES * 3 {
|
||||
content.push_str(segment);
|
||||
}
|
||||
// Should not panic on multi-byte boundary
|
||||
let chunks = split_into_chunks(&content);
|
||||
assert!(chunks.len() >= 2);
|
||||
for (_, chunk) in &chunks {
|
||||
assert!(!chunk.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nbsp_at_overlap_boundary() {
|
||||
// Reproduce the exact crash: \u{a0} (non-breaking space, 2-byte UTF-8)
|
||||
// placed so that split_at - CHUNK_OVERLAP_CHARS lands mid-character
|
||||
let mut content = String::new();
|
||||
// Fill with ASCII up to near CHUNK_MAX_BYTES, then place \u{a0}
|
||||
// near where the overlap subtraction would land
|
||||
let target = CHUNK_MAX_BYTES - CHUNK_OVERLAP_CHARS;
|
||||
while content.len() < target - 2 {
|
||||
content.push('a');
|
||||
}
|
||||
content.push('\u{a0}'); // 2-byte char right at the overlap boundary
|
||||
while content.len() < CHUNK_MAX_BYTES * 3 {
|
||||
content.push('b');
|
||||
}
|
||||
// Should not panic
|
||||
let chunks = split_into_chunks(&content);
|
||||
assert!(chunks.len() >= 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_box_drawing_heavy_content() {
|
||||
// Simulates a document with many box-drawing characters (3-byte UTF-8)
|
||||
// like the ─ (U+2500) character found in markdown tables
|
||||
let mut content = String::new();
|
||||
// Normal text header
|
||||
content.push_str("# Title\n\nSome description text.\n\n");
|
||||
// Table header with box drawing
|
||||
content.push('┌');
|
||||
for _ in 0..200 {
|
||||
content.push('─');
|
||||
}
|
||||
content.push('┬');
|
||||
for _ in 0..200 {
|
||||
content.push('─');
|
||||
}
|
||||
content.push_str("┐\n"); // clippy: push_str is correct here (multi-char)
|
||||
// Table rows
|
||||
for row in 0..50 {
|
||||
content.push_str(&format!("│ row {:<194}│ data {:<193}│\n", row, row));
|
||||
content.push('├');
|
||||
for _ in 0..200 {
|
||||
content.push('─');
|
||||
}
|
||||
content.push('┼');
|
||||
for _ in 0..200 {
|
||||
content.push('─');
|
||||
}
|
||||
content.push_str("┤\n"); // push_str for multi-char
|
||||
}
|
||||
content.push('└');
|
||||
for _ in 0..200 {
|
||||
content.push('─');
|
||||
}
|
||||
content.push('┴');
|
||||
for _ in 0..200 {
|
||||
content.push('─');
|
||||
}
|
||||
content.push_str("┘\n"); // push_str for multi-char
|
||||
|
||||
eprintln!(
|
||||
"Content size: {} bytes, {} chars",
|
||||
content.len(),
|
||||
content.chars().count()
|
||||
);
|
||||
let start = std::time::Instant::now();
|
||||
let chunks = split_into_chunks(&content);
|
||||
let elapsed = start.elapsed();
|
||||
eprintln!(
|
||||
"Chunking took {:?}, produced {} chunks",
|
||||
elapsed,
|
||||
chunks.len()
|
||||
);
|
||||
|
||||
// Should complete in reasonable time
|
||||
assert!(
|
||||
elapsed.as_secs() < 5,
|
||||
"Chunking took too long: {:?}",
|
||||
elapsed
|
||||
);
|
||||
assert!(!chunks.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_real_doc_18526_pattern() {
|
||||
// Reproduce exact pattern: long lines of ─ (3 bytes each, no spaces)
|
||||
// followed by newlines, creating a pattern where chunk windows
|
||||
// land in spaceless regions
|
||||
let mut content = String::new();
|
||||
content.push_str("Header text with spaces\n\n");
|
||||
// Create a very long line of ─ chars (2000+ bytes, exceeding CHUNK_MAX_BYTES)
|
||||
for _ in 0..800 {
|
||||
content.push('─'); // 3 bytes each = 2400 bytes
|
||||
}
|
||||
content.push('\n');
|
||||
content.push_str("Some more text.\n\n");
|
||||
// Another long run
|
||||
for _ in 0..800 {
|
||||
content.push('─');
|
||||
}
|
||||
content.push('\n');
|
||||
content.push_str("End text.\n");
|
||||
|
||||
eprintln!("Content size: {} bytes", content.len());
|
||||
let start = std::time::Instant::now();
|
||||
let chunks = split_into_chunks(&content);
|
||||
let elapsed = start.elapsed();
|
||||
eprintln!(
|
||||
"Chunking took {:?}, produced {} chunks",
|
||||
elapsed,
|
||||
chunks.len()
|
||||
);
|
||||
|
||||
assert!(
|
||||
elapsed.as_secs() < 2,
|
||||
"Chunking took too long: {:?}",
|
||||
elapsed
|
||||
);
|
||||
assert!(!chunks.is_empty());
|
||||
}
|
||||
}
|
||||
#[path = "chunking_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
226
src/embedding/chunking_tests.rs
Normal file
226
src/embedding/chunking_tests.rs
Normal file
@@ -0,0 +1,226 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_empty_content() {
|
||||
let chunks = split_into_chunks("");
|
||||
assert!(chunks.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_short_document_single_chunk() {
|
||||
let content = "Short document content.";
|
||||
let chunks = split_into_chunks(content);
|
||||
assert_eq!(chunks.len(), 1);
|
||||
assert_eq!(chunks[0].0, 0);
|
||||
assert_eq!(chunks[0].1, content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exactly_max_chars() {
|
||||
let content = "a".repeat(CHUNK_MAX_BYTES);
|
||||
let chunks = split_into_chunks(&content);
|
||||
assert_eq!(chunks.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_long_document_multiple_chunks() {
|
||||
let paragraph = "This is a paragraph of text.\n\n";
|
||||
let mut content = String::new();
|
||||
while content.len() < CHUNK_MAX_BYTES * 2 {
|
||||
content.push_str(paragraph);
|
||||
}
|
||||
|
||||
let chunks = split_into_chunks(&content);
|
||||
assert!(
|
||||
chunks.len() >= 2,
|
||||
"Expected multiple chunks, got {}",
|
||||
chunks.len()
|
||||
);
|
||||
|
||||
for (i, (idx, _)) in chunks.iter().enumerate() {
|
||||
assert_eq!(*idx, i);
|
||||
}
|
||||
|
||||
assert!(!chunks.last().unwrap().1.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chunk_overlap() {
|
||||
let paragraph = "This is paragraph content for testing chunk overlap behavior.\n\n";
|
||||
let mut content = String::new();
|
||||
while content.len() < CHUNK_MAX_BYTES + CHUNK_OVERLAP_CHARS + 1000 {
|
||||
content.push_str(paragraph);
|
||||
}
|
||||
|
||||
let chunks = split_into_chunks(&content);
|
||||
assert!(chunks.len() >= 2);
|
||||
|
||||
if chunks.len() >= 2 {
|
||||
let end_of_first = &chunks[0].1;
|
||||
let start_of_second = &chunks[1].1;
|
||||
let overlap_region =
|
||||
&end_of_first[end_of_first.len().saturating_sub(CHUNK_OVERLAP_CHARS)..];
|
||||
assert!(
|
||||
start_of_second.starts_with(overlap_region)
|
||||
|| overlap_region.contains(&start_of_second[..100.min(start_of_second.len())]),
|
||||
"Expected overlap between chunks"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_paragraph_boundary() {
|
||||
let content = "word ".repeat(CHUNK_MAX_BYTES / 5 * 3);
|
||||
let chunks = split_into_chunks(&content);
|
||||
assert!(chunks.len() >= 2);
|
||||
for (_, chunk) in &chunks {
|
||||
assert!(!chunk.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chunk_indices_sequential() {
|
||||
let content = "a ".repeat(CHUNK_MAX_BYTES);
|
||||
let chunks = split_into_chunks(&content);
|
||||
for (i, (idx, _)) in chunks.iter().enumerate() {
|
||||
assert_eq!(*idx, i, "Chunk index mismatch at position {}", i);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multibyte_characters_no_panic() {
|
||||
// Build content with multi-byte UTF-8 chars (smart quotes, emoji, CJK)
|
||||
// placed at positions likely to hit len()*2/3 and len()/2 boundaries
|
||||
let segment = "We\u{2019}ve gradually ar\u{2014}ranged the components. ";
|
||||
let mut content = String::new();
|
||||
while content.len() < CHUNK_MAX_BYTES * 3 {
|
||||
content.push_str(segment);
|
||||
}
|
||||
// Should not panic on multi-byte boundary
|
||||
let chunks = split_into_chunks(&content);
|
||||
assert!(chunks.len() >= 2);
|
||||
for (_, chunk) in &chunks {
|
||||
assert!(!chunk.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nbsp_at_overlap_boundary() {
|
||||
// Reproduce the exact crash: \u{a0} (non-breaking space, 2-byte UTF-8)
|
||||
// placed so that split_at - CHUNK_OVERLAP_CHARS lands mid-character
|
||||
let mut content = String::new();
|
||||
// Fill with ASCII up to near CHUNK_MAX_BYTES, then place \u{a0}
|
||||
// near where the overlap subtraction would land
|
||||
let target = CHUNK_MAX_BYTES - CHUNK_OVERLAP_CHARS;
|
||||
while content.len() < target - 2 {
|
||||
content.push('a');
|
||||
}
|
||||
content.push('\u{a0}'); // 2-byte char right at the overlap boundary
|
||||
while content.len() < CHUNK_MAX_BYTES * 3 {
|
||||
content.push('b');
|
||||
}
|
||||
// Should not panic
|
||||
let chunks = split_into_chunks(&content);
|
||||
assert!(chunks.len() >= 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_box_drawing_heavy_content() {
|
||||
// Simulates a document with many box-drawing characters (3-byte UTF-8)
|
||||
// like the ─ (U+2500) character found in markdown tables
|
||||
let mut content = String::new();
|
||||
// Normal text header
|
||||
content.push_str("# Title\n\nSome description text.\n\n");
|
||||
// Table header with box drawing
|
||||
content.push('┌');
|
||||
for _ in 0..200 {
|
||||
content.push('─');
|
||||
}
|
||||
content.push('┬');
|
||||
for _ in 0..200 {
|
||||
content.push('─');
|
||||
}
|
||||
content.push_str("┐\n"); // clippy: push_str is correct here (multi-char)
|
||||
// Table rows
|
||||
for row in 0..50 {
|
||||
content.push_str(&format!("│ row {:<194}│ data {:<193}│\n", row, row));
|
||||
content.push('├');
|
||||
for _ in 0..200 {
|
||||
content.push('─');
|
||||
}
|
||||
content.push('┼');
|
||||
for _ in 0..200 {
|
||||
content.push('─');
|
||||
}
|
||||
content.push_str("┤\n"); // push_str for multi-char
|
||||
}
|
||||
content.push('└');
|
||||
for _ in 0..200 {
|
||||
content.push('─');
|
||||
}
|
||||
content.push('┴');
|
||||
for _ in 0..200 {
|
||||
content.push('─');
|
||||
}
|
||||
content.push_str("┘\n"); // push_str for multi-char
|
||||
|
||||
eprintln!(
|
||||
"Content size: {} bytes, {} chars",
|
||||
content.len(),
|
||||
content.chars().count()
|
||||
);
|
||||
let start = std::time::Instant::now();
|
||||
let chunks = split_into_chunks(&content);
|
||||
let elapsed = start.elapsed();
|
||||
eprintln!(
|
||||
"Chunking took {:?}, produced {} chunks",
|
||||
elapsed,
|
||||
chunks.len()
|
||||
);
|
||||
|
||||
// Should complete in reasonable time
|
||||
assert!(
|
||||
elapsed.as_secs() < 5,
|
||||
"Chunking took too long: {:?}",
|
||||
elapsed
|
||||
);
|
||||
assert!(!chunks.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_real_doc_18526_pattern() {
|
||||
// Reproduce exact pattern: long lines of ─ (3 bytes each, no spaces)
|
||||
// followed by newlines, creating a pattern where chunk windows
|
||||
// land in spaceless regions
|
||||
let mut content = String::new();
|
||||
content.push_str("Header text with spaces\n\n");
|
||||
// Create a very long line of ─ chars (2000+ bytes, exceeding CHUNK_MAX_BYTES)
|
||||
for _ in 0..800 {
|
||||
content.push('─'); // 3 bytes each = 2400 bytes
|
||||
}
|
||||
content.push('\n');
|
||||
content.push_str("Some more text.\n\n");
|
||||
// Another long run
|
||||
for _ in 0..800 {
|
||||
content.push('─');
|
||||
}
|
||||
content.push('\n');
|
||||
content.push_str("End text.\n");
|
||||
|
||||
eprintln!("Content size: {} bytes", content.len());
|
||||
let start = std::time::Instant::now();
|
||||
let chunks = split_into_chunks(&content);
|
||||
let elapsed = start.elapsed();
|
||||
eprintln!(
|
||||
"Chunking took {:?}, produced {} chunks",
|
||||
elapsed,
|
||||
chunks.len()
|
||||
);
|
||||
|
||||
assert!(
|
||||
elapsed.as_secs() < 2,
|
||||
"Chunking took too long: {:?}",
|
||||
elapsed
|
||||
);
|
||||
assert!(!chunks.is_empty());
|
||||
}
|
||||
@@ -364,930 +364,5 @@ pub async fn fetch_issue_statuses_with_progress(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::core::error::LoreError;
|
||||
use wiremock::matchers::{body_json, header, method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// AC-1: GraphQL Client
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_graphql_query_success() {
|
||||
let server = MockServer::start().await;
|
||||
let response_body = serde_json::json!({
|
||||
"data": { "project": { "id": "1" } }
|
||||
});
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(&response_body))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "test-token");
|
||||
let result = client
|
||||
.query("{ project { id } }", serde_json::json!({}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.data["project"]["id"], "1");
|
||||
assert!(!result.had_partial_errors);
|
||||
assert!(result.first_partial_error.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_graphql_query_with_errors_no_data() {
|
||||
let server = MockServer::start().await;
|
||||
let response_body = serde_json::json!({
|
||||
"errors": [{ "message": "Field 'foo' not found" }]
|
||||
});
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(&response_body))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "test-token");
|
||||
let err = client
|
||||
.query("{ foo }", serde_json::json!({}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
match err {
|
||||
LoreError::Other(msg) => {
|
||||
assert!(msg.contains("Field 'foo' not found"), "got: {msg}");
|
||||
}
|
||||
other => panic!("Expected LoreError::Other, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_graphql_auth_uses_bearer() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.and(header("Authorization", "Bearer my-secret-token"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_json(serde_json::json!({"data": {"ok": true}})),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "my-secret-token");
|
||||
let result = client.query("{ ok }", serde_json::json!({})).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_graphql_401_maps_to_auth_failed() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(ResponseTemplate::new(401))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "bad-token");
|
||||
let err = client
|
||||
.query("{ me }", serde_json::json!({}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(err, LoreError::GitLabAuthFailed));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_graphql_403_maps_to_auth_failed() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(ResponseTemplate::new(403))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "forbidden-token");
|
||||
let err = client
|
||||
.query("{ admin }", serde_json::json!({}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(err, LoreError::GitLabAuthFailed));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_graphql_404_maps_to_not_found() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(ResponseTemplate::new(404))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "token");
|
||||
let err = client
|
||||
.query("{ x }", serde_json::json!({}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
match err {
|
||||
LoreError::GitLabNotFound { resource } => {
|
||||
assert_eq!(resource, "GraphQL endpoint");
|
||||
}
|
||||
other => panic!("Expected GitLabNotFound, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_graphql_partial_data_with_errors_returns_data() {
|
||||
let server = MockServer::start().await;
|
||||
let response_body = serde_json::json!({
|
||||
"data": { "project": { "name": "test" } },
|
||||
"errors": [{ "message": "Some field failed" }]
|
||||
});
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(&response_body))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "token");
|
||||
let result = client
|
||||
.query("{ project { name } }", serde_json::json!({}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.data["project"]["name"], "test");
|
||||
assert!(result.had_partial_errors);
|
||||
assert_eq!(
|
||||
result.first_partial_error.as_deref(),
|
||||
Some("Some field failed")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_retry_after_delta_seconds() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "120"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "token");
|
||||
let err = client
|
||||
.query("{ x }", serde_json::json!({}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
match err {
|
||||
LoreError::GitLabRateLimited { retry_after } => {
|
||||
assert_eq!(retry_after, 120);
|
||||
}
|
||||
other => panic!("Expected GitLabRateLimited, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_retry_after_http_date_format() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
let future = SystemTime::now() + Duration::from_secs(90);
|
||||
let date_str = httpdate::fmt_http_date(future);
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(ResponseTemplate::new(429).insert_header("Retry-After", date_str))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "token");
|
||||
let err = client
|
||||
.query("{ x }", serde_json::json!({}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
match err {
|
||||
LoreError::GitLabRateLimited { retry_after } => {
|
||||
assert!(
|
||||
(85..=95).contains(&retry_after),
|
||||
"retry_after={retry_after}"
|
||||
);
|
||||
}
|
||||
other => panic!("Expected GitLabRateLimited, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_retry_after_invalid_falls_back_to_60() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "garbage"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "token");
|
||||
let err = client
|
||||
.query("{ x }", serde_json::json!({}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
match err {
|
||||
LoreError::GitLabRateLimited { retry_after } => {
|
||||
assert_eq!(retry_after, 60);
|
||||
}
|
||||
other => panic!("Expected GitLabRateLimited, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_graphql_network_error() {
|
||||
let client = GraphqlClient::new("http://127.0.0.1:1", "token");
|
||||
let err = client
|
||||
.query("{ x }", serde_json::json!({}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(
|
||||
matches!(err, LoreError::GitLabNetworkError { .. }),
|
||||
"Expected GitLabNetworkError, got: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_graphql_request_body_format() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
let expected_body = serde_json::json!({
|
||||
"query": "{ project(fullPath: $path) { id } }",
|
||||
"variables": { "path": "group/repo" }
|
||||
});
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.and(body_json(&expected_body))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_json(serde_json::json!({"data": {"project": {"id": "1"}}})),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "token");
|
||||
let result = client
|
||||
.query(
|
||||
"{ project(fullPath: $path) { id } }",
|
||||
serde_json::json!({"path": "group/repo"}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok(), "Body format mismatch: {result:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_graphql_base_url_trailing_slash() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_json(serde_json::json!({"data": {"ok": true}})),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let url_with_slash = format!("{}/", server.uri());
|
||||
let client = GraphqlClient::new(&url_with_slash, "token");
|
||||
let result = client.query("{ ok }", serde_json::json!({})).await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_graphql_data_null_no_errors() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_json(serde_json::json!({"data": null})),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "token");
|
||||
let err = client
|
||||
.query("{ x }", serde_json::json!({}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
match err {
|
||||
LoreError::Other(msg) => {
|
||||
assert!(msg.contains("missing 'data' field"), "got: {msg}");
|
||||
}
|
||||
other => panic!("Expected LoreError::Other, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// AC-3: Status Fetcher
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Helper: build a GraphQL work-items response page with given issues.
|
||||
fn make_work_items_page(
|
||||
items: &[(i64, Option<&str>)],
|
||||
has_next_page: bool,
|
||||
end_cursor: Option<&str>,
|
||||
) -> serde_json::Value {
|
||||
let nodes: Vec<serde_json::Value> = items
|
||||
.iter()
|
||||
.map(|(iid, status_name)| {
|
||||
let mut widgets =
|
||||
vec![serde_json::json!({"__typename": "WorkItemWidgetDescription"})];
|
||||
if let Some(name) = status_name {
|
||||
widgets.push(serde_json::json!({
|
||||
"__typename": "WorkItemWidgetStatus",
|
||||
"status": {
|
||||
"name": name,
|
||||
"category": "IN_PROGRESS",
|
||||
"color": "#1f75cb",
|
||||
"iconName": "status-in-progress"
|
||||
}
|
||||
}));
|
||||
}
|
||||
serde_json::json!({
|
||||
"iid": iid.to_string(),
|
||||
"widgets": widgets,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
serde_json::json!({
|
||||
"data": {
|
||||
"project": {
|
||||
"workItems": {
|
||||
"nodes": nodes,
|
||||
"pageInfo": {
|
||||
"endCursor": end_cursor,
|
||||
"hasNextPage": has_next_page,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Helper: build a page where issue has status widget but status is null.
|
||||
fn make_null_status_widget_page(iid: i64) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"data": {
|
||||
"project": {
|
||||
"workItems": {
|
||||
"nodes": [{
|
||||
"iid": iid.to_string(),
|
||||
"widgets": [
|
||||
{"__typename": "WorkItemWidgetStatus", "status": null}
|
||||
]
|
||||
}],
|
||||
"pageInfo": {
|
||||
"endCursor": null,
|
||||
"hasNextPage": false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_statuses_pagination() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
// Page 1: returns cursor "cursor_page2"
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with({
|
||||
ResponseTemplate::new(200).set_body_json(make_work_items_page(
|
||||
&[(1, Some("In progress")), (2, Some("To do"))],
|
||||
true,
|
||||
Some("cursor_page2"),
|
||||
))
|
||||
})
|
||||
.up_to_n_times(1)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
// Page 2: no more pages
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_json(make_work_items_page(
|
||||
&[(3, Some("Done"))],
|
||||
false,
|
||||
None,
|
||||
)),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "tok123");
|
||||
let result = fetch_issue_statuses(&client, "group/project")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.statuses.len(), 3);
|
||||
assert!(result.statuses.contains_key(&1));
|
||||
assert!(result.statuses.contains_key(&2));
|
||||
assert!(result.statuses.contains_key(&3));
|
||||
assert_eq!(result.all_fetched_iids.len(), 3);
|
||||
assert!(result.unsupported_reason.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_statuses_no_status_widget() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"data": {
|
||||
"project": {
|
||||
"workItems": {
|
||||
"nodes": [{
|
||||
"iid": "42",
|
||||
"widgets": [
|
||||
{"__typename": "WorkItemWidgetDescription"},
|
||||
{"__typename": "WorkItemWidgetLabels"}
|
||||
]
|
||||
}],
|
||||
"pageInfo": {"endCursor": null, "hasNextPage": false}
|
||||
}
|
||||
}
|
||||
}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "tok123");
|
||||
let result = fetch_issue_statuses(&client, "group/project")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.statuses.is_empty(), "No status widget → no statuses");
|
||||
assert!(
|
||||
result.all_fetched_iids.contains(&42),
|
||||
"IID 42 should still be in all_fetched_iids"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_statuses_404_graceful() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(ResponseTemplate::new(404))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "tok123");
|
||||
let result = fetch_issue_statuses(&client, "group/project")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.statuses.is_empty());
|
||||
assert!(result.all_fetched_iids.is_empty());
|
||||
assert!(matches!(
|
||||
result.unsupported_reason,
|
||||
Some(UnsupportedReason::GraphqlEndpointMissing)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_statuses_403_graceful() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(ResponseTemplate::new(403))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "tok123");
|
||||
let result = fetch_issue_statuses(&client, "group/project")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.statuses.is_empty());
|
||||
assert!(result.all_fetched_iids.is_empty());
|
||||
assert!(matches!(
|
||||
result.unsupported_reason,
|
||||
Some(UnsupportedReason::AuthForbidden)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_statuses_unsupported_reason_none_on_success() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_json(make_work_items_page(
|
||||
&[(1, Some("To do"))],
|
||||
false,
|
||||
None,
|
||||
)),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "tok123");
|
||||
let result = fetch_issue_statuses(&client, "group/project")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.unsupported_reason.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_typename_matching_ignores_non_status_widgets() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"data": {
|
||||
"project": {
|
||||
"workItems": {
|
||||
"nodes": [{
|
||||
"iid": "10",
|
||||
"widgets": [
|
||||
{"__typename": "WorkItemWidgetDescription"},
|
||||
{"__typename": "WorkItemWidgetLabels"},
|
||||
{"__typename": "WorkItemWidgetAssignees"},
|
||||
{
|
||||
"__typename": "WorkItemWidgetStatus",
|
||||
"status": {
|
||||
"name": "In progress",
|
||||
"category": "IN_PROGRESS"
|
||||
}
|
||||
}
|
||||
]
|
||||
}],
|
||||
"pageInfo": {"endCursor": null, "hasNextPage": false}
|
||||
}
|
||||
}
|
||||
}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "tok123");
|
||||
let result = fetch_issue_statuses(&client, "group/project")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.statuses.len(), 1);
|
||||
assert_eq!(result.statuses[&10].name, "In progress");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_statuses_cursor_stall_aborts() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
let stall_response = serde_json::json!({
|
||||
"data": {
|
||||
"project": {
|
||||
"workItems": {
|
||||
"nodes": [{"iid": "1", "widgets": []}],
|
||||
"pageInfo": {"endCursor": "same_cursor", "hasNextPage": true}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(stall_response))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "tok123");
|
||||
let result = fetch_issue_statuses(&client, "group/project")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
result.all_fetched_iids.contains(&1),
|
||||
"Should contain the one IID fetched before stall"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_statuses_complexity_error_reduces_page_size() {
|
||||
let server = MockServer::start().await;
|
||||
let call_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let call_count_clone = call_count.clone();
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(move |_req: &wiremock::Request| {
|
||||
let n =
|
||||
call_count_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
if n == 0 {
|
||||
ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"errors": [{"message": "Query has complexity of 300, which exceeds max complexity of 250"}]
|
||||
}))
|
||||
} else {
|
||||
ResponseTemplate::new(200).set_body_json(make_work_items_page(
|
||||
&[(1, Some("In progress"))],
|
||||
false,
|
||||
None,
|
||||
))
|
||||
}
|
||||
})
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "tok123");
|
||||
let result = fetch_issue_statuses(&client, "group/project")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.statuses.len(), 1);
|
||||
assert_eq!(result.statuses[&1].name, "In progress");
|
||||
assert_eq!(call_count.load(std::sync::atomic::Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_statuses_timeout_error_reduces_page_size() {
|
||||
let server = MockServer::start().await;
|
||||
let call_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let call_count_clone = call_count.clone();
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(move |_req: &wiremock::Request| {
|
||||
let n = call_count_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
if n == 0 {
|
||||
ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"errors": [{"message": "Query timeout after 30000ms"}]
|
||||
}))
|
||||
} else {
|
||||
ResponseTemplate::new(200).set_body_json(make_work_items_page(
|
||||
&[(5, Some("Done"))],
|
||||
false,
|
||||
None,
|
||||
))
|
||||
}
|
||||
})
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "tok123");
|
||||
let result = fetch_issue_statuses(&client, "group/project")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.statuses.len(), 1);
|
||||
assert!(call_count.load(std::sync::atomic::Ordering::SeqCst) >= 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_statuses_smallest_page_still_fails() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"errors": [{"message": "Query has complexity of 9999"}]
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "tok123");
|
||||
let err = fetch_issue_statuses(&client, "group/project")
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(
|
||||
matches!(err, LoreError::Other(_)),
|
||||
"Expected error after exhausting all page sizes, got: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_statuses_page_size_resets_after_success() {
|
||||
let server = MockServer::start().await;
|
||||
let call_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let call_count_clone = call_count.clone();
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(move |_req: &wiremock::Request| {
|
||||
let n = call_count_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
match n {
|
||||
0 => {
|
||||
// Page 1 at size 100: success, has next page
|
||||
ResponseTemplate::new(200).set_body_json(make_work_items_page(
|
||||
&[(1, Some("To do"))],
|
||||
true,
|
||||
Some("cursor_p2"),
|
||||
))
|
||||
}
|
||||
1 => {
|
||||
// Page 2 at size 100 (reset): complexity error
|
||||
ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"errors": [{"message": "Query has complexity of 300"}]
|
||||
}))
|
||||
}
|
||||
2 => {
|
||||
// Page 2 retry at size 50: success
|
||||
ResponseTemplate::new(200).set_body_json(make_work_items_page(
|
||||
&[(2, Some("Done"))],
|
||||
false,
|
||||
None,
|
||||
))
|
||||
}
|
||||
_ => ResponseTemplate::new(500),
|
||||
}
|
||||
})
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "tok123");
|
||||
let result = fetch_issue_statuses(&client, "group/project")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.statuses.len(), 2);
|
||||
assert!(result.statuses.contains_key(&1));
|
||||
assert!(result.statuses.contains_key(&2));
|
||||
assert_eq!(call_count.load(std::sync::atomic::Ordering::SeqCst), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_statuses_partial_errors_tracked() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"data": {
|
||||
"project": {
|
||||
"workItems": {
|
||||
"nodes": [{"iid": "1", "widgets": [
|
||||
{"__typename": "WorkItemWidgetStatus", "status": {"name": "To do"}}
|
||||
]}],
|
||||
"pageInfo": {"endCursor": null, "hasNextPage": false}
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": [{"message": "Rate limit warning: approaching limit"}]
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "tok123");
|
||||
let result = fetch_issue_statuses(&client, "group/project")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.partial_error_count, 1);
|
||||
assert_eq!(
|
||||
result.first_partial_error.as_deref(),
|
||||
Some("Rate limit warning: approaching limit")
|
||||
);
|
||||
assert_eq!(result.statuses.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_statuses_empty_project() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"data": {
|
||||
"project": {
|
||||
"workItems": {
|
||||
"nodes": [],
|
||||
"pageInfo": {"endCursor": null, "hasNextPage": false}
|
||||
}
|
||||
}
|
||||
}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "tok123");
|
||||
let result = fetch_issue_statuses(&client, "group/project")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.statuses.is_empty());
|
||||
assert!(result.all_fetched_iids.is_empty());
|
||||
assert!(result.unsupported_reason.is_none());
|
||||
assert_eq!(result.partial_error_count, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_statuses_null_status_in_widget() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_json(make_null_status_widget_page(42)),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "tok123");
|
||||
let result = fetch_issue_statuses(&client, "group/project")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
result.statuses.is_empty(),
|
||||
"Null status should not be in map"
|
||||
);
|
||||
assert!(
|
||||
result.all_fetched_iids.contains(&42),
|
||||
"IID should still be tracked in all_fetched_iids"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_statuses_non_numeric_iid_skipped() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"data": {
|
||||
"project": {
|
||||
"workItems": {
|
||||
"nodes": [
|
||||
{
|
||||
"iid": "not_a_number",
|
||||
"widgets": [{"__typename": "WorkItemWidgetStatus", "status": {"name": "To do"}}]
|
||||
},
|
||||
{
|
||||
"iid": "7",
|
||||
"widgets": [{"__typename": "WorkItemWidgetStatus", "status": {"name": "Done"}}]
|
||||
}
|
||||
],
|
||||
"pageInfo": {"endCursor": null, "hasNextPage": false}
|
||||
}
|
||||
}
|
||||
}
|
||||
})),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "tok123");
|
||||
let result = fetch_issue_statuses(&client, "group/project")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.statuses.len(), 1);
|
||||
assert!(result.statuses.contains_key(&7));
|
||||
assert_eq!(result.all_fetched_iids.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_statuses_null_cursor_with_has_next_aborts() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"data": {
|
||||
"project": {
|
||||
"workItems": {
|
||||
"nodes": [{"iid": "1", "widgets": []}],
|
||||
"pageInfo": {"endCursor": null, "hasNextPage": true}
|
||||
}
|
||||
}
|
||||
}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "tok123");
|
||||
let result = fetch_issue_statuses(&client, "group/project")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.all_fetched_iids.len(), 1);
|
||||
}
|
||||
}
|
||||
#[path = "graphql_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
923
src/gitlab/graphql_tests.rs
Normal file
923
src/gitlab/graphql_tests.rs
Normal file
@@ -0,0 +1,923 @@
|
||||
use super::*;
|
||||
use crate::core::error::LoreError;
|
||||
use wiremock::matchers::{body_json, header, method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// AC-1: GraphQL Client
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_graphql_query_success() {
|
||||
let server = MockServer::start().await;
|
||||
let response_body = serde_json::json!({
|
||||
"data": { "project": { "id": "1" } }
|
||||
});
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(&response_body))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "test-token");
|
||||
let result = client
|
||||
.query("{ project { id } }", serde_json::json!({}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.data["project"]["id"], "1");
|
||||
assert!(!result.had_partial_errors);
|
||||
assert!(result.first_partial_error.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_graphql_query_with_errors_no_data() {
|
||||
let server = MockServer::start().await;
|
||||
let response_body = serde_json::json!({
|
||||
"errors": [{ "message": "Field 'foo' not found" }]
|
||||
});
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(&response_body))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "test-token");
|
||||
let err = client
|
||||
.query("{ foo }", serde_json::json!({}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
match err {
|
||||
LoreError::Other(msg) => {
|
||||
assert!(msg.contains("Field 'foo' not found"), "got: {msg}");
|
||||
}
|
||||
other => panic!("Expected LoreError::Other, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_graphql_auth_uses_bearer() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.and(header("Authorization", "Bearer my-secret-token"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_json(serde_json::json!({"data": {"ok": true}})),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "my-secret-token");
|
||||
let result = client.query("{ ok }", serde_json::json!({})).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_graphql_401_maps_to_auth_failed() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(ResponseTemplate::new(401))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "bad-token");
|
||||
let err = client
|
||||
.query("{ me }", serde_json::json!({}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(err, LoreError::GitLabAuthFailed));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_graphql_403_maps_to_auth_failed() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(ResponseTemplate::new(403))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "forbidden-token");
|
||||
let err = client
|
||||
.query("{ admin }", serde_json::json!({}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(err, LoreError::GitLabAuthFailed));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_graphql_404_maps_to_not_found() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(ResponseTemplate::new(404))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "token");
|
||||
let err = client
|
||||
.query("{ x }", serde_json::json!({}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
match err {
|
||||
LoreError::GitLabNotFound { resource } => {
|
||||
assert_eq!(resource, "GraphQL endpoint");
|
||||
}
|
||||
other => panic!("Expected GitLabNotFound, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_graphql_partial_data_with_errors_returns_data() {
|
||||
let server = MockServer::start().await;
|
||||
let response_body = serde_json::json!({
|
||||
"data": { "project": { "name": "test" } },
|
||||
"errors": [{ "message": "Some field failed" }]
|
||||
});
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(&response_body))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "token");
|
||||
let result = client
|
||||
.query("{ project { name } }", serde_json::json!({}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.data["project"]["name"], "test");
|
||||
assert!(result.had_partial_errors);
|
||||
assert_eq!(
|
||||
result.first_partial_error.as_deref(),
|
||||
Some("Some field failed")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_retry_after_delta_seconds() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "120"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "token");
|
||||
let err = client
|
||||
.query("{ x }", serde_json::json!({}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
match err {
|
||||
LoreError::GitLabRateLimited { retry_after } => {
|
||||
assert_eq!(retry_after, 120);
|
||||
}
|
||||
other => panic!("Expected GitLabRateLimited, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_retry_after_http_date_format() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
let future = SystemTime::now() + Duration::from_secs(90);
|
||||
let date_str = httpdate::fmt_http_date(future);
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(ResponseTemplate::new(429).insert_header("Retry-After", date_str))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "token");
|
||||
let err = client
|
||||
.query("{ x }", serde_json::json!({}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
match err {
|
||||
LoreError::GitLabRateLimited { retry_after } => {
|
||||
assert!(
|
||||
(85..=95).contains(&retry_after),
|
||||
"retry_after={retry_after}"
|
||||
);
|
||||
}
|
||||
other => panic!("Expected GitLabRateLimited, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_retry_after_invalid_falls_back_to_60() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "garbage"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "token");
|
||||
let err = client
|
||||
.query("{ x }", serde_json::json!({}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
match err {
|
||||
LoreError::GitLabRateLimited { retry_after } => {
|
||||
assert_eq!(retry_after, 60);
|
||||
}
|
||||
other => panic!("Expected GitLabRateLimited, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_graphql_network_error() {
|
||||
let client = GraphqlClient::new("http://127.0.0.1:1", "token");
|
||||
let err = client
|
||||
.query("{ x }", serde_json::json!({}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(
|
||||
matches!(err, LoreError::GitLabNetworkError { .. }),
|
||||
"Expected GitLabNetworkError, got: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_graphql_request_body_format() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
let expected_body = serde_json::json!({
|
||||
"query": "{ project(fullPath: $path) { id } }",
|
||||
"variables": { "path": "group/repo" }
|
||||
});
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.and(body_json(&expected_body))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_json(serde_json::json!({"data": {"project": {"id": "1"}}})),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "token");
|
||||
let result = client
|
||||
.query(
|
||||
"{ project(fullPath: $path) { id } }",
|
||||
serde_json::json!({"path": "group/repo"}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok(), "Body format mismatch: {result:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_graphql_base_url_trailing_slash() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_json(serde_json::json!({"data": {"ok": true}})),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let url_with_slash = format!("{}/", server.uri());
|
||||
let client = GraphqlClient::new(&url_with_slash, "token");
|
||||
let result = client.query("{ ok }", serde_json::json!({})).await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_graphql_data_null_no_errors() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"data": null})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "token");
|
||||
let err = client
|
||||
.query("{ x }", serde_json::json!({}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
match err {
|
||||
LoreError::Other(msg) => {
|
||||
assert!(msg.contains("missing 'data' field"), "got: {msg}");
|
||||
}
|
||||
other => panic!("Expected LoreError::Other, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// AC-3: Status Fetcher
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Helper: build a GraphQL work-items response page with given issues.
|
||||
fn make_work_items_page(
|
||||
items: &[(i64, Option<&str>)],
|
||||
has_next_page: bool,
|
||||
end_cursor: Option<&str>,
|
||||
) -> serde_json::Value {
|
||||
let nodes: Vec<serde_json::Value> = items
|
||||
.iter()
|
||||
.map(|(iid, status_name)| {
|
||||
let mut widgets = vec![serde_json::json!({"__typename": "WorkItemWidgetDescription"})];
|
||||
if let Some(name) = status_name {
|
||||
widgets.push(serde_json::json!({
|
||||
"__typename": "WorkItemWidgetStatus",
|
||||
"status": {
|
||||
"name": name,
|
||||
"category": "IN_PROGRESS",
|
||||
"color": "#1f75cb",
|
||||
"iconName": "status-in-progress"
|
||||
}
|
||||
}));
|
||||
}
|
||||
serde_json::json!({
|
||||
"iid": iid.to_string(),
|
||||
"widgets": widgets,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
serde_json::json!({
|
||||
"data": {
|
||||
"project": {
|
||||
"workItems": {
|
||||
"nodes": nodes,
|
||||
"pageInfo": {
|
||||
"endCursor": end_cursor,
|
||||
"hasNextPage": has_next_page,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Helper: build a page where issue has status widget but status is null.
|
||||
fn make_null_status_widget_page(iid: i64) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"data": {
|
||||
"project": {
|
||||
"workItems": {
|
||||
"nodes": [{
|
||||
"iid": iid.to_string(),
|
||||
"widgets": [
|
||||
{"__typename": "WorkItemWidgetStatus", "status": null}
|
||||
]
|
||||
}],
|
||||
"pageInfo": {
|
||||
"endCursor": null,
|
||||
"hasNextPage": false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_statuses_pagination() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
// Page 1: returns cursor "cursor_page2"
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with({
|
||||
ResponseTemplate::new(200).set_body_json(make_work_items_page(
|
||||
&[(1, Some("In progress")), (2, Some("To do"))],
|
||||
true,
|
||||
Some("cursor_page2"),
|
||||
))
|
||||
})
|
||||
.up_to_n_times(1)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
// Page 2: no more pages
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_json(make_work_items_page(
|
||||
&[(3, Some("Done"))],
|
||||
false,
|
||||
None,
|
||||
)),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "tok123");
|
||||
let result = fetch_issue_statuses(&client, "group/project")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.statuses.len(), 3);
|
||||
assert!(result.statuses.contains_key(&1));
|
||||
assert!(result.statuses.contains_key(&2));
|
||||
assert!(result.statuses.contains_key(&3));
|
||||
assert_eq!(result.all_fetched_iids.len(), 3);
|
||||
assert!(result.unsupported_reason.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_statuses_no_status_widget() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"data": {
|
||||
"project": {
|
||||
"workItems": {
|
||||
"nodes": [{
|
||||
"iid": "42",
|
||||
"widgets": [
|
||||
{"__typename": "WorkItemWidgetDescription"},
|
||||
{"__typename": "WorkItemWidgetLabels"}
|
||||
]
|
||||
}],
|
||||
"pageInfo": {"endCursor": null, "hasNextPage": false}
|
||||
}
|
||||
}
|
||||
}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "tok123");
|
||||
let result = fetch_issue_statuses(&client, "group/project")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
result.statuses.is_empty(),
|
||||
"No status widget -> no statuses"
|
||||
);
|
||||
assert!(
|
||||
result.all_fetched_iids.contains(&42),
|
||||
"IID 42 should still be in all_fetched_iids"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_statuses_404_graceful() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(ResponseTemplate::new(404))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "tok123");
|
||||
let result = fetch_issue_statuses(&client, "group/project")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.statuses.is_empty());
|
||||
assert!(result.all_fetched_iids.is_empty());
|
||||
assert!(matches!(
|
||||
result.unsupported_reason,
|
||||
Some(UnsupportedReason::GraphqlEndpointMissing)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_statuses_403_graceful() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(ResponseTemplate::new(403))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "tok123");
|
||||
let result = fetch_issue_statuses(&client, "group/project")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.statuses.is_empty());
|
||||
assert!(result.all_fetched_iids.is_empty());
|
||||
assert!(matches!(
|
||||
result.unsupported_reason,
|
||||
Some(UnsupportedReason::AuthForbidden)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_statuses_unsupported_reason_none_on_success() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_json(make_work_items_page(
|
||||
&[(1, Some("To do"))],
|
||||
false,
|
||||
None,
|
||||
)),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "tok123");
|
||||
let result = fetch_issue_statuses(&client, "group/project")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.unsupported_reason.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_typename_matching_ignores_non_status_widgets() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"data": {
|
||||
"project": {
|
||||
"workItems": {
|
||||
"nodes": [{
|
||||
"iid": "10",
|
||||
"widgets": [
|
||||
{"__typename": "WorkItemWidgetDescription"},
|
||||
{"__typename": "WorkItemWidgetLabels"},
|
||||
{"__typename": "WorkItemWidgetAssignees"},
|
||||
{
|
||||
"__typename": "WorkItemWidgetStatus",
|
||||
"status": {
|
||||
"name": "In progress",
|
||||
"category": "IN_PROGRESS"
|
||||
}
|
||||
}
|
||||
]
|
||||
}],
|
||||
"pageInfo": {"endCursor": null, "hasNextPage": false}
|
||||
}
|
||||
}
|
||||
}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "tok123");
|
||||
let result = fetch_issue_statuses(&client, "group/project")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.statuses.len(), 1);
|
||||
assert_eq!(result.statuses[&10].name, "In progress");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_statuses_cursor_stall_aborts() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
let stall_response = serde_json::json!({
|
||||
"data": {
|
||||
"project": {
|
||||
"workItems": {
|
||||
"nodes": [{"iid": "1", "widgets": []}],
|
||||
"pageInfo": {"endCursor": "same_cursor", "hasNextPage": true}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(stall_response))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "tok123");
|
||||
let result = fetch_issue_statuses(&client, "group/project")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
result.all_fetched_iids.contains(&1),
|
||||
"Should contain the one IID fetched before stall"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_statuses_complexity_error_reduces_page_size() {
|
||||
let server = MockServer::start().await;
|
||||
let call_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let call_count_clone = call_count.clone();
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(move |_req: &wiremock::Request| {
|
||||
let n =
|
||||
call_count_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
if n == 0 {
|
||||
ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"errors": [{"message": "Query has complexity of 300, which exceeds max complexity of 250"}]
|
||||
}))
|
||||
} else {
|
||||
ResponseTemplate::new(200).set_body_json(make_work_items_page(
|
||||
&[(1, Some("In progress"))],
|
||||
false,
|
||||
None,
|
||||
))
|
||||
}
|
||||
})
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "tok123");
|
||||
let result = fetch_issue_statuses(&client, "group/project")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.statuses.len(), 1);
|
||||
assert_eq!(result.statuses[&1].name, "In progress");
|
||||
assert_eq!(call_count.load(std::sync::atomic::Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_statuses_timeout_error_reduces_page_size() {
|
||||
let server = MockServer::start().await;
|
||||
let call_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let call_count_clone = call_count.clone();
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(move |_req: &wiremock::Request| {
|
||||
let n = call_count_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
if n == 0 {
|
||||
ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"errors": [{"message": "Query timeout after 30000ms"}]
|
||||
}))
|
||||
} else {
|
||||
ResponseTemplate::new(200).set_body_json(make_work_items_page(
|
||||
&[(5, Some("Done"))],
|
||||
false,
|
||||
None,
|
||||
))
|
||||
}
|
||||
})
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "tok123");
|
||||
let result = fetch_issue_statuses(&client, "group/project")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.statuses.len(), 1);
|
||||
assert!(call_count.load(std::sync::atomic::Ordering::SeqCst) >= 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_statuses_smallest_page_still_fails() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"errors": [{"message": "Query has complexity of 9999"}]
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "tok123");
|
||||
let err = fetch_issue_statuses(&client, "group/project")
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(
|
||||
matches!(err, LoreError::Other(_)),
|
||||
"Expected error after exhausting all page sizes, got: {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_statuses_page_size_resets_after_success() {
|
||||
let server = MockServer::start().await;
|
||||
let call_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let call_count_clone = call_count.clone();
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(move |_req: &wiremock::Request| {
|
||||
let n = call_count_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
match n {
|
||||
0 => {
|
||||
// Page 1 at size 100: success, has next page
|
||||
ResponseTemplate::new(200).set_body_json(make_work_items_page(
|
||||
&[(1, Some("To do"))],
|
||||
true,
|
||||
Some("cursor_p2"),
|
||||
))
|
||||
}
|
||||
1 => {
|
||||
// Page 2 at size 100 (reset): complexity error
|
||||
ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"errors": [{"message": "Query has complexity of 300"}]
|
||||
}))
|
||||
}
|
||||
2 => {
|
||||
// Page 2 retry at size 50: success
|
||||
ResponseTemplate::new(200).set_body_json(make_work_items_page(
|
||||
&[(2, Some("Done"))],
|
||||
false,
|
||||
None,
|
||||
))
|
||||
}
|
||||
_ => ResponseTemplate::new(500),
|
||||
}
|
||||
})
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "tok123");
|
||||
let result = fetch_issue_statuses(&client, "group/project")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.statuses.len(), 2);
|
||||
assert!(result.statuses.contains_key(&1));
|
||||
assert!(result.statuses.contains_key(&2));
|
||||
assert_eq!(call_count.load(std::sync::atomic::Ordering::SeqCst), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_statuses_partial_errors_tracked() {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"data": {
|
||||
"project": {
|
||||
"workItems": {
|
||||
"nodes": [{"iid": "1", "widgets": [
|
||||
{"__typename": "WorkItemWidgetStatus", "status": {"name": "To do"}}
|
||||
]}],
|
||||
"pageInfo": {"endCursor": null, "hasNextPage": false}
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": [{"message": "Rate limit warning: approaching limit"}]
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "tok123");
|
||||
let result = fetch_issue_statuses(&client, "group/project")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.partial_error_count, 1);
|
||||
assert_eq!(
|
||||
result.first_partial_error.as_deref(),
|
||||
Some("Rate limit warning: approaching limit")
|
||||
);
|
||||
assert_eq!(result.statuses.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_statuses_empty_project() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"data": {
|
||||
"project": {
|
||||
"workItems": {
|
||||
"nodes": [],
|
||||
"pageInfo": {"endCursor": null, "hasNextPage": false}
|
||||
}
|
||||
}
|
||||
}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "tok123");
|
||||
let result = fetch_issue_statuses(&client, "group/project")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.statuses.is_empty());
|
||||
assert!(result.all_fetched_iids.is_empty());
|
||||
assert!(result.unsupported_reason.is_none());
|
||||
assert_eq!(result.partial_error_count, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_statuses_null_status_in_widget() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(make_null_status_widget_page(42)))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "tok123");
|
||||
let result = fetch_issue_statuses(&client, "group/project")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
result.statuses.is_empty(),
|
||||
"Null status should not be in map"
|
||||
);
|
||||
assert!(
|
||||
result.all_fetched_iids.contains(&42),
|
||||
"IID should still be tracked in all_fetched_iids"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_statuses_non_numeric_iid_skipped() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"data": {
|
||||
"project": {
|
||||
"workItems": {
|
||||
"nodes": [
|
||||
{
|
||||
"iid": "not_a_number",
|
||||
"widgets": [{"__typename": "WorkItemWidgetStatus", "status": {"name": "To do"}}]
|
||||
},
|
||||
{
|
||||
"iid": "7",
|
||||
"widgets": [{"__typename": "WorkItemWidgetStatus", "status": {"name": "Done"}}]
|
||||
}
|
||||
],
|
||||
"pageInfo": {"endCursor": null, "hasNextPage": false}
|
||||
}
|
||||
}
|
||||
}
|
||||
})),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "tok123");
|
||||
let result = fetch_issue_statuses(&client, "group/project")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.statuses.len(), 1);
|
||||
assert!(result.statuses.contains_key(&7));
|
||||
assert_eq!(result.all_fetched_iids.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_statuses_null_cursor_with_has_next_aborts() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/graphql"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"data": {
|
||||
"project": {
|
||||
"workItems": {
|
||||
"nodes": [{"iid": "1", "widgets": []}],
|
||||
"pageInfo": {"endCursor": null, "hasNextPage": true}
|
||||
}
|
||||
}
|
||||
}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = GraphqlClient::new(&server.uri(), "tok123");
|
||||
let result = fetch_issue_statuses(&client, "group/project")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.all_fetched_iids.len(), 1);
|
||||
}
|
||||
@@ -93,170 +93,5 @@ pub fn transform_issue(issue: &GitLabIssue) -> Result<IssueWithMetadata, Transfo
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::gitlab::types::{GitLabAuthor, GitLabMilestone};
|
||||
|
||||
fn make_test_issue() -> GitLabIssue {
|
||||
GitLabIssue {
|
||||
id: 12345,
|
||||
iid: 42,
|
||||
project_id: 100,
|
||||
title: "Test issue".to_string(),
|
||||
description: Some("Description here".to_string()),
|
||||
state: "opened".to_string(),
|
||||
created_at: "2024-01-15T10:00:00.000Z".to_string(),
|
||||
updated_at: "2024-01-20T15:30:00.000Z".to_string(),
|
||||
closed_at: None,
|
||||
author: GitLabAuthor {
|
||||
id: 1,
|
||||
username: "testuser".to_string(),
|
||||
name: "Test User".to_string(),
|
||||
},
|
||||
assignees: vec![],
|
||||
labels: vec!["bug".to_string(), "priority::high".to_string()],
|
||||
milestone: None,
|
||||
due_date: None,
|
||||
web_url: "https://gitlab.example.com/group/project/-/issues/42".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transforms_issue_with_all_fields() {
|
||||
let issue = make_test_issue();
|
||||
let result = transform_issue(&issue).unwrap();
|
||||
|
||||
assert_eq!(result.issue.gitlab_id, 12345);
|
||||
assert_eq!(result.issue.iid, 42);
|
||||
assert_eq!(result.issue.project_id, 100);
|
||||
assert_eq!(result.issue.title, "Test issue");
|
||||
assert_eq!(
|
||||
result.issue.description,
|
||||
Some("Description here".to_string())
|
||||
);
|
||||
assert_eq!(result.issue.state, "opened");
|
||||
assert_eq!(result.issue.author_username, "testuser");
|
||||
assert_eq!(
|
||||
result.issue.web_url,
|
||||
"https://gitlab.example.com/group/project/-/issues/42"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_missing_description() {
|
||||
let mut issue = make_test_issue();
|
||||
issue.description = None;
|
||||
|
||||
let result = transform_issue(&issue).unwrap();
|
||||
assert!(result.issue.description.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_label_names() {
|
||||
let issue = make_test_issue();
|
||||
let result = transform_issue(&issue).unwrap();
|
||||
|
||||
assert_eq!(result.label_names.len(), 2);
|
||||
assert_eq!(result.label_names[0], "bug");
|
||||
assert_eq!(result.label_names[1], "priority::high");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_empty_labels() {
|
||||
let mut issue = make_test_issue();
|
||||
issue.labels = vec![];
|
||||
|
||||
let result = transform_issue(&issue).unwrap();
|
||||
assert!(result.label_names.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_timestamps_to_ms_epoch() {
|
||||
let issue = make_test_issue();
|
||||
let result = transform_issue(&issue).unwrap();
|
||||
|
||||
assert_eq!(result.issue.created_at, 1705312800000);
|
||||
assert_eq!(result.issue.updated_at, 1705764600000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_timezone_offset_timestamps() {
|
||||
let mut issue = make_test_issue();
|
||||
issue.created_at = "2024-01-15T05:00:00-05:00".to_string();
|
||||
|
||||
let result = transform_issue(&issue).unwrap();
|
||||
assert_eq!(result.issue.created_at, 1705312800000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_assignee_usernames() {
|
||||
let mut issue = make_test_issue();
|
||||
issue.assignees = vec![
|
||||
GitLabAuthor {
|
||||
id: 2,
|
||||
username: "alice".to_string(),
|
||||
name: "Alice".to_string(),
|
||||
},
|
||||
GitLabAuthor {
|
||||
id: 3,
|
||||
username: "bob".to_string(),
|
||||
name: "Bob".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
let result = transform_issue(&issue).unwrap();
|
||||
assert_eq!(result.assignee_usernames.len(), 2);
|
||||
assert_eq!(result.assignee_usernames[0], "alice");
|
||||
assert_eq!(result.assignee_usernames[1], "bob");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_milestone_info() {
|
||||
let mut issue = make_test_issue();
|
||||
issue.milestone = Some(GitLabMilestone {
|
||||
id: 500,
|
||||
iid: 5,
|
||||
project_id: Some(100),
|
||||
title: "v1.0".to_string(),
|
||||
description: Some("First release".to_string()),
|
||||
state: Some("active".to_string()),
|
||||
due_date: Some("2024-02-01".to_string()),
|
||||
web_url: Some("https://gitlab.example.com/-/milestones/5".to_string()),
|
||||
});
|
||||
|
||||
let result = transform_issue(&issue).unwrap();
|
||||
|
||||
assert_eq!(result.issue.milestone_title, Some("v1.0".to_string()));
|
||||
|
||||
let milestone = result.milestone.expect("should have milestone");
|
||||
assert_eq!(milestone.gitlab_id, 500);
|
||||
assert_eq!(milestone.iid, 5);
|
||||
assert_eq!(milestone.project_id, 100);
|
||||
assert_eq!(milestone.title, "v1.0");
|
||||
assert_eq!(milestone.description, Some("First release".to_string()));
|
||||
assert_eq!(milestone.state, Some("active".to_string()));
|
||||
assert_eq!(milestone.due_date, Some("2024-02-01".to_string()));
|
||||
assert_eq!(
|
||||
milestone.web_url,
|
||||
Some("https://gitlab.example.com/-/milestones/5".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_missing_milestone() {
|
||||
let issue = make_test_issue();
|
||||
let result = transform_issue(&issue).unwrap();
|
||||
|
||||
assert!(result.issue.milestone_title.is_none());
|
||||
assert!(result.milestone.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_due_date() {
|
||||
let mut issue = make_test_issue();
|
||||
issue.due_date = Some("2024-02-15".to_string());
|
||||
|
||||
let result = transform_issue(&issue).unwrap();
|
||||
assert_eq!(result.issue.due_date, Some("2024-02-15".to_string()));
|
||||
}
|
||||
}
|
||||
#[path = "issue_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
165
src/gitlab/transformers/issue_tests.rs
Normal file
165
src/gitlab/transformers/issue_tests.rs
Normal file
@@ -0,0 +1,165 @@
|
||||
use super::*;
|
||||
use crate::gitlab::types::{GitLabAuthor, GitLabMilestone};
|
||||
|
||||
fn make_test_issue() -> GitLabIssue {
|
||||
GitLabIssue {
|
||||
id: 12345,
|
||||
iid: 42,
|
||||
project_id: 100,
|
||||
title: "Test issue".to_string(),
|
||||
description: Some("Description here".to_string()),
|
||||
state: "opened".to_string(),
|
||||
created_at: "2024-01-15T10:00:00.000Z".to_string(),
|
||||
updated_at: "2024-01-20T15:30:00.000Z".to_string(),
|
||||
closed_at: None,
|
||||
author: GitLabAuthor {
|
||||
id: 1,
|
||||
username: "testuser".to_string(),
|
||||
name: "Test User".to_string(),
|
||||
},
|
||||
assignees: vec![],
|
||||
labels: vec!["bug".to_string(), "priority::high".to_string()],
|
||||
milestone: None,
|
||||
due_date: None,
|
||||
web_url: "https://gitlab.example.com/group/project/-/issues/42".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transforms_issue_with_all_fields() {
|
||||
let issue = make_test_issue();
|
||||
let result = transform_issue(&issue).unwrap();
|
||||
|
||||
assert_eq!(result.issue.gitlab_id, 12345);
|
||||
assert_eq!(result.issue.iid, 42);
|
||||
assert_eq!(result.issue.project_id, 100);
|
||||
assert_eq!(result.issue.title, "Test issue");
|
||||
assert_eq!(
|
||||
result.issue.description,
|
||||
Some("Description here".to_string())
|
||||
);
|
||||
assert_eq!(result.issue.state, "opened");
|
||||
assert_eq!(result.issue.author_username, "testuser");
|
||||
assert_eq!(
|
||||
result.issue.web_url,
|
||||
"https://gitlab.example.com/group/project/-/issues/42"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_missing_description() {
|
||||
let mut issue = make_test_issue();
|
||||
issue.description = None;
|
||||
|
||||
let result = transform_issue(&issue).unwrap();
|
||||
assert!(result.issue.description.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_label_names() {
|
||||
let issue = make_test_issue();
|
||||
let result = transform_issue(&issue).unwrap();
|
||||
|
||||
assert_eq!(result.label_names.len(), 2);
|
||||
assert_eq!(result.label_names[0], "bug");
|
||||
assert_eq!(result.label_names[1], "priority::high");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_empty_labels() {
|
||||
let mut issue = make_test_issue();
|
||||
issue.labels = vec![];
|
||||
|
||||
let result = transform_issue(&issue).unwrap();
|
||||
assert!(result.label_names.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_timestamps_to_ms_epoch() {
|
||||
let issue = make_test_issue();
|
||||
let result = transform_issue(&issue).unwrap();
|
||||
|
||||
assert_eq!(result.issue.created_at, 1705312800000);
|
||||
assert_eq!(result.issue.updated_at, 1705764600000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_timezone_offset_timestamps() {
|
||||
let mut issue = make_test_issue();
|
||||
issue.created_at = "2024-01-15T05:00:00-05:00".to_string();
|
||||
|
||||
let result = transform_issue(&issue).unwrap();
|
||||
assert_eq!(result.issue.created_at, 1705312800000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_assignee_usernames() {
|
||||
let mut issue = make_test_issue();
|
||||
issue.assignees = vec![
|
||||
GitLabAuthor {
|
||||
id: 2,
|
||||
username: "alice".to_string(),
|
||||
name: "Alice".to_string(),
|
||||
},
|
||||
GitLabAuthor {
|
||||
id: 3,
|
||||
username: "bob".to_string(),
|
||||
name: "Bob".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
let result = transform_issue(&issue).unwrap();
|
||||
assert_eq!(result.assignee_usernames.len(), 2);
|
||||
assert_eq!(result.assignee_usernames[0], "alice");
|
||||
assert_eq!(result.assignee_usernames[1], "bob");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_milestone_info() {
|
||||
let mut issue = make_test_issue();
|
||||
issue.milestone = Some(GitLabMilestone {
|
||||
id: 500,
|
||||
iid: 5,
|
||||
project_id: Some(100),
|
||||
title: "v1.0".to_string(),
|
||||
description: Some("First release".to_string()),
|
||||
state: Some("active".to_string()),
|
||||
due_date: Some("2024-02-01".to_string()),
|
||||
web_url: Some("https://gitlab.example.com/-/milestones/5".to_string()),
|
||||
});
|
||||
|
||||
let result = transform_issue(&issue).unwrap();
|
||||
|
||||
assert_eq!(result.issue.milestone_title, Some("v1.0".to_string()));
|
||||
|
||||
let milestone = result.milestone.expect("should have milestone");
|
||||
assert_eq!(milestone.gitlab_id, 500);
|
||||
assert_eq!(milestone.iid, 5);
|
||||
assert_eq!(milestone.project_id, 100);
|
||||
assert_eq!(milestone.title, "v1.0");
|
||||
assert_eq!(milestone.description, Some("First release".to_string()));
|
||||
assert_eq!(milestone.state, Some("active".to_string()));
|
||||
assert_eq!(milestone.due_date, Some("2024-02-01".to_string()));
|
||||
assert_eq!(
|
||||
milestone.web_url,
|
||||
Some("https://gitlab.example.com/-/milestones/5".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_missing_milestone() {
|
||||
let issue = make_test_issue();
|
||||
let result = transform_issue(&issue).unwrap();
|
||||
|
||||
assert!(result.issue.milestone_title.is_none());
|
||||
assert!(result.milestone.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_due_date() {
|
||||
let mut issue = make_test_issue();
|
||||
issue.due_date = Some("2024-02-15".to_string());
|
||||
|
||||
let result = transform_issue(&issue).unwrap();
|
||||
assert_eq!(result.issue.due_date, Some("2024-02-15".to_string()));
|
||||
}
|
||||
@@ -124,173 +124,5 @@ pub fn record_dirty_error(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn setup_db() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch("
|
||||
CREATE TABLE dirty_sources (
|
||||
source_type TEXT NOT NULL CHECK (source_type IN ('issue','merge_request','discussion','note')),
|
||||
source_id INTEGER NOT NULL,
|
||||
queued_at INTEGER NOT NULL,
|
||||
attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_attempt_at INTEGER,
|
||||
last_error TEXT,
|
||||
next_attempt_at INTEGER,
|
||||
PRIMARY KEY(source_type, source_id)
|
||||
);
|
||||
CREATE INDEX idx_dirty_sources_next_attempt ON dirty_sources(next_attempt_at);
|
||||
").unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mark_dirty_inserts() {
|
||||
let conn = setup_db();
|
||||
mark_dirty(&conn, SourceType::Issue, 1).unwrap();
|
||||
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM dirty_sources", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mark_dirty_tx_inserts() {
|
||||
let mut conn = setup_db();
|
||||
{
|
||||
let tx = conn.transaction().unwrap();
|
||||
mark_dirty_tx(&tx, SourceType::Issue, 1).unwrap();
|
||||
tx.commit().unwrap();
|
||||
}
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM dirty_sources", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_requeue_resets_backoff() {
|
||||
let conn = setup_db();
|
||||
mark_dirty(&conn, SourceType::Issue, 1).unwrap();
|
||||
record_dirty_error(&conn, SourceType::Issue, 1, "test error").unwrap();
|
||||
|
||||
let attempt: i64 = conn
|
||||
.query_row(
|
||||
"SELECT attempt_count FROM dirty_sources WHERE source_id = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(attempt, 1);
|
||||
|
||||
mark_dirty(&conn, SourceType::Issue, 1).unwrap();
|
||||
let attempt: i64 = conn
|
||||
.query_row(
|
||||
"SELECT attempt_count FROM dirty_sources WHERE source_id = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(attempt, 0);
|
||||
|
||||
let next_at: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT next_attempt_at FROM dirty_sources WHERE source_id = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(next_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_respects_backoff() {
|
||||
let conn = setup_db();
|
||||
mark_dirty(&conn, SourceType::Issue, 1).unwrap();
|
||||
conn.execute(
|
||||
"UPDATE dirty_sources SET next_attempt_at = 9999999999999 WHERE source_id = 1",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let results = get_dirty_sources(&conn).unwrap();
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_orders_by_attempt_count() {
|
||||
let conn = setup_db();
|
||||
mark_dirty(&conn, SourceType::Issue, 1).unwrap();
|
||||
conn.execute(
|
||||
"UPDATE dirty_sources SET attempt_count = 2 WHERE source_id = 1",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
mark_dirty(&conn, SourceType::Issue, 2).unwrap();
|
||||
|
||||
let results = get_dirty_sources(&conn).unwrap();
|
||||
assert_eq!(results.len(), 2);
|
||||
assert_eq!(results[0].1, 2);
|
||||
assert_eq!(results[1].1, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_size_500() {
|
||||
let conn = setup_db();
|
||||
for i in 0..600 {
|
||||
mark_dirty(&conn, SourceType::Issue, i).unwrap();
|
||||
}
|
||||
let results = get_dirty_sources(&conn).unwrap();
|
||||
assert_eq!(results.len(), 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clear_removes() {
|
||||
let conn = setup_db();
|
||||
mark_dirty(&conn, SourceType::Issue, 1).unwrap();
|
||||
clear_dirty(&conn, SourceType::Issue, 1).unwrap();
|
||||
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM dirty_sources", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mark_dirty_note_type() {
|
||||
let conn = setup_db();
|
||||
mark_dirty(&conn, SourceType::Note, 42).unwrap();
|
||||
|
||||
let results = get_dirty_sources(&conn).unwrap();
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].0, SourceType::Note);
|
||||
assert_eq!(results[0].1, 42);
|
||||
|
||||
clear_dirty(&conn, SourceType::Note, 42).unwrap();
|
||||
let results = get_dirty_sources(&conn).unwrap();
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_drain_loop() {
|
||||
let conn = setup_db();
|
||||
for i in 0..1200 {
|
||||
mark_dirty(&conn, SourceType::Issue, i).unwrap();
|
||||
}
|
||||
|
||||
let mut total = 0;
|
||||
loop {
|
||||
let batch = get_dirty_sources(&conn).unwrap();
|
||||
if batch.is_empty() {
|
||||
break;
|
||||
}
|
||||
for (st, id) in &batch {
|
||||
clear_dirty(&conn, *st, *id).unwrap();
|
||||
}
|
||||
total += batch.len();
|
||||
}
|
||||
assert_eq!(total, 1200);
|
||||
}
|
||||
}
|
||||
#[path = "dirty_tracker_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
168
src/ingestion/dirty_tracker_tests.rs
Normal file
168
src/ingestion/dirty_tracker_tests.rs
Normal file
@@ -0,0 +1,168 @@
|
||||
use super::*;
|
||||
|
||||
fn setup_db() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch("
|
||||
CREATE TABLE dirty_sources (
|
||||
source_type TEXT NOT NULL CHECK (source_type IN ('issue','merge_request','discussion','note')),
|
||||
source_id INTEGER NOT NULL,
|
||||
queued_at INTEGER NOT NULL,
|
||||
attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_attempt_at INTEGER,
|
||||
last_error TEXT,
|
||||
next_attempt_at INTEGER,
|
||||
PRIMARY KEY(source_type, source_id)
|
||||
);
|
||||
CREATE INDEX idx_dirty_sources_next_attempt ON dirty_sources(next_attempt_at);
|
||||
").unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mark_dirty_inserts() {
|
||||
let conn = setup_db();
|
||||
mark_dirty(&conn, SourceType::Issue, 1).unwrap();
|
||||
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM dirty_sources", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mark_dirty_tx_inserts() {
|
||||
let mut conn = setup_db();
|
||||
{
|
||||
let tx = conn.transaction().unwrap();
|
||||
mark_dirty_tx(&tx, SourceType::Issue, 1).unwrap();
|
||||
tx.commit().unwrap();
|
||||
}
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM dirty_sources", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_requeue_resets_backoff() {
|
||||
let conn = setup_db();
|
||||
mark_dirty(&conn, SourceType::Issue, 1).unwrap();
|
||||
record_dirty_error(&conn, SourceType::Issue, 1, "test error").unwrap();
|
||||
|
||||
let attempt: i64 = conn
|
||||
.query_row(
|
||||
"SELECT attempt_count FROM dirty_sources WHERE source_id = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(attempt, 1);
|
||||
|
||||
mark_dirty(&conn, SourceType::Issue, 1).unwrap();
|
||||
let attempt: i64 = conn
|
||||
.query_row(
|
||||
"SELECT attempt_count FROM dirty_sources WHERE source_id = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(attempt, 0);
|
||||
|
||||
let next_at: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT next_attempt_at FROM dirty_sources WHERE source_id = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(next_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_respects_backoff() {
|
||||
let conn = setup_db();
|
||||
mark_dirty(&conn, SourceType::Issue, 1).unwrap();
|
||||
conn.execute(
|
||||
"UPDATE dirty_sources SET next_attempt_at = 9999999999999 WHERE source_id = 1",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let results = get_dirty_sources(&conn).unwrap();
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_orders_by_attempt_count() {
|
||||
let conn = setup_db();
|
||||
mark_dirty(&conn, SourceType::Issue, 1).unwrap();
|
||||
conn.execute(
|
||||
"UPDATE dirty_sources SET attempt_count = 2 WHERE source_id = 1",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
mark_dirty(&conn, SourceType::Issue, 2).unwrap();
|
||||
|
||||
let results = get_dirty_sources(&conn).unwrap();
|
||||
assert_eq!(results.len(), 2);
|
||||
assert_eq!(results[0].1, 2);
|
||||
assert_eq!(results[1].1, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_size_500() {
|
||||
let conn = setup_db();
|
||||
for i in 0..600 {
|
||||
mark_dirty(&conn, SourceType::Issue, i).unwrap();
|
||||
}
|
||||
let results = get_dirty_sources(&conn).unwrap();
|
||||
assert_eq!(results.len(), 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clear_removes() {
|
||||
let conn = setup_db();
|
||||
mark_dirty(&conn, SourceType::Issue, 1).unwrap();
|
||||
clear_dirty(&conn, SourceType::Issue, 1).unwrap();
|
||||
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM dirty_sources", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mark_dirty_note_type() {
|
||||
let conn = setup_db();
|
||||
mark_dirty(&conn, SourceType::Note, 42).unwrap();
|
||||
|
||||
let results = get_dirty_sources(&conn).unwrap();
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].0, SourceType::Note);
|
||||
assert_eq!(results[0].1, 42);
|
||||
|
||||
clear_dirty(&conn, SourceType::Note, 42).unwrap();
|
||||
let results = get_dirty_sources(&conn).unwrap();
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_drain_loop() {
|
||||
let conn = setup_db();
|
||||
for i in 0..1200 {
|
||||
mark_dirty(&conn, SourceType::Issue, i).unwrap();
|
||||
}
|
||||
|
||||
let mut total = 0;
|
||||
loop {
|
||||
let batch = get_dirty_sources(&conn).unwrap();
|
||||
if batch.is_empty() {
|
||||
break;
|
||||
}
|
||||
for (st, id) in &batch {
|
||||
clear_dirty(&conn, *st, *id).unwrap();
|
||||
}
|
||||
total += batch.len();
|
||||
}
|
||||
assert_eq!(total, 1200);
|
||||
}
|
||||
@@ -467,475 +467,5 @@ fn update_issue_sync_timestamp(conn: &Connection, issue_id: i64, updated_at: i64
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::core::db::{create_connection, run_migrations};
|
||||
use crate::gitlab::transformers::NormalizedNote;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn result_default_has_zero_counts() {
|
||||
let result = IngestDiscussionsResult::default();
|
||||
assert_eq!(result.discussions_fetched, 0);
|
||||
assert_eq!(result.discussions_upserted, 0);
|
||||
assert_eq!(result.notes_upserted, 0);
|
||||
}
|
||||
|
||||
fn setup() -> Connection {
|
||||
let conn = create_connection(Path::new(":memory:")).unwrap();
|
||||
run_migrations(&conn).unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url) \
|
||||
VALUES (1, 'group/repo', 'https://gitlab.com/group/repo')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO issues (gitlab_id, iid, project_id, title, state, author_username, created_at, updated_at, last_seen_at) \
|
||||
VALUES (100, 1, 1, 'Test Issue', 'opened', 'testuser', 1000, 2000, 3000)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO discussions (gitlab_discussion_id, project_id, issue_id, noteable_type, individual_note, last_seen_at, resolvable, resolved) \
|
||||
VALUES ('disc-1', 1, 1, 'Issue', 0, 3000, 0, 0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn
|
||||
}
|
||||
|
||||
fn get_discussion_id(conn: &Connection) -> i64 {
|
||||
conn.query_row("SELECT id FROM discussions LIMIT 1", [], |row| row.get(0))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn make_note(
|
||||
gitlab_id: i64,
|
||||
project_id: i64,
|
||||
body: &str,
|
||||
note_type: Option<&str>,
|
||||
created_at: i64,
|
||||
updated_at: i64,
|
||||
resolved: bool,
|
||||
resolved_by: Option<&str>,
|
||||
) -> NormalizedNote {
|
||||
NormalizedNote {
|
||||
gitlab_id,
|
||||
project_id,
|
||||
note_type: note_type.map(String::from),
|
||||
is_system: false,
|
||||
author_id: None,
|
||||
author_username: "testuser".to_string(),
|
||||
body: body.to_string(),
|
||||
created_at,
|
||||
updated_at,
|
||||
last_seen_at: updated_at,
|
||||
position: 0,
|
||||
resolvable: false,
|
||||
resolved,
|
||||
resolved_by: resolved_by.map(String::from),
|
||||
resolved_at: None,
|
||||
position_old_path: None,
|
||||
position_new_path: None,
|
||||
position_old_line: None,
|
||||
position_new_line: None,
|
||||
position_type: None,
|
||||
position_line_range_start: None,
|
||||
position_line_range_end: None,
|
||||
position_base_sha: None,
|
||||
position_start_sha: None,
|
||||
position_head_sha: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_note_upsert_stable_id() {
|
||||
let conn = setup();
|
||||
let disc_id = get_discussion_id(&conn);
|
||||
let last_seen_at = 5000;
|
||||
|
||||
let note1 = make_note(1001, 1, "First note", None, 1000, 2000, false, None);
|
||||
let note2 = make_note(1002, 1, "Second note", None, 1000, 2000, false, None);
|
||||
|
||||
let out1 = upsert_note_for_issue(&conn, disc_id, ¬e1, last_seen_at, None).unwrap();
|
||||
let out2 = upsert_note_for_issue(&conn, disc_id, ¬e2, last_seen_at, None).unwrap();
|
||||
let id1 = out1.local_note_id;
|
||||
let id2 = out2.local_note_id;
|
||||
|
||||
// Re-sync same gitlab_ids
|
||||
let out1b = upsert_note_for_issue(&conn, disc_id, ¬e1, last_seen_at + 1, None).unwrap();
|
||||
let out2b = upsert_note_for_issue(&conn, disc_id, ¬e2, last_seen_at + 1, None).unwrap();
|
||||
|
||||
assert_eq!(id1, out1b.local_note_id);
|
||||
assert_eq!(id2, out2b.local_note_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_note_upsert_detects_body_change() {
|
||||
let conn = setup();
|
||||
let disc_id = get_discussion_id(&conn);
|
||||
|
||||
let note = make_note(2001, 1, "Original body", None, 1000, 2000, false, None);
|
||||
upsert_note_for_issue(&conn, disc_id, ¬e, 5000, None).unwrap();
|
||||
|
||||
let mut changed = make_note(2001, 1, "Updated body", None, 1000, 3000, false, None);
|
||||
changed.updated_at = 3000;
|
||||
let outcome = upsert_note_for_issue(&conn, disc_id, &changed, 5001, None).unwrap();
|
||||
assert!(outcome.changed_semantics);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_note_upsert_unchanged_returns_false() {
|
||||
let conn = setup();
|
||||
let disc_id = get_discussion_id(&conn);
|
||||
|
||||
let note = make_note(3001, 1, "Same body", None, 1000, 2000, false, None);
|
||||
upsert_note_for_issue(&conn, disc_id, ¬e, 5000, None).unwrap();
|
||||
|
||||
// Re-sync identical note
|
||||
let outcome = upsert_note_for_issue(&conn, disc_id, ¬e, 5001, None).unwrap();
|
||||
assert!(!outcome.changed_semantics);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_note_upsert_updated_at_only_does_not_mark_semantic_change() {
|
||||
let conn = setup();
|
||||
let disc_id = get_discussion_id(&conn);
|
||||
|
||||
let note = make_note(4001, 1, "Body stays", None, 1000, 2000, false, None);
|
||||
upsert_note_for_issue(&conn, disc_id, ¬e, 5000, None).unwrap();
|
||||
|
||||
// Only change updated_at (non-semantic field)
|
||||
let mut same = make_note(4001, 1, "Body stays", None, 1000, 9999, false, None);
|
||||
same.updated_at = 9999;
|
||||
let outcome = upsert_note_for_issue(&conn, disc_id, &same, 5001, None).unwrap();
|
||||
assert!(!outcome.changed_semantics);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_note_sweep_removes_stale() {
|
||||
let conn = setup();
|
||||
let disc_id = get_discussion_id(&conn);
|
||||
|
||||
let note1 = make_note(5001, 1, "Keep me", None, 1000, 2000, false, None);
|
||||
let note2 = make_note(5002, 1, "Stale me", None, 1000, 2000, false, None);
|
||||
|
||||
upsert_note_for_issue(&conn, disc_id, ¬e1, 5000, None).unwrap();
|
||||
upsert_note_for_issue(&conn, disc_id, ¬e2, 5000, None).unwrap();
|
||||
|
||||
// Re-sync only note1 with newer timestamp
|
||||
upsert_note_for_issue(&conn, disc_id, ¬e1, 6000, None).unwrap();
|
||||
|
||||
// Sweep should remove note2 (last_seen_at=5000 < 6000)
|
||||
let swept = sweep_stale_issue_notes(&conn, disc_id, 6000).unwrap();
|
||||
assert_eq!(swept, 1);
|
||||
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM notes WHERE discussion_id = ?",
|
||||
[disc_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_note_upsert_returns_local_id() {
|
||||
let conn = setup();
|
||||
let disc_id = get_discussion_id(&conn);
|
||||
|
||||
let note = make_note(6001, 1, "Check my ID", None, 1000, 2000, false, None);
|
||||
let outcome = upsert_note_for_issue(&conn, disc_id, ¬e, 5000, None).unwrap();
|
||||
|
||||
// Verify the local_note_id matches what's in the DB
|
||||
let db_id: i64 = conn
|
||||
.query_row(
|
||||
"SELECT id FROM notes WHERE gitlab_id = ?",
|
||||
[6001_i64],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(outcome.local_note_id, db_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_note_upsert_captures_author_id() {
|
||||
let conn = setup();
|
||||
let disc_id = get_discussion_id(&conn);
|
||||
|
||||
let mut note = make_note(7001, 1, "With author", None, 1000, 2000, false, None);
|
||||
note.author_id = Some(12345);
|
||||
|
||||
upsert_note_for_issue(&conn, disc_id, ¬e, 5000, None).unwrap();
|
||||
|
||||
let stored: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT author_id FROM notes WHERE gitlab_id = ?",
|
||||
[7001_i64],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(stored, Some(12345));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_note_upsert_author_id_nullable() {
|
||||
let conn = setup();
|
||||
let disc_id = get_discussion_id(&conn);
|
||||
|
||||
let note = make_note(7002, 1, "No author id", None, 1000, 2000, false, None);
|
||||
// author_id defaults to None in make_note
|
||||
|
||||
upsert_note_for_issue(&conn, disc_id, ¬e, 5000, None).unwrap();
|
||||
|
||||
let stored: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT author_id FROM notes WHERE gitlab_id = ?",
|
||||
[7002_i64],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(stored, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_note_author_id_survives_username_change() {
|
||||
let conn = setup();
|
||||
let disc_id = get_discussion_id(&conn);
|
||||
|
||||
let mut note = make_note(7003, 1, "Original body", None, 1000, 2000, false, None);
|
||||
note.author_id = Some(99999);
|
||||
note.author_username = "oldname".to_string();
|
||||
|
||||
upsert_note_for_issue(&conn, disc_id, ¬e, 5000, None).unwrap();
|
||||
|
||||
// Re-sync with changed username, changed body, same author_id
|
||||
let mut updated = make_note(7003, 1, "Updated body", None, 1000, 3000, false, None);
|
||||
updated.author_id = Some(99999);
|
||||
updated.author_username = "newname".to_string();
|
||||
|
||||
upsert_note_for_issue(&conn, disc_id, &updated, 5001, None).unwrap();
|
||||
|
||||
// author_id must survive the re-sync intact
|
||||
let stored_id: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT author_id FROM notes WHERE gitlab_id = ?",
|
||||
[7003_i64],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(stored_id, Some(99999));
|
||||
}
|
||||
|
||||
fn insert_note_document(conn: &Connection, note_local_id: i64) {
|
||||
conn.execute(
|
||||
"INSERT INTO documents (source_type, source_id, project_id, content_text, content_hash) \
|
||||
VALUES ('note', ?1, 1, 'note content', 'hash123')",
|
||||
[note_local_id],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn insert_note_dirty_source(conn: &Connection, note_local_id: i64) {
|
||||
conn.execute(
|
||||
"INSERT INTO dirty_sources (source_type, source_id, queued_at) \
|
||||
VALUES ('note', ?1, 1000)",
|
||||
[note_local_id],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn count_note_documents(conn: &Connection, note_local_id: i64) -> i64 {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM documents WHERE source_type = 'note' AND source_id = ?",
|
||||
[note_local_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn count_note_dirty_sources(conn: &Connection, note_local_id: i64) -> i64 {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM dirty_sources WHERE source_type = 'note' AND source_id = ?",
|
||||
[note_local_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_note_sweep_deletes_note_documents_immediately() {
|
||||
let conn = setup();
|
||||
let disc_id = get_discussion_id(&conn);
|
||||
|
||||
// Insert 3 notes
|
||||
let note1 = make_note(9001, 1, "Keep me", None, 1000, 2000, false, None);
|
||||
let note2 = make_note(9002, 1, "Keep me too", None, 1000, 2000, false, None);
|
||||
let note3 = make_note(9003, 1, "Stale me", None, 1000, 2000, false, None);
|
||||
|
||||
let out1 = upsert_note_for_issue(&conn, disc_id, ¬e1, 5000, None).unwrap();
|
||||
let out2 = upsert_note_for_issue(&conn, disc_id, ¬e2, 5000, None).unwrap();
|
||||
let out3 = upsert_note_for_issue(&conn, disc_id, ¬e3, 5000, None).unwrap();
|
||||
|
||||
// Add documents for all 3
|
||||
insert_note_document(&conn, out1.local_note_id);
|
||||
insert_note_document(&conn, out2.local_note_id);
|
||||
insert_note_document(&conn, out3.local_note_id);
|
||||
|
||||
// Add dirty_sources for note3
|
||||
insert_note_dirty_source(&conn, out3.local_note_id);
|
||||
|
||||
// Re-sync only notes 1 and 2 with newer timestamp
|
||||
upsert_note_for_issue(&conn, disc_id, ¬e1, 6000, None).unwrap();
|
||||
upsert_note_for_issue(&conn, disc_id, ¬e2, 6000, None).unwrap();
|
||||
|
||||
// Sweep should remove note3 and its document + dirty_source
|
||||
sweep_stale_issue_notes(&conn, disc_id, 6000).unwrap();
|
||||
|
||||
// Stale note's document should be gone
|
||||
assert_eq!(count_note_documents(&conn, out3.local_note_id), 0);
|
||||
assert_eq!(count_note_dirty_sources(&conn, out3.local_note_id), 0);
|
||||
|
||||
// Kept notes' documents should survive
|
||||
assert_eq!(count_note_documents(&conn, out1.local_note_id), 1);
|
||||
assert_eq!(count_note_documents(&conn, out2.local_note_id), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sweep_deletion_handles_note_without_document() {
|
||||
let conn = setup();
|
||||
let disc_id = get_discussion_id(&conn);
|
||||
|
||||
let note = make_note(9004, 1, "No doc", None, 1000, 2000, false, None);
|
||||
upsert_note_for_issue(&conn, disc_id, ¬e, 5000, None).unwrap();
|
||||
|
||||
// Don't insert any document -- sweep should still work without error
|
||||
let swept = sweep_stale_issue_notes(&conn, disc_id, 6000).unwrap();
|
||||
assert_eq!(swept, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_based_deletion_atomicity() {
|
||||
let conn = setup();
|
||||
let disc_id = get_discussion_id(&conn);
|
||||
|
||||
// Insert a stale note with both document and dirty_source
|
||||
let note = make_note(9005, 1, "Stale with deps", None, 1000, 2000, false, None);
|
||||
let out = upsert_note_for_issue(&conn, disc_id, ¬e, 5000, None).unwrap();
|
||||
insert_note_document(&conn, out.local_note_id);
|
||||
insert_note_dirty_source(&conn, out.local_note_id);
|
||||
|
||||
// Verify they exist before sweep
|
||||
assert_eq!(count_note_documents(&conn, out.local_note_id), 1);
|
||||
assert_eq!(count_note_dirty_sources(&conn, out.local_note_id), 1);
|
||||
|
||||
// The sweep function already runs inside a transaction (called from
|
||||
// ingest_discussions_for_issue's tx). Simulate by wrapping in a transaction.
|
||||
let tx = conn.unchecked_transaction().unwrap();
|
||||
sweep_stale_issue_notes(&tx, disc_id, 6000).unwrap();
|
||||
tx.commit().unwrap();
|
||||
|
||||
// All three DELETEs must have happened
|
||||
assert_eq!(count_note_documents(&conn, out.local_note_id), 0);
|
||||
assert_eq!(count_note_dirty_sources(&conn, out.local_note_id), 0);
|
||||
|
||||
let note_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM notes WHERE gitlab_id = ?",
|
||||
[9005_i64],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(note_count, 0);
|
||||
}
|
||||
|
||||
fn count_dirty_notes(conn: &Connection) -> i64 {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM dirty_sources WHERE source_type = 'note'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parent_title_change_marks_notes_dirty() {
|
||||
let conn = setup();
|
||||
let disc_id = get_discussion_id(&conn);
|
||||
|
||||
// Insert two user notes and one system note
|
||||
let note1 = make_note(10001, 1, "User note 1", None, 1000, 2000, false, None);
|
||||
let note2 = make_note(10002, 1, "User note 2", None, 1000, 2000, false, None);
|
||||
let mut sys_note = make_note(10003, 1, "System note", None, 1000, 2000, false, None);
|
||||
sys_note.is_system = true;
|
||||
|
||||
let out1 = upsert_note_for_issue(&conn, disc_id, ¬e1, 5000, None).unwrap();
|
||||
let out2 = upsert_note_for_issue(&conn, disc_id, ¬e2, 5000, None).unwrap();
|
||||
upsert_note_for_issue(&conn, disc_id, &sys_note, 5000, None).unwrap();
|
||||
|
||||
// Clear any dirty_sources from individual note upserts
|
||||
conn.execute("DELETE FROM dirty_sources WHERE source_type = 'note'", [])
|
||||
.unwrap();
|
||||
assert_eq!(count_dirty_notes(&conn), 0);
|
||||
|
||||
// Simulate parent title change triggering discussion re-ingest:
|
||||
// update the issue title, then run the propagation SQL
|
||||
conn.execute("UPDATE issues SET title = 'Changed Title' WHERE id = 1", [])
|
||||
.unwrap();
|
||||
|
||||
// Run the propagation query (same as in ingestion code)
|
||||
conn.execute(
|
||||
"INSERT INTO dirty_sources (source_type, source_id, queued_at)
|
||||
SELECT 'note', n.id, ?1
|
||||
FROM notes n
|
||||
WHERE n.discussion_id = ?2 AND n.is_system = 0
|
||||
ON CONFLICT(source_type, source_id) DO UPDATE SET queued_at = excluded.queued_at, attempt_count = 0",
|
||||
params![now_ms(), disc_id],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Both user notes should be dirty, system note should not
|
||||
assert_eq!(count_dirty_notes(&conn), 2);
|
||||
assert_eq!(count_note_dirty_sources(&conn, out1.local_note_id), 1);
|
||||
assert_eq!(count_note_dirty_sources(&conn, out2.local_note_id), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parent_label_change_marks_notes_dirty() {
|
||||
let conn = setup();
|
||||
let disc_id = get_discussion_id(&conn);
|
||||
|
||||
// Insert one user note
|
||||
let note = make_note(11001, 1, "User note", None, 1000, 2000, false, None);
|
||||
let out = upsert_note_for_issue(&conn, disc_id, ¬e, 5000, None).unwrap();
|
||||
|
||||
// Clear dirty_sources
|
||||
conn.execute("DELETE FROM dirty_sources WHERE source_type = 'note'", [])
|
||||
.unwrap();
|
||||
|
||||
// Simulate label change on parent issue (labels are part of issue metadata)
|
||||
conn.execute("UPDATE issues SET updated_at = 9999 WHERE id = 1", [])
|
||||
.unwrap();
|
||||
|
||||
// Run propagation query
|
||||
conn.execute(
|
||||
"INSERT INTO dirty_sources (source_type, source_id, queued_at)
|
||||
SELECT 'note', n.id, ?1
|
||||
FROM notes n
|
||||
WHERE n.discussion_id = ?2 AND n.is_system = 0
|
||||
ON CONFLICT(source_type, source_id) DO UPDATE SET queued_at = excluded.queued_at, attempt_count = 0",
|
||||
params![now_ms(), disc_id],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(count_dirty_notes(&conn), 1);
|
||||
assert_eq!(count_note_dirty_sources(&conn, out.local_note_id), 1);
|
||||
}
|
||||
}
|
||||
#[path = "discussions_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
470
src/ingestion/discussions_tests.rs
Normal file
470
src/ingestion/discussions_tests.rs
Normal file
@@ -0,0 +1,470 @@
|
||||
use super::*;
|
||||
use crate::core::db::{create_connection, run_migrations};
|
||||
use crate::gitlab::transformers::NormalizedNote;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn result_default_has_zero_counts() {
|
||||
let result = IngestDiscussionsResult::default();
|
||||
assert_eq!(result.discussions_fetched, 0);
|
||||
assert_eq!(result.discussions_upserted, 0);
|
||||
assert_eq!(result.notes_upserted, 0);
|
||||
}
|
||||
|
||||
fn setup() -> Connection {
|
||||
let conn = create_connection(Path::new(":memory:")).unwrap();
|
||||
run_migrations(&conn).unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url) \
|
||||
VALUES (1, 'group/repo', 'https://gitlab.com/group/repo')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO issues (gitlab_id, iid, project_id, title, state, author_username, created_at, updated_at, last_seen_at) \
|
||||
VALUES (100, 1, 1, 'Test Issue', 'opened', 'testuser', 1000, 2000, 3000)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO discussions (gitlab_discussion_id, project_id, issue_id, noteable_type, individual_note, last_seen_at, resolvable, resolved) \
|
||||
VALUES ('disc-1', 1, 1, 'Issue', 0, 3000, 0, 0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn
|
||||
}
|
||||
|
||||
fn get_discussion_id(conn: &Connection) -> i64 {
|
||||
conn.query_row("SELECT id FROM discussions LIMIT 1", [], |row| row.get(0))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn make_note(
|
||||
gitlab_id: i64,
|
||||
project_id: i64,
|
||||
body: &str,
|
||||
note_type: Option<&str>,
|
||||
created_at: i64,
|
||||
updated_at: i64,
|
||||
resolved: bool,
|
||||
resolved_by: Option<&str>,
|
||||
) -> NormalizedNote {
|
||||
NormalizedNote {
|
||||
gitlab_id,
|
||||
project_id,
|
||||
note_type: note_type.map(String::from),
|
||||
is_system: false,
|
||||
author_id: None,
|
||||
author_username: "testuser".to_string(),
|
||||
body: body.to_string(),
|
||||
created_at,
|
||||
updated_at,
|
||||
last_seen_at: updated_at,
|
||||
position: 0,
|
||||
resolvable: false,
|
||||
resolved,
|
||||
resolved_by: resolved_by.map(String::from),
|
||||
resolved_at: None,
|
||||
position_old_path: None,
|
||||
position_new_path: None,
|
||||
position_old_line: None,
|
||||
position_new_line: None,
|
||||
position_type: None,
|
||||
position_line_range_start: None,
|
||||
position_line_range_end: None,
|
||||
position_base_sha: None,
|
||||
position_start_sha: None,
|
||||
position_head_sha: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_note_upsert_stable_id() {
|
||||
let conn = setup();
|
||||
let disc_id = get_discussion_id(&conn);
|
||||
let last_seen_at = 5000;
|
||||
|
||||
let note1 = make_note(1001, 1, "First note", None, 1000, 2000, false, None);
|
||||
let note2 = make_note(1002, 1, "Second note", None, 1000, 2000, false, None);
|
||||
|
||||
let out1 = upsert_note_for_issue(&conn, disc_id, ¬e1, last_seen_at, None).unwrap();
|
||||
let out2 = upsert_note_for_issue(&conn, disc_id, ¬e2, last_seen_at, None).unwrap();
|
||||
let id1 = out1.local_note_id;
|
||||
let id2 = out2.local_note_id;
|
||||
|
||||
// Re-sync same gitlab_ids
|
||||
let out1b = upsert_note_for_issue(&conn, disc_id, ¬e1, last_seen_at + 1, None).unwrap();
|
||||
let out2b = upsert_note_for_issue(&conn, disc_id, ¬e2, last_seen_at + 1, None).unwrap();
|
||||
|
||||
assert_eq!(id1, out1b.local_note_id);
|
||||
assert_eq!(id2, out2b.local_note_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_note_upsert_detects_body_change() {
|
||||
let conn = setup();
|
||||
let disc_id = get_discussion_id(&conn);
|
||||
|
||||
let note = make_note(2001, 1, "Original body", None, 1000, 2000, false, None);
|
||||
upsert_note_for_issue(&conn, disc_id, ¬e, 5000, None).unwrap();
|
||||
|
||||
let mut changed = make_note(2001, 1, "Updated body", None, 1000, 3000, false, None);
|
||||
changed.updated_at = 3000;
|
||||
let outcome = upsert_note_for_issue(&conn, disc_id, &changed, 5001, None).unwrap();
|
||||
assert!(outcome.changed_semantics);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_note_upsert_unchanged_returns_false() {
|
||||
let conn = setup();
|
||||
let disc_id = get_discussion_id(&conn);
|
||||
|
||||
let note = make_note(3001, 1, "Same body", None, 1000, 2000, false, None);
|
||||
upsert_note_for_issue(&conn, disc_id, ¬e, 5000, None).unwrap();
|
||||
|
||||
// Re-sync identical note
|
||||
let outcome = upsert_note_for_issue(&conn, disc_id, ¬e, 5001, None).unwrap();
|
||||
assert!(!outcome.changed_semantics);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_note_upsert_updated_at_only_does_not_mark_semantic_change() {
|
||||
let conn = setup();
|
||||
let disc_id = get_discussion_id(&conn);
|
||||
|
||||
let note = make_note(4001, 1, "Body stays", None, 1000, 2000, false, None);
|
||||
upsert_note_for_issue(&conn, disc_id, ¬e, 5000, None).unwrap();
|
||||
|
||||
// Only change updated_at (non-semantic field)
|
||||
let mut same = make_note(4001, 1, "Body stays", None, 1000, 9999, false, None);
|
||||
same.updated_at = 9999;
|
||||
let outcome = upsert_note_for_issue(&conn, disc_id, &same, 5001, None).unwrap();
|
||||
assert!(!outcome.changed_semantics);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_note_sweep_removes_stale() {
|
||||
let conn = setup();
|
||||
let disc_id = get_discussion_id(&conn);
|
||||
|
||||
let note1 = make_note(5001, 1, "Keep me", None, 1000, 2000, false, None);
|
||||
let note2 = make_note(5002, 1, "Stale me", None, 1000, 2000, false, None);
|
||||
|
||||
upsert_note_for_issue(&conn, disc_id, ¬e1, 5000, None).unwrap();
|
||||
upsert_note_for_issue(&conn, disc_id, ¬e2, 5000, None).unwrap();
|
||||
|
||||
// Re-sync only note1 with newer timestamp
|
||||
upsert_note_for_issue(&conn, disc_id, ¬e1, 6000, None).unwrap();
|
||||
|
||||
// Sweep should remove note2 (last_seen_at=5000 < 6000)
|
||||
let swept = sweep_stale_issue_notes(&conn, disc_id, 6000).unwrap();
|
||||
assert_eq!(swept, 1);
|
||||
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM notes WHERE discussion_id = ?",
|
||||
[disc_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_note_upsert_returns_local_id() {
|
||||
let conn = setup();
|
||||
let disc_id = get_discussion_id(&conn);
|
||||
|
||||
let note = make_note(6001, 1, "Check my ID", None, 1000, 2000, false, None);
|
||||
let outcome = upsert_note_for_issue(&conn, disc_id, ¬e, 5000, None).unwrap();
|
||||
|
||||
// Verify the local_note_id matches what's in the DB
|
||||
let db_id: i64 = conn
|
||||
.query_row(
|
||||
"SELECT id FROM notes WHERE gitlab_id = ?",
|
||||
[6001_i64],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(outcome.local_note_id, db_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_note_upsert_captures_author_id() {
|
||||
let conn = setup();
|
||||
let disc_id = get_discussion_id(&conn);
|
||||
|
||||
let mut note = make_note(7001, 1, "With author", None, 1000, 2000, false, None);
|
||||
note.author_id = Some(12345);
|
||||
|
||||
upsert_note_for_issue(&conn, disc_id, ¬e, 5000, None).unwrap();
|
||||
|
||||
let stored: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT author_id FROM notes WHERE gitlab_id = ?",
|
||||
[7001_i64],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(stored, Some(12345));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_note_upsert_author_id_nullable() {
|
||||
let conn = setup();
|
||||
let disc_id = get_discussion_id(&conn);
|
||||
|
||||
let note = make_note(7002, 1, "No author id", None, 1000, 2000, false, None);
|
||||
// author_id defaults to None in make_note
|
||||
|
||||
upsert_note_for_issue(&conn, disc_id, ¬e, 5000, None).unwrap();
|
||||
|
||||
let stored: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT author_id FROM notes WHERE gitlab_id = ?",
|
||||
[7002_i64],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(stored, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_note_author_id_survives_username_change() {
|
||||
let conn = setup();
|
||||
let disc_id = get_discussion_id(&conn);
|
||||
|
||||
let mut note = make_note(7003, 1, "Original body", None, 1000, 2000, false, None);
|
||||
note.author_id = Some(99999);
|
||||
note.author_username = "oldname".to_string();
|
||||
|
||||
upsert_note_for_issue(&conn, disc_id, ¬e, 5000, None).unwrap();
|
||||
|
||||
// Re-sync with changed username, changed body, same author_id
|
||||
let mut updated = make_note(7003, 1, "Updated body", None, 1000, 3000, false, None);
|
||||
updated.author_id = Some(99999);
|
||||
updated.author_username = "newname".to_string();
|
||||
|
||||
upsert_note_for_issue(&conn, disc_id, &updated, 5001, None).unwrap();
|
||||
|
||||
// author_id must survive the re-sync intact
|
||||
let stored_id: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT author_id FROM notes WHERE gitlab_id = ?",
|
||||
[7003_i64],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(stored_id, Some(99999));
|
||||
}
|
||||
|
||||
fn insert_note_document(conn: &Connection, note_local_id: i64) {
|
||||
conn.execute(
|
||||
"INSERT INTO documents (source_type, source_id, project_id, content_text, content_hash) \
|
||||
VALUES ('note', ?1, 1, 'note content', 'hash123')",
|
||||
[note_local_id],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn insert_note_dirty_source(conn: &Connection, note_local_id: i64) {
|
||||
conn.execute(
|
||||
"INSERT INTO dirty_sources (source_type, source_id, queued_at) \
|
||||
VALUES ('note', ?1, 1000)",
|
||||
[note_local_id],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn count_note_documents(conn: &Connection, note_local_id: i64) -> i64 {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM documents WHERE source_type = 'note' AND source_id = ?",
|
||||
[note_local_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn count_note_dirty_sources(conn: &Connection, note_local_id: i64) -> i64 {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM dirty_sources WHERE source_type = 'note' AND source_id = ?",
|
||||
[note_local_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_note_sweep_deletes_note_documents_immediately() {
|
||||
let conn = setup();
|
||||
let disc_id = get_discussion_id(&conn);
|
||||
|
||||
// Insert 3 notes
|
||||
let note1 = make_note(9001, 1, "Keep me", None, 1000, 2000, false, None);
|
||||
let note2 = make_note(9002, 1, "Keep me too", None, 1000, 2000, false, None);
|
||||
let note3 = make_note(9003, 1, "Stale me", None, 1000, 2000, false, None);
|
||||
|
||||
let out1 = upsert_note_for_issue(&conn, disc_id, ¬e1, 5000, None).unwrap();
|
||||
let out2 = upsert_note_for_issue(&conn, disc_id, ¬e2, 5000, None).unwrap();
|
||||
let out3 = upsert_note_for_issue(&conn, disc_id, ¬e3, 5000, None).unwrap();
|
||||
|
||||
// Add documents for all 3
|
||||
insert_note_document(&conn, out1.local_note_id);
|
||||
insert_note_document(&conn, out2.local_note_id);
|
||||
insert_note_document(&conn, out3.local_note_id);
|
||||
|
||||
// Add dirty_sources for note3
|
||||
insert_note_dirty_source(&conn, out3.local_note_id);
|
||||
|
||||
// Re-sync only notes 1 and 2 with newer timestamp
|
||||
upsert_note_for_issue(&conn, disc_id, ¬e1, 6000, None).unwrap();
|
||||
upsert_note_for_issue(&conn, disc_id, ¬e2, 6000, None).unwrap();
|
||||
|
||||
// Sweep should remove note3 and its document + dirty_source
|
||||
sweep_stale_issue_notes(&conn, disc_id, 6000).unwrap();
|
||||
|
||||
// Stale note's document should be gone
|
||||
assert_eq!(count_note_documents(&conn, out3.local_note_id), 0);
|
||||
assert_eq!(count_note_dirty_sources(&conn, out3.local_note_id), 0);
|
||||
|
||||
// Kept notes' documents should survive
|
||||
assert_eq!(count_note_documents(&conn, out1.local_note_id), 1);
|
||||
assert_eq!(count_note_documents(&conn, out2.local_note_id), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sweep_deletion_handles_note_without_document() {
|
||||
let conn = setup();
|
||||
let disc_id = get_discussion_id(&conn);
|
||||
|
||||
let note = make_note(9004, 1, "No doc", None, 1000, 2000, false, None);
|
||||
upsert_note_for_issue(&conn, disc_id, ¬e, 5000, None).unwrap();
|
||||
|
||||
// Don't insert any document -- sweep should still work without error
|
||||
let swept = sweep_stale_issue_notes(&conn, disc_id, 6000).unwrap();
|
||||
assert_eq!(swept, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_based_deletion_atomicity() {
|
||||
let conn = setup();
|
||||
let disc_id = get_discussion_id(&conn);
|
||||
|
||||
// Insert a stale note with both document and dirty_source
|
||||
let note = make_note(9005, 1, "Stale with deps", None, 1000, 2000, false, None);
|
||||
let out = upsert_note_for_issue(&conn, disc_id, ¬e, 5000, None).unwrap();
|
||||
insert_note_document(&conn, out.local_note_id);
|
||||
insert_note_dirty_source(&conn, out.local_note_id);
|
||||
|
||||
// Verify they exist before sweep
|
||||
assert_eq!(count_note_documents(&conn, out.local_note_id), 1);
|
||||
assert_eq!(count_note_dirty_sources(&conn, out.local_note_id), 1);
|
||||
|
||||
// The sweep function already runs inside a transaction (called from
|
||||
// ingest_discussions_for_issue's tx). Simulate by wrapping in a transaction.
|
||||
let tx = conn.unchecked_transaction().unwrap();
|
||||
sweep_stale_issue_notes(&tx, disc_id, 6000).unwrap();
|
||||
tx.commit().unwrap();
|
||||
|
||||
// All three DELETEs must have happened
|
||||
assert_eq!(count_note_documents(&conn, out.local_note_id), 0);
|
||||
assert_eq!(count_note_dirty_sources(&conn, out.local_note_id), 0);
|
||||
|
||||
let note_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM notes WHERE gitlab_id = ?",
|
||||
[9005_i64],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(note_count, 0);
|
||||
}
|
||||
|
||||
fn count_dirty_notes(conn: &Connection) -> i64 {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM dirty_sources WHERE source_type = 'note'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parent_title_change_marks_notes_dirty() {
|
||||
let conn = setup();
|
||||
let disc_id = get_discussion_id(&conn);
|
||||
|
||||
// Insert two user notes and one system note
|
||||
let note1 = make_note(10001, 1, "User note 1", None, 1000, 2000, false, None);
|
||||
let note2 = make_note(10002, 1, "User note 2", None, 1000, 2000, false, None);
|
||||
let mut sys_note = make_note(10003, 1, "System note", None, 1000, 2000, false, None);
|
||||
sys_note.is_system = true;
|
||||
|
||||
let out1 = upsert_note_for_issue(&conn, disc_id, ¬e1, 5000, None).unwrap();
|
||||
let out2 = upsert_note_for_issue(&conn, disc_id, ¬e2, 5000, None).unwrap();
|
||||
upsert_note_for_issue(&conn, disc_id, &sys_note, 5000, None).unwrap();
|
||||
|
||||
// Clear any dirty_sources from individual note upserts
|
||||
conn.execute("DELETE FROM dirty_sources WHERE source_type = 'note'", [])
|
||||
.unwrap();
|
||||
assert_eq!(count_dirty_notes(&conn), 0);
|
||||
|
||||
// Simulate parent title change triggering discussion re-ingest:
|
||||
// update the issue title, then run the propagation SQL
|
||||
conn.execute("UPDATE issues SET title = 'Changed Title' WHERE id = 1", [])
|
||||
.unwrap();
|
||||
|
||||
// Run the propagation query (same as in ingestion code)
|
||||
conn.execute(
|
||||
"INSERT INTO dirty_sources (source_type, source_id, queued_at)
|
||||
SELECT 'note', n.id, ?1
|
||||
FROM notes n
|
||||
WHERE n.discussion_id = ?2 AND n.is_system = 0
|
||||
ON CONFLICT(source_type, source_id) DO UPDATE SET queued_at = excluded.queued_at, attempt_count = 0",
|
||||
params![now_ms(), disc_id],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Both user notes should be dirty, system note should not
|
||||
assert_eq!(count_dirty_notes(&conn), 2);
|
||||
assert_eq!(count_note_dirty_sources(&conn, out1.local_note_id), 1);
|
||||
assert_eq!(count_note_dirty_sources(&conn, out2.local_note_id), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parent_label_change_marks_notes_dirty() {
|
||||
let conn = setup();
|
||||
let disc_id = get_discussion_id(&conn);
|
||||
|
||||
// Insert one user note
|
||||
let note = make_note(11001, 1, "User note", None, 1000, 2000, false, None);
|
||||
let out = upsert_note_for_issue(&conn, disc_id, ¬e, 5000, None).unwrap();
|
||||
|
||||
// Clear dirty_sources
|
||||
conn.execute("DELETE FROM dirty_sources WHERE source_type = 'note'", [])
|
||||
.unwrap();
|
||||
|
||||
// Simulate label change on parent issue (labels are part of issue metadata)
|
||||
conn.execute("UPDATE issues SET updated_at = 9999 WHERE id = 1", [])
|
||||
.unwrap();
|
||||
|
||||
// Run propagation query
|
||||
conn.execute(
|
||||
"INSERT INTO dirty_sources (source_type, source_id, queued_at)
|
||||
SELECT 'note', n.id, ?1
|
||||
FROM notes n
|
||||
WHERE n.discussion_id = ?2 AND n.is_system = 0
|
||||
ON CONFLICT(source_type, source_id) DO UPDATE SET queued_at = excluded.queued_at, attempt_count = 0",
|
||||
params![now_ms(), disc_id],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(count_dirty_notes(&conn), 1);
|
||||
assert_eq!(count_note_dirty_sources(&conn, out.local_note_id), 1);
|
||||
}
|
||||
@@ -138,29 +138,6 @@ fn passes_cursor_filter_with_ts(gitlab_id: i64, issue_ts: i64, cursor: &SyncCurs
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn passes_cursor_filter(issue: &GitLabIssue, cursor: &SyncCursor) -> Result<bool> {
|
||||
let Some(cursor_ts) = cursor.updated_at_cursor else {
|
||||
return Ok(true);
|
||||
};
|
||||
|
||||
let issue_ts = parse_timestamp(&issue.updated_at)?;
|
||||
|
||||
if issue_ts < cursor_ts {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if issue_ts == cursor_ts
|
||||
&& cursor
|
||||
.tie_breaker_id
|
||||
.is_some_and(|cursor_id| issue.id <= cursor_id)
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn process_single_issue(
|
||||
conn: &Connection,
|
||||
config: &Config,
|
||||
@@ -423,78 +400,5 @@ fn parse_timestamp(ts: &str) -> Result<i64> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::gitlab::types::GitLabAuthor;
|
||||
|
||||
fn make_test_issue(id: i64, updated_at: &str) -> GitLabIssue {
|
||||
GitLabIssue {
|
||||
id,
|
||||
iid: id,
|
||||
project_id: 100,
|
||||
title: format!("Issue {}", id),
|
||||
description: None,
|
||||
state: "opened".to_string(),
|
||||
created_at: "2024-01-01T00:00:00.000Z".to_string(),
|
||||
updated_at: updated_at.to_string(),
|
||||
closed_at: None,
|
||||
author: GitLabAuthor {
|
||||
id: 1,
|
||||
username: "test".to_string(),
|
||||
name: "Test".to_string(),
|
||||
},
|
||||
assignees: vec![],
|
||||
labels: vec![],
|
||||
milestone: None,
|
||||
due_date: None,
|
||||
web_url: "https://example.com".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_filter_allows_newer_issues() {
|
||||
let cursor = SyncCursor {
|
||||
updated_at_cursor: Some(1705312800000),
|
||||
tie_breaker_id: Some(100),
|
||||
};
|
||||
|
||||
let issue = make_test_issue(101, "2024-01-16T10:00:00.000Z");
|
||||
assert!(passes_cursor_filter(&issue, &cursor).unwrap_or(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_filter_blocks_older_issues() {
|
||||
let cursor = SyncCursor {
|
||||
updated_at_cursor: Some(1705312800000),
|
||||
tie_breaker_id: Some(100),
|
||||
};
|
||||
|
||||
let issue = make_test_issue(99, "2024-01-14T10:00:00.000Z");
|
||||
assert!(!passes_cursor_filter(&issue, &cursor).unwrap_or(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_filter_uses_tie_breaker_for_same_timestamp() {
|
||||
let cursor = SyncCursor {
|
||||
updated_at_cursor: Some(1705312800000),
|
||||
tie_breaker_id: Some(100),
|
||||
};
|
||||
|
||||
let issue1 = make_test_issue(101, "2024-01-15T10:00:00.000Z");
|
||||
assert!(passes_cursor_filter(&issue1, &cursor).unwrap_or(false));
|
||||
|
||||
let issue2 = make_test_issue(100, "2024-01-15T10:00:00.000Z");
|
||||
assert!(!passes_cursor_filter(&issue2, &cursor).unwrap_or(true));
|
||||
|
||||
let issue3 = make_test_issue(99, "2024-01-15T10:00:00.000Z");
|
||||
assert!(!passes_cursor_filter(&issue3, &cursor).unwrap_or(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_filter_allows_all_when_no_cursor() {
|
||||
let cursor = SyncCursor::default();
|
||||
|
||||
let issue = make_test_issue(1, "2020-01-01T00:00:00.000Z");
|
||||
assert!(passes_cursor_filter(&issue, &cursor).unwrap_or(false));
|
||||
}
|
||||
}
|
||||
#[path = "issues_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
95
src/ingestion/issues_tests.rs
Normal file
95
src/ingestion/issues_tests.rs
Normal file
@@ -0,0 +1,95 @@
|
||||
use super::*;
|
||||
use crate::gitlab::types::GitLabAuthor;
|
||||
|
||||
fn passes_cursor_filter(issue: &GitLabIssue, cursor: &SyncCursor) -> Result<bool> {
|
||||
let Some(cursor_ts) = cursor.updated_at_cursor else {
|
||||
return Ok(true);
|
||||
};
|
||||
|
||||
let issue_ts = parse_timestamp(&issue.updated_at)?;
|
||||
|
||||
if issue_ts < cursor_ts {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if issue_ts == cursor_ts
|
||||
&& cursor
|
||||
.tie_breaker_id
|
||||
.is_some_and(|cursor_id| issue.id <= cursor_id)
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn make_test_issue(id: i64, updated_at: &str) -> GitLabIssue {
|
||||
GitLabIssue {
|
||||
id,
|
||||
iid: id,
|
||||
project_id: 100,
|
||||
title: format!("Issue {}", id),
|
||||
description: None,
|
||||
state: "opened".to_string(),
|
||||
created_at: "2024-01-01T00:00:00.000Z".to_string(),
|
||||
updated_at: updated_at.to_string(),
|
||||
closed_at: None,
|
||||
author: GitLabAuthor {
|
||||
id: 1,
|
||||
username: "test".to_string(),
|
||||
name: "Test".to_string(),
|
||||
},
|
||||
assignees: vec![],
|
||||
labels: vec![],
|
||||
milestone: None,
|
||||
due_date: None,
|
||||
web_url: "https://example.com".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_filter_allows_newer_issues() {
|
||||
let cursor = SyncCursor {
|
||||
updated_at_cursor: Some(1705312800000),
|
||||
tie_breaker_id: Some(100),
|
||||
};
|
||||
|
||||
let issue = make_test_issue(101, "2024-01-16T10:00:00.000Z");
|
||||
assert!(passes_cursor_filter(&issue, &cursor).unwrap_or(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_filter_blocks_older_issues() {
|
||||
let cursor = SyncCursor {
|
||||
updated_at_cursor: Some(1705312800000),
|
||||
tie_breaker_id: Some(100),
|
||||
};
|
||||
|
||||
let issue = make_test_issue(99, "2024-01-14T10:00:00.000Z");
|
||||
assert!(!passes_cursor_filter(&issue, &cursor).unwrap_or(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_filter_uses_tie_breaker_for_same_timestamp() {
|
||||
let cursor = SyncCursor {
|
||||
updated_at_cursor: Some(1705312800000),
|
||||
tie_breaker_id: Some(100),
|
||||
};
|
||||
|
||||
let issue1 = make_test_issue(101, "2024-01-15T10:00:00.000Z");
|
||||
assert!(passes_cursor_filter(&issue1, &cursor).unwrap_or(false));
|
||||
|
||||
let issue2 = make_test_issue(100, "2024-01-15T10:00:00.000Z");
|
||||
assert!(!passes_cursor_filter(&issue2, &cursor).unwrap_or(true));
|
||||
|
||||
let issue3 = make_test_issue(99, "2024-01-15T10:00:00.000Z");
|
||||
assert!(!passes_cursor_filter(&issue3, &cursor).unwrap_or(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_filter_allows_all_when_no_cursor() {
|
||||
let cursor = SyncCursor::default();
|
||||
|
||||
let issue = make_test_issue(1, "2020-01-01T00:00:00.000Z");
|
||||
assert!(passes_cursor_filter(&issue, &cursor).unwrap_or(false));
|
||||
}
|
||||
@@ -66,207 +66,5 @@ pub fn upsert_mr_file_changes(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::core::db::{create_connection, run_migrations};
|
||||
use std::path::Path;
|
||||
|
||||
fn setup() -> Connection {
|
||||
let conn = create_connection(Path::new(":memory:")).unwrap();
|
||||
run_migrations(&conn).unwrap();
|
||||
|
||||
// Insert a test project
|
||||
conn.execute(
|
||||
"INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url) VALUES (1, 'group/repo', 'https://gitlab.com/group/repo')",
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
// Insert a test MR
|
||||
conn.execute(
|
||||
"INSERT INTO merge_requests (gitlab_id, iid, project_id, title, state, draft, source_branch, target_branch, author_username, created_at, updated_at, last_seen_at) \
|
||||
VALUES (100, 1, 1, 'Test MR', 'merged', 0, 'feature', 'main', 'testuser', 1000, 2000, 3000)",
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derive_change_type_added() {
|
||||
let diff = GitLabMrDiff {
|
||||
old_path: String::new(),
|
||||
new_path: "src/new.rs".to_string(),
|
||||
new_file: true,
|
||||
renamed_file: false,
|
||||
deleted_file: false,
|
||||
};
|
||||
assert_eq!(derive_change_type(&diff), "added");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derive_change_type_renamed() {
|
||||
let diff = GitLabMrDiff {
|
||||
old_path: "src/old.rs".to_string(),
|
||||
new_path: "src/new.rs".to_string(),
|
||||
new_file: false,
|
||||
renamed_file: true,
|
||||
deleted_file: false,
|
||||
};
|
||||
assert_eq!(derive_change_type(&diff), "renamed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derive_change_type_deleted() {
|
||||
let diff = GitLabMrDiff {
|
||||
old_path: "src/gone.rs".to_string(),
|
||||
new_path: "src/gone.rs".to_string(),
|
||||
new_file: false,
|
||||
renamed_file: false,
|
||||
deleted_file: true,
|
||||
};
|
||||
assert_eq!(derive_change_type(&diff), "deleted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derive_change_type_modified() {
|
||||
let diff = GitLabMrDiff {
|
||||
old_path: "src/lib.rs".to_string(),
|
||||
new_path: "src/lib.rs".to_string(),
|
||||
new_file: false,
|
||||
renamed_file: false,
|
||||
deleted_file: false,
|
||||
};
|
||||
assert_eq!(derive_change_type(&diff), "modified");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_inserts_file_changes() {
|
||||
let conn = setup();
|
||||
let diffs = [
|
||||
GitLabMrDiff {
|
||||
old_path: String::new(),
|
||||
new_path: "src/new.rs".to_string(),
|
||||
new_file: true,
|
||||
renamed_file: false,
|
||||
deleted_file: false,
|
||||
},
|
||||
GitLabMrDiff {
|
||||
old_path: "src/lib.rs".to_string(),
|
||||
new_path: "src/lib.rs".to_string(),
|
||||
new_file: false,
|
||||
renamed_file: false,
|
||||
deleted_file: false,
|
||||
},
|
||||
];
|
||||
|
||||
let inserted = upsert_mr_file_changes(&conn, 1, 1, &diffs).unwrap();
|
||||
assert_eq!(inserted, 2);
|
||||
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM mr_file_changes WHERE merge_request_id = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_replaces_existing() {
|
||||
let conn = setup();
|
||||
let diffs_v1 = [GitLabMrDiff {
|
||||
old_path: String::new(),
|
||||
new_path: "src/old.rs".to_string(),
|
||||
new_file: true,
|
||||
renamed_file: false,
|
||||
deleted_file: false,
|
||||
}];
|
||||
upsert_mr_file_changes(&conn, 1, 1, &diffs_v1).unwrap();
|
||||
|
||||
let diffs_v2 = [
|
||||
GitLabMrDiff {
|
||||
old_path: "src/a.rs".to_string(),
|
||||
new_path: "src/a.rs".to_string(),
|
||||
new_file: false,
|
||||
renamed_file: false,
|
||||
deleted_file: false,
|
||||
},
|
||||
GitLabMrDiff {
|
||||
old_path: "src/b.rs".to_string(),
|
||||
new_path: "src/b.rs".to_string(),
|
||||
new_file: false,
|
||||
renamed_file: false,
|
||||
deleted_file: false,
|
||||
},
|
||||
];
|
||||
let inserted = upsert_mr_file_changes(&conn, 1, 1, &diffs_v2).unwrap();
|
||||
assert_eq!(inserted, 2);
|
||||
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM mr_file_changes WHERE merge_request_id = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(count, 2);
|
||||
|
||||
// The old "src/old.rs" should be gone
|
||||
let old_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM mr_file_changes WHERE new_path = 'src/old.rs'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(old_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_renamed_stores_old_path() {
|
||||
let conn = setup();
|
||||
let diffs = [GitLabMrDiff {
|
||||
old_path: "src/old_name.rs".to_string(),
|
||||
new_path: "src/new_name.rs".to_string(),
|
||||
new_file: false,
|
||||
renamed_file: true,
|
||||
deleted_file: false,
|
||||
}];
|
||||
|
||||
upsert_mr_file_changes(&conn, 1, 1, &diffs).unwrap();
|
||||
|
||||
let (old_path, change_type): (Option<String>, String) = conn
|
||||
.query_row(
|
||||
"SELECT old_path, change_type FROM mr_file_changes WHERE new_path = 'src/new_name.rs'",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(old_path.as_deref(), Some("src/old_name.rs"));
|
||||
assert_eq!(change_type, "renamed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_renamed_has_null_old_path() {
|
||||
let conn = setup();
|
||||
let diffs = [GitLabMrDiff {
|
||||
old_path: "src/lib.rs".to_string(),
|
||||
new_path: "src/lib.rs".to_string(),
|
||||
new_file: false,
|
||||
renamed_file: false,
|
||||
deleted_file: false,
|
||||
}];
|
||||
|
||||
upsert_mr_file_changes(&conn, 1, 1, &diffs).unwrap();
|
||||
|
||||
let old_path: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT old_path FROM mr_file_changes WHERE new_path = 'src/lib.rs'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(old_path.is_none());
|
||||
}
|
||||
}
|
||||
#[path = "mr_diffs_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
202
src/ingestion/mr_diffs_tests.rs
Normal file
202
src/ingestion/mr_diffs_tests.rs
Normal file
@@ -0,0 +1,202 @@
|
||||
use super::*;
|
||||
use crate::core::db::{create_connection, run_migrations};
|
||||
use std::path::Path;
|
||||
|
||||
fn setup() -> Connection {
|
||||
let conn = create_connection(Path::new(":memory:")).unwrap();
|
||||
run_migrations(&conn).unwrap();
|
||||
|
||||
// Insert a test project
|
||||
conn.execute(
|
||||
"INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url) VALUES (1, 'group/repo', 'https://gitlab.com/group/repo')",
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
// Insert a test MR
|
||||
conn.execute(
|
||||
"INSERT INTO merge_requests (gitlab_id, iid, project_id, title, state, draft, source_branch, target_branch, author_username, created_at, updated_at, last_seen_at) \
|
||||
VALUES (100, 1, 1, 'Test MR', 'merged', 0, 'feature', 'main', 'testuser', 1000, 2000, 3000)",
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derive_change_type_added() {
|
||||
let diff = GitLabMrDiff {
|
||||
old_path: String::new(),
|
||||
new_path: "src/new.rs".to_string(),
|
||||
new_file: true,
|
||||
renamed_file: false,
|
||||
deleted_file: false,
|
||||
};
|
||||
assert_eq!(derive_change_type(&diff), "added");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derive_change_type_renamed() {
|
||||
let diff = GitLabMrDiff {
|
||||
old_path: "src/old.rs".to_string(),
|
||||
new_path: "src/new.rs".to_string(),
|
||||
new_file: false,
|
||||
renamed_file: true,
|
||||
deleted_file: false,
|
||||
};
|
||||
assert_eq!(derive_change_type(&diff), "renamed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derive_change_type_deleted() {
|
||||
let diff = GitLabMrDiff {
|
||||
old_path: "src/gone.rs".to_string(),
|
||||
new_path: "src/gone.rs".to_string(),
|
||||
new_file: false,
|
||||
renamed_file: false,
|
||||
deleted_file: true,
|
||||
};
|
||||
assert_eq!(derive_change_type(&diff), "deleted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derive_change_type_modified() {
|
||||
let diff = GitLabMrDiff {
|
||||
old_path: "src/lib.rs".to_string(),
|
||||
new_path: "src/lib.rs".to_string(),
|
||||
new_file: false,
|
||||
renamed_file: false,
|
||||
deleted_file: false,
|
||||
};
|
||||
assert_eq!(derive_change_type(&diff), "modified");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_inserts_file_changes() {
|
||||
let conn = setup();
|
||||
let diffs = [
|
||||
GitLabMrDiff {
|
||||
old_path: String::new(),
|
||||
new_path: "src/new.rs".to_string(),
|
||||
new_file: true,
|
||||
renamed_file: false,
|
||||
deleted_file: false,
|
||||
},
|
||||
GitLabMrDiff {
|
||||
old_path: "src/lib.rs".to_string(),
|
||||
new_path: "src/lib.rs".to_string(),
|
||||
new_file: false,
|
||||
renamed_file: false,
|
||||
deleted_file: false,
|
||||
},
|
||||
];
|
||||
|
||||
let inserted = upsert_mr_file_changes(&conn, 1, 1, &diffs).unwrap();
|
||||
assert_eq!(inserted, 2);
|
||||
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM mr_file_changes WHERE merge_request_id = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_replaces_existing() {
|
||||
let conn = setup();
|
||||
let diffs_v1 = [GitLabMrDiff {
|
||||
old_path: String::new(),
|
||||
new_path: "src/old.rs".to_string(),
|
||||
new_file: true,
|
||||
renamed_file: false,
|
||||
deleted_file: false,
|
||||
}];
|
||||
upsert_mr_file_changes(&conn, 1, 1, &diffs_v1).unwrap();
|
||||
|
||||
let diffs_v2 = [
|
||||
GitLabMrDiff {
|
||||
old_path: "src/a.rs".to_string(),
|
||||
new_path: "src/a.rs".to_string(),
|
||||
new_file: false,
|
||||
renamed_file: false,
|
||||
deleted_file: false,
|
||||
},
|
||||
GitLabMrDiff {
|
||||
old_path: "src/b.rs".to_string(),
|
||||
new_path: "src/b.rs".to_string(),
|
||||
new_file: false,
|
||||
renamed_file: false,
|
||||
deleted_file: false,
|
||||
},
|
||||
];
|
||||
let inserted = upsert_mr_file_changes(&conn, 1, 1, &diffs_v2).unwrap();
|
||||
assert_eq!(inserted, 2);
|
||||
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM mr_file_changes WHERE merge_request_id = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(count, 2);
|
||||
|
||||
// The old "src/old.rs" should be gone
|
||||
let old_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM mr_file_changes WHERE new_path = 'src/old.rs'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(old_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_renamed_stores_old_path() {
|
||||
let conn = setup();
|
||||
let diffs = [GitLabMrDiff {
|
||||
old_path: "src/old_name.rs".to_string(),
|
||||
new_path: "src/new_name.rs".to_string(),
|
||||
new_file: false,
|
||||
renamed_file: true,
|
||||
deleted_file: false,
|
||||
}];
|
||||
|
||||
upsert_mr_file_changes(&conn, 1, 1, &diffs).unwrap();
|
||||
|
||||
let (old_path, change_type): (Option<String>, String) = conn
|
||||
.query_row(
|
||||
"SELECT old_path, change_type FROM mr_file_changes WHERE new_path = 'src/new_name.rs'",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(old_path.as_deref(), Some("src/old_name.rs"));
|
||||
assert_eq!(change_type, "renamed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_renamed_has_null_old_path() {
|
||||
let conn = setup();
|
||||
let diffs = [GitLabMrDiff {
|
||||
old_path: "src/lib.rs".to_string(),
|
||||
new_path: "src/lib.rs".to_string(),
|
||||
new_file: false,
|
||||
renamed_file: false,
|
||||
deleted_file: false,
|
||||
}];
|
||||
|
||||
upsert_mr_file_changes(&conn, 1, 1, &diffs).unwrap();
|
||||
|
||||
let old_path: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT old_path FROM mr_file_changes WHERE new_path = 'src/lib.rs'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(old_path.is_none());
|
||||
}
|
||||
Reference in New Issue
Block a user