From cc8601696d2ffc3d6f529341407e08ac680ff5b6 Mon Sep 17 00:00:00 2001 From: Wyatt Baggett Date: Fri, 6 Feb 2026 16:20:50 -0500 Subject: [PATCH] Adds cached sitemap data for sermons Introduces a lightweight, cached data feed tailored for sitemap generation. Provides minimal series and message metadata (IDs and dates) via a dedicated endpoint, leveraging existing caching to reduce repeated queries and improve performance. Caches results for two hours to align with sitemap regeneration strategies. Enhances reliability with comprehensive tests covering cache hits/misses, empty datasets, and partial failures, ensuring consistent behavior and efficient data retrieval. - Adds minimal response models for sitemap use - Implements service logic to aggregate and cache data - Exposes a GET endpoint to retrieve sitemap-ready data - Reuses existing caches to minimize backend load - Adds unit tests for success, edge cases, and error handling --- .../Extensions/Caching/CacheKeys.cs | 5 + .../Responses/SitemapDataResponse.cs | 24 ++ .../Responses/SitemapMessageData.cs | 21 ++ .../Responses/SitemapSeriesData.cs | 36 ++ .../Services/Abstract/ISermonsService.cs | 6 + .../Services/SermonsService.cs | 56 +++ .../Services/SermonsServiceTests.cs | 343 ++++++++++++++++++ .../Controllers/SermonsController.cs | 27 ++ 8 files changed, 518 insertions(+) create mode 100644 API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Core/Responses/SitemapDataResponse.cs create mode 100644 API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Core/Responses/SitemapMessageData.cs create mode 100644 API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Core/Responses/SitemapSeriesData.cs diff --git a/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Core/Extensions/Caching/CacheKeys.cs b/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Core/Extensions/Caching/CacheKeys.cs index 869b346..9ab255a 100644 --- a/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Core/Extensions/Caching/CacheKeys.cs +++ b/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Core/Extensions/Caching/CacheKeys.cs @@ -30,6 +30,11 @@ public static class CacheKeys /// public const string SermonsPattern = "thrive:sermons:*"; + /// + /// Sitemap data containing all series and message IDs + /// + public const string SitemapData = "thrive:sermons:sitemap"; + // ============================================ // Configuration Cache Keys // ============================================ diff --git a/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Core/Responses/SitemapDataResponse.cs b/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Core/Responses/SitemapDataResponse.cs new file mode 100644 index 0000000..d4f1507 --- /dev/null +++ b/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Core/Responses/SitemapDataResponse.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +namespace ThriveChurchOfficialAPI.Core +{ + /// + /// Response containing minimal sermon data optimized for sitemap generation + /// + public class SitemapDataResponse + { + /// + /// C'tor + /// + public SitemapDataResponse() + { + Series = new List(); + } + + /// + /// Collection of sermon series with their messages + /// + public List Series { get; set; } + } +} + diff --git a/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Core/Responses/SitemapMessageData.cs b/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Core/Responses/SitemapMessageData.cs new file mode 100644 index 0000000..86e2e62 --- /dev/null +++ b/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Core/Responses/SitemapMessageData.cs @@ -0,0 +1,21 @@ +using System; + +namespace ThriveChurchOfficialAPI.Core +{ + /// + /// Minimal message data for sitemap generation + /// + public class SitemapMessageData + { + /// + /// The unique identifier of the message + /// + public string Id { get; set; } + + /// + /// The date this message was given + /// + public DateTime? Date { get; set; } + } +} + diff --git a/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Core/Responses/SitemapSeriesData.cs b/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Core/Responses/SitemapSeriesData.cs new file mode 100644 index 0000000..64607dc --- /dev/null +++ b/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Core/Responses/SitemapSeriesData.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; + +namespace ThriveChurchOfficialAPI.Core +{ + /// + /// Minimal series data for sitemap generation + /// + public class SitemapSeriesData + { + /// + /// C'tor + /// + public SitemapSeriesData() + { + Id = null; + Messages = new List(); + } + + /// + /// The unique identifier of the sermon series + /// + public string Id { get; set; } + + /// + /// The last time this series was updated + /// + public DateTime LastUpdated { get; set; } + + /// + /// Collection of messages in this series + /// + public List Messages { get; set; } + } +} + diff --git a/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Services/Services/Abstract/ISermonsService.cs b/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Services/Services/Abstract/ISermonsService.cs index aca39c2..74bf18d 100644 --- a/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Services/Services/Abstract/ISermonsService.cs +++ b/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Services/Services/Abstract/ISermonsService.cs @@ -170,5 +170,11 @@ public interface ISermonsService /// /// Task UpdatePodcastMessage(string messageId, PodcastMessageRequest request); + + /// + /// Gets minimal sermon data for sitemap generation + /// + /// Series IDs, message IDs, and dates for sitemap URLs + Task> GetSitemapData(); } } \ No newline at end of file diff --git a/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Services/Services/SermonsService.cs b/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Services/Services/SermonsService.cs index 4b47645..02798bb 100644 --- a/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Services/Services/SermonsService.cs +++ b/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Services/Services/SermonsService.cs @@ -1872,5 +1872,61 @@ public Task UpdatePodcastMessage(string messageId, PodcastMessag { return _podcastMessagesRepository.UpdatePodcastMessageById(messageId, request); } + + /// + /// Gets minimal sermon data for sitemap generation + /// + /// Series IDs, message IDs, and dates for sitemap URLs + public async Task> GetSitemapData() + { + // Check cache first + var cacheKey = CacheKeys.SitemapData; + var cachedResponse = _cache.ReadFromCache(cacheKey); + if (cachedResponse != null) + { + return new SystemResponse(cachedResponse, "Success!"); + } + + // Use existing cached service method to get all series summaries + var allSermonsResponse = await GetAllSermons(highResImg: false); + if (allSermonsResponse.HasErrors) + { + return new SystemResponse(true, allSermonsResponse.ErrorMessage); + } + + var allSeries = allSermonsResponse.Result.Summaries; + if (allSeries == null) + { + return new SystemResponse(true, "Failed to retrieve sermon series data"); + } + + // For each series, get the full series data (with messages) from cache + // GetSeriesForId already uses caching at thrive:sermons:series:{seriesId} + var seriesDataTasks = allSeries.Select(s => GetSeriesForId(s.Id)).ToList(); + await Task.WhenAll(seriesDataTasks); + + // Build minimal response from cached data + var response = new SitemapDataResponse + { + Series = seriesDataTasks + .Where(t => !t.Result.HasErrors && t.Result.Result != null) + .Select(t => t.Result.Result) + .Select(series => new SitemapSeriesData + { + Id = series.Id, + LastUpdated = series.LastUpdated ?? DateTime.MinValue, + Messages = series.Messages?.Select(msg => new SitemapMessageData + { + Id = msg.MessageId, + Date = msg.Date + }).ToList() ?? new List() + }).ToList() + }; + + // Cache for 2 hours (matches sitemap ISR revalidation) + _cache.InsertIntoCache(cacheKey, response, TimeSpan.FromHours(2)); + + return new SystemResponse(response, "Success!"); + } } } \ No newline at end of file diff --git a/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Tests/Services/SermonsServiceTests.cs b/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Tests/Services/SermonsServiceTests.cs index bddcb27..25a2e76 100644 --- a/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Tests/Services/SermonsServiceTests.cs +++ b/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI.Tests/Services/SermonsServiceTests.cs @@ -1174,6 +1174,349 @@ public async Task GetAllSermons_SeriesWithNoMessages_ReturnsZeroMessageCount() #endregion + #region GetSitemapData Tests + + [TestMethod] + public async Task GetSitemapData_CacheHit_ReturnsCachedData() + { + // Arrange + var cachedData = new SitemapDataResponse + { + Series = new List + { + new SitemapSeriesData + { + Id = "1", + LastUpdated = DateTime.UtcNow, + Messages = new List + { + new SitemapMessageData { Id = "msg1", Date = DateTime.UtcNow } + } + } + } + }; + + _mockCache.Setup(c => c.ReadFromCache(CacheKeys.SitemapData)) + .Returns(cachedData); + + // Act + var result = await _sermonsService.GetSitemapData(); + + // Assert + Assert.IsNotNull(result); + Assert.IsFalse(result.HasErrors); + Assert.AreEqual(1, result.Result.Series.Count); + Assert.AreEqual("1", result.Result.Series[0].Id); + + // Verify repository was NOT called (cache hit) + _mockSermonsRepository.Verify(r => r.GetAllSermons(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task GetSitemapData_CacheMiss_UsesExistingCachedServiceMethods() + { + // Arrange - Mock the flow: GetAllSermons (summary cache) -> GetSeriesForId (series cache) + var series1 = CreateTestSermonSeries("1", "Series 1", "series-1"); + var series2 = CreateTestSermonSeries("2", "Series 2", "series-2"); + var seriesList = new List { series1, series2 }; + + var message1 = CreateTestSermonMessage("msg1", "1", "Message 1"); + var message2 = CreateTestSermonMessage("msg2", "1", "Message 2"); + var message3 = CreateTestSermonMessage("msg3", "2", "Message 3"); + + // Mock GetAllSermons flow (for AllSermonsSummaryResponse) + _mockSermonsRepository.Setup(r => r.GetAllSermons(It.IsAny())) + .ReturnsAsync(seriesList); + _mockMessagesRepository.Setup(r => r.GetAllMessages()) + .ReturnsAsync(new List { message1, message2, message3 }); + + // Mock GetSeriesForId flow (for each series) + _mockSermonsRepository.Setup(r => r.GetSermonSeriesForId("1")) + .ReturnsAsync(new SystemResponse(series1, "Success")); + _mockSermonsRepository.Setup(r => r.GetSermonSeriesForId("2")) + .ReturnsAsync(new SystemResponse(series2, "Success")); + + _mockMessagesRepository.Setup(r => r.GetMessagesBySeriesId("1")) + .ReturnsAsync(new List { message1, message2 }); + _mockMessagesRepository.Setup(r => r.GetMessagesBySeriesId("2")) + .ReturnsAsync(new List { message3 }); + + // Act + var result = await _sermonsService.GetSitemapData(); + + // Assert + Assert.IsNotNull(result); + Assert.IsFalse(result.HasErrors); + Assert.AreEqual(2, result.Result.Series.Count); + + var series1Data = result.Result.Series.First(s => s.Id == "1"); + var series2Data = result.Result.Series.First(s => s.Id == "2"); + Assert.AreEqual(2, series1Data.Messages.Count); + Assert.AreEqual(1, series2Data.Messages.Count); + + // Verify sitemap cache was updated + _mockCache.Verify(c => c.InsertIntoCache( + CacheKeys.SitemapData, + It.IsAny(), + TimeSpan.FromHours(2)), Times.Once); + } + + [TestMethod] + public async Task GetSitemapData_NoSeries_ReturnsEmptyList() + { + // Arrange + var emptySeries = new List(); + var emptyMessages = new List(); + + _mockSermonsRepository.Setup(r => r.GetAllSermons(It.IsAny())) + .ReturnsAsync(emptySeries); + + _mockMessagesRepository.Setup(r => r.GetAllMessages()) + .ReturnsAsync(emptyMessages); + + // Act + var result = await _sermonsService.GetSitemapData(); + + // Assert + Assert.IsNotNull(result); + Assert.IsFalse(result.HasErrors); + Assert.IsNotNull(result.Result.Series); + Assert.AreEqual(0, result.Result.Series.Count); + } + + [TestMethod] + public async Task GetSitemapData_SeriesWithNoMessages_ReturnsEmptyMessagesList() + { + // Arrange + var series = CreateTestSermonSeries("1", "Empty Series", "empty-series"); + var seriesList = new List { series }; + + // Mock GetAllSermons flow + _mockSermonsRepository.Setup(r => r.GetAllSermons(It.IsAny())) + .ReturnsAsync(seriesList); + _mockMessagesRepository.Setup(r => r.GetAllMessages()) + .ReturnsAsync(new List()); + + // Mock GetSeriesForId flow (series with no messages) + _mockSermonsRepository.Setup(r => r.GetSermonSeriesForId("1")) + .ReturnsAsync(new SystemResponse(series, "Success")); + _mockMessagesRepository.Setup(r => r.GetMessagesBySeriesId("1")) + .ReturnsAsync(new List()); + + // Act + var result = await _sermonsService.GetSitemapData(); + + // Assert + Assert.IsNotNull(result); + Assert.IsFalse(result.HasErrors); + Assert.AreEqual(1, result.Result.Series.Count); + Assert.AreEqual(0, result.Result.Series[0].Messages.Count); + } + + [TestMethod] + public async Task GetSitemapData_GetAllSermonsCacheHit_UsesCachedData() + { + // Arrange - AllSermonsSummaryResponse is already in cache + var cachedSummary = new AllSermonsSummaryResponse + { + Summaries = new List + { + new AllSermonSeriesSummary { Id = "1", Title = "Series 1", LastUpdated = DateTime.UtcNow } + } + }; + var series = CreateTestSermonSeries("1", "Series 1", "series-1"); + var message = CreateTestSermonMessage("msg1", "1", "Message 1"); + + // GetAllSermons will find its cache hit + var sermonsSummaryCacheKey = CacheKeys.Format(CacheKeys.SermonsSummary, false); + _mockCache.Setup(c => c.ReadFromCache(sermonsSummaryCacheKey)) + .Returns(cachedSummary); + + // GetSeriesForId will still be called (different cache) + _mockSermonsRepository.Setup(r => r.GetSermonSeriesForId("1")) + .ReturnsAsync(new SystemResponse(series, "Success")); + _mockMessagesRepository.Setup(r => r.GetMessagesBySeriesId("1")) + .ReturnsAsync(new List { message }); + + // Act + var result = await _sermonsService.GetSitemapData(); + + // Assert + Assert.IsNotNull(result); + Assert.IsFalse(result.HasErrors); + Assert.AreEqual(1, result.Result.Series.Count); + + // Verify repository GetAllSermons was NOT called (cache hit for summary) + _mockSermonsRepository.Verify(r => r.GetAllSermons(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task GetSitemapData_GetSeriesForIdFails_SkipsFailedSeries() + { + // Arrange - One series succeeds, one fails + var series1 = CreateTestSermonSeries("1", "Series 1", "series-1"); + var series2 = CreateTestSermonSeries("2", "Series 2", "series-2"); + var message1 = CreateTestSermonMessage("msg1", "1", "Message 1"); + + // Mock GetAllSermons flow + _mockSermonsRepository.Setup(r => r.GetAllSermons(It.IsAny())) + .ReturnsAsync(new List { series1, series2 }); + _mockMessagesRepository.Setup(r => r.GetAllMessages()) + .ReturnsAsync(new List { message1 }); + + // Mock GetSeriesForId - series1 succeeds, series2 fails + _mockSermonsRepository.Setup(r => r.GetSermonSeriesForId("1")) + .ReturnsAsync(new SystemResponse(series1, "Success")); + _mockSermonsRepository.Setup(r => r.GetSermonSeriesForId("2")) + .ReturnsAsync(new SystemResponse(true, "Series not found")); + + _mockMessagesRepository.Setup(r => r.GetMessagesBySeriesId("1")) + .ReturnsAsync(new List { message1 }); + + // Act + var result = await _sermonsService.GetSitemapData(); + + // Assert - Only series1 should be in the result (series2 was skipped due to error) + Assert.IsNotNull(result); + Assert.IsFalse(result.HasErrors); + Assert.AreEqual(1, result.Result.Series.Count); + Assert.AreEqual("1", result.Result.Series[0].Id); + } + + [TestMethod] + public async Task GetSitemapData_ReturnsCorrectSeriesData() + { + // Arrange + var lastUpdated = new DateTime(2026, 1, 15, 10, 30, 0, DateTimeKind.Utc); + var series = new SermonSeries + { + Id = "test-series-id", + Name = "Test Series", + Slug = "test-series", + StartDate = DateTime.UtcNow, + LastUpdated = lastUpdated + }; + + // Mock GetAllSermons flow + _mockSermonsRepository.Setup(r => r.GetAllSermons(It.IsAny())) + .ReturnsAsync(new List { series }); + _mockMessagesRepository.Setup(r => r.GetAllMessages()) + .ReturnsAsync(new List()); + + // Mock GetSeriesForId flow + _mockSermonsRepository.Setup(r => r.GetSermonSeriesForId("test-series-id")) + .ReturnsAsync(new SystemResponse(series, "Success")); + _mockMessagesRepository.Setup(r => r.GetMessagesBySeriesId("test-series-id")) + .ReturnsAsync(new List()); + + // Act + var result = await _sermonsService.GetSitemapData(); + + // Assert + Assert.IsNotNull(result); + Assert.IsFalse(result.HasErrors); + var seriesData = result.Result.Series[0]; + Assert.AreEqual("test-series-id", seriesData.Id); + Assert.AreEqual(lastUpdated, seriesData.LastUpdated); + } + + [TestMethod] + public async Task GetSitemapData_ReturnsCorrectMessageData() + { + // Arrange + var messageDate = new DateTime(2026, 1, 20, 9, 0, 0, DateTimeKind.Utc); + var series = CreateTestSermonSeries("1", "Test Series", "test-series"); + var message = new SermonMessage + { + Id = "test-message-id", + SeriesId = "1", + Title = "Test Message", + Speaker = "Test Speaker", + Date = messageDate, + AudioDuration = 1800 + }; + + // Mock GetAllSermons flow + _mockSermonsRepository.Setup(r => r.GetAllSermons(It.IsAny())) + .ReturnsAsync(new List { series }); + _mockMessagesRepository.Setup(r => r.GetAllMessages()) + .ReturnsAsync(new List { message }); + + // Mock GetSeriesForId flow + _mockSermonsRepository.Setup(r => r.GetSermonSeriesForId("1")) + .ReturnsAsync(new SystemResponse(series, "Success")); + _mockMessagesRepository.Setup(r => r.GetMessagesBySeriesId("1")) + .ReturnsAsync(new List { message }); + + // Act + var result = await _sermonsService.GetSitemapData(); + + // Assert + Assert.IsNotNull(result); + Assert.IsFalse(result.HasErrors); + var messageData = result.Result.Series[0].Messages[0]; + Assert.AreEqual("test-message-id", messageData.Id); + Assert.AreEqual(messageDate, messageData.Date); + } + + [TestMethod] + public async Task GetSitemapData_LeveragesExistingSeriesCaches() + { + // Arrange - This test verifies GetSeriesForId is called for each series (which uses caching) + var series1 = CreateTestSermonSeries("series-1", "Series 1", "series-1"); + var series2 = CreateTestSermonSeries("series-2", "Series 2", "series-2"); + var series3 = CreateTestSermonSeries("series-3", "Series 3", "series-3"); + + var messages = new List + { + CreateTestSermonMessage("msg1", "series-1", "Message 1"), + CreateTestSermonMessage("msg2", "series-1", "Message 2"), + CreateTestSermonMessage("msg3", "series-2", "Message 3"), + CreateTestSermonMessage("msg4", "series-2", "Message 4"), + CreateTestSermonMessage("msg5", "series-2", "Message 5") + // series-3 has no messages + }; + + // Mock GetAllSermons flow + _mockSermonsRepository.Setup(r => r.GetAllSermons(It.IsAny())) + .ReturnsAsync(new List { series1, series2, series3 }); + _mockMessagesRepository.Setup(r => r.GetAllMessages()) + .ReturnsAsync(messages); + + // Mock GetSeriesForId flow for each series + _mockSermonsRepository.Setup(r => r.GetSermonSeriesForId("series-1")) + .ReturnsAsync(new SystemResponse(series1, "Success")); + _mockSermonsRepository.Setup(r => r.GetSermonSeriesForId("series-2")) + .ReturnsAsync(new SystemResponse(series2, "Success")); + _mockSermonsRepository.Setup(r => r.GetSermonSeriesForId("series-3")) + .ReturnsAsync(new SystemResponse(series3, "Success")); + + _mockMessagesRepository.Setup(r => r.GetMessagesBySeriesId("series-1")) + .ReturnsAsync(messages.Where(m => m.SeriesId == "series-1").ToList()); + _mockMessagesRepository.Setup(r => r.GetMessagesBySeriesId("series-2")) + .ReturnsAsync(messages.Where(m => m.SeriesId == "series-2").ToList()); + _mockMessagesRepository.Setup(r => r.GetMessagesBySeriesId("series-3")) + .ReturnsAsync(new List()); + + // Act + var result = await _sermonsService.GetSitemapData(); + + // Assert + Assert.IsNotNull(result); + Assert.IsFalse(result.HasErrors); + Assert.AreEqual(3, result.Result.Series.Count); + + var series1Data = result.Result.Series.First(s => s.Id == "series-1"); + var series2Data = result.Result.Series.First(s => s.Id == "series-2"); + var series3Data = result.Result.Series.First(s => s.Id == "series-3"); + + Assert.AreEqual(2, series1Data.Messages.Count); + Assert.AreEqual(3, series2Data.Messages.Count); + Assert.AreEqual(0, series3Data.Messages.Count); + } + + #endregion + #region Helper Methods private SermonSeries CreateTestSermonSeries(string id, string name, string slug) diff --git a/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI/Controllers/SermonsController.cs b/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI/Controllers/SermonsController.cs index 9ee23ef..7a9bc55 100644 --- a/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI/Controllers/SermonsController.cs +++ b/API/ThriveChurchOfficialAPI/ThriveChurchOfficialAPI/Controllers/SermonsController.cs @@ -729,5 +729,32 @@ public async Task> ImportSermonData([From return Ok(response.Result); } + + /// + /// Get minimal sermon data for sitemap generation + /// + /// + /// Returns all sermon series and their messages with only IDs and dates. + /// Designed for efficient sitemap generation without requiring multiple API calls. + /// Response is cached for 2 hours. + /// + /// Series and message IDs with dates + /// OK - Sitemap data retrieved + /// Bad Request - Failed to retrieve data + [Produces("application/json")] + [HttpGet("sitemap")] + [ProducesResponseType(typeof(SitemapDataResponse), 200)] + [ProducesResponseType(400)] + public async Task> GetSitemapData() + { + var response = await _sermonsService.GetSitemapData(); + + if (response.HasErrors) + { + return StatusCode(400, response.ErrorMessage); + } + + return response.Result; + } } } \ No newline at end of file