mirror of
https://codeberg.org/secana/Forji.git
synced 2026-06-16 05:13:55 -07:00
When the Forgejo API returns 404 for the issues endpoint (issues disabled for the repository), display a ContentUnavailableView instead of surfacing the raw HTTP error alert. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
477 lines
16 KiB
Swift
477 lines
16 KiB
Swift
// swiftlint:disable file_length
|
|
import Foundation
|
|
import Testing
|
|
@testable import Forji
|
|
|
|
// MARK: - Test helpers
|
|
|
|
/// Waits on MainActor until a condition is true, yielding between checks.
|
|
@MainActor
|
|
private func yieldUntil(_ condition: @MainActor () -> Bool) async {
|
|
for _ in 0 ..< 500 {
|
|
if condition() { return }
|
|
await Task.yield()
|
|
}
|
|
}
|
|
|
|
/// A fetch whose completion the test controls via continuations.
|
|
@MainActor
|
|
private final class ControllableFetch {
|
|
private(set) var callCount = 0
|
|
private var continuation: CheckedContinuation<[String], any Error>?
|
|
var isPending: Bool { continuation != nil }
|
|
|
|
func fetch(page: Int, limit: Int) async throws -> [String] {
|
|
callCount += 1
|
|
return try await withCheckedThrowingContinuation { cont in
|
|
self.continuation = cont
|
|
}
|
|
}
|
|
|
|
func complete(returning items: [String]) {
|
|
let cont = continuation
|
|
continuation = nil
|
|
cont?.resume(returning: items)
|
|
}
|
|
|
|
func complete(throwing error: any Error) {
|
|
let cont = continuation
|
|
continuation = nil
|
|
cont?.resume(throwing: error)
|
|
}
|
|
}
|
|
|
|
// MARK: - Basic reload
|
|
|
|
struct PaginationStateBasicTests {
|
|
|
|
@Test @MainActor func reloadSetsItems() async {
|
|
let pagination = PaginationState<String>(pageSize: 5)
|
|
await pagination.reload { _, _ in ["a", "b", "c"] }.value
|
|
#expect(pagination.items == ["a", "b", "c"])
|
|
#expect(!pagination.isLoading)
|
|
#expect(!pagination.hasMore) // 3 < pageSize 5
|
|
}
|
|
|
|
@Test @MainActor func reloadSetsHasMoreWhenFull() async {
|
|
let pagination = PaginationState<String>(pageSize: 3)
|
|
await pagination.reload { _, _ in ["a", "b", "c"] }.value
|
|
#expect(pagination.hasMore) // 3 >= pageSize 3
|
|
}
|
|
|
|
@Test @MainActor func reloadReplacesExistingItems() async {
|
|
let pagination = PaginationState<String>(pageSize: 20)
|
|
await pagination.reload { _, _ in ["old"] }.value
|
|
await pagination.reload { _, _ in ["new"] }.value
|
|
#expect(pagination.items == ["new"])
|
|
}
|
|
|
|
@Test @MainActor func reloadError() async {
|
|
let pagination = PaginationState<String>(pageSize: 20)
|
|
await pagination.reload { _, _ in throw URLError(.badServerResponse) }.value
|
|
#expect(pagination.items.isEmpty)
|
|
#expect(pagination.showError)
|
|
#expect(pagination.errorMessage != nil)
|
|
#expect(!pagination.isLoading)
|
|
}
|
|
|
|
@Test @MainActor func reloadClearsErrorState() async {
|
|
let pagination = PaginationState<String>(pageSize: 20)
|
|
await pagination.reload { _, _ in throw URLError(.badServerResponse) }.value
|
|
#expect(pagination.showError)
|
|
|
|
await pagination.reload { _, _ in ["ok"] }.value
|
|
#expect(!pagination.showError)
|
|
#expect(pagination.items == ["ok"])
|
|
}
|
|
|
|
@Test @MainActor func clearItemsEmptiesImmediately() async {
|
|
let pagination = PaginationState<String>(pageSize: 20)
|
|
await pagination.reload { _, _ in ["a", "b"] }.value
|
|
|
|
let fetcher = ControllableFetch()
|
|
let task = pagination.reload(clearItems: true) { page, limit in
|
|
try await fetcher.fetch(page: page, limit: limit)
|
|
}
|
|
// Items should be cleared synchronously before the fetch starts
|
|
#expect(pagination.items.isEmpty)
|
|
#expect(pagination.isLoading)
|
|
|
|
await yieldUntil { fetcher.isPending }
|
|
fetcher.complete(returning: ["c"])
|
|
await task.value
|
|
#expect(pagination.items == ["c"])
|
|
#expect(!pagination.isLoading)
|
|
}
|
|
}
|
|
|
|
// MARK: - Cancellation
|
|
|
|
struct PaginationStateCancellationTests {
|
|
|
|
@Test @MainActor func cancellationErrorIgnored() async {
|
|
let pagination = PaginationState<String>(pageSize: 20)
|
|
await pagination.reload { _, _ in ["initial"] }.value
|
|
|
|
await pagination.reload { _, _ in throw CancellationError() }.value
|
|
|
|
// Items from new reload are empty since it threw before returning items
|
|
// But cancellation should not surface an error
|
|
#expect(!pagination.showError)
|
|
#expect(!pagination.isLoading)
|
|
}
|
|
|
|
@Test @MainActor func urlErrorCancelledIgnored() async {
|
|
let pagination = PaginationState<String>(pageSize: 20)
|
|
|
|
await pagination.reload { _, _ in throw URLError(.cancelled) }.value
|
|
|
|
#expect(pagination.items.isEmpty)
|
|
#expect(!pagination.showError)
|
|
#expect(!pagination.isLoading)
|
|
}
|
|
}
|
|
|
|
// MARK: - Concurrent reloads (internal task cancellation)
|
|
|
|
struct PaginationStateConcurrentTests {
|
|
|
|
@Test @MainActor func secondReloadCancelsFirst() async {
|
|
let pagination = PaginationState<String>(pageSize: 20)
|
|
let fetcherA = ControllableFetch()
|
|
let fetcherB = ControllableFetch()
|
|
|
|
// Start reload A
|
|
pagination.reload { page, limit in
|
|
try await fetcherA.fetch(page: page, limit: limit)
|
|
}
|
|
await yieldUntil { fetcherA.isPending }
|
|
|
|
// Start reload B — cancels A's internal task
|
|
let taskB = pagination.reload { page, limit in
|
|
try await fetcherB.fetch(page: page, limit: limit)
|
|
}
|
|
await yieldUntil { fetcherB.isPending }
|
|
|
|
// Complete both — only B's results should be applied
|
|
fetcherA.complete(returning: ["stale"])
|
|
fetcherB.complete(returning: ["fresh"])
|
|
await taskB.value
|
|
|
|
#expect(pagination.items == ["fresh"])
|
|
#expect(!pagination.isLoading)
|
|
}
|
|
|
|
@Test @MainActor func staleLoadCompletingFirstIsDiscarded() async {
|
|
let pagination = PaginationState<String>(pageSize: 20)
|
|
let fetcherA = ControllableFetch()
|
|
let fetcherB = ControllableFetch()
|
|
|
|
pagination.reload { page, limit in
|
|
try await fetcherA.fetch(page: page, limit: limit)
|
|
}
|
|
await yieldUntil { fetcherA.isPending }
|
|
|
|
let taskB = pagination.reload { page, limit in
|
|
try await fetcherB.fetch(page: page, limit: limit)
|
|
}
|
|
await yieldUntil { fetcherB.isPending }
|
|
|
|
// Complete A first (stale, its task was cancelled) — must be discarded
|
|
fetcherA.complete(returning: ["stale"])
|
|
await yieldUntil { fetcherA.callCount == 1 }
|
|
|
|
#expect(pagination.items.isEmpty) // B hasn't completed yet
|
|
#expect(pagination.isLoading) // B is still loading
|
|
|
|
// Complete B (fresh)
|
|
fetcherB.complete(returning: ["fresh"])
|
|
await taskB.value
|
|
|
|
#expect(pagination.items == ["fresh"])
|
|
#expect(!pagination.isLoading)
|
|
}
|
|
|
|
@Test @MainActor func threeOverlappingReloadsOnlyLatestApplied() async {
|
|
let pagination = PaginationState<String>(pageSize: 20)
|
|
let fetcherA = ControllableFetch()
|
|
let fetcherB = ControllableFetch()
|
|
let fetcherC = ControllableFetch()
|
|
|
|
pagination.reload { page, limit in
|
|
try await fetcherA.fetch(page: page, limit: limit)
|
|
}
|
|
await yieldUntil { fetcherA.isPending }
|
|
|
|
pagination.reload { page, limit in
|
|
try await fetcherB.fetch(page: page, limit: limit)
|
|
}
|
|
await yieldUntil { fetcherB.isPending }
|
|
|
|
let taskC = pagination.reload { page, limit in
|
|
try await fetcherC.fetch(page: page, limit: limit)
|
|
}
|
|
await yieldUntil { fetcherC.isPending }
|
|
|
|
// Complete in order A, B, C — only C should be applied
|
|
fetcherA.complete(returning: ["a"])
|
|
fetcherB.complete(returning: ["b"])
|
|
fetcherC.complete(returning: ["c"])
|
|
await taskC.value
|
|
|
|
#expect(pagination.items == ["c"])
|
|
#expect(!pagination.isLoading)
|
|
}
|
|
|
|
@Test @MainActor func staleErrorDoesNotSurface() async {
|
|
let pagination = PaginationState<String>(pageSize: 20)
|
|
let fetcherA = ControllableFetch()
|
|
let fetcherB = ControllableFetch()
|
|
|
|
pagination.reload { page, limit in
|
|
try await fetcherA.fetch(page: page, limit: limit)
|
|
}
|
|
await yieldUntil { fetcherA.isPending }
|
|
|
|
let taskB = pagination.reload { page, limit in
|
|
try await fetcherB.fetch(page: page, limit: limit)
|
|
}
|
|
await yieldUntil { fetcherB.isPending }
|
|
|
|
// A fails with error — but its task was cancelled, so no alert
|
|
fetcherA.complete(throwing: URLError(.badServerResponse))
|
|
fetcherB.complete(returning: ["ok"])
|
|
await taskB.value
|
|
|
|
#expect(pagination.items == ["ok"])
|
|
#expect(!pagination.showError)
|
|
}
|
|
|
|
@Test @MainActor func isLoadingStaysTrueWhileLatestIsInFlight() async {
|
|
let pagination = PaginationState<String>(pageSize: 20)
|
|
let fetcherA = ControllableFetch()
|
|
let fetcherB = ControllableFetch()
|
|
|
|
pagination.reload { page, limit in
|
|
try await fetcherA.fetch(page: page, limit: limit)
|
|
}
|
|
await yieldUntil { fetcherA.isPending }
|
|
#expect(pagination.isLoading)
|
|
|
|
let taskB = pagination.reload { page, limit in
|
|
try await fetcherB.fetch(page: page, limit: limit)
|
|
}
|
|
await yieldUntil { fetcherB.isPending }
|
|
#expect(pagination.isLoading)
|
|
|
|
// Complete stale A — isLoading must REMAIN true (B is still in flight)
|
|
fetcherA.complete(returning: ["stale"])
|
|
await yieldUntil { fetcherA.callCount == 1 }
|
|
#expect(pagination.isLoading, "isLoading must stay true while latest reload (B) is pending")
|
|
|
|
fetcherB.complete(returning: ["fresh"])
|
|
await taskB.value
|
|
#expect(!pagination.isLoading)
|
|
}
|
|
|
|
/// Simulates the exact pattern from SearchableOverviewView:
|
|
/// .task fires reload → user changes filter → onChange fires reload (cancels previous)
|
|
@Test @MainActor func simulatedViewFilterChange() async {
|
|
let pagination = PaginationState<String>(pageSize: 20)
|
|
let initialFetcher = ControllableFetch()
|
|
let filterFetcher = ControllableFetch()
|
|
|
|
// Simulate .task { pagination.reload { ... } }
|
|
pagination.reload { page, limit in
|
|
try await initialFetcher.fetch(page: page, limit: limit)
|
|
}
|
|
await yieldUntil { initialFetcher.isPending }
|
|
|
|
// Simulate onChange: user changed filter — just call reload again
|
|
let filterTask = pagination.reload(clearItems: true) { page, limit in
|
|
try await filterFetcher.fetch(page: page, limit: limit)
|
|
}
|
|
await yieldUntil { filterFetcher.isPending }
|
|
|
|
// Initial load completes (stale, its task was cancelled)
|
|
initialFetcher.complete(returning: ["stale-created-results"])
|
|
// Filter load completes with correct results
|
|
filterFetcher.complete(returning: ["fresh-involved-results"])
|
|
|
|
await filterTask.value
|
|
|
|
#expect(pagination.items == ["fresh-involved-results"])
|
|
#expect(!pagination.isLoading)
|
|
#expect(!pagination.showError)
|
|
}
|
|
}
|
|
|
|
// MARK: - hasLoaded / invalidate
|
|
|
|
struct PaginationStateHasLoadedTests {
|
|
|
|
@Test @MainActor func hasLoadedIsFalseInitially() {
|
|
let pagination = PaginationState<String>(pageSize: 5)
|
|
#expect(!pagination.hasLoaded)
|
|
}
|
|
|
|
@Test @MainActor func hasLoadedTrueAfterSuccessfulReload() async {
|
|
let pagination = PaginationState<String>(pageSize: 5)
|
|
await pagination.reload { _, _ in ["a"] }.value
|
|
#expect(pagination.hasLoaded)
|
|
}
|
|
|
|
@Test @MainActor func hasLoadedFalseAfterFailedReload() async {
|
|
let pagination = PaginationState<String>(pageSize: 5)
|
|
await pagination.reload { _, _ in throw URLError(.badServerResponse) }.value
|
|
#expect(!pagination.hasLoaded)
|
|
}
|
|
|
|
@Test @MainActor func invalidateResetsHasLoaded() async {
|
|
let pagination = PaginationState<String>(pageSize: 5)
|
|
await pagination.reload { _, _ in ["a"] }.value
|
|
#expect(pagination.hasLoaded)
|
|
|
|
pagination.invalidate()
|
|
#expect(!pagination.hasLoaded)
|
|
}
|
|
|
|
@Test @MainActor func invalidateThenReloadCycle() async {
|
|
let pagination = PaginationState<String>(pageSize: 5)
|
|
await pagination.reload { _, _ in ["a"] }.value
|
|
#expect(pagination.hasLoaded)
|
|
|
|
pagination.invalidate()
|
|
#expect(!pagination.hasLoaded)
|
|
|
|
await pagination.reload { _, _ in ["b"] }.value
|
|
#expect(pagination.hasLoaded)
|
|
#expect(pagination.items == ["b"])
|
|
}
|
|
|
|
@Test @MainActor func loadMoreDoesNotSetHasLoaded() async {
|
|
let pagination = PaginationState<String>(pageSize: 2)
|
|
// hasLoaded is false before any reload
|
|
#expect(!pagination.hasLoaded)
|
|
|
|
// loadMore should not set hasLoaded
|
|
await pagination.loadMore { _, _ in ["a", "b"] }
|
|
#expect(!pagination.hasLoaded)
|
|
}
|
|
}
|
|
|
|
// MARK: - notFound
|
|
|
|
struct PaginationStateNotFoundTests {
|
|
|
|
@Test @MainActor func notFoundFalseInitially() {
|
|
let pagination = PaginationState<String>(pageSize: 5)
|
|
#expect(!pagination.notFound)
|
|
}
|
|
|
|
@Test @MainActor func notFoundSetByFetchClosureShowsNoAlert() async {
|
|
let pagination = PaginationState<String>(pageSize: 20)
|
|
await pagination.reload { _, _ in
|
|
pagination.notFound = true
|
|
return []
|
|
}.value
|
|
#expect(pagination.notFound)
|
|
#expect(pagination.items.isEmpty)
|
|
#expect(!pagination.showError)
|
|
#expect(pagination.hasLoaded)
|
|
}
|
|
|
|
@Test @MainActor func reloadClearsNotFound() async {
|
|
let pagination = PaginationState<String>(pageSize: 20)
|
|
await pagination.reload { _, _ in
|
|
pagination.notFound = true
|
|
return []
|
|
}.value
|
|
#expect(pagination.notFound)
|
|
|
|
await pagination.reload { _, _ in ["ok"] }.value
|
|
#expect(!pagination.notFound)
|
|
#expect(pagination.items == ["ok"])
|
|
}
|
|
}
|
|
|
|
// MARK: - loadMore
|
|
|
|
struct PaginationStateLoadMoreTests {
|
|
|
|
@Test @MainActor func loadMoreAppendsItems() async {
|
|
let pagination = PaginationState<String>(pageSize: 2)
|
|
await pagination.reload { _, _ in ["a", "b"] }.value
|
|
#expect(pagination.hasMore)
|
|
|
|
await pagination.loadMore { page, _ in
|
|
#expect(page == 2)
|
|
return ["c", "d"]
|
|
}
|
|
#expect(pagination.items == ["a", "b", "c", "d"])
|
|
}
|
|
|
|
@Test @MainActor func loadMoreStopsWhenNoMore() async {
|
|
let pagination = PaginationState<String>(pageSize: 5)
|
|
await pagination.reload { _, _ in ["a", "b", "c", "d", "e"] }.value
|
|
|
|
await pagination.loadMore { _, _ in ["f"] } // 1 < pageSize -> no more
|
|
#expect(!pagination.hasMore)
|
|
#expect(pagination.items == ["a", "b", "c", "d", "e", "f"])
|
|
}
|
|
|
|
@Test @MainActor func loadMoreSkipsWhenAlreadyLoading() async {
|
|
let pagination = PaginationState<String>(pageSize: 2)
|
|
await pagination.reload { _, _ in ["a", "b"] }.value
|
|
|
|
let fetcher = ControllableFetch()
|
|
let firstMore = Task {
|
|
await pagination.loadMore { page, limit in
|
|
try await fetcher.fetch(page: page, limit: limit)
|
|
}
|
|
}
|
|
await yieldUntil { fetcher.isPending }
|
|
|
|
// Second loadMore should bail because isLoading is true
|
|
var secondCalled = false
|
|
await pagination.loadMore { _, _ in
|
|
secondCalled = true
|
|
return ["should-not-run"]
|
|
}
|
|
#expect(!secondCalled)
|
|
|
|
fetcher.complete(returning: ["c"])
|
|
await firstMore.value
|
|
}
|
|
|
|
@Test @MainActor func loadMoreDiscardedWhenReloadSupersedes() async {
|
|
let pagination = PaginationState<String>(pageSize: 2)
|
|
await pagination.reload { _, _ in ["a", "b"] }.value
|
|
|
|
let moreFetcher = ControllableFetch()
|
|
let moreTask = Task {
|
|
await pagination.loadMore { page, limit in
|
|
try await moreFetcher.fetch(page: page, limit: limit)
|
|
}
|
|
}
|
|
await yieldUntil { moreFetcher.isPending }
|
|
|
|
// A reload supersedes the in-flight loadMore (cancels its task)
|
|
let freshFetcher = ControllableFetch()
|
|
let reloadTask = pagination.reload { page, limit in
|
|
try await freshFetcher.fetch(page: page, limit: limit)
|
|
}
|
|
await yieldUntil { freshFetcher.isPending }
|
|
|
|
// loadMore completes — should be discarded (task was cancelled)
|
|
moreFetcher.complete(returning: ["stale-more"])
|
|
await moreTask.value
|
|
|
|
// reload completes — should be applied
|
|
freshFetcher.complete(returning: ["fresh"])
|
|
await reloadTask.value
|
|
|
|
#expect(pagination.items == ["fresh"])
|
|
#expect(!pagination.isLoading)
|
|
}
|
|
}
|