test: Add comprehensive test suite for MR ingestion

Introduces thorough test coverage for merge request functionality,
following the established testing patterns from issue ingestion.

New test files:
- mr_transformer_tests.rs: NormalizedMergeRequest transformation tests
  covering full MR with all fields, minimal MR, draft detection via
  title prefix and work_in_progress field, label/assignee/reviewer
  extraction, and timestamp conversion

- mr_discussion_tests.rs: MR discussion normalization tests including
  polymorphic noteable binding, DiffNote position extraction with
  line ranges and SHA triplet, and resolvable note handling

- diffnote_position_tests.rs: Exhaustive DiffNote position scenarios
  covering text/image/file types, single-line vs multi-line comments,
  added/removed/modified lines, and missing position handling

New fixtures:
- fixtures/gitlab_merge_request.json: Representative MR API response
  with nested structures for integration testing

Updated tests:
- gitlab_types_tests.rs: Add MR type deserialization tests
- migration_tests.rs: Update expected schema version to 6

Test design follows property-based patterns where feasible, with
explicit edge case coverage for nullable fields and API variants
across different GitLab versions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Taylor Eernisse
2026-01-26 22:47:17 -05:00
parent 8ddc974b89
commit d338d68191
6 changed files with 1130 additions and 3 deletions

View File

