From ef6f11c7b112eaa0b144e858536a42eea99ce372 Mon Sep 17 00:00:00 2001 From: diyaa Date: Fri, 19 Jun 2026 04:08:36 +0200 Subject: [PATCH] Add offline resilience coverage --- apps/apple/Velody.xcodeproj/project.pbxproj | 4 + ...braryViewModelOfflineResilienceTests.swift | 635 ++++++++++++++++++ .../VelodySync/RemoteLibrarySyncService.swift | 14 + .../OfflineLibraryServiceTests.swift | 276 +++++++- .../RemoteLibrarySyncServiceTests.swift | 80 +++ 5 files changed, 1007 insertions(+), 2 deletions(-) create mode 100644 apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelOfflineResilienceTests.swift diff --git a/apps/apple/Velody.xcodeproj/project.pbxproj b/apps/apple/Velody.xcodeproj/project.pbxproj index 58221b3..a7d0de8 100644 --- a/apps/apple/Velody.xcodeproj/project.pbxproj +++ b/apps/apple/Velody.xcodeproj/project.pbxproj @@ -27,6 +27,7 @@ A54D8AD8A59D8B77FCA0794F /* MacLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB28FE17346E100F697C1BF4 /* MacLibraryView.swift */; }; A62771F49BF9AA1ABCF7961E /* VelodyUtilities in Frameworks */ = {isa = PBXBuildFile; productRef = B15F842ACBB110CC8A766669 /* VelodyUtilities */; }; AB6C7E42A3A850D395E4F5E7 /* VelodyPersistence in Frameworks */ = {isa = PBXBuildFile; productRef = C8F5FF593C4DB829D1CDD497 /* VelodyPersistence */; }; + B8B8A1F94D594953A4877776 /* iPhoneLibraryViewModelOfflineResilienceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA92429F41945EB96C05858 /* iPhoneLibraryViewModelOfflineResilienceTests.swift */; }; AC8B414ECE5493BD52DEC44A /* VelodyPlayback in Frameworks */ = {isa = PBXBuildFile; productRef = A9678775BC86EBB3155ECBDE /* VelodyPlayback */; }; CDF41A3983C5430598E4E84D /* iPhoneLibraryViewModelPlaybackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DD22E56A863E58C2652306B /* iPhoneLibraryViewModelPlaybackTests.swift */; }; D0D65CE73B9DFF3C73F432DB /* VelodySync in Frameworks */ = {isa = PBXBuildFile; productRef = 2449C403E81DD84D7A8DD7E1 /* VelodySync */; }; @@ -50,6 +51,7 @@ /* Begin PBXFileReference section */ 07508485E10C6E2942FE29AB /* VelodySync */ = {isa = PBXFileReference; lastKnownFileType = folder; name = VelodySync; path = ../../packages/apple/VelodySync; sourceTree = SOURCE_ROOT; }; + 0CA92429F41945EB96C05858 /* iPhoneLibraryViewModelOfflineResilienceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iPhoneLibraryViewModelOfflineResilienceTests.swift; sourceTree = ""; }; 0F6993844F6FD7E86D52EC25 /* VelodyNetworking */ = {isa = PBXFileReference; lastKnownFileType = folder; name = VelodyNetworking; path = ../../packages/apple/VelodyNetworking; sourceTree = SOURCE_ROOT; }; 15A17C02F8CBB0A492A82C14 /* VelodyPlayback */ = {isa = PBXFileReference; lastKnownFileType = folder; name = VelodyPlayback; path = ../../packages/apple/VelodyPlayback; sourceTree = SOURCE_ROOT; }; 1913BA882BB97E1B90C3B30B /* VelodyiPhoneTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = VelodyiPhoneTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -118,6 +120,7 @@ isa = PBXGroup; children = ( 6DE70FE94372F028D76DC335 /* iPhoneLibraryViewModelFavoritesTests.swift */, + 0CA92429F41945EB96C05858 /* iPhoneLibraryViewModelOfflineResilienceTests.swift */, 7DD22E56A863E58C2652306B /* iPhoneLibraryViewModelPlaybackTests.swift */, D2446CD01A27662F88EA0F43 /* iPhoneLibraryViewModelTestSupport.swift */, ); @@ -313,6 +316,7 @@ buildActionMask = 2147483647; files = ( DCB814642BA3F081D4B5A3BE /* iPhoneLibraryViewModelFavoritesTests.swift in Sources */, + B8B8A1F94D594953A4877776 /* iPhoneLibraryViewModelOfflineResilienceTests.swift in Sources */, CDF41A3983C5430598E4E84D /* iPhoneLibraryViewModelPlaybackTests.swift in Sources */, A1FB43063B59B52B1C90A7A7 /* iPhoneLibraryViewModelTestSupport.swift in Sources */, ); diff --git a/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelOfflineResilienceTests.swift b/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelOfflineResilienceTests.swift new file mode 100644 index 0000000..b0b99d4 --- /dev/null +++ b/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelOfflineResilienceTests.swift @@ -0,0 +1,635 @@ +import CryptoKit +import Foundation +import XCTest +import VelodyDomain +import VelodyNetworking +import VelodyPersistence +import VelodySync +import VelodyUtilities +@testable import VelodyiPhone + +@MainActor +final class iPhoneLibraryViewModelOfflineResilienceTests: XCTestCase { + func testRelaunchWhileBackendUnavailableRestoresFavoriteDownloadedArtworkAndOfflinePlayback() async throws { + let fixture = try await seedPersistedLibrary() + defer { + try? FileManager.default.removeItem(at: fixture.tempDirectory) + } + + let player = TestPlayer() + let offlineClient = ScenarioVelodyAPIClient( + bootstrapResponse: fixture.bootstrapResponse, + changeError: .requestFailed("Offline") + ) + let relaunchedViewModel = try makePersistedViewModel( + fixture: fixture, + apiClient: offlineClient, + player: player + ) + + await relaunchedViewModel.loadIfNeeded() + + let remoteTrack = try XCTUnwrap( + remoteRow(in: relaunchedViewModel, trackID: fixture.track.trackId) + ) + let offlineTrack = try XCTUnwrap( + offlineRow(in: relaunchedViewModel, trackID: fixture.track.trackId) + ) + + XCTAssertEqual(relaunchedViewModel.syncStatus, "Library restored from local cache.") + XCTAssertTrue(remoteTrack.isFavorite) + XCTAssertEqual(remoteTrack.status, .downloaded) + XCTAssertEqual(remoteTrack.artworkLocalFilePath, fixture.initialArtworkPath) + XCTAssertTrue(offlineTrack.isFavorite) + XCTAssertEqual(offlineTrack.statusBadgeTitle, "Downloaded") + + relaunchedViewModel.togglePlayback(trackID: fixture.track.trackId) + + let nowPlayingCard = try XCTUnwrap(relaunchedViewModel.nowPlayingCard) + XCTAssertEqual(relaunchedViewModel.nowPlaying.trackID, fixture.track.trackId) + XCTAssertEqual(relaunchedViewModel.nowPlaying.playbackState, .playing) + XCTAssertEqual(nowPlayingCard.artworkLocalFilePath, fixture.initialArtworkPath) + XCTAssertEqual(nowPlayingCard.downloadBadge, .downloaded) + XCTAssertEqual(nowPlayingCard.playbackStateText, "Playing") + } + + func testBackendFailureAfterRelaunchKeepsCachedLibraryUsableAndReusesPersistedCursor() async throws { + let fixture = try await seedPersistedLibrary() + defer { + try? FileManager.default.removeItem(at: fixture.tempDirectory) + } + + let offlineClient = ScenarioVelodyAPIClient( + bootstrapResponse: fixture.bootstrapResponse, + changeError: .requestFailed("Offline") + ) + let relaunchedViewModel = try makePersistedViewModel( + fixture: fixture, + apiClient: offlineClient, + player: TestPlayer() + ) + + await relaunchedViewModel.loadIfNeeded() + await relaunchedViewModel.refreshSync() + + XCTAssertEqual( + relaunchedViewModel.syncStatus, + "Could not reach the backend. Check that the server is running and try again." + ) + XCTAssertEqual(relaunchedViewModel.remoteTracks.map(\.id), [fixture.track.trackId]) + XCTAssertEqual(relaunchedViewModel.availableOfflineTracks.map(\.id), [fixture.track.trackId]) + let recordedChangeCursors = await offlineClient.recordedChangeCursors() + XCTAssertEqual(recordedChangeCursors, ["1"]) + let persistedCursor = try await FileRemoteLibrarySyncCursorStore( + fileURL: fixture.syncCursorFileURL + ).loadCursor() + XCTAssertEqual(persistedCursor, SyncCursor(value: "1")) + } + + func testArtworkReplacementSyncKeepsFavoriteAndDownloadedState() async throws { + let fixture = try await seedPersistedLibrary() + defer { + try? FileManager.default.removeItem(at: fixture.tempDirectory) + } + + let replacementArtwork = RemoteArtwork( + artworkId: "artwork-replacement", + sha256: sha256Hex(sampleArtworkData(seed: "replacement-artwork")), + mimeType: "image/png", + width: 1, + height: 1 + ) + var replacementTrack = fixture.track + replacementTrack.updatedAt = "2026-06-16T09:30:00.000Z" + replacementTrack.artwork = replacementArtwork + let replacementArtworkPath = expectedArtworkFilePath( + in: fixture.artworkDirectory, + artwork: replacementArtwork + ) + let refreshClient = ScenarioVelodyAPIClient( + bootstrapResponse: fixture.bootstrapResponse, + changeResponses: [ + SyncChangesResponse( + nextCursor: SyncCursor(value: "2"), + hasMore: false, + requiresBootstrap: false, + events: [ + SyncEvent( + cursor: SyncCursor(value: "2"), + entityType: "ARTWORK_ASSET", + entityId: replacementArtwork.artworkId, + action: "UPDATED", + track: replacementTrack, + createdAt: "2026-06-16T09:30:00.000Z" + ), + ], + serverTime: "2026-06-16T09:30:00.000Z" + ), + ], + artworkDataByArtworkID: [ + replacementArtwork.artworkId: sampleArtworkData(seed: "replacement-artwork"), + ] + ) + let viewModel = try makePersistedViewModel( + fixture: fixture, + apiClient: refreshClient, + player: TestPlayer() + ) + + await viewModel.loadIfNeeded() + await viewModel.refreshSync() + + let remoteTrack = try XCTUnwrap(remoteRow(in: viewModel, trackID: fixture.track.trackId)) + let offlineTrack = try XCTUnwrap(offlineRow(in: viewModel, trackID: fixture.track.trackId)) + + XCTAssertTrue(remoteTrack.isFavorite) + XCTAssertEqual(remoteTrack.status, .downloaded) + XCTAssertEqual(remoteTrack.artworkLocalFilePath, replacementArtworkPath) + XCTAssertNotEqual(remoteTrack.artworkLocalFilePath, fixture.initialArtworkPath) + XCTAssertEqual(offlineTrack.statusBadgeTitle, "Downloaded") + + viewModel.togglePlayback(trackID: fixture.track.trackId) + + let nowPlayingCard = try XCTUnwrap(viewModel.nowPlayingCard) + XCTAssertEqual(nowPlayingCard.artworkLocalFilePath, replacementArtworkPath) + XCTAssertEqual(nowPlayingCard.downloadBadge, .downloaded) + } + + func testAssetReplacementSyncKeepsFavoriteAndDownloadedState() async throws { + let fixture = try await seedPersistedLibrary() + defer { + try? FileManager.default.removeItem(at: fixture.tempDirectory) + } + + let replacementAssetId = "asset-replacement" + var replacementTrack = makeRemoteTrack( + trackId: fixture.track.trackId, + assetId: replacementAssetId, + title: fixture.track.title, + artwork: fixture.track.artwork + ) + replacementTrack.sha256 = sha256Hex(sampleAudioData(seed: replacementAssetId)) + replacementTrack.updatedAt = "2026-06-16T10:00:00.000Z" + let refreshClient = ScenarioVelodyAPIClient( + bootstrapResponse: fixture.bootstrapResponse, + changeResponses: [ + SyncChangesResponse( + nextCursor: SyncCursor(value: "2"), + hasMore: false, + requiresBootstrap: false, + events: [ + SyncEvent( + cursor: SyncCursor(value: "2"), + entityType: "AUDIO_ASSET", + entityId: replacementAssetId, + action: "UPDATED", + track: replacementTrack, + createdAt: "2026-06-16T10:00:00.000Z" + ), + ], + serverTime: "2026-06-16T10:00:00.000Z" + ), + ] + ) + let viewModel = try makePersistedViewModel( + fixture: fixture, + apiClient: refreshClient, + player: TestPlayer() + ) + + await viewModel.loadIfNeeded() + await viewModel.refreshSync() + + let remoteTrack = try XCTUnwrap(remoteRow(in: viewModel, trackID: fixture.track.trackId)) + let offlineTrack = try XCTUnwrap(offlineRow(in: viewModel, trackID: fixture.track.trackId)) + + XCTAssertTrue(remoteTrack.isFavorite) + XCTAssertEqual(remoteTrack.status, .downloaded) + XCTAssertEqual(offlineTrack.statusBadgeTitle, "Downloaded") + XCTAssertEqual(viewModel.availableOfflineTracks.map(\.id), [fixture.track.trackId]) + + viewModel.togglePlayback(trackID: fixture.track.trackId) + + XCTAssertEqual(viewModel.nowPlaying.trackID, fixture.track.trackId) + XCTAssertEqual(viewModel.nowPlaying.playbackState, .playing) + } + + func testTrackDeletionSyncRemovesDeletedTrackGracefully() async throws { + let fixture = try await seedPersistedLibrary() + defer { + try? FileManager.default.removeItem(at: fixture.tempDirectory) + } + + let refreshClient = ScenarioVelodyAPIClient( + bootstrapResponse: fixture.bootstrapResponse, + changeResponses: [ + SyncChangesResponse( + nextCursor: SyncCursor(value: "2"), + hasMore: false, + requiresBootstrap: false, + events: [ + SyncEvent( + cursor: SyncCursor(value: "2"), + entityType: "TRACK", + entityId: fixture.track.trackId, + action: "DELETED", + deletedTrackId: fixture.track.trackId, + createdAt: "2026-06-16T10:30:00.000Z" + ), + ], + serverTime: "2026-06-16T10:30:00.000Z" + ), + ] + ) + let viewModel = try makePersistedViewModel( + fixture: fixture, + apiClient: refreshClient, + player: TestPlayer() + ) + + await viewModel.loadIfNeeded() + await viewModel.refreshSync() + + XCTAssertTrue(viewModel.remoteTracks.isEmpty) + XCTAssertTrue(viewModel.availableOfflineTracks.isEmpty) + XCTAssertEqual(viewModel.syncStatus, "No music was returned for this library.") + } +} + +private struct PersistedOfflineLibraryFixture { + let tempDirectory: URL + let remoteLibraryFileURL: URL + let downloadStateFileURL: URL + let syncCursorFileURL: URL + let favoriteTrackFileURL: URL + let audioDirectory: URL + let artworkDirectory: URL + let keychainService: MemoryKeychainService + let track: RemoteTrack + let bootstrapResponse: SyncBootstrapResponse + let initialArtworkPath: String +} + +@MainActor +private func seedPersistedLibrary() async throws -> PersistedOfflineLibraryFixture { + 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 syncCursorFileURL = tempDirectory.appendingPathComponent("remote-library-sync-cursor.json") + let favoriteTrackFileURL = tempDirectory.appendingPathComponent("favorite-tracks.json") + let audioDirectory = tempDirectory.appendingPathComponent("audio", isDirectory: true) + let artworkDirectory = tempDirectory.appendingPathComponent("artwork", isDirectory: true) + let keychainService = MemoryKeychainService() + let artworkData = sampleArtworkData(seed: "seed-artwork") + let artwork = RemoteArtwork( + artworkId: "artwork-seed", + sha256: sha256Hex(artworkData), + mimeType: "image/png", + width: 1, + height: 1 + ) + let track = makeConsistentRemoteTrack( + trackId: "remote-offline-resilience", + assetId: "asset-offline-resilience", + title: "Offline Resilience", + artwork: artwork + ) + let bootstrapResponse = SyncBootstrapResponse( + nextCursor: SyncCursor(value: "1"), + tracks: [track], + serverTime: "2026-06-16T09:00:00.000Z" + ) + let onlineClient = ScenarioVelodyAPIClient( + bootstrapResponse: bootstrapResponse, + audioAssetDataByAssetID: [ + track.assetId: sampleAudioData(seed: track.assetId), + ], + artworkDataByArtworkID: [ + artwork.artworkId: artworkData, + ] + ) + let firstViewModel = try makePersistedViewModel( + remoteLibraryFileURL: remoteLibraryFileURL, + downloadStateFileURL: downloadStateFileURL, + syncCursorFileURL: syncCursorFileURL, + favoriteTrackFileURL: favoriteTrackFileURL, + audioDirectory: audioDirectory, + artworkDirectory: artworkDirectory, + apiClient: onlineClient, + keychainService: keychainService, + player: TestPlayer() + ) + + await firstViewModel.loadIfNeeded() + await firstViewModel.refreshSync() + await firstViewModel.downloadTrack(trackID: track.trackId) + await firstViewModel.toggleFavorite(trackID: track.trackId) + + return PersistedOfflineLibraryFixture( + tempDirectory: tempDirectory, + remoteLibraryFileURL: remoteLibraryFileURL, + downloadStateFileURL: downloadStateFileURL, + syncCursorFileURL: syncCursorFileURL, + favoriteTrackFileURL: favoriteTrackFileURL, + audioDirectory: audioDirectory, + artworkDirectory: artworkDirectory, + keychainService: keychainService, + track: track, + bootstrapResponse: bootstrapResponse, + initialArtworkPath: expectedArtworkFilePath(in: artworkDirectory, artwork: artwork) + ) +} + +@MainActor +private func makePersistedViewModel( + fixture: PersistedOfflineLibraryFixture, + apiClient: any VelodyAPIClient, + player: any iPhoneLocalAudioPlaying +) throws -> iPhoneLibraryViewModel { + try makePersistedViewModel( + remoteLibraryFileURL: fixture.remoteLibraryFileURL, + downloadStateFileURL: fixture.downloadStateFileURL, + syncCursorFileURL: fixture.syncCursorFileURL, + favoriteTrackFileURL: fixture.favoriteTrackFileURL, + audioDirectory: fixture.audioDirectory, + artworkDirectory: fixture.artworkDirectory, + apiClient: apiClient, + keychainService: fixture.keychainService, + player: player + ) +} + +@MainActor +private func makePersistedViewModel( + remoteLibraryFileURL: URL, + downloadStateFileURL: URL, + syncCursorFileURL: URL, + favoriteTrackFileURL: URL, + audioDirectory: URL, + artworkDirectory: URL, + apiClient: any VelodyAPIClient, + keychainService: any KeychainService, + player: any iPhoneLocalAudioPlaying +) throws -> iPhoneLibraryViewModel { + let environment = ServerEnvironment( + baseURL: ServerEnvironment.defaultLocalBaseURL, + appVersion: "Tests" + ) + let repository = DefaultRemoteLibraryRepository( + apiClient: apiClient, + store: try FileRemoteLibraryStore(fileURL: remoteLibraryFileURL), + syncCursorStore: try FileRemoteLibrarySyncCursorStore(fileURL: syncCursorFileURL) + ) + let audioFileStore = try FileOfflineAudioFileStore(baseDirectoryURL: audioDirectory) + let artworkStore = try FileArtworkStore(baseDirectoryURL: artworkDirectory) + let syncService = RemoteLibrarySyncService( + repository: repository, + downloadStateStore: try FileRemoteTrackDownloadStateStore(fileURL: downloadStateFileURL), + audioFileStore: audioFileStore, + artworkStore: artworkStore + ) + let offlineLibraryService = OfflineLibraryService( + syncService: syncService, + audioFileStore: audioFileStore, + artworkStore: artworkStore + ) + + return iPhoneLibraryViewModel( + environment: environment, + apiClient: apiClient, + syncService: syncService, + offlineLibraryService: offlineLibraryService, + favoriteTrackStore: try FileFavoriteTrackStore(fileURL: favoriteTrackFileURL), + player: player, + keychainService: keychainService + ) +} + +private actor ScenarioVelodyAPIClient: VelodyAPIClient { + private let bootstrapResponse: SyncBootstrapResponse + private var changeResponses: [SyncChangesResponse] + private let registerError: VelodyAPIError? + private let changeError: VelodyAPIError? + private let audioAssetDataByAssetID: [String: Data] + private let artworkDataByArtworkID: [String: Data] + private var observedChangeCursors: [String] = [] + + init( + bootstrapResponse: SyncBootstrapResponse, + changeResponses: [SyncChangesResponse] = [], + registerError: VelodyAPIError? = nil, + changeError: VelodyAPIError? = nil, + audioAssetDataByAssetID: [String: Data] = [:], + artworkDataByArtworkID: [String: Data] = [:] + ) { + self.bootstrapResponse = bootstrapResponse + self.changeResponses = changeResponses + self.registerError = registerError + self.changeError = changeError + self.audioAssetDataByAssetID = audioAssetDataByAssetID + self.artworkDataByArtworkID = artworkDataByArtworkID + } + + func registerDevice( + _ payload: DeviceRegistrationPayload + ) async throws -> DeviceRegistrationResponse { + _ = payload + + if let registerError { + throw registerError + } + + return DeviceRegistrationResponse( + deviceId: "device-offline-resilience", + deviceAccessToken: "token-offline-resilience", + bootstrapToken: "bootstrap-offline-resilience", + serverTime: bootstrapResponse.serverTime + ) + } + + func sendHeartbeat( + _ payload: DeviceHeartbeatPayload + ) async throws -> DeviceHeartbeatResponse { + _ = payload + + return DeviceHeartbeatResponse( + ok: true, + serverTime: bootstrapResponse.serverTime + ) + } + + func fetchSyncBootstrap() async throws -> SyncBootstrapResponse { + bootstrapResponse + } + + func fetchSyncChanges( + cursor: SyncCursor + ) async throws -> SyncChangesResponse { + observedChangeCursors.append(cursor.value) + + if let changeError { + throw changeError + } + + guard !changeResponses.isEmpty else { + return SyncChangesResponse( + nextCursor: cursor, + hasMore: false, + requiresBootstrap: false, + events: [], + serverTime: bootstrapResponse.serverTime + ) + } + + return changeResponses.removeFirst() + } + + func fetchRemoteLibrary( + deviceId: String + ) async throws -> RemoteLibraryResponseDTO { + _ = deviceId + + return RemoteLibraryResponseDTO( + tracks: bootstrapResponse.tracks.map { track in + 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 { artwork in + RemoteArtworkDTO( + artworkId: artwork.artworkId, + sha256: artwork.sha256, + mimeType: artwork.mimeType, + width: artwork.width, + height: artwork.height + ) + } + ) + } + ) + } + + func downloadAudioAsset( + assetId: String, + deviceId: String + ) async throws -> Data { + _ = deviceId + return audioAssetDataByAssetID[assetId] ?? 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 { + _ = fileURL + _ = mimeType + + return UploadSessionStatusResponse( + uploadId: uploadId, + status: .completed, + receivedBytes: "0", + expectedSizeBytes: "0", + nextOffset: "0" + ) + } + + func finalizeUpload( + uploadId: String, + payload: UploadFinalizeRequest + ) async throws -> UploadFinalizeResponse { + _ = payload + + return UploadFinalizeResponse( + trackId: uploadId, + assetId: uploadId + ) + } + + func recordedChangeCursors() -> [String] { + observedChangeCursors + } +} + +private func makeConsistentRemoteTrack( + trackId: String, + assetId: String, + title: String, + artwork: RemoteArtwork? +) -> RemoteTrack { + var track = makeRemoteTrack( + trackId: trackId, + assetId: assetId, + title: title, + artwork: artwork + ) + track.sha256 = sha256Hex(sampleAudioData(seed: assetId)) + return track +} + +private func sampleAudioData(seed: String) -> Data { + Data([ + 0x49, 0x44, 0x33, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x21, + ] + Array(seed.utf8)) +} + +private func sampleArtworkData(seed: String) -> Data { + Data([ + 0x89, 0x50, 0x4E, 0x47, + ] + Array(seed.utf8)) +} + +private func sha256Hex(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() +} + +private func expectedArtworkFilePath( + in artworkDirectory: URL, + artwork: RemoteArtwork +) -> String { + artworkDirectory + .appendingPathComponent("\(artwork.artworkId).png") + .standardizedFileURL + .path +} diff --git a/packages/apple/VelodySync/Sources/VelodySync/RemoteLibrarySyncService.swift b/packages/apple/VelodySync/Sources/VelodySync/RemoteLibrarySyncService.swift index a15a5c2..700e1c1 100644 --- a/packages/apple/VelodySync/Sources/VelodySync/RemoteLibrarySyncService.swift +++ b/packages/apple/VelodySync/Sources/VelodySync/RemoteLibrarySyncService.swift @@ -117,6 +117,20 @@ public actor RemoteLibrarySyncService { } if existingState.assetId != track.assetId { + if existingState.downloadStatus == .downloaded, + let resolvedLocalFilePath = await audioFileStore.resolveLocalFilePath( + persistedLocalFilePath: existingState.localFilePath, + assetId: existingState.assetId + ) + { + if existingState.localFilePath != resolvedLocalFilePath { + existingState.localFilePath = resolvedLocalFilePath + statesByTrackID[track.trackId] = existingState + didChange = true + } + continue + } + existingState.assetId = track.assetId existingState.localFilePath = "" existingState.downloadedAt = nil diff --git a/packages/apple/VelodySync/Tests/VelodySyncTests/OfflineLibraryServiceTests.swift b/packages/apple/VelodySync/Tests/VelodySyncTests/OfflineLibraryServiceTests.swift index 8a0158e..78d84bd 100644 --- a/packages/apple/VelodySync/Tests/VelodySyncTests/OfflineLibraryServiceTests.swift +++ b/packages/apple/VelodySync/Tests/VelodySyncTests/OfflineLibraryServiceTests.swift @@ -274,6 +274,271 @@ final class OfflineLibraryServiceTests: XCTestCase { XCTAssertEqual(beforeResync.availableTracks.first?.localArtworkFilePath, afterResync.availableTracks.first?.localArtworkFilePath) } + func testArtworkReplacementKeepsDownloadedTrackAvailableAndRefreshesCachedArtwork() 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 originalArtworkData = sampleArtworkData() + let replacementArtworkData = Data([0x89, 0x50, 0x4E, 0x47, 0x01]) + let originalTrack = makeRemoteTrack( + trackId: "track-artwork-replace", + assetId: "asset-artwork-replace", + title: "Artwork Resilience", + artworkId: "artwork-original" + ) + let replacementTrack = makeRemoteTrack( + trackId: originalTrack.trackId, + assetId: originalTrack.assetId, + title: originalTrack.title, + artworkId: "artwork-replacement" + ) + let remoteLibraryStore = InMemoryRemoteLibraryStore() + let apiClient = OfflineLibraryMockAPIClient( + bootstrapResponse: SyncBootstrapResponse( + nextCursor: SyncCursor(value: "1"), + tracks: [originalTrack], + serverTime: "2026-05-30T08:00:00.000Z" + ), + audioAssetData: sampleMp3Data(seed: originalTrack.assetId), + artworkDataByArtworkID: [ + "artwork-original": originalArtworkData, + "artwork-replacement": replacementArtworkData, + ], + changeResponsesByCursor: [ + "1": SyncChangesResponse( + nextCursor: SyncCursor(value: "2"), + hasMore: false, + requiresBootstrap: false, + events: [ + SyncEvent( + cursor: SyncCursor(value: "2"), + entityType: "ARTWORK_ASSET", + entityId: "artwork-replacement", + action: "UPDATED", + track: replacementTrack, + createdAt: "2026-05-30T08:10:00.000Z" + ), + ], + serverTime: "2026-05-30T08:10:00.000Z" + ), + ] + ) + 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(originalTrack, deviceId: "device-123") + let beforeReplacement = try await offlineLibraryService.loadSnapshot() + + _ = try await syncService.syncRemoteLibrary(deviceId: "device-123") + let afterReplacement = try await offlineLibraryService.loadSnapshot() + + XCTAssertEqual(beforeReplacement.availableTracks.map(\.remoteTrackId), [originalTrack.trackId]) + XCTAssertEqual(afterReplacement.availableTracks.map(\.remoteTrackId), [originalTrack.trackId]) + XCTAssertEqual(afterReplacement.remoteTracks.first?.status, .downloaded) + XCTAssertEqual( + afterReplacement.remoteTracks.first?.localArtworkFilePath, + artworkDirectory + .appendingPathComponent("artwork-replacement.png") + .standardizedFileURL + .path + ) + XCTAssertNotEqual( + beforeReplacement.remoteTracks.first?.localArtworkFilePath, + afterReplacement.remoteTracks.first?.localArtworkFilePath + ) + } + + func testAssetReplacementKeepsDownloadedTrackAvailableUntilRedownload() 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 originalTrack = makeRemoteTrack( + trackId: "track-asset-replace", + assetId: "asset-original", + title: "Asset Resilience", + artworkId: "artwork-asset-replace" + ) + let replacementTrack = makeRemoteTrack( + trackId: originalTrack.trackId, + assetId: "asset-replacement", + title: originalTrack.title, + artworkId: "artwork-asset-replace" + ) + let remoteLibraryStore = InMemoryRemoteLibraryStore() + let apiClient = OfflineLibraryMockAPIClient( + bootstrapResponse: SyncBootstrapResponse( + nextCursor: SyncCursor(value: "1"), + tracks: [originalTrack], + serverTime: "2026-05-30T08:00:00.000Z" + ), + audioAssetData: sampleMp3Data(seed: originalTrack.assetId), + artworkDataByArtworkID: [ + "artwork-asset-replace": sampleArtworkData(), + ], + changeResponsesByCursor: [ + "1": SyncChangesResponse( + nextCursor: SyncCursor(value: "2"), + hasMore: false, + requiresBootstrap: false, + events: [ + SyncEvent( + cursor: SyncCursor(value: "2"), + entityType: "AUDIO_ASSET", + entityId: replacementTrack.assetId, + action: "UPDATED", + track: replacementTrack, + createdAt: "2026-05-30T08:10:00.000Z" + ), + ], + serverTime: "2026-05-30T08:10:00.000Z" + ), + ] + ) + 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") + let originalDownloadState = try await syncService.downloadTrack( + originalTrack, + deviceId: "device-123" + ) + + _ = try await syncService.syncRemoteLibrary(deviceId: "device-123") + let afterReplacement = try await offlineLibraryService.loadSnapshot() + + XCTAssertEqual(afterReplacement.availableTracks.map(\.remoteTrackId), [originalTrack.trackId]) + XCTAssertEqual(afterReplacement.availableTracks.first?.assetId, replacementTrack.assetId) + XCTAssertEqual(afterReplacement.remoteTracks.first?.status, .downloaded) + XCTAssertEqual(afterReplacement.remoteTracks.first?.localFilePath, originalDownloadState.localFilePath) + XCTAssertEqual(afterReplacement.availableTracks.first?.localFilePath, originalDownloadState.localFilePath) + } + + func testDeletedTrackDisappearsFromOfflineSnapshotGracefully() 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-deleted", + assetId: "asset-deleted", + title: "Delete Me", + artworkId: "artwork-deleted" + ) + let remoteLibraryStore = InMemoryRemoteLibraryStore() + let apiClient = OfflineLibraryMockAPIClient( + bootstrapResponse: SyncBootstrapResponse( + nextCursor: SyncCursor(value: "1"), + tracks: [track], + serverTime: "2026-05-30T08:00:00.000Z" + ), + audioAssetData: sampleMp3Data(seed: track.assetId), + artworkDataByArtworkID: [ + "artwork-deleted": sampleArtworkData(), + ], + changeResponsesByCursor: [ + "1": SyncChangesResponse( + nextCursor: SyncCursor(value: "2"), + hasMore: false, + requiresBootstrap: false, + events: [ + SyncEvent( + cursor: SyncCursor(value: "2"), + entityType: "TRACK", + entityId: track.trackId, + action: "DELETED", + deletedTrackId: track.trackId, + createdAt: "2026-05-30T08:10:00.000Z" + ), + ], + serverTime: "2026-05-30T08:10:00.000Z" + ), + ] + ) + 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") + + _ = try await syncService.syncRemoteLibrary(deviceId: "device-123") + let afterDeletion = try await offlineLibraryService.loadSnapshot() + + XCTAssertTrue(afterDeletion.remoteTracks.isEmpty) + XCTAssertTrue(afterDeletion.availableTracks.isEmpty) + } + func testRelaunchSimulationRebuildsOfflineLibraryAccurately() async throws { let fileManager = FileManager.default let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent( @@ -416,6 +681,7 @@ private struct OfflineLibraryMockAPIClient: VelodyAPIClient { let audioAssetData: Data? let audioAssetDataByAssetID: [String: Data] let artworkDataByArtworkID: [String: Data] + let changeResponsesByCursor: [String: SyncChangesResponse] init( bootstrapResponse: SyncBootstrapResponse = SyncBootstrapResponse( @@ -425,12 +691,14 @@ private struct OfflineLibraryMockAPIClient: VelodyAPIClient { ), audioAssetData: Data? = nil, audioAssetDataByAssetID: [String: Data] = [:], - artworkDataByArtworkID: [String: Data] = [:] + artworkDataByArtworkID: [String: Data] = [:], + changeResponsesByCursor: [String: SyncChangesResponse] = [:] ) { self.bootstrapResponse = bootstrapResponse self.audioAssetData = audioAssetData self.audioAssetDataByAssetID = audioAssetDataByAssetID self.artworkDataByArtworkID = artworkDataByArtworkID + self.changeResponsesByCursor = changeResponsesByCursor } func registerDevice( @@ -462,7 +730,11 @@ private struct OfflineLibraryMockAPIClient: VelodyAPIClient { func fetchSyncChanges( cursor: SyncCursor ) async throws -> SyncChangesResponse { - SyncChangesResponse( + if let response = changeResponsesByCursor[cursor.value] { + return response + } + + return SyncChangesResponse( nextCursor: cursor, hasMore: false, requiresBootstrap: false, diff --git a/packages/apple/VelodySync/Tests/VelodySyncTests/RemoteLibrarySyncServiceTests.swift b/packages/apple/VelodySync/Tests/VelodySyncTests/RemoteLibrarySyncServiceTests.swift index 0747dce..6217c4a 100644 --- a/packages/apple/VelodySync/Tests/VelodySyncTests/RemoteLibrarySyncServiceTests.swift +++ b/packages/apple/VelodySync/Tests/VelodySyncTests/RemoteLibrarySyncServiceTests.swift @@ -290,6 +290,86 @@ final class RemoteLibrarySyncServiceTests: XCTestCase { 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"