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
168 changes: 168 additions & 0 deletions JsonApiToolkit.Tests/Parsing/StrictPaginationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
using JsonApiToolkit.Configuration;
using JsonApiToolkit.Models.Errors;
using JsonApiToolkit.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;

namespace JsonApiToolkit.Tests.Parsing;

public class StrictPaginationTests
{
private static JsonApiQueryParserService CreateService(JsonApiOptions? options = null)
{
options ??= new JsonApiOptions();
return new JsonApiQueryParserService(
NullLogger<JsonApiQueryParserService>.Instance,
Options.Create(options)
);
}

private static HttpRequest CreateRequest(Dictionary<string, StringValues> query)
{
var httpContext = new DefaultHttpContext();
httpContext.Request.Query = new QueryCollection(query);
return httpContext.Request;
}

// ─────────────────────────────────────────────────────────────────────────
// Default behavior (StrictPagination = false)
// ─────────────────────────────────────────────────────────────────────────

[Fact]
public void Default_PageNumberZero_ClampedToOne()
{
var service = CreateService();
var request = CreateRequest(
new Dictionary<string, StringValues> { ["page[number]"] = "0" }
);

var result = service.Parse(request);

Assert.NotNull(result.Pagination);
Assert.Equal(1, result.Pagination.Number);
}

[Fact]
public void Default_PageSizeExceedsMax_ClampedToMax()
{
var service = CreateService(new JsonApiOptions { MaxPageSize = 50 });
var request = CreateRequest(
new Dictionary<string, StringValues> { ["page[size]"] = "200" }
);

var result = service.Parse(request);

Assert.NotNull(result.Pagination);
Assert.Equal(50, result.Pagination.Size);
}

// ─────────────────────────────────────────────────────────────────────────
// Strict mode (StrictPagination = true)
// ─────────────────────────────────────────────────────────────────────────

[Fact]
public void Strict_PageNumberZero_Throws400()
{
var service = CreateService(new JsonApiOptions { StrictPagination = true });
var request = CreateRequest(
new Dictionary<string, StringValues> { ["page[number]"] = "0" }
);

var ex = Assert.Throws<JsonApiBadRequestException>(() => service.Parse(request));
Assert.Contains("Invalid page number", ex.Message);
}

[Fact]
public void Strict_PageNumberNegative_Throws400()
{
var service = CreateService(new JsonApiOptions { StrictPagination = true });
var request = CreateRequest(
new Dictionary<string, StringValues> { ["page[number]"] = "-5" }
);

var ex = Assert.Throws<JsonApiBadRequestException>(() => service.Parse(request));
Assert.Contains("Invalid page number", ex.Message);
}

[Fact]
public void Strict_PageSizeZero_Throws400()
{
var service = CreateService(new JsonApiOptions { StrictPagination = true });
var request = CreateRequest(new Dictionary<string, StringValues> { ["page[size]"] = "0" });

var ex = Assert.Throws<JsonApiBadRequestException>(() => service.Parse(request));
Assert.Contains("Invalid page size", ex.Message);
}

[Fact]
public void Strict_PageSizeNegative_Throws400()
{
var service = CreateService(new JsonApiOptions { StrictPagination = true });
var request = CreateRequest(
new Dictionary<string, StringValues> { ["page[size]"] = "-10" }
);

var ex = Assert.Throws<JsonApiBadRequestException>(() => service.Parse(request));
Assert.Contains("Invalid page size", ex.Message);
}

[Fact]
public void Strict_PageSizeExceedsMax_Throws400()
{
var service = CreateService(
new JsonApiOptions { StrictPagination = true, MaxPageSize = 50 }
);
var request = CreateRequest(
new Dictionary<string, StringValues> { ["page[size]"] = "100" }
);

var ex = Assert.Throws<JsonApiBadRequestException>(() => service.Parse(request));
Assert.Contains("exceeds maximum", ex.Message);
}

[Fact]
public void Strict_ValidParameters_Works()
{
var service = CreateService(new JsonApiOptions { StrictPagination = true });
var request = CreateRequest(
new Dictionary<string, StringValues> { ["page[number]"] = "3", ["page[size]"] = "25" }
);

var result = service.Parse(request);

Assert.NotNull(result.Pagination);
Assert.Equal(3, result.Pagination.Number);
Assert.Equal(25, result.Pagination.Size);
}

[Fact]
public void Strict_NonParseablePageNumber_DefaultsToOne()
{
var service = CreateService(new JsonApiOptions { StrictPagination = true });
var request = CreateRequest(
new Dictionary<string, StringValues> { ["page[number]"] = "abc" }
);

// Non-parseable values are silently defaulted, not rejected
var result = service.Parse(request);

Assert.NotNull(result.Pagination);
Assert.Equal(1, result.Pagination.Number);
}

[Fact]
public void Strict_PageSizeAtMax_Works()
{
var service = CreateService(
new JsonApiOptions { StrictPagination = true, MaxPageSize = 50 }
);
var request = CreateRequest(new Dictionary<string, StringValues> { ["page[size]"] = "50" });

var result = service.Parse(request);

Assert.NotNull(result.Pagination);
Assert.Equal(50, result.Pagination.Size);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -239,9 +239,9 @@ public void Parse_WithMultipleLimitsExceeded_ThrowsOnFirstViolation()
}
);

// Filters are checked first
// Include depth is validated during parsing (before post-parse filter count check)
var exception = Assert.Throws<JsonApiBadRequestException>(() => service.Parse(request));
Assert.Contains("filters", exception.Message);
Assert.Contains("Include path", exception.Message);
}

// ─────────────────────────────────────────────────────────────────────────
Expand Down
7 changes: 7 additions & 0 deletions JsonApiToolkit/Configuration/JsonApiOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ public class JsonApiOptions
/// </summary>
public int DefaultPageSize { get; set; } = 10;

/// <summary>
/// When true, returns 400 Bad Request for invalid pagination values instead of
/// silently clamping. Invalid page numbers (less than 1) and page sizes (less than 1 or
/// exceeding MaxPageSize) will return errors. Default: false (clamp for backwards compatibility).
/// </summary>
public bool StrictPagination { get; set; }

/// <summary>
/// When true, applies database-level column filtering via EF Core Select() projection
/// when fields[type] is specified in the request. Only fetches requested columns from
Expand Down
Loading
Loading