import CryptoKit import Foundation import XCTest import VelodyDomain import VelodyNetworking import VelodyPersistence @testable import VelodySync final class OfflineLibraryServiceTests: XCTestCase { func testOfflineLibraryContainsOnlyTracksWithExistingLocalFiles() async throws { let fileManager = FileManager.default let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent( UUID().uuidString, isDirectory: true ) let audioDirectory = tempDirectory.appendingPathComponent("audio", isDirectory: true) let availableTrack = makeRemoteTrack( trackId: "track-available", assetId: "asset-available", title: "1 Mai 2026" ) let missingTrack = makeRemoteTrack( trackId: "track-missing", assetId: "asset-missing", title: "2 Mai 2026" ) let availableBytes = sampleMp3Data(seed: availableTrack.assetId) let audioFileStore = try FileOfflineAudioFileStore(baseDirectoryURL: audioDirectory) let availableLocalFilePath = try await audioFileStore.saveAudioFile( availableBytes, assetId: availableTrack.assetId, sha256: availableTrack.sha256 ) let snapshot = try await makeOfflineLibraryService( remoteTracks: [availableTrack, missingTrack], downloadStates: [ RemoteTrackDownloadState( remoteTrackId: availableTrack.trackId, assetId: availableTrack.assetId, localFilePath: availableLocalFilePath, downloadedAt: Date(timeIntervalSince1970: 1_000), downloadStatus: .downloaded ), RemoteTrackDownloadState( remoteTrackId: missingTrack.trackId, assetId: missingTrack.assetId, localFilePath: audioDirectory.appendingPathComponent("asset-missing.mp3").path, downloadedAt: Date(timeIntervalSince1970: 2_000), downloadStatus: .downloaded ), ], audioFileStore: audioFileStore ).loadSnapshot() defer { try? fileManager.removeItem(at: tempDirectory) } XCTAssertEqual(snapshot.availableTracks.map(\.remoteTrackId), [availableTrack.trackId]) XCTAssertEqual( snapshot.remoteTracks.first(where: { $0.remoteTrack.trackId == availableTrack.trackId })?.status, .downloaded ) XCTAssertEqual( snapshot.remoteTracks.first(where: { $0.remoteTrack.trackId == missingTrack.trackId })?.status, .missing ) } func testMissingDownloadedFileBecomesMissingAndNotAvailable() async throws { let missingTrack = makeRemoteTrack( trackId: "track-missing", assetId: "asset-missing", title: "Missing Track" ) let missingState = RemoteTrackDownloadState( remoteTrackId: missingTrack.trackId, assetId: missingTrack.assetId, localFilePath: "/tmp/missing-track.mp3", downloadedAt: Date(timeIntervalSince1970: 1_000), downloadStatus: .downloaded ) let snapshot = try await makeOfflineLibraryService( remoteTracks: [missingTrack], downloadStates: [missingState], audioFileStore: InMemoryOfflineAudioFileStore() ).loadSnapshot() let remoteTrack = try XCTUnwrap(snapshot.remoteTracks.first) XCTAssertEqual(remoteTrack.status, .missing) XCTAssertEqual(remoteTrack.lastDownloadError, "The downloaded MP3 file is missing.") XCTAssertTrue(snapshot.availableTracks.isEmpty) } func testOfflineLibraryRepairsStaleLocalPathsWhenCurrentAudioDirectoryHasFile() async throws { let fileManager = FileManager.default let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent( UUID().uuidString, isDirectory: true ) let stateFileURL = tempDirectory.appendingPathComponent("remote-download-states.json") let firstAudioDirectory = tempDirectory.appendingPathComponent("audio-v1", isDirectory: true) let secondAudioDirectory = tempDirectory.appendingPathComponent("audio-v2", isDirectory: true) let track = makeRemoteTrack( trackId: "track-123", assetId: "asset-456", title: "1 Mai 2026" ) let audioData = sampleMp3Data(seed: track.assetId) let firstAudioStore = try FileOfflineAudioFileStore(baseDirectoryURL: firstAudioDirectory) let firstLocalFilePath = try await firstAudioStore.saveAudioFile( audioData, assetId: track.assetId, sha256: track.sha256 ) let recreatedFileURL = secondAudioDirectory.appendingPathComponent("\(track.assetId).mp3") defer { try? fileManager.removeItem(at: tempDirectory) } try await FileRemoteTrackDownloadStateStore(fileURL: stateFileURL).saveDownloadStates([ RemoteTrackDownloadState( remoteTrackId: track.trackId, assetId: track.assetId, localFilePath: firstLocalFilePath, downloadedAt: Date(timeIntervalSince1970: 1_000), downloadStatus: .downloaded ), ]) try fileManager.createDirectory(at: secondAudioDirectory, withIntermediateDirectories: true) try fileManager.moveItem( at: URL(fileURLWithPath: firstLocalFilePath), to: recreatedFileURL ) let downloadStateStore = try FileRemoteTrackDownloadStateStore(fileURL: stateFileURL) let repository = InMemoryRemoteLibraryRepository(tracks: [track]) let relaunchedAudioStore = try FileOfflineAudioFileStore(baseDirectoryURL: secondAudioDirectory) let syncService = RemoteLibrarySyncService( repository: repository, downloadStateStore: downloadStateStore, audioFileStore: relaunchedAudioStore, artworkStore: InMemoryArtworkStore() ) let offlineLibraryService = OfflineLibraryService( syncService: syncService, audioFileStore: relaunchedAudioStore, artworkStore: InMemoryArtworkStore() ) let snapshot = try await offlineLibraryService.loadSnapshot() let availableTrack = try XCTUnwrap(snapshot.availableTracks.first) let persistedState = try await downloadStateStore.loadDownloadStates().first XCTAssertEqual(availableTrack.localFilePath, recreatedFileURL.standardizedFileURL.path) XCTAssertEqual(persistedState?.localFilePath, recreatedFileURL.standardizedFileURL.path) } func testRedownloadAfterMissingFileRestoresPlayableOfflineState() async throws { let fileManager = FileManager.default let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent( UUID().uuidString, isDirectory: true ) let audioDirectory = tempDirectory.appendingPathComponent("audio", isDirectory: true) let track = makeRemoteTrack( trackId: "track-redownload", assetId: "asset-redownload", title: "Re-download Me" ) let remoteLibraryStore = InMemoryRemoteLibraryStore(tracks: [track]) let downloadStateStore = InMemoryRemoteTrackDownloadStateStore() let audioFileStore = try FileOfflineAudioFileStore(baseDirectoryURL: audioDirectory) let syncService = RemoteLibrarySyncService( repository: DefaultRemoteLibraryRepository( apiClient: OfflineLibraryMockAPIClient(audioAssetData: sampleMp3Data(seed: track.assetId)), store: remoteLibraryStore, syncCursorStore: InMemoryRemoteLibrarySyncCursorStore() ), downloadStateStore: downloadStateStore, audioFileStore: audioFileStore, artworkStore: InMemoryArtworkStore() ) let offlineLibraryService = OfflineLibraryService( syncService: syncService, audioFileStore: audioFileStore, artworkStore: InMemoryArtworkStore() ) defer { try? fileManager.removeItem(at: tempDirectory) } let originalState = try await syncService.downloadTrack(track, deviceId: "device-123") try fileManager.removeItem(at: URL(fileURLWithPath: originalState.localFilePath)) let missingSnapshot = try await offlineLibraryService.loadSnapshot() XCTAssertEqual(missingSnapshot.remoteTracks.first?.status, .missing) XCTAssertTrue(missingSnapshot.availableTracks.isEmpty) _ = try await syncService.downloadTrack(track, deviceId: "device-123") let restoredSnapshot = try await offlineLibraryService.loadSnapshot() XCTAssertEqual(restoredSnapshot.remoteTracks.first?.status, .downloaded) XCTAssertEqual(restoredSnapshot.availableTracks.map(\.remoteTrackId), [track.trackId]) let restoredLocalFilePath = try XCTUnwrap(restoredSnapshot.availableTracks.first?.localFilePath) let fileExists = await audioFileStore.fileExists(at: restoredLocalFilePath) XCTAssertTrue(fileExists) } func testMetadataSyncDoesNotEraseDownloadedOfflineAvailability() async throws { let fileManager = FileManager.default let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent( UUID().uuidString, isDirectory: true ) let audioDirectory = tempDirectory.appendingPathComponent("audio", isDirectory: true) let artworkDirectory = tempDirectory.appendingPathComponent("artwork", isDirectory: true) let track = makeRemoteTrack( trackId: "track-sync", assetId: "asset-sync", title: "Sync Safe", artworkId: "artwork-sync" ) let remoteLibraryStore = InMemoryRemoteLibraryStore() let audioData = sampleMp3Data(seed: track.assetId) let apiClient = OfflineLibraryMockAPIClient( bootstrapResponse: SyncBootstrapResponse( nextCursor: SyncCursor(value: "1"), tracks: [track], serverTime: "2026-05-30T08:00:00.000Z" ), audioAssetData: audioData, artworkDataByArtworkID: [ "artwork-sync": sampleArtworkData(), ] ) let downloadStateStore = InMemoryRemoteTrackDownloadStateStore() let audioFileStore = try FileOfflineAudioFileStore(baseDirectoryURL: audioDirectory) let artworkStore = try FileArtworkStore(baseDirectoryURL: artworkDirectory) let syncService = RemoteLibrarySyncService( repository: DefaultRemoteLibraryRepository( apiClient: apiClient, store: remoteLibraryStore, syncCursorStore: InMemoryRemoteLibrarySyncCursorStore() ), downloadStateStore: downloadStateStore, audioFileStore: audioFileStore, artworkStore: artworkStore ) let offlineLibraryService = OfflineLibraryService( syncService: syncService, audioFileStore: audioFileStore, artworkStore: artworkStore ) defer { try? fileManager.removeItem(at: tempDirectory) } _ = try await syncService.syncRemoteLibrary(deviceId: "device-123") _ = try await syncService.downloadTrack(track, deviceId: "device-123") let beforeResync = try await offlineLibraryService.loadSnapshot() _ = try await syncService.syncRemoteLibrary(deviceId: "device-123") let afterResync = try await offlineLibraryService.loadSnapshot() XCTAssertEqual(beforeResync.availableTracks.map(\.remoteTrackId), [track.trackId]) XCTAssertEqual(afterResync.availableTracks.map(\.remoteTrackId), [track.trackId]) XCTAssertEqual(afterResync.remoteTracks.first?.status, .downloaded) XCTAssertEqual(beforeResync.remoteTracks.first?.localArtworkFilePath, afterResync.remoteTracks.first?.localArtworkFilePath) XCTAssertEqual(beforeResync.availableTracks.first?.localArtworkFilePath, afterResync.availableTracks.first?.localArtworkFilePath) } func testRelaunchSimulationRebuildsOfflineLibraryAccurately() async throws { let fileManager = FileManager.default let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent( UUID().uuidString, isDirectory: true ) let remoteLibraryFileURL = tempDirectory.appendingPathComponent("remote-library.json") let downloadStateFileURL = tempDirectory.appendingPathComponent("remote-download-states.json") let audioDirectory = tempDirectory.appendingPathComponent("audio", isDirectory: true) let tracks = [ makeRemoteTrack(trackId: "track-1", assetId: "asset-1", title: "Track 1", artworkId: "artwork-1"), makeRemoteTrack(trackId: "track-2", assetId: "asset-2", title: "Track 2", artworkId: "artwork-2"), ] let apiClient = OfflineLibraryMockAPIClient( bootstrapResponse: SyncBootstrapResponse( nextCursor: SyncCursor(value: "1"), tracks: tracks, serverTime: "2026-05-30T08:00:00.000Z" ), audioAssetDataByAssetID: [ "asset-1": sampleMp3Data(seed: "asset-1"), "asset-2": sampleMp3Data(seed: "asset-2"), ], artworkDataByArtworkID: [ "artwork-1": sampleArtworkData(), "artwork-2": sampleArtworkData(), ] ) let artworkDirectory = tempDirectory.appendingPathComponent("artwork", isDirectory: true) defer { try? fileManager.removeItem(at: tempDirectory) } let firstRepository = DefaultRemoteLibraryRepository( apiClient: apiClient, store: try FileRemoteLibraryStore(fileURL: remoteLibraryFileURL), syncCursorStore: InMemoryRemoteLibrarySyncCursorStore() ) let firstDownloadStateStore = try FileRemoteTrackDownloadStateStore(fileURL: downloadStateFileURL) let firstAudioStore = try FileOfflineAudioFileStore(baseDirectoryURL: audioDirectory) let firstSyncService = RemoteLibrarySyncService( repository: firstRepository, downloadStateStore: firstDownloadStateStore, audioFileStore: firstAudioStore, artworkStore: try FileArtworkStore(baseDirectoryURL: artworkDirectory) ) let firstOfflineLibraryService = OfflineLibraryService( syncService: firstSyncService, audioFileStore: firstAudioStore, artworkStore: try FileArtworkStore(baseDirectoryURL: artworkDirectory) ) _ = try await firstSyncService.syncRemoteLibrary(deviceId: "device-123") for track in tracks { _ = try await firstSyncService.downloadTrack(track, deviceId: "device-123") } let beforeRelaunch = try await firstOfflineLibraryService.loadSnapshot() let relaunchedRepository = DefaultRemoteLibraryRepository( apiClient: apiClient, store: try FileRemoteLibraryStore(fileURL: remoteLibraryFileURL), syncCursorStore: InMemoryRemoteLibrarySyncCursorStore(cursor: SyncCursor(value: "1")) ) let relaunchedDownloadStateStore = try FileRemoteTrackDownloadStateStore(fileURL: downloadStateFileURL) let relaunchedAudioStore = try FileOfflineAudioFileStore(baseDirectoryURL: audioDirectory) let relaunchedSyncService = RemoteLibrarySyncService( repository: relaunchedRepository, downloadStateStore: relaunchedDownloadStateStore, audioFileStore: relaunchedAudioStore, artworkStore: try FileArtworkStore(baseDirectoryURL: artworkDirectory) ) let relaunchedOfflineLibraryService = OfflineLibraryService( syncService: relaunchedSyncService, audioFileStore: relaunchedAudioStore, artworkStore: try FileArtworkStore(baseDirectoryURL: artworkDirectory) ) let afterRelaunch = try await relaunchedOfflineLibraryService.loadSnapshot() XCTAssertEqual(beforeRelaunch.availableTracks.map(\.remoteTrackId), tracks.map(\.trackId)) XCTAssertEqual(afterRelaunch.availableTracks.map(\.remoteTrackId), tracks.map(\.trackId)) XCTAssertEqual(afterRelaunch.remoteTracks.map(\.status), [.downloaded, .downloaded]) XCTAssertEqual(afterRelaunch.remoteTracks.compactMap(\.localArtworkFilePath).count, 2) XCTAssertEqual(afterRelaunch.availableTracks.compactMap(\.localArtworkFilePath).count, 2) } } private func makeOfflineLibraryService( remoteTracks: [RemoteTrack], downloadStates: [RemoteTrackDownloadState], audioFileStore: any OfflineAudioFileStore ) -> OfflineLibraryService { let syncService = RemoteLibrarySyncService( repository: InMemoryRemoteLibraryRepository(tracks: remoteTracks), downloadStateStore: InMemoryRemoteTrackDownloadStateStore(states: downloadStates), audioFileStore: audioFileStore, artworkStore: InMemoryArtworkStore() ) return OfflineLibraryService( syncService: syncService, audioFileStore: audioFileStore, artworkStore: InMemoryArtworkStore() ) } private actor InMemoryRemoteLibraryRepository: RemoteLibraryRepository { private var tracks: [RemoteTrack] init(tracks: [RemoteTrack]) { self.tracks = tracks } func loadCachedRemoteTracks() async throws -> [RemoteTrack] { tracks } func syncRemoteTracks(deviceId: String) async throws -> [RemoteTrack] { _ = deviceId return tracks } func downloadAudioAsset(assetId: String, deviceId: String) async throws -> Data { _ = assetId _ = deviceId return Data() } func downloadArtwork(artworkId: String, deviceId: String) async throws -> Data { _ = artworkId _ = deviceId return Data() } } private struct OfflineLibraryMockAPIClient: VelodyAPIClient { let bootstrapResponse: SyncBootstrapResponse let audioAssetData: Data? let audioAssetDataByAssetID: [String: Data] let artworkDataByArtworkID: [String: Data] init( bootstrapResponse: SyncBootstrapResponse = SyncBootstrapResponse( nextCursor: SyncCursor(value: "0"), tracks: [], serverTime: "2026-05-30T08:00:00.000Z" ), audioAssetData: Data? = nil, audioAssetDataByAssetID: [String: Data] = [:], artworkDataByArtworkID: [String: Data] = [:] ) { self.bootstrapResponse = bootstrapResponse self.audioAssetData = audioAssetData self.audioAssetDataByAssetID = audioAssetDataByAssetID self.artworkDataByArtworkID = artworkDataByArtworkID } func registerDevice( _ payload: DeviceRegistrationPayload ) async throws -> DeviceRegistrationResponse { _ = payload return DeviceRegistrationResponse( deviceId: UUID().uuidString, deviceAccessToken: UUID().uuidString, bootstrapToken: UUID().uuidString, serverTime: "2026-05-30T08:00:00.000Z" ) } func sendHeartbeat( _ payload: DeviceHeartbeatPayload ) async throws -> DeviceHeartbeatResponse { _ = payload return DeviceHeartbeatResponse( ok: true, serverTime: "2026-05-30T08:00:00.000Z" ) } func fetchSyncBootstrap() async throws -> SyncBootstrapResponse { bootstrapResponse } func fetchSyncChanges( cursor: SyncCursor ) async throws -> SyncChangesResponse { SyncChangesResponse( nextCursor: cursor, hasMore: false, requiresBootstrap: false, events: [], serverTime: "2026-05-30T08:00:00.000Z" ) } func fetchRemoteLibrary( deviceId: String ) async throws -> RemoteLibraryResponseDTO { _ = deviceId return RemoteLibraryResponseDTO( tracks: bootstrapResponse.tracks.map { makeRemoteTrackDTO(from: $0) } ) } func downloadAudioAsset( assetId: String, deviceId: String ) async throws -> Data { _ = deviceId return audioAssetDataByAssetID[assetId] ?? audioAssetData ?? Data() } func downloadArtwork( artworkId: String, deviceId: String ) async throws -> Data { _ = deviceId return artworkDataByArtworkID[artworkId] ?? Data() } func prepareUpload( _ payload: UploadPrepareRequest ) async throws -> UploadPrepareResponse { _ = payload return UploadPrepareResponse(status: .uploadRequired, uploadId: UUID().uuidString, nextOffset: 0) } func fetchUploadStatus( uploadId: String ) async throws -> UploadSessionStatusResponse { UploadSessionStatusResponse( uploadId: uploadId, status: .completed, receivedBytes: "0", expectedSizeBytes: "0", nextOffset: "0" ) } func uploadFile( uploadId: String, fileURL: URL, mimeType: String ) async throws -> UploadSessionStatusResponse { _ = uploadId _ = fileURL _ = mimeType return UploadSessionStatusResponse( uploadId: UUID().uuidString, status: .completed, receivedBytes: "0", expectedSizeBytes: "0", nextOffset: "0" ) } func finalizeUpload( uploadId: String, payload: UploadFinalizeRequest ) async throws -> UploadFinalizeResponse { _ = uploadId _ = payload return UploadFinalizeResponse( trackId: UUID().uuidString, assetId: UUID().uuidString ) } } private func makeRemoteTrack( trackId: String, assetId: String, title: String, artworkId: String? = nil ) -> RemoteTrack { let bytes = sampleMp3Data(seed: assetId) return RemoteTrack( trackId: trackId, title: title, artist: "Remote Artist", durationSeconds: 245, sha256: sha256Hex(bytes), assetId: assetId, createdAt: "2026-05-30T08:00:00.000Z", updatedAt: "2026-05-30T08:05:00.000Z", artwork: artworkId.map { RemoteArtwork( artworkId: $0, sha256: String(repeating: "c", count: 64), mimeType: "image/png", width: 1, height: 1 ) } ) } private func makeRemoteTrackDTO(from track: RemoteTrack) -> RemoteTrackDTO { RemoteTrackDTO( trackId: track.trackId, title: track.title, artist: track.artist, durationSeconds: track.durationSeconds, sha256: track.sha256, assetId: track.assetId, createdAt: track.createdAt, updatedAt: track.updatedAt, artwork: track.artwork.map { RemoteArtworkDTO( artworkId: $0.artworkId, sha256: $0.sha256, mimeType: $0.mimeType, width: $0.width, height: $0.height ) } ) } private func sampleMp3Data(seed: String) -> Data { Data([ 0x49, 0x44, 0x33, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x21, ] + Array(seed.utf8)) } private func sampleArtworkData() -> Data { Data( base64Encoded: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P8z8DwHwAFgwJ/lBi4NwAAAABJRU5ErkJggg==" )! } private func sha256Hex(_ data: Data) -> String { SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() }