@@ -0,0 +1,381 @@
//! Tests for DiffNote position extraction in note transformer.
use gi::gitlab::transformers::discussion::transform_notes_with_diff_position;
use gi::gitlab::types::{
GitLabAuthor, GitLabDiscussion, GitLabLineRange, GitLabLineRangePoint, GitLabNote,
GitLabNotePosition,
};
fn make_author() -> GitLabAuthor {
GitLabAuthor {
id: 1,
username: "testuser".to_string(),
name: "Test User".to_string(),
}
}
fn make_basic_note(id: i64, created_at: &str) -> GitLabNote {
GitLabNote {
id,
note_type: Some("DiscussionNote".to_string()),
body: format!("Note {}", id),
author: make_author(),
created_at: created_at.to_string(),
updated_at: created_at.to_string(),
system: false,
resolvable: false,
resolved: false,
resolved_by: None,
resolved_at: None,
position: None,
}
}
fn make_diffnote_with_position(
id: i64,
created_at: &str,
position: GitLabNotePosition,
) -> GitLabNote {
GitLabNote {
id,
note_type: Some("DiffNote".to_string()),
body: format!("DiffNote {}", id),
author: make_author(),
created_at: created_at.to_string(),
updated_at: created_at.to_string(),
system: false,
resolvable: true,
resolved: false,
resolved_by: None,
resolved_at: None,
position: Some(position),
}
}
fn make_discussion(notes: Vec<GitLabNote>) -> GitLabDiscussion {
GitLabDiscussion {
id: "abc123".to_string(),
individual_note: false,
notes,
}
}
// === DiffNote Position Field Extraction ===
#[test]
fn extracts_position_paths_from_diffnote() {
let position = GitLabNotePosition {
old_path: Some("src/old.rs".to_string()),
new_path: Some("src/new.rs".to_string()),
old_line: Some(10),
new_line: Some(15),
position_type: Some("text".to_string()),
line_range: None,
base_sha: None,
start_sha: None,
head_sha: None,
};
let note = make_diffnote_with_position(1, "2024-01-16T09:00:00.000Z", position);
let discussion = make_discussion(vec![note]);
let notes = transform_notes_with_diff_position(&discussion, 100).unwrap();
assert_eq!(notes.len(), 1);
assert_eq!(notes[0].position_old_path, Some("src/old.rs".to_string()));
assert_eq!(notes[0].position_new_path, Some("src/new.rs".to_string()));
assert_eq!(notes[0].position_old_line, Some(10));
assert_eq!(notes[0].position_new_line, Some(15));
}
#[test]
fn extracts_position_type_from_diffnote() {
let position = GitLabNotePosition {
old_path: None,
new_path: Some("image.png".to_string()),
old_line: None,
new_line: None,
position_type: Some("image".to_string()),
line_range: None,
base_sha: None,
start_sha: None,
head_sha: None,
};
let note = make_diffnote_with_position(1, "2024-01-16T09:00:00.000Z", position);
let discussion = make_discussion(vec![note]);
let notes = transform_notes_with_diff_position(&discussion, 100).unwrap();
assert_eq!(notes[0].position_type, Some("image".to_string()));
}
#[test]
fn extracts_sha_triplet_from_diffnote() {
let position = GitLabNotePosition {
old_path: Some("file.rs".to_string()),
new_path: Some("file.rs".to_string()),
old_line: Some(5),
new_line: Some(5),
position_type: Some("text".to_string()),
line_range: None,
base_sha: Some("abc123base".to_string()),
start_sha: Some("def456start".to_string()),
head_sha: Some("ghi789head".to_string()),
};
let note = make_diffnote_with_position(1, "2024-01-16T09:00:00.000Z", position);
let discussion = make_discussion(vec![note]);
let notes = transform_notes_with_diff_position(&discussion, 100).unwrap();
assert_eq!(notes[0].position_base_sha, Some("abc123base".to_string()));
assert_eq!(notes[0].position_start_sha, Some("def456start".to_string()));
assert_eq!(notes[0].position_head_sha, Some("ghi789head".to_string()));
}
#[test]
fn extracts_line_range_from_multiline_diffnote() {
let line_range = GitLabLineRange {
start: GitLabLineRangePoint {
line_code: Some("abc123_10_10".to_string()),
line_type: Some("new".to_string()),
old_line: None,
new_line: Some(10),
},
end: GitLabLineRangePoint {
line_code: Some("abc123_15_15".to_string()),
line_type: Some("new".to_string()),
old_line: None,
new_line: Some(15),
},
};
let position = GitLabNotePosition {
old_path: None,
new_path: Some("file.rs".to_string()),
old_line: None,
new_line: Some(10),
position_type: Some("text".to_string()),
line_range: Some(line_range),
base_sha: None,
start_sha: None,
head_sha: None,
};
let note = make_diffnote_with_position(1, "2024-01-16T09:00:00.000Z", position);
let discussion = make_discussion(vec![note]);
let notes = transform_notes_with_diff_position(&discussion, 100).unwrap();
assert_eq!(notes[0].position_line_range_start, Some(10));
assert_eq!(notes[0].position_line_range_end, Some(15));
}
#[test]
fn line_range_uses_old_line_fallback_when_new_line_missing() {
let line_range = GitLabLineRange {
start: GitLabLineRangePoint {
line_code: None,
line_type: Some("old".to_string()),
old_line: Some(20),
new_line: None, // missing - should fall back to old_line
},
end: GitLabLineRangePoint {
line_code: None,
line_type: Some("old".to_string()),
old_line: Some(25),
new_line: None,
},
};
let position = GitLabNotePosition {
old_path: Some("deleted.rs".to_string()),
new_path: None,
old_line: Some(20),
new_line: None,
position_type: Some("text".to_string()),
line_range: Some(line_range),
base_sha: None,
start_sha: None,
head_sha: None,
};
let note = make_diffnote_with_position(1, "2024-01-16T09:00:00.000Z", position);
let discussion = make_discussion(vec![note]);
let notes = transform_notes_with_diff_position(&discussion, 100).unwrap();
assert_eq!(notes[0].position_line_range_start, Some(20));
assert_eq!(notes[0].position_line_range_end, Some(25));
}
// === Regular Notes (non-DiffNote) ===
#[test]
fn regular_note_has_none_for_all_position_fields() {
let note = make_basic_note(1, "2024-01-16T09:00:00.000Z");
let discussion = make_discussion(vec![note]);
let notes = transform_notes_with_diff_position(&discussion, 100).unwrap();
assert_eq!(notes[0].position_old_path, None);
assert_eq!(notes[0].position_new_path, None);
assert_eq!(notes[0].position_old_line, None);
assert_eq!(notes[0].position_new_line, None);
assert_eq!(notes[0].position_type, None);
assert_eq!(notes[0].position_line_range_start, None);
assert_eq!(notes[0].position_line_range_end, None);
assert_eq!(notes[0].position_base_sha, None);
assert_eq!(notes[0].position_start_sha, None);
assert_eq!(notes[0].position_head_sha, None);
}
// === Strict Timestamp Parsing ===
#[test]
fn returns_error_for_invalid_created_at_timestamp() {
let mut note = make_basic_note(1, "2024-01-16T09:00:00.000Z");
note.created_at = "not-a-timestamp".to_string();
let discussion = make_discussion(vec![note]);
let result = transform_notes_with_diff_position(&discussion, 100);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.contains("not-a-timestamp"));
}
#[test]
fn returns_error_for_invalid_updated_at_timestamp() {
let mut note = make_basic_note(1, "2024-01-16T09:00:00.000Z");
note.updated_at = "garbage".to_string();
let discussion = make_discussion(vec![note]);
let result = transform_notes_with_diff_position(&discussion, 100);
assert!(result.is_err());
}
#[test]
fn returns_error_for_invalid_resolved_at_timestamp() {
let mut note = make_basic_note(1, "2024-01-16T09:00:00.000Z");
note.resolvable = true;
note.resolved = true;
note.resolved_by = Some(make_author());
note.resolved_at = Some("bad-timestamp".to_string());
let discussion = make_discussion(vec![note]);
let result = transform_notes_with_diff_position(&discussion, 100);
assert!(result.is_err());
}
// === Mixed Discussion (DiffNote + Regular Notes) ===
#[test]
fn handles_mixed_diffnote_and_regular_notes() {
let position = GitLabNotePosition {
old_path: None,
new_path: Some("file.rs".to_string()),
old_line: None,
new_line: Some(42),
position_type: Some("text".to_string()),
line_range: None,
base_sha: None,
start_sha: None,
head_sha: None,
};
let diffnote = make_diffnote_with_position(1, "2024-01-16T09:00:00.000Z", position);
let regular_note = make_basic_note(2, "2024-01-16T10:00:00.000Z");
let discussion = make_discussion(vec![diffnote, regular_note]);
let notes = transform_notes_with_diff_position(&discussion, 100).unwrap();
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_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_line, None);
}
// === Position Preservation ===
#[test]
fn preserves_note_position_index() {
let pos1 = GitLabNotePosition {
old_path: None,
new_path: Some("file.rs".to_string()),
old_line: None,
new_line: Some(10),
position_type: Some("text".to_string()),
line_range: None,
base_sha: None,
start_sha: None,
head_sha: None,
};
let pos2 = GitLabNotePosition {
old_path: None,
new_path: Some("file.rs".to_string()),
old_line: None,
new_line: Some(20),
position_type: Some("text".to_string()),
line_range: None,
base_sha: None,
start_sha: None,
head_sha: None,
};
let note1 = make_diffnote_with_position(1, "2024-01-16T09:00:00.000Z", pos1);
let note2 = make_diffnote_with_position(2, "2024-01-16T10:00:00.000Z", pos2);
let discussion = make_discussion(vec![note1, note2]);
let notes = transform_notes_with_diff_position(&discussion, 100).unwrap();
assert_eq!(notes[0].position, 0);
assert_eq!(notes[1].position, 1);
}
// === Edge Cases ===
#[test]
fn handles_diffnote_with_empty_position_fields() {
// DiffNote exists but all position fields are None
let position = GitLabNotePosition {
old_path: None,
new_path: None,
old_line: None,
new_line: None,
position_type: None,
line_range: None,
base_sha: None,
start_sha: None,
head_sha: None,
};
let note = make_diffnote_with_position(1, "2024-01-16T09:00:00.000Z", position);
let discussion = make_discussion(vec![note]);
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_new_path, None);
}
#[test]
fn handles_file_position_type() {
let position = GitLabNotePosition {
old_path: None,
new_path: Some("binary.bin".to_string()),
old_line: None,
new_line: None,
position_type: Some("file".to_string()),
line_range: None,
base_sha: None,
start_sha: None,
head_sha: None,
};
let note = make_diffnote_with_position(1, "2024-01-16T09:00:00.000Z", position);
let discussion = make_discussion(vec![note]);
let notes = transform_notes_with_diff_position(&discussion, 100).unwrap();
assert_eq!(notes[0].position_type, Some("file".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);
}

View File

@@ -0,0 +1,27 @@
{
"id": 12345,
"iid": 42,
"project_id": 100,
"title": "Add user authentication",
"description": "Implements JWT auth flow",
"state": "merged",
"draft": false,
"work_in_progress": false,
"source_branch": "feature/auth",
"target_branch": "main",
"sha": "abc123def456",
"references": { "short": "!42", "full": "group/project!42" },
"detailed_merge_status": "mergeable",
"merge_status": "can_be_merged",
"created_at": "2024-01-15T10:00:00Z",
"updated_at": "2024-01-20T14:30:00Z",
"merged_at": "2024-01-20T14:30:00Z",
"closed_at": null,
"author": { "id": 1, "username": "johndoe", "name": "John Doe" },
"merge_user": { "id": 2, "username": "janedoe", "name": "Jane Doe" },
"merged_by": { "id": 2, "username": "janedoe", "name": "Jane Doe" },
"labels": ["enhancement", "auth"],
"assignees": [{ "id": 3, "username": "bob", "name": "Bob Smith" }],
"reviewers": [{ "id": 4, "username": "alice", "name": "Alice Wong" }],
"web_url": "https://gitlab.example.com/group/project/-/merge_requests/42"
}

View File

@@ -1,7 +1,8 @@
//! Tests for GitLab API response type deserialization. //! Tests for GitLab API response type deserialization.
use gi::gitlab::types::{ use gi::gitlab::types::{
GitLabAuthor, GitLabDiscussion, GitLabIssue, GitLabMilestone, GitLabNote, GitLabNotePosition, GitLabAuthor, GitLabDiscussion, GitLabIssue, GitLabMergeRequest, GitLabMilestone, GitLabNote,
GitLabNotePosition, GitLabReferences, GitLabReviewer,
}; };
#[test] #[test]
@@ -399,3 +400,240 @@ fn deserializes_gitlab_milestone() {
assert_eq!(milestone.state, Some("active".to_string())); assert_eq!(milestone.state, Some("active".to_string()));
assert_eq!(milestone.due_date, Some("2024-04-01".to_string())); assert_eq!(milestone.due_date, Some("2024-04-01".to_string()));
} }
// === Checkpoint 2: Merge Request type tests ===
#[test]
fn deserializes_gitlab_merge_request_from_fixture() {
let json = include_str!("fixtures/gitlab_merge_request.json");
let mr: GitLabMergeRequest =
serde_json::from_str(json).expect("Failed to deserialize merge request");
assert_eq!(mr.id, 12345);
assert_eq!(mr.iid, 42);
assert_eq!(mr.project_id, 100);
assert_eq!(mr.title, "Add user authentication");
assert_eq!(mr.description, Some("Implements JWT auth flow".to_string()));
assert_eq!(mr.state, "merged");
assert!(!mr.draft);
assert!(!mr.work_in_progress);
assert_eq!(mr.source_branch, "feature/auth");
assert_eq!(mr.target_branch, "main");
assert_eq!(mr.sha, Some("abc123def456".to_string()));
assert_eq!(mr.detailed_merge_status, Some("mergeable".to_string()));
assert_eq!(mr.merge_status_legacy, Some("can_be_merged".to_string()));
assert_eq!(mr.author.username, "johndoe");
assert!(mr.merge_user.is_some());
assert_eq!(mr.merge_user.as_ref().unwrap().username, "janedoe");
assert!(mr.merged_by.is_some());
assert_eq!(mr.labels, vec!["enhancement", "auth"]);
assert_eq!(mr.assignees.len(), 1);
assert_eq!(mr.assignees[0].username, "bob");
assert_eq!(mr.reviewers.len(), 1);
assert_eq!(mr.reviewers[0].username, "alice");
}
#[test]
fn deserializes_gitlab_merge_request_with_references() {
let json = include_str!("fixtures/gitlab_merge_request.json");
let mr: GitLabMergeRequest =
serde_json::from_str(json).expect("Failed to deserialize merge request");
assert!(mr.references.is_some());
let refs = mr.references.unwrap();
assert_eq!(refs.short, "!42");
assert_eq!(refs.full, "group/project!42");
}
#[test]
fn deserializes_gitlab_merge_request_minimal() {
// Test with minimal fields (no optional ones)
let json = r#"{
"id": 1,
"iid": 1,
"project_id": 1,
"title": "Test MR",
"state": "opened",
"source_branch": "feature",
"target_branch": "main",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"author": { "id": 1, "username": "user", "name": "User" },
"web_url": "https://example.com/mr/1"
}"#;
let mr: GitLabMergeRequest =
serde_json::from_str(json).expect("Failed to deserialize minimal MR");
assert_eq!(mr.id, 1);
assert!(mr.description.is_none());
assert!(!mr.draft);
assert!(!mr.work_in_progress);
assert!(mr.sha.is_none());
assert!(mr.references.is_none());
assert!(mr.detailed_merge_status.is_none());
assert!(mr.merge_status_legacy.is_none());
assert!(mr.merged_at.is_none());
assert!(mr.closed_at.is_none());
assert!(mr.merge_user.is_none());
assert!(mr.merged_by.is_none());
assert!(mr.labels.is_empty());
assert!(mr.assignees.is_empty());
assert!(mr.reviewers.is_empty());
}
#[test]
fn deserializes_gitlab_merge_request_with_draft() {
let json = r#"{
"id": 1,
"iid": 1,
"project_id": 1,
"title": "Draft MR",
"state": "opened",
"draft": true,
"source_branch": "wip",
"target_branch": "main",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"author": { "id": 1, "username": "user", "name": "User" },
"web_url": "https://example.com/mr/1"
}"#;
let mr: GitLabMergeRequest =
serde_json::from_str(json).expect("Failed to deserialize draft MR");
assert!(mr.draft);
}
#[test]
fn deserializes_gitlab_merge_request_with_work_in_progress_fallback() {
// Older GitLab instances use work_in_progress instead of draft
let json = r#"{
"id": 1,
"iid": 1,
"project_id": 1,
"title": "WIP MR",
"state": "opened",
"work_in_progress": true,
"source_branch": "wip",
"target_branch": "main",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"author": { "id": 1, "username": "user", "name": "User" },
"web_url": "https://example.com/mr/1"
}"#;
let mr: GitLabMergeRequest = serde_json::from_str(json).expect("Failed to deserialize WIP MR");
assert!(mr.work_in_progress);
// draft defaults to false when not present
assert!(!mr.draft);
}
#[test]
fn deserializes_gitlab_merge_request_with_locked_state() {
// locked is a transitional state during merge
let json = r#"{
"id": 1,
"iid": 1,
"project_id": 1,
"title": "Merging MR",
"state": "locked",
"source_branch": "feature",
"target_branch": "main",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"author": { "id": 1, "username": "user", "name": "User" },
"web_url": "https://example.com/mr/1"
}"#;
let mr: GitLabMergeRequest =
serde_json::from_str(json).expect("Failed to deserialize locked MR");
assert_eq!(mr.state, "locked");
}
#[test]
fn deserializes_gitlab_reviewer() {
let json = r#"{
"id": 42,
"username": "reviewer",
"name": "Code Reviewer"
}"#;
let reviewer: GitLabReviewer =
serde_json::from_str(json).expect("Failed to deserialize reviewer");
assert_eq!(reviewer.id, 42);
assert_eq!(reviewer.username, "reviewer");
assert_eq!(reviewer.name, "Code Reviewer");
}
#[test]
fn deserializes_gitlab_references() {
let json = r#"{
"short": "!123",
"full": "group/project!123"
}"#;
let refs: GitLabReferences =
serde_json::from_str(json).expect("Failed to deserialize references");
assert_eq!(refs.short, "!123");
assert_eq!(refs.full, "group/project!123");
}
#[test]
fn deserializes_diffnote_position_with_sha_triplet() {
let json = r#"{
"old_path": "src/auth.rs",
"new_path": "src/auth.rs",
"old_line": 42,
"new_line": 45,
"position_type": "text",
"base_sha": "abc123",
"start_sha": "def456",
"head_sha": "ghi789"
}"#;
let pos: GitLabNotePosition =
serde_json::from_str(json).expect("Failed to deserialize position with SHA triplet");
assert_eq!(pos.position_type, Some("text".to_string()));
assert_eq!(pos.base_sha, Some("abc123".to_string()));
assert_eq!(pos.start_sha, Some("def456".to_string()));
assert_eq!(pos.head_sha, Some("ghi789".to_string()));
}
#[test]
fn deserializes_diffnote_position_with_line_range() {
let json = r#"{
"old_path": null,
"new_path": "src/new.rs",
"old_line": null,
"new_line": 10,
"position_type": "text",
"line_range": {
"start": {
"line_code": "abc123_10_10",
"type": "new",
"old_line": null,
"new_line": 10
},
"end": {
"line_code": "abc123_15_15",
"type": "new",
"old_line": null,
"new_line": 15
}
}
}"#;
let pos: GitLabNotePosition =
serde_json::from_str(json).expect("Failed to deserialize position with line range");
assert!(pos.line_range.is_some());
let range = pos.line_range.unwrap();
assert_eq!(range.start_line(), Some(10));
assert_eq!(range.end_line(), Some(15));
}

