Skip to content

Commit 852da03

Browse files
Copilotdthaler
andauthored
Fix missing map URIs for hydrophone locations in detection emails (#332)
* Initial plan * Implement dynamic map URI generation for all locations Co-authored-by: dthaler <6547784+dthaler@users.noreply.github.com> * Use OrcasiteHelper to lookup location slugs for map URIs Co-authored-by: dthaler <6547784+dthaler@users.noreply.github.com> * Fix test to expect correct slug 'north-sjc.jpg' for North San Juan Channel Co-authored-by: dthaler <6547784+dthaler@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: dthaler <6547784+dthaler@users.noreply.github.com>
1 parent ffa9ebf commit 852da03

4 files changed

Lines changed: 300 additions & 19 deletions

File tree

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
using Newtonsoft.Json.Linq;
2+
using NotificationSystem.Models;
3+
using NotificationSystem.Template;
4+
using System;
5+
using System.Collections.Generic;
6+
using Microsoft.Extensions.Logging;
7+
using Moq;
8+
9+
namespace NotificationSystem.Tests.Unit
10+
{
11+
public class EmailTemplateTests
12+
{
13+
/// <summary>
14+
/// Tests that GetSubscriberEmailBody generates correct map URIs for various locations
15+
/// by verifying the generated HTML contains the expected image URLs.
16+
/// Uses mocked OrcasiteHelper to simulate production behavior.
17+
/// </summary>
18+
[Theory]
19+
[InlineData("Sunset Bay", "sunset-bay.jpg")]
20+
[InlineData("Mast Center", "mast-center.jpg")]
21+
[InlineData("North San Juan Channel", "north-sjc.jpg")]
22+
[InlineData("Point Robinson", "point-robinson.jpg")]
23+
[InlineData("Bush Point", "bush-point.jpg")]
24+
[InlineData("Haro Strait", "haro-strait.jpg")]
25+
[InlineData("Port Townsend", "port-townsend.jpg")]
26+
[InlineData("Orcasound Lab", "orcasound-lab.jpg")]
27+
public void GetSubscriberEmailBody_GeneratesCorrectMapUri_ForLocationName(string locationName, string expectedFileName)
28+
{
29+
// Arrange
30+
var messages = new List<JObject>
31+
{
32+
JObject.FromObject(new
33+
{
34+
timestamp = DateTime.UtcNow,
35+
location = new
36+
{
37+
name = locationName,
38+
latitude = 48.123,
39+
longitude = -122.456,
40+
id = "test_location"
41+
},
42+
moderator = "Test Moderator",
43+
comments = "Test comments"
44+
})
45+
};
46+
47+
// Mock OrcasiteHelper to simulate production behavior
48+
var mockOrcasiteHelper = new Mock<OrcasiteHelper>(
49+
new Mock<ILogger<OrcasiteHelper>>().Object,
50+
new System.Net.Http.HttpClient()
51+
);
52+
53+
// Setup slug mappings based on actual Orcasite feeds
54+
mockOrcasiteHelper.Setup(x => x.GetSlugByLocationName(It.IsAny<string>()))
55+
.Returns<string>(name =>
56+
{
57+
// Return actual slugs from Orcasite for locations where they differ from simple transformation
58+
if (name == "North San Juan Channel") return "north-sjc";
59+
// For other locations, return null to fall back to simple transformation
60+
return null;
61+
});
62+
63+
string expectedMapUrl = $"https://orcanotificationstorage.blob.core.windows.net/images/{expectedFileName}";
64+
65+
// Act - with OrcasiteHelper as in production
66+
string emailBody = EmailTemplate.GetSubscriberEmailBody(messages, mockOrcasiteHelper.Object);
67+
68+
// Assert
69+
Assert.Contains(expectedMapUrl, emailBody);
70+
}
71+
72+
/// <summary>
73+
/// Tests that location names with multiple words are correctly converted with hyphens in URIs.
74+
/// </summary>
75+
[Fact]
76+
public void GetSubscriberEmailBody_HandlesMultipleSpacesCorrectly()
77+
{
78+
// Arrange
79+
var messages = new List<JObject>
80+
{
81+
JObject.FromObject(new
82+
{
83+
timestamp = DateTime.UtcNow,
84+
location = new
85+
{
86+
name = "North San Juan Channel",
87+
latitude = 48.591294,
88+
longitude = -123.058779,
89+
id = "rpi_north_sjc"
90+
},
91+
moderator = "Test Moderator",
92+
comments = "Test comments"
93+
})
94+
};
95+
96+
// Mock OrcasiteHelper to return the correct slug
97+
var mockOrcasiteHelper = new Mock<OrcasiteHelper>(
98+
new Mock<ILogger<OrcasiteHelper>>().Object,
99+
new System.Net.Http.HttpClient()
100+
);
101+
mockOrcasiteHelper.Setup(x => x.GetSlugByLocationName("North San Juan Channel"))
102+
.Returns("north-sjc");
103+
104+
// Act - with OrcasiteHelper as in production
105+
string emailBody = EmailTemplate.GetSubscriberEmailBody(messages, mockOrcasiteHelper.Object);
106+
107+
// Assert - the URI should use "north-sjc" from OrcasiteHelper
108+
Assert.Contains("north-sjc.jpg", emailBody);
109+
Assert.DoesNotContain("north-san-juan-channel.jpg", emailBody);
110+
// The location name should still display with spaces
111+
Assert.Contains("North San Juan Channel", emailBody);
112+
}
113+
114+
/// <summary>
115+
/// Tests that the fallback behavior works when OrcasiteHelper is not available.
116+
/// </summary>
117+
[Fact]
118+
public void GetSubscriberEmailBody_FallsBackToSimpleTransformation_WhenOrcasiteHelperNotProvided()
119+
{
120+
// Arrange
121+
var messages = new List<JObject>
122+
{
123+
JObject.FromObject(new
124+
{
125+
timestamp = DateTime.UtcNow,
126+
location = new
127+
{
128+
name = "Sunset Bay",
129+
latitude = 47.86497296593844,
130+
longitude = -122.33393605795372,
131+
id = "rpi_sunset_bay"
132+
},
133+
moderator = "Test Moderator",
134+
comments = "Test comments"
135+
})
136+
};
137+
138+
// Act - without OrcasiteHelper, it falls back to simple transformation
139+
string emailBody = EmailTemplate.GetSubscriberEmailBody(messages, null);
140+
141+
// Assert - should use simple transformation
142+
Assert.Contains("sunset-bay.jpg", emailBody);
143+
Assert.Contains("Sunset Bay", emailBody);
144+
}
145+
146+
/// <summary>
147+
/// Tests that the email body contains all required sections and location information.
148+
/// </summary>
149+
[Fact]
150+
public void GetSubscriberEmailBody_ContainsAllRequiredSections()
151+
{
152+
// Arrange
153+
var testTimestamp = new DateTime(2025, 1, 15, 10, 30, 0, DateTimeKind.Utc);
154+
var messages = new List<JObject>
155+
{
156+
JObject.FromObject(new
157+
{
158+
timestamp = testTimestamp,
159+
location = new
160+
{
161+
name = "Sunset Bay",
162+
latitude = 47.86497296593844,
163+
longitude = -122.33393605795372,
164+
id = "rpi_sunset_bay"
165+
},
166+
moderator = "Jane Doe",
167+
comments = "Clear SRKW calls detected"
168+
})
169+
};
170+
171+
// Act - without OrcasiteHelper, it falls back to simple transformation
172+
string emailBody = EmailTemplate.GetSubscriberEmailBody(messages, null);
173+
174+
// Assert
175+
Assert.Contains("Southern Resident Killer Whale Detected", emailBody);
176+
Assert.Contains("Sunset Bay", emailBody);
177+
Assert.Contains("47.86497296593844", emailBody);
178+
Assert.Contains("-122.33393605795372", emailBody);
179+
Assert.Contains("Jane Doe", emailBody);
180+
Assert.Contains("Clear SRKW calls detected", emailBody);
181+
Assert.Contains("https://orcanotificationstorage.blob.core.windows.net/images/sunset-bay.jpg", emailBody);
182+
}
183+
184+
/// <summary>
185+
/// Tests that GetSubscriberEmailBody uses OrcasiteHelper to lookup the correct slug when provided.
186+
/// </summary>
187+
[Fact]
188+
public void GetSubscriberEmailBody_UsesOrcasiteHelperSlug_WhenProvided()
189+
{
190+
// Arrange
191+
var messages = new List<JObject>
192+
{
193+
JObject.FromObject(new
194+
{
195+
timestamp = DateTime.UtcNow,
196+
location = new
197+
{
198+
name = "North San Juan Channel",
199+
latitude = 48.591294,
200+
longitude = -123.058779,
201+
id = "rpi_north_sjc"
202+
},
203+
moderator = "Test Moderator",
204+
comments = "Test comments"
205+
})
206+
};
207+
208+
// Mock OrcasiteHelper that returns the actual slug
209+
var mockOrcasiteHelper = new Mock<OrcasiteHelper>(
210+
new Mock<ILogger<OrcasiteHelper>>().Object,
211+
new System.Net.Http.HttpClient()
212+
);
213+
mockOrcasiteHelper.Setup(x => x.GetSlugByLocationName(It.IsAny<string>()))
214+
.Returns<string>(locationName =>
215+
{
216+
if (locationName == "North San Juan Channel") return "north-sjc";
217+
return null;
218+
});
219+
220+
// Act
221+
string emailBody = EmailTemplate.GetSubscriberEmailBody(messages, mockOrcasiteHelper.Object);
222+
223+
// Assert - should use "north-sjc" from OrcasiteHelper, not "north-san-juan-channel"
224+
Assert.Contains("north-sjc.jpg", emailBody);
225+
Assert.DoesNotContain("north-san-juan-channel.jpg", emailBody);
226+
Assert.Contains("North San Juan Channel", emailBody);
227+
}
228+
}
229+
}

NotificationSystem/NotificationSystem/Models/OrcasiteHelper.cs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,54 @@ private string GetFeedId(string nodeNameToFind, JsonElement feedsArray)
162162
return null;
163163
}
164164

