diff --git a/TickAPI/TickAPI.Tests/Categories/Controllers/CategoriesControllerTests.cs b/TickAPI/TickAPI.Tests/Categories/Controllers/CategoriesControllerTests.cs index 271fd24..79283fd 100644 --- a/TickAPI/TickAPI.Tests/Categories/Controllers/CategoriesControllerTests.cs +++ b/TickAPI/TickAPI.Tests/Categories/Controllers/CategoriesControllerTests.cs @@ -32,7 +32,7 @@ public async Task GetCategories_WhenDataIsValid_ShouldReturnOk() // Assert var result = Assert.IsType>>(res); - var objectResult = Assert.IsType(result.Result); + var objectResult = Assert.IsType(result.Result); Assert.Equal(200, objectResult.StatusCode); Assert.NotNull(objectResult.Value); } diff --git a/TickAPI/TickAPI.Tests/Common/Results/Generic/ResultTests.cs b/TickAPI/TickAPI.Tests/Common/Results/Generic/ResultTests.cs index ffb1390..e343a6a 100644 --- a/TickAPI/TickAPI.Tests/Common/Results/Generic/ResultTests.cs +++ b/TickAPI/TickAPI.Tests/Common/Results/Generic/ResultTests.cs @@ -1,4 +1,6 @@ -using TickAPI.Common.Results; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using TickAPI.Common.Results; using TickAPI.Common.Results.Generic; namespace TickAPI.Tests.Common.Results.Generic; @@ -8,10 +10,13 @@ public class ResultTests [Fact] public void Success_ShouldReturnResultWithValue() { + // Arrange const int value = 123; + // Act var result = Result.Success(value); + // Assert Assert.Equal(value, result.Value); Assert.True(result.IsSuccess); Assert.False(result.IsError); @@ -22,11 +27,14 @@ public void Success_ShouldReturnResultWithValue() [Fact] public void Failure_ShouldReturnResultWithError() { + // Arrange const int statusCode = 500; const string errorMsg = "example error msg"; + // Act var result = Result.Failure(500, errorMsg); + // Assert Assert.True(result.IsError); Assert.False(result.IsSuccess); Assert.Equal(errorMsg, result.ErrorMsg); @@ -36,12 +44,15 @@ public void Failure_ShouldReturnResultWithError() [Fact] public void PropagateError_WhenResultWithErrorPassed_ShouldReturnResultWithError() { + // Arrange const int statusCode = 500; const string errorMsg = "error message"; var resultWithError = Result.Failure(statusCode, errorMsg); + // Act var result = Result.PropagateError(resultWithError); + // Assert Assert.True(result.IsError); Assert.False(result.IsSuccess); Assert.Equal(errorMsg, result.ErrorMsg); @@ -51,12 +62,15 @@ public void PropagateError_WhenResultWithErrorPassed_ShouldReturnResultWithError [Fact] public void PropagateError_WhenNonGenericResultWithErrorPassed_ShouldReturnResultWithError() { + // Arrange const int statusCode = 500; const string errorMsg = "error message"; var resultWithError = Result.Failure(statusCode, errorMsg); + // Act var result = Result.PropagateError(resultWithError); + // Assert Assert.True(result.IsError); Assert.False(result.IsSuccess); Assert.Equal(errorMsg, result.ErrorMsg); @@ -66,20 +80,91 @@ public void PropagateError_WhenNonGenericResultWithErrorPassed_ShouldReturnResul [Fact] public void PropagateError_WhenResultWithSuccessPassed_ShouldThrowArgumentException() { + // Arrange var resultWithSuccess = Result.Success("abc"); + // Act var act = () => Result.PropagateError(resultWithSuccess); + // Assert Assert.Throws(act); } [Fact] public void PropagateError_WhenNonGenericResultWithSuccessPassed_ShouldThrowArgumentException() { + // Arrange var resultWithSuccess = Result.Success(); + // Act var act = () => Result.PropagateError(resultWithSuccess); + // Assert Assert.Throws(act); } + + [Fact] + public void ToObjectResult_WhenGenericResultIsError_ShouldReturnObjectResultWithErrorDetails() + { + // Arrange + const int statusCode = 404; + const string errorMsg = "Not found"; + var result = Result.Failure(statusCode, errorMsg); + + // Act + var objectResult = result.ToObjectResult(); + + // Assert + Assert.IsType(objectResult); + Assert.Equal(statusCode, objectResult.StatusCode); + Assert.Equal(errorMsg, objectResult.Value); + } + + [Fact] + public void ToObjectResult_WhenGenericResultIsSuccess_ShouldReturnObjectResultWithDefaultSuccessCode() + { + // Arrange + const int value = 42; + var result = Result.Success(value); + + // Act + var objectResult = result.ToObjectResult(); + + // Assert + Assert.IsType(objectResult); + Assert.Equal(StatusCodes.Status200OK, objectResult.StatusCode); + Assert.Equal(value, objectResult.Value); + } + + [Fact] + public void ToObjectResult_WhenGenericResultIsSuccessWithCustomStatusCode_ShouldReturnObjectResultWithSpecifiedCode() + { + // Arrange + const string value = "Success data"; + var result = Result.Success(value); + const int customSuccessCode = StatusCodes.Status201Created; + + // Act + var objectResult = result.ToObjectResult(customSuccessCode); + + // Assert + Assert.IsType(objectResult); + Assert.Equal(customSuccessCode, objectResult.StatusCode); + Assert.Equal(value, objectResult.Value); + } + + [Fact] + public void ToObjectResult_WhenGenericResultWithNullValueIsSuccess_ShouldReturnObjectResultWithNullValue() + { + // Arrange + var result = Result.Success(null); + + // Act + var objectResult = result.ToObjectResult(); + + // Assert + Assert.IsType(objectResult); + Assert.Equal(StatusCodes.Status200OK, objectResult.StatusCode); + Assert.Null(objectResult.Value); + } } \ No newline at end of file diff --git a/TickAPI/TickAPI.Tests/Common/Results/ResultTests.cs b/TickAPI/TickAPI.Tests/Common/Results/ResultTests.cs index f09668d..828d4b3 100644 --- a/TickAPI/TickAPI.Tests/Common/Results/ResultTests.cs +++ b/TickAPI/TickAPI.Tests/Common/Results/ResultTests.cs @@ -1,4 +1,6 @@ -using TickAPI.Common.Results; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using TickAPI.Common.Results; using TickAPI.Common.Results.Generic; namespace TickAPI.Tests.Common.Results; @@ -8,8 +10,10 @@ public class ResultTests [Fact] public void Success_ShouldReturnResultWithSuccess() { + // Act var result = Result.Success(); + // Assert Assert.True(result.IsSuccess); Assert.False(result.IsError); Assert.Equal("", result.ErrorMsg); @@ -19,11 +23,29 @@ public void Success_ShouldReturnResultWithSuccess() [Fact] public void Failure_ShouldReturnResultWithError() { + // Arrange const int statusCode = 500; const string errorMsg = "example error msg"; + // Act var result = Result.Failure(500, errorMsg); + // Assert + Assert.True(result.IsError); + Assert.False(result.IsSuccess); + Assert.Equal(errorMsg, result.ErrorMsg); + Assert.Equal(statusCode, result.StatusCode); + } + + [Fact] + public void PropagateError_WhenResultWithErrorPassed_ShouldReturnResultWithError() + { + const int statusCode = 500; + const string errorMsg = "error message"; + var resultWithError = Result.Failure(statusCode, errorMsg); + + var result = Result.PropagateError(resultWithError); + Assert.True(result.IsError); Assert.False(result.IsSuccess); Assert.Equal(errorMsg, result.ErrorMsg); @@ -33,25 +55,89 @@ public void Failure_ShouldReturnResultWithError() [Fact] public void PropagateError_WhenGenericResultWithErrorPassed_ShouldReturnResultWithError() { + // Arrange const int statusCode = 500; const string errorMsg = "error message"; var resultWithError = Result.Failure(statusCode, errorMsg); + // Act var result = Result.PropagateError(resultWithError); + // Assert Assert.True(result.IsError); Assert.False(result.IsSuccess); Assert.Equal(errorMsg, result.ErrorMsg); Assert.Equal(statusCode, result.StatusCode); } + [Fact] + public void PropagateError_WhenResultWithSuccessPassed_ShouldThrowArgumentException() + { + var resultWithSuccess = Result.Success(); + + var act = () => Result.PropagateError(resultWithSuccess); + + Assert.Throws(act); + } + [Fact] public void PropagateError_WhenGenericResultWithSuccessPassed_ShouldThrowArgumentException() { + // Arrange var resultWithSuccess = Result.Success(123); + // Act var act = () => Result.PropagateError(resultWithSuccess); + // Assert Assert.Throws(act); } + + [Fact] + public void ToObjectResult_WhenResultIsError_ShouldReturnObjectResultWithErrorDetails() + { + // Arrange + const int statusCode = 400; + const string errorMsg = "Bad request"; + var result = Result.Failure(statusCode, errorMsg); + + // Act + var objectResult = result.ToObjectResult(); + + // Assert + Assert.IsType(objectResult); + Assert.Equal(statusCode, objectResult.StatusCode); + Assert.Equal(errorMsg, objectResult.Value); + } + + [Fact] + public void ToObjectResult_WhenResultIsSuccess_ShouldReturnObjectResultWithDefaultSuccessCode() + { + // Arrange + var result = Result.Success(); + + // Act + var objectResult = result.ToObjectResult(); + + // Assert + Assert.IsType(objectResult); + Assert.Equal(StatusCodes.Status200OK, objectResult.StatusCode); + Assert.Equal(string.Empty, objectResult.Value); + } + + [Fact] + public void ToObjectResult_WhenResultIsSuccessWithCustomStatusCode_ShouldReturnObjectResultWithSpecifiedCode() + { + // Arrange + var result = Result.Success(); + const int customSuccessCode = StatusCodes.Status201Created; + + // Act + var objectResult = result.ToObjectResult(customSuccessCode); + + // Assert + Assert.IsType(objectResult); + Assert.Equal(customSuccessCode, objectResult.StatusCode); + Assert.Equal(string.Empty, objectResult.Value); + } } \ No newline at end of file diff --git a/TickAPI/TickAPI.Tests/Events/Controllers/EventsControllerTests.cs b/TickAPI/TickAPI.Tests/Events/Controllers/EventsControllerTests.cs index 60e6cc7..6d8b112 100644 --- a/TickAPI/TickAPI.Tests/Events/Controllers/EventsControllerTests.cs +++ b/TickAPI/TickAPI.Tests/Events/Controllers/EventsControllerTests.cs @@ -20,7 +20,7 @@ namespace TickAPI.Tests.Events.Controllers; public class EventsControllerTests { - private readonly EventFiltersDto _emptyFilters = new EventFiltersDto(null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null); + private readonly EventFiltersDto _emptyFilters = new EventFiltersDto(null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null); [Fact] public async Task CreateEvent_WhenDataIsValid_ShouldReturnSuccess() @@ -191,7 +191,7 @@ public async Task GetOrganizerEvents_WhenAllOperationsSucceed_ShouldReturnOkWith // Assert var result = Assert.IsType>>(response); - var okResult = Assert.IsType(result.Result); + var okResult = Assert.IsType(result.Result); Assert.Equal(StatusCodes.Status200OK, okResult.StatusCode); var returnedPaginatedData = Assert.IsType>(okResult.Value); @@ -385,7 +385,7 @@ public async Task GetOrganizerEventsPaginationDetails_WhenAllOperationsSucceed_S // Assert var result = Assert.IsType>(response); - var okResult = Assert.IsType(result.Result); + var okResult = Assert.IsType(result.Result); Assert.Equal(StatusCodes.Status200OK, okResult.StatusCode); var returnedPaginationDetails = Assert.IsType(okResult.Value); @@ -479,7 +479,7 @@ public async Task GetEvents_WhenAllOperationsSucceed_ShouldReturnOkWithPaginated // Assert var result = Assert.IsType>>(response); - var okResult = Assert.IsType(result.Result); + var okResult = Assert.IsType(result.Result); Assert.Equal(StatusCodes.Status200OK, okResult.StatusCode); var returnedPaginatedData = Assert.IsType>(okResult.Value); @@ -544,7 +544,7 @@ public async Task GetEventsPaginationDetails_WhenAllOperationsSucceed_ShouldRetu // Assert var result = Assert.IsType>(response); - var okResult = Assert.IsType(result.Result); + var okResult = Assert.IsType(result.Result); Assert.Equal(StatusCodes.Status200OK, okResult.StatusCode); var returnedPaginationDetails = Assert.IsType(okResult.Value); @@ -602,7 +602,7 @@ public async Task GetEventDetails_WhenAllOperationsSucceed_ShouldReturnOkWithEve // Assert var result = Assert.IsType>(response); - var okResult = Assert.IsType(result.Result); + var okResult = Assert.IsType(result.Result); Assert.Equal(StatusCodes.Status200OK, okResult.StatusCode); var returnedEventDetails = Assert.IsType(okResult.Value); diff --git a/TickAPI/TickAPI.Tests/Events/Filters/EventFilterApplierTests.cs b/TickAPI/TickAPI.Tests/Events/Filters/EventFilterApplierTests.cs index 7657c21..6256303 100644 --- a/TickAPI/TickAPI.Tests/Events/Filters/EventFilterApplierTests.cs +++ b/TickAPI/TickAPI.Tests/Events/Filters/EventFilterApplierTests.cs @@ -24,8 +24,7 @@ public void ApplyFilters_WithName_ShouldCallFilterByName() { // Arrange var filters = new EventFiltersDto( - Name: "test event", - Descritpion: null, + SearchQuery: "test event", StartDate: null, MinStartDate: null, MaxStartDate: null, @@ -40,57 +39,27 @@ public void ApplyFilters_WithName_ShouldCallFilterByName() AddressCity: null, AddressStreet: null, HouseNumber: null, - FlatNumber: null + FlatNumber: null, + PostalCode: null, + CategoriesNames: null ); // Act _eventFilterApplier.ApplyFilters(filters); // Assert - _mockEventFilter.Verify(ef => ef.FilterByName(filters.Name!), Times.Once); + _mockEventFilter.Verify(ef => ef.FilterByName(filters.SearchQuery!), Times.Once); + _mockEventFilter.Verify(ef => ef.FilterByDescription(filters.SearchQuery!), Times.Once); _mockEventFilter.Verify(ef => ef.GetEvents(), Times.Once); } - - [Fact] - public void ApplyFilters_WithDescription_ShouldCallFilterByDescription() - { - // Arrange - var filters = new EventFiltersDto( - Name: null, - Descritpion: "test description", - StartDate: null, - MinStartDate: null, - MaxStartDate: null, - EndDate: null, - MinEndDate: null, - MaxEndDate: null, - MinPrice: null, - MaxPrice: null, - MinAge: null, - MaxMinimumAge: null, - AddressCountry: null, - AddressCity: null, - AddressStreet: null, - HouseNumber: null, - FlatNumber: null - ); - - // Act - _eventFilterApplier.ApplyFilters(filters); - - // Assert - _mockEventFilter.Verify(ef => ef.FilterByDescription(filters.Descritpion!), Times.Once); - _mockEventFilter.Verify(ef => ef.GetEvents(), Times.Once); - } - + [Fact] public void ApplyFilters_WithStartDate_ShouldCallFilterByStartDate() { // Arrange var startDate = new DateTime(2025, 5, 1); var filters = new EventFiltersDto( - Name: null, - Descritpion: null, + SearchQuery: null, StartDate: startDate, MinStartDate: null, MaxStartDate: null, @@ -105,7 +74,9 @@ public void ApplyFilters_WithStartDate_ShouldCallFilterByStartDate() AddressCity: null, AddressStreet: null, HouseNumber: null, - FlatNumber: null + FlatNumber: null, + PostalCode: null, + CategoriesNames: null ); // Act @@ -122,8 +93,7 @@ public void ApplyFilters_WithMinStartDate_ShouldCallFilterByMinStartDate() // Arrange var minStartDate = new DateTime(2025, 5, 1); var filters = new EventFiltersDto( - Name: null, - Descritpion: null, + SearchQuery: null, StartDate: null, MinStartDate: minStartDate, MaxStartDate: null, @@ -138,7 +108,9 @@ public void ApplyFilters_WithMinStartDate_ShouldCallFilterByMinStartDate() AddressCity: null, AddressStreet: null, HouseNumber: null, - FlatNumber: null + FlatNumber: null, + PostalCode: null, + CategoriesNames: null ); // Act @@ -155,8 +127,7 @@ public void ApplyFilters_WithMaxStartDate_ShouldCallFilterByMaxStartDate() // Arrange var maxStartDate = new DateTime(2025, 5, 1); var filters = new EventFiltersDto( - Name: null, - Descritpion: null, + SearchQuery: null, StartDate: null, MinStartDate: null, MaxStartDate: maxStartDate, @@ -171,7 +142,9 @@ public void ApplyFilters_WithMaxStartDate_ShouldCallFilterByMaxStartDate() AddressCity: null, AddressStreet: null, HouseNumber: null, - FlatNumber: null + FlatNumber: null, + PostalCode: null, + CategoriesNames: null ); // Act @@ -188,8 +161,7 @@ public void ApplyFilters_WithEndDate_ShouldCallFilterByEndDate() // Arrange var endDate = new DateTime(2025, 5, 1); var filters = new EventFiltersDto( - Name: null, - Descritpion: null, + SearchQuery: null, StartDate: null, MinStartDate: null, MaxStartDate: null, @@ -204,7 +176,9 @@ public void ApplyFilters_WithEndDate_ShouldCallFilterByEndDate() AddressCity: null, AddressStreet: null, HouseNumber: null, - FlatNumber: null + FlatNumber: null, + PostalCode: null, + CategoriesNames: null ); // Act @@ -221,8 +195,7 @@ public void ApplyFilters_WithMinEndDate_ShouldCallFilterByMinEndDate() // Arrange var minEndDate = new DateTime(2025, 5, 1); var filters = new EventFiltersDto( - Name: null, - Descritpion: null, + SearchQuery: null, StartDate: null, MinStartDate: null, MaxStartDate: null, @@ -237,7 +210,9 @@ public void ApplyFilters_WithMinEndDate_ShouldCallFilterByMinEndDate() AddressCity: null, AddressStreet: null, HouseNumber: null, - FlatNumber: null + FlatNumber: null, + PostalCode: null, + CategoriesNames: null ); // Act @@ -254,8 +229,7 @@ public void ApplyFilters_WithMaxEndDate_ShouldCallFilterByMaxEndDate() // Arrange var maxEndDate = new DateTime(2025, 5, 1); var filters = new EventFiltersDto( - Name: null, - Descritpion: null, + SearchQuery: null, StartDate: null, MinStartDate: null, MaxStartDate: null, @@ -270,7 +244,9 @@ public void ApplyFilters_WithMaxEndDate_ShouldCallFilterByMaxEndDate() AddressCity: null, AddressStreet: null, HouseNumber: null, - FlatNumber: null + FlatNumber: null, + PostalCode: null, + CategoriesNames: null ); // Act @@ -287,8 +263,7 @@ public void ApplyFilters_WithMinPrice_ShouldCallFilterByMinPrice() // Arrange decimal minPrice = 100; var filters = new EventFiltersDto( - Name: null, - Descritpion: null, + SearchQuery: null, StartDate: null, MinStartDate: null, MaxStartDate: null, @@ -303,7 +278,9 @@ public void ApplyFilters_WithMinPrice_ShouldCallFilterByMinPrice() AddressCity: null, AddressStreet: null, HouseNumber: null, - FlatNumber: null + FlatNumber: null, + PostalCode: null, + CategoriesNames: null ); // Act @@ -320,8 +297,7 @@ public void ApplyFilters_WithMaxPrice_ShouldCallFilterByMaxPrice() // Arrange decimal maxPrice = 200; var filters = new EventFiltersDto( - Name: null, - Descritpion: null, + SearchQuery: null, StartDate: null, MinStartDate: null, MaxStartDate: null, @@ -336,7 +312,9 @@ public void ApplyFilters_WithMaxPrice_ShouldCallFilterByMaxPrice() AddressCity: null, AddressStreet: null, HouseNumber: null, - FlatNumber: null + FlatNumber: null, + PostalCode: null, + CategoriesNames: null ); // Act @@ -353,8 +331,7 @@ public void ApplyFilters_WithMinAge_ShouldCallFilterByMinAge() // Arrange uint minAge = 18; var filters = new EventFiltersDto( - Name: null, - Descritpion: null, + SearchQuery: null, StartDate: null, MinStartDate: null, MaxStartDate: null, @@ -369,7 +346,9 @@ public void ApplyFilters_WithMinAge_ShouldCallFilterByMinAge() AddressCity: null, AddressStreet: null, HouseNumber: null, - FlatNumber: null + FlatNumber: null, + PostalCode: null, + CategoriesNames: null ); // Act @@ -386,8 +365,7 @@ public void ApplyFilters_WithMaxMinimumAge_ShouldCallFilterByMaxMinimumAge() // Arrange uint maxMinimumAge = 21; var filters = new EventFiltersDto( - Name: null, - Descritpion: null, + SearchQuery: null, StartDate: null, MinStartDate: null, MaxStartDate: null, @@ -402,7 +380,9 @@ public void ApplyFilters_WithMaxMinimumAge_ShouldCallFilterByMaxMinimumAge() AddressCity: null, AddressStreet: null, HouseNumber: null, - FlatNumber: null + FlatNumber: null, + PostalCode: null, + CategoriesNames: null ); // Act @@ -418,8 +398,7 @@ public void ApplyFilters_WithAddressCountry_ShouldCallFilterByAddressCountry() { // Arrange var filters = new EventFiltersDto( - Name: null, - Descritpion: null, + SearchQuery: null, StartDate: null, MinStartDate: null, MaxStartDate: null, @@ -434,7 +413,9 @@ public void ApplyFilters_WithAddressCountry_ShouldCallFilterByAddressCountry() AddressCity: null, AddressStreet: null, HouseNumber: null, - FlatNumber: null + FlatNumber: null, + PostalCode: null, + CategoriesNames: null ); // Act @@ -450,8 +431,7 @@ public void ApplyFilters_WithAddressCity_ShouldCallFilterByAddressCity() { // Arrange var filters = new EventFiltersDto( - Name: null, - Descritpion: null, + SearchQuery: null, StartDate: null, MinStartDate: null, MaxStartDate: null, @@ -466,7 +446,9 @@ public void ApplyFilters_WithAddressCity_ShouldCallFilterByAddressCity() AddressCity: "Warsaw", AddressStreet: null, HouseNumber: null, - FlatNumber: null + FlatNumber: null, + PostalCode: null, + CategoriesNames: null ); // Act @@ -482,8 +464,7 @@ public void ApplyFilters_WithAddressStreetOnly_ShouldCallFilterByAddressStreet() { // Arrange var filters = new EventFiltersDto( - Name: null, - Descritpion: null, + SearchQuery: null, StartDate: null, MinStartDate: null, MaxStartDate: null, @@ -498,7 +479,9 @@ public void ApplyFilters_WithAddressStreetOnly_ShouldCallFilterByAddressStreet() AddressCity: null, AddressStreet: "Marszałkowska", HouseNumber: null, - FlatNumber: null + FlatNumber: null, + PostalCode: null, + CategoriesNames: null ); // Act @@ -515,8 +498,7 @@ public void ApplyFilters_WithAddressStreetAndHouseNumber_ShouldCallFilterByAddre // Arrange uint houseNumber = 12; var filters = new EventFiltersDto( - Name: null, - Descritpion: null, + SearchQuery: null, StartDate: null, MinStartDate: null, MaxStartDate: null, @@ -531,7 +513,9 @@ public void ApplyFilters_WithAddressStreetAndHouseNumber_ShouldCallFilterByAddre AddressCity: null, AddressStreet: "Marszałkowska", HouseNumber: houseNumber, - FlatNumber: null + FlatNumber: null, + PostalCode: null, + CategoriesNames: null ); // Act @@ -549,8 +533,7 @@ public void ApplyFilters_WithCompleteAddress_ShouldCallFilterByAddressStreet() uint houseNumber = 12; uint flatNumber = 5; var filters = new EventFiltersDto( - Name: null, - Descritpion: null, + SearchQuery: null, StartDate: null, MinStartDate: null, MaxStartDate: null, @@ -565,7 +548,9 @@ public void ApplyFilters_WithCompleteAddress_ShouldCallFilterByAddressStreet() AddressCity: null, AddressStreet: "Marszałkowska", HouseNumber: houseNumber, - FlatNumber: flatNumber + FlatNumber: flatNumber, + PostalCode: null, + CategoriesNames: null ); // Act @@ -575,6 +560,73 @@ public void ApplyFilters_WithCompleteAddress_ShouldCallFilterByAddressStreet() _mockEventFilter.Verify(ef => ef.FilterByAddressStreet(filters.AddressStreet!, filters.HouseNumber, filters.FlatNumber), Times.Once); _mockEventFilter.Verify(ef => ef.GetEvents(), Times.Once); } + + [Fact] + public void ApplyFilters_WithPostalCode_ShouldCallFilterByAddressPostalCode() + { + // Arrange + var filters = new EventFiltersDto( + SearchQuery: null, + StartDate: null, + MinStartDate: null, + MaxStartDate: null, + EndDate: null, + MinEndDate: null, + MaxEndDate: null, + MinPrice: null, + MaxPrice: null, + MinAge: null, + MaxMinimumAge: null, + AddressCountry: null, + AddressCity: null, + AddressStreet: null, + HouseNumber: null, + FlatNumber: null, + PostalCode: "00-001", + CategoriesNames: null + ); + + // Act + _eventFilterApplier.ApplyFilters(filters); + + // Assert + _mockEventFilter.Verify(ef => ef.FilterByAddressPostalCode(filters.PostalCode!), Times.Once); + _mockEventFilter.Verify(ef => ef.GetEvents(), Times.Once); + } + + [Fact] + public void ApplyFilters_WithCategoriesNames_ShouldCallFilterByCategoriesNames() + { + // Arrange + var categoriesNames = new List { "Concert", "Festival", "Exhibition" }; + var filters = new EventFiltersDto( + SearchQuery: null, + StartDate: null, + MinStartDate: null, + MaxStartDate: null, + EndDate: null, + MinEndDate: null, + MaxEndDate: null, + MinPrice: null, + MaxPrice: null, + MinAge: null, + MaxMinimumAge: null, + AddressCountry: null, + AddressCity: null, + AddressStreet: null, + HouseNumber: null, + FlatNumber: null, + PostalCode: null, + CategoriesNames: categoriesNames + ); + + // Act + _eventFilterApplier.ApplyFilters(filters); + + // Assert + _mockEventFilter.Verify(ef => ef.FilterByCategoriesNames(filters.CategoriesNames!), Times.Once); + _mockEventFilter.Verify(ef => ef.GetEvents(), Times.Once); + } [Fact] public void ApplyFilters_WithMultipleFilters_ShouldCallAllRelevantFilters() @@ -584,8 +636,7 @@ public void ApplyFilters_WithMultipleFilters_ShouldCallAllRelevantFilters() decimal minPrice = 50; decimal maxPrice = 200; var filters = new EventFiltersDto( - Name: "Concert", - Descritpion: null, + SearchQuery: "Concert", StartDate: startDate, MinStartDate: null, MaxStartDate: null, @@ -600,14 +651,17 @@ public void ApplyFilters_WithMultipleFilters_ShouldCallAllRelevantFilters() AddressCity: null, AddressStreet: null, HouseNumber: null, - FlatNumber: null + FlatNumber: null, + PostalCode: null, + CategoriesNames: null ); // Act _eventFilterApplier.ApplyFilters(filters); // Assert - _mockEventFilter.Verify(ef => ef.FilterByName(filters.Name!), Times.Once); + _mockEventFilter.Verify(ef => ef.FilterByName(filters.SearchQuery!), Times.Once); + _mockEventFilter.Verify(ef => ef.FilterByDescription(filters.SearchQuery!), Times.Once); _mockEventFilter.Verify(ef => ef.FilterByStartDate(filters.StartDate!.Value), Times.Once); _mockEventFilter.Verify(ef => ef.FilterByMinPrice(filters.MinPrice!.Value), Times.Once); _mockEventFilter.Verify(ef => ef.FilterByMaxPrice(filters.MaxPrice!.Value), Times.Once); @@ -625,8 +679,7 @@ public void ApplyFilters_WithNoFilters_ShouldOnlyCallGetEvents() }.AsQueryable(); _mockEventFilter.Setup(ef => ef.GetEvents()).Returns(expectedResult); var filters = new EventFiltersDto( - Name: null, - Descritpion: null, + SearchQuery: null, StartDate: null, MinStartDate: null, MaxStartDate: null, @@ -641,7 +694,9 @@ public void ApplyFilters_WithNoFilters_ShouldOnlyCallGetEvents() AddressCity: null, AddressStreet: null, HouseNumber: null, - FlatNumber: null + FlatNumber: null, + PostalCode: null, + CategoriesNames: null ); // Act diff --git a/TickAPI/TickAPI.Tests/Events/Services/EventServiceTests.cs b/TickAPI/TickAPI.Tests/Events/Services/EventServiceTests.cs index cda1b68..464170b 100644 --- a/TickAPI/TickAPI.Tests/Events/Services/EventServiceTests.cs +++ b/TickAPI/TickAPI.Tests/Events/Services/EventServiceTests.cs @@ -9,6 +9,7 @@ using TickAPI.Categories.Abstractions; using TickAPI.Categories.DTOs.Request; using TickAPI.Categories.Models; +using TickAPI.Common.Results; using TickAPI.Events.Models; using TickAPI.Organizers.Abstractions; using TickAPI.Organizers.Models; @@ -630,8 +631,8 @@ public async Task GetEventDetailsAsync_WhenSuccessful_ShouldReturnEventDetails() .ReturnsAsync(Result.Success(@event)); ticketServiceMock - .Setup(m => m.GetNumberOfAvailableTicketsByType(It.IsAny())) - .Returns((TicketType input) => + .Setup(m => m.GetNumberOfAvailableTicketsByTypeAsync(It.IsAny())) + .ReturnsAsync((TicketType input) => Result.Success((uint)(input.Price / 10)) ); @@ -683,4 +684,845 @@ public async Task GetEventDetailsAsync_WhenFails_ShouldReturnEventError() Assert.Equal(StatusCodes.Status404NotFound, result.StatusCode); Assert.Equal($"event with id {@event.Id} not found", result.ErrorMsg); } + + [Fact] + public async Task EditEventAsync_WhenDataValid_ShouldUpdateEvent() + { + // Arrange + var organizer = new Organizer + { + Email = "organizer@example.com", + IsVerified = true + }; + var eventId = Guid.NewGuid(); + string name = "Updated Concert"; + string description = "Updated description of a concert"; + DateTime startDate = new DateTime(2025, 6, 1); + DateTime endDate = new DateTime(2025, 7, 1); + uint? minimumAge = 21; + EventStatus eventStatus = EventStatus.SoldOut; + + var existingEvent = new Event + { + Id = eventId, + Name = "Original Concert", + Description = "Original description", + StartDate = new DateTime(2025, 5, 1), + EndDate = new DateTime(2025, 5, 15), + MinimumAge = 18, + EventStatus = EventStatus.TicketsAvailable, + Organizer = organizer, + Categories = new List + { + new Category { Name = "rock" } + }, + Address = new Address + { + Country = "United States", + City = "Chicago", + Street = "State st", + HouseNumber = 100, + FlatNumber = null, + PostalCode = "60001" + }, + TicketTypes = [], + }; + + List categories = + [ + new EditEventCategoryDto("jazz"), + new EditEventCategoryDto("classical") + ]; + + List expectedCategories = + [ + new Category { Name = "jazz" }, + new Category { Name = "classical" } + ]; + + CreateAddressDto editAddress = new CreateAddressDto("United States", "New York", "Broadway", 42, null, "10001"); + var updatedAddress = new Address + { + Country = "United States", + City = "New York", + Street = "Broadway", + HouseNumber = 42, + FlatNumber = null, + PostalCode = "10001" + }; + + var eventRepositoryMock = new Mock(); + eventRepositoryMock + .Setup(e => e.GetEventByIdAndOrganizerAsync(eventId, organizer)) + .ReturnsAsync(Result.Success(existingEvent)); + eventRepositoryMock + .Setup(e => e.SaveEventAsync(It.IsAny())) + .ReturnsAsync(Result.Success()); + + var organizerServiceMock = new Mock(); + + var addressServiceMock = new Mock(); + addressServiceMock + .Setup(m => m.GetOrCreateAddressAsync(editAddress)) + .ReturnsAsync(Result
.Success(updatedAddress)); + + var dateTimeServiceMock = new Mock(); + dateTimeServiceMock + .Setup(m => m.GetCurrentDateTime()) + .Returns(new DateTime(2025, 4, 15)); + + var categoryServiceMock = new Mock(); + categoryServiceMock + .Setup(c => c.GetCategoriesByNames(It.IsAny>())) + .Returns(Result>.Success(expectedCategories)); + + var paginationServiceMock = new Mock(); + var ticketServiceMock = new Mock(); + + var sut = new EventService( + eventRepositoryMock.Object, + organizerServiceMock.Object, + addressServiceMock.Object, + dateTimeServiceMock.Object, + paginationServiceMock.Object, + categoryServiceMock.Object, + ticketServiceMock.Object); + + // Act + var result = await sut.EditEventAsync( + organizer, + eventId, + name, + description, + startDate, + endDate, + minimumAge, + editAddress, + categories, + eventStatus); + + // Assert + Assert.True(result.IsSuccess); + Assert.Equal(name, result.Value!.Name); + Assert.Equal(description, result.Value!.Description); + Assert.Equal(startDate, result.Value!.StartDate); + Assert.Equal(endDate, result.Value!.EndDate); + Assert.Equal(minimumAge, result.Value!.MinimumAge); + Assert.Equal(eventStatus, result.Value!.EventStatus); + Assert.Equal(expectedCategories.Count, result.Value!.Categories.Count); + Assert.Equal(updatedAddress, result.Value!.Address); + + // Verify repository was called to save the updated event + eventRepositoryMock.Verify(e => e.SaveEventAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task EditEventAsync_WhenEventNotFound_ShouldReturnError() + { + // Arrange + var organizer = new Organizer + { + Email = "organizer@example.com", + IsVerified = true + }; + var eventId = Guid.NewGuid(); + string name = "Updated Concert"; + string description = "Updated description"; + DateTime startDate = new DateTime(2025, 6, 1); + DateTime endDate = new DateTime(2025, 7, 1); + uint? minimumAge = 21; + EventStatus eventStatus = EventStatus.SoldOut; + + List categories = + [ + new EditEventCategoryDto("jazz"), + new EditEventCategoryDto("classical") + ]; + + CreateAddressDto editAddress = new CreateAddressDto("United States", "New York", "Broadway", 42, null, "10001"); + + var eventRepositoryMock = new Mock(); + eventRepositoryMock + .Setup(e => e.GetEventByIdAndOrganizerAsync(eventId, organizer)) + .ReturnsAsync(Result.Failure(StatusCodes.Status404NotFound, "Event not found or not owned by organizer")); + + var organizerServiceMock = new Mock(); + var addressServiceMock = new Mock(); + var dateTimeServiceMock = new Mock(); + var categoryServiceMock = new Mock(); + var paginationServiceMock = new Mock(); + var ticketServiceMock = new Mock(); + + var sut = new EventService( + eventRepositoryMock.Object, + organizerServiceMock.Object, + addressServiceMock.Object, + dateTimeServiceMock.Object, + paginationServiceMock.Object, + categoryServiceMock.Object, + ticketServiceMock.Object); + + // Act + var result = await sut.EditEventAsync( + organizer, + eventId, + name, + description, + startDate, + endDate, + minimumAge, + editAddress, + categories, + eventStatus); + + // Assert + Assert.False(result.IsSuccess); + Assert.Equal(StatusCodes.Status404NotFound, result.StatusCode); + Assert.Equal("Event not found or not owned by organizer", result.ErrorMsg); + } + + [Fact] + public async Task EditEventAsync_WhenEndDateBeforeStartDate_ShouldReturnBadRequest() + { + // Arrange + var organizer = new Organizer + { + Email = "organizer@example.com", + IsVerified = true + }; + var eventId = Guid.NewGuid(); + string name = "Updated Concert"; + string description = "Updated description"; + DateTime startDate = new DateTime(2025, 7, 1); + DateTime endDate = new DateTime(2025, 6, 1); // End date before start date + uint? minimumAge = 21; + EventStatus eventStatus = EventStatus.SoldOut; + + var existingEvent = new Event + { + Id = eventId, + Name = "Original Concert", + Organizer = organizer, + TicketTypes = [], + }; + + List categories = + [ + new EditEventCategoryDto("jazz") + ]; + + CreateAddressDto editAddress = new CreateAddressDto("United States", "New York", "Broadway", 42, null, "10001"); + + var eventRepositoryMock = new Mock(); + eventRepositoryMock + .Setup(e => e.GetEventByIdAndOrganizerAsync(eventId, organizer)) + .ReturnsAsync(Result.Success(existingEvent)); + + var organizerServiceMock = new Mock(); + var addressServiceMock = new Mock(); + + var dateTimeServiceMock = new Mock(); + dateTimeServiceMock + .Setup(m => m.GetCurrentDateTime()) + .Returns(new DateTime(2025, 4, 15)); + + var categoryServiceMock = new Mock(); + var paginationServiceMock = new Mock(); + var ticketServiceMock = new Mock(); + + var sut = new EventService( + eventRepositoryMock.Object, + organizerServiceMock.Object, + addressServiceMock.Object, + dateTimeServiceMock.Object, + paginationServiceMock.Object, + categoryServiceMock.Object, + ticketServiceMock.Object); + + // Act + var result = await sut.EditEventAsync( + organizer, + eventId, + name, + description, + startDate, + endDate, + minimumAge, + editAddress, + categories, + eventStatus); + + // Assert + Assert.False(result.IsSuccess); + Assert.Equal(StatusCodes.Status400BadRequest, result.StatusCode); + Assert.Equal("End date should be after start date", result.ErrorMsg); + } + + [Fact] + public async Task EditEventAsync_WhenStartDateChangedAndInPast_ShouldReturnBadRequest() + { + // Arrange + var organizer = new Organizer + { + Email = "organizer@example.com", + IsVerified = true + }; + var eventId = Guid.NewGuid(); + string name = "Updated Concert"; + string description = "Updated description"; + DateTime startDate = new DateTime(2025, 4, 1); // Start date in the past compared to current date + DateTime endDate = new DateTime(2025, 5, 1); + uint? minimumAge = 21; + EventStatus eventStatus = EventStatus.SoldOut; + + var existingEvent = new Event + { + Id = eventId, + Name = "Original Concert", + Organizer = organizer, + TicketTypes = [], + StartDate = new DateTime(2025, 3, 1), + }; + + List categories = + [ + new EditEventCategoryDto("jazz") + ]; + + CreateAddressDto editAddress = new CreateAddressDto("United States", "New York", "Broadway", 42, null, "10001"); + + var eventRepositoryMock = new Mock(); + eventRepositoryMock + .Setup(e => e.GetEventByIdAndOrganizerAsync(eventId, organizer)) + .ReturnsAsync(Result.Success(existingEvent)); + + var organizerServiceMock = new Mock(); + var addressServiceMock = new Mock(); + + var dateTimeServiceMock = new Mock(); + dateTimeServiceMock + .Setup(m => m.GetCurrentDateTime()) + .Returns(new DateTime(2025, 4, 15)); // Current date is after start date + + var categoryServiceMock = new Mock(); + var paginationServiceMock = new Mock(); + var ticketServiceMock = new Mock(); + + var sut = new EventService( + eventRepositoryMock.Object, + organizerServiceMock.Object, + addressServiceMock.Object, + dateTimeServiceMock.Object, + paginationServiceMock.Object, + categoryServiceMock.Object, + ticketServiceMock.Object); + + // Act + var result = await sut.EditEventAsync( + organizer, + eventId, + name, + description, + startDate, + endDate, + minimumAge, + editAddress, + categories, + eventStatus); + + // Assert + Assert.False(result.IsSuccess); + Assert.Equal(StatusCodes.Status400BadRequest, result.StatusCode); + Assert.Equal("Start date is in the past", result.ErrorMsg); + } + + [Fact] + public async Task EditEventAsync_StartDateNotChangedAndInPast_ShouldUpdateEvent() + { + // Arrange + var organizer = new Organizer + { + Email = "organizer@example.com", + IsVerified = true + }; + var eventId = Guid.NewGuid(); + string name = "Updated Concert"; + string description = "Updated description of a concert"; + DateTime startDate = new DateTime(2020, 1, 1); + DateTime endDate = new DateTime(2025, 7, 1); + uint? minimumAge = 21; + EventStatus eventStatus = EventStatus.SoldOut; + + var existingEvent = new Event + { + Id = eventId, + Name = "Original Concert", + Description = "Original description", + StartDate = new DateTime(2020, 1, 1), + EndDate = new DateTime(2025, 5, 15), + MinimumAge = 18, + EventStatus = EventStatus.TicketsAvailable, + Organizer = organizer, + Categories = new List + { + new Category { Name = "rock" } + }, + Address = new Address + { + Country = "United States", + City = "Chicago", + Street = "State st", + HouseNumber = 100, + FlatNumber = null, + PostalCode = "60001" + }, + TicketTypes = [], + }; + + List categories = + [ + new EditEventCategoryDto("jazz"), + new EditEventCategoryDto("classical") + ]; + + List expectedCategories = + [ + new Category { Name = "jazz" }, + new Category { Name = "classical" } + ]; + + CreateAddressDto editAddress = new CreateAddressDto("United States", "New York", "Broadway", 42, null, "10001"); + var updatedAddress = new Address + { + Country = "United States", + City = "New York", + Street = "Broadway", + HouseNumber = 42, + FlatNumber = null, + PostalCode = "10001" + }; + + var eventRepositoryMock = new Mock(); + eventRepositoryMock + .Setup(e => e.GetEventByIdAndOrganizerAsync(eventId, organizer)) + .ReturnsAsync(Result.Success(existingEvent)); + eventRepositoryMock + .Setup(e => e.SaveEventAsync(It.IsAny())) + .ReturnsAsync(Result.Success()); + + var organizerServiceMock = new Mock(); + + var addressServiceMock = new Mock(); + addressServiceMock + .Setup(m => m.GetOrCreateAddressAsync(editAddress)) + .ReturnsAsync(Result
.Success(updatedAddress)); + + var dateTimeServiceMock = new Mock(); + dateTimeServiceMock + .Setup(m => m.GetCurrentDateTime()) + .Returns(new DateTime(2025, 4, 15)); + + var categoryServiceMock = new Mock(); + categoryServiceMock + .Setup(c => c.GetCategoriesByNames(It.IsAny>())) + .Returns(Result>.Success(expectedCategories)); + + var paginationServiceMock = new Mock(); + var ticketServiceMock = new Mock(); + + var sut = new EventService( + eventRepositoryMock.Object, + organizerServiceMock.Object, + addressServiceMock.Object, + dateTimeServiceMock.Object, + paginationServiceMock.Object, + categoryServiceMock.Object, + ticketServiceMock.Object); + + // Act + var result = await sut.EditEventAsync( + organizer, + eventId, + name, + description, + startDate, + endDate, + minimumAge, + editAddress, + categories, + eventStatus); + + // Assert + Assert.True(result.IsSuccess); + Assert.Equal(name, result.Value!.Name); + Assert.Equal(description, result.Value!.Description); + Assert.Equal(startDate, result.Value!.StartDate); + Assert.Equal(endDate, result.Value!.EndDate); + Assert.Equal(minimumAge, result.Value!.MinimumAge); + Assert.Equal(eventStatus, result.Value!.EventStatus); + Assert.Equal(expectedCategories.Count, result.Value!.Categories.Count); + Assert.Equal(updatedAddress, result.Value!.Address); + + // Verify repository was called to save the updated event + eventRepositoryMock.Verify(e => e.SaveEventAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task EditEventAsync_WhenTicketTypeAvailableFromAfterEndDate_ShouldReturnBadRequest() + { + // Arrange + var organizer = new Organizer + { + Email = "organizer@example.com", + IsVerified = true + }; + var eventId = Guid.NewGuid(); + string name = "Updated Concert"; + string description = "Updated description"; + DateTime startDate = new DateTime(2025, 4, 1); + DateTime endDate = new DateTime(2025, 5, 1); + uint? minimumAge = 21; + EventStatus eventStatus = EventStatus.SoldOut; + + var existingEvent = new Event + { + Id = eventId, + Name = "Original Concert", + Organizer = organizer, + TicketTypes = [ + new TicketType + { + AvailableFrom = new DateTime(3000, 12, 12), + } + ], + }; + + List categories = + [ + new EditEventCategoryDto("jazz") + ]; + + CreateAddressDto editAddress = new CreateAddressDto("United States", "New York", "Broadway", 42, null, "10001"); + + var eventRepositoryMock = new Mock(); + eventRepositoryMock + .Setup(e => e.GetEventByIdAndOrganizerAsync(eventId, organizer)) + .ReturnsAsync(Result.Success(existingEvent)); + + var organizerServiceMock = new Mock(); + var addressServiceMock = new Mock(); + + var dateTimeServiceMock = new Mock(); + dateTimeServiceMock + .Setup(m => m.GetCurrentDateTime()) + .Returns(new DateTime(2000, 4, 15)); + + var categoryServiceMock = new Mock(); + var paginationServiceMock = new Mock(); + var ticketServiceMock = new Mock(); + + var sut = new EventService( + eventRepositoryMock.Object, + organizerServiceMock.Object, + addressServiceMock.Object, + dateTimeServiceMock.Object, + paginationServiceMock.Object, + categoryServiceMock.Object, + ticketServiceMock.Object); + + // Act + var result = await sut.EditEventAsync( + organizer, + eventId, + name, + description, + startDate, + endDate, + minimumAge, + editAddress, + categories, + eventStatus); + + // Assert + Assert.False(result.IsSuccess); + Assert.Equal(StatusCodes.Status400BadRequest, result.StatusCode); + Assert.Equal("Tickets can't be available after the event is over", result.ErrorMsg); + } + + [Fact] + public async Task EditEventAsync_WhenAddressServiceFails_ShouldPropagateError() + { + // Arrange + var organizer = new Organizer + { + Email = "organizer@example.com", + IsVerified = true + }; + var eventId = Guid.NewGuid(); + string name = "Updated Concert"; + string description = "Updated description"; + DateTime startDate = new DateTime(2025, 6, 1); + DateTime endDate = new DateTime(2025, 7, 1); + uint? minimumAge = 21; + EventStatus eventStatus = EventStatus.SoldOut; + + var existingEvent = new Event + { + Id = eventId, + Name = "Original Concert", + Organizer = organizer, + TicketTypes = [], + }; + + List categories = + [ + new EditEventCategoryDto("jazz") + ]; + + CreateAddressDto editAddress = new CreateAddressDto("", "", "", 0, null, ""); // Invalid address + + var eventRepositoryMock = new Mock(); + eventRepositoryMock + .Setup(e => e.GetEventByIdAndOrganizerAsync(eventId, organizer)) + .ReturnsAsync(Result.Success(existingEvent)); + + var organizerServiceMock = new Mock(); + + var addressServiceMock = new Mock(); + addressServiceMock + .Setup(m => m.GetOrCreateAddressAsync(editAddress)) + .ReturnsAsync(Result
.Failure(StatusCodes.Status400BadRequest, "Invalid address data")); + + var dateTimeServiceMock = new Mock(); + dateTimeServiceMock + .Setup(m => m.GetCurrentDateTime()) + .Returns(new DateTime(2025, 4, 15)); + + var categoryServiceMock = new Mock(); + var paginationServiceMock = new Mock(); + var ticketServiceMock = new Mock(); + + var sut = new EventService( + eventRepositoryMock.Object, + organizerServiceMock.Object, + addressServiceMock.Object, + dateTimeServiceMock.Object, + paginationServiceMock.Object, + categoryServiceMock.Object, + ticketServiceMock.Object); + + // Act + var result = await sut.EditEventAsync( + organizer, + eventId, + name, + description, + startDate, + endDate, + minimumAge, + editAddress, + categories, + eventStatus); + + // Assert + Assert.False(result.IsSuccess); + Assert.Equal(StatusCodes.Status400BadRequest, result.StatusCode); + Assert.Equal("Invalid address data", result.ErrorMsg); + } + + [Fact] + public async Task EditEventAsync_WhenCategoryServiceFails_ShouldPropagateError() + { + // Arrange + var organizer = new Organizer + { + Email = "organizer@example.com", + IsVerified = true + }; + var eventId = Guid.NewGuid(); + string name = "Updated Concert"; + string description = "Updated description"; + DateTime startDate = new DateTime(2025, 6, 1); + DateTime endDate = new DateTime(2025, 7, 1); + uint? minimumAge = 21; + EventStatus eventStatus = EventStatus.SoldOut; + + var existingEvent = new Event + { + Id = eventId, + Name = "Original Concert", + Organizer = organizer, + TicketTypes = [], + }; + + List categories = + [ + new EditEventCategoryDto("non-existent-category") + ]; + + CreateAddressDto editAddress = new CreateAddressDto("United States", "New York", "Broadway", 42, null, "10001"); + var updatedAddress = new Address + { + Country = "United States", + City = "New York", + Street = "Broadway", + HouseNumber = 42, + FlatNumber = null, + PostalCode = "10001" + }; + + var eventRepositoryMock = new Mock(); + eventRepositoryMock + .Setup(e => e.GetEventByIdAndOrganizerAsync(eventId, organizer)) + .ReturnsAsync(Result.Success(existingEvent)); + + var organizerServiceMock = new Mock(); + + var addressServiceMock = new Mock(); + addressServiceMock + .Setup(m => m.GetOrCreateAddressAsync(editAddress)) + .ReturnsAsync(Result
.Success(updatedAddress)); + + var dateTimeServiceMock = new Mock(); + dateTimeServiceMock + .Setup(m => m.GetCurrentDateTime()) + .Returns(new DateTime(2025, 4, 15)); + + var categoryServiceMock = new Mock(); + categoryServiceMock + .Setup(c => c.GetCategoriesByNames(It.IsAny>())) + .Returns(Result>.Failure(StatusCodes.Status404NotFound, "One or more categories not found")); + + var paginationServiceMock = new Mock(); + var ticketServiceMock = new Mock(); + + var sut = new EventService( + eventRepositoryMock.Object, + organizerServiceMock.Object, + addressServiceMock.Object, + dateTimeServiceMock.Object, + paginationServiceMock.Object, + categoryServiceMock.Object, + ticketServiceMock.Object); + + // Act + var result = await sut.EditEventAsync( + organizer, + eventId, + name, + description, + startDate, + endDate, + minimumAge, + editAddress, + categories, + eventStatus); + + // Assert + Assert.False(result.IsSuccess); + Assert.Equal(StatusCodes.Status404NotFound, result.StatusCode); + Assert.Equal("One or more categories not found", result.ErrorMsg); + } + + [Fact] + public async Task EditEventAsync_WhenSaveEventFails_ShouldPropagateError() + { + // Arrange + var organizer = new Organizer + { + Email = "organizer@example.com", + IsVerified = true + }; + var eventId = Guid.NewGuid(); + string name = "Updated Concert"; + string description = "Updated description"; + DateTime startDate = new DateTime(2025, 6, 1); + DateTime endDate = new DateTime(2025, 7, 1); + uint? minimumAge = 21; + EventStatus eventStatus = EventStatus.SoldOut; + + var existingEvent = new Event + { + Id = eventId, + Name = "Original Concert", + Organizer = organizer, + TicketTypes = [], + }; + + List categories = + [ + new EditEventCategoryDto("jazz") + ]; + + List expectedCategories = + [ + new Category { Name = "jazz" } + ]; + + CreateAddressDto editAddress = new CreateAddressDto("United States", "New York", "Broadway", 42, null, "10001"); + var updatedAddress = new Address + { + Country = "United States", + City = "New York", + Street = "Broadway", + HouseNumber = 42, + FlatNumber = null, + PostalCode = "10001" + }; + + var eventRepositoryMock = new Mock(); + eventRepositoryMock + .Setup(e => e.GetEventByIdAndOrganizerAsync(eventId, organizer)) + .ReturnsAsync(Result.Success(existingEvent)); + eventRepositoryMock + .Setup(e => e.SaveEventAsync(It.IsAny())) + .ReturnsAsync(Result.Failure(StatusCodes.Status500InternalServerError, "Database error occurred")); + + var organizerServiceMock = new Mock(); + + var addressServiceMock = new Mock(); + addressServiceMock + .Setup(m => m.GetOrCreateAddressAsync(editAddress)) + .ReturnsAsync(Result
.Success(updatedAddress)); + + var dateTimeServiceMock = new Mock(); + dateTimeServiceMock + .Setup(m => m.GetCurrentDateTime()) + .Returns(new DateTime(2025, 4, 15)); + + var categoryServiceMock = new Mock(); + categoryServiceMock + .Setup(c => c.GetCategoriesByNames(It.IsAny>())) + .Returns(Result>.Success(expectedCategories)); + + var paginationServiceMock = new Mock(); + var ticketServiceMock = new Mock(); + + var sut = new EventService( + eventRepositoryMock.Object, + organizerServiceMock.Object, + addressServiceMock.Object, + dateTimeServiceMock.Object, + paginationServiceMock.Object, + categoryServiceMock.Object, + ticketServiceMock.Object); + + // Act + var result = await sut.EditEventAsync( + organizer, + eventId, + name, + description, + startDate, + endDate, + minimumAge, + editAddress, + categories, + eventStatus); + + // Assert + Assert.False(result.IsSuccess); + Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); + Assert.Equal("Database error occurred", result.ErrorMsg); + } } \ No newline at end of file diff --git a/TickAPI/TickAPI.Tests/Events/Utils.cs b/TickAPI/TickAPI.Tests/Events/Utils.cs index 479568b..1fa0c76 100644 --- a/TickAPI/TickAPI.Tests/Events/Utils.cs +++ b/TickAPI/TickAPI.Tests/Events/Utils.cs @@ -47,8 +47,8 @@ public static GetEventResponseDto CreateSampleEventResponseDto(string name) new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc), new DateTime(1970, 1, 2, 0, 0, 0, DateTimeKind.Utc), 18, - 100, - 300, + new GetEventResponsePriceInfoDto(100, "PLN"), + new GetEventResponsePriceInfoDto(300, "PLN"), [new GetEventResponseCategoryDto("Test")], EventStatus.TicketsAvailable, new GetEventResponseAddressDto("United States", "New York", "10001", "Main St", 123, null) diff --git a/TickAPI/TickAPI.Tests/Organizers/Controllers/OrganizersControllerTests.cs b/TickAPI/TickAPI.Tests/Organizers/Controllers/OrganizersControllerTests.cs index 164b26c..21f8348 100644 --- a/TickAPI/TickAPI.Tests/Organizers/Controllers/OrganizersControllerTests.cs +++ b/TickAPI/TickAPI.Tests/Organizers/Controllers/OrganizersControllerTests.cs @@ -268,7 +268,7 @@ public async Task VerifyOrganizer_WhenVerificationSuccessful_ShouldReturnOk() var actionResult = await sut.VerifyOrganizer(new VerifyOrganizerDto(email)); // Assert - var result = Assert.IsType(actionResult); + var result = Assert.IsType(actionResult); Assert.Equal(StatusCodes.Status200OK, result.StatusCode); } diff --git a/TickAPI/TickAPI.Tests/Organizers/Services/OrganizerServiceTests.cs b/TickAPI/TickAPI.Tests/Organizers/Services/OrganizerServiceTests.cs index 1e91aa9..e517360 100644 --- a/TickAPI/TickAPI.Tests/Organizers/Services/OrganizerServiceTests.cs +++ b/TickAPI/TickAPI.Tests/Organizers/Services/OrganizerServiceTests.cs @@ -1,9 +1,12 @@ using Microsoft.AspNetCore.Http; using Moq; +using TickAPI.Common.Pagination.Abstractions; +using TickAPI.Common.Pagination.Responses; using TickAPI.Common.Results; using TickAPI.Common.Results.Generic; using TickAPI.Common.Time.Abstractions; using TickAPI.Organizers.Abstractions; +using TickAPI.Organizers.DTOs.Response; using TickAPI.Organizers.Models; using TickAPI.Organizers.Services; @@ -29,9 +32,12 @@ public async Task GetOrganizerByEmailAsync_WhenOrganizerWithEmailIsReturnedFromR var dateTimeServiceMock = new Mock(); + var paginationServiceMock = new Mock(); + var sut = new OrganizerService( organizerRepositoryMock.Object, - dateTimeServiceMock.Object + dateTimeServiceMock.Object, + paginationServiceMock.Object ); // Act @@ -55,9 +61,12 @@ public async Task GetOrganizerByEmailAsync_WhenOrganizerWithEmailIsNotReturnedFr var dateTimeServiceMock = new Mock(); + var paginationServiceMock = new Mock(); + var sut = new OrganizerService( organizerRepositoryMock.Object, - dateTimeServiceMock.Object + dateTimeServiceMock.Object, + paginationServiceMock.Object ); // Act @@ -94,9 +103,12 @@ public async Task CreateNewOrganizerAsync_WhenOrganizerDataIsValid_ShouldReturnN .Setup(m => m.GetCurrentDateTime()) .Returns(currentDate); + var paginationServiceMock = new Mock(); + var sut = new OrganizerService( organizerRepositoryMock.Object, - dateTimeServiceMock.Object + dateTimeServiceMock.Object, + paginationServiceMock.Object ); // Act @@ -138,9 +150,12 @@ public async Task CreateNewOrganizerAsync_WhenLastNameIsNull_ShouldReturnNewOrga .Setup(m => m.GetCurrentDateTime()) .Returns(currentDate); + var paginationServiceMock = new Mock(); + var sut = new OrganizerService( organizerRepositoryMock.Object, - dateTimeServiceMock.Object + dateTimeServiceMock.Object, + paginationServiceMock.Object ); // Act @@ -173,9 +188,12 @@ public async Task CreateNewOrganizerAsync_WhenWithNotUniqueEmail_ShouldReturnFai var dateTimeServiceMock = new Mock(); + var paginationServiceMock = new Mock(); + var sut = new OrganizerService( organizerRepositoryMock.Object, - dateTimeServiceMock.Object + dateTimeServiceMock.Object, + paginationServiceMock.Object ); // Act @@ -199,9 +217,12 @@ public async Task VerifyOrganizerByEmailAsync_WhenVerificationSuccessful_ShouldR var dateTimeServiceMock = new Mock(); + var paginationServiceMock = new Mock(); + var sut = new OrganizerService( organizerRepositoryMock.Object, - dateTimeServiceMock.Object + dateTimeServiceMock.Object, + paginationServiceMock.Object ); // Act @@ -223,9 +244,12 @@ public async Task VerifyOrganizerByEmailAsync_WhenVerificationNotSuccessful_Shou var dateTimeServiceMock = new Mock(); + var paginationServiceMock = new Mock(); + var sut = new OrganizerService( organizerRepositoryMock.Object, - dateTimeServiceMock.Object + dateTimeServiceMock.Object, + paginationServiceMock.Object ); // Act @@ -236,4 +260,303 @@ public async Task VerifyOrganizerByEmailAsync_WhenVerificationNotSuccessful_Shou Assert.Equal(StatusCodes.Status404NotFound, result.StatusCode); Assert.Equal($"organizer with email '{email}' not found", result.ErrorMsg); } + + [Fact] + public async Task GetUnverifiedOrganizersAsync_WhenPaginationSuccessful_ShouldReturnPaginatedUnverifiedOrganizers() + { + // Arrange + const int page = 0; + const int pageSize = 10; + + var unverifiedOrganizers = new List + { + new() { Email = "unverified1@test.com", FirstName = "First1", LastName = "Last1", DisplayName = "Display1", IsVerified = false }, + new() { Email = "unverified2@test.com", FirstName = "First2", LastName = "Last2", DisplayName = "Display2", IsVerified = false }, + new() { Email = "unverified3@test.com", FirstName = "First3", LastName = "Last3", DisplayName = "Display3", IsVerified = false } + }.AsQueryable(); + + var paginationDetails = new PaginationDetails(0, 3); + var paginatedData = new PaginatedData( + unverifiedOrganizers.ToList(), + page, + pageSize, + false, + false, + paginationDetails + ); + + var expectedDtos = new List + { + new("unverified1@test.com", "First1", "Last1", "Display1"), + new("unverified2@test.com", "First2", "Last2", "Display2"), + new("unverified3@test.com", "First3", "Last3", "Display3") + }; + + var mappedData = new PaginatedData( + expectedDtos, + page, + pageSize, + false, + false, + paginationDetails + ); + + var organizerRepositoryMock = new Mock(); + organizerRepositoryMock + .Setup(m => m.GetOrganizers()) + .Returns(unverifiedOrganizers); + + var dateTimeServiceMock = new Mock(); + + var paginationServiceMock = new Mock(); + paginationServiceMock + .Setup(m => m.PaginateAsync(unverifiedOrganizers, pageSize, page)) + .ReturnsAsync(Result>.Success(paginatedData)); + + // Capture and verify the mapping function + Func capturedMapFunction = null; + paginationServiceMock + .Setup(m => m.MapData(paginatedData, It.IsAny>())) + .Returns, Func>((source, mapFunc) => + { + capturedMapFunction = mapFunc; + return mappedData; + }); + + var sut = new OrganizerService( + organizerRepositoryMock.Object, + dateTimeServiceMock.Object, + paginationServiceMock.Object + ); + + // Act + var result = await sut.GetUnverifiedOrganizersAsync(page, pageSize); + + // Assert + Assert.True(result.IsSuccess); + Assert.Equal(mappedData, result.Value); + Assert.Equal(3, result.Value!.Data.Count); + + // Verify each DTO was correctly mapped + for (int i = 0; i < expectedDtos.Count; i++) + { + Assert.Equal(expectedDtos[i], result.Value.Data[i]); + } + + // Verify the mapping function works correctly + Assert.NotNull(capturedMapFunction); + var testOrganizer = new Organizer { Email = "test@example.com", FirstName = "TestFirst", LastName = "TestLast", DisplayName = "TestDisplay" }; + var mappedDto = capturedMapFunction(testOrganizer); + var expectedDto = new GetUnverifiedOrganizerResponseDto("test@example.com", "TestFirst", "TestLast", "TestDisplay"); + Assert.Equal(expectedDto, mappedDto); + } + + [Fact] + public async Task GetUnverifiedOrganizersAsync_WhenFilteringOrganizers_ShouldOnlyReturnUnverifiedOnes() + { + // Arrange + const int page = 0; + const int pageSize = 10; + + var mixedOrganizers = new List + { + new() { Email = "unverified1@test.com", FirstName = "First1", LastName = "Last1", DisplayName = "Display1", IsVerified = false }, + new() { Email = "verified1@test.com", FirstName = "First2", LastName = "Last2", DisplayName = "Display2", IsVerified = true }, + new() { Email = "unverified2@test.com", FirstName = "First3", LastName = "Last3", DisplayName = "Display3", IsVerified = false } + }.AsQueryable(); + + var filteredOrganizers = mixedOrganizers.Where(o => !o.IsVerified).ToList(); + + var paginationDetails = new PaginationDetails(0, 2); + var paginatedData = new PaginatedData( + filteredOrganizers, + page, + pageSize, + false, + false, + paginationDetails + ); + + var expectedDtos = new List + { + new("unverified1@test.com", "First1", "Last1", "Display1"), + new("unverified2@test.com", "First3", "Last3", "Display3") + }; + + var mappedData = new PaginatedData( + expectedDtos, + page, + pageSize, + false, + false, + paginationDetails + ); + + var organizerRepositoryMock = new Mock(); + organizerRepositoryMock + .Setup(m => m.GetOrganizers()) + .Returns(mixedOrganizers); + + var dateTimeServiceMock = new Mock(); + + var paginationServiceMock = new Mock(); + paginationServiceMock + .Setup(m => m.PaginateAsync(It.Is>(q => q.Count() == 2), pageSize, page)) + .ReturnsAsync(Result>.Success(paginatedData)); + paginationServiceMock + .Setup(m => m.MapData(paginatedData, It.IsAny>())) + .Returns(mappedData); + + var sut = new OrganizerService( + organizerRepositoryMock.Object, + dateTimeServiceMock.Object, + paginationServiceMock.Object + ); + + // Act + var result = await sut.GetUnverifiedOrganizersAsync(page, pageSize); + + // Assert + Assert.True(result.IsSuccess); + Assert.Equal(mappedData, result.Value); + Assert.Equal(2, result.Value!.Data.Count); + Assert.Equal(expectedDtos, result.Value.Data); + Assert.DoesNotContain(result.Value.Data, dto => dto.Email == "verified1@test.com"); + } + + [Fact] + public async Task GetUnverifiedOrganizersAsync_WhenNoPaginationResults_ShouldReturnEmptyList() + { + // Arrange + const int page = 0; + const int pageSize = 10; + + var emptyOrganizers = new List().AsQueryable(); + + var paginationDetails = new PaginationDetails(0, 0); + var paginatedData = new PaginatedData( + new List(), + page, + pageSize, + false, + false, + paginationDetails + ); + + var mappedData = new PaginatedData( + new List(), + page, + pageSize, + false, + false, + paginationDetails + ); + + var organizerRepositoryMock = new Mock(); + organizerRepositoryMock + .Setup(m => m.GetOrganizers()) + .Returns(emptyOrganizers); + + var dateTimeServiceMock = new Mock(); + + var paginationServiceMock = new Mock(); + paginationServiceMock + .Setup(m => m.PaginateAsync(It.IsAny>(), pageSize, page)) + .ReturnsAsync(Result>.Success(paginatedData)); + paginationServiceMock + .Setup(m => m.MapData(paginatedData, It.IsAny>())) + .Returns(mappedData); + + var sut = new OrganizerService( + organizerRepositoryMock.Object, + dateTimeServiceMock.Object, + paginationServiceMock.Object + ); + + // Act + var result = await sut.GetUnverifiedOrganizersAsync(page, pageSize); + + // Assert + Assert.True(result.IsSuccess); + Assert.Equal(mappedData, result.Value); + Assert.Empty(result.Value!.Data); + } + + [Fact] + public async Task GetUnverifiedOrganizersAsync_WithNullLastNames_ShouldMapCorrectly() + { + // Arrange + const int page = 0; + const int pageSize = 10; + + var unverifiedOrganizers = new List + { + new() { Email = "nulllast@test.com", FirstName = "First", LastName = null, DisplayName = "Display", IsVerified = false }, + }.AsQueryable(); + + var paginationDetails = new PaginationDetails(0, 1); + var paginatedData = new PaginatedData( + unverifiedOrganizers.ToList(), + page, + pageSize, + false, + false, + paginationDetails + ); + + var expectedDto = new GetUnverifiedOrganizerResponseDto("nulllast@test.com", "First", null, "Display"); + var mappedData = new PaginatedData( + new List { expectedDto }, + page, + pageSize, + false, + false, + paginationDetails + ); + + var organizerRepositoryMock = new Mock(); + organizerRepositoryMock + .Setup(m => m.GetOrganizers()) + .Returns(unverifiedOrganizers); + + var dateTimeServiceMock = new Mock(); + + var paginationServiceMock = new Mock(); + paginationServiceMock + .Setup(m => m.PaginateAsync(It.IsAny>(), pageSize, page)) + .ReturnsAsync(Result>.Success(paginatedData)); + + // Verify the mapping function handles null LastName correctly + Func capturedMapFunction = null; + paginationServiceMock + .Setup(m => m.MapData(paginatedData, It.IsAny>())) + .Returns, Func>((source, mapFunc) => + { + capturedMapFunction = mapFunc; + return mappedData; + }); + + var sut = new OrganizerService( + organizerRepositoryMock.Object, + dateTimeServiceMock.Object, + paginationServiceMock.Object + ); + + // Act + var result = await sut.GetUnverifiedOrganizersAsync(page, pageSize); + + // Assert + Assert.True(result.IsSuccess); + Assert.Equal(mappedData, result.Value); + Assert.Single(result.Value!.Data); + Assert.Equal(expectedDto, result.Value.Data[0]); + Assert.Null(result.Value.Data[0].LastName); + + // Verify the mapping function works correctly with null LastName + Assert.NotNull(capturedMapFunction); + var testOrganizer = new Organizer { Email = "test@example.com", FirstName = "TestFirst", LastName = null, DisplayName = "TestDisplay" }; + var mappedDto = capturedMapFunction(testOrganizer); + var expectedMappedDto = new GetUnverifiedOrganizerResponseDto("test@example.com", "TestFirst", null, "TestDisplay"); + Assert.Equal(expectedMappedDto, mappedDto); + } } \ No newline at end of file diff --git a/TickAPI/TickAPI.Tests/Tickets/Filters/TicketFilterApplierTests.cs b/TickAPI/TickAPI.Tests/Tickets/Filters/TicketFilterApplierTests.cs new file mode 100644 index 0000000..1bf1ecf --- /dev/null +++ b/TickAPI/TickAPI.Tests/Tickets/Filters/TicketFilterApplierTests.cs @@ -0,0 +1,169 @@ +using Moq; +using TickAPI.Tickets.Abstractions; +using TickAPI.Tickets.DTOs.Request; +using TickAPI.Tickets.Filters; +using TickAPI.Tickets.Models; + +namespace TickAPI.Tests.Tickets.Filters; + +public class TicketFilterApplierTests +{ + private readonly Mock _mockTicketFilter; + private readonly TicketFilterApplier _ticketFilterApplier; + private readonly IQueryable _emptyQueryable = new List().AsQueryable(); + + public TicketFilterApplierTests() + { + _mockTicketFilter = new Mock(); + _mockTicketFilter.Setup(tf => tf.GetTickets()).Returns(_emptyQueryable); + _ticketFilterApplier = new TicketFilterApplier(_mockTicketFilter.Object); + } + + [Fact] + public void ApplyFilters_WithEventName_ShouldCallFilterTicketsByEventName() + { + // Arrange + var filters = new TicketFiltersDto + ( + null, + null, + "concert" + ); + + // Act + _ticketFilterApplier.ApplyFilters(filters); + + // Assert + _mockTicketFilter.Verify(tf => tf.FilterTicketsByEventName(filters.EventName!), Times.Once); + _mockTicketFilter.Verify(tf => tf.GetTickets(), Times.Once); + } + + [Fact] + public void ApplyFilters_WithUsedOnly_ShouldCallFilterUsedTickets() + { + // Arrange + var filters = new TicketFiltersDto + ( + UsageFilter.OnlyUsed, + null, + null + ); + + // Act + _ticketFilterApplier.ApplyFilters(filters); + + // Assert + _mockTicketFilter.Verify(tf => tf.FilterUsedTickets(), Times.Once); + _mockTicketFilter.Verify(tf => tf.GetTickets(), Times.Once); + } + + [Fact] + public void ApplyFilters_WithUnusedOnly_ShouldCallFilterUnusedTickets() + { + // Arrange + var filters = new TicketFiltersDto + ( + UsageFilter.OnlyNotUsed, + null, + null + ); + + // Act + _ticketFilterApplier.ApplyFilters(filters); + + // Assert + _mockTicketFilter.Verify(tf => tf.FilterUnusedTickets(), Times.Once); + _mockTicketFilter.Verify(tf => tf.GetTickets(), Times.Once); + } + + [Fact] + public void ApplyFilters_WithForResellOnly_ShouldCallFilterTicketsForResell() + { + // Arrange + var filters = new TicketFiltersDto + ( + null, + ResellFilter.OnlyForResell, + null + ); + + // Act + _ticketFilterApplier.ApplyFilters(filters); + + // Assert + _mockTicketFilter.Verify(tf => tf.FilterTicketsForResell(), Times.Once); + _mockTicketFilter.Verify(tf => tf.GetTickets(), Times.Once); + } + + [Fact] + public void ApplyFilters_WithNotForResellOnly_ShouldCallFilterTicketsNotForResell() + { + // Arrange + var filters = new TicketFiltersDto + ( + null, + ResellFilter.OnlyNotForResell, + null + ); + + // Act + _ticketFilterApplier.ApplyFilters(filters); + + // Assert + _mockTicketFilter.Verify(tf => tf.FilterTicketsNotForResell(), Times.Once); + _mockTicketFilter.Verify(tf => tf.GetTickets(), Times.Once); + } + + [Fact] + public void ApplyFilters_WithMultipleFilters_ShouldCallAllRelevantFilters() + { + // Arrange + var filters = new TicketFiltersDto + ( + UsageFilter.OnlyNotUsed, + ResellFilter.OnlyNotForResell, + "concert" + ); + + // Act + _ticketFilterApplier.ApplyFilters(filters); + + // Assert + _mockTicketFilter.Verify(tf => tf.FilterTicketsByEventName(filters.EventName!), Times.Once); + _mockTicketFilter.Verify(tf => tf.FilterUsedTickets(), Times.Never); + _mockTicketFilter.Verify(tf => tf.FilterTicketsForResell(), Times.Never); + _mockTicketFilter.Verify(tf => tf.FilterUnusedTickets(), Times.Once); + _mockTicketFilter.Verify(tf => tf.FilterTicketsNotForResell(), Times.Once); + _mockTicketFilter.Verify(tf => tf.GetTickets(), Times.Once); + } + + [Fact] + public void ApplyFilters_WithNoFilters_ShouldOnlyCallGetTickets() + { + // Arrange + var expectedResult = new List + { + new Ticket { NameOnTicket = "Test Ticket" } + }.AsQueryable(); + _mockTicketFilter.Setup(tf => tf.GetTickets()).Returns(expectedResult); + var filters = new TicketFiltersDto + ( + null, + null, + null + ); + + // Act + var result = _ticketFilterApplier.ApplyFilters(filters); + + // Assert + _mockTicketFilter.Verify(tf => tf.FilterTicketsByEventName(It.IsAny()), Times.Never); + _mockTicketFilter.Verify(tf => tf.FilterUsedTickets(), Times.Never); + _mockTicketFilter.Verify(tf => tf.FilterUnusedTickets(), Times.Never); + _mockTicketFilter.Verify(tf => tf.FilterTicketsForResell(), Times.Never); + _mockTicketFilter.Verify(tf => tf.FilterTicketsNotForResell(), Times.Never); + _mockTicketFilter.Verify(tf => tf.GetTickets(), Times.Once); + Assert.Same(expectedResult, result); + } + +} \ No newline at end of file diff --git a/TickAPI/TickAPI.Tests/Tickets/Filters/TicketFilterTests.cs b/TickAPI/TickAPI.Tests/Tickets/Filters/TicketFilterTests.cs new file mode 100644 index 0000000..10031b4 --- /dev/null +++ b/TickAPI/TickAPI.Tests/Tickets/Filters/TicketFilterTests.cs @@ -0,0 +1,201 @@ +using TickAPI.Customers.Models; +using TickAPI.Events.Models; +using TickAPI.Tickets.Filters; +using TickAPI.Tickets.Models; +using TickAPI.TicketTypes.Models; + +namespace TickAPI.Tests.Tickets.Filters; + +public class TicketFilterTests +{ + private static List GetTestTickets() => + [ + new Ticket + { + Id = Guid.NewGuid(), + Type = new TicketType + { + Price = 100, + Event = new Event + { + Name = "Concert A", + Description = "An amazing rock concert" + } + }, + Owner = new Customer { FirstName = "John", LastName = "Doe" }, + NameOnTicket = "John Doe", + Seats = "A12", + ForResell = true, + Used = false + }, + + new Ticket + { + Id = Guid.NewGuid(), + Type = new TicketType + { + Price = 50, + Event = new Event + { + Name = "Concert B", + Description = "Chill jazz night" + } + }, + Owner = new Customer { FirstName = "Jane", LastName = "Smith" }, + NameOnTicket = "Jane Smith", + Seats = "B5", + ForResell = false, + Used = true + }, + + new Ticket + { + Id = Guid.NewGuid(), + Type = new TicketType + { + Price = 200, + Event = new Event + { + Name = "Conference", + Description = "Tech event for developers" + } + }, + Owner = new Customer { FirstName = "Mike", LastName = "Johnson" }, + NameOnTicket = "Mike Johnson", + Seats = "C8", + ForResell = false, + Used = false + } + ]; + + [Fact] + public void FilterUsedTickets_ShouldReturnOnlyUsedTickets() + { + // Arrange + var tickets = GetTestTickets(); + var sut = new TicketFilter(tickets.AsQueryable()); + + //Act + sut.FilterUsedTickets(); + var result = sut.GetTickets().ToList(); + + // Assert + Assert.Single(result); + Assert.Contains(tickets[1], result); + } + + [Fact] + public void FilterUnusedTickets_ShouldReturnOnlyUnusedTickets() + { + // Arrange + var tickets = GetTestTickets(); + var sut = new TicketFilter(tickets.AsQueryable()); + + // Act + sut.FilterUnusedTickets(); + var result = sut.GetTickets().ToList(); + + // Assert + Assert.Equal(2, result.Count); + Assert.Contains(tickets[0], result); + Assert.Contains(tickets[2], result); + } + + [Fact] + public void FilterTicketsForResell_ShouldReturnOnlyTicketsForResell() + { + // Arrange + var tickets = GetTestTickets(); + var sut = new TicketFilter(tickets.AsQueryable()); + + //Act + sut.FilterTicketsForResell(); + var result = sut.GetTickets().ToList(); + + // Assert + Assert.Single(result); + Assert.Contains(tickets[0], result); + } + + [Fact] + public void FilterTicketsNotForResell_ShouldReturnOnlyTicketsNotForResell() + { + // Arrange + var tickets = GetTestTickets(); + var sut = new TicketFilter(tickets.AsQueryable()); + + // Act + sut.FilterTicketsNotForResell(); + var result = sut.GetTickets().ToList(); + + // Assert + Assert.Equal(2, result.Count); + Assert.Contains(tickets[1], result); + Assert.Contains(tickets[2], result); + } + + [Fact] + public void FilterTicketsByEventName_ShouldReturnTicketsWithMatchingEventName() + { + // Arrange + var tickets = GetTestTickets(); + + // Act + var ticketFilter = new TicketFilter(tickets.AsQueryable()); + ticketFilter.FilterTicketsByEventName("concert"); + var result = ticketFilter.GetTickets().ToList(); + + // Assert + Assert.Equal(2, result.Count); + Assert.Contains(tickets[0], result); + Assert.Contains(tickets[1], result); + } + + [Fact] + public void FilterTicketsByEventName_CaseInsensitive_ShouldReturnMatchingTickets() + { + // Arrange + var tickets = GetTestTickets(); + + // Act + var ticketFilter = new TicketFilter(tickets.AsQueryable()); + ticketFilter.FilterTicketsByEventName("cONcErt a"); + var result = ticketFilter.GetTickets().ToList(); + + // Assert + Assert.Single(result); + Assert.Contains(tickets[0], result); + } + + [Fact] + public void FilterTicketsByEventName_WithNoMatches_ShouldReturnEmptyList() + { + // Arrange + var tickets = GetTestTickets(); + + // Act + var ticketFilter = new TicketFilter(tickets.AsQueryable()); + ticketFilter.FilterTicketsByEventName("nonexistent event"); + var result = ticketFilter.GetTickets().ToList(); + + // Assert + Assert.Empty(result); + } + + [Fact] + public void GetTickets_WithNoFilters_ShouldReturnAllTickets() + { + // Arrange + var tickets = GetTestTickets(); + + // Act + var ticketFilter = new TicketFilter(tickets.AsQueryable()); + var result = ticketFilter.GetTickets().ToList(); + + // Assert + Assert.Equal(3, result.Count); + Assert.Contains(tickets[0], result); + Assert.Contains(tickets[1], result); + Assert.Contains(tickets[2], result); + } +} \ No newline at end of file diff --git a/TickAPI/TickAPI.Tests/Tickets/Services/TicketServiceTests.cs b/TickAPI/TickAPI.Tests/Tickets/Services/TicketServiceTests.cs index 068a29f..de24569 100644 --- a/TickAPI/TickAPI.Tests/Tickets/Services/TicketServiceTests.cs +++ b/TickAPI/TickAPI.Tests/Tickets/Services/TicketServiceTests.cs @@ -1,9 +1,21 @@ using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using Moq; +using TickAPI.Addresses.Models; +using TickAPI.Common.Pagination.Abstractions; +using TickAPI.Common.Pagination.Responses; +using TickAPI.Common.QR.Abstractions; +using TickAPI.Common.Results; using TickAPI.Common.Results.Generic; +using TickAPI.Customers.Models; +using TickAPI.Events.Models; +using TickAPI.Organizers.Models; +using TickAPI.ShoppingCarts.Abstractions; using TickAPI.Tickets.Abstractions; +using TickAPI.Tickets.DTOs.Response; using TickAPI.Tickets.Models; using TickAPI.Tickets.Services; +using TickAPI.TicketTypes.Abstractions; using TickAPI.TicketTypes.Models; namespace TickAPI.Tests.Tickets.Services; @@ -11,22 +23,31 @@ namespace TickAPI.Tests.Tickets.Services; public class TicketServiceTests { [Fact] - public void GetNumberOfAvailableTicketsByType_AmountsAreCorrect_ShouldReturnCorrectNumberOfTickets() + public async Task GetNumberOfAvailableTicketsByType_AmountsAreCorrect_ShouldReturnCorrectNumberOfTickets() { // Arrange var type = new TicketType { MaxCount = 30 }; var ticketList = new List(new Ticket[10]); - Mock ticketRepositoryMock = new Mock(); + var ticketRepositoryMock = new Mock(); + var ticketTypeRepositoryMock = new Mock(); + var shoppingCartRepositoryMock = new Mock(); + var paginationServiceMock = new Mock(); + var qrServiceMock = new Mock(); ticketRepositoryMock .Setup(m => m.GetAllTicketsByTicketType(type)) .Returns(ticketList.AsQueryable()); - var sut = new TicketService(ticketRepositoryMock.Object); + shoppingCartRepositoryMock + .Setup(s => s.GetAmountOfTicketTypeAsync(type.Id)) + .ReturnsAsync(Result.Success(0)); + + var sut = new TicketService(ticketRepositoryMock.Object, ticketTypeRepositoryMock.Object, + shoppingCartRepositoryMock.Object, paginationServiceMock.Object, qrServiceMock.Object); // Act - var result = sut.GetNumberOfAvailableTicketsByType(type); + var result = await sut.GetNumberOfAvailableTicketsByTypeAsync(type); // Assert Assert.True(result.IsSuccess); @@ -34,26 +55,704 @@ public void GetNumberOfAvailableTicketsByType_AmountsAreCorrect_ShouldReturnCorr } [Fact] - public void GetNumberOfAvailableTicketsByType_WhenMoreTicketExistThanMaxCount_ShouldReturnError() + public async Task GetNumberOfAvailableTicketsByType_WhenMoreTicketExistThanMaxCount_ShouldReturnError() { // Arrange var type = new TicketType { MaxCount = 30 }; var ticketList = new List(new Ticket[50]); - Mock ticketRepositoryMock = new Mock(); + var ticketRepositoryMock = new Mock(); + var ticketTypeRepositoryMock = new Mock(); + var shoppingCartRepositoryMock = new Mock(); + var paginationServiceMock = new Mock(); + var qrServiceMock = new Mock(); ticketRepositoryMock .Setup(m => m.GetAllTicketsByTicketType(type)) .Returns(ticketList.AsQueryable()); + + shoppingCartRepositoryMock + .Setup(s => s.GetAmountOfTicketTypeAsync(type.Id)) + .ReturnsAsync(Result.Success(0)); - var sut = new TicketService(ticketRepositoryMock.Object); + var sut = new TicketService(ticketRepositoryMock.Object, ticketTypeRepositoryMock.Object, + shoppingCartRepositoryMock.Object, paginationServiceMock.Object, qrServiceMock.Object); // Act - var result = sut.GetNumberOfAvailableTicketsByType(type); + var result = await sut.GetNumberOfAvailableTicketsByTypeAsync(type); // Assert Assert.True(result.IsError); Assert.Equal(StatusCodes.Status500InternalServerError, result.StatusCode); Assert.Equal("The number of available tickets is negative.", result.ErrorMsg); } + + [Fact] + public async Task GetTicketsForResellAsync_WhenDataIsValid_ShouldReturnSuccess() + { + // Arrange + var eventId = Guid.NewGuid(); + int pageSize = 10; + int page = 0; + + var ticket1 = new Ticket + { + Id = Guid.NewGuid(), + ForResell = true, + Type = new TicketType + { + Price = 50m, + Currency = "USD", + Description = "VIP" + }, + Seats = "A1" + }; + + var ticket2 = new Ticket + { + Id = Guid.NewGuid(), + ForResell = true, + Type = new TicketType + { + Price = 30m, + Currency = "USD", + Description = "Standard" + }, + Seats = "B2" + }; + + var allTickets = new List { ticket1, ticket2 }.AsQueryable(); + + var ticketRepositoryMock = new Mock(); + ticketRepositoryMock.Setup(repo => repo.GetTicketsByEventId(eventId)) + .Returns(allTickets); + + var ticketTypeRepositoryMock = new Mock(); + var shoppingCartRepositoryMock = new Mock(); + + var paginatedTickets = new PaginatedData( + new List { ticket1, ticket2 }, + page, + pageSize, + false, + false, + new PaginationDetails(0, 2) + ); + + var paginationServiceMock = new Mock(); + paginationServiceMock.Setup(p => p.PaginateAsync(It.IsAny>(), pageSize, page)) + .ReturnsAsync(Result>.Success(paginatedTickets)); + + var mappedDto1 = new GetTicketForResellResponseDto( + ticket1.Id, + ticket1.Type.Price, + ticket1.Type.Currency, + ticket1.Type.Description, + ticket1.Seats + ); + + var mappedDto2 = new GetTicketForResellResponseDto( + ticket2.Id, + ticket2.Type.Price, + ticket2.Type.Currency, + ticket2.Type.Description, + ticket2.Seats + ); + + var mappedData = new PaginatedData( + new List { mappedDto1, mappedDto2 }, + page, + pageSize, + false, + false, + new PaginationDetails(0, 2) + ); + + paginationServiceMock.Setup(p => p.MapData( + paginatedTickets, + It.IsAny>())) + .Returns(mappedData); + + var qrServiceMock = new Mock(); + + var sut = new TicketService(ticketRepositoryMock.Object, ticketTypeRepositoryMock.Object, + shoppingCartRepositoryMock.Object, paginationServiceMock.Object, qrServiceMock.Object); + + // Act + var result = await sut.GetTicketsForResellAsync(eventId, page, pageSize); + + // Assert + Assert.True(result.IsSuccess); + Assert.Equal(2, result.Value!.Data.Count); + Assert.Equal(mappedDto1, result.Value!.Data[0]); + Assert.Equal(mappedDto2, result.Value!.Data[1]); + } + + [Fact] + public async Task GetTicketsForResellAsync_WhenNoTicketsForResell_ShouldReturnEmptyList() + { + // Arrange + var eventId = Guid.NewGuid(); + int pageSize = 10; + int page = 0; + + var tickets = new List + { + new Ticket + { + Id = Guid.NewGuid(), + ForResell = false, + Type = new TicketType + { + Price = 50m, + Currency = "USD", + Description = "VIP" + } + }, + new Ticket + { + Id = Guid.NewGuid(), + ForResell = false, + Type = new TicketType + { + Price = 30m, + Currency = "USD", + Description = "Standard" + } + } + }.AsQueryable(); + + var ticketRepositoryMock = new Mock(); + ticketRepositoryMock.Setup(repo => repo.GetTicketsByEventId(eventId)) + .Returns(tickets); + + var ticketTypeRepositoryMock = new Mock(); + var shoppingCartRepositoryMock = new Mock(); + + var paginatedData = new PaginatedData( + new List(), + page, + pageSize, + false, + false, + new PaginationDetails(0, 0) + ); + + var paginationServiceMock = new Mock(); + paginationServiceMock.Setup(p => p.PaginateAsync(It.IsAny>(), pageSize, page)) + .ReturnsAsync(Result>.Success(paginatedData)); + + var mappedData = new PaginatedData( + new List(), + page, + pageSize, + false, + false, + new PaginationDetails(0, 0) + ); + + paginationServiceMock.Setup(p => p.MapData( + paginatedData, + It.IsAny>())) + .Returns(mappedData); + var qrServiceMock = new Mock(); + + var sut = new TicketService(ticketRepositoryMock.Object, ticketTypeRepositoryMock.Object, + shoppingCartRepositoryMock.Object, paginationServiceMock.Object, qrServiceMock.Object); + + // Act + var result = await sut.GetTicketsForResellAsync(eventId, page, pageSize); + + // Assert + Assert.True(result.IsSuccess); + Assert.Empty(result.Value!.Data); + } + + [Fact] + public async Task GetTicketsForResellAsync_WhenPaginationFails_ShouldPropagateError() + { + // Arrange + var eventId = Guid.NewGuid(); + int pageSize = 10; + int page = 0; + const string errorMsg = "Invalid pagination parameters"; + const int statusCode = 400; + + var tickets = new List + { + new Ticket + { + Id = Guid.NewGuid(), + ForResell = true, + Type = new TicketType + { + Price = 50m, + Currency = "USD", + Description = "VIP" + } + } + }.AsQueryable(); + + var ticketRepositoryMock = new Mock(); + ticketRepositoryMock.Setup(repo => repo.GetTicketsByEventId(eventId)) + .Returns(tickets); + + var ticketTypeRepositoryMock = new Mock(); + var shoppingCartRepositoryMock = new Mock(); + + var paginationServiceMock = new Mock(); + paginationServiceMock.Setup(p => p.PaginateAsync(It.IsAny>(), pageSize, page)) + .ReturnsAsync(Result>.Failure(statusCode, errorMsg)); + + var qrServiceMock = new Mock(); + + var sut = new TicketService(ticketRepositoryMock.Object, ticketTypeRepositoryMock.Object, + shoppingCartRepositoryMock.Object, paginationServiceMock.Object, qrServiceMock.Object); + + // Act + var result = await sut.GetTicketsForResellAsync(eventId, page, pageSize); + + // Assert + Assert.True(result.IsError); + Assert.Equal(errorMsg, result.ErrorMsg); + Assert.Equal(statusCode, result.StatusCode); + } + + [Fact] + public async Task GetTicketsForResellAsync_WhenNoTicketsForEvent_ShouldReturnEmptyList() + { + // Arrange + var eventId = Guid.NewGuid(); + int pageSize = 10; + int page = 0; + + var tickets = new List().AsQueryable(); + + var ticketRepositoryMock = new Mock(); + ticketRepositoryMock.Setup(repo => repo.GetTicketsByEventId(eventId)) + .Returns(tickets); + + var ticketTypeRepositoryMock = new Mock(); + var shoppingCartRepositoryMock = new Mock(); + + var paginatedData = new PaginatedData( + new List(), + page, + pageSize, + false, + false, + new PaginationDetails(0, 0) + ); + + var paginationServiceMock = new Mock(); + paginationServiceMock.Setup(p => p.PaginateAsync(It.IsAny>(), pageSize, page)) + .ReturnsAsync(Result>.Success(paginatedData)); + + var mappedData = new PaginatedData( + new List(), + page, + pageSize, + false, + false, + new PaginationDetails(0, 0) + ); + + paginationServiceMock.Setup(p => p.MapData( + paginatedData, + It.IsAny>())) + .Returns(mappedData); + var qrServiceMock = new Mock(); + + var sut = new TicketService(ticketRepositoryMock.Object, ticketTypeRepositoryMock.Object, + shoppingCartRepositoryMock.Object, paginationServiceMock.Object, qrServiceMock.Object); + + // Act + var result = await sut.GetTicketsForResellAsync(eventId, page, pageSize); + + // Assert + Assert.True(result.IsSuccess); + Assert.Empty(result.Value!.Data); + } + + [Fact] + public async Task GetTicketDetailsAsync_WhenTicketExistsForTheUser_ShouldReturnTicketDetails() + { + + // Arrange + var eventGuid = Guid.NewGuid(); + var ticket = new Ticket + { + Id = Guid.NewGuid(), + ForResell = false, + NameOnTicket = "NameOnTicket", + Seats = null, + Type = new TicketType + { + Id = eventGuid, + Currency = "USD", + Price = 20, + Event = new Event + { + Name = "EventName", + StartDate = new DateTime(2025, 10, 10), + EndDate = new DateTime(2025, 10, 20), + Organizer = new Organizer + { + DisplayName = "organizerName", + }, + Address = new Address + { + City = "Warsaw", + Country = "Poland", + PostalCode = "12345", + FlatNumber = null, + HouseNumber = null, + Street = "Street", + } + } + }, + }; + const string email = "123@123.com"; + const string scanurl = "http://localhost"; + var ticketRepositoryMock = new Mock(); + var ticketTypeRepositoryMock = new Mock(); + var shoppingCartRepositoryMock = new Mock(); + + var paginationServiceMock = new Mock(); + + ticketRepositoryMock.Setup(m => m.GetTicketWithDetailsByIdAndEmailAsync(ticket.Id, email)) + .ReturnsAsync(Result.Success(ticket)); + + var qrServiceMock = new Mock(); + qrServiceMock.Setup(m => m.GenerateQrCode(scanurl)).Returns([]); + + var sut = new TicketService(ticketRepositoryMock.Object, ticketTypeRepositoryMock.Object, + shoppingCartRepositoryMock.Object, paginationServiceMock.Object, qrServiceMock.Object); + + // Act + + var res = await sut.GetTicketDetailsAsync(ticket.Id, email, scanurl); + + // Assert + + Assert.True(res.IsSuccess); + var details = res.Value; + Assert.NotNull(details); + + Assert.Equal(ticket.NameOnTicket, details.NameOnTicket); + Assert.Equal(ticket.Seats, details.Seats); + Assert.Equal(ticket.Type.Currency, details.Currency); + Assert.Equal(ticket.Type.Price, details.Price); + Assert.Equal(ticket.Type.Event.StartDate, details.StartDate); + Assert.Equal(ticket.Type.Event.EndDate, details.EndDate); + Assert.Equal(ticket.Type.Event.Organizer.DisplayName, details.OrganizerName); + Assert.Equal(ticket.Type.Event.Address.Street, details.Address.Street); + Assert.Equal(ticket.Type.Event.Address.HouseNumber, details.Address.HouseNumber); + Assert.Equal(ticket.Type.Event.Address.FlatNumber, details.Address.FlatNumber); + Assert.Equal(ticket.Type.Event.Address.PostalCode, details.Address.PostalCode); + Assert.Equal(ticket.Type.Event.Address.City, details.Address.City); + Assert.Equal(ticket.Type.Event.Address.Country, details.Address.Country); + + } + + [Fact] + public async Task GetTicketDetailsAsync_WhenTicketDoesNotExistForTheUser_ShouldReturnError() + { + + // Arrange + + var ticketId = Guid.NewGuid(); + const string email = "123@123.com"; + const string scanUrl = "http://localhost"; + + var ticketRepositoryMock = new Mock(); + ticketRepositoryMock.Setup(m => m.GetTicketWithDetailsByIdAndEmailAsync(ticketId, email)). + ReturnsAsync(Result.Failure(StatusCodes.Status404NotFound, "Ticket with this id doesn't exist " + + "for this user")); + var ticketTypeRepositoryMock = new Mock(); + var shoppingCartRepositoryMock = new Mock(); + var paginationServiceMock = new Mock(); + var qrServiceMock = new Mock(); + + var sut = new TicketService(ticketRepositoryMock.Object, ticketTypeRepositoryMock.Object, + shoppingCartRepositoryMock.Object, paginationServiceMock.Object, qrServiceMock.Object); + + // Act + + var res = await sut.GetTicketDetailsAsync(ticketId, email, scanUrl); + + // Assert + + Assert.False(res.IsSuccess); + Assert.Equal(StatusCodes.Status404NotFound, res.StatusCode); + Assert.Equal("Ticket with this id doesn't exist for this user", res.ErrorMsg); + } + + [Fact] + public async Task GetTicketsForCustomerAsync_WithValidInput_ReturnsSuccessResult() + { + // Arrange + var email = "test@example.com"; + var page = 0; + var pageSize = 10; + + var tickets = new List + { + new Ticket + { + Id = new Guid(), + Used = false, + Type = new TicketType + { + Event = new Event + { + Name = "EventName", + StartDate = new DateTime(2025, 10, 10), + EndDate = new DateTime(2025, 10, 20), + } + } + }, + new Ticket + { + Id = new Guid(), + Used = false, + Type = new TicketType + { + Event = new Event + { + Name = "EventName2", + StartDate = new DateTime(2025, 11, 10), + EndDate = new DateTime(2025, 11, 20), + } + } + } + }; + + var paginatedData = new PaginatedData + ( + tickets, + page, + pageSize, + false, + false, + new PaginationDetails(0, 2) + ); + var mappedData1 = new GetTicketForCustomerDto(tickets[0].Id, "EventName", new DateTime(2025, 10, 10), new DateTime(2025, 10, 20), false); + var mappedData2 = new GetTicketForCustomerDto(tickets[1].Id, "EventName2", new DateTime(2025, 11, 10), new DateTime(2025, 11, 20), false); + var mappedPaginatedData = new PaginatedData + ( + new List{mappedData1, mappedData2}, + page, + pageSize, + false, + false, + new PaginationDetails(0, 2) + ); + + var ticketRepositoryMock = new Mock(); + ticketRepositoryMock.Setup(r => r.GetTicketsByCustomerEmail(email)).Returns(tickets.AsQueryable()); + var ticketTypeRepositoryMock = new Mock(); + var shoppingCartRepositoryMock = new Mock(); + + var paginationServiceMock = new Mock(); + paginationServiceMock.Setup(p => p.PaginateAsync(tickets.AsQueryable(), pageSize, page)) + .ReturnsAsync(Result>.Success(paginatedData)); + + paginationServiceMock.Setup(p => p.MapData(paginatedData, It.IsAny>())) + .Returns(mappedPaginatedData); + + var qrServiceMock = new Mock(); + + var sut = new TicketService(ticketRepositoryMock.Object, ticketTypeRepositoryMock.Object, + shoppingCartRepositoryMock.Object, paginationServiceMock.Object, qrServiceMock.Object); + + // Act + var result = await sut.GetTicketsForCustomerAsync(email, page, pageSize); + + // Assert + Assert.True(result.IsSuccess); + Assert.Equal(mappedPaginatedData, result.Value); + Assert.Equal(mappedData1, result.Value!.Data[0]); + Assert.Equal(mappedData2, result.Value!.Data[1]); + } + + [Fact] + public async Task GetTicketsForCustomerAsync_WhenUserHasNoTickets_ReturnsEmptyPagination() + { + // Arrange + var email = "empty@example.com"; + var page = 0; + var pageSize = 10; + + var emptyTickets = new List(); + + var emptyPaginatedData = new PaginatedData(emptyTickets, page, pageSize, + false, false, new PaginationDetails(0, 0)); + + var paginatedResult = Result>.Success(emptyPaginatedData); + + var mappedEmptyPaginatedData = new PaginatedData(new List(), + page, pageSize, false, false, new PaginationDetails(0, 0)); + + var ticketRepositoryMock = new Mock(); + ticketRepositoryMock.Setup(r => r.GetTicketsByCustomerEmail(email)).Returns(emptyTickets.AsQueryable()); + + var ticketTypeRepositoryMock = new Mock(); + var shoppingCartRepositoryMock = new Mock(); + + var paginationServiceMock = new Mock(); + paginationServiceMock.Setup(p => p.PaginateAsync(emptyTickets.AsQueryable(), pageSize, page)).ReturnsAsync(paginatedResult); + paginationServiceMock.Setup(p => p.MapData(emptyPaginatedData, It.IsAny>())) + .Returns(mappedEmptyPaginatedData); + + var qrServiceMock = new Mock(); + + var sut = new TicketService(ticketRepositoryMock.Object, ticketTypeRepositoryMock.Object, + shoppingCartRepositoryMock.Object, paginationServiceMock.Object, qrServiceMock.Object); + + // Act + var result = await sut.GetTicketsForCustomerAsync(email, page, pageSize); + + // Assert + Assert.True(result.IsSuccess); + Assert.Empty(result.Value!.Data); + } + + [Fact] + public async Task ScanTicket_WhenScanningSuccesful_ShouldReturnSuccess() + { + // Arrange + var guid = Guid.NewGuid(); + var ticketRepositoryMock = new Mock(); + ticketRepositoryMock.Setup(m => m.MarkTicketAsUsed(guid)).ReturnsAsync(Result.Success()); + var ticketTypeRepositoryMock = new Mock(); + var shoppingCartRepositoryMock = new Mock(); + var paginationServiceMock = new Mock(); + var qrServiceMock = new Mock(); + var sut = new TicketService(ticketRepositoryMock.Object, ticketTypeRepositoryMock.Object, + shoppingCartRepositoryMock.Object, paginationServiceMock.Object, qrServiceMock.Object); + + // Act + var res = await sut.ScanTicket(guid); + + // Assert + Assert.True(res.IsSuccess); + } + + [Fact] + public async Task SetTicketForResellAsync_ReturnsFailure_WhenPriceIsZero() + { + var ticketRepositoryMock = new Mock(); + var paginationServiceMock = new Mock(); + var qrServiceMock = new Mock(); + var ticketTypeRepositoryMock = new Mock(); + var shoppingCartRepositoryMock = new Mock(); + + var sut = new TicketService(ticketRepositoryMock.Object, ticketTypeRepositoryMock.Object, + shoppingCartRepositoryMock.Object, paginationServiceMock.Object, qrServiceMock.Object); + + var result = await sut.SetTicketForResellAsync(Guid.NewGuid(), "test@example.com", 0, "zl"); + + Assert.True(result.IsError); + Assert.Equal(500, result.StatusCode); + } + + [Fact] + public async Task SetTicketForResellAsync_ReturnsFailure_WhenPriceTooHigh() + { + var ticket = CreateTicketForResellTest(price: 100); + var ticketRepositoryMock = new Mock(); + var paginationServiceMock = new Mock(); + var qrServiceMock = new Mock(); + ticketRepositoryMock + .Setup(r => r.GetTicketWithDetailsByIdAndEmailAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(ticket)); + + var ticketTypeRepositoryMock = new Mock(); + var shoppingCartRepositoryMock = new Mock(); + var sut = new TicketService(ticketRepositoryMock.Object, ticketTypeRepositoryMock.Object, + shoppingCartRepositoryMock.Object, paginationServiceMock.Object, qrServiceMock.Object); + var result = await sut.SetTicketForResellAsync(Guid.NewGuid(), "test@example.com", 160, "zl"); + + Assert.True(result.IsError); + Assert.Equal(500, result.StatusCode); + } + + [Fact] + public async Task SetTicketForResellAsync_ReturnsFailure_WhenAlreadyForResell() + { + var ticket = CreateTicketForResellTest(price: 100, forResell: true); + var ticketRepositoryMock = new Mock(); + var paginationServiceMock = new Mock(); + var qrServiceMock = new Mock(); + ticketRepositoryMock + .Setup(r => r.GetTicketWithDetailsByIdAndEmailAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(ticket)); + var ticketTypeRepositoryMock = new Mock(); + var shoppingCartRepositoryMock = new Mock(); + var sut = new TicketService(ticketRepositoryMock.Object, ticketTypeRepositoryMock.Object, + shoppingCartRepositoryMock.Object, paginationServiceMock.Object, qrServiceMock.Object); + var result = await sut.SetTicketForResellAsync(Guid.NewGuid(), "test@example.com", 120, "zl"); + + Assert.True(result.IsError); + Assert.Equal(500, result.StatusCode); + } + + [Fact] + public async Task SetTicketForResellAsync_ReturnsFailure_WhenTicketIsUsed() + { + var ticket = CreateTicketForResellTest(price: 100, used: true); + var ticketRepositoryMock = new Mock(); + var paginationServiceMock = new Mock(); + var qrServiceMock = new Mock(); + + ticketRepositoryMock + .Setup(r => r.GetTicketWithDetailsByIdAndEmailAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(ticket)); + var ticketTypeRepositoryMock = new Mock(); + var shoppingCartRepositoryMock = new Mock(); + var sut = new TicketService(ticketRepositoryMock.Object, ticketTypeRepositoryMock.Object, + shoppingCartRepositoryMock.Object, paginationServiceMock.Object, qrServiceMock.Object); + + var result = await sut.SetTicketForResellAsync(Guid.NewGuid(), "test@example.com", 120, "zl"); + + Assert.True(result.IsError); + Assert.Equal(500, result.StatusCode); + } + + [Fact] + public async Task SetTicketForResellAsync_ReturnsSuccess_WhenValid() + { + var ticketRepositoryMock = new Mock(); + var paginationServiceMock = new Mock(); + var qrServiceMock = new Mock(); + var ticket = CreateTicketForResellTest(price: 100); + ticketRepositoryMock + .Setup(r => r.GetTicketWithDetailsByIdAndEmailAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(ticket)); + + ticketRepositoryMock + .Setup(r => r.SetTicketForResell(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success()); + + var ticketTypeRepositoryMock = new Mock(); + var shoppingCartRepositoryMock = new Mock(); + var sut = new TicketService(ticketRepositoryMock.Object, ticketTypeRepositoryMock.Object, + shoppingCartRepositoryMock.Object, paginationServiceMock.Object, qrServiceMock.Object); + + var result = await sut.SetTicketForResellAsync(Guid.NewGuid(), "test@example.com", 130, "zl"); + + Assert.False(result.IsError); + } + + private Ticket CreateTicketForResellTest(decimal price, bool forResell = false, bool used = false) + { + return new Ticket + { + Id = Guid.NewGuid(), + Type = new TicketType { Price = price }, + ForResell = forResell, + Used = used + }; + } } \ No newline at end of file diff --git a/TickAPI/TickAPI/Addresses/DTOs/Request/CreateAddressDto.cs b/TickAPI/TickAPI/Addresses/DTOs/Request/CreateAddressDto.cs index 8aa9f4c..c79aecd 100644 --- a/TickAPI/TickAPI/Addresses/DTOs/Request/CreateAddressDto.cs +++ b/TickAPI/TickAPI/Addresses/DTOs/Request/CreateAddressDto.cs @@ -1,12 +1,10 @@ -using TickAPI.Events.Models; - -namespace TickAPI.Addresses.DTOs.Request; +namespace TickAPI.Addresses.DTOs.Request; public record CreateAddressDto( - string Country, string City, string? Street, uint? HouseNumber, uint? FlatNumber, - string PostalCode); \ No newline at end of file + string PostalCode +); \ No newline at end of file diff --git a/TickAPI/TickAPI/Admins/Controllers/AdminsController.cs b/TickAPI/TickAPI/Admins/Controllers/AdminsController.cs index e6d70c2..803991e 100644 --- a/TickAPI/TickAPI/Admins/Controllers/AdminsController.cs +++ b/TickAPI/TickAPI/Admins/Controllers/AdminsController.cs @@ -31,20 +31,20 @@ public AdminsController(IGoogleAuthService googleAuthService, IJwtService jwtSer public async Task> GoogleLogin([FromBody] GoogleAdminLoginDto request) { var userDataResult = await _googleAuthService.GetUserDataFromAccessToken(request.AccessToken); - if(userDataResult.IsError) - return StatusCode(userDataResult.StatusCode, userDataResult.ErrorMsg); + if (userDataResult.IsError) + return userDataResult.ToObjectResult(); var userData = userDataResult.Value!; var adminResult = await _adminService.GetAdminByEmailAsync(userData.Email); if (adminResult.IsError) { - return StatusCode(adminResult.StatusCode, adminResult.ErrorMsg); + return adminResult.ToObjectResult(); } var jwtTokenResult = _jwtService.GenerateJwtToken(userData.Email, UserRole.Admin); if (jwtTokenResult.IsError) - return StatusCode(jwtTokenResult.StatusCode, jwtTokenResult.ErrorMsg); + return jwtTokenResult.ToObjectResult(); return new ActionResult(new GoogleAdminLoginResponseDto(jwtTokenResult.Value!)); } @@ -56,7 +56,7 @@ public async Task> AboutMe() var emailResult = _claimsService.GetEmailFromClaims(User.Claims); if (emailResult.IsError) { - return StatusCode(emailResult.StatusCode, emailResult.ErrorMsg); + return emailResult.ToObjectResult(); } var email = emailResult.Value!; @@ -66,8 +66,7 @@ public async Task> AboutMe() "cannot find user with admin privilages in database for authorized admin request"); var admin = adminResult.Value!; - var aboutMeResponse = - new AboutMeAdminResponseDto(admin.Email, admin.Login); + var aboutMeResponse = new AboutMeAdminResponseDto(admin.Email, admin.Login); return new ActionResult(aboutMeResponse); } } diff --git a/TickAPI/TickAPI/Categories/Controllers/CategoriesController.cs b/TickAPI/TickAPI/Categories/Controllers/CategoriesController.cs index 684f0bc..22ad3a4 100644 --- a/TickAPI/TickAPI/Categories/Controllers/CategoriesController.cs +++ b/TickAPI/TickAPI/Categories/Controllers/CategoriesController.cs @@ -24,21 +24,17 @@ public CategoriesController(ICategoryService categoryService) public async Task>> GetCategories([FromQuery] int pageSize, [FromQuery] int page) { var res = await _categoryService.GetCategoriesResponsesAsync(pageSize, page); - if (res.IsError) - { - return StatusCode(StatusCodes.Status500InternalServerError, res.ErrorMsg); - } - return Ok(res.Value); + return res.ToObjectResult(); } - // TODO: Add appropriate policy verification (admin, maybe also organizer?) + [AuthorizeWithPolicy(AuthPolicies.AdminPolicy)] [HttpPost] public async Task CreateCategory([FromBody] CreateCategoryDto request) { var newCategoryResult = await _categoryService.CreateNewCategoryAsync(request.Name); - if(newCategoryResult.IsError) - return StatusCode(newCategoryResult.StatusCode, newCategoryResult.ErrorMsg); + if (newCategoryResult.IsError) + return newCategoryResult.ToObjectResult(); return Ok("category created successfully"); } diff --git a/TickAPI/TickAPI/Categories/DTOs/Request/EditEventCategoryDto.cs b/TickAPI/TickAPI/Categories/DTOs/Request/EditEventCategoryDto.cs new file mode 100644 index 0000000..ae2d123 --- /dev/null +++ b/TickAPI/TickAPI/Categories/DTOs/Request/EditEventCategoryDto.cs @@ -0,0 +1,5 @@ +namespace TickAPI.Categories.DTOs.Request; + +public record EditEventCategoryDto( + string CategoryName +); diff --git a/TickAPI/TickAPI/Common/Mail/Services/MailService.cs b/TickAPI/TickAPI/Common/Mail/Services/MailService.cs index 1e5a1c9..24456bb 100644 --- a/TickAPI/TickAPI/Common/Mail/Services/MailService.cs +++ b/TickAPI/TickAPI/Common/Mail/Services/MailService.cs @@ -1,5 +1,4 @@ -using System.Net; -using SendGrid; +using SendGrid; using SendGrid.Helpers.Mail; using TickAPI.Common.Mail.Abstractions; using TickAPI.Common.Mail.Models; @@ -9,8 +8,8 @@ namespace TickAPI.Common.Mail.Services; public class MailService : IMailService { - private SendGridClient _client; - private EmailAddress _fromEmailAddress; + private readonly SendGridClient _client; + private readonly EmailAddress _fromEmailAddress; public MailService(IConfiguration configuration) { diff --git a/TickAPI/TickAPI/Common/Payment/Abstractions/IPaymentGatewayService.cs b/TickAPI/TickAPI/Common/Payment/Abstractions/IPaymentGatewayService.cs new file mode 100644 index 0000000..b9ca162 --- /dev/null +++ b/TickAPI/TickAPI/Common/Payment/Abstractions/IPaymentGatewayService.cs @@ -0,0 +1,10 @@ +using TickAPI.Common.Payment.Models; +using TickAPI.Common.Results.Generic; + +namespace TickAPI.Common.Payment.Abstractions; + +public interface IPaymentGatewayService +{ + Task HealthCheck(); + Task> ProcessPayment(PaymentRequestPG request); +} diff --git a/TickAPI/TickAPI/Common/Payment/Extensions/PaymentGatewayHealthStatusExtensions.cs b/TickAPI/TickAPI/Common/Payment/Extensions/PaymentGatewayHealthStatusExtensions.cs new file mode 100644 index 0000000..629d4e0 --- /dev/null +++ b/TickAPI/TickAPI/Common/Payment/Extensions/PaymentGatewayHealthStatusExtensions.cs @@ -0,0 +1,11 @@ +using TickAPI.Common.Payment.Models; + +namespace TickAPI.Common.Payment.Extensions; + +public static class PaymentGatewayHealthStatusExtensions +{ + public static bool IsHealthy(this PaymentGatewayHealthStatus response) + { + return response.Status.Equals("ok", StringComparison.CurrentCultureIgnoreCase); + } +} diff --git a/TickAPI/TickAPI/Common/Payment/Health/PaymentGatewayHealthCheck.cs b/TickAPI/TickAPI/Common/Payment/Health/PaymentGatewayHealthCheck.cs new file mode 100644 index 0000000..858a9d3 --- /dev/null +++ b/TickAPI/TickAPI/Common/Payment/Health/PaymentGatewayHealthCheck.cs @@ -0,0 +1,25 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; +using TickAPI.Common.Payment.Abstractions; +using TickAPI.Common.Payment.Extensions; + +namespace TickAPI.Common.Payment.Health; + +public class PaymentGatewayHealthCheck : IHealthCheck +{ + private readonly IPaymentGatewayService _paymentGateway; + + public PaymentGatewayHealthCheck(IPaymentGatewayService paymentGateway) + { + _paymentGateway = paymentGateway; + } + + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = new CancellationToken()) + { + var status = await _paymentGateway.HealthCheck(); + if (status.IsHealthy()) + { + return HealthCheckResult.Healthy("Payment gateway is reachable."); + } + return HealthCheckResult.Unhealthy("Payment gateway is not reachable."); + } +} diff --git a/TickAPI/TickAPI/Common/Payment/Models/PaymentErrorResponsePG.cs b/TickAPI/TickAPI/Common/Payment/Models/PaymentErrorResponsePG.cs new file mode 100644 index 0000000..74e1618 --- /dev/null +++ b/TickAPI/TickAPI/Common/Payment/Models/PaymentErrorResponsePG.cs @@ -0,0 +1,7 @@ +using System.Text.Json.Serialization; + +namespace TickAPI.Common.Payment.Models; + +public record PaymentErrorResponsePG( + [property: JsonPropertyName("error")] string Error +); diff --git a/TickAPI/TickAPI/Common/Payment/Models/PaymentGatewayHealthStatus.cs b/TickAPI/TickAPI/Common/Payment/Models/PaymentGatewayHealthStatus.cs new file mode 100644 index 0000000..e4b0b7a --- /dev/null +++ b/TickAPI/TickAPI/Common/Payment/Models/PaymentGatewayHealthStatus.cs @@ -0,0 +1,7 @@ +using System.Text.Json.Serialization; + +namespace TickAPI.Common.Payment.Models; + +public record PaymentGatewayHealthStatus( + [property: JsonPropertyName("status")] string Status +); diff --git a/TickAPI/TickAPI/Common/Payment/Models/PaymentRequestPG.cs b/TickAPI/TickAPI/Common/Payment/Models/PaymentRequestPG.cs new file mode 100644 index 0000000..68511e8 --- /dev/null +++ b/TickAPI/TickAPI/Common/Payment/Models/PaymentRequestPG.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace TickAPI.Common.Payment.Models; + +public record PaymentRequestPG( + [property: JsonPropertyName("amount")] decimal Amount, + [property: JsonPropertyName("currency")] string Currency, + [property: JsonPropertyName("card_number")] string CardNumber, + [property: JsonPropertyName("card_expiry")] string CardExpiry, + [property: JsonPropertyName("cvv")] string CVV, + [property: JsonPropertyName("force_error")] bool ForceError +); \ No newline at end of file diff --git a/TickAPI/TickAPI/Common/Payment/Models/PaymentResponsePG.cs b/TickAPI/TickAPI/Common/Payment/Models/PaymentResponsePG.cs new file mode 100644 index 0000000..fac4e2a --- /dev/null +++ b/TickAPI/TickAPI/Common/Payment/Models/PaymentResponsePG.cs @@ -0,0 +1,8 @@ +using System.Text.Json.Serialization; + +namespace TickAPI.Common.Payment.Models; + +public record PaymentResponsePG( + [property: JsonPropertyName("transaction_id")] string TransactionId, + [property: JsonPropertyName("status")] string Status +); diff --git a/TickAPI/TickAPI/Common/Payment/Services/PaymentGatewayService.cs b/TickAPI/TickAPI/Common/Payment/Services/PaymentGatewayService.cs new file mode 100644 index 0000000..f30f958 --- /dev/null +++ b/TickAPI/TickAPI/Common/Payment/Services/PaymentGatewayService.cs @@ -0,0 +1,51 @@ +using System.Text; +using System.Text.Json; +using TickAPI.Common.Payment.Abstractions; +using TickAPI.Common.Payment.Models; +using TickAPI.Common.Results.Generic; + +namespace TickAPI.Common.Payment.Services; + +public class PaymentGatewayService : IPaymentGatewayService +{ + private readonly IConfiguration _configuration; + private readonly IHttpClientFactory _httpClientFactory; + + public PaymentGatewayService(IConfiguration configuration, IHttpClientFactory httpClientFactory) + { + _configuration = configuration; + _httpClientFactory = httpClientFactory; + } + + public async Task HealthCheck() + { + var client = _httpClientFactory.CreateClient(); + var baseUrl = _configuration["PaymentGateway:Url"]!; + var url = $"{baseUrl}/health"; + var response = await client.GetAsync(url); + var jsonResponse = await response.Content.ReadAsStringAsync(); + var status = JsonSerializer.Deserialize(jsonResponse, new JsonSerializerOptions()); + return status!; + } + + public async Task> ProcessPayment(PaymentRequestPG request) + { + var client = _httpClientFactory.CreateClient(); + var baseUrl = _configuration["PaymentGateway:Url"]!; + var url = $"{baseUrl}/payments"; + var json = JsonSerializer.Serialize(request); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await client.PostAsync(url, content); + var jsonResponse = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + var errorResponse = JsonSerializer.Deserialize(jsonResponse, new JsonSerializerOptions()); + return Result.Failure((int)response.StatusCode, errorResponse!.Error); + } + + var successResponse = JsonSerializer.Deserialize(jsonResponse, new JsonSerializerOptions()); + return Result.Success(successResponse!); + } +} diff --git a/TickAPI/TickAPI/Common/QR/Abstractions/IQRCodeService.cs b/TickAPI/TickAPI/Common/QR/Abstractions/IQRCodeService.cs new file mode 100644 index 0000000..94b2690 --- /dev/null +++ b/TickAPI/TickAPI/Common/QR/Abstractions/IQRCodeService.cs @@ -0,0 +1,6 @@ +namespace TickAPI.Common.QR.Abstractions; + +public interface IQRCodeService +{ + public byte[] GenerateQrCode(string url); +} \ No newline at end of file diff --git a/TickAPI/TickAPI/Common/QR/Services/QRCodeService.cs b/TickAPI/TickAPI/Common/QR/Services/QRCodeService.cs new file mode 100644 index 0000000..9d5f5c1 --- /dev/null +++ b/TickAPI/TickAPI/Common/QR/Services/QRCodeService.cs @@ -0,0 +1,16 @@ +using QRCoder; +using TickAPI.Common.QR.Abstractions; + +namespace TickAPI.Common.QR.Services; + +public class QRCodeService : IQRCodeService +{ + public byte[] GenerateQrCode(string url) + { + var qrGenerator = new QRCodeGenerator(); + var qrData = qrGenerator.CreateQrCode(url, QRCodeGenerator.ECCLevel.Q); + var qrCode = new PngByteQRCode(qrData); + var qrCodeImage = qrCode.GetGraphic(20); + return qrCodeImage; + } +} \ No newline at end of file diff --git a/TickAPI/TickAPI/Common/Redis/Abstractions/IRedisService.cs b/TickAPI/TickAPI/Common/Redis/Abstractions/IRedisService.cs index 194a7d5..2dd6674 100644 --- a/TickAPI/TickAPI/Common/Redis/Abstractions/IRedisService.cs +++ b/TickAPI/TickAPI/Common/Redis/Abstractions/IRedisService.cs @@ -13,4 +13,5 @@ public interface IRedisService public Task DecrementValueAsync(string key, long value = 1); public Task GetLongValueAsync(string key); public Task SetLongValueAsync(string key, long value, TimeSpan? expiry = null); + public Task> GetKeysByPatternAsync(string pattern); } \ No newline at end of file diff --git a/TickAPI/TickAPI/Common/Redis/Services/RedisService.cs b/TickAPI/TickAPI/Common/Redis/Services/RedisService.cs index bba9b34..9c2b0fa 100644 --- a/TickAPI/TickAPI/Common/Redis/Services/RedisService.cs +++ b/TickAPI/TickAPI/Common/Redis/Services/RedisService.cs @@ -6,11 +6,13 @@ namespace TickAPI.Common.Redis.Services; public class RedisService : IRedisService { + private readonly IConnectionMultiplexer _connectionMultiplexer; private readonly IDatabase _database; private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); public RedisService(IConnectionMultiplexer connectionMultiplexer) { + _connectionMultiplexer = connectionMultiplexer; _database = connectionMultiplexer.GetDatabase(); } @@ -85,6 +87,12 @@ public async Task SetLongValueAsync(string key, long value, TimeSpan? expi return await RetryAsync(async () => await _database.StringSetAsync(key, value.ToString(), expiry)); } + public async Task> GetKeysByPatternAsync(string pattern) + { + var server = _connectionMultiplexer.GetServer(_connectionMultiplexer.GetEndPoints().First()); + return server.Keys(pattern: pattern).Select(k => k.ToString()); + } + private static async Task RetryAsync(Func> action, int retryCount = 3, int millisecondsDelay = 100) { var attempt = 0; diff --git a/TickAPI/TickAPI/Common/Results/Generic/Result.cs b/TickAPI/TickAPI/Common/Results/Generic/Result.cs index 10ccf62..b7e419e 100644 --- a/TickAPI/TickAPI/Common/Results/Generic/Result.cs +++ b/TickAPI/TickAPI/Common/Results/Generic/Result.cs @@ -1,4 +1,6 @@ -namespace TickAPI.Common.Results.Generic; +using Microsoft.AspNetCore.Mvc; + +namespace TickAPI.Common.Results.Generic; public record Result { @@ -45,4 +47,20 @@ public static Result PropagateError(Result other) return Failure(other.StatusCode, other.ErrorMsg); } + + public ObjectResult ToObjectResult(int successCode = StatusCodes.Status200OK) + { + if (IsError) + { + return new ObjectResult(ErrorMsg) + { + StatusCode = StatusCode + }; + } + + return new ObjectResult(Value) + { + StatusCode = successCode + }; + } } \ No newline at end of file diff --git a/TickAPI/TickAPI/Common/Results/Result.cs b/TickAPI/TickAPI/Common/Results/Result.cs index d339858..8d850d7 100644 --- a/TickAPI/TickAPI/Common/Results/Result.cs +++ b/TickAPI/TickAPI/Common/Results/Result.cs @@ -1,4 +1,5 @@ -using TickAPI.Common.Results.Generic; +using Microsoft.AspNetCore.Mvc; +using TickAPI.Common.Results.Generic; namespace TickAPI.Common.Results; @@ -26,6 +27,16 @@ public static Result Failure(int statusCode, string errorMsg) return new Result(false, statusCode, errorMsg); } + public static Result PropagateError(Result other) + { + if (other.IsSuccess) + { + throw new ArgumentException("Trying to propagate error from successful value"); + } + + return Failure(other.StatusCode, other.ErrorMsg); + } + public static Result PropagateError(Result other) { if (other.IsSuccess) @@ -35,4 +46,20 @@ public static Result PropagateError(Result other) return Failure(other.StatusCode, other.ErrorMsg); } + + public ObjectResult ToObjectResult(int successCode = StatusCodes.Status200OK) + { + if (IsError) + { + return new ObjectResult(ErrorMsg) + { + StatusCode = StatusCode + }; + } + + return new ObjectResult(string.Empty) + { + StatusCode = successCode + }; + } } \ No newline at end of file diff --git a/TickAPI/TickAPI/Customers/Controllers/CustomersController.cs b/TickAPI/TickAPI/Customers/Controllers/CustomersController.cs index 4ec0730..0a77fea 100644 --- a/TickAPI/TickAPI/Customers/Controllers/CustomersController.cs +++ b/TickAPI/TickAPI/Customers/Controllers/CustomersController.cs @@ -30,8 +30,8 @@ public CustomersController(IGoogleAuthService googleAuthService, IJwtService jwt public async Task> GoogleLogin([FromBody] GoogleCustomerLoginDto request) { var userDataResult = await _googleAuthService.GetUserDataFromAccessToken(request.AccessToken); - if(userDataResult.IsError) - return StatusCode(userDataResult.StatusCode, userDataResult.ErrorMsg); + if (userDataResult.IsError) + return userDataResult.ToObjectResult(); var userData = userDataResult.Value!; @@ -40,12 +40,12 @@ public async Task> GoogleLogin([Fro { var newCustomerResult = await _customerService.CreateNewCustomerAsync(userData.Email, userData.GivenName, userData.FamilyName); if (newCustomerResult.IsError) - return StatusCode(newCustomerResult.StatusCode, newCustomerResult.ErrorMsg); + return newCustomerResult.ToObjectResult(); } var jwtTokenResult = _jwtService.GenerateJwtToken(userData.Email, UserRole.Customer); if (jwtTokenResult.IsError) - return StatusCode(jwtTokenResult.StatusCode, jwtTokenResult.ErrorMsg); + return jwtTokenResult.ToObjectResult(); return new ActionResult(new GoogleCustomerLoginResponseDto(jwtTokenResult.Value!)); } @@ -57,7 +57,7 @@ public async Task> AboutMe() var emailResult = _claimsService.GetEmailFromClaims(User.Claims); if (emailResult.IsError) { - return StatusCode(emailResult.StatusCode, emailResult.ErrorMsg); + return emailResult.ToObjectResult(); } var email = emailResult.Value!; diff --git a/TickAPI/TickAPI/Events/Abstractions/IEventRepository.cs b/TickAPI/TickAPI/Events/Abstractions/IEventRepository.cs index 8f4c560..a7e4cb1 100644 --- a/TickAPI/TickAPI/Events/Abstractions/IEventRepository.cs +++ b/TickAPI/TickAPI/Events/Abstractions/IEventRepository.cs @@ -1,4 +1,5 @@ -using TickAPI.Common.Results.Generic; +using TickAPI.Common.Results; +using TickAPI.Common.Results.Generic; using TickAPI.Events.Models; using TickAPI.Organizers.Models; @@ -10,4 +11,6 @@ public interface IEventRepository public IQueryable GetEvents(); public IQueryable GetEventsByOranizer(Organizer organizer); public Task> GetEventByIdAsync(Guid eventId); + public Task SaveEventAsync(Event ev); + public Task> GetEventByIdAndOrganizerAsync(Guid eventId, Organizer organizer); } \ No newline at end of file diff --git a/TickAPI/TickAPI/Events/Abstractions/IEventService.cs b/TickAPI/TickAPI/Events/Abstractions/IEventService.cs index 3b4f020..b029b39 100644 --- a/TickAPI/TickAPI/Events/Abstractions/IEventService.cs +++ b/TickAPI/TickAPI/Events/Abstractions/IEventService.cs @@ -20,4 +20,5 @@ public Task> CreateNewEventAsync(string name, string description, public Task>> GetEventsAsync(int page, int pageSize, EventFiltersDto? eventFilters = null); public Task> GetEventsPaginationDetailsAsync(int pageSize); public Task> GetEventDetailsAsync(Guid eventId); + public Task> EditEventAsync(Organizer organizer, Guid eventId, string name, string description, DateTime startDate, DateTime endDate, uint? minimumAge, CreateAddressDto editAddress, List categories, EventStatus eventStatus); } \ No newline at end of file diff --git a/TickAPI/TickAPI/Events/Controllers/EventsController.cs b/TickAPI/TickAPI/Events/Controllers/EventsController.cs index c5b3d09..95cbf68 100644 --- a/TickAPI/TickAPI/Events/Controllers/EventsController.cs +++ b/TickAPI/TickAPI/Events/Controllers/EventsController.cs @@ -33,16 +33,16 @@ public async Task> CreateEvent([FromBody] C var emailResult = _claimsService.GetEmailFromClaims(User.Claims); if (emailResult.IsError) { - return StatusCode(emailResult.StatusCode, emailResult.ErrorMsg); + return emailResult.ToObjectResult(); } var email = emailResult.Value!; var newEventResult = await _eventService.CreateNewEventAsync(request.Name, request.Description, request.StartDate, request.EndDate, request.MinimumAge, request.CreateAddress, request.Categories , request.TicketTypes ,request.EventStatus, email); - + if (newEventResult.IsError) - return StatusCode(newEventResult.StatusCode, newEventResult.ErrorMsg); + return newEventResult.ToObjectResult(); return Ok("Event created succesfully"); } @@ -54,24 +54,19 @@ public async Task>> GetOrganizer var emailResult = _claimsService.GetEmailFromClaims(User.Claims); if (emailResult.IsError) { - return StatusCode(emailResult.StatusCode, emailResult.ErrorMsg); + return emailResult.ToObjectResult(); } var email = emailResult.Value!; var organizerResult = await _organizerService.GetOrganizerByEmailAsync(email); if (organizerResult.IsError) { - return StatusCode(organizerResult.StatusCode, organizerResult.ErrorMsg); + return organizerResult.ToObjectResult(); } var organizer = organizerResult.Value!; var paginatedDataResult = await _eventService.GetOrganizerEventsAsync(organizer, page, pageSize, eventFilters); - if (paginatedDataResult.IsError) - { - return StatusCode(paginatedDataResult.StatusCode, paginatedDataResult.ErrorMsg); - } - - return Ok(paginatedDataResult.Value!); + return paginatedDataResult.ToObjectResult(); } [AuthorizeWithPolicy(AuthPolicies.VerifiedOrganizerPolicy)] @@ -81,24 +76,19 @@ public async Task> GetOrganizerEventsPaginationD var emailResult = _claimsService.GetEmailFromClaims(User.Claims); if (emailResult.IsError) { - return StatusCode(emailResult.StatusCode, emailResult.ErrorMsg); + return emailResult.ToObjectResult(); } var email = emailResult.Value!; var organizerResult = await _organizerService.GetOrganizerByEmailAsync(email); if (organizerResult.IsError) { - return StatusCode(organizerResult.StatusCode, organizerResult.ErrorMsg); + return organizerResult.ToObjectResult(); } var organizer = organizerResult.Value!; var paginationDetailsResult = await _eventService.GetOrganizerEventsPaginationDetailsAsync(organizer, pageSize); - if (paginationDetailsResult.IsError) - { - return StatusCode(paginationDetailsResult.StatusCode, paginationDetailsResult.ErrorMsg); - } - - return Ok(paginationDetailsResult.Value!); + return paginationDetailsResult.ToObjectResult(); } [AuthorizeWithPolicy(AuthPolicies.CustomerPolicy)] @@ -106,11 +96,7 @@ public async Task> GetOrganizerEventsPaginationD public async Task>> GetEvents([FromQuery] int pageSize, [FromQuery] int page, [FromQuery] EventFiltersDto eventFilters) { var paginatedDataResult = await _eventService.GetEventsAsync(page, pageSize, eventFilters); - if (paginatedDataResult.IsError) - { - return StatusCode(paginatedDataResult.StatusCode, paginatedDataResult.ErrorMsg); - } - return Ok(paginatedDataResult.Value!); + return paginatedDataResult.ToObjectResult(); } [AuthorizeWithPolicy(AuthPolicies.CustomerPolicy)] @@ -118,11 +104,7 @@ public async Task>> GetEvents([F public async Task> GetEventsPaginationDetails([FromQuery] int pageSize) { var paginationDetailsResult = await _eventService.GetEventsPaginationDetailsAsync(pageSize); - if (paginationDetailsResult.IsError) - { - return StatusCode(paginationDetailsResult.StatusCode, paginationDetailsResult.ErrorMsg); - } - return Ok(paginationDetailsResult.Value!); + return paginationDetailsResult.ToObjectResult(); } [AuthorizeWithPolicy(AuthPolicies.VerifiedUserPolicy)] @@ -130,10 +112,33 @@ public async Task> GetEventsPaginationDetails([F public async Task> GetEventDetails([FromRoute] Guid id) { var eventDetailsResult = await _eventService.GetEventDetailsAsync(id); - if (eventDetailsResult.IsError) + return eventDetailsResult.ToObjectResult(); + } + + [AuthorizeWithPolicy(AuthPolicies.VerifiedOrganizerPolicy)] + [HttpPatch("{id:guid}")] + public async Task> EditEvent([FromRoute] Guid id, [FromBody] EditEventDto request) + { + var emailResult = _claimsService.GetEmailFromClaims(User.Claims); + if (emailResult.IsError) + { + return emailResult.ToObjectResult(); + } + var email = emailResult.Value!; + + var organizerResult = await _organizerService.GetOrganizerByEmailAsync(email); + if (organizerResult.IsError) { - return StatusCode(eventDetailsResult.StatusCode, eventDetailsResult.ErrorMsg); + return organizerResult.ToObjectResult(); } - return Ok(eventDetailsResult.Value!); + var organizer = organizerResult.Value!; + + var editedEventResult = await _eventService.EditEventAsync(organizer, id, request.Name, request.Description, request.StartDate, request.EndDate, request.MinimumAge, + request.EditAddress, request.Categories, request.EventStatus); + + if (editedEventResult.IsError) + return editedEventResult.ToObjectResult(); + + return Ok("Event edited succesfully"); } } \ No newline at end of file diff --git a/TickAPI/TickAPI/Events/DTOs/Request/EditEventDto.cs b/TickAPI/TickAPI/Events/DTOs/Request/EditEventDto.cs new file mode 100644 index 0000000..e441955 --- /dev/null +++ b/TickAPI/TickAPI/Events/DTOs/Request/EditEventDto.cs @@ -0,0 +1,16 @@ +using TickAPI.Addresses.DTOs.Request; +using TickAPI.Categories.DTOs.Request; +using TickAPI.Events.Models; + +namespace TickAPI.Events.DTOs.Request; + +public record EditEventDto( + string Name, + string Description, + DateTime StartDate, + DateTime EndDate, + uint? MinimumAge, + List Categories, + EventStatus EventStatus, + CreateAddressDto EditAddress +); diff --git a/TickAPI/TickAPI/Events/DTOs/Request/EventFiltersDto.cs b/TickAPI/TickAPI/Events/DTOs/Request/EventFiltersDto.cs index 91e52cc..89ece02 100644 --- a/TickAPI/TickAPI/Events/DTOs/Request/EventFiltersDto.cs +++ b/TickAPI/TickAPI/Events/DTOs/Request/EventFiltersDto.cs @@ -1,8 +1,7 @@ namespace TickAPI.Events.DTOs.Request; public record EventFiltersDto( - string? Name, - string? Descritpion, + string? SearchQuery, DateTime? StartDate, DateTime? MinStartDate, DateTime? MaxStartDate, @@ -17,5 +16,7 @@ public record EventFiltersDto( string? AddressCity, string? AddressStreet, uint? HouseNumber, - uint? FlatNumber + uint? FlatNumber, + string? PostalCode, + List? CategoriesNames ); diff --git a/TickAPI/TickAPI/Events/DTOs/Response/EditEventResponseDto.cs b/TickAPI/TickAPI/Events/DTOs/Response/EditEventResponseDto.cs new file mode 100644 index 0000000..455ecdc --- /dev/null +++ b/TickAPI/TickAPI/Events/DTOs/Response/EditEventResponseDto.cs @@ -0,0 +1,3 @@ +namespace TickAPI.Events.DTOs.Response; + +public record EditEventResponseDto(); diff --git a/TickAPI/TickAPI/Events/DTOs/Response/GetEventResponseDto.cs b/TickAPI/TickAPI/Events/DTOs/Response/GetEventResponseDto.cs index 28b42e4..a280251 100644 --- a/TickAPI/TickAPI/Events/DTOs/Response/GetEventResponseDto.cs +++ b/TickAPI/TickAPI/Events/DTOs/Response/GetEventResponseDto.cs @@ -9,8 +9,8 @@ public record GetEventResponseDto( DateTime StartDate, DateTime EndDate, uint? MinimumAge, - decimal MinimumPrice, - decimal MaximumPrice, + GetEventResponsePriceInfoDto MinimumPrice, + GetEventResponsePriceInfoDto MaximumPrice, List Categories, EventStatus Status, GetEventResponseAddressDto Address diff --git a/TickAPI/TickAPI/Events/DTOs/Response/GetEventResponsePriceInfoDto.cs b/TickAPI/TickAPI/Events/DTOs/Response/GetEventResponsePriceInfoDto.cs new file mode 100644 index 0000000..6c321a7 --- /dev/null +++ b/TickAPI/TickAPI/Events/DTOs/Response/GetEventResponsePriceInfoDto.cs @@ -0,0 +1,6 @@ +namespace TickAPI.Events.DTOs.Response; + +public record GetEventResponsePriceInfoDto( + decimal Price, + string Currency +); diff --git a/TickAPI/TickAPI/Events/Filters/EventFilterApplier.cs b/TickAPI/TickAPI/Events/Filters/EventFilterApplier.cs index 7347465..7a3aa80 100644 --- a/TickAPI/TickAPI/Events/Filters/EventFilterApplier.cs +++ b/TickAPI/TickAPI/Events/Filters/EventFilterApplier.cs @@ -14,8 +14,12 @@ public EventFilterApplier(IEventFilter eventFilter) _eventFilter = eventFilter; _filterActions = new Dictionary, Action> { - { f => !string.IsNullOrEmpty(f.Name), f => _eventFilter.FilterByName(f.Name!) }, - { f => !string.IsNullOrEmpty(f.Descritpion), f => _eventFilter.FilterByDescription(f.Descritpion!) }, + { f => !string.IsNullOrEmpty(f.SearchQuery), f => + { + _eventFilter.FilterByName(f.SearchQuery!); + _eventFilter.FilterByDescription(f.SearchQuery!); + } + }, { f => f.StartDate.HasValue, f => _eventFilter.FilterByStartDate(f.StartDate!.Value) }, { f => f.MinStartDate.HasValue, f => _eventFilter.FilterByMinStartDate(f.MinStartDate!.Value) }, { f => f.MaxStartDate.HasValue, f => _eventFilter.FilterByMaxStartDate(f.MaxStartDate!.Value) }, @@ -31,7 +35,9 @@ public EventFilterApplier(IEventFilter eventFilter) { f => !string.IsNullOrEmpty(f.AddressStreet), f => _eventFilter.FilterByAddressStreet( f.AddressStreet!, f.HouseNumber, - f.FlatNumber) } + f.FlatNumber) }, + {f => !string.IsNullOrEmpty(f.PostalCode), f => _eventFilter.FilterByAddressPostalCode(f.PostalCode!)}, + {f => f.CategoriesNames is { Count: > 0 }, f => _eventFilter.FilterByCategoriesNames(f.CategoriesNames!)} }; } diff --git a/TickAPI/TickAPI/Events/Repositories/EventRepository.cs b/TickAPI/TickAPI/Events/Repositories/EventRepository.cs index bdc59ee..bfd667e 100644 --- a/TickAPI/TickAPI/Events/Repositories/EventRepository.cs +++ b/TickAPI/TickAPI/Events/Repositories/EventRepository.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using TickAPI.Common.Results; using TickAPI.Common.Results.Generic; using TickAPI.Common.TickApiDbContext; using TickAPI.Events.Abstractions; @@ -54,4 +55,24 @@ public async Task> GetEventByIdAsync(Guid eventId) return Result.Success(@event); } + + public async Task SaveEventAsync(Event ev) + { + var fromDb = await GetEventByIdAsync(ev.Id); + if (fromDb.IsError) + return Result.PropagateError(fromDb); + await _tickApiDbContext.SaveChangesAsync(); + return Result.Success(); + } + + public async Task> GetEventByIdAndOrganizerAsync(Guid eventId, Organizer organizer) + { + var organizerEvents = GetEventsByOranizer(organizer); + var ev = await organizerEvents.Where(e => e.Id == eventId).FirstAsync(); + if (ev is null) + { + return Result.Failure(StatusCodes.Status404NotFound, $"Event with id {eventId} not found for organizer with id {organizer.Id}"); + } + return Result.Success(ev); + } } \ No newline at end of file diff --git a/TickAPI/TickAPI/Events/Services/EventService.cs b/TickAPI/TickAPI/Events/Services/EventService.cs index 201a9a9..91cdda2 100644 --- a/TickAPI/TickAPI/Events/Services/EventService.cs +++ b/TickAPI/TickAPI/Events/Services/EventService.cs @@ -4,6 +4,7 @@ using TickAPI.Common.Pagination.Responses; using TickAPI.Categories.Abstractions; using TickAPI.Categories.DTOs.Request; +using TickAPI.Common.Results; using TickAPI.Common.Time.Abstractions; using TickAPI.Events.Abstractions; using TickAPI.Events.Models; @@ -40,36 +41,13 @@ public EventService(IEventRepository eventRepository, IOrganizerService organize _ticketService = ticketService; } - public async Task> CreateNewEventAsync(string name, string description, DateTime startDate, DateTime endDate, + public async Task> CreateNewEventAsync(string name, string description, DateTime startDate, DateTime endDate, uint? minimumAge, CreateAddressDto createAddress, List categories, List ticketTypes, EventStatus eventStatus, string organizerEmail) { var organizerResult = await _organizerService.GetOrganizerByEmailAsync(organizerEmail); if (!organizerResult.IsSuccess) return Result.PropagateError(organizerResult); - - - if (endDate < startDate) - return Result.Failure(StatusCodes.Status400BadRequest, "End date should be after start date"); - - if (startDate < _dateTimeService.GetCurrentDateTime()) - return Result.Failure(StatusCodes.Status400BadRequest, "Start date is in the past"); - - if (ticketTypes.Any(t => t.AvailableFrom > endDate)) - { - return Result.Failure(StatusCodes.Status400BadRequest, "Tickets can't be available after the event is over"); - } - - var address = await _addressService.GetOrCreateAddressAsync(createAddress); - - var categoryNames = categories.Select(c => c.CategoryName).ToList(); - - var categoriesByNameResult = _categoryService.GetCategoriesByNames(categoryNames); - - if (categoriesByNameResult.IsError) - { - return Result.PropagateError(categoriesByNameResult); - } var ticketTypesConverted = ticketTypes.Select(t => new TicketType { @@ -80,7 +58,23 @@ public async Task> CreateNewEventAsync(string name, string descri Price = t.Price, }) .ToList(); - + + var datesCheck = CheckEventDates(startDate, endDate, ticketTypesConverted); + if (datesCheck.IsError) + return Result.PropagateError(datesCheck); + + var address = await _addressService.GetOrCreateAddressAsync(createAddress); + if (address.IsError) + { + return Result.PropagateError(address); + } + var categoryNames = categories.Select(c => c.CategoryName).ToList(); + var categoriesByNameResult = _categoryService.GetCategoriesByNames(categoryNames); + if (categoriesByNameResult.IsError) + { + return Result.PropagateError(categoriesByNameResult); + } + var @event = new Event { Name = name, @@ -139,10 +133,29 @@ public async Task> GetEventDetailsAsync(Guid ? ev.Categories.Select((c) => new GetEventResponseCategoryDto(c.Name)).ToList() : new List(); - var ticketTypes = ev.TicketTypes.Count > 0 - ? ev.TicketTypes.Select((t) => new GetEventDetailsResponseTicketTypeDto(t.Id, t.Description, t.Price, - t.Currency, t.AvailableFrom, _ticketService.GetNumberOfAvailableTicketsByType(t).Value)).ToList() - : new List(); + var ticketTypes = new List(); + + if (ev.TicketTypes.Count > 0) + { + foreach (var t in ev.TicketTypes) + { + var availableCountResult = await _ticketService.GetNumberOfAvailableTicketsByTypeAsync(t); + + if (availableCountResult.IsError) + { + return Result.PropagateError(availableCountResult); + } + + ticketTypes.Add(new GetEventDetailsResponseTicketTypeDto( + t.Id, + t.Description, + t.Price, + t.Currency, + t.AvailableFrom, + availableCountResult.Value + )); + } + } var address = new GetEventResponseAddressDto(ev.Address.Country, ev.Address.City, ev.Address.PostalCode, ev.Address.Street, ev.Address.HouseNumber, ev.Address.FlatNumber); @@ -163,6 +176,51 @@ public async Task> GetEventDetailsAsync(Guid return Result.Success(details); } + public async Task> EditEventAsync(Organizer organizer, Guid eventId, string name, string description, DateTime startDate, DateTime endDate, uint? minimumAge, CreateAddressDto editAddress, List categories, + EventStatus eventStatus) + { + var existingEventResult = await _eventRepository.GetEventByIdAndOrganizerAsync(eventId, organizer); + if (existingEventResult.IsError) + { + return existingEventResult; + } + var existingEvent = existingEventResult.Value!; + + var datesCheck = CheckEventDates(startDate, endDate, existingEvent.TicketTypes, existingEvent.StartDate == startDate); + if (datesCheck.IsError) + return Result.PropagateError(datesCheck); + + var address = await _addressService.GetOrCreateAddressAsync(editAddress); + if (address.IsError) + { + return Result.PropagateError(address); + } + + var categoryNames = categories.Select(c => c.CategoryName).ToList(); + var categoriesByNameResult = _categoryService.GetCategoriesByNames(categoryNames); + if (categoriesByNameResult.IsError) + { + return Result.PropagateError(categoriesByNameResult); + } + + existingEvent.Name = name; + existingEvent.Description = description; + existingEvent.StartDate = startDate; + existingEvent.EndDate = endDate; + existingEvent.MinimumAge = minimumAge; + existingEvent.Address = address.Value!; + existingEvent.Categories = categoriesByNameResult.Value!; + existingEvent.EventStatus = eventStatus; + + var saveResult = await _eventRepository.SaveEventAsync(existingEvent); + if (saveResult.IsError) + { + return Result.PropagateError(saveResult); + } + + return Result.Success(existingEvent); + } + private async Task>> GetPaginatedEventsAsync(IQueryable events, int page, int pageSize) { var paginatedEventsResult = await _paginationService.PaginateAsync(events, pageSize, page); @@ -193,10 +251,30 @@ private static GetEventResponseDto MapEventToGetEventResponseDto(Event ev) var categories = ev.Categories.Count > 0 ? ev.Categories.Select((c) => new GetEventResponseCategoryDto(c.Name)).ToList() : new List(); var address = new GetEventResponseAddressDto(ev.Address.Country, ev.Address.City, ev.Address.PostalCode, ev.Address.Street, ev.Address.HouseNumber, ev.Address.FlatNumber); - var minimumPrice = ev.TicketTypes.Min(t => t.Price); - var maximumPrice = ev.TicketTypes.Max(t => t.Price); + // Here we assume that there is at least one ticket type in each event + var ttMinimumPrice = ev.TicketTypes.MinBy(t => t.Price)!; + var ttMaximumPrice = ev.TicketTypes.MaxBy(t => t.Price)!; + + var minimumPrice = new GetEventResponsePriceInfoDto(ttMinimumPrice.Price, ttMinimumPrice.Currency); + var maximumPrice = new GetEventResponsePriceInfoDto(ttMaximumPrice.Price, ttMaximumPrice.Currency); return new GetEventResponseDto(ev.Id, ev.Name, ev.Description, ev.StartDate, ev.EndDate, ev.MinimumAge, minimumPrice, maximumPrice, categories, ev.EventStatus, address); } + + private Result CheckEventDates(DateTime startDate, DateTime endDate, IEnumerable ticketTypes, bool skipStartDateEvaluation = false) + { + if (endDate < startDate) + return Result.Failure(StatusCodes.Status400BadRequest, "End date should be after start date"); + + if (!skipStartDateEvaluation && startDate < _dateTimeService.GetCurrentDateTime()) + return Result.Failure(StatusCodes.Status400BadRequest, "Start date is in the past"); + + if (ticketTypes.Any(t => t.AvailableFrom > endDate)) + { + return Result.Failure(StatusCodes.Status400BadRequest, "Tickets can't be available after the event is over"); + } + + return Result.Success(); + } } \ No newline at end of file diff --git a/TickAPI/TickAPI/Migrations/20250508114210_UsedTicket.Designer.cs b/TickAPI/TickAPI/Migrations/20250508114210_UsedTicket.Designer.cs new file mode 100644 index 0000000..f2dca06 --- /dev/null +++ b/TickAPI/TickAPI/Migrations/20250508114210_UsedTicket.Designer.cs @@ -0,0 +1,392 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using TickAPI.Common.TickApiDbContext; + +#nullable disable + +namespace TickAPI.Migrations +{ + [DbContext(typeof(TickApiDbContext))] + [Migration("20250508114210_UsedTicket")] + partial class UsedTicket + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CategoryEvent", b => + { + b.Property("CategoriesId") + .HasColumnType("uniqueidentifier"); + + b.Property("EventsId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("CategoriesId", "EventsId"); + + b.HasIndex("EventsId"); + + b.ToTable("CategoryEvent"); + }); + + modelBuilder.Entity("TickAPI.Addresses.Models.Address", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("City") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Country") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("FlatNumber") + .HasColumnType("bigint"); + + b.Property("HouseNumber") + .HasColumnType("bigint"); + + b.Property("PostalCode") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Street") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Addresses"); + }); + + modelBuilder.Entity("TickAPI.Admins.Models.Admin", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Email") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Login") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Admins"); + }); + + modelBuilder.Entity("TickAPI.Categories.Models.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Categories"); + + b.HasData( + new + { + Id = new Guid("ec3daf69-baa9-4fcd-a674-c09884a57272"), + Name = "Music" + }, + new + { + Id = new Guid("de89dd76-3b29-43e1-8f4b-5278b1b8bde2"), + Name = "Sports" + }, + new + { + Id = new Guid("ea58370b-2a17-4770-abea-66399ad69fb8"), + Name = "Conferences" + }, + new + { + Id = new Guid("4a086d9e-59de-4fd1-a1b2-bd9b5eec797c"), + Name = "Theatre" + }, + new + { + Id = new Guid("5f8dbe65-30be-453f-8f22-191a11b2977b"), + Name = "Comedy" + }, + new + { + Id = new Guid("4421327a-4bc8-4706-bec0-666f78ed0c69"), + Name = "Workshops" + }); + }); + + modelBuilder.Entity("TickAPI.Customers.Models.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreationDate") + .HasColumnType("datetime2"); + + b.Property("Email") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("TickAPI.Events.Models.Event", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AddressId") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("EndDate") + .HasColumnType("datetime2"); + + b.Property("EventStatus") + .HasColumnType("int"); + + b.Property("MinimumAge") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OrganizerId") + .HasColumnType("uniqueidentifier"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("AddressId"); + + b.HasIndex("OrganizerId"); + + b.ToTable("Events"); + }); + + modelBuilder.Entity("TickAPI.Organizers.Models.Organizer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreationDate") + .HasColumnType("datetime2"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsVerified") + .HasColumnType("bit"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Organizers"); + }); + + modelBuilder.Entity("TickAPI.TicketTypes.Models.TicketType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AvailableFrom") + .HasColumnType("datetime2"); + + b.Property("Currency") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("EventId") + .HasColumnType("uniqueidentifier"); + + b.Property("MaxCount") + .HasColumnType("bigint"); + + b.Property("Price") + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("EventId"); + + b.ToTable("TicketTypes"); + }); + + modelBuilder.Entity("TickAPI.Tickets.Models.Ticket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ForResell") + .HasColumnType("bit"); + + b.Property("NameOnTicket") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OwnerId") + .HasColumnType("uniqueidentifier"); + + b.Property("Seats") + .HasColumnType("nvarchar(max)"); + + b.Property("TypeId") + .HasColumnType("uniqueidentifier"); + + b.Property("Used") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.HasIndex("TypeId"); + + b.ToTable("Tickets"); + }); + + modelBuilder.Entity("CategoryEvent", b => + { + b.HasOne("TickAPI.Categories.Models.Category", null) + .WithMany() + .HasForeignKey("CategoriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TickAPI.Events.Models.Event", null) + .WithMany() + .HasForeignKey("EventsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TickAPI.Events.Models.Event", b => + { + b.HasOne("TickAPI.Addresses.Models.Address", "Address") + .WithMany() + .HasForeignKey("AddressId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TickAPI.Organizers.Models.Organizer", "Organizer") + .WithMany("Events") + .HasForeignKey("OrganizerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Address"); + + b.Navigation("Organizer"); + }); + + modelBuilder.Entity("TickAPI.TicketTypes.Models.TicketType", b => + { + b.HasOne("TickAPI.Events.Models.Event", "Event") + .WithMany("TicketTypes") + .HasForeignKey("EventId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Event"); + }); + + modelBuilder.Entity("TickAPI.Tickets.Models.Ticket", b => + { + b.HasOne("TickAPI.Customers.Models.Customer", "Owner") + .WithMany("Tickets") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TickAPI.TicketTypes.Models.TicketType", "Type") + .WithMany("Tickets") + .HasForeignKey("TypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + + b.Navigation("Type"); + }); + + modelBuilder.Entity("TickAPI.Customers.Models.Customer", b => + { + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("TickAPI.Events.Models.Event", b => + { + b.Navigation("TicketTypes"); + }); + + modelBuilder.Entity("TickAPI.Organizers.Models.Organizer", b => + { + b.Navigation("Events"); + }); + + modelBuilder.Entity("TickAPI.TicketTypes.Models.TicketType", b => + { + b.Navigation("Tickets"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/TickAPI/TickAPI/Migrations/20250508114210_UsedTicket.cs b/TickAPI/TickAPI/Migrations/20250508114210_UsedTicket.cs new file mode 100644 index 0000000..852c68f --- /dev/null +++ b/TickAPI/TickAPI/Migrations/20250508114210_UsedTicket.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TickAPI.Migrations +{ + /// + public partial class UsedTicket : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Used", + table: "Tickets", + type: "bit", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Used", + table: "Tickets"); + } + } +} diff --git a/TickAPI/TickAPI/Migrations/20250514235701_MadeNameOnTicketUnnecessary.Designer.cs b/TickAPI/TickAPI/Migrations/20250514235701_MadeNameOnTicketUnnecessary.Designer.cs new file mode 100644 index 0000000..e146523 --- /dev/null +++ b/TickAPI/TickAPI/Migrations/20250514235701_MadeNameOnTicketUnnecessary.Designer.cs @@ -0,0 +1,391 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using TickAPI.Common.TickApiDbContext; + +#nullable disable + +namespace TickAPI.Migrations +{ + [DbContext(typeof(TickApiDbContext))] + [Migration("20250514235701_MadeNameOnTicketUnnecessary")] + partial class MadeNameOnTicketUnnecessary + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CategoryEvent", b => + { + b.Property("CategoriesId") + .HasColumnType("uniqueidentifier"); + + b.Property("EventsId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("CategoriesId", "EventsId"); + + b.HasIndex("EventsId"); + + b.ToTable("CategoryEvent"); + }); + + modelBuilder.Entity("TickAPI.Addresses.Models.Address", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("City") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Country") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("FlatNumber") + .HasColumnType("bigint"); + + b.Property("HouseNumber") + .HasColumnType("bigint"); + + b.Property("PostalCode") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Street") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Addresses"); + }); + + modelBuilder.Entity("TickAPI.Admins.Models.Admin", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Email") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Login") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Admins"); + }); + + modelBuilder.Entity("TickAPI.Categories.Models.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Categories"); + + b.HasData( + new + { + Id = new Guid("ec3daf69-baa9-4fcd-a674-c09884a57272"), + Name = "Music" + }, + new + { + Id = new Guid("de89dd76-3b29-43e1-8f4b-5278b1b8bde2"), + Name = "Sports" + }, + new + { + Id = new Guid("ea58370b-2a17-4770-abea-66399ad69fb8"), + Name = "Conferences" + }, + new + { + Id = new Guid("4a086d9e-59de-4fd1-a1b2-bd9b5eec797c"), + Name = "Theatre" + }, + new + { + Id = new Guid("5f8dbe65-30be-453f-8f22-191a11b2977b"), + Name = "Comedy" + }, + new + { + Id = new Guid("4421327a-4bc8-4706-bec0-666f78ed0c69"), + Name = "Workshops" + }); + }); + + modelBuilder.Entity("TickAPI.Customers.Models.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreationDate") + .HasColumnType("datetime2"); + + b.Property("Email") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("TickAPI.Events.Models.Event", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AddressId") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("EndDate") + .HasColumnType("datetime2"); + + b.Property("EventStatus") + .HasColumnType("int"); + + b.Property("MinimumAge") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OrganizerId") + .HasColumnType("uniqueidentifier"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("AddressId"); + + b.HasIndex("OrganizerId"); + + b.ToTable("Events"); + }); + + modelBuilder.Entity("TickAPI.Organizers.Models.Organizer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreationDate") + .HasColumnType("datetime2"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsVerified") + .HasColumnType("bit"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Organizers"); + }); + + modelBuilder.Entity("TickAPI.TicketTypes.Models.TicketType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AvailableFrom") + .HasColumnType("datetime2"); + + b.Property("Currency") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("EventId") + .HasColumnType("uniqueidentifier"); + + b.Property("MaxCount") + .HasColumnType("bigint"); + + b.Property("Price") + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("EventId"); + + b.ToTable("TicketTypes"); + }); + + modelBuilder.Entity("TickAPI.Tickets.Models.Ticket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ForResell") + .HasColumnType("bit"); + + b.Property("NameOnTicket") + .HasColumnType("nvarchar(max)"); + + b.Property("OwnerId") + .HasColumnType("uniqueidentifier"); + + b.Property("Seats") + .HasColumnType("nvarchar(max)"); + + b.Property("TypeId") + .HasColumnType("uniqueidentifier"); + + b.Property("Used") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.HasIndex("TypeId"); + + b.ToTable("Tickets"); + }); + + modelBuilder.Entity("CategoryEvent", b => + { + b.HasOne("TickAPI.Categories.Models.Category", null) + .WithMany() + .HasForeignKey("CategoriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TickAPI.Events.Models.Event", null) + .WithMany() + .HasForeignKey("EventsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TickAPI.Events.Models.Event", b => + { + b.HasOne("TickAPI.Addresses.Models.Address", "Address") + .WithMany() + .HasForeignKey("AddressId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TickAPI.Organizers.Models.Organizer", "Organizer") + .WithMany("Events") + .HasForeignKey("OrganizerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Address"); + + b.Navigation("Organizer"); + }); + + modelBuilder.Entity("TickAPI.TicketTypes.Models.TicketType", b => + { + b.HasOne("TickAPI.Events.Models.Event", "Event") + .WithMany("TicketTypes") + .HasForeignKey("EventId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Event"); + }); + + modelBuilder.Entity("TickAPI.Tickets.Models.Ticket", b => + { + b.HasOne("TickAPI.Customers.Models.Customer", "Owner") + .WithMany("Tickets") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TickAPI.TicketTypes.Models.TicketType", "Type") + .WithMany("Tickets") + .HasForeignKey("TypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + + b.Navigation("Type"); + }); + + modelBuilder.Entity("TickAPI.Customers.Models.Customer", b => + { + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("TickAPI.Events.Models.Event", b => + { + b.Navigation("TicketTypes"); + }); + + modelBuilder.Entity("TickAPI.Organizers.Models.Organizer", b => + { + b.Navigation("Events"); + }); + + modelBuilder.Entity("TickAPI.TicketTypes.Models.TicketType", b => + { + b.Navigation("Tickets"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/TickAPI/TickAPI/Migrations/20250514235701_MadeNameOnTicketUnnecessary.cs b/TickAPI/TickAPI/Migrations/20250514235701_MadeNameOnTicketUnnecessary.cs new file mode 100644 index 0000000..3cbba3e --- /dev/null +++ b/TickAPI/TickAPI/Migrations/20250514235701_MadeNameOnTicketUnnecessary.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TickAPI.Migrations +{ + /// + public partial class MadeNameOnTicketUnnecessary : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "NameOnTicket", + table: "Tickets", + type: "nvarchar(max)", + nullable: true, + oldClrType: typeof(string), + oldType: "nvarchar(max)"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "NameOnTicket", + table: "Tickets", + type: "nvarchar(max)", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "nvarchar(max)", + oldNullable: true); + } + } +} diff --git a/TickAPI/TickAPI/Migrations/20250525174930_resell.Designer.cs b/TickAPI/TickAPI/Migrations/20250525174930_resell.Designer.cs new file mode 100644 index 0000000..7a09fdb --- /dev/null +++ b/TickAPI/TickAPI/Migrations/20250525174930_resell.Designer.cs @@ -0,0 +1,397 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using TickAPI.Common.TickApiDbContext; + +#nullable disable + +namespace TickAPI.Migrations +{ + [DbContext(typeof(TickApiDbContext))] + [Migration("20250525174930_resell")] + partial class resell + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CategoryEvent", b => + { + b.Property("CategoriesId") + .HasColumnType("uniqueidentifier"); + + b.Property("EventsId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("CategoriesId", "EventsId"); + + b.HasIndex("EventsId"); + + b.ToTable("CategoryEvent"); + }); + + modelBuilder.Entity("TickAPI.Addresses.Models.Address", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("City") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Country") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("FlatNumber") + .HasColumnType("bigint"); + + b.Property("HouseNumber") + .HasColumnType("bigint"); + + b.Property("PostalCode") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Street") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Addresses"); + }); + + modelBuilder.Entity("TickAPI.Admins.Models.Admin", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Email") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Login") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Admins"); + }); + + modelBuilder.Entity("TickAPI.Categories.Models.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Categories"); + + b.HasData( + new + { + Id = new Guid("ec3daf69-baa9-4fcd-a674-c09884a57272"), + Name = "Music" + }, + new + { + Id = new Guid("de89dd76-3b29-43e1-8f4b-5278b1b8bde2"), + Name = "Sports" + }, + new + { + Id = new Guid("ea58370b-2a17-4770-abea-66399ad69fb8"), + Name = "Conferences" + }, + new + { + Id = new Guid("4a086d9e-59de-4fd1-a1b2-bd9b5eec797c"), + Name = "Theatre" + }, + new + { + Id = new Guid("5f8dbe65-30be-453f-8f22-191a11b2977b"), + Name = "Comedy" + }, + new + { + Id = new Guid("4421327a-4bc8-4706-bec0-666f78ed0c69"), + Name = "Workshops" + }); + }); + + modelBuilder.Entity("TickAPI.Customers.Models.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreationDate") + .HasColumnType("datetime2"); + + b.Property("Email") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("TickAPI.Events.Models.Event", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AddressId") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("EndDate") + .HasColumnType("datetime2"); + + b.Property("EventStatus") + .HasColumnType("int"); + + b.Property("MinimumAge") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OrganizerId") + .HasColumnType("uniqueidentifier"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("AddressId"); + + b.HasIndex("OrganizerId"); + + b.ToTable("Events"); + }); + + modelBuilder.Entity("TickAPI.Organizers.Models.Organizer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreationDate") + .HasColumnType("datetime2"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsVerified") + .HasColumnType("bit"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Organizers"); + }); + + modelBuilder.Entity("TickAPI.TicketTypes.Models.TicketType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AvailableFrom") + .HasColumnType("datetime2"); + + b.Property("Currency") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("EventId") + .HasColumnType("uniqueidentifier"); + + b.Property("MaxCount") + .HasColumnType("bigint"); + + b.Property("Price") + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("EventId"); + + b.ToTable("TicketTypes"); + }); + + modelBuilder.Entity("TickAPI.Tickets.Models.Ticket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ForResell") + .HasColumnType("bit"); + + b.Property("NameOnTicket") + .HasColumnType("nvarchar(max)"); + + b.Property("OwnerId") + .HasColumnType("uniqueidentifier"); + + b.Property("ResellCurrency") + .HasColumnType("nvarchar(max)"); + + b.Property("ResellPrice") + .HasColumnType("decimal(18,2)"); + + b.Property("Seats") + .HasColumnType("nvarchar(max)"); + + b.Property("TypeId") + .HasColumnType("uniqueidentifier"); + + b.Property("Used") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.HasIndex("TypeId"); + + b.ToTable("Tickets"); + }); + + modelBuilder.Entity("CategoryEvent", b => + { + b.HasOne("TickAPI.Categories.Models.Category", null) + .WithMany() + .HasForeignKey("CategoriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TickAPI.Events.Models.Event", null) + .WithMany() + .HasForeignKey("EventsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TickAPI.Events.Models.Event", b => + { + b.HasOne("TickAPI.Addresses.Models.Address", "Address") + .WithMany() + .HasForeignKey("AddressId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TickAPI.Organizers.Models.Organizer", "Organizer") + .WithMany("Events") + .HasForeignKey("OrganizerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Address"); + + b.Navigation("Organizer"); + }); + + modelBuilder.Entity("TickAPI.TicketTypes.Models.TicketType", b => + { + b.HasOne("TickAPI.Events.Models.Event", "Event") + .WithMany("TicketTypes") + .HasForeignKey("EventId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Event"); + }); + + modelBuilder.Entity("TickAPI.Tickets.Models.Ticket", b => + { + b.HasOne("TickAPI.Customers.Models.Customer", "Owner") + .WithMany("Tickets") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TickAPI.TicketTypes.Models.TicketType", "Type") + .WithMany("Tickets") + .HasForeignKey("TypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + + b.Navigation("Type"); + }); + + modelBuilder.Entity("TickAPI.Customers.Models.Customer", b => + { + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("TickAPI.Events.Models.Event", b => + { + b.Navigation("TicketTypes"); + }); + + modelBuilder.Entity("TickAPI.Organizers.Models.Organizer", b => + { + b.Navigation("Events"); + }); + + modelBuilder.Entity("TickAPI.TicketTypes.Models.TicketType", b => + { + b.Navigation("Tickets"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/TickAPI/TickAPI/Migrations/20250525174930_resell.cs b/TickAPI/TickAPI/Migrations/20250525174930_resell.cs new file mode 100644 index 0000000..2c65ace --- /dev/null +++ b/TickAPI/TickAPI/Migrations/20250525174930_resell.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TickAPI.Migrations +{ + /// + public partial class resell : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "NameOnTicket", + table: "Tickets", + type: "nvarchar(max)", + nullable: true, + oldClrType: typeof(string), + oldType: "nvarchar(max)"); + + migrationBuilder.AddColumn( + name: "ResellCurrency", + table: "Tickets", + type: "nvarchar(max)", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ResellCurrency", + table: "Tickets"); + + migrationBuilder.AlterColumn( + name: "NameOnTicket", + table: "Tickets", + type: "nvarchar(max)", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "nvarchar(max)", + oldNullable: true); + } + } +} diff --git a/TickAPI/TickAPI/Migrations/TickApiDbContextModelSnapshot.cs b/TickAPI/TickAPI/Migrations/TickApiDbContextModelSnapshot.cs index 8866990..71d888e 100644 --- a/TickAPI/TickAPI/Migrations/TickApiDbContextModelSnapshot.cs +++ b/TickAPI/TickAPI/Migrations/TickApiDbContextModelSnapshot.cs @@ -276,18 +276,26 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("bit"); b.Property("NameOnTicket") - .IsRequired() .HasColumnType("nvarchar(max)"); b.Property("OwnerId") .HasColumnType("uniqueidentifier"); + b.Property("ResellCurrency") + .HasColumnType("nvarchar(max)"); + + b.Property("ResellPrice") + .HasColumnType("decimal(18,2)"); + b.Property("Seats") .HasColumnType("nvarchar(max)"); b.Property("TypeId") .HasColumnType("uniqueidentifier"); + b.Property("Used") + .HasColumnType("bit"); + b.HasKey("Id"); b.HasIndex("OwnerId"); diff --git a/TickAPI/TickAPI/Organizers/Abstractions/IOrganizerRepository.cs b/TickAPI/TickAPI/Organizers/Abstractions/IOrganizerRepository.cs index 31b1c22..29513c6 100644 --- a/TickAPI/TickAPI/Organizers/Abstractions/IOrganizerRepository.cs +++ b/TickAPI/TickAPI/Organizers/Abstractions/IOrganizerRepository.cs @@ -9,4 +9,5 @@ public interface IOrganizerRepository Task> GetOrganizerByEmailAsync(string organizerEmail); Task AddNewOrganizerAsync(Organizer organizer); Task VerifyOrganizerByEmailAsync(string organizerEmail); + IQueryable GetOrganizers(); } \ No newline at end of file diff --git a/TickAPI/TickAPI/Organizers/Abstractions/IOrganizerService.cs b/TickAPI/TickAPI/Organizers/Abstractions/IOrganizerService.cs index 0fb0bc6..226028f 100644 --- a/TickAPI/TickAPI/Organizers/Abstractions/IOrganizerService.cs +++ b/TickAPI/TickAPI/Organizers/Abstractions/IOrganizerService.cs @@ -1,5 +1,7 @@ -using TickAPI.Common.Results; +using TickAPI.Common.Pagination.Responses; +using TickAPI.Common.Results; using TickAPI.Common.Results.Generic; +using TickAPI.Organizers.DTOs.Response; using TickAPI.Organizers.Models; namespace TickAPI.Organizers.Abstractions; @@ -11,4 +13,6 @@ public interface IOrganizerService public Task> CreateNewOrganizerAsync(string email, string firstName, string lastName, string displayName); public Task VerifyOrganizerByEmailAsync(string organizerEmail); + + public Task>> GetUnverifiedOrganizersAsync(int page, int pageSize); } \ No newline at end of file diff --git a/TickAPI/TickAPI/Organizers/Controllers/OrganizersController.cs b/TickAPI/TickAPI/Organizers/Controllers/OrganizersController.cs index cb405e3..604ef1a 100644 --- a/TickAPI/TickAPI/Organizers/Controllers/OrganizersController.cs +++ b/TickAPI/TickAPI/Organizers/Controllers/OrganizersController.cs @@ -3,6 +3,7 @@ using TickAPI.Common.Auth.Attributes; using TickAPI.Common.Auth.Enums; using TickAPI.Common.Claims.Abstractions; +using TickAPI.Common.Pagination.Responses; using TickAPI.Common.Results.Generic; using TickAPI.Organizers.Abstractions; using TickAPI.Organizers.DTOs.Request; @@ -32,8 +33,8 @@ public OrganizersController(IGoogleAuthService googleAuthService, IJwtService jw public async Task> GoogleLogin([FromBody] GoogleOrganizerLoginDto request) { var userDataResult = await _googleAuthService.GetUserDataFromAccessToken(request.AccessToken); - if(userDataResult.IsError) - return StatusCode(userDataResult.StatusCode, userDataResult.ErrorMsg); + if (userDataResult.IsError) + return userDataResult.ToObjectResult(); var userData = userDataResult.Value!; @@ -42,9 +43,9 @@ public async Task> GoogleLogin([Fr if (existingOrganizerResult.IsError) { jwtTokenResult = _jwtService.GenerateJwtToken(userData.Email, UserRole.NewOrganizer); - - if(jwtTokenResult.IsError) - return StatusCode(jwtTokenResult.StatusCode, jwtTokenResult.ErrorMsg); + + if (jwtTokenResult.IsError) + return jwtTokenResult.ToObjectResult(); return new ActionResult(new GoogleOrganizerLoginResponseDto(jwtTokenResult.Value!, true, false)); } @@ -54,9 +55,9 @@ public async Task> GoogleLogin([Fr var role = isVerified ? UserRole.Organizer : UserRole.UnverifiedOrganizer; jwtTokenResult = _jwtService.GenerateJwtToken(userData.Email, role); - - if(jwtTokenResult.IsError) - return StatusCode(jwtTokenResult.StatusCode, jwtTokenResult.ErrorMsg); + + if (jwtTokenResult.IsError) + return jwtTokenResult.ToObjectResult(); return new ActionResult(new GoogleOrganizerLoginResponseDto(jwtTokenResult.Value!, false, isVerified)); } @@ -68,31 +69,35 @@ public async Task> CreateOrganizer([Fro var emailResult = _claimsService.GetEmailFromClaims(User.Claims); if (emailResult.IsError) { - return StatusCode(StatusCodes.Status400BadRequest, emailResult.ErrorMsg); + return emailResult.ToObjectResult(); } var email = emailResult.Value!; var newOrganizerResult = await _organizerService.CreateNewOrganizerAsync(email, request.FirstName, request.LastName, request.DisplayName); - if(newOrganizerResult.IsError) - return StatusCode(newOrganizerResult.StatusCode, newOrganizerResult.ErrorMsg); + if (newOrganizerResult.IsError) + return newOrganizerResult.ToObjectResult(); var jwtTokenResult = _jwtService.GenerateJwtToken(newOrganizerResult.Value!.Email, newOrganizerResult.Value!.IsVerified ? UserRole.Organizer : UserRole.UnverifiedOrganizer); - if(jwtTokenResult.IsError) - return StatusCode(jwtTokenResult.StatusCode, jwtTokenResult.ErrorMsg); + if (jwtTokenResult.IsError) + return jwtTokenResult.ToObjectResult(); return new ActionResult(new CreateOrganizerResponseDto(jwtTokenResult.Value!)); } - // TODO: Add authorization with admin policy here + [AuthorizeWithPolicy(AuthPolicies.AdminPolicy)] [HttpPost("verify")] public async Task VerifyOrganizer([FromBody] VerifyOrganizerDto request) { var verifyOrganizerResult = await _organizerService.VerifyOrganizerByEmailAsync(request.Email); - - if(verifyOrganizerResult.IsError) - return StatusCode(verifyOrganizerResult.StatusCode, verifyOrganizerResult.ErrorMsg); - - return Ok(); + return verifyOrganizerResult.ToObjectResult(); + } + + [AuthorizeWithPolicy(AuthPolicies.AdminPolicy)] + [HttpGet("unverified")] + public async Task>> GetUnverifiedOrganizers([FromQuery] int page, [FromQuery] int pageSize) + { + var result = await _organizerService.GetUnverifiedOrganizersAsync(page, pageSize); + return result.ToObjectResult(); } [AuthorizeWithPolicy(AuthPolicies.CreatedOrganizerPolicy)] @@ -102,7 +107,7 @@ public async Task> AboutMe() var emailResult = _claimsService.GetEmailFromClaims(User.Claims); if (emailResult.IsError) { - return StatusCode(StatusCodes.Status400BadRequest, emailResult.ErrorMsg); + return emailResult.ToObjectResult(); } var email = emailResult.Value!; diff --git a/TickAPI/TickAPI/Organizers/DTOs/Response/AboutMeOrganizerResponseDto.cs b/TickAPI/TickAPI/Organizers/DTOs/Response/AboutMeOrganizerResponseDto.cs index 5928d16..b7a983c 100644 --- a/TickAPI/TickAPI/Organizers/DTOs/Response/AboutMeOrganizerResponseDto.cs +++ b/TickAPI/TickAPI/Organizers/DTOs/Response/AboutMeOrganizerResponseDto.cs @@ -3,7 +3,7 @@ public record AboutMeOrganizerResponseDto( string Email, string FirstName, - string LastName, + string? LastName, string DisplayName, bool IsVerified, DateTime CreationDate diff --git a/TickAPI/TickAPI/Organizers/DTOs/Response/GetUnverifiedOrganizerResponseDto.cs b/TickAPI/TickAPI/Organizers/DTOs/Response/GetUnverifiedOrganizerResponseDto.cs new file mode 100644 index 0000000..397fb29 --- /dev/null +++ b/TickAPI/TickAPI/Organizers/DTOs/Response/GetUnverifiedOrganizerResponseDto.cs @@ -0,0 +1,8 @@ +namespace TickAPI.Organizers.DTOs.Response; + +public record GetUnverifiedOrganizerResponseDto( + string Email, + string FirstName, + string? LastName, + string DisplayName +); diff --git a/TickAPI/TickAPI/Organizers/Repositories/OrganizerRepository.cs b/TickAPI/TickAPI/Organizers/Repositories/OrganizerRepository.cs index 1a01bce..6a43c22 100644 --- a/TickAPI/TickAPI/Organizers/Repositories/OrganizerRepository.cs +++ b/TickAPI/TickAPI/Organizers/Repositories/OrganizerRepository.cs @@ -53,4 +53,9 @@ public async Task VerifyOrganizerByEmailAsync(string organizerEmail) return Result.Success(); } + + public IQueryable GetOrganizers() + { + return _tickApiDbContext.Organizers; + } } \ No newline at end of file diff --git a/TickAPI/TickAPI/Organizers/Services/OrganizerService.cs b/TickAPI/TickAPI/Organizers/Services/OrganizerService.cs index 9ff72ce..75bbaf4 100644 --- a/TickAPI/TickAPI/Organizers/Services/OrganizerService.cs +++ b/TickAPI/TickAPI/Organizers/Services/OrganizerService.cs @@ -1,8 +1,11 @@ -using TickAPI.Common.Results; +using TickAPI.Common.Pagination.Abstractions; +using TickAPI.Common.Pagination.Responses; +using TickAPI.Common.Results; using TickAPI.Common.Results.Generic; using TickAPI.Common.Time.Abstractions; using TickAPI.Events.Models; using TickAPI.Organizers.Abstractions; +using TickAPI.Organizers.DTOs.Response; using TickAPI.Organizers.Models; namespace TickAPI.Organizers.Services; @@ -11,11 +14,13 @@ public class OrganizerService : IOrganizerService { private readonly IOrganizerRepository _organizerRepository; private readonly IDateTimeService _dateTimeService; + private readonly IPaginationService _paginationService; - public OrganizerService(IOrganizerRepository organizerRepository, IDateTimeService dateTimeService) + public OrganizerService(IOrganizerRepository organizerRepository, IDateTimeService dateTimeService, IPaginationService paginationService) { _organizerRepository = organizerRepository; _dateTimeService = dateTimeService; + _paginationService = paginationService; } public async Task> GetOrganizerByEmailAsync(string organizerEmail) @@ -48,4 +53,17 @@ public async Task VerifyOrganizerByEmailAsync(string organizerEmail) { return await _organizerRepository.VerifyOrganizerByEmailAsync(organizerEmail); } + + public async Task>> GetUnverifiedOrganizersAsync(int page, int pageSize) + { + var unverifiedOrganizers = _organizerRepository.GetOrganizers().Where(o => !o.IsVerified); + var paginatedResult = await _paginationService.PaginateAsync(unverifiedOrganizers, pageSize, page); + if (paginatedResult.IsError) + { + return Result>.PropagateError(paginatedResult); + } + var paginated = paginatedResult.Value!; + var mapped = _paginationService.MapData(paginated, (o) => new GetUnverifiedOrganizerResponseDto(o.Email, o.FirstName, o.LastName, o.DisplayName)); + return Result>.Success(mapped); + } } \ No newline at end of file diff --git a/TickAPI/TickAPI/Program.cs b/TickAPI/TickAPI/Program.cs index f355ec8..53441b3 100644 --- a/TickAPI/TickAPI/Program.cs +++ b/TickAPI/TickAPI/Program.cs @@ -39,6 +39,18 @@ using TickAPI.Common.Redis.Services; using TickAPI.Common.Mail.Abstractions; using TickAPI.Common.Mail.Services; +using TickAPI.Common.Payment.Abstractions; +using TickAPI.Common.Payment.Health; +using TickAPI.Common.Payment.Services; +using TickAPI.Common.QR.Abstractions; +using TickAPI.Common.QR.Services; +using TickAPI.ShoppingCarts.Abstractions; +using TickAPI.ShoppingCarts.Background; +using TickAPI.ShoppingCarts.Options; +using TickAPI.ShoppingCarts.Repositories; +using TickAPI.ShoppingCarts.Services; +using TickAPI.TicketTypes.Abstractions; +using TickAPI.TicketTypes.Repositories; // Builder constants const string allowClientPolicyName = "AllowClient"; @@ -113,6 +125,16 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); +// Add shopping cart services. +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddHostedService(); +builder.Services.Configure( + builder.Configuration.GetSection("ShoppingCart")); + +// Add ticket type services +builder.Services.AddScoped(); + // Add common services. builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -122,6 +144,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); @@ -180,7 +204,9 @@ // TODO: when we start using redis we should probably also check here if we can connect to it // Setup healtcheck -builder.Services.AddHealthChecks().AddSqlServer(connectionString: builder.Configuration.GetConnectionString("ResellioDatabase") ?? ""); +builder.Services.AddHealthChecks() + .AddSqlServer(connectionString: builder.Configuration.GetConnectionString("ResellioDatabase") ?? "") + .AddCheck("PaymentGateway"); // Add http client builder.Services.AddHttpClient(); diff --git a/TickAPI/TickAPI/ShoppingCarts/Abstractions/IShoppingCartRepository.cs b/TickAPI/TickAPI/ShoppingCarts/Abstractions/IShoppingCartRepository.cs new file mode 100644 index 0000000..d8be591 --- /dev/null +++ b/TickAPI/TickAPI/ShoppingCarts/Abstractions/IShoppingCartRepository.cs @@ -0,0 +1,18 @@ +using TickAPI.Common.Results; +using TickAPI.Common.Results.Generic; +using TickAPI.ShoppingCarts.Models; + +namespace TickAPI.ShoppingCarts.Abstractions; + +public interface IShoppingCartRepository +{ + public Task> GetShoppingCartByEmailAsync(string customerEmail); + public Task UpdateShoppingCartAsync(string customerEmail, ShoppingCart shoppingCart); + public Task AddNewTicketsToCartAsync(string customerEmail, Guid ticketTypeId, uint amount); + public Task RemoveNewTicketsFromCartAsync(string customerEmail, Guid ticketTypeId, uint amount); + public Task> GetAmountOfTicketTypeAsync(Guid ticketTypeId); + public Task SetAmountOfTicketTypeAsync(Guid ticketTypeId, long amount); + public Task> IncrementAmountOfTicketTypeAsync(Guid ticketTypeId, long amount); + public Task> DecrementAmountOfTicketTypeAsync(Guid ticketTypeId, long amount); + public Task RemoveAmountOfTicketTypeAsync(Guid ticketTypeId); +} \ No newline at end of file diff --git a/TickAPI/TickAPI/ShoppingCarts/Abstractions/IShoppingCartService.cs b/TickAPI/TickAPI/ShoppingCarts/Abstractions/IShoppingCartService.cs new file mode 100644 index 0000000..787e62e --- /dev/null +++ b/TickAPI/TickAPI/ShoppingCarts/Abstractions/IShoppingCartService.cs @@ -0,0 +1,16 @@ +using TickAPI.Common.Payment.Models; +using TickAPI.Common.Results; +using TickAPI.Common.Results.Generic; +using TickAPI.ShoppingCarts.DTOs.Response; + +namespace TickAPI.ShoppingCarts.Abstractions; + +public interface IShoppingCartService +{ + public Task AddNewTicketsToCartAsync(Guid ticketTypeId, uint amount, string customerEmail); + public Task> GetTicketsFromCartAsync(string customerEmail); + public Task RemoveNewTicketsFromCartAsync(Guid ticketTypeId, uint amount, string customerEmail); + public Task>> GetDueAmountAsync(string customerEmail); + public Task> CheckoutAsync(string customerEmail, decimal amount, string currency, + string cardNumber, string cardExpiry, string cvv); +} \ No newline at end of file diff --git a/TickAPI/TickAPI/ShoppingCarts/Background/ShoppingCartSyncBackgroundService.cs b/TickAPI/TickAPI/ShoppingCarts/Background/ShoppingCartSyncBackgroundService.cs new file mode 100644 index 0000000..0e99449 --- /dev/null +++ b/TickAPI/TickAPI/ShoppingCarts/Background/ShoppingCartSyncBackgroundService.cs @@ -0,0 +1,84 @@ +using Microsoft.Extensions.Options; +using TickAPI.Common.Redis.Abstractions; +using TickAPI.ShoppingCarts.Models; +using TickAPI.ShoppingCarts.Options; + +namespace TickAPI.ShoppingCarts.Background; + +public class ShoppingCartSyncBackgroundService : BackgroundService +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private readonly TimeSpan _syncInterval; + + public ShoppingCartSyncBackgroundService(IServiceProvider serviceProvider, + ILogger logger, IOptions options) + { + _serviceProvider = serviceProvider; + _logger = logger; + _syncInterval = TimeSpan.FromMinutes(options.Value.SyncIntervalMinutes); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + using var scope = _serviceProvider.CreateScope(); + var redisService = scope.ServiceProvider.GetRequiredService(); + await SyncTicketTypeCountersAsync(redisService, stoppingToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error while syncing shopping cart ticket counters"); + } + + await Task.Delay(_syncInterval, stoppingToken); + } + } + + private async Task SyncTicketTypeCountersAsync(IRedisService redisService, CancellationToken cancellationToken) + { + var cartKeys = await redisService.GetKeysByPatternAsync("cart:*"); + var ticketTypeCounts = new Dictionary(); + + foreach (var cartKey in cartKeys) + { + var cart = await redisService.GetObjectAsync(cartKey); + if (cart == null) continue; + + foreach (var ticket in cart.NewTickets) + { + if (ticketTypeCounts.ContainsKey(ticket.TicketTypeId)) + ticketTypeCounts[ticket.TicketTypeId] += ticket.Quantity; + else + ticketTypeCounts[ticket.TicketTypeId] = ticket.Quantity; + } + + if (cancellationToken.IsCancellationRequested) return; + } + + foreach (var kvp in ticketTypeCounts) + { + await redisService.SetLongValueAsync($"amount:{kvp.Key}", kvp.Value); + } + + var existingAmountKeys = await redisService.GetKeysByPatternAsync("amount:*"); + + foreach (var key in existingAmountKeys) + { + var typeIdStr = key.Split(":").Last(); + if (!Guid.TryParse(typeIdStr, out var ticketTypeId)) continue; + + if (!ticketTypeCounts.ContainsKey(ticketTypeId) || ticketTypeCounts[ticketTypeId] == 0) + { + await redisService.DeleteKeyAsync(key); + } + + if (cancellationToken.IsCancellationRequested) return; + } + + _logger.LogInformation("Synchronized ticket counters for {Count} ticket types", ticketTypeCounts.Count); + } +} diff --git a/TickAPI/TickAPI/ShoppingCarts/Controllers/ShoppingCartsController.cs b/TickAPI/TickAPI/ShoppingCarts/Controllers/ShoppingCartsController.cs new file mode 100644 index 0000000..373d939 --- /dev/null +++ b/TickAPI/TickAPI/ShoppingCarts/Controllers/ShoppingCartsController.cs @@ -0,0 +1,109 @@ +using Microsoft.AspNetCore.Mvc; +using TickAPI.Common.Auth.Attributes; +using TickAPI.Common.Auth.Enums; +using TickAPI.Common.Claims.Abstractions; +using TickAPI.Common.Payment.Models; +using TickAPI.ShoppingCarts.Abstractions; +using TickAPI.ShoppingCarts.DTOs.Request; +using TickAPI.ShoppingCarts.DTOs.Response; + +namespace TickAPI.ShoppingCarts.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class ShoppingCartsController : ControllerBase +{ + private readonly IShoppingCartService _shoppingCartService; + private readonly IClaimsService _claimsService; + + public ShoppingCartsController(IShoppingCartService shoppingCartService, IClaimsService claimsService) + { + _shoppingCartService = shoppingCartService; + _claimsService = claimsService; + } + + [AuthorizeWithPolicy(AuthPolicies.CustomerPolicy)] + [HttpPost] + public async Task AddTickets([FromBody] AddNewTicketDto addTicketDto) + { + var emailResult = _claimsService.GetEmailFromClaims(User.Claims); + if (emailResult.IsError) + { + return emailResult.ToObjectResult(); + } + var email = emailResult.Value!; + + var addTicketResult = + await _shoppingCartService.AddNewTicketsToCartAsync(addTicketDto.TicketTypeId, addTicketDto.Amount, + email); + + return addTicketResult.ToObjectResult(); + } + + [AuthorizeWithPolicy(AuthPolicies.CustomerPolicy)] + [HttpGet] + public async Task> GetTickets() + { + var emailResult = _claimsService.GetEmailFromClaims(User.Claims); + if (emailResult.IsError) + { + return emailResult.ToObjectResult(); + } + var email = emailResult.Value!; + + var getTicketsResult = await _shoppingCartService.GetTicketsFromCartAsync(email); + + return getTicketsResult.ToObjectResult(); + } + + [AuthorizeWithPolicy(AuthPolicies.CustomerPolicy)] + [HttpDelete] + public async Task RemoveTickets([FromBody] RemoveNewTicketDto removeTicketDto) + { + var emailResult = _claimsService.GetEmailFromClaims(User.Claims); + if (emailResult.IsError) + { + return emailResult.ToObjectResult(); + } + var email = emailResult.Value!; + + var removeTicketResult = + await _shoppingCartService.RemoveNewTicketsFromCartAsync(removeTicketDto.TicketTypeId, removeTicketDto.Amount, + email); + + return removeTicketResult.ToObjectResult(); + } + + [AuthorizeWithPolicy(AuthPolicies.CustomerPolicy)] + [HttpGet("due")] + public async Task>> GetDueAmount() + { + var emailResult = _claimsService.GetEmailFromClaims(User.Claims); + if (emailResult.IsError) + { + return emailResult.ToObjectResult(); + } + var email = emailResult.Value!; + + var dueAmountResult = await _shoppingCartService.GetDueAmountAsync(email); + + return dueAmountResult.ToObjectResult(); + } + + [AuthorizeWithPolicy(AuthPolicies.CustomerPolicy)] + [HttpPost("checkout")] + public async Task> Checkout([FromBody] CheckoutDto checkoutDto) + { + var emailResult = _claimsService.GetEmailFromClaims(User.Claims); + if (emailResult.IsError) + { + return emailResult.ToObjectResult(); + } + var email = emailResult.Value!; + + var checkoutResult = await _shoppingCartService.CheckoutAsync(email, checkoutDto.Amount, checkoutDto.Currency, + checkoutDto.CardNumber, checkoutDto.CardExpiry, checkoutDto.Cvv); + + return checkoutResult.ToObjectResult(); + } +} \ No newline at end of file diff --git a/TickAPI/TickAPI/ShoppingCarts/DTOs/Request/AddNewTicketDto.cs b/TickAPI/TickAPI/ShoppingCarts/DTOs/Request/AddNewTicketDto.cs new file mode 100644 index 0000000..48c3d8e --- /dev/null +++ b/TickAPI/TickAPI/ShoppingCarts/DTOs/Request/AddNewTicketDto.cs @@ -0,0 +1,6 @@ +namespace TickAPI.ShoppingCarts.DTOs.Request; + +public record AddNewTicketDto( + Guid TicketTypeId, + uint Amount +); \ No newline at end of file diff --git a/TickAPI/TickAPI/ShoppingCarts/DTOs/Request/CheckoutDto.cs b/TickAPI/TickAPI/ShoppingCarts/DTOs/Request/CheckoutDto.cs new file mode 100644 index 0000000..aae0e68 --- /dev/null +++ b/TickAPI/TickAPI/ShoppingCarts/DTOs/Request/CheckoutDto.cs @@ -0,0 +1,9 @@ +namespace TickAPI.ShoppingCarts.DTOs.Request; + +public record CheckoutDto( + decimal Amount, + string Currency, + string CardNumber, + string CardExpiry, + string Cvv +); \ No newline at end of file diff --git a/TickAPI/TickAPI/ShoppingCarts/DTOs/Request/RemoveNewTicketDto.cs b/TickAPI/TickAPI/ShoppingCarts/DTOs/Request/RemoveNewTicketDto.cs new file mode 100644 index 0000000..012c376 --- /dev/null +++ b/TickAPI/TickAPI/ShoppingCarts/DTOs/Request/RemoveNewTicketDto.cs @@ -0,0 +1,6 @@ +namespace TickAPI.ShoppingCarts.DTOs.Request; + +public record RemoveNewTicketDto( + Guid TicketTypeId, + uint Amount +); \ No newline at end of file diff --git a/TickAPI/TickAPI/ShoppingCarts/DTOs/Response/GetShoppingCartTicketsNewTicketDetailsResponseDto.cs b/TickAPI/TickAPI/ShoppingCarts/DTOs/Response/GetShoppingCartTicketsNewTicketDetailsResponseDto.cs new file mode 100644 index 0000000..579a865 --- /dev/null +++ b/TickAPI/TickAPI/ShoppingCarts/DTOs/Response/GetShoppingCartTicketsNewTicketDetailsResponseDto.cs @@ -0,0 +1,11 @@ +namespace TickAPI.ShoppingCarts.DTOs.Response; + +public record GetShoppingCartTicketsNewTicketDetailsResponseDto( + Guid TicketTypeId, + string EventName, + string TicketType, + string OrganizerName, + uint Quantity, + decimal UnitPrice, + string Currency +); \ No newline at end of file diff --git a/TickAPI/TickAPI/ShoppingCarts/DTOs/Response/GetShoppingCartTicketsResellTicketDetailsResponseDto.cs b/TickAPI/TickAPI/ShoppingCarts/DTOs/Response/GetShoppingCartTicketsResellTicketDetailsResponseDto.cs new file mode 100644 index 0000000..a56fc13 --- /dev/null +++ b/TickAPI/TickAPI/ShoppingCarts/DTOs/Response/GetShoppingCartTicketsResellTicketDetailsResponseDto.cs @@ -0,0 +1,11 @@ +namespace TickAPI.ShoppingCarts.DTOs.Response; + +public record GetShoppingCartTicketsResellTicketDetailsResponseDto( + Guid TicketId, + string EventName, + string TicketType, + string OrganizerName, + string OriginalOwnerEmail, + decimal Price, + string Currency +); \ No newline at end of file diff --git a/TickAPI/TickAPI/ShoppingCarts/DTOs/Response/GetShoppingCartTicketsResponseDto.cs b/TickAPI/TickAPI/ShoppingCarts/DTOs/Response/GetShoppingCartTicketsResponseDto.cs new file mode 100644 index 0000000..3f553d6 --- /dev/null +++ b/TickAPI/TickAPI/ShoppingCarts/DTOs/Response/GetShoppingCartTicketsResponseDto.cs @@ -0,0 +1,6 @@ +namespace TickAPI.ShoppingCarts.DTOs.Response; + +public record GetShoppingCartTicketsResponseDto( + List NewTickets, + List ResellTickets +); \ No newline at end of file diff --git a/TickAPI/TickAPI/ShoppingCarts/Mappers/ShoppingCartMapper.cs b/TickAPI/TickAPI/ShoppingCarts/Mappers/ShoppingCartMapper.cs new file mode 100644 index 0000000..9e75a25 --- /dev/null +++ b/TickAPI/TickAPI/ShoppingCarts/Mappers/ShoppingCartMapper.cs @@ -0,0 +1,36 @@ +using TickAPI.ShoppingCarts.DTOs.Response; +using TickAPI.Tickets.Models; +using TickAPI.TicketTypes.Models; + +namespace TickAPI.ShoppingCarts.Mappers; + +public static class ShoppingCartMapper +{ + public static GetShoppingCartTicketsNewTicketDetailsResponseDto + MapTicketTypeToGetShoppingCartTicketsNewTicketDetailsResponseDto(TicketType type, uint quantity) + { + return new GetShoppingCartTicketsNewTicketDetailsResponseDto( + type.Id, + type.Event.Name, + type.Description, + type.Event.Organizer.DisplayName, + quantity, + type.Price, + type.Currency + ); + } + + public static GetShoppingCartTicketsResellTicketDetailsResponseDto + MapTicketToGetShoppingCartTicketsResellTicketDetailsResponseDto(Ticket ticket) + { + return new GetShoppingCartTicketsResellTicketDetailsResponseDto( + ticket.Id, + ticket.Type.Event.Name, + ticket.Type.Description, + ticket.Type.Event.Organizer.DisplayName, + ticket.Owner.Email, + ticket.Type.Price, + ticket.Type.Currency + ); + } +} \ No newline at end of file diff --git a/TickAPI/TickAPI/ShoppingCarts/Models/ShoppingCart.cs b/TickAPI/TickAPI/ShoppingCarts/Models/ShoppingCart.cs new file mode 100644 index 0000000..4d480eb --- /dev/null +++ b/TickAPI/TickAPI/ShoppingCarts/Models/ShoppingCart.cs @@ -0,0 +1,9 @@ +using TickAPI.Tickets.Models; + +namespace TickAPI.ShoppingCarts.Models; + +public class ShoppingCart +{ + public List NewTickets { get; set; } = []; + public List ResellTickets { get; set; } = []; +} \ No newline at end of file diff --git a/TickAPI/TickAPI/ShoppingCarts/Options/ShoppingCartOptions.cs b/TickAPI/TickAPI/ShoppingCarts/Options/ShoppingCartOptions.cs new file mode 100644 index 0000000..c2f03a2 --- /dev/null +++ b/TickAPI/TickAPI/ShoppingCarts/Options/ShoppingCartOptions.cs @@ -0,0 +1,7 @@ +namespace TickAPI.ShoppingCarts.Options; + +public class ShoppingCartOptions +{ + public int SyncIntervalMinutes { get; set; } + public int LifetimeMinutes { get; set; } +} \ No newline at end of file diff --git a/TickAPI/TickAPI/ShoppingCarts/Repositories/ShoppingCartRepository.cs b/TickAPI/TickAPI/ShoppingCarts/Repositories/ShoppingCartRepository.cs new file mode 100644 index 0000000..493afa5 --- /dev/null +++ b/TickAPI/TickAPI/ShoppingCarts/Repositories/ShoppingCartRepository.cs @@ -0,0 +1,266 @@ +using Microsoft.Extensions.Options; +using TickAPI.Common.Redis.Abstractions; +using TickAPI.Common.Results; +using TickAPI.Common.Results.Generic; +using TickAPI.ShoppingCarts.Abstractions; +using TickAPI.ShoppingCarts.Models; +using TickAPI.ShoppingCarts.Options; +using TickAPI.Tickets.Models; + +namespace TickAPI.ShoppingCarts.Repositories; + +public class ShoppingCartRepository : IShoppingCartRepository +{ + private readonly IRedisService _redisService; + private readonly TimeSpan _defaultExpiry; + + public ShoppingCartRepository(IRedisService redisService, IOptions options) + { + _redisService = redisService; + _defaultExpiry = TimeSpan.FromMinutes(options.Value.LifetimeMinutes); + } + + public async Task> GetShoppingCartByEmailAsync(string customerEmail) + { + var cartKey = GetCartKey(customerEmail); + ShoppingCart? cart; + + try + { + cart = await _redisService.GetObjectAsync(cartKey); + await _redisService.KeyExpireAsync(cartKey, _defaultExpiry); + } + catch (Exception e) + { + return Result.Failure(StatusCodes.Status500InternalServerError, e.Message); + } + + return Result.Success(cart ?? new ShoppingCart()); + } + + public async Task UpdateShoppingCartAsync(string customerEmail, ShoppingCart shoppingCart) + { + var cartKey = GetCartKey(customerEmail); + + try + { + var res = await _redisService.SetObjectAsync(cartKey, shoppingCart, _defaultExpiry); + if (!res) + { + return Result.Failure(StatusCodes.Status500InternalServerError, "the shopping cart could not be updated"); + } + } + catch (Exception e) + { + return Result.Failure(StatusCodes.Status500InternalServerError, e.Message); + } + + return Result.Success(); + } + + public async Task AddNewTicketsToCartAsync(string customerEmail, Guid ticketTypeId, uint amount) + { + if (amount == 0) + { + return Result.Failure(StatusCodes.Status400BadRequest, "amount of bought tickets must be greater than 0"); + } + + var getShoppingCartResult = await GetShoppingCartByEmailAsync(customerEmail); + + if (getShoppingCartResult.IsError) + { + return Result.PropagateError(getShoppingCartResult); + } + + var cart = getShoppingCartResult.Value!; + + var existingEntry = cart.NewTickets.FirstOrDefault(t => t.TicketTypeId == ticketTypeId); + + if (existingEntry != null) + { + existingEntry.Quantity += amount; + } + else + { + cart.NewTickets.Add(new ShoppingCartNewTicket + { + TicketTypeId = ticketTypeId, + Quantity = amount + }); + } + + var incrementTicketAmountResult = await IncrementAmountOfTicketTypeAsync(ticketTypeId, amount); + + if (incrementTicketAmountResult.IsError) + { + return Result.PropagateError(incrementTicketAmountResult); + } + + var updateShoppingCartResult = await UpdateShoppingCartAsync(customerEmail, cart); + + if (updateShoppingCartResult.IsError) + { + return Result.PropagateError(updateShoppingCartResult); + } + + return Result.Success(); + } + + public async Task RemoveNewTicketsFromCartAsync(string customerEmail, Guid ticketTypeId, uint amount) + { + if (amount == 0) + { + return Result.Failure(StatusCodes.Status400BadRequest, "amount of removed tickets must be greater than 0"); + } + + var getShoppingCartResult = await GetShoppingCartByEmailAsync(customerEmail); + + if (getShoppingCartResult.IsError) + { + return Result.PropagateError(getShoppingCartResult); + } + + var cart = getShoppingCartResult.Value!; + + var existingEntry = cart.NewTickets.FirstOrDefault(t => t.TicketTypeId == ticketTypeId); + + if (existingEntry is null) + { + return Result.Failure(StatusCodes.Status404NotFound, "the shopping cart does not contain a ticket of this type"); + } + + if (existingEntry.Quantity < amount) + { + return Result.Failure(StatusCodes.Status400BadRequest, + $"the shopping cart does not contain {amount} tickets of this type"); + } + + existingEntry.Quantity -= amount; + + if (existingEntry.Quantity == 0) + { + cart.NewTickets.Remove(existingEntry); + } + + var decrementTicketAmountResult = await DecrementAmountOfTicketTypeAsync(ticketTypeId, amount); + + if (decrementTicketAmountResult.IsError) + { + return Result.PropagateError(decrementTicketAmountResult); + } + + var updateShoppingCartResult = await UpdateShoppingCartAsync(customerEmail, cart); + + if (updateShoppingCartResult.IsError) + { + return Result.PropagateError(updateShoppingCartResult); + } + + return Result.Success(); + } + + public async Task> GetAmountOfTicketTypeAsync(Guid ticketTypeId) + { + long? amount; + + try + { + amount = await _redisService.GetLongValueAsync(GetAmountKey(ticketTypeId)); + } + catch (Exception e) + { + return Result.Failure(StatusCodes.Status500InternalServerError, e.Message); + } + + if (amount is null) + { + return Result.Success(0); + } + + return Result.Success(amount.Value); + } + + public async Task SetAmountOfTicketTypeAsync(Guid ticketTypeId, long amount) + { + bool success; + + try + { + success = await _redisService.SetLongValueAsync(GetAmountKey(ticketTypeId), amount); + } + catch (Exception e) + { + return Result.Failure(StatusCodes.Status500InternalServerError, e.Message); + } + + if (!success) + { + return Result.Failure(StatusCodes.Status500InternalServerError, "the amount of tickets could not be updated"); + } + + return Result.Success(); + } + + public async Task> IncrementAmountOfTicketTypeAsync(Guid ticketTypeId, long amount) + { + long? newAmount; + + try + { + newAmount = await _redisService.IncrementValueAsync(GetAmountKey(ticketTypeId), amount); + } + catch (Exception e) + { + return Result.Failure(StatusCodes.Status500InternalServerError, e.Message); + } + + return Result.Success(newAmount.Value); + } + + public async Task> DecrementAmountOfTicketTypeAsync(Guid ticketTypeId, long amount) + { + long? newAmount; + + try + { + newAmount = await _redisService.DecrementValueAsync(GetAmountKey(ticketTypeId), amount); + } + catch (Exception e) + { + return Result.Failure(StatusCodes.Status500InternalServerError, e.Message); + } + + return Result.Success(newAmount.Value); + } + + public async Task RemoveAmountOfTicketTypeAsync(Guid ticketTypeId) + { + bool success; + + try + { + success = await _redisService.DeleteKeyAsync(GetAmountKey(ticketTypeId)); + } + catch (Exception e) + { + return Result.Failure(StatusCodes.Status500InternalServerError, e.Message); + } + + if (!success) + { + return Result.Failure(StatusCodes.Status500InternalServerError, "the amount of tickets could not be updated"); + } + + return Result.Success(); + } + + private static string GetCartKey(string customerEmail) + { + return $"cart:{customerEmail}"; + } + + private static string GetAmountKey(Guid ticketTypeId) + { + return $"amount:{ticketTypeId}"; + } +} \ No newline at end of file diff --git a/TickAPI/TickAPI/ShoppingCarts/Services/ShoppingCartService.cs b/TickAPI/TickAPI/ShoppingCarts/Services/ShoppingCartService.cs new file mode 100644 index 0000000..b4032e5 --- /dev/null +++ b/TickAPI/TickAPI/ShoppingCarts/Services/ShoppingCartService.cs @@ -0,0 +1,251 @@ +using Google.Apis.Auth.OAuth2.Web; +using TickAPI.Common.Payment.Abstractions; +using TickAPI.Common.Payment.Models; +using TickAPI.Common.Results; +using TickAPI.Common.Results.Generic; +using TickAPI.Customers.Abstractions; +using TickAPI.Events.Models; +using TickAPI.ShoppingCarts.Abstractions; +using TickAPI.ShoppingCarts.DTOs.Response; +using TickAPI.ShoppingCarts.Mappers; +using TickAPI.Tickets.Abstractions; +using TickAPI.Tickets.Models; +using TickAPI.TicketTypes.Abstractions; + +namespace TickAPI.ShoppingCarts.Services; + +public class ShoppingCartService : IShoppingCartService +{ + private readonly IShoppingCartRepository _shoppingCartRepository; + private readonly ICustomerRepository _customerRepository; + private readonly ITicketService _ticketService; + private readonly IPaymentGatewayService _paymentGatewayService; + + public ShoppingCartService(IShoppingCartRepository shoppingCartRepository, ICustomerRepository customerRepository, + ITicketService ticketService, IPaymentGatewayService paymentGatewayService) + { + _shoppingCartRepository = shoppingCartRepository; + _customerRepository = customerRepository; + _ticketService = ticketService; + _paymentGatewayService = paymentGatewayService; + } + + public async Task AddNewTicketsToCartAsync(Guid ticketTypeId, uint amount, string customerEmail) + { + var availabilityResult = await _ticketService.CheckTicketAvailabilityByTypeIdAsync(ticketTypeId, amount); + + if (availabilityResult.IsError) + { + return Result.PropagateError(availabilityResult); + } + + if (!availabilityResult.Value) + { + return Result.Failure(StatusCodes.Status400BadRequest, $"not enough available tickets of type {ticketTypeId}"); + } + + var addTicketsToCartResult = await _shoppingCartRepository.AddNewTicketsToCartAsync(customerEmail, ticketTypeId, amount); + + if (addTicketsToCartResult.IsError) + { + return Result.PropagateError(addTicketsToCartResult); + } + + return Result.Success(); + } + + public async Task> GetTicketsFromCartAsync(string customerEmail) + { + var getShoppingCartResult = await _shoppingCartRepository.GetShoppingCartByEmailAsync(customerEmail); + + if (getShoppingCartResult.IsError) + { + return Result.PropagateError(getShoppingCartResult); + } + + var cart = getShoppingCartResult.Value!; + + var newTickets = new List(); + + foreach (var ticket in cart.NewTickets) + { + var newTicketResult = await _ticketService.GetTicketTypeByIdAsync(ticket.TicketTypeId); + + if (newTicketResult.IsError) + { + return Result.PropagateError(newTicketResult); + } + + var newTicket = + ShoppingCartMapper.MapTicketTypeToGetShoppingCartTicketsNewTicketDetailsResponseDto( + newTicketResult.Value!, ticket.Quantity); + + newTickets.Add(newTicket); + } + + // TODO: Add resell ticket parsing + + var result = new GetShoppingCartTicketsResponseDto(newTickets, []); + + return Result.Success(result); + } + + public async Task RemoveNewTicketsFromCartAsync(Guid ticketTypeId, uint amount, string customerEmail) + { + var removeTicketsFromCartResult = await _shoppingCartRepository.RemoveNewTicketsFromCartAsync(customerEmail, ticketTypeId, amount); + + if (removeTicketsFromCartResult.IsError) + { + return Result.PropagateError(removeTicketsFromCartResult); + } + + return Result.Success(); + } + + public async Task>> GetDueAmountAsync(string customerEmail) + { + var getShoppingCartResult = await _shoppingCartRepository.GetShoppingCartByEmailAsync(customerEmail); + + if (getShoppingCartResult.IsError) + { + return Result>.PropagateError(getShoppingCartResult); + } + + var cart = getShoppingCartResult.Value!; + + Dictionary dueAmount = new Dictionary(); + + foreach (var newTicket in cart.NewTickets) + { + var ticketTypeResult = await _ticketService.GetTicketTypeByIdAsync(newTicket.TicketTypeId); + + if (ticketTypeResult.IsError) + { + return Result>.PropagateError(ticketTypeResult); + } + + var ticketType = ticketTypeResult.Value!; + + if(dueAmount.ContainsKey(ticketType.Currency)) + { + dueAmount[ticketType.Currency] += newTicket.Quantity * ticketType.Price; + } + else + { + dueAmount.Add(ticketType.Currency, newTicket.Quantity * ticketType.Price); + } + } + + // TODO: Add resell tickets to the calculations + + return Result>.Success(dueAmount); + } + + public async Task> CheckoutAsync(string customerEmail, decimal amount, string currency, + string cardNumber, string cardExpiry, string cvv) + { + var dueAmountResult = await GetDueAmountAsync(customerEmail); + + if (dueAmountResult.IsError) + { + return Result.PropagateError(dueAmountResult); + } + + var currencyExists = dueAmountResult.Value!.TryGetValue(currency, out var dueAmount); + + if (!currencyExists) + { + return Result.Failure(StatusCodes.Status400BadRequest, + $"no tickets paid in {currency} found in cart"); + } + + if (dueAmount != amount) + { + return Result.Failure(StatusCodes.Status400BadRequest, + $"the given amount {amount} {currency} is different than the expected amount of {dueAmount} {currency}"); + } + + var paymentResult = + await _paymentGatewayService.ProcessPayment(new PaymentRequestPG(amount, currency, cardNumber, cardExpiry, + cvv, false)); + + if (paymentResult.IsError) + { + return Result.PropagateError(paymentResult); + } + + var generateTicketsResult = await GenerateBoughtTicketsAsync(customerEmail, currency); + // TODO: Add passing ownership of resell tickets + + if (generateTicketsResult.IsError) + { + return Result.PropagateError(generateTicketsResult); + } + + var payment = paymentResult.Value!; + + return Result.Success(payment); + } + + private async Task GenerateBoughtTicketsAsync(string customerEmail, string currency) + { + var getShoppingCartResult = await _shoppingCartRepository.GetShoppingCartByEmailAsync(customerEmail); + + if (getShoppingCartResult.IsError) + { + return Result.PropagateError(getShoppingCartResult); + } + + var cart = getShoppingCartResult.Value!; + + var getCustomerResult = await _customerRepository.GetCustomerByEmailAsync(customerEmail); + + if (getCustomerResult.IsError) + { + return Result.PropagateError(getCustomerResult); + } + + var owner = getCustomerResult.Value!; + var removals = new List<(Guid id, uint amount)>(); + + foreach (var ticket in cart.NewTickets) + { + var ticketTypeResult = await _ticketService.GetTicketTypeByIdAsync(ticket.TicketTypeId); + + if (ticketTypeResult.IsError) + { + return Result.PropagateError(ticketTypeResult); + } + + var type = ticketTypeResult.Value!; + + if (type.Currency == currency) + { + removals.Add((ticket.TicketTypeId, ticket.Quantity)); + + for (var i = 0; i < ticket.Quantity; i++) + { + // TODO: add seats/name on ticket setting + var createTicketResult = await _ticketService.CreateTicketAsync(type, owner); + + if (createTicketResult.IsError) + { + return Result.PropagateError(createTicketResult); + } + } + } + } + + foreach (var (id, amount) in removals) + { + var removalResult = await RemoveNewTicketsFromCartAsync(id, amount, customerEmail); + + if (removalResult.IsError) + { + return Result.PropagateError(removalResult); + } + } + + return Result.Success(); + } +} \ No newline at end of file diff --git a/TickAPI/TickAPI/TickAPI.csproj b/TickAPI/TickAPI/TickAPI.csproj index a4ca3a8..fb79365 100644 --- a/TickAPI/TickAPI/TickAPI.csproj +++ b/TickAPI/TickAPI/TickAPI.csproj @@ -18,6 +18,8 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + + diff --git a/TickAPI/TickAPI/TicketTypes/Abstractions/ITicketTypeRepository.cs b/TickAPI/TickAPI/TicketTypes/Abstractions/ITicketTypeRepository.cs new file mode 100644 index 0000000..f9fba2f --- /dev/null +++ b/TickAPI/TickAPI/TicketTypes/Abstractions/ITicketTypeRepository.cs @@ -0,0 +1,10 @@ +using TickAPI.Common.Results; +using TickAPI.Common.Results.Generic; +using TickAPI.TicketTypes.Models; + +namespace TickAPI.TicketTypes.Abstractions; + +public interface ITicketTypeRepository +{ + public Task> GetTicketTypeByIdAsync(Guid ticketTypeId); +} \ No newline at end of file diff --git a/TickAPI/TickAPI/TicketTypes/Repositories/TicketTypeRepository.cs b/TickAPI/TickAPI/TicketTypes/Repositories/TicketTypeRepository.cs new file mode 100644 index 0000000..929e471 --- /dev/null +++ b/TickAPI/TickAPI/TicketTypes/Repositories/TicketTypeRepository.cs @@ -0,0 +1,34 @@ +using Microsoft.EntityFrameworkCore; +using TickAPI.Common.Results; +using TickAPI.Common.Results.Generic; +using TickAPI.Common.TickApiDbContext; +using TickAPI.TicketTypes.Abstractions; +using TickAPI.TicketTypes.Models; + +namespace TickAPI.TicketTypes.Repositories; + +public class TicketTypeRepository : ITicketTypeRepository +{ + private readonly TickApiDbContext _tickApiDbContext; + + public TicketTypeRepository(TickApiDbContext tickApiDbContext) + { + _tickApiDbContext = tickApiDbContext; + } + + public async Task> GetTicketTypeByIdAsync(Guid ticketTypeId) + { + var ticketType = await + _tickApiDbContext.TicketTypes + .Include(t => t.Event) + .Include(t => t.Event.Organizer) + .FirstOrDefaultAsync(t => t.Id == ticketTypeId); + + if (ticketType == null) + { + return Result.Failure(StatusCodes.Status404NotFound,$"ticket type with id {ticketTypeId} not found"); + } + + return Result.Success(ticketType); + } +} \ No newline at end of file diff --git a/TickAPI/TickAPI/Tickets/Abstractions/ITicketFilter.cs b/TickAPI/TickAPI/Tickets/Abstractions/ITicketFilter.cs new file mode 100644 index 0000000..56dc48e --- /dev/null +++ b/TickAPI/TickAPI/Tickets/Abstractions/ITicketFilter.cs @@ -0,0 +1,13 @@ +using TickAPI.Tickets.Models; + +namespace TickAPI.Tickets.Abstractions; + +public interface ITicketFilter +{ + public IQueryable GetTickets(); + public void FilterUsedTickets(); + public void FilterUnusedTickets(); + public void FilterTicketsForResell(); + public void FilterTicketsNotForResell(); + public void FilterTicketsByEventName(string name); +} \ No newline at end of file diff --git a/TickAPI/TickAPI/Tickets/Abstractions/ITicketFilterApplier.cs b/TickAPI/TickAPI/Tickets/Abstractions/ITicketFilterApplier.cs new file mode 100644 index 0000000..1c76a38 --- /dev/null +++ b/TickAPI/TickAPI/Tickets/Abstractions/ITicketFilterApplier.cs @@ -0,0 +1,9 @@ +using TickAPI.Tickets.DTOs.Request; +using TickAPI.Tickets.Models; + +namespace TickAPI.Tickets.Abstractions; + +public interface ITicketFilterApplier +{ + public IQueryable ApplyFilters(TicketFiltersDto filters); +} \ No newline at end of file diff --git a/TickAPI/TickAPI/Tickets/Abstractions/ITicketRepository.cs b/TickAPI/TickAPI/Tickets/Abstractions/ITicketRepository.cs index d3220d8..4add328 100644 --- a/TickAPI/TickAPI/Tickets/Abstractions/ITicketRepository.cs +++ b/TickAPI/TickAPI/Tickets/Abstractions/ITicketRepository.cs @@ -1,4 +1,6 @@ -using TickAPI.Tickets.Models; +using TickAPI.Common.Results; +using TickAPI.Common.Results.Generic; +using TickAPI.Tickets.Models; using TickAPI.TicketTypes.Models; namespace TickAPI.Tickets.Abstractions; @@ -6,4 +8,10 @@ namespace TickAPI.Tickets.Abstractions; public interface ITicketRepository { public IQueryable GetAllTicketsByTicketType(TicketType ticketType); + public Task> GetTicketWithDetailsByIdAndEmailAsync(Guid id, string email); + public IQueryable GetTicketsByEventId(Guid eventId); + public IQueryable GetTicketsByCustomerEmail(string email); + public Task MarkTicketAsUsed(Guid id); + public Task SetTicketForResell(Guid ticketId, decimal newPrice, string currency); + public Task AddTicketAsync(Ticket ticket); } \ No newline at end of file diff --git a/TickAPI/TickAPI/Tickets/Abstractions/ITicketService.cs b/TickAPI/TickAPI/Tickets/Abstractions/ITicketService.cs index 85e9ac5..1fce588 100644 --- a/TickAPI/TickAPI/Tickets/Abstractions/ITicketService.cs +++ b/TickAPI/TickAPI/Tickets/Abstractions/ITicketService.cs @@ -1,9 +1,28 @@ -using TickAPI.Common.Results.Generic; +using TickAPI.Common.Pagination.Responses; +using TickAPI.Common.Results; +using TickAPI.Common.Results.Generic; +using TickAPI.Customers.Models; +using TickAPI.Tickets.DTOs.Request; +using TickAPI.Tickets.DTOs.Response; using TickAPI.TicketTypes.Models; namespace TickAPI.Tickets.Abstractions; public interface ITicketService { - public Result GetNumberOfAvailableTicketsByType(TicketType ticketType); + public Task> GetNumberOfAvailableTicketsByTypeAsync(TicketType ticketType); + public Task> GetNumberOfAvailableTicketsByTypeIdAsync(Guid ticketTypeId); + public Task> CheckTicketAvailabilityByTypeIdAsync(Guid ticketTypeId, uint amount); + public Task>> GetTicketsForResellAsync(Guid eventId, int page, + int pageSize); + public Task>> GetTicketsForCustomerAsync(string email, int page, + int pageSize, TicketFiltersDto ? ticketFilters = null); + public Task ScanTicket(Guid ticketGuid); + public Task> GetTicketDetailsAsync(Guid ticketGuid, string email, + string scanUrl); + + public Task SetTicketForResellAsync(Guid ticketId, string email, decimal resellPrice, string resellCurrency); + public Task> GetTicketTypeByIdAsync(Guid ticketTypeId); + public Task CreateTicketAsync(TicketType type, Customer owner, string? nameOnTicket = null, + string? seats = null); } \ No newline at end of file diff --git a/TickAPI/TickAPI/Tickets/Controllers/TicketsController.cs b/TickAPI/TickAPI/Tickets/Controllers/TicketsController.cs index fcf4467..54209b7 100644 --- a/TickAPI/TickAPI/Tickets/Controllers/TicketsController.cs +++ b/TickAPI/TickAPI/Tickets/Controllers/TicketsController.cs @@ -1,4 +1,12 @@ using Microsoft.AspNetCore.Mvc; +using TickAPI.Common.Auth.Attributes; +using TickAPI.Common.Auth.Enums; +using TickAPI.Common.Claims.Abstractions; +using TickAPI.Tickets.Abstractions; +using TickAPI.Tickets.DTOs.Response; +using TickAPI.Common.Pagination.Responses; +using TickAPI.Common.Results; +using TickAPI.Tickets.DTOs.Request; namespace TickAPI.Tickets.Controllers; @@ -6,5 +14,67 @@ namespace TickAPI.Tickets.Controllers; [Route("api/[controller]")] public class TicketsController : ControllerBase { + private readonly IClaimsService _claimsService; + private readonly ITicketService _ticketService; + public TicketsController(IClaimsService claimsService, ITicketService ticketService) + { + _claimsService = claimsService; + _ticketService = ticketService; + } + + [AuthorizeWithPolicy(AuthPolicies.CustomerPolicy)] + [HttpGet("{id:guid}")] + public async Task> GetTicketDetails(Guid id) + { + var emailResult = _claimsService.GetEmailFromClaims(User.Claims); + if (emailResult.IsError) + { + return emailResult.ToObjectResult(); + } + var email = emailResult.Value!; + string? scanTicketUrl = Url.Action("ScanTicket", "Tickets", new { id = id }, Request.Scheme); + var ticket = await _ticketService.GetTicketDetailsAsync(id, email, scanTicketUrl!); + return ticket.ToObjectResult(); + } + + [HttpGet("for-resell")] + public async Task>> GetTicketsForResell([FromQuery] Guid eventId, [FromQuery] int pageSize, [FromQuery] int page) + { + var result = await _ticketService.GetTicketsForResellAsync(eventId, page, pageSize); + return result.ToObjectResult(); + } + + [AuthorizeWithPolicy(AuthPolicies.CustomerPolicy)] + [HttpGet] + public async Task>> GetTicketsForCustomer([FromQuery] int pageSize, [FromQuery] int page, [FromQuery] TicketFiltersDto filters) + { + var emailResult = _claimsService.GetEmailFromClaims(User.Claims); + if (emailResult.IsError) + { + return emailResult.ToObjectResult(); + } + var tickets = await _ticketService.GetTicketsForCustomerAsync(emailResult.Value!, page, pageSize, filters); + return tickets.ToObjectResult(); + } + + [HttpGet("scan/{id:guid}")] + public async Task> ScanTicket(Guid id) + { + var res = await _ticketService.ScanTicket(id); + return res.ToObjectResult(); + } + + [AuthorizeWithPolicy(AuthPolicies.CustomerPolicy)] + [HttpPost("resell/{id:guid}")] + public async Task> SetTicketForResell([FromRoute] Guid id, [FromBody] SetTicketForResellDataDto data) + { + var emailResult = _claimsService.GetEmailFromClaims(User.Claims); + if (emailResult.IsError) + { + return emailResult.ToObjectResult(); + } + var res = await _ticketService.SetTicketForResellAsync(id, emailResult.Value!, data.ResellPrice, data.ResellCurrency); + return res.ToObjectResult(); + } } \ No newline at end of file diff --git a/TickAPI/TickAPI/Tickets/DTOs/Request/SetTicketForResellDataDto.cs b/TickAPI/TickAPI/Tickets/DTOs/Request/SetTicketForResellDataDto.cs new file mode 100644 index 0000000..7ee6d82 --- /dev/null +++ b/TickAPI/TickAPI/Tickets/DTOs/Request/SetTicketForResellDataDto.cs @@ -0,0 +1,7 @@ +namespace TickAPI.Tickets.DTOs.Request; + +public record SetTicketForResellDataDto +( + decimal ResellPrice, + string ResellCurrency +); \ No newline at end of file diff --git a/TickAPI/TickAPI/Tickets/DTOs/Request/TicketFiltersDto.cs b/TickAPI/TickAPI/Tickets/DTOs/Request/TicketFiltersDto.cs new file mode 100644 index 0000000..19cbf47 --- /dev/null +++ b/TickAPI/TickAPI/Tickets/DTOs/Request/TicketFiltersDto.cs @@ -0,0 +1,20 @@ +namespace TickAPI.Tickets.DTOs.Request; + +public enum UsageFilter +{ + OnlyUsed, + OnlyNotUsed +} + +public enum ResellFilter +{ + OnlyForResell, + OnlyNotForResell +} + +public record TicketFiltersDto +( + UsageFilter? Usage, + ResellFilter? Resell, + string? EventName +); \ No newline at end of file diff --git a/TickAPI/TickAPI/Tickets/DTOs/Response/GetTicketDetailsAddressDto.cs b/TickAPI/TickAPI/Tickets/DTOs/Response/GetTicketDetailsAddressDto.cs new file mode 100644 index 0000000..c6dd05c --- /dev/null +++ b/TickAPI/TickAPI/Tickets/DTOs/Response/GetTicketDetailsAddressDto.cs @@ -0,0 +1,10 @@ +namespace TickAPI.Tickets.DTOs.Response; + +public record GetTicketDetailsAddressDto( + string Country, + string City, + string PostalCode, + string? Street, + uint? HouseNumber, + uint? FlatNumber +); \ No newline at end of file diff --git a/TickAPI/TickAPI/Tickets/DTOs/Response/GetTicketDetailsResponseDto.cs b/TickAPI/TickAPI/Tickets/DTOs/Response/GetTicketDetailsResponseDto.cs new file mode 100644 index 0000000..17f4fc5 --- /dev/null +++ b/TickAPI/TickAPI/Tickets/DTOs/Response/GetTicketDetailsResponseDto.cs @@ -0,0 +1,18 @@ +using TickAPI.Addresses.Models; + +namespace TickAPI.Tickets.DTOs.Response; + +public record GetTicketDetailsResponseDto +( + string NameOnTicket, + string? Seats, + decimal Price, + string Currency, + string EventName, + string OrganizerName, + DateTime StartDate, + DateTime EndDate, + GetTicketDetailsAddressDto Address, + Guid eventId, + string qrcode +); \ No newline at end of file diff --git a/TickAPI/TickAPI/Tickets/DTOs/Response/GetTicketForCustomerDto.cs b/TickAPI/TickAPI/Tickets/DTOs/Response/GetTicketForCustomerDto.cs new file mode 100644 index 0000000..4584d76 --- /dev/null +++ b/TickAPI/TickAPI/Tickets/DTOs/Response/GetTicketForCustomerDto.cs @@ -0,0 +1,10 @@ +namespace TickAPI.Tickets.DTOs.Response; + +public record GetTicketForCustomerDto +( + Guid TicketId, + string EventName, + DateTime EventStartDate, + DateTime EventEndDate, + bool Used +); \ No newline at end of file diff --git a/TickAPI/TickAPI/Tickets/DTOs/Response/GetTicketForResellResponseDto.cs b/TickAPI/TickAPI/Tickets/DTOs/Response/GetTicketForResellResponseDto.cs new file mode 100644 index 0000000..831ad47 --- /dev/null +++ b/TickAPI/TickAPI/Tickets/DTOs/Response/GetTicketForResellResponseDto.cs @@ -0,0 +1,9 @@ +namespace TickAPI.Tickets.DTOs.Response; + +public record GetTicketForResellResponseDto( + Guid Id, + decimal Price, + string Currency, + string Description, + string? Seats +); diff --git a/TickAPI/TickAPI/Tickets/Filters/TicketFilter.cs b/TickAPI/TickAPI/Tickets/Filters/TicketFilter.cs new file mode 100644 index 0000000..3e08cd9 --- /dev/null +++ b/TickAPI/TickAPI/Tickets/Filters/TicketFilter.cs @@ -0,0 +1,44 @@ +using TickAPI.Tickets.Abstractions; +using TickAPI.Tickets.Models; + +namespace TickAPI.Tickets.Filters; + +public class TicketFilter : ITicketFilter +{ + IQueryable _tickets; + + public TicketFilter(IQueryable tickets) + { + _tickets = tickets; + } + + public IQueryable GetTickets() + { + return _tickets; + } + + public void FilterUsedTickets() + { + _tickets = _tickets.Where(t => t.Used); + } + + public void FilterUnusedTickets() + { + _tickets = _tickets.Where(t => !t.Used); + } + + public void FilterTicketsForResell() + { + _tickets = _tickets.Where(t => t.ForResell); + } + + public void FilterTicketsNotForResell() + { + _tickets = _tickets.Where(t => !t.ForResell); + } + + public void FilterTicketsByEventName(string name) + { + _tickets = _tickets.Where(t => t.Type.Event.Name.ToLower().Contains(name.ToLower())); + } +} \ No newline at end of file diff --git a/TickAPI/TickAPI/Tickets/Filters/TicketFilterApplier.cs b/TickAPI/TickAPI/Tickets/Filters/TicketFilterApplier.cs new file mode 100644 index 0000000..2b9bf6e --- /dev/null +++ b/TickAPI/TickAPI/Tickets/Filters/TicketFilterApplier.cs @@ -0,0 +1,37 @@ +using TickAPI.Tickets.Abstractions; +using TickAPI.Tickets.DTOs.Request; +using TickAPI.Tickets.Models; + +namespace TickAPI.Tickets.Filters; + +public class TicketFilterApplier +{ + private readonly ITicketFilter _ticketFilter; + private readonly Dictionary, Action> _filterActions; + + public TicketFilterApplier(ITicketFilter ticketFilter) + { + _ticketFilter = ticketFilter; + _filterActions = new Dictionary, Action> + { + { f => !string.IsNullOrEmpty(f.EventName), f => _ticketFilter.FilterTicketsByEventName(f.EventName!) }, + { f => f.Usage == UsageFilter.OnlyUsed, f => _ticketFilter.FilterUsedTickets() }, + { f => f.Usage == UsageFilter.OnlyNotUsed, f => _ticketFilter.FilterUnusedTickets() }, + { f => f.Resell == ResellFilter.OnlyForResell, f => _ticketFilter.FilterTicketsForResell() }, + { f => f.Resell == ResellFilter.OnlyNotForResell, f => _ticketFilter.FilterTicketsNotForResell() }, + }; + } + + public IQueryable ApplyFilters(TicketFiltersDto filters) + { + foreach (var (condition, apply) in _filterActions) + { + if (condition(filters)) + { + apply(filters); + } + } + + return _ticketFilter.GetTickets(); + } +} \ No newline at end of file diff --git a/TickAPI/TickAPI/Tickets/Models/ShoppingCartNewTicket.cs b/TickAPI/TickAPI/Tickets/Models/ShoppingCartNewTicket.cs new file mode 100644 index 0000000..0ed97d9 --- /dev/null +++ b/TickAPI/TickAPI/Tickets/Models/ShoppingCartNewTicket.cs @@ -0,0 +1,7 @@ +namespace TickAPI.Tickets.Models; + +public class ShoppingCartNewTicket +{ + public Guid TicketTypeId { get; set; } + public uint Quantity { get; set; } +} \ No newline at end of file diff --git a/TickAPI/TickAPI/Tickets/Models/ShoppingCartResellTicket.cs b/TickAPI/TickAPI/Tickets/Models/ShoppingCartResellTicket.cs new file mode 100644 index 0000000..9ddb9ac --- /dev/null +++ b/TickAPI/TickAPI/Tickets/Models/ShoppingCartResellTicket.cs @@ -0,0 +1,6 @@ +namespace TickAPI.Tickets.Models; + +public class ShoppingCartResellTicket +{ + public Guid TicketId { get; set; } +} \ No newline at end of file diff --git a/TickAPI/TickAPI/Tickets/Models/Ticket.cs b/TickAPI/TickAPI/Tickets/Models/Ticket.cs index 21f765f..3622cc6 100644 --- a/TickAPI/TickAPI/Tickets/Models/Ticket.cs +++ b/TickAPI/TickAPI/Tickets/Models/Ticket.cs @@ -8,7 +8,10 @@ public class Ticket public Guid Id { get; set; } public TicketType Type { get; set; } public Customer Owner { get; set; } - public string NameOnTicket { get; set; } + public string? NameOnTicket { get; set; } public string? Seats { get; set; } public bool ForResell { get; set; } + public decimal? ResellPrice { get; set; } + public string? ResellCurrency { get; set; } + public bool Used { get; set; } } \ No newline at end of file diff --git a/TickAPI/TickAPI/Tickets/Repositories/TicketRepository.cs b/TickAPI/TickAPI/Tickets/Repositories/TicketRepository.cs index 42ade22..c3d3911 100644 --- a/TickAPI/TickAPI/Tickets/Repositories/TicketRepository.cs +++ b/TickAPI/TickAPI/Tickets/Repositories/TicketRepository.cs @@ -1,4 +1,8 @@ -using TickAPI.Common.TickApiDbContext; +using Microsoft.EntityFrameworkCore; +using TickAPI.Common.Results; +using TickAPI.Common.Results.Generic; +using TickAPI.Common.TickApiDbContext; +using TickAPI.Customers.Models; using TickAPI.Tickets.Abstractions; using TickAPI.Tickets.Models; using TickAPI.TicketTypes.Models; @@ -18,4 +22,83 @@ public IQueryable GetAllTicketsByTicketType(TicketType ticketType) { return _tickApiDbContext.Tickets.Where(t => t.Type == ticketType); } + + public IQueryable GetTicketsByEventId(Guid eventId) + { + return _tickApiDbContext.Tickets + .Include(t => t.Type) + .Include(t => t.Type.Event) + .Where(t => t.Type.Event.Id == eventId); + } + + public IQueryable GetTicketsByCustomerEmail(string email) + { + return _tickApiDbContext.Tickets + .Include(t => t.Owner) + .Include(t => t.Type) + .Include(t =>t.Type.Event) + .Where(t => t.Owner.Email == email); + } + + public async Task> GetTicketWithDetailsByIdAndEmailAsync(Guid id, string email) + { + var ticket = await _tickApiDbContext.Tickets + .Include(t => t.Type) + .Include(t => t.Type.Event) + .Include(t => t.Type.Event.Organizer) + .Include(t => t.Type.Event.Address) + .Where(t => (t.Id == id && t.Owner.Email == email)) + .FirstOrDefaultAsync(); + if (ticket == null) + { + return Result.Failure(StatusCodes.Status404NotFound, "Ticket with this id doesn't exist"); + } + return Result.Success(ticket); + } + + public async Task MarkTicketAsUsed(Guid id) + { + var ticket = await _tickApiDbContext.Tickets.FirstOrDefaultAsync(t => t.Id == id); + if (ticket == null) + { + return Result.Failure(StatusCodes.Status404NotFound, "Ticket with this id doesn't exist"); + } + if (ticket.Used) + { + return Result.Failure(StatusCodes.Status400BadRequest, "Ticket already used"); + } + ticket.Used = true; + await _tickApiDbContext.SaveChangesAsync(); + return Result.Success(); + } + + public async Task AddTicketAsync(Ticket ticket) + { + var maxCount = ticket.Type.MaxCount; + + if (maxCount <= _tickApiDbContext.Tickets.Count(t => t.Type.Id == ticket.Type.Id)) + { + return Result.Failure(StatusCodes.Status400BadRequest, + "The ticket you are trying to buy has already reached its max count"); + } + + _tickApiDbContext.Tickets.Add(ticket); + await _tickApiDbContext.SaveChangesAsync(); + + return Result.Success(); + } + + public async Task SetTicketForResell(Guid ticketId, decimal newPrice, string currency) + { + var ticket = await _tickApiDbContext.Tickets.FirstOrDefaultAsync(t => t.Id == ticketId); + if (ticket == null) + { + return Result.Failure(StatusCodes.Status404NotFound, "Ticket with this id doesn't exist"); + } + ticket.ForResell = true; + ticket.ResellCurrency = currency; + ticket.ResellPrice = newPrice; + await _tickApiDbContext.SaveChangesAsync(); + return Result.Success(); + } } \ No newline at end of file diff --git a/TickAPI/TickAPI/Tickets/Services/TicketService.cs b/TickAPI/TickAPI/Tickets/Services/TicketService.cs index d8d7855..c643424 100644 --- a/TickAPI/TickAPI/Tickets/Services/TicketService.cs +++ b/TickAPI/TickAPI/Tickets/Services/TicketService.cs @@ -1,5 +1,18 @@ -using TickAPI.Common.Results.Generic; +using Azure.Core; +using TickAPI.Common.Pagination.Abstractions; +using TickAPI.Common.Pagination.Responses; +using TickAPI.Common.QR.Abstractions; +using TickAPI.Common.Results; +using TickAPI.Common.Results.Generic; +using TickAPI.Customers.Abstractions; +using TickAPI.Customers.Models; +using TickAPI.ShoppingCarts.Abstractions; using TickAPI.Tickets.Abstractions; +using TickAPI.Tickets.DTOs.Request; +using TickAPI.Tickets.DTOs.Response; +using TickAPI.Tickets.Filters; +using TickAPI.Tickets.Models; +using TickAPI.TicketTypes.Abstractions; using TickAPI.TicketTypes.Models; namespace TickAPI.Tickets.Services; @@ -7,18 +20,34 @@ namespace TickAPI.Tickets.Services; public class TicketService : ITicketService { private readonly ITicketRepository _ticketRepository; + private readonly ITicketTypeRepository _ticketTypeRepository; + private readonly IShoppingCartRepository _shoppingCartRepository; + private readonly IPaginationService _paginationService; + private readonly IQRCodeService _qrCodeService; - public TicketService(ITicketRepository ticketRepository) + public TicketService(ITicketRepository ticketRepository, ITicketTypeRepository ticketTypeRepository, + IShoppingCartRepository shoppingCartRepository, IPaginationService paginationService, IQRCodeService qrCodeService) { _ticketRepository = ticketRepository; + _ticketTypeRepository = ticketTypeRepository; + _shoppingCartRepository = shoppingCartRepository; + _paginationService = paginationService; + _qrCodeService = qrCodeService; } - // TODO: Update this method to also count tickets cached in Redis as unavailable - public Result GetNumberOfAvailableTicketsByType(TicketType ticketType) + public async Task> GetNumberOfAvailableTicketsByTypeAsync(TicketType ticketType) { var unavailableTickets = _ticketRepository.GetAllTicketsByTicketType(ticketType); + var reservedTicketsAmountResult = await _shoppingCartRepository.GetAmountOfTicketTypeAsync(ticketType.Id); + + if (reservedTicketsAmountResult.IsError) + { + return Result.PropagateError(reservedTicketsAmountResult); + } - var availableCount = ticketType.MaxCount - unavailableTickets.Count(); + var reservedTicketsAmount = reservedTicketsAmountResult.Value; + + var availableCount = ticketType.MaxCount - unavailableTickets.Count() - reservedTicketsAmount; if (availableCount < 0) { @@ -28,4 +57,163 @@ public Result GetNumberOfAvailableTicketsByType(TicketType ticketType) return Result.Success((uint)availableCount); } + + public async Task> GetNumberOfAvailableTicketsByTypeIdAsync(Guid ticketTypeId) + { + var ticketTypeResult = await _ticketTypeRepository.GetTicketTypeByIdAsync(ticketTypeId); + + if (ticketTypeResult.IsError) + { + return Result.PropagateError(ticketTypeResult); + } + + return await GetNumberOfAvailableTicketsByTypeAsync(ticketTypeResult.Value!); + } + + public async Task> CheckTicketAvailabilityByTypeIdAsync(Guid ticketTypeId, uint amount) + { + var numberOfTicketsResult = await GetNumberOfAvailableTicketsByTypeIdAsync(ticketTypeId); + + if (numberOfTicketsResult.IsError) + { + return Result.PropagateError(numberOfTicketsResult); + } + + var availableAmount = numberOfTicketsResult.Value!; + + return availableAmount >= amount ? Result.Success(true) : Result.Success(false); + } + + public async Task>> GetTicketsForResellAsync(Guid eventId, int page, int pageSize) + { + var eventTickets = _ticketRepository.GetTicketsByEventId(eventId); + var ticketsForResell = eventTickets.Where(t => t.ForResell); + var paginatedTicketsResult = await _paginationService.PaginateAsync(ticketsForResell, pageSize, page); + if (paginatedTicketsResult.IsError) + { + return Result>.PropagateError(paginatedTicketsResult); + } + var paginatedResult = _paginationService.MapData(paginatedTicketsResult.Value!, + t => new GetTicketForResellResponseDto(t.Id, t.Type.Price, t.Type.Currency, t.Type.Description, t.Seats)); + return Result>.Success(paginatedResult); + } + public async Task>> GetTicketsForCustomerAsync(string email, int page, int pageSize, TicketFiltersDto ? ticketFilters = null) + { + var customerTickets = _ticketRepository.GetTicketsByCustomerEmail(email); + if (ticketFilters != null) + { + var filter = new TicketFilter(customerTickets); + var applier = new TicketFilterApplier(filter); + customerTickets = applier.ApplyFilters(ticketFilters); + } + var paginatedCustomerTickets = await _paginationService.PaginateAsync(customerTickets, pageSize, page); + if (paginatedCustomerTickets.IsError) + { + return Result>.PropagateError(paginatedCustomerTickets); + } + + var paginatedResult = _paginationService.MapData(paginatedCustomerTickets.Value!, + t => new GetTicketForCustomerDto(t.Id, t.Type.Event.Name, t.Type.Event.StartDate, t.Type.Event.EndDate, t.Used)); + + return Result>.Success(paginatedResult); + } + + public async Task> GetTicketDetailsAsync(Guid ticketGuid, string email, string scanUrl) + { + var ticketRes = await _ticketRepository.GetTicketWithDetailsByIdAndEmailAsync(ticketGuid, email); + if (ticketRes.IsError) + { + return Result.PropagateError(ticketRes); + } + var ticket = ticketRes.Value!; + var ev = ticket.Type.Event; + var address = new GetTicketDetailsAddressDto(ev.Address.Country, ev.Address.City, ev.Address.PostalCode, + ev.Address.Street, ev.Address.HouseNumber, ev.Address.FlatNumber); + + var qrbytes = _qrCodeService.GenerateQrCode(scanUrl); + var qrcode = Convert.ToBase64String(qrbytes); + var ticketDetails = new GetTicketDetailsResponseDto + ( + ticket.NameOnTicket, + ticket.Seats, + ticket.Type.Price, + ticket.Type.Currency, + ticket.Type.Event.Name, + ticket.Type.Event.Organizer.DisplayName, + ticket.Type.Event.StartDate, + ticket.Type.Event.EndDate, + address, + ticket.Type.Event.Id, + qrcode + ); + return Result.Success(ticketDetails); + } + + public async Task> GetTicketTypeByIdAsync(Guid ticketTypeId) + { + var ticketTypeResult = await _ticketTypeRepository.GetTicketTypeByIdAsync(ticketTypeId); + + if (ticketTypeResult.IsError) + { + return Result.PropagateError(ticketTypeResult); + } + + return Result.Success(ticketTypeResult.Value!); + } + + public async Task CreateTicketAsync(TicketType type, Customer owner, string? nameOnTicket = null, + string? seats = null) + { + var ticket = new Ticket + { + Type = type, + Owner = owner, + NameOnTicket = nameOnTicket, + Seats = seats, + ForResell = false, + Used = false, + }; + + var addTicketResult = await _ticketRepository.AddTicketAsync(ticket); + + return addTicketResult; + } + + public async Task ScanTicket(Guid ticketGuid) + { + var res = await _ticketRepository.MarkTicketAsUsed(ticketGuid); + return res; + } + + public async Task SetTicketForResellAsync(Guid ticketId, string email, decimal resellPrice, string resellCurrency) + { + if (resellPrice <= 0) + { + return Result.Failure(StatusCodes.Status500InternalServerError, "Price must be greater than zero"); + } + var ticketRes = await _ticketRepository.GetTicketWithDetailsByIdAndEmailAsync(ticketId, email); + if (ticketRes.IsError) + { + return Result.PropagateError(ticketRes); + } + + if (ticketRes.Value!.Type.Price*1.5m < resellPrice) + { + return Result.Failure(StatusCodes.Status500InternalServerError, "Resell price cannot exceed " + + "value of original price times 1.5"); + } + + if (ticketRes.Value!.ForResell) + { + return Result.Failure(StatusCodes.Status500InternalServerError, "Ticket is already set for resell"); + } + + if (ticketRes.Value!.Used) + { + return Result.Failure(StatusCodes.Status500InternalServerError, "Ticket is already used"); + } + + var res = await _ticketRepository.SetTicketForResell(ticketId, resellPrice, resellCurrency); + return res; + } } \ No newline at end of file diff --git a/TickAPI/TickAPI/appsettings.example.json b/TickAPI/TickAPI/appsettings.example.json index bbb517d..c7e46ab 100644 --- a/TickAPI/TickAPI/appsettings.example.json +++ b/TickAPI/TickAPI/appsettings.example.json @@ -26,10 +26,16 @@ "ExpirySeconds" : "3600" } }, - "SendGrid": { "ApiKey": "ApiKey", "FromEmail": "your_mail", "FromName": "Resellio" + }, + "PaymentGateway": { + "Url": "http://localhost:7474" + }, + "ShoppingCart": { + "SyncIntervalMinutes": 5, + "LifetimeMinutes": 15 } } \ No newline at end of file