Skip to content

Commit ab3f4a4

Browse files
authored
Merge pull request #122 from soliktomasz/codex/issue-115-message-templates-v2
Message Templates v2
2 parents bafc0d9 + c38d378 commit ab3f4a4

10 files changed

Lines changed: 919 additions & 64 deletions

File tree

BusLane.Tests/Models/ModelTests.cs

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,9 @@ public void SavedMessage_DefaultValues_AreCorrect()
305305
message.Id.Should().NotBeNullOrEmpty();
306306
message.Name.Should().BeEmpty();
307307
message.Body.Should().BeEmpty();
308+
message.Category.Should().BeEmpty();
309+
message.Tags.Should().BeEmpty();
310+
message.TokenValues.Should().BeEmpty();
308311
message.CustomProperties.Should().BeEmpty();
309312
message.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5));
310313
}
@@ -329,15 +332,51 @@ public void SavedMessage_CanSetAllProperties()
329332
PartitionKey = "partition1",
330333
TimeToLive = TimeSpan.FromMinutes(30),
331334
ScheduledEnqueueTime = new DateTimeOffset(2026, 1, 15, 12, 0, 0, TimeSpan.Zero),
335+
Category = "Orders",
336+
Tags = new List<string> { "smoke", "billing" },
337+
TokenValues = new Dictionary<string, string> { { "OrderId", "ORD-1" } },
332338
CustomProperties = new Dictionary<string, string> { { "key1", "value1" } }
333339
};
334340

335341
// Assert
336342
message.ContentType.Should().Be("application/json");
337343
message.CorrelationId.Should().Be("corr-123");
338344
message.TimeToLive.Should().Be(TimeSpan.FromMinutes(30));
345+
message.Category.Should().Be("Orders");
346+
message.Tags.Should().Equal("smoke", "billing");
347+
message.TokenValues.Should().Contain("OrderId", "ORD-1");
339348
message.CustomProperties.Should().ContainKey("key1");
340349
}
350+
351+
[Fact]
352+
public void SavedMessage_Duplicate_CopiesTemplateMetadataWithNewIdentity()
353+
{
354+
// Arrange
355+
var original = new SavedMessage
356+
{
357+
Id = "original-id",
358+
Name = "Create Order",
359+
Body = "{{OrderId}}",
360+
Category = "Orders",
361+
Tags = new List<string> { "smoke" },
362+
TokenValues = new Dictionary<string, string> { { "OrderId", "ORD-1" } },
363+
CustomProperties = new Dictionary<string, string> { { "tenant", "{{TenantId}}" } },
364+
CreatedAt = DateTime.UtcNow.AddDays(-1)
365+
};
366+
367+
// Act
368+
var duplicate = original.Duplicate();
369+
370+
// Assert
371+
duplicate.Id.Should().NotBe(original.Id);
372+
duplicate.Name.Should().Be("Create Order Copy");
373+
duplicate.Body.Should().Be(original.Body);
374+
duplicate.Category.Should().Be("Orders");
375+
duplicate.Tags.Should().Equal("smoke");
376+
duplicate.TokenValues.Should().Contain("OrderId", "ORD-1");
377+
duplicate.CustomProperties.Should().Contain("tenant", "{{TenantId}}");
378+
duplicate.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5));
379+
}
341380
}
342381