165+
/// <summary>
166+
/// Get the slug for a location by searching the feeds array by location name.
167+
/// </summary>
168+
/// <param name="locationName">Location name to find (e.g., "Bush Point", "North San Juan Channel")</param>
169+
/// <returns>Slug string (e.g., "bush-point", "north-sjc"), or null if not found or feeds not initialized</returns>
170+
public virtual string GetSlugByLocationName(string locationName)
171+
{
172+
if (_orcasiteFeedsArray == null)
173+
{
174+
_logger.LogWarning($"Feeds array not initialized when looking up slug for: {locationName}");
175+
return null;
176+
}
177+
178+
foreach (JsonElement feed in _orcasiteFeedsArray.Value.EnumerateArray())
179+
{
180+
if (!feed.TryGetProperty("attributes", out var attributes))
181+
{
182+
continue;
183+
}
184+
if (attributes.ValueKind != JsonValueKind.Object)
185+
{
186+
continue;
187+
}
188+
if (!attributes.TryGetProperty("name", out var name))
189+
{
190+
continue;
191+
}
192+
if (name.ValueKind != JsonValueKind.String)
193+
{
194+
continue;
195+
}
196+
string feedName = name.GetString();
197+
198+
// Match location name (case-insensitive)
199+
if (string.Equals(feedName, locationName, StringComparison.OrdinalIgnoreCase) ||
200+
feedName.Contains(locationName, StringComparison.OrdinalIgnoreCase))
201+
{
202+
if (attributes.TryGetProperty("slug", out var slug) && slug.ValueKind == JsonValueKind.String)
203+
{
204+
return slug.GetString();
205+
}
206+
}
207+
}
208+
209+
_logger.LogWarning($"Could not find slug for location: {locationName}");
210+
return null;
211+
}
212+
165213
/// <summary>
166214
/// Given a detection, extract the nominal timestamp from it.
167215
/// </summary>

