1# frozen_string_literal: true
2
3require 'spec_helper'
4
5RSpec.describe DiffNote do
6  include RepoHelpers
7
8  let_it_be(:merge_request) { create(:merge_request) }
9  let_it_be(:project) { merge_request.project }
10  let_it_be(:commit) { project.commit(sample_commit.id) }
11
12  let_it_be(:path) { "files/ruby/popen.rb" }
13
14  let(:diff_refs) { merge_request.diff_refs }
15  let!(:position) do
16    Gitlab::Diff::Position.new(
17      old_path: path,
18      new_path: path,
19      old_line: nil,
20      new_line: 14,
21      diff_refs: diff_refs
22    )
23  end
24
25  let!(:new_position) do
26    Gitlab::Diff::Position.new(
27      old_path: path,
28      new_path: path,
29      old_line: 16,
30      new_line: 22,
31      diff_refs: diff_refs
32    )
33  end
34
35  subject { create(:diff_note_on_merge_request, project: project, position: position, noteable: merge_request) }
36
37  describe 'validations' do
38    it_behaves_like 'a valid diff positionable note' do
39      subject { build(:diff_note_on_commit, project: project, commit_id: commit_id, position: position) }
40    end
41
42    it "is not valid when noteable is empty" do
43      note = build(:diff_note_on_merge_request, project: project, noteable: nil)
44
45      note.valid?
46
47      expect(note.errors[:noteable]).to include("doesn't support new-style diff notes")
48    end
49
50    context 'when importing' do
51      it "does not check if it's supported" do
52        note = build(:diff_note_on_merge_request, project: project, noteable: nil)
53        note.importing = true
54        note.valid?
55
56        expect(note.errors.full_messages).not_to include(
57          "Noteable doesn't support new-style diff notes"
58        )
59      end
60    end
61  end
62
63  describe "#position=" do
64    context "when provided a string" do
65      it "sets the position" do
66        subject.position = new_position.to_json
67
68        expect(subject.position).to eq(new_position)
69      end
70    end
71
72    context "when provided a hash" do
73      it "sets the position" do
74        subject.position = new_position.to_h
75
76        expect(subject.position).to eq(new_position)
77      end
78    end
79
80    context "when provided a position object" do
81      it "sets the position" do
82        subject.position = new_position
83
84        expect(subject.position).to eq(new_position)
85      end
86    end
87  end
88
89  describe "#original_position=" do
90    context "when provided a string" do
91      it "sets the original position" do
92        subject.original_position = new_position.to_json
93
94        expect(subject.original_position).to eq(new_position)
95      end
96    end
97
98    context "when provided a hash" do
99      it "sets the original position" do
100        subject.original_position = new_position.to_h
101
102        expect(subject.original_position).to eq(new_position)
103      end
104    end
105
106    context "when provided a position object" do
107      it "sets the original position" do
108        subject.original_position = new_position
109
110        expect(subject.original_position).to eq(new_position)
111      end
112    end
113  end
114
115  describe '#create_diff_file callback' do
116    context 'merge request' do
117      let(:position) do
118        Gitlab::Diff::Position.new(old_path: "files/ruby/popen.rb",
119                                   new_path: "files/ruby/popen.rb",
120                                   old_line: nil,
121                                   new_line: 9,
122                                   diff_refs: merge_request.diff_refs)
123      end
124
125      subject { build(:diff_note_on_merge_request, project: project, position: position, noteable: merge_request) }
126
127      let(:diff_file_from_repository) do
128        position.diff_file(project.repository)
129      end
130
131      let(:diff_file) do
132        diffs = merge_request.diffs
133        raw_diff = diffs.diffable.raw_diffs(diffs.diff_options.merge(paths: ['files/ruby/popen.rb'])).first
134        Gitlab::Diff::File.new(raw_diff,
135                               repository: diffs.project.repository,
136                               diff_refs: diffs.diff_refs,
137                               fallback_diff_refs: diffs.fallback_diff_refs)
138      end
139
140      let(:diff_line) { diff_file.diff_lines.first }
141
142      let(:line_code) { '2f6fcd96b88b36ce98c38da085c795a27d92a3dd_15_14' }
143
144      before do
145        allow(subject.position).to receive(:line_code).and_return('2f6fcd96b88b36ce98c38da085c795a27d92a3dd_15_14')
146      end
147
148      context 'when diffs are already created' do
149        before do
150          allow(subject).to receive(:created_at_diff?).and_return(true)
151        end
152
153        context 'when diff_file is found in persisted diffs' do
154          before do
155            allow(merge_request).to receive_message_chain(:diffs, :diff_files, :first).and_return(diff_file)
156          end
157
158          context 'when importing' do
159            before do
160              subject.importing = true
161              subject.line_code = line_code
162            end
163
164            context 'when diff_line is found in persisted diff_file' do
165              before do
166                allow(diff_file).to receive(:line_for_position).with(position).and_return(diff_line)
167              end
168
169              it 'creates a diff note file' do
170                subject.save!
171                expect(subject.note_diff_file).to be_present
172              end
173            end
174
175            context 'when diff_line is not found in persisted diff_file' do
176              before do
177                allow(diff_file).to receive(:line_for_position).and_return(nil)
178              end
179
180              it_behaves_like 'a valid diff note with after commit callback'
181            end
182          end
183
184          context 'when not importing' do
185            context 'when diff_line is not found' do
186              before do
187                allow(diff_file).to receive(:line_for_position).with(position).and_return(nil)
188              end
189
190              it 'raises an error' do
191                expect { subject.save! }.to raise_error(::DiffNote::NoteDiffFileCreationError,
192                                                       "Failed to find diff line for: #{diff_file.file_path}, "\
193                                                       "old_line: #{position.old_line}"\
194                                                       ", new_line: #{position.new_line}")
195              end
196            end
197
198            context 'when diff_line is found' do
199              before do
200                allow(diff_file).to receive(:line_for_position).with(position).and_return(diff_line)
201              end
202
203              it 'creates a diff note file' do
204                subject.save!
205                expect(subject.reload.note_diff_file).to be_present
206              end
207            end
208          end
209        end
210
211        context 'when diff file is not found in persisted diffs' do
212          before do
213            allow_next_instance_of(Gitlab::Diff::FileCollection::MergeRequestDiff) do |merge_request_diff|
214              allow(merge_request_diff).to receive(:diff_files).and_return([])
215            end
216          end
217
218          it_behaves_like 'a valid diff note with after commit callback'
219        end
220      end
221
222      context 'when diffs are not already created' do
223        before do
224          allow(subject).to receive(:created_at_diff?).and_return(false)
225        end
226
227        it_behaves_like 'a valid diff note with after commit callback'
228      end
229
230      it 'does not create diff note file if it is a reply' do
231        diff_note = create(:diff_note_on_merge_request, project: project, noteable: merge_request)
232
233        expect { create(:diff_note_on_merge_request, noteable: merge_request, in_reply_to: diff_note) }
234          .not_to change(NoteDiffFile, :count)
235      end
236    end
237
238    context 'commit' do
239      let!(:diff_note) { create(:diff_note_on_commit, project: project) }
240
241      it 'creates a diff note file' do
242        expect(diff_note.reload.note_diff_file).to be_present
243      end
244
245      it 'does not create diff note file if it is a reply' do
246        expect { create(:diff_note_on_commit, in_reply_to: diff_note) }
247          .not_to change(NoteDiffFile, :count)
248      end
249    end
250  end
251
252  describe '#diff_file', :clean_gitlab_redis_shared_state do
253    context 'when note_diff_file association exists' do
254      it 'returns persisted diff file data' do
255        diff_file = subject.diff_file
256
257        expect(diff_file.diff.to_hash.with_indifferent_access)
258          .to include(subject.note_diff_file.to_hash)
259      end
260    end
261
262    context 'when the discussion was created in the diff' do
263      context 'when file_identifier_hash is disabled' do
264        before do
265          stub_feature_flags(file_identifier_hash: false)
266        end
267
268        it 'returns correct diff file' do
269          diff_file = subject.diff_file
270
271          expect(diff_file.old_path).to eq(position.old_path)
272          expect(diff_file.new_path).to eq(position.new_path)
273          expect(diff_file.diff_refs).to eq(position.diff_refs)
274        end
275      end
276
277      context 'when file_identifier_hash is enabled' do
278        before do
279          stub_feature_flags(file_identifier_hash: true)
280        end
281
282        it 'returns correct diff file' do
283          diff_file = subject.diff_file
284
285          expect(diff_file.old_path).to eq(position.old_path)
286          expect(diff_file.new_path).to eq(position.new_path)
287          expect(diff_file.diff_refs).to eq(position.diff_refs)
288        end
289      end
290    end
291
292    context 'when discussion is outdated or not created in the diff' do
293      let(:diff_refs) { project.commit(sample_commit.id).diff_refs }
294      let(:position) do
295        Gitlab::Diff::Position.new(
296          old_path: "files/ruby/popen.rb",
297          new_path: "files/ruby/popen.rb",
298          old_line: nil,
299          new_line: 14,
300          diff_refs: diff_refs
301        )
302      end
303
304      it 'returns the correct diff file' do
305        diff_file = subject.diff_file
306
307        expect(diff_file.old_path).to eq(position.old_path)
308        expect(diff_file.new_path).to eq(position.new_path)
309        expect(diff_file.diff_refs).to eq(position.diff_refs)
310      end
311    end
312
313    context 'note diff file creation enqueuing' do
314      it 'enqueues CreateNoteDiffFileWorker if it is the first note of a discussion' do
315        subject.note_diff_file.destroy!
316
317        expect(CreateNoteDiffFileWorker).to receive(:perform_async).with(subject.id)
318
319        subject.reload.diff_file
320      end
321
322      it 'does not enqueues CreateNoteDiffFileWorker if not first note of a discussion' do
323        mr = create(:merge_request)
324        diff_note = create(:diff_note_on_merge_request, project: mr.project, noteable: mr)
325        reply_diff_note = create(:diff_note_on_merge_request, in_reply_to: diff_note)
326
327        expect(CreateNoteDiffFileWorker).not_to receive(:perform_async).with(reply_diff_note.id)
328
329        reply_diff_note.reload.diff_file
330      end
331    end
332
333    context 'when noteable is a Design' do
334      it 'does not return a diff file' do
335        diff_note = create(:diff_note_on_design)
336
337        expect(diff_note.diff_file).to be_nil
338      end
339    end
340  end
341
342  describe '#latest_diff_file' do
343    context 'when noteable is a Design' do
344      it 'does not return a diff file' do
345        diff_note = create(:diff_note_on_design)
346
347        expect(diff_note.latest_diff_file).to be_nil
348      end
349    end
350  end
351
352  describe "#diff_line" do
353    it "returns the correct diff line" do
354      diff_line = subject.diff_line
355
356      expect(diff_line.added?).to be true
357      expect(diff_line.new_line).to eq(position.formatter.new_line)
358      expect(diff_line.text).to eq("+    vars = {")
359    end
360  end
361
362  describe "#line_code" do
363    it "returns the correct line code" do
364      line_code = Gitlab::Git.diff_line_code(position.file_path, position.formatter.new_line, 15)
365
366      expect(subject.line_code).to eq(line_code)
367    end
368  end
369
370  describe "#active?" do
371    context "when noteable is a commit" do
372      subject { build(:diff_note_on_commit, project: project, position: position) }
373
374      it "returns true" do
375        expect(subject.active?).to be true
376      end
377    end
378
379    context "when noteable is a merge request" do
380      context "when the merge request's diff refs match that of the diff note" do
381        it "returns true" do
382          expect(subject.active?).to be true
383        end
384      end
385
386      context "when the merge request's diff refs don't match that of the diff note" do
387        before do
388          allow(subject.noteable).to receive(:diff_refs).and_return(commit.diff_refs)
389        end
390
391        it "returns false" do
392          expect(subject.active?).to be false
393        end
394      end
395    end
396  end
397
398  describe "creation" do
399    describe "updating of position" do
400      context "when noteable is a commit" do
401        let(:diff_refs) { commit.diff_refs }
402
403        subject { create(:diff_note_on_commit, project: project, position: position, commit_id: commit.id) }
404
405        it "doesn't update the position" do
406          is_expected.to have_attributes(original_position: position,
407                                         position: position)
408        end
409      end
410
411      context "when noteable is a merge request" do
412        context "when the note is active" do
413          it "doesn't update the position" do
414            expect(subject.original_position).to eq(position)
415            expect(subject.position).to eq(position)
416          end
417        end
418
419        context "when the note is outdated" do
420          before do
421            allow(merge_request).to receive(:diff_refs).and_return(commit.diff_refs)
422          end
423
424          it "updates the position" do
425            expect(subject.original_position).to eq(position)
426            expect(subject.position).not_to eq(position)
427          end
428        end
429      end
430    end
431  end
432
433  describe "#discussion_id" do
434    let(:note) { create(:diff_note_on_merge_request) }
435
436    context "when it is newly created" do
437      it "has a discussion id" do
438        expect(note.discussion_id).not_to be_nil
439        expect(note.discussion_id).to match(/\A\h{40}\z/)
440      end
441    end
442
443    context "when it didn't store a discussion id before" do
444      before do
445        note.update_column(:discussion_id, nil)
446      end
447
448      it "has a discussion id" do
449        # The discussion_id is set in `after_initialize`, so `reload` won't work
450        reloaded_note = Note.find(note.id)
451
452        expect(reloaded_note.discussion_id).not_to be_nil
453        expect(reloaded_note.discussion_id).to match(/\A\h{40}\z/)
454      end
455    end
456  end
457
458  describe '#created_at_diff?' do
459    let(:diff_refs) { project.commit(sample_commit.id).diff_refs }
460    let(:position) do
461      Gitlab::Diff::Position.new(
462        old_path: "files/ruby/popen.rb",
463        new_path: "files/ruby/popen.rb",
464        old_line: nil,
465        new_line: 14,
466        diff_refs: diff_refs
467      )
468    end
469
470    context "when noteable is a commit" do
471      subject { build(:diff_note_on_commit, project: project, position: position) }
472
473      it "returns true" do
474        expect(subject.created_at_diff?(diff_refs)).to be true
475      end
476    end
477
478    context "when noteable is a merge request" do
479      context "when the diff refs match the original one of the diff note" do
480        it "returns true" do
481          expect(subject.created_at_diff?(diff_refs)).to be true
482        end
483      end
484
485      context "when the diff refs don't match the original one of the diff note" do
486        it "returns false" do
487          expect(subject.created_at_diff?(merge_request.diff_refs)).to be false
488        end
489      end
490    end
491  end
492
493  describe '#supports_suggestion?' do
494    context 'when noteable does not exist' do
495      it 'returns false' do
496        allow(subject).to receive(:noteable) { nil }
497
498        expect(subject.supports_suggestion?).to be(false)
499      end
500    end
501
502    context 'when noteable does not support suggestions' do
503      it 'returns false' do
504        allow(subject.noteable).to receive(:supports_suggestion?) { false }
505
506        expect(subject.supports_suggestion?).to be(false)
507      end
508    end
509
510    context 'when line is not suggestible' do
511      it 'returns false' do
512        allow_next_instance_of(Gitlab::Diff::Line) do |instance|
513          allow(instance).to receive(:suggestible?) { false }
514        end
515
516        expect(subject.supports_suggestion?).to be(false)
517      end
518    end
519  end
520
521  describe '#banzai_render_context' do
522    let(:note) { create(:diff_note_on_merge_request) }
523
524    it 'includes expected context' do
525      context = note.banzai_render_context(:note)
526
527      expect(context).to include(suggestions_filter_enabled: true, noteable: note.noteable, project: note.project)
528    end
529  end
530
531  describe "image diff notes" do
532    subject { build(:image_diff_note_on_merge_request, project: project, noteable: merge_request) }
533
534    describe "validations" do
535      it { is_expected.not_to validate_presence_of(:line_code) }
536
537      it "does not validate diff line" do
538        diff_line = subject.diff_line
539
540        expect(diff_line).to be nil
541        expect(subject).to be_valid
542      end
543
544      it "does not update the position" do
545        expect(subject).not_to receive(:update_position)
546
547        subject.save!
548      end
549    end
550
551    it "returns true for on_image?" do
552      expect(subject.on_image?).to be_truthy
553    end
554  end
555
556  describe '#to_ability_name' do
557    subject { described_class.new.to_ability_name }
558
559    it { is_expected.to eq('note') }
560  end
561
562  describe '#shas' do
563    it 'returns list of SHAs based on original_position' do
564      expect(subject.shas).to match_array([
565        position.base_sha,
566        position.start_sha,
567        position.head_sha
568      ])
569    end
570
571    context 'when position changes' do
572      before do
573        subject.position = new_position
574      end
575
576      it 'includes the new position SHAs' do
577        expect(subject.shas).to match_array([
578          position.base_sha,
579          position.start_sha,
580          position.head_sha,
581          new_position.base_sha,
582          new_position.start_sha,
583          new_position.head_sha
584        ])
585      end
586    end
587  end
588end
589