From b11aea93d685e2feac69505397aa0cdc56f060cf Mon Sep 17 00:00:00 2001 From: peter Date: Tue, 7 Apr 2026 11:39:31 +0100 Subject: [PATCH] feat: add ProcessedUidStore with persistence and pruning Co-Authored-By: Claude Sonnet 4.6 --- src/SpamGuard/State/ProcessedUidStore.cs | 68 ++++++++++++++++++ .../State/ProcessedUidStoreTests.cs | 70 +++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 src/SpamGuard/State/ProcessedUidStore.cs create mode 100644 tests/SpamGuard.Tests/State/ProcessedUidStoreTests.cs diff --git a/src/SpamGuard/State/ProcessedUidStore.cs b/src/SpamGuard/State/ProcessedUidStore.cs new file mode 100644 index 0000000..da591b4 --- /dev/null +++ b/src/SpamGuard/State/ProcessedUidStore.cs @@ -0,0 +1,68 @@ +// src/SpamGuard/State/ProcessedUidStore.cs +namespace SpamGuard.State; + +using System.Text.Json; + +public sealed class ProcessedUidStore +{ + private readonly string _filePath; + private readonly Dictionary _uids; + private readonly object _lock = new(); + + public ProcessedUidStore(string dataDirectory) + { + _filePath = Path.Combine(dataDirectory, "processed-uids.json"); + _uids = Load(); + } + + public int Count + { + get { lock (_lock) { return _uids.Count; } } + } + + public bool Contains(uint uid) + { + lock (_lock) + { + return _uids.ContainsKey(uid); + } + } + + public void Add(uint uid, DateTime? timestamp = null) + { + lock (_lock) + { + _uids.TryAdd(uid, timestamp ?? DateTime.UtcNow); + } + } + + public void Prune(TimeSpan maxAge) + { + lock (_lock) + { + var cutoff = DateTime.UtcNow - maxAge; + var old = _uids.Where(kv => kv.Value < cutoff).Select(kv => kv.Key).ToList(); + foreach (var uid in old) + _uids.Remove(uid); + } + } + + public void Save() + { + lock (_lock) + { + var json = JsonSerializer.Serialize(_uids); + File.WriteAllText(_filePath, json); + } + } + + private Dictionary Load() + { + if (!File.Exists(_filePath)) + return new Dictionary(); + + var json = File.ReadAllText(_filePath); + return JsonSerializer.Deserialize>(json) + ?? new Dictionary(); + } +} diff --git a/tests/SpamGuard.Tests/State/ProcessedUidStoreTests.cs b/tests/SpamGuard.Tests/State/ProcessedUidStoreTests.cs new file mode 100644 index 0000000..23fb479 --- /dev/null +++ b/tests/SpamGuard.Tests/State/ProcessedUidStoreTests.cs @@ -0,0 +1,70 @@ +// tests/SpamGuard.Tests/State/ProcessedUidStoreTests.cs +namespace SpamGuard.Tests.State; + +using SpamGuard.State; + +public class ProcessedUidStoreTests : IDisposable +{ + private readonly string _tempDir; + private readonly ProcessedUidStore _store; + + public ProcessedUidStoreTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"spamguard-test-{Guid.NewGuid()}"); + Directory.CreateDirectory(_tempDir); + _store = new ProcessedUidStore(_tempDir); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, true); + } + + [Fact] + public void Contains_ReturnsFalse_WhenUidNotAdded() + { + Assert.False(_store.Contains(42)); + } + + [Fact] + public void Contains_ReturnsTrue_AfterAdd() + { + _store.Add(42); + Assert.True(_store.Contains(42)); + } + + [Fact] + public void Add_IsDuplicate_DoesNotThrow() + { + _store.Add(42); + _store.Add(42); + Assert.True(_store.Contains(42)); + } + + [Fact] + public void Save_And_Load_RoundTrips() + { + _store.Add(1); + _store.Add(2); + _store.Add(3); + _store.Save(); + + var loaded = new ProcessedUidStore(_tempDir); + Assert.True(loaded.Contains(1)); + Assert.True(loaded.Contains(2)); + Assert.True(loaded.Contains(3)); + Assert.False(loaded.Contains(4)); + } + + [Fact] + public void Prune_RemovesOldEntries() + { + _store.Add(1, DateTime.UtcNow.AddDays(-31)); + _store.Add(2, DateTime.UtcNow); + _store.Prune(TimeSpan.FromDays(30)); + + Assert.False(_store.Contains(1)); + Assert.True(_store.Contains(2)); + } +}