Signal-iOS/Signal/test/Backups/BackupSettingsAttachmentDownloadTrackerTest.swift

325 lines
12 KiB
Swift

//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Testing
@testable import SignalServiceKit
@testable import Signal
@MainActor
@Suite(.serialized)
final class BackupSettingsAttachmentDownloadTrackerTest: BackupSettingsAttachmentTrackerTest<
BackupSettingsAttachmentDownloadTracker.DownloadUpdate
> {
typealias DownloadUpdate = BackupSettingsAttachmentDownloadTracker.DownloadUpdate
/// Simulates "launching with downloads enqueued from a previous launch".
@Test
func testLaunchingWithQueuePopulated() async {
let downloadProgress = MockAttachmentDownloadProgress(total: 4)
let downloadQueueStatusReporter = MockDownloadQueueStatusReporter(.running)
let downloadTracker = BackupSettingsAttachmentDownloadTracker(
backupAttachmentDownloadQueueStatusReporter: downloadQueueStatusReporter,
backupAttachmentDownloadProgress: downloadProgress,
)
let expectedUpdates: [ExpectedUpdate] = [
ExpectedUpdate(
update: DownloadUpdate(.running, downloaded: 0, total: 4),
nextSteps: {
downloadProgress.progressMock = OWSProgress(completedUnitCount: 1, totalUnitCount: 4)
}
),
ExpectedUpdate(
update: DownloadUpdate(.running, downloaded: 1, total: 4),
nextSteps: {
downloadProgress.progressMock = OWSProgress(completedUnitCount: 4, totalUnitCount: 4)
}
),
ExpectedUpdate(
update: DownloadUpdate(.running, downloaded: 4, total: 4),
nextSteps: {
downloadQueueStatusReporter.currentStatusMock = .empty
}
),
ExpectedUpdate(update: nil, nextSteps: {}),
]
await runTest(updateStream: downloadTracker.updates(), expectedUpdates: expectedUpdates)
}
/// Simulates "taps Download Media" when "Optimize Media" is on, by starting
/// with a suspended queue that starts running.
@Test
func testQueueStartsSuspendedThenStartsRunning() async {
let downloadProgress = MockAttachmentDownloadProgress(total: 4)
let downloadQueueStatusReporter = MockDownloadQueueStatusReporter(.suspended)
let downloadTracker = BackupSettingsAttachmentDownloadTracker(
backupAttachmentDownloadQueueStatusReporter: downloadQueueStatusReporter,
backupAttachmentDownloadProgress: downloadProgress
)
let expectedUpdates: [ExpectedUpdate] = [
ExpectedUpdate(
update: DownloadUpdate(.suspended, downloaded: 0, total: 4),
nextSteps: {
downloadQueueStatusReporter.currentStatusMock = .running
}
),
ExpectedUpdate(
update: DownloadUpdate(.running, downloaded: 0, total: 4),
nextSteps: {
downloadProgress.progressMock = OWSProgress(completedUnitCount: 1, totalUnitCount: 4)
}
),
ExpectedUpdate(
update: DownloadUpdate(.running, downloaded: 1, total: 4),
nextSteps: {
downloadProgress.progressMock = OWSProgress(completedUnitCount: 4, totalUnitCount: 4)
}
),
ExpectedUpdate(
update: DownloadUpdate(.running, downloaded: 4, total: 4),
nextSteps: {
downloadQueueStatusReporter.currentStatusMock = .empty
}
),
ExpectedUpdate(update: nil, nextSteps: {}),
]
await runTest(updateStream: downloadTracker.updates(), expectedUpdates: expectedUpdates)
}
/// Simulates downloads running, and the device running out of storage.
///
/// Specifically, running out of disk space when the remaining bytes to
/// download (50) are more than our required minimum available (10).
@Test
func testQueueRunsIntoLowStorage_remainingMoreThanMin() async {
let downloadProgress = MockAttachmentDownloadProgress(precompleted: 50, total: 100)
let downloadQueueStatusReporter = MockDownloadQueueStatusReporter(.running, minimumRequiredDiskSpace: 10)
let downloadTracker = BackupSettingsAttachmentDownloadTracker(
backupAttachmentDownloadQueueStatusReporter: downloadQueueStatusReporter,
backupAttachmentDownloadProgress: downloadProgress
)
let expectedUpdates: [ExpectedUpdate] = [
ExpectedUpdate(
update: DownloadUpdate(.running, downloaded: 50, total: 100),
nextSteps: {
downloadQueueStatusReporter.currentStatusMock = .lowDiskSpace
}
),
ExpectedUpdate(
update: DownloadUpdate(.outOfDiskSpace(bytesRequired: 50), downloaded: 50, total: 100),
nextSteps: {}
),
]
await runTest(updateStream: downloadTracker.updates(), expectedUpdates: expectedUpdates)
}
/// Simulates downloads running, and the device running out of storage.
///
/// Specifically, running out of disk space when the remaining bytes to
/// download (8) are less than our required minimum available (10).
@Test
func testQueueRunsIntoLowStorage_remainingLessThanMin() async {
let downloadProgress = MockAttachmentDownloadProgress(precompleted: 4, total: 12)
let downloadQueueStatusReporter = MockDownloadQueueStatusReporter(.running, minimumRequiredDiskSpace: 10)
let downloadTracker = BackupSettingsAttachmentDownloadTracker(
backupAttachmentDownloadQueueStatusReporter: downloadQueueStatusReporter,
backupAttachmentDownloadProgress: downloadProgress
)
let expectedUpdates: [ExpectedUpdate] = [
ExpectedUpdate(
update: DownloadUpdate(.running, downloaded: 4, total: 12),
nextSteps: {
downloadQueueStatusReporter.currentStatusMock = .lowDiskSpace
}
),
ExpectedUpdate(
update: DownloadUpdate(.outOfDiskSpace(bytesRequired: 10), downloaded: 4, total: 12),
nextSteps: {}
),
]
await runTest(updateStream: downloadTracker.updates(), expectedUpdates: expectedUpdates)
}
/// Simulates downloads running, and a caller tracking (e.g., BackupSettings
/// being presented), then stopping (e.g., dismissing), then starting again.
@Test
func testTrackingStoppingAndReTracking() async {
let downloadProgress = MockAttachmentDownloadProgress(total: 4)
let downloadQueueStatusReporter = MockDownloadQueueStatusReporter(.empty)
let downloadTracker = BackupSettingsAttachmentDownloadTracker(
backupAttachmentDownloadQueueStatusReporter: downloadQueueStatusReporter,
backupAttachmentDownloadProgress: downloadProgress
)
let firstExpectedUpdates: [ExpectedUpdate] = [
ExpectedUpdate(
update: nil,
nextSteps: {
downloadQueueStatusReporter.currentStatusMock = .running
}
),
ExpectedUpdate(
update: DownloadUpdate(.running, downloaded: 0, total: 4),
nextSteps: {}
),
]
await runTest(updateStream: downloadTracker.updates(), expectedUpdates: firstExpectedUpdates)
let secondExpectedUpdates: [ExpectedUpdate] = [
ExpectedUpdate(
update: DownloadUpdate(.running, downloaded: 0, total: 1),
nextSteps: {
downloadProgress.progressMock = OWSProgress(completedUnitCount: 1, totalUnitCount: 1)
}
),
ExpectedUpdate(
update: DownloadUpdate(.running, downloaded: 1, total: 1),
nextSteps: {
downloadQueueStatusReporter.currentStatusMock = .empty
}
),
ExpectedUpdate(
update: nil,
nextSteps: {}
),
]
await runTest(updateStream: downloadTracker.updates(), expectedUpdates: secondExpectedUpdates)
}
@Test
func testTrackingMultipleStreamInstances() async {
let downloadProgress = MockAttachmentDownloadProgress(total: 1)
let downloadQueueStatusReporter = MockDownloadQueueStatusReporter(.empty)
let downloadTracker = BackupSettingsAttachmentDownloadTracker(
backupAttachmentDownloadQueueStatusReporter: downloadQueueStatusReporter,
backupAttachmentDownloadProgress: downloadProgress
)
let expectedUpdates: [ExpectedUpdate] = [
ExpectedUpdate(
update: nil,
nextSteps: {
downloadQueueStatusReporter.currentStatusMock = .running
}
),
ExpectedUpdate(
update: DownloadUpdate(.running, downloaded: 0, total: 1),
nextSteps: {
downloadProgress.progressMock = OWSProgress(completedUnitCount: 1, totalUnitCount: 1)
}
),
ExpectedUpdate(
update: DownloadUpdate(.running, downloaded: 1, total: 1),
nextSteps: {
downloadQueueStatusReporter.currentStatusMock = .empty
}
),
ExpectedUpdate(
update: nil,
nextSteps: {}
),
]
await runTest(
updateStreams: [downloadTracker.updates(), downloadTracker.updates()],
expectedUpdates: expectedUpdates
)
}
}
// MARK: -
private extension BackupSettingsAttachmentDownloadTracker.DownloadUpdate {
init(_ state: State, downloaded: UInt64, total: UInt64) {
self.init(state: state, bytesDownloaded: downloaded, totalBytesToDownload: total)
}
}
// MARK: -
private class MockAttachmentDownloadProgress: BackupAttachmentDownloadProgressMock {
var progressMock: OWSProgress {
didSet {
mockObserverBlocks.get().forEach { $0(progressMock) }
}
}
private let mockObserverBlocks: AtomicValue<[(OWSProgress) -> Void]>
init(precompleted: UInt64 = 0, total: UInt64) {
self.mockObserverBlocks = AtomicValue([], lock: .init())
self.progressMock = OWSProgress(completedUnitCount: precompleted, totalUnitCount: total)
}
override func addObserver(_ block: @escaping (OWSProgress) -> Void) async -> BackupAttachmentDownloadProgressObserver {
block(progressMock)
mockObserverBlocks.update { $0.append(block) }
return await super.addObserver(block)
}
}
// MARK: -
private class MockDownloadQueueStatusReporter: BackupAttachmentDownloadQueueStatusReporter {
init(
_ initialStatus: BackupAttachmentDownloadQueueStatus,
minimumRequiredDiskSpace: UInt64 = 0,
) {
self.currentStatusMock = initialStatus
self.minimumRequiredDiskSpaceMock = minimumRequiredDiskSpace
}
var currentStatusMock: BackupAttachmentDownloadQueueStatus {
didSet {
NotificationCenter.default.postOnMainThread(
name: .backupAttachmentDownloadQueueStatusDidChange(mode: .fullsize),
object: nil,
)
}
}
func currentStatus(for mode: BackupAttachmentDownloadQueueMode) -> BackupAttachmentDownloadQueueStatus {
switch mode {
case .fullsize:
break
case .thumbnail:
fatalError("Only fullsize in this test")
}
return currentStatusMock
}
func currentStatusAndToken(for mode: BackupAttachmentDownloadQueueMode) -> (SignalServiceKit.BackupAttachmentDownloadQueueStatus, any SignalServiceKit.BackupAttachmentDownloadQueueStatusToken) {
switch mode {
case .fullsize:
break
case .thumbnail:
fatalError("Only fullsize in this test")
}
return (currentStatusMock, MockBackupAttachmentDownloadQueueStatusManager.BackupAttachmentDownloadQueueStatusTokenMock())
}
nonisolated let minimumRequiredDiskSpaceMock: UInt64
func minimumRequiredDiskSpaceToCompleteDownloads() -> UInt64 {
minimumRequiredDiskSpaceMock
}
func reattemptDiskSpaceChecks() {
owsFail("Unused by BackupSettingsAttachmentDownloadTracker.")
}
}