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