test: Remove redundant comments from test files
Applies the same doc comment cleanup to test files: - Removes test module headers (//! lines) - Removes obvious test function comments - Retains comments explaining non-obvious test scenarios Test names should be descriptive enough to convey intent without additional comments. Complex test setup or assertions that need explanation retain their comments. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,3 @@
|
|||||||
//! Tests for DiffNote position extraction in note transformer.
|
|
||||||
|
|
||||||
use lore::gitlab::transformers::discussion::transform_notes_with_diff_position;
|
use lore::gitlab::transformers::discussion::transform_notes_with_diff_position;
|
||||||
use lore::gitlab::types::{
|
use lore::gitlab::types::{
|
||||||
GitLabAuthor, GitLabDiscussion, GitLabLineRange, GitLabLineRangePoint, GitLabNote,
|
GitLabAuthor, GitLabDiscussion, GitLabLineRange, GitLabLineRangePoint, GitLabNote,
|
||||||
@@ -60,8 +58,6 @@ fn make_discussion(notes: Vec<GitLabNote>) -> GitLabDiscussion {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// === DiffNote Position Field Extraction ===
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn extracts_position_paths_from_diffnote() {
|
fn extracts_position_paths_from_diffnote() {
|
||||||
let position = GitLabNotePosition {
|
let position = GitLabNotePosition {
|
||||||
@@ -174,7 +170,7 @@ fn line_range_uses_old_line_fallback_when_new_line_missing() {
|
|||||||
line_code: None,
|
line_code: None,
|
||||||
line_type: Some("old".to_string()),
|
line_type: Some("old".to_string()),
|
||||||
old_line: Some(20),
|
old_line: Some(20),
|
||||||
new_line: None, // missing - should fall back to old_line
|
new_line: None,
|
||||||
},
|
},
|
||||||
end: GitLabLineRangePoint {
|
end: GitLabLineRangePoint {
|
||||||
line_code: None,
|
line_code: None,
|
||||||
@@ -203,8 +199,6 @@ fn line_range_uses_old_line_fallback_when_new_line_missing() {
|
|||||||
assert_eq!(notes[0].position_line_range_end, Some(25));
|
assert_eq!(notes[0].position_line_range_end, Some(25));
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Regular Notes (non-DiffNote) ===
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn regular_note_has_none_for_all_position_fields() {
|
fn regular_note_has_none_for_all_position_fields() {
|
||||||
let note = make_basic_note(1, "2024-01-16T09:00:00.000Z");
|
let note = make_basic_note(1, "2024-01-16T09:00:00.000Z");
|
||||||
@@ -224,8 +218,6 @@ fn regular_note_has_none_for_all_position_fields() {
|
|||||||
assert_eq!(notes[0].position_head_sha, None);
|
assert_eq!(notes[0].position_head_sha, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Strict Timestamp Parsing ===
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn returns_error_for_invalid_created_at_timestamp() {
|
fn returns_error_for_invalid_created_at_timestamp() {
|
||||||
let mut note = make_basic_note(1, "2024-01-16T09:00:00.000Z");
|
let mut note = make_basic_note(1, "2024-01-16T09:00:00.000Z");
|
||||||
@@ -264,8 +256,6 @@ fn returns_error_for_invalid_resolved_at_timestamp() {
|
|||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Mixed Discussion (DiffNote + Regular Notes) ===
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn handles_mixed_diffnote_and_regular_notes() {
|
fn handles_mixed_diffnote_and_regular_notes() {
|
||||||
let position = GitLabNotePosition {
|
let position = GitLabNotePosition {
|
||||||
@@ -286,16 +276,12 @@ fn handles_mixed_diffnote_and_regular_notes() {
|
|||||||
let notes = transform_notes_with_diff_position(&discussion, 100).unwrap();
|
let notes = transform_notes_with_diff_position(&discussion, 100).unwrap();
|
||||||
|
|
||||||
assert_eq!(notes.len(), 2);
|
assert_eq!(notes.len(), 2);
|
||||||
// First note is DiffNote with position
|
|
||||||
assert_eq!(notes[0].position_new_path, Some("file.rs".to_string()));
|
assert_eq!(notes[0].position_new_path, Some("file.rs".to_string()));
|
||||||
assert_eq!(notes[0].position_new_line, Some(42));
|
assert_eq!(notes[0].position_new_line, Some(42));
|
||||||
// Second note is regular with None position fields
|
|
||||||
assert_eq!(notes[1].position_new_path, None);
|
assert_eq!(notes[1].position_new_path, None);
|
||||||
assert_eq!(notes[1].position_new_line, None);
|
assert_eq!(notes[1].position_new_line, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Position Preservation ===
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn preserves_note_position_index() {
|
fn preserves_note_position_index() {
|
||||||
let pos1 = GitLabNotePosition {
|
let pos1 = GitLabNotePosition {
|
||||||
@@ -330,11 +316,8 @@ fn preserves_note_position_index() {
|
|||||||
assert_eq!(notes[1].position, 1);
|
assert_eq!(notes[1].position, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Edge Cases ===
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn handles_diffnote_with_empty_position_fields() {
|
fn handles_diffnote_with_empty_position_fields() {
|
||||||
// DiffNote exists but all position fields are None
|
|
||||||
let position = GitLabNotePosition {
|
let position = GitLabNotePosition {
|
||||||
old_path: None,
|
old_path: None,
|
||||||
new_path: None,
|
new_path: None,
|
||||||
@@ -351,7 +334,6 @@ fn handles_diffnote_with_empty_position_fields() {
|
|||||||
|
|
||||||
let notes = transform_notes_with_diff_position(&discussion, 100).unwrap();
|
let notes = transform_notes_with_diff_position(&discussion, 100).unwrap();
|
||||||
|
|
||||||
// All position fields should be None, not cause an error
|
|
||||||
assert_eq!(notes[0].position_old_path, None);
|
assert_eq!(notes[0].position_old_path, None);
|
||||||
assert_eq!(notes[0].position_new_path, None);
|
assert_eq!(notes[0].position_new_path, None);
|
||||||
}
|
}
|
||||||
@@ -376,6 +358,5 @@ fn handles_file_position_type() {
|
|||||||
|
|
||||||
assert_eq!(notes[0].position_type, Some("file".to_string()));
|
assert_eq!(notes[0].position_type, Some("file".to_string()));
|
||||||
assert_eq!(notes[0].position_new_path, Some("binary.bin".to_string()));
|
assert_eq!(notes[0].position_new_path, Some("binary.bin".to_string()));
|
||||||
// File-level comments have no line numbers
|
|
||||||
assert_eq!(notes[0].position_new_line, None);
|
assert_eq!(notes[0].position_new_line, None);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,8 @@
|
|||||||
//! Integration tests for embedding storage and vector search.
|
|
||||||
//!
|
|
||||||
//! These tests create an in-memory SQLite database with sqlite-vec loaded,
|
|
||||||
//! apply all migrations through 010 (chunk config), and verify KNN search
|
|
||||||
//! and metadata operations.
|
|
||||||
|
|
||||||
use lore::core::db::create_connection;
|
use lore::core::db::create_connection;
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
/// Create a test DB on disk (required for sqlite-vec which needs the extension loaded).
|
|
||||||
/// Uses create_connection to get the sqlite-vec extension registered.
|
|
||||||
fn create_test_db() -> (TempDir, Connection) {
|
fn create_test_db() -> (TempDir, Connection) {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
let db_path = tmp.path().join("test.db");
|
let db_path = tmp.path().join("test.db");
|
||||||
@@ -35,7 +27,6 @@ fn create_test_db() -> (TempDir, Connection) {
|
|||||||
.unwrap_or_else(|e| panic!("Migration {} failed: {}", version, e));
|
.unwrap_or_else(|e| panic!("Migration {} failed: {}", version, e));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seed a project
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace) VALUES (1, 100, 'group/project')",
|
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace) VALUES (1, 100, 'group/project')",
|
||||||
[],
|
[],
|
||||||
@@ -54,7 +45,6 @@ fn insert_document(conn: &Connection, id: i64, title: &str, content: &str) {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a 768-dim vector with a specific dimension set to 1.0 (unit vector along axis).
|
|
||||||
fn axis_vector(dim: usize) -> Vec<f32> {
|
fn axis_vector(dim: usize) -> Vec<f32> {
|
||||||
let mut v = vec![0.0f32; 768];
|
let mut v = vec![0.0f32; 768];
|
||||||
v[dim] = 1.0;
|
v[dim] = 1.0;
|
||||||
@@ -89,12 +79,10 @@ fn knn_search_returns_nearest_neighbors() {
|
|||||||
insert_document(&conn, 2, "Doc B", "Content about database optimization.");
|
insert_document(&conn, 2, "Doc B", "Content about database optimization.");
|
||||||
insert_document(&conn, 3, "Doc C", "Content about logging infrastructure.");
|
insert_document(&conn, 3, "Doc C", "Content about logging infrastructure.");
|
||||||
|
|
||||||
// Doc 1: axis 0, Doc 2: axis 1, Doc 3: axis 2
|
|
||||||
insert_embedding(&conn, 1, 0, &axis_vector(0));
|
insert_embedding(&conn, 1, 0, &axis_vector(0));
|
||||||
insert_embedding(&conn, 2, 0, &axis_vector(1));
|
insert_embedding(&conn, 2, 0, &axis_vector(1));
|
||||||
insert_embedding(&conn, 3, 0, &axis_vector(2));
|
insert_embedding(&conn, 3, 0, &axis_vector(2));
|
||||||
|
|
||||||
// Query vector close to axis 0 (should match doc 1)
|
|
||||||
let mut query = vec![0.0f32; 768];
|
let mut query = vec![0.0f32; 768];
|
||||||
query[0] = 0.9;
|
query[0] = 0.9;
|
||||||
query[1] = 0.1;
|
query[1] = 0.1;
|
||||||
@@ -132,7 +120,6 @@ fn knn_search_deduplicates_chunks() {
|
|||||||
"Very long content that was chunked.",
|
"Very long content that was chunked.",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Same document, two chunks, both similar to query
|
|
||||||
let mut v1 = vec![0.0f32; 768];
|
let mut v1 = vec![0.0f32; 768];
|
||||||
v1[0] = 1.0;
|
v1[0] = 1.0;
|
||||||
let mut v2 = vec![0.0f32; 768];
|
let mut v2 = vec![0.0f32; 768];
|
||||||
@@ -144,7 +131,6 @@ fn knn_search_deduplicates_chunks() {
|
|||||||
|
|
||||||
let results = lore::search::search_vector(&conn, &axis_vector(0), 10).unwrap();
|
let results = lore::search::search_vector(&conn, &axis_vector(0), 10).unwrap();
|
||||||
|
|
||||||
// Should deduplicate: same document_id appears at most once
|
|
||||||
let unique_docs: std::collections::HashSet<i64> =
|
let unique_docs: std::collections::HashSet<i64> =
|
||||||
results.iter().map(|r| r.document_id).collect();
|
results.iter().map(|r| r.document_id).collect();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -161,7 +147,6 @@ fn orphan_trigger_deletes_embeddings_on_document_delete() {
|
|||||||
insert_document(&conn, 1, "Will be deleted", "Content.");
|
insert_document(&conn, 1, "Will be deleted", "Content.");
|
||||||
insert_embedding(&conn, 1, 0, &axis_vector(0));
|
insert_embedding(&conn, 1, 0, &axis_vector(0));
|
||||||
|
|
||||||
// Verify embedding exists
|
|
||||||
let count: i64 = conn
|
let count: i64 = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT COUNT(*) FROM embeddings WHERE rowid = 1000",
|
"SELECT COUNT(*) FROM embeddings WHERE rowid = 1000",
|
||||||
@@ -171,11 +156,9 @@ fn orphan_trigger_deletes_embeddings_on_document_delete() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(count, 1, "Embedding should exist before delete");
|
assert_eq!(count, 1, "Embedding should exist before delete");
|
||||||
|
|
||||||
// Delete the document
|
|
||||||
conn.execute("DELETE FROM documents WHERE id = 1", [])
|
conn.execute("DELETE FROM documents WHERE id = 1", [])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Verify embedding was cascade-deleted via trigger
|
|
||||||
let count: i64 = conn
|
let count: i64 = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT COUNT(*) FROM embeddings WHERE rowid = 1000",
|
"SELECT COUNT(*) FROM embeddings WHERE rowid = 1000",
|
||||||
@@ -188,7 +171,6 @@ fn orphan_trigger_deletes_embeddings_on_document_delete() {
|
|||||||
"Trigger should delete embeddings when document is deleted"
|
"Trigger should delete embeddings when document is deleted"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Verify metadata was cascade-deleted via FK
|
|
||||||
let meta_count: i64 = conn
|
let meta_count: i64 = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT COUNT(*) FROM embedding_metadata WHERE document_id = 1",
|
"SELECT COUNT(*) FROM embedding_metadata WHERE document_id = 1",
|
||||||
@@ -207,19 +189,12 @@ fn empty_database_returns_no_results() {
|
|||||||
assert!(results.is_empty(), "Empty DB should return no results");
|
assert!(results.is_empty(), "Empty DB should return no results");
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Bug-fix regression tests ---
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn overflow_doc_with_error_sentinel_not_re_detected_as_pending() {
|
fn overflow_doc_with_error_sentinel_not_re_detected_as_pending() {
|
||||||
// Bug 2: Documents skipped for chunk overflow must record a sentinel error
|
|
||||||
// in embedding_metadata so they are not re-detected as pending on subsequent
|
|
||||||
// pipeline runs (which would cause an infinite re-processing loop).
|
|
||||||
let (_tmp, conn) = create_test_db();
|
let (_tmp, conn) = create_test_db();
|
||||||
|
|
||||||
insert_document(&conn, 1, "Overflow doc", "Some content");
|
insert_document(&conn, 1, "Overflow doc", "Some content");
|
||||||
|
|
||||||
// Simulate what the pipeline does when a document exceeds CHUNK_ROWID_MULTIPLIER:
|
|
||||||
// it records an error sentinel at chunk_index=0.
|
|
||||||
let now = chrono::Utc::now().timestamp_millis();
|
let now = chrono::Utc::now().timestamp_millis();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO embedding_metadata
|
"INSERT INTO embedding_metadata
|
||||||
@@ -230,7 +205,6 @@ fn overflow_doc_with_error_sentinel_not_re_detected_as_pending() {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Now find_pending_documents should NOT return this document
|
|
||||||
let pending =
|
let pending =
|
||||||
lore::embedding::find_pending_documents(&conn, 100, 0, "nomic-embed-text").unwrap();
|
lore::embedding::find_pending_documents(&conn, 100, 0, "nomic-embed-text").unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
@@ -239,7 +213,6 @@ fn overflow_doc_with_error_sentinel_not_re_detected_as_pending() {
|
|||||||
pending.len()
|
pending.len()
|
||||||
);
|
);
|
||||||
|
|
||||||
// count_pending_documents should also return 0
|
|
||||||
let count = lore::embedding::count_pending_documents(&conn, "nomic-embed-text").unwrap();
|
let count = lore::embedding::count_pending_documents(&conn, "nomic-embed-text").unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
count, 0,
|
count, 0,
|
||||||
@@ -249,11 +222,8 @@ fn overflow_doc_with_error_sentinel_not_re_detected_as_pending() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn count_and_find_pending_agree() {
|
fn count_and_find_pending_agree() {
|
||||||
// Bug 1: count_pending_documents and find_pending_documents must use
|
|
||||||
// logically equivalent WHERE clauses to produce consistent results.
|
|
||||||
let (_tmp, conn) = create_test_db();
|
let (_tmp, conn) = create_test_db();
|
||||||
|
|
||||||
// Case 1: No documents at all
|
|
||||||
let count = lore::embedding::count_pending_documents(&conn, "nomic-embed-text").unwrap();
|
let count = lore::embedding::count_pending_documents(&conn, "nomic-embed-text").unwrap();
|
||||||
let found =
|
let found =
|
||||||
lore::embedding::find_pending_documents(&conn, 1000, 0, "nomic-embed-text").unwrap();
|
lore::embedding::find_pending_documents(&conn, 1000, 0, "nomic-embed-text").unwrap();
|
||||||
@@ -263,7 +233,6 @@ fn count_and_find_pending_agree() {
|
|||||||
"Empty DB: count and find should agree"
|
"Empty DB: count and find should agree"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Case 2: New document (no metadata)
|
|
||||||
insert_document(&conn, 1, "New doc", "Content");
|
insert_document(&conn, 1, "New doc", "Content");
|
||||||
let count = lore::embedding::count_pending_documents(&conn, "nomic-embed-text").unwrap();
|
let count = lore::embedding::count_pending_documents(&conn, "nomic-embed-text").unwrap();
|
||||||
let found =
|
let found =
|
||||||
@@ -275,7 +244,6 @@ fn count_and_find_pending_agree() {
|
|||||||
);
|
);
|
||||||
assert_eq!(count, 1);
|
assert_eq!(count, 1);
|
||||||
|
|
||||||
// Case 3: Document with matching metadata (not pending)
|
|
||||||
let now = chrono::Utc::now().timestamp_millis();
|
let now = chrono::Utc::now().timestamp_millis();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO embedding_metadata
|
"INSERT INTO embedding_metadata
|
||||||
@@ -295,7 +263,6 @@ fn count_and_find_pending_agree() {
|
|||||||
);
|
);
|
||||||
assert_eq!(count, 0);
|
assert_eq!(count, 0);
|
||||||
|
|
||||||
// Case 4: Config drift (chunk_max_bytes mismatch)
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE embedding_metadata SET chunk_max_bytes = 999 WHERE document_id = 1",
|
"UPDATE embedding_metadata SET chunk_max_bytes = 999 WHERE document_id = 1",
|
||||||
[],
|
[],
|
||||||
@@ -314,14 +281,11 @@ fn count_and_find_pending_agree() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn full_embed_delete_is_atomic() {
|
fn full_embed_delete_is_atomic() {
|
||||||
// Bug 7: The --full flag's two DELETE statements should be atomic.
|
|
||||||
// This test verifies that both tables are cleared together.
|
|
||||||
let (_tmp, conn) = create_test_db();
|
let (_tmp, conn) = create_test_db();
|
||||||
|
|
||||||
insert_document(&conn, 1, "Doc", "Content");
|
insert_document(&conn, 1, "Doc", "Content");
|
||||||
insert_embedding(&conn, 1, 0, &axis_vector(0));
|
insert_embedding(&conn, 1, 0, &axis_vector(0));
|
||||||
|
|
||||||
// Verify data exists
|
|
||||||
let meta_count: i64 = conn
|
let meta_count: i64 = conn
|
||||||
.query_row("SELECT COUNT(*) FROM embedding_metadata", [], |r| r.get(0))
|
.query_row("SELECT COUNT(*) FROM embedding_metadata", [], |r| r.get(0))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -331,7 +295,6 @@ fn full_embed_delete_is_atomic() {
|
|||||||
assert_eq!(meta_count, 1);
|
assert_eq!(meta_count, 1);
|
||||||
assert_eq!(embed_count, 1);
|
assert_eq!(embed_count, 1);
|
||||||
|
|
||||||
// Execute the atomic delete (same as embed.rs --full)
|
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
"BEGIN;
|
"BEGIN;
|
||||||
DELETE FROM embedding_metadata;
|
DELETE FROM embedding_metadata;
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
//! Tests for test fixtures - verifies they deserialize correctly.
|
|
||||||
|
|
||||||
use lore::gitlab::types::{GitLabDiscussion, GitLabIssue};
|
use lore::gitlab::types::{GitLabDiscussion, GitLabIssue};
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -38,14 +36,11 @@ fn fixture_gitlab_issues_page_deserializes() {
|
|||||||
"Need at least 3 issues for pagination tests"
|
"Need at least 3 issues for pagination tests"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check first issue has labels
|
|
||||||
assert!(!issues[0].labels.is_empty());
|
assert!(!issues[0].labels.is_empty());
|
||||||
|
|
||||||
// Check second issue has null description and empty labels
|
|
||||||
assert!(issues[1].description.is_none());
|
assert!(issues[1].description.is_none());
|
||||||
assert!(issues[1].labels.is_empty());
|
assert!(issues[1].labels.is_empty());
|
||||||
|
|
||||||
// Check third issue has multiple labels
|
|
||||||
assert!(issues[2].labels.len() >= 3);
|
assert!(issues[2].labels.len() >= 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +62,6 @@ fn fixture_gitlab_discussions_page_deserializes() {
|
|||||||
"Need multiple discussions for testing"
|
"Need multiple discussions for testing"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check we have both individual_note=true and individual_note=false
|
|
||||||
let has_individual = discussions.iter().any(|d| d.individual_note);
|
let has_individual = discussions.iter().any(|d| d.individual_note);
|
||||||
let has_threaded = discussions.iter().any(|d| !d.individual_note);
|
let has_threaded = discussions.iter().any(|d| !d.individual_note);
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
//! Integration tests for FTS5 search.
|
|
||||||
//!
|
|
||||||
//! These tests create an in-memory SQLite database, apply migrations through 008 (FTS5),
|
|
||||||
//! seed documents, and verify search behavior.
|
|
||||||
|
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
|
|
||||||
fn create_test_db() -> Connection {
|
fn create_test_db() -> Connection {
|
||||||
@@ -28,7 +23,6 @@ fn create_test_db() -> Connection {
|
|||||||
.unwrap_or_else(|e| panic!("Migration {} failed: {}", version, e));
|
.unwrap_or_else(|e| panic!("Migration {} failed: {}", version, e));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seed a project
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace) VALUES (1, 100, 'group/project')",
|
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace) VALUES (1, 100, 'group/project')",
|
||||||
[],
|
[],
|
||||||
@@ -110,7 +104,6 @@ fn fts_stemming_matches() {
|
|||||||
"Deployment configuration for production servers.",
|
"Deployment configuration for production servers.",
|
||||||
);
|
);
|
||||||
|
|
||||||
// "running" should match "runner" and "executing" via porter stemmer
|
|
||||||
let results =
|
let results =
|
||||||
lore::search::search_fts(&conn, "running", 10, lore::search::FtsQueryMode::Safe).unwrap();
|
lore::search::search_fts(&conn, "running", 10, lore::search::FtsQueryMode::Safe).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
@@ -157,11 +150,9 @@ fn fts_special_characters_handled() {
|
|||||||
"The C++ compiler segfaults on template metaprogramming.",
|
"The C++ compiler segfaults on template metaprogramming.",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Special characters should not crash the search
|
|
||||||
let results =
|
let results =
|
||||||
lore::search::search_fts(&conn, "C++ compiler", 10, lore::search::FtsQueryMode::Safe)
|
lore::search::search_fts(&conn, "C++ compiler", 10, lore::search::FtsQueryMode::Safe)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
// Safe mode sanitizes the query — it should still return results or at least not crash
|
|
||||||
assert!(results.len() <= 1);
|
assert!(results.len() <= 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +160,6 @@ fn fts_special_characters_handled() {
|
|||||||
fn fts_result_ordering_by_relevance() {
|
fn fts_result_ordering_by_relevance() {
|
||||||
let conn = create_test_db();
|
let conn = create_test_db();
|
||||||
|
|
||||||
// Doc 1: "authentication" in title and content
|
|
||||||
insert_document(
|
insert_document(
|
||||||
&conn,
|
&conn,
|
||||||
1,
|
1,
|
||||||
@@ -177,7 +167,6 @@ fn fts_result_ordering_by_relevance() {
|
|||||||
"Authentication system redesign",
|
"Authentication system redesign",
|
||||||
"The authentication system needs a complete redesign. Authentication flows are broken.",
|
"The authentication system needs a complete redesign. Authentication flows are broken.",
|
||||||
);
|
);
|
||||||
// Doc 2: "authentication" only in content, once
|
|
||||||
insert_document(
|
insert_document(
|
||||||
&conn,
|
&conn,
|
||||||
2,
|
2,
|
||||||
@@ -185,7 +174,6 @@ fn fts_result_ordering_by_relevance() {
|
|||||||
"Login page update",
|
"Login page update",
|
||||||
"Updated the login page with better authentication error messages.",
|
"Updated the login page with better authentication error messages.",
|
||||||
);
|
);
|
||||||
// Doc 3: unrelated
|
|
||||||
insert_document(
|
insert_document(
|
||||||
&conn,
|
&conn,
|
||||||
3,
|
3,
|
||||||
@@ -203,7 +191,6 @@ fn fts_result_ordering_by_relevance() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert!(results.len() >= 2, "Should match at least 2 documents");
|
assert!(results.len() >= 2, "Should match at least 2 documents");
|
||||||
// Doc 1 should rank higher (more occurrences of the term)
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
results[0].document_id, 1,
|
results[0].document_id, 1,
|
||||||
"Document with more term occurrences should rank first"
|
"Document with more term occurrences should rank first"
|
||||||
@@ -246,7 +233,6 @@ fn fts_snippet_generated() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert!(!results.is_empty());
|
assert!(!results.is_empty());
|
||||||
// Snippet should contain some text (may have FTS5 highlight markers)
|
|
||||||
assert!(
|
assert!(
|
||||||
!results[0].snippet.is_empty(),
|
!results[0].snippet.is_empty(),
|
||||||
"Snippet should be generated"
|
"Snippet should be generated"
|
||||||
@@ -265,7 +251,6 @@ fn fts_triggers_sync_on_insert() {
|
|||||||
"This is test content for FTS trigger verification.",
|
"This is test content for FTS trigger verification.",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Verify FTS table has an entry via direct query
|
|
||||||
let fts_count: i64 = conn
|
let fts_count: i64 = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT COUNT(*) FROM documents_fts WHERE documents_fts MATCH 'test'",
|
"SELECT COUNT(*) FROM documents_fts WHERE documents_fts MATCH 'test'",
|
||||||
@@ -289,7 +274,6 @@ fn fts_triggers_sync_on_delete() {
|
|||||||
"This content will be deleted from the index.",
|
"This content will be deleted from the index.",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Verify it's indexed
|
|
||||||
let before: i64 = conn
|
let before: i64 = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT COUNT(*) FROM documents_fts WHERE documents_fts MATCH 'deletable'",
|
"SELECT COUNT(*) FROM documents_fts WHERE documents_fts MATCH 'deletable'",
|
||||||
@@ -299,11 +283,9 @@ fn fts_triggers_sync_on_delete() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(before, 1);
|
assert_eq!(before, 1);
|
||||||
|
|
||||||
// Delete the document
|
|
||||||
conn.execute("DELETE FROM documents WHERE id = 1", [])
|
conn.execute("DELETE FROM documents WHERE id = 1", [])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Verify it's removed from FTS
|
|
||||||
let after: i64 = conn
|
let after: i64 = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT COUNT(*) FROM documents_fts WHERE documents_fts MATCH 'deletable'",
|
"SELECT COUNT(*) FROM documents_fts WHERE documents_fts MATCH 'deletable'",
|
||||||
@@ -318,7 +300,6 @@ fn fts_triggers_sync_on_delete() {
|
|||||||
fn fts_null_title_handled() {
|
fn fts_null_title_handled() {
|
||||||
let conn = create_test_db();
|
let conn = create_test_db();
|
||||||
|
|
||||||
// Discussion documents have NULL titles
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO documents (id, source_type, source_id, project_id, title, content_text, content_hash, url)
|
"INSERT INTO documents (id, source_type, source_id, project_id, title, content_text, content_hash, url)
|
||||||
VALUES (1, 'discussion', 1, 1, NULL, 'Discussion about API rate limiting strategies.', 'hash1', 'https://example.com/1')",
|
VALUES (1, 'discussion', 1, 1, NULL, 'Discussion about API rate limiting strategies.', 'hash1', 'https://example.com/1')",
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
//! Golden query test suite.
|
|
||||||
//!
|
|
||||||
//! Verifies end-to-end search quality with known-good expected results.
|
|
||||||
//! Uses a seeded SQLite DB with deterministic fixture data and no external
|
|
||||||
//! dependencies (no Ollama, no GitLab).
|
|
||||||
|
|
||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
@@ -12,7 +6,6 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
use lore::search::{FtsQueryMode, SearchFilters, SearchMode, apply_filters, search_fts};
|
use lore::search::{FtsQueryMode, SearchFilters, SearchMode, apply_filters, search_fts};
|
||||||
|
|
||||||
/// A golden query test case.
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct GoldenQuery {
|
struct GoldenQuery {
|
||||||
query: String,
|
query: String,
|
||||||
@@ -42,12 +35,10 @@ fn load_golden_queries() -> Vec<GoldenQuery> {
|
|||||||
.unwrap_or_else(|e| panic!("Failed to parse golden queries: {}", e))
|
.unwrap_or_else(|e| panic!("Failed to parse golden queries: {}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create an in-memory database with FTS5 schema and seed deterministic fixture data.
|
|
||||||
fn create_seeded_db() -> Connection {
|
fn create_seeded_db() -> Connection {
|
||||||
let conn = Connection::open_in_memory().unwrap();
|
let conn = Connection::open_in_memory().unwrap();
|
||||||
conn.pragma_update(None, "foreign_keys", "ON").unwrap();
|
conn.pragma_update(None, "foreign_keys", "ON").unwrap();
|
||||||
|
|
||||||
// Apply migrations 001-008 (FTS5)
|
|
||||||
let migrations_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("migrations");
|
let migrations_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("migrations");
|
||||||
for version in 1..=8 {
|
for version in 1..=8 {
|
||||||
let entries: Vec<_> = std::fs::read_dir(&migrations_dir)
|
let entries: Vec<_> = std::fs::read_dir(&migrations_dir)
|
||||||
@@ -65,7 +56,6 @@ fn create_seeded_db() -> Connection {
|
|||||||
.unwrap_or_else(|e| panic!("Migration {} failed: {}", version, e));
|
.unwrap_or_else(|e| panic!("Migration {} failed: {}", version, e));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seed project
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url)
|
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url)
|
||||||
VALUES (1, 100, 'group/project', 'https://gitlab.example.com/group/project')",
|
VALUES (1, 100, 'group/project', 'https://gitlab.example.com/group/project')",
|
||||||
@@ -73,9 +63,7 @@ fn create_seeded_db() -> Connection {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Seed deterministic documents
|
|
||||||
let documents = vec![
|
let documents = vec![
|
||||||
// id=1: Auth issue (matches: authentication, login, OAuth, JWT, token, refresh)
|
|
||||||
(
|
(
|
||||||
1,
|
1,
|
||||||
"issue",
|
"issue",
|
||||||
@@ -86,7 +74,6 @@ fn create_seeded_db() -> Connection {
|
|||||||
Multiple users reported authentication failures across all OAuth providers.",
|
Multiple users reported authentication failures across all OAuth providers.",
|
||||||
"testuser",
|
"testuser",
|
||||||
),
|
),
|
||||||
// id=2: User profile MR (matches: user, profile, avatar, upload)
|
|
||||||
(
|
(
|
||||||
2,
|
2,
|
||||||
"merge_request",
|
"merge_request",
|
||||||
@@ -96,7 +83,6 @@ fn create_seeded_db() -> Connection {
|
|||||||
responsive design for mobile and desktop viewports.",
|
responsive design for mobile and desktop viewports.",
|
||||||
"developer1",
|
"developer1",
|
||||||
),
|
),
|
||||||
// id=3: Database migration issue (matches: database, migration, PostgreSQL, schema)
|
|
||||||
(
|
(
|
||||||
3,
|
3,
|
||||||
"issue",
|
"issue",
|
||||||
@@ -106,7 +92,6 @@ fn create_seeded_db() -> Connection {
|
|||||||
rewritten to use the new schema modification syntax. All staging environments affected.",
|
rewritten to use the new schema modification syntax. All staging environments affected.",
|
||||||
"dba_admin",
|
"dba_admin",
|
||||||
),
|
),
|
||||||
// id=4: Performance MR (matches: performance, optimization, caching, query)
|
|
||||||
(
|
(
|
||||||
4,
|
4,
|
||||||
"merge_request",
|
"merge_request",
|
||||||
@@ -116,7 +101,6 @@ fn create_seeded_db() -> Connection {
|
|||||||
to 180ms. Added connection pooling and prepared statement caching.",
|
to 180ms. Added connection pooling and prepared statement caching.",
|
||||||
"senior_dev",
|
"senior_dev",
|
||||||
),
|
),
|
||||||
// id=5: API rate limiting discussion (matches: API, rate, limiting, throttle)
|
|
||||||
(
|
(
|
||||||
5,
|
5,
|
||||||
"discussion",
|
"discussion",
|
||||||
@@ -127,7 +111,6 @@ fn create_seeded_db() -> Connection {
|
|||||||
Need to handle burst traffic during peak hours without throttling legitimate users.",
|
Need to handle burst traffic during peak hours without throttling legitimate users.",
|
||||||
"architect",
|
"architect",
|
||||||
),
|
),
|
||||||
// id=6: UI/CSS issue (matches: CSS, styling, frontend, responsive, UI)
|
|
||||||
(
|
(
|
||||||
6,
|
6,
|
||||||
"issue",
|
"issue",
|
||||||
@@ -138,7 +121,6 @@ fn create_seeded_db() -> Connection {
|
|||||||
conflicting CSS specificity with the theme system.",
|
conflicting CSS specificity with the theme system.",
|
||||||
"frontend_dev",
|
"frontend_dev",
|
||||||
),
|
),
|
||||||
// id=7: CI/CD MR (matches: CI, CD, pipeline, deployment, Docker)
|
|
||||||
(
|
(
|
||||||
7,
|
7,
|
||||||
"merge_request",
|
"merge_request",
|
||||||
@@ -148,7 +130,6 @@ fn create_seeded_db() -> Connection {
|
|||||||
support for failed deployments. Pipeline runtime reduced from 45min to 12min.",
|
support for failed deployments. Pipeline runtime reduced from 45min to 12min.",
|
||||||
"devops_lead",
|
"devops_lead",
|
||||||
),
|
),
|
||||||
// id=8: Security issue (matches: security, vulnerability, XSS, injection)
|
|
||||||
(
|
(
|
||||||
8,
|
8,
|
||||||
"issue",
|
"issue",
|
||||||
@@ -169,7 +150,6 @@ fn create_seeded_db() -> Connection {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seed labels for filtered queries
|
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
"INSERT INTO document_labels (document_id, label_name) VALUES (1, 'bug');
|
"INSERT INTO document_labels (document_id, label_name) VALUES (1, 'bug');
|
||||||
INSERT INTO document_labels (document_id, label_name) VALUES (1, 'authentication');
|
INSERT INTO document_labels (document_id, label_name) VALUES (1, 'authentication');
|
||||||
@@ -212,7 +192,6 @@ fn golden_queries_all_pass() {
|
|||||||
for (i, gq) in queries.iter().enumerate() {
|
for (i, gq) in queries.iter().enumerate() {
|
||||||
let mode = SearchMode::parse(&gq.mode).unwrap_or(SearchMode::Lexical);
|
let mode = SearchMode::parse(&gq.mode).unwrap_or(SearchMode::Lexical);
|
||||||
|
|
||||||
// For lexical-only golden queries (no Ollama needed)
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
mode,
|
mode,
|
||||||
SearchMode::Lexical,
|
SearchMode::Lexical,
|
||||||
@@ -221,11 +200,9 @@ fn golden_queries_all_pass() {
|
|||||||
gq.mode
|
gq.mode
|
||||||
);
|
);
|
||||||
|
|
||||||
// Run FTS search
|
|
||||||
let fts_results = search_fts(&conn, &gq.query, 50, FtsQueryMode::Safe).unwrap();
|
let fts_results = search_fts(&conn, &gq.query, 50, FtsQueryMode::Safe).unwrap();
|
||||||
let doc_ids: Vec<i64> = fts_results.iter().map(|r| r.document_id).collect();
|
let doc_ids: Vec<i64> = fts_results.iter().map(|r| r.document_id).collect();
|
||||||
|
|
||||||
// Apply filters if any
|
|
||||||
let filters = build_search_filters(&gq.filters);
|
let filters = build_search_filters(&gq.filters);
|
||||||
let filtered_ids = if filters.has_any_filter() {
|
let filtered_ids = if filters.has_any_filter() {
|
||||||
apply_filters(&conn, &doc_ids, &filters).unwrap()
|
apply_filters(&conn, &doc_ids, &filters).unwrap()
|
||||||
@@ -233,7 +210,6 @@ fn golden_queries_all_pass() {
|
|||||||
doc_ids.clone()
|
doc_ids.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check min_results
|
|
||||||
if filtered_ids.len() < gq.min_results {
|
if filtered_ids.len() < gq.min_results {
|
||||||
failures.push(format!(
|
failures.push(format!(
|
||||||
"FAIL [{}] \"{}\": expected >= {} results, got {} (description: {})",
|
"FAIL [{}] \"{}\": expected >= {} results, got {} (description: {})",
|
||||||
@@ -246,13 +222,10 @@ fn golden_queries_all_pass() {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check each expected doc_id is in top max_rank
|
|
||||||
for expected_id in &gq.expected_doc_ids {
|
for expected_id in &gq.expected_doc_ids {
|
||||||
let position = filtered_ids.iter().position(|id| id == expected_id);
|
let position = filtered_ids.iter().position(|id| id == expected_id);
|
||||||
match position {
|
match position {
|
||||||
Some(pos) if pos < gq.max_rank => {
|
Some(pos) if pos < gq.max_rank => {}
|
||||||
// Pass
|
|
||||||
}
|
|
||||||
Some(pos) => {
|
Some(pos) => {
|
||||||
failures.push(format!(
|
failures.push(format!(
|
||||||
"FAIL [{}] \"{}\": expected doc_id {} in top {}, found at rank {} (description: {})",
|
"FAIL [{}] \"{}\": expected doc_id {} in top {}, found at rank {} (description: {})",
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
//! Integration tests for hybrid search combining FTS + vector.
|
|
||||||
//!
|
|
||||||
//! Tests all three search modes (lexical, semantic, hybrid) and
|
|
||||||
//! verifies graceful degradation when embeddings are unavailable.
|
|
||||||
|
|
||||||
use lore::core::db::create_connection;
|
use lore::core::db::create_connection;
|
||||||
use lore::search::{FtsQueryMode, SearchFilters, SearchMode, search_fts, search_hybrid};
|
use lore::search::{FtsQueryMode, SearchFilters, SearchMode, search_fts, search_hybrid};
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
@@ -89,7 +84,6 @@ fn lexical_mode_uses_fts_only() {
|
|||||||
|
|
||||||
assert!(!results.is_empty(), "Lexical search should find results");
|
assert!(!results.is_empty(), "Lexical search should find results");
|
||||||
assert_eq!(results[0].document_id, 1);
|
assert_eq!(results[0].document_id, 1);
|
||||||
// Lexical mode should not produce Ollama-related warnings
|
|
||||||
assert!(
|
assert!(
|
||||||
warnings.iter().all(|w| !w.contains("Ollama")),
|
warnings.iter().all(|w| !w.contains("Ollama")),
|
||||||
"Lexical mode should not warn about Ollama"
|
"Lexical mode should not warn about Ollama"
|
||||||
@@ -98,12 +92,10 @@ fn lexical_mode_uses_fts_only() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn lexical_mode_no_embeddings_required() {
|
fn lexical_mode_no_embeddings_required() {
|
||||||
// Use in-memory DB without sqlite-vec for pure FTS
|
|
||||||
let conn = Connection::open_in_memory().unwrap();
|
let conn = Connection::open_in_memory().unwrap();
|
||||||
conn.pragma_update(None, "foreign_keys", "ON").unwrap();
|
conn.pragma_update(None, "foreign_keys", "ON").unwrap();
|
||||||
|
|
||||||
let migrations_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("migrations");
|
let migrations_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("migrations");
|
||||||
// Only apply through migration 008 (FTS5, no embeddings)
|
|
||||||
for version in 1..=8 {
|
for version in 1..=8 {
|
||||||
let entries: Vec<_> = std::fs::read_dir(&migrations_dir)
|
let entries: Vec<_> = std::fs::read_dir(&migrations_dir)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -159,7 +151,7 @@ fn hybrid_mode_degrades_to_fts_without_client() {
|
|||||||
let (results, warnings) = rt
|
let (results, warnings) = rt
|
||||||
.block_on(search_hybrid(
|
.block_on(search_hybrid(
|
||||||
&conn,
|
&conn,
|
||||||
None, // No Ollama client
|
None,
|
||||||
"performance slow",
|
"performance slow",
|
||||||
SearchMode::Hybrid,
|
SearchMode::Hybrid,
|
||||||
&filters,
|
&filters,
|
||||||
@@ -168,7 +160,6 @@ fn hybrid_mode_degrades_to_fts_without_client() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert!(!results.is_empty(), "Should fall back to FTS results");
|
assert!(!results.is_empty(), "Should fall back to FTS results");
|
||||||
// Should warn about missing Ollama client
|
|
||||||
assert!(
|
assert!(
|
||||||
warnings.iter().any(|w| w.to_lowercase().contains("vector")
|
warnings.iter().any(|w| w.to_lowercase().contains("vector")
|
||||||
|| w.to_lowercase().contains("ollama")
|
|| w.to_lowercase().contains("ollama")
|
||||||
@@ -184,14 +175,12 @@ fn hybrid_mode_degrades_to_fts_without_client() {
|
|||||||
fn rrf_ranking_combines_signals() {
|
fn rrf_ranking_combines_signals() {
|
||||||
use lore::search::rank_rrf;
|
use lore::search::rank_rrf;
|
||||||
|
|
||||||
// Two documents with different rankings in each signal
|
let vector_results = vec![(1_i64, 0.1), (2, 0.5)];
|
||||||
let vector_results = vec![(1_i64, 0.1), (2, 0.5)]; // doc 1 closer
|
let fts_results = vec![(2_i64, -5.0), (1, -3.0)];
|
||||||
let fts_results = vec![(2_i64, -5.0), (1, -3.0)]; // doc 2 higher BM25
|
|
||||||
|
|
||||||
let rrf = rank_rrf(&vector_results, &fts_results);
|
let rrf = rank_rrf(&vector_results, &fts_results);
|
||||||
|
|
||||||
assert_eq!(rrf.len(), 2, "Should return both documents");
|
assert_eq!(rrf.len(), 2, "Should return both documents");
|
||||||
// Both docs appear in both signals, so both get RRF scores
|
|
||||||
for r in &rrf {
|
for r in &rrf {
|
||||||
assert!(r.rrf_score > 0.0, "RRF score should be positive");
|
assert!(r.rrf_score > 0.0, "RRF score should be positive");
|
||||||
}
|
}
|
||||||
@@ -235,7 +224,6 @@ fn filters_by_source_type() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn search_mode_variants_exist() {
|
fn search_mode_variants_exist() {
|
||||||
// Verify all enum variants compile and are distinct
|
|
||||||
let hybrid = SearchMode::Hybrid;
|
let hybrid = SearchMode::Hybrid;
|
||||||
let lexical = SearchMode::Lexical;
|
let lexical = SearchMode::Lexical;
|
||||||
let semantic = SearchMode::Semantic;
|
let semantic = SearchMode::Semantic;
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
//! Tests for database migrations.
|
|
||||||
|
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
@@ -41,7 +39,6 @@ fn migration_002_creates_issues_table() {
|
|||||||
let conn = create_test_db();
|
let conn = create_test_db();
|
||||||
apply_migrations(&conn, 2);
|
apply_migrations(&conn, 2);
|
||||||
|
|
||||||
// Verify issues table exists with expected columns
|
|
||||||
let columns: Vec<String> = conn
|
let columns: Vec<String> = conn
|
||||||
.prepare("PRAGMA table_info(issues)")
|
.prepare("PRAGMA table_info(issues)")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -124,13 +121,11 @@ fn migration_002_enforces_state_check() {
|
|||||||
let conn = create_test_db();
|
let conn = create_test_db();
|
||||||
apply_migrations(&conn, 2);
|
apply_migrations(&conn, 2);
|
||||||
|
|
||||||
// First insert a project so we can reference it
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace) VALUES (1, 100, 'group/project')",
|
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace) VALUES (1, 100, 'group/project')",
|
||||||
[],
|
[],
|
||||||
).unwrap();
|
).unwrap();
|
||||||
|
|
||||||
// Valid states should work
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO issues (gitlab_id, project_id, iid, state, created_at, updated_at, last_seen_at)
|
"INSERT INTO issues (gitlab_id, project_id, iid, state, created_at, updated_at, last_seen_at)
|
||||||
VALUES (1, 1, 1, 'opened', 1000, 1000, 1000)",
|
VALUES (1, 1, 1, 'opened', 1000, 1000, 1000)",
|
||||||
@@ -143,7 +138,6 @@ fn migration_002_enforces_state_check() {
|
|||||||
[],
|
[],
|
||||||
).unwrap();
|
).unwrap();
|
||||||
|
|
||||||
// Invalid state should fail
|
|
||||||
let result = conn.execute(
|
let result = conn.execute(
|
||||||
"INSERT INTO issues (gitlab_id, project_id, iid, state, created_at, updated_at, last_seen_at)
|
"INSERT INTO issues (gitlab_id, project_id, iid, state, created_at, updated_at, last_seen_at)
|
||||||
VALUES (3, 1, 3, 'invalid', 1000, 1000, 1000)",
|
VALUES (3, 1, 3, 'invalid', 1000, 1000, 1000)",
|
||||||
@@ -158,7 +152,6 @@ fn migration_002_enforces_noteable_type_check() {
|
|||||||
let conn = create_test_db();
|
let conn = create_test_db();
|
||||||
apply_migrations(&conn, 2);
|
apply_migrations(&conn, 2);
|
||||||
|
|
||||||
// Setup: project and issue
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace) VALUES (1, 100, 'group/project')",
|
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace) VALUES (1, 100, 'group/project')",
|
||||||
[],
|
[],
|
||||||
@@ -169,14 +162,12 @@ fn migration_002_enforces_noteable_type_check() {
|
|||||||
[],
|
[],
|
||||||
).unwrap();
|
).unwrap();
|
||||||
|
|
||||||
// Valid: Issue discussion with issue_id
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO discussions (gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at)
|
"INSERT INTO discussions (gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at)
|
||||||
VALUES ('abc123', 1, 1, 'Issue', 1000)",
|
VALUES ('abc123', 1, 1, 'Issue', 1000)",
|
||||||
[],
|
[],
|
||||||
).unwrap();
|
).unwrap();
|
||||||
|
|
||||||
// Invalid: noteable_type not in allowed values
|
|
||||||
let result = conn.execute(
|
let result = conn.execute(
|
||||||
"INSERT INTO discussions (gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at)
|
"INSERT INTO discussions (gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at)
|
||||||
VALUES ('def456', 1, 1, 'Commit', 1000)",
|
VALUES ('def456', 1, 1, 'Commit', 1000)",
|
||||||
@@ -184,7 +175,6 @@ fn migration_002_enforces_noteable_type_check() {
|
|||||||
);
|
);
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
|
|
||||||
// Invalid: Issue type but no issue_id
|
|
||||||
let result = conn.execute(
|
let result = conn.execute(
|
||||||
"INSERT INTO discussions (gitlab_discussion_id, project_id, noteable_type, last_seen_at)
|
"INSERT INTO discussions (gitlab_discussion_id, project_id, noteable_type, last_seen_at)
|
||||||
VALUES ('ghi789', 1, 'Issue', 1000)",
|
VALUES ('ghi789', 1, 'Issue', 1000)",
|
||||||
@@ -198,7 +188,6 @@ fn migration_002_cascades_on_project_delete() {
|
|||||||
let conn = create_test_db();
|
let conn = create_test_db();
|
||||||
apply_migrations(&conn, 2);
|
apply_migrations(&conn, 2);
|
||||||
|
|
||||||
// Setup: project, issue, label, issue_label link, discussion, note
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace) VALUES (1, 100, 'group/project')",
|
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace) VALUES (1, 100, 'group/project')",
|
||||||
[],
|
[],
|
||||||
@@ -229,11 +218,9 @@ fn migration_002_cascades_on_project_delete() {
|
|||||||
[],
|
[],
|
||||||
).unwrap();
|
).unwrap();
|
||||||
|
|
||||||
// Delete project
|
|
||||||
conn.execute("DELETE FROM projects WHERE id = 1", [])
|
conn.execute("DELETE FROM projects WHERE id = 1", [])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Verify cascade: all related data should be gone
|
|
||||||
let issue_count: i64 = conn
|
let issue_count: i64 = conn
|
||||||
.query_row("SELECT COUNT(*) FROM issues", [], |r| r.get(0))
|
.query_row("SELECT COUNT(*) FROM issues", [], |r| r.get(0))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -265,8 +252,6 @@ fn migration_002_updates_schema_version() {
|
|||||||
assert_eq!(version, 2);
|
assert_eq!(version, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Migration 005 Tests ===
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn migration_005_creates_milestones_table() {
|
fn migration_005_creates_milestones_table() {
|
||||||
let conn = create_test_db();
|
let conn = create_test_db();
|
||||||
@@ -331,7 +316,6 @@ fn migration_005_milestones_cascade_on_project_delete() {
|
|||||||
let conn = create_test_db();
|
let conn = create_test_db();
|
||||||
apply_migrations(&conn, 5);
|
apply_migrations(&conn, 5);
|
||||||
|
|
||||||
// Setup: project with milestone
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace) VALUES (1, 100, 'group/project')",
|
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace) VALUES (1, 100, 'group/project')",
|
||||||
[],
|
[],
|
||||||
@@ -341,11 +325,9 @@ fn migration_005_milestones_cascade_on_project_delete() {
|
|||||||
[],
|
[],
|
||||||
).unwrap();
|
).unwrap();
|
||||||
|
|
||||||
// Delete project
|
|
||||||
conn.execute("DELETE FROM projects WHERE id = 1", [])
|
conn.execute("DELETE FROM projects WHERE id = 1", [])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Verify milestone is gone
|
|
||||||
let count: i64 = conn
|
let count: i64 = conn
|
||||||
.query_row("SELECT COUNT(*) FROM milestones", [], |r| r.get(0))
|
.query_row("SELECT COUNT(*) FROM milestones", [], |r| r.get(0))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -357,7 +339,6 @@ fn migration_005_assignees_cascade_on_issue_delete() {
|
|||||||
let conn = create_test_db();
|
let conn = create_test_db();
|
||||||
apply_migrations(&conn, 5);
|
apply_migrations(&conn, 5);
|
||||||
|
|
||||||
// Setup: project, issue, assignee
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace) VALUES (1, 100, 'group/project')",
|
"INSERT INTO projects (id, gitlab_project_id, path_with_namespace) VALUES (1, 100, 'group/project')",
|
||||||
[],
|
[],
|
||||||
@@ -373,10 +354,8 @@ fn migration_005_assignees_cascade_on_issue_delete() {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Delete issue
|
|
||||||
conn.execute("DELETE FROM issues WHERE id = 1", []).unwrap();
|
conn.execute("DELETE FROM issues WHERE id = 1", []).unwrap();
|
||||||
|
|
||||||
// Verify assignee link is gone
|
|
||||||
let count: i64 = conn
|
let count: i64 = conn
|
||||||
.query_row("SELECT COUNT(*) FROM issue_assignees", [], |r| r.get(0))
|
.query_row("SELECT COUNT(*) FROM issue_assignees", [], |r| r.get(0))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
//! Tests for MR discussion transformer.
|
|
||||||
|
|
||||||
use lore::gitlab::transformers::discussion::transform_mr_discussion;
|
use lore::gitlab::transformers::discussion::transform_mr_discussion;
|
||||||
use lore::gitlab::types::{GitLabAuthor, GitLabDiscussion, GitLabNote};
|
use lore::gitlab::types::{GitLabAuthor, GitLabDiscussion, GitLabNote};
|
||||||
|
|
||||||
@@ -77,7 +75,7 @@ fn transform_mr_discussion_computes_resolvable_from_notes() {
|
|||||||
let result = transform_mr_discussion(&discussion, 100, 42);
|
let result = transform_mr_discussion(&discussion, 100, 42);
|
||||||
|
|
||||||
assert!(result.resolvable);
|
assert!(result.resolvable);
|
||||||
assert!(!result.resolved); // resolvable but not resolved
|
assert!(!result.resolved);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
//! Tests for MR transformer module.
|
|
||||||
|
|
||||||
use lore::gitlab::transformers::merge_request::transform_merge_request;
|
use lore::gitlab::transformers::merge_request::transform_merge_request;
|
||||||
use lore::gitlab::types::{GitLabAuthor, GitLabMergeRequest, GitLabReferences, GitLabReviewer};
|
use lore::gitlab::types::{GitLabAuthor, GitLabMergeRequest, GitLabReferences, GitLabReviewer};
|
||||||
|
|
||||||
@@ -63,7 +61,7 @@ fn transforms_mr_with_all_fields() {
|
|||||||
|
|
||||||
assert_eq!(result.merge_request.gitlab_id, 12345);
|
assert_eq!(result.merge_request.gitlab_id, 12345);
|
||||||
assert_eq!(result.merge_request.iid, 42);
|
assert_eq!(result.merge_request.iid, 42);
|
||||||
assert_eq!(result.merge_request.project_id, 200); // Local project ID, not GitLab's
|
assert_eq!(result.merge_request.project_id, 200);
|
||||||
assert_eq!(result.merge_request.title, "Add user authentication");
|
assert_eq!(result.merge_request.title, "Add user authentication");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result.merge_request.description,
|
result.merge_request.description,
|
||||||
@@ -105,22 +103,17 @@ fn parses_timestamps_to_ms_epoch() {
|
|||||||
let mr = make_test_mr();
|
let mr = make_test_mr();
|
||||||
let result = transform_merge_request(&mr, 200).unwrap();
|
let result = transform_merge_request(&mr, 200).unwrap();
|
||||||
|
|
||||||
// 2024-01-15T10:00:00.000Z = 1705312800000 ms
|
|
||||||
assert_eq!(result.merge_request.created_at, 1705312800000);
|
assert_eq!(result.merge_request.created_at, 1705312800000);
|
||||||
// 2024-01-20T14:30:00.000Z = 1705761000000 ms
|
|
||||||
assert_eq!(result.merge_request.updated_at, 1705761000000);
|
assert_eq!(result.merge_request.updated_at, 1705761000000);
|
||||||
// merged_at should also be parsed
|
|
||||||
assert_eq!(result.merge_request.merged_at, Some(1705761000000));
|
assert_eq!(result.merge_request.merged_at, Some(1705761000000));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn handles_timezone_offset_timestamps() {
|
fn handles_timezone_offset_timestamps() {
|
||||||
let mut mr = make_test_mr();
|
let mut mr = make_test_mr();
|
||||||
// GitLab can return timestamps with timezone offset
|
|
||||||
mr.created_at = "2024-01-15T05:00:00-05:00".to_string();
|
mr.created_at = "2024-01-15T05:00:00-05:00".to_string();
|
||||||
|
|
||||||
let result = transform_merge_request(&mr, 200).unwrap();
|
let result = transform_merge_request(&mr, 200).unwrap();
|
||||||
// 05:00 EST = 10:00 UTC = same as original test
|
|
||||||
assert_eq!(result.merge_request.created_at, 1705312800000);
|
assert_eq!(result.merge_request.created_at, 1705312800000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,7 +315,6 @@ fn handles_closed_at_timestamp() {
|
|||||||
|
|
||||||
let result = transform_merge_request(&mr, 200).unwrap();
|
let result = transform_merge_request(&mr, 200).unwrap();
|
||||||
assert!(result.merge_request.merged_at.is_none());
|
assert!(result.merge_request.merged_at.is_none());
|
||||||
// 2024-01-18T12:00:00.000Z = 1705579200000 ms
|
|
||||||
assert_eq!(result.merge_request.closed_at, Some(1705579200000));
|
assert_eq!(result.merge_request.closed_at, Some(1705579200000));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user