Add offline resilience coverage
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user