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>>,
|
pub fields: Option<Vec<String>>,
|
||||||
|
|
||||||
/// Show per-MR detail breakdown (expert mode only)
|
/// 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,
|
pub detail: bool,
|
||||||
|
|
||||||
#[arg(long = "no-detail", hide = true, overrides_with = "detail")]
|
#[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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "db_tests.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
|
|
||||||
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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "note_parser_tests.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
|
|
||||||
#[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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "payloads_tests.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "project_tests.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "references_tests.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "sync_run_tests.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "timeline_collect_tests.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "timeline_expand_tests.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "timeline_seed_tests.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "regenerator_tests.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "change_detector_tests.rs"]
|
||||||
use std::path::Path;
|
mod tests;
|
||||||
|
|
||||||
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)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "chunking_tests.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
|
|
||||||
#[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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "graphql_tests.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "issue_tests.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
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()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "dirty_tracker_tests.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "discussions_tests.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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
|
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(
|
fn process_single_issue(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
config: &Config,
|
config: &Config,
|
||||||
@@ -423,78 +400,5 @@ fn parse_timestamp(ts: &str) -> Result<i64> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "issues_tests.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "mr_diffs_tests.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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