Skip to content
Closed
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
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