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
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
using Sessionize.Api.Client.Abstractions;
using Sessionize.Api.Client.Exceptions;

namespace Sessionize.Api.Client.IntegrationTests.ApiIntegrationTests;

public class HasDataChangedTests : SessionizeIntegrationTestBase
{
private const string PublicApiId = "t9hbeiv7";

[Fact]
public async Task HasDataChanged_WithNoPreviousHash_ReturnsChangedWithHash()
{
WithAppSettingsConfiguration("appsettings-public-api.json");
WithSessionizeClientRegistered();

// Arrange
var client = GetService<ISessionizeApiClient>();

// Act
var result = await client.HasDataChangedAsync("All");

// Assert
Assert.True(result.HasChanged);
Assert.NotEmpty(result.Hash);
}

[Fact]
public async Task HasDataChanged_WithDifferentHash_ReturnsChanged()
{
WithAppSettingsConfiguration("appsettings-public-api.json");
WithSessionizeClientRegistered();

// Arrange
var client = GetService<ISessionizeApiClient>();
var fakeHash = "0000000000000000000000000000000000000000";

// Act
var result = await client.HasDataChangedAsync("All", fakeHash);

// Assert
Assert.True(result.HasChanged);
Assert.NotEmpty(result.Hash);
}

[Fact]
public async Task HasDataChanged_WithCurrentHash_ReturnsNotChanged()
{
WithAppSettingsConfiguration("appsettings-public-api.json");
WithSessionizeClientRegistered();

// Arrange
var client = GetService<ISessionizeApiClient>();

// First, get the current hash
var initial = await client.HasDataChangedAsync("All");
Assert.NotEmpty(initial.Hash);

// Act - check again with the hash we just got
var result = await client.HasDataChangedAsync("All", initial.Hash);

// Assert
Assert.False(result.HasChanged);
Assert.NotEmpty(result.Hash);
}

[Fact]
public async Task HasDataChanged_WithApiIdParameter_ReturnsResult()
{
WithAppSettingsConfiguration("appsettings-without-api-id.json");
WithSessionizeClientRegistered();

// Arrange
var client = GetService<ISessionizeApiClient>();

// Act
var result = await client.HasDataChangedAsync("All", sessionizeApiId: PublicApiId);

// Assert
Assert.True(result.HasChanged);
Assert.NotEmpty(result.Hash);
}

[Fact]
public async Task HasDataChanged_WithDifferentViewNames_ReturnsDifferentHashes()
{
WithAppSettingsConfiguration("appsettings-public-api.json");
WithSessionizeClientRegistered();

// Arrange
var client = GetService<ISessionizeApiClient>();

// Act
var allResult = await client.HasDataChangedAsync("All");
var sessionsResult = await client.HasDataChangedAsync("Sessions");
var speakersResult = await client.HasDataChangedAsync("Speakers");

// Assert
Assert.NotEmpty(allResult.Hash);
Assert.NotEmpty(sessionsResult.Hash);
Assert.NotEmpty(speakersResult.Hash);
// Different views should return different hashes
Assert.NotEqual(allResult.Hash, sessionsResult.Hash);
}

[Fact]
public async Task HasDataChanged_WithoutConfiguration_ThrowsException()
{
WithAppSettingsConfiguration("appsettings-without-api-id.json");
WithSessionizeClientRegistered();

// Arrange
var client = GetService<ISessionizeApiClient>();

// Act & Assert
await Assert.ThrowsAsync<SessionizeApiClientException>(() =>
client.HasDataChangedAsync("All"));
}
}
198 changes: 198 additions & 0 deletions src/Sessionize.Api.Client.Tests/HasDataChangedTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
using System.Net;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using Moq.Protected;
using Sessionize.Api.Client.Configuration;
using Sessionize.Api.Client.DataTransferObjects;

namespace Sessionize.Api.Client.Tests;