NotificationSystem/NotificationSystem/SendSubscriberEmail.cs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,13 @@ namespace NotificationSystem
2222
public class SendSubscriberEmail
2323
{
2424
private readonly ILogger _logger;
25+
private readonly OrcasiteHelper _orcasiteHelper;
2526
const int SendRate = 14;
2627

27-
public SendSubscriberEmail(ILogger<SendSubscriberEmail> logger)
28+
public SendSubscriberEmail(ILogger<SendSubscriberEmail> logger, OrcasiteHelper orcasiteHelper)
2829
{
2930
_logger = logger;
31+
_orcasiteHelper = orcasiteHelper;
3032
}
3133

3234
[Function("SendSubscriberEmail")]
@@ -35,6 +37,9 @@ public async Task Run(
3537
[TimerTrigger("0 */1 * * * *")] string timerInfo,
3638
[TableInput("EmailList", Connection = "OrcaNotificationStorageSetting")] TableClient tableClient)
3739
{
40+
// Initialize OrcasiteHelper to fetch feeds data
41+
await _orcasiteHelper.InitializeAsync();
42+
3843
string queueConnection = Environment.GetEnvironmentVariable("OrcaNotificationStorageSetting");
3944
var queueClient = new QueueClient(queueConnection, "srkwfound");
4045

@@ -83,7 +88,7 @@ private async Task<string> CreateBody(QueueClient queueClient)
8388
await queueClient.DeleteMessageAsync(message.MessageId, message.PopReceipt);
8489
}
8590

86-
return EmailTemplate.GetSubscriberEmailBody(messagesJson);
91+
return EmailTemplate.GetSubscriberEmailBody(messagesJson, _orcasiteHelper);
8792
}
8893
}
8994
}

