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 */; };
|
A54D8AD8A59D8B77FCA0794F /* MacLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB28FE17346E100F697C1BF4 /* MacLibraryView.swift */; };
|
||||||
A62771F49BF9AA1ABCF7961E /* VelodyUtilities in Frameworks */ = {isa = PBXBuildFile; productRef = B15F842ACBB110CC8A766669 /* VelodyUtilities */; };
|
A62771F49BF9AA1ABCF7961E /* VelodyUtilities in Frameworks */ = {isa = PBXBuildFile; productRef = B15F842ACBB110CC8A766669 /* VelodyUtilities */; };
|
||||||
AB6C7E42A3A850D395E4F5E7 /* VelodyPersistence in Frameworks */ = {isa = PBXBuildFile; productRef = C8F5FF593C4DB829D1CDD497 /* VelodyPersistence */; };
|
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 */; };
|
AC8B414ECE5493BD52DEC44A /* VelodyPlayback in Frameworks */ = {isa = PBXBuildFile; productRef = A9678775BC86EBB3155ECBDE /* VelodyPlayback */; };
|
||||||
CDF41A3983C5430598E4E84D /* iPhoneLibraryViewModelPlaybackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DD22E56A863E58C2652306B /* iPhoneLibraryViewModelPlaybackTests.swift */; };
|
CDF41A3983C5430598E4E84D /* iPhoneLibraryViewModelPlaybackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DD22E56A863E58C2652306B /* iPhoneLibraryViewModelPlaybackTests.swift */; };
|
||||||
D0D65CE73B9DFF3C73F432DB /* VelodySync in Frameworks */ = {isa = PBXBuildFile; productRef = 2449C403E81DD84D7A8DD7E1 /* VelodySync */; };
|
D0D65CE73B9DFF3C73F432DB /* VelodySync in Frameworks */ = {isa = PBXBuildFile; productRef = 2449C403E81DD84D7A8DD7E1 /* VelodySync */; };
|
||||||
@ -50,6 +51,7 @@
|
|||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
07508485E10C6E2942FE29AB /* VelodySync */ = {isa = PBXFileReference; lastKnownFileType = folder; name = VelodySync; path = ../../packages/apple/VelodySync; sourceTree = SOURCE_ROOT; };
|
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; };
|
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; };
|
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; };
|
1913BA882BB97E1B90C3B30B /* VelodyiPhoneTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = VelodyiPhoneTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
@ -118,6 +120,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
6DE70FE94372F028D76DC335 /* iPhoneLibraryViewModelFavoritesTests.swift */,
|
6DE70FE94372F028D76DC335 /* iPhoneLibraryViewModelFavoritesTests.swift */,
|
||||||
|
0CA92429F41945EB96C05858 /* iPhoneLibraryViewModelOfflineResilienceTests.swift */,
|
||||||
7DD22E56A863E58C2652306B /* iPhoneLibraryViewModelPlaybackTests.swift */,
|
7DD22E56A863E58C2652306B /* iPhoneLibraryViewModelPlaybackTests.swift */,
|
||||||
D2446CD01A27662F88EA0F43 /* iPhoneLibraryViewModelTestSupport.swift */,
|
D2446CD01A27662F88EA0F43 /* iPhoneLibraryViewModelTestSupport.swift */,
|
||||||
);
|
);
|
||||||
@ -313,6 +316,7 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
DCB814642BA3F081D4B5A3BE /* iPhoneLibraryViewModelFavoritesTests.swift in Sources */,
|
DCB814642BA3F081D4B5A3BE /* iPhoneLibraryViewModelFavoritesTests.swift in Sources */,
|
||||||
|
B8B8A1F94D594953A4877776 /* iPhoneLibraryViewModelOfflineResilienceTests.swift in Sources */,
|
||||||
CDF41A3983C5430598E4E84D /* iPhoneLibraryViewModelPlaybackTests.swift in Sources */,
|
CDF41A3983C5430598E4E84D /* iPhoneLibraryViewModelPlaybackTests.swift in Sources */,
|
||||||
A1FB43063B59B52B1C90A7A7 /* iPhoneLibraryViewModelTestSupport.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.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.assetId = track.assetId
|
||||||
existingState.localFilePath = ""
|
existingState.localFilePath = ""
|
||||||
existingState.downloadedAt = nil
|
existingState.downloadedAt = nil
|
||||||
|
|||||||
@ -274,6 +274,271 @@ final class OfflineLibraryServiceTests: XCTestCase {
|
|||||||
XCTAssertEqual(beforeResync.availableTracks.first?.localArtworkFilePath, afterResync.availableTracks.first?.localArtworkFilePath)
|
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 {
|
func testRelaunchSimulationRebuildsOfflineLibraryAccurately() async throws {
|
||||||
let fileManager = FileManager.default
|
let fileManager = FileManager.default
|
||||||
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
|
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
|
||||||
@ -416,6 +681,7 @@ private struct OfflineLibraryMockAPIClient: VelodyAPIClient {
|
|||||||
let audioAssetData: Data?
|
let audioAssetData: Data?
|
||||||
let audioAssetDataByAssetID: [String: Data]
|
let audioAssetDataByAssetID: [String: Data]
|
||||||
let artworkDataByArtworkID: [String: Data]
|
let artworkDataByArtworkID: [String: Data]
|
||||||
|
let changeResponsesByCursor: [String: SyncChangesResponse]
|
||||||
|
|
||||||
init(
|
init(
|
||||||
bootstrapResponse: SyncBootstrapResponse = SyncBootstrapResponse(
|
bootstrapResponse: SyncBootstrapResponse = SyncBootstrapResponse(
|
||||||
@ -425,12 +691,14 @@ private struct OfflineLibraryMockAPIClient: VelodyAPIClient {
|
|||||||
),
|
),
|
||||||
audioAssetData: Data? = nil,
|
audioAssetData: Data? = nil,
|
||||||
audioAssetDataByAssetID: [String: Data] = [:],
|
audioAssetDataByAssetID: [String: Data] = [:],
|
||||||
artworkDataByArtworkID: [String: Data] = [:]
|
artworkDataByArtworkID: [String: Data] = [:],
|
||||||
|
changeResponsesByCursor: [String: SyncChangesResponse] = [:]
|
||||||
) {
|
) {
|
||||||
self.bootstrapResponse = bootstrapResponse
|
self.bootstrapResponse = bootstrapResponse
|
||||||
self.audioAssetData = audioAssetData
|
self.audioAssetData = audioAssetData
|
||||||
self.audioAssetDataByAssetID = audioAssetDataByAssetID
|
self.audioAssetDataByAssetID = audioAssetDataByAssetID
|
||||||
self.artworkDataByArtworkID = artworkDataByArtworkID
|
self.artworkDataByArtworkID = artworkDataByArtworkID
|
||||||
|
self.changeResponsesByCursor = changeResponsesByCursor
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerDevice(
|
func registerDevice(
|
||||||
@ -462,7 +730,11 @@ private struct OfflineLibraryMockAPIClient: VelodyAPIClient {
|
|||||||
func fetchSyncChanges(
|
func fetchSyncChanges(
|
||||||
cursor: SyncCursor
|
cursor: SyncCursor
|
||||||
) async throws -> SyncChangesResponse {
|
) async throws -> SyncChangesResponse {
|
||||||
SyncChangesResponse(
|
if let response = changeResponsesByCursor[cursor.value] {
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
return SyncChangesResponse(
|
||||||
nextCursor: cursor,
|
nextCursor: cursor,
|
||||||
hasMore: false,
|
hasMore: false,
|
||||||
requiresBootstrap: false,
|
requiresBootstrap: false,
|
||||||
|
|||||||
@ -290,6 +290,86 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
|||||||
XCTAssertEqual(storedCursor, SyncCursor(value: "7"))
|
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 {
|
func testSyncFailurePreservesDownloadedStateAndLocalFile() async throws {
|
||||||
let track = makeRemoteTrack(trackId: "track-offline", assetId: "asset-offline")
|
let track = makeRemoteTrack(trackId: "track-offline", assetId: "asset-offline")
|
||||||
let localFilePath = "/in-memory/\(track.assetId).mp3"
|
let localFilePath = "/in-memory/\(track.assetId).mp3"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user