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:
Taylor Eernisse
2026-02-05 00:04:39 -05:00
parent 65583ed5d6
commit dd2869fd98
9 changed files with 7 additions and 158 deletions

View File

@@ -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);
} }

View File

@@ -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;

View File

@@ -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!(

View File

@@ -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')",

View File

@@ -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: {})",

View File

@@ -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;

View File

@@ -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();

View File

@@ -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]

View File

@@ -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));
} }