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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.2.1] - 2026-05-13

### Fixed

- `GetComputersAsync`, `GetProductsAsync`, `GetWebhooksAsync`, and `GetWebhookDeliveriesAsync` no longer throw `System.Text.Json.JsonException` ("The input does not contain any JSON tokens") when the server returns HTTP 204 No Content or HTTP 200 with an empty body. They now return an empty sequence.
- The same empty-body guard was applied to the single-item `GetLicenseAsync`, `GetReceiptAsync`, `GetProductAsync`, `GetComputerAsync`, and `GetWebhookAsync` methods, which now return `null` consistently on 204 or 200-with-empty-body responses.
- Deserialization is centralized in new `ReadJsonOrDefaultAsync<T>` / `ReadJsonOrEmptyAsync<T>` helpers so future endpoints inherit the guard automatically.

## [1.2.0] - 2025-12-17

### Added
Expand Down
10 changes: 9 additions & 1 deletion LicenseManagement.Client.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

<!-- NuGet Package Properties -->
<PackageId>LicenseManagement.Client</PackageId>
<Version>1.2.0</Version>
<Version>1.2.1</Version>
<Authors>Hymma</Authors>
<Company>Hymma</Company>
<Product>License Management Client SDK</Product>
Expand All @@ -27,6 +27,14 @@
<None Include="README.md" Pack="true" PackagePath="" />
</ItemGroup>

<!-- Test project lives under Tests/ — exclude from the package's implicit globs. -->
<ItemGroup>
<Compile Remove="Tests\**" />
<Content Remove="Tests\**" />
<EmbeddedResource Remove="Tests\**" />
<None Remove="Tests\**" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
Expand Down
64 changes: 34 additions & 30 deletions LicenseManagementClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,8 @@ private void ConfigureHttpClient()
public async Task<License?> GetLicenseAsync(string productId, string computerId, CancellationToken cancellationToken = default)
{
var response = await _httpClient.GetAsync($"license?product={Uri.EscapeDataString(productId)}&computer={Uri.EscapeDataString(computerId)}", cancellationToken);

if (response.StatusCode == HttpStatusCode.NoContent)
return null;

await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<License>(JsonOptions, cancellationToken);
return await ReadJsonOrDefaultAsync<License>(response, cancellationToken);
}

