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!);