Files
vnidrop/apple/Tests/ApprovalCoordinatorTests.swift
cdricms 3469b122c2 feat(apple): local notifications for transfer lifecycle events
Adds background notifications for the "the thing you were waiting for is
done" moments, alongside the existing incoming-approval-request one:

  - a receive finished downloading            (receive -> done)
  - a receive failed / was interrupted         (receive -> failed)
  - a share you own failed                      (send -> failed)
  - a receiver finished downloading your share  (receiver status completed)

A new TransferNotificationCoordinator observes core state + signals and
publishes these; the decision of which moments notify is a pure function
(plannedTransferNotifications / plannedReceiverNotifications), unit-tested
independently. The first state snapshot only primes existing history as seen
so launch doesn't spam.

Notification permission is now the single source of truth. The in-app
notifications toggle and its decoupled UserDefaults preference are gone;
the Settings section shows an "Allow notifications" button that requests the
OS permission (or deep-links to Settings once decided), and notifications
gate purely on `permission == .granted`.

macOS delivery fixes:
  - add a UNUserNotificationCenterDelegate so banners present even while the
    app is active (the app window is usually open on macOS)
  - present-when-active on macOS, suppress-when-foregrounded on iOS
  - reserve the notification id before awaiting publish: the CombineLatest
    fired several times and re-added the same identifier, which macOS
    coalesces into a silent update with no banner
  - LocalNotificationService seeds its permission at init so gating can't
    race a not-yet-refreshed .notDetermined

Eight localized title/body strings added (apple-only); the shared
notifications_description copy is generalized from "receive requests" to
"transfer activity".
2026-07-23 23:29:40 +02:00

69 lines
2.7 KiB
Swift

import XCTest
import Combine
@testable import VniDrop
/// Ports `feature/approvals/ApprovalCoordinatorTest.kt` (the gateway-observable
/// parts; notification assertions require a notification-service seam we don't
/// have on Apple yet).
@MainActor
final class ApprovalCoordinatorTests: XCTestCase {
private func makeCoordinator(_ core: FakeCoreGateway) -> ApprovalCoordinator {
ApprovalCoordinator(
repository: core,
notifications: LocalNotificationService(),
visibility: AppVisibility(),
messages: UiMessageController()
)
}
func testOrdersPendingRequestsByRequestedAt() async {
let core = FakeCoreGateway()
core.requests[1] = [Fixtures.request(id: "new", requestedAt: 20),
Fixtures.request(id: "old", requestedAt: 10)]
let coordinator = makeCoordinator(core)
core.setState(CoreState(isInitialized: true, transfers: [Fixtures.transfer(id: 1, direction: .send, status: .sharing)]))
core.emit(.approvalChanged(transferId: 1))
await waitUntil { !coordinator.state.pending.isEmpty }
XCTAssertEqual(coordinator.state.pending.map(\.id), ["old", "new"])
XCTAssertEqual(coordinator.state.current?.id, "old")
}
func testFailedResponseKeepsRequestVisibleAndClearsResponding() async {
let core = FakeCoreGateway()
core.requests[1] = [Fixtures.request(id: "request", requestedAt: 10)]
core.responseResult = .failure(TestError.unimplemented)
let coordinator = makeCoordinator(core)
core.setState(CoreState(isInitialized: true, transfers: [Fixtures.transfer(id: 1, direction: .send, status: .sharing)]))
core.emit(.approvalChanged(transferId: 1))
await waitUntil { coordinator.state.pending.contains { $0.id == "request" } }
coordinator.accept("request")
await waitUntil { coordinator.state.respondingIds.isEmpty && core.responses.count == 1 }
XCTAssertTrue(coordinator.state.pending.contains { $0.id == "request" })
XCTAssertTrue(coordinator.state.respondingIds.isEmpty)
XCTAssertEqual(core.responses.first?.accepted, true)
}
func testAcceptRespondsPositivelyAndSingleFlights() async {
let core = FakeCoreGateway()
core.requests[1] = [Fixtures.request(id: "request", requestedAt: 10)]
let coordinator = makeCoordinator(core)
core.setState(CoreState(isInitialized: true, transfers: [Fixtures.transfer(id: 1, direction: .send, status: .sharing)]))
core.emit(.approvalChanged(transferId: 1))
await waitUntil { coordinator.state.current != nil }
coordinator.accept("request")
coordinator.accept("request") // second call must be ignored (single-flight)
await waitUntil { core.responses.count >= 1 }
try? await Task.sleep(nanoseconds: 50_000_000)
XCTAssertEqual(core.responses.count, 1)
XCTAssertEqual(core.responses.first?.id, "request")
}
}