Add offline resilience coverage

This commit is contained in:
diyaa 2026-06-19 04:08:36 +02:00
parent 6f35912a38
commit ef6f11c7b1
5 changed files with 1007 additions and 2 deletions

View File

@ -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 = "<group>"; };
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 */,
);

View File

@ -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
}

View File

@ -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

View File

@ -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,

View File

@ -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"