/// <inheritdoc />
Expand Down Expand Up @@ -91,12 +87,8 @@ public async Task UpdateLicenseAsync(UpdateLicenseRequest request, CancellationT
public async Task<Receipt?> GetReceiptAsync(string code, CancellationToken cancellationToken = default)
{
var response = await _httpClient.GetAsync($"receipt?code={Uri.EscapeDataString(code)}", cancellationToken);

if (response.StatusCode == HttpStatusCode.NoContent)
return null;

await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<Receipt>(JsonOptions, cancellationToken);
return await ReadJsonOrDefaultAsync<Receipt>(response, cancellationToken);
}

/// <inheritdoc />
Expand Down Expand Up @@ -184,12 +176,8 @@ await CreateReceiptAsync(new CreateReceiptRequest
public async Task<IEnumerable<Receipt>> GetReceiptsAsync(string buyerEmail, string productId, CancellationToken cancellationToken = default)
{
var response = await _httpClient.GetAsync($"receipt/all?buyerEmail={Uri.EscapeDataString(buyerEmail)}&product={Uri.EscapeDataString(productId)}", cancellationToken);

if (response.StatusCode == HttpStatusCode.NoContent)
return Enumerable.Empty<Receipt>();

await EnsureSuccessAsync(response);
return (await response.Content.ReadFromJsonAsync<IEnumerable<Receipt>>(JsonOptions, cancellationToken)) ?? Enumerable.Empty<Receipt>();
return await ReadJsonOrEmptyAsync<Receipt>(response, cancellationToken);
}

#endregion
Expand All @@ -200,20 +188,16 @@ public async Task<IEnumerable<Receipt>> GetReceiptsAsync(string buyerEmail, stri
public async Task<Product?> GetProductAsync(string productId, CancellationToken cancellationToken = default)
{
var response = await _httpClient.GetAsync($"product?product={Uri.EscapeDataString(productId)}", cancellationToken);

if (response.StatusCode == HttpStatusCode.NoContent)
return null;

await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<Product>(JsonOptions, cancellationToken);
return await ReadJsonOrDefaultAsync<Product>(response, cancellationToken);
}

/// <inheritdoc />
public async Task<IEnumerable<Product>> GetProductsAsync(CancellationToken cancellationToken = default)
{
var response = await _httpClient.GetAsync("product/all", cancellationToken);
await EnsureSuccessAsync(response);
return (await response.Content.ReadFromJsonAsync<IEnumerable<Product>>(JsonOptions, cancellationToken))!;
return await ReadJsonOrEmptyAsync<Product>(response, cancellationToken);
}

/// <inheritdoc />
Expand Down Expand Up @@ -241,12 +225,8 @@ public async Task<Product> CreateProductAsync(CreateProductRequest request, Canc
public async Task<Computer?> GetComputerAsync(string macAddress, CancellationToken cancellationToken = default)
{
var response = await _httpClient.GetAsync($"computer?macAddress={Uri.EscapeDataString(macAddress)}", cancellationToken);

if (response.StatusCode == HttpStatusCode.NoContent)
return null;

await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<Computer>(JsonOptions, cancellationToken);
return await ReadJsonOrDefaultAsync<Computer>(response, cancellationToken);
}

/// <inheritdoc />
Expand All @@ -271,7 +251,7 @@ public async Task<IEnumerable<Computer>> GetComputersAsync(string receiptCode, C
{
var response = await _httpClient.GetAsync($"computer/all?receiptCode={Uri.EscapeDataString(receiptCode)}", cancellationToken);
await EnsureSuccessAsync(response);
return (await response.Content.ReadFromJsonAsync<IEnumerable<Computer>>(JsonOptions, cancellationToken))!;
return await ReadJsonOrEmptyAsync<Computer>(response, cancellationToken);
}

#endregion
Expand Down Expand Up @@ -299,7 +279,7 @@ public async Task<IEnumerable<Webhook>> GetWebhooksAsync(CancellationToken cance
{
var response = await _httpClient.GetAsync("webhook", cancellationToken);
await EnsureSuccessAsync(response);
return (await response.Content.ReadFromJsonAsync<IEnumerable<Webhook>>(JsonOptions, cancellationToken))!;
return await ReadJsonOrEmptyAsync<Webhook>(response, cancellationToken);
}

/// <inheritdoc />
Expand All @@ -311,7 +291,7 @@ public async Task<IEnumerable<Webhook>> GetWebhooksAsync(CancellationToken cance
return null;

await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<Webhook>(JsonOptions, cancellationToken);
return await ReadJsonOrDefaultAsync<Webhook>(response, cancellationToken);
}

/// <inheritdoc />
Expand Down Expand Up @@ -369,7 +349,7 @@ public async Task<IEnumerable<WebhookDelivery>> GetWebhookDeliveriesAsync(string

var response = await _httpClient.GetAsync(url, cancellationToken);
await EnsureSuccessAsync(response);
return (await response.Content.ReadFromJsonAsync<IEnumerable<WebhookDelivery>>(JsonOptions, cancellationToken))!;
return await ReadJsonOrEmptyAsync<WebhookDelivery>(response, cancellationToken);
}

/// <inheritdoc />
Expand Down Expand Up @@ -430,6 +410,30 @@ public async Task TestWebhookAsync(string webhookId, CancellationToken cancellat

#region Helpers

/// <summary>
/// Deserializes a JSON response body, returning default(T) when the response is 204 No Content
/// or has an empty body. Guards against System.Text.Json throwing on empty input.
/// </summary>
private static async Task<T?> ReadJsonOrDefaultAsync<T>(HttpResponseMessage response, CancellationToken cancellationToken)
{
if (response.StatusCode == HttpStatusCode.NoContent || response.Content.Headers.ContentLength == 0)
return default;

return await response.Content.ReadFromJsonAsync<T>(JsonOptions, cancellationToken);
}

/// <summary>
/// Deserializes a JSON array response body, returning an empty sequence when the response is
/// 204 No Content, has an empty body, or deserializes to null.
/// </summary>
private static async Task<IEnumerable<T>> ReadJsonOrEmptyAsync<T>(HttpResponseMessage response, CancellationToken cancellationToken)
{
if (response.StatusCode == HttpStatusCode.NoContent || response.Content.Headers.ContentLength == 0)
return Enumerable.Empty<T>();

return (await response.Content.ReadFromJsonAsync<IEnumerable<T>>(JsonOptions, cancellationToken)) ?? Enumerable.Empty<T>();
}

/// <summary>
/// Sends a PATCH request with JSON body (cross-platform compatible).
/// </summary>
Expand Down
177 changes: 177 additions & 0 deletions Tests/LicenseManagement.Client.Tests/EmptyResponseTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
using System.Net;
using System.Text;
using LicenseManagement.Client;
using LicenseManagement.Client.Models;
using Microsoft.Extensions.Options;

namespace LicenseManagement.Client.Tests;

/// <summary>
/// Regression tests for the bug where Get* collection methods threw
/// System.Text.Json.JsonException on HTTP 204 No Content or 200 with empty body.
/// </summary>
public class EmptyResponseTests
{
private static LicenseManagementClient CreateClient(Func<HttpRequestMessage, HttpResponseMessage> responder)
{
var handler = new StubHandler(responder);
var httpClient = new HttpClient(handler);
var options = Options.Create(new LicenseManagementClientOptions
{
BaseUrl = "https://example.test",
ApiKey = "test-key",
TimeoutSeconds = 30
});
return new LicenseManagementClient(httpClient, options);
}

private static HttpResponseMessage NoContent() => new(HttpStatusCode.NoContent);

private static HttpResponseMessage OkEmpty()
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(string.Empty, Encoding.UTF8, "application/json")
};
return response;
}

[Fact]
public async Task GetComputersAsync_Returns_Empty_On_204_NoContent()
{
var client = CreateClient(_ => NoContent());

var result = await client.GetComputersAsync("RCP_123");

Assert.NotNull(result);
Assert.Empty(result);
}

[Fact]
public async Task GetComputersAsync_Returns_Empty_On_200_With_Empty_Body()
{
var client = CreateClient(_ => OkEmpty());

var result = await client.GetComputersAsync("RCP_123");

Assert.NotNull(result);
Assert.Empty(result);
}

[Fact]
public async Task GetComputersAsync_Returns_Items_On_200_With_Json_Array()
{
var json = "[{\"id\":\"PC_1\",\"macAddress\":\"AA:BB:CC:DD:EE:FF\",\"name\":\"Dev box\"}]";
var client = CreateClient(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
});

var result = (await client.GetComputersAsync("RCP_123")).ToList();

Assert.Single(result);
Assert.Equal("PC_1", result[0].Id);
}

[Fact]
public async Task GetProductsAsync_Returns_Empty_On_204_NoContent()
{
var client = CreateClient(_ => NoContent());

var result = await client.GetProductsAsync();

Assert.NotNull(result);
Assert.Empty(result);
}

[Fact]
public async Task GetProductsAsync_Returns_Empty_On_200_With_Empty_Body()
{
var client = CreateClient(_ => OkEmpty());

var result = await client.GetProductsAsync();

Assert.NotNull(result);
Assert.Empty(result);
}

[Fact]
public async Task GetReceiptsAsync_Returns_Empty_On_204_NoContent()
{
var client = CreateClient(_ => NoContent());

var result = await client.GetReceiptsAsync("buyer@example.com", "PRD_1");

Assert.NotNull(result);
Assert.Empty(result);
}

[Fact]
public async Task GetReceiptsAsync_Returns_Empty_On_200_With_Empty_Body()
{
var client = CreateClient(_ => OkEmpty());

var result = await client.GetReceiptsAsync("buyer@example.com", "PRD_1");

Assert.NotNull(result);
Assert.Empty(result);
}

[Fact]
public async Task GetWebhooksAsync_Returns_Empty_On_204_NoContent()
{
var client = CreateClient(_ => NoContent());

var result = await client.GetWebhooksAsync();

Assert.NotNull(result);
Assert.Empty(result);
}

[Fact]
public async Task GetWebhooksAsync_Returns_Empty_On_200_With_Empty_Body()
{
var client = CreateClient(_ => OkEmpty());

var result = await client.GetWebhooksAsync();

Assert.NotNull(result);
Assert.Empty(result);
}

[Fact]
public async Task GetWebhookDeliveriesAsync_Returns_Empty_On_204_NoContent()
{
var client = CreateClient(_ => NoContent());

var result = await client.GetWebhookDeliveriesAsync("WH_1");

Assert.NotNull(result);
Assert.Empty(result);
}

[Fact]
public async Task GetComputerAsync_Returns_Null_On_200_With_Empty_Body()
{
var client = CreateClient(_ => OkEmpty());

var result = await client.GetComputerAsync("AA:BB:CC:DD:EE:FF");

Assert.Null(result);
}

private sealed class StubHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responder;

public StubHandler(Func<HttpRequestMessage, HttpResponseMessage> responder)
{
_responder = responder;
}

protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return Task.FromResult(_responder(request));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>

<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\LicenseManagement.Client.csproj" />
</ItemGroup>

</Project>
Loading