343382
public class CustomPropertyTests
@@ -677,4 +716,3 @@ public void LiveStreamMessage_ContainsAllProperties()
677716
msg.Properties.Should().HaveCount(2);
678717
}
679718
}
680-
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
namespace BusLane.Tests.Services.Templates;
2+
3+
using BusLane.Models;
4+
using BusLane.Services.Templates;
5+
using FluentAssertions;
6+
7+
public class MessageTemplateEngineTests
8+
{
9+
[Fact]
10+
public void ExtractTokenNames_WithTokensAcrossSendableFields_ReturnsDistinctNames()
11+
{
12+
// Arrange
13+
var message = new SavedMessage
14+
{
15+
Body = "{ \"order\": \"{{OrderId}}\" }",
16+
CorrelationId = "{{CorrelationId}}",
17+
Subject = "Order {{OrderId}}",
18+
CustomProperties = new Dictionary<string, string>
19+
{
20+
["tenant"] = "{{TenantId}}"
21+
}
22+
};
23+
24+
// Act
25+
var tokens = MessageTemplateEngine.ExtractTokenNames(message);
26+
27+
// Assert
28+
tokens.Should().Equal("OrderId", "CorrelationId", "TenantId");
29+
}
30+
31+
[Fact]
32+
public void FindMissingTokenValues_WithBlankValues_ReturnsMissingNames()
33+
{
34+
// Arrange
35+
var message = new SavedMessage
36+
{
37+
Body = "{{OrderId}}",
38+
CorrelationId = "{{CorrelationId}}"
39+
};
40+
41+
var values = new Dictionary<string, string?>
42+
{
43+
["OrderId"] = "123",
44+
["CorrelationId"] = ""
45+
};
46+
47+
// Act
48+
var missing = MessageTemplateEngine.FindMissingTokenValues(message, values);
49+
50+
// Assert
51+
missing.Should().Equal("CorrelationId");
52+
}
53+
54+
[Fact]
55+
public void Apply_WithValues_ReplacesTokensInAllSendableStringFields()
56+
{
57+
// Arrange
58+
var message = new SavedMessage
59+
{
60+
Body = "{{OrderId}}",
61+
ContentType = "application/{{Format}}",
62+
CorrelationId = "{{CorrelationId}}",
63+
MessageId = "{{MessageId}}",
64+
SessionId = "{{SessionId}}",
65+
Subject = "{{Subject}}",
66+
To = "{{To}}",
67+
ReplyTo = "{{ReplyTo}}",
68+
ReplyToSessionId = "{{ReplySession}}",
69+
PartitionKey = "{{PartitionKey}}",
70+
CustomProperties = new Dictionary<string, string>
71+
{
72+
["tenant"] = "{{TenantId}}"
73+
}
74+
};
75+
76+
var values = new Dictionary<string, string?>
77+
{
78+
["OrderId"] = "ORD-42",
79+
["Format"] = "json",
80+
["CorrelationId"] = "COR-42",
81+
["MessageId"] = "MSG-42",
82+
["SessionId"] = "SES-42",
83+
["Subject"] = "Created",
84+
["To"] = "billing",
85+
["ReplyTo"] = "inbox",
86+
["ReplySession"] = "reply-session",
87+
["PartitionKey"] = "partition",
88+
["TenantId"] = "tenant-a"
89+
};
90+
91+
// Act
92+
var applied = MessageTemplateEngine.Apply(message, values);
93+
94+
// Assert
95+
applied.Body.Should().Be("ORD-42");
96+
applied.ContentType.Should().Be("application/json");
97+
applied.CorrelationId.Should().Be("COR-42");
98+
applied.MessageId.Should().Be("MSG-42");
99+
applied.SessionId.Should().Be("SES-42");
100+
applied.Subject.Should().Be("Created");
101+
applied.To.Should().Be("billing");
102+
applied.ReplyTo.Should().Be("inbox");
103+
applied.ReplyToSessionId.Should().Be("reply-session");
104+
applied.PartitionKey.Should().Be("partition");
105+
applied.CustomProperties.Should().Contain("tenant", "tenant-a");
106+
}
107+
}
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
namespace BusLane.Tests.ViewModels;
2+
3+
using BusLane.Models;
4+
using BusLane.Services.ServiceBus;
5+
using BusLane.ViewModels;
6+
using FluentAssertions;
7+
using NSubstitute;
8+
9+
public class SendMessageViewModelTests
10+
{
11+
private readonly IServiceBusOperations _operations = Substitute.For<IServiceBusOperations>();
12+
private readonly string _savedMessagesPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString(), "saved_messages.json");
13+
private string? _statusMessage;
14+
private bool _closed;
15+
16+
[Fact]
17+
public void SaveMessage_WithCategoryAndTags_PersistsTemplateMetadata()
18+
{
19+
// Arrange
20+
var sut = CreateSut();
21+
sut.Body = "{{OrderId}}";
22+
sut.SaveMessageName = "Create Order";
23+
sut.SaveMessageCategory = "Orders";
24+
sut.SaveMessageTags = "smoke, billing";
25+
26+
// Act
27+
sut.SaveMessageCommand.Execute(null);
28+
29+
// Assert
30+
sut.SavedMessages.Should().ContainSingle();
31+
var saved = sut.SavedMessages.Single();
32+
saved.Category.Should().Be("Orders");
33+
saved.Tags.Should().Equal("smoke", "billing");
34+
}
35+
36+
[Fact]
37+
public void FilteredSavedMessages_WithSearchQuery_MatchesNameCategoryTagsAndPayload()
38+
{
39+
// Arrange
40+
var sut = CreateSut();
41+
sut.SavedMessages.Add(new SavedMessage
42+
{
43+
Name = "Create Order",
44+
Category = "Orders",
45+
Tags = new List<string> { "billing" },
46+
Body = "payload"
47+
});
48+
sut.SavedMessages.Add(new SavedMessage
49+
{
50+
Name = "Ping",
51+
Category = "Health",
52+
Body = "ready"
53+
});
54+
55+
// Act
56+
sut.TemplateSearchQuery = "billing";
57+
58+
// Assert
59+
sut.FilteredSavedMessages.Should().ContainSingle()
60+
.Which.Name.Should().Be("Create Order");
61+
}
62+
63+
[Fact]
64+
public void LoadMessage_WithParameterizedTemplate_PopulatesTokenValues()
65+
{
66+
// Arrange
67+
var sut = CreateSut();
68+
var template = new SavedMessage
69+
{
70+
Body = "{{OrderId}}",
71+
CorrelationId = "{{CorrelationId}}",
72+
TokenValues = new Dictionary<string, string> { { "OrderId", "ORD-1" } }
73+
};
74+
75+
// Act
76+
sut.LoadMessageCommand.Execute(template);
77+
78+
// Assert
79+
sut.TemplateTokenValues.Should().HaveCount(2);
80+
sut.TemplateTokenValues.Should().Contain(t => t.Name == "OrderId" && t.Value == "ORD-1");
81+
sut.TemplateTokenValues.Should().Contain(t => t.Name == "CorrelationId" && t.Value == "");
82+
}
83+
84+
[Fact]
85+
public async Task SendAsync_WithMissingTemplateValue_SetsErrorAndDoesNotSend()
86+
{
87+
// Arrange
88+
var sut = CreateSut();
89+
sut.LoadMessageCommand.Execute(new SavedMessage
90+
{
91+
Body = "{{OrderId}}",
92+
CorrelationId = "{{CorrelationId}}"
93+
});
94+
sut.TemplateTokenValues.Single(t => t.Name == "OrderId").Value = "ORD-1";
95+
96+
// Act
97+
await sut.SendCommand.ExecuteAsync(null);
98+
99+
// Assert
100+
sut.ErrorMessage.Should().Be("Missing template values: CorrelationId");
101+
await _operations.DidNotReceiveWithAnyArgs().SendMessageAsync(default!, default!, default);
102+
}
103+
104+
[Fact]
105+
public async Task SendAsync_WithTemplateValues_SendsAppliedMessage()
106+
{
107+
// Arrange
108+
var sut = CreateSut();
109+
sut.LoadMessageCommand.Execute(new SavedMessage
110+
{
111+
Body = "{{OrderId}}",
112+
ContentType = "application/json",
113+
CorrelationId = "{{CorrelationId}}",
114+
CustomProperties = new Dictionary<string, string>
115+
{
116+
["tenant"] = "{{TenantId}}"
117+
}
118+
});
119+
sut.TemplateTokenValues.Single(t => t.Name == "OrderId").Value = "ORD-1";
120+
sut.TemplateTokenValues.Single(t => t.Name == "CorrelationId").Value = "COR-1";
121+
sut.TemplateTokenValues.Single(t => t.Name == "TenantId").Value = "tenant-a";
122+
123+
// Act
124+
await sut.SendCommand.ExecuteAsync(null);
125+
126+
// Assert
127+
await _operations.Received(1).SendMessageAsync(
128+
"queue",
129+
"ORD-1",
130+
Arg.Is<IDictionary<string, object>>(p => p["tenant"].Equals("tenant-a")),
131+
"application/json",
132+
"COR-1");
133+
_closed.Should().BeTrue();
134+
_statusMessage.Should().Be("Message sent successfully");
135+
}
136+
137+
[Fact]
138+
public void DuplicateSavedMessage_CopiesTemplateWithNewName()
139+
{
140+
// Arrange
141+
var sut = CreateSut();
142+
var template = new SavedMessage
143+
{
144+
Name = "Create Order",
145+
Body = "{{OrderId}}",
146+
Category = "Orders",
147+
Tags = new List<string> { "billing" }
148+
};
149+
sut.SavedMessages.Add(template);
150+
151+
// Act
152+
sut.DuplicateSavedMessageCommand.Execute(template);
153+
154+
// Assert
155+
sut.SavedMessages.Should().HaveCount(2);
156+
var duplicate = sut.SavedMessages.Last();
157+
duplicate.Name.Should().Be("Create Order Copy");
158+
duplicate.Category.Should().Be("Orders");
159+
duplicate.Tags.Should().Equal("billing");
160+
}
161+
162+
[Fact]
163+
public void UpdateActiveTemplate_WithLoadedTemplate_EditsExistingTemplate()
164+
{
165+
// Arrange
166+
var sut = CreateSut();
167+
var template = new SavedMessage
168+
{
169+
Name = "Create Order",
170+
Body = "{{OrderId}}",
171+
Category = "Orders"
172+
};
173+
sut.SavedMessages.Add(template);
174+
sut.LoadMessageCommand.Execute(template);
175+
sut.Body = "{{OrderId}}-updated";
176+
sut.TemplateTokenValues.Single(t => t.Name == "OrderId").Value = "ORD-2";
177+
178+
// Act
179+
sut.UpdateActiveTemplateCommand.Execute(null);
180+
181+
// Assert
182+
sut.SavedMessages.Should().ContainSingle();
183+
template.Body.Should().Be("{{OrderId}}-updated");
184+
template.TokenValues.Should().Contain("OrderId", "ORD-2");
185+
template.Category.Should().Be("Orders");
186+
}
187+
188+
private SendMessageViewModel CreateSut()
189+
{
190+
return new SendMessageViewModel(
191+
_operations,
192+
"queue",
193+
() => _closed = true,
194+
message => _statusMessage = message,
195+
savedMessagesPath: _savedMessagesPath);
196+
}
197+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
namespace BusLane.Converters;
2+
3+
using System;
4+
using System.Globalization;
5+
using Avalonia.Data.Converters;
6+
7+
/// <summary>
8+
/// Converts a string to boolean. Returns true when the string is non-null and non-empty, false otherwise.
9+
/// </summary>
10+
public class StringToBoolConverter : IValueConverter
11+
{
12+
public static StringToBoolConverter Instance { get; } = new();
13+
14+
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
15+
{
16+
return value is string str && !string.IsNullOrEmpty(str);
17+
}
18+
19+
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
20+
{
21+
throw new NotSupportedException();
22+
}
23+
}

0 commit comments

Comments
 (0)