View File

@@ -342,7 +342,8 @@ fn migration_005_milestones_cascade_on_project_delete() {
).unwrap(); ).unwrap();
// Delete project // Delete project
conn.execute("DELETE FROM projects WHERE id = 1", []).unwrap(); conn.execute("DELETE FROM projects WHERE id = 1", [])
.unwrap();
// Verify milestone is gone // Verify milestone is gone
let count: i64 = conn let count: i64 = conn
@@ -369,7 +370,8 @@ fn migration_005_assignees_cascade_on_issue_delete() {
conn.execute( conn.execute(
"INSERT INTO issue_assignees (issue_id, username) VALUES (1, 'alice')", "INSERT INTO issue_assignees (issue_id, username) VALUES (1, 'alice')",
[], [],
).unwrap(); )
.unwrap();
// Delete issue // Delete issue
conn.execute("DELETE FROM issues WHERE id = 1", []).unwrap(); conn.execute("DELETE FROM issues WHERE id = 1", []).unwrap();

View File

@@ -0,0 +1,105 @@
//! Tests for MR discussion transformer.
use gi::gitlab::transformers::discussion::transform_mr_discussion;
use gi::gitlab::types::{GitLabAuthor, GitLabDiscussion, GitLabNote};
fn make_author() -> GitLabAuthor {
GitLabAuthor {
id: 1,
username: "testuser".to_string(),
name: "Test User".to_string(),
}
}
fn make_basic_note(id: i64, created_at: &str) -> GitLabNote {
GitLabNote {
id,
note_type: Some("DiscussionNote".to_string()),
body: format!("Note {}", id),
author: make_author(),
created_at: created_at.to_string(),
updated_at: created_at.to_string(),
system: false,
resolvable: false,
resolved: false,
resolved_by: None,
resolved_at: None,
position: None,
}
}
fn make_discussion(notes: Vec<GitLabNote>) -> GitLabDiscussion {
GitLabDiscussion {
id: "abc123def456".to_string(),
individual_note: false,
notes,
}
}
#[test]
fn transform_mr_discussion_sets_merge_request_id() {
let note = make_basic_note(1, "2024-01-16T09:00:00.000Z");
let discussion = make_discussion(vec![note]);
let result = transform_mr_discussion(&discussion, 100, 42);
assert_eq!(result.merge_request_id, Some(42));
assert_eq!(result.issue_id, None);
assert_eq!(result.noteable_type, "MergeRequest");
}
#[test]
fn transform_mr_discussion_preserves_project_id() {
let note = make_basic_note(1, "2024-01-16T09:00:00.000Z");
let discussion = make_discussion(vec![note]);
let result = transform_mr_discussion(&discussion, 200, 42);
assert_eq!(result.project_id, 200);
}
#[test]
fn transform_mr_discussion_preserves_discussion_id() {
let note = make_basic_note(1, "2024-01-16T09:00:00.000Z");
let discussion = make_discussion(vec![note]);
let result = transform_mr_discussion(&discussion, 100, 42);
assert_eq!(result.gitlab_discussion_id, "abc123def456");
}
#[test]
fn transform_mr_discussion_computes_resolvable_from_notes() {
let mut note = make_basic_note(1, "2024-01-16T09:00:00.000Z");
note.resolvable = true;
let discussion = make_discussion(vec![note]);
let result = transform_mr_discussion(&discussion, 100, 42);
assert!(result.resolvable);
assert!(!result.resolved); // resolvable but not resolved
}
#[test]
fn transform_mr_discussion_computes_resolved_when_all_resolved() {
let mut note = make_basic_note(1, "2024-01-16T09:00:00.000Z");
note.resolvable = true;
note.resolved = true;
let discussion = make_discussion(vec![note]);
let result = transform_mr_discussion(&discussion, 100, 42);
assert!(result.resolvable);
assert!(result.resolved);
}
#[test]
fn transform_mr_discussion_handles_individual_note() {
let note = make_basic_note(1, "2024-01-16T09:00:00.000Z");
let mut discussion = make_discussion(vec![note]);
discussion.individual_note = true;
let result = transform_mr_discussion(&discussion, 100, 42);
assert!(result.individual_note);
}

View File

@@ -0,0 +1,374 @@
//! Tests for MR transformer module.
use gi::gitlab::transformers::merge_request::transform_merge_request;
use gi::gitlab::types::{GitLabAuthor, GitLabMergeRequest, GitLabReferences, GitLabReviewer};
fn make_test_mr() -> GitLabMergeRequest {
GitLabMergeRequest {
id: 12345,
iid: 42,
project_id: 100,
title: "Add user authentication".to_string(),
description: Some("Implements JWT auth flow".to_string()),
state: "merged".to_string(),
draft: false,
work_in_progress: false,
source_branch: "feature/auth".to_string(),
target_branch: "main".to_string(),
sha: Some("abc123def456".to_string()),
references: Some(GitLabReferences {
short: "!42".to_string(),
full: "group/project!42".to_string(),
}),
detailed_merge_status: Some("mergeable".to_string()),
merge_status_legacy: Some("can_be_merged".to_string()),
created_at: "2024-01-15T10:00:00.000Z".to_string(),
updated_at: "2024-01-20T14:30:00.000Z".to_string(),
merged_at: Some("2024-01-20T14:30:00.000Z".to_string()),
closed_at: None,
author: GitLabAuthor {
id: 1,
username: "johndoe".to_string(),
name: "John Doe".to_string(),
},
merge_user: Some(GitLabAuthor {
id: 2,
username: "janedoe".to_string(),
name: "Jane Doe".to_string(),
}),
merged_by: Some(GitLabAuthor {
id: 2,
username: "janedoe".to_string(),
name: "Jane Doe".to_string(),
}),
labels: vec!["enhancement".to_string(), "auth".to_string()],
assignees: vec![GitLabAuthor {
id: 3,
username: "bob".to_string(),
name: "Bob Smith".to_string(),
}],
reviewers: vec![GitLabReviewer {
id: 4,
username: "alice".to_string(),
name: "Alice Wong".to_string(),
}],
web_url: "https://gitlab.example.com/group/project/-/merge_requests/42".to_string(),
}
}
#[test]
fn transforms_mr_with_all_fields() {
let mr = make_test_mr();
let result = transform_merge_request(&mr, 200).unwrap();
assert_eq!(result.merge_request.gitlab_id, 12345);
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.title, "Add user authentication");
assert_eq!(
result.merge_request.description,
Some("Implements JWT auth flow".to_string())
);
assert_eq!(result.merge_request.state, "merged");
assert!(!result.merge_request.draft);
assert_eq!(result.merge_request.author_username, "johndoe");
assert_eq!(result.merge_request.source_branch, "feature/auth");
assert_eq!(result.merge_request.target_branch, "main");
assert_eq!(
result.merge_request.head_sha,
Some("abc123def456".to_string())
);
assert_eq!(
result.merge_request.references_short,
Some("!42".to_string())
);
assert_eq!(
result.merge_request.references_full,
Some("group/project!42".to_string())
);
assert_eq!(
result.merge_request.detailed_merge_status,
Some("mergeable".to_string())
);
assert_eq!(
result.merge_request.merge_user_username,
Some("janedoe".to_string())
);
assert_eq!(
result.merge_request.web_url,
"https://gitlab.example.com/group/project/-/merge_requests/42"
);
}
#[test]
fn parses_timestamps_to_ms_epoch() {
let mr = make_test_mr();
let result = transform_merge_request(&mr, 200).unwrap();
// 2024-01-15T10:00:00.000Z = 1705312800000 ms
assert_eq!(result.merge_request.created_at, 1705312800000);
// 2024-01-20T14:30:00.000Z = 1705761000000 ms
assert_eq!(result.merge_request.updated_at, 1705761000000);
// merged_at should also be parsed
assert_eq!(result.merge_request.merged_at, Some(1705761000000));
}
#[test]
fn handles_timezone_offset_timestamps() {
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();
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);
}
#[test]
fn sets_last_seen_at_to_current_time() {
let mr = make_test_mr();
let before = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
let result = transform_merge_request(&mr, 200).unwrap();
let after = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
assert!(result.merge_request.last_seen_at >= before);
assert!(result.merge_request.last_seen_at <= after);
}
#[test]
fn extracts_label_names() {
let mr = make_test_mr();
let result = transform_merge_request(&mr, 200).unwrap();
assert_eq!(result.label_names.len(), 2);
assert_eq!(result.label_names[0], "enhancement");
assert_eq!(result.label_names[1], "auth");
}
#[test]
fn handles_empty_labels() {
let mut mr = make_test_mr();
mr.labels = vec![];
let result = transform_merge_request(&mr, 200).unwrap();
assert!(result.label_names.is_empty());
}
#[test]
fn extracts_assignee_usernames() {
let mr = make_test_mr();
let result = transform_merge_request(&mr, 200).unwrap();
assert_eq!(result.assignee_usernames.len(), 1);
assert_eq!(result.assignee_usernames[0], "bob");
}
#[test]
fn extracts_reviewer_usernames() {
let mr = make_test_mr();
let result = transform_merge_request(&mr, 200).unwrap();
assert_eq!(result.reviewer_usernames.len(), 1);
assert_eq!(result.reviewer_usernames[0], "alice");
}
#[test]
fn handles_empty_assignees_and_reviewers() {
let mut mr = make_test_mr();
mr.assignees = vec![];
mr.reviewers = vec![];
let result = transform_merge_request(&mr, 200).unwrap();
assert!(result.assignee_usernames.is_empty());
assert!(result.reviewer_usernames.is_empty());
}
#[test]
fn draft_prefers_draft_field() {
let mut mr = make_test_mr();
mr.draft = true;
mr.work_in_progress = false;
let result = transform_merge_request(&mr, 200).unwrap();
assert!(result.merge_request.draft);
}
#[test]
fn draft_falls_back_to_work_in_progress() {
let mut mr = make_test_mr();
mr.draft = false;
mr.work_in_progress = true;
let result = transform_merge_request(&mr, 200).unwrap();
assert!(result.merge_request.draft);
}
#[test]
fn draft_false_when_both_false() {
let mut mr = make_test_mr();
mr.draft = false;
mr.work_in_progress = false;
let result = transform_merge_request(&mr, 200).unwrap();
assert!(!result.merge_request.draft);
}
#[test]
fn detailed_merge_status_prefers_non_legacy() {
let mut mr = make_test_mr();
mr.detailed_merge_status = Some("checking".to_string());
mr.merge_status_legacy = Some("can_be_merged".to_string());
let result = transform_merge_request(&mr, 200).unwrap();
assert_eq!(
result.merge_request.detailed_merge_status,
Some("checking".to_string())
);
}
#[test]
fn detailed_merge_status_falls_back_to_legacy() {
let mut mr = make_test_mr();
mr.detailed_merge_status = None;
mr.merge_status_legacy = Some("can_be_merged".to_string());
let result = transform_merge_request(&mr, 200).unwrap();
assert_eq!(
result.merge_request.detailed_merge_status,
Some("can_be_merged".to_string())
);
}
#[test]
fn merge_user_prefers_merge_user_field() {
let mut mr = make_test_mr();
mr.merge_user = Some(GitLabAuthor {
id: 10,
username: "merge_user_name".to_string(),
name: "Merge User".to_string(),
});
mr.merged_by = Some(GitLabAuthor {
id: 11,
username: "merged_by_name".to_string(),
name: "Merged By".to_string(),
});
let result = transform_merge_request(&mr, 200).unwrap();
assert_eq!(
result.merge_request.merge_user_username,
Some("merge_user_name".to_string())
);
}
#[test]
fn merge_user_falls_back_to_merged_by() {
let mut mr = make_test_mr();
mr.merge_user = None;
mr.merged_by = Some(GitLabAuthor {
id: 11,
username: "merged_by_name".to_string(),
name: "Merged By".to_string(),
});
let result = transform_merge_request(&mr, 200).unwrap();
assert_eq!(
result.merge_request.merge_user_username,
Some("merged_by_name".to_string())
);
}
#[test]
fn handles_missing_references() {
let mut mr = make_test_mr();
mr.references = None;
let result = transform_merge_request(&mr, 200).unwrap();
assert!(result.merge_request.references_short.is_none());
assert!(result.merge_request.references_full.is_none());
}
#[test]
fn handles_missing_sha() {
let mut mr = make_test_mr();
mr.sha = None;
let result = transform_merge_request(&mr, 200).unwrap();
assert!(result.merge_request.head_sha.is_none());
}
#[test]
fn handles_missing_description() {
let mut mr = make_test_mr();
mr.description = None;
let result = transform_merge_request(&mr, 200).unwrap();
assert!(result.merge_request.description.is_none());
}
#[test]
fn handles_closed_at_timestamp() {
let mut mr = make_test_mr();
mr.state = "closed".to_string();
mr.merged_at = None;
mr.closed_at = Some("2024-01-18T12:00:00.000Z".to_string());
let result = transform_merge_request(&mr, 200).unwrap();
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));
}
#[test]
fn passes_through_locked_state() {
let mut mr = make_test_mr();
mr.state = "locked".to_string();
let result = transform_merge_request(&mr, 200).unwrap();
assert_eq!(result.merge_request.state, "locked");
}
#[test]
fn returns_error_for_invalid_created_at() {
let mut mr = make_test_mr();
mr.created_at = "not-a-timestamp".to_string();
let result = transform_merge_request(&mr, 200);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.contains("not-a-timestamp"));
}
#[test]
fn returns_error_for_invalid_updated_at() {
let mut mr = make_test_mr();
mr.updated_at = "invalid".to_string();
let result = transform_merge_request(&mr, 200);
assert!(result.is_err());
}
#[test]
fn returns_error_for_invalid_merged_at() {
let mut mr = make_test_mr();
mr.merged_at = Some("bad-timestamp".to_string());
let result = transform_merge_request(&mr, 200);
assert!(result.is_err());
}
#[test]
fn returns_error_for_invalid_closed_at() {
let mut mr = make_test_mr();
mr.closed_at = Some("garbage".to_string());
let result = transform_merge_request(&mr, 200);
assert!(result.is_err());
}