Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
49 changes: 23 additions & 26 deletions Pulse/Home/API/CachingNewsService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -141,30 +141,31 @@ final class CachingNewsService: NewsService {

// 3. If offline: serve stale data or fail
if networkMonitor?.isConnected == false {
// Try stale L1
if let staleL1: CacheEntry<T> = memoryCacheStore.get(for: key) {
Logger.shared.service("Offline: serving stale L1 for \(label)", level: .debug)
return Just(staleL1.data)
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
// Try stale L2 (expired but better than nothing)
if let staleL2: CacheEntry<T> = diskCacheStore?.get(for: key) {
Logger.shared.service("Offline: serving stale L2 for \(label)", level: .debug)
return Just(staleL2.data)
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
Logger.shared.service("Offline: no cache for \(label)", level: .debug)
return Fail(error: PulseError.offlineNoCache)
.eraseToAnyPublisher()
return serveStaleData(for: key, label: label)
}

// 4. Online: fetch from network, write-through to both caches
// MARK: Retry budget: 2 retries × 15s timeout = up to ~48s worst case per cache miss
return fetchFromNetwork(key: key, label: label, networkFetch: networkFetch)
}

// (includes exponential backoff delays: 1s + 2s). If the underlying service has its own
// fallback chain (e.g. Supabase → Guardian), retries re-attempt the full chain.
private func serveStaleData<T>(for key: NewsCacheKey, label: String) -> AnyPublisher<T, Error> {
if let staleL1: CacheEntry<T> = memoryCacheStore.get(for: key) {
Logger.shared.service("Offline: serving stale L1 for \(label)", level: .debug)
return Just(staleL1.data).setFailureType(to: Error.self).eraseToAnyPublisher()
}
if let staleL2: CacheEntry<T> = diskCacheStore?.get(for: key) {
Logger.shared.service("Offline: serving stale L2 for \(label)", level: .debug)
return Just(staleL2.data).setFailureType(to: Error.self).eraseToAnyPublisher()
}
Logger.shared.service("Offline: no cache for \(label)", level: .debug)
return Fail(error: PulseError.offlineNoCache).eraseToAnyPublisher()
}

private func fetchFromNetwork<T>(
key: NewsCacheKey,
label: String,
networkFetch: @escaping () -> AnyPublisher<T, Error>
) -> AnyPublisher<T, Error> {
Logger.shared.service("Cache miss for \(label)", level: .debug)
let fetch = networkFetch()
let resilientFetch = networkResilienceEnabled
Expand All @@ -177,15 +178,11 @@ final class CachingNewsService: NewsService {
self?.diskCacheStore?.set(entry, for: key)
})
.catch { [weak self] error -> AnyPublisher<T, Error> in
// On network failure, fall back to stale disk cache
if let staleL2: CacheEntry<T> = self?.diskCacheStore?.get(for: key) {
Logger.shared.service("Network error: serving stale L2 for \(label)", level: .debug)
return Just(staleL2.data)
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
return Just(staleL2.data).setFailureType(to: Error.self).eraseToAnyPublisher()
}
return Fail(error: error)
.eraseToAnyPublisher()
return Fail(error: error).eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
Expand Down
10 changes: 4 additions & 6 deletions Pulse/Home/Domain/HomeDomainInteractor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -120,19 +120,17 @@ final class HomeDomainInteractor: CombineInteractor {
private func handleArticleActions(_ action: HomeDomainAction) -> Bool {
switch action {
case let .selectArticle(articleId):
if let article = findArticle(by: articleId) { selectArticle(article) }
findArticle(by: articleId).map(selectArticle)
case .clearSelectedArticle:
clearSelectedArticle()
case let .bookmarkArticle(articleId):
if let article = findArticle(by: articleId) { toggleBookmark(article) }
findArticle(by: articleId).map(toggleBookmark)
case let .shareArticle(articleId):
if let article = findArticle(by: articleId) { shareArticle(article) }
findArticle(by: articleId).map(shareArticle)
case .clearArticleToShare:
clearArticleToShare()
case let .selectRecentlyRead(articleId):
if let article = currentState.recentlyRead.first(where: { $0.id == articleId }) {
selectArticle(article)
}
currentState.recentlyRead.first { $0.id == articleId }.map(selectArticle)
default:
return false
}
Expand Down
16 changes: 0 additions & 16 deletions Pulse/Home/View/HomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,22 +84,6 @@ enum HomeViewConstants {
// MARK: - HomeView

/// Main home screen displaying breaking news and headline feeds.
///
/// This view follows the generic router pattern for testability, accepting any type
/// conforming to `HomeNavigationRouter` for navigation handling.
///
/// ## Features
/// - Breaking news carousel at the top
/// - Scrollable headline feed with infinite scroll
/// - Pull-to-refresh with cache invalidation
/// - Settings access via toolbar button
/// - Article sharing via share sheet
///
/// ## Usage
/// ```swift
/// HomeView(router: HomeNavigationRouter(coordinator: coordinator),
/// viewModel: HomeViewModel(serviceLocator: serviceLocator))
/// ```
struct HomeView<R: HomeNavigationRouter>: View {
// MARK: - Properties

Expand Down
47 changes: 24 additions & 23 deletions Pulse/Media/API/CachingMediaService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,26 +117,31 @@ final class CachingMediaService: MediaService {

// 3. If offline: serve stale data or fail
if networkMonitor?.isConnected == false {
// Try stale L1
if let staleL1: CacheEntry<T> = memoryCacheStore.get(for: key) {
Logger.shared.service("Offline: serving stale L1 for \(label)", level: .debug)
return Just(staleL1.data)
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
// Try stale L2 (expired but better than nothing)
if let staleL2: CacheEntry<T> = diskCacheStore?.get(for: key) {
Logger.shared.service("Offline: serving stale L2 for \(label)", level: .debug)
return Just(staleL2.data)
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
Logger.shared.service("Offline: no cache for \(label)", level: .debug)
return Fail(error: PulseError.offlineNoCache)
.eraseToAnyPublisher()
return serveStaleData(for: key, label: label)
}

// 4. Online: fetch from network, write-through to both caches
return fetchFromNetwork(key: key, label: label, networkFetch: networkFetch)
}

private func serveStaleData<T>(for key: NewsCacheKey, label: String) -> AnyPublisher<T, Error> {
if let staleL1: CacheEntry<T> = memoryCacheStore.get(for: key) {
Logger.shared.service("Offline: serving stale L1 for \(label)", level: .debug)
return Just(staleL1.data).setFailureType(to: Error.self).eraseToAnyPublisher()
}
if let staleL2: CacheEntry<T> = diskCacheStore?.get(for: key) {
Logger.shared.service("Offline: serving stale L2 for \(label)", level: .debug)
return Just(staleL2.data).setFailureType(to: Error.self).eraseToAnyPublisher()
}
Logger.shared.service("Offline: no cache for \(label)", level: .debug)
return Fail(error: PulseError.offlineNoCache).eraseToAnyPublisher()
}

private func fetchFromNetwork<T>(
key: NewsCacheKey,
label: String,
networkFetch: @escaping () -> AnyPublisher<T, Error>
) -> AnyPublisher<T, Error> {
Logger.shared.service("Cache miss for \(label)", level: .debug)
let fetch = networkFetch()
let resilientFetch = networkResilienceEnabled
Expand All @@ -149,15 +154,11 @@ final class CachingMediaService: MediaService {
self?.diskCacheStore?.set(entry, for: key)
})
.catch { [weak self] error -> AnyPublisher<T, Error> in
// On network failure, fall back to stale disk cache
if let staleL2: CacheEntry<T> = self?.diskCacheStore?.get(for: key) {
Logger.shared.service("Network error: serving stale L2 for \(label)", level: .debug)
return Just(staleL2.data)
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
return Just(staleL2.data).setFailureType(to: Error.self).eraseToAnyPublisher()
}
return Fail(error: error)
.eraseToAnyPublisher()
return Fail(error: error).eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
Expand Down
11 changes: 10 additions & 1 deletion PulseTests/Configs/Networking/APIContractTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -223,8 +223,17 @@ struct SupabaseAPIContractTests {
#expect(articles.count == 1)
#expect(articles[0].id == "extra-001")
}
}

// MARK: - Supabase Article Mapping Contract Tests

// MARK: - toArticle Content Mapping
@Suite("Supabase Article Mapping Contract Tests")
struct SupabaseArticleMappingContractTests {
private let decoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return decoder
}()

@Test("toArticle maps content and summary correctly when both present")
func toArticleMapsContentAndSummary() throws {
Expand Down
7 changes: 6 additions & 1 deletion PulseTests/Home/API/CachingNewsServiceArticleTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ struct CachingNewsServiceArticleTests {
init() {
mockNewsService = MockNewsService()
mockCacheStore = MockNewsCacheStore()
sut = CachingNewsService(wrapping: mockNewsService, cacheStore: mockCacheStore, diskCacheStore: nil, networkResilienceEnabled: false)
sut = CachingNewsService(
wrapping: mockNewsService,
cacheStore: mockCacheStore,
diskCacheStore: nil,
networkResilienceEnabled: false
)
}

@Test("fetchArticle returns cached article when available")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ struct CachingNewsServiceBreakingNewsTests {
init() {
mockNewsService = MockNewsService()
mockCacheStore = MockNewsCacheStore()
sut = CachingNewsService(wrapping: mockNewsService, cacheStore: mockCacheStore, diskCacheStore: nil, networkResilienceEnabled: false)
sut = CachingNewsService(
wrapping: mockNewsService,
cacheStore: mockCacheStore,
diskCacheStore: nil,
networkResilienceEnabled: false
)
}

@Test("fetchBreakingNews returns cached data when available")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ struct CachingNewsServiceCacheInvalidationTests {
init() {
mockNewsService = MockNewsService()
mockCacheStore = MockNewsCacheStore()
sut = CachingNewsService(wrapping: mockNewsService, cacheStore: mockCacheStore, diskCacheStore: nil, networkResilienceEnabled: false)
sut = CachingNewsService(
wrapping: mockNewsService,
cacheStore: mockCacheStore,
diskCacheStore: nil,
networkResilienceEnabled: false
)
}

@Test("invalidateCache clears all cached data")
Expand Down
7 changes: 6 additions & 1 deletion PulseTests/Home/API/CachingNewsServiceCategoryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ struct CachingNewsServiceCategoryTests {
init() {
mockNewsService = MockNewsService()
mockCacheStore = MockNewsCacheStore()
sut = CachingNewsService(wrapping: mockNewsService, cacheStore: mockCacheStore, diskCacheStore: nil, networkResilienceEnabled: false)
sut = CachingNewsService(
wrapping: mockNewsService,
cacheStore: mockCacheStore,
diskCacheStore: nil,
networkResilienceEnabled: false
)
}

@Test("fetchTopHeadlines with category returns cached data when available")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ struct CachingNewsServiceTopHeadlinesTests {
init() {
mockNewsService = MockNewsService()
mockCacheStore = MockNewsCacheStore()
sut = CachingNewsService(wrapping: mockNewsService, cacheStore: mockCacheStore, diskCacheStore: nil, networkResilienceEnabled: false)
sut = CachingNewsService(
wrapping: mockNewsService,
cacheStore: mockCacheStore,
diskCacheStore: nil,
networkResilienceEnabled: false
)
}

@Test("fetchTopHeadlines returns cached data when available and not expired")
Expand Down
2 changes: 2 additions & 0 deletions project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,8 @@ targets:
deploymentTarget: "26.3"
sources:
- path: PulseSnapshotTests
excludes:
- "**/__Snapshots__/**"
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.bruno.PulseSnapshotTests
Expand Down
Loading