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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/Apify.Client/Apify.Client.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<!-- NuGet package metadata (see the publish workflow). -->
<PackageId>Apify.Client</PackageId>
<Version>0.3.3</Version>
<Version>0.3.4</Version>
<Authors>Apify</Authors>
<Company>Apify</Company>
<Product>Apify API client for .NET</Product>
Expand Down
4 changes: 2 additions & 2 deletions src/Apify.Client/ApifyClientVersion.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
public const string ClientVersion = "0.3.3";
public const string ClientVersion = "0.3.4";

/// <summary>
/// The version of the Apify OpenAPI specification this client was generated and verified against.
/// Corresponds to the <c>info.version</c> field of the Apify OpenAPI document.
/// </summary>
public const string ApiSpecVersion = "v2-2026-09-02T154542Z";
public const string ApiSpecVersion = "v2-2026-09-10T091137Z";
}
102 changes: 102 additions & 0 deletions tests/Apify.Client.Tests/Integration/IntegrationTestBase.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Threading.Tasks;
using Apify.Client;
using Xunit;

Expand All @@ -20,6 +22,19 @@ public abstract class IntegrationTestBase
/// <summary>The integration-test contract fallback base URL.</summary>
private const string DefaultApiUrl = "https://api.apify.com/v2";

/// <summary>
/// Retry budget for <see cref="FindsAllEventuallyAsync{T}"/>: how many times a freshly created
/// resource's collection listing is re-scanned before giving up on it having propagated.
/// </summary>
private const int EventualConsistencyAttempts = 16;

/// <summary>
/// Delay between retries in <see cref="FindsAllEventuallyAsync{T}"/>. Combined with
/// <see cref="EventualConsistencyAttempts"/>, the total wait budget is
/// <c>(EventualConsistencyAttempts - 1) * EventualConsistencyBackoff</c> = ~15s.
/// </summary>
private static readonly TimeSpan EventualConsistencyBackoff = TimeSpan.FromSeconds(1);

/// <summary>
/// Derives the client base URL from an optional <c>APIFY_API_URL</c>. The variable includes the
/// <c>/v2</c> suffix (per the integration-test contract) and falls back to the default. Since the client
Expand Down Expand Up @@ -79,4 +94,91 @@ protected static string UniqueName(string prefix)
},
},
};

/// <summary>A minimal Actor task definition targeting the public <c>apify/hello-world</c> Actor.</summary>
protected static object MinimalTask(string name) => new
{
actId = "apify/hello-world",
name,
options = new { build = "latest", memoryMbytes = 256, timeoutSecs = 60 },
input = new { message = "hello" },
};

/// <summary>A minimal, disabled schedule definition (no actions, so it never actually fires).</summary>
protected static object MinimalSchedule(string name) => new
{
name,
cronExpression = "0 0 * * *",
isEnabled = false,
isExclusive = true,
actions = Array.Empty<object>(),
};

/// <summary>A minimal ad-hoc webhook definition targeting a condition that never actually fires.</summary>
protected static object MinimalWebhook(string requestUrl) => new
{
isAdHoc = true,
eventTypes = new[] { "ACTOR.RUN.SUCCEEDED" },
condition = new { actorRunId = "ZZZZZZZZZZZZZZZZZ" },
requestUrl,
};

/// <summary>
/// Retries <paramref name="check"/> up to <paramref name="attempts"/> times, sleeping
/// <paramref name="backoff"/> between attempts, until it returns <c>true</c>. 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.
/// </summary>
protected static async Task<bool> PollUntilAsync(int attempts, TimeSpan backoff, Func<Task<bool>> 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;
}

/// <summary>
/// Drains <paramref name="items"/>, removing each item's id (via <paramref name="idOf"/>) from a copy of
/// <paramref name="targetIds"/>, stopping as soon as every target has been seen (or the sequence
/// completes). <paramref name="safetyLimit"/> bounds the scan purely as a safety net against an
/// unbounded sequence; no test in this suite scans anywhere near that many items.
/// </summary>
private static async Task<bool> FindsAllAsync<T>(IAsyncEnumerable<T> items, Func<T, string> idOf, IReadOnlySet<string> targetIds, int safetyLimit)
{
var remaining = new HashSet<string>(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;
}

/// <summary>
/// Asserts that iterating a freshly-built sequence (via <paramref name="newSequence"/>, called again on
/// every retry) eventually yields every id in <paramref name="targetIds"/>, tolerating collection-listing
/// eventual consistency (see <see cref="PollUntilAsync"/>). An already-consistent account matches on the
/// first pass with no sleeping.
/// </summary>
protected static Task<bool> FindsAllEventuallyAsync<T>(
Func<IAsyncEnumerable<T>> newSequence,
Func<T, string> idOf,
IReadOnlySet<string> targetIds,
int safetyLimit = 10_000)
=> PollUntilAsync(EventualConsistencyAttempts, EventualConsistencyBackoff, () => FindsAllAsync(newSequence(), idOf, targetIds, safetyLimit));
}
Loading
Loading