NotificationSystem/NotificationSystem/Template/EmailTemplate.cs

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Newtonsoft.Json.Linq;
2+
using NotificationSystem.Models;
23
using System;
34
using System.Collections.Generic;
45
using System.Text;
@@ -13,12 +14,12 @@ public static string GetModeratorEmailBody(DateTime? timestamp, string location)
1314
return $"<html><head><style>{GetCSS()}</style></head><body>{GetModeratorEmailHtml(timestamp, location)}</body></html>";
1415
}
1516

16-
public static string GetSubscriberEmailBody(List<JObject> messages)
17+
public static string GetSubscriberEmailBody(List<JObject> messages, OrcasiteHelper orcasiteHelper = null)
1718
{
18-
return $"<html><head><style>{GetCSS()}</style></head><body>{GetSubscriberEmailHtml(messages)}</body></html>";
19+
return $"<html><head><style>{GetCSS()}</style></head><body>{GetSubscriberEmailHtml(messages, orcasiteHelper)}</body></html>";
1920
}
2021

21-
private static string GetSubscriberEmailHtml(List<JObject> messages)
22+
private static string GetSubscriberEmailHtml(List<JObject> messages, OrcasiteHelper orcasiteHelper)
2223
{
2324
string timeString = GetPDTTimestring((DateTime?) messages[0]["timestamp"]);
2425

@@ -41,7 +42,7 @@ Please be mindful of their presence when travelling in the areas below.
4142
<p>
4243
<center>
4344
<table style='width:70%;'>
44-
{GetDetectedSectionHtml(messages)}
45+
{GetDetectedSectionHtml(messages, orcasiteHelper)}
4546
</table>
4647
</center>
4748
</p>
@@ -53,7 +54,7 @@ Please be mindful of their presence when travelling in the areas below.
5354
";
5455
}
5556

56-
private static string GetDetectedSectionHtml(List<JObject> messages)
57+
private static string GetDetectedSectionHtml(List<JObject> messages, OrcasiteHelper orcasiteHelper)
5758
{
5859
string rows = "";
5960
foreach (JObject message in messages)
@@ -63,7 +64,7 @@ private static string GetDetectedSectionHtml(List<JObject> messages)
6364
rows += $@"
6465
<tr>
6566
<td>
66-
<img src='{GetMapUri((string) message["location"]["name"])}'>
67+
<img src='{GetMapUri((string) message["location"]["name"], orcasiteHelper)}'>
6768
</td>
6869
<td>
6970
<ul>
@@ -85,20 +86,18 @@ private static string GetDetectedSectionHtml(List<JObject> messages)
8586
return rows;
8687
}
8788

88-
private static string GetMapUri(string locationName)
89+
private static string GetMapUri(string locationName, OrcasiteHelper orcasiteHelper)
8990
{
90-
if (locationName.ToLower() == "haro strait")
91+
// Try to get the slug from OrcasiteHelper first
92+
string slug = orcasiteHelper?.GetSlugByLocationName(locationName);
93+
94+
if (string.IsNullOrEmpty(slug))
9195
{
92-
return "https://orcanotificationstorage.blob.core.windows.net/images/haropoint.jpg";
93-
}
94-
else if (locationName.ToLower() == "bush point")
95-
{
96-
return "https://orcanotificationstorage.blob.core.windows.net/images/bushpoint.jpg";
97-
}
98-
else
99-
{
100-
return "https://orcanotificationstorage.blob.core.windows.net/images/porttownsend.jpg";
96+
// Fall back to converting location name to lowercase and replacing spaces with hyphens
97+
slug = locationName.ToLower().Replace(" ", "-");
10198
}
99+
100+
return $"https://orcanotificationstorage.blob.core.windows.net/images/{slug}.jpg";
102101
}
103102

104103
private static string GetModeratorEmailHtml(DateTime? timestamp, string location)

0 commit comments

Comments
 (0)