diff --git a/NoExp.Application/Interfaces/IJobAdService.cs b/NoExp.Application/Interfaces/IJobAdService.cs index 309e2dd..a4eb469 100644 --- a/NoExp.Application/Interfaces/IJobAdService.cs +++ b/NoExp.Application/Interfaces/IJobAdService.cs @@ -11,4 +11,8 @@ public interface IJobAdService Task> GetAllEmployerJobAdsAsync(string employerProfileId); Task GetJobAdByIdAsync(Guid jobAdId); + + Task RemoveJobAdAsync(Guid jobAdId); + + Task UpdateJobAdAsync(JobAd jobAd); } \ No newline at end of file diff --git a/NoExp.Application/Services/JobAdService.cs b/NoExp.Application/Services/JobAdService.cs index cce35b3..abc0fe2 100644 --- a/NoExp.Application/Services/JobAdService.cs +++ b/NoExp.Application/Services/JobAdService.cs @@ -25,4 +25,15 @@ public async Task GetJobAdByIdAsync(Guid jobAdId) { return await jobAdRepository.GetJobAdByIdAsync(jobAdId); } + + public async Task RemoveJobAdAsync(Guid jobAdId) + { + await jobAdRepository.SafeDeleteJobAdAsync(jobAdId); + return jobAdId; + } + + public async Task UpdateJobAdAsync(JobAd jobAd) + { + return await jobAdRepository.UpdateJobAdAsync(jobAd); + } } \ No newline at end of file diff --git a/NoExp.Domain/Interfaces/IJobAdRepository.cs b/NoExp.Domain/Interfaces/IJobAdRepository.cs index c30b716..c187c87 100644 --- a/NoExp.Domain/Interfaces/IJobAdRepository.cs +++ b/NoExp.Domain/Interfaces/IJobAdRepository.cs @@ -11,4 +11,8 @@ public interface IJobAdRepository Task> GetJobAdsByEmployerProfileIdAsync(string employerProfileId); Task GetJobAdByIdAsync(Guid jobAdId); + + Task SafeDeleteJobAdAsync(Guid jobAdId); + + Task UpdateJobAdAsync(JobAd jobAd); } \ No newline at end of file diff --git a/NoExp.Infrastructure/Repositories/JobAdRepository.cs b/NoExp.Infrastructure/Repositories/JobAdRepository.cs index 47c3bb8..b1361f1 100644 --- a/NoExp.Infrastructure/Repositories/JobAdRepository.cs +++ b/NoExp.Infrastructure/Repositories/JobAdRepository.cs @@ -34,4 +34,21 @@ public async Task> GetJobAdsByEmployerProfileIdAsync(string employer .Include(i => i.EmployerProfile) .FirstOrDefaultAsync(j => j.Id == jobAdId)!; } + + public async Task SafeDeleteJobAdAsync(Guid jobAdId) + { + var jobAd = await context.JobAds.FindAsync(jobAdId); + if (jobAd != null) + { + context.JobAds.Remove(jobAd); + await context.SaveChangesAsync(); + } + } + + public async Task UpdateJobAdAsync(JobAd jobAd) + { + context.JobAds.Update(jobAd); + await context.SaveChangesAsync(); + return jobAd; + } } \ No newline at end of file diff --git a/NoExp.Presentation/Components/Dialogs/ListEmployerJobAdDeleteConfirmation.razor b/NoExp.Presentation/Components/Dialogs/ListEmployerJobAdDeleteConfirmation.razor new file mode 100644 index 0000000..08f8e5c --- /dev/null +++ b/NoExp.Presentation/Components/Dialogs/ListEmployerJobAdDeleteConfirmation.razor @@ -0,0 +1,26 @@ + + + @ContentText + + + Cancel + @ButtonText + + +@code { + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public string ContentText { get; set; } = string.Empty; + + [Parameter] + public string ButtonText { get; set; } = string.Empty; + + [Parameter] + public Color Color { get; set; } = Color.Default; + + private void Submit() => MudDialog.Close(DialogResult.Ok(true)); + + private void Cancel() => MudDialog.Cancel(); +} \ No newline at end of file diff --git a/NoExp.Presentation/Components/Pages/JobAds/CreateJobAd.razor b/NoExp.Presentation/Components/Pages/JobAds/CreateJobAd.razor index e00619a..97917b1 100644 --- a/NoExp.Presentation/Components/Pages/JobAds/CreateJobAd.razor +++ b/NoExp.Presentation/Components/Pages/JobAds/CreateJobAd.razor @@ -184,16 +184,15 @@ { if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(Input.TechStack)) { - if (!AddedTechStack.Contains(Input.TechStack)) + if (!AddedTechStack.Contains(Input.TechStack, StringComparer.OrdinalIgnoreCase)) { AddedTechStack.Add(Input.TechStack.Trim()); } else { - Snackbar.Add(new MarkupString($"{Input.TechStack} skill exists!"), Severity.Error); + Snackbar.Add($"{Input.TechStack} skill exists!", Severity.Error); } - await Task.Delay(10); Input.TechStack = string.Empty; StateHasChanged(); } @@ -235,12 +234,6 @@ public string TechStack { get; set; } - public string Requirements { get; set; } - - public string Responsibilities { get; set; } - - public string Offer { get; set; } - public DateTime? ExpirationDate { get; set; } } diff --git a/NoExp.Presentation/Components/Pages/JobAds/EditJobAd.razor b/NoExp.Presentation/Components/Pages/JobAds/EditJobAd.razor new file mode 100644 index 0000000..e5b2c42 --- /dev/null +++ b/NoExp.Presentation/Components/Pages/JobAds/EditJobAd.razor @@ -0,0 +1,349 @@ +@page "/JobAds/Edit/{JobAdId:guid}" +@using FluentValidation +@using Microsoft.AspNetCore.Authorization +@using MudExRichTextEditor.Types +@using NoExp.Application.Interfaces +@using NoExp.Domain.Entities +@using NoExp.Domain.Enums.JobAd +@using Severity = MudBlazor.Severity +@rendermode InteractiveServer +@attribute [Authorize(Roles = "EmployerRole")] + +@inject IDialogService DialogService +@inject AuthenticationStateProvider AuthStateProvider +@inject IProfileService ProfileService +@inject IJobAdService JobAdService +@inject ISnackbar Snackbar +@inject NavigationManager NavigationManager + + + @if (isLoading) + { + + } + else if (Input != null) + { + + + + + + Description + + + + + + + + + Tech Stack + + + + + @foreach (var skill in AddedTechStack) + { + + } + + + + + + + + + + + + + @foreach (var type in Enum.GetValues(typeof(WorkType)).Cast()) + { + @type + } + + + + + @foreach (var mode in Enum.GetValues(typeof(WorkMode)).Cast()) + { + @mode + } + + + + + + + + + + + + + + + +
+ Update + Cancel +
+
+ } +
+ +@code { + [Parameter] + public Guid JobAdId { get; set; } + + private MudForm? form; + private JobAdModel? Input; + readonly JobAdModelValidator validator = new(); + private List AddedTechStack { get; set; } = new(); + private bool isLoading = true; + private JobAd? existingJobAd; + + protected override async Task OnInitializedAsync() + { + try + { + existingJobAd = await JobAdService.GetJobAdByIdAsync(JobAdId); + + if (existingJobAd == null) + { + Snackbar.Add("Job advertisement not found.", Severity.Error); + NavigationManager.NavigateTo("/JobAds/Own"); + return; + } + + // Verify that the current user owns this job ad + var user = (await AuthStateProvider.GetAuthenticationStateAsync()).User; + var userId = user.FindFirst(c => c.Type.Contains("nameidentifier"))?.Value; + var employerProfile = await ProfileService.GetEmployerProfileByUserIdAsync(userId); + + if (employerProfile == null || existingJobAd.EmployerProfileId != employerProfile.Id) + { + Snackbar.Add("You are not authorized to edit this job advertisement.", Severity.Error); + NavigationManager.NavigateTo("/JobAds/Own"); + return; + } + + // Populate the form with existing data + Input = new JobAdModel + { + Title = existingJobAd.Title, + Description = existingJobAd.Description, + WorkType = existingJobAd.WorkType, + WorkMode = existingJobAd.WorkMode, + Location = existingJobAd.Location, + SalaryMin = existingJobAd.SalaryMin ?? 0, + SalaryMax = existingJobAd.SalaryMax ?? 0, + ExpirationDate = existingJobAd.ExpirationDate, + TechStack = string.Empty + }; + + AddedTechStack = existingJobAd.TechStack.ToList(); + } + catch (Exception ex) + { + Snackbar.Add($"Error loading job advertisement: {ex.Message}", Severity.Error); + NavigationManager.NavigateTo("/JobAds/Own"); + } + finally + { + isLoading = false; + } + } + + private async Task OnValidSubmitAsync() + { + await form!.Validate(); + var result = await validator.ValidateAsync(Input!); + + if (result.IsValid) + { + try + { + existingJobAd!.Title = Input!.Title; + existingJobAd.Description = Input.Description; + existingJobAd.WorkType = Input.WorkType; + existingJobAd.WorkMode = Input.WorkMode; + existingJobAd.Location = Input.Location; + existingJobAd.SalaryMin = Input.SalaryMin; + existingJobAd.SalaryMax = Input.SalaryMax; + existingJobAd.ExpirationDate = Input.ExpirationDate; + existingJobAd.TechStack = AddedTechStack; + + await JobAdService.UpdateJobAdAsync(existingJobAd); + Snackbar.Add("Job advertisement updated successfully.", Severity.Success); + NavigationManager.NavigateTo("/JobAds/Own"); + } + catch (Exception ex) + { + Snackbar.Add($"Error: {ex.Message}", Severity.Error); + } + } + } + + private async Task TechStackHandleKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(Input!.TechStack)) + { + if (!AddedTechStack.Contains(Input.TechStack, StringComparer.OrdinalIgnoreCase)) + { + AddedTechStack.Add(Input.TechStack.Trim()); + } + else + { + Snackbar.Add($"{Input.TechStack} skill exists!", Severity.Error); + } + + Input.TechStack = string.Empty; + StateHasChanged(); + } + } + + private void RemoveItem(List list, string item) + { + list.Remove(item); + } + + private void Cancel() + { + NavigationManager.NavigateTo("/JobAds/Own"); + } + + private readonly List CustomTools = new() + { + new QuillTool("ql-header", group: 1, options: new[] { "", "1", "2", "3", "4", "5", "6" }), + new QuillTool("ql-bold", group: 2), + new QuillTool("ql-italic", group: 2), + new QuillTool("ql-underline", group: 2), + new QuillTool("ql-list", group: 4, value: "ordered"), + new QuillTool("ql-list", group: 4, value: "bullet"), + new QuillTool("ql-align", group: 4, options: new[] { "", "center", "right", "justify" }), + new QuillTool("ql-color", group: 3, options: Array.Empty()), + new QuillTool("ql-blockquote", group: 5) + }; + + private sealed class JobAdModel + { + public string Title { get; set; } + + public string Description { get; set; } + + public WorkType WorkType { get; set; } + + public WorkMode WorkMode { get; set; } + + public string Location { get; set; } + + public decimal SalaryMin { get; set; } + + public decimal SalaryMax { get; set; } + + public string TechStack { get; set; } + + public DateTime? ExpirationDate { get; set; } + } + + private class JobAdModelValidator : AbstractValidator + { + public JobAdModelValidator() + { + RuleFor(model => model.Title) + .NotNull() + .MinimumLength(5) + .MaximumLength(100); + + RuleFor(model => model.WorkType) + .NotNull() + .IsInEnum(); + + RuleFor(model => model.WorkMode) + .NotNull() + .IsInEnum(); + + RuleFor(model => model.TechStack) + .MaximumLength(200); + + RuleFor(model => model.ExpirationDate) + .Must(date => date.HasValue) + .WithMessage("Expiration date is required") + .Must(date => !date.HasValue || date.Value > DateTime.UtcNow) + .WithMessage("Expiration date must be in the future"); + + RuleFor(model => model.Location) + .NotNull() + .MinimumLength(3) + .MaximumLength(100); + + RuleFor(model => model.SalaryMin) + .NotNull() + .GreaterThanOrEqualTo(0) + .WithMessage("Minimum salary cannot be negative") + .LessThan(1000000) + .WithMessage("Minimum salary is too high"); + + RuleFor(model => model.SalaryMax) + .NotNull() + .GreaterThanOrEqualTo(model => model.SalaryMin) + .WithMessage("Maximum salary must be greater than or equal to minimum salary") + .LessThan(1000000) + .WithMessage("Maximum salary is too high") + .When(model => model.SalaryMin > 0); + } + + public Func>> ValidateValue => async (model, propertyName) => + { + var result = await ValidateAsync(ValidationContext.CreateWithOptions((JobAdModel)model, x => x.IncludeProperties(propertyName))); + if (result.IsValid) + return Array.Empty(); + return result.Errors.Select(e => e.ErrorMessage); + }; + } + +} diff --git a/NoExp.Presentation/Components/Pages/JobAds/ListEmployerJobAd.razor b/NoExp.Presentation/Components/Pages/JobAds/ListEmployerJobAd.razor index 03cca81..94043b3 100644 --- a/NoExp.Presentation/Components/Pages/JobAds/ListEmployerJobAd.razor +++ b/NoExp.Presentation/Components/Pages/JobAds/ListEmployerJobAd.razor @@ -2,6 +2,7 @@ @using Microsoft.AspNetCore.Authorization @using NoExp.Application.Interfaces @using NoExp.Domain.Entities +@using NoExp.Presentation.Components.Dialogs @rendermode InteractiveServer @attribute [Authorize(Roles = "EmployerRole")] @@ -9,41 +10,64 @@ @inject IJobAdService JobAdService @inject ISnackbar Snackbar @inject IProfileService ProfileService +@inject IDialogService DialogService +@inject NavigationManager NavigationManager @foreach (var jobAd in jobAds) { - - - - - - + + + + @jobAd.Title + + +
+ + + +
+
+
+ + + + + + + @((MarkupString)jobAd.Description) + + @foreach (var skill in jobAd.TechStack) + { + @skill + } - - @jobAd.Title - @((MarkupString)jobAd.Description) - - @foreach (var skill in jobAd.TechStack) - { - @skill - } - - - - - - - Apply Now! - - -
-
+ + + + } @code { private List jobAds = new(); protected override async Task OnInitializedAsync() + { + await LoadJobAdsAsync(); + } + + private async Task LoadJobAdsAsync() { try { @@ -66,4 +90,32 @@ } } + private async Task RemoveJobAdAsync(Guid jobAdId) + { + var parameters = new DialogParameters + { + { x => x.ContentText, "Do you really want to delete this Job Advertisement? This process cannot be undone." }, + { x => x.ButtonText, "Delete" }, + { x => x.Color, Color.Error } + }; + + var options = new DialogOptions() { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; + + var dialogResult = await DialogService.ShowAsync("Delete", parameters, options); + var result = await dialogResult.Result; + + if (!result.Canceled) + { + try + { + await JobAdService.RemoveJobAdAsync(jobAdId); + Snackbar.Add("Job advertisement deleted successfully.", Severity.Success); + await LoadJobAdsAsync(); + } + catch (Exception ex) + { + Snackbar.Add($"Error deleting job advertisement: {ex.Message}", Severity.Error); + } + } + } } \ No newline at end of file diff --git a/NoExp.Presentation/Components/Pages/JobAds/ListEmployerJobAd.razor.css b/NoExp.Presentation/Components/Pages/JobAds/ListEmployerJobAd.razor.css new file mode 100644 index 0000000..7625f4f --- /dev/null +++ b/NoExp.Presentation/Components/Pages/JobAds/ListEmployerJobAd.razor.css @@ -0,0 +1,16 @@ +.employer-job-ad-card { + position: relative; +} + +.job-ad-header { + display: flex; + justify-content: space-between; + align-items: flex-start; +} + +.job-ad-actions { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 1rem; +} diff --git a/NoExp.Test/Services/JobAdServiceTests.cs b/NoExp.Test/Services/JobAdServiceTests.cs index f23f3d1..1ae7e12 100644 --- a/NoExp.Test/Services/JobAdServiceTests.cs +++ b/NoExp.Test/Services/JobAdServiceTests.cs @@ -189,6 +189,25 @@ public async Task GetAllEmployerJobAdsAsync_ShouldReturnJobAdsForEmployer_WhenEm _mockRepository.Verify(repo => repo.GetJobAdsByEmployerProfileIdAsync(employerProfileId), Times.Once); } + [Fact] + public async Task GetAllEmployerJobAdsAsync_ShouldReturnEmptyList_WhenEmployerHasNoJobAds() + { + // Arrange + var employerProfileId = "employer-456"; + + _mockRepository + .Setup(repo => repo.GetJobAdsByEmployerProfileIdAsync(employerProfileId)) + .ReturnsAsync(new List()); + + // Act + var result = await _service.GetAllEmployerJobAdsAsync(employerProfileId); + + // Assert + result.Should().NotBeNull(); + result.Should().BeEmpty(); + _mockRepository.Verify(repo => repo.GetJobAdsByEmployerProfileIdAsync(employerProfileId), Times.Once); + } + #endregion #region GetJobAdByIdAsync Tests @@ -253,4 +272,499 @@ public async Task GetJobAdByIdAsync_ShouldReturnNull_WhenJobAdDoesNotExist() } #endregion + + #region RemoveJobAdAsync Tests + + [Fact] + public async Task RemoveJobAdAsync_ShouldReturnJobAdId_WhenJobAdIsDeleted() + { + // Arrange + var jobAdId = Guid.NewGuid(); + + _mockRepository + .Setup(repo => repo.SafeDeleteJobAdAsync(jobAdId)) + .Returns(Task.CompletedTask); + + // Act + var result = await _service.RemoveJobAdAsync(jobAdId); + + // Assert + result.Should().Be(jobAdId); + _mockRepository.Verify(repo => repo.SafeDeleteJobAdAsync(jobAdId), Times.Once); + } + + [Fact] + public async Task RemoveJobAdAsync_ShouldCallRepositoryMethod_WhenJobAdDoesNotExist() + { + // Arrange + var jobAdId = Guid.NewGuid(); + + _mockRepository + .Setup(repo => repo.SafeDeleteJobAdAsync(jobAdId)) + .Returns(Task.CompletedTask); + + // Act + var result = await _service.RemoveJobAdAsync(jobAdId); + + // Assert + result.Should().Be(jobAdId); + _mockRepository.Verify(repo => repo.SafeDeleteJobAdAsync(jobAdId), Times.Once); + } + + #endregion + + #region UpdateJobAdAsync Tests + + [Fact] + public async Task UpdateJobAdAsync_ShouldReturnUpdatedJobAd_WhenJobAdIsValid() + { + // Arrange + var employerProfile = new EmployerProfile + { + Id = "employer-123", + UserId = "user-123", + CompanyName = "Test Company" + }; + + var jobAd = new JobAd + { + Id = Guid.NewGuid(), + Title = "Updated Senior .NET Developer", + Description = "Updated description for senior developer", + WorkType = WorkType.FullTime, + Location = "San Francisco", + WorkMode = WorkMode.Hybrid, + SalaryMin = 90000, + SalaryMax = 130000, + TechStack = new List { "C#", ".NET", "Azure", "Kubernetes" }, + PublishDate = DateTime.UtcNow, + ExpirationDate = DateTime.UtcNow.AddDays(30), + WorkStatus = WorkStatus.Active, + EmployerProfileId = employerProfile.Id, + EmployerProfile = employerProfile + }; + + _mockRepository + .Setup(repo => repo.UpdateJobAdAsync(It.IsAny())) + .ReturnsAsync(jobAd); + + // Act + var result = await _service.UpdateJobAdAsync(jobAd); + + // Assert + result.Should().NotBeNull(); + result.Should().Be(jobAd); + result.Title.Should().Be("Updated Senior .NET Developer"); + result.Location.Should().Be("San Francisco"); + result.WorkMode.Should().Be(WorkMode.Hybrid); + result.SalaryMin.Should().Be(90000); + result.SalaryMax.Should().Be(130000); + result.TechStack.Should().HaveCount(4); + _mockRepository.Verify(repo => repo.UpdateJobAdAsync(jobAd), Times.Once); + } + + [Fact] + public async Task UpdateJobAdAsync_ShouldUpdateAllProperties_WhenCalled() + { + // Arrange + var employerProfile = new EmployerProfile + { + Id = "employer-456", + UserId = "user-456", + CompanyName = "Updated Company" + }; + + var originalJobAd = new JobAd + { + Id = Guid.NewGuid(), + Title = "Original Title", + Description = "Original Description", + WorkType = WorkType.PartTime, + Location = "Original Location", + WorkMode = WorkMode.Onsite, + SalaryMin = 30000, + SalaryMax = 50000, + TechStack = new List { "Java" }, + EmployerProfileId = employerProfile.Id, + EmployerProfile = employerProfile + }; + + var updatedJobAd = new JobAd + { + Id = originalJobAd.Id, + Title = "New Title", + Description = "New Description", + WorkType = WorkType.FullTime, + Location = "New Location", + WorkMode = WorkMode.Remote, + SalaryMin = 80000, + SalaryMax = 120000, + TechStack = new List { "C#", "Python", "Go" }, + EmployerProfileId = employerProfile.Id, + EmployerProfile = employerProfile + }; + + _mockRepository + .Setup(repo => repo.UpdateJobAdAsync(It.IsAny())) + .ReturnsAsync(updatedJobAd); + + // Act + var result = await _service.UpdateJobAdAsync(updatedJobAd); + + // Assert + result.Should().NotBeNull(); + result.Id.Should().Be(originalJobAd.Id); + result.Title.Should().Be("New Title"); + result.Description.Should().Be("New Description"); + result.WorkType.Should().Be(WorkType.FullTime); + result.Location.Should().Be("New Location"); + result.WorkMode.Should().Be(WorkMode.Remote); + result.SalaryMin.Should().Be(80000); + result.SalaryMax.Should().Be(120000); + result.TechStack.Should().BeEquivalentTo(new List { "C#", "Python", "Go" }); + _mockRepository.Verify(repo => repo.UpdateJobAdAsync(updatedJobAd), Times.Once); + } + + #endregion + + #region Exception Handling Tests + + [Fact] + public async Task SaveJobAdAsync_ShouldThrowException_WhenRepositoryFails() + { + // Arrange + var employerProfile = new EmployerProfile + { + Id = "employer-123", + UserId = "user-123", + CompanyName = "Test Company" + }; + + var jobAd = new JobAd + { + Id = Guid.NewGuid(), + Title = "Test Job", + Description = "Test Description", + WorkType = WorkType.FullTime, + Location = "Test Location", + WorkMode = WorkMode.Remote, + SalaryMin = 50000, + SalaryMax = 70000, + TechStack = new List { "C#" }, + EmployerProfileId = employerProfile.Id, + EmployerProfile = employerProfile + }; + + _mockRepository + .Setup(repo => repo.AddJobAdAsync(It.IsAny())) + .ThrowsAsync(new Exception("Database connection failed")); + + // Act + Func act = async () => await _service.SaveJobAdAsync(jobAd); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("Database connection failed"); + _mockRepository.Verify(repo => repo.AddJobAdAsync(jobAd), Times.Once); + } + + [Fact] + public async Task GetJobAdByIdAsync_ShouldThrowException_WhenRepositoryFails() + { + // Arrange + var jobAdId = Guid.NewGuid(); + + _mockRepository + .Setup(repo => repo.GetJobAdByIdAsync(jobAdId)) + .ThrowsAsync(new Exception("Database error")); + + // Act + Func act = async () => await _service.GetJobAdByIdAsync(jobAdId); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("Database error"); + } + + [Fact] + public async Task UpdateJobAdAsync_ShouldThrowException_WhenRepositoryFails() + { + // Arrange + var employerProfile = new EmployerProfile + { + Id = "employer-123", + UserId = "user-123", + CompanyName = "Test Company" + }; + + var jobAd = new JobAd + { + Id = Guid.NewGuid(), + Title = "Updated Job", + Description = "Updated Description", + WorkType = WorkType.FullTime, + Location = "Updated Location", + WorkMode = WorkMode.Remote, + SalaryMin = 60000, + SalaryMax = 80000, + TechStack = new List { "C#", ".NET" }, + EmployerProfileId = employerProfile.Id, + EmployerProfile = employerProfile + }; + + _mockRepository + .Setup(repo => repo.UpdateJobAdAsync(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Cannot update job ad")); + + // Act + Func act = async () => await _service.UpdateJobAdAsync(jobAd); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("Cannot update job ad"); + } + + [Fact] + public async Task RemoveJobAdAsync_ShouldThrowException_WhenRepositoryFails() + { + // Arrange + var jobAdId = Guid.NewGuid(); + + _mockRepository + .Setup(repo => repo.SafeDeleteJobAdAsync(jobAdId)) + .ThrowsAsync(new Exception("Failed to delete job ad")); + + // Act + Func act = async () => await _service.RemoveJobAdAsync(jobAdId); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("Failed to delete job ad"); + } + + #endregion + + #region Edge Cases and Validation Tests + + [Fact] + public async Task SaveJobAdAsync_ShouldSaveJobAd_WithEmptyTechStack() + { + // Arrange + var employerProfile = new EmployerProfile + { + Id = "employer-123", + UserId = "user-123", + CompanyName = "Test Company" + }; + + var jobAd = new JobAd + { + Id = Guid.NewGuid(), + Title = "Entry Level Position", + Description = "No specific tech stack required", + WorkType = WorkType.FullTime, + Location = "Anywhere", + WorkMode = WorkMode.Remote, + SalaryMin = 30000, + SalaryMax = 50000, + TechStack = new List(), // Empty tech stack + EmployerProfileId = employerProfile.Id, + EmployerProfile = employerProfile + }; + + _mockRepository + .Setup(repo => repo.AddJobAdAsync(It.IsAny())) + .ReturnsAsync(jobAd); + + // Act + var result = await _service.SaveJobAdAsync(jobAd); + + // Assert + result.Should().NotBeNull(); + result.TechStack.Should().BeEmpty(); + _mockRepository.Verify(repo => repo.AddJobAdAsync(jobAd), Times.Once); + } + + [Fact] + public async Task SaveJobAdAsync_ShouldSaveJobAd_WithLargeTechStack() + { + // Arrange + var employerProfile = new EmployerProfile + { + Id = "employer-123", + UserId = "user-123", + CompanyName = "Test Company" + }; + + var largeTechStack = new List + { + "C#", ".NET", "Azure", "SQL Server", "MongoDB", "Redis", + "Docker", "Kubernetes", "React", "Angular", "TypeScript", + "Python", "AWS", "Jenkins", "Git", "Microservices" + }; + + var jobAd = new JobAd + { + Id = Guid.NewGuid(), + Title = "Full Stack Architect", + Description = "Comprehensive tech stack knowledge required", + WorkType = WorkType.FullTime, + Location = "San Francisco", + WorkMode = WorkMode.Hybrid, + SalaryMin = 150000, + SalaryMax = 200000, + TechStack = largeTechStack, + EmployerProfileId = employerProfile.Id, + EmployerProfile = employerProfile + }; + + _mockRepository + .Setup(repo => repo.AddJobAdAsync(It.IsAny())) + .ReturnsAsync(jobAd); + + // Act + var result = await _service.SaveJobAdAsync(jobAd); + + // Assert + result.Should().NotBeNull(); + result.TechStack.Should().HaveCount(16); + result.TechStack.Should().Contain("Kubernetes"); + _mockRepository.Verify(repo => repo.AddJobAdAsync(jobAd), Times.Once); + } + + [Fact] + public async Task UpdateJobAdAsync_ShouldUpdateJobAd_WithExpirationDateInFuture() + { + // Arrange + var employerProfile = new EmployerProfile + { + Id = "employer-123", + UserId = "user-123", + CompanyName = "Test Company" + }; + + var futureDate = DateTime.UtcNow.AddDays(60); + var jobAd = new JobAd + { + Id = Guid.NewGuid(), + Title = "Long-term Position", + Description = "Extended deadline", + WorkType = WorkType.FullTime, + Location = "Remote", + WorkMode = WorkMode.Remote, + SalaryMin = 70000, + SalaryMax = 90000, + TechStack = new List { "Java" }, + ExpirationDate = futureDate, + EmployerProfileId = employerProfile.Id, + EmployerProfile = employerProfile + }; + + _mockRepository + .Setup(repo => repo.UpdateJobAdAsync(It.IsAny())) + .ReturnsAsync(jobAd); + + // Act + var result = await _service.UpdateJobAdAsync(jobAd); + + // Assert + result.Should().NotBeNull(); + result.ExpirationDate.Should().Be(futureDate); + result.ExpirationDate.Should().BeAfter(DateTime.UtcNow); + _mockRepository.Verify(repo => repo.UpdateJobAdAsync(jobAd), Times.Once); + } + + [Fact] + public async Task GetAllJobAdsAsync_ShouldReturnJobAdsWithDifferentWorkTypes() + { + // Arrange + var employerProfile = new EmployerProfile + { + Id = "employer-123", + UserId = "user-123", + CompanyName = "Test Company" + }; + + var jobAds = new List + { + new() + { + Id = Guid.NewGuid(), + Title = "Full Time Job", + Description = "Full time position", + WorkType = WorkType.FullTime, + Location = "Office", + WorkMode = WorkMode.Onsite, + SalaryMin = 60000, + SalaryMax = 80000, + TechStack = new List { "C#" }, + EmployerProfileId = employerProfile.Id, + EmployerProfile = employerProfile + }, + new() + { + Id = Guid.NewGuid(), + Title = "Part Time Job", + Description = "Part time position", + WorkType = WorkType.PartTime, + Location = "Remote", + WorkMode = WorkMode.Remote, + SalaryMin = 30000, + SalaryMax = 45000, + TechStack = new List { "Python" }, + EmployerProfileId = employerProfile.Id, + EmployerProfile = employerProfile + }, + new() + { + Id = Guid.NewGuid(), + Title = "Freelance Job", + Description = "Freelance position", + WorkType = WorkType.Freelance, + Location = "Anywhere", + WorkMode = WorkMode.Remote, + SalaryMin = 50, + SalaryMax = 100, + TechStack = new List { "JavaScript" }, + EmployerProfileId = employerProfile.Id, + EmployerProfile = employerProfile + } + }; + + _mockRepository + .Setup(repo => repo.GetAllJobAdsAsync()) + .ReturnsAsync(jobAds); + + // Act + var result = await _service.GetAllJobAdsAsync(); + + // Assert + result.Should().NotBeNull(); + result.Should().HaveCount(3); + result.Should().Contain(j => j.WorkType == WorkType.FullTime); + result.Should().Contain(j => j.WorkType == WorkType.PartTime); + result.Should().Contain(j => j.WorkType == WorkType.Freelance); + _mockRepository.Verify(repo => repo.GetAllJobAdsAsync(), Times.Once); + } + + [Fact] + public async Task RemoveJobAdAsync_ShouldSucceed_WithValidGuid() + { + // Arrange + var jobAdId = Guid.NewGuid(); + + _mockRepository + .Setup(repo => repo.SafeDeleteJobAdAsync(jobAdId)) + .Returns(Task.CompletedTask); + + // Act + var result = await _service.RemoveJobAdAsync(jobAdId); + + // Assert + result.Should().Be(jobAdId); + result.Should().NotBeEmpty(); + _mockRepository.Verify(repo => repo.SafeDeleteJobAdAsync(jobAdId), Times.Once); + } + + #endregion } \ No newline at end of file diff --git a/NoExp.Test/Services/ProfileServiceTests.cs b/NoExp.Test/Services/ProfileServiceTests.cs index fe06ac7..19900f8 100644 --- a/NoExp.Test/Services/ProfileServiceTests.cs +++ b/NoExp.Test/Services/ProfileServiceTests.cs @@ -167,6 +167,24 @@ public async Task GetCandidateProfileByUserIdAsync_ShouldReturnProfile_WhenProfi _mockRepository.Verify(repo => repo.GetCandidateProfileByUserIdAsync(userId), Times.Once); } + [Fact] + public async Task GetCandidateProfileByUserIdAsync_ShouldReturnNull_WhenProfileDoesNotExist() + { + // Arrange + var userId = "nonexistent-user"; + + _mockRepository + .Setup(repo => repo.GetCandidateProfileByUserIdAsync(userId)) + .ReturnsAsync((CandidateProfile?)null); + + // Act + var result = await _service.GetCandidateProfileByUserIdAsync(userId); + + // Assert + result.Should().BeNull(); + _mockRepository.Verify(repo => repo.GetCandidateProfileByUserIdAsync(userId), Times.Once); + } + #endregion #region UpdateEmployerProfileAsync Tests @@ -203,5 +221,294 @@ public async Task UpdateEmployerProfileAsync_ShouldReturnUpdatedProfile_WhenProf _mockRepository.Verify(repo => repo.UpdateEmployerProfileAsync(employerProfile), Times.Once); } + [Fact] + public async Task UpdateEmployerProfileAsync_ShouldUpdatePartialFields_WhenOnlySomeFieldsChange() + { + // Arrange + var originalProfile = new EmployerProfile + { + Id = "profile-999", + UserId = "user-999", + CompanyName = "Original Company", + IdentificationNumber = "123456789", + CompanyDescription = "Original description", + CompanySize = 100, + IsVerified = false + }; + + var updatedProfile = new EmployerProfile + { + Id = "profile-999", + UserId = "user-999", + CompanyName = "Original Company", // Not changed + IdentificationNumber = "123456789", // Not changed + CompanyDescription = "New description", // Changed + CompanySize = 150, // Changed + IsVerified = false, // Not changed + UpdatedAt = DateTime.UtcNow + }; + + _mockRepository + .Setup(repo => repo.UpdateEmployerProfileAsync(It.IsAny())) + .ReturnsAsync(updatedProfile); + + // Act + var result = await _service.UpdateEmployerProfileAsync(updatedProfile); + + // Assert + result.Should().NotBeNull(); + result.CompanyDescription.Should().Be("New description"); + result.CompanySize.Should().Be(150); + result.CompanyName.Should().Be("Original Company"); + result.UpdatedAt.Should().NotBeNull(); + _mockRepository.Verify(repo => repo.UpdateEmployerProfileAsync(updatedProfile), Times.Once); + } + + [Fact] + public async Task UpdateEmployerProfileAsync_ShouldSetUpdatedAtTimestamp_WhenProfileIsUpdated() + { + // Arrange + var beforeUpdate = DateTime.UtcNow; + var employerProfile = new EmployerProfile + { + Id = "profile-888", + UserId = "user-888", + CompanyName = "Test Company", + IdentificationNumber = "987654321", + UpdatedAt = DateTime.UtcNow + }; + + _mockRepository + .Setup(repo => repo.UpdateEmployerProfileAsync(It.IsAny())) + .ReturnsAsync(employerProfile); + + // Act + var result = await _service.UpdateEmployerProfileAsync(employerProfile); + var afterUpdate = DateTime.UtcNow; + + // Assert + result.UpdatedAt.Should().NotBeNull(); + result.UpdatedAt.Should().BeOnOrAfter(beforeUpdate); + result.UpdatedAt.Should().BeOnOrBefore(afterUpdate); + _mockRepository.Verify(repo => repo.UpdateEmployerProfileAsync(employerProfile), Times.Once); + } + + #endregion + + #region Exception Handling Tests + + [Fact] + public async Task SaveProfileAsync_ShouldThrowException_WhenRepositoryFails() + { + // Arrange + var candidateProfile = new CandidateProfile + { + Id = "profile-123", + UserId = "user-123", + Bio = "Test bio", + Skills = "C#, .NET" + }; + + _mockRepository + .Setup(repo => repo.AddProfileAsync(It.IsAny())) + .ThrowsAsync(new Exception("Database error")); + + // Act + Func act = async () => await _service.SaveProfileAsync(candidateProfile); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("Database error"); + } + + [Fact] + public async Task GetEmployerProfileByUserIdAsync_ShouldThrowException_WhenRepositoryFails() + { + // Arrange + var userId = "user-123"; + + _mockRepository + .Setup(repo => repo.GetEmployerProfileByUserIdAsync(userId)) + .ThrowsAsync(new InvalidOperationException("Connection timeout")); + + // Act + Func act = async () => await _service.GetEmployerProfileByUserIdAsync(userId); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("Connection timeout"); + } + + [Fact] + public async Task GetCandidateProfileByUserIdAsync_ShouldThrowException_WhenRepositoryFails() + { + // Arrange + var userId = "user-456"; + + _mockRepository + .Setup(repo => repo.GetCandidateProfileByUserIdAsync(userId)) + .ThrowsAsync(new Exception("Query failed")); + + // Act + Func act = async () => await _service.GetCandidateProfileByUserIdAsync(userId); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("Query failed"); + } + + [Fact] + public async Task UpdateEmployerProfileAsync_ShouldThrowException_WhenRepositoryFails() + { + // Arrange + var employerProfile = new EmployerProfile + { + Id = "profile-999", + UserId = "user-999", + CompanyName = "Test Company" + }; + + _mockRepository + .Setup(repo => repo.UpdateEmployerProfileAsync(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Update failed")); + + // Act + Func act = async () => await _service.UpdateEmployerProfileAsync(employerProfile); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("Update failed"); + } + + #endregion + + #region Edge Cases Tests + + [Fact] + public async Task SaveProfileAsync_ShouldSaveProfile_WithMinimalRequiredFields() + { + // Arrange + var candidateProfile = new CandidateProfile + { + Id = "profile-minimal", + UserId = "user-minimal" + // Only required fields, no optional fields + }; + + _mockRepository + .Setup(repo => repo.AddProfileAsync(It.IsAny())) + .ReturnsAsync(candidateProfile); + + // Act + var result = await _service.SaveProfileAsync(candidateProfile); + + // Assert + result.Should().NotBeNull(); + result.Id.Should().Be("profile-minimal"); + result.UserId.Should().Be("user-minimal"); + _mockRepository.Verify(repo => repo.AddProfileAsync(candidateProfile), Times.Once); + } + + [Fact] + public async Task SaveProfileAsync_ShouldSaveProfile_WithAllOptionalFields() + { + // Arrange + var candidateProfile = new CandidateProfile + { + Id = "profile-complete", + UserId = "user-complete", + Bio = "Comprehensive bio with detailed information", + Location = "San Francisco, CA", + Skills = "C#, .NET, Azure, SQL, Docker, Kubernetes, React, TypeScript", + Experience = "10+ years of professional experience", + Education = "Master's in Computer Science from Stanford", + IsOpenToWork = true, + ExpectedSalary = 150000, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + _mockRepository + .Setup(repo => repo.AddProfileAsync(It.IsAny())) + .ReturnsAsync(candidateProfile); + + // Act + var result = await _service.SaveProfileAsync(candidateProfile); + + // Assert + result.Should().NotBeNull(); + var candidate = result.Should().BeOfType().Subject; + candidate.Bio.Should().NotBeNullOrEmpty(); + candidate.Skills.Should().Contain("Azure"); + candidate.Experience.Should().Contain("10+"); + candidate.ExpectedSalary.Should().Be(150000); + _mockRepository.Verify(repo => repo.AddProfileAsync(candidateProfile), Times.Once); + } + + [Fact] + public async Task SaveProfileAsync_ShouldSaveEmployerProfile_WithVerificationStatus() + { + // Arrange + var employerProfile = new EmployerProfile + { + Id = "profile-verified", + UserId = "user-verified", + CompanyName = "Verified Corp", + IdentificationNumber = "VER123456", + IsVerified = true, + CompanySize = 5000, + CompanyAddress = "123 Business Ave, Suite 100" + }; + + _mockRepository + .Setup(repo => repo.AddProfileAsync(It.IsAny())) + .ReturnsAsync(employerProfile); + + // Act + var result = await _service.SaveProfileAsync(employerProfile); + + // Assert + result.Should().NotBeNull(); + var employer = result.Should().BeOfType().Subject; + employer.IsVerified.Should().BeTrue(); + employer.CompanySize.Should().Be(5000); + _mockRepository.Verify(repo => repo.AddProfileAsync(employerProfile), Times.Once); + } + + [Fact] + public async Task UpdateEmployerProfileAsync_ShouldToggleVerificationStatus_WhenUpdated() + { + // Arrange + var originalProfile = new EmployerProfile + { + Id = "profile-toggle", + UserId = "user-toggle", + CompanyName = "Toggle Corp", + IsVerified = false + }; + + var updatedProfile = new EmployerProfile + { + Id = "profile-toggle", + UserId = "user-toggle", + CompanyName = "Toggle Corp", + IsVerified = true, // Changed from false to true + UpdatedAt = DateTime.UtcNow + }; + + _mockRepository + .Setup(repo => repo.UpdateEmployerProfileAsync(It.IsAny())) + .ReturnsAsync(updatedProfile); + + // Act + var result = await _service.UpdateEmployerProfileAsync(updatedProfile); + + // Assert + result.Should().NotBeNull(); + result.IsVerified.Should().BeTrue(); + result.UpdatedAt.Should().NotBeNull(); + _mockRepository.Verify(repo => repo.UpdateEmployerProfileAsync(updatedProfile), Times.Once); + } + #endregion } \ No newline at end of file diff --git a/NoExp.sln b/NoExp.sln index 0a5a093..b4069f1 100644 --- a/NoExp.sln +++ b/NoExp.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18 -VisualStudioVersion = 18.0.11205.157 d18.0 +VisualStudioVersion = 18.0.11205.157 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NoExp.Presentation", "NoExp.Presentation\NoExp.Presentation.csproj", "{56C0A921-2062-455D-931A-358662227593}" EndProject