Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,29 @@ public async Task<IActionResult> GetMaintenanceRequests(
return Ok(response);
}

/// <summary>
/// Get the current tenant's assigned property info (Story 20.5, AC #2).
/// Returns read-only property data (name, address) — no financial data.
/// </summary>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Tenant property info</returns>
/// <response code="200">Returns the tenant's property info</response>
/// <response code="401">If user is not authenticated</response>
/// <response code="404">If property not found</response>
[HttpGet("tenant-property")]
[ProducesResponseType(typeof(TenantPropertyDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetTenantProperty(CancellationToken cancellationToken)
{
var query = new GetTenantPropertyQuery();
var result = await _mediator.Send(query, cancellationToken);

_logger.LogInformation("Retrieved tenant property {PropertyId}", result.Id);

return Ok(result);
}

/// <summary>
/// Get a single maintenance request by ID (AC #7).
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using PropertyManager.Application.Common.Interfaces;
using PropertyManager.Domain.Exceptions;

namespace PropertyManager.Application.MaintenanceRequests;

/// <summary>
/// Query to get the current tenant's assigned property info (Story 20.5, AC #2).
/// Returns read-only property data (no financial info).
/// </summary>
public record GetTenantPropertyQuery() : IRequest<TenantPropertyDto>;

/// <summary>
/// DTO for tenant property display — read-only, no financial data.
/// </summary>
public record TenantPropertyDto(
Guid Id,
string Name,
string Street,
string City,
string State,
string ZipCode
);

/// <summary>
/// Handler for GetTenantPropertyQuery.
/// Returns property info for the current tenant user using PropertyId from JWT.
/// </summary>
public class GetTenantPropertyQueryHandler : IRequestHandler<GetTenantPropertyQuery, TenantPropertyDto>
{
private readonly IAppDbContext _dbContext;
private readonly ICurrentUser _currentUser;

public GetTenantPropertyQueryHandler(IAppDbContext dbContext, ICurrentUser currentUser)
{
_dbContext = dbContext;
_currentUser = currentUser;
}

public async Task<TenantPropertyDto> Handle(GetTenantPropertyQuery request, CancellationToken cancellationToken)
{
if (_currentUser.Role != "Tenant")
{
throw new BusinessRuleException("This endpoint is only accessible to Tenant users.");
}

if (!_currentUser.PropertyId.HasValue)
{
throw new BusinessRuleException("Tenant user must have an assigned property.");
}

var property = await _dbContext.Properties
.Where(p => p.Id == _currentUser.PropertyId.Value
&& p.AccountId == _currentUser.AccountId
&& p.DeletedAt == null)
.AsNoTracking()
.FirstOrDefaultAsync(cancellationToken);

if (property is null)
{
throw new NotFoundException(nameof(Domain.Entities.Property), _currentUser.PropertyId.Value);
}

return new TenantPropertyDto(
property.Id,
property.Name,
property.Street,
property.City,
property.State,
property.ZipCode
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
using FluentAssertions;
using MockQueryable.Moq;
using Moq;
using PropertyManager.Application.Common.Interfaces;
using PropertyManager.Application.MaintenanceRequests;
using PropertyManager.Domain.Entities;
using PropertyManager.Domain.Exceptions;

namespace PropertyManager.Application.Tests.MaintenanceRequests;

/// <summary>
/// Unit tests for GetTenantPropertyQueryHandler (Story 20.5, AC #2).
/// </summary>
public class GetTenantPropertyHandlerTests
{
private readonly Mock<IAppDbContext> _dbContextMock;
private readonly Mock<ICurrentUser> _currentUserMock;
private readonly Guid _testAccountId = Guid.NewGuid();
private readonly Guid _testUserId = Guid.NewGuid();
private readonly Guid _testPropertyId = Guid.NewGuid();

public GetTenantPropertyHandlerTests()
{
_dbContextMock = new Mock<IAppDbContext>();
_currentUserMock = new Mock<ICurrentUser>();

_currentUserMock.Setup(x => x.AccountId).Returns(_testAccountId);
_currentUserMock.Setup(x => x.UserId).Returns(_testUserId);
_currentUserMock.Setup(x => x.IsAuthenticated).Returns(true);
_currentUserMock.Setup(x => x.Role).Returns("Tenant");
}

private GetTenantPropertyQueryHandler CreateHandler()
{
return new GetTenantPropertyQueryHandler(_dbContextMock.Object, _currentUserMock.Object);
}

private void SetupDbSet(List<Property> properties)
{
var filtered = properties
.Where(p => p.AccountId == _testAccountId && p.DeletedAt == null)
.ToList();
var mockDbSet = filtered.BuildMockDbSet();
_dbContextMock.Setup(x => x.Properties).Returns(mockDbSet.Object);
}

[Fact]
public async Task Handle_ValidPropertyId_ReturnsPropertyInfo()
{
// Arrange
_currentUserMock.Setup(x => x.PropertyId).Returns(_testPropertyId);

var property = new Property
{
Id = _testPropertyId,
AccountId = _testAccountId,
Name = "Sunset Apartments",
Street = "123 Main St",
City = "Austin",
State = "TX",
ZipCode = "78701"
};

SetupDbSet(new List<Property> { property });

var handler = CreateHandler();

// Act
var result = await handler.Handle(new GetTenantPropertyQuery(), CancellationToken.None);

// Assert
result.Id.Should().Be(_testPropertyId);
result.Name.Should().Be("Sunset Apartments");
result.Street.Should().Be("123 Main St");
result.City.Should().Be("Austin");
result.State.Should().Be("TX");
result.ZipCode.Should().Be("78701");
}

[Fact]
public async Task Handle_PropertyNotFound_ThrowsNotFoundException()
{
// Arrange
_currentUserMock.Setup(x => x.PropertyId).Returns(Guid.NewGuid());

SetupDbSet(new List<Property>());

var handler = CreateHandler();

// Act
var act = () => handler.Handle(new GetTenantPropertyQuery(), CancellationToken.None);

// Assert
await act.Should().ThrowAsync<NotFoundException>();
}

[Fact]
public async Task Handle_NullPropertyId_ThrowsBusinessRuleException()
{
// Arrange
_currentUserMock.Setup(x => x.PropertyId).Returns((Guid?)null);

SetupDbSet(new List<Property>());

var handler = CreateHandler();

// Act
var act = () => handler.Handle(new GetTenantPropertyQuery(), CancellationToken.None);

// Assert
await act.Should().ThrowAsync<BusinessRuleException>();
}

[Fact]
public async Task Handle_NonTenantRole_ThrowsBusinessRuleException()
{
// Arrange
_currentUserMock.Setup(x => x.Role).Returns("Owner");
_currentUserMock.Setup(x => x.PropertyId).Returns(_testPropertyId);

SetupDbSet(new List<Property>());

var handler = CreateHandler();

// Act
var act = () => handler.Handle(new GetTenantPropertyQuery(), CancellationToken.None);

// Assert
await act.Should().ThrowAsync<BusinessRuleException>()
.WithMessage("*Tenant*");
}
}
1 change: 1 addition & 0 deletions docs/project/sprint-status.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -327,3 +327,4 @@ development_status:
20-2-tenant-invitation-flow: done # FR-TP1, FR-TP4, FR-TP5, NFR-TP4 - Size 5
20-3-maintenance-request-entity-api: done # FR-TP18, FR-TP19, FR-TP20, NFR-TP6 - Size 5
20-4-maintenance-request-photos: done # FR-TP21 - Size 5
20-5-tenant-dashboard-role-routing: done # FR-TP6, FR-TP8, FR-TP9, FR-TP11, NFR-TP5, NFR-TP7 - Size 5
Loading
Loading