public class HasDataChangedTests
{
private readonly Mock<IHttpClientFactory> _httpClientFactory = new();
private readonly Mock<ILogger<SessionizeApiClient>> _logger = new();
private readonly IOptions<SessionizeConfiguration> _configuration;

public HasDataChangedTests()
{
_configuration = Options.Create(new SessionizeConfiguration
{
ApiId = "test-api-id",
BaseUrl = "https://sessionize.com/api/v2/"
});
}

private SessionizeApiClient CreateClient(HttpResponseMessage response)
{
var handler = new Mock<HttpMessageHandler>();
handler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(response);

var httpClient = new HttpClient(handler.Object);
_httpClientFactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);

return new SessionizeApiClient(_httpClientFactory.Object, _logger.Object, _configuration);
}

[Fact]
public async Task HasDataChanged_SendsGetRequestWithHashOnlyParameter()
{
// Arrange
HttpRequestMessage? capturedRequest = null;
var handler = new Mock<HttpMessageHandler>();
handler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedRequest = req)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("abc123hash")
});

var httpClient = new HttpClient(handler.Object);
_httpClientFactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
var client = new SessionizeApiClient(_httpClientFactory.Object, _logger.Object, _configuration);

// Act
await client.HasDataChangedAsync("All");

// Assert
capturedRequest.Should().NotBeNull();
capturedRequest!.Method.Should().Be(HttpMethod.Get);
capturedRequest.RequestUri!.ToString().Should().Contain("test-api-id/view/All?hashonly=true");
}

[Fact]
public async Task HasDataChanged_WithEmptyResponse_ReturnsChangedWithEmptyHash()
{
// Arrange
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("")
};
var client = CreateClient(response);

// Act
var result = await client.HasDataChangedAsync("All");

// Assert
result.HasChanged.Should().BeTrue();
result.Hash.Should().BeEmpty();
}

[Fact]
public async Task HasDataChanged_WithNoPreviousHash_ReturnsChangedWithServerHash()
{
// Arrange
var serverHash = "aa94b4bbb12bdbcbb9b5059e7dfa37818ebd5f8e";
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(serverHash)
};
var client = CreateClient(response);

// Act
var result = await client.HasDataChangedAsync("All");

// Assert
result.HasChanged.Should().BeTrue();
result.Hash.Should().Be(serverHash);
}

[Fact]
public async Task HasDataChanged_WithDifferentHash_ReturnsChanged()
{
// Arrange
var serverHash = "aa94b4bbb12bdbcbb9b5059e7dfa37818ebd5f8e";
var localHash = "0000000000000000000000000000000000000000";
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(serverHash)
};
var client = CreateClient(response);

// Act
var result = await client.HasDataChangedAsync("All", localHash);

// Assert
result.HasChanged.Should().BeTrue();
result.Hash.Should().Be(serverHash);
}

[Fact]
public async Task HasDataChanged_WithMatchingHash_ReturnsNotChanged()
{
// Arrange
var hash = "aa94b4bbb12bdbcbb9b5059e7dfa37818ebd5f8e";
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(hash)
};
var client = CreateClient(response);

// Act
var result = await client.HasDataChangedAsync("All", hash);

// Assert
result.HasChanged.Should().BeFalse();
result.Hash.Should().Be(hash);
}

[Fact]
public async Task HasDataChanged_HashComparisonIsCaseInsensitive()
{
// Arrange
var serverHash = "AA94B4BBB12BDBCBB9B5059E7DFA37818EBD5F8E";
var localHash = "aa94b4bbb12bdbcbb9b5059e7dfa37818ebd5f8e";
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(serverHash)
};
var client = CreateClient(response);

// Act
var result = await client.HasDataChangedAsync("All", localHash);

// Assert
result.HasChanged.Should().BeFalse();
}

[Fact]
public async Task HasDataChanged_UsesExplicitApiId_WhenProvided()
{
// Arrange
HttpRequestMessage? capturedRequest = null;
var handler = new Mock<HttpMessageHandler>();
handler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedRequest = req)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("somehash")
});

var httpClient = new HttpClient(handler.Object);
_httpClientFactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
var client = new SessionizeApiClient(_httpClientFactory.Object, _logger.Object, _configuration);

// Act
await client.HasDataChangedAsync("All", sessionizeApiId: "custom-api-id");

