Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

# Opt into Node.js 24 to suppress Node.js 20 deprecation warnings from actions
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

jobs:
code-quality:
name: Code Quality
Expand Down
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
30 changes: 15 additions & 15 deletions PulseUITests/ArticleDetailUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ final class ArticleDetailUITests: BaseUITestCase {

// Wait for articles to load
let topHeadlinesHeader = app.staticTexts["Top Headlines"]
guard topHeadlinesHeader.waitForExistence(timeout: 10) else {
guard topHeadlinesHeader.safeWaitForExistence(timeout: 10) else {
return false
}

Expand All @@ -25,7 +25,7 @@ final class ArticleDetailUITests: BaseUITestCase {
}

let firstCard = articleCards.firstMatch
guard firstCard.waitForExistence(timeout: 5) else {
guard firstCard.safeWaitForExistence(timeout: 5) else {
return false
}

Expand Down Expand Up @@ -63,18 +63,18 @@ final class ArticleDetailUITests: BaseUITestCase {
if bookmarkButton.exists {
bookmarkButton.tap()
XCTAssertTrue(
bookmarkFilledButton.waitForExistence(timeout: 3),
bookmarkFilledButton.safeWaitForExistence(timeout: 3),
"Bookmark should become filled after tapping"
)
} else if bookmarkFilledButton.exists {
bookmarkFilledButton.tap()
XCTAssertTrue(
bookmarkButton.waitForExistence(timeout: 3),
bookmarkButton.safeWaitForExistence(timeout: 3),
"Bookmark should become unfilled after tapping"
)
bookmarkButton.tap()
XCTAssertTrue(
bookmarkFilledButton.waitForExistence(timeout: 3),
bookmarkFilledButton.safeWaitForExistence(timeout: 3),
"Bookmark should become filled after tapping again"
)
}
Expand All @@ -86,9 +86,9 @@ final class ArticleDetailUITests: BaseUITestCase {
let copyButton = app.buttons["Copy"]
let closeButton = app.buttons["Close"]

let shareSheetAppeared = shareSheet.waitForExistence(timeout: 5) ||
copyButton.waitForExistence(timeout: 5) ||
closeButton.waitForExistence(timeout: 5)
let shareSheetAppeared = shareSheet.safeWaitForExistence(timeout: 5) ||
copyButton.safeWaitForExistence(timeout: 5) ||
closeButton.safeWaitForExistence(timeout: 5)

XCTAssertTrue(shareSheetAppeared, "Share sheet should appear after tapping share button")

Expand All @@ -101,7 +101,7 @@ final class ArticleDetailUITests: BaseUITestCase {
// Wait for share sheet to dismiss and back button to become available
let backButtonAfterShare = app.buttons["backButton"]
XCTAssertTrue(
backButtonAfterShare.waitForExistence(timeout: 5),
backButtonAfterShare.safeWaitForExistence(timeout: 5),
"Back button should exist after share sheet dismissed"
)

Expand All @@ -115,7 +115,7 @@ final class ArticleDetailUITests: BaseUITestCase {
XCTAssertTrue(hasMetadata, "Article should display metadata (author, source, or date)")

let scrollView = app.scrollViews["articleDetailScrollView"]
XCTAssertTrue(scrollView.waitForExistence(timeout: 5), "Article detail should have a scroll view")
XCTAssertTrue(scrollView.safeWaitForExistence(timeout: 5), "Article detail should have a scroll view")

let scrollViewGeneric = app.scrollViews.firstMatch
XCTAssertTrue(scrollViewGeneric.exists, "Article detail should have a scroll view")
Expand All @@ -128,7 +128,7 @@ final class ArticleDetailUITests: BaseUITestCase {
NSPredicate(format: "label CONTAINS[c] 'Read Full Article'")
).firstMatch
XCTAssertTrue(
readFullButton.waitForExistence(timeout: 3),
readFullButton.safeWaitForExistence(timeout: 3),
"Read Full Article button should be visible after scrolling"
)

Expand Down Expand Up @@ -165,12 +165,12 @@ final class ArticleDetailUITests: BaseUITestCase {
// Check various indicators that we're back at home
let topHeadlinesHeader = app.staticTexts["Top Headlines"]
let breakingNewsHeader = app.staticTexts["Breaking News"]
let homeTabSelected = homeTab.waitForExistence(timeout: 5) && homeTab.isSelected
let homeTabSelected = homeTab.safeWaitForExistence(timeout: 5) && homeTab.isSelected

let navigatedBack = homeNavBar.waitForExistence(timeout: 10) ||
let navigatedBack = homeNavBar.safeWaitForExistence(timeout: 10) ||
homeTabSelected ||
topHeadlinesHeader.waitForExistence(timeout: 3) ||
breakingNewsHeader.waitForExistence(timeout: 3)
topHeadlinesHeader.safeWaitForExistence(timeout: 3) ||
breakingNewsHeader.safeWaitForExistence(timeout: 3)

XCTAssertTrue(navigatedBack, "Should navigate back to Home")

Expand Down
Loading
Loading