diff --git a/src/Sessionize.Api.Client.IntegrationTests/ApiIntegrationTests/HasDataChangedTests.cs b/src/Sessionize.Api.Client.IntegrationTests/ApiIntegrationTests/HasDataChangedTests.cs new file mode 100644 index 0000000..17b350e --- /dev/null +++ b/src/Sessionize.Api.Client.IntegrationTests/ApiIntegrationTests/HasDataChangedTests.cs @@ -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(); + + // 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(); + 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(); + + // 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(); + + // 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(); + + // 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(); + + // Act & Assert + await Assert.ThrowsAsync(() => + client.HasDataChangedAsync("All")); + } +} diff --git a/src/Sessionize.Api.Client.Tests/HasDataChangedTests.cs b/src/Sessionize.Api.Client.Tests/HasDataChangedTests.cs new file mode 100644 index 0000000..cc2a5da --- /dev/null +++ b/src/Sessionize.Api.Client.Tests/HasDataChangedTests.cs @@ -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 _httpClientFactory = new(); + private readonly Mock> _logger = new(); + private readonly IOptions _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(); + handler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(response); + + var httpClient = new HttpClient(handler.Object); + _httpClientFactory.Setup(f => f.CreateClient(It.IsAny())).Returns(httpClient); + + return new SessionizeApiClient(_httpClientFactory.Object, _logger.Object, _configuration); + } + + [Fact] + public async Task HasDataChanged_SendsGetRequestWithHashOnlyParameter() + { + // Arrange + HttpRequestMessage? capturedRequest = null; + var handler = new Mock(); + handler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((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())).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(); + handler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((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())).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"); + } +} diff --git a/src/Sessionize.Api.Client/Abstractions/ISessionizeApiClient.cs b/src/Sessionize.Api.Client/Abstractions/ISessionizeApiClient.cs index e3d18f5..e72f6ed 100644 --- a/src/Sessionize.Api.Client/Abstractions/ISessionizeApiClient.cs +++ b/src/Sessionize.Api.Client/Abstractions/ISessionizeApiClient.cs @@ -10,4 +10,16 @@ public interface ISessionizeApiClient Task> GetSpeakersListAsync(string? sessionizeApiId = null, CancellationToken? cancellationToken = null); Task> GetSessionsListAsync(string? sessionizeApiId = null, CancellationToken? cancellationToken = null); Task> GetSpeakerWallAsync(string? sessionizeApiId = null, CancellationToken? cancellationToken = null); + + /// + /// Checks whether data for the specified view has changed by fetching a lightweight hash + /// using the ?hashonly=true query parameter and comparing it against a previously stored hash. + /// This avoids downloading the full response payload when data hasn't changed. + /// + /// The Sessionize view name (e.g., "All", "Sessions", "Speakers", "GridSmart", "SpeakerWall"). + /// The hash from a previous call. If null, the method will return HasChanged=true along with the current hash. + /// Optional Sessionize API ID. Uses the configured default if not provided. + /// Optional cancellation token. + /// A indicating whether data has changed and the current hash value. + Task HasDataChangedAsync(string viewName, string? lastKnownHash = null, string? sessionizeApiId = null, CancellationToken? cancellationToken = null); } \ No newline at end of file diff --git a/src/Sessionize.Api.Client/DataTransferObjects/DataChangedResponse.cs b/src/Sessionize.Api.Client/DataTransferObjects/DataChangedResponse.cs new file mode 100644 index 0000000..caab832 --- /dev/null +++ b/src/Sessionize.Api.Client/DataTransferObjects/DataChangedResponse.cs @@ -0,0 +1,10 @@ +namespace Sessionize.Api.Client.DataTransferObjects; + +/// +/// Response from a data change check against the Sessionize API. +/// Uses the ?hashonly=true query parameter to retrieve a lightweight +/// hash of the data for a given view endpoint, avoiding a full data download. +/// +/// True if the server hash differs from the provided hash, or if no previous hash was provided. +/// The current hash from the server. Store this value and pass it to subsequent calls. +public record DataChangedResponse(bool HasChanged, string Hash); diff --git a/src/Sessionize.Api.Client/SessionizeApiClient.cs b/src/Sessionize.Api.Client/SessionizeApiClient.cs index 2fce830..57929bf 100644 --- a/src/Sessionize.Api.Client/SessionizeApiClient.cs +++ b/src/Sessionize.Api.Client/SessionizeApiClient.cs @@ -52,6 +52,40 @@ public Task> GetSpeakerWallAsync(string? sessionizeApi return SendRequestAsync>("SpeakerWall", sessionizeApiId, cancellationToken); } + public async Task 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 SendRequestAsync(string endpoint, string? sessionizeApiId = null, CancellationToken? cancellationToken = null) where TResult : class { var ct = cancellationToken ?? CancellationToken.None;