Skip to content

Commit d716fdd

Browse files
authored
Merge pull request #308 from MacMagazine/fix/feed-category-reconciliation
fix(#297): reconcile FeedDB category membership on each fetch
2 parents 5553b51 + e886048 commit d716fdd

3 files changed

Lines changed: 181 additions & 4 deletions

File tree

‎MacMagazine/Features/FeedLibrary/Sources/FeedLibrary/Services/StorageService.swift‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,24 @@ extension Database {
2121
save(feed: feed, in: sharedModelContainer.mainContext)
2222
}
2323

24+
/// Saves category-specific fetch results and reconciles stale category membership.
25+
///
26+
/// Each group's synthetic category key (`NewsCategory.filterKey`) is added to matching posts
27+
/// as usual, then removed from any locally stored post whose `pubDate` falls inside the
28+
/// group's fetched date range but which the fetch no longer returned - covering the case
29+
/// where the server removed a post from that category (e.g. a de-highlighted post).
30+
@MainActor
31+
func save(feed groups: [(category: NewsCategory, posts: [FeedDB])]) {
32+
let ctx = sharedModelContainer.mainContext
33+
for group in groups {
34+
group.posts.forEach {
35+
_ = save(feed: $0, in: ctx)
36+
}
37+
reconcile(category: group.category, fetched: group.posts, in: ctx)
38+
}
39+
FeedDB.deduplicate(using: ctx)
40+
}
41+
2442
@MainActor
2543
private func save(feed: FeedDB, in ctx: ModelContext) -> FeedDB {
2644
let postId = feed.postId
@@ -44,6 +62,37 @@ extension Database {
4462
try? ctx.save()
4563
return feed
4664
}
65+
66+
/// Categories whose membership is reconciled (added and removed) on each fetch.
67+
///
68+
/// `.news` is excluded: it represents the unfiltered feed, so removal has no meaning there
69+
/// and its date window is the widest, making false-positive removals most likely.
70+
private static let reconcilableCategories: Set<NewsCategory> = [
71+
.highlights, .appletv, .reviews, .tutorials, .rumors
72+
]
73+
74+
@MainActor
75+
private func reconcile(category: NewsCategory, fetched: [FeedDB], in ctx: ModelContext) {
76+
guard Self.reconcilableCategories.contains(category),
77+
let windowStart = fetched.map(\.pubDate).min(),
78+
let windowEnd = fetched.map(\.pubDate).max() else { return }
79+
80+
let key = category.filterKey
81+
let fetchedIds = Set(fetched.map(\.postId))
82+
let descriptor = FetchDescriptor<FeedDB>(
83+
predicate: #Predicate { $0.pubDate >= windowStart && $0.pubDate <= windowEnd }
84+
)
85+
86+
guard let candidates = try? ctx.fetch(descriptor) else { return }
87+
let stale = candidates.filter { $0.categories.contains(key) && !fetchedIds.contains($0.postId) }
88+
guard !stale.isEmpty else { return }
89+
90+
for post in stale {
91+
post.categories.removeAll { $0 == key }
92+
post.modifiedAt = Date()
93+
}
94+
try? ctx.save()
95+
}
4796
}
4897

4998
// MARK: - Podcast -

‎MacMagazine/Features/FeedLibrary/Sources/FeedLibrary/ViewModel/FeedViewModel.swift‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,17 @@ public class FeedViewModel {
4747
async let reviews = self.fetch(category: .reviews, page: page)
4848
async let tutoriais = self.fetch(category: .tutorials, page: page)
4949
async let rumors = self.fetch(category: .rumors, page: page)
50-
let feed = try await [highlights, appletv, reviews, tutoriais, rumors]
51-
self.storage.save(feed: Array(feed.joined()).toFeedDB)
52-
let posts = try await self.fetch(category: .news, page: page)
53-
self.storage.save(feed: posts.toFeedDB)
50+
async let news = self.fetch(category: .news, page: page)
51+
52+
let groups: [(category: NewsCategory, posts: [FeedDB])] = [
53+
(.highlights, try await highlights.toFeedDB),
54+
(.appletv, try await appletv.toFeedDB),
55+
(.reviews, try await reviews.toFeedDB),
56+
(.tutorials, try await tutoriais.toFeedDB),
57+
(.rumors, try await rumors.toFeedDB),
58+
(.news, try await news.toFeedDB)
59+
]
60+
self.storage.save(feed: groups)
5461
}.value
5562
status = .done
5663
} catch {

‎MacMagazine/Features/FeedLibrary/Tests/FeedLibraryTests/StorageServiceTests.swift‎

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
@testable import FeedLibrary
22
import Foundation
3+
import MacMagazineLibrary
34
import StorageLibrary
45
import Testing
56

@@ -379,6 +380,126 @@ struct StorageServiceTests {
379380
#expect(cats.contains("Reviews"))
380381
}
381382

383+
// MARK: - Category Reconciliation Tests
384+
385+
@Test("grouped save inserts a post carrying its fetched category key")
386+
func groupedSaveAddsCategoryKey() {
387+
let storage = Database(models: [FeedDB.self], inMemory: true)
388+
let post = FeedDB(postId: "1", title: "Rumor", pubDate: Date(), categories: [NewsCategory.rumors.filterKey])
389+
390+
storage.save(feed: [(category: NewsCategory.rumors, posts: [post])])
391+
392+
let fetched = storage.fetch(FeedDB.self).first
393+
#expect(fetched?.categories.contains(NewsCategory.rumors.filterKey) == true)
394+
}
395+
396+
@Test("grouped save removes a stale category key for a post that dropped out of its date window")
397+
func groupedSaveRemovesStaleCategoryKey() {
398+
let storage = Database(models: [FeedDB.self], inMemory: true)
399+
let earlier = Date(timeIntervalSince1970: 900_000)
400+
let dehighlightedDate = Date(timeIntervalSince1970: 1_000_000)
401+
let later = Date(timeIntervalSince1970: 1_100_000)
402+
403+
let dehighlighted = FeedDB(
404+
postId: "1",
405+
title: "No Longer Highlighted",
406+
pubDate: dehighlightedDate,
407+
categories: [NewsCategory.highlights.filterKey]
408+
)
409+
storage.save(feed: dehighlighted)
410+
411+
let before = FeedDB(postId: "2", title: "Before", pubDate: earlier, categories: [NewsCategory.highlights.filterKey])
412+
let after = FeedDB(postId: "3", title: "After", pubDate: later, categories: [NewsCategory.highlights.filterKey])
413+
414+
storage.save(feed: [(category: NewsCategory.highlights, posts: [before, after])])
415+
416+
let predicate = #Predicate<FeedDB> { $0.postId == "1" }
417+
let fetched = storage.fetch(FeedDB.self, predicate: predicate).first
418+
#expect(fetched?.categories.contains(NewsCategory.highlights.filterKey) == false)
419+
}
420+
421+
@Test("grouped save never touches a sibling category's key while reconciling")
422+
func groupedSavePreservesSiblingCategoryKey() {
423+
let storage = Database(models: [FeedDB.self], inMemory: true)
424+
let date = Date(timeIntervalSince1970: 1_000_000)
425+
426+
let multiCategory = FeedDB(
427+
postId: "1",
428+
title: "Review And Rumor",
429+
pubDate: date,
430+
categories: [NewsCategory.reviews.filterKey, NewsCategory.rumors.filterKey]
431+
)
432+
storage.save(feed: multiCategory)
433+
434+
let otherReview = FeedDB(postId: "2", title: "Other Review", pubDate: date, categories: [NewsCategory.reviews.filterKey])
435+
storage.save(feed: [(category: NewsCategory.reviews, posts: [otherReview])])
436+
437+
let predicate = #Predicate<FeedDB> { $0.postId == "1" }
438+
let fetched = storage.fetch(FeedDB.self, predicate: predicate).first
439+
#expect(fetched?.categories.contains(NewsCategory.reviews.filterKey) == false)
440+
#expect(fetched?.categories.contains(NewsCategory.rumors.filterKey) == true)
441+
}
442+
443+
@Test("grouped save skips reconciliation when the fetched category result is empty")
444+
func groupedSaveSkipsReconciliationForEmptyResult() {
445+
let storage = Database(models: [FeedDB.self], inMemory: true)
446+
let highlighted = FeedDB(
447+
postId: "1",
448+
title: "Highlight",
449+
pubDate: Date(),
450+
categories: [NewsCategory.highlights.filterKey]
451+
)
452+
storage.save(feed: highlighted)
453+
454+
storage.save(feed: [(category: NewsCategory.highlights, posts: [])])
455+
456+
let fetched = storage.fetch(FeedDB.self).first
457+
#expect(fetched?.categories.contains(NewsCategory.highlights.filterKey) == true)
458+
}
459+
460+
@Test("grouped save leaves a category key untouched when the post falls outside the fetched date window")
461+
func groupedSavePreservesKeyOutsideDateWindow() {
462+
let storage = Database(models: [FeedDB.self], inMemory: true)
463+
let farInThePast = Date(timeIntervalSince1970: 0)
464+
let recent = Date(timeIntervalSince1970: 2_000_000)
465+
466+
let oldHighlight = FeedDB(
467+
postId: "1",
468+
title: "Old Highlight",
469+
pubDate: farInThePast,
470+
categories: [NewsCategory.highlights.filterKey]
471+
)
472+
storage.save(feed: oldHighlight)
473+
474+
let recentHighlight = FeedDB(postId: "2", title: "Recent Highlight", pubDate: recent, categories: [NewsCategory.highlights.filterKey])
475+
storage.save(feed: [(category: NewsCategory.highlights, posts: [recentHighlight])])
476+
477+
let predicate = #Predicate<FeedDB> { $0.postId == "1" }
478+
let fetched = storage.fetch(FeedDB.self, predicate: predicate).first
479+
#expect(fetched?.categories.contains(NewsCategory.highlights.filterKey) == true)
480+
}
481+
482+
@Test("grouped save does not reconcile the news category")
483+
func groupedSaveDoesNotReconcileNewsCategory() {
484+
let storage = Database(models: [FeedDB.self], inMemory: true)
485+
let date = Date(timeIntervalSince1970: 1_000_000)
486+
487+
let post = FeedDB(
488+
postId: "1",
489+
title: "Old News Marker",
490+
pubDate: date,
491+
categories: [NewsCategory.news.filterKey]
492+
)
493+
storage.save(feed: post)
494+
495+
let otherPost = FeedDB(postId: "2", title: "Other Post", pubDate: date, categories: [NewsCategory.news.filterKey])
496+
storage.save(feed: [(category: NewsCategory.news, posts: [otherPost])])
497+
498+
let predicate = #Predicate<FeedDB> { $0.postId == "1" }
499+
let fetched = storage.fetch(FeedDB.self, predicate: predicate).first
500+
#expect(fetched?.categories.contains(NewsCategory.news.filterKey) == true)
501+
}
502+
382503
@Test("save should preserve favorite status on update for podcast")
383504
func savePreservesFavoriteStatusForPodcast() {
384505
let storage = Database(models: [PodcastDB.self], inMemory: true)

0 commit comments

Comments
 (0)