TM-SGNL-iOS/SignalServiceKit/Messages/Attachments/V2/OrphanedAttachments/OrphanedAttachmentCleanerTest.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

299 lines
10 KiB
Swift

//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import GRDB
import XCTest
@testable import SignalServiceKit
class OrphanedAttachmentCleanerTest: XCTestCase {
private var db: InMemoryDB!
private var attachmentStore: AttachmentStoreImpl!
private var orphanedAttachmentCleaner: OrphanedAttachmentCleanerImpl!
private var mockFileSystem: OrphanedAttachmentCleanerImpl.Mocks.OWSFileSystem!
private var mockTaskScheduler: OrphanedAttachmentCleanerImpl.Mocks.TaskScheduler!
override func setUp() async throws {
db = InMemoryDB()
attachmentStore = AttachmentStoreImpl()
mockFileSystem = OrphanedAttachmentCleanerImpl.Mocks.OWSFileSystem()
mockTaskScheduler = OrphanedAttachmentCleanerImpl.Mocks.TaskScheduler()
orphanedAttachmentCleaner = OrphanedAttachmentCleanerImpl(
dbProvider: { [db] in db!.databaseQueue },
fileSystem: mockFileSystem,
taskScheduler: mockTaskScheduler
)
}
func testDeleteAttachment() async throws {
let localRelativeFilePath = UUID().uuidString
let attachmentParams = Attachment.ConstructionParams.mockStream(
streamInfo: .mock(localRelativeFilePath: localRelativeFilePath)
)
let referenceParams = AttachmentReference.ConstructionParams.mock(
owner: .thread(.globalThreadWallpaperImage(creationTimestamp: Date().ows_millisecondsSince1970))
)
try db.write { tx in
try attachmentStore.insert(
attachmentParams,
reference: referenceParams,
tx: tx
)
}
// Start observing; should have deleted a file _after_ we commit
// deletion of an attachment.
orphanedAttachmentCleaner.beginObserving()
XCTAssertEqual(mockTaskScheduler.tasks.count, 1)
_ = try await mockTaskScheduler.tasks[0].value
mockTaskScheduler.tasks = []
try db.write { tx in
try Attachment.Record.deleteAll(tx.db)
// No deletions until the transaction commits!
XCTAssertEqual(mockTaskScheduler.tasks.count, 0)
}
XCTAssertEqual(mockTaskScheduler.tasks.count, 1)
_ = try await mockTaskScheduler.tasks[0].value
// Should have deleted the one file.
XCTAssertEqual(
mockFileSystem.deletedFiles,
[AttachmentStream.absoluteAttachmentFileURL(relativeFilePath: localRelativeFilePath)]
+ thumbnailFileURLs(localRelativeFilePath: localRelativeFilePath)
)
// And no rows left.
try db.read { tx in
XCTAssertNil(try OrphanedAttachmentRecord.fetchOne(tx.db))
}
}
func testDeleteMultiple() async throws {
let filePaths = (0...5).map { _ in UUID().uuidString }
try db.write { tx in
try filePaths.forEach { filePath in
var record = OrphanedAttachmentRecord(
localRelativeFilePath: filePath,
localRelativeFilePathThumbnail: nil,
localRelativeFilePathAudioWaveform: nil,
localRelativeFilePathVideoStillFrame: nil
)
try record.insert(tx.db)
}
}
// Should delete all existing rows as soon as we start observing.
orphanedAttachmentCleaner.beginObserving()
XCTAssertEqual(mockTaskScheduler.tasks.count, 1)
_ = try await mockTaskScheduler.tasks[0].value
XCTAssertEqual(
mockFileSystem.deletedFiles,
filePaths.flatMap {
return [AttachmentStream.absoluteAttachmentFileURL(relativeFilePath: $0)]
+ thumbnailFileURLs(localRelativeFilePath: $0)
}
)
// And no rows left.
try db.read { tx in
XCTAssertNil(try OrphanedAttachmentRecord.fetchOne(tx.db))
}
}
func testIgnoreFailingRowIds() async throws {
let filePath1 = UUID().uuidString
let url1 = AttachmentStream.absoluteAttachmentFileURL(relativeFilePath: filePath1)
let filePath2 = UUID().uuidString
let url2 = AttachmentStream.absoluteAttachmentFileURL(relativeFilePath: filePath2)
try db.write { tx in
try [filePath1, filePath2].forEach { filePath in
var record = OrphanedAttachmentRecord(
localRelativeFilePath: filePath,
localRelativeFilePathThumbnail: nil,
localRelativeFilePathAudioWaveform: nil,
localRelativeFilePathVideoStillFrame: nil
)
try record.insert(tx.db)
}
}
struct SomeError: Error {}
let allThumbnailFilePaths = thumbnailFileURLs(localRelativeFilePath: filePath1)
+ thumbnailFileURLs(localRelativeFilePath: filePath2)
var file1WasAttempted = false
mockFileSystem.deleteFileMock = { url in
if url == url1 {
file1WasAttempted = true
throw SomeError()
} else if url == url2 {
guard file1WasAttempted else {
XCTFail("Unexpected deletion order")
return
}
} else if allThumbnailFilePaths.contains(url) {
return
} else {
XCTFail("Unexpected file deleted")
}
}
// Should delete all existing rows as soon as we start observing.
orphanedAttachmentCleaner.beginObserving()
XCTAssertEqual(mockTaskScheduler.tasks.count, 1)
_ = try await mockTaskScheduler.tasks[0].value
// The fact that the first failed shouldn't have stopped the second.
XCTAssertEqual(
mockFileSystem.deletedFiles,
[url2]
+ thumbnailFileURLs(localRelativeFilePath: filePath2)
)
// The first row should still be around.
try db.read { tx in
let record = try OrphanedAttachmentRecord.fetchOne(tx.db)
XCTAssertEqual(record?.localRelativeFilePath, filePath1)
}
// If we insert again the first row should be ignored.
mockFileSystem.deletedFiles = []
mockTaskScheduler.tasks = []
let filePath3 = UUID().uuidString
let url3 = AttachmentStream.absoluteAttachmentFileURL(relativeFilePath: filePath3)
mockFileSystem.deleteFileMock = { url in
if url == url3 {
return
} else if self.thumbnailFileURLs(localRelativeFilePath: filePath3).contains(url) {
return
} else {
XCTFail("Unexpected file deleted")
}
}
try db.write { tx in
var record = OrphanedAttachmentRecord(
localRelativeFilePath: filePath3,
localRelativeFilePathThumbnail: nil,
localRelativeFilePathAudioWaveform: nil,
localRelativeFilePathVideoStillFrame: nil
)
try record.insert(tx.db)
}
XCTAssertEqual(mockTaskScheduler.tasks.count, 1)
_ = try await mockTaskScheduler.tasks[0].value
// The fact that the first failed shouldn't have stopped the third.
XCTAssertEqual(
mockFileSystem.deletedFiles,
[url3]
+ thumbnailFileURLs(localRelativeFilePath: filePath3)
)
// The first row should still be around.
try db.read { tx in
let record = try OrphanedAttachmentRecord.fetchOne(tx.db)
XCTAssertEqual(record?.localRelativeFilePath, filePath1)
}
}
func testOrphanRecordFieldCoverage() async throws {
var record = OrphanedAttachmentRecord(
localRelativeFilePath: UUID().uuidString,
localRelativeFilePathThumbnail: UUID().uuidString,
localRelativeFilePathAudioWaveform: UUID().uuidString,
localRelativeFilePathVideoStillFrame: UUID().uuidString
)
try db.write { tx in
try record.insert(tx.db)
}
// Should delete all existing rows as soon as we start observing.
orphanedAttachmentCleaner.beginObserving()
XCTAssertEqual(mockTaskScheduler.tasks.count, 1)
_ = try await mockTaskScheduler.tasks[0].value
// Check that all string fields were deleted.
// If a new non-file string field is added, make sure to exclude it here.
var fieldCount = 0
for (_, value) in Mirror(reflecting: record).children {
guard type(of: value) == String.self || type(of: value) == Optional<String>.self else {
continue
}
let url = AttachmentStream.absoluteAttachmentFileURL(relativeFilePath: value as! String)
XCTAssert(mockFileSystem.deletedFiles.contains(url))
fieldCount += 1
}
// Should also get a deletion for every thumbnail size.
fieldCount += AttachmentThumbnailQuality.allCases.count
XCTAssertEqual(mockFileSystem.deletedFiles.count, fieldCount)
}
// MARK: - Helpers
private func thumbnailFileURLs(localRelativeFilePath: String) -> [URL] {
return AttachmentThumbnailQuality.allCases.map { quality in
return AttachmentThumbnailQuality.thumbnailCacheFileUrl(
attachmentLocalRelativeFilePath: localRelativeFilePath,
at: quality
)
}
}
}
extension OrphanedAttachmentCleanerImpl {
enum Mocks {
fileprivate typealias OWSFileSystem = _OrphanedAttachmentCleanerImpl_OWSFileSystemMock
fileprivate typealias TaskScheduler = _OrphanedAttachmentCleanerImpl_TaskSchedulerMock
}
}
private class _OrphanedAttachmentCleanerImpl_OWSFileSystemMock: _OrphanedAttachmentCleanerImpl_OWSFileSystemShim {
init() {}
func fileOrFolderExists(url: URL) -> Bool {
true
}
var deletedFiles = [URL]()
var deleteFileMock: (URL) throws -> Void = { _ in }
func deleteFileIfExists(url: URL) throws {
try deleteFileMock(url)
deletedFiles.append(url)
}
}
private class _OrphanedAttachmentCleanerImpl_TaskSchedulerMock: _OrphanedAttachmentCleanerImpl_TaskSchedulerShim {
init() {}
var tasks = [Task<Void, Error>]()
func task(_ block: @escaping () async throws -> Void) {
let task = Task {
try await block()
}
tasks.append(task)
}
}