diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 519ddc1..7563d48 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -58,14 +58,12 @@ jobs: uses: actions/setup-dotnet@v4 with: dotnet-version: ${{ env.DOTNET_VERSION }} - cache: true - cache-dependency-path: '**/packages.lock.json' - name: Cache NuGet packages uses: actions/cache@v4 with: path: ${{ env.NUGET_PACKAGES }} - key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json', '**/Directory.Packages.props') }} + key: ${{ runner.os }}-nuget-${{ hashFiles('**/Directory.Packages.props', '**/*.csproj') }} restore-keys: | ${{ runner.os }}-nuget- @@ -73,7 +71,7 @@ jobs: run: dotnet restore Luminous.sln - name: Build solution - run: dotnet build Luminous.sln --configuration Release --no-restore + run: dotnet build Luminous.sln --configuration Release - name: Run tests run: | diff --git a/Directory.Packages.props b/Directory.Packages.props index ed569b9..5091e7f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -16,6 +16,7 @@ + @@ -41,6 +42,7 @@ + diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 398371f..3a3031a 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # Luminous Development Roadmap -> **Document Version:** 2.7.0 +> **Document Version:** 2.8.0 > **Last Updated:** 2025-12-23 > **Status:** Active > **TOGAF Phase:** Phase E/F (Opportunities, Solutions & Migration Planning) @@ -103,7 +103,7 @@ Phase 6: Intelligence & Ecosystem | Phase | Name | Focus | Key Deliverables | Status | |-------|------|-------|------------------|--------| | **0** | Foundation | Infrastructure | Azure IaC, .NET solution, Angular shell, Passwordless Auth, Local Dev, CI/CD, Docs | ✅ Complete | -| **1** | Core Platform | Multi-tenancy | Family sign-up, device linking, CosmosDB, web MVP | 🔄 In Progress (1.1 Complete) | +| **1** | Core Platform | Multi-tenancy | Family sign-up, device linking, CosmosDB, web MVP | 🔄 In Progress (1.1, 1.2 Complete) | | **2** | Display & Calendar | Calendar visibility | Display app, calendar integration, SignalR sync | ⬜ Not Started | | **3** | Native Mobile | Mobile apps | iOS (Swift), Android (Kotlin), push notifications | ⬜ Not Started | | **4** | Task Management | Chores and routines | Task creation, completion tracking, rewards | ⬜ Not Started | @@ -286,13 +286,29 @@ Deliver the multi-tenant platform with user registration, family creation, devic - *Implemented: ITenantContext and TenantContext services* - *All queries use familyId partition key; tenant access validated in handlers* -#### 1.2 Device Linking +#### 1.2 Device Linking ✅ COMPLETED + +- [x] **1.2.1** Implement link code generation endpoint + - *Implemented: GenerateLinkCodeCommand creates unlinked device with 6-digit code (15-min expiry)* + - *API: POST /api/devices/link-code* +- [x] **1.2.2** Implement link code validation and device registration + - *Implemented: LinkDeviceCommand validates code, links device to family* + - *API: POST /api/devices/link* +- [x] **1.2.3** Implement device token issuance + - *Implemented: LinkDeviceCommand returns LinkedDeviceDto with JWT device token (30-day expiry)* + - *Token includes: device_id, family_id, device_type, device_name claims* +- [x] **1.2.4** Create device management endpoints + - *Implemented: GetDeviceQuery, GetFamilyDevicesQuery, UpdateDeviceCommand, UnlinkDeviceCommand, DeleteDeviceCommand* + - *APIs: GET/PUT/DELETE /api/devices/family/{familyId}/{id}, POST /api/devices/family/{familyId}/{id}/unlink* +- [x] **1.2.5** Implement device heartbeat/status tracking + - *Implemented: RecordHeartbeatCommand updates LastSeenAt, AppVersion; returns DeviceHeartbeatDto* + - *API: POST /api/devices/family/{familyId}/{id}/heartbeat* -- [ ] **1.2.1** Implement link code generation endpoint -- [ ] **1.2.2** Implement link code validation and device registration -- [ ] **1.2.3** Implement device token issuance -- [ ] **1.2.4** Create device management endpoints -- [ ] **1.2.5** Implement device heartbeat/status tracking +**Additional deliverables:** +- [x] Device entity methods: Unlink, Rename, UpdateSettings, Activate, Deactivate +- [x] DeviceSettingsDto, DeviceHeartbeatDto, LinkedDeviceDto DTOs +- [x] Unit tests for Device entity and command handlers +- [x] Authorization: FamilyMember for read, FamilyAdmin for write operations #### 1.3 Family Member Management @@ -706,3 +722,4 @@ These can be developed in parallel after Phase 0: | 2.5.0 | 2025-12-22 | Luminous Team | Phase 0.5 CI/CD Pipeline completed | | 2.6.0 | 2025-12-23 | Luminous Team | Phase 0.6 Documentation completed; Phase 0 complete | | 2.7.0 | 2025-12-23 | Luminous Team | Phase 1.1 Multi-Tenant API completed | +| 2.8.0 | 2025-12-23 | Luminous Team | Phase 1.2 Device Linking completed | diff --git a/src/Luminous.Api/Controllers/DevicesController.cs b/src/Luminous.Api/Controllers/DevicesController.cs index 4b0ff8c..de2d488 100644 --- a/src/Luminous.Api/Controllers/DevicesController.cs +++ b/src/Luminous.Api/Controllers/DevicesController.cs @@ -1,5 +1,6 @@ using Luminous.Application.DTOs; using Luminous.Application.Features.Devices.Commands; +using Luminous.Application.Features.Devices.Queries; using Luminous.Domain.Enums; using Luminous.Shared.Contracts; using Microsoft.AspNetCore.Authorization; @@ -35,13 +36,13 @@ public async Task>> GenerateLinkCode /// Links a device to a family using a link code. /// /// The link request. - /// The linked device. + /// The linked device with authentication token. [HttpPost("link")] [Authorize] - [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] - public async Task>> LinkDevice([FromBody] LinkDeviceRequest request) + public async Task>> LinkDevice([FromBody] LinkDeviceRequest request) { var command = new LinkDeviceCommand { @@ -52,6 +53,147 @@ public async Task>> LinkDevice([FromBody] Li var result = await Mediator.Send(command); return OkResponse(result); } + + /// + /// Gets a device by ID. + /// + /// The family ID. + /// The device ID. + /// The device. + [HttpGet("family/{familyId}/{id}")] + [Authorize(Policy = "FamilyMember")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task>> GetDevice(string familyId, string id) + { + var query = new GetDeviceQuery + { + DeviceId = id, + FamilyId = familyId + }; + var result = await Mediator.Send(query); + return OkResponse(result); + } + + /// + /// Gets all devices for a family. + /// + /// The family ID. + /// If true, only returns active devices. + /// The list of devices. + [HttpGet("family/{familyId}")] + [Authorize(Policy = "FamilyMember")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>>> GetFamilyDevices( + string familyId, + [FromQuery] bool? activeOnly = null) + { + var query = new GetFamilyDevicesQuery + { + FamilyId = familyId, + ActiveOnly = activeOnly + }; + var result = await Mediator.Send(query); + return OkResponse(result); + } + + /// + /// Updates a device. + /// + /// The family ID. + /// The device ID. + /// The update request. + /// The updated device. + [HttpPut("family/{familyId}/{id}")] + [Authorize(Policy = "FamilyAdmin")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task>> UpdateDevice( + string familyId, + string id, + [FromBody] UpdateDeviceRequest request) + { + var command = new UpdateDeviceCommand + { + DeviceId = id, + FamilyId = familyId, + Name = request.Name, + Settings = request.Settings + }; + var result = await Mediator.Send(command); + return OkResponse(result); + } + + /// + /// Unlinks a device from a family. + /// + /// The family ID. + /// The device ID. + /// No content on success. + [HttpPost("family/{familyId}/{id}/unlink")] + [Authorize(Policy = "FamilyAdmin")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task UnlinkDevice(string familyId, string id) + { + var command = new UnlinkDeviceCommand + { + DeviceId = id, + FamilyId = familyId + }; + await Mediator.Send(command); + return NoContent(); + } + + /// + /// Deletes a device. + /// + /// The family ID. + /// The device ID. + /// No content on success. + [HttpDelete("family/{familyId}/{id}")] + [Authorize(Policy = "FamilyAdmin")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task DeleteDevice(string familyId, string id) + { + var command = new DeleteDeviceCommand + { + DeviceId = id, + FamilyId = familyId + }; + await Mediator.Send(command); + return NoContent(); + } + + /// + /// Records a device heartbeat (updates last seen timestamp). + /// + /// The family ID. + /// The device ID. + /// The heartbeat request. + /// The heartbeat response. + [HttpPost("family/{familyId}/{id}/heartbeat")] + [Authorize(Policy = "FamilyMember")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task>> RecordHeartbeat( + string familyId, + string id, + [FromBody] RecordHeartbeatRequest request) + { + var command = new RecordHeartbeatCommand + { + DeviceId = id, + FamilyId = familyId, + AppVersion = request.AppVersion + }; + var result = await Mediator.Send(command); + return OkResponse(result); + } } /// @@ -72,3 +214,20 @@ public record LinkDeviceRequest public string FamilyId { get; init; } = string.Empty; public string DeviceName { get; init; } = string.Empty; } + +/// +/// Request to update a device. +/// +public record UpdateDeviceRequest +{ + public string? Name { get; init; } + public DeviceSettingsDto? Settings { get; init; } +} + +/// +/// Request to record a device heartbeat. +/// +public record RecordHeartbeatRequest +{ + public string? AppVersion { get; init; } +} diff --git a/src/Luminous.Api/Middleware/TenantValidationMiddleware.cs b/src/Luminous.Api/Middleware/TenantValidationMiddleware.cs index 8fc9661..3fa22fc 100644 --- a/src/Luminous.Api/Middleware/TenantValidationMiddleware.cs +++ b/src/Luminous.Api/Middleware/TenantValidationMiddleware.cs @@ -63,7 +63,7 @@ public async Task InvokeAsync(HttpContext context, ITenantContext tenantContext) context.Response.StatusCode = StatusCodes.Status403Forbidden; context.Response.ContentType = "application/json"; - var response = ApiResponse.Failure( + var response = ApiResponse.Fail( "TENANT_ACCESS_DENIED", "You do not have access to this family's data."); diff --git a/src/Luminous.Application/DTOs/DeviceDto.cs b/src/Luminous.Application/DTOs/DeviceDto.cs index 7742490..e685d8d 100644 --- a/src/Luminous.Application/DTOs/DeviceDto.cs +++ b/src/Luminous.Application/DTOs/DeviceDto.cs @@ -43,3 +43,26 @@ public sealed record DeviceLinkCodeDto public string LinkCode { get; init; } = string.Empty; public DateTime ExpiresAt { get; init; } } + +/// +/// Data transfer object for device heartbeat response. +/// +public sealed record DeviceHeartbeatDto +{ + public string DeviceId { get; init; } = string.Empty; + public DateTime LastSeenAt { get; init; } + public bool IsActive { get; init; } + public string? AppVersion { get; init; } +} + +/// +/// Data transfer object for linked device with token response. +/// +public sealed record LinkedDeviceDto +{ + public DeviceDto Device { get; init; } = new(); + public string AccessToken { get; init; } = string.Empty; + public string? RefreshToken { get; init; } + public string TokenType { get; init; } = "Bearer"; + public int ExpiresIn { get; init; } +} diff --git a/src/Luminous.Application/Features/Devices/Commands/DeleteDeviceCommand.cs b/src/Luminous.Application/Features/Devices/Commands/DeleteDeviceCommand.cs new file mode 100644 index 0000000..5f57223 --- /dev/null +++ b/src/Luminous.Application/Features/Devices/Commands/DeleteDeviceCommand.cs @@ -0,0 +1,54 @@ +using FluentValidation; +using Luminous.Application.Common.Exceptions; +using Luminous.Domain.Interfaces; +using MediatR; + +namespace Luminous.Application.Features.Devices.Commands; + +/// +/// Command to delete a device. +/// +public sealed record DeleteDeviceCommand : IRequest +{ + public string DeviceId { get; init; } = string.Empty; + public string FamilyId { get; init; } = string.Empty; +} + +/// +/// Validator for DeleteDeviceCommand. +/// +public sealed class DeleteDeviceCommandValidator : AbstractValidator +{ + public DeleteDeviceCommandValidator() + { + RuleFor(x => x.DeviceId) + .NotEmpty().WithMessage("Device ID is required."); + + RuleFor(x => x.FamilyId) + .NotEmpty().WithMessage("Family ID is required."); + } +} + +/// +/// Handler for DeleteDeviceCommand. +/// +public sealed class DeleteDeviceCommandHandler : IRequestHandler +{ + private readonly IUnitOfWork _unitOfWork; + + public DeleteDeviceCommandHandler(IUnitOfWork unitOfWork) + { + _unitOfWork = unitOfWork; + } + + public async Task Handle(DeleteDeviceCommand request, CancellationToken cancellationToken) + { + var device = await _unitOfWork.Devices.GetByIdAsync(request.DeviceId, request.FamilyId, cancellationToken) + ?? throw new NotFoundException("Device", request.DeviceId); + + await _unitOfWork.Devices.DeleteAsync(device, cancellationToken); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + return Unit.Value; + } +} diff --git a/src/Luminous.Application/Features/Devices/Commands/LinkDeviceCommand.cs b/src/Luminous.Application/Features/Devices/Commands/LinkDeviceCommand.cs index ec5bfa3..fc57197 100644 --- a/src/Luminous.Application/Features/Devices/Commands/LinkDeviceCommand.cs +++ b/src/Luminous.Application/Features/Devices/Commands/LinkDeviceCommand.cs @@ -10,7 +10,7 @@ namespace Luminous.Application.Features.Devices.Commands; /// /// Command to link a device to a family. /// -public sealed record LinkDeviceCommand : IRequest +public sealed record LinkDeviceCommand : IRequest { public string LinkCode { get; init; } = string.Empty; public string FamilyId { get; init; } = string.Empty; @@ -40,18 +40,23 @@ public LinkDeviceCommandValidator() /// /// Handler for LinkDeviceCommand. /// -public sealed class LinkDeviceCommandHandler : IRequestHandler +public sealed class LinkDeviceCommandHandler : IRequestHandler { private readonly IUnitOfWork _unitOfWork; private readonly ICurrentUserService _currentUserService; + private readonly ITokenService _tokenService; - public LinkDeviceCommandHandler(IUnitOfWork unitOfWork, ICurrentUserService currentUserService) + public LinkDeviceCommandHandler( + IUnitOfWork unitOfWork, + ICurrentUserService currentUserService, + ITokenService tokenService) { _unitOfWork = unitOfWork; _currentUserService = currentUserService; + _tokenService = tokenService; } - public async Task Handle(LinkDeviceCommand request, CancellationToken cancellationToken) + public async Task Handle(LinkDeviceCommand request, CancellationToken cancellationToken) { // Find device by link code var device = await _unitOfWork.Devices.GetByLinkCodeAsync(request.LinkCode, cancellationToken) @@ -60,7 +65,7 @@ public async Task Handle(LinkDeviceCommand request, CancellationToken // Verify link code is still valid if (!device.IsLinkCodeValid) { - throw new ValidationException([new FluentValidation.Results.ValidationFailure( + throw new FluentValidation.ValidationException([new FluentValidation.Results.ValidationFailure( "LinkCode", "Link code has expired. Please generate a new code.")]); } @@ -77,7 +82,10 @@ public async Task Handle(LinkDeviceCommand request, CancellationToken await _unitOfWork.Devices.UpdateAsync(device, cancellationToken); await _unitOfWork.SaveChangesAsync(cancellationToken); - return new DeviceDto + // Generate device token + var authResult = _tokenService.GenerateDeviceToken(device, request.FamilyId); + + var deviceDto = new DeviceDto { Id = device.Id, FamilyId = device.FamilyId, @@ -100,5 +108,14 @@ public async Task Handle(LinkDeviceCommand request, CancellationToken Platform = device.Platform, AppVersion = device.AppVersion }; + + return new LinkedDeviceDto + { + Device = deviceDto, + AccessToken = authResult.AccessToken, + RefreshToken = authResult.RefreshToken, + TokenType = authResult.TokenType, + ExpiresIn = authResult.ExpiresIn + }; } } diff --git a/src/Luminous.Application/Features/Devices/Commands/RecordHeartbeatCommand.cs b/src/Luminous.Application/Features/Devices/Commands/RecordHeartbeatCommand.cs new file mode 100644 index 0000000..1ba236b --- /dev/null +++ b/src/Luminous.Application/Features/Devices/Commands/RecordHeartbeatCommand.cs @@ -0,0 +1,76 @@ +using FluentValidation; +using Luminous.Application.Common.Exceptions; +using Luminous.Application.DTOs; +using Luminous.Domain.Interfaces; +using MediatR; + +namespace Luminous.Application.Features.Devices.Commands; + +/// +/// Command to record a device heartbeat (used for tracking device online status). +/// +public sealed record RecordHeartbeatCommand : IRequest +{ + public string DeviceId { get; init; } = string.Empty; + public string FamilyId { get; init; } = string.Empty; + public string? AppVersion { get; init; } +} + +/// +/// Validator for RecordHeartbeatCommand. +/// +public sealed class RecordHeartbeatCommandValidator : AbstractValidator +{ + public RecordHeartbeatCommandValidator() + { + RuleFor(x => x.DeviceId) + .NotEmpty().WithMessage("Device ID is required."); + + RuleFor(x => x.FamilyId) + .NotEmpty().WithMessage("Family ID is required."); + + When(x => x.AppVersion != null, () => + { + RuleFor(x => x.AppVersion) + .MaximumLength(50).WithMessage("App version must not exceed 50 characters."); + }); + } +} + +/// +/// Handler for RecordHeartbeatCommand. +/// +public sealed class RecordHeartbeatCommandHandler : IRequestHandler +{ + private readonly IUnitOfWork _unitOfWork; + + public RecordHeartbeatCommandHandler(IUnitOfWork unitOfWork) + { + _unitOfWork = unitOfWork; + } + + public async Task Handle(RecordHeartbeatCommand request, CancellationToken cancellationToken) + { + var device = await _unitOfWork.Devices.GetByIdAsync(request.DeviceId, request.FamilyId, cancellationToken) + ?? throw new NotFoundException("Device", request.DeviceId); + + if (!device.IsLinked) + { + throw new FluentValidation.ValidationException([new FluentValidation.Results.ValidationFailure( + "DeviceId", "Device is not linked to any family.")]); + } + + device.RecordHeartbeat(request.AppVersion); + + await _unitOfWork.Devices.UpdateAsync(device, cancellationToken); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + return new DeviceHeartbeatDto + { + DeviceId = device.Id, + LastSeenAt = device.LastSeenAt, + IsActive = device.IsActive, + AppVersion = device.AppVersion + }; + } +} diff --git a/src/Luminous.Application/Features/Devices/Commands/UnlinkDeviceCommand.cs b/src/Luminous.Application/Features/Devices/Commands/UnlinkDeviceCommand.cs new file mode 100644 index 0000000..a5e63ab --- /dev/null +++ b/src/Luminous.Application/Features/Devices/Commands/UnlinkDeviceCommand.cs @@ -0,0 +1,65 @@ +using FluentValidation; +using Luminous.Application.Common.Exceptions; +using Luminous.Application.Common.Interfaces; +using Luminous.Domain.Interfaces; +using MediatR; + +namespace Luminous.Application.Features.Devices.Commands; + +/// +/// Command to unlink a device from a family. +/// +public sealed record UnlinkDeviceCommand : IRequest +{ + public string DeviceId { get; init; } = string.Empty; + public string FamilyId { get; init; } = string.Empty; +} + +/// +/// Validator for UnlinkDeviceCommand. +/// +public sealed class UnlinkDeviceCommandValidator : AbstractValidator +{ + public UnlinkDeviceCommandValidator() + { + RuleFor(x => x.DeviceId) + .NotEmpty().WithMessage("Device ID is required."); + + RuleFor(x => x.FamilyId) + .NotEmpty().WithMessage("Family ID is required."); + } +} + +/// +/// Handler for UnlinkDeviceCommand. +/// +public sealed class UnlinkDeviceCommandHandler : IRequestHandler +{ + private readonly IUnitOfWork _unitOfWork; + private readonly ICurrentUserService _currentUserService; + + public UnlinkDeviceCommandHandler(IUnitOfWork unitOfWork, ICurrentUserService currentUserService) + { + _unitOfWork = unitOfWork; + _currentUserService = currentUserService; + } + + public async Task Handle(UnlinkDeviceCommand request, CancellationToken cancellationToken) + { + var device = await _unitOfWork.Devices.GetByIdAsync(request.DeviceId, request.FamilyId, cancellationToken) + ?? throw new NotFoundException("Device", request.DeviceId); + + if (!device.IsLinked) + { + throw new FluentValidation.ValidationException([new FluentValidation.Results.ValidationFailure( + "DeviceId", "Device is not linked to any family.")]); + } + + device.Unlink(_currentUserService.UserId ?? "system"); + + await _unitOfWork.Devices.UpdateAsync(device, cancellationToken); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + return Unit.Value; + } +} diff --git a/src/Luminous.Application/Features/Devices/Commands/UpdateDeviceCommand.cs b/src/Luminous.Application/Features/Devices/Commands/UpdateDeviceCommand.cs new file mode 100644 index 0000000..fc996de --- /dev/null +++ b/src/Luminous.Application/Features/Devices/Commands/UpdateDeviceCommand.cs @@ -0,0 +1,129 @@ +using FluentValidation; +using Luminous.Application.Common.Exceptions; +using Luminous.Application.Common.Interfaces; +using Luminous.Application.DTOs; +using Luminous.Domain.Interfaces; +using Luminous.Domain.ValueObjects; +using MediatR; + +namespace Luminous.Application.Features.Devices.Commands; + +/// +/// Command to update a device's name and/or settings. +/// +public sealed record UpdateDeviceCommand : IRequest +{ + public string DeviceId { get; init; } = string.Empty; + public string FamilyId { get; init; } = string.Empty; + public string? Name { get; init; } + public DeviceSettingsDto? Settings { get; init; } +} + +/// +/// Validator for UpdateDeviceCommand. +/// +public sealed class UpdateDeviceCommandValidator : AbstractValidator +{ + public UpdateDeviceCommandValidator() + { + RuleFor(x => x.DeviceId) + .NotEmpty().WithMessage("Device ID is required."); + + RuleFor(x => x.FamilyId) + .NotEmpty().WithMessage("Family ID is required."); + + When(x => x.Name != null, () => + { + RuleFor(x => x.Name) + .MaximumLength(50).WithMessage("Device name must not exceed 50 characters."); + }); + + When(x => x.Settings != null, () => + { + RuleFor(x => x.Settings!.Brightness) + .InclusiveBetween(0, 100).WithMessage("Brightness must be between 0 and 100."); + + RuleFor(x => x.Settings!.Volume) + .InclusiveBetween(0, 100).WithMessage("Volume must be between 0 and 100."); + + RuleFor(x => x.Settings!.Orientation) + .Must(o => o == "portrait" || o == "landscape") + .WithMessage("Orientation must be 'portrait' or 'landscape'."); + + RuleFor(x => x.Settings!.DefaultView) + .Must(v => new[] { "day", "week", "month", "agenda" }.Contains(v)) + .WithMessage("Default view must be 'day', 'week', 'month', or 'agenda'."); + }); + } +} + +/// +/// Handler for UpdateDeviceCommand. +/// +public sealed class UpdateDeviceCommandHandler : IRequestHandler +{ + private readonly IUnitOfWork _unitOfWork; + private readonly ICurrentUserService _currentUserService; + + public UpdateDeviceCommandHandler(IUnitOfWork unitOfWork, ICurrentUserService currentUserService) + { + _unitOfWork = unitOfWork; + _currentUserService = currentUserService; + } + + public async Task Handle(UpdateDeviceCommand request, CancellationToken cancellationToken) + { + var device = await _unitOfWork.Devices.GetByIdAsync(request.DeviceId, request.FamilyId, cancellationToken) + ?? throw new NotFoundException("Device", request.DeviceId); + + var modifiedBy = _currentUserService.UserId ?? "system"; + + // Update name if provided + if (!string.IsNullOrEmpty(request.Name)) + { + device.Rename(request.Name, modifiedBy); + } + + // Update settings if provided + if (request.Settings != null) + { + var settings = new DeviceSettings + { + DefaultView = request.Settings.DefaultView, + Brightness = request.Settings.Brightness, + AutoBrightness = request.Settings.AutoBrightness, + Orientation = request.Settings.Orientation, + SoundEnabled = request.Settings.SoundEnabled, + Volume = request.Settings.Volume + }; + device.UpdateSettings(settings, modifiedBy); + } + + await _unitOfWork.Devices.UpdateAsync(device, cancellationToken); + await _unitOfWork.SaveChangesAsync(cancellationToken); + + return new DeviceDto + { + Id = device.Id, + FamilyId = device.FamilyId, + Type = device.Type, + Name = device.Name, + IsLinked = device.IsLinked, + LinkedAt = device.LinkedAt, + LinkedBy = device.LinkedBy, + Settings = new DeviceSettingsDto + { + DefaultView = device.Settings.DefaultView, + Brightness = device.Settings.Brightness, + AutoBrightness = device.Settings.AutoBrightness, + Orientation = device.Settings.Orientation, + SoundEnabled = device.Settings.SoundEnabled, + Volume = device.Settings.Volume + }, + LastSeenAt = device.LastSeenAt, + IsActive = device.IsActive, + Platform = device.Platform, + AppVersion = device.AppVersion + }; + } +} diff --git a/src/Luminous.Application/Features/Devices/Queries/GetDeviceQuery.cs b/src/Luminous.Application/Features/Devices/Queries/GetDeviceQuery.cs new file mode 100644 index 0000000..ef581f2 --- /dev/null +++ b/src/Luminous.Application/Features/Devices/Queries/GetDeviceQuery.cs @@ -0,0 +1,74 @@ +using FluentValidation; +using Luminous.Application.Common.Exceptions; +using Luminous.Application.DTOs; +using Luminous.Domain.Interfaces; +using MediatR; + +namespace Luminous.Application.Features.Devices.Queries; + +/// +/// Query to get a device by ID. +/// +public sealed record GetDeviceQuery : IRequest +{ + public string DeviceId { get; init; } = string.Empty; + public string FamilyId { get; init; } = string.Empty; +} + +/// +/// Validator for GetDeviceQuery. +/// +public sealed class GetDeviceQueryValidator : AbstractValidator +{ + public GetDeviceQueryValidator() + { + RuleFor(x => x.DeviceId) + .NotEmpty().WithMessage("Device ID is required."); + + RuleFor(x => x.FamilyId) + .NotEmpty().WithMessage("Family ID is required."); + } +} + +/// +/// Handler for GetDeviceQuery. +/// +public sealed class GetDeviceQueryHandler : IRequestHandler +{ + private readonly IUnitOfWork _unitOfWork; + + public GetDeviceQueryHandler(IUnitOfWork unitOfWork) + { + _unitOfWork = unitOfWork; + } + + public async Task Handle(GetDeviceQuery request, CancellationToken cancellationToken) + { + var device = await _unitOfWork.Devices.GetByIdAsync(request.DeviceId, request.FamilyId, cancellationToken) + ?? throw new NotFoundException("Device", request.DeviceId); + + return new DeviceDto + { + Id = device.Id, + FamilyId = device.FamilyId, + Type = device.Type, + Name = device.Name, + IsLinked = device.IsLinked, + LinkedAt = device.LinkedAt, + LinkedBy = device.LinkedBy, + Settings = new DeviceSettingsDto + { + DefaultView = device.Settings.DefaultView, + Brightness = device.Settings.Brightness, + AutoBrightness = device.Settings.AutoBrightness, + Orientation = device.Settings.Orientation, + SoundEnabled = device.Settings.SoundEnabled, + Volume = device.Settings.Volume + }, + LastSeenAt = device.LastSeenAt, + IsActive = device.IsActive, + Platform = device.Platform, + AppVersion = device.AppVersion + }; + } +} diff --git a/src/Luminous.Application/Features/Devices/Queries/GetFamilyDevicesQuery.cs b/src/Luminous.Application/Features/Devices/Queries/GetFamilyDevicesQuery.cs new file mode 100644 index 0000000..51170c3 --- /dev/null +++ b/src/Luminous.Application/Features/Devices/Queries/GetFamilyDevicesQuery.cs @@ -0,0 +1,74 @@ +using FluentValidation; +using Luminous.Application.DTOs; +using Luminous.Domain.Interfaces; +using MediatR; + +namespace Luminous.Application.Features.Devices.Queries; + +/// +/// Query to get all devices for a family. +/// +public sealed record GetFamilyDevicesQuery : IRequest> +{ + public string FamilyId { get; init; } = string.Empty; + public bool? ActiveOnly { get; init; } +} + +/// +/// Validator for GetFamilyDevicesQuery. +/// +public sealed class GetFamilyDevicesQueryValidator : AbstractValidator +{ + public GetFamilyDevicesQueryValidator() + { + RuleFor(x => x.FamilyId) + .NotEmpty().WithMessage("Family ID is required."); + } +} + +/// +/// Handler for GetFamilyDevicesQuery. +/// +public sealed class GetFamilyDevicesQueryHandler : IRequestHandler> +{ + private readonly IUnitOfWork _unitOfWork; + + public GetFamilyDevicesQueryHandler(IUnitOfWork unitOfWork) + { + _unitOfWork = unitOfWork; + } + + public async Task> Handle(GetFamilyDevicesQuery request, CancellationToken cancellationToken) + { + var devices = await _unitOfWork.Devices.GetByFamilyIdAsync(request.FamilyId, cancellationToken); + + if (request.ActiveOnly == true) + { + devices = devices.Where(d => d.IsActive).ToList(); + } + + return devices.Select(device => new DeviceDto + { + Id = device.Id, + FamilyId = device.FamilyId, + Type = device.Type, + Name = device.Name, + IsLinked = device.IsLinked, + LinkedAt = device.LinkedAt, + LinkedBy = device.LinkedBy, + Settings = new DeviceSettingsDto + { + DefaultView = device.Settings.DefaultView, + Brightness = device.Settings.Brightness, + AutoBrightness = device.Settings.AutoBrightness, + Orientation = device.Settings.Orientation, + SoundEnabled = device.Settings.SoundEnabled, + Volume = device.Settings.Volume + }, + LastSeenAt = device.LastSeenAt, + IsActive = device.IsActive, + Platform = device.Platform, + AppVersion = device.AppVersion + }).ToList(); + } +} diff --git a/src/Luminous.Application/Luminous.Application.csproj b/src/Luminous.Application/Luminous.Application.csproj index 6776295..1ce8041 100644 --- a/src/Luminous.Application/Luminous.Application.csproj +++ b/src/Luminous.Application/Luminous.Application.csproj @@ -10,6 +10,7 @@ + diff --git a/src/Luminous.Domain/Common/DomainEvent.cs b/src/Luminous.Domain/Common/DomainEvent.cs index 415289b..f2ee717 100644 --- a/src/Luminous.Domain/Common/DomainEvent.cs +++ b/src/Luminous.Domain/Common/DomainEvent.cs @@ -1,4 +1,4 @@ -using Nanoid; +using NanoidDotNet; namespace Luminous.Domain.Common; diff --git a/src/Luminous.Domain/Common/Entity.cs b/src/Luminous.Domain/Common/Entity.cs index a07e272..f6dd448 100644 --- a/src/Luminous.Domain/Common/Entity.cs +++ b/src/Luminous.Domain/Common/Entity.cs @@ -1,4 +1,4 @@ -using Nanoid; +using NanoidDotNet; namespace Luminous.Domain.Common; diff --git a/src/Luminous.Domain/Entities/Device.cs b/src/Luminous.Domain/Entities/Device.cs index fe14a7f..4ce996f 100644 --- a/src/Luminous.Domain/Entities/Device.cs +++ b/src/Luminous.Domain/Entities/Device.cs @@ -125,6 +125,67 @@ public void RecordHeartbeat(string? appVersion = null) AppVersion = appVersion; } + /// + /// Unlinks this device from its family. + /// + /// The user ID who unlinked the device. + public void Unlink(string unlinkedBy) + { + if (!IsLinked) + throw new InvalidOperationException("Device is not linked."); + + FamilyId = string.Empty; + Name = string.Empty; + LinkedAt = null; + LinkedBy = null; + IsActive = false; + ModifiedAt = DateTime.UtcNow; + ModifiedBy = unlinkedBy; + } + + /// + /// Updates the device settings. + /// + public void UpdateSettings(DeviceSettings settings, string modifiedBy) + { + Settings = settings ?? throw new ArgumentNullException(nameof(settings)); + ModifiedAt = DateTime.UtcNow; + ModifiedBy = modifiedBy; + } + + /// + /// Updates the device name. + /// + public void Rename(string name, string modifiedBy) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Device name is required.", nameof(name)); + + Name = name.Trim(); + ModifiedAt = DateTime.UtcNow; + ModifiedBy = modifiedBy; + } + + /// + /// Deactivates the device. + /// + public void Deactivate(string modifiedBy) + { + IsActive = false; + ModifiedAt = DateTime.UtcNow; + ModifiedBy = modifiedBy; + } + + /// + /// Activates the device. + /// + public void Activate(string modifiedBy) + { + IsActive = true; + ModifiedAt = DateTime.UtcNow; + ModifiedBy = modifiedBy; + } + /// /// Generates a new 6-digit link code. /// diff --git a/src/Luminous.Domain/Entities/FamilyList.cs b/src/Luminous.Domain/Entities/FamilyList.cs index e16255b..3f92613 100644 --- a/src/Luminous.Domain/Entities/FamilyList.cs +++ b/src/Luminous.Domain/Entities/FamilyList.cs @@ -1,5 +1,5 @@ using Luminous.Domain.Common; -using Nanoid; +using NanoidDotNet; namespace Luminous.Domain.Entities; diff --git a/src/Luminous.Domain/Entities/Invitation.cs b/src/Luminous.Domain/Entities/Invitation.cs index ba83162..e77935c 100644 --- a/src/Luminous.Domain/Entities/Invitation.cs +++ b/src/Luminous.Domain/Entities/Invitation.cs @@ -1,6 +1,6 @@ using Luminous.Domain.Common; using Luminous.Domain.Enums; -using Nanoid; +using NanoidDotNet; namespace Luminous.Domain.Entities; diff --git a/src/Luminous.Domain/Interfaces/IDeviceRepository.cs b/src/Luminous.Domain/Interfaces/IDeviceRepository.cs index be2c135..5826e9e 100644 --- a/src/Luminous.Domain/Interfaces/IDeviceRepository.cs +++ b/src/Luminous.Domain/Interfaces/IDeviceRepository.cs @@ -10,7 +10,7 @@ public interface IDeviceRepository : IRepository /// /// Gets a device by ID within a family. /// - Task GetByIdAsync(string deviceId, string familyId, CancellationToken cancellationToken = default); + new Task GetByIdAsync(string deviceId, string? familyId, CancellationToken cancellationToken = default); /// /// Gets a device by its link code (for linking flow). diff --git a/src/Luminous.Domain/Interfaces/IUserRepository.cs b/src/Luminous.Domain/Interfaces/IUserRepository.cs index d568ad7..34e1d5e 100644 --- a/src/Luminous.Domain/Interfaces/IUserRepository.cs +++ b/src/Luminous.Domain/Interfaces/IUserRepository.cs @@ -10,7 +10,7 @@ public interface IUserRepository : IRepository /// /// Gets a user by ID within a family. /// - Task GetByIdAsync(string userId, string familyId, CancellationToken cancellationToken = default); + new Task GetByIdAsync(string userId, string? familyId, CancellationToken cancellationToken = default); /// /// Gets a user by email address. diff --git a/src/Luminous.Infrastructure/Luminous.Infrastructure.csproj b/src/Luminous.Infrastructure/Luminous.Infrastructure.csproj index e7baef1..0f66df0 100644 --- a/src/Luminous.Infrastructure/Luminous.Infrastructure.csproj +++ b/src/Luminous.Infrastructure/Luminous.Infrastructure.csproj @@ -8,6 +8,7 @@ + diff --git a/src/Luminous.Infrastructure/Persistence/CosmosDbContext.cs b/src/Luminous.Infrastructure/Persistence/CosmosDbContext.cs index 64908f5..64afc3e 100644 --- a/src/Luminous.Infrastructure/Persistence/CosmosDbContext.cs +++ b/src/Luminous.Infrastructure/Persistence/CosmosDbContext.cs @@ -57,7 +57,7 @@ public CosmosDbContext(IOptions settings, ILogger /// Gets the Cosmos DB database. /// - public async Task GetDatabaseAsync(CancellationToken cancellationToken = default) + public Task GetDatabaseAsync(CancellationToken cancellationToken = default) { if (_database == null) { @@ -65,7 +65,7 @@ public async Task GetDatabaseAsync(CancellationToken cancellationToken _logger.LogDebug("Connected to database {DatabaseName}", _settings.DatabaseName); } - return _database; + return Task.FromResult(_database); } /// @@ -113,12 +113,13 @@ public Task GetDevicesContainerAsync(CancellationToken cancellationTo public Task GetCredentialsContainerAsync(CancellationToken cancellationToken = default) => GetContainerAsync(ContainerNames.Credentials, cancellationToken); - public async ValueTask DisposeAsync() + public ValueTask DisposeAsync() { if (!_disposed) { _client.Dispose(); _disposed = true; } + return ValueTask.CompletedTask; } } diff --git a/src/Luminous.Infrastructure/Persistence/Repositories/DeviceRepository.cs b/src/Luminous.Infrastructure/Persistence/Repositories/DeviceRepository.cs index 896e976..e9738ce 100644 --- a/src/Luminous.Infrastructure/Persistence/Repositories/DeviceRepository.cs +++ b/src/Luminous.Infrastructure/Persistence/Repositories/DeviceRepository.cs @@ -26,9 +26,9 @@ protected override PartitionKey GetPartitionKey(Device entity) protected override string GetPartitionKeyPath() => "/familyId"; - public async Task GetByIdAsync(string deviceId, string familyId, CancellationToken cancellationToken = default) + public new async Task GetByIdAsync(string deviceId, string? familyId, CancellationToken cancellationToken = default) { - return await GetByIdAsync(deviceId, familyId, cancellationToken); + return await base.GetByIdAsync(deviceId, familyId, cancellationToken); } public async Task GetByLinkCodeAsync(string linkCode, CancellationToken cancellationToken = default) diff --git a/src/Luminous.Infrastructure/Persistence/Repositories/UserRepository.cs b/src/Luminous.Infrastructure/Persistence/Repositories/UserRepository.cs index ec80eea..b0fd6fc 100644 --- a/src/Luminous.Infrastructure/Persistence/Repositories/UserRepository.cs +++ b/src/Luminous.Infrastructure/Persistence/Repositories/UserRepository.cs @@ -1,8 +1,8 @@ -using Luminous.Domain.Entities; using Luminous.Domain.Interfaces; using Luminous.Infrastructure.Persistence.Configuration; using Microsoft.Azure.Cosmos; using Microsoft.Extensions.Logging; +using User = Luminous.Domain.Entities.User; namespace Luminous.Infrastructure.Persistence.Repositories; @@ -20,9 +20,9 @@ public UserRepository(CosmosDbContext context, ILogger logger) protected override string GetPartitionKeyPath() => "/familyId"; - public async Task GetByIdAsync(string userId, string familyId, CancellationToken cancellationToken = default) + public new async Task GetByIdAsync(string userId, string? familyId, CancellationToken cancellationToken = default) { - return await GetByIdAsync(userId, familyId, cancellationToken); + return await base.GetByIdAsync(userId, familyId, cancellationToken); } public async Task GetByEmailAsync(string email, CancellationToken cancellationToken = default) diff --git a/tests/Luminous.Api.Tests/Controllers/FamiliesControllerTests.cs b/tests/Luminous.Api.Tests/Controllers/FamiliesControllerTests.cs index 4bc50e3..06085de 100644 --- a/tests/Luminous.Api.Tests/Controllers/FamiliesControllerTests.cs +++ b/tests/Luminous.Api.Tests/Controllers/FamiliesControllerTests.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Moq; +using Xunit; namespace Luminous.Api.Tests.Controllers; diff --git a/tests/Luminous.Application.Tests/Devices/Commands/LinkDeviceCommandTests.cs b/tests/Luminous.Application.Tests/Devices/Commands/LinkDeviceCommandTests.cs new file mode 100644 index 0000000..60ab262 --- /dev/null +++ b/tests/Luminous.Application.Tests/Devices/Commands/LinkDeviceCommandTests.cs @@ -0,0 +1,245 @@ +using FluentAssertions; +using Luminous.Application.Common.Exceptions; +using Luminous.Application.Common.Interfaces; +using Luminous.Application.Features.Devices.Commands; +using Luminous.Domain.Entities; +using Luminous.Domain.Enums; +using Luminous.Domain.Interfaces; +using Moq; +using Xunit; + +namespace Luminous.Application.Tests.Devices.Commands; + +public class LinkDeviceCommandTests +{ + private readonly Mock _unitOfWorkMock; + private readonly Mock _currentUserServiceMock; + private readonly Mock _tokenServiceMock; + private readonly Mock _deviceRepositoryMock; + private readonly Mock _familyRepositoryMock; + private readonly LinkDeviceCommandHandler _handler; + + public LinkDeviceCommandTests() + { + _unitOfWorkMock = new Mock(); + _currentUserServiceMock = new Mock(); + _tokenServiceMock = new Mock(); + _deviceRepositoryMock = new Mock(); + _familyRepositoryMock = new Mock(); + + _unitOfWorkMock.Setup(x => x.Devices).Returns(_deviceRepositoryMock.Object); + _unitOfWorkMock.Setup(x => x.Families).Returns(_familyRepositoryMock.Object); + _currentUserServiceMock.Setup(x => x.UserId).Returns("user-123"); + + _handler = new LinkDeviceCommandHandler( + _unitOfWorkMock.Object, + _currentUserServiceMock.Object, + _tokenServiceMock.Object); + } + + [Fact] + public async Task Handle_WithValidLinkCode_ShouldLinkDeviceAndReturnToken() + { + // Arrange + var device = Device.CreateWithLinkCode(DeviceType.Display); + var family = new Family { Id = "family-123", Name = "Test Family" }; + + var command = new LinkDeviceCommand + { + LinkCode = device.LinkCode!, + FamilyId = family.Id, + DeviceName = "Kitchen Display" + }; + + _deviceRepositoryMock.Setup(x => x.GetByLinkCodeAsync(device.LinkCode!, It.IsAny())) + .ReturnsAsync(device); + _familyRepositoryMock.Setup(x => x.GetByIdAsync(family.Id, It.IsAny())) + .ReturnsAsync(family); + _tokenServiceMock.Setup(x => x.GenerateDeviceToken(It.IsAny(), It.IsAny())) + .Returns(new DTOs.AuthResultDto + { + AccessToken = "test-token", + RefreshToken = "refresh-token", + TokenType = "Bearer", + ExpiresIn = 2592000 + }); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result.Device.Name.Should().Be("Kitchen Display"); + result.Device.FamilyId.Should().Be(family.Id); + result.Device.IsLinked.Should().BeTrue(); + result.AccessToken.Should().Be("test-token"); + result.RefreshToken.Should().Be("refresh-token"); + + _deviceRepositoryMock.Verify(x => x.UpdateAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WithInvalidLinkCode_ShouldThrowNotFoundException() + { + // Arrange + var command = new LinkDeviceCommand + { + LinkCode = "123456", + FamilyId = "family-123", + DeviceName = "Kitchen Display" + }; + + _deviceRepositoryMock.Setup(x => x.GetByLinkCodeAsync("123456", It.IsAny())) + .ReturnsAsync((Device?)null); + + // Act + var act = async () => await _handler.Handle(command, CancellationToken.None); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*link code*"); + } + + [Fact] + public async Task Handle_WithExpiredLinkCode_ShouldThrowValidationException() + { + // Arrange + var device = new Device + { + Type = DeviceType.Display, + LinkCode = "123456", + LinkCodeExpiry = DateTime.UtcNow.AddMinutes(-5) // Expired + }; + + var command = new LinkDeviceCommand + { + LinkCode = "123456", + FamilyId = "family-123", + DeviceName = "Kitchen Display" + }; + + _deviceRepositoryMock.Setup(x => x.GetByLinkCodeAsync("123456", It.IsAny())) + .ReturnsAsync(device); + + // Act + var act = async () => await _handler.Handle(command, CancellationToken.None); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*expired*"); + } + + [Fact] + public async Task Handle_WithInvalidFamily_ShouldThrowNotFoundException() + { + // Arrange + var device = Device.CreateWithLinkCode(DeviceType.Display); + + var command = new LinkDeviceCommand + { + LinkCode = device.LinkCode!, + FamilyId = "invalid-family", + DeviceName = "Kitchen Display" + }; + + _deviceRepositoryMock.Setup(x => x.GetByLinkCodeAsync(device.LinkCode!, It.IsAny())) + .ReturnsAsync(device); + _familyRepositoryMock.Setup(x => x.GetByIdAsync("invalid-family", It.IsAny())) + .ReturnsAsync((Family?)null); + + // Act + var act = async () => await _handler.Handle(command, CancellationToken.None); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*Family*"); + } +} + +public class LinkDeviceCommandValidatorTests +{ + private readonly LinkDeviceCommandValidator _validator; + + public LinkDeviceCommandValidatorTests() + { + _validator = new LinkDeviceCommandValidator(); + } + + [Fact] + public void Validate_WithValidData_ShouldPass() + { + // Arrange + var command = new LinkDeviceCommand + { + LinkCode = "123456", + FamilyId = "family-123", + DeviceName = "Kitchen Display" + }; + + // Act + var result = _validator.Validate(command); + + // Assert + result.IsValid.Should().BeTrue(); + } + + [Theory] + [InlineData("")] + [InlineData("12345")] + [InlineData("1234567")] + public void Validate_WithInvalidLinkCode_ShouldFail(string linkCode) + { + // Arrange + var command = new LinkDeviceCommand + { + LinkCode = linkCode, + FamilyId = "family-123", + DeviceName = "Kitchen Display" + }; + + // Act + var result = _validator.Validate(command); + + // Assert + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "LinkCode"); + } + + [Fact] + public void Validate_WithEmptyFamilyId_ShouldFail() + { + // Arrange + var command = new LinkDeviceCommand + { + LinkCode = "123456", + FamilyId = "", + DeviceName = "Kitchen Display" + }; + + // Act + var result = _validator.Validate(command); + + // Assert + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "FamilyId"); + } + + [Fact] + public void Validate_WithDeviceNameTooLong_ShouldFail() + { + // Arrange + var command = new LinkDeviceCommand + { + LinkCode = "123456", + FamilyId = "family-123", + DeviceName = new string('a', 51) + }; + + // Act + var result = _validator.Validate(command); + + // Assert + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "DeviceName"); + } +} diff --git a/tests/Luminous.Application.Tests/Devices/Commands/RecordHeartbeatCommandTests.cs b/tests/Luminous.Application.Tests/Devices/Commands/RecordHeartbeatCommandTests.cs new file mode 100644 index 0000000..0f1da2b --- /dev/null +++ b/tests/Luminous.Application.Tests/Devices/Commands/RecordHeartbeatCommandTests.cs @@ -0,0 +1,166 @@ +using FluentAssertions; +using Luminous.Application.Common.Exceptions; +using Luminous.Application.Features.Devices.Commands; +using Luminous.Domain.Entities; +using Luminous.Domain.Enums; +using Luminous.Domain.Interfaces; +using Moq; +using NanoidDotNet; +using Xunit; + +namespace Luminous.Application.Tests.Devices.Commands; + +public class RecordHeartbeatCommandTests +{ + private readonly Mock _unitOfWorkMock; + private readonly Mock _deviceRepositoryMock; + private readonly RecordHeartbeatCommandHandler _handler; + + public RecordHeartbeatCommandTests() + { + _unitOfWorkMock = new Mock(); + _deviceRepositoryMock = new Mock(); + + _unitOfWorkMock.Setup(x => x.Devices).Returns(_deviceRepositoryMock.Object); + + _handler = new RecordHeartbeatCommandHandler(_unitOfWorkMock.Object); + } + + [Fact] + public async Task Handle_WithLinkedDevice_ShouldRecordHeartbeat() + { + // Arrange + var familyId = Nanoid.Generate(); + var device = Device.CreateWithLinkCode(DeviceType.Display); + device.Link(familyId, "Kitchen Display", "user-123"); + var previousLastSeen = device.LastSeenAt; + + var command = new RecordHeartbeatCommand + { + DeviceId = device.Id, + FamilyId = familyId, + AppVersion = "2.0.0" + }; + + _deviceRepositoryMock.Setup(x => x.GetByIdAsync(device.Id, familyId, It.IsAny())) + .ReturnsAsync(device); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result.DeviceId.Should().Be(device.Id); + result.AppVersion.Should().Be("2.0.0"); + result.LastSeenAt.Should().BeOnOrAfter(previousLastSeen); + _deviceRepositoryMock.Verify(x => x.UpdateAsync(device, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WithNonExistentDevice_ShouldThrowNotFoundException() + { + // Arrange + var command = new RecordHeartbeatCommand + { + DeviceId = "non-existent", + FamilyId = "family-123" + }; + + _deviceRepositoryMock.Setup(x => x.GetByIdAsync("non-existent", "family-123", It.IsAny())) + .ReturnsAsync((Device?)null); + + // Act + var act = async () => await _handler.Handle(command, CancellationToken.None); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Handle_WithUnlinkedDevice_ShouldThrowValidationException() + { + // Arrange + var device = Device.CreateWithLinkCode(DeviceType.Display); + + var command = new RecordHeartbeatCommand + { + DeviceId = device.Id, + FamilyId = "family-123" + }; + + _deviceRepositoryMock.Setup(x => x.GetByIdAsync(device.Id, "family-123", It.IsAny())) + .ReturnsAsync(device); + + // Act + var act = async () => await _handler.Handle(command, CancellationToken.None); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*not linked*"); + } +} + +public class RecordHeartbeatCommandValidatorTests +{ + private readonly RecordHeartbeatCommandValidator _validator; + + public RecordHeartbeatCommandValidatorTests() + { + _validator = new RecordHeartbeatCommandValidator(); + } + + [Fact] + public void Validate_WithValidData_ShouldPass() + { + // Arrange + var command = new RecordHeartbeatCommand + { + DeviceId = "device-123", + FamilyId = "family-123", + AppVersion = "1.0.0" + }; + + // Act + var result = _validator.Validate(command); + + // Assert + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void Validate_WithEmptyDeviceId_ShouldFail() + { + // Arrange + var command = new RecordHeartbeatCommand + { + DeviceId = "", + FamilyId = "family-123" + }; + + // Act + var result = _validator.Validate(command); + + // Assert + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "DeviceId"); + } + + [Fact] + public void Validate_WithAppVersionTooLong_ShouldFail() + { + // Arrange + var command = new RecordHeartbeatCommand + { + DeviceId = "device-123", + FamilyId = "family-123", + AppVersion = new string('a', 51) + }; + + // Act + var result = _validator.Validate(command); + + // Assert + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "AppVersion"); + } +} diff --git a/tests/Luminous.Application.Tests/Devices/Commands/UnlinkDeviceCommandTests.cs b/tests/Luminous.Application.Tests/Devices/Commands/UnlinkDeviceCommandTests.cs new file mode 100644 index 0000000..35af458 --- /dev/null +++ b/tests/Luminous.Application.Tests/Devices/Commands/UnlinkDeviceCommandTests.cs @@ -0,0 +1,149 @@ +using FluentAssertions; +using Luminous.Application.Common.Exceptions; +using Luminous.Application.Common.Interfaces; +using Luminous.Application.Features.Devices.Commands; +using Luminous.Domain.Entities; +using Luminous.Domain.Enums; +using Luminous.Domain.Interfaces; +using MediatR; +using Moq; +using NanoidDotNet; +using Xunit; + +namespace Luminous.Application.Tests.Devices.Commands; + +public class UnlinkDeviceCommandTests +{ + private readonly Mock _unitOfWorkMock; + private readonly Mock _currentUserServiceMock; + private readonly Mock _deviceRepositoryMock; + private readonly UnlinkDeviceCommandHandler _handler; + + public UnlinkDeviceCommandTests() + { + _unitOfWorkMock = new Mock(); + _currentUserServiceMock = new Mock(); + _deviceRepositoryMock = new Mock(); + + _unitOfWorkMock.Setup(x => x.Devices).Returns(_deviceRepositoryMock.Object); + _currentUserServiceMock.Setup(x => x.UserId).Returns("user-123"); + + _handler = new UnlinkDeviceCommandHandler( + _unitOfWorkMock.Object, + _currentUserServiceMock.Object); + } + + [Fact] + public async Task Handle_WithLinkedDevice_ShouldUnlinkDevice() + { + // Arrange + var familyId = Nanoid.Generate(); + var device = Device.CreateWithLinkCode(DeviceType.Display); + device.Link(familyId, "Kitchen Display", "user-123"); + + var command = new UnlinkDeviceCommand + { + DeviceId = device.Id, + FamilyId = familyId + }; + + _deviceRepositoryMock.Setup(x => x.GetByIdAsync(device.Id, familyId, It.IsAny())) + .ReturnsAsync(device); + + // Act + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert + result.Should().Be(Unit.Value); + device.IsLinked.Should().BeFalse(); + _deviceRepositoryMock.Verify(x => x.UpdateAsync(device, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WithNonExistentDevice_ShouldThrowNotFoundException() + { + // Arrange + var command = new UnlinkDeviceCommand + { + DeviceId = "non-existent", + FamilyId = "family-123" + }; + + _deviceRepositoryMock.Setup(x => x.GetByIdAsync("non-existent", "family-123", It.IsAny())) + .ReturnsAsync((Device?)null); + + // Act + var act = async () => await _handler.Handle(command, CancellationToken.None); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Handle_WithUnlinkedDevice_ShouldThrowValidationException() + { + // Arrange + var device = Device.CreateWithLinkCode(DeviceType.Display); + + var command = new UnlinkDeviceCommand + { + DeviceId = device.Id, + FamilyId = "family-123" + }; + + _deviceRepositoryMock.Setup(x => x.GetByIdAsync(device.Id, "family-123", It.IsAny())) + .ReturnsAsync(device); + + // Act + var act = async () => await _handler.Handle(command, CancellationToken.None); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*not linked*"); + } +} + +public class UnlinkDeviceCommandValidatorTests +{ + private readonly UnlinkDeviceCommandValidator _validator; + + public UnlinkDeviceCommandValidatorTests() + { + _validator = new UnlinkDeviceCommandValidator(); + } + + [Fact] + public void Validate_WithValidData_ShouldPass() + { + // Arrange + var command = new UnlinkDeviceCommand + { + DeviceId = "device-123", + FamilyId = "family-123" + }; + + // Act + var result = _validator.Validate(command); + + // Assert + result.IsValid.Should().BeTrue(); + } + + [Fact] + public void Validate_WithEmptyDeviceId_ShouldFail() + { + // Arrange + var command = new UnlinkDeviceCommand + { + DeviceId = "", + FamilyId = "family-123" + }; + + // Act + var result = _validator.Validate(command); + + // Assert + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == "DeviceId"); + } +} diff --git a/tests/Luminous.Application.Tests/Families/Commands/CreateFamilyCommandTests.cs b/tests/Luminous.Application.Tests/Families/Commands/CreateFamilyCommandTests.cs index 318db68..94ef152 100644 --- a/tests/Luminous.Application.Tests/Families/Commands/CreateFamilyCommandTests.cs +++ b/tests/Luminous.Application.Tests/Families/Commands/CreateFamilyCommandTests.cs @@ -5,6 +5,7 @@ using Luminous.Domain.Entities; using Luminous.Domain.Interfaces; using Moq; +using Xunit; namespace Luminous.Application.Tests.Families.Commands; diff --git a/tests/Luminous.Domain.Tests/Entities/DeviceTests.cs b/tests/Luminous.Domain.Tests/Entities/DeviceTests.cs index aacea66..9b0471b 100644 --- a/tests/Luminous.Domain.Tests/Entities/DeviceTests.cs +++ b/tests/Luminous.Domain.Tests/Entities/DeviceTests.cs @@ -1,7 +1,8 @@ using FluentAssertions; using Luminous.Domain.Entities; using Luminous.Domain.Enums; -using Nanoid; +using NanoidDotNet; +using Xunit; namespace Luminous.Domain.Tests.Entities; @@ -75,4 +76,160 @@ public void Link_WithInvalidFamilyId_ShouldThrowException(string? familyId) act.Should().Throw() .WithMessage("*Family ID*"); } + + [Fact] + public void Unlink_WhenLinked_ShouldUnlinkDevice() + { + // Arrange + var device = Device.CreateWithLinkCode(DeviceType.Display); + device.Link(Nanoid.Generate(), "Kitchen Display", "user-id"); + + // Act + device.Unlink("admin-id"); + + // Assert + device.IsLinked.Should().BeFalse(); + device.FamilyId.Should().BeEmpty(); + device.Name.Should().BeEmpty(); + device.LinkedAt.Should().BeNull(); + device.LinkedBy.Should().BeNull(); + device.IsActive.Should().BeFalse(); + device.ModifiedBy.Should().Be("admin-id"); + } + + [Fact] + public void Unlink_WhenNotLinked_ShouldThrowException() + { + // Arrange + var device = Device.CreateWithLinkCode(DeviceType.Display); + + // Act + var act = () => device.Unlink("user-id"); + + // Assert + act.Should().Throw() + .WithMessage("Device is not linked."); + } + + [Fact] + public void Rename_ShouldUpdateDeviceName() + { + // Arrange + var device = Device.CreateWithLinkCode(DeviceType.Display); + device.Link(Nanoid.Generate(), "Kitchen Display", "user-id"); + + // Act + device.Rename("Living Room Display", "admin-id"); + + // Assert + device.Name.Should().Be("Living Room Display"); + device.ModifiedBy.Should().Be("admin-id"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void Rename_WithInvalidName_ShouldThrowException(string? name) + { + // Arrange + var device = Device.CreateWithLinkCode(DeviceType.Display); + device.Link(Nanoid.Generate(), "Kitchen Display", "user-id"); + + // Act + var act = () => device.Rename(name!, "admin-id"); + + // Assert + act.Should().Throw() + .WithMessage("*Device name*"); + } + + [Fact] + public void UpdateSettings_ShouldUpdateDeviceSettings() + { + // Arrange + var device = Device.CreateWithLinkCode(DeviceType.Display); + device.Link(Nanoid.Generate(), "Kitchen Display", "user-id"); + var newSettings = new Luminous.Domain.ValueObjects.DeviceSettings + { + Brightness = 75, + AutoBrightness = false, + DefaultView = "week", + Orientation = "landscape", + Volume = 30, + SoundEnabled = false + }; + + // Act + device.UpdateSettings(newSettings, "admin-id"); + + // Assert + device.Settings.Brightness.Should().Be(75); + device.Settings.AutoBrightness.Should().BeFalse(); + device.Settings.DefaultView.Should().Be("week"); + device.Settings.Orientation.Should().Be("landscape"); + device.Settings.Volume.Should().Be(30); + device.Settings.SoundEnabled.Should().BeFalse(); + device.ModifiedBy.Should().Be("admin-id"); + } + + [Fact] + public void UpdateSettings_WithNullSettings_ShouldThrowException() + { + // Arrange + var device = Device.CreateWithLinkCode(DeviceType.Display); + + // Act + var act = () => device.UpdateSettings(null!, "user-id"); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Deactivate_ShouldSetIsActiveToFalse() + { + // Arrange + var device = Device.CreateWithLinkCode(DeviceType.Display); + device.Link(Nanoid.Generate(), "Kitchen Display", "user-id"); + device.IsActive.Should().BeTrue(); + + // Act + device.Deactivate("admin-id"); + + // Assert + device.IsActive.Should().BeFalse(); + device.ModifiedBy.Should().Be("admin-id"); + } + + [Fact] + public void Activate_ShouldSetIsActiveToTrue() + { + // Arrange + var device = Device.CreateWithLinkCode(DeviceType.Display); + device.Link(Nanoid.Generate(), "Kitchen Display", "user-id"); + device.Deactivate("admin-id"); + device.IsActive.Should().BeFalse(); + + // Act + device.Activate("admin-id"); + + // Assert + device.IsActive.Should().BeTrue(); + device.ModifiedBy.Should().Be("admin-id"); + } + + [Theory] + [InlineData(DeviceType.Display)] + [InlineData(DeviceType.Mobile)] + [InlineData(DeviceType.Web)] + public void CreateWithLinkCode_ShouldSetCorrectDeviceType(DeviceType deviceType) + { + // Arrange & Act + var device = Device.CreateWithLinkCode(deviceType, "iOS 17.0"); + + // Assert + device.Type.Should().Be(deviceType); + device.Platform.Should().Be("iOS 17.0"); + } } diff --git a/tests/Luminous.Domain.Tests/Entities/FamilyTests.cs b/tests/Luminous.Domain.Tests/Entities/FamilyTests.cs index 82a2bbf..a62ef36 100644 --- a/tests/Luminous.Domain.Tests/Entities/FamilyTests.cs +++ b/tests/Luminous.Domain.Tests/Entities/FamilyTests.cs @@ -1,6 +1,7 @@ using FluentAssertions; using Luminous.Domain.Entities; using Luminous.Domain.ValueObjects; +using Xunit; namespace Luminous.Domain.Tests.Entities; diff --git a/tests/Luminous.Domain.Tests/Entities/UserTests.cs b/tests/Luminous.Domain.Tests/Entities/UserTests.cs index c2152d8..6a884c0 100644 --- a/tests/Luminous.Domain.Tests/Entities/UserTests.cs +++ b/tests/Luminous.Domain.Tests/Entities/UserTests.cs @@ -1,7 +1,8 @@ using FluentAssertions; using Luminous.Domain.Entities; using Luminous.Domain.Enums; -using Nanoid; +using NanoidDotNet; +using Xunit; namespace Luminous.Domain.Tests.Entities;