From 30303eb5577db3741c2fdcf738c7e0d3dcf11a48 Mon Sep 17 00:00:00 2001 From: Wyatt Baggett Date: Wed, 4 Feb 2026 00:18:44 -0500 Subject: [PATCH] Introduces transcript caching and DI integration Adds server-side caching to transcript retrieval to reduce repeated storage reads and improve response times. Leverages a long-lived cache policy aligned with immutable transcript data and checks the cache before downloading content. Integrates the cache service via dependency injection, defines keys and an invalidation pattern for transcript data, and updates unit tests to use a mock cache. Adjusts service registration to provide the cache dependency, with no changes to external behavior. - Adds cache keys and pattern for transcript data - Caches deserialized transcript content and reuses on hits - Injects cache service into transcript handling and updates registration - Updates tests to accommodate the new dependency --- .../Extensions/Caching/CacheKeys.cs | 14 ++++++ .../Services/TranscriptService.cs | 38 ++++++++++++++-- .../Services/TranscriptServiceTests.cs | 44 ++++++++++++------- .../ThriveChurchOfficialAPI/Startup.cs | 5 ++- 4 files changed, 79 insertions(+), 22 deletions(-) diff --git a/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Core/Extensions/Caching/CacheKeys.cs b/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Core/Extensions/Caching/CacheKeys.cs index 19e0465..869b346 100644 --- a/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Core/Extensions/Caching/CacheKeys.cs +++ b/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Core/Extensions/Caching/CacheKeys.cs @@ -68,6 +68,20 @@ public static class CacheKeys /// public const string EventsPattern = "thrive:events:*"; + // ============================================ + // Transcript Cache Keys + // ============================================ + + /// + /// Sermon transcript blob. Format: thrive:transcripts:blob:{messageId} + /// + public const string TranscriptBlob = "thrive:transcripts:blob:{0}"; + + /// + /// Pattern for invalidating all transcript caches + /// + public const string TranscriptsPattern = "thrive:transcripts:*"; + // ============================================ // Bible Passage Cache Keys // ============================================ diff --git a/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Services/Services/TranscriptService.cs b/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Services/Services/TranscriptService.cs index 70ed293..1a43df2 100644 --- a/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Services/Services/TranscriptService.cs +++ b/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Services/Services/TranscriptService.cs @@ -15,14 +15,23 @@ namespace ThriveChurchOfficialAPI.Services public class TranscriptService : ITranscriptService { private readonly BlobContainerClient _containerClient; + private readonly ICacheService _cache; + + /// + /// Cache expiration for transcript data (365 days - transcripts are immutable once created) + /// + private static readonly TimeSpan CacheExpiration = TimeSpan.FromDays(365); /// /// Constructor for TranscriptService /// /// Azure Storage connection string /// Name of the blob container storing transcripts - public TranscriptService(string connectionString, string containerName) + /// Cache service instance + public TranscriptService(string connectionString, string containerName, ICacheService cache) { + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + if (string.IsNullOrEmpty(connectionString)) { Log.Warning("TranscriptService initialized with empty connection string"); @@ -39,9 +48,11 @@ public TranscriptService(string connectionString, string containerName) /// Constructor for testing - allows injecting a mock container client /// /// Mock blob container client for testing - public TranscriptService(BlobContainerClient containerClient) + /// Cache service instance + public TranscriptService(BlobContainerClient containerClient, ICacheService cache) { _containerClient = containerClient; + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); Log.Information("TranscriptService initialized with injected container client"); } @@ -212,10 +223,20 @@ public async Task> GetStudyGuideAsync(string #region Private Helper Methods /// - /// Downloads and parses the transcript blob + /// Downloads and parses the transcript blob with caching /// private async Task DownloadBlobAsync(string messageId) { + // Check cache first + var cacheKey = string.Format(CacheKeys.TranscriptBlob, messageId.ToLowerInvariant()); + var cachedBlob = _cache.ReadFromCache(cacheKey); + if (cachedBlob != null) + { + Log.Debug("Cache hit for transcript blob: {MessageId}", messageId); + return cachedBlob; + } + + // Cache miss - download from Azure Blob Storage var blobName = $"{messageId}.json"; var blobClient = _containerClient.GetBlobClient(blobName); @@ -227,7 +248,16 @@ private async Task DownloadBlobAsync(string messageId) var downloadResponse = await blobClient.DownloadContentAsync(); var content = downloadResponse.Value.Content.ToString(); - return JsonConvert.DeserializeObject(content); + var blob = JsonConvert.DeserializeObject(content); + + // Cache the result + if (blob != null) + { + _cache.InsertIntoCache(cacheKey, blob, CacheExpiration); + Log.Debug("Cached transcript blob for message: {MessageId}", messageId); + } + + return blob; } /// diff --git a/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Tests/Services/TranscriptServiceTests.cs b/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Tests/Services/TranscriptServiceTests.cs index 26b1ac3..d41e740 100644 --- a/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Tests/Services/TranscriptServiceTests.cs +++ b/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Tests/Services/TranscriptServiceTests.cs @@ -1,5 +1,7 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; using System.Threading.Tasks; +using ThriveChurchOfficialAPI.Core; using ThriveChurchOfficialAPI.Services; namespace ThriveChurchOfficialAPI.Tests.Services @@ -12,13 +14,21 @@ namespace ThriveChurchOfficialAPI.Tests.Services [TestClass] public class TranscriptServiceTests { + private Mock _mockCache; + + [TestInitialize] + public void Setup() + { + _mockCache = new Mock(); + } + #region Constructor Tests [TestMethod] public void Constructor_WithNullConnectionString_CreatesServiceWithWarning() { // Act - Using connection string constructor with null - var service = new TranscriptService(null, "transcripts"); + var service = new TranscriptService(null, "transcripts", _mockCache.Object); // Assert - Service should be created but will return errors when used Assert.IsNotNull(service); @@ -28,7 +38,7 @@ public void Constructor_WithNullConnectionString_CreatesServiceWithWarning() public void Constructor_WithEmptyConnectionString_CreatesServiceWithWarning() { // Act - var service = new TranscriptService(string.Empty, "transcripts"); + var service = new TranscriptService(string.Empty, "transcripts", _mockCache.Object); // Assert Assert.IsNotNull(service); @@ -42,7 +52,7 @@ public void Constructor_WithEmptyConnectionString_CreatesServiceWithWarning() public async Task GetTranscriptAsync_NullMessageId_ReturnsError() { // Arrange - var service = new TranscriptService(null, "transcripts"); + var service = new TranscriptService(null, "transcripts", _mockCache.Object); // Act var result = await service.GetTranscriptAsync(null); @@ -56,7 +66,7 @@ public async Task GetTranscriptAsync_NullMessageId_ReturnsError() public async Task GetTranscriptAsync_EmptyMessageId_ReturnsError() { // Arrange - var service = new TranscriptService(null, "transcripts"); + var service = new TranscriptService(null, "transcripts", _mockCache.Object); // Act var result = await service.GetTranscriptAsync(string.Empty); @@ -70,7 +80,7 @@ public async Task GetTranscriptAsync_EmptyMessageId_ReturnsError() public async Task GetTranscriptAsync_WhitespaceMessageId_ReturnsError() { // Arrange - var service = new TranscriptService(null, "transcripts"); + var service = new TranscriptService(null, "transcripts", _mockCache.Object); // Act var result = await service.GetTranscriptAsync(" "); @@ -87,7 +97,7 @@ public async Task GetTranscriptAsync_WhitespaceMessageId_ReturnsError() public async Task GetTranscriptAsync_ServiceNotConfigured_ReturnsConfigurationError() { // Arrange - Create service with null connection string - var unconfiguredService = new TranscriptService(null, "transcripts"); + var unconfiguredService = new TranscriptService(null, "transcripts", _mockCache.Object); var messageId = "507f1f77bcf86cd799439011"; // Act @@ -102,7 +112,7 @@ public async Task GetTranscriptAsync_ServiceNotConfigured_ReturnsConfigurationEr public async Task GetTranscriptAsync_EmptyConnectionString_ReturnsConfigurationError() { // Arrange - var service = new TranscriptService(string.Empty, "transcripts"); + var service = new TranscriptService(string.Empty, "transcripts", _mockCache.Object); var messageId = "507f1f77bcf86cd799439011"; // Act @@ -121,7 +131,7 @@ public async Task GetTranscriptAsync_EmptyConnectionString_ReturnsConfigurationE public async Task GetSermonNotesAsync_NullMessageId_ReturnsError() { // Arrange - var service = new TranscriptService(null, "transcripts"); + var service = new TranscriptService(null, "transcripts", _mockCache.Object); // Act var result = await service.GetSermonNotesAsync(null); @@ -135,7 +145,7 @@ public async Task GetSermonNotesAsync_NullMessageId_ReturnsError() public async Task GetSermonNotesAsync_EmptyMessageId_ReturnsError() { // Arrange - var service = new TranscriptService(null, "transcripts"); + var service = new TranscriptService(null, "transcripts", _mockCache.Object); // Act var result = await service.GetSermonNotesAsync(string.Empty); @@ -149,7 +159,7 @@ public async Task GetSermonNotesAsync_EmptyMessageId_ReturnsError() public async Task GetSermonNotesAsync_WhitespaceMessageId_ReturnsConfigurationError() { // Arrange - var service = new TranscriptService(null, "transcripts"); + var service = new TranscriptService(null, "transcripts", _mockCache.Object); // Act var result = await service.GetSermonNotesAsync(" "); @@ -167,7 +177,7 @@ public async Task GetSermonNotesAsync_WhitespaceMessageId_ReturnsConfigurationEr public async Task GetSermonNotesAsync_ServiceNotConfigured_ReturnsConfigurationError() { // Arrange - Create service with null connection string - var unconfiguredService = new TranscriptService(null, "transcripts"); + var unconfiguredService = new TranscriptService(null, "transcripts", _mockCache.Object); var messageId = "507f1f77bcf86cd799439011"; // Act @@ -182,7 +192,7 @@ public async Task GetSermonNotesAsync_ServiceNotConfigured_ReturnsConfigurationE public async Task GetSermonNotesAsync_EmptyConnectionString_ReturnsConfigurationError() { // Arrange - var service = new TranscriptService(string.Empty, "transcripts"); + var service = new TranscriptService(string.Empty, "transcripts", _mockCache.Object); var messageId = "507f1f77bcf86cd799439011"; // Act @@ -201,7 +211,7 @@ public async Task GetSermonNotesAsync_EmptyConnectionString_ReturnsConfiguration public async Task GetStudyGuideAsync_NullMessageId_ReturnsError() { // Arrange - var service = new TranscriptService(null, "transcripts"); + var service = new TranscriptService(null, "transcripts", _mockCache.Object); // Act var result = await service.GetStudyGuideAsync(null); @@ -215,7 +225,7 @@ public async Task GetStudyGuideAsync_NullMessageId_ReturnsError() public async Task GetStudyGuideAsync_EmptyMessageId_ReturnsError() { // Arrange - var service = new TranscriptService(null, "transcripts"); + var service = new TranscriptService(null, "transcripts", _mockCache.Object); // Act var result = await service.GetStudyGuideAsync(string.Empty); @@ -229,7 +239,7 @@ public async Task GetStudyGuideAsync_EmptyMessageId_ReturnsError() public async Task GetStudyGuideAsync_WhitespaceMessageId_ReturnsConfigurationError() { // Arrange - var service = new TranscriptService(null, "transcripts"); + var service = new TranscriptService(null, "transcripts", _mockCache.Object); // Act var result = await service.GetStudyGuideAsync(" "); @@ -247,7 +257,7 @@ public async Task GetStudyGuideAsync_WhitespaceMessageId_ReturnsConfigurationErr public async Task GetStudyGuideAsync_ServiceNotConfigured_ReturnsConfigurationError() { // Arrange - Create service with null connection string - var unconfiguredService = new TranscriptService(null, "transcripts"); + var unconfiguredService = new TranscriptService(null, "transcripts", _mockCache.Object); var messageId = "507f1f77bcf86cd799439011"; // Act @@ -262,7 +272,7 @@ public async Task GetStudyGuideAsync_ServiceNotConfigured_ReturnsConfigurationEr public async Task GetStudyGuideAsync_EmptyConnectionString_ReturnsConfigurationError() { // Arrange - var service = new TranscriptService(string.Empty, "transcripts"); + var service = new TranscriptService(string.Empty, "transcripts", _mockCache.Object); var messageId = "507f1f77bcf86cd799439011"; // Act diff --git a/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI/Startup.cs b/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI/Startup.cs index 93cc4be..243e840 100644 --- a/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI/Startup.cs +++ b/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI/Startup.cs @@ -390,7 +390,10 @@ public void ConfigureServices(IServiceCollection services) // Azure Blob Storage services (for transcripts) var azureStorageConnectionString = Configuration["AzureStorageConnectionString"]; services.AddSingleton(sp => - new TranscriptService(azureStorageConnectionString, "transcripts")); + new TranscriptService( + azureStorageConnectionString, + "transcripts", + sp.GetRequiredService())); Log.Information("Services configured."); }