diff --git a/tests/diffnote_position_tests.rs b/tests/diffnote_position_tests.rs new file mode 100644 index 0000000..ddc50b9 --- /dev/null +++ b/tests/diffnote_position_tests.rs @@ -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) -> 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); +} diff --git a/tests/fixtures/gitlab_merge_request.json b/tests/fixtures/gitlab_merge_request.json new file mode 100644 index 0000000..c707581 --- /dev/null +++ b/tests/fixtures/gitlab_merge_request.json @@ -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" +} diff --git a/tests/gitlab_types_tests.rs b/tests/gitlab_types_tests.rs index 4000c53..133c56a 100644 --- a/tests/gitlab_types_tests.rs +++ b/tests/gitlab_types_tests.rs @@ -1,7 +1,8 @@ //! Tests for GitLab API response type deserialization. use gi::gitlab::types::{ - GitLabAuthor, GitLabDiscussion, GitLabIssue, GitLabMilestone, GitLabNote, GitLabNotePosition, + GitLabAuthor, GitLabDiscussion, GitLabIssue, GitLabMergeRequest, GitLabMilestone, GitLabNote, + GitLabNotePosition, GitLabReferences, GitLabReviewer, }; #[test] @@ -399,3 +400,240 @@ fn deserializes_gitlab_milestone() { assert_eq!(milestone.state, Some("active".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)); +} diff --git a/tests/migration_tests.rs b/tests/migration_tests.rs index bd209b4..e4d4ab1 100644 --- a/tests/migration_tests.rs +++ b/tests/migration_tests.rs @@ -342,7 +342,8 @@ fn migration_005_milestones_cascade_on_project_delete() { ).unwrap(); // Delete project - conn.execute("DELETE FROM projects WHERE id = 1", []).unwrap(); + conn.execute("DELETE FROM projects WHERE id = 1", []) + .unwrap(); // Verify milestone is gone let count: i64 = conn @@ -369,7 +370,8 @@ fn migration_005_assignees_cascade_on_issue_delete() { conn.execute( "INSERT INTO issue_assignees (issue_id, username) VALUES (1, 'alice')", [], - ).unwrap(); + ) + .unwrap(); // Delete issue conn.execute("DELETE FROM issues WHERE id = 1", []).unwrap(); diff --git a/tests/mr_discussion_tests.rs b/tests/mr_discussion_tests.rs new file mode 100644 index 0000000..6dd2722 --- /dev/null +++ b/tests/mr_discussion_tests.rs @@ -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) -> 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); +} diff --git a/tests/mr_transformer_tests.rs b/tests/mr_transformer_tests.rs new file mode 100644 index 0000000..4372233 --- /dev/null +++ b/tests/mr_transformer_tests.rs @@ -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()); +}