From e8860487b2d56f9568c9c1799225ba229c00eb84 Mon Sep 17 00:00:00 2001 From: Cassio Rossi Date: Fri, 17 Jul 2026 18:17:28 +0100 Subject: [PATCH] fix(#297): reconcile FeedDB category membership on each fetch Synthetic category keys (e.g. NewsCategoryHighlights) were only ever unioned into FeedDB.categories on upsert, never removed. A post dropped by the server from a category (classic case: de-highlighted) kept showing under that category forever. getFeed now fetches all six categories in parallel and hands each category's result to a grouped save that reconciles membership within the pubDate window the fetch actually covers - adding the key to matching posts and removing it from posts inside that window the fetch no longer returned. Reconciliation is scoped per category so multi-category posts and deep pagination (categories paginate at different rates) are unaffected; .news stays add-only. Co-Authored-By: Claude --- .../FeedLibrary/Services/StorageService.swift | 49 +++++++ .../FeedLibrary/ViewModel/FeedViewModel.swift | 15 ++- .../StorageServiceTests.swift | 121 ++++++++++++++++++ 3 files changed, 181 insertions(+), 4 deletions(-) diff --git a/MacMagazine/Features/FeedLibrary/Sources/FeedLibrary/Services/StorageService.swift b/MacMagazine/Features/FeedLibrary/Sources/FeedLibrary/Services/StorageService.swift index 974319f3..20893eb5 100644 --- a/MacMagazine/Features/FeedLibrary/Sources/FeedLibrary/Services/StorageService.swift +++ b/MacMagazine/Features/FeedLibrary/Sources/FeedLibrary/Services/StorageService.swift @@ -21,6 +21,24 @@ extension Database { save(feed: feed, in: sharedModelContainer.mainContext) } + /// Saves category-specific fetch results and reconciles stale category membership. + /// + /// Each group's synthetic category key (`NewsCategory.filterKey`) is added to matching posts + /// as usual, then removed from any locally stored post whose `pubDate` falls inside the + /// group's fetched date range but which the fetch no longer returned - covering the case + /// where the server removed a post from that category (e.g. a de-highlighted post). + @MainActor + func save(feed groups: [(category: NewsCategory, posts: [FeedDB])]) { + let ctx = sharedModelContainer.mainContext + for group in groups { + group.posts.forEach { + _ = save(feed: $0, in: ctx) + } + reconcile(category: group.category, fetched: group.posts, in: ctx) + } + FeedDB.deduplicate(using: ctx) + } + @MainActor private func save(feed: FeedDB, in ctx: ModelContext) -> FeedDB { let postId = feed.postId @@ -44,6 +62,37 @@ extension Database { try? ctx.save() return feed } + + /// Categories whose membership is reconciled (added and removed) on each fetch. + /// + /// `.news` is excluded: it represents the unfiltered feed, so removal has no meaning there + /// and its date window is the widest, making false-positive removals most likely. + private static let reconcilableCategories: Set = [ + .highlights, .appletv, .reviews, .tutorials, .rumors + ] + + @MainActor + private func reconcile(category: NewsCategory, fetched: [FeedDB], in ctx: ModelContext) { + guard Self.reconcilableCategories.contains(category), + let windowStart = fetched.map(\.pubDate).min(), + let windowEnd = fetched.map(\.pubDate).max() else { return } + + let key = category.filterKey + let fetchedIds = Set(fetched.map(\.postId)) + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.pubDate >= windowStart && $0.pubDate <= windowEnd } + ) + + guard let candidates = try? ctx.fetch(descriptor) else { return } + let stale = candidates.filter { $0.categories.contains(key) && !fetchedIds.contains($0.postId) } + guard !stale.isEmpty else { return } + + for post in stale { + post.categories.removeAll { $0 == key } + post.modifiedAt = Date() + } + try? ctx.save() + } } // MARK: - Podcast - diff --git a/MacMagazine/Features/FeedLibrary/Sources/FeedLibrary/ViewModel/FeedViewModel.swift b/MacMagazine/Features/FeedLibrary/Sources/FeedLibrary/ViewModel/FeedViewModel.swift index 555bcbff..5a9e2da3 100644 --- a/MacMagazine/Features/FeedLibrary/Sources/FeedLibrary/ViewModel/FeedViewModel.swift +++ b/MacMagazine/Features/FeedLibrary/Sources/FeedLibrary/ViewModel/FeedViewModel.swift @@ -47,10 +47,17 @@ public class FeedViewModel { async let reviews = self.fetch(category: .reviews, page: page) async let tutoriais = self.fetch(category: .tutorials, page: page) async let rumors = self.fetch(category: .rumors, page: page) - let feed = try await [highlights, appletv, reviews, tutoriais, rumors] - self.storage.save(feed: Array(feed.joined()).toFeedDB) - let posts = try await self.fetch(category: .news, page: page) - self.storage.save(feed: posts.toFeedDB) + async let news = self.fetch(category: .news, page: page) + + let groups: [(category: NewsCategory, posts: [FeedDB])] = [ + (.highlights, try await highlights.toFeedDB), + (.appletv, try await appletv.toFeedDB), + (.reviews, try await reviews.toFeedDB), + (.tutorials, try await tutoriais.toFeedDB), + (.rumors, try await rumors.toFeedDB), + (.news, try await news.toFeedDB) + ] + self.storage.save(feed: groups) }.value status = .done } catch { diff --git a/MacMagazine/Features/FeedLibrary/Tests/FeedLibraryTests/StorageServiceTests.swift b/MacMagazine/Features/FeedLibrary/Tests/FeedLibraryTests/StorageServiceTests.swift index 2142d72a..a0ba0032 100644 --- a/MacMagazine/Features/FeedLibrary/Tests/FeedLibraryTests/StorageServiceTests.swift +++ b/MacMagazine/Features/FeedLibrary/Tests/FeedLibraryTests/StorageServiceTests.swift @@ -1,5 +1,6 @@ @testable import FeedLibrary import Foundation +import MacMagazineLibrary import StorageLibrary import Testing @@ -379,6 +380,126 @@ struct StorageServiceTests { #expect(cats.contains("Reviews")) } + // MARK: - Category Reconciliation Tests + + @Test("grouped save inserts a post carrying its fetched category key") + func groupedSaveAddsCategoryKey() { + let storage = Database(models: [FeedDB.self], inMemory: true) + let post = FeedDB(postId: "1", title: "Rumor", pubDate: Date(), categories: [NewsCategory.rumors.filterKey]) + + storage.save(feed: [(category: NewsCategory.rumors, posts: [post])]) + + let fetched = storage.fetch(FeedDB.self).first + #expect(fetched?.categories.contains(NewsCategory.rumors.filterKey) == true) + } + + @Test("grouped save removes a stale category key for a post that dropped out of its date window") + func groupedSaveRemovesStaleCategoryKey() { + let storage = Database(models: [FeedDB.self], inMemory: true) + let earlier = Date(timeIntervalSince1970: 900_000) + let dehighlightedDate = Date(timeIntervalSince1970: 1_000_000) + let later = Date(timeIntervalSince1970: 1_100_000) + + let dehighlighted = FeedDB( + postId: "1", + title: "No Longer Highlighted", + pubDate: dehighlightedDate, + categories: [NewsCategory.highlights.filterKey] + ) + storage.save(feed: dehighlighted) + + let before = FeedDB(postId: "2", title: "Before", pubDate: earlier, categories: [NewsCategory.highlights.filterKey]) + let after = FeedDB(postId: "3", title: "After", pubDate: later, categories: [NewsCategory.highlights.filterKey]) + + storage.save(feed: [(category: NewsCategory.highlights, posts: [before, after])]) + + let predicate = #Predicate { $0.postId == "1" } + let fetched = storage.fetch(FeedDB.self, predicate: predicate).first + #expect(fetched?.categories.contains(NewsCategory.highlights.filterKey) == false) + } + + @Test("grouped save never touches a sibling category's key while reconciling") + func groupedSavePreservesSiblingCategoryKey() { + let storage = Database(models: [FeedDB.self], inMemory: true) + let date = Date(timeIntervalSince1970: 1_000_000) + + let multiCategory = FeedDB( + postId: "1", + title: "Review And Rumor", + pubDate: date, + categories: [NewsCategory.reviews.filterKey, NewsCategory.rumors.filterKey] + ) + storage.save(feed: multiCategory) + + let otherReview = FeedDB(postId: "2", title: "Other Review", pubDate: date, categories: [NewsCategory.reviews.filterKey]) + storage.save(feed: [(category: NewsCategory.reviews, posts: [otherReview])]) + + let predicate = #Predicate { $0.postId == "1" } + let fetched = storage.fetch(FeedDB.self, predicate: predicate).first + #expect(fetched?.categories.contains(NewsCategory.reviews.filterKey) == false) + #expect(fetched?.categories.contains(NewsCategory.rumors.filterKey) == true) + } + + @Test("grouped save skips reconciliation when the fetched category result is empty") + func groupedSaveSkipsReconciliationForEmptyResult() { + let storage = Database(models: [FeedDB.self], inMemory: true) + let highlighted = FeedDB( + postId: "1", + title: "Highlight", + pubDate: Date(), + categories: [NewsCategory.highlights.filterKey] + ) + storage.save(feed: highlighted) + + storage.save(feed: [(category: NewsCategory.highlights, posts: [])]) + + let fetched = storage.fetch(FeedDB.self).first + #expect(fetched?.categories.contains(NewsCategory.highlights.filterKey) == true) + } + + @Test("grouped save leaves a category key untouched when the post falls outside the fetched date window") + func groupedSavePreservesKeyOutsideDateWindow() { + let storage = Database(models: [FeedDB.self], inMemory: true) + let farInThePast = Date(timeIntervalSince1970: 0) + let recent = Date(timeIntervalSince1970: 2_000_000) + + let oldHighlight = FeedDB( + postId: "1", + title: "Old Highlight", + pubDate: farInThePast, + categories: [NewsCategory.highlights.filterKey] + ) + storage.save(feed: oldHighlight) + + let recentHighlight = FeedDB(postId: "2", title: "Recent Highlight", pubDate: recent, categories: [NewsCategory.highlights.filterKey]) + storage.save(feed: [(category: NewsCategory.highlights, posts: [recentHighlight])]) + + let predicate = #Predicate { $0.postId == "1" } + let fetched = storage.fetch(FeedDB.self, predicate: predicate).first + #expect(fetched?.categories.contains(NewsCategory.highlights.filterKey) == true) + } + + @Test("grouped save does not reconcile the news category") + func groupedSaveDoesNotReconcileNewsCategory() { + let storage = Database(models: [FeedDB.self], inMemory: true) + let date = Date(timeIntervalSince1970: 1_000_000) + + let post = FeedDB( + postId: "1", + title: "Old News Marker", + pubDate: date, + categories: [NewsCategory.news.filterKey] + ) + storage.save(feed: post) + + let otherPost = FeedDB(postId: "2", title: "Other Post", pubDate: date, categories: [NewsCategory.news.filterKey]) + storage.save(feed: [(category: NewsCategory.news, posts: [otherPost])]) + + let predicate = #Predicate { $0.postId == "1" } + let fetched = storage.fetch(FeedDB.self, predicate: predicate).first + #expect(fetched?.categories.contains(NewsCategory.news.filterKey) == true) + } + @Test("save should preserve favorite status on update for podcast") func savePreservesFavoriteStatusForPodcast() { let storage = Database(models: [PodcastDB.self], inMemory: true)