diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml
index f66b177b..8421b6b6 100644
--- a/.github/workflows/dotnet.yml
+++ b/.github/workflows/dotnet.yml
@@ -62,6 +62,8 @@ jobs:
dockerfile: Web/Resgrid.Web.Eventing/Dockerfile
- image: resgridllc/resgridwebmcp
dockerfile: Web/Resgrid.Web.Mcp/Dockerfile
+ - image: resgridllc/resgridwebtts
+ dockerfile: Web/Resgrid.Web.Tts/Dockerfile
- image: resgridllc/resgridworkersconsole
dockerfile: Workers/Resgrid.Workers.Console/Dockerfile
diff --git a/Core/Resgrid.Config/Resgrid.Config.csproj b/Core/Resgrid.Config/Resgrid.Config.csproj
index 0ba4ca4b..23779d20 100644
--- a/Core/Resgrid.Config/Resgrid.Config.csproj
+++ b/Core/Resgrid.Config/Resgrid.Config.csproj
@@ -1,15 +1,15 @@
-
-
- net9.0
- Debug;Release;Docker
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+
+
+ net8.0;net9.0
+ Debug;Release;Docker
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Core/Resgrid.Config/TtsConfig.cs b/Core/Resgrid.Config/TtsConfig.cs
new file mode 100644
index 00000000..6481041b
--- /dev/null
+++ b/Core/Resgrid.Config/TtsConfig.cs
@@ -0,0 +1,44 @@
+namespace Resgrid.Config
+{
+ ///
+ /// Shared configuration for the Resgrid TTS microservice.
+ /// Values are loaded through ConfigProcessor from ResgridConfig.json or RESGRID:* environment variables.
+ ///
+ public static class TtsConfig
+ {
+ public static string ServiceBaseUrl = "";
+ public static string PlaybackBaseUrl = "";
+ public static string StaticPromptAdminKey = "";
+ public static int StaticPromptRefreshIntervalMinutes = 1440;
+ public static int PlaybackMemoryCacheMinutes = 60;
+ public static int PlaybackCacheControlSeconds = 86400;
+
+ public static string S3Endpoint = "";
+ public static string S3AccessKey = "";
+ public static string S3SecretKey = "";
+ public static string S3Bucket = "";
+ public static string S3Region = "us-east-1";
+ public static bool S3UseSsl = false;
+ public static bool S3ForcePathStyle = true;
+ public static bool S3UsePresignedUrls = true;
+ public static int S3PresignedUrlExpiryMinutes = 60;
+ public static string S3PublicBaseUrl = "";
+
+ public static string DefaultVoice = "en-us";
+ public static int DefaultSpeed = 175;
+ public static int MaxConcurrentGenerations = 4;
+ public static int MaxTextLength = 1000;
+ public static string EspeakExecutable = "espeak-ng";
+ public static string FfmpegExecutable = "ffmpeg";
+ public static string TempDirectory = "";
+ public static string CachePrefix = "tts";
+ public static int NormalizedSampleRate = 8000;
+ public static int NormalizedChannels = 1;
+ public static bool WarmupEnabled = true;
+ public static string PreGeneratedPrompts = "Press 1 for yes;Press 2 for no;Invalid option;Please try again;Please stay on the line;This call has been closed. Goodbye.;You have been marked responding to the scene, goodbye.;Sorry, that was not a valid selection.;Hello, this is Resgrid calling with your verification code.;That was your Resgrid verification code. Goodbye.;Thank you for calling Resgrid, automated personnel system. The number you called is not tied to an active department or the department doesn't have this feature enabled. Goodbye.;We couldn't complete your verification call. Please request a new code and try again. Goodbye.;Please select from the following options.;To list current active calls, press 1.;To list current user statuses, press 2.;To list current unit statuses, press 3.;To list upcoming calendar events, press 4.;To list upcoming shifts, press 5.;To set your current status, press 6.;To set your current staffing level, press 7.;Press 0 to repeat. Press 1 to respond to the scene.;Press 0 to go back to the main menu.;Invalid status selection, goodbye.;No status selection made, goodbye.;Invalid staffing selection. Returning to the main menu.;No staffing selection made. Returning to the main menu.;Thank you. Your response has been recorded.";
+
+ public static int RateLimitPermitLimit = 60;
+ public static int RateLimitQueueLimit = 10;
+ public static int RateLimitWindowSeconds = 60;
+ }
+}
diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx
index 42505b69..abfc222f 100644
--- a/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx
+++ b/Core/Resgrid.Localization/Areas/User/Department/Department.en.resx
@@ -645,4 +645,10 @@
All
-
\ No newline at end of file
+
+ TTS Language
+
+
+ Select the eSpeak-NG language or dialect to use for this department's voice prompts.
+
+
diff --git a/Core/Resgrid.Localization/Areas/User/Department/Department.resx b/Core/Resgrid.Localization/Areas/User/Department/Department.resx
index 768fe3d7..3d09b45f 100644
--- a/Core/Resgrid.Localization/Areas/User/Department/Department.resx
+++ b/Core/Resgrid.Localization/Areas/User/Department/Department.resx
@@ -330,4 +330,10 @@
-
\ No newline at end of file
+
+ TTS Language
+
+
+ Select the eSpeak-NG language or dialect to use for this department's voice prompts.
+
+
diff --git a/Core/Resgrid.Model/DepartmentSettingTypes.cs b/Core/Resgrid.Model/DepartmentSettingTypes.cs
index 995c97f8..a341d0cc 100644
--- a/Core/Resgrid.Model/DepartmentSettingTypes.cs
+++ b/Core/Resgrid.Model/DepartmentSettingTypes.cs
@@ -50,5 +50,6 @@ public enum DepartmentSettingTypes
MappingUseMapboxOverride = 46,
MappingMapboxStyleUrl = 47,
MappingMapboxAccessToken = 48,
+ TtsLanguage = 49,
}
}
diff --git a/Core/Resgrid.Model/EspeakVoiceCatalog.cs b/Core/Resgrid.Model/EspeakVoiceCatalog.cs
new file mode 100644
index 00000000..6e96d0af
--- /dev/null
+++ b/Core/Resgrid.Model/EspeakVoiceCatalog.cs
@@ -0,0 +1,194 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Resgrid.Model
+{
+ public static class EspeakVoiceCatalog
+ {
+ private static readonly IReadOnlyList VoicesInternal = new List
+ {
+ new TtsVoiceOption("af", "Afrikaans"),
+ new TtsVoiceOption("sq", "Albanian"),
+ new TtsVoiceOption("am", "Amharic"),
+ new TtsVoiceOption("ar", "Arabic"),
+ new TtsVoiceOption("an", "Aragonese"),
+ new TtsVoiceOption("hy", "Armenian", "East Armenian"),
+ new TtsVoiceOption("hyw", "Armenian", "West Armenian"),
+ new TtsVoiceOption("as", "Assamese"),
+ new TtsVoiceOption("az", "Azerbaijani"),
+ new TtsVoiceOption("ba", "Bashkir"),
+ new TtsVoiceOption("cu", "Chuvash"),
+ new TtsVoiceOption("eu", "Basque"),
+ new TtsVoiceOption("be", "Belarusian"),
+ new TtsVoiceOption("bn", "Bengali"),
+ new TtsVoiceOption("bpy", "Bishnupriya Manipuri"),
+ new TtsVoiceOption("bs", "Bosnian"),
+ new TtsVoiceOption("bg", "Bulgarian"),
+ new TtsVoiceOption("my", "Burmese"),
+ new TtsVoiceOption("ca", "Catalan"),
+ new TtsVoiceOption("chr", "Cherokee", "Western/C.E.D."),
+ new TtsVoiceOption("yue", "Chinese", "Cantonese"),
+ new TtsVoiceOption("hak", "Chinese", "Hakka"),
+ new TtsVoiceOption("haw", "Hawaiian"),
+ new TtsVoiceOption("cmn", "Chinese", "Mandarin"),
+ new TtsVoiceOption("hr", "Croatian"),
+ new TtsVoiceOption("cs", "Czech"),
+ new TtsVoiceOption("da", "Danish"),
+ new TtsVoiceOption("nl", "Dutch"),
+ new TtsVoiceOption("en-us", "English", "American"),
+ new TtsVoiceOption("en", "English", "British"),
+ new TtsVoiceOption("en-029", "English", "Caribbean"),
+ new TtsVoiceOption("en-gb-x-gbclan", "English", "Lancastrian"),
+ new TtsVoiceOption("en-gb-x-rp", "English", "Received Pronunciation"),
+ new TtsVoiceOption("en-gb-scotland", "English", "Scottish"),
+ new TtsVoiceOption("en-gb-x-gbcwmd", "English", "West Midlands"),
+ new TtsVoiceOption("eo", "Esperanto"),
+ new TtsVoiceOption("et", "Estonian"),
+ new TtsVoiceOption("fa", "Persian"),
+ new TtsVoiceOption("fa-latn", "Persian"),
+ new TtsVoiceOption("fi", "Finnish"),
+ new TtsVoiceOption("fr-be", "French", "Belgium"),
+ new TtsVoiceOption("fr", "French", "France"),
+ new TtsVoiceOption("fr-ch", "French", "Switzerland"),
+ new TtsVoiceOption("ga", "Gaelic", "Irish"),
+ new TtsVoiceOption("gd", "Gaelic", "Scottish"),
+ new TtsVoiceOption("ka", "Georgian"),
+ new TtsVoiceOption("de", "German"),
+ new TtsVoiceOption("grc", "Greek", "Ancient"),
+ new TtsVoiceOption("el", "Greek", "Modern"),
+ new TtsVoiceOption("kl", "Greenlandic"),
+ new TtsVoiceOption("gn", "Guarani"),
+ new TtsVoiceOption("gu", "Gujarati"),
+ new TtsVoiceOption("ht", "Haitian Creole"),
+ new TtsVoiceOption("he", "Hebrew"),
+ new TtsVoiceOption("hi", "Hindi"),
+ new TtsVoiceOption("hu", "Hungarian"),
+ new TtsVoiceOption("is", "Icelandic"),
+ new TtsVoiceOption("id", "Indonesian"),
+ new TtsVoiceOption("ia", "Interlingua"),
+ new TtsVoiceOption("io", "Ido"),
+ new TtsVoiceOption("it", "Italian"),
+ new TtsVoiceOption("ja", "Japanese"),
+ new TtsVoiceOption("kn", "Kannada"),
+ new TtsVoiceOption("kok", "Konkani"),
+ new TtsVoiceOption("ko", "Korean"),
+ new TtsVoiceOption("ku", "Kurdish"),
+ new TtsVoiceOption("kk", "Kazakh"),
+ new TtsVoiceOption("ky", "Kyrgyz"),
+ new TtsVoiceOption("la", "Latin"),
+ new TtsVoiceOption("lb", "Luxembourgish"),
+ new TtsVoiceOption("ltg", "Latgalian"),
+ new TtsVoiceOption("lv", "Latvian"),
+ new TtsVoiceOption("lfn", "Lingua Franca Nova"),
+ new TtsVoiceOption("lt", "Lithuanian"),
+ new TtsVoiceOption("jbo", "Lojban"),
+ new TtsVoiceOption("mi", "Māori"),
+ new TtsVoiceOption("mk", "Macedonian"),
+ new TtsVoiceOption("ms", "Malay"),
+ new TtsVoiceOption("ml", "Malayalam"),
+ new TtsVoiceOption("mt", "Maltese"),
+ new TtsVoiceOption("mr", "Marathi"),
+ new TtsVoiceOption("nci", "Nahuatl", "Classical"),
+ new TtsVoiceOption("ne", "Nepali"),
+ new TtsVoiceOption("nb", "Norwegian Bokmål"),
+ new TtsVoiceOption("nog", "Nogai"),
+ new TtsVoiceOption("or", "Oriya"),
+ new TtsVoiceOption("om", "Oromo"),
+ new TtsVoiceOption("pap", "Papiamento"),
+ new TtsVoiceOption("py", "Pyash"),
+ new TtsVoiceOption("pl", "Polish"),
+ new TtsVoiceOption("qdb", "Lang Belta"),
+ new TtsVoiceOption("qu", "Quechua"),
+ new TtsVoiceOption("quc", "K'iche'"),
+ new TtsVoiceOption("qya", "Quenya"),
+ new TtsVoiceOption("pt", "Portuguese", "Portugal"),
+ new TtsVoiceOption("pt-br", "Portuguese", "Brazil"),
+ new TtsVoiceOption("pa", "Punjabi"),
+ new TtsVoiceOption("piqd", "Klingon"),
+ new TtsVoiceOption("ro", "Romanian"),
+ new TtsVoiceOption("ru", "Russian"),
+ new TtsVoiceOption("ru-lv", "Russian", "Latvia"),
+ new TtsVoiceOption("uk", "Ukrainian"),
+ new TtsVoiceOption("sjn", "Sindarin"),
+ new TtsVoiceOption("sr", "Serbian"),
+ new TtsVoiceOption("tn", "Setswana"),
+ new TtsVoiceOption("sd", "Sindhi"),
+ new TtsVoiceOption("shn", "Shan (Tai Yai)"),
+ new TtsVoiceOption("si", "Sinhala"),
+ new TtsVoiceOption("sk", "Slovak"),
+ new TtsVoiceOption("sl", "Slovenian"),
+ new TtsVoiceOption("smj", "Lule Saami"),
+ new TtsVoiceOption("es", "Spanish", "Spain"),
+ new TtsVoiceOption("es-419", "Spanish", "Latin American"),
+ new TtsVoiceOption("sw", "Swahili"),
+ new TtsVoiceOption("sv", "Swedish"),
+ new TtsVoiceOption("ta", "Tamil"),
+ new TtsVoiceOption("th", "Thai"),
+ new TtsVoiceOption("tk", "Turkmen"),
+ new TtsVoiceOption("tt", "Tatar"),
+ new TtsVoiceOption("te", "Telugu"),
+ new TtsVoiceOption("tr", "Turkish"),
+ new TtsVoiceOption("ug", "Uyghur"),
+ new TtsVoiceOption("ur", "Urdu"),
+ new TtsVoiceOption("uz", "Uzbek"),
+ new TtsVoiceOption("vi-vn-x-central", "Vietnamese", "Central Vietnam"),
+ new TtsVoiceOption("vi", "Vietnamese", "Northern Vietnam"),
+ new TtsVoiceOption("vi-vn-x-south", "Vietnamese", "Southern Vietnam"),
+ new TtsVoiceOption("cy", "Welsh"),
+ };
+
+ private static readonly Dictionary VoiceLookup = VoicesInternal.ToDictionary(x => x.Identifier, StringComparer.OrdinalIgnoreCase);
+
+ public const string DefaultIdentifier = "en-us";
+
+ public static IReadOnlyList Voices => VoicesInternal;
+
+ public static bool TryNormalizeIdentifier(string voice, out string normalizedVoice)
+ {
+ normalizedVoice = null;
+
+ if (string.IsNullOrWhiteSpace(voice))
+ return false;
+
+ if (!VoiceLookup.TryGetValue(voice.Trim(), out var option))
+ return false;
+
+ normalizedVoice = option.Identifier;
+ return true;
+ }
+
+ public static string GetDisplayName(string voice)
+ {
+ if (TryGetOption(voice, out var option))
+ return option.DisplayName;
+
+ return string.IsNullOrWhiteSpace(voice) ? DefaultIdentifier : voice.Trim();
+ }
+
+ public static bool TryGetOption(string voice, out TtsVoiceOption option)
+ {
+ option = null;
+
+ if (string.IsNullOrWhiteSpace(voice))
+ return false;
+
+ return VoiceLookup.TryGetValue(voice.Trim(), out option);
+ }
+ }
+
+ public sealed class TtsVoiceOption
+ {
+ public TtsVoiceOption(string identifier, string language, string accent = null)
+ {
+ Identifier = identifier;
+ Language = language;
+ Accent = accent;
+ }
+
+ public string Identifier { get; }
+ public string Language { get; }
+ public string Accent { get; }
+ public string DisplayName => string.IsNullOrWhiteSpace(Accent) ? $"{Language} ({Identifier})" : $"{Language} - {Accent} ({Identifier})";
+ }
+}
diff --git a/Core/Resgrid.Model/Services/ICommunicationTestService.cs b/Core/Resgrid.Model/Services/ICommunicationTestService.cs
index 49aa1cb3..ce45ff1e 100644
--- a/Core/Resgrid.Model/Services/ICommunicationTestService.cs
+++ b/Core/Resgrid.Model/Services/ICommunicationTestService.cs
@@ -25,6 +25,7 @@ public interface ICommunicationTestService
Task RecordEmailResponseAsync(string responseToken);
Task RecordVoiceResponseAsync(string responseToken);
Task RecordPushResponseAsync(string responseToken);
+ Task GetDepartmentIdByResponseTokenAsync(string responseToken);
Task ProcessScheduledTestsAsync(CancellationToken cancellationToken = default);
Task CompleteExpiredRunsAsync(CancellationToken cancellationToken = default);
diff --git a/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs b/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs
index 1eb3a01a..1e5a334a 100644
--- a/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs
+++ b/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs
@@ -169,6 +169,8 @@ public interface IDepartmentSettingsService
/// Task<System.String>.
Task GetDispatchEmailForDepartmentAsync(int departmentId);
+ Task GetTtsLanguageForDepartmentAsync(int departmentId);
+
///
/// Gets the disable automatic available for department by user identifier asynchronous.
///
diff --git a/Core/Resgrid.Model/Services/ITtsAudioService.cs b/Core/Resgrid.Model/Services/ITtsAudioService.cs
new file mode 100644
index 00000000..aa3c025e
--- /dev/null
+++ b/Core/Resgrid.Model/Services/ITtsAudioService.cs
@@ -0,0 +1,14 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Services
+{
+ public interface ITtsAudioService
+ {
+ Task GenerateSpeechUrlAsync(string text, string voice = null, int? speed = null, CancellationToken cancellationToken = default);
+
+ Task RegenerateStaticPromptsAsync(IEnumerable prompts, CancellationToken cancellationToken = default);
+ }
+}
diff --git a/Core/Resgrid.Model/TwilioVoicePromptCatalog.cs b/Core/Resgrid.Model/TwilioVoicePromptCatalog.cs
new file mode 100644
index 00000000..36c460eb
--- /dev/null
+++ b/Core/Resgrid.Model/TwilioVoicePromptCatalog.cs
@@ -0,0 +1,81 @@
+using System.Collections.Generic;
+
+namespace Resgrid.Model
+{
+ public static class TwilioVoicePromptCatalog
+ {
+ public const string CallClosed = "This call has been closed. Goodbye.";
+ public const string RespondingToScene = "You have been marked responding to the scene, goodbye.";
+ public const string InvalidSelection = "Sorry, that was not a valid selection.";
+ public const string VerificationGreeting = "Hello, this is Resgrid calling with your verification code.";
+ public const string VerificationClosing = "That was your Resgrid verification code. Goodbye.";
+ public const string InboundVoiceUnavailable = "Thank you for calling Resgrid, automated personnel system. The number you called is not tied to an active department or the department doesn't have this feature enabled. Goodbye.";
+ public const string VoiceVerificationFailure = "We couldn't complete your verification call. Please request a new code and try again. Goodbye.";
+ public const string MainMenuSelectionIntro = "Please select from the following options.";
+ public const string MainMenuActiveCalls = "To list current active calls, press 1.";
+ public const string MainMenuUserStatuses = "To list current user statuses, press 2.";
+ public const string MainMenuUnitStatuses = "To list current unit statuses, press 3.";
+ public const string MainMenuCalendarEvents = "To list upcoming calendar events, press 4.";
+ public const string MainMenuShifts = "To list upcoming shifts, press 5.";
+ public const string MainMenuSetStatus = "To set your current status, press 6.";
+ public const string MainMenuSetStaffing = "To set your current staffing level, press 7.";
+ public const string RepeatAndRespondToScene = "Press 0 to repeat. Press 1 to respond to the scene.";
+ public const string GoBackToMainMenu = "Press 0 to go back to the main menu.";
+ public const string StatusSelectionIntro = "To set your current status, please select from the following options.";
+ public const string StaffingSelectionIntro = "To set your current staffing, please select from the following options.";
+ public const string InvalidStatusSelection = "Invalid status selection, goodbye.";
+ public const string NoStatusSelection = "No status selection made, goodbye.";
+ public const string InvalidStaffingSelection = "Invalid staffing selection. Returning to the main menu.";
+ public const string NoStaffingSelection = "No staffing selection made. Returning to the main menu.";
+ public const string CommunicationTestRecorded = "Thank you. Your response has been recorded.";
+
+ public static string CallClosedByNumber(string callNumber) => $"This call, Id {callNumber} has been closed. Goodbye.";
+
+ public static string RespondingToStation(string stationName) => $"You have been marked responding to {stationName}, goodbye.";
+
+ public static string RespondToStationOption(int digit, string stationName) => $"Press {digit} to respond to {stationName}.";
+
+ public static string VerificationCode(string spokenCode) => $"Your verification code is: {spokenCode}.";
+
+ public static string MainMenuGreeting(string firstName, string departmentName) => $"Hello {firstName}, this is the Resgrid automated voice system for {departmentName}.";
+
+ public static string StatusOption(int digit, string buttonText) => $"Press {digit} for {buttonText}.";
+
+ public static string StaffingOption(int digit, string buttonText) => $"Press {digit} for {buttonText}.";
+
+ public static string StatusMarked(string buttonText) => $"You have been marked as {buttonText}, goodbye.";
+
+ public static string StaffingMarked(string buttonText) => $"You have been marked as {buttonText}. Goodbye.";
+
+ public static IReadOnlyCollection GetStaticPrompts()
+ {
+ return new[]
+ {
+ CallClosed,
+ RespondingToScene,
+ InvalidSelection,
+ VerificationGreeting,
+ VerificationClosing,
+ InboundVoiceUnavailable,
+ VoiceVerificationFailure,
+ MainMenuSelectionIntro,
+ MainMenuActiveCalls,
+ MainMenuUserStatuses,
+ MainMenuUnitStatuses,
+ MainMenuCalendarEvents,
+ MainMenuShifts,
+ MainMenuSetStatus,
+ MainMenuSetStaffing,
+ RepeatAndRespondToScene,
+ GoBackToMainMenu,
+ StatusSelectionIntro,
+ StaffingSelectionIntro,
+ InvalidStatusSelection,
+ NoStatusSelection,
+ InvalidStaffingSelection,
+ NoStaffingSelection,
+ CommunicationTestRecorded
+ };
+ }
+ }
+}
diff --git a/Core/Resgrid.Services/CommunicationTestService.cs b/Core/Resgrid.Services/CommunicationTestService.cs
index 0fa325bb..a527619c 100644
--- a/Core/Resgrid.Services/CommunicationTestService.cs
+++ b/Core/Resgrid.Services/CommunicationTestService.cs
@@ -300,6 +300,15 @@ public async Task RecordPushResponseAsync(string responseToken)
return await RecordResponseByTokenAsync(responseToken, CommunicationTestChannel.Push);
}
+ public async Task GetDepartmentIdByResponseTokenAsync(string responseToken)
+ {
+ if (string.IsNullOrWhiteSpace(responseToken))
+ return null;
+
+ var result = await _communicationTestResultRepository.GetResultByResponseTokenAsync(responseToken);
+ return result?.DepartmentId;
+ }
+
public async Task ProcessScheduledTestsAsync(CancellationToken cancellationToken = default)
{
var now = DateTime.UtcNow;
diff --git a/Core/Resgrid.Services/DepartmentSettingsService.cs b/Core/Resgrid.Services/DepartmentSettingsService.cs
index 90067085..991713ff 100644
--- a/Core/Resgrid.Services/DepartmentSettingsService.cs
+++ b/Core/Resgrid.Services/DepartmentSettingsService.cs
@@ -19,6 +19,7 @@ public class DepartmentSettingsService : IDepartmentSettingsService
private static string PaddleCustomerCacheKey = "DSetPaddleCus_{0}";
private static string BigBoardCenterGps = "DSetBBCenterGps_{0}";
private static string StaffingSupressInfo = "DSetStaffingSupress_{0}";
+ private static string TtsLanguageCacheKey = "DSetTtsLanguage_{0}";
private static string PersonnelOnUnitSetUnitStatusCacheKey = "DSetPersonnelOnUnitSetUnitStatus_{0}";
private static TimeSpan LongCacheLength = TimeSpan.FromDays(14);
private static TimeSpan ThatsNotLongThisIsLongCacheLength = TimeSpan.FromDays(365);
@@ -65,6 +66,9 @@ public DepartmentSettingsService(IDepartmentSettingsRepository departmentSetting
case DepartmentSettingTypes.StaffingSuppressStaffingLevels:
await _cacheProvider.RemoveAsync(string.Format(StaffingSupressInfo, departmentId));
break;
+ case DepartmentSettingTypes.TtsLanguage:
+ await _cacheProvider.RemoveAsync(string.Format(TtsLanguageCacheKey, departmentId));
+ break;
case DepartmentSettingTypes.PersonnelOnUnitSetUnitStatus:
await _cacheProvider.RemoveAsync(string.Format(PersonnelOnUnitSetUnitStatusCacheKey, departmentId));
break;
@@ -464,6 +468,27 @@ public async Task GetDispatchEmailForDepartmentAsync(int departmentId)
return null;
}
+ public async Task GetTtsLanguageForDepartmentAsync(int departmentId)
+ {
+ async Task getSetting()
+ {
+ var settingValue = await GetSettingByDepartmentIdType(departmentId, DepartmentSettingTypes.TtsLanguage);
+
+ if (settingValue != null && EspeakVoiceCatalog.TryNormalizeIdentifier(settingValue.Setting, out var normalizedSetting))
+ return normalizedSetting;
+
+ return GetDefaultTtsLanguage();
+ }
+
+ if (Config.SystemBehaviorConfig.CacheEnabled)
+ {
+ return await _cacheProvider.RetrieveAsync(string.Format(TtsLanguageCacheKey, departmentId),
+ getSetting, LongCacheLength);
+ }
+
+ return await getSetting();
+ }
+
public async Task GetDepartmentUpdateTimestampAsync(int departmentId)
{
var settingValue = await GetSettingByDepartmentIdType(departmentId, DepartmentSettingTypes.UpdateTimestamp);
@@ -841,5 +866,16 @@ public async Task GetSettingByTypeAsync(int departmentId, Dep
{
return await GetSettingByDepartmentIdType(departmentId, type);
}
+
+ private static string GetDefaultTtsLanguage()
+ {
+ if (EspeakVoiceCatalog.TryNormalizeIdentifier(TtsConfig.DefaultVoice, out var normalizedVoice))
+ return normalizedVoice;
+
+ if (!string.IsNullOrWhiteSpace(TtsConfig.DefaultVoice))
+ return TtsConfig.DefaultVoice.Trim();
+
+ return EspeakVoiceCatalog.DefaultIdentifier;
+ }
}
}
diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs
index 0ec55973..c23f4939 100644
--- a/Core/Resgrid.Services/ServicesModule.cs
+++ b/Core/Resgrid.Services/ServicesModule.cs
@@ -1,11 +1,17 @@
-using Autofac;
+using System;
+using Autofac;
using Resgrid.Model.Services;
using Resgrid.Services.CallEmailTemplates;
+using RestSharp;
+using RestSharp.Serializers.NewtonsoftJson;
+using Resgrid.Config;
namespace Resgrid.Services
{
public class ServicesModule : Module
{
+ private const string TtsRestClientRegistrationName = "tts-rest-client";
+
protected override void Load(ContainerBuilder builder)
{
builder.RegisterType().As().InstancePerLifetimeScope();
@@ -75,6 +81,26 @@ protected override void Load(ContainerBuilder builder)
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
+ builder.Register(_ =>
+ {
+ if (string.IsNullOrWhiteSpace(TtsConfig.ServiceBaseUrl))
+ throw new InvalidOperationException("TtsConfig.ServiceBaseUrl must be configured before using the TTS service.");
+
+ var options = new RestClientOptions(TtsConfig.ServiceBaseUrl.TrimEnd('/'))
+ {
+ Timeout = TimeSpan.FromSeconds(5)
+ };
+
+ return new RestClient(options, configureSerialization: serializer => serializer.UseNewtonsoftJson());
+ })
+ .Named(TtsRestClientRegistrationName)
+ .SingleInstance();
+ builder.RegisterType()
+ .As()
+ .WithParameter(
+ (parameter, _) => parameter.ParameterType == typeof(RestClient),
+ (_, context) => context.ResolveNamed(TtsRestClientRegistrationName))
+ .InstancePerLifetimeScope();
// SSO / Security Policy
builder.RegisterType().As().InstancePerLifetimeScope();
diff --git a/Core/Resgrid.Services/TtsAudioService.cs b/Core/Resgrid.Services/TtsAudioService.cs
new file mode 100644
index 00000000..a60ce57d
--- /dev/null
+++ b/Core/Resgrid.Services/TtsAudioService.cs
@@ -0,0 +1,109 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using System.Threading;
+using System.Threading.Tasks;
+using Resgrid.Config;
+using Resgrid.Model.Services;
+using RestSharp;
+
+namespace Resgrid.Services
+{
+ public class TtsAudioService : ITtsAudioService
+ {
+ private const string AdminKeyHeaderName = "X-Resgrid-Admin-Key";
+ private readonly RestClient _restClient;
+
+ public TtsAudioService(RestClient restClient)
+ {
+ _restClient = restClient ?? throw new ArgumentNullException(nameof(restClient));
+ }
+
+ public async Task GenerateSpeechUrlAsync(string text, string voice = null, int? speed = null, CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(text))
+ throw new ArgumentException("Text is required.", nameof(text));
+
+ var request = new RestRequest("tts", Method.Post);
+ request.AddJsonBody(new GenerateSpeechRequest
+ {
+ Text = text,
+ Voice = string.IsNullOrWhiteSpace(voice) ? TtsConfig.DefaultVoice : voice,
+ Speed = speed ?? TtsConfig.DefaultSpeed
+ });
+
+ var response = await _restClient.ExecuteAsync(request, cancellationToken);
+
+ if (!response.IsSuccessful || response.Data == null || string.IsNullOrWhiteSpace(response.Data.Url))
+ throw CreateRequestFailure("generate speech audio", response);
+
+ return new Uri(response.Data.Url, UriKind.Absolute);
+ }
+
+ public async Task RegenerateStaticPromptsAsync(IEnumerable prompts, CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(TtsConfig.StaticPromptAdminKey))
+ throw new InvalidOperationException("TtsConfig.StaticPromptAdminKey must be configured before refreshing static prompts.");
+
+ var promptRequests = prompts?
+ .Where(prompt => !string.IsNullOrWhiteSpace(prompt))
+ .Select(prompt => new GenerateSpeechRequest
+ {
+ Text = prompt.Trim(),
+ Voice = TtsConfig.DefaultVoice,
+ Speed = TtsConfig.DefaultSpeed
+ })
+ .ToList() ?? new List();
+
+ if (!promptRequests.Any())
+ throw new ArgumentException("At least one static prompt is required.", nameof(prompts));
+
+ var request = new RestRequest("tts/admin/static-prompts", Method.Post);
+ request.AddHeader(AdminKeyHeaderName, TtsConfig.StaticPromptAdminKey);
+ request.AddJsonBody(new RegenerateStaticPromptsRequest
+ {
+ Prompts = promptRequests
+ });
+
+ var response = await _restClient.ExecuteAsync(request, cancellationToken);
+
+ if (!response.IsSuccessful)
+ throw CreateRequestFailure("regenerate static prompts", response);
+ }
+
+ private static Exception CreateRequestFailure(string operation, RestResponse response)
+ {
+ var status = response.StatusCode == 0 ? "no-response" : response.StatusCode.ToString();
+ var detail = response.ErrorException?.Message;
+
+ if (string.IsNullOrWhiteSpace(detail))
+ detail = response.ErrorMessage;
+
+ if (string.IsNullOrWhiteSpace(detail) && !string.IsNullOrWhiteSpace(response.StatusDescription))
+ detail = response.StatusDescription;
+
+ if (string.IsNullOrWhiteSpace(detail))
+ detail = "No additional error details were provided.";
+
+ return new InvalidOperationException($"The TTS service failed to {operation}. Status: {status}. Detail: {detail}");
+ }
+
+ private sealed class GenerateSpeechRequest
+ {
+ public string Text { get; set; }
+ public string Voice { get; set; }
+ public int Speed { get; set; }
+ }
+
+ private sealed class GenerateSpeechResponse
+ {
+ public string Url { get; set; }
+ }
+
+ private sealed class RegenerateStaticPromptsRequest
+ {
+ public List Prompts { get; set; }
+ }
+ }
+}
diff --git a/Resgrid.sln b/Resgrid.sln
index de74e3a9..6e87b529 100644
--- a/Resgrid.sln
+++ b/Resgrid.sln
@@ -98,6 +98,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Quidjibo.Postgres", "Worker
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resgrid.Providers.Weather", "Providers\Resgrid.Providers.Weather\Resgrid.Providers.Weather.csproj", "{FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resgrid.Web.Tts", "Web\Resgrid.Web.Tts\Resgrid.Web.Tts.csproj", "{684FA75D-6712-41AE-A396-B6E0918899C0}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Azure|Any CPU = Azure|Any CPU
@@ -1344,6 +1346,42 @@ Global
{FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Staging|x86.Build.0 = Debug|Any CPU
{FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Staging|x64.ActiveCfg = Debug|Any CPU
{FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA}.Staging|x64.Build.0 = Debug|Any CPU
+ {684FA75D-6712-41AE-A396-B6E0918899C0}.Azure|Any CPU.ActiveCfg = Debug|Any CPU
+ {684FA75D-6712-41AE-A396-B6E0918899C0}.Azure|Any CPU.Build.0 = Debug|Any CPU
+ {684FA75D-6712-41AE-A396-B6E0918899C0}.Azure|x86.ActiveCfg = Debug|Any CPU
+ {684FA75D-6712-41AE-A396-B6E0918899C0}.Azure|x86.Build.0 = Debug|Any CPU
+ {684FA75D-6712-41AE-A396-B6E0918899C0}.Azure|x64.ActiveCfg = Debug|Any CPU
+ {684FA75D-6712-41AE-A396-B6E0918899C0}.Azure|x64.Build.0 = Debug|Any CPU
+ {684FA75D-6712-41AE-A396-B6E0918899C0}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU
+ {684FA75D-6712-41AE-A396-B6E0918899C0}.Cloud|Any CPU.Build.0 = Debug|Any CPU
+ {684FA75D-6712-41AE-A396-B6E0918899C0}.Cloud|x86.ActiveCfg = Debug|Any CPU
+ {684FA75D-6712-41AE-A396-B6E0918899C0}.Cloud|x86.Build.0 = Debug|Any CPU
+ {684FA75D-6712-41AE-A396-B6E0918899C0}.Cloud|x64.ActiveCfg = Debug|Any CPU
+ {684FA75D-6712-41AE-A396-B6E0918899C0}.Cloud|x64.Build.0 = Debug|Any CPU
+ {684FA75D-6712-41AE-A396-B6E0918899C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {684FA75D-6712-41AE-A396-B6E0918899C0}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {684FA75D-6712-41AE-A396-B6E0918899C0}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {684FA75D-6712-41AE-A396-B6E0918899C0}.Debug|x86.Build.0 = Debug|Any CPU
+ {684FA75D-6712-41AE-A396-B6E0918899C0}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {684FA75D-6712-41AE-A396-B6E0918899C0}.Debug|x64.Build.0 = Debug|Any CPU
+ {684FA75D-6712-41AE-A396-B6E0918899C0}.Docker|Any CPU.ActiveCfg = Debug|Any CPU
+ {684FA75D-6712-41AE-A396-B6E0918899C0}.Docker|Any CPU.Build.0 = Debug|Any CPU
+ {684FA75D-6712-41AE-A396-B6E0918899C0}.Docker|x86.ActiveCfg = Debug|Any CPU
+ {684FA75D-6712-41AE-A396-B6E0918899C0}.Docker|x86.Build.0 = Debug|Any CPU
+ {684FA75D-6712-41AE-A396-B6E0918899C0}.Docker|x64.ActiveCfg = Debug|Any CPU
+ {684FA75D-6712-41AE-A396-B6E0918899C0}.Docker|x64.Build.0 = Debug|Any CPU
+ {684FA75D-6712-41AE-A396-B6E0918899C0}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {684FA75D-6712-41AE-A396-B6E0918899C0}.Release|Any CPU.Build.0 = Release|Any CPU
+ {684FA75D-6712-41AE-A396-B6E0918899C0}.Release|x86.ActiveCfg = Release|Any CPU
+ {684FA75D-6712-41AE-A396-B6E0918899C0}.Release|x86.Build.0 = Release|Any CPU
+ {684FA75D-6712-41AE-A396-B6E0918899C0}.Release|x64.ActiveCfg = Release|Any CPU
+ {684FA75D-6712-41AE-A396-B6E0918899C0}.Release|x64.Build.0 = Release|Any CPU
+ {684FA75D-6712-41AE-A396-B6E0918899C0}.Staging|Any CPU.ActiveCfg = Debug|Any CPU
+ {684FA75D-6712-41AE-A396-B6E0918899C0}.Staging|Any CPU.Build.0 = Debug|Any CPU
+ {684FA75D-6712-41AE-A396-B6E0918899C0}.Staging|x86.ActiveCfg = Debug|Any CPU
+ {684FA75D-6712-41AE-A396-B6E0918899C0}.Staging|x86.Build.0 = Debug|Any CPU
+ {684FA75D-6712-41AE-A396-B6E0918899C0}.Staging|x64.ActiveCfg = Debug|Any CPU
+ {684FA75D-6712-41AE-A396-B6E0918899C0}.Staging|x64.Build.0 = Debug|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -1384,6 +1422,7 @@ Global
{89331D76-C527-479D-8F30-8033A04C625F} = {DBB9862A-C008-4C3F-A9DB-320429E4A07F}
{744B3BB7-B5F6-4002-93E2-FC0821D41963} = {89331D76-C527-479D-8F30-8033A04C625F}
{FA7DB8BC-315A-42D2-8BE7-859A9EBF19CA} = {F06D475C-635C-4DE4-82BA-C49A90BA8FCD}
+ {684FA75D-6712-41AE-A396-B6E0918899C0} = {53B024F9-E293-42F1-BA67-7F68C3F3C243}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {156116FF-243E-45E8-8717-DB72E95F56AF}
diff --git a/Tests/Resgrid.Tests/Resgrid.Tests.csproj b/Tests/Resgrid.Tests/Resgrid.Tests.csproj
index 9788e569..bfcbbe6f 100644
--- a/Tests/Resgrid.Tests/Resgrid.Tests.csproj
+++ b/Tests/Resgrid.Tests/Resgrid.Tests.csproj
@@ -56,6 +56,8 @@
+
+
diff --git a/Tests/Resgrid.Tests/Services/DepartmentSettingsServiceTtsLanguageTests.cs b/Tests/Resgrid.Tests/Services/DepartmentSettingsServiceTtsLanguageTests.cs
new file mode 100644
index 00000000..6c283d1b
--- /dev/null
+++ b/Tests/Resgrid.Tests/Services/DepartmentSettingsServiceTtsLanguageTests.cs
@@ -0,0 +1,101 @@
+using System.Threading.Tasks;
+using FluentAssertions;
+using Moq;
+using NUnit.Framework;
+using Resgrid.Config;
+using Resgrid.Model;
+using Resgrid.Model.Providers;
+using Resgrid.Model.Repositories;
+using Resgrid.Model.Services;
+using Resgrid.Services;
+
+namespace Resgrid.Tests.Services
+{
+ [TestFixture]
+ public class DepartmentSettingsServiceTtsLanguageTests
+ {
+ private Mock _departmentSettingsRepository;
+ private Mock _addressService;
+ private Mock _geoLocationProvider;
+ private Mock _cacheProvider;
+ private DepartmentSettingsService _service;
+ private bool _originalCacheEnabled;
+ private string _originalDefaultVoice;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _originalCacheEnabled = global::Resgrid.Config.SystemBehaviorConfig.CacheEnabled;
+ _originalDefaultVoice = TtsConfig.DefaultVoice;
+
+ _departmentSettingsRepository = new Mock();
+ _addressService = new Mock();
+ _geoLocationProvider = new Mock();
+ _cacheProvider = new Mock();
+
+ _service = new DepartmentSettingsService(
+ _departmentSettingsRepository.Object,
+ _addressService.Object,
+ _geoLocationProvider.Object,
+ _cacheProvider.Object);
+
+ global::Resgrid.Config.SystemBehaviorConfig.CacheEnabled = false;
+ TtsConfig.DefaultVoice = "en-us";
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ global::Resgrid.Config.SystemBehaviorConfig.CacheEnabled = _originalCacheEnabled;
+ TtsConfig.DefaultVoice = _originalDefaultVoice;
+ }
+
+ [Test]
+ public async Task should_return_department_tts_language_override_when_supported()
+ {
+ _departmentSettingsRepository
+ .Setup(x => x.GetDepartmentSettingByIdTypeAsync(7, DepartmentSettingTypes.TtsLanguage))
+ .ReturnsAsync(new DepartmentSetting
+ {
+ DepartmentId = 7,
+ Setting = "es-419",
+ SettingType = (int)DepartmentSettingTypes.TtsLanguage
+ });
+
+ var result = await _service.GetTtsLanguageForDepartmentAsync(7);
+
+ result.Should().Be("es-419");
+ }
+
+ [Test]
+ public async Task should_fall_back_to_default_tts_language_when_setting_missing()
+ {
+ _departmentSettingsRepository
+ .Setup(x => x.GetDepartmentSettingByIdTypeAsync(7, DepartmentSettingTypes.TtsLanguage))
+ .ReturnsAsync((DepartmentSetting)null);
+
+ var result = await _service.GetTtsLanguageForDepartmentAsync(7);
+
+ result.Should().Be("en-us");
+ }
+
+ [Test]
+ public async Task should_fall_back_to_default_tts_language_when_setting_is_invalid()
+ {
+ TtsConfig.DefaultVoice = "fr";
+
+ _departmentSettingsRepository
+ .Setup(x => x.GetDepartmentSettingByIdTypeAsync(7, DepartmentSettingTypes.TtsLanguage))
+ .ReturnsAsync(new DepartmentSetting
+ {
+ DepartmentId = 7,
+ Setting = "not-a-real-voice",
+ SettingType = (int)DepartmentSettingTypes.TtsLanguage
+ });
+
+ var result = await _service.GetTtsLanguageForDepartmentAsync(7);
+
+ result.Should().Be("fr");
+ }
+ }
+}
diff --git a/Tests/Resgrid.Tests/Web/Services/CommunicationTestResponseControllerTests.cs b/Tests/Resgrid.Tests/Web/Services/CommunicationTestResponseControllerTests.cs
new file mode 100644
index 00000000..073b7f93
--- /dev/null
+++ b/Tests/Resgrid.Tests/Web/Services/CommunicationTestResponseControllerTests.cs
@@ -0,0 +1,82 @@
+using System;
+using System.Reflection;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Moq;
+using NUnit.Framework;
+using Resgrid.Model;
+using Resgrid.Model.Services;
+using Resgrid.Web.Services.Controllers.v4;
+using Resgrid.Web.Services.Twilio;
+using Twilio.TwiML;
+using Twilio.TwiML.Voice;
+
+namespace Resgrid.Tests.Web.Services
+{
+ [TestFixture]
+ public class CommunicationTestResponseControllerTests
+ {
+ private Mock _communicationTestServiceMock;
+ private Mock _departmentSettingsServiceMock;
+ private Mock _twilioVoiceResponseServiceMock;
+ private CommunicationTestResponseController _controller;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _communicationTestServiceMock = new Mock(MockBehavior.Strict);
+ _departmentSettingsServiceMock = new Mock(MockBehavior.Strict);
+ _twilioVoiceResponseServiceMock = new Mock(MockBehavior.Strict);
+ _twilioVoiceResponseServiceMock
+ .Setup(x => x.AppendPromptAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns((response, text, _, __) =>
+ {
+ response.Append(new Play
+ {
+ Url = new Uri($"https://tts.example/{Uri.EscapeDataString(text)}.wav")
+ });
+ return System.Threading.Tasks.Task.CompletedTask;
+ });
+
+ _controller = new CommunicationTestResponseController(
+ _communicationTestServiceMock.Object,
+ _departmentSettingsServiceMock.Object,
+ _twilioVoiceResponseServiceMock.Object)
+ {
+ ControllerContext = new ControllerContext
+ {
+ HttpContext = new DefaultHttpContext()
+ }
+ };
+ }
+
+ [Test]
+ public async System.Threading.Tasks.Task voice_webhook_should_skip_department_lookup_when_token_missing()
+ {
+ var result = await _controller.VoiceWebhook(null, "1");
+
+ var content = result.Content;
+ content.Should().Contain("");
+ _twilioVoiceResponseServiceMock.Verify(x => x.AppendPromptAsync(It.IsAny(), TwilioVoicePromptCatalog.CommunicationTestRecorded, It.IsAny(), null), Times.Once);
+ _communicationTestServiceMock.Verify(x => x.GetDepartmentIdByResponseTokenAsync(It.IsAny()), Times.Never);
+ _communicationTestServiceMock.Verify(x => x.RecordVoiceResponseAsync(It.IsAny()), Times.Never);
+ _departmentSettingsServiceMock.Verify(x => x.GetTtsLanguageForDepartmentAsync(It.IsAny()), Times.Never);
+ }
+
+ [Test]
+ public async System.Threading.Tasks.Task department_tts_language_helper_should_return_null_without_lookup_for_blank_token()
+ {
+ var method = typeof(CommunicationTestResponseController).GetMethod("GetDepartmentTtsLanguageAsync", BindingFlags.Instance | BindingFlags.NonPublic);
+
+ var task = (System.Threading.Tasks.Task)method!.Invoke(_controller, new object[] { " " });
+ var result = await task;
+
+ result.Should().BeNull();
+ _communicationTestServiceMock.Verify(x => x.GetDepartmentIdByResponseTokenAsync(It.IsAny()), Times.Never);
+ _departmentSettingsServiceMock.Verify(x => x.GetTtsLanguageForDepartmentAsync(It.IsAny()), Times.Never);
+ }
+ }
+}
diff --git a/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs b/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs
index 60415c57..78978c51 100644
--- a/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs
+++ b/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
@@ -11,6 +12,9 @@
using Resgrid.Model.Providers;
using Resgrid.Model.Services;
using Resgrid.Web.Services.Controllers;
+using Resgrid.Web.Services.Twilio;
+using Twilio.TwiML;
+using Twilio.TwiML.Voice;
namespace Resgrid.Tests.Web.Services
{
@@ -36,6 +40,7 @@ public class TwilioControllerVoiceVerificationTests : TestBase
private Mock _calendarServiceMock;
private Mock _communicationTestServiceMock;
private Mock _encryptionServiceMock;
+ private Mock _twilioVoiceResponseServiceMock;
protected override void Before_all_tests()
{
@@ -58,6 +63,17 @@ protected override void Before_all_tests()
_calendarServiceMock = new Mock();
_communicationTestServiceMock = new Mock();
_encryptionServiceMock = new Mock();
+ _twilioVoiceResponseServiceMock = new Mock();
+ _twilioVoiceResponseServiceMock
+ .Setup(x => x.AppendPromptAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns((response, text, _, __) =>
+ {
+ response.Append(new Play
+ {
+ Url = new Uri($"https://tts.example/{Uri.EscapeDataString(text)}.wav")
+ });
+ return System.Threading.Tasks.Task.CompletedTask;
+ });
}
private TwilioController BuildController()
@@ -81,11 +97,12 @@ private TwilioController BuildController()
_usersServiceMock.Object,
_calendarServiceMock.Object,
_communicationTestServiceMock.Object,
- _encryptionServiceMock.Object);
+ _encryptionServiceMock.Object,
+ _twilioVoiceResponseServiceMock.Object);
}
[Test]
- public async Task should_mark_home_code_consumed_after_successful_voice_generation()
+ public async System.Threading.Tasks.Task should_mark_home_code_consumed_after_successful_voice_generation()
{
var profile = new UserProfile
{
@@ -99,6 +116,7 @@ public async Task should_mark_home_code_consumed_after_successful_voice_generati
_userProfileServiceMock.Setup(x => x.GetProfileByUserIdAsync("user1", true)).ReturnsAsync(profile);
_encryptionServiceMock.Setup(x => x.Decrypt("ENC:123456")).Returns("123456");
_departmentsServiceMock.Setup(x => x.GetDepartmentByUserIdAsync("user1", false)).ReturnsAsync(department);
+ _departmentSettingsServiceMock.Setup(x => x.GetTtsLanguageForDepartmentAsync(7)).ReturnsAsync("es");
_userProfileServiceMock
.Setup(x => x.SaveProfileAsync(7, It.IsAny(), It.IsAny()))
.Callback((_, p, _) => savedProfile = p)
@@ -107,14 +125,18 @@ public async Task should_mark_home_code_consumed_after_successful_voice_generati
var result = await BuildController().VoiceVerification("user1", (int)ContactVerificationType.HomeNumber);
var content = ((ContentResult)result).Content;
- content.Should().Contain("Your verification code is: 1, 2, 3, 4, 5, 6.");
+ content.Should().Contain("");
+ content.Should().Contain(Uri.EscapeDataString("Your verification code is: 1, 2, 3, 4, 5, 6."));
savedProfile.Should().NotBeNull();
savedProfile!.HomeVerificationVoiceCodeConsumed.Should().BeTrue();
savedProfile.HomeVerificationCode.Should().Be("ENC:123456");
+ _twilioVoiceResponseServiceMock.Verify(x => x.AppendPromptAsync(It.IsAny(), "Hello, this is Resgrid calling with your verification code.", It.IsAny(), "es"), Times.Once);
+ _twilioVoiceResponseServiceMock.Verify(x => x.AppendPromptAsync(It.IsAny(), "Your verification code is: 1, 2, 3, 4, 5, 6.", It.IsAny(), "es"), Times.Exactly(3));
+ _twilioVoiceResponseServiceMock.Verify(x => x.AppendPromptAsync(It.IsAny(), "That was your Resgrid verification code. Goodbye.", It.IsAny(), "es"), Times.Once);
}
[Test]
- public async Task should_return_generic_message_when_home_code_already_consumed()
+ public async System.Threading.Tasks.Task should_return_generic_message_when_home_code_already_consumed()
{
var profile = new UserProfile
{
@@ -129,13 +151,15 @@ public async Task should_return_generic_message_when_home_code_already_consumed(
var result = await BuildController().VoiceVerification("user1", (int)ContactVerificationType.HomeNumber);
var content = ((ContentResult)result).Content;
- content.Should().Contain("We couldn't complete your verification call.");
+ content.Should().Contain("");
+ content.Should().Contain(Uri.EscapeDataString("We couldn't complete your verification call. Please request a new code and try again. Goodbye."));
content.Should().NotContain("123456");
_userProfileServiceMock.Verify(x => x.SaveProfileAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
+ _twilioVoiceResponseServiceMock.Verify(x => x.AppendPromptAsync(It.IsAny(), "We couldn't complete your verification call. Please request a new code and try again. Goodbye.", It.IsAny(), It.IsAny()), Times.Once);
}
[Test]
- public async Task should_return_generic_message_when_decryption_fails()
+ public async System.Threading.Tasks.Task should_return_generic_message_when_decryption_fails()
{
var profile = new UserProfile
{
@@ -150,8 +174,59 @@ public async Task should_return_generic_message_when_decryption_fails()
var result = await BuildController().VoiceVerification("user1", (int)ContactVerificationType.HomeNumber);
var content = ((ContentResult)result).Content;
- content.Should().Contain("We couldn't complete your verification call.");
+ content.Should().Contain("");
+ content.Should().Contain(Uri.EscapeDataString("We couldn't complete your verification call. Please request a new code and try again. Goodbye."));
content.Should().NotContain("broken");
+ _twilioVoiceResponseServiceMock.Verify(x => x.AppendPromptAsync(It.IsAny(), "We couldn't complete your verification call. Please request a new code and try again. Goodbye.", It.IsAny(), It.IsAny()), Times.Once);
+ }
+
+ [Test]
+ public async System.Threading.Tasks.Task should_redirect_invalid_status_selection_back_to_user_scoped_menu()
+ {
+ var department = new Department { DepartmentId = 7, Name = "Dept 1" };
+ var profile = new UserProfile { UserId = "user1", FirstName = "Pat" };
+
+ _departmentsServiceMock.Setup(x => x.GetDepartmentByUserIdAsync("user1", false)).ReturnsAsync(department);
+ _userProfileServiceMock.Setup(x => x.GetProfileByUserIdAsync("user1", false)).ReturnsAsync(profile);
+ _customStateServiceMock.Setup(x => x.GetCustomPersonnelStatusesOrDefaultsAsync(7)).ReturnsAsync(new List
+ {
+ new CustomStateDetail { CustomStateDetailId = 1, ButtonText = "Available" }
+ });
+
+ var controller = BuildController();
+ var action = typeof(TwilioController).GetMethod(nameof(TwilioController.InboundVoiceActionStatus));
+ dynamic request = Activator.CreateInstance(action!.GetParameters()[1].ParameterType);
+ request.Digits = "9";
+ var result = await (Task)action.Invoke(controller, new object[] { "user1", request });
+
+ var content = ((ContentResult)result).Content;
+ content.Should().Contain("https://resgridapi.local/api/Twilio/InboundVoiceAction?userId=user1");
+ content.Should().NotContain("https://resgridapi.local/api/Twilio/InboundVoice");
+ _twilioVoiceResponseServiceMock.Verify(x => x.AppendPromptAsync(It.IsAny(), TwilioVoicePromptCatalog.InvalidStatusSelection, It.IsAny(), It.IsAny()), Times.Once);
+ }
+
+ [Test]
+ public async System.Threading.Tasks.Task should_redirect_missing_status_selection_back_to_user_scoped_menu()
+ {
+ var department = new Department { DepartmentId = 7, Name = "Dept 1" };
+ var profile = new UserProfile { UserId = "user1", FirstName = "Pat" };
+
+ _departmentsServiceMock.Setup(x => x.GetDepartmentByUserIdAsync("user1", false)).ReturnsAsync(department);
+ _userProfileServiceMock.Setup(x => x.GetProfileByUserIdAsync("user1", false)).ReturnsAsync(profile);
+ _customStateServiceMock.Setup(x => x.GetCustomPersonnelStatusesOrDefaultsAsync(7)).ReturnsAsync(new List
+ {
+ new CustomStateDetail { CustomStateDetailId = 1, ButtonText = "Available" }
+ });
+
+ var controller = BuildController();
+ var action = typeof(TwilioController).GetMethod(nameof(TwilioController.InboundVoiceActionStatus));
+ var request = Activator.CreateInstance(action!.GetParameters()[1].ParameterType);
+ var result = await (Task)action.Invoke(controller, new object[] { "user1", request });
+
+ var content = ((ContentResult)result).Content;
+ content.Should().Contain("https://resgridapi.local/api/Twilio/InboundVoiceAction?userId=user1");
+ content.Should().NotContain("https://resgridapi.local/api/Twilio/InboundVoice");
+ _twilioVoiceResponseServiceMock.Verify(x => x.AppendPromptAsync(It.IsAny(), TwilioVoicePromptCatalog.NoStatusSelection, It.IsAny(), It.IsAny()), Times.Once);
}
}
}
diff --git a/Tests/Resgrid.Tests/Web/Services/TwilioVoiceResponseServiceTests.cs b/Tests/Resgrid.Tests/Web/Services/TwilioVoiceResponseServiceTests.cs
new file mode 100644
index 00000000..505719a1
--- /dev/null
+++ b/Tests/Resgrid.Tests/Web/Services/TwilioVoiceResponseServiceTests.cs
@@ -0,0 +1,136 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Moq;
+using NUnit.Framework;
+using Resgrid.Model.Services;
+using Resgrid.Web.Services.Twilio;
+using Twilio.TwiML;
+
+namespace Resgrid.Tests.Web.Services
+{
+ [TestFixture]
+ public class TwilioVoiceResponseServiceTests
+ {
+ [Test]
+ public async Task append_prompt_async_should_reuse_generated_tts_url_within_request_scope()
+ {
+ var promptUrl = new Uri("https://tts.example.com/tts/audio/abc.wav");
+ var ttsAudioService = new Mock(MockBehavior.Strict);
+ ttsAudioService
+ .Setup(x => x.GenerateSpeechUrlAsync("Hello from Resgrid", null, null, It.IsAny()))
+ .ReturnsAsync(promptUrl);
+
+ var service = new TwilioVoiceResponseService(ttsAudioService.Object);
+ var response = new VoiceResponse();
+
+ await service.AppendPromptAsync(response, "Hello from Resgrid", CancellationToken.None);
+ await service.AppendPromptAsync(response, "Hello from Resgrid", CancellationToken.None);
+
+ var xml = response.ToString();
+
+ xml.Should().Contain(promptUrl.ToString());
+ xml.Split("").Length.Should().Be(3);
+ ttsAudioService.Verify(
+ x => x.GenerateSpeechUrlAsync("Hello from Resgrid", null, null, It.IsAny()),
+ Times.Once);
+ }
+
+ [Test]
+ public async Task append_prompt_async_should_scope_cache_entries_by_voice()
+ {
+ var spanishPromptUrl = new Uri("https://tts.example.com/tts/audio/es.wav");
+ var frenchPromptUrl = new Uri("https://tts.example.com/tts/audio/fr.wav");
+ var ttsAudioService = new Mock(MockBehavior.Strict);
+ ttsAudioService
+ .Setup(x => x.GenerateSpeechUrlAsync("Hello from Resgrid", "es", null, It.IsAny()))
+ .ReturnsAsync(spanishPromptUrl);
+ ttsAudioService
+ .Setup(x => x.GenerateSpeechUrlAsync("Hello from Resgrid", "fr", null, It.IsAny()))
+ .ReturnsAsync(frenchPromptUrl);
+
+ var service = new TwilioVoiceResponseService(ttsAudioService.Object);
+ var response = new VoiceResponse();
+
+ await service.AppendPromptAsync(response, "Hello from Resgrid", CancellationToken.None, "es");
+ await service.AppendPromptAsync(response, "Hello from Resgrid", CancellationToken.None, "es");
+ await service.AppendPromptAsync(response, "Hello from Resgrid", CancellationToken.None, "fr");
+
+ var xml = response.ToString();
+
+ xml.Should().Contain(spanishPromptUrl.ToString());
+ xml.Should().Contain(frenchPromptUrl.ToString());
+ xml.Split("").Length.Should().Be(4);
+ ttsAudioService.Verify(
+ x => x.GenerateSpeechUrlAsync("Hello from Resgrid", "es", null, It.IsAny()),
+ Times.Once);
+ ttsAudioService.Verify(
+ x => x.GenerateSpeechUrlAsync("Hello from Resgrid", "fr", null, It.IsAny()),
+ Times.Once);
+ }
+
+ [Test]
+ public async Task append_prompt_async_should_generate_cached_prompt_without_request_cancellation_token()
+ {
+ var promptUrl = new Uri("https://tts.example.com/tts/audio/abc.wav");
+ var requestCancellation = new CancellationTokenSource();
+ var capturedToken = CancellationToken.None;
+ var ttsAudioService = new Mock(MockBehavior.Strict);
+ ttsAudioService
+ .Setup(x => x.GenerateSpeechUrlAsync("Hello from Resgrid", null, null, It.IsAny()))
+ .Callback((_, _, _, token) => capturedToken = token)
+ .ReturnsAsync(promptUrl);
+
+ var service = new TwilioVoiceResponseService(ttsAudioService.Object);
+ var response = new VoiceResponse();
+
+ await service.AppendPromptAsync(response, "Hello from Resgrid", requestCancellation.Token);
+
+ capturedToken.CanBeCanceled.Should().BeFalse();
+ ttsAudioService.Verify(
+ x => x.GenerateSpeechUrlAsync("Hello from Resgrid", null, null, It.IsAny()),
+ Times.Once);
+ }
+
+ [Test]
+ public async Task append_prompt_async_should_not_evict_cached_generation_when_a_waiting_request_is_cancelled()
+ {
+ var promptUrl = new Uri("https://tts.example.com/tts/audio/abc.wav");
+ var generationStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var promptGeneration = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var ttsAudioService = new Mock(MockBehavior.Strict);
+ ttsAudioService
+ .Setup(x => x.GenerateSpeechUrlAsync("Hello from Resgrid", null, null, It.IsAny()))
+ .Returns((_, _, _, _) =>
+ {
+ generationStarted.TrySetResult(true);
+ return promptGeneration.Task;
+ });
+
+ var service = new TwilioVoiceResponseService(ttsAudioService.Object);
+ using var requestCancellation = new CancellationTokenSource();
+ var cancelledResponse = new VoiceResponse();
+ var cancelledAppendTask = service.AppendPromptAsync(cancelledResponse, "Hello from Resgrid", requestCancellation.Token);
+
+ await generationStarted.Task;
+ requestCancellation.Cancel();
+
+ await FluentActions
+ .Awaiting(() => cancelledAppendTask)
+ .Should()
+ .ThrowAsync();
+
+ var response = new VoiceResponse();
+ var secondAppendTask = service.AppendPromptAsync(response, "Hello from Resgrid", CancellationToken.None);
+
+ promptGeneration.SetResult(promptUrl);
+ await secondAppendTask;
+
+ response.ToString().Should().Contain(promptUrl.ToString());
+ ttsAudioService.Verify(
+ x => x.GenerateSpeechUrlAsync("Hello from Resgrid", null, null, It.IsAny()),
+ Times.Once);
+ }
+ }
+}
diff --git a/Tests/Resgrid.Tests/Web/Tts/CacheServiceTests.cs b/Tests/Resgrid.Tests/Web/Tts/CacheServiceTests.cs
new file mode 100644
index 00000000..e8f828c0
--- /dev/null
+++ b/Tests/Resgrid.Tests/Web/Tts/CacheServiceTests.cs
@@ -0,0 +1,141 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Microsoft.Extensions.Caching.Distributed;
+using Microsoft.Extensions.Options;
+using Moq;
+using NUnit.Framework;
+using Resgrid.Web.Tts.Configuration;
+using Resgrid.Web.Tts.Services;
+
+namespace Resgrid.Tests.Web.Tts
+{
+ [TestFixture]
+ public class CacheServiceTests
+ {
+ private Mock _storageService;
+ private Mock _distributedCache;
+ private Dictionary _cacheEntries;
+ private CacheService _service;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _storageService = new Mock();
+ _distributedCache = new Mock(MockBehavior.Strict);
+ _cacheEntries = new Dictionary(StringComparer.Ordinal);
+ _distributedCache
+ .Setup(x => x.GetAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync((string key, CancellationToken _) => _cacheEntries.TryGetValue(key, out var value) ? value : null);
+ _distributedCache
+ .Setup(x => x.SetAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .Callback((key, value, _, _) => _cacheEntries[key] = value.ToArray())
+ .Returns(Task.CompletedTask);
+ _distributedCache
+ .Setup(x => x.RemoveAsync(It.IsAny(), It.IsAny()))
+ .Callback((key, _) => _cacheEntries.Remove(key))
+ .Returns(Task.CompletedTask);
+ _service = new CacheService(
+ _storageService.Object,
+ _distributedCache.Object,
+ Options.Create(new TtsOptions
+ {
+ CachePrefix = "tts",
+ PlaybackMemoryCacheMinutes = 60
+ }));
+ }
+
+ [Test]
+ public void create_cache_key_should_be_deterministic_for_same_inputs()
+ {
+ var first = _service.CreateCacheKey("Press 1 for yes", "en-us", 175);
+ var second = _service.CreateCacheKey("Press 1 for yes", "en-us", 175);
+
+ first.Should().Be(second);
+ first.ObjectKey.Should().Be($"tts/{first.Hash}.wav");
+ }
+
+ [Test]
+ public void create_cache_key_should_change_when_inputs_change()
+ {
+ var baseline = _service.CreateCacheKey("Press 1 for yes", "en-us", 175);
+ var differentText = _service.CreateCacheKey("Press 2 for no", "en-us", 175);
+ var differentVoice = _service.CreateCacheKey("Press 1 for yes", "en-gb", 175);
+ var differentSpeed = _service.CreateCacheKey("Press 1 for yes", "en-us", 200);
+
+ differentText.Hash.Should().NotBe(baseline.Hash);
+ differentVoice.Hash.Should().NotBe(baseline.Hash);
+ differentSpeed.Hash.Should().NotBe(baseline.Hash);
+ }
+
+ [Test]
+ public async Task store_async_should_seed_redis_cache_for_immediate_playback()
+ {
+ var cacheKey = _service.CreateCacheKey("Press 1 for yes", "en-us", 175);
+ var audioBytes = new byte[] { 1, 2, 3, 4 };
+
+ _storageService
+ .Setup(x => x.UploadAsync(cacheKey.ObjectKey, It.IsAny(), "audio/wav", It.IsAny()))
+ .Returns(Task.CompletedTask);
+ _storageService
+ .Setup(x => x.GetObjectUrlAsync(cacheKey.ObjectKey, It.IsAny()))
+ .ReturnsAsync(new Uri("https://tts.example.com/tts/audio/abc.wav"));
+
+ await _service.StoreAsync(cacheKey, audioBytes, CancellationToken.None);
+
+ var audio = await _service.TryGetAudioAsync(cacheKey.Hash, CancellationToken.None);
+
+ audio.Should().NotBeNull();
+ audio!.AudioBytes.Should().Equal(audioBytes);
+ audio.ContentType.Should().Be("audio/wav");
+ _storageService.Verify(x => x.GetObjectAsync(It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [Test]
+ public async Task try_get_audio_async_should_cache_storage_result_in_redis()
+ {
+ var cacheKey = _service.CreateCacheKey("Press 1 for yes", "en-us", 175);
+ var storageAudio = new TtsAudioContent(
+ new byte[] { 9, 8, 7 },
+ "audio/wav",
+ "\"etag\"",
+ DateTimeOffset.UtcNow);
+
+ _storageService
+ .Setup(x => x.GetObjectAsync(cacheKey.ObjectKey, It.IsAny()))
+ .ReturnsAsync(storageAudio);
+
+ var first = await _service.TryGetAudioAsync(cacheKey.Hash, CancellationToken.None);
+ var second = await _service.TryGetAudioAsync(cacheKey.Hash, CancellationToken.None);
+
+ first.Should().BeEquivalentTo(storageAudio);
+ second.Should().BeEquivalentTo(storageAudio);
+ _storageService.Verify(x => x.GetObjectAsync(cacheKey.ObjectKey, It.IsAny()), Times.Once);
+ }
+
+ [Test]
+ public async Task try_get_cached_url_async_should_skip_s3_exists_when_audio_is_already_in_redis()
+ {
+ var cacheKey = _service.CreateCacheKey("Press 1 for yes", "en-us", 175);
+ var cachedUrl = new Uri("https://tts.example.com/tts/audio/cached.wav");
+
+ _storageService
+ .Setup(x => x.UploadAsync(cacheKey.ObjectKey, It.IsAny(), "audio/wav", It.IsAny()))
+ .Returns(Task.CompletedTask);
+ _storageService
+ .Setup(x => x.GetObjectUrlAsync(cacheKey.ObjectKey, It.IsAny()))
+ .ReturnsAsync(cachedUrl);
+
+ await _service.StoreAsync(cacheKey, new byte[] { 5, 4, 3, 2 }, CancellationToken.None);
+
+ var result = await _service.TryGetCachedUrlAsync(cacheKey, CancellationToken.None);
+
+ result.Should().Be(cachedUrl);
+ _storageService.Verify(x => x.ExistsAsync(It.IsAny(), It.IsAny()), Times.Never);
+ }
+ }
+}
diff --git a/Tests/Resgrid.Tests/Web/Tts/PromptWarmupHostedServiceTests.cs b/Tests/Resgrid.Tests/Web/Tts/PromptWarmupHostedServiceTests.cs
new file mode 100644
index 00000000..58c196b5
--- /dev/null
+++ b/Tests/Resgrid.Tests/Web/Tts/PromptWarmupHostedServiceTests.cs
@@ -0,0 +1,83 @@
+using System;
+using System.Collections.Generic;
+using System.Reflection;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Moq;
+using NUnit.Framework;
+using Resgrid.Web.Tts.Configuration;
+using Resgrid.Web.Tts.Services;
+
+namespace Resgrid.Tests.Web.Tts
+{
+ [TestFixture]
+ public class PromptWarmupHostedServiceTests
+ {
+ [Test]
+ public async Task execute_async_should_log_and_swallow_unexpected_warmup_failures()
+ {
+ var failure = new InvalidOperationException("warmup failed");
+ var ttsService = new Mock(MockBehavior.Strict);
+ ttsService
+ .Setup(x => x.WarmPromptsAsync(It.IsAny()))
+ .ThrowsAsync(failure);
+ var logger = new RecordingLogger();
+ var service = new PromptWarmupHostedService(
+ ttsService.Object,
+ Options.Create(new TtsOptions
+ {
+ WarmupEnabled = true,
+ PreGeneratedPrompts = new List { "Prompt 1" }
+ }),
+ logger);
+
+ await InvokeExecuteAsync(service, CancellationToken.None);
+
+ logger.Entries.Should().ContainSingle(x =>
+ x.Level == LogLevel.Error &&
+ x.Exception == failure &&
+ x.Message == "TTS prompt warmup failed but will not stop host.");
+ ttsService.Verify(x => x.WarmPromptsAsync(It.IsAny()), Times.Once);
+ }
+
+ private static Task InvokeExecuteAsync(PromptWarmupHostedService service, CancellationToken cancellationToken)
+ {
+ var method = typeof(PromptWarmupHostedService).GetMethod("ExecuteAsync", BindingFlags.Instance | BindingFlags.NonPublic);
+ return (Task)method!.Invoke(service, new object[] { cancellationToken })!;
+ }
+
+ private sealed class RecordingLogger : ILogger
+ {
+ public List Entries { get; } = new();
+
+ public IDisposable BeginScope(TState state)
+ {
+ return NullScope.Instance;
+ }
+
+ public bool IsEnabled(LogLevel logLevel)
+ {
+ return true;
+ }
+
+ public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter)
+ {
+ Entries.Add(new LogEntry(logLevel, exception, formatter(state, exception)));
+ }
+
+ public sealed record LogEntry(LogLevel Level, Exception Exception, string Message);
+
+ private sealed class NullScope : IDisposable
+ {
+ public static readonly NullScope Instance = new();
+
+ public void Dispose()
+ {
+ }
+ }
+ }
+ }
+}
diff --git a/Tests/Resgrid.Tests/Web/Tts/S3StorageServiceTests.cs b/Tests/Resgrid.Tests/Web/Tts/S3StorageServiceTests.cs
new file mode 100644
index 00000000..7b3f3472
--- /dev/null
+++ b/Tests/Resgrid.Tests/Web/Tts/S3StorageServiceTests.cs
@@ -0,0 +1,138 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Amazon.S3;
+using Amazon.S3.Model;
+using FluentAssertions;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Moq;
+using NUnit.Framework;
+using Resgrid.Web.Tts.Configuration;
+using Resgrid.Web.Tts.Services;
+
+namespace Resgrid.Tests.Web.Tts
+{
+ [TestFixture]
+ public class S3StorageServiceTests
+ {
+ [Test]
+ public async Task upload_async_should_buffer_non_seekable_stream_for_retries()
+ {
+ var uploadedPayloads = new List();
+ var attempt = 0;
+ var s3Client = new Mock(MockBehavior.Strict);
+ s3Client
+ .Setup(x => x.PutObjectAsync(It.IsAny(), It.IsAny()))
+ .Returns(async (request, _) =>
+ {
+ using var captureStream = new MemoryStream();
+ await request.InputStream.CopyToAsync(captureStream);
+ uploadedPayloads.Add(captureStream.ToArray());
+ attempt++;
+
+ if (attempt == 1)
+ {
+ throw new IOException("transient upload failure");
+ }
+
+ return new PutObjectResponse();
+ });
+
+ var service = new S3StorageService(
+ s3Client.Object,
+ Options.Create(new S3StorageOptions
+ {
+ Bucket = "tts-bucket",
+ AccessKey = "access-key",
+ SecretKey = "secret-key"
+ }),
+ Mock.Of>());
+
+ await using var content = new NonSeekableReadStream(new byte[] { 1, 2, 3, 4 });
+
+ await service.UploadAsync("tts/audio.wav", content, "audio/wav", CancellationToken.None);
+
+ uploadedPayloads.Should().HaveCount(2);
+ uploadedPayloads[0].Should().Equal(1, 2, 3, 4);
+ uploadedPayloads[1].Should().Equal(1, 2, 3, 4);
+ s3Client.Verify(x => x.PutObjectAsync(It.IsAny(), It.IsAny()), Times.Exactly(2));
+ }
+
+ private sealed class NonSeekableReadStream : Stream
+ {
+ private readonly MemoryStream _inner;
+
+ public NonSeekableReadStream(byte[] bytes)
+ {
+ _inner = new MemoryStream(bytes);
+ }
+
+ public override bool CanRead => true;
+
+ public override bool CanSeek => false;
+
+ public override bool CanWrite => false;
+
+ public override long Length => _inner.Length;
+
+ public override long Position
+ {
+ get => throw new NotSupportedException();
+ set => throw new NotSupportedException();
+ }
+
+ public override void Flush()
+ {
+ }
+
+ public override int Read(byte[] buffer, int offset, int count)
+ {
+ return _inner.Read(buffer, offset, count);
+ }
+
+ public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
+ {
+ return _inner.ReadAsync(buffer, offset, count, cancellationToken);
+ }
+
+ public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default)
+ {
+ return _inner.ReadAsync(buffer, cancellationToken);
+ }
+
+ public override long Seek(long offset, SeekOrigin origin)
+ {
+ throw new NotSupportedException();
+ }
+
+ public override void SetLength(long value)
+ {
+ throw new NotSupportedException();
+ }
+
+ public override void Write(byte[] buffer, int offset, int count)
+ {
+ throw new NotSupportedException();
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _inner.Dispose();
+ }
+
+ base.Dispose(disposing);
+ }
+
+ public override async ValueTask DisposeAsync()
+ {
+ await _inner.DisposeAsync();
+ await base.DisposeAsync();
+ }
+ }
+ }
+}
diff --git a/Tests/Resgrid.Tests/Web/Tts/TtsAdminControllerTests.cs b/Tests/Resgrid.Tests/Web/Tts/TtsAdminControllerTests.cs
new file mode 100644
index 00000000..dffdb6de
--- /dev/null
+++ b/Tests/Resgrid.Tests/Web/Tts/TtsAdminControllerTests.cs
@@ -0,0 +1,124 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Options;
+using Moq;
+using NUnit.Framework;
+using Resgrid.Web.Tts.Configuration;
+using Resgrid.Web.Tts.Controllers;
+using Resgrid.Web.Tts.Models;
+using Resgrid.Web.Tts.Services;
+
+namespace Resgrid.Tests.Web.Tts
+{
+ [TestFixture]
+ public class TtsAdminControllerTests
+ {
+ private Mock _ttsService;
+ private Mock _ttsPlaybackUrlService;
+ private TtsOptions _options;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _ttsService = new Mock(MockBehavior.Strict);
+ _ttsPlaybackUrlService = new Mock(MockBehavior.Strict);
+ _ttsPlaybackUrlService
+ .Setup(x => x.CreatePlaybackUrl(It.IsAny(), It.IsAny()))
+ .Returns((_, hash) => new Uri($"https://tts.example.com/tts/audio/{hash}.wav"));
+ _options = new TtsOptions
+ {
+ DefaultVoice = "en-us",
+ DefaultSpeed = 175,
+ StaticPromptAdminKey = "secret-key",
+ PreGeneratedPrompts = new List { "Alpha", "Beta" }
+ };
+ }
+
+ [Test]
+ public async Task regenerate_static_prompts_should_reject_invalid_admin_key()
+ {
+ var controller = BuildController();
+
+ var result = await controller.RegenerateStaticPromptsAsync("bad-key", new StaticPromptRegenerationRequest(), CancellationToken.None);
+
+ result.Result.Should().BeOfType();
+ _ttsService.Verify(x => x.GenerateBatchAsync(It.IsAny>(), It.IsAny()), Times.Never);
+ }
+
+ [Test]
+ public async Task regenerate_static_prompts_should_use_supplied_prompts_when_authorized()
+ {
+ var responses = new[]
+ {
+ new TtsResponse { Hash = "a", ObjectKey = "tts/a.wav", Url = "https://cdn.example.com/tts/a.wav", Voice = "en-us", Speed = 175 }
+ };
+ List capturedPrompts = null;
+ _ttsService
+ .Setup(x => x.GenerateBatchAsync(It.IsAny>(), It.IsAny()))
+ .Callback, CancellationToken>((requests, _) => capturedPrompts = requests.ToList())
+ .ReturnsAsync(responses);
+
+ var controller = BuildController();
+ var request = new StaticPromptRegenerationRequest
+ {
+ Prompts = new List
+ {
+ new TtsRequest { Text = "Prompt 1", Voice = "en-gb", Speed = 180 }
+ }
+ };
+
+ var result = await controller.RegenerateStaticPromptsAsync("secret-key", request, CancellationToken.None);
+
+ var okResult = result.Result.Should().BeOfType().Subject;
+ var response = okResult.Value.Should().BeOfType().Subject;
+ response.PromptCount.Should().Be(1);
+ response.Prompts.Should().BeEquivalentTo(responses, options => options.Excluding(x => x.Url));
+ response.Prompts.Single().Url.Should().Be("https://tts.example.com/tts/audio/a.wav");
+ capturedPrompts.Should().ContainSingle();
+ capturedPrompts![0].Text.Should().Be("Prompt 1");
+ capturedPrompts[0].Voice.Should().Be("en-gb");
+ capturedPrompts[0].Speed.Should().Be(180);
+ }
+
+ [Test]
+ public async Task regenerate_static_prompts_should_fall_back_to_configured_prompts_when_request_is_empty()
+ {
+ List capturedPrompts = null;
+ _ttsService
+ .Setup(x => x.GenerateBatchAsync(It.IsAny>(), It.IsAny()))
+ .Callback, CancellationToken>((requests, _) => capturedPrompts = requests.ToList())
+ .ReturnsAsync(new[]
+ {
+ new TtsResponse { Hash = "a", ObjectKey = "tts/a.wav", Url = "https://cdn.example.com/tts/a.wav", Voice = "en-us", Speed = 175 },
+ new TtsResponse { Hash = "b", ObjectKey = "tts/b.wav", Url = "https://cdn.example.com/tts/b.wav", Voice = "en-us", Speed = 175 }
+ });
+
+ var controller = BuildController();
+
+ var result = await controller.RegenerateStaticPromptsAsync("secret-key", new StaticPromptRegenerationRequest(), CancellationToken.None);
+
+ result.Result.Should().BeOfType();
+ capturedPrompts.Should().HaveCount(2);
+ capturedPrompts!.Select(x => x.Text).Should().Equal("Alpha", "Beta");
+ capturedPrompts.Select(x => x.Voice).Should().OnlyContain(x => x == "en-us");
+ capturedPrompts.Select(x => x.Speed).Should().OnlyContain(x => x == 175);
+ }
+
+ private TtsAdminController BuildController()
+ {
+ return new TtsAdminController(_ttsService.Object, _ttsPlaybackUrlService.Object, Options.Create(_options))
+ {
+ ControllerContext = new ControllerContext
+ {
+ HttpContext = new DefaultHttpContext()
+ }
+ };
+ }
+ }
+}
diff --git a/Tests/Resgrid.Tests/Web/Tts/TtsConfigurationRegistrationTests.cs b/Tests/Resgrid.Tests/Web/Tts/TtsConfigurationRegistrationTests.cs
new file mode 100644
index 00000000..b60395ef
--- /dev/null
+++ b/Tests/Resgrid.Tests/Web/Tts/TtsConfigurationRegistrationTests.cs
@@ -0,0 +1,121 @@
+using FluentAssertions;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using NUnit.Framework;
+using Resgrid.Config;
+using Resgrid.Web.Tts.Configuration;
+
+namespace Resgrid.Tests.Web.Tts
+{
+ [TestFixture]
+ public class TtsConfigurationRegistrationTests
+ {
+ private string _originalS3AccessKey;
+ private string _originalS3SecretKey;
+ private string _originalS3Bucket;
+ private string _originalS3Endpoint;
+ private string _originalDefaultVoice;
+ private int _originalDefaultSpeed;
+ private string _originalPreGeneratedPrompts;
+ private string _originalPlaybackBaseUrl;
+ private int _originalPlaybackMemoryCacheMinutes;
+ private int _originalPlaybackCacheControlSeconds;
+ private int _originalRateLimitPermitLimit;
+ private string _originalTempDirectory;
+ private string _originalStaticPromptAdminKey;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _originalS3AccessKey = TtsConfig.S3AccessKey;
+ _originalS3SecretKey = TtsConfig.S3SecretKey;
+ _originalS3Bucket = TtsConfig.S3Bucket;
+ _originalS3Endpoint = TtsConfig.S3Endpoint;
+ _originalDefaultVoice = TtsConfig.DefaultVoice;
+ _originalDefaultSpeed = TtsConfig.DefaultSpeed;
+ _originalPreGeneratedPrompts = TtsConfig.PreGeneratedPrompts;
+ _originalPlaybackBaseUrl = TtsConfig.PlaybackBaseUrl;
+ _originalPlaybackMemoryCacheMinutes = TtsConfig.PlaybackMemoryCacheMinutes;
+ _originalPlaybackCacheControlSeconds = TtsConfig.PlaybackCacheControlSeconds;
+ _originalRateLimitPermitLimit = TtsConfig.RateLimitPermitLimit;
+ _originalTempDirectory = TtsConfig.TempDirectory;
+ _originalStaticPromptAdminKey = TtsConfig.StaticPromptAdminKey;
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ TtsConfig.S3AccessKey = _originalS3AccessKey;
+ TtsConfig.S3SecretKey = _originalS3SecretKey;
+ TtsConfig.S3Bucket = _originalS3Bucket;
+ TtsConfig.S3Endpoint = _originalS3Endpoint;
+ TtsConfig.DefaultVoice = _originalDefaultVoice;
+ TtsConfig.DefaultSpeed = _originalDefaultSpeed;
+ TtsConfig.PreGeneratedPrompts = _originalPreGeneratedPrompts;
+ TtsConfig.PlaybackBaseUrl = _originalPlaybackBaseUrl;
+ TtsConfig.PlaybackMemoryCacheMinutes = _originalPlaybackMemoryCacheMinutes;
+ TtsConfig.PlaybackCacheControlSeconds = _originalPlaybackCacheControlSeconds;
+ TtsConfig.RateLimitPermitLimit = _originalRateLimitPermitLimit;
+ TtsConfig.TempDirectory = _originalTempDirectory;
+ TtsConfig.StaticPromptAdminKey = _originalStaticPromptAdminKey;
+ }
+
+ [Test]
+ public void add_tts_configuration_should_map_resgrid_config_values_into_options()
+ {
+ TtsConfig.S3AccessKey = "access-key";
+ TtsConfig.S3SecretKey = "secret-key";
+ TtsConfig.S3Bucket = "tts-bucket";
+ TtsConfig.S3Endpoint = "https://minio.example.com";
+ TtsConfig.DefaultVoice = "en-gb";
+ TtsConfig.DefaultSpeed = 190;
+ TtsConfig.PreGeneratedPrompts = "Alpha;Beta";
+ TtsConfig.PlaybackBaseUrl = "https://tts.example.com";
+ TtsConfig.PlaybackMemoryCacheMinutes = 90;
+ TtsConfig.PlaybackCacheControlSeconds = 7200;
+ TtsConfig.RateLimitPermitLimit = 15;
+ TtsConfig.TempDirectory = "/tmp/custom-tts";
+ TtsConfig.StaticPromptAdminKey = "prompt-admin-key";
+
+ var services = new ServiceCollection();
+ services.AddTtsConfiguration();
+
+ using var provider = services.BuildServiceProvider();
+
+ var s3Options = provider.GetRequiredService>().Value;
+ var ttsOptions = provider.GetRequiredService>().Value;
+ var rateLimitOptions = provider.GetRequiredService>().Value;
+
+ s3Options.AccessKey.Should().Be("access-key");
+ s3Options.SecretKey.Should().Be("secret-key");
+ s3Options.Bucket.Should().Be("tts-bucket");
+ s3Options.Endpoint.Should().Be("https://minio.example.com");
+ ttsOptions.DefaultVoice.Should().Be("en-gb");
+ ttsOptions.DefaultSpeed.Should().Be(190);
+ ttsOptions.PlaybackBaseUrl.Should().Be("https://tts.example.com");
+ ttsOptions.PlaybackMemoryCacheMinutes.Should().Be(90);
+ ttsOptions.PlaybackCacheControlSeconds.Should().Be(7200);
+ ttsOptions.TempDirectory.Should().Be("/tmp/custom-tts");
+ ttsOptions.StaticPromptAdminKey.Should().Be("prompt-admin-key");
+ ttsOptions.PreGeneratedPrompts.Should().Equal("Alpha", "Beta");
+ rateLimitOptions.PermitLimit.Should().Be(15);
+ }
+
+ [Test]
+ public void add_tts_configuration_should_require_static_prompt_admin_key()
+ {
+ TtsConfig.StaticPromptAdminKey = " ";
+
+ var services = new ServiceCollection();
+ services.AddTtsConfiguration();
+
+ using var provider = services.BuildServiceProvider();
+
+ FluentActions
+ .Invoking(() => provider.GetRequiredService>().Value)
+ .Should()
+ .Throw()
+ .WithMessage("*A static prompt admin key is required.*");
+ }
+ }
+}
diff --git a/Tests/Resgrid.Tests/Web/Tts/TtsControllerTests.cs b/Tests/Resgrid.Tests/Web/Tts/TtsControllerTests.cs
new file mode 100644
index 00000000..6095c62e
--- /dev/null
+++ b/Tests/Resgrid.Tests/Web/Tts/TtsControllerTests.cs
@@ -0,0 +1,115 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Options;
+using Moq;
+using NUnit.Framework;
+using Resgrid.Web.Tts.Configuration;
+using Resgrid.Web.Tts.Controllers;
+using Resgrid.Web.Tts.Models;
+using Resgrid.Web.Tts.Services;
+
+namespace Resgrid.Tests.Web.Tts
+{
+ [TestFixture]
+ public class TtsControllerTests
+ {
+ private Mock _ttsService;
+ private Mock _cacheService;
+ private Mock _ttsPlaybackUrlService;
+ private TtsController _controller;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _ttsService = new Mock(MockBehavior.Strict);
+ _cacheService = new Mock(MockBehavior.Strict);
+ _ttsPlaybackUrlService = new Mock(MockBehavior.Strict);
+
+ _controller = new TtsController(
+ _ttsService.Object,
+ _cacheService.Object,
+ _ttsPlaybackUrlService.Object,
+ Options.Create(new TtsOptions
+ {
+ PlaybackCacheControlSeconds = 7200
+ }))
+ {
+ ControllerContext = new ControllerContext
+ {
+ HttpContext = new DefaultHttpContext()
+ }
+ };
+ }
+
+ [Test]
+ public async Task generate_async_should_return_playback_api_url()
+ {
+ var serviceResponse = new TtsResponse
+ {
+ Hash = new string('a', 64),
+ ObjectKey = "tts/audio.wav",
+ Url = "https://s3.example.com/tts/audio.wav",
+ Voice = "en-us",
+ Speed = 175
+ };
+
+ _ttsService
+ .Setup(x => x.GenerateAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(serviceResponse);
+ _ttsPlaybackUrlService
+ .Setup(x => x.CreatePlaybackUrl(It.IsAny(), serviceResponse.Hash))
+ .Returns(new Uri($"https://tts.example.com/tts/audio/{serviceResponse.Hash}.wav"));
+
+ var result = await _controller.GenerateAsync(new TtsRequest { Text = "Hello" }, CancellationToken.None);
+
+ var okResult = result.Result.Should().BeOfType().Subject;
+ var response = okResult.Value.Should().BeOfType().Subject;
+ response.Url.Should().Be($"https://tts.example.com/tts/audio/{serviceResponse.Hash}.wav");
+ }
+
+ [Test]
+ public async Task get_audio_async_should_return_cached_audio_with_cache_headers()
+ {
+ var hash = new string('b', 64);
+ var audio = new TtsAudioContent(
+ new byte[] { 1, 2, 3 },
+ "audio/wav",
+ "\"etag-123\"",
+ new DateTimeOffset(2026, 4, 22, 12, 0, 0, TimeSpan.Zero));
+
+ _cacheService
+ .Setup(x => x.TryGetAudioAsync(hash, It.IsAny()))
+ .ReturnsAsync(audio);
+
+ var result = await _controller.GetAudioAsync(hash, CancellationToken.None);
+
+ var fileResult = result.Should().BeOfType().Subject;
+ fileResult.FileContents.Should().Equal(audio.AudioBytes);
+ fileResult.ContentType.Should().Be("audio/wav");
+ fileResult.EnableRangeProcessing.Should().BeTrue();
+ fileResult.EntityTag.Should().NotBeNull();
+ fileResult.EntityTag!.ToString().Should().Be("\"etag-123\"");
+ fileResult.LastModified.Should().Be(audio.LastModified);
+ _controller.Response.Headers["Cache-Control"].ToString().Should().Be("public,max-age=7200,immutable");
+ }
+
+ [Test]
+ public async Task get_audio_async_should_return_not_found_when_audio_missing()
+ {
+ var hash = new string('c', 64);
+
+ _cacheService
+ .Setup(x => x.TryGetAudioAsync(hash, It.IsAny()))
+ .ReturnsAsync((TtsAudioContent)null);
+
+ var result = await _controller.GetAudioAsync(hash, CancellationToken.None);
+
+ result.Should().BeOfType();
+ }
+ }
+}
diff --git a/Tests/Resgrid.Tests/Web/Tts/TtsPlaybackUrlServiceTests.cs b/Tests/Resgrid.Tests/Web/Tts/TtsPlaybackUrlServiceTests.cs
new file mode 100644
index 00000000..eea8df15
--- /dev/null
+++ b/Tests/Resgrid.Tests/Web/Tts/TtsPlaybackUrlServiceTests.cs
@@ -0,0 +1,58 @@
+using System;
+using FluentAssertions;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Options;
+using NUnit.Framework;
+using Resgrid.Web.Tts.Configuration;
+using Resgrid.Web.Tts.Services;
+
+namespace Resgrid.Tests.Web.Tts
+{
+ [TestFixture]
+ public class TtsPlaybackUrlServiceTests
+ {
+ [Test]
+ public void create_playback_url_should_normalize_and_escape_hash()
+ {
+ var service = new TtsPlaybackUrlService(Options.Create(new TtsOptions
+ {
+ PlaybackBaseUrl = "https://tts.example.com"
+ }));
+
+ var result = service.CreatePlaybackUrl(null, " Ab C ");
+
+ result.Should().Be(new Uri("https://tts.example.com/tts/audio/ab%20c.wav"));
+ }
+
+ [TestCase("abc/123")]
+ [TestCase("abc?123")]
+ [TestCase("abc#123")]
+ public void create_playback_url_should_reject_hashes_with_url_delimiters(string hash)
+ {
+ var service = new TtsPlaybackUrlService(Options.Create(new TtsOptions
+ {
+ PlaybackBaseUrl = "https://tts.example.com"
+ }));
+
+ FluentActions
+ .Invoking(() => service.CreatePlaybackUrl(null, hash))
+ .Should()
+ .Throw()
+ .WithParameterName("hash");
+ }
+
+ [Test]
+ public void create_playback_url_should_use_request_base_when_configured_base_url_missing()
+ {
+ var service = new TtsPlaybackUrlService(Options.Create(new TtsOptions()));
+ var httpContext = new DefaultHttpContext();
+ httpContext.Request.Scheme = "https";
+ httpContext.Request.Host = new HostString("api.example.com");
+ httpContext.Request.PathBase = new PathString("/tts-service");
+
+ var result = service.CreatePlaybackUrl(httpContext.Request, "ABC123");
+
+ result.Should().Be(new Uri("https://api.example.com/tts-service/tts/audio/abc123.wav"));
+ }
+ }
+}
diff --git a/Tests/Resgrid.Tests/Web/Tts/TtsRequestIdentityTests.cs b/Tests/Resgrid.Tests/Web/Tts/TtsRequestIdentityTests.cs
new file mode 100644
index 00000000..a7d9774f
--- /dev/null
+++ b/Tests/Resgrid.Tests/Web/Tts/TtsRequestIdentityTests.cs
@@ -0,0 +1,81 @@
+using System;
+using System.Net;
+using System.Linq;
+using System.Reflection;
+using FluentAssertions;
+using Microsoft.AspNetCore.Http;
+using NUnit.Framework;
+using Resgrid.Config;
+using Resgrid.Web.Tts.Configuration;
+
+namespace Resgrid.Tests.Web.Tts
+{
+ [TestFixture]
+ public class TtsRequestIdentityTests
+ {
+ private string _originalIngressProxyNetwork;
+ private int _originalIngressProxyNetworkCidr;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _originalIngressProxyNetwork = WebConfig.IngressProxyNetwork;
+ _originalIngressProxyNetworkCidr = WebConfig.IngressProxyNetworkCidr;
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ WebConfig.IngressProxyNetwork = _originalIngressProxyNetwork;
+ WebConfig.IngressProxyNetworkCidr = _originalIngressProxyNetworkCidr;
+ }
+
+ [Test]
+ public void configure_forwarded_headers_should_register_configured_ingress_proxy_network()
+ {
+ WebConfig.IngressProxyNetwork = "10.42.0.0";
+ WebConfig.IngressProxyNetworkCidr = 16;
+ var configureMethod = typeof(TtsRequestIdentity)
+ .GetMethod(nameof(TtsRequestIdentity.ConfigureForwardedHeaders), BindingFlags.Public | BindingFlags.Static)!;
+ var optionsType = configureMethod.GetParameters()[0].ParameterType;
+ var options = Activator.CreateInstance(optionsType!);
+
+ configureMethod.Invoke(null, new object[] { options! });
+
+ optionsType!.GetProperty("ForwardedHeaders")!.GetValue(options)!.ToString().Should().Be("XForwardedFor, XForwardedProto");
+
+ var knownNetworks = ((System.Collections.IEnumerable)optionsType.GetProperty("KnownNetworks")!.GetValue(options)!)
+ .Cast