Add offline resilience coverage
This commit is contained in:
parent
6f35912a38
commit
ef6f11c7b1
@ -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 */,
|
||||
);
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user