velody/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelOfflineResilienceTests.swift
2026-06-19 04:08:36 +02:00

636 lines
22 KiB
Swift

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
}