Events-MVC (example with htmx)
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
using System.Net;
|
||||
using Events.Tests.IntegrationTests.Infrastructure;
|
||||
|
||||
namespace Events.Tests.IntegrationTests;
|
||||
|
||||
public class CountriesCrudShould
|
||||
{
|
||||
[Fact]
|
||||
public async Task CreateCountry()
|
||||
{
|
||||
await using var factory = new CustomWebApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await AntiforgeryRequestHelper.PostFormAsync(
|
||||
client,
|
||||
"/Countries",
|
||||
"/Countries/Create",
|
||||
[
|
||||
new("Code", "DE"),
|
||||
new("Alpha3", "DEU"),
|
||||
new("Name", "Germany"),
|
||||
new("Translations[0].LanguageCode", "hr"),
|
||||
new("Translations[0].Name", "Germany")
|
||||
]);
|
||||
var html = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Contains("Germany", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EditCountry()
|
||||
{
|
||||
await using var factory = new CustomWebApplicationFactory(ctx => ctx.Countries.Add(TestDataSeederCountry()));
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await AntiforgeryRequestHelper.PostFormAsync(
|
||||
client,
|
||||
"/Countries",
|
||||
"/Countries/Edit/HR",
|
||||
[
|
||||
new("Code", "HR"),
|
||||
new("Alpha3", "HRV"),
|
||||
new("Name", "Republic of Croatia"),
|
||||
new("Translations[0].LanguageCode", ""),
|
||||
new("Translations[0].Name", "")
|
||||
]);
|
||||
var html = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Contains("Republic of Croatia", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteCountry()
|
||||
{
|
||||
await using var factory = new CustomWebApplicationFactory(ctx => ctx.Countries.Add(TestDataSeederCountry()));
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await AntiforgeryRequestHelper.PostFormAsync(client, "/Countries", "/Countries/Delete/HR", []);
|
||||
var html = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.DoesNotContain("Croatia", html);
|
||||
}
|
||||
|
||||
private static Events.EF.Models.Country TestDataSeederCountry()
|
||||
{
|
||||
return new()
|
||||
{
|
||||
Code = "HR",
|
||||
Alpha3 = "HRV",
|
||||
Name = "Croatia"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="10.0.0">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Events.MVC\Events.MVC.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Net;
|
||||
using Events.Tests.IntegrationTests.Infrastructure;
|
||||
|
||||
namespace Events.Tests.IntegrationTests;
|
||||
|
||||
public class EventsCrudShould
|
||||
{
|
||||
[Fact]
|
||||
public async Task CreateEvent()
|
||||
{
|
||||
await using var factory = new CustomWebApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await AntiforgeryRequestHelper.PostFormAsync(
|
||||
client,
|
||||
"/Events",
|
||||
"/Events/Create",
|
||||
[new("Name", "Autumn Cup"), new("EventDate", "2026-09-10")]);
|
||||
var html = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Contains("Autumn Cup", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EditEvent()
|
||||
{
|
||||
await using var factory = new CustomWebApplicationFactory(TestDataSeeder.SeedEvents);
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await AntiforgeryRequestHelper.PostFormAsync(
|
||||
client,
|
||||
"/Events",
|
||||
"/Events/Edit/100",
|
||||
[new("Id", "100"), new("Name", "Updated Games"), new("EventDate", "2026-04-20")]);
|
||||
var html = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Contains("Updated Games", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteEvent()
|
||||
{
|
||||
await using var factory = new CustomWebApplicationFactory(TestDataSeeder.SeedEvents);
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await AntiforgeryRequestHelper.PostFormAsync(client, "/Events", "/Events/Delete/100", []);
|
||||
var html = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.DoesNotContain("Spring Games", html);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Net;
|
||||
using Events.Tests.IntegrationTests.Infrastructure;
|
||||
|
||||
namespace Events.Tests.IntegrationTests;
|
||||
|
||||
public class EventsPageShould
|
||||
{
|
||||
[Fact]
|
||||
public async Task ShowParticipantsCountAndRegistrationsLinkWhenRegistrationsExist()
|
||||
{
|
||||
await using var factory = new CustomWebApplicationFactory(ctx =>
|
||||
{
|
||||
TestDataSeeder.SeedRegistrationsScenario(ctx);
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/Events");
|
||||
var html = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Contains("Spring Games", html);
|
||||
Assert.Contains("/Registrations?eventId=100", html);
|
||||
Assert.Contains(">2<", html);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
global using Xunit;
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.Net;
|
||||
using Events.Tests.IntegrationTests.Infrastructure;
|
||||
|
||||
namespace Events.Tests.IntegrationTests;
|
||||
|
||||
public class HomePageShould
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReturnSuccessAndContainEnglishDescription()
|
||||
{
|
||||
await using var factory = new CustomWebApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/");
|
||||
var html = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Contains("This sample demonstrates an ASP.NET Core MVC application", html);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Events.Tests.IntegrationTests.Infrastructure;
|
||||
|
||||
internal static partial class AntiforgeryRequestHelper
|
||||
{
|
||||
private const string AntiforgeryFieldName = "__RequestVerificationToken";
|
||||
|
||||
public static async Task<HttpResponseMessage> PostFormAsync(
|
||||
HttpClient client,
|
||||
string pageUrl,
|
||||
string postUrl,
|
||||
IEnumerable<KeyValuePair<string, string?>> fields)
|
||||
{
|
||||
var pageHtml = await client.GetStringAsync(pageUrl);
|
||||
var token = ExtractAntiforgeryToken(pageHtml);
|
||||
|
||||
var payload = fields
|
||||
.Append(new KeyValuePair<string, string?>(AntiforgeryFieldName, token))
|
||||
.ToArray();
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, postUrl)
|
||||
{
|
||||
Content = new FormUrlEncodedContent(payload!)
|
||||
};
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/html"));
|
||||
return await client.SendAsync(request);
|
||||
}
|
||||
|
||||
private static string ExtractAntiforgeryToken(string html)
|
||||
{
|
||||
var match = AntiforgeryTokenRegex().Match(html);
|
||||
Assert.True(match.Success, "Expected antiforgery token field was not found in the HTML response.");
|
||||
return match.Groups["token"].Value;
|
||||
}
|
||||
|
||||
[GeneratedRegex("<input[^>]*name=\"__RequestVerificationToken\"[^>]*value=\"(?<token>[^\"]+)\"", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||
private static partial Regex AntiforgeryTokenRegex();
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
#if POSTGRES
|
||||
using Events.EF.Data.Postgres;
|
||||
#else
|
||||
using Events.EF.Data.MSSQL;
|
||||
#endif
|
||||
using Events.EF.Models;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace Events.Tests.IntegrationTests.Infrastructure;
|
||||
|
||||
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly Action<EventsContext>? seed;
|
||||
private readonly InMemoryDatabaseRoot databaseRoot = new();
|
||||
private readonly string databaseName = Guid.NewGuid().ToString();
|
||||
|
||||
public CustomWebApplicationFactory(Action<EventsContext>? seed = null)
|
||||
{
|
||||
this.seed = seed;
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Development");
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll(typeof(DbContextOptions<EventsContext>));
|
||||
services.RemoveAll(typeof(IDbContextOptionsConfiguration<EventsContext>));
|
||||
services.RemoveAll(typeof(EventsContext));
|
||||
|
||||
services.AddDbContext<EventsContext>(options =>
|
||||
options
|
||||
.UseInMemoryDatabase(databaseName, databaseRoot)
|
||||
.ConfigureWarnings(warnings => warnings.Ignore(CoreEventId.ManyServiceProvidersCreatedWarning)));
|
||||
|
||||
var serviceProvider = services.BuildServiceProvider();
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<EventsContext>();
|
||||
ctx.Database.EnsureCreated();
|
||||
seed?.Invoke(ctx);
|
||||
ctx.SaveChanges();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
#if POSTGRES
|
||||
using Events.EF.Data.Postgres;
|
||||
#else
|
||||
using Events.EF.Data.MSSQL;
|
||||
#endif
|
||||
using Events.EF.Models;
|
||||
|
||||
namespace Events.Tests.IntegrationTests.Infrastructure;
|
||||
|
||||
internal static class TestDataSeeder
|
||||
{
|
||||
public static void SeedSports(EventsContext ctx)
|
||||
{
|
||||
ctx.Sports.AddRange(
|
||||
new Sport { Id = 1, Name = "Football" },
|
||||
new Sport { Id = 2, Name = "Basketball" });
|
||||
}
|
||||
|
||||
public static void SeedPeople(EventsContext ctx)
|
||||
{
|
||||
ctx.Countries.Add(new Country
|
||||
{
|
||||
Code = "HR",
|
||||
Alpha3 = "HRV",
|
||||
Name = "Croatia"
|
||||
});
|
||||
|
||||
ctx.People.Add(new Person
|
||||
{
|
||||
Id = 1,
|
||||
FirstName = "Ivan",
|
||||
LastName = "Horvat",
|
||||
FirstNameTranscription = "Ivan",
|
||||
LastNameTranscription = "Horvat",
|
||||
AddressLine = "Ilica 1",
|
||||
PostalCode = "10000",
|
||||
City = "Zagreb",
|
||||
AddressCountry = "Croatia",
|
||||
Email = "ivan.horvat@example.com",
|
||||
ContactPhone = "+38591111222",
|
||||
BirthDate = new DateOnly(1990, 5, 1),
|
||||
DocumentNumber = "DOC-1",
|
||||
CountryCode = "HR"
|
||||
});
|
||||
}
|
||||
|
||||
public static void SeedEvents(EventsContext ctx)
|
||||
{
|
||||
ctx.Events.AddRange(
|
||||
new Event { Id = 100, Name = "Spring Games", EventDate = new DateOnly(2026, 4, 15) },
|
||||
new Event { Id = 200, Name = "Summer Cup", EventDate = new DateOnly(2026, 6, 20) });
|
||||
}
|
||||
|
||||
public static void SeedRegistrationsScenario(EventsContext ctx)
|
||||
{
|
||||
ctx.Countries.AddRange(
|
||||
new Country
|
||||
{
|
||||
Code = "HR",
|
||||
Alpha3 = "HRV",
|
||||
Name = "Croatia"
|
||||
},
|
||||
new Country
|
||||
{
|
||||
Code = "DE",
|
||||
Alpha3 = "DEU",
|
||||
Name = "Germany"
|
||||
});
|
||||
|
||||
ctx.People.AddRange(
|
||||
new Person
|
||||
{
|
||||
Id = 1,
|
||||
FirstName = "Ivan",
|
||||
LastName = "Horvat",
|
||||
FirstNameTranscription = "Ivan",
|
||||
LastNameTranscription = "Horvat",
|
||||
AddressLine = "Ilica 1",
|
||||
PostalCode = "10000",
|
||||
City = "Zagreb",
|
||||
AddressCountry = "Croatia",
|
||||
Email = "ivan.horvat@example.com",
|
||||
ContactPhone = "+38591111222",
|
||||
BirthDate = new DateOnly(1990, 5, 1),
|
||||
DocumentNumber = "DOC-1",
|
||||
CountryCode = "HR"
|
||||
},
|
||||
new Person
|
||||
{
|
||||
Id = 2,
|
||||
FirstName = "Johann",
|
||||
LastName = "Schmidt",
|
||||
FirstNameTranscription = "Johann",
|
||||
LastNameTranscription = "Schmidt",
|
||||
AddressLine = "Unter den Linden 5",
|
||||
PostalCode = "10117",
|
||||
City = "Berlin",
|
||||
AddressCountry = "Germany",
|
||||
Email = "johann.schmidt@example.com",
|
||||
ContactPhone = "+49170111222",
|
||||
BirthDate = new DateOnly(1988, 3, 12),
|
||||
DocumentNumber = "DOC-2",
|
||||
CountryCode = "DE"
|
||||
});
|
||||
|
||||
ctx.Sports.AddRange(
|
||||
new Sport { Id = 10, Name = "Football" },
|
||||
new Sport { Id = 20, Name = "Basketball" });
|
||||
|
||||
ctx.Events.AddRange(
|
||||
new Event { Id = 100, Name = "Spring Games", EventDate = new DateOnly(2026, 4, 15) },
|
||||
new Event { Id = 200, Name = "Summer Cup", EventDate = new DateOnly(2026, 6, 20) });
|
||||
|
||||
ctx.Registrations.AddRange(
|
||||
new Registration
|
||||
{
|
||||
Id = 1000,
|
||||
EventId = 100,
|
||||
PersonId = 1,
|
||||
SportId = 10,
|
||||
RegisteredAt = new DateTime(2026, 3, 1, 9, 30, 0, DateTimeKind.Utc)
|
||||
},
|
||||
new Registration
|
||||
{
|
||||
Id = 1001,
|
||||
EventId = 100,
|
||||
PersonId = 2,
|
||||
SportId = 20,
|
||||
RegisteredAt = new DateTime(2026, 3, 2, 10, 0, 0, DateTimeKind.Utc)
|
||||
},
|
||||
new Registration
|
||||
{
|
||||
Id = 1002,
|
||||
EventId = 200,
|
||||
PersonId = 1,
|
||||
SportId = 20,
|
||||
RegisteredAt = new DateTime(2026, 3, 3, 11, 0, 0, DateTimeKind.Utc)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using System.Net;
|
||||
#if POSTGRES
|
||||
using Events.EF.Data.Postgres;
|
||||
#else
|
||||
using Events.EF.Data.MSSQL;
|
||||
#endif
|
||||
using Events.EF.Models;
|
||||
using Events.Tests.IntegrationTests.Infrastructure;
|
||||
|
||||
namespace Events.Tests.IntegrationTests;
|
||||
|
||||
public class PeopleCrudShould
|
||||
{
|
||||
[Fact]
|
||||
public async Task CreatePerson()
|
||||
{
|
||||
await using var factory = new CustomWebApplicationFactory(ctx => ctx.Countries.Add(new Country { Code = "HR", Alpha3 = "HRV", Name = "Croatia" }));
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await AntiforgeryRequestHelper.PostFormAsync(
|
||||
client,
|
||||
"/People",
|
||||
"/People/Create",
|
||||
[
|
||||
new("FirstName", "Ana"),
|
||||
new("LastName", "Kovac"),
|
||||
new("FirstNameTranscription", "Ana"),
|
||||
new("LastNameTranscription", "Kovac"),
|
||||
new("Email", "ana.kovac@example.com"),
|
||||
new("ContactPhone", "+38591123456"),
|
||||
new("BirthDate", "1995-01-01"),
|
||||
new("CountryCode", "HR"),
|
||||
new("DocumentNumber", "DOC-2"),
|
||||
new("AddressLine", "Main Street 1"),
|
||||
new("PostalCode", "10000"),
|
||||
new("City", "Zagreb"),
|
||||
new("AddressCountry", "Croatia")
|
||||
]);
|
||||
var html = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Contains("Ana Kovac", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EditPerson()
|
||||
{
|
||||
await using var factory = new CustomWebApplicationFactory(TestDataSeeder.SeedPeople);
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await AntiforgeryRequestHelper.PostFormAsync(
|
||||
client,
|
||||
"/People",
|
||||
"/People/Edit/1",
|
||||
[
|
||||
new("Id", "1"),
|
||||
new("FirstName", "Ivan"),
|
||||
new("LastName", "Kovac"),
|
||||
new("FirstNameTranscription", "Ivan"),
|
||||
new("LastNameTranscription", "Kovac"),
|
||||
new("Email", "ivan.kovac@example.com"),
|
||||
new("ContactPhone", "+38591111222"),
|
||||
new("BirthDate", "1990-05-01"),
|
||||
new("CountryCode", "HR"),
|
||||
new("DocumentNumber", "DOC-1"),
|
||||
new("AddressLine", "Ilica 1"),
|
||||
new("PostalCode", "10000"),
|
||||
new("City", "Zagreb"),
|
||||
new("AddressCountry", "Croatia")
|
||||
]);
|
||||
var html = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Contains("Ivan Kovac", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeletePerson()
|
||||
{
|
||||
await using var factory = new CustomWebApplicationFactory(TestDataSeeder.SeedPeople);
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await AntiforgeryRequestHelper.PostFormAsync(client, "/People", "/People/Delete/1", []);
|
||||
var html = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.DoesNotContain("Ivan Horvat", html);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Net;
|
||||
using Events.Tests.IntegrationTests.Infrastructure;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace Events.Tests.IntegrationTests;
|
||||
|
||||
public class PeopleGuardShould
|
||||
{
|
||||
[Fact]
|
||||
public async Task RedirectToCountriesWhenPeoplePageIsRequestedWithoutCountries()
|
||||
{
|
||||
await using var factory = new CustomWebApplicationFactory();
|
||||
using var client = factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
|
||||
var response = await client.GetAsync("/People");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
|
||||
Assert.Equal("/Countries", response.Headers.Location?.OriginalString);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Net;
|
||||
using Events.Tests.IntegrationTests.Infrastructure;
|
||||
|
||||
namespace Events.Tests.IntegrationTests;
|
||||
|
||||
public class PeoplePageShould
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReturnPageWithPeopleListWhenCountryAndPersonExist()
|
||||
{
|
||||
await using var factory = new CustomWebApplicationFactory(TestDataSeeder.SeedPeople);
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/People");
|
||||
var html = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Contains("People list", html);
|
||||
Assert.Contains("First name", html);
|
||||
Assert.Contains("Last name", html);
|
||||
Assert.Contains("Ivan Horvat", html);
|
||||
Assert.Contains("Croatia", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenderNativeFullNameAndTranscribedNamesInSeparateColumns()
|
||||
{
|
||||
await using var factory = new CustomWebApplicationFactory(ctx =>
|
||||
{
|
||||
ctx.Countries.Add(new Events.EF.Models.Country
|
||||
{
|
||||
Code = "UA",
|
||||
Alpha3 = "UKR",
|
||||
Name = "Ukraine"
|
||||
});
|
||||
|
||||
ctx.People.Add(new Events.EF.Models.Person
|
||||
{
|
||||
Id = 1,
|
||||
FirstName = "Олексій",
|
||||
LastName = "Шевченко",
|
||||
FirstNameTranscription = "Oleksii",
|
||||
LastNameTranscription = "Shevchenko",
|
||||
AddressLine = "Khreshchatyk 1",
|
||||
PostalCode = "01001",
|
||||
City = "Kyiv",
|
||||
AddressCountry = "Ukraine",
|
||||
Email = "oleksii.shevchenko@example.com",
|
||||
ContactPhone = "+38050111222",
|
||||
BirthDate = new DateOnly(1991, 6, 1),
|
||||
DocumentNumber = "DOC-1",
|
||||
CountryCode = "UA"
|
||||
});
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/People");
|
||||
var html = await response.Content.ReadAsStringAsync();
|
||||
var decodedHtml = WebUtility.HtmlDecode(html);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Contains("<th></th>", decodedHtml);
|
||||
Assert.Contains("<td>Олексій Шевченко</td>", decodedHtml);
|
||||
Assert.Contains("<td>Oleksii</td>", decodedHtml);
|
||||
Assert.Contains("<td>Shevchenko</td>", decodedHtml);
|
||||
|
||||
var firstNameColumnIndex = decodedHtml.IndexOf("<td>Oleksii</td>", StringComparison.Ordinal);
|
||||
var lastNameColumnIndex = decodedHtml.IndexOf("<td>Shevchenko</td>", StringComparison.Ordinal);
|
||||
var nativeFullNameColumnIndex = decodedHtml.IndexOf("<td>Олексій Шевченко</td>", StringComparison.Ordinal);
|
||||
|
||||
Assert.True(firstNameColumnIndex < lastNameColumnIndex);
|
||||
Assert.True(lastNameColumnIndex < nativeFullNameColumnIndex);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Net;
|
||||
using Events.Tests.IntegrationTests.Infrastructure;
|
||||
|
||||
namespace Events.Tests.IntegrationTests;
|
||||
|
||||
public class RegistrationsCrudShould
|
||||
{
|
||||
[Fact]
|
||||
public async Task CreateRegistration()
|
||||
{
|
||||
await using var factory = new CustomWebApplicationFactory(TestDataSeeder.SeedRegistrationsScenario);
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await AntiforgeryRequestHelper.PostFormAsync(
|
||||
client,
|
||||
"/Registrations?eventId=100",
|
||||
"/Registrations/Create",
|
||||
[new("EventId", "100"), new("PersonId", "1"), new("SportId", "20"), new("PersonLookup", "Ivan Horvat")]);
|
||||
var html = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Contains("Basketball", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EditRegistration()
|
||||
{
|
||||
await using var factory = new CustomWebApplicationFactory(TestDataSeeder.SeedRegistrationsScenario);
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await AntiforgeryRequestHelper.PostFormAsync(
|
||||
client,
|
||||
"/Registrations?eventId=100",
|
||||
"/Registrations/Edit/1000",
|
||||
[
|
||||
new("Id", "1000"),
|
||||
new("EventId", "100"),
|
||||
new("PersonId", "2"),
|
||||
new("SportId", "20"),
|
||||
new("PersonLookup", "Johann Schmidt")
|
||||
]);
|
||||
var html = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Contains("Johann Schmidt", html);
|
||||
Assert.Contains("Basketball", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteRegistration()
|
||||
{
|
||||
await using var factory = new CustomWebApplicationFactory(TestDataSeeder.SeedRegistrationsScenario);
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await AntiforgeryRequestHelper.PostFormAsync(client, "/Registrations?eventId=100", "/Registrations/Delete/1000?eventId=100", []);
|
||||
var html = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.DoesNotContain(">1000<", html);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Net;
|
||||
#if POSTGRES
|
||||
using Events.EF.Data.Postgres;
|
||||
#else
|
||||
using Events.EF.Data.MSSQL;
|
||||
#endif
|
||||
using Events.EF.Models;
|
||||
using Events.Tests.IntegrationTests.Infrastructure;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace Events.Tests.IntegrationTests;
|
||||
|
||||
public class RegistrationsGuardShould
|
||||
{
|
||||
[Fact]
|
||||
public async Task RedirectToSportsWhenRegistrationsPageIsRequestedWithoutSports()
|
||||
{
|
||||
await using var factory = new CustomWebApplicationFactory(ctx =>
|
||||
{
|
||||
ctx.Countries.Add(new Country
|
||||
{
|
||||
Code = "HR",
|
||||
Alpha3 = "HRV",
|
||||
Name = "Croatia"
|
||||
});
|
||||
ctx.People.Add(new Person
|
||||
{
|
||||
Id = 1,
|
||||
FirstName = "Ivan",
|
||||
LastName = "Horvat",
|
||||
FirstNameTranscription = "Ivan",
|
||||
LastNameTranscription = "Horvat",
|
||||
AddressLine = "Ilica 1",
|
||||
PostalCode = "10000",
|
||||
City = "Zagreb",
|
||||
AddressCountry = "Croatia",
|
||||
Email = "ivan.horvat@example.com",
|
||||
ContactPhone = "+38591111222",
|
||||
BirthDate = new DateOnly(1990, 5, 1),
|
||||
DocumentNumber = "DOC-1",
|
||||
CountryCode = "HR"
|
||||
});
|
||||
ctx.Events.Add(new Event
|
||||
{
|
||||
Id = 100,
|
||||
Name = "Spring Games",
|
||||
EventDate = new DateOnly(2026, 4, 15)
|
||||
});
|
||||
});
|
||||
using var client = factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
|
||||
var response = await client.GetAsync("/Registrations");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
|
||||
Assert.Equal("/Sports", response.Headers.Location?.OriginalString);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RedirectToPeopleWhenRegistrationsPageIsRequestedWithoutPeople()
|
||||
{
|
||||
await using var factory = new CustomWebApplicationFactory(ctx =>
|
||||
{
|
||||
ctx.Sports.Add(new Sport { Id = 10, Name = "Football" });
|
||||
ctx.Events.Add(new Event
|
||||
{
|
||||
Id = 100,
|
||||
Name = "Spring Games",
|
||||
EventDate = new DateOnly(2026, 4, 15)
|
||||
});
|
||||
});
|
||||
using var client = factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
|
||||
var response = await client.GetAsync("/Registrations");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
|
||||
Assert.Equal("/People", response.Headers.Location?.OriginalString);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Net;
|
||||
using Events.Tests.IntegrationTests.Infrastructure;
|
||||
|
||||
namespace Events.Tests.IntegrationTests;
|
||||
|
||||
public class RegistrationsPageShould
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReturnEventSpecificRegistrationsForSelectedEvent()
|
||||
{
|
||||
await using var factory = new CustomWebApplicationFactory(TestDataSeeder.SeedRegistrationsScenario);
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/Registrations?eventId=100");
|
||||
var html = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Contains("Registrations for: Spring Games", html);
|
||||
Assert.Contains("Ivan Horvat", html);
|
||||
Assert.Contains("Johann Schmidt", html);
|
||||
Assert.Contains("Croatia", html);
|
||||
Assert.Contains("Germany", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnFilteredPartialForHtmxRequestWithCountryFilter()
|
||||
{
|
||||
await using var factory = new CustomWebApplicationFactory(TestDataSeeder.SeedRegistrationsScenario);
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add(Events.MVC.Constants.HtmxHeaders.Request, "true");
|
||||
|
||||
var response = await client.GetAsync("/Registrations?eventId=100&filters=CountryCode==HR");
|
||||
var html = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Contains("id=\"registrations-panel\"", html);
|
||||
Assert.Contains("Ivan Horvat", html);
|
||||
Assert.DoesNotContain("Johann Schmidt", html);
|
||||
Assert.DoesNotContain("<html", html, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnOnlyMatchingCountrySuggestionsForPersonLookup()
|
||||
{
|
||||
await using var factory = new CustomWebApplicationFactory(TestDataSeeder.SeedRegistrationsScenario);
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/Registrations/PersonSuggestions?personLookup=jo&countryFilter=DE");
|
||||
var html = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Contains("Johann Schmidt", html);
|
||||
Assert.Contains("Johann", html);
|
||||
Assert.DoesNotContain("Ivan Horvat", html);
|
||||
Assert.Contains("data-person-suggestion", html);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Net;
|
||||
using Events.Tests.IntegrationTests.Infrastructure;
|
||||
|
||||
namespace Events.Tests.IntegrationTests;
|
||||
|
||||
public class SportsCrudShould
|
||||
{
|
||||
[Fact]
|
||||
public async Task CreateSport()
|
||||
{
|
||||
await using var factory = new CustomWebApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await AntiforgeryRequestHelper.PostFormAsync(
|
||||
client,
|
||||
"/Sports",
|
||||
"/Sports/Create",
|
||||
[new("Name", "Volleyball")]);
|
||||
var html = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Contains("Volleyball", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnValidationErrorWhenCreatingSportWithoutName()
|
||||
{
|
||||
await using var factory = new CustomWebApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await AntiforgeryRequestHelper.PostFormAsync(
|
||||
client,
|
||||
"/Sports",
|
||||
"/Sports/Create",
|
||||
[new("Name", string.Empty)]);
|
||||
var html = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Contains("The Name field is required.", html);
|
||||
Assert.DoesNotContain("was added successfully", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EditSport()
|
||||
{
|
||||
await using var factory = new CustomWebApplicationFactory(TestDataSeeder.SeedSports);
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await AntiforgeryRequestHelper.PostFormAsync(
|
||||
client,
|
||||
"/Sports",
|
||||
"/Sports/Edit/1",
|
||||
[new("Id", "1"), new("Name", "Handball")]);
|
||||
var html = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Contains("Handball", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSport()
|
||||
{
|
||||
await using var factory = new CustomWebApplicationFactory(TestDataSeeder.SeedSports);
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await AntiforgeryRequestHelper.PostFormAsync(client, "/Sports", "/Sports/Delete/1", []);
|
||||
var html = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.DoesNotContain("Football", html);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Net;
|
||||
using Events.Tests.IntegrationTests.Infrastructure;
|
||||
|
||||
namespace Events.Tests.IntegrationTests;
|
||||
|
||||
public class SportsPageShould
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReturnPageWithSportsListWhenSportsExist()
|
||||
{
|
||||
await using var factory = new CustomWebApplicationFactory(TestDataSeeder.SeedSports);
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/Sports");
|
||||
var html = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Contains("Sports list", html);
|
||||
Assert.Contains("Football", html);
|
||||
Assert.Contains("Basketball", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnOnlySportsPartialForHtmxRequest()
|
||||
{
|
||||
await using var factory = new CustomWebApplicationFactory(TestDataSeeder.SeedSports);
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add(Events.MVC.Constants.HtmxHeaders.Request, "true");
|
||||
|
||||
var response = await client.GetAsync("/Sports");
|
||||
var html = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Contains("id=\"sports-list\"", html);
|
||||
Assert.DoesNotContain("<html", html, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("navbar", html, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
|
||||
<PackageReference Include="Microsoft.Playwright" Version="1.59.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Events.MVC\Events.MVC.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="README.md" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
1
Events-MVC/Tests/Events.Tests.UITests/GlobalUsings.cs
Normal file
1
Events-MVC/Tests/Events.Tests.UITests/GlobalUsings.cs
Normal file
@@ -0,0 +1 @@
|
||||
global using Xunit;
|
||||
@@ -0,0 +1,77 @@
|
||||
using Events.Tests.UITests.Infrastructure;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace Events.Tests.UITests;
|
||||
|
||||
public class HomeAndSportsPageTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task HomePageShouldDisplayEnglishDescription()
|
||||
{
|
||||
await using var harness = await UiTestHarness.CreateAsync();
|
||||
|
||||
await harness.Page.GotoAsync($"{harness.RootUrl}/");
|
||||
|
||||
await Assertions.Expect(harness.Page.GetByRole(AriaRole.Heading, new() { Name = "Events" })).ToBeVisibleAsync();
|
||||
await Assertions.Expect(harness.Page.GetByText("This sample demonstrates an ASP.NET Core MVC application")).ToBeVisibleAsync();
|
||||
await Assertions.Expect(harness.Page.GetByRole(AriaRole.Link, new() { Name = "Sports" })).ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SportsPageShouldDisplayExistingSportsAndAllowOpeningCreatePanel()
|
||||
{
|
||||
await using var harness = await UiTestHarness.CreateAsync();
|
||||
var expectedSports = new[] { "Running", "Chess", "Swimming" };
|
||||
|
||||
await harness.Page.GotoAsync($"{harness.RootUrl}/Sports");
|
||||
|
||||
await Assertions.Expect(harness.Page.GetByRole(AriaRole.Heading, new() { Name = "Sports list" })).ToBeVisibleAsync();
|
||||
await Assertions.Expect(harness.Page.Locator("#sports-table-body tr").First).ToBeVisibleAsync();
|
||||
await Assertions.Expect(harness.Page.GetByPlaceholder("Search by sport name")).ToBeVisibleAsync();
|
||||
foreach (var expectedSport in expectedSports)
|
||||
{
|
||||
await Assertions.Expect(harness.Page.Locator("#sports-list")).ToContainTextAsync(expectedSport);
|
||||
}
|
||||
|
||||
await harness.Page.GetByRole(AriaRole.Button, new() { Name = "New sport" }).ClickAsync();
|
||||
|
||||
await Assertions.Expect(harness.Page.GetByRole(AriaRole.Heading, new() { Name = "Add a new sport" })).ToBeVisibleAsync();
|
||||
await Assertions.Expect(
|
||||
harness.Page.Locator("#create-sport-form").GetByLabel("Name", new() { Exact = true }))
|
||||
.ToBeVisibleAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SportsPageShouldCreateSportAndShowSuccessToast()
|
||||
{
|
||||
await using var harness = await UiTestHarness.CreateAsync();
|
||||
var sportName = $"UI Test Sport {Guid.NewGuid():N}";
|
||||
|
||||
await harness.Page.GotoAsync($"{harness.RootUrl}/Sports");
|
||||
await harness.Page.GetByRole(AriaRole.Button, new() { Name = "New sport" }).ClickAsync();
|
||||
|
||||
var createForm = harness.Page.Locator("#create-sport-form");
|
||||
await createForm.GetByLabel("Name", new() { Exact = true }).FillAsync(sportName);
|
||||
await createForm.GetByRole(AriaRole.Button, new() { Name = "Add sport" }).ClickAsync();
|
||||
|
||||
await Assertions.Expect(harness.Page.Locator("#app-toast")).ToBeVisibleAsync();
|
||||
await Assertions.Expect(harness.Page.Locator("#app-toast-title")).ToHaveTextAsync("Success");
|
||||
await Assertions.Expect(harness.Page.Locator("#app-toast-body")).ToContainTextAsync($"Sport '{sportName}' was added successfully.");
|
||||
|
||||
await harness.Page.GetByPlaceholder("Search by sport name").FillAsync(sportName);
|
||||
await harness.Page.GetByRole(AriaRole.Button, new() { Name = "Filter" }).ClickAsync();
|
||||
|
||||
await Assertions.Expect(harness.Page.Locator("#sports-list")).ToContainTextAsync(sportName);
|
||||
|
||||
var sportRow = harness.Page.Locator("#sports-table-body tr").Filter(new() { HasText = sportName });
|
||||
await Assertions.Expect(sportRow).ToHaveCountAsync(1);
|
||||
|
||||
harness.Page.Dialog += async (_, dialog) => await dialog.AcceptAsync();
|
||||
await sportRow.GetByRole(AriaRole.Button, new() { Name = "Delete" }).ClickAsync();
|
||||
|
||||
await Assertions.Expect(harness.Page.Locator("#app-toast")).ToBeVisibleAsync();
|
||||
await Assertions.Expect(harness.Page.Locator("#app-toast-title")).ToHaveTextAsync("Success");
|
||||
await Assertions.Expect(harness.Page.Locator("#app-toast-body")).ToContainTextAsync($"Sport '{sportName}' was deleted successfully.");
|
||||
await Assertions.Expect(harness.Page.Locator("#sports-list")).Not.ToContainTextAsync(sportName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace Events.Tests.UITests.Infrastructure;
|
||||
|
||||
internal sealed class UiTestHarness : IAsyncDisposable
|
||||
{
|
||||
private readonly IPlaywright playwright;
|
||||
private readonly Process appProcess;
|
||||
private readonly StringBuilder processOutput;
|
||||
|
||||
private UiTestHarness(
|
||||
IPlaywright playwright,
|
||||
IBrowser browser,
|
||||
IBrowserContext browserContext,
|
||||
IPage page,
|
||||
Process appProcess,
|
||||
StringBuilder processOutput,
|
||||
string rootUrl)
|
||||
{
|
||||
this.playwright = playwright;
|
||||
this.appProcess = appProcess;
|
||||
this.processOutput = processOutput;
|
||||
Browser = browser;
|
||||
BrowserContext = browserContext;
|
||||
Page = page;
|
||||
RootUrl = rootUrl;
|
||||
}
|
||||
|
||||
public IBrowser Browser { get; }
|
||||
|
||||
public IBrowserContext BrowserContext { get; }
|
||||
|
||||
public IPage Page { get; }
|
||||
|
||||
public string RootUrl { get; }
|
||||
|
||||
public static async Task<UiTestHarness> CreateAsync()
|
||||
{
|
||||
var port = FindFreePort();
|
||||
var rootUrl = $"http://127.0.0.1:{port}";
|
||||
var mvcProjectPath = Path.GetFullPath(Path.Combine(
|
||||
AppContext.BaseDirectory,
|
||||
"..", "..", "..", "..", "..",
|
||||
"Events.MVC", "Events.MVC.csproj"));
|
||||
|
||||
var processOutput = new StringBuilder();
|
||||
var startInfo = new ProcessStartInfo("dotnet", $"run --project \"{mvcProjectPath}\" --urls {rootUrl}")
|
||||
{
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
startInfo.Environment["ASPNETCORE_ENVIRONMENT"] = "UITest";
|
||||
#if POSTGRES
|
||||
startInfo.Environment["ConnectionStrings__EventsPostgres"] = ResolveUiTestConnectionString();
|
||||
#else
|
||||
startInfo.Environment["ConnectionStrings__EventsMssql"] = ResolveUiTestConnectionString();
|
||||
#endif
|
||||
|
||||
var appProcess = Process.Start(startInfo)
|
||||
?? throw new InvalidOperationException("Failed to start the MVC app process for UI tests.");
|
||||
|
||||
appProcess.OutputDataReceived += (_, args) =>
|
||||
{
|
||||
if (args.Data is not null)
|
||||
{
|
||||
processOutput.AppendLine(args.Data);
|
||||
}
|
||||
};
|
||||
appProcess.ErrorDataReceived += (_, args) =>
|
||||
{
|
||||
if (args.Data is not null)
|
||||
{
|
||||
processOutput.AppendLine(args.Data);
|
||||
}
|
||||
};
|
||||
appProcess.BeginOutputReadLine();
|
||||
appProcess.BeginErrorReadLine();
|
||||
|
||||
await WaitForServerAsync(rootUrl, appProcess, processOutput);
|
||||
|
||||
IPlaywright playwright;
|
||||
try
|
||||
{
|
||||
playwright = await Playwright.CreateAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await StopProcessAsync(appProcess);
|
||||
throw;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
|
||||
{
|
||||
Headless = true
|
||||
});
|
||||
var browserContext = await browser.NewContextAsync(new BrowserNewContextOptions
|
||||
{
|
||||
BaseURL = rootUrl
|
||||
});
|
||||
var page = await browserContext.NewPageAsync();
|
||||
|
||||
return new UiTestHarness(playwright, browser, browserContext, page, appProcess, processOutput, rootUrl);
|
||||
}
|
||||
catch (PlaywrightException)
|
||||
{
|
||||
await StopProcessAsync(appProcess);
|
||||
playwright.Dispose();
|
||||
throw new InvalidOperationException(
|
||||
"Playwright browser is not installed. Run 'dotnet tool install --global Microsoft.Playwright.CLI' and then 'playwright install'.");
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await BrowserContext.DisposeAsync();
|
||||
await Browser.DisposeAsync();
|
||||
playwright.Dispose();
|
||||
await StopProcessAsync(appProcess);
|
||||
}
|
||||
|
||||
private static int FindFreePort()
|
||||
{
|
||||
using var listener = new TcpListener(System.Net.IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
return ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
|
||||
}
|
||||
|
||||
private static async Task WaitForServerAsync(string rootUrl, Process appProcess, StringBuilder processOutput)
|
||||
{
|
||||
using var httpClient = new HttpClient();
|
||||
var timeoutAt = DateTime.UtcNow.AddSeconds(30);
|
||||
|
||||
while (DateTime.UtcNow < timeoutAt)
|
||||
{
|
||||
if (appProcess.HasExited)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"MVC app process exited before the UI test server became ready.{Environment.NewLine}{processOutput}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await httpClient.GetAsync(rootUrl);
|
||||
if ((int)response.StatusCode < 500)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
await Task.Delay(250);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Timed out while waiting for the UI test server at {rootUrl}.{Environment.NewLine}{processOutput}");
|
||||
}
|
||||
|
||||
private static string ResolveUiTestConnectionString()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddUserSecrets<Program>(optional: true)
|
||||
.Build();
|
||||
|
||||
var connectionString = configuration.GetConnectionString("EventDB-Test");
|
||||
if (string.IsNullOrWhiteSpace(connectionString))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The EventDB-Test connection string must be available so UI tests can connect to the selected provider's test database.");
|
||||
}
|
||||
|
||||
return connectionString;
|
||||
}
|
||||
|
||||
private static async Task StopProcessAsync(Process appProcess)
|
||||
{
|
||||
if (appProcess.HasExited)
|
||||
{
|
||||
appProcess.Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
appProcess.Kill(entireProcessTree: true);
|
||||
await appProcess.WaitForExitAsync();
|
||||
appProcess.Dispose();
|
||||
}
|
||||
}
|
||||
43
Events-MVC/Tests/Events.Tests.UITests/README.md
Normal file
43
Events-MVC/Tests/Events.Tests.UITests/README.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Events.Tests.UITests
|
||||
|
||||
This project contains Playwright-based UI tests for `Events-MVC`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- .NET SDK 10.0
|
||||
- Playwright CLI
|
||||
- Playwright browser binaries
|
||||
|
||||
## Playwright Installation
|
||||
|
||||
Install the Playwright CLI once:
|
||||
|
||||
```powershell
|
||||
dotnet tool install --global Microsoft.Playwright.CLI
|
||||
```
|
||||
|
||||
Install browser binaries:
|
||||
|
||||
```powershell
|
||||
playwright install
|
||||
```
|
||||
|
||||
## Running the UI Tests
|
||||
|
||||
Run the full UI test project:
|
||||
|
||||
```powershell
|
||||
dotnet test Events-MVC\Tests\Events.Tests.UITests\Events.Tests.UITests.csproj
|
||||
```
|
||||
|
||||
Run a single test:
|
||||
|
||||
```powershell
|
||||
dotnet test Events-MVC\Tests\Events.Tests.UITests\Events.Tests.UITests.csproj --filter HomeAndSportsPageTests.HomePageShouldDisplayEnglishDescription
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The UI test harness starts the MVC application automatically
|
||||
- UI tests connect the MVC application to the selected provider's test database from `ConnectionStrings:EventDB-Test`
|
||||
- The browser is currently configured in headless mode
|
||||
@@ -0,0 +1,120 @@
|
||||
#if POSTGRES
|
||||
using Events.EF.Data.Postgres;
|
||||
#else
|
||||
using Events.EF.Data.MSSQL;
|
||||
#endif
|
||||
using Events.EF.Models;
|
||||
using Events.MVC.Controllers;
|
||||
using Events.MVC.Models.Countries;
|
||||
using Events.Tests.UnitTests.Infrastructure;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Events.Tests.UnitTests.Controllers;
|
||||
|
||||
public class CountriesControllerShould
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReturnPartialViewWithExpectedViewModelForExistingRow()
|
||||
{
|
||||
await using var ctx = ControllerTestContext.CreateContext();
|
||||
ctx.Countries.Add(ControllerTestContext.CreateCountry());
|
||||
await ctx.SaveChangesAsync();
|
||||
var controller = CreateController(ctx, useSieve: false);
|
||||
|
||||
var result = await controller.Row("HR");
|
||||
|
||||
var partial = Assert.IsType<PartialViewResult>(result);
|
||||
Assert.Equal("_CountryRow", partial.ViewName);
|
||||
Assert.Equal("Croatia", Assert.IsType<CountryViewModel>(partial.Model).Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateCountry()
|
||||
{
|
||||
await using var ctx = ControllerTestContext.CreateContext();
|
||||
var controller = CreateController(ctx);
|
||||
|
||||
var result = await controller.Create(
|
||||
new CountryViewModel
|
||||
{
|
||||
Code = "de",
|
||||
Alpha3 = "deu",
|
||||
Name = "Germany",
|
||||
Translations =
|
||||
[
|
||||
new CountryTranslationViewModel { LanguageCode = "hr", Name = "Germany" }
|
||||
]
|
||||
},
|
||||
ControllerTestContext.EmptySieveModel());
|
||||
|
||||
var partial = Assert.IsType<PartialViewResult>(result);
|
||||
Assert.Equal("_CountriesList", partial.ViewName);
|
||||
var country = await ctx.Countries.SingleAsync();
|
||||
Assert.Equal("DE", country.Code);
|
||||
Assert.Equal("Germany", country.Name);
|
||||
Assert.Contains("was added successfully", controller.Response.Headers[Events.MVC.Constants.HtmxHeaders.Trigger].ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EditCountry()
|
||||
{
|
||||
await using var ctx = ControllerTestContext.CreateContext();
|
||||
ctx.Countries.Add(ControllerTestContext.CreateCountry());
|
||||
await ctx.SaveChangesAsync();
|
||||
var controller = CreateController(ctx, useSieve: false);
|
||||
|
||||
var result = await controller.Edit("HR", new CountryViewModel
|
||||
{
|
||||
Code = "HR",
|
||||
Alpha3 = "HRV",
|
||||
Name = "Republic of Croatia",
|
||||
Translations = []
|
||||
});
|
||||
|
||||
var partial = Assert.IsType<PartialViewResult>(result);
|
||||
Assert.Equal("_CountryRow", partial.ViewName);
|
||||
Assert.Equal("Republic of Croatia", (await ctx.Countries.SingleAsync()).Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteCountry()
|
||||
{
|
||||
await using var ctx = ControllerTestContext.CreateContext();
|
||||
ctx.Countries.Add(ControllerTestContext.CreateCountry());
|
||||
await ctx.SaveChangesAsync();
|
||||
var controller = CreateController(ctx);
|
||||
|
||||
var result = await controller.Delete("HR", ControllerTestContext.EmptySieveModel());
|
||||
|
||||
var partial = Assert.IsType<PartialViewResult>(result);
|
||||
Assert.Equal("_CountriesList", partial.ViewName);
|
||||
Assert.Empty(ctx.Countries);
|
||||
Assert.Contains("was deleted successfully", controller.Response.Headers[Events.MVC.Constants.HtmxHeaders.Trigger].ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnConflictWhenDeletingCountryWithRelatedPeople()
|
||||
{
|
||||
await using var ctx = ControllerTestContext.CreateContext();
|
||||
ctx.Countries.Add(ControllerTestContext.CreateCountry());
|
||||
ctx.People.Add(ControllerTestContext.CreatePerson());
|
||||
await ctx.SaveChangesAsync();
|
||||
var controller = CreateController(ctx);
|
||||
|
||||
var result = await controller.Delete("HR", ControllerTestContext.EmptySieveModel());
|
||||
|
||||
var content = Assert.IsType<ContentResult>(result);
|
||||
Assert.Equal(409, controller.Response.StatusCode);
|
||||
Assert.Equal("The country cannot be deleted because related people exist.", content.Content);
|
||||
}
|
||||
|
||||
private static CountriesController CreateController(EventsContext ctx, bool useSieve = true)
|
||||
{
|
||||
return new CountriesController(
|
||||
ctx,
|
||||
useSieve ? ControllerTestContext.CreateSieveProcessor() : null!,
|
||||
ControllerTestContext.CreatePagingOptions())
|
||||
.WithTempData();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
#if POSTGRES
|
||||
using Events.EF.Data.Postgres;
|
||||
#else
|
||||
using Events.EF.Data.MSSQL;
|
||||
#endif
|
||||
using Events.EF.Models;
|
||||
using Events.MVC.Controllers;
|
||||
using Events.MVC.Models.Events;
|
||||
using Events.Tests.UnitTests.Infrastructure;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Events.Tests.UnitTests.Controllers;
|
||||
|
||||
public class EventsControllerShould
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReturnPartialViewWithExpectedViewModelForExistingRow()
|
||||
{
|
||||
await using var ctx = ControllerTestContext.CreateContext();
|
||||
ctx.Events.Add(ControllerTestContext.CreateEvent());
|
||||
await ctx.SaveChangesAsync();
|
||||
var controller = CreateController(ctx, useSieve: false);
|
||||
|
||||
var result = await controller.Row(100);
|
||||
|
||||
var partial = Assert.IsType<PartialViewResult>(result);
|
||||
Assert.Equal("_EventRow", partial.ViewName);
|
||||
Assert.Equal("Spring Games", Assert.IsType<EventViewModel>(partial.Model).Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateEvent()
|
||||
{
|
||||
await using var ctx = ControllerTestContext.CreateContext();
|
||||
var controller = CreateController(ctx);
|
||||
|
||||
var result = await controller.Create(
|
||||
new EventViewModel { Name = "Autumn Cup", EventDate = new DateOnly(2026, 9, 10) },
|
||||
ControllerTestContext.EmptySieveModel());
|
||||
|
||||
var partial = Assert.IsType<PartialViewResult>(result);
|
||||
Assert.Equal("_EventsList", partial.ViewName);
|
||||
Assert.Contains(ctx.Events, e => e.Name == "Autumn Cup");
|
||||
Assert.Contains("was added successfully", controller.Response.Headers[Events.MVC.Constants.HtmxHeaders.Trigger].ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EditEvent()
|
||||
{
|
||||
await using var ctx = ControllerTestContext.CreateContext();
|
||||
ctx.Events.Add(ControllerTestContext.CreateEvent());
|
||||
await ctx.SaveChangesAsync();
|
||||
var controller = CreateController(ctx, useSieve: false);
|
||||
|
||||
var result = await controller.Edit(100, new EventViewModel { Id = 100, Name = "Updated Games", EventDate = new DateOnly(2026, 5, 20) });
|
||||
|
||||
var partial = Assert.IsType<PartialViewResult>(result);
|
||||
Assert.Equal("_EventRow", partial.ViewName);
|
||||
Assert.Equal("Updated Games", (await ctx.Events.SingleAsync()).Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteEvent()
|
||||
{
|
||||
await using var ctx = ControllerTestContext.CreateContext();
|
||||
ctx.Events.Add(ControllerTestContext.CreateEvent());
|
||||
await ctx.SaveChangesAsync();
|
||||
var controller = CreateController(ctx);
|
||||
|
||||
var result = await controller.Delete(100, ControllerTestContext.EmptySieveModel());
|
||||
|
||||
var partial = Assert.IsType<PartialViewResult>(result);
|
||||
Assert.Equal("_EventsList", partial.ViewName);
|
||||
Assert.Empty(ctx.Events);
|
||||
Assert.Contains("was deleted successfully", controller.Response.Headers[Events.MVC.Constants.HtmxHeaders.Trigger].ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnConflictWhenDeletingEventWithRegistrations()
|
||||
{
|
||||
await using var ctx = ControllerTestContext.CreateContext();
|
||||
ctx.Countries.Add(ControllerTestContext.CreateCountry());
|
||||
ctx.People.Add(ControllerTestContext.CreatePerson());
|
||||
ctx.Events.Add(ControllerTestContext.CreateEvent());
|
||||
ctx.Sports.Add(ControllerTestContext.CreateSport());
|
||||
ctx.Registrations.Add(new Registration { Id = 1000, EventId = 100, PersonId = 1, SportId = 10 });
|
||||
await ctx.SaveChangesAsync();
|
||||
var controller = CreateController(ctx);
|
||||
|
||||
var result = await controller.Delete(100, ControllerTestContext.EmptySieveModel());
|
||||
|
||||
var content = Assert.IsType<ContentResult>(result);
|
||||
Assert.Equal(409, controller.Response.StatusCode);
|
||||
Assert.Equal("The event cannot be deleted because registrations exist.", content.Content);
|
||||
}
|
||||
|
||||
private static EventsController CreateController(EventsContext ctx, bool useSieve = true)
|
||||
{
|
||||
return new EventsController(
|
||||
ctx,
|
||||
useSieve ? ControllerTestContext.CreateSieveProcessor() : null!,
|
||||
ControllerTestContext.CreatePagingOptions())
|
||||
.WithTempData();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
#if POSTGRES
|
||||
using Events.EF.Data.Postgres;
|
||||
#else
|
||||
using Events.EF.Data.MSSQL;
|
||||
#endif
|
||||
using Events.EF.Models;
|
||||
using Events.MVC.Controllers;
|
||||
using Events.MVC.Models;
|
||||
using Events.MVC.Models.People;
|
||||
using Events.Tests.UnitTests.Infrastructure;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Sieve.Models;
|
||||
|
||||
namespace Events.Tests.UnitTests.Controllers;
|
||||
|
||||
public class PeopleControllerShould
|
||||
{
|
||||
[Fact]
|
||||
public async Task RedirectToCountriesAndSetToastWhenIndexIsRequestedWithoutCountries()
|
||||
{
|
||||
await using var ctx = ControllerTestContext.CreateContext();
|
||||
var controller = CreateController(ctx, useSieve: false);
|
||||
|
||||
var result = await controller.Index(new SieveModel());
|
||||
|
||||
var redirect = Assert.IsType<RedirectToActionResult>(result);
|
||||
Assert.Equal("Index", redirect.ActionName);
|
||||
Assert.Equal("Countries", redirect.ControllerName);
|
||||
Assert.Equal(Events.MVC.Constants.Messages.CountriesRequiredForPeople, controller.TempData[Events.MVC.Constants.TempDataKeys.ToastMessage]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePerson()
|
||||
{
|
||||
await using var ctx = ControllerTestContext.CreateContext();
|
||||
ctx.Countries.Add(ControllerTestContext.CreateCountry());
|
||||
await ctx.SaveChangesAsync();
|
||||
var controller = CreateController(ctx);
|
||||
|
||||
var result = await controller.Create(
|
||||
new PersonViewModel
|
||||
{
|
||||
FirstName = "Ana",
|
||||
LastName = "Kovac",
|
||||
FirstNameTranscription = "Ana",
|
||||
LastNameTranscription = "Kovac",
|
||||
AddressLine = "Main Street 1",
|
||||
PostalCode = "10000",
|
||||
City = "Zagreb",
|
||||
AddressCountry = "Croatia",
|
||||
Email = "ana.kovac@example.com",
|
||||
ContactPhone = "+38591123456",
|
||||
BirthDate = new DateOnly(1995, 1, 1),
|
||||
DocumentNumber = "DOC-2",
|
||||
CountryCode = "hr"
|
||||
},
|
||||
ControllerTestContext.EmptySieveModel());
|
||||
|
||||
var partial = Assert.IsType<PartialViewResult>(result);
|
||||
Assert.Equal("_PeopleList", partial.ViewName);
|
||||
var model = Assert.IsType<PeoplePageViewModel>(partial.Model);
|
||||
Assert.Contains(model.People.Data, p => p.FullName == "Ana Kovac" && p.CountryName == "Croatia");
|
||||
Assert.Contains("was added successfully", controller.Response.Headers[Events.MVC.Constants.HtmxHeaders.Trigger].ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnPartialViewWithExpectedPeopleListForPersonAddedBeforeIndexRead()
|
||||
{
|
||||
await using var ctx = ControllerTestContext.CreateContext();
|
||||
ctx.Countries.Add(ControllerTestContext.CreateCountry());
|
||||
await ctx.SaveChangesAsync();
|
||||
var controller = CreateController(ctx);
|
||||
|
||||
await controller.Create(
|
||||
new PersonViewModel
|
||||
{
|
||||
FirstName = "Ana",
|
||||
LastName = "Kovac",
|
||||
FirstNameTranscription = "Ana",
|
||||
LastNameTranscription = "Kovac",
|
||||
AddressLine = "Main Street 1",
|
||||
PostalCode = "10000",
|
||||
City = "Zagreb",
|
||||
AddressCountry = "Croatia",
|
||||
Email = "ana.kovac@example.com",
|
||||
ContactPhone = "+38591123456",
|
||||
BirthDate = new DateOnly(1995, 1, 1),
|
||||
DocumentNumber = "DOC-2",
|
||||
CountryCode = "hr"
|
||||
},
|
||||
ControllerTestContext.EmptySieveModel());
|
||||
|
||||
controller.Request.Headers[Events.MVC.Constants.HtmxHeaders.Request] = "true";
|
||||
|
||||
var result = await controller.Index(new SieveModel
|
||||
{
|
||||
Filters = "FirstName==Ana"
|
||||
});
|
||||
|
||||
var partial = Assert.IsType<PartialViewResult>(result);
|
||||
Assert.Equal("_PeopleList", partial.ViewName);
|
||||
var model = Assert.IsType<PeoplePageViewModel>(partial.Model);
|
||||
var person = Assert.Single(model.People.Data);
|
||||
Assert.Equal("Ana Kovac", person.FullName);
|
||||
Assert.Equal("Croatia", person.CountryName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EditPerson()
|
||||
{
|
||||
await using var ctx = ControllerTestContext.CreateContext();
|
||||
ctx.Countries.Add(ControllerTestContext.CreateCountry());
|
||||
ctx.People.Add(ControllerTestContext.CreatePerson());
|
||||
await ctx.SaveChangesAsync();
|
||||
var controller = CreateController(ctx, useSieve: false);
|
||||
|
||||
var result = await controller.Edit(1, new PersonViewModel
|
||||
{
|
||||
Id = 1,
|
||||
FirstName = "Ivan",
|
||||
LastName = "Kovac",
|
||||
FirstNameTranscription = "Ivan",
|
||||
LastNameTranscription = "Kovac",
|
||||
AddressLine = "Updated Street 2",
|
||||
PostalCode = "10000",
|
||||
City = "Zagreb",
|
||||
AddressCountry = "Croatia",
|
||||
Email = "ivan.kovac@example.com",
|
||||
ContactPhone = "+38591111222",
|
||||
BirthDate = new DateOnly(1990, 5, 1),
|
||||
DocumentNumber = "DOC-1",
|
||||
CountryCode = "HR"
|
||||
});
|
||||
|
||||
var partial = Assert.IsType<PartialViewResult>(result);
|
||||
Assert.Equal("_PersonRow", partial.ViewName);
|
||||
Assert.Equal("Kovac", (await ctx.People.SingleAsync()).LastName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeletePerson()
|
||||
{
|
||||
await using var ctx = ControllerTestContext.CreateContext();
|
||||
ctx.Countries.Add(ControllerTestContext.CreateCountry());
|
||||
ctx.People.Add(ControllerTestContext.CreatePerson());
|
||||
await ctx.SaveChangesAsync();
|
||||
var controller = CreateController(ctx);
|
||||
|
||||
var result = await controller.Delete(1, ControllerTestContext.EmptySieveModel());
|
||||
|
||||
var partial = Assert.IsType<PartialViewResult>(result);
|
||||
Assert.Equal("_PeopleList", partial.ViewName);
|
||||
Assert.Empty(ctx.People);
|
||||
Assert.Contains("was deleted successfully", controller.Response.Headers[Events.MVC.Constants.HtmxHeaders.Trigger].ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FilterPeopleByTranscribedFullName()
|
||||
{
|
||||
await using var ctx = ControllerTestContext.CreateContext();
|
||||
ctx.Countries.Add(ControllerTestContext.CreateCountry());
|
||||
ctx.People.AddRange(
|
||||
ControllerTestContext.CreatePerson(id: 1, firstName: "Ђорђе", lastName: "Петровић"),
|
||||
ControllerTestContext.CreatePerson(id: 2, firstName: "Ana", lastName: "Kovac"));
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var person = await ctx.People.SingleAsync(p => p.Id == 1);
|
||||
person.FirstNameTranscription = "Djordje";
|
||||
person.LastNameTranscription = "Petrovic";
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var controller = CreateController(ctx);
|
||||
|
||||
var result = await controller.Index(new SieveModel
|
||||
{
|
||||
Filters = "FullNameTranscription@=*Petrovic"
|
||||
});
|
||||
|
||||
var view = Assert.IsType<ViewResult>(result);
|
||||
var model = Assert.IsType<PeoplePageViewModel>(view.Model);
|
||||
var people = Assert.Single(model.People.Data);
|
||||
Assert.Equal(1, people.Id);
|
||||
Assert.Equal("Ђорђе Петровић", people.FullName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FilterPeopleByCountry()
|
||||
{
|
||||
await using var ctx = ControllerTestContext.CreateContext();
|
||||
ctx.Countries.AddRange(
|
||||
ControllerTestContext.CreateCountry(),
|
||||
ControllerTestContext.CreateCountry(code: "SI", alpha3: "SVN", name: "Slovenia"));
|
||||
ctx.People.AddRange(
|
||||
ControllerTestContext.CreatePerson(id: 1, countryCode: "HR", firstName: "Ivan", lastName: "Horvat"),
|
||||
ControllerTestContext.CreatePerson(id: 2, countryCode: "SI", firstName: "Ana", lastName: "Novak"));
|
||||
await ctx.SaveChangesAsync();
|
||||
var controller = CreateController(ctx);
|
||||
|
||||
var result = await controller.Index(new SieveModel
|
||||
{
|
||||
Filters = "CountryCode==SI"
|
||||
});
|
||||
|
||||
var view = Assert.IsType<ViewResult>(result);
|
||||
var model = Assert.IsType<PeoplePageViewModel>(view.Model);
|
||||
var person = Assert.Single(model.People.Data);
|
||||
Assert.Equal("Ana Novak", person.FullName);
|
||||
Assert.Equal("SI", model.CountryFilter);
|
||||
Assert.Contains(model.CountryOptions, option => option.Value == "SI" && option.Selected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnConflictWhenDeletingPersonWithRegistrations()
|
||||
{
|
||||
await using var ctx = ControllerTestContext.CreateContext();
|
||||
ctx.Countries.Add(ControllerTestContext.CreateCountry());
|
||||
ctx.People.Add(ControllerTestContext.CreatePerson());
|
||||
ctx.Events.Add(ControllerTestContext.CreateEvent());
|
||||
ctx.Sports.Add(ControllerTestContext.CreateSport());
|
||||
ctx.Registrations.Add(new Registration
|
||||
{
|
||||
Id = 1000,
|
||||
EventId = 100,
|
||||
PersonId = 1,
|
||||
SportId = 10
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
var controller = CreateController(ctx);
|
||||
|
||||
var result = await controller.Delete(1, ControllerTestContext.EmptySieveModel());
|
||||
|
||||
var content = Assert.IsType<ContentResult>(result);
|
||||
Assert.Equal(409, controller.Response.StatusCode);
|
||||
Assert.Equal("The person cannot be deleted because registrations exist.", content.Content);
|
||||
}
|
||||
|
||||
private static PeopleController CreateController(EventsContext ctx, bool useSieve = true)
|
||||
{
|
||||
return new PeopleController(
|
||||
ctx,
|
||||
useSieve ? ControllerTestContext.CreateSieveProcessor() : null!,
|
||||
ControllerTestContext.CreatePagingOptions())
|
||||
.WithTempData();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
#if POSTGRES
|
||||
using Events.EF.Data.Postgres;
|
||||
#else
|
||||
using Events.EF.Data.MSSQL;
|
||||
#endif
|
||||
using Events.EF.Models;
|
||||
using Events.MVC.Controllers;
|
||||
using Events.MVC.Models.Registrations;
|
||||
using Events.Tests.UnitTests.Infrastructure;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Sieve.Models;
|
||||
|
||||
namespace Events.Tests.UnitTests.Controllers;
|
||||
|
||||
public class RegistrationsControllerShould
|
||||
{
|
||||
[Fact]
|
||||
public async Task RedirectToEventsWhenIndexIsRequestedWithoutEvents()
|
||||
{
|
||||
await using var ctx = ControllerTestContext.CreateContext();
|
||||
var controller = CreateController(ctx, useSieve: false);
|
||||
|
||||
var result = await controller.Index(null, new SieveModel());
|
||||
|
||||
var redirect = Assert.IsType<RedirectToActionResult>(result);
|
||||
Assert.Equal("Events", redirect.ControllerName);
|
||||
Assert.Equal(Events.MVC.Constants.Messages.EventsRequiredForRegistrations, controller.TempData[Events.MVC.Constants.TempDataKeys.ToastMessage]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RedirectToSportsWhenIndexIsRequestedWithoutSports()
|
||||
{
|
||||
await using var ctx = ControllerTestContext.CreateContext();
|
||||
ctx.Events.Add(new Event { Id = 1, Name = "Event 1", EventDate = new DateOnly(2026, 3, 23) });
|
||||
await ctx.SaveChangesAsync();
|
||||
var controller = CreateController(ctx, useSieve: false);
|
||||
|
||||
var result = await controller.Index(1, new SieveModel());
|
||||
|
||||
var redirect = Assert.IsType<RedirectToActionResult>(result);
|
||||
Assert.Equal("Sports", redirect.ControllerName);
|
||||
Assert.Equal(Events.MVC.Constants.Messages.SportsRequiredForRegistrations, controller.TempData[Events.MVC.Constants.TempDataKeys.ToastMessage]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RedirectToPeopleWhenIndexIsRequestedWithoutPeople()
|
||||
{
|
||||
await using var ctx = ControllerTestContext.CreateContext();
|
||||
ctx.Events.Add(new Event { Id = 1, Name = "Event 1", EventDate = new DateOnly(2026, 3, 23) });
|
||||
ctx.Sports.Add(new Sport { Id = 1, Name = "Football" });
|
||||
await ctx.SaveChangesAsync();
|
||||
var controller = CreateController(ctx, useSieve: false);
|
||||
|
||||
var result = await controller.Index(1, new SieveModel());
|
||||
|
||||
var redirect = Assert.IsType<RedirectToActionResult>(result);
|
||||
Assert.Equal("People", redirect.ControllerName);
|
||||
Assert.Equal(Events.MVC.Constants.Messages.PeopleRequiredForRegistrations, controller.TempData[Events.MVC.Constants.TempDataKeys.ToastMessage]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateRegistration()
|
||||
{
|
||||
await using var ctx = ControllerTestContext.CreateContext();
|
||||
SeedRegistrationDependencies(ctx);
|
||||
await ctx.SaveChangesAsync();
|
||||
var controller = CreateController(ctx);
|
||||
|
||||
var result = await controller.Create(
|
||||
new RegistrationViewModel
|
||||
{
|
||||
EventId = 100,
|
||||
PersonId = 1,
|
||||
SportId = 10
|
||||
},
|
||||
ControllerTestContext.EmptySieveModel());
|
||||
|
||||
var partial = Assert.IsType<PartialViewResult>(result);
|
||||
Assert.Equal("_RegistrationsPanel", partial.ViewName);
|
||||
Assert.Single(ctx.Registrations);
|
||||
Assert.Contains("Registration was added successfully", controller.Response.Headers[Events.MVC.Constants.HtmxHeaders.Trigger].ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EditRegistration()
|
||||
{
|
||||
await using var ctx = ControllerTestContext.CreateContext();
|
||||
SeedRegistrationDependencies(ctx);
|
||||
ctx.People.Add(ControllerTestContext.CreatePerson(id: 2, firstName: "Ana", lastName: "Kovac"));
|
||||
ctx.Sports.Add(ControllerTestContext.CreateSport(id: 20, name: "Volleyball"));
|
||||
ctx.Registrations.Add(new Registration { Id = 1000, EventId = 100, PersonId = 1, SportId = 10 });
|
||||
await ctx.SaveChangesAsync();
|
||||
var controller = CreateController(ctx, useSieve: false);
|
||||
|
||||
var result = await controller.Edit(1000, new RegistrationViewModel
|
||||
{
|
||||
Id = 1000,
|
||||
EventId = 100,
|
||||
PersonId = 2,
|
||||
SportId = 20
|
||||
});
|
||||
|
||||
var partial = Assert.IsType<PartialViewResult>(result);
|
||||
Assert.Equal("_RegistrationRow", partial.ViewName);
|
||||
var registration = await ctx.Registrations.SingleAsync();
|
||||
Assert.Equal(2, registration.PersonId);
|
||||
Assert.Equal(20, registration.SportId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteRegistration()
|
||||
{
|
||||
await using var ctx = ControllerTestContext.CreateContext();
|
||||
SeedRegistrationDependencies(ctx);
|
||||
ctx.Registrations.Add(new Registration { Id = 1000, EventId = 100, PersonId = 1, SportId = 10 });
|
||||
await ctx.SaveChangesAsync();
|
||||
var controller = CreateController(ctx);
|
||||
|
||||
var result = await controller.Delete(1000, 100, ControllerTestContext.EmptySieveModel());
|
||||
|
||||
var partial = Assert.IsType<PartialViewResult>(result);
|
||||
Assert.Equal("_RegistrationsPanel", partial.ViewName);
|
||||
Assert.Empty(ctx.Registrations);
|
||||
Assert.Contains("Registration was deleted successfully", controller.Response.Headers[Events.MVC.Constants.HtmxHeaders.Trigger].ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnConflictWhenCreatingRegistrationWithoutDependencies()
|
||||
{
|
||||
await using var ctx = ControllerTestContext.CreateContext();
|
||||
var controller = CreateController(ctx);
|
||||
|
||||
var result = await controller.Create(new RegistrationViewModel { EventId = 100, PersonId = 1, SportId = 10 }, ControllerTestContext.EmptySieveModel());
|
||||
|
||||
var content = Assert.IsType<ContentResult>(result);
|
||||
Assert.Equal(409, controller.Response.StatusCode);
|
||||
Assert.Equal("At least one event, one person, and one sport are required before adding registrations.", content.Content);
|
||||
}
|
||||
|
||||
private static RegistrationsController CreateController(EventsContext ctx, bool useSieve = true)
|
||||
{
|
||||
return new RegistrationsController(
|
||||
ctx,
|
||||
useSieve ? ControllerTestContext.CreateSieveProcessor() : null!,
|
||||
ControllerTestContext.CreatePagingOptions())
|
||||
.WithTempData();
|
||||
}
|
||||
|
||||
private static void SeedRegistrationDependencies(EventsContext ctx)
|
||||
{
|
||||
ctx.Countries.Add(ControllerTestContext.CreateCountry());
|
||||
ctx.People.Add(ControllerTestContext.CreatePerson());
|
||||
ctx.Events.Add(ControllerTestContext.CreateEvent());
|
||||
ctx.Sports.Add(ControllerTestContext.CreateSport());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
#if POSTGRES
|
||||
using Events.EF.Data.Postgres;
|
||||
#else
|
||||
using Events.EF.Data.MSSQL;
|
||||
#endif
|
||||
using Events.EF.Models;
|
||||
using Events.MVC.Controllers;
|
||||
using Events.MVC.Models;
|
||||
using Events.MVC.Models.Sports;
|
||||
using Events.Tests.UnitTests.Infrastructure;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using Sieve.Models;
|
||||
|
||||
namespace Events.Tests.UnitTests.Controllers;
|
||||
|
||||
public class SportsControllerShould
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReturnPartialViewWithExpectedViewModelForExistingRow()
|
||||
{
|
||||
await using var ctx = ControllerTestContext.CreateContext();
|
||||
ctx.Sports.Add(new Sport { Id = 5, Name = "Basketball" });
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var controller = CreateController(ctx, useSieve: false);
|
||||
|
||||
var result = await controller.Row(5);
|
||||
|
||||
var partial = Assert.IsType<PartialViewResult>(result);
|
||||
Assert.Equal("_SportRow", partial.ViewName);
|
||||
var model = Assert.IsType<SportViewModel>(partial.Model);
|
||||
Assert.Equal(5, model.Id);
|
||||
Assert.Equal("Basketball", model.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnNotFoundForMissingRow()
|
||||
{
|
||||
await using var ctx = ControllerTestContext.CreateContext();
|
||||
var controller = CreateController(ctx, useSieve: false);
|
||||
|
||||
var result = await controller.Row(404);
|
||||
|
||||
Assert.IsType<NotFoundResult>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSport()
|
||||
{
|
||||
await using var ctx = ControllerTestContext.CreateContext();
|
||||
var controller = CreateController(ctx);
|
||||
|
||||
var result = await controller.Create(new SportViewModel { Name = "Volleyball" }, ControllerTestContext.EmptySieveModel());
|
||||
|
||||
var partial = Assert.IsType<PartialViewResult>(result);
|
||||
Assert.Equal("_SportsList", partial.ViewName);
|
||||
Assert.Contains(ctx.Sports, s => s.Name == "Volleyball");
|
||||
Assert.Contains("was added successfully", controller.Response.Headers[Events.MVC.Constants.HtmxHeaders.Trigger].ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnPagedSportsWhenIndexIsRequestedUsingMockedPagingOptions()
|
||||
{
|
||||
await using var ctx = ControllerTestContext.CreateContext();
|
||||
ctx.Sports.AddRange(
|
||||
new Sport { Id = 1, Name = "Athletics" },
|
||||
new Sport { Id = 2, Name = "Basketball" },
|
||||
new Sport { Id = 3, Name = "Cycling" });
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var optionsMock = new Mock<IOptions<PagingSettings>>();
|
||||
optionsMock
|
||||
.SetupGet(options => options.Value)
|
||||
.Returns(new PagingSettings { PageSize = 2 });
|
||||
|
||||
var controller = new SportsController(
|
||||
ctx,
|
||||
ControllerTestContext.CreateSieveProcessor(),
|
||||
optionsMock.Object)
|
||||
.WithTempData();
|
||||
|
||||
var result = await controller.Index(new SieveModel());
|
||||
|
||||
var view = Assert.IsType<ViewResult>(result);
|
||||
var model = Assert.IsType<PagedList<SportViewModel>>(view.Model);
|
||||
Assert.Equal(2, model.Data.Count);
|
||||
Assert.Equal(2, model.PagingInfo.ItemsPerPage);
|
||||
Assert.Equal(3, model.PagingInfo.TotalItemsCount);
|
||||
Assert.Equal(3, model.PagingInfo.FilteredItemsCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PopulateModelStateValidationErrorsForMissingName()
|
||||
{
|
||||
await using var ctx = ControllerTestContext.CreateContext();
|
||||
var controller = CreateController(ctx);
|
||||
var invalidModel = new SportViewModel { Name = string.Empty };
|
||||
|
||||
var result = await controller.Create(invalidModel, ControllerTestContext.EmptySieveModel());
|
||||
|
||||
Assert.False(
|
||||
controller.ModelState.IsValid,
|
||||
"This assertion intentionally demonstrates an incorrect expectation: unit tests do not run the MVC validation pipeline automatically.");
|
||||
Assert.True(
|
||||
controller.ModelState.ContainsKey(nameof(SportViewModel.Name)),
|
||||
"This assertion intentionally demonstrates an incorrect expectation: without MVC model validation, ModelState should not contain a validation entry for Name.");
|
||||
Assert.Contains(
|
||||
controller.ModelState[nameof(SportViewModel.Name)]!.Errors,
|
||||
error => error.ErrorMessage == "The Name field is required.");
|
||||
|
||||
var partial = Assert.IsType<PartialViewResult>(result);
|
||||
Assert.Equal("_CreateSportForm", partial.ViewName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EditSport()
|
||||
{
|
||||
await using var ctx = ControllerTestContext.CreateContext();
|
||||
ctx.Sports.Add(ControllerTestContext.CreateSport());
|
||||
await ctx.SaveChangesAsync();
|
||||
var controller = CreateController(ctx, useSieve: false);
|
||||
|
||||
var result = await controller.Edit(10, new SportViewModel { Id = 10, Name = "Volleyball" });
|
||||
|
||||
var partial = Assert.IsType<PartialViewResult>(result);
|
||||
Assert.Equal("_SportRow", partial.ViewName);
|
||||
Assert.Equal("Volleyball", (await ctx.Sports.SingleAsync()).Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSport()
|
||||
{
|
||||
await using var ctx = ControllerTestContext.CreateContext();
|
||||
ctx.Sports.Add(ControllerTestContext.CreateSport());
|
||||
await ctx.SaveChangesAsync();
|
||||
var controller = CreateController(ctx);
|
||||
|
||||
var result = await controller.Delete(10, ControllerTestContext.EmptySieveModel());
|
||||
|
||||
var partial = Assert.IsType<PartialViewResult>(result);
|
||||
Assert.Equal("_SportsList", partial.ViewName);
|
||||
Assert.Empty(ctx.Sports);
|
||||
Assert.Contains("was deleted successfully", controller.Response.Headers[Events.MVC.Constants.HtmxHeaders.Trigger].ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnConflictWhenDeletingSportWithRegistrations()
|
||||
{
|
||||
await using var ctx = ControllerTestContext.CreateContext();
|
||||
ctx.Countries.Add(ControllerTestContext.CreateCountry());
|
||||
ctx.People.Add(ControllerTestContext.CreatePerson());
|
||||
ctx.Events.Add(ControllerTestContext.CreateEvent());
|
||||
ctx.Sports.Add(ControllerTestContext.CreateSport());
|
||||
ctx.Registrations.Add(new Registration
|
||||
{
|
||||
Id = 1000,
|
||||
EventId = 100,
|
||||
PersonId = 1,
|
||||
SportId = 10
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
var controller = CreateController(ctx);
|
||||
|
||||
var result = await controller.Delete(10, ControllerTestContext.EmptySieveModel());
|
||||
|
||||
var content = Assert.IsType<ContentResult>(result);
|
||||
Assert.Equal(409, controller.Response.StatusCode);
|
||||
Assert.Equal("The sport cannot be deleted because registrations exist.", content.Content);
|
||||
}
|
||||
|
||||
private static SportsController CreateController(EventsContext ctx, bool useSieve = true)
|
||||
{
|
||||
var controller = new SportsController(
|
||||
ctx,
|
||||
useSieve ? ControllerTestContext.CreateSieveProcessor() : null!,
|
||||
ControllerTestContext.CreatePagingOptions())
|
||||
.WithTempData();
|
||||
|
||||
return controller;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<UserSecretsId>Erasmus-STA-2026</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="10.0.0">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Events.MVC\Events.MVC.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
1
Events-MVC/Tests/Events.Tests.UnitTests/GlobalUsings.cs
Normal file
1
Events-MVC/Tests/Events.Tests.UnitTests/GlobalUsings.cs
Normal file
@@ -0,0 +1 @@
|
||||
global using Xunit;
|
||||
@@ -0,0 +1,98 @@
|
||||
#if POSTGRES
|
||||
using Events.EF.Data.Postgres;
|
||||
#else
|
||||
using Events.EF.Data.MSSQL;
|
||||
#endif
|
||||
using Events.EF.Models;
|
||||
using Events.MVC.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Sieve.Models;
|
||||
using Sieve.Services;
|
||||
|
||||
namespace Events.Tests.UnitTests.Infrastructure;
|
||||
|
||||
internal static class ControllerTestContext
|
||||
{
|
||||
public static EventsContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<EventsContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
|
||||
return new EventsContext(options);
|
||||
}
|
||||
|
||||
public static IOptions<PagingSettings> CreatePagingOptions(int pageSize = 10)
|
||||
{
|
||||
return Options.Create(new PagingSettings { PageSize = pageSize });
|
||||
}
|
||||
|
||||
public static SieveModel EmptySieveModel()
|
||||
{
|
||||
return new SieveModel();
|
||||
}
|
||||
|
||||
public static ISieveProcessor CreateSieveProcessor()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddOptions();
|
||||
services.AddScoped<ISieveProcessor, SieveProcessor>();
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
using var scope = provider.CreateScope();
|
||||
return scope.ServiceProvider.GetRequiredService<ISieveProcessor>();
|
||||
}
|
||||
|
||||
public static Country CreateCountry(string code = "HR", string alpha3 = "HRV", string name = "Croatia")
|
||||
{
|
||||
return new Country
|
||||
{
|
||||
Code = code,
|
||||
Alpha3 = alpha3,
|
||||
Name = name
|
||||
};
|
||||
}
|
||||
|
||||
public static Person CreatePerson(int id = 1, string countryCode = "HR", string firstName = "Ivan", string lastName = "Horvat")
|
||||
{
|
||||
return new Person
|
||||
{
|
||||
Id = id,
|
||||
FirstName = firstName,
|
||||
LastName = lastName,
|
||||
FirstNameTranscription = firstName,
|
||||
LastNameTranscription = lastName,
|
||||
AddressLine = "Ilica 1",
|
||||
PostalCode = "10000",
|
||||
City = "Zagreb",
|
||||
AddressCountry = "Croatia",
|
||||
Email = $"{firstName.ToLowerInvariant()}.{lastName.ToLowerInvariant()}@example.com",
|
||||
ContactPhone = "+38591111222",
|
||||
BirthDate = new DateOnly(1990, 5, 1),
|
||||
DocumentNumber = $"DOC-{id}",
|
||||
CountryCode = countryCode
|
||||
};
|
||||
}
|
||||
|
||||
public static Event CreateEvent(int id = 100, string name = "Spring Games")
|
||||
{
|
||||
return new Event
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
EventDate = new DateOnly(2026, 4, 15)
|
||||
};
|
||||
}
|
||||
|
||||
public static Sport CreateSport(int id = 10, string name = "Football")
|
||||
{
|
||||
return new Sport
|
||||
{
|
||||
Id = id,
|
||||
Name = name
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.ViewFeatures;
|
||||
|
||||
namespace Events.Tests.UnitTests.Infrastructure;
|
||||
|
||||
internal static class ControllerTestExtensions
|
||||
{
|
||||
public static T WithTempData<T>(this T controller) where T : Controller
|
||||
{
|
||||
var httpContext = new DefaultHttpContext();
|
||||
controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = httpContext
|
||||
};
|
||||
controller.TempData = new TempDataDictionary(httpContext, new TestTempDataProvider());
|
||||
return controller;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Npgsql;
|
||||
|
||||
namespace Events.Tests.UnitTests.Infrastructure;
|
||||
|
||||
public class ProviderSpecificQueryShould
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReturnMatchingRowsWhenUsingILikeWithInMemoryProvider()
|
||||
{
|
||||
await using var ctx = ControllerTestContext.CreateContext();
|
||||
ctx.Countries.Add(ControllerTestContext.CreateCountry());
|
||||
ctx.People.Add(ControllerTestContext.CreatePerson());
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var people = await ctx.People
|
||||
.Where(person => person.FirstName != null && Microsoft.EntityFrameworkCore.EF.Functions.ILike(person.FirstName, "%iv%"))
|
||||
.ToListAsync();
|
||||
|
||||
Assert.Single(people);
|
||||
Assert.Equal("Ivan", people[0].FirstName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ThrowInvalidOperationExceptionWhenUsingILikeWithInMemoryProvider()
|
||||
{
|
||||
await using var ctx = ControllerTestContext.CreateContext();
|
||||
ctx.Countries.Add(ControllerTestContext.CreateCountry());
|
||||
ctx.People.Add(ControllerTestContext.CreatePerson());
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
await ctx.People
|
||||
.Where(person => person.FirstName != null && Microsoft.EntityFrameworkCore.EF.Functions.ILike(person.FirstName, "%iv%"))
|
||||
.ToListAsync());
|
||||
|
||||
Assert.Contains("ILike", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteILikeWhenUsingPostgreSqlProvider()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.SetBasePath(AppContext.BaseDirectory)
|
||||
.AddJsonFile("appsettings.json", optional: false)
|
||||
.AddUserSecrets<ProviderSpecificQueryShould>(optional: true)
|
||||
.Build();
|
||||
|
||||
var productionConnectionString = configuration.GetConnectionString("EventDB-Test");
|
||||
Assert.False(
|
||||
string.IsNullOrWhiteSpace(productionConnectionString),
|
||||
"The EventDB-Test connection string must be available so the PostgreSQL-backed provider test can connect to the PostgreSQL copy.");
|
||||
|
||||
var connectionStringBuilder = new NpgsqlConnectionStringBuilder(productionConnectionString)
|
||||
{
|
||||
SslMode = SslMode.Disable
|
||||
};
|
||||
|
||||
var options = new DbContextOptionsBuilder<Events.EF.Data.Postgres.EventsContext>()
|
||||
.UseNpgsql(connectionStringBuilder.ConnectionString)
|
||||
.Options;
|
||||
|
||||
await using var ctx = new Events.EF.Data.Postgres.EventsContext(options);
|
||||
|
||||
var people = await ctx.People
|
||||
.Where(person => person.FirstName != null && Microsoft.EntityFrameworkCore.EF.Functions.ILike(person.FirstName, "%iv%"))
|
||||
.Take(20)
|
||||
.ToListAsync();
|
||||
|
||||
Assert.NotEmpty(people);
|
||||
Assert.All(
|
||||
people,
|
||||
person => Assert.Contains(
|
||||
"iv",
|
||||
person.FirstName,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc.ViewFeatures;
|
||||
|
||||
namespace Events.Tests.UnitTests.Infrastructure;
|
||||
|
||||
internal sealed class TestTempDataProvider : ITempDataProvider
|
||||
{
|
||||
private Dictionary<string, object> values = [];
|
||||
|
||||
public IDictionary<string, object> LoadTempData(HttpContext context)
|
||||
{
|
||||
return new Dictionary<string, object>(values);
|
||||
}
|
||||
|
||||
public void SaveTempData(HttpContext context, IDictionary<string, object> values)
|
||||
{
|
||||
this.values = new Dictionary<string, object>(values);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using Events.MVC.Models;
|
||||
|
||||
namespace Events.Tests.UnitTests.Models;
|
||||
|
||||
public class PagingInfoTests
|
||||
{
|
||||
public static IEnumerable<object[]> TotalPagesCases =>
|
||||
[
|
||||
[5, 10, 1],
|
||||
[20, 10, 2],
|
||||
[25, 10, 3]
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public void TotalPages_ReturnsAtLeastOne()
|
||||
{
|
||||
var pagingInfo = new PagingInfo
|
||||
{
|
||||
FilteredItemsCount = 0,
|
||||
ItemsPerPage = 10,
|
||||
CurrentPage = 1
|
||||
};
|
||||
|
||||
Assert.Equal(1, pagingInfo.TotalPages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TotalPages_RoundsUpWhenFilteredItemsDoNotDivideEvenly()
|
||||
{
|
||||
var pagingInfo = new PagingInfo
|
||||
{
|
||||
FilteredItemsCount = 21,
|
||||
ItemsPerPage = 10,
|
||||
CurrentPage = 1
|
||||
};
|
||||
|
||||
Assert.Equal(3, pagingInfo.TotalPages);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, 10, 1)]
|
||||
[InlineData(10, 10, 1)]
|
||||
[InlineData(11, 10, 2)]
|
||||
[InlineData(21, 10, 3)]
|
||||
public void TotalPages_ReturnsExpectedPageCount(int filteredItemsCount, int itemsPerPage, int expectedTotalPages)
|
||||
{
|
||||
var pagingInfo = new PagingInfo
|
||||
{
|
||||
FilteredItemsCount = filteredItemsCount,
|
||||
ItemsPerPage = itemsPerPage,
|
||||
CurrentPage = 1
|
||||
};
|
||||
|
||||
Assert.Equal(expectedTotalPages, pagingInfo.TotalPages);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(TotalPagesCases))]
|
||||
public void TotalPages_ReturnsExpectedPageCount_WhenUsingMemberData(
|
||||
int filteredItemsCount,
|
||||
int itemsPerPage,
|
||||
int expectedTotalPages)
|
||||
{
|
||||
var pagingInfo = new PagingInfo
|
||||
{
|
||||
FilteredItemsCount = filteredItemsCount,
|
||||
ItemsPerPage = itemsPerPage,
|
||||
CurrentPage = 1
|
||||
};
|
||||
|
||||
Assert.Equal(expectedTotalPages, pagingInfo.TotalPages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToggleSort_ReturnsDescending_WhenAlreadySortedBySameProperty()
|
||||
{
|
||||
var pagingInfo = new PagingInfo
|
||||
{
|
||||
Sorts = "Name",
|
||||
ItemsPerPage = 10,
|
||||
CurrentPage = 1
|
||||
};
|
||||
|
||||
Assert.Equal("-Name", pagingInfo.ToggleSort("Name"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsSortedBy_And_IsDescending_ReflectCurrentState()
|
||||
{
|
||||
var pagingInfo = new PagingInfo
|
||||
{
|
||||
Sorts = "-RegisteredAt",
|
||||
ItemsPerPage = 10,
|
||||
CurrentPage = 1
|
||||
};
|
||||
|
||||
Assert.True(pagingInfo.IsSortedBy("RegisteredAt"), "PagingInfo should report that sorting is applied by RegisteredAt.");
|
||||
Assert.True(pagingInfo.IsDescending(), "PagingInfo should report descending sort order when the sort expression starts with '-'.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using Events.MVC.Util.Extensions;
|
||||
using Sieve.Models;
|
||||
|
||||
namespace Events.Tests.UnitTests.Util;
|
||||
|
||||
public class SieveModelExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void SetDefaultPagingAndSorting_AssignsDefaults_WhenValuesAreMissing()
|
||||
{
|
||||
var model = new SieveModel();
|
||||
|
||||
model.SetDefaultPagingAndSorting(defaultPageSize: 10, defaultSort: "Name");
|
||||
|
||||
Assert.Equal(1, model.Page);
|
||||
Assert.Equal(10, model.PageSize);
|
||||
Assert.Equal("Name", model.Sorts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetDefaultPagingAndSorting_ClampsInvalidPage_AndPageSize()
|
||||
{
|
||||
var model = new SieveModel
|
||||
{
|
||||
Page = 0,
|
||||
PageSize = -5
|
||||
};
|
||||
|
||||
model.SetDefaultPagingAndSorting(defaultPageSize: 20, defaultSort: "RegisteredAt");
|
||||
|
||||
Assert.Equal(1, model.Page);
|
||||
Assert.Equal(20, model.PageSize);
|
||||
Assert.Equal("RegisteredAt", model.Sorts);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Name@=*basketball", "Name", "basketball")]
|
||||
[InlineData("Name@=basketball", "Name", "basketball")]
|
||||
[InlineData("PersonTranscription@=*ivan", "PersonTranscription", "ivan")]
|
||||
[InlineData(" PersonTranscription@=*ivan ", "PersonTranscription", "ivan")]
|
||||
public void ExtractFilterValue_ReturnsExpectedValue_ForDefaultOperators(
|
||||
string filters,
|
||||
string propertyName,
|
||||
string expected)
|
||||
{
|
||||
var value = SieveModelExtensions.ExtractFilterValue(filters, propertyName);
|
||||
|
||||
Assert.Equal(expected, value);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("CountryCode==HR", "CountryCode", "HR")]
|
||||
[InlineData("PersonTranscription@=*ivan,CountryCode==HR", "CountryCode", "HR")]
|
||||
[InlineData("CountryCode==HR,PersonTranscription@=*ivan", "CountryCode", "HR")]
|
||||
public void ExtractFilterValue_ReturnsExpectedValue_ForCustomOperator(
|
||||
string filters,
|
||||
string propertyName,
|
||||
string expected)
|
||||
{
|
||||
var value = SieveModelExtensions.ExtractFilterValue(filters, propertyName, "==");
|
||||
|
||||
Assert.Equal(expected, value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractFilterValue_ReturnsEmptyString_WhenPropertyIsMissing()
|
||||
{
|
||||
var value = SieveModelExtensions.ExtractFilterValue(
|
||||
"PersonTranscription@=*ivan,CountryCode==HR",
|
||||
"Name");
|
||||
|
||||
Assert.Equal(string.Empty, value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractFilterValue_FromModel_UsesFiltersProperty()
|
||||
{
|
||||
var model = new SieveModel
|
||||
{
|
||||
Filters = "FullName@=*ana"
|
||||
};
|
||||
|
||||
var value = model.ExtractFilterValue("FullName");
|
||||
|
||||
Assert.Equal("ana", value);
|
||||
}
|
||||
}
|
||||
5
Events-MVC/Tests/Events.Tests.UnitTests/appsettings.json
Normal file
5
Events-MVC/Tests/Events.Tests.UnitTests/appsettings.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"EventDB-Test": "Host=localhost;Port=5433;Database=events;Username=sport;Persist Security Info=True;"
|
||||
}
|
||||
}
|
||||
72
Events-MVC/Tests/README.md
Normal file
72
Events-MVC/Tests/README.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Test Plan
|
||||
|
||||
## Events.Tests.UnitTests
|
||||
|
||||
- `PagingInfo` and `PagedList`
|
||||
- `TotalPages` calculation
|
||||
- `ToggleSort`, `IsSortedBy`, `IsDescending`
|
||||
- pagination behavior and page bounds
|
||||
- `SieveModelExtensions`
|
||||
- normalization of `page`, `pageSize`, and default `sort`
|
||||
- extracting filter values for `@=*`, `@=`, and `==`
|
||||
- controller guards and redirects
|
||||
- `People` without countries
|
||||
- `Registrations` without events, sports, or people
|
||||
- verify that the correct `RedirectToActionResult` is returned and the expected toast is stored in `TempData`
|
||||
- controller result and view model checks
|
||||
- verify whether an action returns the correct `ViewResult`, `PartialViewResult`, `NotFoundResult`, `BadRequestResult`, or `ContentResult`
|
||||
- verify that an action returns the expected view model type and expected values inside the view model
|
||||
- validation and mapping logic from controllers
|
||||
- `CountriesController` translations `json <-> view model`
|
||||
- duplicate language validation and empty translation row handling
|
||||
- small helpers and formatting logic
|
||||
- toast payload helpers if they are extracted later
|
||||
- route/pager helper logic that can be tested without a full MVC host
|
||||
|
||||
## Events.Tests.IntegrationTests
|
||||
|
||||
- HTTP tests against the MVC application through `WebApplicationFactory`
|
||||
- `GET` requests for screens return `200`
|
||||
- HTMX requests return the correct partial
|
||||
- redirect and guard scenarios
|
||||
- `People` without countries redirects to `Countries`
|
||||
- `Registrations` without events/sports/people redirects to the correct screen
|
||||
- CRUD happy paths for main screens
|
||||
- create/edit/delete for `Sports`, `Events`, `Countries`, and `People`
|
||||
- create/edit/delete for `Registrations` for the selected event
|
||||
- validation and error flow
|
||||
- invalid input returns a partial with validation messages
|
||||
- DB conflicts return readable `ProblemDetails`
|
||||
- filtering, sorting, and paging
|
||||
- `Sieve` query strings return expected results
|
||||
- transcription and country filtering for registrations
|
||||
|
||||
For these tests, the best setup is a dedicated PostgreSQL test container or an isolated test database with seeded data.
|
||||
|
||||
## Events.Tests.UITests
|
||||
|
||||
- end-to-end user flows in the browser
|
||||
- open a screen, use the collapse form, save a record, see a toast
|
||||
- paging and sorting without duplicating layout
|
||||
- HTMX inline edit and cancel
|
||||
- `Registrations` screen
|
||||
- changing the event refreshes the table
|
||||
- person autocomplete works and respects the country filter
|
||||
- create/edit/delete registration
|
||||
- navigation smoke tests
|
||||
- all main links work
|
||||
- redirect messages through toast are displayed
|
||||
|
||||
For UI tests, Playwright is the most natural choice, with a small smoke suite and a few critical end-to-end flows.
|
||||
|
||||
Suggested local setup:
|
||||
|
||||
```powershell
|
||||
dotnet tool install --global Microsoft.Playwright.CLI
|
||||
```
|
||||
|
||||
Then inside the UI test project:
|
||||
|
||||
```powershell
|
||||
playwright install
|
||||
```
|
||||
Reference in New Issue
Block a user