Skip to content

Commit e97269c

Browse files
committed
Add Playwright browser smoke tests and refactor validation logic
- Introduced Playwright for browser smoke tests covering critical operator flows. - Updated README.md to include Playwright setup instructions and test execution. - Added ScopedFormValidation class to centralize form validation logic across pages. - Refactored existing page models to utilize ScopedFormValidation for improved validation handling. - Created BrowserProductWebHost to facilitate integration testing with a real web host. - Implemented MultiFormHttpWorkflowTests to validate form submissions under various conditions. - Enhanced SourceManagementPageTests with new test cases for candidate discovery. - Updated Program.cs to support a more modular application startup configuration.
1 parent 3aad9a4 commit e97269c

13 files changed

Lines changed: 975 additions & 114 deletions
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
using Microsoft.AspNetCore.Builder;
2+
using Microsoft.AspNetCore.Hosting.Server;
3+
using Microsoft.AspNetCore.Hosting.Server.Features;
4+
using Microsoft.Extensions.DependencyInjection;
5+
using System.Reflection;
6+
7+
namespace ProductNormaliser.Web.Tests;
8+
9+
internal sealed class BrowserProductWebHost(FakeAdminApiClient adminApiClient) : IAsyncDisposable
10+
{
11+
private WebApplication? app;
12+
13+
public Uri RootUri { get; private set; } = null!;
14+
15+
public async Task StartAsync()
16+
{
17+
if (app is not null)
18+
{
19+
return;
20+
}
21+
22+
app = Program.BuildApp([], builder =>
23+
{
24+
ProductWebTestHostConfiguration.Configure(builder, adminApiClient);
25+
builder.WebHost.UseSetting("urls", "http://127.0.0.1:0");
26+
},
27+
environmentName: "Development",
28+
applicationName: typeof(Program).Assembly.GetName().Name,
29+
contentRootPath: GetWebContentRoot());
30+
31+
await app.StartAsync();
32+
33+
var server = app.Services.GetRequiredService<IServer>();
34+
var addresses = server.Features.Get<IServerAddressesFeature>()?.Addresses;
35+
RootUri = addresses?
36+
.Select(address => new Uri(address))
37+
.LastOrDefault()
38+
?? throw new InvalidOperationException("The browser test host did not expose a base address.");
39+
}
40+
41+
public async ValueTask DisposeAsync()
42+
{
43+
if (app is null)
44+
{
45+
return;
46+
}
47+
48+
await app.StopAsync();
49+
await app.DisposeAsync();
50+
}
51+
52+
private static string GetWebContentRoot()
53+
{
54+
return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "ProductNormaliser.Web"));
55+
}
56+
}
Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
using System.Net;
2+
using System.Text.RegularExpressions;
3+
using ProductNormaliser.Web.Contracts;
4+
5+
namespace ProductNormaliser.Web.Tests;
6+
7+
[TestFixture]
8+
public sealed class MultiFormHttpWorkflowTests
9+
{
10+
[Test]
11+
public async Task SourcesIndex_DiscoverCandidatesForm_PostsSuccessfully_WhenRegisterFormIsEmpty()
12+
{
13+
var fakeAdminApiClient = new FakeAdminApiClient
14+
{
15+
Categories = CreateCategories(),
16+
Sources = [],
17+
CreatedDiscoveryRun = new DiscoveryRunDto
18+
{
19+
RunId = "discovery_run_http",
20+
RequestedCategoryKeys = ["laptop", "tv"],
21+
Locale = "en-GB",
22+
Market = "UK",
23+
AutomationMode = "auto_accept_and_seed",
24+
Status = "queued",
25+
CurrentStage = "search",
26+
StatusMessage = "Discovery run is queued and waiting for worker capacity.",
27+
LlmStatus = "disabled",
28+
LlmStatusMessage = "LLM validation is disabled.",
29+
CreatedUtc = new DateTime(2026, 03, 27, 09, 00, 00, DateTimeKind.Utc),
30+
UpdatedUtc = new DateTime(2026, 03, 27, 09, 00, 00, DateTimeKind.Utc)
31+
}
32+
};
33+
34+
await using var factory = new ProductWebApplicationFactory(fakeAdminApiClient);
35+
using var client = await factory.CreateOperatorClientAsync();
36+
37+
var pageHtml = await client.GetStringAsync("/Sources");
38+
var requestVerificationToken = ExtractRequestVerificationToken(pageHtml);
39+
40+
var response = await client.PostAsync("/Sources?handler=DiscoverCandidates", new FormUrlEncodedContent(
41+
[
42+
new KeyValuePair<string, string>("__RequestVerificationToken", requestVerificationToken),
43+
new KeyValuePair<string, string>("CandidateDiscovery.CategoryKeys", "laptop"),
44+
new KeyValuePair<string, string>("CandidateDiscovery.CategoryKeys", "tv"),
45+
new KeyValuePair<string, string>("CandidateDiscovery.Locale", "en-GB"),
46+
new KeyValuePair<string, string>("CandidateDiscovery.Market", "UK"),
47+
new KeyValuePair<string, string>("CandidateDiscovery.MaxCandidates", "10"),
48+
new KeyValuePair<string, string>("CandidateDiscovery.AutomationMode", "auto_accept_and_seed")
49+
]));
50+
51+
Assert.Multiple(() =>
52+
{
53+
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Redirect));
54+
Assert.That(response.Headers.Location, Is.Not.Null);
55+
Assert.That(response.Headers.Location!.ToString(), Does.Contain("/Sources/DiscoveryRuns/Details"));
56+
Assert.That(response.Headers.Location!.ToString(), Does.Contain("runId=discovery_run_http"));
57+
Assert.That(fakeAdminApiClient.LastCreateDiscoveryRunRequest, Is.Not.Null);
58+
Assert.That(fakeAdminApiClient.LastCreateDiscoveryRunRequest!.CategoryKeys, Is.EqualTo(new[] { "laptop", "tv" }));
59+
Assert.That(fakeAdminApiClient.LastCreateDiscoveryRunRequest.Locale, Is.EqualTo("en-GB"));
60+
Assert.That(fakeAdminApiClient.LastCreateDiscoveryRunRequest.Market, Is.EqualTo("UK"));
61+
});
62+
}
63+
64+
[Test]
65+
public async Task SourceDetails_ThrottlingForm_PostsSuccessfully_WhenIdentityFormIsEmpty()
66+
{
67+
var source = CreateSource("ao_uk", "AO UK", ["tv"]);
68+
var fakeAdminApiClient = new FakeAdminApiClient
69+
{
70+
Categories = CreateCategories(),
71+
Source = source,
72+
Sources = [source]
73+
};
74+
75+
await using var factory = new ProductWebApplicationFactory(fakeAdminApiClient);
76+
using var client = await factory.CreateOperatorClientAsync();
77+
78+
var pageHtml = await client.GetStringAsync("/Sources/Details/ao_uk");
79+
var requestVerificationToken = ExtractRequestVerificationToken(pageHtml);
80+
81+
var response = await client.PostAsync("/Sources/Details/ao_uk?handler=Throttling", new FormUrlEncodedContent(
82+
[
83+
new KeyValuePair<string, string>("__RequestVerificationToken", requestVerificationToken),
84+
new KeyValuePair<string, string>("Throttling.MinDelayMs", "500"),
85+
new KeyValuePair<string, string>("Throttling.MaxDelayMs", "1500"),
86+
new KeyValuePair<string, string>("Throttling.MaxConcurrentRequests", "2"),
87+
new KeyValuePair<string, string>("Throttling.RequestsPerMinute", "20"),
88+
new KeyValuePair<string, string>("Throttling.RespectRobotsTxt", "true")
89+
]));
90+
91+
Assert.Multiple(() =>
92+
{
93+
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Redirect));
94+
Assert.That(response.Headers.Location, Is.Not.Null);
95+
Assert.That(response.Headers.Location!.ToString(), Does.Contain("/Sources/Details/ao_uk"));
96+
Assert.That(fakeAdminApiClient.LastUpdatedThrottlingSourceId, Is.EqualTo("ao_uk"));
97+
Assert.That(fakeAdminApiClient.LastUpdatedThrottlingRequest, Is.Not.Null);
98+
Assert.That(fakeAdminApiClient.LastUpdatedThrottlingRequest!.MinDelayMs, Is.EqualTo(500));
99+
Assert.That(fakeAdminApiClient.LastUpdatedThrottlingRequest.MaxDelayMs, Is.EqualTo(1500));
100+
Assert.That(fakeAdminApiClient.LastUpdatedThrottlingRequest.MaxConcurrentRequests, Is.EqualTo(2));
101+
Assert.That(fakeAdminApiClient.LastUpdatedThrottlingRequest.RequestsPerMinute, Is.EqualTo(20));
102+
Assert.That(fakeAdminApiClient.LastUpdatedThrottlingRequest.RespectRobotsTxt, Is.True);
103+
});
104+
}
105+
106+
[Test]
107+
public async Task OperatorLanding_SaveCategorySchema_PostsSuccessfully_WhenQuickCrawlFormIsEmpty()
108+
{
109+
var fakeAdminApiClient = new FakeAdminApiClient
110+
{
111+
Categories = CreateCategories(),
112+
CategoryDetail = CreateCategoryDetail("tv", "TVs"),
113+
Sources = [CreateSource("ao_uk", "AO UK", ["tv"])]
114+
};
115+
116+
await using var factory = new ProductWebApplicationFactory(fakeAdminApiClient);
117+
using var client = await factory.CreateOperatorClientAsync();
118+
119+
var pageHtml = await client.GetStringAsync("/?category=tv&selectedCategory=tv");
120+
var requestVerificationToken = ExtractRequestVerificationToken(pageHtml);
121+
122+
var response = await client.PostAsync("/?handler=SaveCategorySchema&category=tv&selectedCategory=tv", new FormUrlEncodedContent(
123+
[
124+
new KeyValuePair<string, string>("__RequestVerificationToken", requestVerificationToken),
125+
new KeyValuePair<string, string>("CategorySchema.CategoryKey", "tv"),
126+
new KeyValuePair<string, string>("CategorySchema.Attributes[0].Key", "panel_type"),
127+
new KeyValuePair<string, string>("CategorySchema.Attributes[0].DisplayName", "Panel Type"),
128+
new KeyValuePair<string, string>("CategorySchema.Attributes[0].ValueType", "string"),
129+
new KeyValuePair<string, string>("CategorySchema.Attributes[0].Unit", string.Empty),
130+
new KeyValuePair<string, string>("CategorySchema.Attributes[0].ConflictSensitivity", "High"),
131+
new KeyValuePair<string, string>("CategorySchema.Attributes[0].Description", "Display panel technology."),
132+
new KeyValuePair<string, string>("CategorySchema.Attributes[0].IsRequired", "true")
133+
]));
134+
135+
Assert.Multiple(() =>
136+
{
137+
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Redirect));
138+
Assert.That(response.Headers.Location, Is.Not.Null);
139+
Assert.That(response.Headers.Location!.ToString(), Does.Contain("category=tv"));
140+
Assert.That(fakeAdminApiClient.LastUpdatedCategorySchemaCategoryKey, Is.EqualTo("tv"));
141+
Assert.That(fakeAdminApiClient.LastUpdatedCategorySchemaRequest, Is.Not.Null);
142+
Assert.That(fakeAdminApiClient.LastUpdatedCategorySchemaRequest!.Attributes, Has.Count.EqualTo(1));
143+
Assert.That(fakeAdminApiClient.LastUpdatedCategorySchemaRequest.Attributes[0].Key, Is.EqualTo("panel_type"));
144+
Assert.That(fakeAdminApiClient.LastUpdatedCategorySchemaRequest.Attributes[0].IsRequired, Is.True);
145+
});
146+
}
147+
148+
private static string ExtractRequestVerificationToken(string html)
149+
{
150+
var match = Regex.Match(
151+
html,
152+
"<input[^>]*name=\"__RequestVerificationToken\"[^>]*value=\"([^\"]+)\"",
153+
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
154+
155+
if (!match.Success)
156+
{
157+
Assert.Fail("Expected page to render an antiforgery token.");
158+
}
159+
160+
return WebUtility.HtmlDecode(match.Groups[1].Value);
161+
}
162+
163+
private static IReadOnlyList<CategoryMetadataDto> CreateCategories()
164+
{
165+
return
166+
[
167+
new CategoryMetadataDto
168+
{
169+
CategoryKey = "tv",
170+
DisplayName = "TVs",
171+
FamilyKey = "display",
172+
FamilyDisplayName = "Display",
173+
IconKey = "tv",
174+
IsEnabled = true,
175+
CrawlSupportStatus = "Supported",
176+
SchemaCompletenessScore = 0.95m
177+
},
178+
new CategoryMetadataDto
179+
{
180+
CategoryKey = "laptop",
181+
DisplayName = "Laptops",
182+
FamilyKey = "computing",
183+
FamilyDisplayName = "Computing",
184+
IconKey = "laptop",
185+
IsEnabled = true,
186+
CrawlSupportStatus = "Supported",
187+
SchemaCompletenessScore = 0.93m
188+
}
189+
];
190+
}
191+
192+
private static CategoryDetailDto CreateCategoryDetail(string categoryKey, string displayName)
193+
{
194+
return new CategoryDetailDto
195+
{
196+
Metadata = new CategoryMetadataDto
197+
{
198+
CategoryKey = categoryKey,
199+
DisplayName = displayName,
200+
FamilyKey = "display",
201+
FamilyDisplayName = "Display",
202+
IconKey = categoryKey,
203+
IsEnabled = true,
204+
CrawlSupportStatus = "Supported",
205+
SchemaCompletenessScore = 0.95m
206+
},
207+
Schema = new CategorySchemaDto
208+
{
209+
CategoryKey = categoryKey,
210+
DisplayName = displayName,
211+
Attributes =
212+
[
213+
new CategorySchemaAttributeDto
214+
{
215+
Key = "panel_type",
216+
DisplayName = "Panel Type",
217+
ValueType = "string",
218+
ConflictSensitivity = "High",
219+
Description = "Display panel technology.",
220+
IsRequired = false
221+
}
222+
]
223+
}
224+
};
225+
}
226+
227+
private static SourceDto CreateSource(string sourceId, string displayName, IReadOnlyList<string> categoryKeys)
228+
{
229+
return new SourceDto
230+
{
231+
SourceId = sourceId,
232+
DisplayName = displayName,
233+
BaseUrl = $"https://{sourceId}.example/",
234+
Host = $"{sourceId}.example",
235+
Description = $"{displayName} source",
236+
IsEnabled = true,
237+
AllowedMarkets = ["UK"],
238+
PreferredLocale = "en-GB",
239+
AutomationPolicy = new SourceAutomationPolicyDto
240+
{
241+
Mode = "operator_assisted"
242+
},
243+
SupportedCategoryKeys = categoryKeys,
244+
DiscoveryProfile = new SourceDiscoveryProfileDto
245+
{
246+
AllowedMarkets = ["UK"],
247+
PreferredLocale = "en-GB",
248+
CategoryEntryPages = new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase)
249+
{
250+
[categoryKeys[0]] = [$"https://{sourceId}.example/{categoryKeys[0]}"]
251+
},
252+
SitemapHints = [$"https://{sourceId}.example/sitemap.xml"],
253+
AllowedPathPrefixes = [$"/{categoryKeys[0]}", "/product"],
254+
ExcludedPathPrefixes = ["/support"],
255+
ProductUrlPatterns = ["/product/"],
256+
ListingUrlPatterns = ["/category/"],
257+
MaxDiscoveryDepth = 3,
258+
MaxUrlsPerRun = 500,
259+
MaxRetryCount = 3,
260+
RetryBackoffBaseMs = 1000,
261+
RetryBackoffMaxMs = 30000
262+
},
263+
ThrottlingPolicy = new SourceThrottlingPolicyDto
264+
{
265+
MinDelayMs = 1000,
266+
MaxDelayMs = 4000,
267+
MaxConcurrentRequests = 2,
268+
RequestsPerMinute = 24,
269+
RespectRobotsTxt = true
270+
},
271+
Readiness = new SourceReadinessDto
272+
{
273+
Status = "Ready",
274+
AssignedCategoryCount = categoryKeys.Count,
275+
CrawlableCategoryCount = categoryKeys.Count,
276+
Summary = $"All {categoryKeys.Count} assigned categories are crawl-ready."
277+
},
278+
Health = new SourceHealthSummaryDto
279+
{
280+
Status = "Healthy",
281+
TrustScore = 91m,
282+
CoveragePercent = 87m,
283+
SuccessfulCrawlRate = 93m,
284+
ExtractabilityRate = 81m,
285+
NoProductRate = 19m,
286+
Automation = new SourceAutomationPostureDto
287+
{
288+
Status = "advisory",
289+
EffectiveMode = "operator_assisted",
290+
RecommendedAction = "none"
291+
}
292+
},
293+
CreatedUtc = new DateTime(2026, 03, 20, 10, 00, 00, DateTimeKind.Utc),
294+
UpdatedUtc = new DateTime(2026, 03, 27, 09, 00, 00, DateTimeKind.Utc)
295+
};
296+
}
297+
}

0 commit comments

Comments
 (0)