feat: add ProcessedUidStore with persistence and pruning
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
68
src/SpamGuard/State/ProcessedUidStore.cs
Normal file
68
src/SpamGuard/State/ProcessedUidStore.cs
Normal 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>();
|
||||||
|
}
|
||||||
|
}
|
||||||
70
tests/SpamGuard.Tests/State/ProcessedUidStoreTests.cs
Normal file
70
tests/SpamGuard.Tests/State/ProcessedUidStoreTests.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user