Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<NewsCategory> = [
.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<FeedDB>(
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 -
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
@testable import FeedLibrary
import Foundation
import MacMagazineLibrary
import StorageLibrary
import Testing

Expand Down Expand Up @@ -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<FeedDB> { $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<FeedDB> { $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<FeedDB> { $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<FeedDB> { $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)
Expand Down
Loading