feat: add ProcessedUidStore with persistence and pruning

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-07 11:39:31 +01:00
parent 0ee0a43b6a
commit b11aea93d6
2 changed files with 138 additions and 0 deletions

View File

@@ -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<uint, DateTime> _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<uint, DateTime> Load()
{
if (!File.Exists(_filePath))
return new Dictionary<uint, DateTime>();
var json = File.ReadAllText(_filePath);
return JsonSerializer.Deserialize<Dictionary<uint, DateTime>>(json)
?? new Dictionary<uint, DateTime>();
}
}

View File

@@ -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));
}
}