diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..574ca21
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,13 @@
+{
+ // Use IntelliSense to learn about possible attributes.
+ // Hover to view descriptions of existing attributes.
+ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "C#: Launch Startup Project",
+ "type": "dotnet",
+ "request": "launch"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/Aquiis.SimpleStart.Tests/BaseServiceTests.cs b/Aquiis.SimpleStart.Tests/BaseServiceTests.cs
new file mode 100644
index 0000000..23342a1
--- /dev/null
+++ b/Aquiis.SimpleStart.Tests/BaseServiceTests.cs
@@ -0,0 +1,692 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Security.Claims;
+using System.Threading.Tasks;
+using Aquiis.SimpleStart.Core.Constants;
+using Aquiis.SimpleStart.Core.Entities;
+using Aquiis.SimpleStart.Core.Services;
+using Aquiis.SimpleStart.Infrastructure.Data;
+using Aquiis.SimpleStart.Shared.Components.Account;
+using Aquiis.SimpleStart.Shared.Services;
+using Microsoft.AspNetCore.Components.Authorization;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Moq;
+using Xunit;
+
+namespace Aquiis.SimpleStart.Tests
+{
+ ///
+ /// Unit tests for BaseService generic CRUD operations.
+ /// Tests organization isolation, soft delete, audit fields, and security.
+ ///
+ public class BaseServiceTests : IDisposable
+ {
+ private readonly ApplicationDbContext _context;
+ private readonly UserContextService _userContext;
+ private readonly TestPropertyService _service;
+ private readonly string _testUserId;
+ private readonly Guid _testOrgId;
+ private readonly Microsoft.Data.Sqlite.SqliteConnection _connection;
+
+ public BaseServiceTests()
+ {
+ // Setup SQLite in-memory database
+ _connection = new Microsoft.Data.Sqlite.SqliteConnection("Data Source=:memory:");
+ _connection.Open();
+
+ var options = new DbContextOptionsBuilder()
+ .UseSqlite(_connection)
+ .Options;
+
+ _context = new ApplicationDbContext(options);
+ _context.Database.EnsureCreated();
+
+ // Setup test user and organization
+ _testUserId = "test-user-123";
+ _testOrgId = Guid.NewGuid();
+
+ // Mock AuthenticationStateProvider
+ var claims = new ClaimsPrincipal(new ClaimsIdentity(
+ new[] { new Claim(ClaimTypes.NameIdentifier, _testUserId) },
+ "TestAuth"));
+ var mockAuth = new Mock();
+ mockAuth.Setup(a => a.GetAuthenticationStateAsync())
+ .ReturnsAsync(new AuthenticationState(claims));
+
+ // Mock UserManager
+ var mockUserStore = new Mock>();
+ var mockUserManager = new Mock>(
+ mockUserStore.Object, null, null, null, null, null, null, null, null);
+
+ var appUser = new ApplicationUser
+ {
+ Id = _testUserId,
+ UserName = "testuser",
+ Email = "test@example.com",
+ ActiveOrganizationId = _testOrgId
+ };
+ mockUserManager.Setup(u => u.FindByIdAsync(It.IsAny()))
+ .ReturnsAsync(appUser);
+
+ var serviceProvider = new Mock();
+ _userContext = new UserContextService(mockAuth.Object, mockUserManager.Object, serviceProvider.Object);
+
+ // Seed test data
+ var user = new ApplicationUser
+ {
+ Id = _testUserId,
+ UserName = "testuser",
+ Email = "test@example.com",
+ ActiveOrganizationId = _testOrgId
+ };
+ _context.Users.Add(user);
+
+ var org = new Organization
+ {
+ Id = _testOrgId,
+ Name = "Test Org",
+ OwnerId = _testUserId,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Organizations.Add(org);
+ _context.SaveChanges();
+
+ // Create service with mocked settings
+ var mockSettings = Options.Create(new ApplicationSettings
+ {
+ SoftDeleteEnabled = true
+ });
+
+ var mockLogger = new Mock>();
+ _service = new TestPropertyService(_context, mockLogger.Object, _userContext, mockSettings);
+ }
+
+ public void Dispose()
+ {
+ _context?.Dispose();
+ _connection?.Dispose();
+ }
+
+ #region CreateAsync Tests
+
+ [Fact]
+ public async Task CreateAsync_ValidEntity_CreatesSuccessfully()
+ {
+ // Arrange
+ var property = new Property
+ {
+ Address = "123 Main St",
+ City = "Test City",
+ State = "TS",
+ ZipCode = "12345",
+ PropertyType = "House"
+ };
+
+ // Act
+ var result = await _service.CreateAsync(property);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.NotEqual(Guid.Empty, result.Id);
+ Assert.Equal(_testOrgId, result.OrganizationId);
+ Assert.Equal(_testUserId, result.CreatedBy);
+ Assert.True(result.CreatedOn <= DateTime.UtcNow);
+ Assert.False(result.IsDeleted);
+ }
+
+ [Fact]
+ public async Task CreateAsync_AutoGeneratesIdIfEmpty()
+ {
+ // Arrange
+ var property = new Property
+ {
+ Id = Guid.Empty, // Explicitly empty
+ Address = "456 Oak Ave",
+ City = "Test City",
+ State = "TS",
+ ZipCode = "12345",
+ PropertyType = "Apartment"
+ };
+
+ // Act
+ var result = await _service.CreateAsync(property);
+
+ // Assert
+ Assert.NotEqual(Guid.Empty, result.Id);
+ }
+
+ [Fact]
+ public async Task CreateAsync_SetsAuditFieldsAutomatically()
+ {
+ // Arrange
+ var property = new Property
+ {
+ Address = "789 Pine St",
+ City = "Test City",
+ State = "TS",
+ ZipCode = "12345",
+ PropertyType = "Condo"
+ };
+ var beforeCreate = DateTime.UtcNow;
+
+ // Act
+ var result = await _service.CreateAsync(property);
+
+ // Assert
+ Assert.Equal(_testUserId, result.CreatedBy);
+ Assert.True(result.CreatedOn >= beforeCreate);
+ Assert.True(result.CreatedOn <= DateTime.UtcNow);
+ Assert.Null(result.LastModifiedBy);
+ Assert.Null(result.LastModifiedOn);
+ }
+
+ [Fact]
+ public async Task CreateAsync_SetsOrganizationIdAutomatically()
+ {
+ // Arrange
+ var property = new Property
+ {
+ OrganizationId = Guid.Empty, // Even if explicitly empty
+ Address = "321 Elm St",
+ City = "Test City",
+ State = "TS",
+ ZipCode = "12345",
+ PropertyType = "Townhouse"
+ };
+
+ // Act
+ var result = await _service.CreateAsync(property);
+
+ // Assert
+ Assert.Equal(_testOrgId, result.OrganizationId);
+ }
+
+ #endregion
+
+ #region GetByIdAsync Tests
+
+ [Fact]
+ public async Task GetByIdAsync_ExistingEntity_ReturnsEntity()
+ {
+ // Arrange
+ var property = new Property
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = _testOrgId,
+ Address = "555 Maple Dr",
+ City = "Test City",
+ State = "TS",
+ ZipCode = "12345",
+ PropertyType = "House",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Properties.Add(property);
+ await _context.SaveChangesAsync();
+
+ // Act
+ var result = await _service.GetByIdAsync(property.Id);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Equal(property.Id, result.Id);
+ Assert.Equal(property.Address, result.Address);
+ }
+
+ [Fact]
+ public async Task GetByIdAsync_NonExistentEntity_ReturnsNull()
+ {
+ // Arrange
+ var nonExistentId = Guid.NewGuid();
+
+ // Act
+ var result = await _service.GetByIdAsync(nonExistentId);
+
+ // Assert
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public async Task GetByIdAsync_SoftDeletedEntity_ReturnsNull()
+ {
+ // Arrange
+ var property = new Property
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = _testOrgId,
+ Address = "777 Birch Ln",
+ City = "Test City",
+ State = "TS",
+ ZipCode = "12345",
+ PropertyType = "House",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow,
+ IsDeleted = true // Soft deleted
+ };
+ _context.Properties.Add(property);
+ await _context.SaveChangesAsync();
+
+ // Act
+ var result = await _service.GetByIdAsync(property.Id);
+
+ // Assert
+ Assert.Null(result); // Should not return deleted entities
+ }
+
+ [Fact]
+ public async Task GetByIdAsync_DifferentOrganization_ReturnsNull()
+ {
+ // Arrange
+ var differentOrgId = Guid.NewGuid();
+ var differentOrg = new Organization
+ {
+ Id = differentOrgId,
+ Name = "Different Org",
+ OwnerId = _testUserId,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Organizations.Add(differentOrg);
+
+ var property = new Property
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = differentOrgId, // Different organization
+ Address = "999 Cedar Ct",
+ City = "Test City",
+ State = "TS",
+ ZipCode = "12345",
+ PropertyType = "House",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Properties.Add(property);
+ await _context.SaveChangesAsync();
+
+ // Act
+ var result = await _service.GetByIdAsync(property.Id);
+
+ // Assert
+ Assert.Null(result); // Should not return entities from other orgs
+ }
+
+ #endregion
+
+ #region GetAllAsync Tests
+
+ [Fact]
+ public async Task GetAllAsync_ReturnsAllActiveEntities()
+ {
+ // Arrange
+ var properties = new[]
+ {
+ new Property { Id = Guid.NewGuid(), OrganizationId = _testOrgId, Address = "100 Test St", City = "City", State = "ST", ZipCode = "12345", PropertyType = "House", CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow },
+ new Property { Id = Guid.NewGuid(), OrganizationId = _testOrgId, Address = "200 Test St", City = "City", State = "ST", ZipCode = "12345", PropertyType = "Apartment", CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow },
+ new Property { Id = Guid.NewGuid(), OrganizationId = _testOrgId, Address = "300 Test St", City = "City", State = "ST", ZipCode = "12345", PropertyType = "Condo", CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow }
+ };
+ _context.Properties.AddRange(properties);
+ await _context.SaveChangesAsync();
+
+ // Act
+ var result = await _service.GetAllAsync();
+
+ // Assert
+ Assert.Equal(3, result.Count);
+ }
+
+ [Fact]
+ public async Task GetAllAsync_ExcludesSoftDeletedEntities()
+ {
+ // Arrange
+ var activeProperty = new Property { Id = Guid.NewGuid(), OrganizationId = _testOrgId, Address = "400 Test St", City = "City", State = "ST", ZipCode = "12345", PropertyType = "House", CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow, IsDeleted = false };
+ var deletedProperty = new Property { Id = Guid.NewGuid(), OrganizationId = _testOrgId, Address = "500 Test St", City = "City", State = "ST", ZipCode = "12345", PropertyType = "House", CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow, IsDeleted = true };
+ _context.Properties.AddRange(activeProperty, deletedProperty);
+ await _context.SaveChangesAsync();
+
+ // Act
+ var result = await _service.GetAllAsync();
+
+ // Assert
+ Assert.Single(result);
+ Assert.Equal(activeProperty.Id, result[0].Id);
+ }
+
+ [Fact]
+ public async Task GetAllAsync_FiltersOnlyCurrentOrganization()
+ {
+ // Arrange
+ var differentOrgId = Guid.NewGuid();
+ var differentOrg = new Organization
+ {
+ Id = differentOrgId,
+ Name = "Different Org",
+ OwnerId = _testUserId,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Organizations.Add(differentOrg);
+
+ var myProperty = new Property { Id = Guid.NewGuid(), OrganizationId = _testOrgId, Address = "600 Test St", City = "City", State = "ST", ZipCode = "12345", PropertyType = "House", CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow };
+ var otherProperty = new Property { Id = Guid.NewGuid(), OrganizationId = differentOrgId, Address = "700 Test St", City = "City", State = "ST", ZipCode = "12345", PropertyType = "House", CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow };
+ _context.Properties.AddRange(myProperty, otherProperty);
+ await _context.SaveChangesAsync();
+
+ // Act
+ var result = await _service.GetAllAsync();
+
+ // Assert
+ Assert.Single(result);
+ Assert.Equal(myProperty.Id, result[0].Id);
+ }
+
+ #endregion
+
+ #region UpdateAsync Tests
+
+ [Fact]
+ public async Task UpdateAsync_ValidEntity_UpdatesSuccessfully()
+ {
+ // Arrange
+ var property = new Property
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = _testOrgId,
+ Address = "800 Original St",
+ City = "Test City",
+ State = "TS",
+ ZipCode = "12345",
+ PropertyType = "House",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Properties.Add(property);
+ await _context.SaveChangesAsync();
+
+ property.Address = "800 Updated St";
+ var beforeUpdate = DateTime.UtcNow;
+
+ // Act
+ var result = await _service.UpdateAsync(property);
+
+ // Assert
+ Assert.Equal("800 Updated St", result.Address);
+ Assert.Equal(_testUserId, result.LastModifiedBy);
+ Assert.NotNull(result.LastModifiedOn);
+ Assert.True(result.LastModifiedOn >= beforeUpdate);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_SetsLastModifiedFields()
+ {
+ // Arrange
+ var property = new Property
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = _testOrgId,
+ Address = "900 Test St",
+ City = "Test City",
+ State = "TS",
+ ZipCode = "12345",
+ PropertyType = "House",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Properties.Add(property);
+ await _context.SaveChangesAsync();
+
+ property.MonthlyRent = 1500m;
+ var beforeUpdate = DateTime.UtcNow;
+
+ // Act
+ var result = await _service.UpdateAsync(property);
+
+ // Assert
+ Assert.Equal(_testUserId, result.LastModifiedBy);
+ Assert.NotNull(result.LastModifiedOn);
+ Assert.True(result.LastModifiedOn >= beforeUpdate);
+ Assert.True(result.LastModifiedOn <= DateTime.UtcNow);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_NonExistentEntity_ThrowsException()
+ {
+ // Arrange
+ var property = new Property
+ {
+ Id = Guid.NewGuid(), // Not in database
+ OrganizationId = _testOrgId,
+ Address = "1000 Test St",
+ City = "Test City",
+ State = "TS",
+ ZipCode = "12345",
+ PropertyType = "House"
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _service.UpdateAsync(property));
+ }
+
+ [Fact]
+ public async Task UpdateAsync_DifferentOrganization_ThrowsUnauthorizedException()
+ {
+ // Arrange
+ var differentOrgId = Guid.NewGuid();
+ var differentOrg = new Organization
+ {
+ Id = differentOrgId,
+ Name = "Different Org",
+ OwnerId = _testUserId,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Organizations.Add(differentOrg);
+
+ var property = new Property
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = differentOrgId,
+ Address = "1100 Test St",
+ City = "Test City",
+ State = "TS",
+ ZipCode = "12345",
+ PropertyType = "House",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Properties.Add(property);
+ await _context.SaveChangesAsync();
+
+ property.Address = "1100 Updated St";
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _service.UpdateAsync(property));
+ }
+
+ [Fact]
+ public async Task UpdateAsync_PreventsOrganizationHijacking()
+ {
+ // Arrange
+ var property = new Property
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = _testOrgId,
+ Address = "1200 Test St",
+ City = "Test City",
+ State = "TS",
+ ZipCode = "12345",
+ PropertyType = "House",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Properties.Add(property);
+ await _context.SaveChangesAsync();
+
+ // Detach the entity so we can simulate an external update attempt
+ _context.Entry(property).State = Microsoft.EntityFrameworkCore.EntityState.Detached;
+
+ // Attempt to change organization on a new instance
+ var updatedProperty = new Property
+ {
+ Id = property.Id,
+ OrganizationId = Guid.NewGuid(), // Try to hijack
+ Address = "1200 Updated St", // Also update something else
+ City = "Test City",
+ State = "TS",
+ ZipCode = "12345",
+ PropertyType = "House",
+ CreatedBy = _testUserId,
+ CreatedOn = property.CreatedOn
+ };
+
+ // Act
+ var result = await _service.UpdateAsync(updatedProperty);
+
+ // Assert - OrganizationId should be preserved as original
+ Assert.Equal(_testOrgId, result.OrganizationId);
+ Assert.Equal("1200 Updated St", result.Address); // Other changes should apply
+ }
+
+ #endregion
+
+ #region DeleteAsync Tests
+
+ [Fact]
+ public async Task DeleteAsync_SoftDeleteEnabled_SoftDeletesEntity()
+ {
+ // Arrange
+ var property = new Property
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = _testOrgId,
+ Address = "1300 Test St",
+ City = "Test City",
+ State = "TS",
+ ZipCode = "12345",
+ PropertyType = "House",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Properties.Add(property);
+ await _context.SaveChangesAsync();
+
+ // Act
+ var result = await _service.DeleteAsync(property.Id);
+
+ // Assert
+ Assert.True(result);
+ var deletedEntity = await _context.Properties.FindAsync(property.Id);
+ Assert.NotNull(deletedEntity);
+ Assert.True(deletedEntity!.IsDeleted);
+ Assert.Equal(_testUserId, deletedEntity.LastModifiedBy);
+ Assert.NotNull(deletedEntity.LastModifiedOn);
+ }
+
+ [Fact]
+ public async Task DeleteAsync_NonExistentEntity_ReturnsFalse()
+ {
+ // Arrange
+ var nonExistentId = Guid.NewGuid();
+
+ // Act
+ var result = await _service.DeleteAsync(nonExistentId);
+
+ // Assert
+ Assert.False(result);
+ }
+
+ [Fact]
+ public async Task DeleteAsync_DifferentOrganization_ThrowsUnauthorizedException()
+ {
+ // Arrange
+ var differentOrgId = Guid.NewGuid();
+ var differentOrg = new Organization
+ {
+ Id = differentOrgId,
+ Name = "Different Org",
+ OwnerId = _testUserId,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Organizations.Add(differentOrg);
+
+ var property = new Property
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = differentOrgId,
+ Address = "1400 Test St",
+ City = "Test City",
+ State = "TS",
+ ZipCode = "12345",
+ PropertyType = "House",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Properties.Add(property);
+ await _context.SaveChangesAsync();
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _service.DeleteAsync(property.Id));
+ }
+
+ #endregion
+
+ #region Security & Authorization Tests
+
+ [Fact]
+ public async Task CreateAsync_UnauthenticatedUser_ThrowsUnauthorizedException()
+ {
+ // Arrange - Create service with no authenticated user
+ var mockAuth = new Mock();
+ var claims = new ClaimsPrincipal(new ClaimsIdentity()); // Not authenticated
+ mockAuth.Setup(a => a.GetAuthenticationStateAsync())
+ .ReturnsAsync(new AuthenticationState(claims));
+
+ var mockUserStore = new Mock>();
+ var mockUserManager = new Mock>(
+ mockUserStore.Object, null, null, null, null, null, null, null, null);
+ mockUserManager.Setup(u => u.FindByIdAsync(It.IsAny()))
+ .ReturnsAsync((ApplicationUser?)null);
+
+ var serviceProvider = new Mock();
+ var unauthorizedUserContext = new UserContextService(mockAuth.Object, mockUserManager.Object, serviceProvider.Object);
+
+ var mockSettings = Options.Create(new ApplicationSettings { SoftDeleteEnabled = true });
+ var mockLogger = new Mock>();
+ var unauthorizedService = new TestPropertyService(_context, mockLogger.Object, unauthorizedUserContext, mockSettings);
+
+ var property = new Property
+ {
+ Address = "1500 Test St",
+ City = "Test City",
+ State = "TS",
+ ZipCode = "12345",
+ PropertyType = "House"
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => unauthorizedService.CreateAsync(property));
+ }
+
+ #endregion
+
+ ///
+ /// Test implementation of BaseService using Property entity for testing purposes.
+ ///
+ public class TestPropertyService : BaseService
+ {
+ public TestPropertyService(
+ ApplicationDbContext context,
+ ILogger logger,
+ UserContextService userContext,
+ IOptions settings)
+ : base(context, logger, userContext, settings)
+ {
+ }
+ }
+ }
+}
diff --git a/Aquiis.Tests/Core/Validation/GuidValidationAttributeTests.cs b/Aquiis.SimpleStart.Tests/Core/Validation/GuidValidationAttributeTests.cs
similarity index 82%
rename from Aquiis.Tests/Core/Validation/GuidValidationAttributeTests.cs
rename to Aquiis.SimpleStart.Tests/Core/Validation/GuidValidationAttributeTests.cs
index ddc5e42..0086347 100644
--- a/Aquiis.Tests/Core/Validation/GuidValidationAttributeTests.cs
+++ b/Aquiis.SimpleStart.Tests/Core/Validation/GuidValidationAttributeTests.cs
@@ -1,12 +1,13 @@
using Aquiis.SimpleStart.Core.Validation;
+using System;
using System.ComponentModel.DataAnnotations;
-using NUnit.Framework;
+using Xunit;
-namespace Aquiis.Tests.Core.Validation;
+namespace Aquiis.SimpleStart.Tests.Core.Validation;
public class RequiredGuidAttributeTests
{
- [Test]
+ [Fact]
public void RequiredGuid_GuidEmpty_ReturnsFalse()
{
// Arrange
@@ -17,10 +18,10 @@ public void RequiredGuid_GuidEmpty_ReturnsFalse()
var result = attribute.IsValid(value);
// Assert
- Assert.That(result, Is.False);
+ Assert.False(result);
}
- [Test]
+ [Fact]
public void RequiredGuid_ValidGuid_ReturnsTrue()
{
// Arrange
@@ -31,10 +32,10 @@ public void RequiredGuid_ValidGuid_ReturnsTrue()
var result = attribute.IsValid(value);
// Assert
- Assert.That(result, Is.True);
+ Assert.True(result);
}
- [Test]
+ [Fact]
public void RequiredGuid_Null_ReturnsFalse()
{
// Arrange
@@ -45,10 +46,10 @@ public void RequiredGuid_Null_ReturnsFalse()
var result = attribute.IsValid(value);
// Assert
- Assert.That(result, Is.False);
+ Assert.False(result);
}
- [Test]
+ [Fact]
public void RequiredGuid_WithContext_GuidEmpty_ReturnsValidationError()
{
// Arrange
@@ -60,11 +61,11 @@ public void RequiredGuid_WithContext_GuidEmpty_ReturnsValidationError()
var result = attribute.GetValidationResult(Guid.Empty, context);
// Assert
- Assert.That(result, Is.Not.Null);
- Assert.That(result, Is.Not.EqualTo(ValidationResult.Success));
+ Assert.NotNull(result);
+ Assert.NotEqual(ValidationResult.Success, result);
}
- [Test]
+ [Fact]
public void RequiredGuid_WithContext_ValidGuid_ReturnsSuccess()
{
// Arrange
@@ -76,10 +77,10 @@ public void RequiredGuid_WithContext_ValidGuid_ReturnsSuccess()
var result = attribute.GetValidationResult(model.Id, context);
// Assert
- Assert.That(result, Is.EqualTo(ValidationResult.Success));
+ Assert.Equal(ValidationResult.Success, result);
}
- [Test]
+ [Fact]
public void RequiredGuid_CustomErrorMessage_UsesCustomMessage()
{
// Arrange
@@ -92,8 +93,8 @@ public void RequiredGuid_CustomErrorMessage_UsesCustomMessage()
var result = attribute.GetValidationResult(Guid.Empty, context);
// Assert
- Assert.That(result, Is.Not.Null);
- Assert.That(result!.ErrorMessage, Is.EqualTo(customMessage));
+ Assert.NotNull(result);
+ Assert.Equal(customMessage, result!.ErrorMessage);
}
private class TestModel
@@ -104,7 +105,7 @@ private class TestModel
public class OptionalGuidAttributeTests
{
- [Test]
+ [Fact]
public void OptionalGuid_Null_ReturnsTrue()
{
// Arrange
@@ -115,10 +116,10 @@ public void OptionalGuid_Null_ReturnsTrue()
var result = attribute.IsValid(value);
// Assert
- Assert.That(result, Is.True);
+ Assert.True(result);
}
- [Test]
+ [Fact]
public void OptionalGuid_GuidEmpty_ReturnsFalse()
{
// Arrange
@@ -129,10 +130,10 @@ public void OptionalGuid_GuidEmpty_ReturnsFalse()
var result = attribute.IsValid(value);
// Assert
- Assert.That(result, Is.False);
+ Assert.False(result);
}
- [Test]
+ [Fact]
public void OptionalGuid_ValidGuid_ReturnsTrue()
{
// Arrange
@@ -143,10 +144,10 @@ public void OptionalGuid_ValidGuid_ReturnsTrue()
var result = attribute.IsValid(value);
// Assert
- Assert.That(result, Is.True);
+ Assert.True(result);
}
- [Test]
+ [Fact]
public void OptionalGuid_WithContext_Null_ReturnsSuccess()
{
// Arrange
@@ -158,10 +159,10 @@ public void OptionalGuid_WithContext_Null_ReturnsSuccess()
var result = attribute.GetValidationResult(null, context);
// Assert
- Assert.That(result, Is.EqualTo(ValidationResult.Success));
+ Assert.Equal(ValidationResult.Success, result);
}
- [Test]
+ [Fact]
public void OptionalGuid_WithContext_GuidEmpty_ReturnsValidationError()
{
// Arrange
@@ -173,11 +174,11 @@ public void OptionalGuid_WithContext_GuidEmpty_ReturnsValidationError()
var result = attribute.GetValidationResult(Guid.Empty, context);
// Assert
- Assert.That(result, Is.Not.Null);
- Assert.That(result, Is.Not.EqualTo(ValidationResult.Success));
+ Assert.NotNull(result);
+ Assert.NotEqual(ValidationResult.Success, result);
}
- [Test]
+ [Fact]
public void OptionalGuid_WithContext_ValidGuid_ReturnsSuccess()
{
// Arrange
@@ -190,7 +191,7 @@ public void OptionalGuid_WithContext_ValidGuid_ReturnsSuccess()
var result = attribute.GetValidationResult(validGuid, context);
// Assert
- Assert.That(result, Is.EqualTo(ValidationResult.Success));
+ Assert.Equal(ValidationResult.Success, result);
}
private class TestModel
diff --git a/Aquiis.SimpleStart.Tests/DocumentServiceTests.cs b/Aquiis.SimpleStart.Tests/DocumentServiceTests.cs
new file mode 100644
index 0000000..4a81a69
--- /dev/null
+++ b/Aquiis.SimpleStart.Tests/DocumentServiceTests.cs
@@ -0,0 +1,834 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Linq;
+using System.Security.Claims;
+using System.Threading.Tasks;
+using Aquiis.SimpleStart.Core.Constants;
+using Aquiis.SimpleStart.Core.Entities;
+using Aquiis.SimpleStart.Infrastructure.Data;
+using Aquiis.SimpleStart.Shared.Components.Account;
+using Aquiis.SimpleStart.Shared.Services;
+using Microsoft.AspNetCore.Components.Authorization;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Moq;
+using Xunit;
+using DocumentService = Aquiis.SimpleStart.Application.Services.DocumentService;
+
+namespace Aquiis.SimpleStart.Tests
+{
+ ///
+ /// Comprehensive unit tests for DocumentService.
+ /// Tests CRUD operations, validation, business logic, and organization isolation.
+ ///
+ public class DocumentServiceTests : IDisposable
+ {
+ private readonly SqliteConnection _connection;
+ private readonly ApplicationDbContext _context;
+ private readonly UserContextService _userContext;
+ private readonly Mock> _mockLogger;
+ private readonly IOptions _mockSettings;
+ private readonly DocumentService _service;
+ private readonly Guid _testOrgId = Guid.NewGuid();
+ private readonly string _testUserId = "test-user-123";
+ private readonly Guid _testPropertyId = Guid.NewGuid();
+ private readonly Guid _testTenantId = Guid.NewGuid();
+ private readonly Guid _testLeaseId = Guid.NewGuid();
+
+ public DocumentServiceTests()
+ {
+ // Setup SQLite in-memory database
+ _connection = new SqliteConnection("DataSource=:memory:");
+ _connection.Open();
+
+ var options = new DbContextOptionsBuilder()
+ .UseSqlite(_connection)
+ .Options;
+
+ _context = new ApplicationDbContext(options);
+ _context.Database.EnsureCreated();
+
+ // Mock AuthenticationStateProvider with claims
+ var claims = new ClaimsPrincipal(new ClaimsIdentity(
+ new[] { new Claim(ClaimTypes.NameIdentifier, _testUserId) },
+ "TestAuth"));
+ var mockAuth = new Mock();
+ mockAuth.Setup(a => a.GetAuthenticationStateAsync())
+ .ReturnsAsync(new AuthenticationState(claims));
+
+ // Mock UserManager
+ var mockUserStore = new Mock>();
+ var mockUserManager = new Mock>(
+ mockUserStore.Object, null, null, null, null, null, null, null, null);
+
+ var appUser = new ApplicationUser
+ {
+ Id = _testUserId,
+ UserName = "testuser",
+ Email = "testuser@example.com",
+ ActiveOrganizationId = _testOrgId
+ };
+ mockUserManager.Setup(u => u.FindByIdAsync(It.IsAny()))
+ .ReturnsAsync(appUser);
+
+ var serviceProvider = new Mock();
+ _userContext = new UserContextService(mockAuth.Object, mockUserManager.Object, serviceProvider.Object);
+
+ // Create test user
+ var user = new ApplicationUser
+ {
+ Id = _testUserId,
+ UserName = "testuser",
+ Email = "testuser@example.com",
+ ActiveOrganizationId = _testOrgId
+ };
+ _context.Users.Add(user);
+
+ // Create test organization
+ var organization = new Organization
+ {
+ Id = _testOrgId,
+ Name = "Test Organization",
+ OwnerId = _testUserId,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Organizations.Add(organization);
+
+ // Create test property
+ var property = new Property
+ {
+ Id = _testPropertyId,
+ OrganizationId = _testOrgId,
+ Address = "123 Test St",
+ City = "Test City",
+ State = "TS",
+ ZipCode = "12345",
+ IsAvailable = true,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Properties.Add(property);
+
+ // Create test tenant
+ var tenant = new Tenant
+ {
+ Id = _testTenantId,
+ OrganizationId = _testOrgId,
+ FirstName = "Test",
+ LastName = "Tenant",
+ Email = "tenant@test.com",
+ IdentificationNumber = "SSN123456",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Tenants.Add(tenant);
+
+ // Create test lease
+ var lease = new Lease
+ {
+ Id = _testLeaseId,
+ OrganizationId = _testOrgId,
+ PropertyId = _testPropertyId,
+ TenantId = _testTenantId,
+ StartDate = DateTime.Today,
+ EndDate = DateTime.Today.AddYears(1),
+ MonthlyRent = 1500,
+ SecurityDeposit = 1500,
+ Status = "Active",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Leases.Add(lease);
+
+ _context.SaveChanges();
+
+ // Setup logger and settings
+ _mockLogger = new Mock>();
+
+ _mockSettings = Options.Create(new ApplicationSettings
+ {
+ SoftDeleteEnabled = true
+ });
+
+ // Create service instance
+ _service = new DocumentService(
+ _context,
+ _mockLogger.Object,
+ _userContext,
+ _mockSettings);
+ }
+
+ public void Dispose()
+ {
+ _context.Dispose();
+ _connection.Dispose();
+ }
+
+ #region Validation Tests
+
+ [Fact]
+ public async Task CreateAsync_ValidDocument_CreatesSuccessfully()
+ {
+ // Arrange
+ var document = new Document
+ {
+ OrganizationId = _testOrgId,
+ FileName = "TestDocument.pdf",
+ FileExtension = ".pdf",
+ FileData = new byte[] { 1, 2, 3, 4, 5 },
+ ContentType = "application/pdf",
+ FileType = "PDF",
+ FileSize = 5,
+ DocumentType = "Lease Agreement",
+ PropertyId = _testPropertyId,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ // Act
+ var result = await _service.CreateAsync(document);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.NotEqual(Guid.Empty, result.Id);
+ Assert.Equal("TestDocument.pdf", result.FileName);
+ }
+
+ [Fact]
+ public async Task CreateAsync_MissingFileName_ThrowsException()
+ {
+ // Arrange
+ var document = new Document
+ {
+ OrganizationId = _testOrgId,
+ FileName = "", // Missing
+ FileExtension = ".pdf",
+ FileData = new byte[] { 1, 2, 3 },
+ DocumentType = "Invoice",
+ PropertyId = _testPropertyId,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _service.CreateAsync(document));
+ }
+
+ [Fact]
+ public async Task CreateAsync_MissingFileExtension_ThrowsException()
+ {
+ // Arrange
+ var document = new Document
+ {
+ OrganizationId = _testOrgId,
+ FileName = "test.pdf",
+ FileExtension = "", // Missing
+ FileData = new byte[] { 1, 2, 3 },
+ DocumentType = "Invoice",
+ PropertyId = _testPropertyId,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _service.CreateAsync(document));
+ }
+
+ [Fact]
+ public async Task CreateAsync_MissingDocumentType_ThrowsException()
+ {
+ // Arrange
+ var document = new Document
+ {
+ OrganizationId = _testOrgId,
+ FileName = "test.pdf",
+ FileExtension = ".pdf",
+ FileData = new byte[] { 1, 2, 3 },
+ DocumentType = "", // Missing
+ PropertyId = _testPropertyId,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _service.CreateAsync(document));
+ }
+
+ [Fact]
+ public async Task CreateAsync_MissingFileData_ThrowsException()
+ {
+ // Arrange
+ var document = new Document
+ {
+ OrganizationId = _testOrgId,
+ FileName = "test.pdf",
+ FileExtension = ".pdf",
+ FileData = Array.Empty(), // Missing
+ DocumentType = "Invoice",
+ PropertyId = _testPropertyId,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _service.CreateAsync(document));
+ }
+
+ [Fact]
+ public async Task CreateAsync_NoForeignKeys_ThrowsException()
+ {
+ // Arrange
+ var document = new Document
+ {
+ OrganizationId = _testOrgId,
+ FileName = "test.pdf",
+ FileExtension = ".pdf",
+ FileData = new byte[] { 1, 2, 3 },
+ DocumentType = "Invoice",
+ // No foreign keys set
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _service.CreateAsync(document));
+ }
+
+ [Fact]
+ public async Task CreateAsync_FileSizeExceedsLimit_ThrowsException()
+ {
+ // Arrange - Create 11MB file (exceeds 10MB limit)
+ var largeFile = new byte[11 * 1024 * 1024];
+ var document = new Document
+ {
+ OrganizationId = _testOrgId,
+ FileName = "large.pdf",
+ FileExtension = ".pdf",
+ FileData = largeFile,
+ FileSize = largeFile.Length,
+ DocumentType = "Invoice",
+ PropertyId = _testPropertyId,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _service.CreateAsync(document));
+ }
+
+ #endregion
+
+ #region Retrieval Tests
+
+ [Fact]
+ public async Task GetDocumentsByPropertyIdAsync_ReturnsPropertyDocuments()
+ {
+ // Arrange - Create documents
+ var doc1 = new Document
+ {
+ OrganizationId = _testOrgId,
+ FileName = "property_doc1.pdf",
+ FileExtension = ".pdf",
+ FileData = new byte[] { 1, 2, 3 },
+ FileSize = 3,
+ DocumentType = "Photo",
+ PropertyId = _testPropertyId,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(doc1);
+
+ var doc2 = new Document
+ {
+ OrganizationId = _testOrgId,
+ FileName = "property_doc2.jpg",
+ FileExtension = ".jpg",
+ FileData = new byte[] { 4, 5, 6 },
+ FileSize = 3,
+ DocumentType = "Photo",
+ PropertyId = _testPropertyId,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(doc2);
+
+ // Act
+ var result = await _service.GetDocumentsByPropertyIdAsync(_testPropertyId);
+
+ // Assert
+ Assert.Equal(2, result.Count);
+ Assert.All(result, d => Assert.Equal(_testPropertyId, d.PropertyId));
+ }
+
+ [Fact]
+ public async Task GetDocumentsByTenantIdAsync_ReturnsTenantDocuments()
+ {
+ // Arrange
+ var document = new Document
+ {
+ OrganizationId = _testOrgId,
+ FileName = "tenant_id.pdf",
+ FileExtension = ".pdf",
+ FileData = new byte[] { 1, 2, 3 },
+ FileSize = 3,
+ DocumentType = "Identification",
+ TenantId = _testTenantId,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(document);
+
+ // Act
+ var result = await _service.GetDocumentsByTenantIdAsync(_testTenantId);
+
+ // Assert
+ Assert.Single(result);
+ Assert.Equal(_testTenantId, result[0].TenantId);
+ }
+
+ [Fact]
+ public async Task GetDocumentsByLeaseIdAsync_ReturnsLeaseDocuments()
+ {
+ // Arrange
+ var document = new Document
+ {
+ OrganizationId = _testOrgId,
+ FileName = "lease_agreement.pdf",
+ FileExtension = ".pdf",
+ FileData = new byte[] { 1, 2, 3 },
+ FileSize = 3,
+ DocumentType = "Lease Agreement",
+ LeaseId = _testLeaseId,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(document);
+
+ // Act
+ var result = await _service.GetDocumentsByLeaseIdAsync(_testLeaseId);
+
+ // Assert
+ Assert.Single(result);
+ Assert.Equal(_testLeaseId, result[0].LeaseId);
+ }
+
+ [Fact]
+ public async Task GetDocumentsByTypeAsync_ReturnsDocumentsOfType()
+ {
+ // Arrange - Create documents of different types
+ var leaseDoc = new Document
+ {
+ OrganizationId = _testOrgId,
+ FileName = "lease1.pdf",
+ FileExtension = ".pdf",
+ FileData = new byte[] { 1, 2, 3 },
+ FileSize = 3,
+ DocumentType = "Lease Agreement",
+ LeaseId = _testLeaseId,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(leaseDoc);
+
+ var photo = new Document
+ {
+ OrganizationId = _testOrgId,
+ FileName = "photo.jpg",
+ FileExtension = ".jpg",
+ FileData = new byte[] { 4, 5, 6 },
+ FileSize = 3,
+ DocumentType = "Photo",
+ PropertyId = _testPropertyId,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(photo);
+
+ // Act
+ var leaseResults = await _service.GetDocumentsByTypeAsync("Lease Agreement");
+ var photoResults = await _service.GetDocumentsByTypeAsync("Photo");
+
+ // Assert
+ Assert.Single(leaseResults);
+ Assert.Single(photoResults);
+ Assert.Equal("Lease Agreement", leaseResults[0].DocumentType);
+ Assert.Equal("Photo", photoResults[0].DocumentType);
+ }
+
+ [Fact]
+ public async Task SearchDocumentsByFilenameAsync_FindsMatchingDocuments()
+ {
+ // Arrange
+ var doc1 = new Document
+ {
+ OrganizationId = _testOrgId,
+ FileName = "lease_agreement_2025.pdf",
+ FileExtension = ".pdf",
+ FileData = new byte[] { 1, 2, 3 },
+ FileSize = 3,
+ DocumentType = "Lease Agreement",
+ LeaseId = _testLeaseId,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(doc1);
+
+ var doc2 = new Document
+ {
+ OrganizationId = _testOrgId,
+ FileName = "property_photo.jpg",
+ FileExtension = ".jpg",
+ FileData = new byte[] { 4, 5, 6 },
+ FileSize = 3,
+ DocumentType = "Photo",
+ PropertyId = _testPropertyId,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(doc2);
+
+ // Act
+ var leaseResults = await _service.SearchDocumentsByFilenameAsync("lease");
+ var photoResults = await _service.SearchDocumentsByFilenameAsync("photo");
+
+ // Assert
+ Assert.Single(leaseResults);
+ Assert.Single(photoResults);
+ Assert.Contains("lease", leaseResults[0].FileName.ToLower());
+ Assert.Contains("photo", photoResults[0].FileName.ToLower());
+ }
+
+ [Fact]
+ public async Task SearchDocumentsByFilenameAsync_EmptySearch_ReturnsRecentDocuments()
+ {
+ // Arrange - Create documents directly in database to control CreatedOn
+ for (int i = 0; i < 3; i++)
+ {
+ var doc = new Document
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = _testOrgId,
+ FileName = $"doc{i}.pdf",
+ FileExtension = ".pdf",
+ FileData = new byte[] { 1, 2, 3 },
+ FileSize = 3,
+ DocumentType = "Test",
+ PropertyId = _testPropertyId,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow.AddMinutes(i), // doc2 is most recent, doc0 is oldest
+ IsDeleted = false
+ };
+ _context.Documents.Add(doc);
+ }
+ await _context.SaveChangesAsync();
+
+ // Act
+ var result = await _service.SearchDocumentsByFilenameAsync("");
+
+ // Assert
+ Assert.Equal(3, result.Count);
+ // Should be ordered by most recent first (descending CreatedOn)
+ Assert.Equal("doc2.pdf", result[0].FileName); // Most recent
+ Assert.Equal("doc1.pdf", result[1].FileName);
+ Assert.Equal("doc0.pdf", result[2].FileName); // Oldest
+ }
+
+ [Fact]
+ public async Task GetDocumentWithRelationsAsync_LoadsAllRelations()
+ {
+ // Arrange
+ var document = new Document
+ {
+ OrganizationId = _testOrgId,
+ FileName = "full_relations.pdf",
+ FileExtension = ".pdf",
+ FileData = new byte[] { 1, 2, 3 },
+ FileSize = 3,
+ DocumentType = "Lease Agreement",
+ PropertyId = _testPropertyId,
+ TenantId = _testTenantId,
+ LeaseId = _testLeaseId,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ var created = await _service.CreateAsync(document);
+
+ // Act
+ var result = await _service.GetDocumentWithRelationsAsync(created.Id);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.NotNull(result.Property);
+ Assert.NotNull(result.Tenant);
+ Assert.NotNull(result.Lease);
+ Assert.Equal(_testPropertyId, result.Property.Id);
+ Assert.Equal(_testTenantId, result.Tenant!.Id);
+ Assert.Equal(_testLeaseId, result.Lease!.Id);
+ }
+
+ #endregion
+
+ #region Business Logic Tests
+
+ [Fact]
+ public async Task CalculateTotalStorageUsedAsync_ReturnsCorrectTotal()
+ {
+ // Arrange - Create documents with known sizes
+ var doc1 = new Document
+ {
+ OrganizationId = _testOrgId,
+ FileName = "doc1.pdf",
+ FileExtension = ".pdf",
+ FileData = new byte[1000],
+ FileSize = 1000,
+ DocumentType = "Test",
+ PropertyId = _testPropertyId,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(doc1);
+
+ var doc2 = new Document
+ {
+ OrganizationId = _testOrgId,
+ FileName = "doc2.jpg",
+ FileExtension = ".jpg",
+ FileData = new byte[2500],
+ FileSize = 2500,
+ DocumentType = "Photo",
+ PropertyId = _testPropertyId,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(doc2);
+
+ // Act
+ var totalStorage = await _service.CalculateTotalStorageUsedAsync();
+
+ // Assert
+ Assert.Equal(3500, totalStorage);
+ }
+
+ [Fact]
+ public async Task GetDocumentsByDateRangeAsync_ReturnsDocumentsInRange()
+ {
+ // Arrange - Create documents at different times
+ var oldDoc = new Document
+ {
+ OrganizationId = _testOrgId,
+ FileName = "old.pdf",
+ FileExtension = ".pdf",
+ FileData = new byte[] { 1, 2, 3 },
+ FileSize = 3,
+ DocumentType = "Test",
+ PropertyId = _testPropertyId,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow.AddMonths(-2)
+ };
+ _context.Documents.Add(oldDoc);
+
+ var recentDoc = new Document
+ {
+ OrganizationId = _testOrgId,
+ FileName = "recent.pdf",
+ FileExtension = ".pdf",
+ FileData = new byte[] { 4, 5, 6 },
+ FileSize = 3,
+ DocumentType = "Test",
+ PropertyId = _testPropertyId,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(recentDoc);
+
+ // Act
+ var startDate = DateTime.UtcNow.AddDays(-7);
+ var endDate = DateTime.UtcNow.AddDays(1);
+ var result = await _service.GetDocumentsByDateRangeAsync(startDate, endDate);
+
+ // Assert
+ Assert.Single(result);
+ Assert.Equal("recent.pdf", result[0].FileName);
+ }
+
+ [Fact]
+ public async Task GetDocumentCountByTypeAsync_ReturnsCorrectCounts()
+ {
+ // Arrange - Create documents of various types
+ var types = new[] { "Lease Agreement", "Lease Agreement", "Photo", "Invoice" };
+ foreach (var type in types)
+ {
+ var doc = new Document
+ {
+ OrganizationId = _testOrgId,
+ FileName = $"{type}.pdf",
+ FileExtension = ".pdf",
+ FileData = new byte[] { 1, 2, 3 },
+ FileSize = 3,
+ DocumentType = type,
+ PropertyId = _testPropertyId,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(doc);
+ }
+
+ // Act
+ var counts = await _service.GetDocumentCountByTypeAsync();
+
+ // Assert
+ Assert.Equal(2, counts["Lease Agreement"]);
+ Assert.Equal(1, counts["Photo"]);
+ Assert.Equal(1, counts["Invoice"]);
+ }
+
+ #endregion
+
+ #region Organization Isolation Tests
+
+ [Fact]
+ public async Task GetByIdAsync_DifferentOrganization_ReturnsNull()
+ {
+ // Arrange - Create different organization and document
+ var otherUserId = "other-user-456";
+ var otherUser = new ApplicationUser
+ {
+ Id = otherUserId,
+ UserName = "otheruser",
+ Email = "otheruser@example.com"
+ };
+ _context.Users.Add(otherUser);
+ await _context.SaveChangesAsync();
+
+ var otherOrg = new Organization
+ {
+ Id = Guid.NewGuid(),
+ Name = "Other Organization",
+ OwnerId = otherUserId,
+ CreatedBy = otherUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Organizations.AddAsync(otherOrg);
+
+ var otherProperty = new Property
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = otherOrg.Id,
+ Address = "999 Other St",
+ City = "Other City",
+ State = "OT",
+ ZipCode = "99999",
+ IsAvailable = true,
+ CreatedBy = otherUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Properties.AddAsync(otherProperty);
+
+ var otherOrgDoc = new Document
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = otherOrg.Id,
+ FileName = "other_doc.pdf",
+ FileExtension = ".pdf",
+ FileData = new byte[] { 1, 2, 3 },
+ FileSize = 3,
+ DocumentType = "Test",
+ PropertyId = otherProperty.Id,
+ CreatedBy = otherUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Documents.AddAsync(otherOrgDoc);
+ await _context.SaveChangesAsync();
+
+ // Act
+ var result = await _service.GetByIdAsync(otherOrgDoc.Id);
+
+ // Assert
+ Assert.Null(result); // Should not access document from different org
+ }
+
+ [Fact]
+ public async Task GetAllAsync_ReturnsOnlyCurrentOrganizationDocuments()
+ {
+ // Arrange - Create document in test org
+ var testOrgDoc = new Document
+ {
+ OrganizationId = _testOrgId,
+ FileName = "test_org.pdf",
+ FileExtension = ".pdf",
+ FileData = new byte[] { 1, 2, 3 },
+ FileSize = 3,
+ DocumentType = "Test",
+ PropertyId = _testPropertyId,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(testOrgDoc);
+
+ // Create document in different org
+ var otherUserId = "other-user-456";
+ var otherUser = new ApplicationUser
+ {
+ Id = otherUserId,
+ UserName = "otheruser",
+ Email = "otheruser@example.com"
+ };
+ _context.Users.Add(otherUser);
+ await _context.SaveChangesAsync();
+
+ var otherOrg = new Organization
+ {
+ Id = Guid.NewGuid(),
+ Name = "Other Organization",
+ OwnerId = otherUserId,
+ CreatedBy = otherUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Organizations.AddAsync(otherOrg);
+
+ var otherProperty = new Property
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = otherOrg.Id,
+ Address = "888 Other Ave",
+ City = "Other City",
+ State = "OT",
+ ZipCode = "88888",
+ IsAvailable = true,
+ CreatedBy = otherUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Properties.AddAsync(otherProperty);
+
+ var otherOrgDoc = new Document
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = otherOrg.Id,
+ FileName = "other.pdf",
+ FileExtension = ".pdf",
+ FileData = new byte[] { 4, 5, 6 },
+ FileSize = 3,
+ DocumentType = "Test",
+ PropertyId = otherProperty.Id,
+ CreatedBy = otherUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Documents.AddAsync(otherOrgDoc);
+ await _context.SaveChangesAsync();
+
+ // Act
+ var result = await _service.GetAllAsync();
+
+ // Assert
+ Assert.Single(result);
+ Assert.Equal(_testOrgId, result[0].OrganizationId);
+ }
+
+ #endregion
+ }
+}
diff --git a/Aquiis.SimpleStart.Tests/InvoiceServiceTests.cs b/Aquiis.SimpleStart.Tests/InvoiceServiceTests.cs
new file mode 100644
index 0000000..3b44833
--- /dev/null
+++ b/Aquiis.SimpleStart.Tests/InvoiceServiceTests.cs
@@ -0,0 +1,1009 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Linq;
+using System.Security.Claims;
+using System.Threading.Tasks;
+using Aquiis.SimpleStart.Core.Constants;
+using Aquiis.SimpleStart.Core.Entities;
+using Aquiis.SimpleStart.Infrastructure.Data;
+using Aquiis.SimpleStart.Shared.Components.Account;
+using Aquiis.SimpleStart.Shared.Services;
+using Microsoft.AspNetCore.Components.Authorization;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Moq;
+using Xunit;
+using InvoiceService = Aquiis.SimpleStart.Application.Services.InvoiceService;
+
+namespace Aquiis.SimpleStart.Tests
+{
+ ///
+ /// Comprehensive unit tests for InvoiceService.
+ /// Tests CRUD operations, validation, business logic, and organization isolation.
+ ///
+ public class InvoiceServiceTests : IDisposable
+ {
+ private readonly SqliteConnection _connection;
+ private readonly ApplicationDbContext _context;
+ private readonly UserContextService _userContext;
+ private readonly Mock> _mockLogger;
+ private readonly IOptions _mockSettings;
+ private readonly InvoiceService _service;
+ private readonly Guid _testOrgId = Guid.NewGuid();
+ private readonly string _testUserId = "test-user-123";
+ private readonly Guid _testPropertyId = Guid.NewGuid();
+ private readonly Guid _testTenantId = Guid.NewGuid();
+ private readonly Guid _testLeaseId = Guid.NewGuid();
+
+ public InvoiceServiceTests()
+ {
+ // Setup SQLite in-memory database
+ _connection = new SqliteConnection("DataSource=:memory:");
+ _connection.Open();
+
+ var options = new DbContextOptionsBuilder()
+ .UseSqlite(_connection)
+ .Options;
+
+ _context = new ApplicationDbContext(options);
+ _context.Database.EnsureCreated();
+
+ // Mock AuthenticationStateProvider with claims
+ var claims = new ClaimsPrincipal(new ClaimsIdentity(
+ new[] { new Claim(ClaimTypes.NameIdentifier, _testUserId) },
+ "TestAuth"));
+ var mockAuth = new Mock();
+ mockAuth.Setup(a => a.GetAuthenticationStateAsync())
+ .ReturnsAsync(new AuthenticationState(claims));
+
+ // Mock UserManager
+ var mockUserStore = new Mock>();
+ var mockUserManager = new Mock>(
+ mockUserStore.Object, null, null, null, null, null, null, null, null);
+
+ var appUser = new ApplicationUser
+ {
+ Id = _testUserId,
+ UserName = "testuser",
+ Email = "testuser@example.com",
+ ActiveOrganizationId = _testOrgId
+ };
+ mockUserManager.Setup(u => u.FindByIdAsync(It.IsAny()))
+ .ReturnsAsync(appUser);
+
+ var serviceProvider = new Mock();
+ _userContext = new UserContextService(mockAuth.Object, mockUserManager.Object, serviceProvider.Object);
+
+ // Create test user
+ var user = new ApplicationUser
+ {
+ Id = _testUserId,
+ UserName = "testuser",
+ Email = "testuser@example.com",
+ ActiveOrganizationId = _testOrgId
+ };
+ _context.Users.Add(user);
+
+ // Create test organization
+ var organization = new Organization
+ {
+ Id = _testOrgId,
+ Name = "Test Organization",
+ OwnerId = _testUserId,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Organizations.Add(organization);
+
+ // Create test property
+ var property = new Property
+ {
+ Id = _testPropertyId,
+ OrganizationId = _testOrgId,
+ Address = "123 Test St",
+ City = "Test City",
+ State = "TS",
+ ZipCode = "12345",
+ IsAvailable = false,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Properties.Add(property);
+
+ // Create test tenant
+ var tenant = new Tenant
+ {
+ Id = _testTenantId,
+ OrganizationId = _testOrgId,
+ FirstName = "Test",
+ LastName = "Tenant",
+ Email = "tenant@test.com",
+ IdentificationNumber = "SSN123456",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Tenants.Add(tenant);
+
+ // Create test lease
+ var lease = new Lease
+ {
+ Id = _testLeaseId,
+ OrganizationId = _testOrgId,
+ PropertyId = _testPropertyId,
+ TenantId = _testTenantId,
+ StartDate = DateTime.Today,
+ EndDate = DateTime.Today.AddYears(1),
+ MonthlyRent = 1500,
+ SecurityDeposit = 1500,
+ Status = "Active",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Leases.Add(lease);
+
+ _context.SaveChanges();
+
+ // Setup logger and settings
+ _mockLogger = new Mock>();
+
+ _mockSettings = Options.Create(new ApplicationSettings
+ {
+ SoftDeleteEnabled = true
+ });
+
+ // Create service instance
+ _service = new InvoiceService(
+ _context,
+ _mockLogger.Object,
+ _userContext,
+ _mockSettings);
+ }
+
+ public void Dispose()
+ {
+ _context.Dispose();
+ _connection.Dispose();
+ }
+
+ #region Validation Tests
+
+ [Fact]
+ public async Task CreateAsync_ValidInvoice_CreatesSuccessfully()
+ {
+ // Arrange
+ var invoice = new Invoice
+ {
+ OrganizationId = _testOrgId,
+ LeaseId = _testLeaseId,
+ InvoiceNumber = "INV-202512-00001",
+ InvoicedOn = DateTime.Today,
+ DueOn = DateTime.Today.AddDays(30),
+ Amount = 1500,
+ Description = "Monthly Rent - December 2025",
+ Status = "Pending",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ // Act
+ var result = await _service.CreateAsync(invoice);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.NotEqual(Guid.Empty, result.Id);
+ Assert.Equal("INV-202512-00001", result.InvoiceNumber);
+ Assert.Equal(1500, result.Amount);
+ }
+
+ [Fact]
+ public async Task CreateAsync_MissingLeaseId_ThrowsException()
+ {
+ // Arrange
+ var invoice = new Invoice
+ {
+ OrganizationId = _testOrgId,
+ LeaseId = Guid.Empty, // Missing
+ InvoiceNumber = "INV-001",
+ InvoicedOn = DateTime.Today,
+ DueOn = DateTime.Today.AddDays(30),
+ Amount = 1500,
+ Description = "Test",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _service.CreateAsync(invoice));
+ }
+
+ [Fact]
+ public async Task CreateAsync_MissingInvoiceNumber_ThrowsException()
+ {
+ // Arrange
+ var invoice = new Invoice
+ {
+ OrganizationId = _testOrgId,
+ LeaseId = _testLeaseId,
+ InvoiceNumber = "", // Missing
+ InvoicedOn = DateTime.Today,
+ DueOn = DateTime.Today.AddDays(30),
+ Amount = 1500,
+ Description = "Test",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _service.CreateAsync(invoice));
+ }
+
+ [Fact]
+ public async Task CreateAsync_MissingDescription_ThrowsException()
+ {
+ // Arrange
+ var invoice = new Invoice
+ {
+ OrganizationId = _testOrgId,
+ LeaseId = _testLeaseId,
+ InvoiceNumber = "INV-001",
+ InvoicedOn = DateTime.Today,
+ DueOn = DateTime.Today.AddDays(30),
+ Amount = 1500,
+ Description = "", // Missing
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _service.CreateAsync(invoice));
+ }
+
+ [Fact]
+ public async Task CreateAsync_ZeroAmount_ThrowsException()
+ {
+ // Arrange
+ var invoice = new Invoice
+ {
+ OrganizationId = _testOrgId,
+ LeaseId = _testLeaseId,
+ InvoiceNumber = "INV-001",
+ InvoicedOn = DateTime.Today,
+ DueOn = DateTime.Today.AddDays(30),
+ Amount = 0, // Invalid
+ Description = "Test",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _service.CreateAsync(invoice));
+ }
+
+ [Fact]
+ public async Task CreateAsync_DueBeforeInvoiced_ThrowsException()
+ {
+ // Arrange
+ var invoice = new Invoice
+ {
+ OrganizationId = _testOrgId,
+ LeaseId = _testLeaseId,
+ InvoiceNumber = "INV-001",
+ InvoicedOn = DateTime.Today,
+ DueOn = DateTime.Today.AddDays(-1), // Before invoice date
+ Amount = 1500,
+ Description = "Test",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _service.CreateAsync(invoice));
+ }
+
+ [Fact]
+ public async Task CreateAsync_DuplicateInvoiceNumber_ThrowsException()
+ {
+ // Arrange
+ var invoice1 = new Invoice
+ {
+ OrganizationId = _testOrgId,
+ LeaseId = _testLeaseId,
+ InvoiceNumber = "INV-DUPLICATE",
+ InvoicedOn = DateTime.Today,
+ DueOn = DateTime.Today.AddDays(30),
+ Amount = 1500,
+ Description = "Test",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(invoice1);
+
+ var invoice2 = new Invoice
+ {
+ OrganizationId = _testOrgId,
+ LeaseId = _testLeaseId,
+ InvoiceNumber = "INV-DUPLICATE", // Same number
+ InvoicedOn = DateTime.Today,
+ DueOn = DateTime.Today.AddDays(30),
+ Amount = 1500,
+ Description = "Test",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _service.CreateAsync(invoice2));
+ }
+
+ [Fact]
+ public async Task CreateAsync_InvalidStatus_ThrowsException()
+ {
+ // Arrange
+ var invoice = new Invoice
+ {
+ OrganizationId = _testOrgId,
+ LeaseId = _testLeaseId,
+ InvoiceNumber = "INV-001",
+ InvoicedOn = DateTime.Today,
+ DueOn = DateTime.Today.AddDays(30),
+ Amount = 1500,
+ Description = "Test",
+ Status = "InvalidStatus", // Invalid
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _service.CreateAsync(invoice));
+ }
+
+ #endregion
+
+ #region Retrieval Tests
+
+ [Fact]
+ public async Task GetInvoicesByLeaseIdAsync_ReturnsLeaseInvoices()
+ {
+ // Arrange - Create invoices
+ var invoice1 = new Invoice
+ {
+ OrganizationId = _testOrgId,
+ LeaseId = _testLeaseId,
+ InvoiceNumber = "INV-001",
+ InvoicedOn = DateTime.Today,
+ DueOn = DateTime.Today.AddDays(30),
+ Amount = 1500,
+ Description = "Rent - Month 1",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(invoice1);
+
+ var invoice2 = new Invoice
+ {
+ OrganizationId = _testOrgId,
+ LeaseId = _testLeaseId,
+ InvoiceNumber = "INV-002",
+ InvoicedOn = DateTime.Today.AddMonths(1),
+ DueOn = DateTime.Today.AddMonths(1).AddDays(30),
+ Amount = 1500,
+ Description = "Rent - Month 2",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(invoice2);
+
+ // Act
+ var result = await _service.GetInvoicesByLeaseIdAsync(_testLeaseId);
+
+ // Assert
+ Assert.Equal(2, result.Count);
+ Assert.All(result, i => Assert.Equal(_testLeaseId, i.LeaseId));
+ }
+
+ [Fact]
+ public async Task GetInvoicesByStatusAsync_ReturnsMatchingInvoices()
+ {
+ // Arrange
+ var pending = new Invoice
+ {
+ OrganizationId = _testOrgId,
+ LeaseId = _testLeaseId,
+ InvoiceNumber = "INV-PENDING",
+ InvoicedOn = DateTime.Today,
+ DueOn = DateTime.Today.AddDays(30),
+ Amount = 1500,
+ Description = "Pending Invoice",
+ Status = "Pending",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(pending);
+
+ var paid = new Invoice
+ {
+ OrganizationId = _testOrgId,
+ LeaseId = _testLeaseId,
+ InvoiceNumber = "INV-PAID",
+ InvoicedOn = DateTime.Today,
+ DueOn = DateTime.Today.AddDays(30),
+ Amount = 1500,
+ Description = "Paid Invoice",
+ Status = "Paid",
+ PaidOn = DateTime.Today,
+ AmountPaid = 1500,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(paid);
+
+ // Act
+ var pendingResults = await _service.GetInvoicesByStatusAsync("Pending");
+ var paidResults = await _service.GetInvoicesByStatusAsync("Paid");
+
+ // Assert
+ Assert.Single(pendingResults);
+ Assert.Single(paidResults);
+ Assert.Equal("Pending", pendingResults[0].Status);
+ Assert.Equal("Paid", paidResults[0].Status);
+ }
+
+ [Fact]
+ public async Task GetOverdueInvoicesAsync_ReturnsOnlyOverdueInvoices()
+ {
+ // Arrange - Create overdue invoice
+ var overdue = new Invoice
+ {
+ OrganizationId = _testOrgId,
+ LeaseId = _testLeaseId,
+ InvoiceNumber = "INV-OVERDUE",
+ InvoicedOn = DateTime.Today.AddDays(-60),
+ DueOn = DateTime.Today.AddDays(-30), // 30 days overdue
+ Amount = 1500,
+ Description = "Overdue Invoice",
+ Status = "Pending",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(overdue);
+
+ // Create current invoice (not overdue)
+ var current = new Invoice
+ {
+ OrganizationId = _testOrgId,
+ LeaseId = _testLeaseId,
+ InvoiceNumber = "INV-CURRENT",
+ InvoicedOn = DateTime.Today,
+ DueOn = DateTime.Today.AddDays(30),
+ Amount = 1500,
+ Description = "Current Invoice",
+ Status = "Pending",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(current);
+
+ // Act
+ var result = await _service.GetOverdueInvoicesAsync();
+
+ // Assert
+ Assert.Single(result);
+ Assert.Equal("INV-OVERDUE", result[0].InvoiceNumber);
+ Assert.True(result[0].DueOn < DateTime.Today);
+ }
+
+ [Fact]
+ public async Task GetInvoicesDueSoonAsync_ReturnsInvoicesDueWithinThreshold()
+ {
+ // Arrange - Create invoice due in 5 days
+ var dueSoon = new Invoice
+ {
+ OrganizationId = _testOrgId,
+ LeaseId = _testLeaseId,
+ InvoiceNumber = "INV-DUE-SOON",
+ InvoicedOn = DateTime.Today,
+ DueOn = DateTime.Today.AddDays(5),
+ Amount = 1500,
+ Description = "Due Soon",
+ Status = "Pending",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(dueSoon);
+
+ // Create invoice due in 30 days (outside threshold)
+ var dueLater = new Invoice
+ {
+ OrganizationId = _testOrgId,
+ LeaseId = _testLeaseId,
+ InvoiceNumber = "INV-DUE-LATER",
+ InvoicedOn = DateTime.Today,
+ DueOn = DateTime.Today.AddDays(30),
+ Amount = 1500,
+ Description = "Due Later",
+ Status = "Pending",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(dueLater);
+
+ // Act
+ var result = await _service.GetInvoicesDueSoonAsync(7);
+
+ // Assert
+ Assert.Single(result);
+ Assert.Equal("INV-DUE-SOON", result[0].InvoiceNumber);
+ }
+
+ [Fact]
+ public async Task GetInvoiceWithRelationsAsync_LoadsAllRelations()
+ {
+ // Arrange
+ var invoice = new Invoice
+ {
+ OrganizationId = _testOrgId,
+ LeaseId = _testLeaseId,
+ InvoiceNumber = "INV-RELATIONS",
+ InvoicedOn = DateTime.Today,
+ DueOn = DateTime.Today.AddDays(30),
+ Amount = 1500,
+ Description = "Test Relations",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ var created = await _service.CreateAsync(invoice);
+
+ // Act
+ var result = await _service.GetInvoiceWithRelationsAsync(created.Id);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.NotNull(result.Lease);
+ Assert.NotNull(result.Lease.Property);
+ Assert.NotNull(result.Lease.Tenant);
+ Assert.Equal(_testLeaseId, result.Lease.Id);
+ }
+
+ #endregion
+
+ #region Business Logic Tests
+
+ [Fact]
+ public async Task GenerateInvoiceNumberAsync_GeneratesUniqueNumber()
+ {
+ // Act
+ var invoiceNumber1 = await _service.GenerateInvoiceNumberAsync();
+ var invoiceNumber2 = await _service.GenerateInvoiceNumberAsync();
+
+ // Assert
+ Assert.NotNull(invoiceNumber1);
+ Assert.NotNull(invoiceNumber2);
+ Assert.StartsWith("INV-", invoiceNumber1);
+ Assert.StartsWith("INV-", invoiceNumber2);
+ // Numbers should be same format but potentially different sequence
+ Assert.Matches(@"^INV-\d{6}-\d{5}$", invoiceNumber1);
+ }
+
+ [Fact]
+ public async Task ApplyLateFeeAsync_AddsLateFeeToInvoice()
+ {
+ // Arrange
+ var invoice = new Invoice
+ {
+ OrganizationId = _testOrgId,
+ LeaseId = _testLeaseId,
+ InvoiceNumber = "INV-LATE-FEE",
+ InvoicedOn = DateTime.Today.AddDays(-60),
+ DueOn = DateTime.Today.AddDays(-30),
+ Amount = 1500,
+ Description = "Overdue Invoice",
+ Status = "Pending",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ var created = await _service.CreateAsync(invoice);
+
+ // Act
+ var result = await _service.ApplyLateFeeAsync(created.Id, 50m);
+
+ // Assert
+ Assert.Equal(50m, result.LateFeeAmount);
+ Assert.True(result.LateFeeApplied);
+ Assert.NotNull(result.LateFeeAppliedOn);
+ Assert.Equal("Overdue", result.Status);
+ }
+
+ [Fact]
+ public async Task ApplyLateFeeAsync_AlreadyApplied_ThrowsException()
+ {
+ // Arrange
+ var invoice = new Invoice
+ {
+ OrganizationId = _testOrgId,
+ LeaseId = _testLeaseId,
+ InvoiceNumber = "INV-TEST",
+ InvoicedOn = DateTime.Today,
+ DueOn = DateTime.Today.AddDays(30),
+ Amount = 1500,
+ Description = "Test",
+ Status = "Pending",
+ LateFeeApplied = true,
+ LateFeeAmount = 50,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ var created = await _service.CreateAsync(invoice);
+
+ // Act & Assert
+ await Assert.ThrowsAsync(
+ () => _service.ApplyLateFeeAsync(created.Id, 50m));
+ }
+
+ [Fact]
+ public async Task MarkReminderSentAsync_UpdatesReminderFields()
+ {
+ // Arrange
+ var invoice = new Invoice
+ {
+ OrganizationId = _testOrgId,
+ LeaseId = _testLeaseId,
+ InvoiceNumber = "INV-REMINDER",
+ InvoicedOn = DateTime.Today,
+ DueOn = DateTime.Today.AddDays(5),
+ Amount = 1500,
+ Description = "Test Reminder",
+ Status = "Pending",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ var created = await _service.CreateAsync(invoice);
+
+ // Act
+ var result = await _service.MarkReminderSentAsync(created.Id);
+
+ // Assert
+ Assert.True(result.ReminderSent);
+ Assert.NotNull(result.ReminderSentOn);
+ }
+
+ [Fact]
+ public async Task UpdateInvoiceStatusAsync_FullyPaid_UpdatesStatusToPaid()
+ {
+ // Arrange
+ var invoice = new Invoice
+ {
+ OrganizationId = _testOrgId,
+ LeaseId = _testLeaseId,
+ InvoiceNumber = "INV-STATUS-TEST",
+ InvoicedOn = DateTime.Today,
+ DueOn = DateTime.Today.AddDays(30),
+ Amount = 1500,
+ Description = "Test Status",
+ Status = "Pending",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ var created = await _service.CreateAsync(invoice);
+
+ // Create payment
+ var payment = new Payment
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = _testOrgId,
+ InvoiceId = created.Id,
+ Amount = 1500,
+ PaymentMethod = "Check",
+ PaidOn = DateTime.Today,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow,
+ IsDeleted = false
+ };
+ _context.Payments.Add(payment);
+ await _context.SaveChangesAsync();
+
+ // Act
+ var result = await _service.UpdateInvoiceStatusAsync(created.Id);
+
+ // Assert
+ Assert.Equal("Paid", result.Status);
+ Assert.Equal(1500, result.AmountPaid);
+ Assert.NotNull(result.PaidOn);
+ }
+
+ [Fact]
+ public async Task CalculateTotalOutstandingAsync_ReturnsCorrectTotal()
+ {
+ // Arrange - Create unpaid invoices
+ var invoice1 = new Invoice
+ {
+ OrganizationId = _testOrgId,
+ LeaseId = _testLeaseId,
+ InvoiceNumber = "INV-OUT-1",
+ InvoicedOn = DateTime.Today,
+ DueOn = DateTime.Today.AddDays(30),
+ Amount = 1500,
+ Description = "Outstanding 1",
+ Status = "Pending",
+ AmountPaid = 0,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(invoice1);
+
+ var invoice2 = new Invoice
+ {
+ OrganizationId = _testOrgId,
+ LeaseId = _testLeaseId,
+ InvoiceNumber = "INV-OUT-2",
+ InvoicedOn = DateTime.Today,
+ DueOn = DateTime.Today.AddDays(30),
+ Amount = 2000,
+ Description = "Outstanding 2",
+ Status = "Pending",
+ AmountPaid = 500, // Partially paid
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(invoice2);
+
+ // Act
+ var total = await _service.CalculateTotalOutstandingAsync();
+
+ // Assert
+ Assert.Equal(3000m, total); // 1500 + (2000 - 500)
+ }
+
+ [Fact]
+ public async Task GetInvoicesByDateRangeAsync_ReturnsInvoicesInRange()
+ {
+ // Arrange
+ var oldInvoice = new Invoice
+ {
+ OrganizationId = _testOrgId,
+ LeaseId = _testLeaseId,
+ InvoiceNumber = "INV-OLD",
+ InvoicedOn = DateTime.Today.AddMonths(-2),
+ DueOn = DateTime.Today.AddMonths(-1),
+ Amount = 1500,
+ Description = "Old Invoice",
+ Status = "Paid",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(oldInvoice);
+
+ var recentInvoice = new Invoice
+ {
+ OrganizationId = _testOrgId,
+ LeaseId = _testLeaseId,
+ InvoiceNumber = "INV-RECENT",
+ InvoicedOn = DateTime.Today,
+ DueOn = DateTime.Today.AddDays(30),
+ Amount = 1500,
+ Description = "Recent Invoice",
+ Status = "Pending",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(recentInvoice);
+
+ // Act
+ var startDate = DateTime.Today.AddDays(-7);
+ var endDate = DateTime.Today.AddDays(7);
+ var result = await _service.GetInvoicesByDateRangeAsync(startDate, endDate);
+
+ // Assert
+ Assert.Single(result);
+ Assert.Equal("INV-RECENT", result[0].InvoiceNumber);
+ }
+
+ #endregion
+
+ #region Organization Isolation Tests
+
+ [Fact]
+ public async Task GetByIdAsync_DifferentOrganization_ReturnsNull()
+ {
+ // Arrange - Create different organization and invoice
+ var otherUserId = "other-user-456";
+ var otherUser = new ApplicationUser
+ {
+ Id = otherUserId,
+ UserName = "otheruser",
+ Email = "otheruser@example.com"
+ };
+ _context.Users.Add(otherUser);
+ await _context.SaveChangesAsync();
+
+ var otherOrg = new Organization
+ {
+ Id = Guid.NewGuid(),
+ Name = "Other Organization",
+ OwnerId = otherUserId,
+ CreatedBy = otherUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Organizations.AddAsync(otherOrg);
+
+ var otherProperty = new Property
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = otherOrg.Id,
+ Address = "999 Other St",
+ City = "Other City",
+ State = "OT",
+ ZipCode = "99999",
+ IsAvailable = true,
+ CreatedBy = otherUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Properties.AddAsync(otherProperty);
+
+ var otherTenant = new Tenant
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = otherOrg.Id,
+ FirstName = "Other",
+ LastName = "Tenant",
+ Email = "other@test.com",
+ IdentificationNumber = "ID999",
+ CreatedBy = otherUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Tenants.AddAsync(otherTenant);
+
+ var otherLease = new Lease
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = otherOrg.Id,
+ PropertyId = otherProperty.Id,
+ TenantId = otherTenant.Id,
+ StartDate = DateTime.Today,
+ EndDate = DateTime.Today.AddYears(1),
+ MonthlyRent = 2000,
+ SecurityDeposit = 2000,
+ Status = "Active",
+ CreatedBy = otherUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Leases.AddAsync(otherLease);
+
+ var otherOrgInvoice = new Invoice
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = otherOrg.Id,
+ LeaseId = otherLease.Id,
+ InvoiceNumber = "INV-OTHER",
+ InvoicedOn = DateTime.Today,
+ DueOn = DateTime.Today.AddDays(30),
+ Amount = 2000,
+ Description = "Other Org Invoice",
+ Status = "Pending",
+ CreatedBy = otherUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Invoices.AddAsync(otherOrgInvoice);
+ await _context.SaveChangesAsync();
+
+ // Act
+ var result = await _service.GetByIdAsync(otherOrgInvoice.Id);
+
+ // Assert
+ Assert.Null(result); // Should not access invoice from different org
+ }
+
+ [Fact]
+ public async Task GetAllAsync_ReturnsOnlyCurrentOrganizationInvoices()
+ {
+ // Arrange - Create invoice in test org
+ var testOrgInvoice = new Invoice
+ {
+ OrganizationId = _testOrgId,
+ LeaseId = _testLeaseId,
+ InvoiceNumber = "INV-TEST-ORG",
+ InvoicedOn = DateTime.Today,
+ DueOn = DateTime.Today.AddDays(30),
+ Amount = 1500,
+ Description = "Test Org Invoice",
+ Status = "Pending",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(testOrgInvoice);
+
+ // Create invoice in different org
+ var otherUserId = "other-user-456";
+ var otherUser = new ApplicationUser
+ {
+ Id = otherUserId,
+ UserName = "otheruser",
+ Email = "otheruser@example.com"
+ };
+ _context.Users.Add(otherUser);
+ await _context.SaveChangesAsync();
+
+ var otherOrg = new Organization
+ {
+ Id = Guid.NewGuid(),
+ Name = "Other Organization",
+ OwnerId = otherUserId,
+ CreatedBy = otherUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Organizations.AddAsync(otherOrg);
+
+ var otherProperty = new Property
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = otherOrg.Id,
+ Address = "888 Other Ave",
+ City = "Other City",
+ State = "OT",
+ ZipCode = "88888",
+ IsAvailable = true,
+ CreatedBy = otherUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Properties.AddAsync(otherProperty);
+
+ var otherTenant = new Tenant
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = otherOrg.Id,
+ FirstName = "Other",
+ LastName = "Tenant",
+ Email = "other@test.com",
+ IdentificationNumber = "ID888",
+ CreatedBy = otherUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Tenants.AddAsync(otherTenant);
+
+ var otherLease = new Lease
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = otherOrg.Id,
+ PropertyId = otherProperty.Id,
+ TenantId = otherTenant.Id,
+ StartDate = DateTime.Today,
+ EndDate = DateTime.Today.AddYears(1),
+ MonthlyRent = 2500,
+ SecurityDeposit = 2500,
+ Status = "Active",
+ CreatedBy = otherUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Leases.AddAsync(otherLease);
+
+ var otherOrgInvoice = new Invoice
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = otherOrg.Id,
+ LeaseId = otherLease.Id,
+ InvoiceNumber = "INV-OTHER-ORG",
+ InvoicedOn = DateTime.Today,
+ DueOn = DateTime.Today.AddDays(30),
+ Amount = 2500,
+ Description = "Other",
+ Status = "Pending",
+ CreatedBy = otherUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Invoices.AddAsync(otherOrgInvoice);
+ await _context.SaveChangesAsync();
+
+ // Act
+ var result = await _service.GetAllAsync();
+
+ // Assert
+ Assert.Single(result);
+ Assert.Equal(_testOrgId, result[0].OrganizationId);
+ }
+
+ #endregion
+ }
+}
diff --git a/Aquiis.SimpleStart.Tests/LeaseServiceTests.cs b/Aquiis.SimpleStart.Tests/LeaseServiceTests.cs
new file mode 100644
index 0000000..09cefb2
--- /dev/null
+++ b/Aquiis.SimpleStart.Tests/LeaseServiceTests.cs
@@ -0,0 +1,933 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Linq;
+using System.Security.Claims;
+using System.Threading.Tasks;
+using Aquiis.SimpleStart.Application.Services;
+using Aquiis.SimpleStart.Core.Constants;
+using Aquiis.SimpleStart.Core.Entities;
+using Aquiis.SimpleStart.Infrastructure.Data;
+using Aquiis.SimpleStart.Shared.Components.Account;
+using Aquiis.SimpleStart.Shared.Services;
+using Microsoft.AspNetCore.Components.Authorization;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Moq;
+using Xunit;
+
+namespace Aquiis.SimpleStart.Tests
+{
+ ///
+ /// Comprehensive unit tests for LeaseService.
+ /// Tests CRUD operations, business logic, validation, and organization isolation.
+ ///
+ public class LeaseServiceTests : IDisposable
+ {
+ private readonly SqliteConnection _connection;
+ private readonly ApplicationDbContext _context;
+ private readonly UserContextService _userContext;
+ private readonly Mock> _mockLogger;
+ private readonly IOptions _mockSettings;
+ private readonly LeaseService _service;
+ private readonly Guid _testOrgId = Guid.NewGuid();
+ private readonly string _testUserId = "test-user-123";
+ private readonly Guid _testPropertyId = Guid.NewGuid();
+ private readonly Guid _testTenantId = Guid.NewGuid();
+
+ public LeaseServiceTests()
+ {
+ // Setup SQLite in-memory database
+ _connection = new SqliteConnection("DataSource=:memory:");
+ _connection.Open();
+
+ var options = new DbContextOptionsBuilder()
+ .UseSqlite(_connection)
+ .Options;
+
+ _context = new ApplicationDbContext(options);
+ _context.Database.EnsureCreated();
+
+ // Mock AuthenticationStateProvider with claims
+ var claims = new ClaimsPrincipal(new ClaimsIdentity(
+ new[] { new Claim(ClaimTypes.NameIdentifier, _testUserId) },
+ "TestAuth"));
+ var mockAuth = new Mock();
+ mockAuth.Setup(a => a.GetAuthenticationStateAsync())
+ .ReturnsAsync(new AuthenticationState(claims));
+
+ // Mock UserManager
+ var mockUserStore = new Mock>();
+ var mockUserManager = new Mock>(
+ mockUserStore.Object, null, null, null, null, null, null, null, null);
+
+ var appUser = new ApplicationUser
+ {
+ Id = _testUserId,
+ UserName = "testuser",
+ Email = "testuser@example.com",
+ ActiveOrganizationId = _testOrgId
+ };
+ mockUserManager.Setup(u => u.FindByIdAsync(It.IsAny()))
+ .ReturnsAsync(appUser);
+
+ var serviceProvider = new Mock();
+ _userContext = new UserContextService(mockAuth.Object, mockUserManager.Object, serviceProvider.Object);
+
+ // Create test user
+ var user = new ApplicationUser
+ {
+ Id = _testUserId,
+ UserName = "testuser",
+ Email = "testuser@example.com",
+ ActiveOrganizationId = _testOrgId
+ };
+ _context.Users.Add(user);
+
+ // Create test organization
+ var organization = new Organization
+ {
+ Id = _testOrgId,
+ Name = "Test Organization",
+ OwnerId = _testUserId,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Organizations.Add(organization);
+
+ // Create test property
+ var property = new Property
+ {
+ Id = _testPropertyId,
+ OrganizationId = _testOrgId,
+ Address = "123 Test St",
+ City = "Test City",
+ State = "TS",
+ ZipCode = "12345",
+ IsAvailable = true,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Properties.Add(property);
+
+ // Create test tenant
+ var tenant = new Tenant
+ {
+ Id = _testTenantId,
+ OrganizationId = _testOrgId,
+ FirstName = "Test",
+ LastName = "Tenant",
+ Email = "tenant@test.com",
+ IdentificationNumber = "SSN123456",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Tenants.Add(tenant);
+
+ _context.SaveChanges();
+
+ // Setup logger and settings
+ _mockLogger = new Mock>();
+
+ _mockSettings = Options.Create(new ApplicationSettings
+ {
+ SoftDeleteEnabled = true
+ });
+
+ // Create service instance
+ _service = new LeaseService(
+ _context,
+ _mockLogger.Object,
+ _userContext,
+ _mockSettings);
+ }
+
+ public void Dispose()
+ {
+ _context.Dispose();
+ _connection.Dispose();
+ }
+
+ #region Validation Tests
+
+ [Fact]
+ public async Task CreateAsync_ValidLease_CreatesSuccessfully()
+ {
+ // Arrange
+ var lease = new Lease
+ {
+ OrganizationId = _testOrgId,
+ PropertyId = _testPropertyId,
+ TenantId = _testTenantId,
+ StartDate = DateTime.Today,
+ EndDate = DateTime.Today.AddYears(1),
+ MonthlyRent = 1500,
+ SecurityDeposit = 1500,
+ Status = ApplicationConstants.LeaseStatuses.Pending,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ // Act
+ var result = await _service.CreateAsync(lease);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.NotEqual(Guid.Empty, result.Id);
+ Assert.Equal(_testPropertyId, result.PropertyId);
+ Assert.Equal(_testTenantId, result.TenantId);
+ }
+
+ [Fact]
+ public async Task CreateAsync_MissingPropertyId_ThrowsException()
+ {
+ // Arrange
+ var lease = new Lease
+ {
+ OrganizationId = _testOrgId,
+ PropertyId = Guid.Empty, // Missing
+ TenantId = _testTenantId,
+ StartDate = DateTime.Today,
+ EndDate = DateTime.Today.AddYears(1),
+ MonthlyRent = 1500,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _service.CreateAsync(lease));
+ }
+
+ [Fact]
+ public async Task CreateAsync_MissingTenantId_ThrowsException()
+ {
+ // Arrange
+ var lease = new Lease
+ {
+ OrganizationId = _testOrgId,
+ PropertyId = _testPropertyId,
+ TenantId = Guid.Empty, // Missing
+ StartDate = DateTime.Today,
+ EndDate = DateTime.Today.AddYears(1),
+ MonthlyRent = 1500,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _service.CreateAsync(lease));
+ }
+
+ [Fact]
+ public async Task CreateAsync_EndDateBeforeStartDate_ThrowsException()
+ {
+ // Arrange
+ var lease = new Lease
+ {
+ OrganizationId = _testOrgId,
+ PropertyId = _testPropertyId,
+ TenantId = _testTenantId,
+ StartDate = DateTime.Today.AddYears(1),
+ EndDate = DateTime.Today, // Before start date
+ MonthlyRent = 1500,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _service.CreateAsync(lease));
+ }
+
+ [Fact]
+ public async Task CreateAsync_ZeroMonthlyRent_ThrowsException()
+ {
+ // Arrange
+ var lease = new Lease
+ {
+ OrganizationId = _testOrgId,
+ PropertyId = _testPropertyId,
+ TenantId = _testTenantId,
+ StartDate = DateTime.Today,
+ EndDate = DateTime.Today.AddYears(1),
+ MonthlyRent = 0, // Invalid
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _service.CreateAsync(lease));
+ }
+
+ [Fact]
+ public async Task CreateAsync_OverlappingLease_ThrowsException()
+ {
+ // Arrange - Create first lease
+ var firstLease = new Lease
+ {
+ OrganizationId = _testOrgId,
+ PropertyId = _testPropertyId,
+ TenantId = _testTenantId,
+ StartDate = DateTime.Today,
+ EndDate = DateTime.Today.AddYears(1),
+ MonthlyRent = 1500,
+ Status = ApplicationConstants.LeaseStatuses.Active,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(firstLease);
+
+ // Try to create overlapping lease
+ var overlappingLease = new Lease
+ {
+ OrganizationId = _testOrgId,
+ PropertyId = _testPropertyId,
+ TenantId = _testTenantId,
+ StartDate = DateTime.Today.AddMonths(6),
+ EndDate = DateTime.Today.AddMonths(18),
+ MonthlyRent = 1500,
+ Status = ApplicationConstants.LeaseStatuses.Pending,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _service.CreateAsync(overlappingLease));
+ }
+
+ #endregion
+
+ #region Property Availability Tests
+
+ [Fact]
+ public async Task CreateAsync_ActiveLease_MarksPropertyUnavailable()
+ {
+ // Arrange
+ var lease = new Lease
+ {
+ OrganizationId = _testOrgId,
+ PropertyId = _testPropertyId,
+ TenantId = _testTenantId,
+ StartDate = DateTime.Today,
+ EndDate = DateTime.Today.AddYears(1),
+ MonthlyRent = 1500,
+ Status = ApplicationConstants.LeaseStatuses.Active,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ // Act
+ await _service.CreateAsync(lease);
+
+ // Assert
+ var property = await _context.Properties.FindAsync(_testPropertyId);
+ Assert.False(property!.IsAvailable);
+ }
+
+ [Fact]
+ public async Task CreateAsync_PendingLease_DoesNotMarkPropertyUnavailable()
+ {
+ // Arrange
+ var lease = new Lease
+ {
+ OrganizationId = _testOrgId,
+ PropertyId = _testPropertyId,
+ TenantId = _testTenantId,
+ StartDate = DateTime.Today,
+ EndDate = DateTime.Today.AddYears(1),
+ MonthlyRent = 1500,
+ Status = ApplicationConstants.LeaseStatuses.Pending,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ // Act
+ await _service.CreateAsync(lease);
+
+ // Assert
+ var property = await _context.Properties.FindAsync(_testPropertyId);
+ Assert.True(property!.IsAvailable);
+ }
+
+ [Fact]
+ public async Task DeleteAsync_ActiveLease_MarksPropertyAvailable()
+ {
+ // Arrange - Create active lease
+ var lease = new Lease
+ {
+ OrganizationId = _testOrgId,
+ PropertyId = _testPropertyId,
+ TenantId = _testTenantId,
+ StartDate = DateTime.Today,
+ EndDate = DateTime.Today.AddYears(1),
+ MonthlyRent = 1500,
+ Status = ApplicationConstants.LeaseStatuses.Active,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ var created = await _service.CreateAsync(lease);
+
+ // Act
+ await _service.DeleteAsync(created.Id);
+
+ // Assert
+ var property = await _context.Properties.FindAsync(_testPropertyId);
+ Assert.True(property!.IsAvailable);
+ }
+
+ #endregion
+
+ #region Retrieval Tests
+
+ [Fact]
+ public async Task GetLeasesByPropertyIdAsync_ReturnsPropertyLeases()
+ {
+ // Arrange - Create multiple leases
+ var lease1 = new Lease
+ {
+ OrganizationId = _testOrgId,
+ PropertyId = _testPropertyId,
+ TenantId = _testTenantId,
+ StartDate = DateTime.Today.AddYears(-1),
+ EndDate = DateTime.Today.AddMonths(-1),
+ MonthlyRent = 1200,
+ Status = ApplicationConstants.LeaseStatuses.Expired,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(lease1);
+
+ var lease2 = new Lease
+ {
+ OrganizationId = _testOrgId,
+ PropertyId = _testPropertyId,
+ TenantId = _testTenantId,
+ StartDate = DateTime.Today,
+ EndDate = DateTime.Today.AddYears(1),
+ MonthlyRent = 1500,
+ Status = ApplicationConstants.LeaseStatuses.Active,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(lease2);
+
+ // Act
+ var result = await _service.GetLeasesByPropertyIdAsync(_testPropertyId);
+
+ // Assert
+ Assert.Equal(2, result.Count);
+ Assert.All(result, l => Assert.Equal(_testPropertyId, l.PropertyId));
+ }
+
+ [Fact]
+ public async Task GetLeasesByTenantIdAsync_ReturnsTenantLeases()
+ {
+ // Arrange - Create lease
+ var lease = new Lease
+ {
+ OrganizationId = _testOrgId,
+ PropertyId = _testPropertyId,
+ TenantId = _testTenantId,
+ StartDate = DateTime.Today,
+ EndDate = DateTime.Today.AddYears(1),
+ MonthlyRent = 1500,
+ Status = ApplicationConstants.LeaseStatuses.Active,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(lease);
+
+ // Act
+ var result = await _service.GetLeasesByTenantIdAsync(_testTenantId);
+
+ // Assert
+ Assert.Single(result);
+ Assert.Equal(_testTenantId, result[0].TenantId);
+ }
+
+ [Fact]
+ public async Task GetActiveLeasesAsync_ReturnsOnlyActiveLeases()
+ {
+ // Arrange - Create leases with different statuses
+ var pendingLease = new Lease
+ {
+ OrganizationId = _testOrgId,
+ PropertyId = _testPropertyId,
+ TenantId = _testTenantId,
+ StartDate = DateTime.Today.AddMonths(1),
+ EndDate = DateTime.Today.AddMonths(13),
+ MonthlyRent = 1400,
+ Status = ApplicationConstants.LeaseStatuses.Pending,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(pendingLease);
+
+ // Create a second property and tenant for active lease
+ var property2 = new Property
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = _testOrgId,
+ Address = "456 Test Ave",
+ City = "Test City",
+ State = "TS",
+ ZipCode = "12345",
+ IsAvailable = true,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Properties.Add(property2);
+
+ var tenant2 = new Tenant
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = _testOrgId,
+ FirstName = "Active",
+ LastName = "Tenant",
+ Email = "active@test.com",
+ IdentificationNumber = "SSN789012",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Tenants.Add(tenant2);
+ await _context.SaveChangesAsync();
+
+ var activeLease = new Lease
+ {
+ OrganizationId = _testOrgId,
+ PropertyId = property2.Id,
+ TenantId = tenant2.Id,
+ StartDate = DateTime.Today.AddMonths(-1),
+ EndDate = DateTime.Today.AddMonths(11),
+ MonthlyRent = 1500,
+ Status = ApplicationConstants.LeaseStatuses.Active,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(activeLease);
+
+ // Act
+ var result = await _service.GetActiveLeasesAsync();
+
+ // Assert
+ Assert.Single(result);
+ Assert.Equal(ApplicationConstants.LeaseStatuses.Active, result[0].Status);
+ Assert.True(result[0].StartDate <= DateTime.Today);
+ Assert.True(result[0].EndDate >= DateTime.Today);
+ }
+
+ [Fact]
+ public async Task GetLeasesExpiringSoonAsync_ReturnsLeasesWithinThreshold()
+ {
+ // Arrange - Create leases with different expiration dates
+ // Create additional property
+ var property2 = new Property
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = _testOrgId,
+ Address = "789 Test Blvd",
+ City = "Test City",
+ State = "TS",
+ ZipCode = "12345",
+ IsAvailable = true,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Properties.Add(property2);
+
+ var tenant2 = new Tenant
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = _testOrgId,
+ FirstName = "Expiring",
+ LastName = "Tenant",
+ Email = "expiring@test.com",
+ IdentificationNumber = "SSN345678",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Tenants.Add(tenant2);
+ await _context.SaveChangesAsync();
+
+ // Lease expiring in 30 days (within 90-day threshold)
+ var expiringSoonLease = new Lease
+ {
+ OrganizationId = _testOrgId,
+ PropertyId = _testPropertyId,
+ TenantId = _testTenantId,
+ StartDate = DateTime.Today.AddYears(-1),
+ EndDate = DateTime.Today.AddDays(30),
+ MonthlyRent = 1400,
+ Status = ApplicationConstants.LeaseStatuses.Active,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(expiringSoonLease);
+
+ // Lease expiring in 6 months (outside 90-day threshold)
+ var farOutLease = new Lease
+ {
+ OrganizationId = _testOrgId,
+ PropertyId = property2.Id,
+ TenantId = tenant2.Id,
+ StartDate = DateTime.Today,
+ EndDate = DateTime.Today.AddMonths(6),
+ MonthlyRent = 1500,
+ Status = ApplicationConstants.LeaseStatuses.Active,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(farOutLease);
+
+ // Act
+ var result = await _service.GetLeasesExpiringSoonAsync(90);
+
+ // Assert
+ Assert.Single(result);
+ Assert.Equal(expiringSoonLease.Id, result[0].Id);
+ }
+
+ [Fact]
+ public async Task GetLeasesByStatusAsync_ReturnsLeasesWithStatus()
+ {
+ // Arrange - Create leases with different statuses
+ var activeLease = new Lease
+ {
+ OrganizationId = _testOrgId,
+ PropertyId = _testPropertyId,
+ TenantId = _testTenantId,
+ StartDate = DateTime.Today,
+ EndDate = DateTime.Today.AddYears(1),
+ MonthlyRent = 1500,
+ Status = ApplicationConstants.LeaseStatuses.Active,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(activeLease);
+
+ // Create second property for pending lease
+ var property2 = new Property
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = _testOrgId,
+ Address = "321 Pending Rd",
+ City = "Test City",
+ State = "TS",
+ ZipCode = "12345",
+ IsAvailable = true,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Properties.Add(property2);
+ await _context.SaveChangesAsync();
+
+ var pendingLease = new Lease
+ {
+ OrganizationId = _testOrgId,
+ PropertyId = property2.Id,
+ TenantId = _testTenantId,
+ StartDate = DateTime.Today.AddMonths(1),
+ EndDate = DateTime.Today.AddMonths(13),
+ MonthlyRent = 1400,
+ Status = ApplicationConstants.LeaseStatuses.Pending,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(pendingLease);
+
+ // Act
+ var activeResults = await _service.GetLeasesByStatusAsync(ApplicationConstants.LeaseStatuses.Active);
+ var pendingResults = await _service.GetLeasesByStatusAsync(ApplicationConstants.LeaseStatuses.Pending);
+
+ // Assert
+ Assert.Single(activeResults);
+ Assert.Single(pendingResults);
+ Assert.Equal(ApplicationConstants.LeaseStatuses.Active, activeResults[0].Status);
+ Assert.Equal(ApplicationConstants.LeaseStatuses.Pending, pendingResults[0].Status);
+ }
+
+ [Fact]
+ public async Task GetLeaseWithRelationsAsync_LoadsAllRelations()
+ {
+ // Arrange - Create lease
+ var lease = new Lease
+ {
+ OrganizationId = _testOrgId,
+ PropertyId = _testPropertyId,
+ TenantId = _testTenantId,
+ StartDate = DateTime.Today,
+ EndDate = DateTime.Today.AddYears(1),
+ MonthlyRent = 1500,
+ Status = ApplicationConstants.LeaseStatuses.Active,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ var created = await _service.CreateAsync(lease);
+
+ // Act
+ var result = await _service.GetLeaseWithRelationsAsync(created.Id);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.NotNull(result.Property);
+ Assert.NotNull(result.Tenant);
+ Assert.Equal(_testPropertyId, result.Property.Id);
+ Assert.Equal(_testTenantId, result.Tenant!.Id);
+ }
+
+ #endregion
+
+ #region Business Logic Tests
+
+ [Fact]
+ public async Task CalculateTotalLeaseValueAsync_CalculatesCorrectly()
+ {
+ // Arrange - Create 12-month lease
+ var lease = new Lease
+ {
+ OrganizationId = _testOrgId,
+ PropertyId = _testPropertyId,
+ TenantId = _testTenantId,
+ StartDate = new DateTime(2025, 1, 1),
+ EndDate = new DateTime(2025, 12, 31),
+ MonthlyRent = 1500,
+ Status = ApplicationConstants.LeaseStatuses.Active,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ var created = await _service.CreateAsync(lease);
+
+ // Act
+ var totalValue = await _service.CalculateTotalLeaseValueAsync(created.Id);
+
+ // Assert
+ // 12 months * $1500 = $18,000
+ Assert.Equal(18000, totalValue);
+ }
+
+ [Fact]
+ public async Task UpdateLeaseStatusAsync_UpdatesStatusAndPropertyAvailability()
+ {
+ // Arrange - Create pending lease
+ var lease = new Lease
+ {
+ OrganizationId = _testOrgId,
+ PropertyId = _testPropertyId,
+ TenantId = _testTenantId,
+ StartDate = DateTime.Today,
+ EndDate = DateTime.Today.AddYears(1),
+ MonthlyRent = 1500,
+ Status = ApplicationConstants.LeaseStatuses.Pending,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ var created = await _service.CreateAsync(lease);
+
+ // Act - Change to Active
+ var updated = await _service.UpdateLeaseStatusAsync(created.Id, ApplicationConstants.LeaseStatuses.Active);
+
+ // Assert
+ Assert.Equal(ApplicationConstants.LeaseStatuses.Active, updated.Status);
+ var property = await _context.Properties.FindAsync(_testPropertyId);
+ Assert.False(property!.IsAvailable);
+ }
+
+ [Fact]
+ public async Task UpdateLeaseStatusAsync_ToTerminated_MarksPropertyAvailable()
+ {
+ // Arrange - Create active lease
+ var lease = new Lease
+ {
+ OrganizationId = _testOrgId,
+ PropertyId = _testPropertyId,
+ TenantId = _testTenantId,
+ StartDate = DateTime.Today,
+ EndDate = DateTime.Today.AddYears(1),
+ MonthlyRent = 1500,
+ Status = ApplicationConstants.LeaseStatuses.Active,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ var created = await _service.CreateAsync(lease);
+
+ // Act - Change to Terminated
+ await _service.UpdateLeaseStatusAsync(created.Id, ApplicationConstants.LeaseStatuses.Terminated);
+
+ // Assert
+ var property = await _context.Properties.FindAsync(_testPropertyId);
+ Assert.True(property!.IsAvailable);
+ }
+
+ #endregion
+
+ #region Organization Isolation Tests
+
+ [Fact]
+ public async Task GetByIdAsync_DifferentOrganization_ReturnsNull()
+ {
+ // Arrange - Create different organization and lease
+ var otherUserId = "other-user-456";
+ var otherUser = new ApplicationUser
+ {
+ Id = otherUserId,
+ UserName = "otheruser",
+ Email = "otheruser@example.com"
+ };
+ _context.Users.Add(otherUser);
+ await _context.SaveChangesAsync();
+
+ var otherOrg = new Organization
+ {
+ Id = Guid.NewGuid(),
+ Name = "Other Organization",
+ OwnerId = otherUserId,
+ CreatedBy = otherUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Organizations.AddAsync(otherOrg);
+
+ var otherProperty = new Property
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = otherOrg.Id,
+ Address = "999 Other St",
+ City = "Other City",
+ State = "OT",
+ ZipCode = "99999",
+ IsAvailable = true,
+ CreatedBy = otherUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Properties.AddAsync(otherProperty);
+
+ var otherTenant = new Tenant
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = otherOrg.Id,
+ FirstName = "Other",
+ LastName = "Tenant",
+ Email = "other@example.com",
+ IdentificationNumber = "SSN999999",
+ CreatedBy = otherUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Tenants.AddAsync(otherTenant);
+
+ var otherOrgLease = new Lease
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = otherOrg.Id,
+ PropertyId = otherProperty.Id,
+ TenantId = otherTenant.Id,
+ StartDate = DateTime.Today,
+ EndDate = DateTime.Today.AddYears(1),
+ MonthlyRent = 2000,
+ Status = ApplicationConstants.LeaseStatuses.Active,
+ CreatedBy = otherUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Leases.AddAsync(otherOrgLease);
+ await _context.SaveChangesAsync();
+
+ // Act
+ var result = await _service.GetByIdAsync(otherOrgLease.Id);
+
+ // Assert
+ Assert.Null(result); // Should not access lease from different org
+ }
+
+ [Fact]
+ public async Task GetAllAsync_ReturnsOnlyCurrentOrganizationLeases()
+ {
+ // Arrange - Create lease in test org
+ var testOrgLease = new Lease
+ {
+ OrganizationId = _testOrgId,
+ PropertyId = _testPropertyId,
+ TenantId = _testTenantId,
+ StartDate = DateTime.Today,
+ EndDate = DateTime.Today.AddYears(1),
+ MonthlyRent = 1500,
+ Status = ApplicationConstants.LeaseStatuses.Active,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(testOrgLease);
+
+ // Create lease in different org
+ var otherUserId = "other-user-456";
+ var otherUser = new ApplicationUser
+ {
+ Id = otherUserId,
+ UserName = "otheruser",
+ Email = "otheruser@example.com"
+ };
+ _context.Users.Add(otherUser);
+ await _context.SaveChangesAsync();
+
+ var otherOrg = new Organization
+ {
+ Id = Guid.NewGuid(),
+ Name = "Other Organization",
+ OwnerId = otherUserId,
+ CreatedBy = otherUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Organizations.AddAsync(otherOrg);
+
+ var otherProperty = new Property
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = otherOrg.Id,
+ Address = "888 Other Ave",
+ City = "Other City",
+ State = "OT",
+ ZipCode = "88888",
+ IsAvailable = true,
+ CreatedBy = otherUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Properties.AddAsync(otherProperty);
+
+ var otherTenant = new Tenant
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = otherOrg.Id,
+ FirstName = "Other",
+ LastName = "Person",
+ Email = "otherperson@example.com",
+ IdentificationNumber = "SSN888888",
+ CreatedBy = otherUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Tenants.AddAsync(otherTenant);
+
+ var otherOrgLease = new Lease
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = otherOrg.Id,
+ PropertyId = otherProperty.Id,
+ TenantId = otherTenant.Id,
+ StartDate = DateTime.Today,
+ EndDate = DateTime.Today.AddYears(1),
+ MonthlyRent = 2000,
+ Status = ApplicationConstants.LeaseStatuses.Active,
+ CreatedBy = otherUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Leases.AddAsync(otherOrgLease);
+ await _context.SaveChangesAsync();
+
+ // Act
+ var result = await _service.GetAllAsync();
+
+ // Assert
+ Assert.Single(result);
+ Assert.Equal(_testOrgId, result[0].Property.OrganizationId);
+ }
+
+ #endregion
+ }
+}
diff --git a/Aquiis.SimpleStart.Tests/MaintenanceServiceTests.cs b/Aquiis.SimpleStart.Tests/MaintenanceServiceTests.cs
new file mode 100644
index 0000000..150722b
--- /dev/null
+++ b/Aquiis.SimpleStart.Tests/MaintenanceServiceTests.cs
@@ -0,0 +1,1234 @@
+using Aquiis.SimpleStart.Application.Services;
+using Aquiis.SimpleStart.Application.Services.Workflows;
+using Aquiis.SimpleStart.Core.Constants;
+using Aquiis.SimpleStart.Core.Entities;
+using Aquiis.SimpleStart.Core.Interfaces;
+using Aquiis.SimpleStart.Infrastructure.Data;
+using Aquiis.SimpleStart.Shared;
+using Aquiis.SimpleStart.Shared.Components.Account;
+using Aquiis.SimpleStart.Shared.Services;
+using Microsoft.AspNetCore.Components.Authorization;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Moq;
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Linq;
+using System.Security.Claims;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace Aquiis.SimpleStart.Tests
+{
+ public class MaintenanceServiceTests : IDisposable
+ {
+ private readonly ApplicationDbContext _context;
+ private readonly MaintenanceService _service;
+ private readonly Mock> _mockUserManager;
+ private readonly Mock> _mockLogger;
+ private readonly Mock _mockCalendarEventService;
+ private readonly UserContextService _userContext;
+ private readonly IOptions _mockSettings;
+ private readonly SqliteConnection _connection;
+ private readonly ApplicationUser _testUser;
+ private readonly Organization _testOrg;
+
+ private readonly Guid _testOrgId = Guid.NewGuid();
+
+ private readonly string _testUserId = Guid.NewGuid().ToString();
+
+ private readonly Guid _testPropertyId = Guid.NewGuid();
+
+ private readonly Guid _testLeaseId = Guid.NewGuid();
+
+ private readonly Guid _testTenantId = Guid.NewGuid();
+
+ private readonly Property _testProperty;
+ private readonly Lease _testLease;
+
+ private readonly Tenant _testTenant;
+
+ public MaintenanceServiceTests()
+ {
+ // Create in-memory SQLite database
+ _connection = new SqliteConnection("DataSource=:memory:");
+ _connection.Open();
+
+ var options = new DbContextOptionsBuilder()
+ .UseSqlite(_connection)
+ .Options;
+
+ _context = new ApplicationDbContext(options);
+ _context.Database.EnsureCreated();
+
+ // Setup test data
+ _testUser = new ApplicationUser
+ {
+ Id = _testUserId,
+ UserName = "testuser@test.com",
+ Email = "testuser@test.com"
+ };
+
+ _testOrg = new Organization
+ {
+ Id = _testOrgId,
+ Name = "Test Organization",
+ OwnerId = _testUserId,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ _testProperty = new Property
+ {
+ Id = _testPropertyId,
+ OrganizationId = _testOrg.Id,
+ Address = "123 Test St",
+ City = "Test City",
+ State = "TS",
+ ZipCode = "12345",
+ PropertyType = "Single Family",
+ Bedrooms = 3,
+ Bathrooms = 2,
+ SquareFeet = 1500,
+ IsAvailable = true,
+ CreatedBy = _testUser.Id,
+ CreatedOn = DateTime.UtcNow
+ };
+
+
+ _testTenant = new Tenant
+ {
+ Id = _testTenantId,
+ OrganizationId = _testOrg.Id,
+ FirstName = "Test",
+ LastName = "Tenant",
+ Email = "testtenant@test.com",
+ PhoneNumber = "123-456-7890",
+ CreatedBy = _testUser.Id,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ _testLease = new Lease
+ {
+ Id = _testLeaseId,
+ PropertyId = _testProperty.Id,
+ OrganizationId = _testOrg.Id,
+ TenantId = _testTenant.Id,
+ StartDate = DateTime.Today,
+ EndDate = DateTime.Today.AddMonths(12),
+ MonthlyRent = 1500,
+ CreatedBy = _testUser.Id,
+ CreatedOn = DateTime.UtcNow
+ };
+
+
+ _context.Users.Add(_testUser);
+ _context.SaveChanges(); // Save user first so OwnerId foreign key can be satisfied
+
+ _context.Organizations.Add(_testOrg);
+ _context.Properties.Add(_testProperty);
+ _context.Tenants.Add(_testTenant);
+ _context.Leases.Add(_testLease);
+ _context.SaveChanges();
+
+ // Setup mocks
+ var userStore = new Mock>();
+ _mockUserManager = new Mock>(
+ userStore.Object, null, null, null, null, null, null, null, null);
+
+ _testUser.ActiveOrganizationId = _testOrg.Id;
+ _mockUserManager.Setup(x => x.FindByIdAsync(It.IsAny()))
+ .ReturnsAsync(_testUser);
+
+ var mockAuthStateProvider = new Mock();
+ var claims = new List
+ {
+ new Claim(ClaimTypes.NameIdentifier, _testUser.Id),
+ new Claim("OrganizationId", _testOrg.Id.ToString())
+ };
+ var identity = new ClaimsIdentity(claims, "TestAuth");
+ var claimsPrincipal = new ClaimsPrincipal(identity);
+ mockAuthStateProvider.Setup(x => x.GetAuthenticationStateAsync())
+ .ReturnsAsync(new Microsoft.AspNetCore.Components.Authorization.AuthenticationState(claimsPrincipal));
+
+ var serviceProvider = new Mock();
+ _userContext = new UserContextService(
+ mockAuthStateProvider.Object,
+ _mockUserManager.Object,
+ serviceProvider.Object);
+
+ _mockLogger = new Mock>();
+
+ _mockSettings = Options.Create(new ApplicationSettings
+ {
+ SoftDeleteEnabled = true
+ });
+
+ _mockCalendarEventService = new Mock();
+
+ _service = new MaintenanceService(
+ _context,
+ _mockLogger.Object,
+ _userContext,
+ _mockSettings,
+ _mockCalendarEventService.Object);
+ }
+
+ public void Dispose()
+ {
+ _context.Database.EnsureDeleted();
+ _context.Dispose();
+ _connection.Close();
+ _connection.Dispose();
+ }
+
+ #region Validation Tests
+
+ [Fact]
+ public async Task CreateAsync_MissingPropertyId_ThrowsException()
+ {
+ // Arrange
+ var maintenanceRequest = new MaintenanceRequest
+ {
+ PropertyId = Guid.Empty,
+ Title = "Test Request",
+ Description = "Test Description",
+ RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing,
+ Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium,
+ Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted,
+ RequestedOn = DateTime.Today,
+ OrganizationId = _testOrg.Id
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _service.CreateAsync(maintenanceRequest));
+ }
+
+ [Fact]
+ public async Task CreateAsync_MissingTitle_ThrowsException()
+ {
+ // Arrange
+ var maintenanceRequest = new MaintenanceRequest
+ {
+ PropertyId = _testProperty.Id,
+ Title = "",
+ Description = "Test Description",
+ RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing,
+ Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium,
+ Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted,
+ RequestedOn = DateTime.Today,
+ OrganizationId = _testOrg.Id
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _service.CreateAsync(maintenanceRequest));
+ }
+
+ [Fact]
+ public async Task CreateAsync_MissingDescription_ThrowsException()
+ {
+ // Arrange
+ var maintenanceRequest = new MaintenanceRequest
+ {
+ PropertyId = _testProperty.Id,
+ Title = "Test Request",
+ Description = "",
+ RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing,
+ Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium,
+ Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted,
+ RequestedOn = DateTime.Today,
+ OrganizationId = _testOrg.Id
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _service.CreateAsync(maintenanceRequest));
+ }
+
+ [Fact]
+ public async Task CreateAsync_InvalidPriority_ThrowsException()
+ {
+ // Arrange
+ var maintenanceRequest = new MaintenanceRequest
+ {
+ PropertyId = _testProperty.Id,
+ Title = "Test Request",
+ Description = "Test Description",
+ RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing,
+ Priority = "InvalidPriority",
+ Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted,
+ RequestedOn = DateTime.Today,
+ OrganizationId = _testOrg.Id
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _service.CreateAsync(maintenanceRequest));
+ }
+
+ [Fact]
+ public async Task CreateAsync_InvalidStatus_ThrowsException()
+ {
+ // Arrange
+ var maintenanceRequest = new MaintenanceRequest
+ {
+ PropertyId = _testProperty.Id,
+ Title = "Test Request",
+ Description = "Test Description",
+ RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing,
+ Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium,
+ Status = "InvalidStatus",
+ RequestedOn = DateTime.Today,
+ OrganizationId = _testOrg.Id
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _service.CreateAsync(maintenanceRequest));
+ }
+
+ [Fact]
+ public async Task CreateAsync_FutureRequestedDate_ThrowsException()
+ {
+ // Arrange
+ var maintenanceRequest = new MaintenanceRequest
+ {
+ PropertyId = _testProperty.Id,
+ Title = "Test Request",
+ Description = "Test Description",
+ RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing,
+ Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium,
+ Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted,
+ RequestedOn = DateTime.Today.AddDays(1),
+ OrganizationId = _testOrg.Id
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _service.CreateAsync(maintenanceRequest));
+ }
+
+ [Fact]
+ public async Task CreateAsync_ScheduledBeforeRequested_ThrowsException()
+ {
+ // Arrange
+ var maintenanceRequest = new MaintenanceRequest
+ {
+ PropertyId = _testProperty.Id,
+ Title = "Test Request",
+ Description = "Test Description",
+ RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing,
+ Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium,
+ Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted,
+ RequestedOn = DateTime.Today,
+ ScheduledOn = DateTime.Today.AddDays(-1),
+ OrganizationId = _testOrg.Id
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _service.CreateAsync(maintenanceRequest));
+ }
+
+ [Fact]
+ public async Task CreateAsync_NegativeEstimatedCost_ThrowsException()
+ {
+ // Arrange
+ var maintenanceRequest = new MaintenanceRequest
+ {
+ PropertyId = _testPropertyId,
+ LeaseId = _testLeaseId,
+ Title = "Test Request",
+ Description = "Test Description",
+ RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing,
+ Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium,
+ Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted,
+ RequestedOn = DateTime.Today,
+ EstimatedCost = -100,
+ OrganizationId = _testOrg.Id
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _service.CreateAsync(maintenanceRequest));
+ }
+
+ [Fact]
+ public async Task CreateAsync_CompletedWithoutDate_ThrowsException()
+ {
+ // Arrange
+ var maintenanceRequest = new MaintenanceRequest
+ {
+ PropertyId = _testProperty.Id,
+ Title = "Test Request",
+ Description = "Test Description",
+ RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing,
+ Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium,
+ Status = ApplicationConstants.MaintenanceRequestStatuses.Completed,
+ RequestedOn = DateTime.Today,
+ CompletedOn = null,
+ OrganizationId = _testOrg.Id
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _service.CreateAsync(maintenanceRequest));
+ }
+
+ [Fact]
+ public async Task CreateAsync_InvalidPropertyOrganization_ThrowsException()
+ {
+ // Arrange
+ var otherOrg = new Organization
+ {
+ Id = Guid.NewGuid(),
+ Name = "Other Org",
+ OwnerId = _testUserId,
+ CreatedBy = _testUser.Id,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Organizations.Add(otherOrg);
+
+ var otherProperty = new Property
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = otherOrg.Id,
+ Address = "999 Other St",
+ City = "Other City",
+ State = "OT",
+ ZipCode = "99999",
+ PropertyType = "Apartment",
+ Bedrooms = 2,
+ Bathrooms = 1,
+ SquareFeet = 900,
+ IsAvailable = true,
+ CreatedBy = _testUser.Id,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Properties.Add(otherProperty);
+ await _context.SaveChangesAsync();
+
+ var maintenanceRequest = new MaintenanceRequest
+ {
+ PropertyId = otherProperty.Id, // Property from different org
+ Title = "Test Request",
+ Description = "Test Description",
+ RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing,
+ Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium,
+ Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted,
+ RequestedOn = DateTime.Today
+ // OrganizationId will be auto-set from user context, which won't match property's org
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _service.CreateAsync(maintenanceRequest));
+ }
+
+ [Fact]
+ public async Task CreateAsync_ValidMaintenanceRequest_CreatesSuccessfully()
+ {
+ // Arrange
+ var maintenanceRequest = new MaintenanceRequest
+ {
+ PropertyId = _testProperty.Id,
+ Title = "Leaky Faucet",
+ Description = "Kitchen faucet is dripping",
+ RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing,
+ Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium,
+ Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted,
+ RequestedOn = DateTime.Today,
+ EstimatedCost = 150,
+ OrganizationId = _testOrg.Id
+ };
+
+ // Act
+ var result = await _service.CreateAsync(maintenanceRequest);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.NotEqual(Guid.Empty, result.Id);
+ Assert.Equal("Leaky Faucet", result.Title);
+ Assert.Equal(_testOrg.Id, result.OrganizationId);
+
+ // Verify calendar event was created
+ _mockCalendarEventService.Verify(
+ x => x.CreateOrUpdateEventAsync(It.IsAny()),
+ Times.Once);
+ }
+
+ #endregion
+
+ #region Retrieval Tests
+
+ [Fact]
+ public async Task GetMaintenanceRequestsByPropertyAsync_ReturnsPropertyRequests()
+ {
+ // Arrange
+ var request1 = new MaintenanceRequest
+ {
+ PropertyId = _testProperty.Id,
+ Title = "Request 1",
+ Description = "Description 1",
+ RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing,
+ Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium,
+ Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted,
+ RequestedOn = DateTime.Today,
+ OrganizationId = _testOrg.Id,
+ CreatedBy = _testUser.Id,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ var request2 = new MaintenanceRequest
+ {
+ PropertyId = _testProperty.Id,
+ Title = "Request 2",
+ Description = "Description 2",
+ RequestType = ApplicationConstants.MaintenanceRequestTypes.Electrical,
+ Priority = ApplicationConstants.MaintenanceRequestPriorities.High,
+ Status = ApplicationConstants.MaintenanceRequestStatuses.InProgress,
+ RequestedOn = DateTime.Today.AddDays(-1),
+ OrganizationId = _testOrg.Id,
+ CreatedBy = _testUser.Id,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ await _service.CreateAsync(request1);
+ await _service.CreateAsync(request2);
+
+ // Act
+ var results = await _service.GetMaintenanceRequestsByPropertyAsync(_testProperty.Id);
+
+ // Assert
+ Assert.Equal(2, results.Count);
+ Assert.All(results, r => Assert.Equal(_testProperty.Id, r.PropertyId));
+ }
+
+ [Fact]
+ public async Task GetMaintenanceRequestsByStatusAsync_ReturnsMatchingRequests()
+ {
+ // Arrange
+ var submitted = new MaintenanceRequest
+ {
+ PropertyId = _testProperty.Id,
+ Title = "Submitted Request",
+ Description = "Description",
+ RequestType = "Plumbing",
+ Priority = "Medium",
+ Status = "Submitted",
+ RequestedOn = DateTime.Today,
+ OrganizationId = _testOrg.Id,
+ CreatedBy = _testUser.Id,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ var inProgress = new MaintenanceRequest
+ {
+ PropertyId = _testProperty.Id,
+ Title = "In Progress Request",
+ Description = "Description",
+ RequestType = ApplicationConstants.MaintenanceRequestTypes.Electrical,
+ Priority = ApplicationConstants.MaintenanceRequestPriorities.High,
+ Status = ApplicationConstants.MaintenanceRequestStatuses.InProgress,
+ RequestedOn = DateTime.Today,
+ OrganizationId = _testOrg.Id,
+ CreatedBy = _testUser.Id,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ await _service.CreateAsync(submitted);
+ await _service.CreateAsync(inProgress);
+
+ // Act
+ var results = await _service.GetMaintenanceRequestsByStatusAsync("Submitted");
+
+ // Assert
+ Assert.Single(results);
+ Assert.Equal("Submitted", results[0].Status);
+ }
+
+ [Fact]
+ public async Task GetMaintenanceRequestsByPriorityAsync_ReturnsMatchingRequests()
+ {
+ // Arrange
+ var urgent = new MaintenanceRequest
+ {
+ PropertyId = _testProperty.Id,
+ Title = "Urgent Request",
+ Description = "Description",
+ RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing,
+ Priority = ApplicationConstants.MaintenanceRequestPriorities.Urgent,
+ Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted,
+ RequestedOn = DateTime.Today,
+ OrganizationId = _testOrg.Id,
+ CreatedBy = _testUser.Id,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ var low = new MaintenanceRequest
+ {
+ PropertyId = _testProperty.Id,
+ Title = "Low Request",
+ Description = "Description",
+ RequestType = ApplicationConstants.MaintenanceRequestTypes.Other,
+ Priority = ApplicationConstants.MaintenanceRequestPriorities.Low,
+ Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted,
+ RequestedOn = DateTime.Today,
+ OrganizationId = _testOrg.Id,
+ CreatedBy = _testUser.Id,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ await _service.CreateAsync(urgent);
+ await _service.CreateAsync(low);
+
+ // Act
+ var results = await _service.GetMaintenanceRequestsByPriorityAsync("Urgent");
+
+ // Assert
+ Assert.Single(results);
+ Assert.Equal("Urgent", results[0].Priority);
+ }
+
+ [Fact]
+ public async Task GetOverdueMaintenanceRequestsAsync_ReturnsOverdueRequests()
+ {
+ // Arrange
+ var overdue = new MaintenanceRequest
+ {
+ PropertyId = _testProperty.Id,
+ Title = "Overdue Request",
+ Description = "Description",
+ RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing,
+ Priority = ApplicationConstants.MaintenanceRequestPriorities.High,
+ Status = ApplicationConstants.MaintenanceRequestStatuses.InProgress,
+ RequestedOn = DateTime.Today.AddDays(-5),
+ ScheduledOn = DateTime.Today.AddDays(-2),
+ OrganizationId = _testOrg.Id,
+ CreatedBy = _testUser.Id,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ var notOverdue = new MaintenanceRequest
+ {
+ PropertyId = _testProperty.Id,
+ Title = "Not Overdue Request",
+ Description = "Description",
+ RequestType = ApplicationConstants.MaintenanceRequestTypes.Electrical,
+ Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium,
+ Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted,
+ RequestedOn = DateTime.Today,
+ ScheduledOn = DateTime.Today.AddDays(2),
+ OrganizationId = _testOrg.Id,
+ CreatedBy = _testUser.Id,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ await _service.CreateAsync(overdue);
+ await _service.CreateAsync(notOverdue);
+
+ // Act
+ var results = await _service.GetOverdueMaintenanceRequestsAsync();
+
+ // Assert
+ Assert.Single(results);
+ Assert.Equal("Overdue Request", results[0].Title);
+ }
+
+ [Fact]
+ public async Task GetMaintenanceRequestWithRelationsAsync_LoadsAllRelations()
+ {
+ // Arrange
+ // Tenant and Lease already exist from constructor, no need to re-add them
+
+ var request = new MaintenanceRequest
+ {
+ PropertyId = _testProperty.Id,
+ LeaseId = _testLease.Id,
+ Title = "Test Request",
+ Description = "Description",
+ RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing,
+ Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium,
+ Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted,
+ RequestedOn = DateTime.Today,
+ OrganizationId = _testOrg.Id,
+ CreatedBy = _testUser.Id,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(request);
+
+ // Act
+ var result = await _service.GetMaintenanceRequestWithRelationsAsync(request.Id);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.NotNull(result.Property);
+ Assert.NotNull(result.Lease);
+ Assert.NotNull(result.Lease.Tenant);
+ Assert.Equal("Test", result.Lease.Tenant.FirstName);
+ }
+
+ #endregion
+
+ #region Business Logic Tests
+
+ [Fact]
+ public async Task UpdateMaintenanceRequestStatusAsync_UpdatesStatusAndSetsCompletedDate()
+ {
+ // Arrange
+ var request = new MaintenanceRequest
+ {
+ PropertyId = _testProperty.Id,
+ Title = "Test Request",
+ Description = "Description",
+ RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing,
+ Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium,
+ Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted,
+ RequestedOn = DateTime.Today,
+ OrganizationId = _testOrg.Id,
+ CreatedBy = _testUser.Id,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(request);
+
+ // Act
+ var result = await _service.UpdateMaintenanceRequestStatusAsync(request.Id, "Completed");
+
+ // Assert
+ Assert.Equal("Completed", result.Status);
+ Assert.NotNull(result.CompletedOn);
+ Assert.Equal(DateTime.Today, result.CompletedOn.Value.Date);
+ }
+
+ [Fact]
+ public async Task AssignMaintenanceRequestAsync_UpdatesAssignmentAndStatus()
+ {
+ // Arrange
+ var request = new MaintenanceRequest
+ {
+ PropertyId = _testProperty.Id,
+ Title = "Test Request",
+ Description = "Description",
+ RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing,
+ Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium,
+ Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted,
+ RequestedOn = DateTime.Today,
+ OrganizationId = _testOrg.Id,
+ CreatedBy = _testUser.Id,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(request);
+
+ // Act
+ var scheduledDate = DateTime.Today.AddDays(2);
+ var result = await _service.AssignMaintenanceRequestAsync(
+ request.Id,
+ "John Smith",
+ scheduledDate);
+
+ // Assert
+ Assert.Equal("John Smith", result.AssignedTo);
+ Assert.Equal(scheduledDate, result.ScheduledOn);
+ Assert.Equal("In Progress", result.Status);
+ }
+
+ [Fact]
+ public async Task CompleteMaintenanceRequestAsync_UpdatesAllCompletionFields()
+ {
+ // Arrange
+ var request = new MaintenanceRequest
+ {
+ PropertyId = _testProperty.Id,
+ Title = "Test Request",
+ Description = "Description",
+ RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing,
+ Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium,
+ Status = ApplicationConstants.MaintenanceRequestStatuses.InProgress,
+ RequestedOn = DateTime.Today.AddDays(-3),
+ EstimatedCost = 200,
+ OrganizationId = _testOrg.Id,
+ CreatedBy = _testUser.Id,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(request);
+
+ // Act
+ var result = await _service.CompleteMaintenanceRequestAsync(
+ request.Id,
+ 175.50m,
+ "Fixed the leak and replaced washers");
+
+ // Assert
+ Assert.Equal("Completed", result.Status);
+ Assert.Equal(175.50m, result.ActualCost);
+ Assert.Equal("Fixed the leak and replaced washers", result.ResolutionNotes);
+ Assert.NotNull(result.CompletedOn);
+ }
+
+ [Fact]
+ public async Task GetOpenMaintenanceRequestCountAsync_ReturnsCorrectCount()
+ {
+ // Arrange
+ var submitted = new MaintenanceRequest
+ {
+ PropertyId = _testProperty.Id,
+ Title = "Submitted",
+ Description = "Description",
+ RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing,
+ Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium,
+ Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted,
+ RequestedOn = DateTime.Today,
+ OrganizationId = _testOrg.Id,
+ CreatedBy = _testUser.Id,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ var inProgress = new MaintenanceRequest
+ {
+ PropertyId = _testProperty.Id,
+ Title = "In Progress",
+ Description = "Description",
+ RequestType = "Electrical",
+ Priority = "High",
+ Status = "In Progress",
+ RequestedOn = DateTime.Today,
+ OrganizationId = _testOrg.Id,
+ CreatedBy = _testUser.Id,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ var completed = new MaintenanceRequest
+ {
+ PropertyId = _testProperty.Id,
+ Title = "Completed",
+ Description = "Description",
+ RequestType = "Other",
+ Priority = "Low",
+ Status = "Completed",
+ RequestedOn = DateTime.Today.AddDays(-5),
+ CompletedOn = DateTime.Today,
+ OrganizationId = _testOrg.Id,
+ CreatedBy = _testUser.Id,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ await _service.CreateAsync(submitted);
+ await _service.CreateAsync(inProgress);
+ await _service.CreateAsync(completed);
+
+ // Act
+ var count = await _service.GetOpenMaintenanceRequestCountAsync();
+
+ // Assert
+ Assert.Equal(2, count); // Only submitted and in progress
+ }
+
+ [Fact]
+ public async Task GetUrgentMaintenanceRequestCountAsync_ReturnsCorrectCount()
+ {
+ // Arrange
+ var urgent1 = new MaintenanceRequest
+ {
+ PropertyId = _testProperty.Id,
+ Title = "Urgent 1",
+ Description = "Description",
+ RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing,
+ Priority = ApplicationConstants.MaintenanceRequestPriorities.Urgent,
+ Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted,
+ RequestedOn = DateTime.Today,
+ OrganizationId = _testOrg.Id,
+ CreatedBy = _testUser.Id,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ var urgent2 = new MaintenanceRequest
+ {
+ PropertyId = _testProperty.Id,
+ Title = "Urgent 2",
+ Description = "Description",
+ RequestType = ApplicationConstants.MaintenanceRequestTypes.Electrical,
+ Priority = ApplicationConstants.MaintenanceRequestPriorities.Urgent,
+ Status = ApplicationConstants.MaintenanceRequestStatuses.InProgress,
+ RequestedOn = DateTime.Today,
+ OrganizationId = _testOrg.Id,
+ CreatedBy = _testUser.Id,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ var high = new MaintenanceRequest
+ {
+ PropertyId = _testProperty.Id,
+ Title = "High Priority",
+ Description = "Description",
+ RequestType = ApplicationConstants.MaintenanceRequestTypes.Other,
+ Priority = ApplicationConstants.MaintenanceRequestPriorities.High,
+ Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted,
+ RequestedOn = DateTime.Today,
+ OrganizationId = _testOrg.Id,
+ CreatedBy = _testUser.Id,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ await _service.CreateAsync(urgent1);
+ await _service.CreateAsync(urgent2);
+ await _service.CreateAsync(high);
+
+ // Act
+ var count = await _service.GetUrgentMaintenanceRequestCountAsync();
+
+ // Assert
+ Assert.Equal(2, count);
+ }
+
+ [Fact]
+ public async Task GetMaintenanceRequestsByAssigneeAsync_ReturnsSortedByPriority()
+ {
+ // Arrange
+ var low = new MaintenanceRequest
+ {
+ PropertyId = _testProperty.Id,
+ Title = "Low Priority",
+ Description = "Description",
+ RequestType = ApplicationConstants.MaintenanceRequestTypes.Other,
+ Priority = ApplicationConstants.MaintenanceRequestPriorities.Low,
+ Status = ApplicationConstants.MaintenanceRequestStatuses.InProgress,
+ AssignedTo = "John Smith",
+ RequestedOn = DateTime.Today,
+ OrganizationId = _testOrg.Id,
+ CreatedBy = _testUser.Id,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ var urgent = new MaintenanceRequest
+ {
+ PropertyId = _testProperty.Id,
+ Title = "Urgent Request",
+ Description = "Description",
+ RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing,
+ Priority = ApplicationConstants.MaintenanceRequestPriorities.Urgent,
+ Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted,
+ AssignedTo = "John Smith",
+ RequestedOn = DateTime.Today,
+ OrganizationId = _testOrg.Id,
+ CreatedBy = _testUser.Id,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ var high = new MaintenanceRequest
+ {
+ PropertyId = _testProperty.Id,
+ Title = "High Priority",
+ Description = "Description",
+ RequestType = ApplicationConstants.MaintenanceRequestTypes.Electrical,
+ Priority = ApplicationConstants.MaintenanceRequestPriorities.High,
+ Status = ApplicationConstants.MaintenanceRequestStatuses.InProgress,
+ AssignedTo = "John Smith",
+ RequestedOn = DateTime.Today,
+ OrganizationId = _testOrg.Id,
+ CreatedBy = _testUser.Id,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ await _service.CreateAsync(low);
+ await _service.CreateAsync(urgent);
+ await _service.CreateAsync(high);
+
+ // Act
+ var results = await _service.GetMaintenanceRequestsByAssigneeAsync("John Smith");
+
+ // Assert
+ Assert.Equal(3, results.Count);
+ Assert.Equal("Urgent", results[0].Priority); // Urgent first
+ Assert.Equal("High", results[1].Priority); // High second
+ Assert.Equal("Low", results[2].Priority); // Low last
+ }
+
+ [Fact]
+ public async Task CalculateAverageDaysToCompleteAsync_ReturnsCorrectAverage()
+ {
+ // Arrange
+ var completed1 = new MaintenanceRequest
+ {
+ PropertyId = _testProperty.Id,
+ Title = "Request 1",
+ Description = "Description",
+ RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing,
+ Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium,
+ Status = ApplicationConstants.MaintenanceRequestStatuses.Completed,
+ RequestedOn = DateTime.Today.AddDays(-10),
+ CompletedOn = DateTime.Today.AddDays(-5), // 5 days
+ OrganizationId = _testOrg.Id,
+ CreatedBy = _testUser.Id,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ var completed2 = new MaintenanceRequest
+ {
+ PropertyId = _testProperty.Id,
+ Title = "Request 2",
+ Description = "Description",
+ RequestType = ApplicationConstants.MaintenanceRequestTypes.Electrical,
+ Priority = ApplicationConstants.MaintenanceRequestPriorities.High,
+ Status = ApplicationConstants.MaintenanceRequestStatuses.Completed,
+ RequestedOn = DateTime.Today.AddDays(-7),
+ CompletedOn = DateTime.Today, // 7 days
+ OrganizationId = _testOrg.Id,
+ CreatedBy = _testUser.Id,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ await _service.CreateAsync(completed1);
+ await _service.CreateAsync(completed2);
+
+ // Act
+ var average = await _service.CalculateAverageDaysToCompleteAsync();
+
+ // Assert
+ Assert.Equal(6.0, average); // (5 + 7) / 2 = 6
+ }
+
+ [Fact]
+ public async Task GetMaintenanceCostsByPropertyAsync_ReturnsCorrectTotals()
+ {
+ // Arrange
+ var property2 = new Property
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = _testOrg.Id,
+ Address = "456 Other St",
+ City = "Test City",
+ State = "TS",
+ ZipCode = "12345",
+ PropertyType = "Condo",
+ Bedrooms = 2,
+ Bathrooms = 1,
+ SquareFeet = 1000,
+ IsAvailable = true,
+ CreatedBy = _testUser.Id,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Properties.Add(property2);
+ await _context.SaveChangesAsync();
+
+ var request1 = new MaintenanceRequest
+ {
+ PropertyId = _testProperty.Id,
+ Title = "Request 1",
+ Description = "Description",
+ RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing,
+ Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium,
+ Status = ApplicationConstants.MaintenanceRequestStatuses.Completed,
+ RequestedOn = DateTime.Today.AddDays(-10),
+ CompletedOn = DateTime.Today,
+ ActualCost = 150,
+ OrganizationId = _testOrg.Id,
+ CreatedBy = _testUser.Id,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ var request2 = new MaintenanceRequest
+ {
+ PropertyId = _testProperty.Id,
+ Title = "Request 2",
+ Description = "Description",
+ RequestType = ApplicationConstants.MaintenanceRequestTypes.Electrical,
+ Priority = ApplicationConstants.MaintenanceRequestPriorities.High,
+ Status = ApplicationConstants.MaintenanceRequestStatuses.Completed,
+ RequestedOn = DateTime.Today.AddDays(-5),
+ CompletedOn = DateTime.Today,
+ ActualCost = 200,
+ OrganizationId = _testOrg.Id,
+ CreatedBy = _testUser.Id,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ var request3 = new MaintenanceRequest
+ {
+ PropertyId = property2.Id,
+ Title = "Request 3",
+ Description = "Description",
+ RequestType = ApplicationConstants.MaintenanceRequestTypes.Other,
+ Priority = ApplicationConstants.MaintenanceRequestPriorities.Low,
+ Status = ApplicationConstants.MaintenanceRequestStatuses.Completed,
+ RequestedOn = DateTime.Today.AddDays(-3),
+ CompletedOn = DateTime.Today,
+ ActualCost = 75,
+ OrganizationId = _testOrg.Id,
+ CreatedBy = _testUser.Id,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ await _service.CreateAsync(request1);
+ await _service.CreateAsync(request2);
+ await _service.CreateAsync(request3);
+
+ // Act
+ var costs = await _service.GetMaintenanceCostsByPropertyAsync();
+
+ // Assert
+ Assert.Equal(2, costs.Count);
+ Assert.Equal(350m, costs[_testProperty.Id]); // 150 + 200
+ Assert.Equal(75m, costs[property2.Id]);
+ }
+
+ [Fact]
+ public async Task DeleteAsync_RemovesCalendarEvent()
+ {
+ // Arrange
+ var request = new MaintenanceRequest
+ {
+ PropertyId = _testProperty.Id,
+ Title = "Test Request",
+ Description = "Description",
+ RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing,
+ Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium,
+ Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted,
+ RequestedOn = DateTime.Today,
+ OrganizationId = _testOrg.Id,
+ CreatedBy = _testUser.Id,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(request);
+
+ // Act
+ await _service.DeleteAsync(request.Id);
+
+ // Assert
+ _mockCalendarEventService.Verify(
+ x => x.DeleteEventAsync(It.IsAny()),
+ Times.Once);
+ }
+
+ #endregion
+
+ #region Organization Isolation Tests
+
+ [Fact]
+ public async Task GetByIdAsync_DifferentOrganization_ReturnsNull()
+ {
+ // Arrange
+ var otherOrg = new Organization
+ {
+ Id = Guid.NewGuid(),
+ Name = "Other Organization",
+ OwnerId = _testUserId,
+ CreatedBy = _testUser.Id,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Organizations.Add(otherOrg);
+ await _context.SaveChangesAsync();
+
+ var otherProperty = new Property
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = otherOrg.Id,
+ Address = "999 Other St",
+ City = "Other City",
+ State = "OT",
+ ZipCode = "99999",
+ PropertyType = "Condo",
+ Bedrooms = 2,
+ Bathrooms = 1,
+ SquareFeet = 900,
+ IsAvailable = true,
+ CreatedBy = _testUser.Id,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Properties.Add(otherProperty);
+ await _context.SaveChangesAsync();
+
+ var otherRequest = new MaintenanceRequest
+ {
+ PropertyId = otherProperty.Id,
+ Title = "Other Request",
+ Description = "Description",
+ RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing,
+ Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium,
+ Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted,
+ RequestedOn = DateTime.Today,
+ OrganizationId = otherOrg.Id,
+ CreatedBy = _testUser.Id,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.MaintenanceRequests.Add(otherRequest);
+ await _context.SaveChangesAsync();
+
+ // Act
+ var result = await _service.GetByIdAsync(otherRequest.Id);
+
+ // Assert
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public async Task GetAllAsync_ReturnsOnlyCurrentOrganizationRequests()
+ {
+ // Arrange
+ var myRequest = new MaintenanceRequest
+ {
+ PropertyId = _testProperty.Id,
+ Title = "My Request",
+ Description = "Description",
+ RequestType = ApplicationConstants.MaintenanceRequestTypes.Plumbing,
+ Priority = ApplicationConstants.MaintenanceRequestPriorities.Medium,
+ Status = ApplicationConstants.MaintenanceRequestStatuses.Submitted,
+ RequestedOn = DateTime.Today,
+ OrganizationId = _testOrg.Id,
+ CreatedBy = _testUser.Id,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(myRequest);
+
+ var otherOrg = new Organization
+ {
+ Id = Guid.NewGuid(),
+ Name = "Other Organization",
+ OwnerId = _testUserId,
+ CreatedBy = _testUser.Id,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Organizations.Add(otherOrg);
+ await _context.SaveChangesAsync();
+
+ var otherProperty = new Property
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = otherOrg.Id,
+ Address = "999 Other St",
+ City = "Other City",
+ State = "OT",
+ ZipCode = "99999",
+ PropertyType = "Condo",
+ Bedrooms = 2,
+ Bathrooms = 1,
+ SquareFeet = 900,
+ IsAvailable = true,
+ CreatedBy = _testUser.Id,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Properties.Add(otherProperty);
+
+ var otherRequest = new MaintenanceRequest
+ {
+ PropertyId = otherProperty.Id,
+ Title = "Other Request",
+ Description = "Description",
+ RequestType = ApplicationConstants.MaintenanceRequestTypes.Electrical,
+ Priority = ApplicationConstants.MaintenanceRequestPriorities.High,
+ Status = ApplicationConstants.MaintenanceRequestStatuses.InProgress,
+ RequestedOn = DateTime.Today,
+ OrganizationId = otherOrg.Id,
+ CreatedBy = _testUser.Id,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.MaintenanceRequests.Add(otherRequest);
+ await _context.SaveChangesAsync();
+
+ // Act
+ var results = await _service.GetAllAsync();
+
+ // Assert
+ Assert.Single(results);
+ Assert.Equal(_testOrg.Id, results[0].OrganizationId);
+ Assert.Equal("My Request", results[0].Title);
+ }
+
+ #endregion
+ }
+}
diff --git a/Aquiis.SimpleStart.Tests/PaymentServiceTests.cs b/Aquiis.SimpleStart.Tests/PaymentServiceTests.cs
new file mode 100644
index 0000000..57ed152
--- /dev/null
+++ b/Aquiis.SimpleStart.Tests/PaymentServiceTests.cs
@@ -0,0 +1,983 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Linq;
+using System.Security.Claims;
+using System.Threading.Tasks;
+using Aquiis.SimpleStart.Core.Constants;
+using Aquiis.SimpleStart.Core.Entities;
+using Aquiis.SimpleStart.Infrastructure.Data;
+using Aquiis.SimpleStart.Shared.Components.Account;
+using Aquiis.SimpleStart.Shared.Services;
+using Microsoft.AspNetCore.Components.Authorization;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Moq;
+using Xunit;
+using PaymentService = Aquiis.SimpleStart.Application.Services.PaymentService;
+
+namespace Aquiis.SimpleStart.Tests
+{
+ ///
+ /// Comprehensive unit tests for PaymentService.
+ /// Tests CRUD operations, validation, business logic, and organization isolation.
+ ///
+ public class PaymentServiceTests : IDisposable
+ {
+ private readonly SqliteConnection _connection;
+ private readonly ApplicationDbContext _context;
+ private readonly UserContextService _userContext;
+ private readonly Mock> _mockLogger;
+ private readonly IOptions _mockSettings;
+ private readonly PaymentService _service;
+ private readonly Guid _testOrgId = Guid.NewGuid();
+ private readonly string _testUserId = "test-user-123";
+ private readonly Guid _testPropertyId = Guid.NewGuid();
+ private readonly Guid _testTenantId = Guid.NewGuid();
+ private readonly Guid _testLeaseId = Guid.NewGuid();
+ private readonly Guid _testInvoiceId = Guid.NewGuid();
+
+ public PaymentServiceTests()
+ {
+ // Setup SQLite in-memory database
+ _connection = new SqliteConnection("DataSource=:memory:");
+ _connection.Open();
+
+ var options = new DbContextOptionsBuilder()
+ .UseSqlite(_connection)
+ .Options;
+
+ _context = new ApplicationDbContext(options);
+ _context.Database.EnsureCreated();
+
+ // Mock AuthenticationStateProvider with claims
+ var claims = new ClaimsPrincipal(new ClaimsIdentity(
+ new[] { new Claim(ClaimTypes.NameIdentifier, _testUserId) },
+ "TestAuth"));
+ var mockAuth = new Mock();
+ mockAuth.Setup(a => a.GetAuthenticationStateAsync())
+ .ReturnsAsync(new AuthenticationState(claims));
+
+ // Mock UserManager
+ var mockUserStore = new Mock>();
+ var mockUserManager = new Mock>(
+ mockUserStore.Object, null, null, null, null, null, null, null, null);
+
+ var appUser = new ApplicationUser
+ {
+ Id = _testUserId,
+ UserName = "testuser",
+ Email = "testuser@example.com",
+ ActiveOrganizationId = _testOrgId
+ };
+ mockUserManager.Setup(u => u.FindByIdAsync(It.IsAny()))
+ .ReturnsAsync(appUser);
+
+ var serviceProvider = new Mock();
+ _userContext = new UserContextService(mockAuth.Object, mockUserManager.Object, serviceProvider.Object);
+
+ // Create test user
+ var user = new ApplicationUser
+ {
+ Id = _testUserId,
+ UserName = "testuser",
+ Email = "testuser@example.com",
+ ActiveOrganizationId = _testOrgId
+ };
+ _context.Users.Add(user);
+
+ // Create test organization
+ var organization = new Organization
+ {
+ Id = _testOrgId,
+ Name = "Test Organization",
+ OwnerId = _testUserId,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Organizations.Add(organization);
+
+ // Create test property
+ var property = new Property
+ {
+ Id = _testPropertyId,
+ OrganizationId = _testOrgId,
+ Address = "123 Test St",
+ City = "Test City",
+ State = "TS",
+ ZipCode = "12345",
+ IsAvailable = false,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Properties.Add(property);
+
+ // Create test tenant
+ var tenant = new Tenant
+ {
+ Id = _testTenantId,
+ OrganizationId = _testOrgId,
+ FirstName = "Test",
+ LastName = "Tenant",
+ Email = "tenant@test.com",
+ IdentificationNumber = "SSN123456",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Tenants.Add(tenant);
+
+ // Create test lease
+ var lease = new Lease
+ {
+ Id = _testLeaseId,
+ OrganizationId = _testOrgId,
+ PropertyId = _testPropertyId,
+ TenantId = _testTenantId,
+ StartDate = DateTime.Today,
+ EndDate = DateTime.Today.AddYears(1),
+ MonthlyRent = 1500,
+ SecurityDeposit = 1500,
+ Status = "Active",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Leases.Add(lease);
+
+ // Create test invoice
+ var invoice = new Invoice
+ {
+ Id = _testInvoiceId,
+ OrganizationId = _testOrgId,
+ LeaseId = _testLeaseId,
+ InvoiceNumber = "INV-TEST-001",
+ InvoicedOn = DateTime.Today,
+ DueOn = DateTime.Today.AddDays(30),
+ Amount = 1500,
+ Description = "Test Invoice",
+ Status = "Pending",
+ AmountPaid = 0,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Invoices.Add(invoice);
+
+ _context.SaveChanges();
+
+ // Setup logger and settings
+ _mockLogger = new Mock>();
+
+ _mockSettings = Options.Create(new ApplicationSettings
+ {
+ SoftDeleteEnabled = true
+ });
+
+ // Create service instance
+ _service = new PaymentService(
+ _context,
+ _mockLogger.Object,
+ _userContext,
+ _mockSettings);
+ }
+
+ public void Dispose()
+ {
+ _context.Dispose();
+ _connection.Dispose();
+ }
+
+ #region Validation Tests
+
+ [Fact]
+ public async Task CreateAsync_ValidPayment_CreatesSuccessfully()
+ {
+ // Arrange
+ var payment = new Payment
+ {
+ OrganizationId = _testOrgId,
+ InvoiceId = _testInvoiceId,
+ Amount = 1500,
+ PaidOn = DateTime.Today,
+ PaymentMethod = "Check",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ // Act
+ var result = await _service.CreateAsync(payment);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.NotEqual(Guid.Empty, result.Id);
+ Assert.Equal(1500, result.Amount);
+ Assert.Equal("Check", result.PaymentMethod);
+ }
+
+ [Fact]
+ public async Task CreateAsync_MissingInvoiceId_ThrowsException()
+ {
+ // Arrange
+ var payment = new Payment
+ {
+ OrganizationId = _testOrgId,
+ InvoiceId = Guid.Empty, // Missing
+ Amount = 1500,
+ PaidOn = DateTime.Today,
+ PaymentMethod = "Check",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _service.CreateAsync(payment));
+ }
+
+ [Fact]
+ public async Task CreateAsync_ZeroAmount_ThrowsException()
+ {
+ // Arrange
+ var payment = new Payment
+ {
+ OrganizationId = _testOrgId,
+ InvoiceId = _testInvoiceId,
+ Amount = 0, // Invalid
+ PaidOn = DateTime.Today,
+ PaymentMethod = "Check",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _service.CreateAsync(payment));
+ }
+
+ [Fact]
+ public async Task CreateAsync_NegativeAmount_ThrowsException()
+ {
+ // Arrange
+ var payment = new Payment
+ {
+ OrganizationId = _testOrgId,
+ InvoiceId = _testInvoiceId,
+ Amount = -100, // Invalid
+ PaidOn = DateTime.Today,
+ PaymentMethod = "Check",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _service.CreateAsync(payment));
+ }
+
+ [Fact]
+ public async Task CreateAsync_FuturePaymentDate_ThrowsException()
+ {
+ // Arrange
+ var payment = new Payment
+ {
+ OrganizationId = _testOrgId,
+ InvoiceId = _testInvoiceId,
+ Amount = 1500,
+ PaidOn = DateTime.Today.AddDays(5), // Future date
+ PaymentMethod = "Check",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _service.CreateAsync(payment));
+ }
+
+ [Fact]
+ public async Task CreateAsync_ExceedsInvoiceBalance_ThrowsException()
+ {
+ // Arrange
+ var payment = new Payment
+ {
+ OrganizationId = _testOrgId,
+ InvoiceId = _testInvoiceId,
+ Amount = 2000, // Exceeds invoice amount of 1500
+ PaidOn = DateTime.Today,
+ PaymentMethod = "Check",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _service.CreateAsync(payment));
+ }
+
+ [Fact]
+ public async Task CreateAsync_InvalidPaymentMethod_ThrowsException()
+ {
+ // Arrange
+ var payment = new Payment
+ {
+ OrganizationId = _testOrgId,
+ InvoiceId = _testInvoiceId,
+ Amount = 1500,
+ PaidOn = DateTime.Today,
+ PaymentMethod = "InvalidMethod", // Invalid
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _service.CreateAsync(payment));
+ }
+
+ #endregion
+
+ #region Retrieval Tests
+
+ [Fact]
+ public async Task GetPaymentsByInvoiceIdAsync_ReturnsInvoicePayments()
+ {
+ // Arrange - Create payments
+ var payment1 = new Payment
+ {
+ OrganizationId = _testOrgId,
+ InvoiceId = _testInvoiceId,
+ Amount = 500,
+ PaidOn = DateTime.Today,
+ PaymentMethod = "Check",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(payment1);
+
+ var payment2 = new Payment
+ {
+ OrganizationId = _testOrgId,
+ InvoiceId = _testInvoiceId,
+ Amount = 500,
+ PaidOn = DateTime.Today.AddDays(1),
+ PaymentMethod = "Cash",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(payment2);
+
+ // Act
+ var result = await _service.GetPaymentsByInvoiceIdAsync(_testInvoiceId);
+
+ // Assert
+ Assert.Equal(2, result.Count);
+ Assert.All(result, p => Assert.Equal(_testInvoiceId, p.InvoiceId));
+ }
+
+ [Fact]
+ public async Task GetPaymentsByMethodAsync_ReturnsMatchingPayments()
+ {
+ // Arrange
+ var check = new Payment
+ {
+ OrganizationId = _testOrgId,
+ InvoiceId = _testInvoiceId,
+ Amount = 500,
+ PaidOn = DateTime.Today,
+ PaymentMethod = "Check",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(check);
+
+ var cash = new Payment
+ {
+ OrganizationId = _testOrgId,
+ InvoiceId = _testInvoiceId,
+ Amount = 500,
+ PaidOn = DateTime.Today,
+ PaymentMethod = "Cash",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(cash);
+
+ // Act
+ var checkResults = await _service.GetPaymentsByMethodAsync("Check");
+ var cashResults = await _service.GetPaymentsByMethodAsync("Cash");
+
+ // Assert
+ Assert.Single(checkResults);
+ Assert.Single(cashResults);
+ Assert.Equal("Check", checkResults[0].PaymentMethod);
+ Assert.Equal("Cash", cashResults[0].PaymentMethod);
+ }
+
+ [Fact]
+ public async Task GetPaymentsByDateRangeAsync_ReturnsPaymentsInRange()
+ {
+ // Arrange
+ var oldPayment = new Payment
+ {
+ OrganizationId = _testOrgId,
+ InvoiceId = _testInvoiceId,
+ Amount = 500,
+ PaidOn = DateTime.Today.AddMonths(-2),
+ PaymentMethod = "Check",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(oldPayment);
+
+ var recentPayment = new Payment
+ {
+ OrganizationId = _testOrgId,
+ InvoiceId = _testInvoiceId,
+ Amount = 500,
+ PaidOn = DateTime.Today,
+ PaymentMethod = "Cash",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(recentPayment);
+
+ // Act
+ var startDate = DateTime.Today.AddDays(-7);
+ var endDate = DateTime.Today.AddDays(1);
+ var result = await _service.GetPaymentsByDateRangeAsync(startDate, endDate);
+
+ // Assert
+ Assert.Single(result);
+ Assert.Equal(recentPayment.Amount, result[0].Amount);
+ }
+
+ [Fact]
+ public async Task GetPaymentWithRelationsAsync_LoadsAllRelations()
+ {
+ // Arrange
+ var payment = new Payment
+ {
+ OrganizationId = _testOrgId,
+ InvoiceId = _testInvoiceId,
+ Amount = 1500,
+ PaidOn = DateTime.Today,
+ PaymentMethod = "Check",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ var created = await _service.CreateAsync(payment);
+
+ // Act
+ var result = await _service.GetPaymentWithRelationsAsync(created.Id);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.NotNull(result.Invoice);
+ Assert.NotNull(result.Invoice.Lease);
+ Assert.NotNull(result.Invoice.Lease.Property);
+ Assert.NotNull(result.Invoice.Lease.Tenant);
+ Assert.Equal(_testInvoiceId, result.Invoice.Id);
+ }
+
+ #endregion
+
+ #region Business Logic Tests
+
+ [Fact]
+ public async Task CreateAsync_UpdatesInvoiceStatus_WhenFullyPaid()
+ {
+ // Arrange
+ var payment = new Payment
+ {
+ OrganizationId = _testOrgId,
+ InvoiceId = _testInvoiceId,
+ Amount = 1500, // Full amount
+ PaidOn = DateTime.Today,
+ PaymentMethod = "Check",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ // Act
+ await _service.CreateAsync(payment);
+
+ // Assert - Check invoice status updated
+ var invoice = await _context.Invoices.FindAsync(_testInvoiceId);
+ Assert.NotNull(invoice);
+ Assert.Equal("Paid", invoice.Status);
+ Assert.Equal(1500, invoice.AmountPaid);
+ Assert.NotNull(invoice.PaidOn);
+ }
+
+ [Fact]
+ public async Task CreateAsync_PartialPayment_UpdatesInvoiceAmountPaid()
+ {
+ // Arrange
+ var payment = new Payment
+ {
+ OrganizationId = _testOrgId,
+ InvoiceId = _testInvoiceId,
+ Amount = 750, // Partial payment
+ PaidOn = DateTime.Today,
+ PaymentMethod = "Check",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ // Act
+ await _service.CreateAsync(payment);
+
+ // Assert - Check invoice updated but not marked as paid
+ var invoice = await _context.Invoices.FindAsync(_testInvoiceId);
+ Assert.NotNull(invoice);
+ Assert.Equal("Pending", invoice.Status);
+ Assert.Equal(750, invoice.AmountPaid);
+ }
+
+ [Fact]
+ public async Task CreateAsync_MultiplePayments_UpdatesInvoiceCorrectly()
+ {
+ // Arrange - Create two partial payments
+ var payment1 = new Payment
+ {
+ OrganizationId = _testOrgId,
+ InvoiceId = _testInvoiceId,
+ Amount = 750,
+ PaidOn = DateTime.Today,
+ PaymentMethod = "Check",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(payment1);
+
+ var payment2 = new Payment
+ {
+ OrganizationId = _testOrgId,
+ InvoiceId = _testInvoiceId,
+ Amount = 750,
+ PaidOn = DateTime.Today.AddDays(1),
+ PaymentMethod = "Cash",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ // Act
+ await _service.CreateAsync(payment2);
+
+ // Assert - Invoice should be fully paid
+ var invoice = await _context.Invoices.FindAsync(_testInvoiceId);
+ Assert.NotNull(invoice);
+ Assert.Equal("Paid", invoice.Status);
+ Assert.Equal(1500, invoice.AmountPaid);
+ }
+
+ [Fact]
+ public async Task DeleteAsync_UpdatesInvoiceStatus()
+ {
+ // Arrange - Create full payment
+ var payment = new Payment
+ {
+ OrganizationId = _testOrgId,
+ InvoiceId = _testInvoiceId,
+ Amount = 1500,
+ PaidOn = DateTime.Today,
+ PaymentMethod = ApplicationConstants.PaymentMethods.Check,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ var created = await _service.CreateAsync(payment);
+
+ // Verify invoice is paid
+ var invoice = await _context.Invoices.FindAsync(_testInvoiceId);
+ Assert.Equal("Paid", invoice!.Status);
+
+ // Act - Delete payment
+ await _service.DeleteAsync(created.Id);
+
+ // Assert - Invoice should be back to pending
+ invoice = await _context.Invoices.FindAsync(_testInvoiceId);
+ Assert.NotNull(invoice);
+ Assert.Equal("Pending", invoice.Status);
+ Assert.Equal(0, invoice.AmountPaid);
+ }
+
+ [Fact]
+ public async Task CalculateTotalPaymentsAsync_ReturnsCorrectTotal()
+ {
+ // Arrange - Create payments
+ var payment1 = new Payment
+ {
+ OrganizationId = _testOrgId,
+ InvoiceId = _testInvoiceId,
+ Amount = 500,
+ PaidOn = DateTime.Today,
+ PaymentMethod = ApplicationConstants.PaymentMethods.Check,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(payment1);
+
+ var payment2 = new Payment
+ {
+ OrganizationId = _testOrgId,
+ InvoiceId = _testInvoiceId,
+ Amount = 750,
+ PaidOn = DateTime.Today,
+ PaymentMethod = ApplicationConstants.PaymentMethods.Cash,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(payment2);
+
+ // Act
+ var total = await _service.CalculateTotalPaymentsAsync();
+
+ // Assert
+ Assert.Equal(1250m, total);
+ }
+
+ [Fact]
+ public async Task CalculateTotalPaymentsAsync_WithDateRange_ReturnsFilteredTotal()
+ {
+ // Arrange
+ var oldPayment = new Payment
+ {
+ OrganizationId = _testOrgId,
+ InvoiceId = _testInvoiceId,
+ Amount = 500,
+ PaidOn = DateTime.Today.AddMonths(-2),
+ PaymentMethod = ApplicationConstants.PaymentMethods.Check,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(oldPayment);
+
+ var recentPayment = new Payment
+ {
+ OrganizationId = _testOrgId,
+ InvoiceId = _testInvoiceId,
+ Amount = 750,
+ PaidOn = DateTime.Today,
+ PaymentMethod = ApplicationConstants.PaymentMethods.Cash,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(recentPayment);
+
+ // Act
+ var startDate = DateTime.Today.AddDays(-7);
+ var endDate = DateTime.Today.AddDays(1);
+ var total = await _service.CalculateTotalPaymentsAsync(startDate, endDate);
+
+ // Assert
+ Assert.Equal(750m, total); // Only recent payment
+ }
+
+ [Fact]
+ public async Task GetPaymentSummaryByMethodAsync_ReturnsCorrectSummary()
+ {
+ // Arrange
+ var check1 = new Payment
+ {
+ OrganizationId = _testOrgId,
+ InvoiceId = _testInvoiceId,
+ Amount = 500,
+ PaidOn = DateTime.Today,
+ PaymentMethod = "Check",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(check1);
+
+ var check2 = new Payment
+ {
+ OrganizationId = _testOrgId,
+ InvoiceId = _testInvoiceId,
+ Amount = 300,
+ PaidOn = DateTime.Today,
+ PaymentMethod = ApplicationConstants.PaymentMethods.Check,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(check2);
+
+ var cash = new Payment
+ {
+ OrganizationId = _testOrgId,
+ InvoiceId = _testInvoiceId,
+ Amount = 700,
+ PaidOn = DateTime.Today,
+ PaymentMethod = ApplicationConstants.PaymentMethods.Cash,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(cash);
+
+ // Act
+ var summary = await _service.GetPaymentSummaryByMethodAsync();
+
+ // Assert
+ Assert.Equal(2, summary.Count);
+ Assert.Equal(800m, summary["Check"]); // 500 + 300
+ Assert.Equal(700m, summary["Cash"]);
+ }
+
+ [Fact]
+ public async Task GetTotalPaidForInvoiceAsync_ReturnsCorrectTotal()
+ {
+ // Arrange
+ var payment1 = new Payment
+ {
+ OrganizationId = _testOrgId,
+ InvoiceId = _testInvoiceId,
+ Amount = 500,
+ PaidOn = DateTime.Today,
+ PaymentMethod = ApplicationConstants.PaymentMethods.OnlinePayment,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(payment1);
+
+ var payment2 = new Payment
+ {
+ OrganizationId = _testOrgId,
+ InvoiceId = _testInvoiceId,
+ Amount = 750,
+ PaidOn = DateTime.Today,
+ PaymentMethod = ApplicationConstants.PaymentMethods.Cash,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(payment2);
+
+ // Act
+ var total = await _service.GetTotalPaidForInvoiceAsync(_testInvoiceId);
+
+ // Assert
+ Assert.Equal(1250m, total);
+ }
+
+ #endregion
+
+ #region Organization Isolation Tests
+
+ [Fact]
+ public async Task GetByIdAsync_DifferentOrganization_ReturnsNull()
+ {
+ // Arrange - Create different organization and payment
+ var otherUserId = "other-user-456";
+ var otherUser = new ApplicationUser
+ {
+ Id = otherUserId,
+ UserName = "otheruser",
+ Email = "otheruser@example.com"
+ };
+ _context.Users.Add(otherUser);
+ await _context.SaveChangesAsync();
+
+ var otherOrg = new Organization
+ {
+ Id = Guid.NewGuid(),
+ Name = "Other Organization",
+ OwnerId = otherUserId,
+ CreatedBy = otherUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Organizations.AddAsync(otherOrg);
+
+ var otherProperty = new Property
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = otherOrg.Id,
+ Address = "999 Other St",
+ City = "Other City",
+ State = "OT",
+ ZipCode = "99999",
+ IsAvailable = true,
+ CreatedBy = otherUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Properties.AddAsync(otherProperty);
+
+ var otherTenant = new Tenant
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = otherOrg.Id,
+ FirstName = "Other",
+ LastName = "Tenant",
+ Email = "other@test.com",
+ IdentificationNumber = "ID999",
+ CreatedBy = otherUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Tenants.AddAsync(otherTenant);
+
+ var otherLease = new Lease
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = otherOrg.Id,
+ PropertyId = otherProperty.Id,
+ TenantId = otherTenant.Id,
+ StartDate = DateTime.Today,
+ EndDate = DateTime.Today.AddYears(1),
+ MonthlyRent = 2000,
+ SecurityDeposit = 2000,
+ Status = "Active",
+ CreatedBy = otherUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Leases.AddAsync(otherLease);
+
+ var otherInvoice = new Invoice
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = otherOrg.Id,
+ LeaseId = otherLease.Id,
+ InvoiceNumber = "INV-OTHER",
+ InvoicedOn = DateTime.Today,
+ DueOn = DateTime.Today.AddDays(30),
+ Amount = 2000,
+ Description = "Other Org Invoice",
+ Status = "Pending",
+ CreatedBy = otherUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Invoices.AddAsync(otherInvoice);
+
+ var otherOrgPayment = new Payment
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = otherOrg.Id,
+ InvoiceId = otherInvoice.Id,
+ Amount = 2000,
+ PaidOn = DateTime.Today,
+ PaymentMethod = "Check",
+ CreatedBy = otherUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Payments.AddAsync(otherOrgPayment);
+ await _context.SaveChangesAsync();
+
+ // Act
+ var result = await _service.GetByIdAsync(otherOrgPayment.Id);
+
+ // Assert
+ Assert.Null(result); // Should not access payment from different org
+ }
+
+ [Fact]
+ public async Task GetAllAsync_ReturnsOnlyCurrentOrganizationPayments()
+ {
+ // Arrange - Create payment in test org
+ var testOrgPayment = new Payment
+ {
+ OrganizationId = _testOrgId,
+ InvoiceId = _testInvoiceId,
+ Amount = 1500,
+ PaidOn = DateTime.Today,
+ PaymentMethod = "Check",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _service.CreateAsync(testOrgPayment);
+
+ // Create payment in different org
+ var otherUserId = "other-user-456";
+ var otherUser = new ApplicationUser
+ {
+ Id = otherUserId,
+ UserName = "otheruser",
+ Email = "otheruser@example.com"
+ };
+ _context.Users.Add(otherUser);
+ await _context.SaveChangesAsync();
+
+ var otherOrg = new Organization
+ {
+ Id = Guid.NewGuid(),
+ Name = "Other Organization",
+ OwnerId = otherUserId,
+ CreatedBy = otherUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Organizations.AddAsync(otherOrg);
+
+ var otherProperty = new Property
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = otherOrg.Id,
+ Address = "888 Other Ave",
+ City = "Other City",
+ State = "OT",
+ ZipCode = "88888",
+ IsAvailable = true,
+ CreatedBy = otherUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Properties.AddAsync(otherProperty);
+
+ var otherTenant = new Tenant
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = otherOrg.Id,
+ FirstName = "Other",
+ LastName = "Tenant",
+ Email = "other@test.com",
+ IdentificationNumber = "ID888",
+ CreatedBy = otherUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Tenants.AddAsync(otherTenant);
+
+ var otherLease = new Lease
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = otherOrg.Id,
+ PropertyId = otherProperty.Id,
+ TenantId = otherTenant.Id,
+ StartDate = DateTime.Today,
+ EndDate = DateTime.Today.AddYears(1),
+ MonthlyRent = 2500,
+ SecurityDeposit = 2500,
+ Status = "Active",
+ CreatedBy = otherUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Leases.AddAsync(otherLease);
+
+ var otherInvoice = new Invoice
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = otherOrg.Id,
+ LeaseId = otherLease.Id,
+ InvoiceNumber = "INV-OTHER-ORG",
+ InvoicedOn = DateTime.Today,
+ DueOn = DateTime.Today.AddDays(30),
+ Amount = 2500,
+ Description = "Other",
+ Status = "Pending",
+ CreatedBy = otherUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Invoices.AddAsync(otherInvoice);
+
+ var otherOrgPayment = new Payment
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = otherOrg.Id,
+ InvoiceId = otherInvoice.Id,
+ Amount = 2500,
+ PaidOn = DateTime.Today,
+ PaymentMethod = "Cash",
+ CreatedBy = otherUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Payments.AddAsync(otherOrgPayment);
+ await _context.SaveChangesAsync();
+
+ // Act
+ var result = await _service.GetAllAsync();
+
+ // Assert
+ Assert.Single(result);
+ Assert.Equal(_testOrgId, result[0].OrganizationId);
+ }
+
+ #endregion
+ }
+}
diff --git a/Aquiis.SimpleStart.Tests/PropertyServiceTests.cs b/Aquiis.SimpleStart.Tests/PropertyServiceTests.cs
new file mode 100644
index 0000000..80a9ac9
--- /dev/null
+++ b/Aquiis.SimpleStart.Tests/PropertyServiceTests.cs
@@ -0,0 +1,666 @@
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.Linq;
+using System.Security.Claims;
+using System.Threading.Tasks;
+using Aquiis.SimpleStart.Application.Services;
+using Aquiis.SimpleStart.Core.Constants;
+using Aquiis.SimpleStart.Core.Entities;
+using Aquiis.SimpleStart.Infrastructure.Data;
+using Aquiis.SimpleStart.Shared.Components.Account;
+using Aquiis.SimpleStart.Shared.Services;
+using Microsoft.AspNetCore.Components.Authorization;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Moq;
+using Xunit;
+
+namespace Aquiis.SimpleStart.Tests
+{
+ ///
+ /// Unit tests for PropertyService business logic and property-specific operations.
+ ///
+ public class PropertyServiceTests : IDisposable
+ {
+ private readonly ApplicationDbContext _context;
+ private readonly UserContextService _userContext;
+ private readonly PropertyService _service;
+ private readonly string _testUserId;
+ private readonly Guid _testOrgId;
+ private readonly Microsoft.Data.Sqlite.SqliteConnection _connection;
+
+ public PropertyServiceTests()
+ {
+ // Setup SQLite in-memory database
+ _connection = new Microsoft.Data.Sqlite.SqliteConnection("Data Source=:memory:");
+ _connection.Open();
+
+ var options = new DbContextOptionsBuilder()
+ .UseSqlite(_connection)
+ .Options;
+
+ _context = new ApplicationDbContext(options);
+ _context.Database.EnsureCreated();
+
+ // Setup test user and organization
+ _testUserId = "test-user-123";
+ _testOrgId = Guid.NewGuid();
+
+ // Mock AuthenticationStateProvider
+ var claims = new ClaimsPrincipal(new ClaimsIdentity(
+ new[] { new Claim(ClaimTypes.NameIdentifier, _testUserId) },
+ "TestAuth"));
+ var mockAuth = new Mock();
+ mockAuth.Setup(a => a.GetAuthenticationStateAsync())
+ .ReturnsAsync(new AuthenticationState(claims));
+
+ // Mock UserManager
+ var mockUserStore = new Mock>();
+ var mockUserManager = new Mock>(
+ mockUserStore.Object, null, null, null, null, null, null, null, null);
+
+ var appUser = new ApplicationUser
+ {
+ Id = _testUserId,
+ UserName = "testuser",
+ Email = "test@example.com",
+ ActiveOrganizationId = _testOrgId
+ };
+ mockUserManager.Setup(u => u.FindByIdAsync(It.IsAny()))
+ .ReturnsAsync(appUser);
+
+ var serviceProvider = new Mock();
+ _userContext = new UserContextService(mockAuth.Object, mockUserManager.Object, serviceProvider.Object);
+
+ // Seed test data
+ var user = new ApplicationUser
+ {
+ Id = _testUserId,
+ UserName = "testuser",
+ Email = "test@example.com",
+ ActiveOrganizationId = _testOrgId
+ };
+ _context.Users.Add(user);
+
+ var org = new Organization
+ {
+ Id = _testOrgId,
+ Name = "Test Org",
+ OwnerId = _testUserId,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Organizations.Add(org);
+ _context.SaveChanges();
+
+ // Create PropertyService with mocked dependencies
+ var mockSettings = Options.Create(new ApplicationSettings
+ {
+ SoftDeleteEnabled = true
+ });
+
+ var mockLogger = new Mock>();
+
+ // Create real CalendarEventService for testing
+ var mockCalendarSettings = new Mock(_context, _userContext);
+ var calendarService = new CalendarEventService(_context, mockCalendarSettings.Object, _userContext);
+
+ _service = new PropertyService(_context, mockLogger.Object, _userContext, mockSettings, calendarService);
+ }
+
+ public void Dispose()
+ {
+ _context?.Dispose();
+ _connection?.Dispose();
+ }
+
+ #region CreateAsync Override Tests
+
+ [Fact]
+ public async Task CreateAsync_SetsNextRoutineInspectionDate()
+ {
+ // Arrange
+ var property = new Property
+ {
+ Address = "123 Main St",
+ City = "Test City",
+ State = "TS",
+ ZipCode = "12345",
+ PropertyType = "House"
+ };
+ var expectedDate = DateTime.Today.AddDays(30);
+
+ // Act
+ var result = await _service.CreateAsync(property);
+
+ // Assert
+ Assert.NotNull(result.NextRoutineInspectionDueDate);
+ Assert.Equal(expectedDate, result.NextRoutineInspectionDueDate!.Value.Date);
+ }
+
+ #endregion
+
+ #region Validation Tests
+
+ [Fact]
+ public async Task CreateAsync_MissingAddress_ThrowsValidationException()
+ {
+ // Arrange
+ var property = new Property
+ {
+ Address = "", // Empty address
+ City = "Test City",
+ State = "TS",
+ ZipCode = "12345",
+ PropertyType = "House"
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _service.CreateAsync(property));
+ }
+
+ [Fact]
+ public async Task CreateAsync_DuplicateAddress_ThrowsValidationException()
+ {
+ // Arrange
+ var existingProperty = new Property
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = _testOrgId,
+ Address = "456 Duplicate St",
+ City = "Same City",
+ State = "SC",
+ ZipCode = "54321",
+ PropertyType = "House",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Properties.Add(existingProperty);
+ await _context.SaveChangesAsync();
+
+ var duplicateProperty = new Property
+ {
+ Address = "456 Duplicate St", // Same address
+ City = "Same City",
+ State = "SC",
+ ZipCode = "54321",
+ PropertyType = "Apartment"
+ };
+
+ // Act & Assert
+ var exception = await Assert.ThrowsAsync(() => _service.CreateAsync(duplicateProperty));
+ Assert.Contains("already exists", exception.Message);
+ }
+
+ [Fact]
+ public async Task CreateAsync_SameAddressDifferentOrganization_AllowsCreation()
+ {
+ // Arrange
+ var differentOrgId = Guid.NewGuid();
+ var differentOrg = new Organization
+ {
+ Id = differentOrgId,
+ Name = "Different Org",
+ OwnerId = _testUserId,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Organizations.Add(differentOrg);
+
+ var existingProperty = new Property
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = differentOrgId, // Different organization
+ Address = "789 Shared St",
+ City = "Test City",
+ State = "TS",
+ ZipCode = "12345",
+ PropertyType = "House",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Properties.Add(existingProperty);
+ await _context.SaveChangesAsync();
+
+ var newProperty = new Property
+ {
+ Address = "789 Shared St", // Same address, different org
+ City = "Test City",
+ State = "TS",
+ ZipCode = "12345",
+ PropertyType = "Apartment"
+ };
+
+ // Act
+ var result = await _service.CreateAsync(newProperty);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Equal(newProperty.Address, result.Address);
+ }
+
+ #endregion
+
+ #region GetPropertyWithRelationsAsync Tests
+
+ [Fact]
+ public async Task GetPropertyWithRelationsAsync_LoadsLeasesAndDocuments()
+ {
+ // Arrange
+ var property = new Property
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = _testOrgId,
+ Address = "100 Relation St",
+ City = "Test City",
+ State = "TS",
+ ZipCode = "12345",
+ PropertyType = "House",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Properties.Add(property);
+
+ var tenant = new Tenant
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = _testOrgId,
+ FirstName = "John",
+ LastName = "Doe",
+ Email = "john.doe@example.com",
+ PhoneNumber = "555-1234",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Tenants.Add(tenant);
+
+ var lease = new Lease
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = _testOrgId,
+ PropertyId = property.Id,
+ TenantId = tenant.Id,
+ StartDate = DateTime.Today,
+ EndDate = DateTime.Today.AddYears(1),
+ MonthlyRent = 1500m,
+ Status = ApplicationConstants.LeaseStatuses.Active,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Leases.Add(lease);
+
+ var document = new Document
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = _testOrgId,
+ PropertyId = property.Id,
+ FileName = "test.pdf",
+ FileType = "application/pdf",
+ FileSize = 1024,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Documents.Add(document);
+
+ await _context.SaveChangesAsync();
+
+ // Act
+ var result = await _service.GetPropertyWithRelationsAsync(property.Id);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.NotNull(result.Leases);
+ Assert.NotNull(result.Documents);
+ Assert.Single(result.Leases);
+ Assert.Single(result.Documents);
+ }
+
+ #endregion
+
+ #region SearchPropertiesByAddressAsync Tests
+
+ [Fact]
+ public async Task SearchPropertiesByAddressAsync_EmptySearchTerm_ReturnsFirst20()
+ {
+ // Arrange - Create 25 properties
+ for (int i = 1; i <= 25; i++)
+ {
+ var property = new Property
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = _testOrgId,
+ Address = $"{i * 100} Test St",
+ City = "Test City",
+ State = "TS",
+ ZipCode = "12345",
+ PropertyType = "House",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Properties.Add(property);
+ }
+ await _context.SaveChangesAsync();
+
+ // Act
+ var result = await _service.SearchPropertiesByAddressAsync("");
+
+ // Assert
+ Assert.Equal(20, result.Count); // Should limit to 20
+ }
+
+ [Fact]
+ public async Task SearchPropertiesByAddressAsync_SearchByAddress_ReturnsMatches()
+ {
+ // Arrange
+ var properties = new[]
+ {
+ new Property { Id = Guid.NewGuid(), OrganizationId = _testOrgId, Address = "123 Main St", City = "City", State = "ST", ZipCode = "12345", PropertyType = "House", CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow },
+ new Property { Id = Guid.NewGuid(), OrganizationId = _testOrgId, Address = "456 Main Ave", City = "City", State = "ST", ZipCode = "12345", PropertyType = "Apartment", CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow },
+ new Property { Id = Guid.NewGuid(), OrganizationId = _testOrgId, Address = "789 Oak St", City = "City", State = "ST", ZipCode = "12345", PropertyType = "Condo", CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow }
+ };
+ _context.Properties.AddRange(properties);
+ await _context.SaveChangesAsync();
+
+ // Act
+ var result = await _service.SearchPropertiesByAddressAsync("Main");
+
+ // Assert
+ Assert.Equal(2, result.Count);
+ Assert.All(result, p => Assert.Contains("Main", p.Address));
+ }
+
+ [Fact]
+ public async Task SearchPropertiesByAddressAsync_SearchByCity_ReturnsMatches()
+ {
+ // Arrange
+ var properties = new[]
+ {
+ new Property { Id = Guid.NewGuid(), OrganizationId = _testOrgId, Address = "100 Test St", City = "Springfield", State = "ST", ZipCode = "12345", PropertyType = "House", CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow },
+ new Property { Id = Guid.NewGuid(), OrganizationId = _testOrgId, Address = "200 Test St", City = "Springfield", State = "ST", ZipCode = "12345", PropertyType = "Apartment", CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow },
+ new Property { Id = Guid.NewGuid(), OrganizationId = _testOrgId, Address = "300 Test St", City = "Shelbyville", State = "ST", ZipCode = "54321", PropertyType = "Condo", CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow }
+ };
+ _context.Properties.AddRange(properties);
+ await _context.SaveChangesAsync();
+
+ // Act
+ var result = await _service.SearchPropertiesByAddressAsync("Springfield");
+
+ // Assert
+ Assert.Equal(2, result.Count);
+ Assert.All(result, p => Assert.Equal("Springfield", p.City));
+ }
+
+ [Fact]
+ public async Task SearchPropertiesByAddressAsync_SearchByZipCode_ReturnsMatches()
+ {
+ // Arrange
+ var properties = new[]
+ {
+ new Property { Id = Guid.NewGuid(), OrganizationId = _testOrgId, Address = "100 Test St", City = "City", State = "ST", ZipCode = "90210", PropertyType = "House", CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow },
+ new Property { Id = Guid.NewGuid(), OrganizationId = _testOrgId, Address = "200 Test St", City = "City", State = "ST", ZipCode = "12345", PropertyType = "Apartment", CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow }
+ };
+ _context.Properties.AddRange(properties);
+ await _context.SaveChangesAsync();
+
+ // Act
+ var result = await _service.SearchPropertiesByAddressAsync("90210");
+
+ // Assert
+ Assert.Single(result);
+ Assert.Equal("90210", result[0].ZipCode);
+ }
+
+ #endregion
+
+ #region GetVacantPropertiesAsync Tests
+
+ [Fact]
+ public async Task GetVacantPropertiesAsync_ReturnsOnlyVacantProperties()
+ {
+ // Arrange
+ var vacantProperty = new Property
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = _testOrgId,
+ Address = "100 Vacant St",
+ City = "Test City",
+ State = "TS",
+ ZipCode = "12345",
+ PropertyType = "House",
+ IsAvailable = true,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ var occupiedProperty = new Property
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = _testOrgId,
+ Address = "200 Occupied St",
+ City = "Test City",
+ State = "TS",
+ ZipCode = "12345",
+ PropertyType = "House",
+ IsAvailable = true,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ _context.Properties.AddRange(vacantProperty, occupiedProperty);
+ await _context.SaveChangesAsync();
+
+ // Add active lease to occupied property
+ var tenant = new Tenant
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = _testOrgId,
+ FirstName = "John",
+ LastName = "Doe",
+ Email = "john@example.com",
+ PhoneNumber = "555-1234",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Tenants.Add(tenant);
+
+ var activeLease = new Lease
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = _testOrgId,
+ PropertyId = occupiedProperty.Id,
+ TenantId = tenant.Id,
+ StartDate = DateTime.Today.AddMonths(-1),
+ EndDate = DateTime.Today.AddMonths(11),
+ MonthlyRent = 1500m,
+ Status = ApplicationConstants.LeaseStatuses.Active,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Leases.Add(activeLease);
+ await _context.SaveChangesAsync();
+
+ // Act
+ var result = await _service.GetVacantPropertiesAsync();
+
+ // Assert
+ Assert.Single(result);
+ Assert.Equal(vacantProperty.Id, result[0].Id);
+ }
+
+ [Fact]
+ public async Task GetVacantPropertiesAsync_ExcludesUnavailableProperties()
+ {
+ // Arrange
+ var unavailableProperty = new Property
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = _testOrgId,
+ Address = "300 Unavailable St",
+ City = "Test City",
+ State = "TS",
+ ZipCode = "12345",
+ PropertyType = "House",
+ IsAvailable = false, // Not available
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Properties.Add(unavailableProperty);
+ await _context.SaveChangesAsync();
+
+ // Act
+ var result = await _service.GetVacantPropertiesAsync();
+
+ // Assert
+ Assert.Empty(result);
+ }
+
+ #endregion
+
+ #region CalculateOccupancyRateAsync Tests
+
+ [Fact]
+ public async Task CalculateOccupancyRateAsync_NoProperties_ReturnsZero()
+ {
+ // Act
+ var result = await _service.CalculateOccupancyRateAsync();
+
+ // Assert
+ Assert.Equal(0, result);
+ }
+
+ [Fact]
+ public async Task CalculateOccupancyRateAsync_CalculatesCorrectPercentage()
+ {
+ // Arrange - Create 4 available properties, 3 occupied
+ var properties = Enumerable.Range(1, 4).Select(i => new Property
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = _testOrgId,
+ Address = $"{i}00 Test St",
+ City = "Test City",
+ State = "TS",
+ ZipCode = "12345",
+ PropertyType = "House",
+ IsAvailable = true,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ }).ToArray();
+ _context.Properties.AddRange(properties);
+ await _context.SaveChangesAsync();
+
+ // Create tenant
+ var tenant = new Tenant
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = _testOrgId,
+ FirstName = "Jane",
+ LastName = "Smith",
+ Email = "jane@example.com",
+ PhoneNumber = "555-5678",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Tenants.Add(tenant);
+
+ // Add active leases to 3 properties
+ for (int i = 0; i < 3; i++)
+ {
+ var lease = new Lease
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = _testOrgId,
+ PropertyId = properties[i].Id,
+ TenantId = tenant.Id,
+ StartDate = DateTime.Today,
+ EndDate = DateTime.Today.AddYears(1),
+ MonthlyRent = 1500m,
+ Status = ApplicationConstants.LeaseStatuses.Active,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Leases.Add(lease);
+ }
+ await _context.SaveChangesAsync();
+
+ // Act
+ var result = await _service.CalculateOccupancyRateAsync();
+
+ // Assert
+ Assert.Equal(75m, result); // 3 out of 4 = 75%
+ }
+
+ #endregion
+
+ #region GetPropertiesDueForInspectionAsync Tests
+
+ [Fact]
+ public async Task GetPropertiesDueForInspectionAsync_ReturnsDueProperties()
+ {
+ // Arrange
+ var dueProperty = new Property
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = _testOrgId,
+ Address = "400 Due St",
+ City = "Test City",
+ State = "TS",
+ ZipCode = "12345",
+ PropertyType = "House",
+ NextRoutineInspectionDueDate = DateTime.Today.AddDays(5), // Due in 5 days
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ var notDueProperty = new Property
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = _testOrgId,
+ Address = "500 Not Due St",
+ City = "Test City",
+ State = "TS",
+ ZipCode = "12345",
+ PropertyType = "House",
+ NextRoutineInspectionDueDate = DateTime.Today.AddDays(30), // Not due yet
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ _context.Properties.AddRange(dueProperty, notDueProperty);
+ await _context.SaveChangesAsync();
+
+ // Act
+ var result = await _service.GetPropertiesDueForInspectionAsync(7);
+
+ // Assert
+ Assert.Single(result);
+ Assert.Equal(dueProperty.Id, result[0].Id);
+ }
+
+ [Fact]
+ public async Task GetPropertiesDueForInspectionAsync_OrdersByDueDate()
+ {
+ // Arrange
+ var properties = new[]
+ {
+ new Property { Id = Guid.NewGuid(), OrganizationId = _testOrgId, Address = "600 Test St", City = "City", State = "ST", ZipCode = "12345", PropertyType = "House", NextRoutineInspectionDueDate = DateTime.Today.AddDays(5), CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow },
+ new Property { Id = Guid.NewGuid(), OrganizationId = _testOrgId, Address = "700 Test St", City = "City", State = "ST", ZipCode = "12345", PropertyType = "House", NextRoutineInspectionDueDate = DateTime.Today.AddDays(2), CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow },
+ new Property { Id = Guid.NewGuid(), OrganizationId = _testOrgId, Address = "800 Test St", City = "City", State = "ST", ZipCode = "12345", PropertyType = "House", NextRoutineInspectionDueDate = DateTime.Today.AddDays(7), CreatedBy = _testUserId, CreatedOn = DateTime.UtcNow }
+ };
+ _context.Properties.AddRange(properties);
+ await _context.SaveChangesAsync();
+
+ // Act
+ var result = await _service.GetPropertiesDueForInspectionAsync(10);
+
+ // Assert
+ Assert.Equal(3, result.Count);
+ Assert.Equal("700 Test St", result[0].Address); // Due in 2 days (first)
+ Assert.Equal("600 Test St", result[1].Address); // Due in 5 days (second)
+ Assert.Equal("800 Test St", result[2].Address); // Due in 7 days (third)
+ }
+
+ #endregion
+ }
+}
diff --git a/Aquiis.SimpleStart.Tests/TenantServiceTests.cs b/Aquiis.SimpleStart.Tests/TenantServiceTests.cs
new file mode 100644
index 0000000..1c9293b
--- /dev/null
+++ b/Aquiis.SimpleStart.Tests/TenantServiceTests.cs
@@ -0,0 +1,865 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Linq;
+using System.Security.Claims;
+using System.Threading.Tasks;
+using Aquiis.SimpleStart.Application.Services;
+using Aquiis.SimpleStart.Core.Constants;
+using Aquiis.SimpleStart.Core.Entities;
+using Aquiis.SimpleStart.Infrastructure.Data;
+using Aquiis.SimpleStart.Shared.Components.Account;
+using Aquiis.SimpleStart.Shared.Services;
+using Microsoft.AspNetCore.Components.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Moq;
+using Xunit;
+
+namespace Aquiis.SimpleStart.Tests
+{
+ ///
+ /// Comprehensive unit tests for TenantService.
+ /// Tests business logic, validation, search, and relationships.
+ ///
+ public class TenantServiceTests : IDisposable
+ {
+ private readonly SqliteConnection _connection;
+ private readonly ApplicationDbContext _context;
+ private readonly UserContextService _userContext;
+ private readonly Mock> _mockLogger;
+ private readonly IOptions _mockSettings;
+ private readonly TenantService _service;
+ private readonly Guid _testOrgId = Guid.NewGuid();
+ private readonly string _testUserId = Guid.NewGuid().ToString();
+
+ public TenantServiceTests()
+ {
+ // Setup SQLite in-memory database
+ _connection = new SqliteConnection("DataSource=:memory:");
+ _connection.Open();
+
+ var options = new DbContextOptionsBuilder()
+ .UseSqlite(_connection)
+ .Options;
+
+ _context = new ApplicationDbContext(options);
+ _context.Database.EnsureCreated();
+
+ // Mock AuthenticationStateProvider with claims
+ var claims = new ClaimsPrincipal(new ClaimsIdentity(
+ new[] { new Claim(ClaimTypes.NameIdentifier, _testUserId) },
+ "TestAuth"));
+ var mockAuth = new Mock();
+ mockAuth.Setup(a => a.GetAuthenticationStateAsync())
+ .ReturnsAsync(new AuthenticationState(claims));
+
+ // Mock UserManager
+ var mockUserStore = new Mock>();
+ var mockUserManager = new Mock>(
+ mockUserStore.Object, null, null, null, null, null, null, null, null);
+
+ var appUser = new ApplicationUser
+ {
+ Id = _testUserId,
+ UserName = "testuser",
+ Email = "testuser@example.com",
+ ActiveOrganizationId = _testOrgId
+ };
+ mockUserManager.Setup(u => u.FindByIdAsync(It.IsAny()))
+ .ReturnsAsync(appUser);
+
+ var serviceProvider = new Mock();
+ _userContext = new UserContextService(mockAuth.Object, mockUserManager.Object, serviceProvider.Object);
+
+ // Create test user (required for Organization.OwnerId foreign key)
+ var user = new ApplicationUser
+ {
+ Id = _testUserId,
+ UserName = "testuser",
+ Email = "testuser@example.com",
+ ActiveOrganizationId = _testOrgId
+ };
+ _context.Users.Add(user);
+
+ // Create test organization
+ var organization = new Organization
+ {
+ Id = _testOrgId,
+ Name = "Test Organization",
+ OwnerId = _testUserId,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ _context.Organizations.Add(organization);
+ _context.SaveChanges();
+
+ // Setup logger and settings
+ _mockLogger = new Mock>();
+
+ _mockSettings = Options.Create(new ApplicationSettings
+ {
+ SoftDeleteEnabled = true
+ });
+
+ // Create service instance
+ _service = new TenantService(
+ _context,
+ _mockLogger.Object,
+ _userContext,
+ _mockSettings);
+ }
+
+ public void Dispose()
+ {
+ _context.Dispose();
+ _connection.Close();
+ _connection.Dispose();
+ }
+
+ #region Validation Tests
+
+ [Fact]
+ public async Task CreateAsync_ValidTenant_CreatesSuccessfully()
+ {
+ // Arrange
+ var tenant = new Tenant
+ {
+ FirstName = "John",
+ LastName = "Doe",
+ Email = "john.doe@example.com",
+ IdentificationNumber = "SSN123456789",
+ PhoneNumber = "555-1234"
+ };
+
+ // Act
+ var result = await _service.CreateAsync(tenant);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.NotEqual(Guid.Empty, result.Id);
+ Assert.Equal(_testOrgId, result.OrganizationId);
+ Assert.Equal(_testUserId, result.CreatedBy);
+ Assert.Equal("John", result.FirstName);
+ Assert.Equal("Doe", result.LastName);
+ Assert.Equal("john.doe@example.com", result.Email);
+ Assert.Equal("SSN123456789", result.IdentificationNumber);
+ }
+
+ [Fact]
+ public async Task CreateAsync_MissingEmail_ThrowsException()
+ {
+ // Arrange
+ var tenant = new Tenant
+ {
+ FirstName = "John",
+ LastName = "Doe",
+ Email = "", // Missing email
+ IdentificationNumber = "SSN123456789"
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _service.CreateAsync(tenant));
+ }
+
+ [Fact]
+ public async Task CreateAsync_MissingIdentificationNumber_ThrowsException()
+ {
+ // Arrange
+ var tenant = new Tenant
+ {
+ FirstName = "John",
+ LastName = "Doe",
+ Email = "john.doe@example.com",
+ IdentificationNumber = "" // Missing ID number
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _service.CreateAsync(tenant));
+ }
+
+ [Fact]
+ public async Task CreateAsync_DuplicateEmail_ThrowsException()
+ {
+ // Arrange
+ var tenant1 = new Tenant
+ {
+ FirstName = "John",
+ LastName = "Doe",
+ Email = "duplicate@example.com",
+ IdentificationNumber = "SSN111111111"
+ };
+ await _service.CreateAsync(tenant1);
+
+ var tenant2 = new Tenant
+ {
+ FirstName = "Jane",
+ LastName = "Smith",
+ Email = "duplicate@example.com", // Duplicate email
+ IdentificationNumber = "SSN222222222"
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _service.CreateAsync(tenant2));
+ }
+
+ [Fact]
+ public async Task CreateAsync_DuplicateIdentificationNumber_ThrowsException()
+ {
+ // Arrange
+ var tenant1 = new Tenant
+ {
+ FirstName = "John",
+ LastName = "Doe",
+ Email = "john@example.com",
+ IdentificationNumber = "SSN999999999"
+ };
+ await _service.CreateAsync(tenant1);
+
+ var tenant2 = new Tenant
+ {
+ FirstName = "Jane",
+ LastName = "Smith",
+ Email = "jane@example.com",
+ IdentificationNumber = "SSN999999999" // Duplicate ID number
+ };
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => _service.CreateAsync(tenant2));
+ }
+
+ #endregion
+
+ #region Search Tests
+
+ [Fact]
+ public async Task SearchTenantsAsync_ByFirstName_ReturnsTenants()
+ {
+ // Arrange
+ await _service.CreateAsync(new Tenant
+ {
+ FirstName = "Alice",
+ LastName = "Johnson",
+ Email = "alice@example.com",
+ IdentificationNumber = "SSN001"
+ });
+ await _service.CreateAsync(new Tenant
+ {
+ FirstName = "Bob",
+ LastName = "Smith",
+ Email = "bob@example.com",
+ IdentificationNumber = "SSN002"
+ });
+
+ // Act
+ var result = await _service.SearchTenantsAsync("Alice");
+
+ // Assert
+ Assert.Single(result);
+ Assert.Equal("Alice", result[0].FirstName);
+ }
+
+ [Fact]
+ public async Task SearchTenantsAsync_ByLastName_ReturnsTenants()
+ {
+ // Arrange
+ await _service.CreateAsync(new Tenant
+ {
+ FirstName = "John",
+ LastName = "Williams",
+ Email = "john.w@example.com",
+ IdentificationNumber = "SSN003"
+ });
+ await _service.CreateAsync(new Tenant
+ {
+ FirstName = "Jane",
+ LastName = "Williams",
+ Email = "jane.w@example.com",
+ IdentificationNumber = "SSN004"
+ });
+
+ // Act
+ var result = await _service.SearchTenantsAsync("Williams");
+
+ // Assert
+ Assert.Equal(2, result.Count);
+ Assert.All(result, t => Assert.Equal("Williams", t.LastName));
+ }
+
+ [Fact]
+ public async Task SearchTenantsAsync_ByEmail_ReturnsTenant()
+ {
+ // Arrange
+ await _service.CreateAsync(new Tenant
+ {
+ FirstName = "Charlie",
+ LastName = "Brown",
+ Email = "charlie.unique@example.com",
+ IdentificationNumber = "SSN005"
+ });
+
+ // Act
+ var result = await _service.SearchTenantsAsync("charlie.unique");
+
+ // Assert
+ Assert.Single(result);
+ Assert.Equal("charlie.unique@example.com", result[0].Email);
+ }
+
+ [Fact]
+ public async Task SearchTenantsAsync_ByIdentificationNumber_ReturnsTenant()
+ {
+ // Arrange
+ await _service.CreateAsync(new Tenant
+ {
+ FirstName = "David",
+ LastName = "Miller",
+ Email = "david@example.com",
+ IdentificationNumber = "SSN777777777"
+ });
+
+ // Act
+ var result = await _service.SearchTenantsAsync("SSN777777777");
+
+ // Assert
+ Assert.Single(result);
+ Assert.Equal("SSN777777777", result[0].IdentificationNumber);
+ }
+
+ [Fact]
+ public async Task SearchTenantsAsync_EmptySearchTerm_ReturnsFirst20()
+ {
+ // Arrange - Create 25 tenants
+ for (int i = 1; i <= 25; i++)
+ {
+ await _service.CreateAsync(new Tenant
+ {
+ FirstName = $"Tenant{i}",
+ LastName = $"Test{i}",
+ Email = $"tenant{i}@example.com",
+ IdentificationNumber = $"SSN{i:D9}"
+ });
+ }
+
+ // Act
+ var result = await _service.SearchTenantsAsync("");
+
+ // Assert
+ Assert.Equal(20, result.Count); // Should limit to 20
+ }
+
+ [Fact]
+ public async Task SearchTenantsAsync_NoMatch_ReturnsEmpty()
+ {
+ // Arrange
+ await _service.CreateAsync(new Tenant
+ {
+ FirstName = "Eve",
+ LastName = "Anderson",
+ Email = "eve@example.com",
+ IdentificationNumber = "SSN006"
+ });
+
+ // Act
+ var result = await _service.SearchTenantsAsync("NonExistentName");
+
+ // Assert
+ Assert.Empty(result);
+ }
+
+ #endregion
+
+ #region Lookup Tests
+
+ [Fact]
+ public async Task GetTenantByEmailAsync_ExistingEmail_ReturnsTenant()
+ {
+ // Arrange
+ var tenant = await _service.CreateAsync(new Tenant
+ {
+ FirstName = "Frank",
+ LastName = "Garcia",
+ Email = "frank.garcia@example.com",
+ IdentificationNumber = "SSN007"
+ });
+
+ // Act
+ var result = await _service.GetTenantByEmailAsync("frank.garcia@example.com");
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Equal(tenant.Id, result.Id);
+ Assert.Equal("frank.garcia@example.com", result.Email);
+ }
+
+ [Fact]
+ public async Task GetTenantByEmailAsync_NonExistentEmail_ReturnsNull()
+ {
+ // Act
+ var result = await _service.GetTenantByEmailAsync("nonexistent@example.com");
+
+ // Assert
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public async Task GetTenantByIdentificationNumberAsync_ExistingNumber_ReturnsTenant()
+ {
+ // Arrange
+ var tenant = await _service.CreateAsync(new Tenant
+ {
+ FirstName = "Grace",
+ LastName = "Martinez",
+ Email = "grace@example.com",
+ IdentificationNumber = "SSN888888888"
+ });
+
+ // Act
+ var result = await _service.GetTenantByIdentificationNumberAsync("SSN888888888");
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Equal(tenant.Id, result.Id);
+ Assert.Equal("SSN888888888", result.IdentificationNumber);
+ }
+
+ [Fact]
+ public async Task GetTenantByIdentificationNumberAsync_NonExistentNumber_ReturnsNull()
+ {
+ // Act
+ var result = await _service.GetTenantByIdentificationNumberAsync("NONEXISTENT");
+
+ // Assert
+ Assert.Null(result);
+ }
+
+ #endregion
+
+ #region Active Tenant Tests
+
+ [Fact]
+ public async Task GetActiveTenantsAsync_ReturnsOnlyActiveTenants()
+ {
+ // Arrange
+ var activeTenant = await _service.CreateAsync(new Tenant
+ {
+ FirstName = "Active",
+ LastName = "Tenant",
+ Email = "active@example.com",
+ IdentificationNumber = "SSN010",
+ IsActive = true
+ });
+
+ var inactiveTenant = await _service.CreateAsync(new Tenant
+ {
+ FirstName = "Inactive",
+ LastName = "Tenant",
+ Email = "inactive@example.com",
+ IdentificationNumber = "SSN011",
+ IsActive = false
+ });
+
+ // Act
+ var result = await _service.GetActiveTenantsAsync();
+
+ // Assert
+ Assert.Single(result);
+ Assert.Equal(activeTenant.Id, result[0].Id);
+ Assert.True(result[0].IsActive);
+ }
+
+ #endregion
+
+ #region Tenant with Active Leases Tests
+
+ [Fact]
+ public async Task GetTenantsWithActiveLeasesAsync_ReturnsOnlyTenantsWithActiveLeases()
+ {
+ // Arrange
+ var property = new Property
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = _testOrgId,
+ Address = "123 Main St",
+ City = "Test City",
+ State = "TS",
+ ZipCode = "12345",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Properties.AddAsync(property);
+
+ var tenantWithActiveLease = await _service.CreateAsync(new Tenant
+ {
+ FirstName = "With",
+ LastName = "ActiveLease",
+ Email = "withlease@example.com",
+ IdentificationNumber = "SSN012"
+ });
+
+ var tenantWithoutLease = await _service.CreateAsync(new Tenant
+ {
+ FirstName = "Without",
+ LastName = "Lease",
+ Email = "withoutlease@example.com",
+ IdentificationNumber = "SSN013"
+ });
+
+ var activeLease = new Lease
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = _testOrgId,
+ PropertyId = property.Id,
+ TenantId = tenantWithActiveLease.Id,
+ Status = ApplicationConstants.LeaseStatuses.Active,
+ StartDate = DateTime.UtcNow,
+ EndDate = DateTime.UtcNow.AddMonths(12),
+ MonthlyRent = 1000,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Leases.AddAsync(activeLease);
+ await _context.SaveChangesAsync();
+
+ // Act
+ var result = await _service.GetTenantsWithActiveLeasesAsync();
+
+ // Assert
+ Assert.Single(result);
+ Assert.Equal(tenantWithActiveLease.Id, result[0].Id);
+ }
+
+ #endregion
+
+ #region Relationship Tests
+
+ [Fact]
+ public async Task GetTenantWithRelationsAsync_LoadsLeases()
+ {
+ // Arrange
+ var property = new Property
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = _testOrgId,
+ Address = "456 Oak Ave",
+ City = "Test City",
+ State = "TS",
+ ZipCode = "12345",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Properties.AddAsync(property);
+
+ var tenant = await _service.CreateAsync(new Tenant
+ {
+ FirstName = "Henry",
+ LastName = "Wilson",
+ Email = "henry@example.com",
+ IdentificationNumber = "SSN014"
+ });
+
+ var lease = new Lease
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = _testOrgId,
+ PropertyId = property.Id,
+ TenantId = tenant.Id,
+ Status = ApplicationConstants.LeaseStatuses.Active,
+ StartDate = DateTime.UtcNow,
+ EndDate = DateTime.UtcNow.AddMonths(6),
+ MonthlyRent = 1200,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Leases.AddAsync(lease);
+ await _context.SaveChangesAsync();
+
+ // Act
+ var result = await _service.GetTenantWithRelationsAsync(tenant.Id);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.NotNull(result.Leases);
+ Assert.Single(result.Leases);
+ Assert.Equal(lease.Id, result.Leases.First().Id);
+ }
+
+ [Fact]
+ public async Task GetTenantsByPropertyIdAsync_ReturnsTenantsForProperty()
+ {
+ // Arrange
+ var property = new Property
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = _testOrgId,
+ Address = "789 Pine Rd",
+ City = "Test City",
+ State = "TS",
+ ZipCode = "12345",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Properties.AddAsync(property);
+
+ var tenant1 = await _service.CreateAsync(new Tenant
+ {
+ FirstName = "Tenant",
+ LastName = "One",
+ Email = "tenant1@example.com",
+ IdentificationNumber = "SSN015"
+ });
+
+ var tenant2 = await _service.CreateAsync(new Tenant
+ {
+ FirstName = "Tenant",
+ LastName = "Two",
+ Email = "tenant2@example.com",
+ IdentificationNumber = "SSN016"
+ });
+
+ var lease1 = new Lease
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = _testOrgId,
+ PropertyId = property.Id,
+ TenantId = tenant1.Id,
+ Status = ApplicationConstants.LeaseStatuses.Active,
+ StartDate = DateTime.UtcNow,
+ EndDate = DateTime.UtcNow.AddMonths(12),
+ MonthlyRent = 1000,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Leases.AddAsync(lease1);
+ await _context.SaveChangesAsync();
+
+ // Act
+ var result = await _service.GetTenantsByPropertyIdAsync(property.Id);
+
+ // Assert
+ Assert.Single(result);
+ Assert.Equal(tenant1.Id, result[0].Id);
+ }
+
+ #endregion
+
+ #region Balance Calculation Tests
+
+ [Fact]
+ public async Task CalculateTenantBalanceAsync_CalculatesCorrectBalance()
+ {
+ // Arrange
+ var property = new Property
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = _testOrgId,
+ Address = "111 Balance St",
+ City = "Test City",
+ State = "TS",
+ ZipCode = "12345",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Properties.AddAsync(property);
+
+ var tenant = await _service.CreateAsync(new Tenant
+ {
+ FirstName = "Balance",
+ LastName = "Test",
+ Email = "balance@example.com",
+ IdentificationNumber = "SSN017"
+ });
+
+ var lease = new Lease
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = _testOrgId,
+ PropertyId = property.Id,
+ TenantId = tenant.Id,
+ Status = ApplicationConstants.LeaseStatuses.Active,
+ StartDate = DateTime.UtcNow,
+ EndDate = DateTime.UtcNow.AddMonths(12),
+ MonthlyRent = 1000,
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Leases.AddAsync(lease);
+
+ var invoice = new Invoice
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = _testOrgId,
+ LeaseId = lease.Id,
+ Amount = 1000,
+ AmountPaid = 0,
+ DueOn = DateTime.UtcNow,
+ Status = "Outstanding",
+ InvoiceNumber = "INV-001",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Invoices.AddAsync(invoice);
+
+ var payment = new Payment
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = _testOrgId,
+ InvoiceId = invoice.Id,
+ Amount = 600,
+ PaidOn = DateTime.UtcNow,
+ PaymentMethod = "Check",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Payments.AddAsync(payment);
+ await _context.SaveChangesAsync();
+
+ // Act
+ var balance = await _service.CalculateTenantBalanceAsync(tenant.Id);
+
+ // Assert
+ Assert.Equal(400, balance); // 1000 - 600 = 400
+ }
+
+ [Fact]
+ public async Task CalculateTenantBalanceAsync_NoInvoices_ReturnsZero()
+ {
+ // Arrange
+ var tenant = await _service.CreateAsync(new Tenant
+ {
+ FirstName = "Zero",
+ LastName = "Balance",
+ Email = "zero@example.com",
+ IdentificationNumber = "SSN018"
+ });
+
+ // Act
+ var balance = await _service.CalculateTenantBalanceAsync(tenant.Id);
+
+ // Assert
+ Assert.Equal(0, balance);
+ }
+
+ [Fact]
+ public async Task CalculateTenantBalanceAsync_NonExistentTenant_ThrowsException()
+ {
+ // Arrange
+ var nonExistentTenantId = Guid.NewGuid();
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() =>
+ _service.CalculateTenantBalanceAsync(nonExistentTenantId));
+ }
+
+ #endregion
+
+ #region Organization Isolation Tests
+
+ [Fact]
+ public async Task GetByIdAsync_DifferentOrganization_ReturnsNull()
+ {
+ // Arrange - Create different organization with different owner
+ var otherUserId = "other-user-456";
+ var otherUser = new ApplicationUser
+ {
+ Id = otherUserId,
+ UserName = "otheruser",
+ Email = "otheruser@example.com"
+ };
+ _context.Users.Add(otherUser);
+ await _context.SaveChangesAsync();
+
+ var otherOrg = new Organization
+ {
+ Id = Guid.NewGuid(),
+ Name = "Other Organization",
+ OwnerId = otherUserId,
+ CreatedBy = otherUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Organizations.AddAsync(otherOrg);
+
+ var otherOrgTenant = new Tenant
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = otherOrg.Id,
+ FirstName = "Other",
+ LastName = "Org",
+ Email = "other@example.com",
+ IdentificationNumber = "SSN999",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Tenants.AddAsync(otherOrgTenant);
+ await _context.SaveChangesAsync();
+
+ // Act
+ var result = await _service.GetByIdAsync(otherOrgTenant.Id);
+
+ // Assert
+ Assert.Null(result); // Should not access tenant from different org
+ }
+
+ [Fact]
+ public async Task GetAllAsync_ReturnsOnlyCurrentOrganizationTenants()
+ {
+ // Arrange - Create different organization with different owner
+ var otherUserId = "other-user-456";
+ var otherUser = new ApplicationUser
+ {
+ Id = otherUserId,
+ UserName = "otheruser",
+ Email = "otheruser@example.com"
+ };
+ _context.Users.Add(otherUser);
+ await _context.SaveChangesAsync();
+
+ var otherOrg = new Organization
+ {
+ Id = Guid.NewGuid(),
+ Name = "Other Organization",
+ OwnerId = otherUserId,
+ CreatedBy = otherUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Organizations.AddAsync(otherOrg);
+
+ // Create tenant in test org
+ await _service.CreateAsync(new Tenant
+ {
+ FirstName = "Test",
+ LastName = "Org",
+ Email = "testorg@example.com",
+ IdentificationNumber = "SSN020"
+ });
+
+ // Create tenant in other org
+ var otherOrgTenant = new Tenant
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = otherOrg.Id,
+ FirstName = "Other",
+ LastName = "Org",
+ Email = "otherorg@example.com",
+ IdentificationNumber = "SSN021",
+ CreatedBy = _testUserId,
+ CreatedOn = DateTime.UtcNow
+ };
+ await _context.Tenants.AddAsync(otherOrgTenant);
+ await _context.SaveChangesAsync();
+
+ // Act
+ var result = await _service.GetAllAsync();
+
+ // Assert
+ Assert.Single(result);
+ Assert.Equal(_testOrgId, result[0].OrganizationId);
+ }
+
+ #endregion
+ }
+}
diff --git a/Aquiis.Tests/.runsettings b/Aquiis.SimpleStart.UI.Tests/.runsettings
similarity index 100%
rename from Aquiis.Tests/.runsettings
rename to Aquiis.SimpleStart.UI.Tests/.runsettings
diff --git a/Aquiis.Tests/Aquiis.Tests.csproj b/Aquiis.SimpleStart.UI.Tests/Aquiis.SimpleStart.UI.Tests.csproj
similarity index 100%
rename from Aquiis.Tests/Aquiis.Tests.csproj
rename to Aquiis.SimpleStart.UI.Tests/Aquiis.SimpleStart.UI.Tests.csproj
diff --git a/Aquiis.Tests/AccountTests.cs b/Aquiis.SimpleStart.UI.Tests/NewSetupUITests.cs
similarity index 97%
rename from Aquiis.Tests/AccountTests.cs
rename to Aquiis.SimpleStart.UI.Tests/NewSetupUITests.cs
index 9c492ed..3fb8327 100644
--- a/Aquiis.Tests/AccountTests.cs
+++ b/Aquiis.SimpleStart.UI.Tests/NewSetupUITests.cs
@@ -1,15 +1,12 @@
using Microsoft.Playwright.NUnit;
using Microsoft.Playwright;
-using NUnit.Framework;
-using System.IO;
-using System.Threading.Tasks;
-namespace Aquiis.Tests;
+namespace Aquiis.SimpleStart.UI.Tests;
[Parallelizable(ParallelScope.Self)]
[TestFixture]
-public class AccountManagementTests : PageTest
+public class NewSetupUITests : PageTest
{
private const string BaseUrl = "http://localhost:5197";
@@ -65,9 +62,7 @@ public async Task CreateNewAccount()
await Page.GetByRole(AriaRole.Heading, new() { Name = "Property Management Dashboard", Exact= true }).ClickAsync();
- //Keep browser open for review/recording
- // if (KeepBrowserOpenSeconds > 0)
- // await Task.Delay(KeepBrowserOpenSeconds * 1000);
+
}
[Test, Order(2)]
@@ -221,9 +216,6 @@ public async Task ScheduleAndCompleteTour()
await Page.GetByText("Interested", new() { Exact = true }).ClickAsync();
- await Page.GetByRole(AriaRole.Textbox, new() { Name = "Enter value" }).Nth(1).ClickAsync();
- await Page.GetByRole(AriaRole.Textbox, new() { Name = "Enter value" }).Nth(1).FillAsync("None");
-
await Page.GetByRole(AriaRole.Button, new() { Name = " Save Progress" }).ClickAsync();
await Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
@@ -236,8 +228,6 @@ public async Task ScheduleAndCompleteTour()
await Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
- //await Task.Delay(5000); // Wait for PDF generation
-
var page1 = await Page.RunAndWaitForPopupAsync(async () =>
{
await Page.GetByRole(AriaRole.Button, new() { Name = " View PDF" }).ClickAsync();
diff --git a/Aquiis.Tests/README.md b/Aquiis.SimpleStart.UI.Tests/README.md
similarity index 100%
rename from Aquiis.Tests/README.md
rename to Aquiis.SimpleStart.UI.Tests/README.md
diff --git a/Aquiis.Tests/UnitTest1.cs.bak b/Aquiis.SimpleStart.UI.Tests/UnitTest1.cs.bak
similarity index 100%
rename from Aquiis.Tests/UnitTest1.cs.bak
rename to Aquiis.SimpleStart.UI.Tests/UnitTest1.cs.bak
diff --git a/Aquiis.Tests/run-tests-debug.sh b/Aquiis.SimpleStart.UI.Tests/run-tests-debug.sh
similarity index 100%
rename from Aquiis.Tests/run-tests-debug.sh
rename to Aquiis.SimpleStart.UI.Tests/run-tests-debug.sh
diff --git a/Aquiis.Tests/run-tests.sh b/Aquiis.SimpleStart.UI.Tests/run-tests.sh
similarity index 100%
rename from Aquiis.Tests/run-tests.sh
rename to Aquiis.SimpleStart.UI.Tests/run-tests.sh
diff --git a/Aquiis.SimpleStart/Application/Services/ApplicationService.cs b/Aquiis.SimpleStart/Application/Services/ApplicationService.cs
index e12ace7..cab5367 100644
--- a/Aquiis.SimpleStart/Application/Services/ApplicationService.cs
+++ b/Aquiis.SimpleStart/Application/Services/ApplicationService.cs
@@ -7,16 +7,19 @@ namespace Aquiis.SimpleStart.Application.Services
public class ApplicationService
{
private readonly ApplicationSettings _settings;
- private readonly PropertyManagementService _propertyManagementService;
+ private readonly PaymentService _paymentService;
+ private readonly LeaseService _leaseService;
public bool SoftDeleteEnabled { get; }
public ApplicationService(
IOptions settings,
- PropertyManagementService propertyManagementService)
+ PaymentService paymentService,
+ LeaseService leaseService)
{
_settings = settings.Value;
- _propertyManagementService = propertyManagementService;
+ _paymentService = paymentService;
+ _leaseService = leaseService;
SoftDeleteEnabled = _settings.SoftDeleteEnabled;
}
@@ -30,7 +33,7 @@ public string GetAppInfo()
///
public async Task GetDailyPaymentTotalAsync(DateTime date)
{
- var payments = await _propertyManagementService.GetPaymentsAsync();
+ var payments = await _paymentService.GetAllAsync();
return payments
.Where(p => p.PaidOn.Date == date.Date && !p.IsDeleted)
.Sum(p => p.Amount);
@@ -49,7 +52,7 @@ public async Task GetTodayPaymentTotalAsync()
///
public async Task GetPaymentTotalForRangeAsync(DateTime startDate, DateTime endDate)
{
- var payments = await _propertyManagementService.GetPaymentsAsync();
+ var payments = await _paymentService.GetAllAsync();
return payments
.Where(p => p.PaidOn.Date >= startDate.Date &&
p.PaidOn.Date <= endDate.Date &&
@@ -62,7 +65,7 @@ public async Task GetPaymentTotalForRangeAsync(DateTime startDate, Date
///
public async Task GetPaymentStatisticsAsync(DateTime startDate, DateTime endDate)
{
- var payments = await _propertyManagementService.GetPaymentsAsync();
+ var payments = await _paymentService.GetAllAsync();
var periodPayments = payments
.Where(p => p.PaidOn.Date >= startDate.Date &&
p.PaidOn.Date <= endDate.Date &&
@@ -87,7 +90,7 @@ public async Task GetPaymentStatisticsAsync(DateTime startDat
///
public async Task GetLeasesExpiringCountAsync(int daysAhead)
{
- var leases = await _propertyManagementService.GetLeasesAsync();
+ var leases = await _leaseService.GetAllAsync();
return leases
.Where(l => l.EndDate >= DateTime.Today &&
l.EndDate <= DateTime.Today.AddDays(daysAhead) &&
diff --git a/Aquiis.SimpleStart/Application/Services/CalendarEventService.cs b/Aquiis.SimpleStart/Application/Services/CalendarEventService.cs
index b5a92e9..432fd23 100644
--- a/Aquiis.SimpleStart/Application/Services/CalendarEventService.cs
+++ b/Aquiis.SimpleStart/Application/Services/CalendarEventService.cs
@@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Aquiis.SimpleStart.Infrastructure.Data;
using Aquiis.SimpleStart.Core.Entities;
+using Aquiis.SimpleStart.Core.Interfaces;
using Aquiis.SimpleStart.Shared.Services;
namespace Aquiis.SimpleStart.Application.Services
@@ -8,7 +9,7 @@ namespace Aquiis.SimpleStart.Application.Services
///
/// Service for managing calendar events and synchronizing with schedulable entities
///
- public class CalendarEventService
+ public class CalendarEventService : ICalendarEventService
{
private readonly ApplicationDbContext _context;
private readonly CalendarSettingsService _settingsService;
diff --git a/Aquiis.SimpleStart/Application/Services/DocumentService.cs b/Aquiis.SimpleStart/Application/Services/DocumentService.cs
new file mode 100644
index 0000000..351227d
--- /dev/null
+++ b/Aquiis.SimpleStart/Application/Services/DocumentService.cs
@@ -0,0 +1,432 @@
+using Aquiis.SimpleStart.Core.Constants;
+using Aquiis.SimpleStart.Core.Entities;
+using Aquiis.SimpleStart.Core.Services;
+using Aquiis.SimpleStart.Infrastructure.Data;
+using Aquiis.SimpleStart.Shared.Services;
+using Aquiis.SimpleStart.Application.Services.PdfGenerators;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using System.ComponentModel.DataAnnotations;
+
+namespace Aquiis.SimpleStart.Application.Services
+{
+ ///
+ /// Service for managing Document entities.
+ /// Inherits common CRUD operations from BaseService and adds document-specific business logic.
+ ///
+ public class DocumentService : BaseService
+ {
+ public DocumentService(
+ ApplicationDbContext context,
+ ILogger logger,
+ UserContextService userContext,
+ IOptions settings)
+ : base(context, logger, userContext, settings)
+ {
+ }
+
+ #region Overrides with Document-Specific Logic
+
+ ///
+ /// Validates a document entity before create/update operations.
+ ///
+ protected override async Task ValidateEntityAsync(Document entity)
+ {
+ var errors = new List();
+
+ // Required field validation
+ if (string.IsNullOrWhiteSpace(entity.FileName))
+ {
+ errors.Add("FileName is required");
+ }
+
+ if (string.IsNullOrWhiteSpace(entity.FileExtension))
+ {
+ errors.Add("FileExtension is required");
+ }
+
+ if (string.IsNullOrWhiteSpace(entity.DocumentType))
+ {
+ errors.Add("DocumentType is required");
+ }
+
+ if (entity.FileData == null || entity.FileData.Length == 0)
+ {
+ errors.Add("FileData is required");
+ }
+
+ // Business rule: At least one foreign key must be set
+ if (!entity.PropertyId.HasValue
+ && !entity.TenantId.HasValue
+ && !entity.LeaseId.HasValue
+ && !entity.InvoiceId.HasValue
+ && !entity.PaymentId.HasValue)
+ {
+ errors.Add("Document must be associated with at least one entity (Property, Tenant, Lease, Invoice, or Payment)");
+ }
+
+ // Validate file size (e.g., max 10MB)
+ const long maxFileSizeBytes = 10 * 1024 * 1024; // 10MB
+ if (entity.FileSize > maxFileSizeBytes)
+ {
+ errors.Add($"File size exceeds maximum allowed size of {maxFileSizeBytes / (1024 * 1024)}MB");
+ }
+
+ if (errors.Any())
+ {
+ throw new ValidationException(string.Join("; ", errors));
+ }
+
+ await base.ValidateEntityAsync(entity);
+ }
+
+ #endregion
+
+ #region Retrieval Methods
+
+ ///
+ /// Gets a document with all related entities.
+ ///
+ public async Task GetDocumentWithRelationsAsync(Guid documentId)
+ {
+ try
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ var document = await _context.Documents
+ .Include(d => d.Property)
+ .Include(d => d.Tenant)
+ .Include(d => d.Lease)
+ .ThenInclude(l => l!.Property)
+ .Include(d => d.Lease)
+ .ThenInclude(l => l!.Tenant)
+ .Include(d => d.Invoice)
+ .Include(d => d.Payment)
+ .Where(d => d.Id == documentId
+ && !d.IsDeleted
+ && d.OrganizationId == organizationId)
+ .FirstOrDefaultAsync();
+
+ return document;
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "GetDocumentWithRelations");
+ throw;
+ }
+ }
+
+ ///
+ /// Gets all documents with related entities.
+ ///
+ public async Task> GetDocumentsWithRelationsAsync()
+ {
+ try
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ return await _context.Documents
+ .Include(d => d.Property)
+ .Include(d => d.Tenant)
+ .Include(d => d.Lease)
+ .ThenInclude(l => l!.Property)
+ .Include(d => d.Lease)
+ .ThenInclude(l => l!.Tenant)
+ .Include(d => d.Invoice)
+ .Include(d => d.Payment)
+ .Where(d => !d.IsDeleted && d.OrganizationId == organizationId)
+ .OrderByDescending(d => d.CreatedOn)
+ .ToListAsync();
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "GetDocumentsWithRelations");
+ throw;
+ }
+ }
+
+ #endregion
+
+ #region Business Logic Methods
+
+ ///
+ /// Gets all documents for a specific property.
+ ///
+ public async Task> GetDocumentsByPropertyIdAsync(Guid propertyId)
+ {
+ try
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ return await _context.Documents
+ .Include(d => d.Property)
+ .Include(d => d.Tenant)
+ .Include(d => d.Lease)
+ .Where(d => d.PropertyId == propertyId
+ && !d.IsDeleted
+ && d.OrganizationId == organizationId)
+ .OrderByDescending(d => d.CreatedOn)
+ .ToListAsync();
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "GetDocumentsByPropertyId");
+ throw;
+ }
+ }
+
+ ///
+ /// Gets all documents for a specific tenant.
+ ///
+ public async Task> GetDocumentsByTenantIdAsync(Guid tenantId)
+ {
+ try
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ return await _context.Documents
+ .Include(d => d.Property)
+ .Include(d => d.Tenant)
+ .Include(d => d.Lease)
+ .Where(d => d.TenantId == tenantId
+ && !d.IsDeleted
+ && d.OrganizationId == organizationId)
+ .OrderByDescending(d => d.CreatedOn)
+ .ToListAsync();
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "GetDocumentsByTenantId");
+ throw;
+ }
+ }
+
+ ///
+ /// Gets all documents for a specific lease.
+ ///
+ public async Task> GetDocumentsByLeaseIdAsync(Guid leaseId)
+ {
+ try
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ return await _context.Documents
+ .Include(d => d.Lease)
+ .ThenInclude(l => l!.Property)
+ .Include(d => d.Lease)
+ .ThenInclude(l => l!.Tenant)
+ .Where(d => d.LeaseId == leaseId
+ && !d.IsDeleted
+ && d.OrganizationId == organizationId)
+ .OrderByDescending(d => d.CreatedOn)
+ .ToListAsync();
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "GetDocumentsByLeaseId");
+ throw;
+ }
+ }
+
+ ///
+ /// Gets all documents for a specific invoice.
+ ///
+ public async Task> GetDocumentsByInvoiceIdAsync(Guid invoiceId)
+ {
+ try
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ return await _context.Documents
+ .Include(d => d.Invoice)
+ .Where(d => d.InvoiceId == invoiceId
+ && !d.IsDeleted
+ && d.OrganizationId == organizationId)
+ .OrderByDescending(d => d.CreatedOn)
+ .ToListAsync();
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "GetDocumentsByInvoiceId");
+ throw;
+ }
+ }
+
+ ///
+ /// Gets all documents for a specific payment.
+ ///
+ public async Task> GetDocumentsByPaymentIdAsync(Guid paymentId)
+ {
+ try
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ return await _context.Documents
+ .Include(d => d.Payment)
+ .Where(d => d.PaymentId == paymentId
+ && !d.IsDeleted
+ && d.OrganizationId == organizationId)
+ .OrderByDescending(d => d.CreatedOn)
+ .ToListAsync();
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "GetDocumentsByPaymentId");
+ throw;
+ }
+ }
+
+ ///
+ /// Gets documents by document type (e.g., "Lease Agreement", "Invoice", "Receipt").
+ ///
+ public async Task> GetDocumentsByTypeAsync(string documentType)
+ {
+ try
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ return await _context.Documents
+ .Include(d => d.Property)
+ .Include(d => d.Tenant)
+ .Include(d => d.Lease)
+ .Where(d => d.DocumentType == documentType
+ && !d.IsDeleted
+ && d.OrganizationId == organizationId)
+ .OrderByDescending(d => d.CreatedOn)
+ .ToListAsync();
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "GetDocumentsByType");
+ throw;
+ }
+ }
+
+ ///
+ /// Searches documents by filename.
+ ///
+ public async Task> SearchDocumentsByFilenameAsync(string searchTerm, int maxResults = 20)
+ {
+ try
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ if (string.IsNullOrWhiteSpace(searchTerm))
+ {
+ // Return recent documents if no search term
+ return await _context.Documents
+ .Include(d => d.Property)
+ .Include(d => d.Tenant)
+ .Include(d => d.Lease)
+ .Where(d => !d.IsDeleted && d.OrganizationId == organizationId)
+ .OrderByDescending(d => d.CreatedOn)
+ .Take(maxResults)
+ .ToListAsync();
+ }
+
+ var searchLower = searchTerm.ToLower();
+
+ return await _context.Documents
+ .Include(d => d.Property)
+ .Include(d => d.Tenant)
+ .Include(d => d.Lease)
+ .Where(d => !d.IsDeleted
+ && d.OrganizationId == organizationId
+ && (d.FileName.ToLower().Contains(searchLower)
+ || d.Description.ToLower().Contains(searchLower)))
+ .OrderByDescending(d => d.CreatedOn)
+ .Take(maxResults)
+ .ToListAsync();
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "SearchDocumentsByFilename");
+ throw;
+ }
+ }
+
+ ///
+ /// Calculates total storage used by all documents in the organization (in bytes).
+ ///
+ public async Task CalculateTotalStorageUsedAsync()
+ {
+ try
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ return await _context.Documents
+ .Where(d => !d.IsDeleted && d.OrganizationId == organizationId)
+ .SumAsync(d => d.FileSize);
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "CalculateTotalStorageUsed");
+ throw;
+ }
+ }
+
+ ///
+ /// Gets documents uploaded within a specific date range.
+ ///
+ public async Task> GetDocumentsByDateRangeAsync(DateTime startDate, DateTime endDate)
+ {
+ try
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ return await _context.Documents
+ .Include(d => d.Property)
+ .Include(d => d.Tenant)
+ .Include(d => d.Lease)
+ .Where(d => !d.IsDeleted
+ && d.OrganizationId == organizationId
+ && d.CreatedOn >= startDate
+ && d.CreatedOn <= endDate)
+ .OrderByDescending(d => d.CreatedOn)
+ .ToListAsync();
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "GetDocumentsByDateRange");
+ throw;
+ }
+ }
+
+ ///
+ /// Gets document count by document type for reporting.
+ ///
+ public async Task> GetDocumentCountByTypeAsync()
+ {
+ try
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ return await _context.Documents
+ .Where(d => !d.IsDeleted && d.OrganizationId == organizationId)
+ .GroupBy(d => d.DocumentType)
+ .Select(g => new { Type = g.Key, Count = g.Count() })
+ .ToDictionaryAsync(x => x.Type, x => x.Count);
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "GetDocumentCountByType");
+ throw;
+ }
+ }
+
+ #endregion
+
+ #region PDF Generation Methods
+
+ ///
+ /// Generates a lease document PDF.
+ ///
+ public async Task GenerateLeaseDocumentAsync(Lease lease)
+ {
+ return await LeasePdfGenerator.GenerateLeasePdf(lease);
+ }
+
+ #endregion
+ }
+}
diff --git a/Aquiis.SimpleStart/Application/Services/InspectionService.cs b/Aquiis.SimpleStart/Application/Services/InspectionService.cs
new file mode 100644
index 0000000..c3f4270
--- /dev/null
+++ b/Aquiis.SimpleStart/Application/Services/InspectionService.cs
@@ -0,0 +1,276 @@
+using System.ComponentModel.DataAnnotations;
+using Aquiis.SimpleStart.Core.Constants;
+using Aquiis.SimpleStart.Core.Entities;
+using Aquiis.SimpleStart.Core.Interfaces;
+using Aquiis.SimpleStart.Core.Services;
+using Aquiis.SimpleStart.Infrastructure.Data;
+using Aquiis.SimpleStart.Shared.Services;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Options;
+
+namespace Aquiis.SimpleStart.Application.Services
+{
+ ///
+ /// Service for managing property inspections with business logic for scheduling,
+ /// tracking, and integration with calendar events.
+ ///
+ public class InspectionService : BaseService
+ {
+ private readonly ICalendarEventService _calendarEventService;
+
+ public InspectionService(
+ ApplicationDbContext context,
+ ILogger logger,
+ UserContextService userContext,
+ IOptions settings,
+ ICalendarEventService calendarEventService)
+ : base(context, logger, userContext, settings)
+ {
+ _calendarEventService = calendarEventService;
+ }
+
+ #region Helper Methods
+
+ protected async Task GetUserIdAsync()
+ {
+ var userId = await _userContext.GetUserIdAsync();
+ if (string.IsNullOrEmpty(userId))
+ {
+ throw new UnauthorizedAccessException("User is not authenticated.");
+ }
+ return userId;
+ }
+
+ protected async Task GetActiveOrganizationIdAsync()
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+ if (!organizationId.HasValue)
+ {
+ throw new UnauthorizedAccessException("No active organization.");
+ }
+ return organizationId.Value;
+ }
+
+ #endregion
+
+ ///
+ /// Validates inspection business rules.
+ ///
+ protected override async Task ValidateEntityAsync(Inspection entity)
+ {
+ var errors = new List();
+
+ // Required fields
+ if (entity.PropertyId == Guid.Empty)
+ {
+ errors.Add("Property is required");
+ }
+
+ if (string.IsNullOrWhiteSpace(entity.InspectionType))
+ {
+ errors.Add("Inspection type is required");
+ }
+
+ if (entity.CompletedOn == default)
+ {
+ errors.Add("Completion date is required");
+ }
+
+ if (errors.Any())
+ {
+ throw new InvalidOperationException(string.Join("; ", errors));
+ }
+
+ await Task.CompletedTask;
+ }
+
+ ///
+ /// Gets all inspections for the active organization.
+ ///
+ public override async Task> GetAllAsync()
+ {
+ var organizationId = await GetActiveOrganizationIdAsync();
+
+ return await _context.Inspections
+ .Include(i => i.Property)
+ .Include(i => i.Lease)
+ .ThenInclude(l => l!.Tenant)
+ .Where(i => !i.IsDeleted && i.OrganizationId == organizationId)
+ .OrderByDescending(i => i.CompletedOn)
+ .ToListAsync();
+ }
+
+ ///
+ /// Gets inspections by property ID.
+ ///
+ public async Task> GetByPropertyIdAsync(Guid propertyId)
+ {
+ var organizationId = await GetActiveOrganizationIdAsync();
+
+ return await _context.Inspections
+ .Include(i => i.Property)
+ .Include(i => i.Lease)
+ .ThenInclude(l => l!.Tenant)
+ .Where(i => i.PropertyId == propertyId && !i.IsDeleted && i.OrganizationId == organizationId)
+ .OrderByDescending(i => i.CompletedOn)
+ .ToListAsync();
+ }
+
+ ///
+ /// Gets a single inspection by ID with related data.
+ ///
+ public override async Task GetByIdAsync(Guid id)
+ {
+ var organizationId = await GetActiveOrganizationIdAsync();
+
+ return await _context.Inspections
+ .Include(i => i.Property)
+ .Include(i => i.Lease)
+ .ThenInclude(l => l!.Tenant)
+ .FirstOrDefaultAsync(i => i.Id == id && !i.IsDeleted && i.OrganizationId == organizationId);
+ }
+
+ ///
+ /// Creates a new inspection with calendar event integration.
+ ///
+ public override async Task CreateAsync(Inspection inspection)
+ {
+ // Base validation and creation
+ await ValidateEntityAsync(inspection);
+
+ var userId = await GetUserIdAsync();
+ var organizationId = await GetActiveOrganizationIdAsync();
+
+ inspection.Id = Guid.NewGuid();
+ inspection.OrganizationId = organizationId;
+ inspection.CreatedBy = userId;
+ inspection.CreatedOn = DateTime.UtcNow;
+
+ await _context.Inspections.AddAsync(inspection);
+ await _context.SaveChangesAsync();
+
+ // Create calendar event for the inspection
+ await _calendarEventService.CreateOrUpdateEventAsync(inspection);
+
+ // Update property inspection tracking if this is a routine inspection
+ if (inspection.InspectionType == ApplicationConstants.InspectionTypes.Routine)
+ {
+ await HandleRoutineInspectionCompletionAsync(inspection);
+ }
+
+ _logger.LogInformation("Created inspection {InspectionId} for property {PropertyId}",
+ inspection.Id, inspection.PropertyId);
+
+ return inspection;
+ }
+
+ ///
+ /// Updates an existing inspection.
+ ///
+ public override async Task UpdateAsync(Inspection inspection)
+ {
+ await ValidateEntityAsync(inspection);
+
+ var userId = await GetUserIdAsync();
+ var organizationId = await GetActiveOrganizationIdAsync();
+
+ // Security: Verify inspection belongs to active organization
+ var existing = await _context.Inspections
+ .FirstOrDefaultAsync(i => i.Id == inspection.Id && i.OrganizationId == organizationId);
+
+ if (existing == null)
+ {
+ throw new UnauthorizedAccessException($"Inspection {inspection.Id} not found in active organization.");
+ }
+
+ // Set tracking fields
+ inspection.LastModifiedBy = userId;
+ inspection.LastModifiedOn = DateTime.UtcNow;
+ inspection.OrganizationId = organizationId; // Prevent org hijacking
+
+ _context.Entry(existing).CurrentValues.SetValues(inspection);
+ await _context.SaveChangesAsync();
+
+ // Update calendar event
+ await _calendarEventService.CreateOrUpdateEventAsync(inspection);
+
+ // Update property inspection tracking if routine inspection date changed
+ if (inspection.InspectionType == ApplicationConstants.InspectionTypes.Routine)
+ {
+ await HandleRoutineInspectionCompletionAsync(inspection);
+ }
+
+ _logger.LogInformation("Updated inspection {InspectionId}", inspection.Id);
+
+ return inspection;
+ }
+
+ ///
+ /// Deletes an inspection (soft delete).
+ ///
+ public override async Task DeleteAsync(Guid id)
+ {
+ var userId = await GetUserIdAsync();
+ var organizationId = await GetActiveOrganizationIdAsync();
+
+ var inspection = await _context.Inspections
+ .FirstOrDefaultAsync(i => i.Id == id && i.OrganizationId == organizationId);
+
+ if (inspection == null)
+ {
+ throw new KeyNotFoundException($"Inspection {id} not found.");
+ }
+
+ inspection.IsDeleted = true;
+ inspection.LastModifiedBy = userId;
+ inspection.LastModifiedOn = DateTime.UtcNow;
+
+ await _context.SaveChangesAsync();
+
+ // TODO: Delete associated calendar event when interface method is available
+ // await _calendarEventService.DeleteEventBySourceAsync(id, nameof(Inspection));
+
+ _logger.LogInformation("Deleted inspection {InspectionId}", id);
+
+ return true;
+ }
+
+ ///
+ /// Handles routine inspection completion by updating property tracking and removing old calendar events.
+ ///
+ private async Task HandleRoutineInspectionCompletionAsync(Inspection inspection)
+ {
+ // Find and update/delete the original property-based routine inspection calendar event
+ var propertyBasedEvent = await _context.CalendarEvents
+ .FirstOrDefaultAsync(e =>
+ e.PropertyId == inspection.PropertyId &&
+ e.SourceEntityType == "Property" &&
+ e.EventType == CalendarEventTypes.Inspection &&
+ !e.IsDeleted);
+
+ if (propertyBasedEvent != null)
+ {
+ // Remove the old property-based event since we now have an actual inspection record
+ _context.CalendarEvents.Remove(propertyBasedEvent);
+ }
+
+ // Update property's routine inspection tracking
+ var property = await _context.Properties
+ .FirstOrDefaultAsync(p => p.Id == inspection.PropertyId);
+
+ if (property != null)
+ {
+ property.LastRoutineInspectionDate = inspection.CompletedOn;
+
+ // Calculate next routine inspection date based on interval
+ if (property.RoutineInspectionIntervalMonths > 0)
+ {
+ property.NextRoutineInspectionDueDate = inspection.CompletedOn
+ .AddMonths(property.RoutineInspectionIntervalMonths);
+ }
+
+ await _context.SaveChangesAsync();
+ }
+ }
+ }
+}
diff --git a/Aquiis.SimpleStart/Application/Services/InvoiceService.cs b/Aquiis.SimpleStart/Application/Services/InvoiceService.cs
new file mode 100644
index 0000000..19811b6
--- /dev/null
+++ b/Aquiis.SimpleStart/Application/Services/InvoiceService.cs
@@ -0,0 +1,465 @@
+using Aquiis.SimpleStart.Core.Constants;
+using Aquiis.SimpleStart.Core.Entities;
+using Aquiis.SimpleStart.Core.Services;
+using Aquiis.SimpleStart.Infrastructure.Data;
+using Aquiis.SimpleStart.Shared.Services;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using System.ComponentModel.DataAnnotations;
+
+namespace Aquiis.SimpleStart.Application.Services
+{
+ ///
+ /// Service for managing Invoice entities.
+ /// Inherits common CRUD operations from BaseService and adds invoice-specific business logic.
+ ///
+ public class InvoiceService : BaseService
+ {
+ public InvoiceService(
+ ApplicationDbContext context,
+ ILogger logger,
+ UserContextService userContext,
+ IOptions settings)
+ : base(context, logger, userContext, settings)
+ {
+ }
+
+ ///
+ /// Validates an invoice before create/update operations.
+ ///
+ protected override async Task ValidateEntityAsync(Invoice entity)
+ {
+ var errors = new List();
+
+ // Required fields
+ if (entity.LeaseId == Guid.Empty)
+ {
+ errors.Add("Lease ID is required.");
+ }
+
+ if (string.IsNullOrWhiteSpace(entity.InvoiceNumber))
+ {
+ errors.Add("Invoice number is required.");
+ }
+
+ if (string.IsNullOrWhiteSpace(entity.Description))
+ {
+ errors.Add("Description is required.");
+ }
+
+ if (entity.Amount <= 0)
+ {
+ errors.Add("Amount must be greater than zero.");
+ }
+
+ if (entity.DueOn < entity.InvoicedOn)
+ {
+ errors.Add("Due date cannot be before invoice date.");
+ }
+
+ // Validate lease exists and belongs to organization
+ if (entity.LeaseId != Guid.Empty)
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+ var lease = await _context.Leases
+ .Include(l => l.Property)
+ .FirstOrDefaultAsync(l => l.Id == entity.LeaseId && !l.IsDeleted);
+
+ if (lease == null)
+ {
+ errors.Add($"Lease with ID {entity.LeaseId} does not exist.");
+ }
+ else if (lease.Property.OrganizationId != organizationId)
+ {
+ errors.Add("Lease does not belong to the current organization.");
+ }
+ }
+
+ // Check for duplicate invoice number in same organization
+ if (!string.IsNullOrWhiteSpace(entity.InvoiceNumber))
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+ var duplicate = await _context.Invoices
+ .AnyAsync(i => i.InvoiceNumber == entity.InvoiceNumber
+ && i.OrganizationId == organizationId
+ && i.Id != entity.Id
+ && !i.IsDeleted);
+
+ if (duplicate)
+ {
+ errors.Add($"Invoice number '{entity.InvoiceNumber}' already exists.");
+ }
+ }
+
+ // Validate status
+ var validStatuses = new[] { "Pending", "Paid", "Overdue", "Cancelled" };
+ if (!string.IsNullOrWhiteSpace(entity.Status) && !validStatuses.Contains(entity.Status))
+ {
+ errors.Add($"Status must be one of: {string.Join(", ", validStatuses)}");
+ }
+
+ // Validate amount paid doesn't exceed amount
+ if (entity.AmountPaid > entity.Amount + (entity.LateFeeAmount ?? 0))
+ {
+ errors.Add("Amount paid cannot exceed invoice amount plus late fees.");
+ }
+
+ if (errors.Any())
+ {
+ throw new ValidationException(string.Join(" ", errors));
+ }
+ }
+
+ ///
+ /// Gets all invoices for a specific lease.
+ ///
+ public async Task> GetInvoicesByLeaseIdAsync(Guid leaseId)
+ {
+ try
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ return await _context.Invoices
+ .Include(i => i.Lease)
+ .ThenInclude(l => l.Property)
+ .Include(i => i.Lease)
+ .ThenInclude(l => l.Tenant)
+ .Include(i => i.Payments)
+ .Where(i => i.LeaseId == leaseId
+ && !i.IsDeleted
+ && i.OrganizationId == organizationId)
+ .OrderByDescending(i => i.DueOn)
+ .ToListAsync();
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "GetInvoicesByLeaseId");
+ throw;
+ }
+ }
+
+ ///
+ /// Gets all invoices with a specific status.
+ ///
+ public async Task> GetInvoicesByStatusAsync(string status)
+ {
+ try
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ return await _context.Invoices
+ .Include(i => i.Lease)
+ .ThenInclude(l => l.Property)
+ .Include(i => i.Lease)
+ .ThenInclude(l => l.Tenant)
+ .Include(i => i.Payments)
+ .Where(i => i.Status == status
+ && !i.IsDeleted
+ && i.OrganizationId == organizationId)
+ .OrderByDescending(i => i.DueOn)
+ .ToListAsync();
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "GetInvoicesByStatus");
+ throw;
+ }
+ }
+
+ ///
+ /// Gets all overdue invoices (due date passed and not paid).
+ ///
+ public async Task> GetOverdueInvoicesAsync()
+ {
+ try
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+ var today = DateTime.Today;
+
+ return await _context.Invoices
+ .Include(i => i.Lease)
+ .ThenInclude(l => l.Property)
+ .Include(i => i.Lease)
+ .ThenInclude(l => l.Tenant)
+ .Include(i => i.Payments)
+ .Where(i => i.Status != "Paid"
+ && i.Status != "Cancelled"
+ && i.DueOn < today
+ && !i.IsDeleted
+ && i.OrganizationId == organizationId)
+ .OrderBy(i => i.DueOn)
+ .ToListAsync();
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "GetOverdueInvoices");
+ throw;
+ }
+ }
+
+ ///
+ /// Gets invoices due within the specified number of days.
+ ///
+ public async Task> GetInvoicesDueSoonAsync(int daysThreshold = 7)
+ {
+ try
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+ var today = DateTime.Today;
+ var thresholdDate = today.AddDays(daysThreshold);
+
+ return await _context.Invoices
+ .Include(i => i.Lease)
+ .ThenInclude(l => l.Property)
+ .Include(i => i.Lease)
+ .ThenInclude(l => l.Tenant)
+ .Include(i => i.Payments)
+ .Where(i => i.Status == "Pending"
+ && i.DueOn >= today
+ && i.DueOn <= thresholdDate
+ && !i.IsDeleted
+ && i.OrganizationId == organizationId)
+ .OrderBy(i => i.DueOn)
+ .ToListAsync();
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "GetInvoicesDueSoon");
+ throw;
+ }
+ }
+
+ ///
+ /// Gets an invoice with all related entities loaded.
+ ///
+ public async Task GetInvoiceWithRelationsAsync(Guid invoiceId)
+ {
+ try
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ return await _context.Invoices
+ .Include(i => i.Lease)
+ .ThenInclude(l => l.Property)
+ .Include(i => i.Lease)
+ .ThenInclude(l => l.Tenant)
+ .Include(i => i.Payments)
+ .Include(i => i.Document)
+ .FirstOrDefaultAsync(i => i.Id == invoiceId
+ && !i.IsDeleted
+ && i.OrganizationId == organizationId);
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "GetInvoiceWithRelations");
+ throw;
+ }
+ }
+
+ ///
+ /// Generates a unique invoice number for the organization.
+ /// Format: INV-YYYYMM-00001
+ ///
+ public async Task GenerateInvoiceNumberAsync()
+ {
+ try
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+ var invoiceCount = await _context.Invoices
+ .Where(i => i.OrganizationId == organizationId)
+ .CountAsync();
+
+ var nextNumber = invoiceCount + 1;
+ return $"INV-{DateTime.Now:yyyyMM}-{nextNumber:D5}";
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "GenerateInvoiceNumber");
+ throw;
+ }
+ }
+
+ ///
+ /// Applies a late fee to an overdue invoice.
+ ///
+ public async Task ApplyLateFeeAsync(Guid invoiceId, decimal lateFeeAmount)
+ {
+ try
+ {
+ var invoice = await GetByIdAsync(invoiceId);
+ if (invoice == null)
+ {
+ throw new InvalidOperationException($"Invoice {invoiceId} not found.");
+ }
+
+ if (invoice.Status == "Paid" || invoice.Status == "Cancelled")
+ {
+ throw new InvalidOperationException("Cannot apply late fee to paid or cancelled invoice.");
+ }
+
+ if (invoice.LateFeeApplied == true)
+ {
+ throw new InvalidOperationException("Late fee has already been applied to this invoice.");
+ }
+
+ if (lateFeeAmount <= 0)
+ {
+ throw new ArgumentException("Late fee amount must be greater than zero.");
+ }
+
+ invoice.LateFeeAmount = lateFeeAmount;
+ invoice.LateFeeApplied = true;
+ invoice.LateFeeAppliedOn = DateTime.UtcNow;
+
+ // Update status to overdue if not already
+ if (invoice.Status == "Pending")
+ {
+ invoice.Status = "Overdue";
+ }
+
+ await UpdateAsync(invoice);
+
+ return invoice;
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "ApplyLateFee");
+ throw;
+ }
+ }
+
+ ///
+ /// Marks a reminder as sent for an invoice.
+ ///
+ public async Task MarkReminderSentAsync(Guid invoiceId)
+ {
+ try
+ {
+ var invoice = await GetByIdAsync(invoiceId);
+ if (invoice == null)
+ {
+ throw new InvalidOperationException($"Invoice {invoiceId} not found.");
+ }
+
+ invoice.ReminderSent = true;
+ invoice.ReminderSentOn = DateTime.UtcNow;
+
+ await UpdateAsync(invoice);
+
+ return invoice;
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "MarkReminderSent");
+ throw;
+ }
+ }
+
+ ///
+ /// Updates the invoice status based on payments received.
+ ///
+ public async Task UpdateInvoiceStatusAsync(Guid invoiceId)
+ {
+ try
+ {
+ var invoice = await GetInvoiceWithRelationsAsync(invoiceId);
+ if (invoice == null)
+ {
+ throw new InvalidOperationException($"Invoice {invoiceId} not found.");
+ }
+
+ // Calculate total amount due (including late fees)
+ var totalDue = invoice.Amount + (invoice.LateFeeAmount ?? 0);
+ var totalPaid = invoice.Payments.Where(p => !p.IsDeleted).Sum(p => p.Amount);
+
+ invoice.AmountPaid = totalPaid;
+
+ // Update status
+ if (totalPaid >= totalDue)
+ {
+ invoice.Status = "Paid";
+ invoice.PaidOn = invoice.Payments
+ .Where(p => !p.IsDeleted)
+ .OrderByDescending(p => p.PaidOn)
+ .FirstOrDefault()?.PaidOn ?? DateTime.UtcNow;
+ }
+ else if (invoice.Status == "Cancelled")
+ {
+ // Don't change cancelled status
+ }
+ else if (invoice.DueOn < DateTime.Today)
+ {
+ invoice.Status = "Overdue";
+ }
+ else
+ {
+ invoice.Status = "Pending";
+ }
+
+ await UpdateAsync(invoice);
+
+ return invoice;
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "UpdateInvoiceStatus");
+ throw;
+ }
+ }
+
+ ///
+ /// Calculates the total outstanding balance across all unpaid invoices.
+ ///
+ public async Task CalculateTotalOutstandingAsync()
+ {
+ try
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ var total = await _context.Invoices
+ .Where(i => i.Status != "Paid"
+ && i.Status != "Cancelled"
+ && !i.IsDeleted
+ && i.OrganizationId == organizationId)
+ .SumAsync(i => (i.Amount + (i.LateFeeAmount ?? 0)) - i.AmountPaid);
+
+ return total;
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "CalculateTotalOutstanding");
+ throw;
+ }
+ }
+
+ ///
+ /// Gets invoices within a specific date range.
+ ///
+ public async Task> GetInvoicesByDateRangeAsync(DateTime startDate, DateTime endDate)
+ {
+ try
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ return await _context.Invoices
+ .Include(i => i.Lease)
+ .ThenInclude(l => l.Property)
+ .Include(i => i.Lease)
+ .ThenInclude(l => l.Tenant)
+ .Include(i => i.Payments)
+ .Where(i => i.InvoicedOn >= startDate
+ && i.InvoicedOn <= endDate
+ && !i.IsDeleted
+ && i.OrganizationId == organizationId)
+ .OrderByDescending(i => i.InvoicedOn)
+ .ToListAsync();
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "GetInvoicesByDateRange");
+ throw;
+ }
+ }
+ }
+}
diff --git a/Aquiis.SimpleStart/Application/Services/LeaseOfferService.cs b/Aquiis.SimpleStart/Application/Services/LeaseOfferService.cs
new file mode 100644
index 0000000..6ad41ff
--- /dev/null
+++ b/Aquiis.SimpleStart/Application/Services/LeaseOfferService.cs
@@ -0,0 +1,294 @@
+using System.ComponentModel.DataAnnotations;
+using Aquiis.SimpleStart.Core.Constants;
+using Aquiis.SimpleStart.Core.Entities;
+using Aquiis.SimpleStart.Core.Services;
+using Aquiis.SimpleStart.Infrastructure.Data;
+using Aquiis.SimpleStart.Shared.Services;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Options;
+
+namespace Aquiis.SimpleStart.Application.Services
+{
+ ///
+ /// Service for managing LeaseOffer entities.
+ /// Inherits common CRUD operations from BaseService and adds lease offer-specific business logic.
+ ///
+ public class LeaseOfferService : BaseService
+ {
+ public LeaseOfferService(
+ ApplicationDbContext context,
+ ILogger logger,
+ UserContextService userContext,
+ IOptions settings)
+ : base(context, logger, userContext, settings)
+ {
+ }
+
+ #region Overrides with LeaseOffer-Specific Logic
+
+ ///
+ /// Validates a lease offer entity before create/update operations.
+ ///
+ protected override async Task ValidateEntityAsync(LeaseOffer entity)
+ {
+ var errors = new List();
+
+ // Required field validation
+ if (entity.RentalApplicationId == Guid.Empty)
+ {
+ errors.Add("RentalApplicationId is required");
+ }
+
+ if (entity.PropertyId == Guid.Empty)
+ {
+ errors.Add("PropertyId is required");
+ }
+
+ if (entity.ProspectiveTenantId == Guid.Empty)
+ {
+ errors.Add("ProspectiveTenantId is required");
+ }
+
+ if (entity.MonthlyRent <= 0)
+ {
+ errors.Add("MonthlyRent must be greater than zero");
+ }
+
+ if (entity.SecurityDeposit < 0)
+ {
+ errors.Add("SecurityDeposit cannot be negative");
+ }
+
+ if (entity.OfferedOn == DateTime.MinValue)
+ {
+ errors.Add("OfferedOn is required");
+ }
+
+ if (errors.Any())
+ {
+ throw new ValidationException(string.Join("; ", errors));
+ }
+
+ await base.ValidateEntityAsync(entity);
+ }
+
+ ///
+ /// Sets default values for create operations.
+ ///
+ protected override async Task SetCreateDefaultsAsync(LeaseOffer entity)
+ {
+ entity = await base.SetCreateDefaultsAsync(entity);
+
+ // Set default status if not already set
+ if (string.IsNullOrWhiteSpace(entity.Status))
+ {
+ entity.Status = "Pending";
+ }
+
+ // Set offered date if not already set
+ if (entity.OfferedOn == DateTime.MinValue)
+ {
+ entity.OfferedOn = DateTime.UtcNow;
+ }
+
+ // Set expiration date if not already set (default 7 days)
+ if (entity.ExpiresOn == DateTime.MinValue)
+ {
+ entity.ExpiresOn = entity.OfferedOn.AddDays(7);
+ }
+
+ return entity;
+ }
+
+ #endregion
+
+ #region Retrieval Methods
+
+ ///
+ /// Gets a lease offer with all related entities.
+ ///
+ public async Task GetLeaseOfferWithRelationsAsync(Guid leaseOfferId)
+ {
+ try
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ return await _context.LeaseOffers
+ .Include(lo => lo.RentalApplication)
+ .Include(lo => lo.Property)
+ .Include(lo => lo.ProspectiveTenant)
+ .FirstOrDefaultAsync(lo => lo.Id == leaseOfferId
+ && !lo.IsDeleted
+ && lo.OrganizationId == organizationId);
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "GetLeaseOfferWithRelations");
+ throw;
+ }
+ }
+
+ ///
+ /// Gets all lease offers with related entities.
+ ///
+ public async Task> GetLeaseOffersWithRelationsAsync()
+ {
+ try
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ return await _context.LeaseOffers
+ .Include(lo => lo.RentalApplication)
+ .Include(lo => lo.Property)
+ .Include(lo => lo.ProspectiveTenant)
+ .Where(lo => !lo.IsDeleted && lo.OrganizationId == organizationId)
+ .OrderByDescending(lo => lo.OfferedOn)
+ .ToListAsync();
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "GetLeaseOffersWithRelations");
+ throw;
+ }
+ }
+
+ #endregion
+
+ #region Business Logic Methods
+
+ ///
+ /// Gets lease offer by rental application ID.
+ ///
+ public async Task GetLeaseOfferByApplicationIdAsync(Guid applicationId)
+ {
+ try
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ return await _context.LeaseOffers
+ .Include(lo => lo.RentalApplication)
+ .Include(lo => lo.Property)
+ .Include(lo => lo.ProspectiveTenant)
+ .FirstOrDefaultAsync(lo => lo.RentalApplicationId == applicationId
+ && !lo.IsDeleted
+ && lo.OrganizationId == organizationId);
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "GetLeaseOfferByApplicationId");
+ throw;
+ }
+ }
+
+ ///
+ /// Gets lease offers by property ID.
+ ///
+ public async Task> GetLeaseOffersByPropertyIdAsync(Guid propertyId)
+ {
+ try
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ return await _context.LeaseOffers
+ .Include(lo => lo.RentalApplication)
+ .Include(lo => lo.Property)
+ .Include(lo => lo.ProspectiveTenant)
+ .Where(lo => lo.PropertyId == propertyId
+ && !lo.IsDeleted
+ && lo.OrganizationId == organizationId)
+ .OrderByDescending(lo => lo.OfferedOn)
+ .ToListAsync();
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "GetLeaseOffersByPropertyId");
+ throw;
+ }
+ }
+
+ ///
+ /// Gets lease offers by status.
+ ///
+ public async Task> GetLeaseOffersByStatusAsync(string status)
+ {
+ try
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ return await _context.LeaseOffers
+ .Include(lo => lo.RentalApplication)
+ .Include(lo => lo.Property)
+ .Include(lo => lo.ProspectiveTenant)
+ .Where(lo => lo.Status == status
+ && !lo.IsDeleted
+ && lo.OrganizationId == organizationId)
+ .OrderByDescending(lo => lo.OfferedOn)
+ .ToListAsync();
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "GetLeaseOffersByStatus");
+ throw;
+ }
+ }
+
+ ///
+ /// Gets active (pending) lease offers.
+ ///
+ public async Task> GetActiveLeaseOffersAsync()
+ {
+ try
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ return await _context.LeaseOffers
+ .Include(lo => lo.RentalApplication)
+ .Include(lo => lo.Property)
+ .Include(lo => lo.ProspectiveTenant)
+ .Where(lo => lo.Status == "Pending"
+ && !lo.IsDeleted
+ && lo.OrganizationId == organizationId
+ && lo.ExpiresOn > DateTime.UtcNow)
+ .OrderByDescending(lo => lo.OfferedOn)
+ .ToListAsync();
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "GetActiveLeaseOffers");
+ throw;
+ }
+ }
+
+ ///
+ /// Updates lease offer status.
+ ///
+ public async Task UpdateLeaseOfferStatusAsync(Guid leaseOfferId, string newStatus, string? responseNotes = null)
+ {
+ try
+ {
+ var leaseOffer = await GetByIdAsync(leaseOfferId);
+ if (leaseOffer == null)
+ {
+ throw new InvalidOperationException($"Lease offer {leaseOfferId} not found");
+ }
+
+ leaseOffer.Status = newStatus;
+ leaseOffer.RespondedOn = DateTime.UtcNow;
+
+ if (!string.IsNullOrWhiteSpace(responseNotes))
+ {
+ leaseOffer.ResponseNotes = responseNotes;
+ }
+
+ return await UpdateAsync(leaseOffer);
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "UpdateLeaseOfferStatus");
+ throw;
+ }
+ }
+
+ #endregion
+ }
+}
diff --git a/Aquiis.SimpleStart/Application/Services/LeaseService.cs b/Aquiis.SimpleStart/Application/Services/LeaseService.cs
new file mode 100644
index 0000000..639a8ca
--- /dev/null
+++ b/Aquiis.SimpleStart/Application/Services/LeaseService.cs
@@ -0,0 +1,492 @@
+using Aquiis.SimpleStart.Core.Constants;
+using Aquiis.SimpleStart.Core.Entities;
+using Aquiis.SimpleStart.Core.Services;
+using Aquiis.SimpleStart.Infrastructure.Data;
+using Aquiis.SimpleStart.Shared.Services;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using System.ComponentModel.DataAnnotations;
+
+namespace Aquiis.SimpleStart.Application.Services
+{
+ ///
+ /// Service for managing Lease entities.
+ /// Inherits common CRUD operations from BaseService and adds lease-specific business logic.
+ ///
+ public class LeaseService : BaseService
+ {
+ public LeaseService(
+ ApplicationDbContext context,
+ ILogger logger,
+ UserContextService userContext,
+ IOptions settings)
+ : base(context, logger, userContext, settings)
+ {
+ }
+
+ #region Overrides with Lease-Specific Logic
+
+ ///
+ /// Validates a lease entity before create/update operations.
+ ///
+ protected override async Task ValidateEntityAsync(Lease entity)
+ {
+ var errors = new List();
+
+ // Required field validation
+ if (entity.PropertyId == Guid.Empty)
+ {
+ errors.Add("PropertyId is required");
+ }
+
+ if (entity.TenantId == Guid.Empty)
+ {
+ errors.Add("TenantId is required");
+ }
+
+ if (entity.StartDate == default)
+ {
+ errors.Add("StartDate is required");
+ }
+
+ if (entity.EndDate == default)
+ {
+ errors.Add("EndDate is required");
+ }
+
+ if (entity.MonthlyRent <= 0)
+ {
+ errors.Add("MonthlyRent must be greater than 0");
+ }
+
+ // Business rule validation
+ if (entity.EndDate <= entity.StartDate)
+ {
+ errors.Add("EndDate must be after StartDate");
+ }
+
+ // Check for overlapping leases on the same property
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+ var overlappingLease = await _context.Leases
+ .Include(l => l.Property)
+ .Where(l => l.PropertyId == entity.PropertyId
+ && l.Id != entity.Id
+ && !l.IsDeleted
+ && l.Property.OrganizationId == organizationId
+ && (l.Status == ApplicationConstants.LeaseStatuses.Active
+ || l.Status == ApplicationConstants.LeaseStatuses.Pending))
+ .Where(l =>
+ // New lease starts during existing lease
+ (entity.StartDate >= l.StartDate && entity.StartDate <= l.EndDate) ||
+ // New lease ends during existing lease
+ (entity.EndDate >= l.StartDate && entity.EndDate <= l.EndDate) ||
+ // New lease completely encompasses existing lease
+ (entity.StartDate <= l.StartDate && entity.EndDate >= l.EndDate))
+ .FirstOrDefaultAsync();
+
+ if (overlappingLease != null)
+ {
+ errors.Add($"A lease already exists for this property during the specified date range (Lease ID: {overlappingLease.Id})");
+ }
+
+ if (errors.Any())
+ {
+ throw new ValidationException(string.Join("; ", errors));
+ }
+
+ await base.ValidateEntityAsync(entity);
+ }
+
+ ///
+ /// Creates a new lease and updates the property availability status.
+ ///
+ public override async Task CreateAsync(Lease entity)
+ {
+ var lease = await base.CreateAsync(entity);
+
+ // If lease is active, mark property as unavailable
+ if (entity.Status == ApplicationConstants.LeaseStatuses.Active)
+ {
+ var property = await _context.Properties.FindAsync(entity.PropertyId);
+ if (property != null)
+ {
+ property.IsAvailable = false;
+ property.LastModifiedOn = DateTime.UtcNow;
+ property.LastModifiedBy = await _userContext.GetUserIdAsync();
+ _context.Properties.Update(property);
+ await _context.SaveChangesAsync();
+ }
+ }
+
+ return lease;
+ }
+
+ ///
+ /// Deletes (soft deletes) a lease and updates property availability if needed.
+ ///
+ public override async Task DeleteAsync(Guid id)
+ {
+ var lease = await GetByIdAsync(id);
+ if (lease == null) return false;
+
+ var result = await base.DeleteAsync(id);
+
+ // If lease was active, check if property should be marked available
+ if (result && lease.Status == ApplicationConstants.LeaseStatuses.Active)
+ {
+ var property = await _context.Properties.FindAsync(lease.PropertyId);
+ if (property != null)
+ {
+ // Check if there are any other active/pending leases for this property
+ var hasOtherActiveLeases = await _context.Leases
+ .AnyAsync(l => l.PropertyId == lease.PropertyId
+ && l.Id != lease.Id
+ && !l.IsDeleted
+ && (l.Status == ApplicationConstants.LeaseStatuses.Active
+ || l.Status == ApplicationConstants.LeaseStatuses.Pending));
+
+ if (!hasOtherActiveLeases)
+ {
+ property.IsAvailable = true;
+ property.LastModifiedOn = DateTime.UtcNow;
+ property.LastModifiedBy = await _userContext.GetUserIdAsync();
+ _context.Properties.Update(property);
+ await _context.SaveChangesAsync();
+ }
+ }
+ }
+
+ return result;
+ }
+
+ #endregion
+
+ #region Retrieval Methods
+
+ ///
+ /// Gets a lease with all related entities (Property, Tenant, Documents, Invoices).
+ ///
+ public async Task GetLeaseWithRelationsAsync(Guid leaseId)
+ {
+ try
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ var lease = await _context.Leases
+ .Include(l => l.Property)
+ .Include(l => l.Tenant)
+ .Include(l => l.Document)
+ .Include(l => l.Documents)
+ .Include(l => l.Invoices)
+ .Where(l => l.Id == leaseId
+ && !l.IsDeleted
+ && l.Property.OrganizationId == organizationId)
+ .FirstOrDefaultAsync();
+
+ return lease;
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "GetLeaseWithRelations");
+ throw;
+ }
+ }
+
+ ///
+ /// Gets all leases with Property and Tenant relations.
+ ///
+ public async Task> GetLeasesWithRelationsAsync()
+ {
+ try
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ return await _context.Leases
+ .Include(l => l.Property)
+ .Include(l => l.Tenant)
+ .Where(l => !l.IsDeleted && l.Property.OrganizationId == organizationId)
+ .OrderByDescending(l => l.CreatedOn)
+ .ToListAsync();
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "GetLeasesWithRelations");
+ throw;
+ }
+ }
+
+ #endregion
+
+ #region Business Logic Methods
+
+ ///
+ /// Gets all leases for a specific property.
+ ///
+ public async Task> GetLeasesByPropertyIdAsync(Guid propertyId)
+ {
+ try
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ return await _context.Leases
+ .Include(l => l.Property)
+ .Include(l => l.Tenant)
+ .Where(l => l.PropertyId == propertyId
+ && !l.IsDeleted
+ && l.Property.OrganizationId == organizationId)
+ .OrderByDescending(l => l.StartDate)
+ .ToListAsync();
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "GetLeasesByPropertyId");
+ throw;
+ }
+ }
+
+ ///
+ /// Gets all leases for a specific tenant.
+ ///
+ public async Task> GetLeasesByTenantIdAsync(Guid tenantId)
+ {
+ try
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ return await _context.Leases
+ .Include(l => l.Property)
+ .Include(l => l.Tenant)
+ .Where(l => l.TenantId == tenantId
+ && !l.IsDeleted
+ && l.Property.OrganizationId == organizationId)
+ .OrderByDescending(l => l.StartDate)
+ .ToListAsync();
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "GetLeasesByTenantId");
+ throw;
+ }
+ }
+
+ ///
+ /// Gets all active leases (current leases within their term).
+ ///
+ public async Task> GetActiveLeasesAsync()
+ {
+ try
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+ var today = DateTime.Today;
+
+ return await _context.Leases
+ .Include(l => l.Property)
+ .Include(l => l.Tenant)
+ .Where(l => !l.IsDeleted
+ && l.Property.OrganizationId == organizationId
+ && l.Status == ApplicationConstants.LeaseStatuses.Active
+ && l.StartDate <= today
+ && l.EndDate >= today)
+ .OrderBy(l => l.Property.Address)
+ .ToListAsync();
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "GetActiveLeases");
+ throw;
+ }
+ }
+
+ ///
+ /// Gets leases that are expiring within the specified number of days.
+ ///
+ public async Task> GetLeasesExpiringSoonAsync(int daysThreshold = 90)
+ {
+ try
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+ var today = DateTime.Today;
+ var expirationDate = today.AddDays(daysThreshold);
+
+ return await _context.Leases
+ .Include(l => l.Property)
+ .Include(l => l.Tenant)
+ .Where(l => !l.IsDeleted
+ && l.Property.OrganizationId == organizationId
+ && l.Status == ApplicationConstants.LeaseStatuses.Active
+ && l.EndDate >= today
+ && l.EndDate <= expirationDate)
+ .OrderBy(l => l.EndDate)
+ .ToListAsync();
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "GetLeasesExpiringSoon");
+ throw;
+ }
+ }
+
+ ///
+ /// Gets leases by status.
+ ///
+ public async Task> GetLeasesByStatusAsync(string status)
+ {
+ try
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ return await _context.Leases
+ .Include(l => l.Property)
+ .Include(l => l.Tenant)
+ .Where(l => !l.IsDeleted
+ && l.Property.OrganizationId == organizationId
+ && l.Status == status)
+ .OrderByDescending(l => l.StartDate)
+ .ToListAsync();
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "GetLeasesByStatus");
+ throw;
+ }
+ }
+
+ ///
+ /// Gets current and upcoming leases for a property (Active or Pending status).
+ ///
+ public async Task> GetCurrentAndUpcomingLeasesByPropertyIdAsync(Guid propertyId)
+ {
+ try
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ return await _context.Leases
+ .Include(l => l.Property)
+ .Include(l => l.Tenant)
+ .Where(l => l.PropertyId == propertyId
+ && !l.IsDeleted
+ && l.Property.OrganizationId == organizationId
+ && (l.Status == ApplicationConstants.LeaseStatuses.Active
+ || l.Status == ApplicationConstants.LeaseStatuses.Pending))
+ .OrderBy(l => l.StartDate)
+ .ToListAsync();
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "GetCurrentAndUpcomingLeasesByPropertyId");
+ throw;
+ }
+ }
+
+ ///
+ /// Gets active leases for a specific property.
+ ///
+ public async Task> GetActiveLeasesByPropertyIdAsync(Guid propertyId)
+ {
+ try
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+ var today = DateTime.Today;
+
+ return await _context.Leases
+ .Include(l => l.Property)
+ .Include(l => l.Tenant)
+ .Where(l => l.PropertyId == propertyId
+ && !l.IsDeleted
+ && l.Property.OrganizationId == organizationId
+ && l.Status == ApplicationConstants.LeaseStatuses.Active
+ && l.StartDate <= today
+ && l.EndDate >= today)
+ .ToListAsync();
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "GetActiveLeasesByPropertyId");
+ throw;
+ }
+ }
+
+ ///
+ /// Calculates the total rent for a lease over its entire term.
+ ///
+ public async Task CalculateTotalLeaseValueAsync(Guid leaseId)
+ {
+ try
+ {
+ var lease = await GetByIdAsync(leaseId);
+ if (lease == null)
+ {
+ throw new InvalidOperationException($"Lease not found: {leaseId}");
+ }
+
+ var months = ((lease.EndDate.Year - lease.StartDate.Year) * 12)
+ + lease.EndDate.Month - lease.StartDate.Month;
+
+ // Add 1 to include both start and end months
+ return lease.MonthlyRent * (months + 1);
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "CalculateTotalLeaseValue");
+ throw;
+ }
+ }
+
+ ///
+ /// Updates the status of a lease.
+ ///
+ public async Task UpdateLeaseStatusAsync(Guid leaseId, string newStatus)
+ {
+ try
+ {
+ var lease = await GetByIdAsync(leaseId);
+ if (lease == null)
+ {
+ throw new InvalidOperationException($"Lease not found: {leaseId}");
+ }
+
+ lease.Status = newStatus;
+
+ // Update property availability based on status
+ var property = await _context.Properties.FindAsync(lease.PropertyId);
+ if (property != null)
+ {
+ if (newStatus == ApplicationConstants.LeaseStatuses.Active)
+ {
+ property.IsAvailable = false;
+ }
+ else if (newStatus == ApplicationConstants.LeaseStatuses.Terminated
+ || newStatus == ApplicationConstants.LeaseStatuses.Expired)
+ {
+ // Only mark available if no other active leases exist
+ var hasOtherActiveLeases = await _context.Leases
+ .AnyAsync(l => l.PropertyId == lease.PropertyId
+ && l.Id != lease.Id
+ && !l.IsDeleted
+ && (l.Status == ApplicationConstants.LeaseStatuses.Active
+ || l.Status == ApplicationConstants.LeaseStatuses.Pending));
+
+ if (!hasOtherActiveLeases)
+ {
+ property.IsAvailable = true;
+ }
+ }
+
+ property.LastModifiedOn = DateTime.UtcNow;
+ property.LastModifiedBy = await _userContext.GetUserIdAsync();
+ _context.Properties.Update(property);
+ }
+
+ return await UpdateAsync(lease);
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "UpdateLeaseStatus");
+ throw;
+ }
+ }
+
+ #endregion
+ }
+}
diff --git a/Aquiis.SimpleStart/Application/Services/MaintenanceService.cs b/Aquiis.SimpleStart/Application/Services/MaintenanceService.cs
new file mode 100644
index 0000000..f3351ec
--- /dev/null
+++ b/Aquiis.SimpleStart/Application/Services/MaintenanceService.cs
@@ -0,0 +1,492 @@
+using Aquiis.SimpleStart.Application.Services.Workflows;
+using Aquiis.SimpleStart.Core.Constants;
+using Aquiis.SimpleStart.Core.Entities;
+using Aquiis.SimpleStart.Core.Interfaces;
+using Aquiis.SimpleStart.Core.Services;
+using Aquiis.SimpleStart.Infrastructure.Data;
+using Aquiis.SimpleStart.Shared.Services;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Options;
+using System.ComponentModel.DataAnnotations;
+
+namespace Aquiis.SimpleStart.Application.Services
+{
+ ///
+ /// Service for managing maintenance requests with business logic for status updates,
+ /// assignment tracking, and overdue detection.
+ ///
+ public class MaintenanceService : BaseService
+ {
+ private readonly ICalendarEventService _calendarEventService;
+
+ public MaintenanceService(
+ ApplicationDbContext context,
+ ILogger logger,
+ UserContextService userContext,
+ IOptions settings,
+ ICalendarEventService calendarEventService)
+ : base(context, logger, userContext, settings)
+ {
+ _calendarEventService = calendarEventService;
+ }
+
+ ///
+ /// Validates maintenance request business rules.
+ ///
+ protected override async Task ValidateEntityAsync(MaintenanceRequest entity)
+ {
+ var errors = new List();
+
+ // Required fields
+ if (entity.PropertyId == Guid.Empty)
+ {
+ errors.Add("Property is required");
+ }
+
+ if (string.IsNullOrWhiteSpace(entity.Title))
+ {
+ errors.Add("Title is required");
+ }
+
+ if (string.IsNullOrWhiteSpace(entity.Description))
+ {
+ errors.Add("Description is required");
+ }
+
+ if (string.IsNullOrWhiteSpace(entity.RequestType))
+ {
+ errors.Add("Request type is required");
+ }
+
+ if (string.IsNullOrWhiteSpace(entity.Priority))
+ {
+ errors.Add("Priority is required");
+ }
+
+ if (string.IsNullOrWhiteSpace(entity.Status))
+ {
+ errors.Add("Status is required");
+ }
+
+ // Validate priority
+ var validPriorities = new[] { "Low", "Medium", "High", "Urgent" };
+ if (!validPriorities.Contains(entity.Priority))
+ {
+ errors.Add($"Priority must be one of: {string.Join(", ", validPriorities)}");
+ }
+
+ // Validate status
+ var validStatuses = new[] { "Submitted", "In Progress", "Completed", "Cancelled" };
+ if (!validStatuses.Contains(entity.Status))
+ {
+ errors.Add($"Status must be one of: {string.Join(", ", validStatuses)}");
+ }
+
+ // Validate dates
+ if (entity.RequestedOn > DateTime.Today)
+ {
+ errors.Add("Requested date cannot be in the future");
+ }
+
+ if (entity.ScheduledOn.HasValue && entity.ScheduledOn.Value.Date < entity.RequestedOn.Date)
+ {
+ errors.Add("Scheduled date cannot be before requested date");
+ }
+
+ if (entity.CompletedOn.HasValue && entity.CompletedOn.Value.Date < entity.RequestedOn.Date)
+ {
+ errors.Add("Completed date cannot be before requested date");
+ }
+
+ // Validate costs
+ if (entity.EstimatedCost < 0)
+ {
+ errors.Add("Estimated cost cannot be negative");
+ }
+
+ if (entity.ActualCost < 0)
+ {
+ errors.Add("Actual cost cannot be negative");
+ }
+
+ // Validate status-specific rules
+ if (entity.Status == "Completed")
+ {
+ if (!entity.CompletedOn.HasValue)
+ {
+ errors.Add("Completed date is required when status is Completed");
+ }
+ }
+
+ // Verify property exists and belongs to organization
+ if (entity.PropertyId != Guid.Empty)
+ {
+ var property = await _context.Properties
+ .FirstOrDefaultAsync(p => p.Id == entity.PropertyId && !p.IsDeleted);
+
+ if (property == null)
+ {
+ errors.Add($"Property with ID {entity.PropertyId} not found");
+ }
+ else if (property.OrganizationId != entity.OrganizationId)
+ {
+ errors.Add("Property does not belong to the same organization");
+ }
+ }
+
+ // If LeaseId is provided, verify it exists and belongs to the same property
+ if (entity.LeaseId.HasValue && entity.LeaseId.Value != Guid.Empty)
+ {
+ var lease = await _context.Leases
+ .FirstOrDefaultAsync(l => l.Id == entity.LeaseId.Value && !l.IsDeleted);
+
+ if (lease == null)
+ {
+ errors.Add($"Lease with ID {entity.LeaseId.Value} not found");
+ }
+ else if (lease.PropertyId != entity.PropertyId)
+ {
+ errors.Add("Lease does not belong to the specified property");
+ }
+ else if (lease.OrganizationId != entity.OrganizationId)
+ {
+ errors.Add("Lease does not belong to the same organization");
+ }
+ }
+
+ if (errors.Any())
+ {
+ throw new ValidationException(string.Join("; ", errors));
+ }
+
+ await Task.CompletedTask;
+ }
+
+ ///
+ /// Creates a maintenance request and automatically creates a calendar event.
+ ///
+ public override async Task CreateAsync(MaintenanceRequest entity)
+ {
+ var maintenanceRequest = await base.CreateAsync(entity);
+
+ // Create calendar event for the maintenance request
+ await _calendarEventService.CreateOrUpdateEventAsync(maintenanceRequest);
+
+ return maintenanceRequest;
+ }
+
+ ///
+ /// Updates a maintenance request and synchronizes the calendar event.
+ ///
+ public override async Task UpdateAsync(MaintenanceRequest entity)
+ {
+ var maintenanceRequest = await base.UpdateAsync(entity);
+
+ // Update calendar event
+ await _calendarEventService.CreateOrUpdateEventAsync(maintenanceRequest);
+
+ return maintenanceRequest;
+ }
+
+ ///
+ /// Deletes a maintenance request and removes the associated calendar event.
+ ///
+ public override async Task DeleteAsync(Guid id)
+ {
+ var maintenanceRequest = await GetByIdAsync(id);
+
+ var result = await base.DeleteAsync(id);
+
+ if (result && maintenanceRequest != null)
+ {
+ // Delete associated calendar event
+ await _calendarEventService.DeleteEventAsync(maintenanceRequest.CalendarEventId);
+ }
+
+ return result;
+ }
+
+ ///
+ /// Gets all maintenance requests for a specific property.
+ ///
+ public async Task> GetMaintenanceRequestsByPropertyAsync(Guid propertyId)
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ return await _context.MaintenanceRequests
+ .Include(m => m.Property)
+ .Include(m => m.Lease)
+ .Where(m => m.PropertyId == propertyId &&
+ m.OrganizationId == organizationId &&
+ !m.IsDeleted)
+ .OrderByDescending(m => m.RequestedOn)
+ .ToListAsync();
+ }
+
+ ///
+ /// Gets all maintenance requests for a specific lease.
+ ///
+ public async Task> GetMaintenanceRequestsByLeaseAsync(Guid leaseId)
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ return await _context.MaintenanceRequests
+ .Include(m => m.Property)
+ .Include(m => m.Lease)
+ .Where(m => m.LeaseId == leaseId &&
+ m.OrganizationId == organizationId &&
+ !m.IsDeleted)
+ .OrderByDescending(m => m.RequestedOn)
+ .ToListAsync();
+ }
+
+ ///
+ /// Gets maintenance requests by status.
+ ///
+ public async Task> GetMaintenanceRequestsByStatusAsync(string status)
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ return await _context.MaintenanceRequests
+ .Include(m => m.Property)
+ .Include(m => m.Lease)
+ .Where(m => m.Status == status &&
+ m.OrganizationId == organizationId &&
+ !m.IsDeleted)
+ .OrderByDescending(m => m.RequestedOn)
+ .ToListAsync();
+ }
+
+ ///
+ /// Gets maintenance requests by priority level.
+ ///
+ public async Task> GetMaintenanceRequestsByPriorityAsync(string priority)
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ return await _context.MaintenanceRequests
+ .Include(m => m.Property)
+ .Include(m => m.Lease)
+ .Where(m => m.Priority == priority &&
+ m.OrganizationId == organizationId &&
+ !m.IsDeleted)
+ .OrderByDescending(m => m.RequestedOn)
+ .ToListAsync();
+ }
+
+ ///
+ /// Gets overdue maintenance requests (scheduled date has passed but not completed).
+ ///
+ public async Task> GetOverdueMaintenanceRequestsAsync()
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+ var today = DateTime.Today;
+
+ return await _context.MaintenanceRequests
+ .Include(m => m.Property)
+ .Include(m => m.Lease)
+ .Where(m => m.OrganizationId == organizationId &&
+ !m.IsDeleted &&
+ m.Status != "Completed" &&
+ m.Status != "Cancelled" &&
+ m.ScheduledOn.HasValue &&
+ m.ScheduledOn.Value.Date < today)
+ .OrderBy(m => m.ScheduledOn)
+ .ToListAsync();
+ }
+
+ ///
+ /// Gets the count of open (not completed/cancelled) maintenance requests.
+ ///
+ public async Task GetOpenMaintenanceRequestCountAsync()
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ return await _context.MaintenanceRequests
+ .Where(m => m.OrganizationId == organizationId &&
+ !m.IsDeleted &&
+ m.Status != "Completed" &&
+ m.Status != "Cancelled")
+ .CountAsync();
+ }
+
+ ///
+ /// Gets the count of urgent priority maintenance requests.
+ ///
+ public async Task GetUrgentMaintenanceRequestCountAsync()
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ return await _context.MaintenanceRequests
+ .Where(m => m.OrganizationId == organizationId &&
+ !m.IsDeleted &&
+ m.Priority == "Urgent" &&
+ m.Status != "Completed" &&
+ m.Status != "Cancelled")
+ .CountAsync();
+ }
+
+ ///
+ /// Gets a maintenance request with all related entities loaded.
+ ///
+ public async Task GetMaintenanceRequestWithRelationsAsync(Guid id)
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ return await _context.MaintenanceRequests
+ .Include(m => m.Property)
+ .Include(m => m.Lease)
+ .ThenInclude(l => l!.Tenant)
+ .FirstOrDefaultAsync(m => m.Id == id &&
+ m.OrganizationId == organizationId &&
+ !m.IsDeleted);
+ }
+
+ ///
+ /// Updates the status of a maintenance request with automatic date tracking.
+ ///
+ public async Task UpdateMaintenanceRequestStatusAsync(Guid id, string status)
+ {
+ var maintenanceRequest = await GetByIdAsync(id);
+
+ if (maintenanceRequest == null)
+ {
+ throw new ValidationException($"Maintenance request {id} not found");
+ }
+
+ maintenanceRequest.Status = status;
+
+ // Auto-set completed date when marked as completed
+ if (status == "Completed" && !maintenanceRequest.CompletedOn.HasValue)
+ {
+ maintenanceRequest.CompletedOn = DateTime.Today;
+ }
+
+ return await UpdateAsync(maintenanceRequest);
+ }
+
+ ///
+ /// Assigns a maintenance request to a contractor or maintenance person.
+ ///
+ public async Task AssignMaintenanceRequestAsync(Guid id, string assignedTo, DateTime? scheduledOn = null)
+ {
+ var maintenanceRequest = await GetByIdAsync(id);
+
+ if (maintenanceRequest == null)
+ {
+ throw new ValidationException($"Maintenance request {id} not found");
+ }
+
+ maintenanceRequest.AssignedTo = assignedTo;
+
+ if (scheduledOn.HasValue)
+ {
+ maintenanceRequest.ScheduledOn = scheduledOn.Value;
+ }
+
+ // Auto-update status to In Progress if still Submitted
+ if (maintenanceRequest.Status == "Submitted")
+ {
+ maintenanceRequest.Status = "In Progress";
+ }
+
+ return await UpdateAsync(maintenanceRequest);
+ }
+
+ ///
+ /// Completes a maintenance request with actual cost and resolution notes.
+ ///
+ public async Task CompleteMaintenanceRequestAsync(
+ Guid id,
+ decimal actualCost,
+ string resolutionNotes)
+ {
+ var maintenanceRequest = await GetByIdAsync(id);
+
+ if (maintenanceRequest == null)
+ {
+ throw new ValidationException($"Maintenance request {id} not found");
+ }
+
+ maintenanceRequest.Status = "Completed";
+ maintenanceRequest.CompletedOn = DateTime.Today;
+ maintenanceRequest.ActualCost = actualCost;
+ maintenanceRequest.ResolutionNotes = resolutionNotes;
+
+ return await UpdateAsync(maintenanceRequest);
+ }
+
+ ///
+ /// Gets maintenance requests assigned to a specific person.
+ ///
+ public async Task> GetMaintenanceRequestsByAssigneeAsync(string assignedTo)
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ return await _context.MaintenanceRequests
+ .Include(m => m.Property)
+ .Include(m => m.Lease)
+ .Where(m => m.AssignedTo == assignedTo &&
+ m.OrganizationId == organizationId &&
+ !m.IsDeleted &&
+ m.Status != "Completed" &&
+ m.Status != "Cancelled")
+ .OrderByDescending(m => m.Priority == "Urgent")
+ .ThenByDescending(m => m.Priority == "High")
+ .ThenBy(m => m.ScheduledOn)
+ .ToListAsync();
+ }
+
+ ///
+ /// Calculates average days to complete maintenance requests.
+ ///
+ public async Task CalculateAverageDaysToCompleteAsync()
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ var completedRequests = await _context.MaintenanceRequests
+ .Where(m => m.OrganizationId == organizationId &&
+ !m.IsDeleted &&
+ m.Status == "Completed" &&
+ m.CompletedOn.HasValue)
+ .Select(m => new { m.RequestedOn, m.CompletedOn })
+ .ToListAsync();
+
+ if (!completedRequests.Any())
+ {
+ return 0;
+ }
+
+ var totalDays = completedRequests.Sum(r => (r.CompletedOn!.Value.Date - r.RequestedOn.Date).Days);
+ return (double)totalDays / completedRequests.Count;
+ }
+
+ ///
+ /// Gets maintenance cost summary by property.
+ ///
+ public async Task> GetMaintenanceCostsByPropertyAsync(DateTime? startDate = null, DateTime? endDate = null)
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ var query = _context.MaintenanceRequests
+ .Where(m => m.OrganizationId == organizationId &&
+ !m.IsDeleted &&
+ m.Status == "Completed");
+
+ if (startDate.HasValue)
+ {
+ query = query.Where(m => m.CompletedOn >= startDate.Value);
+ }
+
+ if (endDate.HasValue)
+ {
+ query = query.Where(m => m.CompletedOn <= endDate.Value);
+ }
+
+ return await query
+ .GroupBy(m => m.PropertyId)
+ .Select(g => new { PropertyId = g.Key, TotalCost = g.Sum(m => m.ActualCost) })
+ .ToDictionaryAsync(x => x.PropertyId, x => x.TotalCost);
+ }
+ }
+}
diff --git a/Aquiis.SimpleStart/Application/Services/OrganizationService.cs b/Aquiis.SimpleStart/Application/Services/OrganizationService.cs
index afbfa69..21788e7 100644
--- a/Aquiis.SimpleStart/Application/Services/OrganizationService.cs
+++ b/Aquiis.SimpleStart/Application/Services/OrganizationService.cs
@@ -446,6 +446,50 @@ public async Task> GetActiveUserAssignmentsAsync()
.ToListAsync();
}
+ ///
+ /// Gets organization settings by organization ID (for scheduled tasks).
+ ///
+ public async Task GetOrganizationSettingsByOrgIdAsync(Guid organizationId)
+ {
+ return await _dbContext.OrganizationSettings
+ .Where(s => !s.IsDeleted && s.OrganizationId == organizationId)
+ .FirstOrDefaultAsync();
+ }
+
+ ///
+ /// Gets the organization settings for the current user's active organization.
+ /// If no settings exist, creates default settings.
+ ///
+ public async Task GetOrganizationSettingsAsync()
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+ if (!organizationId.HasValue || organizationId == Guid.Empty)
+ throw new InvalidOperationException("Organization ID not found for current user");
+
+ return await GetOrganizationSettingsByOrgIdAsync(organizationId.Value);
+ }
+
+ ///
+ /// Updates the organization settings for the current user's organization.
+ ///
+ public async Task UpdateOrganizationSettingsAsync(OrganizationSettings settings)
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+ if (!organizationId.HasValue || organizationId == Guid.Empty)
+ throw new InvalidOperationException("Organization ID not found for current user");
+
+ if (settings.OrganizationId != organizationId.Value)
+ throw new InvalidOperationException("Cannot update settings for a different organization");
+
+ var userId = await _userContext.GetUserIdAsync();
+ settings.LastModifiedOn = DateTime.UtcNow;
+ settings.LastModifiedBy = string.IsNullOrEmpty(userId) ? string.Empty : userId;
+
+ _dbContext.OrganizationSettings.Update(settings);
+ await _dbContext.SaveChangesAsync();
+ return true;
+ }
+
#endregion
}
}
diff --git a/Aquiis.SimpleStart/Application/Services/PaymentService.cs b/Aquiis.SimpleStart/Application/Services/PaymentService.cs
new file mode 100644
index 0000000..4f83899
--- /dev/null
+++ b/Aquiis.SimpleStart/Application/Services/PaymentService.cs
@@ -0,0 +1,410 @@
+using Aquiis.SimpleStart.Core.Constants;
+using Aquiis.SimpleStart.Core.Entities;
+using Aquiis.SimpleStart.Core.Services;
+using Aquiis.SimpleStart.Infrastructure.Data;
+using Aquiis.SimpleStart.Shared.Services;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using System.ComponentModel.DataAnnotations;
+
+namespace Aquiis.SimpleStart.Application.Services
+{
+ ///
+ /// Service for managing Payment entities.
+ /// Inherits common CRUD operations from BaseService and adds payment-specific business logic.
+ ///
+ public class PaymentService : BaseService
+ {
+ public PaymentService(
+ ApplicationDbContext context,
+ ILogger logger,
+ UserContextService userContext,
+ IOptions settings)
+ : base(context, logger, userContext, settings)
+ {
+ }
+
+ ///
+ /// Validates a payment before create/update operations.
+ ///
+ protected override async Task ValidateEntityAsync(Payment entity)
+ {
+ var errors = new List();
+
+ // Required fields
+ if (entity.InvoiceId == Guid.Empty)
+ {
+ errors.Add("Invoice ID is required.");
+ }
+
+ if (entity.Amount <= 0)
+ {
+ errors.Add("Payment amount must be greater than zero.");
+ }
+
+ if (entity.PaidOn > DateTime.UtcNow.Date.AddDays(1))
+ {
+ errors.Add("Payment date cannot be in the future.");
+ }
+
+ // Validate invoice exists and belongs to organization
+ if (entity.InvoiceId != Guid.Empty)
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+ var invoice = await _context.Invoices
+ .Include(i => i.Lease)
+ .ThenInclude(l => l.Property)
+ .FirstOrDefaultAsync(i => i.Id == entity.InvoiceId && !i.IsDeleted);
+
+ if (invoice == null)
+ {
+ errors.Add($"Invoice with ID {entity.InvoiceId} does not exist.");
+ }
+ else if (invoice.Lease?.Property?.OrganizationId != organizationId)
+ {
+ errors.Add("Invoice does not belong to the current organization.");
+ }
+ else
+ {
+ // Validate payment doesn't exceed invoice balance
+ var existingPayments = await _context.Payments
+ .Where(p => p.InvoiceId == entity.InvoiceId
+ && !p.IsDeleted
+ && p.Id != entity.Id) // Exclude current payment for updates
+ .SumAsync(p => p.Amount);
+
+ var totalWithThisPayment = existingPayments + entity.Amount;
+ var invoiceTotal = invoice.Amount + (invoice.LateFeeAmount ?? 0);
+
+ if (totalWithThisPayment > invoiceTotal)
+ {
+ errors.Add($"Payment amount would exceed invoice balance. Invoice total: {invoiceTotal:C}, Already paid: {existingPayments:C}, This payment: {entity.Amount:C}");
+ }
+ }
+ }
+
+ // Validate payment method
+ var validMethods = ApplicationConstants.PaymentMethods.AllPaymentMethods;
+
+ if (!string.IsNullOrWhiteSpace(entity.PaymentMethod) && !validMethods.Contains(entity.PaymentMethod))
+ {
+ errors.Add($"Payment method must be one of: {string.Join(", ", validMethods)}");
+ }
+
+ if (errors.Any())
+ {
+ throw new ValidationException(string.Join(" ", errors));
+ }
+ }
+
+ ///
+ /// Creates a payment and automatically updates the associated invoice.
+ ///
+ public override async Task CreateAsync(Payment entity)
+ {
+ var payment = await base.CreateAsync(entity);
+ await UpdateInvoiceAfterPaymentChangeAsync(payment.InvoiceId);
+ return payment;
+ }
+
+ ///
+ /// Updates a payment and automatically updates the associated invoice.
+ ///
+ public override async Task UpdateAsync(Payment entity)
+ {
+ var payment = await base.UpdateAsync(entity);
+ await UpdateInvoiceAfterPaymentChangeAsync(payment.InvoiceId);
+ return payment;
+ }
+
+ ///
+ /// Deletes a payment and automatically updates the associated invoice.
+ ///
+ public override async Task DeleteAsync(Guid id)
+ {
+ var payment = await GetByIdAsync(id);
+ if (payment != null)
+ {
+ var invoiceId = payment.InvoiceId;
+ var result = await base.DeleteAsync(id);
+ await UpdateInvoiceAfterPaymentChangeAsync(invoiceId);
+ return result;
+ }
+ return false;
+ }
+
+ ///
+ /// Gets all payments for a specific invoice.
+ ///
+ public async Task> GetPaymentsByInvoiceIdAsync(Guid invoiceId)
+ {
+ try
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ return await _context.Payments
+ .Include(p => p.Invoice)
+ .ThenInclude(i => i.Lease)
+ .ThenInclude(l => l.Property)
+ .Include(p => p.Invoice)
+ .ThenInclude(i => i.Lease)
+ .ThenInclude(l => l.Tenant)
+ .Where(p => p.InvoiceId == invoiceId
+ && !p.IsDeleted
+ && p.OrganizationId == organizationId)
+ .OrderByDescending(p => p.PaidOn)
+ .ToListAsync();
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "GetPaymentsByInvoiceId");
+ throw;
+ }
+ }
+
+ ///
+ /// Gets payments by payment method.
+ ///
+ public async Task> GetPaymentsByMethodAsync(string paymentMethod)
+ {
+ try
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ return await _context.Payments
+ .Include(p => p.Invoice)
+ .ThenInclude(i => i.Lease)
+ .ThenInclude(l => l.Property)
+ .Include(p => p.Invoice)
+ .ThenInclude(i => i.Lease)
+ .ThenInclude(l => l.Tenant)
+ .Where(p => p.PaymentMethod == paymentMethod
+ && !p.IsDeleted
+ && p.OrganizationId == organizationId)
+ .OrderByDescending(p => p.PaidOn)
+ .ToListAsync();
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "GetPaymentsByMethod");
+ throw;
+ }
+ }
+
+ ///
+ /// Gets payments within a specific date range.
+ ///
+ public async Task> GetPaymentsByDateRangeAsync(DateTime startDate, DateTime endDate)
+ {
+ try
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ return await _context.Payments
+ .Include(p => p.Invoice)
+ .ThenInclude(i => i.Lease)
+ .ThenInclude(l => l.Property)
+ .Include(p => p.Invoice)
+ .ThenInclude(i => i.Lease)
+ .ThenInclude(l => l.Tenant)
+ .Where(p => p.PaidOn >= startDate
+ && p.PaidOn <= endDate
+ && !p.IsDeleted
+ && p.OrganizationId == organizationId)
+ .OrderByDescending(p => p.PaidOn)
+ .ToListAsync();
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "GetPaymentsByDateRange");
+ throw;
+ }
+ }
+
+ ///
+ /// Gets a payment with all related entities loaded.
+ ///
+ public async Task GetPaymentWithRelationsAsync(Guid paymentId)
+ {
+ try
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ return await _context.Payments
+ .Include(p => p.Invoice)
+ .ThenInclude(i => i.Lease)
+ .ThenInclude(l => l.Property)
+ .Include(p => p.Invoice)
+ .ThenInclude(i => i.Lease)
+ .ThenInclude(l => l.Tenant)
+ .Include(p => p.Document)
+ .FirstOrDefaultAsync(p => p.Id == paymentId
+ && !p.IsDeleted
+ && p.OrganizationId == organizationId);
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "GetPaymentWithRelations");
+ throw;
+ }
+ }
+
+ ///
+ /// Calculates the total payments received within a date range.
+ ///
+ public async Task CalculateTotalPaymentsAsync(DateTime? startDate = null, DateTime? endDate = null)
+ {
+ try
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ var query = _context.Payments
+ .Where(p => !p.IsDeleted && p.OrganizationId == organizationId);
+
+ if (startDate.HasValue)
+ {
+ query = query.Where(p => p.PaidOn >= startDate.Value);
+ }
+
+ if (endDate.HasValue)
+ {
+ query = query.Where(p => p.PaidOn <= endDate.Value);
+ }
+
+ return await query.SumAsync(p => p.Amount);
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "CalculateTotalPayments");
+ throw;
+ }
+ }
+
+ ///
+ /// Gets payment summary grouped by payment method.
+ ///
+ public async Task> GetPaymentSummaryByMethodAsync(DateTime? startDate = null, DateTime? endDate = null)
+ {
+ try
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ var query = _context.Payments
+ .Where(p => !p.IsDeleted && p.OrganizationId == organizationId);
+
+ if (startDate.HasValue)
+ {
+ query = query.Where(p => p.PaidOn >= startDate.Value);
+ }
+
+ if (endDate.HasValue)
+ {
+ query = query.Where(p => p.PaidOn <= endDate.Value);
+ }
+
+ return await query
+ .GroupBy(p => p.PaymentMethod)
+ .Select(g => new { Method = g.Key, Total = g.Sum(p => p.Amount) })
+ .ToDictionaryAsync(x => x.Method, x => x.Total);
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "GetPaymentSummaryByMethod");
+ throw;
+ }
+ }
+
+ ///
+ /// Gets the total amount paid for a specific invoice.
+ ///
+ public async Task GetTotalPaidForInvoiceAsync(Guid invoiceId)
+ {
+ try
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+
+ return await _context.Payments
+ .Where(p => p.InvoiceId == invoiceId
+ && !p.IsDeleted
+ && p.OrganizationId == organizationId)
+ .SumAsync(p => p.Amount);
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "GetTotalPaidForInvoice");
+ throw;
+ }
+ }
+
+ ///
+ /// Updates the invoice status and paid amount after a payment change.
+ ///
+ private async Task UpdateInvoiceAfterPaymentChangeAsync(Guid invoiceId)
+ {
+ try
+ {
+ var organizationId = await _userContext.GetActiveOrganizationIdAsync();
+ var invoice = await _context.Invoices
+ .Include(i => i.Payments)
+ .FirstOrDefaultAsync(i => i.Id == invoiceId && i.OrganizationId == organizationId);
+
+ if (invoice != null)
+ {
+ var totalPaid = invoice.Payments
+ .Where(p => !p.IsDeleted)
+ .Sum(p => p.Amount);
+
+ invoice.AmountPaid = totalPaid;
+
+ var totalDue = invoice.Amount + (invoice.LateFeeAmount ?? 0);
+
+ // Update invoice status based on payment
+ if (totalPaid >= totalDue)
+ {
+ invoice.Status = ApplicationConstants.InvoiceStatuses.Paid;
+ invoice.PaidOn = invoice.Payments
+ .Where(p => !p.IsDeleted)
+ .OrderByDescending(p => p.PaidOn)
+ .FirstOrDefault()?.PaidOn ?? DateTime.UtcNow;
+ }
+ else if (totalPaid > 0 && invoice.Status != ApplicationConstants.InvoiceStatuses.Cancelled)
+ {
+ // Invoice is partially paid
+ if (invoice.DueOn < DateTime.Today)
+ {
+ invoice.Status = ApplicationConstants.InvoiceStatuses.Overdue;
+ }
+ else
+ {
+ invoice.Status = ApplicationConstants.InvoiceStatuses.Pending;
+ }
+ }
+ else if (invoice.Status != ApplicationConstants.InvoiceStatuses.Cancelled)
+ {
+ // No payments
+ if (invoice.DueOn < DateTime.Today)
+ {
+ invoice.Status = ApplicationConstants.InvoiceStatuses.Overdue;
+ }
+ else
+ {
+ invoice.Status = ApplicationConstants.InvoiceStatuses.Pending;
+ }
+ }
+
+ var userId = await _userContext.GetUserIdAsync();
+ invoice.LastModifiedBy = userId ?? "system";
+ invoice.LastModifiedOn = DateTime.UtcNow;
+
+ await _context.SaveChangesAsync();
+ }
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(ex, "UpdateInvoiceAfterPaymentChange");
+ throw;
+ }
+ }
+ }
+}
diff --git a/Aquiis.SimpleStart/Application/Services/PropertyService.cs b/Aquiis.SimpleStart/Application/Services/PropertyService.cs
new file mode 100644
index 0000000..e96901f
--- /dev/null
+++ b/Aquiis.SimpleStart/Application/Services/PropertyService.cs
@@ -0,0 +1,382 @@
+using System.ComponentModel.DataAnnotations;
+using Aquiis.SimpleStart.Core.Constants;
+using Aquiis.SimpleStart.Core.Entities;
+using Aquiis.SimpleStart.Core.Services;
+using Aquiis.SimpleStart.Infrastructure.Data;
+using Aquiis.SimpleStart.Shared.Services;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Options;
+
+namespace Aquiis.SimpleStart.Application.Services
+{
+ ///
+ /// Service for managing Property entities.
+ /// Inherits common CRUD operations from BaseService and adds property-specific business logic.
+ ///
+ public class PropertyService : BaseService
+ {
+ private readonly CalendarEventService _calendarEventService;
+ private readonly ApplicationSettings _appSettings;
+
+ public PropertyService(
+ ApplicationDbContext context,
+ ILogger logger,
+ UserContextService userContext,
+ IOptions settings,
+ CalendarEventService calendarEventService)
+ : base(context, logger, userContext, settings)
+ {
+ _calendarEventService = calendarEventService;
+ _appSettings = settings.Value;
+ }
+
+ #region Overrides with Property-Specific Logic
+
+ ///