diff --git a/src/Dexpace.Sdk.Core/Pagination/AsyncPageable.cs b/src/Dexpace.Sdk.Core/Pagination/AsyncPageable.cs
new file mode 100644
index 0000000..2676a65
--- /dev/null
+++ b/src/Dexpace.Sdk.Core/Pagination/AsyncPageable.cs
@@ -0,0 +1,36 @@
+// Copyright (c) 2026 dexpace and Omar Aljarrah.
+// Licensed under the MIT License. See LICENSE in the repository root for details.
+
+namespace Dexpace.Sdk.Core.Pagination;
+
+///
+/// An async-enumerable sequence of items that is backed by a series of HTTP pages.
+///
+/// The item type.
+///
+///
+/// Consumers can iterate items directly (await foreach (var item in pageable)) or iterate
+/// pages via for access to per-page metadata such as status and headers.
+///
+///
+/// Enumeration is lazy: the next page is fetched only when the consumer advances past the last
+/// item of the current page. Each page send is an independent pipeline invocation.
+///
+///
+public abstract class AsyncPageable : IAsyncEnumerable
+{
+ /// Returns an async sequence of instances.
+ ///
+ /// An optional hint for the number of items per page. How (or whether) this is used depends
+ /// on the concrete implementation.
+ ///
+ /// An async sequence of pages.
+ public abstract IAsyncEnumerable> AsPages(int? pageSizeHint = null);
+
+ ///
+ /// Returns an enumerator that iterates items from all pages in sequence.
+ ///
+ /// A token to cancel enumeration.
+ /// An over all items across all pages.
+ public abstract IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default);
+}
diff --git a/src/Dexpace.Sdk.Core/Pagination/Page.cs b/src/Dexpace.Sdk.Core/Pagination/Page.cs
new file mode 100644
index 0000000..28bbd63
--- /dev/null
+++ b/src/Dexpace.Sdk.Core/Pagination/Page.cs
@@ -0,0 +1,43 @@
+// Copyright (c) 2026 dexpace and Omar Aljarrah.
+// Licensed under the MIT License. See LICENSE in the repository root for details.
+
+using Dexpace.Sdk.Core.Http.Common;
+using Dexpace.Sdk.Core.Http.Response;
+
+namespace Dexpace.Sdk.Core.Pagination;
+
+///
+/// A single page of results returned by a paginated operation.
+///
+/// The item type.
+///
+///
+/// contains the deserialized items for this page.
+/// and are the HTTP metadata captured from the
+/// response before it was disposed; they are immutable and safe to retain after iteration.
+///
+///
+public sealed class Page
+{
+ /// Creates a page.
+ /// The items on this page.
+ /// The HTTP status of the response that produced this page.
+ /// The HTTP response headers for this page.
+ public Page(IReadOnlyList values, Status status, Headers headers)
+ {
+ ArgumentNullException.ThrowIfNull(values);
+ ArgumentNullException.ThrowIfNull(headers);
+ Values = values;
+ Status = status;
+ Headers = headers;
+ }
+
+ /// The items on this page.
+ public IReadOnlyList Values { get; }
+
+ /// The HTTP status code of the response that produced this page.
+ public Status Status { get; }
+
+ /// The HTTP response headers for this page.
+ public Headers Headers { get; }
+}
diff --git a/src/Dexpace.Sdk.Core/Pagination/Pageable.cs b/src/Dexpace.Sdk.Core/Pagination/Pageable.cs
new file mode 100644
index 0000000..3d38209
--- /dev/null
+++ b/src/Dexpace.Sdk.Core/Pagination/Pageable.cs
@@ -0,0 +1,156 @@
+// Copyright (c) 2026 dexpace and Omar Aljarrah.
+// Licensed under the MIT License. See LICENSE in the repository root for details.
+
+using System.Runtime.CompilerServices;
+using Dexpace.Sdk.Core.Configuration;
+using Dexpace.Sdk.Core.Errors;
+using Dexpace.Sdk.Core.Http.Common;
+using Dexpace.Sdk.Core.Http.Request;
+using Dexpace.Sdk.Core.Http.Response;
+using Dexpace.Sdk.Core.Pipeline;
+using Dexpace.Sdk.Core.Serialization;
+
+namespace Dexpace.Sdk.Core.Pagination;
+
+///
+/// Factory methods for creating instances.
+///
+public static class Pageable
+{
+ ///
+ /// Creates an that fetches pages through
+ /// , starting with .
+ ///
+ /// The deserialized page-envelope type.
+ /// The item type extracted from each page.
+ /// The pipeline used for each page request.
+ /// The initial request to send.
+ /// The serde used to deserialize each .
+ /// Client options forwarded to each pipeline call.
+ ///
+ /// Extracts the ordered item list from a deserialized page envelope.
+ ///
+ ///
+ /// Given the deserialized page, the raw response (before disposal), and the current request,
+ /// returns the next request to send, or to end iteration.
+ ///
+ ///
+ /// Maximum number of pages to fetch. means no limit.
+ ///
+ ///
+ /// A lazy that fetches exactly one page per consumer advance.
+ ///
+ public static AsyncPageable Create(
+ HttpPipeline pipeline,
+ Request first,
+ ISerde serde,
+ DexpaceClientOptions options,
+ Func> selectItems,
+ Func nextRequest,
+ int? maxPages = null)
+ {
+ ArgumentNullException.ThrowIfNull(pipeline);
+ ArgumentNullException.ThrowIfNull(first);
+ ArgumentNullException.ThrowIfNull(serde);
+ ArgumentNullException.ThrowIfNull(options);
+ ArgumentNullException.ThrowIfNull(selectItems);
+ ArgumentNullException.ThrowIfNull(nextRequest);
+
+ return new PipelinePageable(pipeline, first, serde, options, selectItems, nextRequest, maxPages);
+ }
+
+ // ── internal implementation ────────────────────────────────────────────────────────────────
+
+ private sealed class PipelinePageable(
+ HttpPipeline pipeline,
+ Request first,
+ ISerde serde,
+ DexpaceClientOptions options,
+ Func> selectItems,
+ Func nextRequest,
+ int? maxPages) : AsyncPageable
+ {
+ ///
+ ///
+ /// is not plumbed into the outgoing request in v1; cancel
+ /// the pages path via .WithCancellation(token) on the returned sequence.
+ ///
+ public override IAsyncEnumerable> AsPages(int? pageSizeHint = null) =>
+ PagesCore(CancellationToken.None);
+
+ ///
+ public override IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) =>
+ ItemsCore(cancellationToken).GetAsyncEnumerator(cancellationToken);
+
+ // Page iterator — fetches one HTTP page per yield.
+ private async IAsyncEnumerable> PagesCore(
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ var current = first;
+ var fetched = 0;
+
+ while (true)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (maxPages.HasValue && fetched >= maxPages.Value)
+ {
+ yield break;
+ }
+
+ var response = await pipeline.SendAsync(current, options, cancellationToken)
+ .ConfigureAwait(false);
+
+ TPage page;
+ Status status;
+ Headers headers;
+ Request? next;
+
+ try
+ {
+ page = await response.Body
+ .ReadValueAsync(serde, cancellationToken)
+ .ConfigureAwait(false)
+ ?? throw new InvalidOperationException(
+ $"Serde returned null when deserializing page type '{typeof(TPage).FullName}'. " +
+ "The page deserialization must produce a non-null value.");
+
+ status = response.Status;
+ headers = response.Headers;
+
+ // Capture next while the response (and its headers) are still alive.
+ next = nextRequest(page, response, current);
+ }
+ finally
+ {
+ await response.DisposeAsync().ConfigureAwait(false);
+ }
+
+ fetched++;
+ yield return new Page(selectItems(page), status, headers);
+
+ if (next is null)
+ {
+ yield break;
+ }
+
+ current = next;
+ }
+ }
+
+ // Item iterator — flattens PagesCore.
+ private async IAsyncEnumerable ItemsCore(
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ await foreach (var page in PagesCore(cancellationToken)
+ .WithCancellation(cancellationToken)
+ .ConfigureAwait(false))
+ {
+ foreach (var item in page.Values)
+ {
+ yield return item;
+ }
+ }
+ }
+ }
+}
diff --git a/src/Dexpace.Sdk.Core/Pagination/PaginationStrategies.cs b/src/Dexpace.Sdk.Core/Pagination/PaginationStrategies.cs
new file mode 100644
index 0000000..cc3425b
--- /dev/null
+++ b/src/Dexpace.Sdk.Core/Pagination/PaginationStrategies.cs
@@ -0,0 +1,434 @@
+// Copyright (c) 2026 dexpace and Omar Aljarrah.
+// Licensed under the MIT License. See LICENSE in the repository root for details.
+
+using System.Globalization;
+using Dexpace.Sdk.Core.Http.Request;
+using Dexpace.Sdk.Core.Http.Response;
+
+namespace Dexpace.Sdk.Core.Pagination;
+
+///
+/// Factory methods that build nextRequest delegates for use with
+/// .
+///
+///
+/// Each factory returns a compatible with the
+/// nextRequest parameter of . The returned delegate
+/// receives the deserialized page, the raw (not-yet-disposed) response, and the current request,
+/// and must return the next to send or to stop
+/// iteration.
+///
+public static class PaginationStrategies
+{
+ // ── Cursor ────────────────────────────────────────────────────────────────────────────────
+
+ ///
+ /// Builds a next-request delegate that drives cursor-based pagination.
+ ///
+ /// The deserialized page-envelope type.
+ ///
+ /// Extracts the continuation cursor from a deserialized page. Return
+ /// or empty string to end iteration.
+ ///
+ ///
+ /// The URL query-string key to set to the cursor value on the next request.
+ ///
+ ///
+ /// A delegate that returns the next with
+ /// set to the cursor, or when the cursor is absent.
+ ///
+ public static Func Cursor(
+ Func nextCursor,
+ string queryParameter)
+ {
+ ArgumentNullException.ThrowIfNull(nextCursor);
+ ArgumentNullException.ThrowIfNull(queryParameter);
+
+ return (page, _, current) =>
+ {
+ var cursor = nextCursor(page);
+ if (string.IsNullOrEmpty(cursor))
+ {
+ return null;
+ }
+
+ return current with { Url = SetQueryParameter(current.Url, queryParameter, cursor) };
+ };
+ }
+
+ // ── PageNumber ────────────────────────────────────────────────────────────────────────────
+
+ ///
+ /// Builds a next-request delegate that drives page-number pagination.
+ ///
+ /// The deserialized page-envelope type.
+ ///
+ /// The URL query-string key that carries the page number. If absent on the current request URL
+ /// the current page number is treated as 1.
+ ///
+ ///
+ /// Returns when the response contains more pages to fetch.
+ ///
+ ///
+ /// A delegate that increments the page-number query parameter and returns the next
+ /// , or when returns
+ /// .
+ ///
+ public static Func PageNumber(
+ string queryParameter,
+ Func hasMore)
+ {
+ ArgumentNullException.ThrowIfNull(queryParameter);
+ ArgumentNullException.ThrowIfNull(hasMore);
+
+ return (page, _, current) =>
+ {
+ if (!hasMore(page))
+ {
+ return null;
+ }
+
+ var raw = GetQueryParameter(current.Url, queryParameter);
+ var currentPage = raw is not null && int.TryParse(raw, out var n) ? n : 1;
+ var nextPage = currentPage + 1;
+ return current with { Url = SetQueryParameter(current.Url, queryParameter, nextPage.ToString(CultureInfo.InvariantCulture)) };
+ };
+ }
+
+ // ── LinkHeader ────────────────────────────────────────────────────────────────────────────
+
+ ///
+ /// Builds a next-request delegate that drives link-header pagination (RFC 8288).
+ ///
+ ///
+ /// The rel type to look for in the Link response header.
+ /// Defaults to "next".
+ ///
+ ///
+ /// A delegate that reads the Link response header, resolves the URL for the
+ /// given , and returns the next ; or
+ /// if no matching entry is found or the URL cannot be resolved.
+ ///
+ public static Func LinkHeader(string rel = "next")
+ {
+ ArgumentNullException.ThrowIfNull(rel);
+
+ return (_, response, current) =>
+ {
+ var linkHeaderValue = response.Headers.Get("Link");
+ if (string.IsNullOrEmpty(linkHeaderValue))
+ {
+ return null;
+ }
+
+ var linkUrl = ParseLinkHeader(linkHeaderValue, rel);
+ if (linkUrl is null)
+ {
+ return null;
+ }
+
+ // Resolve the link URL against the current request URL.
+ if (!Uri.TryCreate(current.Url, linkUrl, out var resolved))
+ {
+ return null;
+ }
+
+ // Scheme guard: only allow http/https to prevent untrusted Link headers
+ // (mailto:, javascript:, ftp:, etc.) from producing a request with a
+ // non-http/https URL. Note: `current with { Url = ... }` bypasses the
+ // Request constructor's scheme validation, so the guard must live here.
+ if (!resolved.IsAbsoluteUri
+ || (!resolved.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
+ && !resolved.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)))
+ {
+ return null;
+ }
+
+ return current with { Url = resolved };
+ };
+ }
+
+ // ── private helpers ───────────────────────────────────────────────────────────────────────
+
+ ///
+ /// Returns the raw (URI-decoded) value of the first occurrence of
+ /// in the query string of , or
+ /// when the key is absent.
+ ///
+ private static string? GetQueryParameter(Uri uri, string key)
+ {
+ var query = uri.Query;
+ if (string.IsNullOrEmpty(query))
+ {
+ return null;
+ }
+
+ // Strip leading '?'.
+ var span = query.AsSpan().TrimStart('?');
+ var encodedKey = Uri.EscapeDataString(key);
+
+ foreach (var pair in new QueryPairEnumerator(span))
+ {
+ var eqIndex = pair.IndexOf('=');
+ var pairKey = eqIndex < 0 ? pair : pair[..eqIndex];
+ if (pairKey.Equals(encodedKey.AsSpan(), StringComparison.OrdinalIgnoreCase))
+ {
+ if (eqIndex < 0)
+ {
+ return string.Empty;
+ }
+
+ return Uri.UnescapeDataString(new string(pair[(eqIndex + 1)..]));
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Returns a new identical to except that
+ /// is set to (replacing any existing
+ /// occurrence, or appending if absent).
+ ///
+ private static Uri SetQueryParameter(Uri uri, string key, string value)
+ {
+ var encodedKey = Uri.EscapeDataString(key);
+ var encodedValue = Uri.EscapeDataString(value);
+ var pair = $"{encodedKey}={encodedValue}";
+
+ var existing = uri.Query.TrimStart('?');
+ string newQuery;
+
+ if (string.IsNullOrEmpty(existing))
+ {
+ newQuery = pair;
+ }
+ else
+ {
+ // Replace the existing key=value if present, otherwise append.
+ var span = existing.AsSpan();
+ var sb = new System.Text.StringBuilder();
+ var replaced = false;
+
+ foreach (var segment in new QueryPairEnumerator(span))
+ {
+ var eqIndex = segment.IndexOf('=');
+ var segKey = eqIndex < 0 ? segment : segment[..eqIndex];
+
+ if (!replaced && segKey.Equals(encodedKey.AsSpan(), StringComparison.OrdinalIgnoreCase))
+ {
+ if (sb.Length > 0) { sb.Append('&'); }
+ sb.Append(pair);
+ replaced = true;
+ }
+ else
+ {
+ if (sb.Length > 0) { sb.Append('&'); }
+ sb.Append(new string(segment));
+ }
+ }
+
+ if (!replaced)
+ {
+ if (sb.Length > 0) { sb.Append('&'); }
+ sb.Append(pair);
+ }
+
+ newQuery = sb.ToString();
+ }
+
+ var builder = new UriBuilder(uri) { Query = newQuery };
+ return builder.Uri;
+ }
+
+ ///
+ /// Parses the value of a Link header and returns the URL string for the entry
+ /// whose rel matches (case-insensitive), or
+ /// if not found.
+ ///
+ ///
+ ///
+ /// Handles the comma-separated format:
+ /// <https://api.example.com/items?page=2>; rel="next", <...>; rel="prev"
+ ///
+ ///
+ /// Quoted parameter values (e.g. title="Page 3, final") are handled correctly:
+ /// commas and semicolons inside a double-quoted run are not treated as delimiters.
+ ///
+ ///
+ /// Per RFC 8288, rel is a space-separated token list; any token that matches
+ /// is accepted.
+ ///
+ ///
+ private static string? ParseLinkHeader(string headerValue, string rel)
+ {
+ // Split on commas that are not inside angle brackets or double-quoted strings.
+ var entries = SplitLinkEntries(headerValue);
+
+ foreach (var entry in entries)
+ {
+ var trimmed = entry.Trim();
+ if (trimmed.Length == 0) { continue; }
+
+ // Each entry: ; param=value; param=value…
+ // Use quote-aware split on ';'.
+ var parts = SplitLinkParams(trimmed);
+ if (parts.Count < 2) { continue; }
+
+ // First part must be .
+ var urlPart = parts[0].Trim();
+ if (!urlPart.StartsWith('<') || !urlPart.EndsWith('>')) { continue; }
+
+ var linkUrl = urlPart[1..^1].Trim();
+
+ // Scan the remaining parts for rel= (RFC 8288: space-separated token list).
+ var hasMatchingRel = false;
+ for (var i = 1; i < parts.Count; i++)
+ {
+ var param = parts[i].Trim();
+
+ if (param.StartsWith("rel=", StringComparison.OrdinalIgnoreCase))
+ {
+ // Strip optional surrounding quotes, then split the token list on whitespace.
+ var relValue = param["rel=".Length..].Trim().Trim('"');
+ foreach (var token in relValue.Split(' ', StringSplitOptions.RemoveEmptyEntries))
+ {
+ if (string.Equals(token, rel, StringComparison.OrdinalIgnoreCase))
+ {
+ hasMatchingRel = true;
+ break;
+ }
+ }
+
+ if (hasMatchingRel) { break; }
+ }
+ }
+
+ if (hasMatchingRel)
+ {
+ return linkUrl;
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Splits a Link header value on top-level commas — those that are outside
+ /// both angle brackets (<…>) and double-quoted strings ("…").
+ ///
+ private static IEnumerable SplitLinkEntries(string headerValue)
+ {
+ var depth = 0; // angle-bracket nesting
+ var inQuotes = false; // inside "…"
+ var start = 0;
+
+ for (var i = 0; i < headerValue.Length; i++)
+ {
+ var ch = headerValue[i];
+
+ if (ch == '"')
+ {
+ inQuotes = !inQuotes;
+ }
+ else if (!inQuotes)
+ {
+ switch (ch)
+ {
+ case '<': depth++; break;
+ case '>': if (depth > 0) { depth--; } break;
+ case ',' when depth == 0:
+ yield return headerValue[start..i];
+ start = i + 1;
+ break;
+ }
+ }
+ }
+
+ if (start < headerValue.Length)
+ {
+ yield return headerValue[start..];
+ }
+ }
+
+ ///
+ /// Splits one Link entry on semicolons that are outside double-quoted strings.
+ /// The first element is always the <URL> target; the remainder are parameters.
+ ///
+ private static List SplitLinkParams(string entry)
+ {
+ var parts = new List();
+ var inQuotes = false;
+ var start = 0;
+
+ for (var i = 0; i < entry.Length; i++)
+ {
+ var ch = entry[i];
+
+ if (ch == '"')
+ {
+ inQuotes = !inQuotes;
+ }
+ else if (!inQuotes && ch == ';')
+ {
+ parts.Add(entry[start..i]);
+ start = i + 1;
+ }
+ }
+
+ if (start <= entry.Length)
+ {
+ parts.Add(entry[start..]);
+ }
+
+ return parts;
+ }
+
+ // ── query-pair ref-struct enumerator ──────────────────────────────────────────────────────
+
+ ///
+ /// Zero-allocation enumerator over key=value pairs in a query string span
+ /// (without the leading ?). Each iteration returns a
+ /// slice covering one key=value segment.
+ ///
+ private ref struct QueryPairEnumerator
+ {
+ private ReadOnlySpan _remaining;
+ private ReadOnlySpan _current;
+
+ internal QueryPairEnumerator(ReadOnlySpan query) => _remaining = query;
+
+ /// Returns this so the type works in foreach.
+ public readonly QueryPairEnumerator GetEnumerator() => this;
+
+ /// The current segment.
+ public readonly ReadOnlySpan Current => _current;
+
+ /// Advances to the next segment.
+ public bool MoveNext()
+ {
+ while (!_remaining.IsEmpty)
+ {
+ var idx = _remaining.IndexOf('&');
+ if (idx < 0)
+ {
+ _current = _remaining;
+ _remaining = [];
+ }
+ else
+ {
+ _current = _remaining[..idx];
+ _remaining = _remaining[(idx + 1)..];
+ }
+
+ // Skip empty segments (e.g. leading/trailing '&').
+ if (!_current.IsEmpty)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+ }
+}
diff --git a/tests/Dexpace.Sdk.Core.Tests/Pagination/PageableTests.cs b/tests/Dexpace.Sdk.Core.Tests/Pagination/PageableTests.cs
new file mode 100644
index 0000000..81f7c5a
--- /dev/null
+++ b/tests/Dexpace.Sdk.Core.Tests/Pagination/PageableTests.cs
@@ -0,0 +1,641 @@
+// Copyright (c) 2026 dexpace and Omar Aljarrah.
+// Licensed under the MIT License. See LICENSE in the repository root for details.
+
+using System.Buffers;
+using Dexpace.Sdk.Core.Client;
+using Dexpace.Sdk.Core.Configuration;
+using Dexpace.Sdk.Core.Http.Common;
+using Dexpace.Sdk.Core.Http.Request;
+using Dexpace.Sdk.Core.Http.Response;
+using Dexpace.Sdk.Core.Pagination;
+using Dexpace.Sdk.Core.Pipeline;
+using Dexpace.Sdk.Core.Serialization;
+using Xunit;
+
+namespace Dexpace.Sdk.Core.Tests.Pagination;
+
+///
+/// Integration tests for /
+/// / .
+///
+public class PageableTests
+{
+ // ── helpers ────────────────────────────────────────────────────────────────────────────────
+
+ private static DexpaceClientOptions DefaultOptions => new();
+
+ // A simple page envelope for tests.
+ private sealed record TestPage(IReadOnlyList Items, bool HasNext);
+
+ // Builds a pipeline + scripted transport from a sequence of pre-canned responses.
+ private static (HttpPipeline, ScriptedTransport) MakePipeline(params Response[] responses)
+ {
+ var transport = new ScriptedTransport(responses);
+ var pipeline = new PipelineBuilder().Build(transport);
+ return (pipeline, transport);
+ }
+
+ // nextRequest: advance to the next URL when HasNext is true.
+ private static Request? NextRequest(TestPage page, Response _, Request current) =>
+ page.HasNext ? current with { Url = new Uri(current.Url + "/next") } : null;
+
+ // Convenience: create a pageable over TestPage with int items.
+ private static AsyncPageable MakePageable(
+ HttpPipeline pipeline,
+ ISerde serde,
+ int? maxPages = null) =>
+ Pageable.Create(
+ pipeline,
+ Request.Get("https://api.example.com/items"),
+ serde,
+ DefaultOptions,
+ p => p.Items,
+ NextRequest,
+ maxPages);
+
+ // ── scripted transport ─────────────────────────────────────────────────────────────────────
+
+ private sealed class ScriptedTransport(params Response[] responses) : IAsyncHttpClient
+ {
+ private int _index;
+
+ public int CallCount => _index;
+
+ public Task ExecuteAsync(Request request, CancellationToken cancellationToken = default)
+ {
+ if (_index >= responses.Length)
+ {
+ throw new InvalidOperationException(
+ $"ScriptedTransport exhausted: {responses.Length} response(s) scripted, call #{_index + 1} received.");
+ }
+
+ return Task.FromResult(responses[_index++]);
+ }
+
+ public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+ }
+
+ // ── scripted serde ─────────────────────────────────────────────────────────────────────────
+
+ // A fake ISerde that ignores the stream and returns scripted page objects.
+ private sealed class ScriptedSerde(params TScripted[] pages) : ISerde
+ {
+ private int _index;
+
+ public MediaType DefaultMediaType => MediaType.Of("application", "json");
+
+ public ValueTask SerializeAsync(Stream destination, TVal value, CancellationToken ct = default) =>
+ ValueTask.CompletedTask;
+
+ public async ValueTask DeserializeAsync(Stream source, CancellationToken ct = default)
+ {
+ // Consume the stream to avoid leak warnings.
+ await source.CopyToAsync(Stream.Null, ct).ConfigureAwait(false);
+
+ if (typeof(TVal) != typeof(TScripted))
+ {
+ throw new InvalidOperationException(
+ $"ScriptedSerde<{typeof(TScripted).Name}> asked for {typeof(TVal).Name}.");
+ }
+
+ if (_index >= pages.Length)
+ {
+ throw new InvalidOperationException("ScriptedSerde exhausted.");
+ }
+
+ return (TVal)(object)pages[_index++]!;
+ }
+
+ public void Serialize(IBufferWriter destination, TVal value) { }
+
+ public TVal? Deserialize(ReadOnlySpan utf8) => default;
+ }
+
+ // ── tracking response body (asserts disposal) ─────────────────────────────────────────────
+
+ private sealed class TrackingBody : ResponseBody
+ {
+ private int _consumed;
+
+ public bool Disposed { get; private set; }
+
+ public override MediaType? ContentType => null;
+
+ public override Task OpenReadAsync(CancellationToken cancellationToken = default)
+ {
+ if (System.Threading.Interlocked.Exchange(ref _consumed, 1) != 0)
+ {
+ throw new Errors.StreamConsumedException("Already consumed.");
+ }
+
+ return Task.FromResult(new MemoryStream(Array.Empty(), writable: false));
+ }
+
+ public override void Dispose()
+ {
+ Disposed = true;
+ base.Dispose();
+ }
+
+ public override ValueTask DisposeAsync()
+ {
+ Disposed = true;
+ return base.DisposeAsync();
+ }
+ }
+
+ // ── item flattening ────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task GetAsyncEnumerator_FlattensItemsAcrossPages()
+ {
+ var page1 = new TestPage([1, 2], HasNext: true);
+ var page2 = new TestPage([3, 4], HasNext: false);
+
+ var serde = new ScriptedSerde(page1, page2);
+ var (pipeline, _) = MakePipeline(
+ new Response(Status.Ok),
+ new Response(Status.Ok));
+
+ var pageable = MakePageable(pipeline, serde);
+
+ var items = new List();
+ await foreach (var item in pageable)
+ {
+ items.Add(item);
+ }
+
+ Assert.Equal([1, 2, 3, 4], items);
+ }
+
+ // ── AsPages ────────────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task AsPages_YieldsCorrectNumberOfPages()
+ {
+ var page1 = new TestPage([10, 20], HasNext: true);
+ var page2 = new TestPage([30], HasNext: false);
+
+ var serde = new ScriptedSerde(page1, page2);
+ var (pipeline, transport) = MakePipeline(
+ new Response(Status.Ok),
+ new Response(Status.Ok));
+
+ var pageable = MakePageable(pipeline, serde);
+
+ var count = 0;
+ await foreach (var _ in pageable.AsPages())
+ {
+ count++;
+ }
+
+ Assert.Equal(2, count);
+ Assert.Equal(2, transport.CallCount);
+ }
+
+ [Fact]
+ public async Task AsPages_PageValuesMatchSelectItems()
+ {
+ var page1 = new TestPage([7, 8, 9], HasNext: false);
+
+ var serde = new ScriptedSerde(page1);
+ var (pipeline, _) = MakePipeline(new Response(Status.Ok));
+
+ var pageable = MakePageable(pipeline, serde);
+
+ var pages = new List>();
+ await foreach (var page in pageable.AsPages())
+ {
+ pages.Add(page);
+ }
+
+ Assert.Single(pages);
+ Assert.Equal([7, 8, 9], pages[0].Values);
+ }
+
+ [Fact]
+ public async Task AsPages_ExposesStatusAndHeaders()
+ {
+ var responseHeaders = Headers.Empty.With("X-Page", "42");
+ var page1 = new TestPage([1], HasNext: false);
+
+ var serde = new ScriptedSerde(page1);
+ var (pipeline, _) = MakePipeline(new Response(Status.Ok, responseHeaders));
+
+ var pageable = MakePageable(pipeline, serde);
+
+ var pages = new List>();
+ await foreach (var page in pageable.AsPages())
+ {
+ pages.Add(page);
+ }
+
+ Assert.Single(pages);
+ Assert.Equal(Status.Ok, pages[0].Status);
+ Assert.Equal(
+ responseHeaders.GetAll("X-Page"),
+ pages[0].Headers.GetAll("X-Page"));
+ }
+
+ // ── laziness ───────────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task Laziness_AfterFirstPageConsumed_TransportCalledOnce()
+ {
+ var page1 = new TestPage([1, 2], HasNext: true);
+ var page2 = new TestPage([3, 4], HasNext: false);
+
+ var serde = new ScriptedSerde(page1, page2);
+ var (pipeline, transport) = MakePipeline(
+ new Response(Status.Ok),
+ new Response(Status.Ok));
+
+ var pageable = MakePageable(pipeline, serde);
+
+ // Consume only the first page via AsPages enumerator.
+ await using var enumerator = pageable.AsPages().GetAsyncEnumerator();
+ var moved = await enumerator.MoveNextAsync();
+
+ Assert.True(moved);
+ // Only one HTTP call should have been made — the second page must not be pre-fetched.
+ Assert.Equal(1, transport.CallCount);
+ }
+
+ [Fact]
+ public async Task Laziness_AdvancingToSecondPage_TriggersSecondSend()
+ {
+ var page1 = new TestPage([1], HasNext: true);
+ var page2 = new TestPage([2], HasNext: false);
+
+ var serde = new ScriptedSerde(page1, page2);
+ var (pipeline, transport) = MakePipeline(
+ new Response(Status.Ok),
+ new Response(Status.Ok));
+
+ var pageable = MakePageable(pipeline, serde);
+
+ await using var enumerator = pageable.AsPages().GetAsyncEnumerator();
+
+ await enumerator.MoveNextAsync(); // fetches page 1 → 1 call
+ Assert.Equal(1, transport.CallCount);
+
+ await enumerator.MoveNextAsync(); // fetches page 2 → 2 calls
+ Assert.Equal(2, transport.CallCount);
+ }
+
+ // ── maxPages ───────────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task MaxPages_CapsNumberOfPagesFetched()
+ {
+ // Script 5 pages; cap at 2.
+ var scriptedPages = Enumerable.Range(0, 5)
+ .Select(i => new TestPage([i], HasNext: true))
+ .ToArray();
+
+ var serde = new ScriptedSerde(scriptedPages);
+ var responses = Enumerable.Range(0, 5).Select(_ => new Response(Status.Ok)).ToArray();
+ var (pipeline, transport) = MakePipeline(responses);
+
+ var pageable = MakePageable(pipeline, serde, maxPages: 2);
+
+ var collected = new List();
+ await foreach (var item in pageable)
+ {
+ collected.Add(item);
+ }
+
+ Assert.Equal(2, transport.CallCount);
+ Assert.Equal([0, 1], collected);
+ }
+
+ [Fact]
+ public async Task MaxPages_WhenNull_IteratesAllPages()
+ {
+ var page1 = new TestPage([1], HasNext: true);
+ var page2 = new TestPage([2], HasNext: true);
+ var page3 = new TestPage([3], HasNext: false);
+
+ var serde = new ScriptedSerde(page1, page2, page3);
+ var (pipeline, transport) = MakePipeline(
+ new Response(Status.Ok),
+ new Response(Status.Ok),
+ new Response(Status.Ok));
+
+ var pageable = MakePageable(pipeline, serde, maxPages: null);
+
+ var items = new List();
+ await foreach (var item in pageable)
+ {
+ items.Add(item);
+ }
+
+ Assert.Equal(3, transport.CallCount);
+ Assert.Equal([1, 2, 3], items);
+ }
+
+ // ── nextRequest returning null ends iteration ──────────────────────────────────────────────
+
+ [Fact]
+ public async Task NextRequestReturningNull_EndsIteration()
+ {
+ var page1 = new TestPage([1, 2], HasNext: false); // HasNext=false → nextRequest returns null
+
+ var serde = new ScriptedSerde(page1);
+ var (pipeline, transport) = MakePipeline(new Response(Status.Ok));
+
+ var pageable = MakePageable(pipeline, serde);
+
+ var items = new List();
+ await foreach (var item in pageable)
+ {
+ items.Add(item);
+ }
+
+ Assert.Equal(1, transport.CallCount);
+ Assert.Equal([1, 2], items);
+ }
+
+ // ── response disposal ──────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task EachResponse_IsDisposedAfterPageYield()
+ {
+ var body1 = new TrackingBody();
+ var body2 = new TrackingBody();
+
+ var page1 = new TestPage([1], HasNext: true);
+ var page2 = new TestPage([2], HasNext: false);
+
+ var serde = new ScriptedSerde(page1, page2);
+ var (pipeline, _) = MakePipeline(
+ new Response(Status.Ok, body: body1),
+ new Response(Status.Ok, body: body2));
+
+ var pageable = MakePageable(pipeline, serde);
+
+ await foreach (var _ in pageable) { }
+
+ Assert.True(body1.Disposed, "First response body should be disposed.");
+ Assert.True(body2.Disposed, "Second response body should be disposed.");
+ }
+
+ // ── Page constructor ────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void Page_Constructor_SetsProperties()
+ {
+ var values = new List { 1, 2, 3 };
+ var status = Status.Ok;
+ var headers = Headers.Empty.With("X-Foo", "bar");
+
+ var page = new Page(values, status, headers);
+
+ Assert.Same(values, page.Values);
+ Assert.Equal(status, page.Status);
+ Assert.Same(headers, page.Headers);
+ }
+
+ [Fact]
+ public void Page_Constructor_NullValues_Throws()
+ {
+ Assert.Throws(() => new Page(null!, Status.Ok, Headers.Empty));
+ }
+
+ [Fact]
+ public void Page_Constructor_NullHeaders_Throws()
+ {
+ Assert.Throws(() => new Page(Array.Empty(), Status.Ok, null!));
+ }
+
+ // ── Pageable.Create argument guards ───────────────────────────────────────────────────────
+
+ [Fact]
+ public void Create_NullPipeline_Throws()
+ {
+ var serde = new ScriptedSerde();
+ Assert.Throws(() =>
+ Pageable.Create(
+ null!,
+ Request.Get("https://x.com"),
+ serde,
+ DefaultOptions,
+ p => p.Items,
+ NextRequest));
+ }
+
+ [Fact]
+ public void Create_NullRequest_Throws()
+ {
+ var (pipeline, _) = MakePipeline();
+ var serde = new ScriptedSerde();
+ Assert.Throws(() =>
+ Pageable.Create(
+ pipeline,
+ null!,
+ serde,
+ DefaultOptions,
+ p => p.Items,
+ NextRequest));
+ }
+
+ [Fact]
+ public void Create_NullSerde_Throws()
+ {
+ var (pipeline, _) = MakePipeline();
+ Assert.Throws(() =>
+ Pageable.Create(
+ pipeline,
+ Request.Get("https://x.com"),
+ null!,
+ DefaultOptions,
+ p => p.Items,
+ NextRequest));
+ }
+
+ [Fact]
+ public void Create_NullOptions_Throws()
+ {
+ var (pipeline, _) = MakePipeline();
+ var serde = new ScriptedSerde();
+ Assert.Throws(() =>
+ Pageable.Create(
+ pipeline,
+ Request.Get("https://x.com"),
+ serde,
+ null!,
+ p => p.Items,
+ NextRequest));
+ }
+
+ [Fact]
+ public void Create_NullSelectItems_Throws()
+ {
+ var (pipeline, _) = MakePipeline();
+ var serde = new ScriptedSerde();
+ Assert.Throws(() =>
+ Pageable.Create(
+ pipeline,
+ Request.Get("https://x.com"),
+ serde,
+ DefaultOptions,
+ null!,
+ NextRequest));
+ }
+
+ [Fact]
+ public void Create_NullNextRequest_Throws()
+ {
+ var (pipeline, _) = MakePipeline();
+ var serde = new ScriptedSerde();
+ Assert.Throws(() =>
+ Pageable.Create(
+ pipeline,
+ Request.Get("https://x.com"),
+ serde,
+ DefaultOptions,
+ p => p.Items,
+ null!));
+ }
+
+ // ── disposal: early break ─────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task EarlyBreak_FirstBodyDisposed_AndTransportCalledOnce()
+ {
+ var body1 = new TrackingBody();
+ var page1 = new TestPage([1, 2], HasNext: true);
+ var page2 = new TestPage([3, 4], HasNext: false);
+
+ var serde = new ScriptedSerde(page1, page2);
+ var (pipeline, transport) = MakePipeline(
+ new Response(Status.Ok, body: body1),
+ new Response(Status.Ok));
+
+ var pageable = MakePageable(pipeline, serde);
+
+ // Consume only the first item then break — second page must never be fetched.
+ await foreach (var _ in pageable)
+ {
+ break;
+ }
+
+ Assert.True(body1.Disposed, "First response body should be disposed after early break.");
+ Assert.Equal(1, transport.CallCount);
+ }
+
+ // ── disposal: exception path ──────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task ExceptionFromSerde_BodyStillDisposed()
+ {
+ var body = new TrackingBody();
+
+ // Serde throws on the first call.
+ var throwingSerde = new ThrowingOnFirstCallSerde();
+ var (pipeline, _) = MakePipeline(new Response(Status.Ok, body: body));
+
+ var pageable = Pageable.Create(
+ pipeline,
+ Request.Get("https://api.example.com/items"),
+ throwingSerde,
+ DefaultOptions,
+ p => p.Items,
+ NextRequest);
+
+ await Assert.ThrowsAsync(async () =>
+ {
+ await foreach (var _ in pageable) { }
+ });
+
+ Assert.True(body.Disposed, "Response body must be disposed even when the serde throws.");
+ }
+
+ // ── re-enumeration ────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task ReEnumeration_EachEnumerationRestartsFromFirst()
+ {
+ // Two passes, each should see the same items and drive the transport independently.
+ var p1 = new TestPage([10, 20], HasNext: false);
+ var p2 = new TestPage([10, 20], HasNext: false); // scripted twice
+
+ var serde = new ScriptedSerde(p1, p2);
+ var (pipeline, transport) = MakePipeline(
+ new Response(Status.Ok),
+ new Response(Status.Ok));
+
+ var pageable = MakePageable(pipeline, serde);
+
+ var first = new List();
+ await foreach (var item in pageable) { first.Add(item); }
+
+ var second = new List();
+ await foreach (var item in pageable) { second.Add(item); }
+
+ Assert.Equal([10, 20], first);
+ Assert.Equal([10, 20], second);
+ // Each enumeration should have caused exactly one HTTP call (2 total).
+ Assert.Equal(2, transport.CallCount);
+ }
+
+ // ── cancellation ──────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task Cancellation_AlreadyCancelled_Items_ThrowsBeforeTransport()
+ {
+ var (pipeline, transport) = MakePipeline(new Response(Status.Ok));
+ var serde = new ScriptedSerde(new TestPage([1], HasNext: false));
+ var pageable = MakePageable(pipeline, serde);
+
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ await Assert.ThrowsAnyAsync(async () =>
+ {
+ await foreach (var _ in pageable.WithCancellation(cts.Token)) { }
+ });
+
+ Assert.Equal(0, transport.CallCount);
+ }
+
+ [Fact]
+ public async Task Cancellation_AlreadyCancelled_Pages_ThrowsBeforeTransport()
+ {
+ var (pipeline, transport) = MakePipeline(new Response(Status.Ok));
+ var serde = new ScriptedSerde(new TestPage([1], HasNext: false));
+ var pageable = MakePageable(pipeline, serde);
+
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ await Assert.ThrowsAnyAsync(async () =>
+ {
+ await foreach (var _ in pageable.AsPages().WithCancellation(cts.Token)) { }
+ });
+
+ Assert.Equal(0, transport.CallCount);
+ }
+
+ // ── helpers for the new tests ─────────────────────────────────────────────────────────────
+
+ // ISerde that always throws InvalidOperationException from DeserializeAsync.
+ private sealed class ThrowingOnFirstCallSerde : ISerde
+ {
+ public MediaType DefaultMediaType => MediaType.Of("application", "json");
+
+ public ValueTask SerializeAsync(Stream destination, TVal value, CancellationToken ct = default) =>
+ ValueTask.CompletedTask;
+
+ public async ValueTask DeserializeAsync(Stream source, CancellationToken ct = default)
+ {
+ // Drain the body before throwing so disposal tracking stays clean.
+ await source.CopyToAsync(Stream.Null, ct).ConfigureAwait(false);
+ throw new InvalidOperationException("Serde failure injected by test.");
+ }
+
+ public void Serialize(System.Buffers.IBufferWriter destination, TVal value) { }
+
+ public TVal? Deserialize(ReadOnlySpan utf8) => default;
+ }
+}
diff --git a/tests/Dexpace.Sdk.Core.Tests/Pagination/PaginationStrategiesTests.cs b/tests/Dexpace.Sdk.Core.Tests/Pagination/PaginationStrategiesTests.cs
new file mode 100644
index 0000000..86a5d89
--- /dev/null
+++ b/tests/Dexpace.Sdk.Core.Tests/Pagination/PaginationStrategiesTests.cs
@@ -0,0 +1,496 @@
+// Copyright (c) 2026 dexpace and Omar Aljarrah.
+// Licensed under the MIT License. See LICENSE in the repository root for details.
+
+using System.Buffers;
+using Dexpace.Sdk.Core.Client;
+using Dexpace.Sdk.Core.Configuration;
+using Dexpace.Sdk.Core.Http.Common;
+using Dexpace.Sdk.Core.Http.Request;
+using Dexpace.Sdk.Core.Http.Response;
+using Dexpace.Sdk.Core.Pagination;
+using Dexpace.Sdk.Core.Pipeline;
+using Dexpace.Sdk.Core.Serialization;
+using Xunit;
+
+namespace Dexpace.Sdk.Core.Tests.Pagination;
+
+///
+/// Unit tests for .
+///
+public class PaginationStrategiesTests
+{
+ // ── helpers ────────────────────────────────────────────────────────────────────────────────
+
+ private static DexpaceClientOptions DefaultOptions => new();
+
+ // Simple page envelope.
+ private sealed record TestPage(IReadOnlyList Items, string? Cursor, bool HasMore);
+
+ private static Request BaseRequest(string url) => Request.Get(url);
+
+ // A no-op response used when the response is not examined by the strategy under test.
+ private static Response EmptyResponse => new(Status.Ok);
+
+ // A response that carries a Link header.
+ private static Response ResponseWithLink(string linkHeaderValue) =>
+ new(Status.Ok, Headers.Empty.With("Link", linkHeaderValue));
+
+ // ── ScriptedTransport + ScriptedSerde (mirrors PageableTests helpers) ──────────────────────
+
+ private sealed class ScriptedTransport(params Response[] responses) : IAsyncHttpClient
+ {
+ private int _index;
+
+ public int CallCount => _index;
+
+ public Task ExecuteAsync(Request request, CancellationToken cancellationToken = default)
+ {
+ if (_index >= responses.Length)
+ {
+ throw new InvalidOperationException(
+ $"ScriptedTransport exhausted: call #{_index + 1} received.");
+ }
+
+ return Task.FromResult(responses[_index++]);
+ }
+
+ public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+ }
+
+ private sealed class ScriptedSerde(params TScripted[] pages) : ISerde
+ {
+ private int _index;
+
+ public MediaType DefaultMediaType => MediaType.Of("application", "json");
+
+ public ValueTask SerializeAsync(Stream destination, TVal value, CancellationToken ct = default) =>
+ ValueTask.CompletedTask;
+
+ public async ValueTask DeserializeAsync(Stream source, CancellationToken ct = default)
+ {
+ await source.CopyToAsync(Stream.Null, ct).ConfigureAwait(false);
+
+ if (typeof(TVal) != typeof(TScripted))
+ {
+ throw new InvalidOperationException(
+ $"ScriptedSerde<{typeof(TScripted).Name}> asked for {typeof(TVal).Name}.");
+ }
+
+ if (_index >= pages.Length)
+ {
+ throw new InvalidOperationException("ScriptedSerde exhausted.");
+ }
+
+ return (TVal)(object)pages[_index++]!;
+ }
+
+ public void Serialize(IBufferWriter destination, TVal value) { }
+
+ public TVal? Deserialize(ReadOnlySpan utf8) => default;
+ }
+
+ // Builds a pipeline from a scripted transport.
+ private static (HttpPipeline, ScriptedTransport) MakePipeline(params Response[] responses)
+ {
+ var transport = new ScriptedTransport(responses);
+ var pipeline = new PipelineBuilder().Build(transport);
+ return (pipeline, transport);
+ }
+
+ // ── Cursor ────────────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void Cursor_WithNonNullCursor_SetsQueryParameter()
+ {
+ var strategy = PaginationStrategies.Cursor(p => p.Cursor, "cursor");
+ var current = BaseRequest("https://api.example.com/items");
+ var page = new TestPage([], "abc123", HasMore: true);
+
+ var next = strategy(page, EmptyResponse, current);
+
+ Assert.NotNull(next);
+ Assert.Equal("abc123", GetQueryParam(next.Url, "cursor"));
+ }
+
+ [Fact]
+ public void Cursor_WithNullCursor_ReturnsNull()
+ {
+ var strategy = PaginationStrategies.Cursor(p => p.Cursor, "cursor");
+ var current = BaseRequest("https://api.example.com/items");
+ var page = new TestPage([], null, HasMore: false);
+
+ var next = strategy(page, EmptyResponse, current);
+
+ Assert.Null(next);
+ }
+
+ [Fact]
+ public void Cursor_WithEmptyStringCursor_ReturnsNull()
+ {
+ var strategy = PaginationStrategies.Cursor(p => string.Empty, "cursor");
+ var current = BaseRequest("https://api.example.com/items");
+ var page = new TestPage([], string.Empty, HasMore: false);
+
+ var next = strategy(page, EmptyResponse, current);
+
+ Assert.Null(next);
+ }
+
+ [Fact]
+ public void Cursor_PreservesMethodAndHeaders()
+ {
+ var strategy = PaginationStrategies.Cursor(p => p.Cursor, "after");
+ var current = BaseRequest("https://api.example.com/items")
+ .WithHeader("Authorization", "Bearer tok");
+ var page = new TestPage([], "tok_next", HasMore: true);
+
+ var next = strategy(page, EmptyResponse, current);
+
+ Assert.NotNull(next);
+ Assert.Equal(current.Method, next.Method);
+ Assert.Equal("Bearer tok", next.Headers.Get("Authorization"));
+ }
+
+ [Fact]
+ public void Cursor_ReplacesExistingCursorOnSubsequentPages()
+ {
+ var strategy = PaginationStrategies.Cursor(p => p.Cursor, "cursor");
+ var current = BaseRequest("https://api.example.com/items?cursor=old");
+ var page = new TestPage([], "new_cursor", HasMore: true);
+
+ var next = strategy(page, EmptyResponse, current);
+
+ Assert.NotNull(next);
+ Assert.Equal("new_cursor", GetQueryParam(next.Url, "cursor"));
+ // Old value must not appear.
+ Assert.DoesNotContain("old", next.Url.Query);
+ }
+
+ // ── PageNumber ────────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void PageNumber_WhenHasMore_IncrementsPageParameter()
+ {
+ var strategy = PaginationStrategies.PageNumber("page", p => p.HasMore);
+ var current = BaseRequest("https://api.example.com/items?page=2");
+ var page = new TestPage([], null, HasMore: true);
+
+ var next = strategy(page, EmptyResponse, current);
+
+ Assert.NotNull(next);
+ Assert.Equal("3", GetQueryParam(next.Url, "page"));
+ }
+
+ [Fact]
+ public void PageNumber_WhenPageParameterAbsent_DefaultsToOneAndIncrements()
+ {
+ var strategy = PaginationStrategies.PageNumber("page", p => p.HasMore);
+ var current = BaseRequest("https://api.example.com/items");
+ var page = new TestPage([], null, HasMore: true);
+
+ var next = strategy(page, EmptyResponse, current);
+
+ Assert.NotNull(next);
+ Assert.Equal("2", GetQueryParam(next.Url, "page"));
+ }
+
+ [Fact]
+ public void PageNumber_WhenNoMore_ReturnsNull()
+ {
+ var strategy = PaginationStrategies.PageNumber("page", p => p.HasMore);
+ var current = BaseRequest("https://api.example.com/items?page=3");
+ var page = new TestPage([], null, HasMore: false);
+
+ var next = strategy(page, EmptyResponse, current);
+
+ Assert.Null(next);
+ }
+
+ // ── LinkHeader ────────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void LinkHeader_WithNextRel_ReturnsAbsoluteUrl()
+ {
+ var strategy = PaginationStrategies.LinkHeader("next");
+ var current = BaseRequest("https://api.example.com/items");
+ var response = ResponseWithLink("; rel=\"next\"");
+
+ var next = strategy(default!, response, current);
+
+ Assert.NotNull(next);
+ Assert.Equal(new Uri("https://api.example.com/items?page=2"), next.Url);
+ }
+
+ [Fact]
+ public void LinkHeader_WithNoMatchingRel_ReturnsNull()
+ {
+ var strategy = PaginationStrategies.LinkHeader("next");
+ var current = BaseRequest("https://api.example.com/items");
+ var response = ResponseWithLink("; rel=\"prev\"");
+
+ var next = strategy(default!, response, current);
+
+ Assert.Null(next);
+ }
+
+ [Fact]
+ public void LinkHeader_WithRelativeUrl_ResolvesAgainstCurrentRequest()
+ {
+ var strategy = PaginationStrategies.LinkHeader("next");
+ var current = BaseRequest("https://api.example.com/items");
+ // Relative URL — should be resolved against current request URL.
+ var response = ResponseWithLink("; rel=\"next\"");
+
+ var next = strategy(default!, response, current);
+
+ Assert.NotNull(next);
+ Assert.Equal(new Uri("https://api.example.com/items?page=2"), next.Url);
+ }
+
+ [Fact]
+ public void LinkHeader_WithMissingLinkHeader_ReturnsNull()
+ {
+ var strategy = PaginationStrategies.LinkHeader("next");
+ var current = BaseRequest("https://api.example.com/items");
+
+ var next = strategy(default!, EmptyResponse, current);
+
+ Assert.Null(next);
+ }
+
+ [Fact]
+ public void LinkHeader_WithMultipleRelEntries_FindsNext()
+ {
+ var strategy = PaginationStrategies.LinkHeader("next");
+ var current = BaseRequest("https://api.example.com/items");
+ var response = ResponseWithLink(
+ "; rel=\"prev\", ; rel=\"next\"");
+
+ var next = strategy(default!, response, current);
+
+ Assert.NotNull(next);
+ Assert.Equal(new Uri("https://api.example.com/items?page=3"), next.Url);
+ }
+
+ [Fact]
+ public void LinkHeader_WithMalformedEntry_ReturnsNull()
+ {
+ var strategy = PaginationStrategies.LinkHeader("next");
+ var current = BaseRequest("https://api.example.com/items");
+ // No angle brackets — malformed.
+ var response = ResponseWithLink("not-a-valid-link-header");
+
+ var next = strategy(default!, response, current);
+
+ Assert.Null(next);
+ }
+
+ [Fact]
+ public void LinkHeader_DefaultRelIsNext()
+ {
+ // No rel= parameter — use default.
+ var strategy = PaginationStrategies.LinkHeader();
+ var current = BaseRequest("https://api.example.com/items");
+ var response = ResponseWithLink("; rel=\"next\"");
+
+ var next = strategy(default!, response, current);
+
+ Assert.NotNull(next);
+ }
+
+ [Fact]
+ public void LinkHeader_QuotedCommaInParam_DoesNotSplitEntry()
+ {
+ // A comma inside a quoted parameter value must not be treated as an entry separator.
+ // Without quote-aware splitting, the entry is broken at the comma inside the title
+ // and the rel="next" token ends up in a truncated fragment, so parsing returns null.
+ var strategy = PaginationStrategies.LinkHeader("next");
+ var current = BaseRequest("https://api.example.com/items");
+ var response = ResponseWithLink(
+ "; rel=\"next\"; title=\"Page 3, final\"");
+
+ var next = strategy(default!, response, current);
+
+ Assert.NotNull(next);
+ Assert.Equal(new Uri("https://api.example.com/items?page=3"), next.Url);
+ }
+
+ [Fact]
+ public void LinkHeader_QuotedSemicolonInParam_DoesNotBreakParamParsing()
+ {
+ // A semicolon inside a quoted parameter value must not be treated as a param separator.
+ var strategy = PaginationStrategies.LinkHeader("next");
+ var current = BaseRequest("https://api.example.com/items");
+ var response = ResponseWithLink(
+ "; title=\"A; B\"; rel=\"next\"");
+
+ var next = strategy(default!, response, current);
+
+ Assert.NotNull(next);
+ Assert.Equal(new Uri("https://api.example.com/items?page=2"), next.Url);
+ }
+
+ [Fact]
+ public void LinkHeader_MultiValuedRel_MatchesWhenAnyTokenEquals()
+ {
+ // RFC 8288: rel is a space-separated token list; rel="prev next" should match "next".
+ var strategy = PaginationStrategies.LinkHeader("next");
+ var current = BaseRequest("https://api.example.com/items");
+ var response = ResponseWithLink(
+ "; rel=\"prev next\"");
+
+ var next = strategy(default!, response, current);
+
+ Assert.NotNull(next);
+ Assert.Equal(new Uri("https://api.example.com/items?page=2"), next.Url);
+ }
+
+ [Fact]
+ public void LinkHeader_MailtoScheme_ReturnsNull()
+ {
+ // A Link header with a mailto: URL must not be followed.
+ var strategy = PaginationStrategies.LinkHeader("next");
+ var current = BaseRequest("https://api.example.com/items");
+ var response = ResponseWithLink("; rel=\"next\"");
+
+ var next = strategy(default!, response, current);
+
+ Assert.Null(next);
+ }
+
+ [Fact]
+ public void LinkHeader_FtpScheme_ReturnsNull()
+ {
+ // A Link header with an ftp: URL must not be followed.
+ var strategy = PaginationStrategies.LinkHeader("next");
+ var current = BaseRequest("https://api.example.com/items");
+ var response = ResponseWithLink("; rel=\"next\"");
+
+ var next = strategy(default!, response, current);
+
+ Assert.Null(next);
+ }
+
+ // ── SetQueryParameter ─────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void SetQueryParameter_PreservesFragment()
+ {
+ // SetQueryParameter must keep an existing fragment.
+ var strategy = PaginationStrategies.Cursor(p => p.Cursor, "cursor");
+ var current = BaseRequest("https://api.example.com/items#section2");
+ var page = new TestPage([], "abc", HasMore: true);
+
+ var next = strategy(page, EmptyResponse, current);
+
+ Assert.NotNull(next);
+ Assert.Equal("section2", next.Url.Fragment.TrimStart('#'));
+ Assert.Equal("abc", GetQueryParam(next.Url, "cursor"));
+ }
+
+ [Fact]
+ public void SetQueryParameter_PreservesSiblingQueryParam()
+ {
+ // SetQueryParameter must keep unrelated query parameters while replacing/adding the target.
+ var strategy = PaginationStrategies.Cursor(p => p.Cursor, "cursor");
+ var current = BaseRequest("https://api.example.com/items?limit=10");
+ var page = new TestPage([], "tok", HasMore: true);
+
+ var next = strategy(page, EmptyResponse, current);
+
+ Assert.NotNull(next);
+ Assert.Equal("tok", GetQueryParam(next.Url, "cursor"));
+ Assert.Equal("10", GetQueryParam(next.Url, "limit"));
+ }
+
+ // ── Body preservation ─────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void Cursor_PreservesRequestBody()
+ {
+ // A request carrying a body must have that body carried over to the next request.
+ var strategy = PaginationStrategies.Cursor(p => p.Cursor, "cursor");
+ var body = RequestBody.FromBytes(new byte[] { 0x01, 0x02 }, MediaType.Of("application", "octet-stream"));
+ var current = Request.Create(Method.Post, "https://api.example.com/items", body: body);
+ var page = new TestPage([], "tok", HasMore: true);
+
+ var next = strategy(page, EmptyResponse, current);
+
+ Assert.NotNull(next);
+ Assert.Same(body, next.Body);
+ Assert.Equal("tok", GetQueryParam(next.Url, "cursor"));
+ }
+
+ [Fact]
+ public void PageNumber_PreservesRequestBody()
+ {
+ // A request carrying a body must have that body carried over to the next request.
+ var strategy = PaginationStrategies.PageNumber("page", p => p.HasMore);
+ var body = RequestBody.FromBytes(new byte[] { 0xAB }, MediaType.Of("application", "octet-stream"));
+ var current = Request.Create(Method.Post, "https://api.example.com/items?page=1", body: body);
+ var page = new TestPage([], null, HasMore: true);
+
+ var next = strategy(page, EmptyResponse, current);
+
+ Assert.NotNull(next);
+ Assert.Same(body, next.Body);
+ Assert.Equal("2", GetQueryParam(next.Url, "page"));
+ }
+
+ // ── End-to-end: Pageable.Create + a strategy ──────────────────────────────────────────────
+
+ [Fact]
+ public async Task EndToEnd_CursorStrategy_TwoPagesViaFakePipeline()
+ {
+ var page1 = new TestPage([1, 2], Cursor: "cur1", HasMore: true);
+ var page2 = new TestPage([3, 4], Cursor: null, HasMore: false);
+
+ var serde = new ScriptedSerde(page1, page2);
+ var (pipeline, transport) = MakePipeline(
+ new Response(Status.Ok),
+ new Response(Status.Ok));
+
+ var strategy = PaginationStrategies.Cursor(p => p.Cursor, "cursor");
+
+ var pageable = Pageable.Create(
+ pipeline,
+ Request.Get("https://api.example.com/items"),
+ serde,
+ DefaultOptions,
+ p => p.Items,
+ strategy);
+
+ var items = new List();
+ await foreach (var item in pageable)
+ {
+ items.Add(item);
+ }
+
+ Assert.Equal([1, 2, 3, 4], items);
+ Assert.Equal(2, transport.CallCount);
+ }
+
+ // ── helpers ────────────────────────────────────────────────────────────────────────────────
+
+ ///
+ /// Extracts the raw (decoded) value of from 's
+ /// query string, or if absent.
+ ///
+ private static string? GetQueryParam(Uri uri, string key)
+ {
+ var query = uri.Query.TrimStart('?');
+ if (string.IsNullOrEmpty(query)) { return null; }
+
+ foreach (var pair in query.Split('&'))
+ {
+ var eq = pair.IndexOf('=', StringComparison.Ordinal);
+ var pairKey = eq < 0 ? pair : pair[..eq];
+ if (string.Equals(Uri.UnescapeDataString(pairKey), key, StringComparison.OrdinalIgnoreCase))
+ {
+ return eq < 0 ? string.Empty : Uri.UnescapeDataString(pair[(eq + 1)..]);
+ }
+ }
+
+ return null;
+ }
+}