File: HtmlGenerationTest.cs
Web Access
Project: src\src\Mvc\test\Mvc.FunctionalTests\Microsoft.AspNetCore.Mvc.FunctionalTests.csproj (Microsoft.AspNetCore.Mvc.FunctionalTests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Reflection;
using System.Text;
using AngleSharp.Dom;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Logging;
using Xunit.Abstractions;
 
namespace Microsoft.AspNetCore.Mvc.FunctionalTests;
 
public class HtmlGenerationTest : LoggedTest
{
    private static readonly Assembly _resourcesAssembly = typeof(HtmlGenerationTest).GetTypeInfo().Assembly;
 
    protected override void Initialize(TestContext context, MethodInfo methodInfo, object[] testMethodArguments, ITestOutputHelper testOutputHelper)
    {
        base.Initialize(context, methodInfo, testMethodArguments, testOutputHelper);
        Factory = new MvcTestFixture<HtmlGenerationWebSite.Startup>(LoggerFactory).WithWebHostBuilder(ConfigureWebHostBuilder);
        EncodedFactory = new MvcEncodedTestFixture<HtmlGenerationWebSite.Startup>(LoggerFactory).WithWebHostBuilder(ConfigureWebHostBuilder);
        Client = Factory.CreateDefaultClient();
        EncodedClient = EncodedFactory.CreateDefaultClient();
    }
 
    public override void Dispose()
    {
        Factory.Dispose();
        EncodedFactory.Dispose();
        base.Dispose();
    }
 
    public WebApplicationFactory<HtmlGenerationWebSite.Startup> Factory { get; private set; }
    public WebApplicationFactory<HtmlGenerationWebSite.Startup> EncodedFactory { get; private set; }
    public HttpClient Client { get; private set; }
    public HttpClient EncodedClient { get; private set; }
 
    private static void ConfigureWebHostBuilder(IWebHostBuilder builder) =>
        builder.UseStartup<HtmlGenerationWebSite.Startup>();
 
    public static TheoryData<string, string> WebPagesData
    {
        get
        {
            var data = new TheoryData<string, string>
                {
                    { "Customer", "/Customer/HtmlGeneration_Customer" },
                    { "Index", null },
                    { "Product", null },
                    { "Link", null },
                    { "Script", null },
                    // Testing attribute values with boolean and null values
                    { "AttributesWithBooleanValues", null },
                    // Testing SelectTagHelper with Html.BeginForm
                    { "CreateWarehouse", "/HtmlGeneration_Home/CreateWarehouse" },
                    // Testing the HTML helpers with FormTagHelper
                    { "EditWarehouse", null },
                    { "Form", "/HtmlGeneration_Home/Form" },
                    // Testing MVC tag helpers invoked in the editor templates from HTML helpers
                    { "EmployeeList", "/HtmlGeneration_Home/EmployeeList" },
                    // Testing the EnvironmentTagHelper
                    { "Environment", null },
                    // Testing InputTagHelper with File
                    { "Input", null },
                    // Test ability to generate nearly identical HTML with MVC tag and HTML helpers.
                    // Only attribute order should differ.
                    { "Order", "/HtmlGeneration_Order/Submit" },
                    { "OrderUsingHtmlHelpers", "/HtmlGeneration_Order/Submit" },
                    // Testing PartialTagHelper
                    { "PartialTagHelperWithoutModel", null },
                    { "Warehouse", null },
                    // Testing InputTagHelpers invoked in the partial views
                    { "ProductList", "/HtmlGeneration_Product" },
                    { "ProductListUsingTagHelpers", "/HtmlGeneration_Product" },
                    { "ProductListUsingTagHelpersWithNullModel", "/HtmlGeneration_Product" },
                };
 
            return data;
        }
    }
 
    [Fact]
    public async Task EnumValues_SerializeCorrectly()
    {
        // Arrange & Act
        var response = await Client.GetStringAsync("http://localhost/HtmlGeneration_Home/Enum");
 
        // Assert
        Assert.Equal($"Vrijdag{Environment.NewLine}Month: FirstOne", response, ignoreLineEndingDifferences: true);
    }
 
    [Theory]
    [MemberData(nameof(WebPagesData))]
    public async Task HtmlGenerationWebSite_GeneratesExpectedResults(string action, string antiforgeryPath)
    {
        // Arrange
        var expectedMediaType = MediaTypeHeaderValue.Parse("text/html; charset=utf-8");
        var outputFile = "compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home." + action + ".html";
        var expectedContent =
            await ResourceFile.ReadResourceAsync(_resourcesAssembly, outputFile, sourceFile: false);
 
        // Act
        // The host is not important as everything runs in memory and tests are isolated from each other.
        var response = await Client.GetAsync("http://localhost/HtmlGeneration_Home/" + action);
        var responseContent = await response.Content.ReadAsStringAsync();
 
        // Assert
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        Assert.Equal(expectedMediaType, response.Content.Headers.ContentType);
 
        responseContent = responseContent.Trim();
        if (antiforgeryPath == null)
        {
            ResourceFile.UpdateOrVerify(_resourcesAssembly, outputFile, expectedContent, responseContent);
        }
        else
        {
            var forgeryToken = AntiforgeryTestHelper.RetrieveAntiforgeryToken(responseContent, antiforgeryPath);
            ResourceFile.UpdateOrVerify(_resourcesAssembly, outputFile, expectedContent, responseContent, forgeryToken);
        }
    }
 
    [Fact]
    public async Task HtmlGenerationWebSite_GeneratesExpectedResults_WithImageData()
    {
        var expectedMediaType = MediaTypeHeaderValue.Parse("text/html; charset=utf-8");
 
        // Act
        // The host is not important as everything runs in memory and tests are isolated from each other.
        var response = await Client.GetAsync("HtmlGeneration_Home/image");
 
        // Assert
        await response.AssertStatusCodeAsync(HttpStatusCode.OK);
        Assert.Equal(expectedMediaType, response.Content.Headers.ContentType);
        var document = await response.GetHtmlDocumentAsync();
 
        AssertImgElement(document.GetElementById("1"), new()
        {
            ["src"] = "/images/red.png",
            ["alt"] = "Red block",
            ["title"] = "<the title>",
        });
 
        // <img src = "/images/red.png?v=W2F5D366_nQ2fQqUk3URdgWy2ZekXjHzHJaY5yaiOOk" alt = "Red versioned" title = "Red versioned" />
        AssertImgElement(document.GetElementById("2"), new()
        {
            ["src"] = "/images/red.png?v=W2F5D366_nQ2fQqUk3URdgWy2ZekXjHzHJaY5yaiOOk",
            ["alt"] = "Red versioned",
            ["title"] = "Red versioned",
        });
 
        // <img src="/images/red.png" alt="Red explicitly not versioned" title="Red versioned">
        AssertImgElement(document.GetElementById("3"), new()
        {
            ["src"] = "/images/red.png",
            ["alt"] = "Red explicitly not versioned",
            ["title"] = "Red versioned",
        });
 
        // <img src="http://contoso.com/hello/world" alt="Absolute path versioned">
        AssertImgElement(document.GetElementById("4"), new()
        {
            ["src"] = "http://contoso.com/hello/world",
            ["alt"] = "Absolute path versioned",
        });
 
        // <img src="/images/fake.png" alt="Path to non existing file" />
        AssertImgElement(document.GetElementById("5"), new()
        {
            ["src"] = "/images/fake.png",
            ["alt"] = "Path to non existing file",
        });
 
        // <img src="/images/red.png?abc=def&amp;v=W2F5D366_nQ2fQqUk3URdgWy2ZekXjHzHJaY5yaiOOk" alt="Path with query string">
        AssertImgElement(document.GetElementById("6"), new()
        {
            ["src"] = "/images/red.png?abc=def&v=W2F5D366_nQ2fQqUk3URdgWy2ZekXjHzHJaY5yaiOOk",
            ["alt"] = "Path with query string",
        });
 
        // <img src="/images/red.png?v=W2F5D366_nQ2fQqUk3URdgWy2ZekXjHzHJaY5yaiOOk#abc" alt="Path with query string" />
        AssertImgElement(document.GetElementById("7"), new()
        {
            ["src"] = "/images/red.png?v=W2F5D366_nQ2fQqUk3URdgWy2ZekXjHzHJaY5yaiOOk#abc",
            ["alt"] = "Path with query string",
        });
 
        // <img src="/controller/action" alt="Path linking to some action">
        AssertImgElement(document.GetElementById("8"), new()
        {
            ["src"] = "/controller/action",
            ["alt"] = "Path linking to some action",
        });
 
        static void AssertImgElement(IElement imgElement, Dictionary<string, string> expectedAttributes)
        {
            Assert.NotNull(imgElement);
            Assert.Equal("img", imgElement.TagName, ignoreCase: true);
            Assert.Equal(expectedAttributes.Count + 1, imgElement.Attributes.Length); // + 1 for the id-attribute
            foreach (var attribute in expectedAttributes)
            {
                Assert.Equal(attribute.Value, imgElement.GetAttribute(attribute.Key));
            }
        }
    }
 
    [Fact]
    public async Task HtmlGenerationWebSite_LinkGeneration_With21CompatibilityBehavior()
    {
        // Arrange
        var client = Factory
             .WithWebHostBuilder(builder => builder.UseStartup<HtmlGenerationWebSite.StartupWithoutEndpointRouting>())
             .CreateDefaultClient();
        var expectedMediaType = MediaTypeHeaderValue.Parse("text/html; charset=utf-8");
        var outputFile = "compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Index21Compat.html";
        var expectedContent =
            await ResourceFile.ReadResourceAsync(_resourcesAssembly, outputFile, sourceFile: false);
 
        // Act
        // The host is not important as everything runs in memory and tests are isolated from each other.
        var response = await client.GetAsync("http://localhost/HtmlGeneration_Home/");
        var responseContent = await response.Content.ReadAsStringAsync();
 
        // Assert
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        Assert.Equal(expectedMediaType, response.Content.Headers.ContentType);
 
        responseContent = responseContent.Trim();
 
        ResourceFile.UpdateOrVerify(_resourcesAssembly, outputFile, expectedContent, responseContent);
    }
 
    public static TheoryData<string, string> EncodedPagesData
    {
        get
        {
            return new TheoryData<string, string>
                {
                    { "AttributesWithBooleanValues", null },
                    { "EditWarehouse", null },
                    { "Index", null },
                    { "Order", "/HtmlGeneration_Order/Submit" },
                    { "OrderUsingHtmlHelpers", "/HtmlGeneration_Order/Submit" },
                    { "Product", null },
                };
        }
    }
 
    [Theory]
    [MemberData(nameof(EncodedPagesData))]
    public async Task HtmlGenerationWebSite_GenerateEncodedResults(string action, string antiforgeryPath)
    {
        // Arrange
        var expectedMediaType = MediaTypeHeaderValue.Parse("text/html; charset=utf-8");
        var outputFile = "compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home." + action + ".Encoded.html";
        var expectedContent =
            await ResourceFile.ReadResourceAsync(_resourcesAssembly, outputFile, sourceFile: false);
 
        // Act
        // The host is not important as everything runs in memory and tests are isolated from each other.
        var response = await EncodedClient.GetAsync("http://localhost/HtmlGeneration_Home/" + action);
        var responseContent = await response.Content.ReadAsStringAsync();
 
        // Assert
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        Assert.Equal(expectedMediaType, response.Content.Headers.ContentType);
 
        responseContent = responseContent.Trim();
        if (antiforgeryPath == null)
        {
            ResourceFile.UpdateOrVerify(_resourcesAssembly, outputFile, expectedContent, responseContent);
        }
        else
        {
            ResourceFile.UpdateOrVerify(_resourcesAssembly, outputFile, expectedContent, responseContent, token: AntiforgeryTestHelper.RetrieveAntiforgeryToken(responseContent, antiforgeryPath));
        }
    }
 
    [ConditionalTheory]
    [InlineData("Link", null)]
    [InlineData("Script", null)]
    [SkipOnHelix("https://github.com/dotnet/aspnetcore/issues/10423")]
    public Task HtmlGenerationWebSite_GenerateEncodedResultsNotReadyForHelix(string action, string antiforgeryPath)
        => HtmlGenerationWebSite_GenerateEncodedResults(action, antiforgeryPath);
 
    // Testing how ModelMetadata is handled as ViewDataDictionary instances are created.
    [Theory]
    [InlineData("AtViewModel")]
    [InlineData("NullViewModel")]
    [InlineData("ViewModel")]
    public async Task CheckViewData_GeneratesExpectedResults(string action)
    {
        // Arrange
        var expectedMediaType = MediaTypeHeaderValue.Parse("text/html; charset=utf-8");
        var outputFile = "compiler/resources/HtmlGenerationWebSite.CheckViewData." + action + ".html";
        var expectedContent =
            await ResourceFile.ReadResourceAsync(_resourcesAssembly, outputFile, sourceFile: false);
 
        // Act
        var response = await Client.GetAsync("http://localhost/CheckViewData/" + action);
        var responseContent = await response.Content.ReadAsStringAsync();
 
        // Assert
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        Assert.Equal(expectedMediaType, response.Content.Headers.ContentType);
 
        responseContent = responseContent.Trim();
        ResourceFile.UpdateOrVerify(_resourcesAssembly, outputFile, expectedContent, responseContent);
    }
 
    [Fact]
    public async Task ValidationTagHelpers_GeneratesExpectedSpansAndDivs()
    {
        // Arrange
        var outputFile = "compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Customer.Index.html";
        var expectedContent =
            await ResourceFile.ReadResourceAsync(_resourcesAssembly, outputFile, sourceFile: false);
 
        var request = new HttpRequestMessage(HttpMethod.Post, "http://localhost/Customer/HtmlGeneration_Customer");
        var nameValueCollection = new List<KeyValuePair<string, string>>
            {
                new KeyValuePair<string,string>("Number", string.Empty),
                new KeyValuePair<string,string>("Name", string.Empty),
                new KeyValuePair<string,string>("Email", string.Empty),
                new KeyValuePair<string,string>("PhoneNumber", string.Empty),
                new KeyValuePair<string,string>("Password", string.Empty)
            };
        request.Content = new FormUrlEncodedContent(nameValueCollection);
 
        // Act
        var response = await Client.SendAsync(request);
        var responseContent = await response.Content.ReadAsStringAsync();
 
        // Assert
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
 
        responseContent = responseContent.Trim();
        ResourceFile.UpdateOrVerify(_resourcesAssembly, outputFile, expectedContent, responseContent, token: AntiforgeryTestHelper.RetrieveAntiforgeryToken(responseContent, "Customer/HtmlGeneration_Customer"));
    }
 
    [Fact]
    public async Task ClientValidators_AreGeneratedDuringInitialRender()
    {
        // Arrange
        var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost/Customer/HtmlGeneration_Customer/CustomerWithRecords");
 
        // Act
        var response = await Client.SendAsync(request);
 
        // Assert
        var document = await response.GetHtmlDocumentAsync();
 
        var numberInput = document.RequiredQuerySelector("input[id=Number]");
        Assert.Equal("true", numberInput.GetAttribute("data-val"));
        Assert.Equal("The field Number must be between 1 and 100.", numberInput.GetAttribute("data-val-range"));
        Assert.Equal("The Number field is required.", numberInput.GetAttribute("data-val-required"));
 
        var passwordInput = document.RequiredQuerySelector("input[id=Password]");
        Assert.Equal("true", passwordInput.GetAttribute("data-val"));
        Assert.Equal("The Password field is required.", passwordInput.GetAttribute("data-val-required"));
 
        var addressInput = document.RequiredQuerySelector("input[id=Address]");
        Assert.Equal("true", addressInput.GetAttribute("data-val"));
        Assert.Equal("The Address field is required.", addressInput.GetAttribute("data-val-required"));
    }
 
    [Fact]
    public async Task ValidationTagHelpers_UsingRecords()
    {
        // Arrange
        var request = new HttpRequestMessage(HttpMethod.Post, "http://localhost/Customer/HtmlGeneration_Customer/CustomerWithRecords");
        var nameValueCollection = new List<KeyValuePair<string, string>>
            {
                new KeyValuePair<string,string>("Number", string.Empty),
                new KeyValuePair<string,string>("Name", string.Empty),
                new KeyValuePair<string,string>("Email", string.Empty),
                new KeyValuePair<string,string>("PhoneNumber", string.Empty),
                new KeyValuePair<string,string>("Password", string.Empty),
                new KeyValuePair<string,string>("Address", string.Empty),
            };
        request.Content = new FormUrlEncodedContent(nameValueCollection);
 
        // Act
        var response = await Client.SendAsync(request);
 
        // Assert
        var document = await response.GetHtmlDocumentAsync();
 
        var validation = document.RequiredQuerySelector("span[data-valmsg-for=Number]");
        Assert.Equal("The value '' is invalid.", validation.TextContent);
 
        validation = document.QuerySelector("span[data-valmsg-for=Name]");
        Assert.Null(validation);
 
        validation = document.QuerySelector("span[data-valmsg-for=Email]");
        Assert.Equal("field-validation-valid", validation.ClassName);
 
        validation = document.QuerySelector("span[data-valmsg-for=Password]");
        Assert.Equal("The Password field is required.", validation.TextContent);
 
        validation = document.QuerySelector("span[data-valmsg-for=Address]");
        Assert.Equal("The Address field is required.", validation.TextContent);
    }
 
    [Fact]
    public async Task CacheTagHelper_CanCachePortionsOfViewsPartialViewsAndViewComponents()
    {
        // Arrange
        var assertFile =
            "compiler/resources/CacheTagHelper_CanCachePortionsOfViewsPartialViewsAndViewComponents.Assert";
 
        var outputFile1 = assertFile + "1.txt";
        var expected1 =
            await ResourceFile.ReadResourceAsync(_resourcesAssembly, outputFile1, sourceFile: false);
        var outputFile2 = assertFile + "2.txt";
        var expected2 =
            await ResourceFile.ReadResourceAsync(_resourcesAssembly, outputFile2, sourceFile: false);
        var outputFile3 = assertFile + "3.txt";
        var expected3 =
            await ResourceFile.ReadResourceAsync(_resourcesAssembly, outputFile3, sourceFile: false);
 
        // Act - 1
        // Verify that content gets cached based on vary-by-params
        var targetUrl = "/catalog?categoryId=1&correlationid=1";
        var request = RequestWithLocale(targetUrl, "North");
        var response1 = await (await Client.SendAsync(request)).Content.ReadAsStringAsync();
        request = RequestWithLocale(targetUrl, "North");
        var response2 = await (await Client.SendAsync(request)).Content.ReadAsStringAsync();
 
        // Assert - 1
        ResourceFile.UpdateOrVerify(_resourcesAssembly, outputFile1, expected1, response1.Trim());
 
        if (!ResourceFile.GenerateBaselines)
        {
            Assert.Equal(expected1, response2.Trim(), ignoreLineEndingDifferences: true);
        }
 
        // Act - 2
        // Verify content gets changed in partials when one of the vary by parameters is changed
        targetUrl = "/catalog?categoryId=3&correlationid=2";
        request = RequestWithLocale(targetUrl, "North");
        var response3 = await (await Client.SendAsync(request)).Content.ReadAsStringAsync();
        request = RequestWithLocale(targetUrl, "North");
        var response4 = await (await Client.SendAsync(request)).Content.ReadAsStringAsync();
 
        // Assert - 2
        ResourceFile.UpdateOrVerify(_resourcesAssembly, outputFile2, expected2, response3.Trim());
        if (!ResourceFile.GenerateBaselines)
        {
            Assert.Equal(expected2, response4.Trim(), ignoreLineEndingDifferences: true);
        }
 
        // Act - 3
        // Verify content gets changed in a View Component when the Vary-by-header parameters is changed
        targetUrl = "/catalog?categoryId=3&correlationid=3";
        request = RequestWithLocale(targetUrl, "East");
        var response5 = await (await Client.SendAsync(request)).Content.ReadAsStringAsync();
        request = RequestWithLocale(targetUrl, "East");
        var response6 = await (await Client.SendAsync(request)).Content.ReadAsStringAsync();
 
        // Assert - 3
        ResourceFile.UpdateOrVerify(_resourcesAssembly, outputFile3, expected3, response5.Trim());
        if (!ResourceFile.GenerateBaselines)
        {
            Assert.Equal(expected3, response6.Trim(), ignoreLineEndingDifferences: true);
        }
    }
 
    [Fact]
    public async Task CacheTagHelper_ExpiresContent_BasedOnExpiresParameter()
    {
        // Arrange & Act - 1
        var response1 = await Client.GetStringAsync("/catalog/2");
 
        // Assert - 1
        var expected1 = "Cached content for 2";
        Assert.Equal(expected1, response1.Trim());
 
        // Act - 2
        await Task.Delay(TimeSpan.FromSeconds(2));
        var response2 = await Client.GetStringAsync("/catalog/3");
 
        // Assert - 2
        var expected2 = "Cached content for 3";
        Assert.Equal(expected2, response2.Trim());
    }
 
    [Fact]
    public async Task CacheTagHelper_UsesVaryByCookie_ToVaryContent()
    {
        // Arrange & Act - 1
        var response1 = await Client.GetStringAsync("/catalog/cart?correlationid=1");
 
        // Assert - 1
        var expected1 = "Cart content for 1";
        Assert.Equal(expected1, response1.Trim());
 
        // Act - 2
        var request = new HttpRequestMessage(HttpMethod.Get, "/catalog/cart?correlationid=2");
        request.Headers.Add("Cookie", "CartId=10");
        var response2 = await (await Client.SendAsync(request)).Content.ReadAsStringAsync();
 
        // Assert - 2
        var expected2 = "Cart content for 2";
        Assert.Equal(expected2, response2.Trim());
 
        // Act - 3
        // Resend the cookieless request and cached result from the first response.
        var response3 = await Client.GetStringAsync("/catalog/cart?correlationid=3");
 
        // Assert - 3
        Assert.Equal(expected1, response3.Trim());
    }
 
    [Fact]
    public async Task CacheTagHelper_VariesByRoute()
    {
        // Arrange & Act - 1
        var response1 = await Client.GetStringAsync(
            "/catalog/north-west/confirm-payment?confirmationId=1");
 
        // Assert - 1
        var expected1 = "Welcome Guest. Your confirmation id is 1. (Region north-west)";
        Assert.Equal(expected1, response1.Trim());
 
        // Act - 2
        var response2 = await Client.GetStringAsync(
            "/catalog/south-central/confirm-payment?confirmationId=2");
 
        // Assert - 2
        var expected2 = "Welcome Guest. Your confirmation id is 2. (Region south-central)";
        Assert.Equal(expected2, response2.Trim());
 
        // Act 3
        var response3 = await Client.GetStringAsync(
            "/catalog/north-west/Silver/confirm-payment?confirmationId=4");
 
        var expected3 = "Welcome Silver member. Your confirmation id is 4. (Region north-west)";
        Assert.Equal(expected3, response3.Trim());
 
        // Act 4
        var response4 = await Client.GetStringAsync(
            "/catalog/north-west/Gold/confirm-payment?confirmationId=5");
 
        var expected4 = "Welcome Gold member. Your confirmation id is 5. (Region north-west)";
        Assert.Equal(expected4, response4.Trim());
 
        // Act - 4
        // Resend the responses and expect cached results.
        response1 = await Client.GetStringAsync(
            "/catalog/north-west/confirm-payment?confirmationId=301");
        response2 = await Client.GetStringAsync(
            "/catalog/south-central/confirm-payment?confirmationId=402");
        response3 = await Client.GetStringAsync(
            "/catalog/north-west/Silver/confirm-payment?confirmationId=503");
        response4 = await Client.GetStringAsync(
            "/catalog/north-west/Gold/confirm-payment?confirmationId=608");
 
        // Assert - 4
        Assert.Equal(expected1, response1.Trim());
        Assert.Equal(expected2, response2.Trim());
        Assert.Equal(expected3, response3.Trim());
        Assert.Equal(expected4, response4.Trim());
    }
 
    [Fact]
    public async Task CacheTagHelper_VariesByUserId()
    {
        // Arrange & Act - 1
        var response1 = await Client.GetStringAsync("/catalog/past-purchases/test1?correlationid=1");
        var response2 = await Client.GetStringAsync("/catalog/past-purchases/test1?correlationid=2");
 
        // Assert - 1
        var expected1 = "Past purchases for user test1 (1)";
        Assert.Equal(expected1, response1.Trim());
        Assert.Equal(expected1, response2.Trim());
 
        // Act - 2
        var response3 = await Client.GetStringAsync("/catalog/past-purchases/test2?correlationid=3");
        var response4 = await Client.GetStringAsync("/catalog/past-purchases/test2?correlationid=4");
 
        // Assert - 2
        var expected2 = "Past purchases for user test2 (3)";
        Assert.Equal(expected2, response3.Trim());
        Assert.Equal(expected2, response4.Trim());
    }
 
    [Fact]
    public async Task CacheTagHelper_BubblesExpirationOfNestedTagHelpers()
    {
        // Arrange & Act - 1
        var response1 = await Client.GetStringAsync("/categories/Books?correlationId=1");
 
        // Assert - 1
        var expected1 =
@"Category: Books
Products: Book1, Book2 (1)";
        Assert.Equal(expected1, response1.Trim(), ignoreLineEndingDifferences: true);
 
        // Act - 2
        var response2 = await Client.GetStringAsync("/categories/Electronics?correlationId=2");
 
        // Assert - 2
        var expected2 =
@"Category: Electronics
Products: Book1, Book2 (1)";
        Assert.Equal(expected2, response2.Trim(), ignoreLineEndingDifferences: true);
 
        // Act - 3
        // Trigger an expiration of the nested content.
        var content = @"[{ ""productName"": ""Music Systems"" },{ ""productName"": ""Televisions"" }]"[{ ""productName"": ""Music Systems"" },{ ""productName"": ""Televisions"" }]";
        var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/categories/Electronics");
        requestMessage.Content = new StringContent(content, Encoding.UTF8, "application/json");
        (await Client.SendAsync(requestMessage)).EnsureSuccessStatusCode();
 
        var response3 = await Client.GetStringAsync("/categories/Electronics?correlationId=3");
 
        // Assert - 3
        var expected3 =
@"Category: Electronics
Products: Music Systems, Televisions (3)";
        Assert.Equal(expected3, response3.Trim(), ignoreLineEndingDifferences: true);
    }
 
    [Fact]
    public async Task CacheTagHelper_DoesNotCacheIfDisabled()
    {
        // Arrange & Act
        var response1 = await Client.GetStringAsync("/catalog/GetDealPercentage/20?isEnabled=true");
        var response2 = await Client.GetStringAsync("/catalog/GetDealPercentage/40?isEnabled=true");
        var response3 = await Client.GetStringAsync("/catalog/GetDealPercentage/30?isEnabled=false");
 
        // Assert
        Assert.Equal("Deal percentage is 20", response1.Trim());
        Assert.Equal("Deal percentage is 20", response2.Trim());
        Assert.Equal("Deal percentage is 30", response3.Trim());
    }
 
    [Fact]
    public async Task EditorTemplateWithNoModel_RendersWithCorrectMetadata()
    {
        // Arrange
        var expected =
            "<label class=\"control-label col-md-2\" for=\"Name\">ItemName</label>" + Environment.NewLine +
            "<input id=\"Name\" name=\"Name\" type=\"text\" value=\"\" />" + Environment.NewLine + Environment.NewLine +
            "<label class=\"control-label col-md-2\" for=\"Id\">ItemNo</label>" + Environment.NewLine +
            "<input data-val=\"true\" data-val-required=\"The ItemNo field is required.\" id=\"Id\" name=\"Id\" type=\"text\" value=\"\" />" +
            Environment.NewLine + Environment.NewLine;
 
        // Act
        var response = await Client.GetStringAsync("http://localhost/HtmlGeneration_Home/ItemUsingSharedEditorTemplate");
 
        // Assert
        Assert.Equal(expected, response, ignoreLineEndingDifferences: true);
    }
 
    [Fact]
    public async Task EditorTemplateWithSpecificModel_RendersWithCorrectMetadata()
    {
        // Arrange
        var expected = "<label for=\"Description\">ItemDesc</label>" + Environment.NewLine +
            "<input id=\"Description\" name=\"Description\" type=\"text\" value=\"\" />" + Environment.NewLine + Environment.NewLine;
 
        // Act
        var response = await Client.GetStringAsync("http://localhost/HtmlGeneration_Home/ItemUsingModelSpecificEditorTemplate");
 
        // Assert
        Assert.Equal(expected, response, ignoreLineEndingDifferences: true);
    }
 
    // We want to make sure that for 'weird' model expressions involving:
    // - fields
    // - statics
    // - private
    //
    // These tests verify that we don't throw, and can evaluate the expression to get the model
    // value. One quirk of behavior for these cases is that we can't return a correct model metadata
    // instance (this is true for anything other than a public instance property). We're not overly
    // concerned with that, and so the accuracy of the model metadata is not verified by the test.
    [Theory]
    [InlineData("GetWeirdWithHtmlHelpers")]
    [InlineData("GetWeirdWithTagHelpers")]
    public async Task WeirdModelExpressions_CanAccessModelValues(string action)
    {
        // Arrange
        var url = "http://localhost/HtmlGeneration_WeirdExpressions/" + action;
 
        // Act
        var response = await Client.GetStringAsync(url);
 
        // Assert
        Assert.Contains("Hello, Field World!", response);
        Assert.Contains("Hello, Static World!", response);
        Assert.Contains("Hello, Private World!", response);
    }
 
    [Fact]
    public async Task PartialTagHelper_AllowsPassingModelValue()
    {
        // Arrange
        var url = "/HtmlGeneration_Home/StatusMessage";
 
        // Act
        var document = await Client.GetHtmlDocumentAsync(url);
 
        // Assert
        var banner = document.RequiredQuerySelector(".banner");
        Assert.Equal("Some status message", banner.TextContent);
    }
 
    [Fact]
    public async Task PartialTagHelper_AllowsPassingNullModelValue()
    {
        // Regression test for https://github.com/aspnet/Mvc/issues/7667.
        // Arrange
        var url = "/HtmlGeneration_Home/NullStatusMessage";
 
        // Act
        var document = await Client.GetHtmlDocumentAsync(url);
 
        // Assert
        var banner = document.RequiredQuerySelector(".banner");
        Assert.Empty(banner.TextContent);
    }
 
    [Fact]
    public async Task PartialTagHelper_AllowsUsingFallback()
    {
        // Arrange
        var url = "/Customer/PartialWithFallback";
 
        // Act
        var document = await Client.GetHtmlDocumentAsync(url);
 
        // Assert
        var content = document.RequiredQuerySelector("#content");
        Assert.Equal("Hello from fallback", content.TextContent);
    }
 
    [Fact]
    public async Task PartialTagHelper_AllowsUsingOptional()
    {
        // Arrange
        var url = "/Customer/PartialWithOptional";
 
        // Act
        var document = await Client.GetHtmlDocumentAsync(url);
 
        // Assert
        var content = document.RequiredQuerySelector("#content");
        Assert.Empty(content.TextContent);
    }
 
    [Fact]
    public async Task ValidationProviderAttribute_ValidationTagHelpers_GeneratesExpectedDataAttributes()
    {
        // Act
        var document = await Client.GetHtmlDocumentAsync("HtmlGeneration_Home/ValidationProviderAttribute");
 
        // Assert
        var firstName = document.RequiredQuerySelector("#FirstName");
        Assert.Equal("true", firstName.GetAttribute("data-val"));
        Assert.Equal("The FirstName field is required.", firstName.GetAttribute("data-val-required"));
        Assert.Equal("The field FirstName must be a string with a maximum length of 5.", firstName.GetAttribute("data-val-length"));
        Assert.Equal("5", firstName.GetAttribute("data-val-length-max"));
        Assert.Equal("The field FirstName must match the regular expression '[A-Za-z]*'.", firstName.GetAttribute("data-val-regex"));
        Assert.Equal("[A-Za-z]*", firstName.GetAttribute("data-val-regex-pattern"));
 
        var lastName = document.RequiredQuerySelector("#LastName");
        Assert.Equal("true", lastName.GetAttribute("data-val"));
        Assert.Equal("The LastName field is required.", lastName.GetAttribute("data-val-required"));
        Assert.Equal("The field LastName must be a string with a maximum length of 6.", lastName.GetAttribute("data-val-length"));
        Assert.Equal("6", lastName.GetAttribute("data-val-length-max"));
        Assert.False(lastName.HasAttribute("data-val-regex"));
    }
 
    [Fact]
    public async Task ValidationProviderAttribute_ValidationTagHelpers_GeneratesExpectedSpansAndDivsOnValidationError()
    {
        // Arrange
        var request = new HttpRequestMessage(HttpMethod.Post, "HtmlGeneration_Home/ValidationProviderAttribute");
        request.Content = new FormUrlEncodedContent(new Dictionary<string, string>
            {
                { "FirstName", "TestFirstName" },
            });
 
        // Act
        var response = await Client.SendAsync(request);
 
        // Assert
        await response.AssertStatusCodeAsync(HttpStatusCode.OK);
        var document = await response.GetHtmlDocumentAsync();
        Assert.Collection(
            document.QuerySelectorAll("div.validation-summary-errors ul li"),
            item => Assert.Equal("The field FirstName must be a string with a maximum length of 5.", item.TextContent),
            item => Assert.Equal("The LastName field is required.", item.TextContent));
    }
 
    private static HttpRequestMessage RequestWithLocale(string url, string locale)
    {
        var request = new HttpRequestMessage(HttpMethod.Get, url);
        request.Headers.Add("Locale", locale);
 
        return request;
    }
}