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