Events-MVC (example with htmx)

This commit is contained in:
Boris Milašinović
2026-04-25 22:21:35 +02:00
parent eb04483417
commit 0ee1b22f61
114 changed files with 7966 additions and 0 deletions

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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());
}
}

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -0,0 +1 @@
global using Xunit;

View File

@@ -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
};
}
}

View File

@@ -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;
}
}

View File

@@ -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));
}
}

View File

@@ -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);
}
}

View File

@@ -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 '-'.");
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,5 @@
{
"ConnectionStrings": {
"EventDB-Test": "Host=localhost;Port=5433;Database=events;Username=sport;Persist Security Info=True;"
}
}