Skip to content

Commit a477bf0

Browse files
stho32claude
andcommitted
Implement R010: Add duplicate message handling
- Added duplicate message ID tracking with 1-hour retention - Messages with same ID are rejected within the prevention window - Added cleanup for expired message IDs - Added comprehensive unit tests for duplicate handling 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 3b727b7 commit a477bf0

File tree

2 files changed

+112
-0
lines changed

2 files changed

+112
-0
lines changed

Source/LocalNetAppChat/LocalNetAppChat.Server.Domain.Tests/Messaging/MessageListTests.cs

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,4 +77,84 @@ public void We_can_get_a_little_status_report_from_it()
7777
var messageList = GetMessageList();
7878
Assert.IsNotEmpty(messageList.GetStatus());
7979
}
80+
81+
[Test]
82+
public void Duplicate_messages_with_same_id_are_rejected()
83+
{
84+
var messageList = GetMessageList();
85+
var messageId = Guid.NewGuid().ToString();
86+
87+
// Create two messages with the same ID
88+
var message1 = CreateMessageWithId(messageId);
89+
var message2 = CreateMessageWithId(messageId);
90+
91+
// Add first message
92+
messageList.Add(message1);
93+
94+
// Try to add duplicate
95+
messageList.Add(message2);
96+
97+
// Should only have one message
98+
var messagesForClient = messageList.GetMessagesForClient("TestClient");
99+
Assert.AreEqual(1, messagesForClient.Length);
100+
Assert.AreEqual(messageId, messagesForClient[0].Message.Id);
101+
}
102+
103+
[Test]
104+
public void Duplicate_messages_are_rejected_within_one_hour()
105+
{
106+
var messageList = GetMessageList();
107+
var messageId = Guid.NewGuid().ToString();
108+
109+
// Add first message
110+
var message1 = CreateMessageWithId(messageId);
111+
messageList.Add(message1);
112+
113+
// Try to add another message with same ID within the hour
114+
var message2 = CreateMessageWithId(messageId);
115+
messageList.Add(message2);
116+
117+
// Should still only have one message
118+
var messagesForClient = messageList.GetMessagesForClient("TestClient");
119+
Assert.AreEqual(1, messagesForClient.Length);
120+
121+
// Try with a third message
122+
var message3 = CreateMessageWithId(messageId);
123+
messageList.Add(message3);
124+
125+
// Clear client state and check again
126+
messageList.GetMessagesForClient("TestClient");
127+
var allMessages = messageList.GetMessagesForClient("TestClient");
128+
Assert.AreEqual(0, allMessages.Length); // 0 because we already retrieved it
129+
}
130+
131+
[Test]
132+
public void Messages_without_id_are_always_accepted()
133+
{
134+
var messageList = GetMessageList();
135+
136+
// Create messages without ID (empty string)
137+
var message1 = CreateMessageWithId("");
138+
var message2 = CreateMessageWithId("");
139+
140+
messageList.Add(message1);
141+
messageList.Add(message2);
142+
143+
var messagesForClient = messageList.GetMessagesForClient("TestClient");
144+
Assert.AreEqual(2, messagesForClient.Length);
145+
}
146+
147+
private static ReceivedMessage CreateMessageWithId(string messageId, DateTime? explicitTime = null)
148+
{
149+
var processors = MessageProcessorFactory.Get(
150+
new ThreadSafeCounter(),
151+
new DateTimeProviderMock(explicitTime ?? DateTime.Now));
152+
153+
var message = new LnacMessage(messageId, "TestSender", "Test Message",
154+
Array.Empty<string>(),
155+
true,
156+
"Message").ToReceivedMessage();
157+
158+
return processors.Process(message);
159+
}
80160
}

Source/LocalNetAppChat/LocalNetAppChat.Server.Domain/SynchronizedCollectionBasedMessageList.cs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ public class SynchronizedCollectionBasedMessageList : IMessageList
1010
private readonly SynchronizedCollection<ReceivedMessage> _messages = new();
1111
private readonly ConcurrentDictionary<long, long> _redirectedPatternMessages = new();
1212
private readonly ConcurrentDictionary<string, long> _clientStates = new();
13+
private readonly ConcurrentDictionary<string, DateTime> _messageIdTracker = new();
14+
private readonly TimeSpan _duplicatePreventionLifetime = TimeSpan.FromHours(1);
1315

1416
public SynchronizedCollectionBasedMessageList(TimeSpan messageLifetime)
1517
{
@@ -19,6 +21,7 @@ public SynchronizedCollectionBasedMessageList(TimeSpan messageLifetime)
1921
private void Cleanup()
2022
{
2123
var currentEndOfLife = CurrentEndOfLife();
24+
var duplicateTrackerEndOfLife = DateTime.Now - _duplicatePreventionLifetime;
2225

2326
for (var i = _messages.Count-1; i >= 0; i--)
2427
{
@@ -31,11 +34,40 @@ private void Cleanup()
3134
_redirectedPatternMessages.TryRemove(message.Id, out _);
3235
}
3336
}
37+
38+
// Clean up expired message IDs from duplicate tracker
39+
var expiredIds = _messageIdTracker
40+
.Where(kvp => kvp.Value < duplicateTrackerEndOfLife)
41+
.Select(kvp => kvp.Key)
42+
.ToList();
43+
44+
foreach (var id in expiredIds)
45+
{
46+
_messageIdTracker.TryRemove(id, out _);
47+
}
3448
}
3549

3650

3751
public void Add(ReceivedMessage receivedMessage)
3852
{
53+
// Check if this message ID was already received within the duplicate prevention lifetime
54+
if (!string.IsNullOrEmpty(receivedMessage.Message.Id))
55+
{
56+
var now = DateTime.Now;
57+
if (_messageIdTracker.TryGetValue(receivedMessage.Message.Id, out var existingTimestamp))
58+
{
59+
// Message with this ID already exists and hasn't expired
60+
if (existingTimestamp > now - _duplicatePreventionLifetime)
61+
{
62+
// Reject duplicate message
63+
return;
64+
}
65+
}
66+
67+
// Track this message ID
68+
_messageIdTracker.AddOrUpdate(receivedMessage.Message.Id, now, (_, _) => now);
69+
}
70+
3971
_messages.Add(receivedMessage);
4072

4173
Cleanup();

0 commit comments

Comments
 (0)