diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f9c729b..32cea9c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -23,7 +23,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v5 with: - dotnet-version: '9.x' + dotnet-version: '10.x' - name: Set Branch-Specific Variables run: | diff --git a/CLAUDE.md b/CLAUDE.md index cb3413c..5257e40 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,28 +2,17 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -## Linear Configuration +## Work Tracking + +Work is tracked in GitHub (issues and pull requests) on [iamadamreed/Unifi.NET](https://github.com/iamadamreed/Unifi.NET). Use the `gh` CLI for GitHub operations. ```yaml -LINEAR_PROJECT_ID: "62b16559-5a98-4a38-8980-bcfc96cc72f1" -LINEAR_PROJECT_NAME: "Unifi.NET" -LINEAR_TEAM_ID: "7fe71b21-cd2a-4776-98ef-8814b2921b8c" -LINEAR_TEAM_NAME: "Spire Recovery Solutions" - -# Status IDs -LINEAR_STATUS_ON_DECK: "2ec690d5-e27e-45dc-bf26-9201f3b2e276" -LINEAR_STATUS_IN_PROGRESS: "d171b2d5-87f8-4c4e-b7d0-a13dd5bc6689" -LINEAR_STATUS_BLOCKED: "8a88d533-e078-425a-b1f1-ad93cad81ef2" -LINEAR_STATUS_IN_REVIEW: "6f1d6fc0-4e3c-42da-903a-85fcc3ea83e9" -LINEAR_STATUS_DONE: "cf75dc3e-2542-4918-b2da-980fac469533" - -# Branch Configuration DEFAULT_BRANCH: "main" ``` ## Project Overview -Unifi.NET is a collection of .NET 9 SDKs for Ubiquiti UniFi APIs. The solution wraps UniFi service APIs (Access, Network, Protect, Site Manager) with strongly-typed, Native AOT-compatible client libraries. +Unifi.NET is a collection of .NET 10 SDKs for Ubiquiti UniFi APIs. The solution wraps UniFi service APIs (Access, Network, Protect, Site Manager) with strongly-typed, Native AOT-compatible client libraries. ## Solution Structure @@ -39,7 +28,7 @@ Unifi.NET.sln ├── Unifi.NET.Samples/ # Sample console application ├── Directory.Build.props # Shared MSBuild properties ├── Directory.Packages.props # Central package management -├── global.json # .NET SDK version (9.0.100) +├── global.json # .NET SDK version (10.0.100) └── .editorconfig # Code style configuration ``` @@ -201,11 +190,15 @@ NuGet packages follow folder structure: Central package versions (Directory.Packages.props): - **RestSharp**: 112.1.0 -- **Microsoft.Extensions.Http.Resilience**: 9.0.0 -- **System.Text.Json**: 9.0.0 -- **xunit**: 2.9.2 +- **Microsoft.Extensions.Http.Resilience**: 10.0.0 +- **Microsoft.OpenApi**: 2.11.0 (pinned for GHSA-v5pm-xwqc-g5wc) +- **Microsoft.NET.Test.Sdk**: 18.8.1 +- **xunit**: 2.9.3 - **FluentAssertions**: 6.12.2 - **Moq**: 4.20.72 +- **Microsoft.CodeAnalysis.NetAnalyzers**: 10.0.0 + +Note: `System.Text.Json` is framework-provided in .NET 10, so it no longer has an explicit `PackageVersion` entry. ## API Implementation Workflow diff --git a/Directory.Build.props b/Directory.Build.props index 415a25c..fcb17da 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - net9.0 + net10.0 latest enable enable diff --git a/Directory.Packages.props b/Directory.Packages.props index 3305adb..661bd90 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,34 +6,34 @@ - - - - - - - - - - - - - - + + + + + + + + + + + + + - - - - + + + + + - - + + - + - + \ No newline at end of file diff --git a/Dockerfile.windows-cross b/Dockerfile.windows-cross index f5d1247..47f9dd6 100644 --- a/Dockerfile.windows-cross +++ b/Dockerfile.windows-cross @@ -1,5 +1,5 @@ # Use Linux .NET SDK to cross-compile for Windows -FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src diff --git a/README.md b/README.md index e85e506..12b30ca 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A comprehensive collection of .NET SDKs for interacting with Ubiquiti UniFi APIs [![NuGet](https://img.shields.io/nuget/v/Unifi.NET.Access.svg)](https://www.nuget.org/packages/Unifi.NET.Access/) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -[![.NET](https://img.shields.io/badge/.NET-9.0-512BD4)](https://dotnet.microsoft.com/download) +[![.NET](https://img.shields.io/badge/.NET-10.0-512BD4)](https://dotnet.microsoft.com/download) ## 📦 Available Packages @@ -30,13 +30,13 @@ A comprehensive collection of .NET SDKs for interacting with Ubiquiti UniFi APIs ## 📋 Prerequisites -- .NET 9.0 or later +- .NET 10.0 or later - UniFi Console with the respective service installed - API Token from UniFi Portal (for authentication) - Network access to your UniFi Console (default port: 12445 for Access) ### Native AOT Requirements -- .NET 9 SDK for building +- .NET 10 SDK for building - Target platform runtime identifier (e.g., `linux-x64`, `win-x64`, `osx-arm64`) - No additional runtime dependencies needed for deployment @@ -129,7 +129,7 @@ var users = await accessClient.Users.GetAllAsync(); ### Native AOT Deployment -The SDKs are designed for full Native AOT compatibility in .NET 9: +The SDKs are designed for full Native AOT compatibility in .NET 10: ```xml @@ -209,7 +209,7 @@ Unifi.NET/ ### Native AOT Design Principles -All SDKs follow .NET 9 Native AOT best practices: +All SDKs follow .NET 10 Native AOT best practices: #### ✅ **Fully Compatible Components** - **System.Text.Json** with source generators (no reflection) diff --git a/Unifi.NET.Access.Tests/Serialization/UnifiAccessJsonContextTests.cs b/Unifi.NET.Access.Tests/Serialization/UnifiAccessJsonContextTests.cs new file mode 100644 index 0000000..cf60c2c --- /dev/null +++ b/Unifi.NET.Access.Tests/Serialization/UnifiAccessJsonContextTests.cs @@ -0,0 +1,183 @@ +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using FluentAssertions; +using Unifi.NET.Access.Models; +using Unifi.NET.Access.Models.Doors; +using Unifi.NET.Access.Models.Users; +using Unifi.NET.Access.Serialization; + +namespace Unifi.NET.Access.Tests.Serialization; + +/// +/// Verifies that representative models round-trip through the combined +/// resolver, proving Native AOT +/// source-generated serialization coverage across service-specific contexts. +/// +public class UnifiAccessJsonContextTests +{ + private readonly JsonSerializerOptions _options = UnifiAccessJsonContext.CreateOptions(); + + [Fact] + public void CreateOptions_UsesCombinedResolverFromAllContexts() + { + // Act & Assert + _options.TypeInfoResolver.Should().BeSameAs(UnifiAccessJsonContext.Combined); + } + + [Fact] + public void UserResponse_RoundTripsThroughCombinedResolver() + { + // Arrange - snake_case wire format as returned by the UniFi Access API, + // exercising UserJsonContext (registered in the combined resolver). + const string json = """ + { + "id": "usr-123", + "first_name": "Ada", + "last_name": "Lovelace", + "full_name": "Ada Lovelace", + "user_email": "ada@example.com", + "status": "ACTIVE", + "access_policy_ids": ["policy-1", "policy-2"] + } + """; + + var typeInfo = (JsonTypeInfo)_options.GetTypeInfo(typeof(UserResponse)); + + // Act + var user = JsonSerializer.Deserialize(json, typeInfo); + user.Should().NotBeNull(); + var reserialized = JsonSerializer.Serialize(user!, typeInfo); + var roundTripped = JsonSerializer.Deserialize(reserialized, typeInfo); + + // Assert + user!.Id.Should().Be("usr-123"); + user.FirstName.Should().Be("Ada"); + user.LastName.Should().Be("Lovelace"); + user.UserEmail.Should().Be("ada@example.com"); + user.Status.Should().Be("ACTIVE"); + user.AccessPolicyIds.Should().Equal("policy-1", "policy-2"); + + roundTripped.Should().BeEquivalentTo(user); + } + + [Fact] + public void UnifiApiResponse_OfUserResponse_RoundTripsThroughCombinedResolver() + { + // Arrange - full API envelope, exercising the UnifiApiResponse + // wrapper type registered by UserJsonContext. + const string json = """ + { + "code": "SUCCESS", + "msg": "success", + "data": { + "id": "usr-456", + "first_name": "Grace", + "last_name": "Hopper" + } + } + """; + + var typeInfo = (JsonTypeInfo>) + _options.GetTypeInfo(typeof(UnifiApiResponse)); + + // Act + var apiResponse = JsonSerializer.Deserialize(json, typeInfo); + + // Assert + apiResponse.Should().NotBeNull(); + apiResponse!.Code.Should().Be("SUCCESS"); + apiResponse.Data.Should().NotBeNull(); + apiResponse.Data!.FirstName.Should().Be("Grace"); + apiResponse.Data.LastName.Should().Be("Hopper"); + } + + [Fact] + public void DoorResponse_RoundTripsThroughCombinedResolver() + { + // Arrange - exercises DoorJsonContext (a different service-specific + // context) via the same combined resolver used above for UserResponse. + const string json = """ + { + "id": "door-789", + "name": "Front Door", + "full_name": "Main Building / Front Door", + "type": "door", + "is_bind_hub": true, + "door_lock_relay_status": "lock", + "door_position_status": "close" + } + """; + + var typeInfo = (JsonTypeInfo)_options.GetTypeInfo(typeof(DoorResponse)); + + // Act + var door = JsonSerializer.Deserialize(json, typeInfo); + door.Should().NotBeNull(); + var reserialized = JsonSerializer.Serialize(door!, typeInfo); + var roundTripped = JsonSerializer.Deserialize(reserialized, typeInfo); + + // Assert + door!.Id.Should().Be("door-789"); + door.Name.Should().Be("Front Door"); + door.IsBindHub.Should().BeTrue(); + door.DoorLockRelayStatus.Should().Be("lock"); + door.DoorPositionStatus.Should().Be("close"); + + roundTripped.Should().BeEquivalentTo(door); + } + + [Fact] + public void UnifiApiResponse_OfDoorResponseList_RoundTripsThroughCombinedResolver() + { + // Arrange - exercises the List-wrapped envelope type, + // as returned by the "list doors" endpoint. + const string json = """ + { + "code": "SUCCESS", + "msg": "success", + "data": [ + { "id": "door-1", "name": "Lobby", "full_name": "Lobby", "type": "door", "is_bind_hub": false }, + { "id": "door-2", "name": "Garage", "full_name": "Garage", "type": "door", "is_bind_hub": true } + ] + } + """; + + var typeInfo = (JsonTypeInfo>>) + _options.GetTypeInfo(typeof(UnifiApiResponse>)); + + // Act + var apiResponse = JsonSerializer.Deserialize(json, typeInfo); + + // Assert + apiResponse.Should().NotBeNull(); + apiResponse!.Data.Should().HaveCount(2); + apiResponse.Data![0].Id.Should().Be("door-1"); + apiResponse.Data[1].IsBindHub.Should().BeTrue(); + } + + [Fact] + public void UserResponse_Serialize_OmitsNullOptionalPropertiesUsingJsonPropertyNames() + { + // Arrange - the wire format comes from the explicit [JsonPropertyName] + // attributes (snake_case), and nulls are omitted on write; verify that + // policy holds through the combined resolver too. + var user = new UserResponse + { + Id = "usr-1", + FirstName = "Jane", + LastName = "Doe" + }; + + var typeInfo = (JsonTypeInfo)_options.GetTypeInfo(typeof(UserResponse)); + + // Act + var json = JsonSerializer.Serialize(user, typeInfo); + + // Assert - property names use the explicit [JsonPropertyName] (snake_case) + // values, and unset nullable members are absent from the payload. + json.Should().Contain("\"id\":\"usr-1\""); + json.Should().Contain("\"first_name\":\"Jane\""); + json.Should().NotContain("full_name"); + json.Should().NotContain("nfc_cards"); + } +} diff --git a/Unifi.NET.Access.Tests/Unifi.NET.Access.Tests.csproj b/Unifi.NET.Access.Tests/Unifi.NET.Access.Tests.csproj index 371b6ff..0122f96 100644 --- a/Unifi.NET.Access.Tests/Unifi.NET.Access.Tests.csproj +++ b/Unifi.NET.Access.Tests/Unifi.NET.Access.Tests.csproj @@ -1,7 +1,7 @@  - net9.0 + net10.0 enable enable false diff --git a/Unifi.NET.Access.Tests/UnitTest1.cs b/Unifi.NET.Access.Tests/UnitTest1.cs deleted file mode 100644 index ab505df..0000000 --- a/Unifi.NET.Access.Tests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Unifi.NET.Access.Tests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} diff --git a/Unifi.NET.Access/Serialization/Contexts/AccessPolicyJsonContext.cs b/Unifi.NET.Access/Serialization/Contexts/AccessPolicyJsonContext.cs index 95f0085..b8df053 100644 --- a/Unifi.NET.Access/Serialization/Contexts/AccessPolicyJsonContext.cs +++ b/Unifi.NET.Access/Serialization/Contexts/AccessPolicyJsonContext.cs @@ -20,6 +20,6 @@ namespace Unifi.NET.Access.Serialization.Contexts; // API wrapper types [JsonSerializable(typeof(UnifiApiResponse))] [JsonSerializable(typeof(UnifiApiResponse>))] -internal partial class AccessPolicyJsonContext : JsonSerializerContext +internal sealed partial class AccessPolicyJsonContext : JsonSerializerContext { } \ No newline at end of file diff --git a/Unifi.NET.Access/Serialization/Contexts/CoreJsonContext.cs b/Unifi.NET.Access/Serialization/Contexts/CoreJsonContext.cs index 8c6d52d..0b3e316 100644 --- a/Unifi.NET.Access/Serialization/Contexts/CoreJsonContext.cs +++ b/Unifi.NET.Access/Serialization/Contexts/CoreJsonContext.cs @@ -16,6 +16,6 @@ namespace Unifi.NET.Access.Serialization.Contexts; [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(PaginationInfo))] -internal partial class CoreJsonContext : JsonSerializerContext +internal sealed partial class CoreJsonContext : JsonSerializerContext { } \ No newline at end of file diff --git a/Unifi.NET.Access/Serialization/Contexts/CredentialJsonContext.cs b/Unifi.NET.Access/Serialization/Contexts/CredentialJsonContext.cs index f78f33d..6c882b8 100644 --- a/Unifi.NET.Access/Serialization/Contexts/CredentialJsonContext.cs +++ b/Unifi.NET.Access/Serialization/Contexts/CredentialJsonContext.cs @@ -34,6 +34,6 @@ namespace Unifi.NET.Access.Serialization.Contexts; [JsonSerializable(typeof(UnifiApiResponse))] [JsonSerializable(typeof(UnifiApiResponse>))] [JsonSerializable(typeof(UnifiApiResponse>))] -internal partial class CredentialJsonContext : JsonSerializerContext +internal sealed partial class CredentialJsonContext : JsonSerializerContext { } \ No newline at end of file diff --git a/Unifi.NET.Access/Serialization/Contexts/DeviceJsonContext.cs b/Unifi.NET.Access/Serialization/Contexts/DeviceJsonContext.cs index 1cf1140..31c7a72 100644 --- a/Unifi.NET.Access/Serialization/Contexts/DeviceJsonContext.cs +++ b/Unifi.NET.Access/Serialization/Contexts/DeviceJsonContext.cs @@ -18,6 +18,6 @@ namespace Unifi.NET.Access.Serialization.Contexts; [JsonSerializable(typeof(List>))] // API wrapper types [JsonSerializable(typeof(UnifiApiResponse>>))] -internal partial class DeviceJsonContext : JsonSerializerContext +internal sealed partial class DeviceJsonContext : JsonSerializerContext { } \ No newline at end of file diff --git a/Unifi.NET.Access/Serialization/Contexts/DoorJsonContext.cs b/Unifi.NET.Access/Serialization/Contexts/DoorJsonContext.cs index f913435..9c59ca7 100644 --- a/Unifi.NET.Access/Serialization/Contexts/DoorJsonContext.cs +++ b/Unifi.NET.Access/Serialization/Contexts/DoorJsonContext.cs @@ -24,6 +24,6 @@ namespace Unifi.NET.Access.Serialization.Contexts; [JsonSerializable(typeof(UnifiApiResponse>))] [JsonSerializable(typeof(UnifiApiResponse))] [JsonSerializable(typeof(UnifiApiResponse))] -internal partial class DoorJsonContext : JsonSerializerContext +internal sealed partial class DoorJsonContext : JsonSerializerContext { } \ No newline at end of file diff --git a/Unifi.NET.Access/Serialization/Contexts/SystemLogJsonContext.cs b/Unifi.NET.Access/Serialization/Contexts/SystemLogJsonContext.cs index ad866a3..5260f35 100644 --- a/Unifi.NET.Access/Serialization/Contexts/SystemLogJsonContext.cs +++ b/Unifi.NET.Access/Serialization/Contexts/SystemLogJsonContext.cs @@ -25,6 +25,6 @@ namespace Unifi.NET.Access.Serialization.Contexts; [JsonSerializable(typeof(List))] [JsonSerializable(typeof(UnifiApiResponse))] [JsonSerializable(typeof(UnifiApiResponse))] -internal partial class SystemLogJsonContext : JsonSerializerContext +internal sealed partial class SystemLogJsonContext : JsonSerializerContext { } \ No newline at end of file diff --git a/Unifi.NET.Access/Serialization/Contexts/UserGroupJsonContext.cs b/Unifi.NET.Access/Serialization/Contexts/UserGroupJsonContext.cs index 99b9ff2..e6022f8 100644 --- a/Unifi.NET.Access/Serialization/Contexts/UserGroupJsonContext.cs +++ b/Unifi.NET.Access/Serialization/Contexts/UserGroupJsonContext.cs @@ -26,6 +26,6 @@ namespace Unifi.NET.Access.Serialization.Contexts; [JsonSerializable(typeof(UnifiApiResponse))] [JsonSerializable(typeof(UnifiApiResponse>))] [JsonSerializable(typeof(UnifiApiResponse>))] -internal partial class UserGroupJsonContext : JsonSerializerContext +internal sealed partial class UserGroupJsonContext : JsonSerializerContext { } \ No newline at end of file diff --git a/Unifi.NET.Access/Serialization/Contexts/UserJsonContext.cs b/Unifi.NET.Access/Serialization/Contexts/UserJsonContext.cs index 40b489a..cb3cbec 100644 --- a/Unifi.NET.Access/Serialization/Contexts/UserJsonContext.cs +++ b/Unifi.NET.Access/Serialization/Contexts/UserJsonContext.cs @@ -24,6 +24,6 @@ namespace Unifi.NET.Access.Serialization.Contexts; // API wrapper types [JsonSerializable(typeof(UnifiApiResponse))] [JsonSerializable(typeof(UnifiApiResponse>))] -internal partial class UserJsonContext : JsonSerializerContext +internal sealed partial class UserJsonContext : JsonSerializerContext { } \ No newline at end of file diff --git a/Unifi.NET.Access/Serialization/UnifiAccessJsonContext.cs b/Unifi.NET.Access/Serialization/UnifiAccessJsonContext.cs index 27c316b..3be1f7a 100644 --- a/Unifi.NET.Access/Serialization/UnifiAccessJsonContext.cs +++ b/Unifi.NET.Access/Serialization/UnifiAccessJsonContext.cs @@ -12,7 +12,7 @@ namespace Unifi.NET.Access.Serialization; public static class UnifiAccessJsonContext { private static IJsonTypeInfoResolver? _combined; - private static readonly object _lock = new(); + private static readonly Lock _lock = new(); /// /// Gets the combined type info resolver for all UniFi Access types. diff --git a/Unifi.NET.Access/Services/CredentialService.cs b/Unifi.NET.Access/Services/CredentialService.cs index 4fd34e6..bffb4b9 100644 --- a/Unifi.NET.Access/Services/CredentialService.cs +++ b/Unifi.NET.Access/Services/CredentialService.cs @@ -84,7 +84,7 @@ public async Task>> GetNfcCardsAsync(int { return new PaginatedResponse> { - Items = new List(), + Items = [], Page = pageNum ?? 1, PageSize = pageSize ?? 25, Total = 0 @@ -108,7 +108,7 @@ public async Task>> GetNfcCardsAsync(int return new PaginatedResponse> { - Items = apiResponse.Data ?? new List(), + Items = apiResponse.Data ?? [], Page = apiResponse.Pagination?.PageNum ?? pageNum ?? 1, PageSize = apiResponse.Pagination?.PageSize ?? pageSize ?? 25, Total = apiResponse.Pagination?.Total ?? 0 diff --git a/Unifi.NET.Access/Services/DeviceService.cs b/Unifi.NET.Access/Services/DeviceService.cs index 16accfe..3eeaaa1 100644 --- a/Unifi.NET.Access/Services/DeviceService.cs +++ b/Unifi.NET.Access/Services/DeviceService.cs @@ -28,7 +28,7 @@ public async Task> GetDevicesAsync(CancellationToken { return devices.SelectMany(list => list ?? new List()); } - + return new List(); } } \ No newline at end of file diff --git a/Unifi.NET.Access/Services/SystemLogService.cs b/Unifi.NET.Access/Services/SystemLogService.cs index 99a51c8..4f0ab7a 100644 --- a/Unifi.NET.Access/Services/SystemLogService.cs +++ b/Unifi.NET.Access/Services/SystemLogService.cs @@ -122,7 +122,7 @@ public async Task ExportSystemLogsAsync( throw new UnifiAccessException($"Failed to export system logs: {response.StatusCode}", "CODE_SYSTEM_ERROR", (int?)response.StatusCode); } - return response.RawBytes ?? Array.Empty(); + return response.RawBytes ?? []; } /// diff --git a/Unifi.NET.Access/Services/UserGroupService.cs b/Unifi.NET.Access/Services/UserGroupService.cs index af634d6..e0e6337 100644 --- a/Unifi.NET.Access/Services/UserGroupService.cs +++ b/Unifi.NET.Access/Services/UserGroupService.cs @@ -63,7 +63,7 @@ public async Task>> GetUserGroupsAsync { return new PaginatedResponse> { - Items = new List(), + Items = [], Page = pageNum ?? 1, PageSize = pageSize ?? 25, Total = 0 @@ -87,7 +87,7 @@ public async Task>> GetUserGroupsAsync return new PaginatedResponse> { - Items = apiResponse.Data ?? new List(), + Items = apiResponse.Data ?? [], Page = apiResponse.Pagination?.PageNum ?? pageNum ?? 1, PageSize = apiResponse.Pagination?.PageSize ?? pageSize ?? 25, Total = apiResponse.Pagination?.Total ?? 0 @@ -176,7 +176,7 @@ public async Task>> GetUsersInGroupAsync(st { return new PaginatedResponse> { - Items = new List(), + Items = [], Page = pageNum ?? 1, PageSize = pageSize ?? 25, Total = 0 @@ -200,7 +200,7 @@ public async Task>> GetUsersInGroupAsync(st return new PaginatedResponse> { - Items = apiResponse.Data ?? new List(), + Items = apiResponse.Data ?? [], Page = apiResponse.Pagination?.PageNum ?? pageNum ?? 1, PageSize = apiResponse.Pagination?.PageSize ?? pageSize ?? 25, Total = apiResponse.Pagination?.Total ?? 0 diff --git a/Unifi.NET.Access/Services/UserService.cs b/Unifi.NET.Access/Services/UserService.cs index b60ca13..a96d6b0 100644 --- a/Unifi.NET.Access/Services/UserService.cs +++ b/Unifi.NET.Access/Services/UserService.cs @@ -132,7 +132,7 @@ public async Task>> GetUsersAsync(int pageN { return new PaginatedResponse> { - Items = new List(), + Items = [], Page = pageNum, PageSize = pageSize, Total = 0 @@ -156,7 +156,7 @@ public async Task>> GetUsersAsync(int pageN return new PaginatedResponse> { - Items = apiResponse.Data ?? new List(), + Items = apiResponse.Data ?? [], Page = apiResponse.Pagination?.PageNum ?? pageNum, PageSize = apiResponse.Pagination?.PageSize ?? pageSize, Total = apiResponse.Pagination?.Total ?? 0 diff --git a/Unifi.NET.Access/Unifi.NET.Access.csproj b/Unifi.NET.Access/Unifi.NET.Access.csproj index 6fc5e45..693e154 100644 --- a/Unifi.NET.Access/Unifi.NET.Access.csproj +++ b/Unifi.NET.Access/Unifi.NET.Access.csproj @@ -14,7 +14,6 @@ - diff --git a/Unifi.NET.AccessViewer/Directory.Build.props b/Unifi.NET.AccessViewer/Directory.Build.props index 2e660e8..231095f 100644 --- a/Unifi.NET.AccessViewer/Directory.Build.props +++ b/Unifi.NET.AccessViewer/Directory.Build.props @@ -1,7 +1,7 @@ - false + false false true diff --git a/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Client/Unifi.NET.AccessViewer.Client.csproj b/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Client/Unifi.NET.AccessViewer.Client.csproj index 13347cd..9e0b15e 100644 --- a/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Client/Unifi.NET.AccessViewer.Client.csproj +++ b/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Client/Unifi.NET.AccessViewer.Client.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable enable diff --git a/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Server/Program.cs b/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Server/Program.cs index fbaf526..d7e4a7e 100644 --- a/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Server/Program.cs +++ b/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Server/Program.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Http.HttpResults; using Unifi.NET.Access; using Unifi.NET.Access.Configuration; using Unifi.NET.Access.Models.SystemLogs; @@ -55,11 +56,13 @@ app.UseHttpsRedirection(); // Map API endpoints -app.MapGet("/api/users/search", async (string? employeeNumber, IUnifiAccessClient client) => +var api = app.MapGroup("/api"); + +api.MapGet("/users/search", async Task, BadRequest, NotFound, ProblemHttpResult>> (string? employeeNumber, IUnifiAccessClient client) => { if (string.IsNullOrWhiteSpace(employeeNumber)) { - return Results.BadRequest("Employee number is required"); + return TypedResults.BadRequest("Employee number is required"); } try @@ -67,13 +70,13 @@ // Use search API for much faster response var users = await client.Users.SearchUsersAsync(employeeNumber); var user = users.FirstOrDefault(u => u.EmployeeNumber == employeeNumber); - + if (user == null) { - return Results.NotFound($"No user found with employee number: {employeeNumber}"); + return TypedResults.NotFound($"No user found with employee number: {employeeNumber}"); } - return Results.Ok(new UserSearchResult + return TypedResults.Ok(new UserSearchResult { Id = user.Id, FirstName = user.FirstName, @@ -85,12 +88,12 @@ } catch (Exception ex) { - return Results.Problem($"Error searching users: {ex.Message}"); + return TypedResults.Problem($"Error searching users: {ex.Message}"); } }) .WithName("SearchUser"); -app.MapPost("/api/access-logs", async (AccessLogRequest request, IUnifiAccessClient client) => +api.MapPost("/access-logs", async Task, ProblemHttpResult>> (AccessLogRequest request, IUnifiAccessClient client) => { try { @@ -103,8 +106,8 @@ }; var response = await client.SystemLogs.GetSystemLogsAsync( - logRequest, - request.PageNum ?? 1, + logRequest, + request.PageNum ?? 1, request.PageSize ?? 50); var logs = response.Hits.Select(hit => new AccessLogEntry @@ -121,7 +124,7 @@ BuildingName = hit.Source.Target?.FirstOrDefault(t => t.Type == "building")?.DisplayName }).ToList(); - return Results.Ok(new AccessLogResponse + return TypedResults.Ok(new AccessLogResponse { Logs = logs, TotalCount = response.Total, @@ -130,7 +133,7 @@ } catch (Exception ex) { - return Results.Problem($"Error fetching access logs: {ex.Message}"); + return TypedResults.Problem($"Error fetching access logs: {ex.Message}"); } }) .WithName("GetAccessLogs"); diff --git a/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Server/Unifi.NET.AccessViewer.Server.csproj b/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Server/Unifi.NET.AccessViewer.Server.csproj index eb5ac8d..48ce937 100644 --- a/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Server/Unifi.NET.AccessViewer.Server.csproj +++ b/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Server/Unifi.NET.AccessViewer.Server.csproj @@ -1,13 +1,15 @@ - net9.0 + net10.0 enable enable + + diff --git a/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Shared/Unifi.NET.AccessViewer.Shared.csproj b/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Shared/Unifi.NET.AccessViewer.Shared.csproj index 125f4c9..b760144 100644 --- a/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Shared/Unifi.NET.AccessViewer.Shared.csproj +++ b/Unifi.NET.AccessViewer/Unifi.NET.AccessViewer.Shared/Unifi.NET.AccessViewer.Shared.csproj @@ -1,7 +1,7 @@  - net9.0 + net10.0 enable enable diff --git a/Unifi.NET.Common.Tests/Exceptions/UnifiExceptionTests.cs b/Unifi.NET.Common.Tests/Exceptions/UnifiExceptionTests.cs new file mode 100644 index 0000000..544ad5f --- /dev/null +++ b/Unifi.NET.Common.Tests/Exceptions/UnifiExceptionTests.cs @@ -0,0 +1,97 @@ +using FluentAssertions; +using Unifi.NET.Common.Exceptions; + +namespace Unifi.NET.Common.Tests.Exceptions; + +public class UnifiExceptionTests +{ + [Fact] + public void Constructor_WithMessageOnly_SetsMessageAndLeavesErrorInfoNull() + { + // Arrange & Act + var exception = new UnifiException("Something went wrong"); + + // Assert + exception.Message.Should().Be("Something went wrong"); + exception.ErrorCode.Should().BeNull(); + exception.StatusCode.Should().BeNull(); + exception.InnerException.Should().BeNull(); + } + + [Fact] + public void Constructor_WithInnerException_PreservesInnerException() + { + // Arrange + var inner = new InvalidOperationException("root cause"); + + // Act + var exception = new UnifiException("wrapped failure", inner); + + // Assert + exception.Message.Should().Be("wrapped failure"); + exception.InnerException.Should().BeSameAs(inner); + exception.ErrorCode.Should().BeNull(); + exception.StatusCode.Should().BeNull(); + } + + [Fact] + public void Constructor_WithErrorCodeAndStatusCode_PopulatesErrorInfo() + { + // Arrange & Act + var exception = new UnifiException("Invalid parameters", "CODE_PARAMS_INVALID", 400); + + // Assert + exception.Message.Should().Be("Invalid parameters"); + exception.ErrorCode.Should().Be("CODE_PARAMS_INVALID"); + exception.StatusCode.Should().Be(400); + } + + [Fact] + public void UnifiAuthenticationException_IsAUnifiException() + { + // Arrange & Act + var exception = new UnifiAuthenticationException("Auth failed", "CODE_AUTH_FAILED", 401); + + // Assert + exception.Should().BeAssignableTo(); + exception.ErrorCode.Should().Be("CODE_AUTH_FAILED"); + exception.StatusCode.Should().Be(401); + exception.Message.Should().Be("Auth failed"); + } + + [Fact] + public void UnifiNotFoundException_IsAUnifiException() + { + // Arrange & Act + var exception = new UnifiNotFoundException("Resource not found", "CODE_RESOURCE_NOT_FOUND", 404); + + // Assert + exception.Should().BeAssignableTo(); + exception.ErrorCode.Should().Be("CODE_RESOURCE_NOT_FOUND"); + exception.StatusCode.Should().Be(404); + } + + [Fact] + public void UnifiAuthenticationException_MessageOnlyConstructor_LeavesErrorInfoNull() + { + // Arrange & Act + var exception = new UnifiAuthenticationException("Auth failed"); + + // Assert + exception.Message.Should().Be("Auth failed"); + exception.ErrorCode.Should().BeNull(); + exception.StatusCode.Should().BeNull(); + } + + [Fact] + public void UnifiNotFoundException_MessageOnlyConstructor_LeavesErrorInfoNull() + { + // Arrange & Act + var exception = new UnifiNotFoundException("Not found"); + + // Assert + exception.Message.Should().Be("Not found"); + exception.ErrorCode.Should().BeNull(); + exception.StatusCode.Should().BeNull(); + } +} diff --git a/Unifi.NET.Common.Tests/Models/UnifiApiResponseTests.cs b/Unifi.NET.Common.Tests/Models/UnifiApiResponseTests.cs new file mode 100644 index 0000000..72181db --- /dev/null +++ b/Unifi.NET.Common.Tests/Models/UnifiApiResponseTests.cs @@ -0,0 +1,69 @@ +using FluentAssertions; +using Unifi.NET.Common.Models; + +namespace Unifi.NET.Common.Tests.Models; + +public class UnifiApiResponseTests +{ + [Fact] + public void IsSuccess_WhenCodeIsSuccess_ReturnsTrue() + { + // Arrange + var response = new UnifiApiResponse + { + Code = "SUCCESS", + Message = "success", + Data = "payload" + }; + + // Act & Assert + response.IsSuccess.Should().BeTrue(); + } + + [Theory] + [InlineData("CODE_PARAMS_INVALID")] + [InlineData("CODE_AUTH_FAILED")] + [InlineData("")] + public void IsSuccess_WhenCodeIsNotSuccess_ReturnsFalse(string code) + { + // Arrange + var response = new UnifiApiResponse + { + Code = code, + Message = "failure" + }; + + // Act & Assert + response.IsSuccess.Should().BeFalse(); + } + + [Fact] + public void Data_WhenNotSet_DefaultsToNullForReferenceType() + { + // Arrange + var response = new UnifiApiResponse + { + Code = "SUCCESS", + Message = "success" + }; + + // Act & Assert + response.Data.Should().BeNull(); + } + + [Fact] + public void Data_CarriesTypedPayload() + { + // Arrange + var response = new UnifiApiResponse> + { + Code = "SUCCESS", + Message = "success", + Data = [1, 2, 3] + }; + + // Act & Assert + response.Data.Should().BeEquivalentTo([1, 2, 3]); + response.IsSuccess.Should().BeTrue(); + } +} diff --git a/Unifi.NET.Common.Tests/Unifi.NET.Common.Tests.csproj b/Unifi.NET.Common.Tests/Unifi.NET.Common.Tests.csproj index e8d1d44..9868570 100644 --- a/Unifi.NET.Common.Tests/Unifi.NET.Common.Tests.csproj +++ b/Unifi.NET.Common.Tests/Unifi.NET.Common.Tests.csproj @@ -1,7 +1,7 @@  - net9.0 + net10.0 enable enable false diff --git a/Unifi.NET.Common.Tests/UnitTest1.cs b/Unifi.NET.Common.Tests/UnitTest1.cs deleted file mode 100644 index 556ac74..0000000 --- a/Unifi.NET.Common.Tests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Unifi.NET.Common.Tests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} diff --git a/Unifi.NET.Common/Unifi.NET.Common.csproj b/Unifi.NET.Common/Unifi.NET.Common.csproj index 2959523..6a4293a 100644 --- a/Unifi.NET.Common/Unifi.NET.Common.csproj +++ b/Unifi.NET.Common/Unifi.NET.Common.csproj @@ -9,7 +9,6 @@ - diff --git a/Unifi.NET.Network/Unifi.NET.Network.csproj b/Unifi.NET.Network/Unifi.NET.Network.csproj index 125f4c9..b760144 100644 --- a/Unifi.NET.Network/Unifi.NET.Network.csproj +++ b/Unifi.NET.Network/Unifi.NET.Network.csproj @@ -1,7 +1,7 @@  - net9.0 + net10.0 enable enable diff --git a/Unifi.NET.Protect/Unifi.NET.Protect.csproj b/Unifi.NET.Protect/Unifi.NET.Protect.csproj index 125f4c9..b760144 100644 --- a/Unifi.NET.Protect/Unifi.NET.Protect.csproj +++ b/Unifi.NET.Protect/Unifi.NET.Protect.csproj @@ -1,7 +1,7 @@  - net9.0 + net10.0 enable enable diff --git a/Unifi.NET.Samples/README.md b/Unifi.NET.Samples/README.md index aefd1a7..7202c9b 100644 --- a/Unifi.NET.Samples/README.md +++ b/Unifi.NET.Samples/README.md @@ -15,7 +15,7 @@ The UniFi Access sample is a console application that demonstrates key features 1. **UniFi Access Console**: You need access to a UniFi Access console (v1.9.1 or later) 2. **API Token**: Generate an API token from your UniFi Access console -3. **.NET 9 SDK**: Install the .NET 9 SDK from [dotnet.microsoft.com](https://dotnet.microsoft.com) +3. **.NET 10 SDK**: Install the .NET 10 SDK from [dotnet.microsoft.com](https://dotnet.microsoft.com) ### Getting Started diff --git a/Unifi.NET.Samples/Unifi.NET.Samples.csproj b/Unifi.NET.Samples/Unifi.NET.Samples.csproj index 0fc4dd6..ade972f 100644 --- a/Unifi.NET.Samples/Unifi.NET.Samples.csproj +++ b/Unifi.NET.Samples/Unifi.NET.Samples.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable true diff --git a/Unifi.NET.SiteManager/Unifi.NET.SiteManager.csproj b/Unifi.NET.SiteManager/Unifi.NET.SiteManager.csproj index 125f4c9..b760144 100644 --- a/Unifi.NET.SiteManager/Unifi.NET.SiteManager.csproj +++ b/Unifi.NET.SiteManager/Unifi.NET.SiteManager.csproj @@ -1,7 +1,7 @@  - net9.0 + net10.0 enable enable diff --git a/global.json b/global.json index d5bf446..f72210c 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "9.0.100", + "version": "10.0.100", "rollForward": "latestFeature" } } \ No newline at end of file