Skip to content

Commit 6898c59

Browse files
authored
Merge pull request #5810 from Particular/john/at_phase4
Add acceptance tests for core ServicePulse API routes
2 parents 2ebb08a + 6ca2f67 commit 6898c59

18 files changed

Lines changed: 1608 additions & 283 deletions

‎docs/acceptance-test-review-plan.md‎

Lines changed: 0 additions & 254 deletions
This file was deleted.

‎docs/writing-acceptance-tests.md‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,38 @@ Registering the double correctly is only half the job. If the assertion would al
109109

110110
The suite runs against every persister. An assertion about how RavenDB happens to store or trim something says nothing about ServiceControl's behaviour. It also ends up on another persister's exclusion list, where it looks like a missing feature instead of a test that asks for too much. Assert what every persister has to do to be correct.
111111

112+
### The wait that does not cover what the assertion reads
113+
114+
Wait on the path you are about to assert on, not on a cheaper one that looks equivalent.
115+
116+
Persisters do not make a write visible everywhere at the same moment, and search is where they differ most. PostgreSQL indexes a `to_tsvector` expression with GIN, which is maintained inside the writing transaction, so a committed message is searchable at once. SQL Server's full text index is populated by a background process (`CHANGE_TRACKING AUTO`), so a `q=` search answers nothing for a while after the plain list already returns the same message. A test that waits for ingestion by listing messages and then searches therefore passes on PostgreSQL and RavenDB, and fails on SQL Server:
117+
118+
```csharp
119+
// Wrong: the list does not go through the search index, so the search below can run against
120+
// an index that has not caught up.
121+
.Do("Wait for the failures to be ingested", async _ => (await Query(string.Empty)).Count == 3)
122+
.Do("Search", async _ => matches = await Query($"q={SearchTerm}"))
123+
124+
// Right: the wait runs the same query the assertion depends on.
125+
.Do("Wait for the search index to catch up", async _ =>
126+
{
127+
matches = await Query($"q={SearchTerm}");
128+
return matches.Count == 2;
129+
})
130+
```
131+
132+
This cuts across [the assertion that could not have failed](#the-assertion-that-could-not-have-failed), so be deliberate about where the wait stops and the assertion starts.
133+
134+
Wait for the loosest condition that makes the query answerable, not for the answer you expect. `Count == 2` never advances when a search matches three, so the interesting regression, matching too much, is reported as a 90 second timeout rather than as the assertion that would have named the extra row. `Count >= 2` advances as soon as there is enough to judge and lets the assertion do the judging, which turns that same regression into a failure in a few seconds reading `Extra (1): DeliveryFailed`.
135+
136+
Whatever the wait cannot avoid holding, record on the scenario context, because the runner prints the context when a scenario does not finish while a `TimeoutException` on its own says only that 90 seconds passed:
137+
138+
```csharp
139+
ctx.SearchMatched = string.Join(", ", TypesIn(matchingTerm));
140+
```
141+
142+
A stalled run then reads as the step it stopped on, from `Advancing from X to Y`, plus what that step kept seeing, which separates an index that never populated from one that matched the wrong thing.
143+
112144
### The setup that nothing reads
113145

114146
Headers put into a dictionary nothing reads, constants nothing compares against, fixtures left behind after an assertion was deleted. Each one is harmless by itself. Together they make a test look like it covers more than it does, which is how everything above gets through review.
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
namespace ServiceControl.AcceptanceTests.Licensing
2+
{
3+
using System.Net;
4+
using System.Threading.Tasks;
5+
using AcceptanceTesting;
6+
using NServiceBus.AcceptanceTesting;
7+
using NUnit.Framework;
8+
using ServiceControl.Licensing;
9+
10+
class When_the_license_is_requested : AcceptanceTest
11+
{
12+
[Test]
13+
public async Task Should_report_the_instance_and_where_to_extend_the_trial()
14+
{
15+
LicenseInfo license = null;
16+
17+
await Define<Context>()
18+
.Done(async _ =>
19+
{
20+
var result = await this.TryGet<LicenseInfo>($"/api/license?refresh=true&clientName={ClientName}");
21+
license = result.Item;
22+
return result.HasResult;
23+
})
24+
.Run();
25+
26+
using (Assert.EnterMultipleScope())
27+
{
28+
Assert.That(license.InstanceName, Is.EqualTo(Settings.InstanceName),
29+
"ServicePulse labels the license page with the instance it is talking to");
30+
31+
Assert.That(license.LicenseExtensionUrl, Does.StartWith("https://particular.net/extend-your-trial"),
32+
"With no MassTransit connector reporting in, the license page offers the trial extension rather than the connector link");
33+
34+
Assert.That(license.LicenseExtensionUrl, Does.Contain($"p={ClientName}"),
35+
"The link has to carry the caller through, or Particular cannot tell which product the request came from");
36+
}
37+
}
38+
39+
[Test]
40+
public async Task Should_reject_a_request_that_names_no_client()
41+
{
42+
HttpStatusCode status = default;
43+
44+
await Define<Context>()
45+
.Done(async _ =>
46+
{
47+
using var response = await this.GetRaw("/api/license");
48+
status = response.StatusCode;
49+
return true;
50+
})
51+
.Run();
52+
53+
Assert.That(status, Is.EqualTo(HttpStatusCode.BadRequest));
54+
}
55+
56+
const string ClientName = "servicepulse";
57+
58+
class Context : ScenarioContext;
59+
}
60+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
namespace ServiceControl.AcceptanceTests.Monitoring.CustomChecks
2+
{
3+
using System;
4+
using System.Linq;
5+
using System.Threading;
6+
using System.Threading.Tasks;
7+
using AcceptanceTesting;
8+
using AcceptanceTesting.EndpointTemplates;
9+
using NServiceBus;
10+
using NServiceBus.AcceptanceTesting;
11+
using NServiceBus.CustomChecks;
12+
using NUnit.Framework;
13+
using ServiceBus.Management.Infrastructure.Settings;
14+
using CustomCheckView = global::ServiceControl.Contracts.CustomChecks.CustomCheck;
15+
using CheckStatus = global::ServiceControl.Persistence.Status;
16+
17+
class When_a_failing_custom_check_is_dismissed : AcceptanceTest
18+
{
19+
[Test]
20+
public async Task Should_come_back_while_the_check_is_still_failing()
21+
{
22+
CustomCheckView dismissed = null;
23+
CustomCheckView returned = null;
24+
25+
await Define<Context>()
26+
.WithEndpoint<Checked>()
27+
.Do("Wait for the check to report a failure", async ctx =>
28+
{
29+
var checks = await this.TryGetMany<CustomCheckView>("/api/customchecks",
30+
check => check.CustomCheckId == CheckId && check.Status == CheckStatus.Fail);
31+
32+
dismissed = checks.HasResult ? checks.Items.Single() : null;
33+
34+
return dismissed != null;
35+
})
36+
.Do("Dismiss it from the page", async _ =>
37+
await this.Delete($"/api/customchecks/{WithoutPrefix(dismissed.Id)}"))
38+
.Do("Wait until it has gone", async _ =>
39+
{
40+
var checks = await this.TryGetMany<CustomCheckView>("/api/customchecks");
41+
42+
return checks.Items.All(check => check.CustomCheckId != CheckId);
43+
})
44+
.Do("Wait for the next report from the endpoint", async _ =>
45+
{
46+
var checks = await this.TryGetMany<CustomCheckView>("/api/customchecks",
47+
check => check.CustomCheckId == CheckId && check.Status == CheckStatus.Fail);
48+
49+
returned = checks.HasResult ? checks.Items.Single() : null;
50+
51+
return returned != null;
52+
})
53+
.Done(_ => true)
54+
.Run();
55+
56+
Assert.That(returned.FailureReason, Is.EqualTo(dismissed.FailureReason),
57+
"Dismissing a check that is still failing cannot silence it for good, or a real failure disappears from the page for as long as it lasts");
58+
}
59+
60+
static string WithoutPrefix(string id) =>
61+
id.StartsWith(DocumentPrefix, StringComparison.OrdinalIgnoreCase) ? id[DocumentPrefix.Length..] : id;
62+
63+
const string DocumentPrefix = "CustomChecks/";
64+
const string CheckId = "DismissedCheck";
65+
66+
class Context : ScenarioContext, ISequenceContext
67+
{
68+
public int Step { get; set; }
69+
}
70+
71+
public class Checked : EndpointConfigurationBuilder
72+
{
73+
public Checked() =>
74+
EndpointSetup<DefaultServerWithoutAudit>(c => c.ReportCustomChecksTo(Settings.DEFAULT_INSTANCE_NAME, TimeSpan.FromSeconds(1)));
75+
76+
class FailingCheck() : CustomCheck(CheckId, "Testing", TimeSpan.FromSeconds(1))
77+
{
78+
public override Task<CheckResult> PerformCheck(CancellationToken cancellationToken = default) =>
79+
Task.FromResult(CheckResult.Failed("Still failing"));
80+
}
81+
}
82+
}
83+
}

0 commit comments

Comments
 (0)