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 }