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() ) 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() ) 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() ) 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 ) 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() ) 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) ) 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 ) 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) } } private struct MockVelodyAPIClient: VelodyAPIClient { let remoteLibraryResponse: RemoteLibraryResponseDTO? let remoteLibraryError: VelodyAPIError? let audioAssetData: Data? let downloadError: VelodyAPIError? init( remoteLibraryResponse: RemoteLibraryResponseDTO? = nil, remoteLibraryError: VelodyAPIError? = nil, audioAssetData: Data? = nil, downloadError: VelodyAPIError? = nil ) { self.remoteLibraryResponse = remoteLibraryResponse self.remoteLibraryError = remoteLibraryError self.audioAssetData = audioAssetData self.downloadError = downloadError } func registerDevice( _ payload: DeviceRegistrationPayload ) async throws -> DeviceRegistrationResponse { _ = payload return DeviceRegistrationResponse( deviceId: 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 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 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) } }