diff --git a/CHANGELOG.md b/CHANGELOG.md
index e5a87b5..d218136 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,16 @@
# Changelog
+## 0.3.4
+
+- Bumped `ApifyClientVersion.ApiSpecVersion` to the Apify OpenAPI spec `v2-2026-09-10T091137Z` and
+ the project version to `0.3.4`. This spec update only documents `X-Apify-Pagination-*` response
+ headers and the `offset`/`limit`/`desc` query parameters already supported by this client; no
+ client behavior changed.
+- Added missing iteration integration tests (`IterationIntegrationTests`) covering every
+ `IterateAsync` collection client that previously had no dedicated test: Actors, Actor versions,
+ Actor environment variables, datasets, key-value stores, request queues, tasks, schedules,
+ webhooks, builds, runs, and webhook dispatches.
+
## 0.3.3
- Bumped `ApifyClientVersion.ApiSpecVersion` to the Apify OpenAPI spec `v2-2026-09-02T154542Z` and
diff --git a/src/Apify.Client/Apify.Client.csproj b/src/Apify.Client/Apify.Client.csproj
index 17c6e17..f7b4421 100644
--- a/src/Apify.Client/Apify.Client.csproj
+++ b/src/Apify.Client/Apify.Client.csproj
@@ -6,7 +6,7 @@
Apify.Client
- 0.3.3
+ 0.3.4
Apify
Apify
Apify API client for .NET
diff --git a/src/Apify.Client/ApifyClientVersion.cs b/src/Apify.Client/ApifyClientVersion.cs
index 93809ff..919b915 100644
--- a/src/Apify.Client/ApifyClientVersion.cs
+++ b/src/Apify.Client/ApifyClientVersion.cs
@@ -14,11 +14,11 @@ public static class ApifyClientVersion
/// The semantic version of this client library (see https://semver.org/). Changes to the public
/// interface other than additive ones are considered breaking changes.
///
- public const string ClientVersion = "0.3.3";
+ public const string ClientVersion = "0.3.4";
///
/// The version of the Apify OpenAPI specification this client was generated and verified against.
/// Corresponds to the info.version field of the Apify OpenAPI document.
///
- public const string ApiSpecVersion = "v2-2026-09-02T154542Z";
+ public const string ApiSpecVersion = "v2-2026-09-10T091137Z";
}
diff --git a/tests/Apify.Client.Tests/Integration/IntegrationTestBase.cs b/tests/Apify.Client.Tests/Integration/IntegrationTestBase.cs
index f8fa16b..c1bb2e6 100644
--- a/tests/Apify.Client.Tests/Integration/IntegrationTestBase.cs
+++ b/tests/Apify.Client.Tests/Integration/IntegrationTestBase.cs
@@ -1,5 +1,7 @@
using System;
+using System.Collections.Generic;
using System.Security.Cryptography;
+using System.Threading.Tasks;
using Apify.Client;
using Xunit;
@@ -20,6 +22,19 @@ public abstract class IntegrationTestBase
/// The integration-test contract fallback base URL.
private const string DefaultApiUrl = "https://api.apify.com/v2";
+ ///
+ /// Retry budget for : how many times a freshly created
+ /// resource's collection listing is re-scanned before giving up on it having propagated.
+ ///
+ private const int EventualConsistencyAttempts = 16;
+
+ ///
+ /// Delay between retries in . Combined with
+ /// , the total wait budget is
+ /// (EventualConsistencyAttempts - 1) * EventualConsistencyBackoff = ~15s.
+ ///
+ private static readonly TimeSpan EventualConsistencyBackoff = TimeSpan.FromSeconds(1);
+
///
/// Derives the client base URL from an optional APIFY_API_URL . The variable includes the
/// /v2 suffix (per the integration-test contract) and falls back to the default. Since the client
@@ -79,4 +94,91 @@ protected static string UniqueName(string prefix)
},
},
};
+
+ /// A minimal Actor task definition targeting the public apify/hello-world Actor.
+ protected static object MinimalTask(string name) => new
+ {
+ actId = "apify/hello-world",
+ name,
+ options = new { build = "latest", memoryMbytes = 256, timeoutSecs = 60 },
+ input = new { message = "hello" },
+ };
+
+ /// A minimal, disabled schedule definition (no actions, so it never actually fires).
+ protected static object MinimalSchedule(string name) => new
+ {
+ name,
+ cronExpression = "0 0 * * *",
+ isEnabled = false,
+ isExclusive = true,
+ actions = Array.Empty(),
+ };
+
+ /// A minimal ad-hoc webhook definition targeting a condition that never actually fires.
+ protected static object MinimalWebhook(string requestUrl) => new
+ {
+ isAdHoc = true,
+ eventTypes = new[] { "ACTOR.RUN.SUCCEEDED" },
+ condition = new { actorRunId = "ZZZZZZZZZZZZZZZZZ" },
+ requestUrl,
+ };
+
+ ///
+ /// Retries up to times, sleeping
+ /// between attempts, until it returns true . Used to tolerate
+ /// collection-listing eventual consistency: a resource created through a write endpoint is not always
+ /// immediately reflected in that collection's LIST response.
+ ///
+ protected static async Task PollUntilAsync(int attempts, TimeSpan backoff, Func> check)
+ {
+ for (var attempt = 0; attempt < attempts; attempt++)
+ {
+ if (await check().ConfigureAwait(false))
+ {
+ return true;
+ }
+
+ if (attempt < attempts - 1)
+ {
+ await Task.Delay(backoff).ConfigureAwait(false);
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Drains , removing each item's id (via ) from a copy of
+ /// , stopping as soon as every target has been seen (or the sequence
+ /// completes). bounds the scan purely as a safety net against an
+ /// unbounded sequence; no test in this suite scans anywhere near that many items.
+ ///
+ private static async Task FindsAllAsync(IAsyncEnumerable items, Func idOf, IReadOnlySet targetIds, int safetyLimit)
+ {
+ var remaining = new HashSet(targetIds);
+ var scanned = 0;
+ await foreach (var item in items)
+ {
+ remaining.Remove(idOf(item));
+ if (remaining.Count == 0 || ++scanned >= safetyLimit)
+ {
+ break;
+ }
+ }
+
+ return remaining.Count == 0;
+ }
+
+ ///
+ /// Asserts that iterating a freshly-built sequence (via , called again on
+ /// every retry) eventually yields every id in , tolerating collection-listing
+ /// eventual consistency (see ). An already-consistent account matches on the
+ /// first pass with no sleeping.
+ ///
+ protected static Task FindsAllEventuallyAsync(
+ Func> newSequence,
+ Func idOf,
+ IReadOnlySet targetIds,
+ int safetyLimit = 10_000)
+ => PollUntilAsync(EventualConsistencyAttempts, EventualConsistencyBackoff, () => FindsAllAsync(newSequence(), idOf, targetIds, safetyLimit));
}
diff --git a/tests/Apify.Client.Tests/Integration/IterationIntegrationTests.cs b/tests/Apify.Client.Tests/Integration/IterationIntegrationTests.cs
new file mode 100644
index 0000000..7e30556
--- /dev/null
+++ b/tests/Apify.Client.Tests/Integration/IterationIntegrationTests.cs
@@ -0,0 +1,318 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Apify.Client.Models;
+using Apify.Client.Options;
+using Xunit;
+
+namespace Apify.Client.Tests.Integration;
+
+///
+/// Iteration coverage for the 12 collection -of-resources IterateAsync clients that had no
+/// dedicated test before this suite was added (Actors, Actor versions, Actor environment variables,
+/// datasets, key-value stores, request queues, tasks, schedules, webhooks, builds, runs, and webhook
+/// dispatches — exercised by the 11 test methods below, since Actor versions and Actor environment
+/// variables share one). already covers the thirteenth
+/// (StoreCollectionClient ), so it is not duplicated here.
+///
+///
+/// This suite is about iterating a collection of resources (e.g. "every dataset"), not a resource's own
+/// contents: dataset item iteration (DatasetClient.IterateItemsAsync ) is covered by a unit test
+/// (AutoPagingTests ) against a mocked transport, since it needs to exercise multi-page paging logic
+/// deterministically; request-queue request iteration has its own integration test
+/// (RequestQueueIntegrationTests.RequestQueuePaginateMultiplePages ). Key-value stores expose no item
+/// iterator (only ListKeysAsync ), so there is nothing to cover there.
+///
+/// Where creation is cheap, each test below creates a couple of uniquely-named resources and asserts that
+/// iterating the collection (newest-first) eventually surfaces every one of them — exercising the real
+/// async generator, not just a single ListAsync call. Builds, runs, and webhook dispatches are
+/// expensive or slow to create on demand, so those tests instead drain a Limit -bounded slice of
+/// whatever already exists on the test account and assert the iterator behaves (terminates, respects the
+/// cap, yields well-formed items).
+///
+[Trait("Category", "Integration")]
+public sealed class IterationIntegrationTests : IntegrationTestBase
+{
+ [SkippableFact]
+ public async Task IterateDatasets()
+ {
+ var client = RequireClient();
+ var ids = new HashSet();
+ try
+ {
+ for (var i = 0; i < 3; i++)
+ {
+ ids.Add((await client.Datasets().GetOrCreateAsync(UniqueName("it-ds-" + i))).Id!);
+ }
+
+ Assert.True(
+ await FindsAllEventuallyAsync(
+ () => client.Datasets().IterateAsync(new StorageListOptions { Desc = true }),
+ static d => d.Id!,
+ ids),
+ "expected iteration to eventually find every created dataset");
+ }
+ finally
+ {
+ foreach (var id in ids)
+ {
+ await client.Dataset(id).DeleteAsync();
+ }
+ }
+ }
+
+ [SkippableFact]
+ public async Task IterateKeyValueStores()
+ {
+ var client = RequireClient();
+ var ids = new HashSet();
+ try
+ {
+ for (var i = 0; i < 2; i++)
+ {
+ ids.Add((await client.KeyValueStores().GetOrCreateAsync(UniqueName("it-kvs-" + i))).Id!);
+ }
+
+ Assert.True(
+ await FindsAllEventuallyAsync(
+ () => client.KeyValueStores().IterateAsync(new StorageListOptions { Desc = true }),
+ static s => s.Id!,
+ ids),
+ "expected iteration to eventually find every created key-value store");
+ }
+ finally
+ {
+ foreach (var id in ids)
+ {
+ await client.KeyValueStore(id).DeleteAsync();
+ }
+ }
+ }
+
+ [SkippableFact]
+ public async Task IterateRequestQueues()
+ {
+ var client = RequireClient();
+ var ids = new HashSet();
+ try
+ {
+ for (var i = 0; i < 2; i++)
+ {
+ ids.Add((await client.RequestQueues().GetOrCreateAsync(UniqueName("it-rq-" + i))).Id!);
+ }
+
+ Assert.True(
+ await FindsAllEventuallyAsync(
+ () => client.RequestQueues().IterateAsync(new StorageListOptions { Desc = true }),
+ static q => q.Id!,
+ ids),
+ "expected iteration to eventually find every created request queue");
+ }
+ finally
+ {
+ foreach (var id in ids)
+ {
+ await client.RequestQueue(id).DeleteAsync();
+ }
+ }
+ }
+
+ [SkippableFact]
+ public async Task IterateTasks()
+ {
+ var client = RequireClient();
+ var ids = new HashSet();
+ try
+ {
+ for (var i = 0; i < 2; i++)
+ {
+ ids.Add((await client.Tasks().CreateAsync(MinimalTask(UniqueName("it-task-" + i)))).Id!);
+ }
+
+ Assert.True(
+ await FindsAllEventuallyAsync(
+ () => client.Tasks().IterateAsync(new ListOptions { Desc = true }),
+ static t => t.Id!,
+ ids),
+ "expected iteration to eventually find every created task");
+ }
+ finally
+ {
+ foreach (var id in ids)
+ {
+ await client.Task(id).DeleteAsync();
+ }
+ }
+ }
+
+ [SkippableFact]
+ public async Task IterateSchedules()
+ {
+ var client = RequireClient();
+ var ids = new HashSet();
+ try
+ {
+ for (var i = 0; i < 2; i++)
+ {
+ ids.Add((await client.Schedules().CreateAsync(MinimalSchedule(UniqueName("it-sch-" + i)))).Id!);
+ }
+
+ Assert.True(
+ await FindsAllEventuallyAsync(
+ () => client.Schedules().IterateAsync(new ListOptions { Desc = true }),
+ static s => s.Id!,
+ ids),
+ "expected iteration to eventually find every created schedule");
+ }
+ finally
+ {
+ foreach (var id in ids)
+ {
+ await client.Schedule(id).DeleteAsync();
+ }
+ }
+ }
+
+ [SkippableFact]
+ public async Task IterateWebhooks()
+ {
+ var client = RequireClient();
+ var ids = new HashSet();
+ try
+ {
+ for (var i = 0; i < 2; i++)
+ {
+ ids.Add((await client.Webhooks().CreateAsync(MinimalWebhook("https://example.com/it-wh-" + i))).Id!);
+ }
+
+ Assert.True(
+ await FindsAllEventuallyAsync(
+ () => client.Webhooks().IterateAsync(new ListOptions { Desc = true }),
+ static w => w.Id!,
+ ids),
+ "expected iteration to eventually find every created webhook");
+ }
+ finally
+ {
+ foreach (var id in ids)
+ {
+ await client.Webhook(id).DeleteAsync();
+ }
+ }
+ }
+
+ [SkippableFact]
+ public async Task IterateActors()
+ {
+ var client = RequireClient();
+ var ids = new HashSet();
+ try
+ {
+ for (var i = 0; i < 2; i++)
+ {
+ ids.Add((await client.Actors().CreateAsync(MinimalActor(UniqueName("it-act-" + i)))).Id!);
+ }
+
+ // Restrict to the current user's Actors so iteration finds the freshly-created ones quickly
+ // rather than scanning the public store.
+ Assert.True(
+ await FindsAllEventuallyAsync(
+ () => client.Actors().IterateAsync(new ActorListOptions { My = true, Desc = true }),
+ static a => a.Id!,
+ ids),
+ "expected iteration to eventually find every created Actor");
+ }
+ finally
+ {
+ foreach (var id in ids)
+ {
+ await client.Actor(id).DeleteAsync();
+ }
+ }
+ }
+
+ [SkippableFact]
+ public async Task IterateActorVersionsAndEnvVars()
+ {
+ var client = RequireClient();
+ var actor = await client.Actors().CreateAsync(MinimalActor(UniqueName("it-ver")));
+ try
+ {
+ var actorClient = client.Actor(actor.Id!);
+
+ // The versions endpoint is not paginated (one fetch returns every version); draining the
+ // iterator fully must terminate and must not re-yield a version. The minimal Actor ships with
+ // version 0.0, so iteration yields at least that one version, exactly once.
+ var versionNumbers = new HashSet();
+ var versionCount = 0;
+ await foreach (var version in actorClient.Versions().IterateAsync())
+ {
+ versionNumbers.Add(version.VersionNumber!);
+ versionCount++;
+ }
+
+ Assert.True(versionCount >= 1, "expected at least the initial version");
+ Assert.Equal(versionCount, versionNumbers.Count);
+ Assert.Contains("0.0", versionNumbers);
+
+ var envVars = actorClient.Version("0.0").EnvVars();
+ await envVars.CreateAsync(new ActorEnvVar("IT_VAR_A", "a"));
+ await envVars.CreateAsync(new ActorEnvVar("IT_VAR_B", "b"));
+
+ var seen = new HashSet();
+ await foreach (var envVar in envVars.IterateAsync())
+ {
+ seen.Add(envVar.Name!);
+ }
+
+ Assert.True(seen.Contains("IT_VAR_A") && seen.Contains("IT_VAR_B"), "saw " + string.Join(",", seen));
+ }
+ finally
+ {
+ await client.Actor(actor.Id!).DeleteAsync();
+ }
+ }
+
+ [SkippableFact]
+ public async Task IterateBuildsBounded()
+ {
+ var client = RequireClient();
+ // Builds require building an Actor (expensive); assert a Limit-bounded slice of whatever already
+ // exists iterates cleanly instead of creating a fresh build here.
+ var count = 0;
+ await foreach (var build in client.Builds().IterateAsync(new ListOptions { Limit = 5 }))
+ {
+ Assert.False(string.IsNullOrEmpty(build.Id));
+ count++;
+ }
+
+ Assert.True(count <= 5, "the total-cap limit must bound iteration; got " + count);
+ }
+
+ [SkippableFact]
+ public async Task IterateRunsBounded()
+ {
+ var client = RequireClient();
+ var count = 0;
+ await foreach (var run in client.Runs().IterateAsync(new ListOptions { Limit = 5 }, new RunListOptions()))
+ {
+ Assert.False(string.IsNullOrEmpty(run.Id));
+ count++;
+ }
+
+ Assert.True(count <= 5, "the total-cap limit must bound iteration; got " + count);
+ }
+
+ [SkippableFact]
+ public async Task IterateWebhookDispatchesBounded()
+ {
+ var client = RequireClient();
+ var count = 0;
+ await foreach (var dispatch in client.WebhookDispatches().IterateAsync(new ListOptions { Limit = 5 }))
+ {
+ Assert.False(string.IsNullOrEmpty(dispatch.Id));
+ count++;
+ }
+
+ Assert.True(count <= 5, "the total-cap limit must bound iteration; got " + count);
+ }
+}
diff --git a/tests/Apify.Client.Tests/Integration/ScheduleIntegrationTests.cs b/tests/Apify.Client.Tests/Integration/ScheduleIntegrationTests.cs
index 1759940..ae3e245 100644
--- a/tests/Apify.Client.Tests/Integration/ScheduleIntegrationTests.cs
+++ b/tests/Apify.Client.Tests/Integration/ScheduleIntegrationTests.cs
@@ -7,15 +7,6 @@ namespace Apify.Client.Tests.Integration;
[Trait("Category", "Integration")]
public sealed class ScheduleIntegrationTests : IntegrationTestBase
{
- private static object ScheduleDef(string name) => new
- {
- name,
- cronExpression = "0 0 * * *",
- isEnabled = false,
- isExclusive = true,
- actions = System.Array.Empty(),
- };
-
[SkippableFact]
public async Task ListSchedules()
{
@@ -30,7 +21,7 @@ public async Task ListSchedules()
public async Task GetSchedule()
{
var client = RequireClient();
- var sch = await client.Schedules().CreateAsync(ScheduleDef(UniqueName("sch-get")));
+ var sch = await client.Schedules().CreateAsync(MinimalSchedule(UniqueName("sch-get")));
try
{
var got = await client.Schedule(sch.Id!).GetAsync();
@@ -47,7 +38,7 @@ public async Task GetSchedule()
public async Task ScheduleCrudFlow()
{
var client = RequireClient();
- var sch = await client.Schedules().CreateAsync(ScheduleDef(UniqueName("sch-crud")));
+ var sch = await client.Schedules().CreateAsync(MinimalSchedule(UniqueName("sch-crud")));
try
{
var schedule = client.Schedule(sch.Id!);
diff --git a/tests/Apify.Client.Tests/Integration/TaskIntegrationTests.cs b/tests/Apify.Client.Tests/Integration/TaskIntegrationTests.cs
index 756275b..e4dc58d 100644
--- a/tests/Apify.Client.Tests/Integration/TaskIntegrationTests.cs
+++ b/tests/Apify.Client.Tests/Integration/TaskIntegrationTests.cs
@@ -8,14 +8,6 @@ namespace Apify.Client.Tests.Integration;
[Trait("Category", "Integration")]
public sealed class TaskIntegrationTests : IntegrationTestBase
{
- private static object TaskDef(string name) => new
- {
- actId = "apify/hello-world",
- name,
- options = new { build = "latest", memoryMbytes = 256, timeoutSecs = 60 },
- input = new { message = "hello" },
- };
-
[SkippableFact]
public async Task ListTasks()
{
@@ -30,7 +22,7 @@ public async Task ListTasks()
public async Task GetTask()
{
var client = RequireClient();
- var task = await client.Tasks().CreateAsync(TaskDef(UniqueName("task-get")));
+ var task = await client.Tasks().CreateAsync(MinimalTask(UniqueName("task-get")));
try
{
var got = await client.Task(task.Id!).GetAsync();
@@ -47,7 +39,7 @@ public async Task GetTask()
public async Task TaskCrudFlow()
{
var client = RequireClient();
- var task = await client.Tasks().CreateAsync(TaskDef(UniqueName("task-crud")));
+ var task = await client.Tasks().CreateAsync(MinimalTask(UniqueName("task-crud")));
try
{
var tc = client.Task(task.Id!);
@@ -70,7 +62,7 @@ public async Task TaskCrudFlow()
public async Task TaskPublishUnpublish()
{
var client = RequireClient();
- var task = await client.Tasks().CreateAsync(TaskDef(UniqueName("task-publish")));
+ var task = await client.Tasks().CreateAsync(MinimalTask(UniqueName("task-publish")));
try
{
var tc = client.Task(task.Id!);
diff --git a/tests/Apify.Client.Tests/Integration/WebhookIntegrationTests.cs b/tests/Apify.Client.Tests/Integration/WebhookIntegrationTests.cs
index 9eabb2b..61ba64f 100644
--- a/tests/Apify.Client.Tests/Integration/WebhookIntegrationTests.cs
+++ b/tests/Apify.Client.Tests/Integration/WebhookIntegrationTests.cs
@@ -7,14 +7,6 @@ namespace Apify.Client.Tests.Integration;
[Trait("Category", "Integration")]
public sealed class WebhookIntegrationTests : IntegrationTestBase
{
- private static object WebhookDef(string url) => new
- {
- isAdHoc = true,
- eventTypes = new[] { "ACTOR.RUN.SUCCEEDED" },
- condition = new { actorRunId = "ZZZZZZZZZZZZZZZZZ" },
- requestUrl = url,
- };
-
[SkippableFact]
public async Task ListWebhooks()
{
@@ -39,7 +31,7 @@ public async Task ListWebhookDispatches()
public async Task GetWebhook()
{
var client = RequireClient();
- var wh = await client.Webhooks().CreateAsync(WebhookDef("https://example.com/webhook"));
+ var wh = await client.Webhooks().CreateAsync(MinimalWebhook("https://example.com/webhook"));
try
{
var got = await client.Webhook(wh.Id!).GetAsync();
@@ -56,7 +48,7 @@ public async Task GetWebhook()
public async Task GetWebhookDispatch()
{
var client = RequireClient();
- var wh = await client.Webhooks().CreateAsync(WebhookDef("https://example.com/webhook"));
+ var wh = await client.Webhooks().CreateAsync(MinimalWebhook("https://example.com/webhook"));
try
{
var dispatch = await client.Webhook(wh.Id!).TestAsync();
@@ -74,7 +66,7 @@ public async Task GetWebhookDispatch()
public async Task WebhookCrudFlow()
{
var client = RequireClient();
- var wh = await client.Webhooks().CreateAsync(WebhookDef("https://example.com/webhook"));
+ var wh = await client.Webhooks().CreateAsync(MinimalWebhook("https://example.com/webhook"));
try
{
var webhook = client.Webhook(wh.Id!);