import CryptoKit import Foundation import XCTest @testable import VelodyPersistence final class OfflineAudioFileStoreTests: XCTestCase { func testFileOfflineAudioFileStoreWritesAndReadsAudioData() async throws { let fileManager = FileManager.default let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent( UUID().uuidString, isDirectory: true ) defer { try? fileManager.removeItem(at: tempDirectory) } let store = try FileOfflineAudioFileStore(baseDirectoryURL: tempDirectory) let bytes = sampleMp3Data(seed: "offline-audio") let localFilePath = try await store.saveAudioFile( bytes, assetId: "asset-123", sha256: sha256Hex(bytes) ) let storedBytes = try await store.readAudioFile(at: localFilePath) let fileExists = await store.fileExists(at: localFilePath) XCTAssertEqual(storedBytes, bytes) XCTAssertTrue(fileExists) } func testFileOfflineAudioFileStoreRejectsEmptyAudioData() async throws { let store = try FileOfflineAudioFileStore( baseDirectoryURL: FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) ) await XCTAssertThrowsErrorAsync { _ = try await store.saveAudioFile( Data(), assetId: "asset-123", sha256: nil ) } assertion: { error in XCTAssertEqual(error as? OfflineAudioFileStoreError, .emptyAudioData) } } func testFileOfflineAudioFileStoreRejectsShaMismatch() async throws { let fileManager = FileManager.default let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent( UUID().uuidString, isDirectory: true ) defer { try? fileManager.removeItem(at: tempDirectory) } let store = try FileOfflineAudioFileStore(baseDirectoryURL: tempDirectory) let bytes = sampleMp3Data(seed: "sha-mismatch") await XCTAssertThrowsErrorAsync { _ = try await store.saveAudioFile( bytes, assetId: "asset-123", sha256: String(repeating: "f", count: 64) ) } assertion: { error in guard case .sha256Mismatch = error as? OfflineAudioFileStoreError else { return XCTFail("Expected a sha256Mismatch error.") } } } func testFileOfflineAudioFileStoreResolvesCurrentBaseDirectoryWhenPersistedPathIsStale() async throws { let fileManager = FileManager.default let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent( UUID().uuidString, isDirectory: true ) let firstAudioDirectory = tempDirectory.appendingPathComponent("audio-v1", isDirectory: true) let secondAudioDirectory = tempDirectory.appendingPathComponent("audio-v2", isDirectory: true) let bytes = sampleMp3Data(seed: "path-repair") defer { try? fileManager.removeItem(at: tempDirectory) } let staleFilePath = firstAudioDirectory .appendingPathComponent("asset-123.mp3") .standardizedFileURL .path let secondStore = try FileOfflineAudioFileStore(baseDirectoryURL: secondAudioDirectory) let currentFilePath = try await secondStore.saveAudioFile( bytes, assetId: "asset-123", sha256: sha256Hex(bytes) ) let resolvedFilePath = await secondStore.resolveLocalFilePath( persistedLocalFilePath: staleFilePath, assetId: "asset-123" ) XCTAssertEqual(resolvedFilePath, currentFilePath) } } private func sampleMp3Data(seed: String) -> Data { Data([ 0x49, 0x44, 0x33, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x21, ] + Array(seed.utf8)) } private func sha256Hex(_ data: Data) -> String { SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() } private func XCTAssertThrowsErrorAsync( _ expression: @escaping () async throws -> Void, assertion: (Error) -> Void, file: StaticString = #filePath, line: UInt = #line ) async { do { try await expression() XCTFail("Expected expression to throw an error.", file: file, line: line) } catch { assertion(error) } }