From 7826e835efaee18be45d4483b2bb5d39ab34fa56 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sun, 11 Jan 2026 10:32:57 -0600 Subject: [PATCH] feat(youtube): multiple stream notifications Refactors the notification storage to support multiple stream IDs using a ConcurrentDictionary. This ensures that the bot can track and ignore duplicate notifications for multiple streams within a rolling 24-hour window, rather than only tracking the single most recent stream. - Updated ILastPostedStreamStore to include RemoveValuesOlderThan24Hours. - Switched LastPostedStreamStore to use ConcurrentDictionary with UTC timestamps. - Injected TimeProvider to enable deterministic testing of time-based expiration. - Added automatic cache pruning in NotificationHandler on every request. - Renamed Unit/LastPostedStreamStore.cs to Unit/LastPostedStreamStoreTests.cs and expanded test coverage. --- .../YouTube/Handlers/NotificationHandler.cs | 2 + .../YouTube/ILastPostedStreamStore.cs | 1 + .../YouTube/LastPostedStreamStore.cs | 35 +++++++++-- .../Unit/LastPostedStreamStore.cs | 36 ----------- .../Unit/LastPostedStreamStoreTests.cs | 63 +++++++++++++++++++ 5 files changed, 97 insertions(+), 40 deletions(-) delete mode 100644 src/tests/StevesBot.Webhook.Tests/Unit/LastPostedStreamStore.cs create mode 100644 src/tests/StevesBot.Webhook.Tests/Unit/LastPostedStreamStoreTests.cs diff --git a/src/src/StevesBot.Webhook/YouTube/Handlers/NotificationHandler.cs b/src/src/StevesBot.Webhook/YouTube/Handlers/NotificationHandler.cs index 8f600ed..0aeb3a9 100644 --- a/src/src/StevesBot.Webhook/YouTube/Handlers/NotificationHandler.cs +++ b/src/src/StevesBot.Webhook/YouTube/Handlers/NotificationHandler.cs @@ -12,6 +12,8 @@ internal static class NotificationHandler CancellationToken cancellationToken = default ) { + lastPostedStreamStore.RemoveValuesOlderThan24Hours(); + using StreamReader stream = new(context.Request.Body); var body = await stream.ReadToEndAsync(cancellationToken); diff --git a/src/src/StevesBot.Webhook/YouTube/ILastPostedStreamStore.cs b/src/src/StevesBot.Webhook/YouTube/ILastPostedStreamStore.cs index 84a85f7..3027872 100644 --- a/src/src/StevesBot.Webhook/YouTube/ILastPostedStreamStore.cs +++ b/src/src/StevesBot.Webhook/YouTube/ILastPostedStreamStore.cs @@ -4,4 +4,5 @@ internal interface ILastPostedStreamStore { void SetValue(string value); bool HasValue(string value); + void RemoveValuesOlderThan24Hours(); } \ No newline at end of file diff --git a/src/src/StevesBot.Webhook/YouTube/LastPostedStreamStore.cs b/src/src/StevesBot.Webhook/YouTube/LastPostedStreamStore.cs index 4ab8975..394f3bd 100644 --- a/src/src/StevesBot.Webhook/YouTube/LastPostedStreamStore.cs +++ b/src/src/StevesBot.Webhook/YouTube/LastPostedStreamStore.cs @@ -1,18 +1,45 @@ namespace StevesBot.Webhook.YouTube; -internal class LastPostedStreamStore : ILastPostedStreamStore +internal class LastPostedStreamStore(TimeProvider timeProvider) : ILastPostedStreamStore { - private string _value = string.Empty; + private readonly TimeProvider _timeProvider = timeProvider; + private readonly ConcurrentDictionary _values = []; public void SetValue(string value) { ArgumentNullException.ThrowIfNull(value); - _value = value; + _values[value] = _timeProvider.GetUtcNow() + .ToUnixTimeMilliseconds(); } public bool HasValue(string value) { ArgumentNullException.ThrowIfNull(value); - return _value.Equals(value, StringComparison.OrdinalIgnoreCase); + return _values.TryGetValue(value, out var _); + } + + public void RemoveValuesOlderThan24Hours() + { + var currentTime = _timeProvider.GetUtcNow(); + var twentyFourHoursAgo = currentTime + .AddHours(-24) + .ToUnixTimeMilliseconds(); + + var keysToRemove = new List(); + + foreach (var (k, v) in _values) + { + if (v > twentyFourHoursAgo) + { + continue; + } + + keysToRemove.Add(k); + } + + foreach (var k in keysToRemove) + { + _values.Remove(k, out var _); + } } } \ No newline at end of file diff --git a/src/tests/StevesBot.Webhook.Tests/Unit/LastPostedStreamStore.cs b/src/tests/StevesBot.Webhook.Tests/Unit/LastPostedStreamStore.cs deleted file mode 100644 index 90f1cd1..0000000 --- a/src/tests/StevesBot.Webhook.Tests/Unit/LastPostedStreamStore.cs +++ /dev/null @@ -1,36 +0,0 @@ -namespace StevesBot.Webhook.Tests.Unit; - -public class LastPostedStreamStoreTests -{ - [Fact] - public void SetValue_WhenCalledWithNull_ItShouldThrowArgumentNullException() - { - var store = new LastPostedStreamStore(); - - var act = () => store.SetValue(null!); - - act.Should().Throw(); - } - - [Fact] - public void HasValue_WhenCalledWithNull_ItShouldThrowArgumentNullException() - { - var store = new LastPostedStreamStore(); - - var act = () => store.HasValue(null!); - - act.Should().Throw(); - } - - - [Fact] - public void SetValueHasValue_WhenCalled_ItShouldStoreValue() - { - var store = new LastPostedStreamStore(); - var streamId = "test-stream-id"; - - store.SetValue(streamId); - - store.HasValue(streamId).Should().BeTrue(); - } -} \ No newline at end of file diff --git a/src/tests/StevesBot.Webhook.Tests/Unit/LastPostedStreamStoreTests.cs b/src/tests/StevesBot.Webhook.Tests/Unit/LastPostedStreamStoreTests.cs new file mode 100644 index 0000000..df20af0 --- /dev/null +++ b/src/tests/StevesBot.Webhook.Tests/Unit/LastPostedStreamStoreTests.cs @@ -0,0 +1,63 @@ +namespace StevesBot.Webhook.Tests.Unit; + +public class LastPostedStreamStoreTests +{ + private readonly Mock _mockTimeProvider = new(); + private readonly LastPostedStreamStore _sut; + + public LastPostedStreamStoreTests() + { + _sut = new(_mockTimeProvider.Object); + } + + [Fact] + public void SetValue_WhenCalledWithNull_ItShouldThrowArgumentNullException() + { + var act = () => _sut.SetValue(null!); + + act.Should().Throw(); + } + + [Fact] + public void HasValue_WhenCalledWithNull_ItShouldThrowArgumentNullException() + { + var act = () => _sut.HasValue(null!); + + act.Should().Throw(); + } + + + [Fact] + public void SetValueHasValue_WhenCalled_ItShouldStoreValue() + { + var streamId = "test-stream-id"; + + _sut.SetValue(streamId); + + _sut.HasValue(streamId).Should().BeTrue(); + } + + [Fact] + public void RemoveValuesOlderThan24Hours_WhenCalled_ItShouldRemoveItemsOlderThan24Hours() + { + var oldStreamId = "test-stream-id-old"; + var currentStreamId = "test-stream-id-current"; + + var currentTime = DateTimeOffset.UtcNow; + var invalidTime = DateTimeOffset.UtcNow.AddHours(-25); + var validTime = DateTimeOffset.UtcNow.AddHours(-10); + + _mockTimeProvider.SetupSequence(static m => m.GetUtcNow()) + .Returns(invalidTime) + .Returns(validTime) + .Returns(currentTime); + + _sut.SetValue(oldStreamId); + _sut.SetValue(currentStreamId); + + _sut.RemoveValuesOlderThan24Hours(); + + _sut.HasValue(oldStreamId).Should().BeFalse(); + _sut.HasValue(currentStreamId).Should().BeTrue(); + } +} \ No newline at end of file