]> source.dussan.org Git - sonarqube.git/blob
ad3ab1c94297bf6434804d5a9d0a3e049f67b2b3
[sonarqube.git] /
1 /*
2  * SonarQube
3  * Copyright (C) 2009-2024 SonarSource SA
4  * mailto:info AT sonarsource DOT com
5  *
6  * This program is free software; you can redistribute it and/or
7  * modify it under the terms of the GNU Lesser General Public
8  * License as published by the Free Software Foundation; either
9  * version 3 of the License, or (at your option) any later version.
10  *
11  * This program is distributed in the hope that it will be useful,
12  * but WITHOUT ANY WARRANTY; without even the implied warranty of
13  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
14  * Lesser General Public License for more details.
15  *
16  * You should have received a copy of the GNU Lesser General Public License
17  * along with this program; if not, write to the Free Software Foundation,
18  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19  */
20 package org.sonar.server.issue.notification;
21
22 import com.google.common.collect.ImmutableSet;
23 import com.tngtech.java.junit.dataprovider.DataProvider;
24 import com.tngtech.java.junit.dataprovider.DataProviderRunner;
25 import com.tngtech.java.junit.dataprovider.UseDataProvider;
26 import java.util.Arrays;
27 import java.util.Collections;
28 import java.util.List;
29 import java.util.Locale;
30 import java.util.Random;
31 import java.util.Set;
32 import java.util.function.Function;
33 import java.util.stream.IntStream;
34 import java.util.stream.Stream;
35 import org.elasticsearch.common.util.set.Sets;
36 import org.junit.Test;
37 import org.junit.runner.RunWith;
38 import org.sonar.api.config.EmailSettings;
39 import org.sonar.api.notifications.Notification;
40 import org.sonar.api.rules.RuleType;
41 import org.sonar.core.i18n.I18n;
42 import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.AnalysisChange;
43 import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Change;
44 import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.ChangedIssue;
45 import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Project;
46 import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.Rule;
47 import org.sonar.server.issue.notification.IssuesChangesNotificationBuilder.UserChange;
48 import org.sonar.test.html.HtmlFragmentAssert;
49 import org.sonar.test.html.HtmlListAssert;
50 import org.sonar.test.html.HtmlParagraphAssert;
51
52 import static java.util.stream.Collectors.joining;
53 import static java.util.stream.Collectors.toList;
54 import static java.util.stream.Collectors.toSet;
55 import static org.apache.commons.lang3.RandomStringUtils.randomAlphabetic;
56 import static org.apache.commons.lang3.StringEscapeUtils.escapeHtml4;
57 import static org.assertj.core.api.Assertions.assertThat;
58 import static org.assertj.core.api.Assertions.assertThatThrownBy;
59 import static org.mockito.Mockito.mock;
60 import static org.mockito.Mockito.when;
61 import static org.sonar.api.issue.Issue.STATUS_CLOSED;
62 import static org.sonar.api.issue.Issue.STATUS_CONFIRMED;
63 import static org.sonar.api.issue.Issue.STATUS_OPEN;
64 import static org.sonar.api.issue.Issue.STATUS_REOPENED;
65 import static org.sonar.api.issue.Issue.STATUS_RESOLVED;
66 import static org.sonar.api.issue.Issue.STATUS_REVIEWED;
67 import static org.sonar.api.issue.Issue.STATUS_TO_REVIEW;
68 import static org.sonar.api.rules.RuleType.SECURITY_HOTSPOT;
69 import static org.sonar.server.issue.notification.IssuesChangesNotificationBuilderTesting.newAnalysisChange;
70 import static org.sonar.server.issue.notification.IssuesChangesNotificationBuilderTesting.newBranch;
71 import static org.sonar.server.issue.notification.IssuesChangesNotificationBuilderTesting.newChangedIssue;
72 import static org.sonar.server.issue.notification.IssuesChangesNotificationBuilderTesting.newProject;
73 import static org.sonar.server.issue.notification.IssuesChangesNotificationBuilderTesting.newRandomNotAHotspotRule;
74 import static org.sonar.server.issue.notification.IssuesChangesNotificationBuilderTesting.newRule;
75 import static org.sonar.server.issue.notification.IssuesChangesNotificationBuilderTesting.newSecurityHotspotRule;
76 import static org.sonar.server.issue.notification.IssuesChangesNotificationBuilderTesting.newUserChange;
77 import static org.sonar.server.issue.notification.IssuesChangesNotificationBuilderTesting.randomRuleTypeHotspotExcluded;
78
79 @RunWith(DataProviderRunner.class)
80 public class ChangesOnMyIssuesEmailTemplateTest {
81   private static final String[] ISSUE_STATUSES = {STATUS_OPEN, STATUS_RESOLVED, STATUS_CONFIRMED, STATUS_REOPENED, STATUS_CLOSED};
82   private static final String[] SECURITY_HOTSPOTS_STATUSES = {STATUS_TO_REVIEW, STATUS_REVIEWED};
83
84
85   private I18n i18n = mock(I18n.class);
86   private EmailSettings emailSettings = mock(EmailSettings.class);
87   private ChangesOnMyIssuesEmailTemplate underTest = new ChangesOnMyIssuesEmailTemplate(i18n, emailSettings);
88
89   @Test
90   public void format_returns_null_on_Notification() {
91     EmailMessage emailMessage = underTest.format(mock(Notification.class));
92
93     assertThat(emailMessage).isNull();
94   }
95
96   @Test
97   public void formats_fails_with_ISE_if_change_from_Analysis_and_no_issue() {
98     AnalysisChange analysisChange = newAnalysisChange();
99
100     assertThatThrownBy(() -> underTest.format(new ChangesOnMyIssuesNotification(analysisChange, Collections.emptySet())))
101       .isInstanceOf(IllegalStateException.class)
102       .hasMessage("changedIssues can't be empty");
103   }
104
105   @Test
106   public void format_sets_message_id_with_project_key_of_first_issue_in_set_when_change_from_Analysis() {
107     Set<ChangedIssue> changedIssues = IntStream.range(0, 2 + new Random().nextInt(4))
108       .mapToObj(i -> newChangedIssue(i + "", randomValidStatus(), newProject("prj_" + i), newRandomNotAHotspotRule("rule_" + i)))
109       .collect(toSet());
110     AnalysisChange analysisChange = newAnalysisChange();
111
112     EmailMessage emailMessage = underTest.format(new ChangesOnMyIssuesNotification(analysisChange, changedIssues));
113
114     assertThat(emailMessage.getMessageId()).isEqualTo("changes-on-my-issues/" + changedIssues.iterator().next().getProject().getKey());
115   }
116
117   @Test
118   public void format_sets_subject_with_project_name_of_first_issue_in_set_when_change_from_Analysis() {
119     Set<ChangedIssue> changedIssues = IntStream.range(0, 2 + new Random().nextInt(4))
120       .mapToObj(i -> newChangedIssue(i + "", randomValidStatus(), newProject("prj_" + i), newRandomNotAHotspotRule("rule_" + i)))
121       .collect(toSet());
122     AnalysisChange analysisChange = IssuesChangesNotificationBuilderTesting.newAnalysisChange();
123
124     EmailMessage emailMessage = underTest.format(new ChangesOnMyIssuesNotification(analysisChange, changedIssues));
125
126     Project project = changedIssues.iterator().next().getProject();
127     assertThat(emailMessage.getSubject()).isEqualTo("Analysis has changed some of your issues in " + project.getProjectName());
128   }
129
130   @Test
131   public void format_sets_subject_with_project_name_and_branch_name_of_first_issue_in_set_when_change_from_Analysis() {
132     Set<ChangedIssue> changedIssues = IntStream.range(0, 2 + new Random().nextInt(4))
133       .mapToObj(i -> newChangedIssue(i + "", randomValidStatus(), newBranch("prj_" + i, "br_" + i), newRandomNotAHotspotRule("rule_" + i)))
134       .collect(toSet());
135     AnalysisChange analysisChange = newAnalysisChange();
136
137     EmailMessage emailMessage = underTest.format(new ChangesOnMyIssuesNotification(analysisChange, changedIssues));
138
139     Project project = changedIssues.iterator().next().getProject();
140     assertThat(emailMessage.getSubject()).isEqualTo("Analysis has changed some of your issues in " + project.getProjectName() + " (" + project.getBranchName().get() + ")");
141   }
142
143   @Test
144   public void format_set_html_message_with_header_dealing_with_plural_when_change_from_Analysis() {
145     Set<ChangedIssue> changedIssues = IntStream.range(0, 2 + new Random().nextInt(4))
146       .mapToObj(i -> newChangedIssue(i + "", randomValidStatus(), newProject("prj_" + i), newRandomNotAHotspotRule("rule_" + i)))
147       .collect(toSet());
148     AnalysisChange analysisChange = newAnalysisChange();
149
150     EmailMessage singleIssueMessage = underTest.format(new ChangesOnMyIssuesNotification(analysisChange, changedIssues.stream().limit(1).collect(toSet())));
151     EmailMessage multiIssueMessage = underTest.format(new ChangesOnMyIssuesNotification(analysisChange, changedIssues));
152
153     HtmlFragmentAssert.assertThat(singleIssueMessage.getMessage())
154       .hasParagraph("Hi,")
155       .hasParagraph("An analysis has updated an issue assigned to you:");
156     HtmlFragmentAssert.assertThat(multiIssueMessage.getMessage())
157       .hasParagraph("Hi,")
158       .hasParagraph("An analysis has updated issues assigned to you:");
159   }
160
161   @Test
162   public void format_sets_static_message_id_when_change_from_User() {
163     Set<ChangedIssue> changedIssues = IntStream.range(0, 2 + new Random().nextInt(4))
164       .mapToObj(i -> newChangedIssue(i + "", randomValidStatus(), newProject("prj_" + i), newRandomNotAHotspotRule("rule_" + i)))
165       .collect(toSet());
166     UserChange userChange = newUserChange();
167
168     EmailMessage emailMessage = underTest.format(new ChangesOnMyIssuesNotification(userChange, changedIssues));
169
170     assertThat(emailMessage.getMessageId()).isEqualTo("changes-on-my-issues");
171   }
172
173   @Test
174   public void format_sets_static_subject_when_change_from_User() {
175     Set<ChangedIssue> changedIssues = IntStream.range(0, 2 + new Random().nextInt(4))
176       .mapToObj(i -> newChangedIssue(i + "", randomValidStatus(), newProject("prj_" + i), newRandomNotAHotspotRule("rule_" + i)))
177       .collect(toSet());
178     UserChange userChange = newUserChange();
179
180     EmailMessage emailMessage = underTest.format(new ChangesOnMyIssuesNotification(userChange, changedIssues));
181
182     assertThat(emailMessage.getSubject()).isEqualTo("A manual update has changed some of your issues/hotspots");
183   }
184
185   @Test
186   public void format_set_html_message_with_header_dealing_with_plural_issues_when_change_from_User() {
187     Set<ChangedIssue> changedIssues = IntStream.range(0, 2 + new Random().nextInt(4))
188       .mapToObj(i -> newChangedIssue(i + "", randomValidStatus(), newProject("prj_" + i), newRandomNotAHotspotRule("rule_" + i)))
189       .collect(toSet());
190     UserChange userChange = newUserChange();
191
192     EmailMessage singleIssueMessage = underTest.format(new ChangesOnMyIssuesNotification(
193       userChange, changedIssues.stream().limit(1).collect(toSet())));
194     EmailMessage multiIssueMessage = underTest.format(new ChangesOnMyIssuesNotification(userChange, changedIssues));
195
196     HtmlFragmentAssert.assertThat(singleIssueMessage.getMessage())
197       .hasParagraph("Hi,")
198       .withoutLink()
199       .hasParagraph("A manual change has updated an issue assigned to you:")
200       .withoutLink();
201     HtmlFragmentAssert.assertThat(multiIssueMessage.getMessage())
202       .hasParagraph("Hi,")
203       .withoutLink()
204       .hasParagraph("A manual change has updated issues assigned to you:")
205       .withoutLink();
206   }
207
208   @Test
209   public void format_set_html_message_with_header_dealing_with_plural_security_hotspots_when_change_from_User() {
210     Set<ChangedIssue> changedIssues = IntStream.range(0, 2 + new Random().nextInt(4))
211       .mapToObj(i -> newChangedIssue(i + "", randomValidStatus(), newProject("prj_" + i), newSecurityHotspotRule("rule_" + i)))
212       .collect(toSet());
213     UserChange userChange = newUserChange();
214
215     EmailMessage singleIssueMessage = underTest.format(new ChangesOnMyIssuesNotification(
216       userChange, changedIssues.stream().limit(1).collect(toSet())));
217     EmailMessage multiIssueMessage = underTest.format(new ChangesOnMyIssuesNotification(userChange, changedIssues));
218
219     HtmlFragmentAssert.assertThat(singleIssueMessage.getMessage())
220       .hasParagraph("Hi,")
221       .withoutLink()
222       .hasParagraph("A manual change has updated a hotspot assigned to you:")
223       .withoutLink();
224     HtmlFragmentAssert.assertThat(multiIssueMessage.getMessage())
225       .hasParagraph("Hi,")
226       .withoutLink()
227       .hasParagraph("A manual change has updated hotspots assigned to you:")
228       .withoutLink();
229   }
230
231   @Test
232   public void format_set_html_message_with_header_dealing_with_plural_security_hotspots_and_issues_when_change_from_User() {
233     Set<ChangedIssue> changedIssues = IntStream.range(0, 2 + new Random().nextInt(4))
234       .mapToObj(i -> newChangedIssue(i + "", randomValidStatus(), newProject("prj_" + i), newRandomNotAHotspotRule("rule_" + i)))
235       .collect(toSet());
236
237     Set<ChangedIssue> changedHotspots = IntStream.range(0, 2 + new Random().nextInt(4))
238       .mapToObj(i -> newChangedIssue(i + "", randomValidStatus(), newProject("prj_" + i), newSecurityHotspotRule("rule_" + i)))
239       .collect(toSet());
240
241     Set<ChangedIssue> issuesAndHotspots = Sets.union(changedIssues, changedHotspots);
242
243     UserChange userChange = newUserChange();
244
245     EmailMessage multiIssueMessage = underTest.format(new ChangesOnMyIssuesNotification(userChange, issuesAndHotspots));
246
247     HtmlFragmentAssert.assertThat(multiIssueMessage.getMessage())
248       .hasParagraph("Hi,")
249       .withoutLink()
250       .hasParagraph("A manual change has updated issues/hotspots assigned to you:")
251       .withoutLink();
252   }
253
254   @Test
255   @UseDataProvider("issueStatuses")
256   public void format_set_html_message_with_footer_when_issue_change_from_user(String issueStatus) {
257     UserChange userChange = newUserChange();
258     format_set_html_message_with_footer(userChange, issueStatus, c -> c
259       // skip content
260       .hasParagraph() // skip project header
261       .hasList(), // rule list,
262       randomRuleTypeHotspotExcluded());
263   }
264
265   @Test
266   @UseDataProvider("issueStatuses")
267   public void format_set_html_message_with_footer_when_issue_change_from_analysis(String issueStatus) {
268     AnalysisChange analysisChange = newAnalysisChange();
269     format_set_html_message_with_footer(analysisChange, issueStatus, c -> c
270       .hasParagraph() // status
271       .hasList(), // rule list,
272       randomRuleTypeHotspotExcluded());
273   }
274
275   @Test
276   @UseDataProvider("securityHotspotsStatuses")
277   public void format_set_html_message_with_footer_when_security_hotspot_change_from_analysis(String securityHotspotStatus) {
278     AnalysisChange analysisChange = newAnalysisChange();
279     format_set_html_message_with_footer(analysisChange, securityHotspotStatus, c -> c
280       .hasParagraph()
281       .hasList(), // rule list
282       SECURITY_HOTSPOT);
283   }
284
285   @Test
286   @UseDataProvider("securityHotspotsStatuses")
287   public void format_set_html_message_with_footer_when_security_hotspot_change_from_user(String securityHotspotStatus) {
288     UserChange userChange = newUserChange();
289     format_set_html_message_with_footer(userChange, securityHotspotStatus, c -> c
290       .hasParagraph()
291       .hasList(), // rule list
292       SECURITY_HOTSPOT);
293   }
294
295   @DataProvider
296   public static Object[][] issueStatuses() {
297     return Arrays.stream(ISSUE_STATUSES)
298       .map(t -> new Object[] {t})
299       .toArray(Object[][]::new);
300   }
301
302   @DataProvider
303   public static Object[][] securityHotspotsStatuses() {
304     return Arrays.stream(SECURITY_HOTSPOTS_STATUSES)
305       .map(t -> new Object[] {t})
306       .toArray(Object[][]::new);
307   }
308
309   private void format_set_html_message_with_footer(Change change, String issueStatus, Function<HtmlParagraphAssert, HtmlListAssert> skipContent, RuleType ruleType) {
310     String wordingNotification = randomAlphabetic(20);
311     String host = randomAlphabetic(15);
312     when(i18n.message(Locale.ENGLISH, "notification.dispatcher.ChangesOnMyIssue", "notification.dispatcher.ChangesOnMyIssue"))
313       .thenReturn(wordingNotification);
314     when(emailSettings.getServerBaseURL()).thenReturn(host);
315     Project project = newProject("foo");
316     Rule rule = newRule("bar", ruleType);
317     Set<ChangedIssue> changedIssues = IntStream.range(0, 2 + new Random().nextInt(4))
318       .mapToObj(i -> newChangedIssue(i + "", issueStatus, project, rule))
319       .collect(toSet());
320
321     EmailMessage singleIssueMessage = underTest.format(new ChangesOnMyIssuesNotification(
322       change, changedIssues.stream().limit(1).collect(toSet())));
323     EmailMessage multiIssueMessage = underTest.format(new ChangesOnMyIssuesNotification(change, changedIssues));
324
325     Stream.of(singleIssueMessage, multiIssueMessage)
326       .forEach(issueMessage -> {
327         HtmlParagraphAssert htmlAssert = HtmlFragmentAssert.assertThat(issueMessage.getMessage())
328           .hasParagraph().hasParagraph(); // skip header
329         // skip content
330         HtmlListAssert htmlListAssert = skipContent.apply(htmlAssert);
331
332         String footerText = "You received this email because you are subscribed to \"" + wordingNotification + "\" notifications from SonarQube."
333           + " Click here to edit your email preferences.";
334         htmlListAssert.hasEmptyParagraph()
335           .hasParagraph(footerText)
336           .withSmallOn(footerText)
337           .withLink("here", host + "/account/notifications")
338           .noMoreBlock();
339       });
340   }
341
342   @Test
343   public void format_set_html_message_with_issues_grouped_by_status_closed_or_any_other_when_change_from_analysis() {
344     Project project = newProject("foo");
345     Rule rule = newRandomNotAHotspotRule("bar");
346     Set<ChangedIssue> changedIssues = Arrays.stream(ISSUE_STATUSES)
347       .map(status -> newChangedIssue(status + "", status, project, rule))
348       .collect(toSet());
349     AnalysisChange analysisChange = newAnalysisChange();
350
351     EmailMessage emailMessage = underTest.format(new ChangesOnMyIssuesNotification(analysisChange, changedIssues));
352
353     HtmlListAssert htmlListAssert = HtmlFragmentAssert.assertThat(emailMessage.getMessage())
354       .hasParagraph().hasParagraph() // skip header
355       .hasParagraph("Closed issue:")
356       .withoutLink()
357       .hasList("Rule " + rule.getName() + " - See the single issue")
358       .withLinkOn("See the single issue")
359       .hasParagraph("Open issues:")
360       .withoutLink()
361       .hasList("Rule " + rule.getName() + " - See all " + (ISSUE_STATUSES.length - 1) + " issues")
362       .withLinkOn("See all " + (ISSUE_STATUSES.length - 1) + " issues");
363     verifyEnd(htmlListAssert);
364   }
365
366   @Test
367   public void format_set_html_message_with_issue_status_title_handles_plural_when_change_from_analysis() {
368     Project project = newProject("foo");
369     Rule rule = newRandomNotAHotspotRule("bar");
370     Set<ChangedIssue> closedIssues = IntStream.range(0, 2 + new Random().nextInt(5))
371       .mapToObj(status -> newChangedIssue(status + "", STATUS_CLOSED, project, rule))
372       .collect(toSet());
373     Set<ChangedIssue> openIssues = IntStream.range(0, 2 + new Random().nextInt(5))
374       .mapToObj(status -> newChangedIssue(status + "", STATUS_OPEN, project, rule))
375       .collect(toSet());
376     AnalysisChange analysisChange = newAnalysisChange();
377
378     EmailMessage closedIssuesMessage = underTest.format(new ChangesOnMyIssuesNotification(analysisChange, closedIssues));
379     EmailMessage openIssuesMessage = underTest.format(new ChangesOnMyIssuesNotification(analysisChange, openIssues));
380
381     HtmlListAssert htmlListAssert = HtmlFragmentAssert.assertThat(closedIssuesMessage.getMessage())
382       .hasParagraph().hasParagraph() // skip header
383       .hasParagraph("Closed issues:")
384       .hasList();
385     verifyEnd(htmlListAssert);
386     htmlListAssert = HtmlFragmentAssert.assertThat(openIssuesMessage.getMessage())
387       .hasParagraph().hasParagraph() // skip header
388       .hasParagraph("Open issues:")
389       .hasList();
390     verifyEnd(htmlListAssert);
391   }
392
393   @Test
394   public void formats_returns_html_message_for_single_issue_on_master_when_analysis_change() {
395     Project project = newProject("1");
396     String ruleName = randomAlphabetic(8);
397     String host = randomAlphabetic(15);
398     ChangedIssue changedIssue = newChangedIssue("key", randomValidStatus(), project, ruleName, randomRuleTypeHotspotExcluded());
399     AnalysisChange analysisChange = newAnalysisChange();
400     when(emailSettings.getServerBaseURL()).thenReturn(host);
401
402     EmailMessage emailMessage = underTest.format(new ChangesOnMyIssuesNotification(analysisChange, ImmutableSet.of(changedIssue)));
403
404     HtmlFragmentAssert.assertThat(emailMessage.getMessage())
405       .hasParagraph().hasParagraph() // skip header
406       .hasParagraph()// skip title based on status
407       .hasList("Rule " + ruleName + " - See the single issue")
408       .withLink("See the single issue", host + "/project/issues?id=" + project.getKey() + "&issues=" + changedIssue.getKey() + "&open=" + changedIssue.getKey())
409       .hasParagraph().hasParagraph() // skip footer
410       .noMoreBlock();
411   }
412
413   @Test
414   public void user_input_content_should_be_html_escape() {
415     Project project = new Project.Builder("uuid").setProjectName("</projectName>").setKey("project_key").build();
416     String ruleName = "</RandomRule>";
417     String host = randomAlphabetic(15);
418     Rule rule = newRule(ruleName, randomRuleTypeHotspotExcluded());
419     List<ChangedIssue> changedIssues = IntStream.range(0, 2 + new Random().nextInt(5))
420       .mapToObj(i -> newChangedIssue("issue_" + i, randomValidStatus(), project, rule))
421       .collect(toList());
422     UserChange userChange = newUserChange();
423     when(emailSettings.getServerBaseURL()).thenReturn(host);
424
425     EmailMessage emailMessage = underTest.format(new ChangesOnMyIssuesNotification(userChange, ImmutableSet.copyOf(changedIssues)));
426
427     assertThat(emailMessage.getMessage())
428       .doesNotContain(project.getProjectName())
429       .contains(escapeHtml4(project.getProjectName()))
430       .doesNotContain(ruleName)
431       .contains(escapeHtml4(ruleName));
432
433     String expectedHref = host + "/project/issues?id=" + project.getKey()
434       + "&issues=" + changedIssues.stream().map(ChangedIssue::getKey).collect(joining("%2C"));
435     String expectedLinkText = "See all " + changedIssues.size() + " issues";
436     HtmlFragmentAssert.assertThat(emailMessage.getMessage())
437       .hasParagraph().hasParagraph() // skip header
438       .hasParagraph(project.getProjectName())
439       .hasList("Rule " + ruleName + " - " + expectedLinkText)
440       .withLink(expectedLinkText, expectedHref)
441       .hasParagraph().hasParagraph() // skip footer
442       .noMoreBlock();
443   }
444
445   @Test
446   public void formats_returns_html_message_for_single_issue_on_master_when_user_change() {
447     Project project = newProject("1");
448     String ruleName = randomAlphabetic(8);
449     String host = randomAlphabetic(15);
450     ChangedIssue changedIssue = newChangedIssue("key", randomValidStatus(), project, ruleName, randomRuleTypeHotspotExcluded());
451     UserChange userChange = newUserChange();
452     when(emailSettings.getServerBaseURL()).thenReturn(host);
453
454     EmailMessage emailMessage = underTest.format(new ChangesOnMyIssuesNotification(userChange, ImmutableSet.of(changedIssue)));
455
456     HtmlFragmentAssert.assertThat(emailMessage.getMessage())
457       .hasParagraph().hasParagraph() // skip header
458       .hasParagraph(project.getProjectName())
459       .hasList("Rule " + ruleName + " - See the single issue")
460       .withLink("See the single issue", host + "/project/issues?id=" + project.getKey() + "&issues=" + changedIssue.getKey() + "&open=" + changedIssue.getKey())
461       .hasParagraph().hasParagraph() // skip footer
462       .noMoreBlock();
463   }
464
465   @Test
466   public void formats_returns_html_message_for_single_issue_on_branch_when_analysis_change() {
467     String branchName = randomAlphabetic(6);
468     Project project = newBranch("1", branchName);
469     String ruleName = randomAlphabetic(8);
470     String host = randomAlphabetic(15);
471     String key = "key";
472     ChangedIssue changedIssue = newChangedIssue(key, randomValidStatus(), project, ruleName, randomRuleTypeHotspotExcluded());
473     AnalysisChange analysisChange = newAnalysisChange();
474     when(emailSettings.getServerBaseURL()).thenReturn(host);
475
476     EmailMessage emailMessage = underTest.format(new ChangesOnMyIssuesNotification(analysisChange, ImmutableSet.of(changedIssue)));
477
478     HtmlFragmentAssert.assertThat(emailMessage.getMessage())
479       .hasParagraph().hasParagraph() // skip header
480       .hasParagraph()// skip title based on status
481       .hasList("Rule " + ruleName + " - See the single issue")
482       .withLink("See the single issue",
483         host + "/project/issues?id=" + project.getKey() + "&branch=" + branchName + "&issues=" + changedIssue.getKey() + "&open=" + changedIssue.getKey())
484       .hasParagraph().hasParagraph() // skip footer
485       .noMoreBlock();
486   }
487
488   @Test
489   public void formats_returns_html_message_for_single_issue_on_branch_when_user_change() {
490     String branchName = randomAlphabetic(6);
491     Project project = newBranch("1", branchName);
492     String ruleName = randomAlphabetic(8);
493     String host = randomAlphabetic(15);
494     String key = "key";
495     ChangedIssue changedIssue = newChangedIssue(key, randomValidStatus(), project, ruleName, randomRuleTypeHotspotExcluded());
496     UserChange userChange = newUserChange();
497     when(emailSettings.getServerBaseURL()).thenReturn(host);
498
499     EmailMessage emailMessage = underTest.format(new ChangesOnMyIssuesNotification(userChange, ImmutableSet.of(changedIssue)));
500
501     HtmlFragmentAssert.assertThat(emailMessage.getMessage())
502       .hasParagraph().hasParagraph() // skip header
503       .hasParagraph(project.getProjectName() + ", " + branchName)
504       .hasList("Rule " + ruleName + " - See the single issue")
505       .withLink("See the single issue",
506         host + "/project/issues?id=" + project.getKey() + "&branch=" + branchName + "&issues=" + changedIssue.getKey() + "&open=" + changedIssue.getKey())
507       .hasParagraph().hasParagraph() // skip footer
508       .noMoreBlock();
509   }
510
511   @Test
512   public void formats_returns_html_message_for_multiple_issues_of_same_rule_on_same_project_on_master_when_analysis_change() {
513     Project project = newProject("1");
514     String ruleName = randomAlphabetic(8);
515     String host = randomAlphabetic(15);
516     Rule rule = newRule(ruleName, randomRuleTypeHotspotExcluded());
517     String issueStatus = randomValidStatus();
518     List<ChangedIssue> changedIssues = IntStream.range(0, 2 + new Random().nextInt(5))
519       .mapToObj(i -> newChangedIssue("issue_" + i, issueStatus, project, rule))
520       .collect(toList());
521     AnalysisChange analysisChange = newAnalysisChange();
522     when(emailSettings.getServerBaseURL()).thenReturn(host);
523
524     EmailMessage emailMessage = underTest.format(new ChangesOnMyIssuesNotification(analysisChange, ImmutableSet.copyOf(changedIssues)));
525
526     String expectedHref = host + "/project/issues?id=" + project.getKey()
527       + "&issues=" + changedIssues.stream().map(ChangedIssue::getKey).collect(joining("%2C"));
528     String expectedLinkText = "See all " + changedIssues.size() + " issues";
529     HtmlFragmentAssert.assertThat(emailMessage.getMessage())
530       .hasParagraph().hasParagraph() // skip header
531       .hasParagraph() // skip title based on status
532       .hasList("Rule " + ruleName + " - " + expectedLinkText)
533       .withLink(expectedLinkText, expectedHref)
534       .hasParagraph().hasParagraph() // skip footer
535       .noMoreBlock();
536   }
537
538   @Test
539   public void formats_returns_html_message_for_multiple_issues_of_same_rule_on_same_project_on_master_when_user_change() {
540     Project project = newProject("1");
541     String ruleName = randomAlphabetic(8);
542     String host = randomAlphabetic(15);
543     Rule rule = newRule(ruleName, randomRuleTypeHotspotExcluded());
544     List<ChangedIssue> changedIssues = IntStream.range(0, 2 + new Random().nextInt(5))
545       .mapToObj(i -> newChangedIssue("issue_" + i, randomValidStatus(), project, rule))
546       .collect(toList());
547     UserChange userChange = newUserChange();
548     when(emailSettings.getServerBaseURL()).thenReturn(host);
549
550     EmailMessage emailMessage = underTest.format(new ChangesOnMyIssuesNotification(userChange, ImmutableSet.copyOf(changedIssues)));
551
552     String expectedHref = host + "/project/issues?id=" + project.getKey()
553       + "&issues=" + changedIssues.stream().map(ChangedIssue::getKey).collect(joining("%2C"));
554     String expectedLinkText = "See all " + changedIssues.size() + " issues";
555     HtmlFragmentAssert.assertThat(emailMessage.getMessage())
556       .hasParagraph().hasParagraph() // skip header
557       .hasParagraph(project.getProjectName())
558       .hasList("Rule " + ruleName + " - " + expectedLinkText)
559       .withLink(expectedLinkText, expectedHref)
560       .hasParagraph().hasParagraph() // skip footer
561       .noMoreBlock();
562   }
563
564   @Test
565   public void formats_returns_html_message_for_multiple_issues_of_same_rule_on_same_project_on_branch_when_analysis_change() {
566     String branchName = randomAlphabetic(19);
567     Project project = newBranch("1", branchName);
568     String ruleName = randomAlphabetic(8);
569     String host = randomAlphabetic(15);
570     Rule rule = newRule(ruleName, randomRuleTypeHotspotExcluded());
571     String status = randomValidStatus();
572     List<ChangedIssue> changedIssues = IntStream.range(0, 2 + new Random().nextInt(5))
573       .mapToObj(i -> newChangedIssue("issue_" + i, status, project, rule))
574       .collect(toList());
575     AnalysisChange analysisChange = newAnalysisChange();
576     when(emailSettings.getServerBaseURL()).thenReturn(host);
577
578     EmailMessage emailMessage = underTest.format(new ChangesOnMyIssuesNotification(analysisChange, ImmutableSet.copyOf(changedIssues)));
579
580     String expectedHref = host + "/project/issues?id=" + project.getKey() + "&branch=" + branchName
581       + "&issues=" + changedIssues.stream().map(ChangedIssue::getKey).collect(joining("%2C"));
582     String expectedLinkText = "See all " + changedIssues.size() + " issues";
583     HtmlFragmentAssert.assertThat(emailMessage.getMessage())
584       .hasParagraph().hasParagraph() // skip header
585       .hasParagraph()// skip title based on status
586       .hasList("Rule " + ruleName + " - " + expectedLinkText)
587       .withLink(expectedLinkText, expectedHref)
588       .hasParagraph().hasParagraph() // skip footer
589       .noMoreBlock();
590   }
591
592   @Test
593   public void formats_returns_html_message_for_multiple_issues_of_same_rule_on_same_project_on_branch_when_user_change() {
594     String branchName = randomAlphabetic(19);
595     Project project = newBranch("1", branchName);
596     String ruleName = randomAlphabetic(8);
597     String host = randomAlphabetic(15);
598     Rule rule = newRandomNotAHotspotRule(ruleName);
599     List<ChangedIssue> changedIssues = IntStream.range(0, 2 + new Random().nextInt(5))
600       .mapToObj(i -> newChangedIssue("issue_" + i, randomValidStatus(), project, rule))
601       .collect(toList());
602     UserChange userChange = newUserChange();
603     when(emailSettings.getServerBaseURL()).thenReturn(host);
604
605     EmailMessage emailMessage = underTest.format(new ChangesOnMyIssuesNotification(userChange, ImmutableSet.copyOf(changedIssues)));
606
607     String expectedHref = host + "/project/issues?id=" + project.getKey() + "&branch=" + branchName
608       + "&issues=" + changedIssues.stream().map(ChangedIssue::getKey).collect(joining("%2C"));
609     String expectedLinkText = "See all " + changedIssues.size() + " issues";
610     HtmlFragmentAssert.assertThat(emailMessage.getMessage())
611       .hasParagraph().hasParagraph() // skip header
612       .hasParagraph(project.getProjectName() + ", " + branchName)
613       .hasList("Rule " + ruleName + " - " + expectedLinkText)
614       .withLink(expectedLinkText, expectedHref)
615       .hasParagraph().hasParagraph() // skip footer
616       .noMoreBlock();
617   }
618
619   @Test
620   public void formats_returns_html_message_with_projects_ordered_by_name_when_user_change() {
621     Project project1 = newProject("1");
622     Project project1Branch1 = newBranch("1", "a");
623     Project project1Branch2 = newBranch("1", "b");
624     Project project2 = newProject("B");
625     Project project2Branch1 = newBranch("B", "a");
626     Project project3 = newProject("C");
627     String host = randomAlphabetic(15);
628     List<ChangedIssue> changedIssues = Stream.of(project1, project1Branch1, project1Branch2, project2, project2Branch1, project3)
629       .map(project -> newChangedIssue("issue_" + project.getUuid(), randomValidStatus(), project, newRule(randomAlphabetic(2), randomRuleTypeHotspotExcluded())))
630       .collect(toList());
631     Collections.shuffle(changedIssues);
632     UserChange userChange = newUserChange();
633     when(emailSettings.getServerBaseURL()).thenReturn(host);
634
635     EmailMessage emailMessage = underTest.format(new ChangesOnMyIssuesNotification(userChange, ImmutableSet.copyOf(changedIssues)));
636
637     HtmlFragmentAssert.assertThat(emailMessage.getMessage())
638       .hasParagraph().hasParagraph() // skip header
639       .hasParagraph(project1.getProjectName())
640       .hasList()
641       .hasParagraph(project1Branch1.getProjectName() + ", " + project1Branch1.getBranchName().get())
642       .hasList()
643       .hasParagraph(project1Branch2.getProjectName() + ", " + project1Branch2.getBranchName().get())
644       .hasList()
645       .hasParagraph(project2.getProjectName())
646       .hasList()
647       .hasParagraph(project2Branch1.getProjectName() + ", " + project2Branch1.getBranchName().get())
648       .hasList()
649       .hasParagraph(project3.getProjectName())
650       .hasList()
651       .hasParagraph().hasParagraph() // skip footer
652       .noMoreBlock();
653   }
654
655   @Test
656   public void formats_returns_html_message_with_rules_ordered_by_name_when_analysis_change() {
657     Project project = newProject("1");
658     Rule rule1 = newRandomNotAHotspotRule("1");
659     Rule rule2 = newRandomNotAHotspotRule("a");
660     Rule rule3 = newRandomNotAHotspotRule("b");
661     Rule rule4 = newRandomNotAHotspotRule("X");
662
663     String host = randomAlphabetic(15);
664     String issueStatus = randomValidStatus();
665     List<ChangedIssue> changedIssues = Stream.of(rule1, rule2, rule3, rule4)
666       .map(rule -> newChangedIssue("issue_" + rule.getName(), issueStatus, project, rule))
667       .collect(toList());
668     Collections.shuffle(changedIssues);
669     AnalysisChange analysisChange = newAnalysisChange();
670     when(emailSettings.getServerBaseURL()).thenReturn(host);
671
672     EmailMessage emailMessage = underTest.format(new ChangesOnMyIssuesNotification(analysisChange, ImmutableSet.copyOf(changedIssues)));
673
674     HtmlFragmentAssert.assertThat(emailMessage.getMessage())
675       .hasParagraph().hasParagraph() // skip header
676       .hasParagraph()// skip title based on status
677       .hasList(
678         "Rule " + rule1.getName() + " - See the single issue",
679         "Rule " + rule2.getName() + " - See the single issue",
680         "Rule " + rule3.getName() + " - See the single issue",
681         "Rule " + rule4.getName() + " - See the single issue")
682       .hasParagraph().hasParagraph() // skip footer
683       .noMoreBlock();
684   }
685
686   @Test
687   public void formats_returns_html_message_with_rules_ordered_by_name_user_change() {
688     Project project = newProject("1");
689     Rule rule1 = newRandomNotAHotspotRule("1");
690     Rule rule2 = newRandomNotAHotspotRule("a");
691     Rule rule3 = newRandomNotAHotspotRule("b");
692     Rule rule4 = newRandomNotAHotspotRule("X");
693
694     Rule hotspot1 = newSecurityHotspotRule("S");
695     Rule hotspot2 = newSecurityHotspotRule("Z");
696     Rule hotspot3 = newSecurityHotspotRule("N");
697     Rule hotspot4 = newSecurityHotspotRule("M");
698
699     String host = randomAlphabetic(15);
700     List<ChangedIssue> changedIssues = Stream.of(rule1, rule2, rule3, rule4, hotspot1, hotspot2, hotspot3, hotspot4)
701       .map(rule -> newChangedIssue("issue_" + rule.getName(), randomValidStatus(), project, rule))
702       .collect(toList());
703     Collections.shuffle(changedIssues);
704     UserChange userChange = newUserChange();
705     when(emailSettings.getServerBaseURL()).thenReturn(host);
706
707     EmailMessage emailMessage = underTest.format(new ChangesOnMyIssuesNotification(userChange, ImmutableSet.copyOf(changedIssues)));
708
709     HtmlFragmentAssert.assertThat(emailMessage.getMessage())
710       .hasParagraph()
711       .hasParagraph()
712       .hasParagraph() // skip project name
713       .hasList(
714         "Rule " + rule1.getName() + " - See the single issue",
715         "Rule " + rule2.getName() + " - See the single issue",
716         "Rule " + rule3.getName() + " - See the single issue",
717         "Rule " + rule4.getName() + " - See the single issue")
718       .hasEmptyParagraph()
719       .hasList(
720         "Rule " + hotspot1.getName() + " - See the single hotspot",
721         "Rule " + hotspot2.getName() + " - See the single hotspot",
722         "Rule " + hotspot3.getName() + " - See the single hotspot",
723         "Rule " + hotspot4.getName() + " - See the single hotspot")
724       .hasParagraph().hasParagraph() // skip footer
725       .noMoreBlock();
726   }
727
728   @Test
729   public void formats_returns_html_message_with_multiple_links_by_rule_of_groups_of_up_to_40_issues_when_analysis_change() {
730     Project project1 = newProject("1");
731     Rule rule1 = newRandomNotAHotspotRule("1");
732     Rule rule2 = newRandomNotAHotspotRule("a");
733
734     String host = randomAlphabetic(15);
735     String issueStatusClosed = STATUS_CLOSED;
736     String otherIssueStatus = STATUS_RESOLVED;
737
738     List<ChangedIssue> changedIssues = Stream.of(
739       IntStream.range(0, 39).mapToObj(i -> newChangedIssue("39_" + i, issueStatusClosed, project1, rule1)),
740       IntStream.range(0, 40).mapToObj(i -> newChangedIssue("40_" + i, issueStatusClosed, project1, rule2)),
741       IntStream.range(0, 81).mapToObj(i -> newChangedIssue("1-40_41-80_1_" + i, otherIssueStatus, project1, rule2)),
742       IntStream.range(0, 6).mapToObj(i -> newChangedIssue("6_" + i, otherIssueStatus, project1, rule1)))
743       .flatMap(t -> t)
744       .collect(toList());
745
746     Collections.shuffle(changedIssues);
747     AnalysisChange analysisChange = newAnalysisChange();
748     when(emailSettings.getServerBaseURL()).thenReturn(host);
749
750     EmailMessage emailMessage = underTest.format(new ChangesOnMyIssuesNotification(analysisChange, ImmutableSet.copyOf(changedIssues)));
751
752     HtmlFragmentAssert.assertThat(emailMessage.getMessage())
753       .hasParagraph().hasParagraph() // skip header
754       .hasParagraph("Closed issues:") // skip title based on status
755       .hasList(
756         "Rule " + rule1.getName() + " - See all 39 issues",
757         "Rule " + rule2.getName() + " - See all 40 issues")
758       .withLink("See all 39 issues",
759         host + "/project/issues?id=" + project1.getKey()
760           + "&issues=" + IntStream.range(0, 39).mapToObj(i -> "39_" + i).sorted().collect(joining("%2C")))
761       .withLink("See all 40 issues",
762         host + "/project/issues?id=" + project1.getKey()
763           + "&issues=" + IntStream.range(0, 40).mapToObj(i -> "40_" + i).sorted().collect(joining("%2C")))
764       .hasParagraph("Open issues:")
765       .hasList(
766         "Rule " + rule2.getName() + " - See issues 1-40 41-80 81",
767         "Rule " + rule1.getName() + " - See all 6 issues")
768       .withLink("1-40",
769         host + "/project/issues?id=" + project1.getKey()
770           + "&issues=" + IntStream.range(0, 81).mapToObj(i -> "1-40_41-80_1_" + i).sorted().limit(40).collect(joining("%2C")))
771       .withLink("41-80",
772         host + "/project/issues?id=" + project1.getKey()
773           + "&issues=" + IntStream.range(0, 81).mapToObj(i -> "1-40_41-80_1_" + i).sorted().skip(40).limit(40).collect(joining("%2C")))
774       .withLink("81",
775         host + "/project/issues?id=" + project1.getKey()
776           + "&issues=" + "1-40_41-80_1_9" + "&open=" + "1-40_41-80_1_9")
777       .withLink("See all 6 issues",
778         host + "/project/issues?id=" + project1.getKey()
779           + "&issues=" + IntStream.range(0, 6).mapToObj(i -> "6_" + i).sorted().collect(joining("%2C")))
780       .hasParagraph().hasParagraph() // skip footer
781       .noMoreBlock();
782   }
783
784   @Test
785   public void formats_returns_html_message_with_multiple_links_by_rule_of_groups_of_up_to_40_issues_and_hotspots_when_user_change() {
786     Project project1 = newProject("1");
787     Project project2 = newProject("V");
788     Project project2Branch = newBranch("V", "AB");
789     Rule rule1 = newRule("1", randomRuleTypeHotspotExcluded());
790     Rule rule2 = newRule("a", randomRuleTypeHotspotExcluded());
791
792     Rule hotspot1 = newSecurityHotspotRule("h1");
793     Rule hotspot2 = newSecurityHotspotRule("h2");
794
795     String status = randomValidStatus();
796     String host = randomAlphabetic(15);
797     List<ChangedIssue> changedIssues = Stream.of(
798       IntStream.range(0, 39).mapToObj(i -> newChangedIssue("39_" + i, status, project1, rule1)),
799       IntStream.range(0, 40).mapToObj(i -> newChangedIssue("40_" + i, status, project1, rule2)),
800       IntStream.range(0, 81).mapToObj(i -> newChangedIssue("1-40_41-80_1_" + i, status, project2, rule2)),
801       IntStream.range(0, 6).mapToObj(i -> newChangedIssue("6_" + i, status, project2Branch, rule1)),
802
803       IntStream.range(0, 39).mapToObj(i -> newChangedIssue("39_" + i, STATUS_REVIEWED, project1, hotspot1)),
804       IntStream.range(0, 40).mapToObj(i -> newChangedIssue("40_" + i, STATUS_REVIEWED, project1, hotspot2)),
805       IntStream.range(0, 81).mapToObj(i -> newChangedIssue("1-40_41-80_1_" + i, STATUS_TO_REVIEW, project2, hotspot2)),
806       IntStream.range(0, 6).mapToObj(i -> newChangedIssue("6_" + i, STATUS_TO_REVIEW, project2Branch, hotspot1)))
807       .flatMap(t -> t)
808       .collect(toList());
809     Collections.shuffle(changedIssues);
810     UserChange userChange = newUserChange();
811     when(emailSettings.getServerBaseURL()).thenReturn(host);
812
813     EmailMessage emailMessage = underTest.format(new ChangesOnMyIssuesNotification(userChange, ImmutableSet.copyOf(changedIssues)));
814
815     HtmlFragmentAssert.assertThat(emailMessage.getMessage())
816       .hasParagraph().hasParagraph() // skip header
817       .hasParagraph(project1.getProjectName())
818       .hasList()
819       .withItemTexts(
820         "Rule " + rule1.getName() + " - See all 39 issues",
821         "Rule " + rule2.getName() + " - See all 40 issues")
822       .withLink("See all 39 issues",
823         host + "/project/issues?id=" + project1.getKey()
824           + "&issues=" + IntStream.range(0, 39).mapToObj(i -> "39_" + i).sorted().collect(joining("%2C")))
825       .withLink("See all 40 issues",
826         host + "/project/issues?id=" + project1.getKey()
827           + "&issues=" + IntStream.range(0, 40).mapToObj(i -> "40_" + i).sorted().collect(joining("%2C")))
828       .hasEmptyParagraph()
829       .hasList()
830       .withItemTexts(
831         "Rule " + hotspot1.getName() + " - See all 39 hotspots",
832         "Rule " + hotspot2.getName() + " - See all 40 hotspots")
833       .withLink("See all 39 hotspots",
834         host + "/security_hotspots?id=" + project1.getKey()
835           + "&hotspots=" + IntStream.range(0, 39).mapToObj(i -> "39_" + i).sorted().collect(joining("%2C")))
836       .withLink("See all 40 hotspots",
837         host + "/security_hotspots?id=" + project1.getKey()
838           + "&hotspots=" + IntStream.range(0, 40).mapToObj(i -> "40_" + i).sorted().collect(joining("%2C")))
839       .hasParagraph(project2.getProjectName())
840       .hasList(
841         "Rule " + rule2.getName() + " - See issues 1-40 41-80 81")
842       .withLink("1-40",
843         host + "/project/issues?id=" + project2.getKey()
844           + "&issues=" + IntStream.range(0, 81).mapToObj(i -> "1-40_41-80_1_" + i).sorted().limit(40).collect(joining("%2C")))
845       .withLink("41-80",
846         host + "/project/issues?id=" + project2.getKey()
847           + "&issues=" + IntStream.range(0, 81).mapToObj(i -> "1-40_41-80_1_" + i).sorted().skip(40).limit(40).collect(joining("%2C")))
848       .withLink("81",
849         host + "/project/issues?id=" + project2.getKey()
850           + "&issues=" + "1-40_41-80_1_9" + "&open=" + "1-40_41-80_1_9")
851       .hasEmptyParagraph()
852       .hasList("Rule " + hotspot2.getName() + " - See hotspots 1-40 41-80 81")
853       .withLink("1-40",
854         host + "/security_hotspots?id=" + project2.getKey()
855           + "&hotspots=" + IntStream.range(0, 81).mapToObj(i -> "1-40_41-80_1_" + i).sorted().limit(40).collect(joining("%2C")))
856       .withLink("41-80",
857         host + "/security_hotspots?id=" + project2.getKey()
858           + "&hotspots=" + IntStream.range(0, 81).mapToObj(i -> "1-40_41-80_1_" + i).sorted().skip(40).limit(40).collect(joining("%2C")))
859       .withLink("81",
860         host + "/security_hotspots?id=" + project2.getKey()
861           + "&hotspots=" + "1-40_41-80_1_9")
862       .hasParagraph(project2Branch.getProjectName() + ", " + project2Branch.getBranchName().get())
863       .hasList(
864         "Rule " + rule1.getName() + " - See all 6 issues")
865       .withLink("See all 6 issues",
866         host + "/project/issues?id=" + project2Branch.getKey() + "&branch=" + project2Branch.getBranchName().get()
867           + "&issues=" + IntStream.range(0, 6).mapToObj(i -> "6_" + i).sorted().collect(joining("%2C")))
868       .hasEmptyParagraph()
869       .hasList("Rule " + hotspot1.getName() + " - See all 6 hotspots")
870       .withLink("See all 6 hotspots",
871         host + "/security_hotspots?id=" + project2Branch.getKey() + "&branch=" + project2Branch.getBranchName().get()
872           + "&hotspots=" + IntStream.range(0, 6).mapToObj(i -> "6_" + i).sorted().collect(joining("%2C")))
873       .hasParagraph().hasParagraph() // skip footer
874       .noMoreBlock();
875   }
876
877   private static String randomValidStatus() {
878     return ISSUE_STATUSES[new Random().nextInt(ISSUE_STATUSES.length)];
879   }
880
881   private void verifyEnd(HtmlListAssert htmlListAssert) {
882     htmlListAssert
883       .hasEmptyParagraph()
884       .hasParagraph()
885       .noMoreBlock();
886   }
887
888 }