diff --git a/TickAPI/TickAPI.Tests/Customers/Controllers/CustomerControllerTests.cs b/TickAPI/TickAPI.Tests/Customers/Controllers/CustomerControllerTests.cs new file mode 100644 index 0000000..d36ac0f --- /dev/null +++ b/TickAPI/TickAPI.Tests/Customers/Controllers/CustomerControllerTests.cs @@ -0,0 +1,150 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using TickAPI.Common.Auth.Abstractions; +using TickAPI.Common.Auth.Enums; +using TickAPI.Common.Result; +using TickAPI.Customers.Abstractions; +using TickAPI.Customers.Controllers; +using TickAPI.Customers.DTOs.Request; +using TickAPI.Customers.Models; + +namespace TickAPI.Tests.Customers.Controllers; + +public class CustomerControllerTests +{ + [Fact] + public async Task GoogleLogin_WhenAuthSuccessAndCustomerExists_ShouldReturnTokenAndNotNewCustomer() + { + const string email = "existing@test.com"; + const string idToken = "valid-google-token"; + const string jwtToken = "valid-jwt-token"; + + var authServiceMock = new Mock(); + authServiceMock.Setup(m => m.LoginAsync(idToken)) + .ReturnsAsync(Result.Success(email)); + + var customerServiceMock = new Mock(); + customerServiceMock.Setup(m => m.GetCustomerByEmailAsync(email)) + .ReturnsAsync(Result.Success(new Customer { Email = email })); + + var jwtServiceMock = new Mock(); + jwtServiceMock.Setup(m => m.GenerateJwtToken(email, UserRole.Customer)) + .Returns(Result.Success(jwtToken)); + + var sut = new CustomerController( + authServiceMock.Object, + jwtServiceMock.Object, + customerServiceMock.Object); + + var actionResult = await sut.GoogleLogin(new GoogleLoginDto(idToken)); + + Assert.Equal(jwtToken, actionResult.Value?.Token); + Assert.False(actionResult.Value?.IsNewCustomer); + } + + [Fact] + public async Task GoogleLogin_WhenAuthSuccessAndCustomerDoesNotExist_ShouldReturnTokenAndNewCustomer() + { + const string email = "new@test.com"; + const string idToken = "valid-google-token"; + const string jwtToken = "valid-jwt-token"; + + var authServiceMock = new Mock(); + authServiceMock.Setup(m => m.LoginAsync(idToken)) + .ReturnsAsync(Result.Success(email)); + + var customerServiceMock = new Mock(); + customerServiceMock.Setup(m => m.GetCustomerByEmailAsync(email)) + .ReturnsAsync(Result.Failure(StatusCodes.Status404NotFound, $"customer with email '{email}' not found")); + + var jwtServiceMock = new Mock(); + jwtServiceMock.Setup(m => m.GenerateJwtToken(email, UserRole.NewCustomer)) + .Returns(Result.Success(jwtToken)); + + var sut = new CustomerController( + authServiceMock.Object, + jwtServiceMock.Object, + customerServiceMock.Object); + + var result = await sut.GoogleLogin(new GoogleLoginDto(idToken)); + + Assert.Equal(jwtToken, result.Value?.Token); + Assert.True(result.Value?.IsNewCustomer); + } + + [Fact] + public async Task GoogleCreateNewAccount_WhenCreatingAccountIsSuccessful_ShouldReturnToken() + { + const string email = "new@test.com"; + const string firstName = "First"; + const string lastName = "Last"; + const string jwtToken = "valid-jwt-token"; + + var authServiceMock = new Mock(); + var customerServiceMock = new Mock(); + customerServiceMock.Setup(m => m.CreateNewCustomerAsync(email, firstName, lastName)) + .ReturnsAsync(Result.Success(new Customer + { + Id = Guid.NewGuid(), + Email = email, + FirstName = firstName, + LastName = lastName + })); + + var jwtServiceMock = new Mock(); + jwtServiceMock.Setup(m => m.GenerateJwtToken(email, UserRole.Customer)) + .Returns(Result.Success(jwtToken)); + + var sut = new CustomerController( + authServiceMock.Object, + jwtServiceMock.Object, + customerServiceMock.Object); + + var claims = new List + { + new Claim(ClaimTypes.Email, email) + }; + sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(claims)) + } + }; + + var result = await sut.GoogleCreateNewAccount( + new GoogleCreateNewAccountDto( firstName, lastName )); + + Assert.Equal(jwtToken, result.Value?.Token); + } + + [Fact] + public async Task GoogleCreateNewAccount_WhenEmailClaimIsMissing_ShouldReturnBadRequest() + { + var authServiceMock = new Mock(); + var jwtServiceMock = new Mock(); + var customerServiceMock = new Mock(); + + var sut = new CustomerController( + authServiceMock.Object, + jwtServiceMock.Object, + customerServiceMock.Object); + + sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new List())) + } + }; + + var result = await sut.GoogleCreateNewAccount( + new GoogleCreateNewAccountDto("First","Last")); + + var objectResult = Assert.IsType(result.Result); + Assert.Equal(StatusCodes.Status400BadRequest, objectResult.StatusCode); + Assert.Equal("missing email claim", objectResult.Value); + } +} \ No newline at end of file diff --git a/TickAPI/TickAPI.Tests/Customers/Services/CustomerServiceTests.cs b/TickAPI/TickAPI.Tests/Customers/Services/CustomerServiceTests.cs new file mode 100644 index 0000000..ba0960e --- /dev/null +++ b/TickAPI/TickAPI.Tests/Customers/Services/CustomerServiceTests.cs @@ -0,0 +1,93 @@ +using Microsoft.AspNetCore.Http; +using Moq; +using TickAPI.Common.Result; +using TickAPI.Common.Time.Abstractions; +using TickAPI.Customers.Abstractions; +using TickAPI.Customers.Models; +using TickAPI.Customers.Services; + +namespace TickAPI.Tests.Customers.Services; + +public class CustomerServiceTests +{ + [Fact] + public async Task GetCustomerByEmailAsync_WhenCustomerWithEmailIsReturnedFromRepository_ShouldReturnUser() + { + var customer = new Customer + { + Email = "example@test.com" + }; + var dateTimeServiceMock = new Mock(); + var customerRepositoryMock = new Mock(); + customerRepositoryMock.Setup(m => m.GetCustomerByEmailAsync(customer.Email)).ReturnsAsync(Result.Success(customer)); + var sut = new CustomerService(customerRepositoryMock.Object, dateTimeServiceMock.Object); + + var result = await sut.GetCustomerByEmailAsync(customer.Email); + + Assert.True(result.IsSuccess); + Assert.Equal(customer, result.Value); + } + + [Fact] + public async Task GetCustomerByEmailAsync_WhenCustomerWithEmailIsNotReturnedFromRepository_ShouldReturnFailure() + { + const string email = "not@existing.com"; + var dateTimeServiceMock = new Mock(); + var customerRepositoryMock = new Mock(); + customerRepositoryMock.Setup(m => m.GetCustomerByEmailAsync(email)).ReturnsAsync(Result.Failure(StatusCodes.Status404NotFound, $"customer with email '{email}' not found")); + var sut = new CustomerService(customerRepositoryMock.Object, dateTimeServiceMock.Object); + + var result = await sut.GetCustomerByEmailAsync(email); + + Assert.True(result.IsError); + Assert.Equal(StatusCodes.Status404NotFound, result.StatusCode); + Assert.Equal($"customer with email '{email}' not found", result.ErrorMsg); + } + + [Fact] + public async Task CreateNewCustomerAsync_WhenCustomerWithUniqueEmail_ShouldReturnNewCustomer() + { + const string email = "new@customer.com"; + const string firstName = "First"; + const string lastName = "Last"; + Guid id = Guid.NewGuid(); + DateTime createdAt = new DateTime(1970, 1, 1, 8, 0, 0, DateTimeKind.Utc); + var dateTimeServiceMock = new Mock(); + dateTimeServiceMock.Setup(m => m.GetCurrentDateTime()).Returns(createdAt); + var customerRepositoryMock = new Mock(); + customerRepositoryMock.Setup(m => m.GetCustomerByEmailAsync(email)).ReturnsAsync(Result.Failure(StatusCodes.Status404NotFound, $"customer with email '{email}' not found")); + customerRepositoryMock + .Setup(m => m.AddNewCustomerAsync(It.IsAny())) + .Callback(c => c.Id = id) + .Returns(Task.CompletedTask); + var sut = new CustomerService(customerRepositoryMock.Object, dateTimeServiceMock.Object); + + var result = await sut.CreateNewCustomerAsync(email, firstName, lastName); + + Assert.True(result.IsSuccess); + Assert.Equal(email, result.Value!.Email); + Assert.Equal(firstName, result.Value!.FirstName); + Assert.Equal(lastName, result.Value!.LastName); + Assert.Equal(createdAt, result.Value!.CreationDate); + Assert.Equal(id, result.Value!.Id); + } + + [Fact] + public async Task CreateNewCustomerAsync_WhenCustomerWithNotUniqueEmail_ShouldReturnFailure() + { + var customer = new Customer + { + Email = "already@exists.com" + }; + var dateTimeServiceMock = new Mock(); + var customerRepositoryMock = new Mock(); + customerRepositoryMock.Setup(m => m.GetCustomerByEmailAsync(customer.Email)).ReturnsAsync(Result.Success(customer)); + var sut = new CustomerService(customerRepositoryMock.Object, dateTimeServiceMock.Object); + + var result = await sut.CreateNewCustomerAsync(customer.Email, "First", "Last"); + + Assert.True(result.IsError); + Assert.Equal(StatusCodes.Status400BadRequest, result.StatusCode); + Assert.Equal($"customer with email '{customer.Email}' already exists", result.ErrorMsg); + } +} \ No newline at end of file diff --git a/TickAPI/TickAPI/Common/Auth/Attributes/AuthorizeWithPolicy.cs b/TickAPI/TickAPI/Common/Auth/Attributes/AuthorizeWithPolicy.cs new file mode 100644 index 0000000..eba7fe9 --- /dev/null +++ b/TickAPI/TickAPI/Common/Auth/Attributes/AuthorizeWithPolicy.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Authorization; +using TickAPI.Common.Auth.Enums; + +namespace TickAPI.Common.Auth.Attributes; + +public class AuthorizeWithPolicy : AuthorizeAttribute +{ + public AuthorizeWithPolicy(AuthPolicies policy) + { + Policy = policy.ToString(); + } +} \ No newline at end of file diff --git a/TickAPI/TickAPI/Customers/Abstractions/ICustomerRepository.cs b/TickAPI/TickAPI/Customers/Abstractions/ICustomerRepository.cs index 8dc6310..5177854 100644 --- a/TickAPI/TickAPI/Customers/Abstractions/ICustomerRepository.cs +++ b/TickAPI/TickAPI/Customers/Abstractions/ICustomerRepository.cs @@ -1,6 +1,10 @@ -namespace TickAPI.Customers.Abstractions; +using TickAPI.Common.Result; +using TickAPI.Customers.Models; + +namespace TickAPI.Customers.Abstractions; public interface ICustomerRepository { - + Task> GetCustomerByEmailAsync(string customerEmail); + Task AddNewCustomerAsync(Customer customer); } \ No newline at end of file diff --git a/TickAPI/TickAPI/Customers/Abstractions/ICustomerService.cs b/TickAPI/TickAPI/Customers/Abstractions/ICustomerService.cs index 8371ac2..ea4e7b8 100644 --- a/TickAPI/TickAPI/Customers/Abstractions/ICustomerService.cs +++ b/TickAPI/TickAPI/Customers/Abstractions/ICustomerService.cs @@ -1,6 +1,10 @@ -namespace TickAPI.Customers.Abstractions; +using TickAPI.Common.Result; +using TickAPI.Customers.Models; + +namespace TickAPI.Customers.Abstractions; public interface ICustomerService { - + Task> GetCustomerByEmailAsync(string customerEmail); + Task> CreateNewCustomerAsync(string email, string firstName, string lastName); } \ No newline at end of file diff --git a/TickAPI/TickAPI/Customers/Controllers/CustomerController.cs b/TickAPI/TickAPI/Customers/Controllers/CustomerController.cs index 44599d3..fabb150 100644 --- a/TickAPI/TickAPI/Customers/Controllers/CustomerController.cs +++ b/TickAPI/TickAPI/Customers/Controllers/CustomerController.cs @@ -1,4 +1,11 @@ -using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; +using Microsoft.AspNetCore.Mvc; +using TickAPI.Common.Auth.Abstractions; +using TickAPI.Common.Auth.Attributes; +using TickAPI.Common.Auth.Enums; +using TickAPI.Customers.Abstractions; +using TickAPI.Customers.DTOs.Request; +using TickAPI.Customers.DTOs.Response; namespace TickAPI.Customers.Controllers; @@ -6,5 +13,62 @@ namespace TickAPI.Customers.Controllers; [Route("api/[controller]")] public class CustomerController : ControllerBase { + private readonly IAuthService _authService; + private readonly IJwtService _jwtService; + private readonly ICustomerService _customerService; + public CustomerController(IAuthService authService, IJwtService jwtService, ICustomerService customerService) + { + _authService = authService; + _jwtService = jwtService; + _customerService = customerService; + } + + [HttpPost("google-login")] + public async Task> GoogleLogin([FromBody] GoogleLoginDto request) + { + var loginResult = await _authService.LoginAsync(request.IdToken); + if(loginResult.IsError) + return StatusCode(loginResult.StatusCode, loginResult.ErrorMsg); + + UserRole role; + bool isNewCustomer; + + var existingCustomerResult = await _customerService.GetCustomerByEmailAsync(loginResult.Value!); + if (existingCustomerResult.IsSuccess) + { + role = UserRole.Customer; + isNewCustomer = false; + } + else + { + role = UserRole.NewCustomer; + isNewCustomer = true; + } + + var jwtTokenResult = _jwtService.GenerateJwtToken(loginResult.Value, role); + if (jwtTokenResult.IsError) + return StatusCode(jwtTokenResult.StatusCode, jwtTokenResult.ErrorMsg); + + return new ActionResult(new GoogleLoginResponseDto(jwtTokenResult.Value!, isNewCustomer)); + } + + [AuthorizeWithPolicy(AuthPolicies.NewCustomerPolicy)] + [HttpPost("google-create-new-account")] + public async Task> GoogleCreateNewAccount([FromBody] GoogleCreateNewAccountDto request) + { + var email = User.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)?.Value; + if (email == null) + return StatusCode(StatusCodes.Status400BadRequest, "missing email claim"); + + var newCustomerResult = await _customerService.CreateNewCustomerAsync(email, request.FirstName, request.LastName); + if (newCustomerResult.IsError) + return StatusCode(newCustomerResult.StatusCode, newCustomerResult.ErrorMsg); + + var jwtTokenResult = _jwtService.GenerateJwtToken(newCustomerResult.Value!.Email, UserRole.Customer); + if (jwtTokenResult.IsError) + return StatusCode(jwtTokenResult.StatusCode, jwtTokenResult.ErrorMsg); + + return new ActionResult(new GoogleCreateNewAccountResponseDto(jwtTokenResult.Value!)); + } } \ No newline at end of file diff --git a/TickAPI/TickAPI/Customers/DTOs/Request/GoogleCreateNewAccountDto.cs b/TickAPI/TickAPI/Customers/DTOs/Request/GoogleCreateNewAccountDto.cs new file mode 100644 index 0000000..f896265 --- /dev/null +++ b/TickAPI/TickAPI/Customers/DTOs/Request/GoogleCreateNewAccountDto.cs @@ -0,0 +1,6 @@ +namespace TickAPI.Customers.DTOs.Request; + +public record GoogleCreateNewAccountDto( + string FirstName, + string LastName +); \ No newline at end of file diff --git a/TickAPI/TickAPI/Customers/DTOs/Request/GoogleLoginDto.cs b/TickAPI/TickAPI/Customers/DTOs/Request/GoogleLoginDto.cs new file mode 100644 index 0000000..043b7ce --- /dev/null +++ b/TickAPI/TickAPI/Customers/DTOs/Request/GoogleLoginDto.cs @@ -0,0 +1,5 @@ +namespace TickAPI.Customers.DTOs.Request; + +public record GoogleLoginDto( + string IdToken +); \ No newline at end of file diff --git a/TickAPI/TickAPI/Customers/DTOs/Response/GoogleCreateNewAccountResponseDto.cs b/TickAPI/TickAPI/Customers/DTOs/Response/GoogleCreateNewAccountResponseDto.cs new file mode 100644 index 0000000..b49ac83 --- /dev/null +++ b/TickAPI/TickAPI/Customers/DTOs/Response/GoogleCreateNewAccountResponseDto.cs @@ -0,0 +1,5 @@ +namespace TickAPI.Customers.DTOs.Response; + +public record GoogleCreateNewAccountResponseDto( + string Token +); \ No newline at end of file diff --git a/TickAPI/TickAPI/Customers/DTOs/Response/GoogleLoginResponseDto.cs b/TickAPI/TickAPI/Customers/DTOs/Response/GoogleLoginResponseDto.cs new file mode 100644 index 0000000..b2d5434 --- /dev/null +++ b/TickAPI/TickAPI/Customers/DTOs/Response/GoogleLoginResponseDto.cs @@ -0,0 +1,6 @@ +namespace TickAPI.Customers.DTOs.Response; + +public record GoogleLoginResponseDto( + string Token, + bool IsNewCustomer +); \ No newline at end of file diff --git a/TickAPI/TickAPI/Customers/Repositories/CustomerRepository.cs b/TickAPI/TickAPI/Customers/Repositories/CustomerRepository.cs index 8b21f7e..9adb003 100644 --- a/TickAPI/TickAPI/Customers/Repositories/CustomerRepository.cs +++ b/TickAPI/TickAPI/Customers/Repositories/CustomerRepository.cs @@ -1,8 +1,35 @@ -using TickAPI.Customers.Abstractions; +using Microsoft.EntityFrameworkCore; +using TickAPI.Common.Result; +using TickAPI.Common.TickApiDbContext; +using TickAPI.Customers.Abstractions; +using TickAPI.Customers.Models; namespace TickAPI.Customers.Repositories; public class CustomerRepository : ICustomerRepository { - + private readonly TickApiDbContext _tickApiDbContext; + + public CustomerRepository(TickApiDbContext tickApiDbContext) + { + _tickApiDbContext = tickApiDbContext; + } + + public async Task> GetCustomerByEmailAsync(string customerEmail) + { + var customer = await _tickApiDbContext.Customers.FirstOrDefaultAsync(customer => customer.Email == customerEmail); + + if (customer == null) + { + return Result.Failure(StatusCodes.Status404NotFound, $"customer with email '{customerEmail}' not found"); + } + + return Result.Success(customer); + } + + public async Task AddNewCustomerAsync(Customer customer) + { + _tickApiDbContext.Customers.Add(customer); + await _tickApiDbContext.SaveChangesAsync(); + } } \ No newline at end of file diff --git a/TickAPI/TickAPI/Customers/Services/CustomerService.cs b/TickAPI/TickAPI/Customers/Services/CustomerService.cs index 4b75299..33289c7 100644 --- a/TickAPI/TickAPI/Customers/Services/CustomerService.cs +++ b/TickAPI/TickAPI/Customers/Services/CustomerService.cs @@ -1,8 +1,42 @@ -using TickAPI.Customers.Abstractions; +using TickAPI.Common.Result; +using TickAPI.Common.Time.Abstractions; +using TickAPI.Customers.Abstractions; +using TickAPI.Customers.Models; +using TickAPI.Tickets.Models; namespace TickAPI.Customers.Services; public class CustomerService : ICustomerService { - + private readonly ICustomerRepository _customerRepository; + private readonly IDateTimeService _dateTimeService; + + public CustomerService(ICustomerRepository customerRepository, IDateTimeService dateTimeService) + { + _customerRepository = customerRepository; + _dateTimeService = dateTimeService; + } + + public async Task> GetCustomerByEmailAsync(string customerEmail) + { + return await _customerRepository.GetCustomerByEmailAsync(customerEmail); + } + + public async Task> CreateNewCustomerAsync(string email, string firstName, string lastName) + { + var alreadyExistingResult = await GetCustomerByEmailAsync(email); + if (alreadyExistingResult.IsSuccess) + return Result.Failure(StatusCodes.Status400BadRequest, + $"customer with email '{email}' already exists"); + var customer = new Customer + { + Email = email, + FirstName = firstName, + LastName = lastName, + CreationDate = _dateTimeService.GetCurrentDateTime(), + Tickets = new List() + }; + await _customerRepository.AddNewCustomerAsync(customer); + return Result.Success(customer); + } } \ No newline at end of file