import CryptoKit import Foundation import XCTest import VelodyDomain import VelodyNetworking import VelodyPersistence @testable import VelodySync final class RemoteLibrarySyncServiceTests: XCTestCase { func testSuccessfulSyncPersistsRemoteTracks() async throws { let store = InMemoryRemoteLibraryStore() let downloadStateStore = InMemoryRemoteTrackDownloadStateStore() let service = RemoteLibrarySyncService( repository: DefaultRemoteLibraryRepository( apiClient: MockVelodyAPIClient( remoteLibraryResponse: RemoteLibraryResponseDTO( tracks: [ RemoteTrackDTO( trackId: "track-123", title: "Remote Title", artist: "Remote Artist", durationSeconds: 245, sha256: String(repeating: "a", count: 64), assetId: "asset-456", createdAt: "2026-05-29T08:00:00.000Z", updatedAt: "2026-05-29T08:05:00.000Z" ), ] ) ), store: store ), downloadStateStore: downloadStateStore, audioFileStore: InMemoryOfflineAudioFileStore(), artworkStore: InMemoryArtworkStore() ) let tracks = try await service.syncRemoteLibrary(deviceId: "device-123") let cachedTracks = try await service.loadCachedRemoteTracks() let downloadStates = try await service.loadDownloadStates() XCTAssertEqual(tracks.count, 1) XCTAssertEqual(cachedTracks, tracks) XCTAssertEqual(cachedTracks.first?.trackId, "track-123") XCTAssertEqual(downloadStates.first?.downloadStatus, .notDownloaded) } func testEmptyResponseClearsCachedRemoteLibrary() async throws { let store = InMemoryRemoteLibraryStore( tracks: [ RemoteTrack( trackId: "track-123", title: "Old", artist: "Artist", durationSeconds: 100, sha256: String(repeating: "b", count: 64), assetId: "asset-123", createdAt: "2026-05-29T08:00:00.000Z", updatedAt: "2026-05-29T08:05:00.000Z" ), ] ) let downloadStateStore = InMemoryRemoteTrackDownloadStateStore( states: [ RemoteTrackDownloadState( remoteTrackId: "track-123", assetId: "asset-123", downloadStatus: .downloaded ), ] ) let service = RemoteLibrarySyncService( repository: DefaultRemoteLibraryRepository( apiClient: MockVelodyAPIClient( remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: []) ), store: store ), downloadStateStore: downloadStateStore, audioFileStore: InMemoryOfflineAudioFileStore(), artworkStore: InMemoryArtworkStore() ) let tracks = try await service.syncRemoteLibrary(deviceId: "device-123") let cachedTracks = try await service.loadCachedRemoteTracks() let downloadStates = try await service.loadDownloadStates() XCTAssertEqual(tracks, []) XCTAssertEqual(cachedTracks, []) XCTAssertEqual(downloadStates.count, 1) } func testNetworkFailureLeavesCachedRemoteLibraryIntact() async throws { let cachedTrack = RemoteTrack( trackId: "track-123", title: "Cached", artist: "Artist", durationSeconds: 100, sha256: String(repeating: "c", count: 64), assetId: "asset-123", createdAt: "2026-05-29T08:00:00.000Z", updatedAt: "2026-05-29T08:05:00.000Z" ) let store = InMemoryRemoteLibraryStore(tracks: [cachedTrack]) let downloadStateStore = InMemoryRemoteTrackDownloadStateStore() let service = RemoteLibrarySyncService( repository: DefaultRemoteLibraryRepository( apiClient: MockVelodyAPIClient( remoteLibraryError: VelodyAPIError.requestFailed("Offline") ), store: store ), downloadStateStore: downloadStateStore, audioFileStore: InMemoryOfflineAudioFileStore(), artworkStore: InMemoryArtworkStore() ) await XCTAssertThrowsErrorAsync { _ = try await service.syncRemoteLibrary(deviceId: "device-123") } let cachedTracks = try await service.loadCachedRemoteTracks() XCTAssertEqual(cachedTracks, [cachedTrack]) } func testDownloadTrackPersistsDownloadedStateAndFile() async throws { let downloadStateStore = InMemoryRemoteTrackDownloadStateStore() let audioFileStore = InMemoryOfflineAudioFileStore() let service = RemoteLibrarySyncService( repository: DefaultRemoteLibraryRepository( apiClient: MockVelodyAPIClient( remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: []), audioAssetData: sampleMp3Data(seed: "download-success") ), store: InMemoryRemoteLibraryStore() ), downloadStateStore: downloadStateStore, audioFileStore: audioFileStore, artworkStore: InMemoryArtworkStore() ) let track = RemoteTrack( trackId: "track-123", title: "Remote Title", artist: "Remote Artist", durationSeconds: 245, sha256: sha256Hex(sampleMp3Data(seed: "download-success")), assetId: "asset-456", createdAt: "2026-05-29T08:00:00.000Z", updatedAt: "2026-05-29T08:05:00.000Z" ) let state = try await service.downloadTrack(track, deviceId: "device-123") let storedStates = try await service.loadDownloadStates() let fileExists = await audioFileStore.fileExists(at: state.localFilePath) XCTAssertEqual(state.downloadStatus, .downloaded) XCTAssertEqual(state.assetId, "asset-456") XCTAssertFalse(state.localFilePath.isEmpty) XCTAssertEqual(storedStates.first?.downloadStatus, .downloaded) XCTAssertTrue(fileExists) } func testDownloadTrackPersistsFailureState() async throws { let service = RemoteLibrarySyncService( repository: DefaultRemoteLibraryRepository( apiClient: MockVelodyAPIClient( remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: []), downloadError: VelodyAPIError.server(statusCode: 404, message: "Missing") ), store: InMemoryRemoteLibraryStore() ), downloadStateStore: InMemoryRemoteTrackDownloadStateStore(), audioFileStore: InMemoryOfflineAudioFileStore(), artworkStore: InMemoryArtworkStore() ) let track = RemoteTrack( trackId: "track-123", title: "Remote Title", artist: "Remote Artist", durationSeconds: 245, sha256: sha256Hex(sampleMp3Data(seed: "download-failure")), assetId: "asset-456", createdAt: "2026-05-29T08:00:00.000Z", updatedAt: "2026-05-29T08:05:00.000Z" ) await XCTAssertThrowsErrorAsync { _ = try await service.downloadTrack(track, deviceId: "device-123") } let storedStates = try await service.loadDownloadStates() XCTAssertEqual(storedStates.first?.downloadStatus, .failed) XCTAssertEqual(storedStates.first?.remoteTrackId, "track-123") } func testLoadDownloadStatesRepairsStaleLocalFilePathAfterStoreRecreation() 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 stateFileURL = tempDirectory.appendingPathComponent("remote-download-states.json") let audioData = sampleMp3Data(seed: "relaunch-repair") let track = RemoteTrack( trackId: "track-123", title: "1 Mai 2026", artist: "Remote Artist", durationSeconds: 245, sha256: sha256Hex(audioData), assetId: "asset-456", createdAt: "2026-05-29T08:00:00.000Z", updatedAt: "2026-05-29T08:05:00.000Z" ) defer { try? fileManager.removeItem(at: tempDirectory) } let firstService = RemoteLibrarySyncService( repository: DefaultRemoteLibraryRepository( apiClient: MockVelodyAPIClient( remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: []), audioAssetData: audioData ), store: InMemoryRemoteLibraryStore() ), downloadStateStore: try FileRemoteTrackDownloadStateStore(fileURL: stateFileURL), audioFileStore: try FileOfflineAudioFileStore(baseDirectoryURL: firstAudioDirectory), artworkStore: InMemoryArtworkStore() ) let originalState = try await firstService.downloadTrack(track, deviceId: "device-123") let originalFileURL = URL(fileURLWithPath: originalState.localFilePath) let recreatedStoreFileURL = secondAudioDirectory.appendingPathComponent("asset-456.mp3") try fileManager.createDirectory(at: secondAudioDirectory, withIntermediateDirectories: true) try fileManager.moveItem(at: originalFileURL, to: recreatedStoreFileURL) let relaunchedAudioStore = try FileOfflineAudioFileStore(baseDirectoryURL: secondAudioDirectory) let relaunchedService = RemoteLibrarySyncService( repository: DefaultRemoteLibraryRepository( apiClient: MockVelodyAPIClient(remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: [])), store: InMemoryRemoteLibraryStore() ), downloadStateStore: try FileRemoteTrackDownloadStateStore(fileURL: stateFileURL), audioFileStore: relaunchedAudioStore, artworkStore: InMemoryArtworkStore() ) let restoredStates = try await relaunchedService.loadDownloadStates() let restoredState = try XCTUnwrap(restoredStates.first) let restoredBytes = try await relaunchedAudioStore.readAudioFile(at: restoredState.localFilePath) let persistedRestoredState = try await FileRemoteTrackDownloadStateStore(fileURL: stateFileURL) .loadDownloadStates() .first XCTAssertEqual(restoredState.downloadStatus, .downloaded) XCTAssertEqual(restoredState.localFilePath, recreatedStoreFileURL.standardizedFileURL.path) XCTAssertEqual(persistedRestoredState?.localFilePath, recreatedStoreFileURL.standardizedFileURL.path) XCTAssertTrue(fileManager.fileExists(atPath: restoredState.localFilePath)) XCTAssertEqual(restoredBytes, audioData) } func testSyncCachesArtworkIndependentlyFromAudioDownloads() async throws { let artwork = RemoteArtwork( artworkId: "artwork-123", sha256: String(repeating: "d", count: 64), mimeType: "image/png", width: 1, height: 1 ) let artworkStore = InMemoryArtworkStore() let service = RemoteLibrarySyncService( repository: DefaultRemoteLibraryRepository( apiClient: MockVelodyAPIClient( remoteLibraryResponse: RemoteLibraryResponseDTO( tracks: [ RemoteTrackDTO( trackId: "track-123", title: "Remote Title", artist: "Remote Artist", durationSeconds: 245, sha256: String(repeating: "a", count: 64), assetId: "asset-456", createdAt: "2026-05-29T08:00:00.000Z", updatedAt: "2026-05-29T08:05:00.000Z", artwork: RemoteArtworkDTO( artworkId: artwork.artworkId, sha256: artwork.sha256, mimeType: artwork.mimeType, width: artwork.width, height: artwork.height ) ), ] ), artworkData: sampleArtworkData() ), store: InMemoryRemoteLibraryStore() ), downloadStateStore: InMemoryRemoteTrackDownloadStateStore(), audioFileStore: InMemoryOfflineAudioFileStore(), artworkStore: artworkStore ) let tracks = try await service.syncRemoteLibrary(deviceId: "device-123") let cachedArtworkPath = await artworkStore.cachedFilePath(for: artwork) let cachedArtworkBytes = try await artworkStore.readArtwork( at: try XCTUnwrap(cachedArtworkPath) ) XCTAssertEqual(tracks.first?.artwork, artwork) XCTAssertEqual(cachedArtworkBytes, sampleArtworkData()) } } private struct MockVelodyAPIClient: VelodyAPIClient { let remoteLibraryResponse: RemoteLibraryResponseDTO? let remoteLibraryError: VelodyAPIError? let audioAssetData: Data? let downloadError: VelodyAPIError? let artworkData: Data? let artworkDownloadError: VelodyAPIError? init( remoteLibraryResponse: RemoteLibraryResponseDTO? = nil, remoteLibraryError: VelodyAPIError? = nil, audioAssetData: Data? = nil, downloadError: VelodyAPIError? = nil, artworkData: Data? = nil, artworkDownloadError: VelodyAPIError? = nil ) { self.remoteLibraryResponse = remoteLibraryResponse self.remoteLibraryError = remoteLibraryError self.audioAssetData = audioAssetData self.downloadError = downloadError self.artworkData = artworkData self.artworkDownloadError = artworkDownloadError } func registerDevice( _ payload: DeviceRegistrationPayload ) async throws -> DeviceRegistrationResponse { _ = payload return DeviceRegistrationResponse( deviceId: UUID().uuidString, deviceAccessToken: UUID().uuidString, bootstrapToken: UUID().uuidString, serverTime: "2026-05-29T08:00:00.000Z" ) } func sendHeartbeat( _ payload: DeviceHeartbeatPayload ) async throws -> DeviceHeartbeatResponse { _ = payload return DeviceHeartbeatResponse( ok: true, serverTime: "2026-05-29T08:00:00.000Z" ) } func fetchSyncBootstrap() async throws -> SyncBootstrapResponse { SyncBootstrapResponse( nextCursor: SyncCursor(value: "0"), tracks: [], events: [], deletedTrackIds: [], serverTime: "2026-05-29T08:00:00.000Z" ) } func fetchRemoteLibrary( deviceId: String ) async throws -> RemoteLibraryResponseDTO { _ = deviceId if let remoteLibraryError { throw remoteLibraryError } return remoteLibraryResponse ?? RemoteLibraryResponseDTO(tracks: []) } func downloadAudioAsset( assetId: String, deviceId: String ) async throws -> Data { _ = assetId _ = deviceId if let downloadError { throw downloadError } return audioAssetData ?? Data() } func downloadArtwork( artworkId: String, deviceId: String ) async throws -> Data { _ = artworkId _ = deviceId if let artworkDownloadError { throw artworkDownloadError } return artworkData ?? 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 { _ = fileURL _ = mimeType return UploadSessionStatusResponse( uploadId: uploadId, 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 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() } private func XCTAssertThrowsErrorAsync( _ expression: @escaping () async throws -> Void, file: StaticString = #filePath, line: UInt = #line ) async { do { try await expression() XCTFail("Expected expression to throw an error.", file: file, line: line) } catch { XCTAssertTrue(true) } }