// Assert
capturedRequest!.RequestUri!.ToString().Should().Contain("custom-api-id/view/All?hashonly=true");
}
}
12 changes: 12 additions & 0 deletions src/Sessionize.Api.Client/Abstractions/ISessionizeApiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,16 @@ public interface ISessionizeApiClient
Task<List<SpeakerDetailsResponse>> GetSpeakersListAsync(string? sessionizeApiId = null, CancellationToken? cancellationToken = null);
Task<List<SessionListResponse>> GetSessionsListAsync(string? sessionizeApiId = null, CancellationToken? cancellationToken = null);
Task<List<SpeakerWallResponse>> GetSpeakerWallAsync(string? sessionizeApiId = null, CancellationToken? cancellationToken = null);

/// <summary>
/// Checks whether data for the specified view has changed by fetching a lightweight hash
/// using the <c>?hashonly=true</c> query parameter and comparing it against a previously stored hash.
/// This avoids downloading the full response payload when data hasn't changed.
/// </summary>
/// <param name="viewName">The Sessionize view name (e.g., "All", "Sessions", "Speakers", "GridSmart", "SpeakerWall").</param>
/// <param name="lastKnownHash">The hash from a previous call. If null, the method will return HasChanged=true along with the current hash.</param>
/// <param name="sessionizeApiId">Optional Sessionize API ID. Uses the configured default if not provided.</param>
/// <param name="cancellationToken">Optional cancellation token.</param>
/// <returns>A <see cref="DataChangedResponse"/> indicating whether data has changed and the current hash value.</returns>
Task<DataChangedResponse> HasDataChangedAsync(string viewName, string? lastKnownHash = null, string? sessionizeApiId = null, CancellationToken? cancellationToken = null);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace Sessionize.Api.Client.DataTransferObjects;

/// <summary>
/// Response from a data change check against the Sessionize API.
/// Uses the <c>?hashonly=true</c> query parameter to retrieve a lightweight
/// hash of the data for a given view endpoint, avoiding a full data download.
/// </summary>
/// <param name="HasChanged">True if the server hash differs from the provided hash, or if no previous hash was provided.</param>
/// <param name="Hash">The current hash from the server. Store this value and pass it to subsequent calls.</param>
public record DataChangedResponse(bool HasChanged, string Hash);
34 changes: 34 additions & 0 deletions src/Sessionize.Api.Client/SessionizeApiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,40 @@ public Task<List<SpeakerWallResponse>> GetSpeakerWallAsync(string? sessionizeApi
return SendRequestAsync<List<SpeakerWallResponse>>("SpeakerWall", sessionizeApiId, cancellationToken);
}

public async Task<DataChangedResponse> HasDataChangedAsync(string viewName, string? lastKnownHash = null, string? sessionizeApiId = null, CancellationToken? cancellationToken = null)
{
var ct = cancellationToken ?? CancellationToken.None;
var httpClient = _httpClientFactory.CreateClient();
httpClient.BaseAddress = new Uri(_sessionizeConfiguration.Value.BaseUrl);

var endpoint = GetViewEndpoint(viewName, sessionizeApiId) + "?hashonly=true";
var request = new HttpRequestMessage(HttpMethod.Get, endpoint);

_logger.LogInformation("Fetching data hash from endpoint {Endpoint}", endpoint);

var response = await httpClient.SendAsync(request, ct);
response.EnsureSuccessStatusCode();

var serverHash = (await response.Content.ReadAsStringAsync(ct)).Trim();
if (string.IsNullOrWhiteSpace(serverHash))
{
_logger.LogWarning("Empty hash response from {Endpoint}, assuming data has changed", endpoint);
return new DataChangedResponse(true, string.Empty);
}

if (lastKnownHash is null)
{
_logger.LogInformation("No previous hash provided, returning current server hash");
return new DataChangedResponse(true, serverHash);
}

var hasChanged = !string.Equals(serverHash, lastKnownHash, StringComparison.OrdinalIgnoreCase);
_logger.LogInformation("Data changed check for {Endpoint}: server={ServerHash}, local={LocalHash}, hasChanged={HasChanged}",
endpoint, serverHash, lastKnownHash, hasChanged);

return new DataChangedResponse(hasChanged, serverHash);
}

private async Task<TResult> SendRequestAsync<TResult>(string endpoint, string? sessionizeApiId = null, CancellationToken? cancellationToken = null) where TResult : class
{
var ct = cancellationToken ?? CancellationToken.None;
Expand Down