import CryptoKit import Foundation import XCTest import VelodyDomain import VelodyNetworking import VelodyPersistence @testable import VelodySync final class RemoteLibrarySyncServiceTests: XCTestCase { func testBootstrapFirstSyncPersistsRemoteTracksAndCursor() async throws { let store = InMemoryRemoteLibraryStore() let cursorStore = InMemoryRemoteLibrarySyncCursorStore() let track = makeRemoteTrack(trackId: "track-123") let service = makeSyncService( apiClient: MockVelodyAPIClient( bootstrapResponse: SyncBootstrapResponse( nextCursor: SyncCursor(value: "4"), tracks: [track], serverTime: "2026-06-15T12:00:00.000Z" ) ), store: store, cursorStore: cursorStore ) let tracks = try await service.syncRemoteLibrary(deviceId: "device-123") let cachedTracks = try await service.loadCachedRemoteTracks() let storedCursor = try await cursorStore.loadCursor() let downloadStates = try await service.loadDownloadStates() XCTAssertEqual(tracks, [track]) XCTAssertEqual(cachedTracks, [track]) XCTAssertEqual(storedCursor, SyncCursor(value: "4")) XCTAssertEqual(downloadStates.first?.downloadStatus, RemoteTrackDownloadStatus.notDownloaded) } func testChangesForExistingCursorApplyWithoutDuplicateTracks() async throws { let originalTrack = makeRemoteTrack(trackId: "track-123", title: "Old Title") let updatedTrack = makeRemoteTrack(trackId: "track-123", title: "New Title") let secondTrack = makeRemoteTrack( trackId: "track-456", title: "Second Track", createdAt: "2026-06-15T12:01:00.000Z" ) let store = InMemoryRemoteLibraryStore(tracks: [originalTrack]) let cursorStore = InMemoryRemoteLibrarySyncCursorStore( cursor: SyncCursor(value: "4") ) let apiClient = MockVelodyAPIClient( changeResponses: [ SyncChangesResponse( nextCursor: SyncCursor(value: "6"), hasMore: false, requiresBootstrap: false, events: [ SyncEvent( cursor: SyncCursor(value: "5"), entityType: "TRACK", entityId: updatedTrack.trackId, action: "UPDATED", track: updatedTrack, createdAt: "2026-06-15T12:00:30.000Z" ), SyncEvent( cursor: SyncCursor(value: "6"), entityType: "AUDIO_ASSET", entityId: secondTrack.assetId, action: "CREATED", track: secondTrack, createdAt: "2026-06-15T12:01:00.000Z" ), ], serverTime: "2026-06-15T12:01:00.000Z" ), ] ) let service = makeSyncService( apiClient: apiClient, store: store, cursorStore: cursorStore ) let tracks = try await service.syncRemoteLibrary(deviceId: "device-123") let storedCursor = try await cursorStore.loadCursor() XCTAssertEqual(tracks, [updatedTrack, secondTrack]) XCTAssertEqual(Set(tracks.map(\.trackId)), ["track-123", "track-456"]) XCTAssertEqual(storedCursor, SyncCursor(value: "6")) let changeCursors = await apiClient.recordedChangeCursors() XCTAssertEqual(changeCursors, ["4"]) } func testPaginationAndReplayEventsDoNotDuplicateTracks() async throws { let originalTrack = makeRemoteTrack(trackId: "track-123", title: "Old Title") let updatedTrack = makeRemoteTrack(trackId: "track-123", title: "Updated Once") let secondUpdate = makeRemoteTrack(trackId: "track-123", title: "Updated Twice") let secondTrack = makeRemoteTrack( trackId: "track-456", title: "Another Track", createdAt: "2026-06-15T12:02:00.000Z" ) let store = InMemoryRemoteLibraryStore(tracks: [originalTrack]) let cursorStore = InMemoryRemoteLibrarySyncCursorStore( cursor: SyncCursor(value: "3") ) let apiClient = MockVelodyAPIClient( changeResponses: [ SyncChangesResponse( nextCursor: SyncCursor(value: "5"), hasMore: true, requiresBootstrap: false, events: [ SyncEvent( cursor: SyncCursor(value: "4"), entityType: "TRACK", entityId: updatedTrack.trackId, action: "UPDATED", track: updatedTrack, createdAt: "2026-06-15T12:00:30.000Z" ), SyncEvent( cursor: SyncCursor(value: "5"), entityType: "TRACK", entityId: secondUpdate.trackId, action: "UPDATED", track: secondUpdate, createdAt: "2026-06-15T12:01:00.000Z" ), ], serverTime: "2026-06-15T12:01:00.000Z" ), SyncChangesResponse( nextCursor: SyncCursor(value: "6"), hasMore: false, requiresBootstrap: false, events: [ SyncEvent( cursor: SyncCursor(value: "6"), entityType: "TRACK", entityId: secondTrack.trackId, action: "CREATED", track: secondTrack, createdAt: "2026-06-15T12:02:00.000Z" ), ], serverTime: "2026-06-15T12:02:00.000Z" ), ] ) let service = makeSyncService( apiClient: apiClient, store: store, cursorStore: cursorStore ) let tracks = try await service.syncRemoteLibrary(deviceId: "device-123") XCTAssertEqual(tracks, [secondUpdate, secondTrack]) XCTAssertEqual(Set(tracks.map(\.trackId)).count, 2) let changeCursors = await apiClient.recordedChangeCursors() XCTAssertEqual(changeCursors, ["3", "5"]) } func testRequiresBootstrapFallbackReplacesCachedLibraryAndCursor() async throws { let staleTrack = makeRemoteTrack(trackId: "track-stale", title: "Stale") let freshTrack = makeRemoteTrack(trackId: "track-fresh", title: "Fresh") let store = InMemoryRemoteLibraryStore(tracks: [staleTrack]) let cursorStore = InMemoryRemoteLibrarySyncCursorStore( cursor: SyncCursor(value: "2") ) let apiClient = MockVelodyAPIClient( bootstrapResponse: SyncBootstrapResponse( nextCursor: SyncCursor(value: "9"), tracks: [freshTrack], serverTime: "2026-06-15T12:05:00.000Z" ), changeResponses: [ SyncChangesResponse( nextCursor: SyncCursor(value: "2"), hasMore: false, requiresBootstrap: true, reason: "cursor_too_old", events: [], serverTime: "2026-06-15T12:04:00.000Z" ), ] ) let service = makeSyncService( apiClient: apiClient, store: store, cursorStore: cursorStore ) let tracks = try await service.syncRemoteLibrary(deviceId: "device-123") let storedCursor = try await cursorStore.loadCursor() XCTAssertEqual(tracks, [freshTrack]) XCTAssertEqual(storedCursor, SyncCursor(value: "9")) let changeCursors = await apiClient.recordedChangeCursors() XCTAssertEqual(changeCursors, ["2"]) } func testCursorPersistsAcrossRepositoryInstances() async throws { let fileManager = FileManager.default let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent( UUID().uuidString, isDirectory: true ) let remoteLibraryFileURL = tempDirectory.appendingPathComponent("remote-library.json") let syncCursorFileURL = tempDirectory.appendingPathComponent("remote-library-sync-cursor.json") let bootstrapTrack = makeRemoteTrack(trackId: "track-bootstrap") let changedTrack = makeRemoteTrack(trackId: "track-bootstrap", title: "Track Bootstrap Updated") defer { try? fileManager.removeItem(at: tempDirectory) } let firstClient = MockVelodyAPIClient( bootstrapResponse: SyncBootstrapResponse( nextCursor: SyncCursor(value: "2"), tracks: [bootstrapTrack], serverTime: "2026-06-15T12:00:00.000Z" ) ) let firstService = makeSyncService( apiClient: firstClient, store: try FileRemoteLibraryStore(fileURL: remoteLibraryFileURL), cursorStore: try FileRemoteLibrarySyncCursorStore(fileURL: syncCursorFileURL) ) _ = try await firstService.syncRemoteLibrary(deviceId: "device-123") let secondClient = MockVelodyAPIClient( changeResponses: [ SyncChangesResponse( nextCursor: SyncCursor(value: "3"), hasMore: false, requiresBootstrap: false, events: [ SyncEvent( cursor: SyncCursor(value: "3"), entityType: "TRACK", entityId: changedTrack.trackId, action: "UPDATED", track: changedTrack, createdAt: "2026-06-15T12:01:00.000Z" ), ], serverTime: "2026-06-15T12:01:00.000Z" ), ] ) let secondService = makeSyncService( apiClient: secondClient, store: try FileRemoteLibraryStore(fileURL: remoteLibraryFileURL), cursorStore: try FileRemoteLibrarySyncCursorStore(fileURL: syncCursorFileURL) ) let tracks = try await secondService.syncRemoteLibrary(deviceId: "device-123") let storedCursor = try await FileRemoteLibrarySyncCursorStore(fileURL: syncCursorFileURL) .loadCursor() XCTAssertEqual(tracks, [changedTrack]) XCTAssertEqual(storedCursor, SyncCursor(value: "3")) let changeCursors = await secondClient.recordedChangeCursors() XCTAssertEqual(changeCursors, ["2"]) } func testNetworkFailureLeavesCachedLibraryAndCursorIntact() async throws { let cachedTrack = makeRemoteTrack(trackId: "track-123", title: "Cached") let store = InMemoryRemoteLibraryStore(tracks: [cachedTrack]) let cursorStore = InMemoryRemoteLibrarySyncCursorStore( cursor: SyncCursor(value: "7") ) let service = makeSyncService( apiClient: MockVelodyAPIClient( changeError: VelodyAPIError.requestFailed("Offline") ), store: store, cursorStore: cursorStore ) await XCTAssertThrowsErrorAsync { _ = try await service.syncRemoteLibrary(deviceId: "device-123") } let cachedTracks = try await service.loadCachedRemoteTracks() let storedCursor = try await cursorStore.loadCursor() XCTAssertEqual(cachedTracks, [cachedTrack]) XCTAssertEqual(storedCursor, SyncCursor(value: "7")) } func testPersistedCursorSurvivesRelaunchFailureAndLaterRecovery() async throws { let fileManager = FileManager.default let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent( UUID().uuidString, isDirectory: true ) let remoteLibraryFileURL = tempDirectory.appendingPathComponent("remote-library.json") let syncCursorFileURL = tempDirectory.appendingPathComponent("remote-library-sync-cursor.json") let bootstrapTrack = makeRemoteTrack(trackId: "track-cursor-persist") let updatedTrack = makeRemoteTrack( trackId: bootstrapTrack.trackId, title: "Track Cursor Updated" ) defer { try? fileManager.removeItem(at: tempDirectory) } let firstService = makeSyncService( apiClient: MockVelodyAPIClient( bootstrapResponse: SyncBootstrapResponse( nextCursor: SyncCursor(value: "1"), tracks: [bootstrapTrack], serverTime: "2026-06-15T12:00:00.000Z" ) ), store: try FileRemoteLibraryStore(fileURL: remoteLibraryFileURL), cursorStore: try FileRemoteLibrarySyncCursorStore(fileURL: syncCursorFileURL) ) _ = try await firstService.syncRemoteLibrary(deviceId: "device-123") let offlineService = makeSyncService( apiClient: MockVelodyAPIClient( changeError: VelodyAPIError.requestFailed("Offline") ), store: try FileRemoteLibraryStore(fileURL: remoteLibraryFileURL), cursorStore: try FileRemoteLibrarySyncCursorStore(fileURL: syncCursorFileURL) ) await XCTAssertThrowsErrorAsync { _ = try await offlineService.syncRemoteLibrary(deviceId: "device-123") } let recoveryClient = MockVelodyAPIClient( changeResponses: [ SyncChangesResponse( nextCursor: SyncCursor(value: "2"), hasMore: false, requiresBootstrap: false, events: [ SyncEvent( cursor: SyncCursor(value: "2"), entityType: "TRACK", entityId: updatedTrack.trackId, action: "UPDATED", track: updatedTrack, createdAt: "2026-06-15T12:05:00.000Z" ), ], serverTime: "2026-06-15T12:05:00.000Z" ), ] ) let recoveredService = makeSyncService( apiClient: recoveryClient, store: try FileRemoteLibraryStore(fileURL: remoteLibraryFileURL), cursorStore: try FileRemoteLibrarySyncCursorStore(fileURL: syncCursorFileURL) ) let recoveredTracks = try await recoveredService.syncRemoteLibrary(deviceId: "device-123") let recoveredCursor = try await FileRemoteLibrarySyncCursorStore(fileURL: syncCursorFileURL) .loadCursor() XCTAssertEqual(recoveredTracks, [updatedTrack]) XCTAssertEqual(recoveredCursor, SyncCursor(value: "2")) let recordedChangeCursors = await recoveryClient.recordedChangeCursors() XCTAssertEqual(recordedChangeCursors, ["1"]) } func testSyncFailurePreservesDownloadedStateAndLocalFile() async throws { let track = makeRemoteTrack(trackId: "track-offline", assetId: "asset-offline") let localFilePath = "/in-memory/\(track.assetId).mp3" let downloadStateStore = InMemoryRemoteTrackDownloadStateStore(states: [ RemoteTrackDownloadState( remoteTrackId: track.trackId, assetId: track.assetId, localFilePath: localFilePath, downloadedAt: Date(timeIntervalSince1970: 3_000), downloadStatus: .downloaded ), ]) let audioFileStore = InMemoryOfflineAudioFileStore(files: [ localFilePath: sampleMp3Data(seed: "network-safe"), ]) let service = makeSyncService( apiClient: MockVelodyAPIClient( changeError: VelodyAPIError.requestFailed("Offline") ), store: InMemoryRemoteLibraryStore(tracks: [track]), cursorStore: InMemoryRemoteLibrarySyncCursorStore( cursor: SyncCursor(value: "7") ), downloadStateStore: downloadStateStore, audioFileStore: audioFileStore ) await XCTAssertThrowsErrorAsync { _ = try await service.syncRemoteLibrary(deviceId: "device-123") } let recoveredState = try await service.loadDownloadStates().first let fileExists = await audioFileStore.fileExists(at: localFilePath) XCTAssertEqual(recoveredState?.downloadStatus, .downloaded) XCTAssertEqual(recoveredState?.localFilePath, localFilePath) XCTAssertTrue(fileExists) } func testDownloadTrackPersistsDownloadedStateAndFile() async throws { let downloadStateStore = InMemoryRemoteTrackDownloadStateStore() let audioFileStore = InMemoryOfflineAudioFileStore() let service = RemoteLibrarySyncService( repository: DefaultRemoteLibraryRepository( apiClient: MockVelodyAPIClient( audioAssetData: sampleMp3Data(seed: "download-success") ), store: InMemoryRemoteLibraryStore(), syncCursorStore: InMemoryRemoteLibrarySyncCursorStore() ), downloadStateStore: downloadStateStore, audioFileStore: audioFileStore, artworkStore: InMemoryArtworkStore() ) let track = makeRemoteTrack( trackId: "track-123", sha256: sha256Hex(sampleMp3Data(seed: "download-success")), assetId: "asset-456" ) 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, RemoteTrackDownloadStatus.downloaded) XCTAssertEqual(state.assetId, "asset-456") XCTAssertFalse(state.localFilePath.isEmpty) XCTAssertEqual(storedStates.first?.downloadStatus, RemoteTrackDownloadStatus.downloaded) XCTAssertTrue(fileExists) } func testDownloadTrackPersistsFailureState() async throws { let service = RemoteLibrarySyncService( repository: DefaultRemoteLibraryRepository( apiClient: MockVelodyAPIClient( downloadError: VelodyAPIError.server(statusCode: 404, message: "Missing") ), store: InMemoryRemoteLibraryStore(), syncCursorStore: InMemoryRemoteLibrarySyncCursorStore() ), downloadStateStore: InMemoryRemoteTrackDownloadStateStore(), audioFileStore: InMemoryOfflineAudioFileStore(), artworkStore: InMemoryArtworkStore() ) let track = makeRemoteTrack(trackId: "track-123", assetId: "asset-456") await XCTAssertThrowsErrorAsync { _ = try await service.downloadTrack(track, deviceId: "device-123") } let storedStates = try await service.loadDownloadStates() XCTAssertEqual(storedStates.first?.downloadStatus, RemoteTrackDownloadStatus.failed) XCTAssertEqual(storedStates.first?.remoteTrackId, "track-123") } func testRetryAfterFailureCanSucceedAndPersistDownloadedState() async throws { let audioData = sampleMp3Data(seed: "retry-success") let track = makeRemoteTrack( trackId: "track-retry", sha256: sha256Hex(audioData), assetId: "asset-retry" ) let downloadStateStore = InMemoryRemoteTrackDownloadStateStore() let audioFileStore = InMemoryOfflineAudioFileStore() let service = RemoteLibrarySyncService( repository: SequencedDownloadRepository( downloadResults: [ .failure(VelodyAPIError.server(statusCode: 503, message: "Try Again")), .success(audioData), ] ), downloadStateStore: downloadStateStore, audioFileStore: audioFileStore, artworkStore: InMemoryArtworkStore() ) await XCTAssertThrowsErrorAsync { _ = try await service.downloadTrack(track, deviceId: "device-123") } let failedState = try await service.loadDownloadStates().first XCTAssertEqual(failedState?.downloadStatus, .failed) let recoveredState = try await service.downloadTrack(track, deviceId: "device-123") let persistedState = try await downloadStateStore.loadDownloadStates().first let fileExists = await audioFileStore.fileExists(at: recoveredState.localFilePath) XCTAssertEqual(recoveredState.downloadStatus, .downloaded) XCTAssertEqual(persistedState?.downloadStatus, .downloaded) XCTAssertFalse(recoveredState.localFilePath.isEmpty) XCTAssertTrue(fileExists) } func testLoadDownloadStatesRecoversInterruptedDownloadToFailedRetryStateWhenFileIsMissing() async throws { let interruptedState = RemoteTrackDownloadState( remoteTrackId: "track-123", assetId: "asset-456", localFilePath: "/in-memory/asset-456.mp3", downloadedAt: Date(timeIntervalSince1970: 1_000), downloadStatus: .downloading ) let downloadStateStore = InMemoryRemoteTrackDownloadStateStore(states: [interruptedState]) let service = makeSyncService( apiClient: MockVelodyAPIClient(), store: InMemoryRemoteLibraryStore(), cursorStore: InMemoryRemoteLibrarySyncCursorStore(), downloadStateStore: downloadStateStore, audioFileStore: InMemoryOfflineAudioFileStore() ) let recoveredStates = try await service.loadDownloadStates() let recoveredState = try XCTUnwrap(recoveredStates.first) let persistedState = try await downloadStateStore.loadDownloadStates().first XCTAssertEqual(recoveredState.downloadStatus, .failed) XCTAssertEqual( recoveredState.lastDownloadError, "The previous download did not finish. Try again." ) XCTAssertEqual(recoveredState.localFilePath, "") XCTAssertNil(recoveredState.downloadedAt) XCTAssertEqual(persistedState, recoveredState) } func testLoadDownloadStatesRecoversInterruptedDownloadToDownloadedWhenFileExists() async throws { let recoveredFilePath = "/in-memory/asset-456.mp3" let recoveredDate = Date(timeIntervalSince1970: 2_000) let interruptedState = RemoteTrackDownloadState( remoteTrackId: "track-123", assetId: "asset-456", localFilePath: "", downloadedAt: recoveredDate, downloadStatus: .downloading, lastDownloadError: "Interrupted" ) let downloadStateStore = InMemoryRemoteTrackDownloadStateStore(states: [interruptedState]) let audioFileStore = InMemoryOfflineAudioFileStore(files: [ recoveredFilePath: sampleMp3Data(seed: "interrupted-file"), ]) let service = makeSyncService( apiClient: MockVelodyAPIClient(), store: InMemoryRemoteLibraryStore(), cursorStore: InMemoryRemoteLibrarySyncCursorStore(), downloadStateStore: downloadStateStore, audioFileStore: audioFileStore ) let recoveredStates = try await service.loadDownloadStates() let recoveredState = try XCTUnwrap(recoveredStates.first) let persistedState = try await downloadStateStore.loadDownloadStates().first XCTAssertEqual(recoveredState.downloadStatus, .downloaded) XCTAssertEqual(recoveredState.localFilePath, recoveredFilePath) XCTAssertEqual(recoveredState.downloadedAt, recoveredDate) XCTAssertNil(recoveredState.lastDownloadError) XCTAssertEqual(persistedState, recoveredState) } 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 = makeRemoteTrack( trackId: "track-123", title: "1 Mai 2026", sha256: sha256Hex(audioData), assetId: "asset-456" ) defer { try? fileManager.removeItem(at: tempDirectory) } let firstService = RemoteLibrarySyncService( repository: DefaultRemoteLibraryRepository( apiClient: MockVelodyAPIClient(audioAssetData: audioData), store: InMemoryRemoteLibraryStore(), syncCursorStore: InMemoryRemoteLibrarySyncCursorStore() ), 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(), store: InMemoryRemoteLibraryStore(), syncCursorStore: InMemoryRemoteLibrarySyncCursorStore() ), 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, RemoteTrackDownloadStatus.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 track = makeRemoteTrack( trackId: "track-123", artwork: artwork ) let artworkStore = InMemoryArtworkStore() let service = makeSyncService( apiClient: MockVelodyAPIClient( bootstrapResponse: SyncBootstrapResponse( nextCursor: SyncCursor(value: "1"), tracks: [track], serverTime: "2026-06-15T12:00:00.000Z" ), artworkData: sampleArtworkData() ), store: InMemoryRemoteLibraryStore(), cursorStore: InMemoryRemoteLibrarySyncCursorStore(), 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 func makeSyncService( apiClient: any VelodyAPIClient, store: any RemoteLibraryStore, cursorStore: any RemoteLibrarySyncCursorStore, downloadStateStore: any RemoteTrackDownloadStateStore = InMemoryRemoteTrackDownloadStateStore(), audioFileStore: any OfflineAudioFileStore = InMemoryOfflineAudioFileStore(), artworkStore: any ArtworkStore = InMemoryArtworkStore() ) -> RemoteLibrarySyncService { RemoteLibrarySyncService( repository: DefaultRemoteLibraryRepository( apiClient: apiClient, store: store, syncCursorStore: cursorStore ), downloadStateStore: downloadStateStore, audioFileStore: audioFileStore, artworkStore: artworkStore ) } private actor MockVelodyAPIClient: VelodyAPIClient { private let bootstrapResponse: SyncBootstrapResponse private let changeResponses: [SyncChangesResponse] private let changeError: VelodyAPIError? private let audioAssetData: Data? private let downloadError: VelodyAPIError? private let artworkData: Data? private let artworkDownloadError: VelodyAPIError? private var changeResponseIndex = 0 private var changeCursors: [String] = [] init( bootstrapResponse: SyncBootstrapResponse = SyncBootstrapResponse( nextCursor: SyncCursor(value: "0"), tracks: [], serverTime: "2026-06-15T12:00:00.000Z" ), changeResponses: [SyncChangesResponse] = [], changeError: VelodyAPIError? = nil, audioAssetData: Data? = nil, downloadError: VelodyAPIError? = nil, artworkData: Data? = nil, artworkDownloadError: VelodyAPIError? = nil ) { self.bootstrapResponse = bootstrapResponse self.changeResponses = changeResponses self.changeError = changeError 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-06-15T12:00:00.000Z" ) } func sendHeartbeat( _ payload: DeviceHeartbeatPayload ) async throws -> DeviceHeartbeatResponse { _ = payload return DeviceHeartbeatResponse( ok: true, serverTime: "2026-06-15T12:00:00.000Z" ) } func fetchSyncBootstrap() async throws -> SyncBootstrapResponse { bootstrapResponse } func fetchSyncChanges( cursor: SyncCursor ) async throws -> SyncChangesResponse { changeCursors.append(cursor.value) if let changeError { throw changeError } guard !changeResponses.isEmpty else { return SyncChangesResponse( nextCursor: cursor, hasMore: false, requiresBootstrap: false, events: [], serverTime: "2026-06-15T12:00:00.000Z" ) } let response = changeResponses[min(changeResponseIndex, changeResponses.count - 1)] changeResponseIndex += 1 return response } func fetchRemoteLibrary( deviceId: String ) async throws -> RemoteLibraryResponseDTO { _ = deviceId return 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 ) } func recordedChangeCursors() -> [String] { changeCursors } } private actor SequencedDownloadRepository: RemoteLibraryRepository { private var downloadResults: [Result] private let tracks: [RemoteTrack] init( downloadResults: [Result], tracks: [RemoteTrack] = [] ) { self.downloadResults = downloadResults 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 guard !downloadResults.isEmpty else { return Data() } let result = downloadResults.removeFirst() switch result { case let .success(data): return data case let .failure(error): throw error } } func downloadArtwork(artworkId: String, deviceId: String) async throws -> Data { _ = artworkId _ = deviceId return Data() } } private func makeRemoteTrack( trackId: String, title: String = "Remote Title", artist: String = "Remote Artist", durationSeconds: Int = 245, sha256: String = String(repeating: "a", count: 64), assetId: String? = nil, createdAt: String = "2026-06-15T12:00:00.000Z", updatedAt: String = "2026-06-15T12:05:00.000Z", artwork: RemoteArtwork? = nil ) -> RemoteTrack { RemoteTrack( trackId: trackId, title: title, artist: artist, durationSeconds: durationSeconds, sha256: sha256, assetId: assetId ?? "asset-\(trackId)", createdAt: createdAt, updatedAt: updatedAt, artwork: artwork ) } 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) } }