Implement incremental sync and offline recovery
This commit is contained in:
@@ -176,7 +176,8 @@ final class OfflineLibraryServiceTests: XCTestCase {
|
||||
let syncService = RemoteLibrarySyncService(
|
||||
repository: DefaultRemoteLibraryRepository(
|
||||
apiClient: OfflineLibraryMockAPIClient(audioAssetData: sampleMp3Data(seed: track.assetId)),
|
||||
store: remoteLibraryStore
|
||||
store: remoteLibraryStore,
|
||||
syncCursorStore: InMemoryRemoteLibrarySyncCursorStore()
|
||||
),
|
||||
downloadStateStore: downloadStateStore,
|
||||
audioFileStore: audioFileStore,
|
||||
@@ -226,8 +227,10 @@ final class OfflineLibraryServiceTests: XCTestCase {
|
||||
let remoteLibraryStore = InMemoryRemoteLibraryStore()
|
||||
let audioData = sampleMp3Data(seed: track.assetId)
|
||||
let apiClient = OfflineLibraryMockAPIClient(
|
||||
remoteLibraryResponse: RemoteLibraryResponseDTO(
|
||||
tracks: [makeRemoteTrackDTO(from: track)]
|
||||
bootstrapResponse: SyncBootstrapResponse(
|
||||
nextCursor: SyncCursor(value: "1"),
|
||||
tracks: [track],
|
||||
serverTime: "2026-05-30T08:00:00.000Z"
|
||||
),
|
||||
audioAssetData: audioData,
|
||||
artworkDataByArtworkID: [
|
||||
@@ -240,7 +243,8 @@ final class OfflineLibraryServiceTests: XCTestCase {
|
||||
let syncService = RemoteLibrarySyncService(
|
||||
repository: DefaultRemoteLibraryRepository(
|
||||
apiClient: apiClient,
|
||||
store: remoteLibraryStore
|
||||
store: remoteLibraryStore,
|
||||
syncCursorStore: InMemoryRemoteLibrarySyncCursorStore()
|
||||
),
|
||||
downloadStateStore: downloadStateStore,
|
||||
audioFileStore: audioFileStore,
|
||||
@@ -284,8 +288,10 @@ final class OfflineLibraryServiceTests: XCTestCase {
|
||||
makeRemoteTrack(trackId: "track-2", assetId: "asset-2", title: "Track 2", artworkId: "artwork-2"),
|
||||
]
|
||||
let apiClient = OfflineLibraryMockAPIClient(
|
||||
remoteLibraryResponse: RemoteLibraryResponseDTO(
|
||||
tracks: tracks.map { makeRemoteTrackDTO(from: $0) }
|
||||
bootstrapResponse: SyncBootstrapResponse(
|
||||
nextCursor: SyncCursor(value: "1"),
|
||||
tracks: tracks,
|
||||
serverTime: "2026-05-30T08:00:00.000Z"
|
||||
),
|
||||
audioAssetDataByAssetID: [
|
||||
"asset-1": sampleMp3Data(seed: "asset-1"),
|
||||
@@ -304,7 +310,8 @@ final class OfflineLibraryServiceTests: XCTestCase {
|
||||
|
||||
let firstRepository = DefaultRemoteLibraryRepository(
|
||||
apiClient: apiClient,
|
||||
store: try FileRemoteLibraryStore(fileURL: remoteLibraryFileURL)
|
||||
store: try FileRemoteLibraryStore(fileURL: remoteLibraryFileURL),
|
||||
syncCursorStore: InMemoryRemoteLibrarySyncCursorStore()
|
||||
)
|
||||
let firstDownloadStateStore = try FileRemoteTrackDownloadStateStore(fileURL: downloadStateFileURL)
|
||||
let firstAudioStore = try FileOfflineAudioFileStore(baseDirectoryURL: audioDirectory)
|
||||
@@ -329,7 +336,8 @@ final class OfflineLibraryServiceTests: XCTestCase {
|
||||
|
||||
let relaunchedRepository = DefaultRemoteLibraryRepository(
|
||||
apiClient: apiClient,
|
||||
store: try FileRemoteLibraryStore(fileURL: remoteLibraryFileURL)
|
||||
store: try FileRemoteLibraryStore(fileURL: remoteLibraryFileURL),
|
||||
syncCursorStore: InMemoryRemoteLibrarySyncCursorStore(cursor: SyncCursor(value: "1"))
|
||||
)
|
||||
let relaunchedDownloadStateStore = try FileRemoteTrackDownloadStateStore(fileURL: downloadStateFileURL)
|
||||
let relaunchedAudioStore = try FileOfflineAudioFileStore(baseDirectoryURL: audioDirectory)
|
||||
@@ -404,18 +412,22 @@ private actor InMemoryRemoteLibraryRepository: RemoteLibraryRepository {
|
||||
}
|
||||
|
||||
private struct OfflineLibraryMockAPIClient: VelodyAPIClient {
|
||||
let remoteLibraryResponse: RemoteLibraryResponseDTO?
|
||||
let bootstrapResponse: SyncBootstrapResponse
|
||||
let audioAssetData: Data?
|
||||
let audioAssetDataByAssetID: [String: Data]
|
||||
let artworkDataByArtworkID: [String: Data]
|
||||
|
||||
init(
|
||||
remoteLibraryResponse: RemoteLibraryResponseDTO? = nil,
|
||||
bootstrapResponse: SyncBootstrapResponse = SyncBootstrapResponse(
|
||||
nextCursor: SyncCursor(value: "0"),
|
||||
tracks: [],
|
||||
serverTime: "2026-05-30T08:00:00.000Z"
|
||||
),
|
||||
audioAssetData: Data? = nil,
|
||||
audioAssetDataByAssetID: [String: Data] = [:],
|
||||
artworkDataByArtworkID: [String: Data] = [:]
|
||||
) {
|
||||
self.remoteLibraryResponse = remoteLibraryResponse
|
||||
self.bootstrapResponse = bootstrapResponse
|
||||
self.audioAssetData = audioAssetData
|
||||
self.audioAssetDataByAssetID = audioAssetDataByAssetID
|
||||
self.artworkDataByArtworkID = artworkDataByArtworkID
|
||||
@@ -444,11 +456,17 @@ private struct OfflineLibraryMockAPIClient: VelodyAPIClient {
|
||||
}
|
||||
|
||||
func fetchSyncBootstrap() async throws -> SyncBootstrapResponse {
|
||||
SyncBootstrapResponse(
|
||||
nextCursor: SyncCursor(value: "0"),
|
||||
tracks: [],
|
||||
bootstrapResponse
|
||||
}
|
||||
|
||||
func fetchSyncChanges(
|
||||
cursor: SyncCursor
|
||||
) async throws -> SyncChangesResponse {
|
||||
SyncChangesResponse(
|
||||
nextCursor: cursor,
|
||||
hasMore: false,
|
||||
requiresBootstrap: false,
|
||||
events: [],
|
||||
deletedTrackIds: [],
|
||||
serverTime: "2026-05-30T08:00:00.000Z"
|
||||
)
|
||||
}
|
||||
@@ -457,7 +475,9 @@ private struct OfflineLibraryMockAPIClient: VelodyAPIClient {
|
||||
deviceId: String
|
||||
) async throws -> RemoteLibraryResponseDTO {
|
||||
_ = deviceId
|
||||
return remoteLibraryResponse ?? RemoteLibraryResponseDTO(tracks: [])
|
||||
return RemoteLibraryResponseDTO(
|
||||
tracks: bootstrapResponse.tracks.map { makeRemoteTrackDTO(from: $0) }
|
||||
)
|
||||
}
|
||||
|
||||
func downloadAudioAsset(
|
||||
|
||||
+566
-176
@@ -7,112 +7,277 @@ import VelodyPersistence
|
||||
@testable import VelodySync
|
||||
|
||||
final class RemoteLibrarySyncServiceTests: XCTestCase {
|
||||
func testSuccessfulSyncPersistsRemoteTracks() async throws {
|
||||
func testBootstrapFirstSyncPersistsRemoteTracksAndCursor() async throws {
|
||||
let store = InMemoryRemoteLibraryStore()
|
||||
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore()
|
||||
let service = RemoteLibrarySyncService(
|
||||
repository: DefaultRemoteLibraryRepository(
|
||||
apiClient: MockVelodyAPIClient(
|
||||
remoteLibraryResponse: RemoteLibraryResponseDTO(
|
||||
tracks: [
|
||||
RemoteTrackDTO(
|
||||
trackId: "track-123",
|
||||
title: "Remote Title",
|
||||
artist: "Remote Artist",
|
||||
durationSeconds: 245,
|
||||
sha256: String(repeating: "a", count: 64),
|
||||
assetId: "asset-456",
|
||||
createdAt: "2026-05-29T08:00:00.000Z",
|
||||
updatedAt: "2026-05-29T08:05:00.000Z"
|
||||
),
|
||||
]
|
||||
)
|
||||
),
|
||||
store: store
|
||||
let cursorStore = InMemoryRemoteLibrarySyncCursorStore()
|
||||
let track = makeRemoteTrack(trackId: "track-123")
|
||||
let service = makeSyncService(
|
||||
apiClient: MockVelodyAPIClient(
|
||||
bootstrapResponse: SyncBootstrapResponse(
|
||||
nextCursor: SyncCursor(value: "4"),
|
||||
tracks: [track],
|
||||
serverTime: "2026-06-15T12:00:00.000Z"
|
||||
)
|
||||
),
|
||||
downloadStateStore: downloadStateStore,
|
||||
audioFileStore: InMemoryOfflineAudioFileStore(),
|
||||
artworkStore: InMemoryArtworkStore()
|
||||
store: store,
|
||||
cursorStore: cursorStore
|
||||
)
|
||||
|
||||
let tracks = try await service.syncRemoteLibrary(deviceId: "device-123")
|
||||
let cachedTracks = try await service.loadCachedRemoteTracks()
|
||||
let storedCursor = try await cursorStore.loadCursor()
|
||||
let downloadStates = try await service.loadDownloadStates()
|
||||
|
||||
XCTAssertEqual(tracks.count, 1)
|
||||
XCTAssertEqual(cachedTracks, tracks)
|
||||
XCTAssertEqual(cachedTracks.first?.trackId, "track-123")
|
||||
XCTAssertEqual(downloadStates.first?.downloadStatus, .notDownloaded)
|
||||
XCTAssertEqual(tracks, [track])
|
||||
XCTAssertEqual(cachedTracks, [track])
|
||||
XCTAssertEqual(storedCursor, SyncCursor(value: "4"))
|
||||
XCTAssertEqual(downloadStates.first?.downloadStatus, RemoteTrackDownloadStatus.notDownloaded)
|
||||
}
|
||||
|
||||
func testEmptyResponseClearsCachedRemoteLibrary() async throws {
|
||||
let store = InMemoryRemoteLibraryStore(
|
||||
tracks: [
|
||||
RemoteTrack(
|
||||
trackId: "track-123",
|
||||
title: "Old",
|
||||
artist: "Artist",
|
||||
durationSeconds: 100,
|
||||
sha256: String(repeating: "b", count: 64),
|
||||
assetId: "asset-123",
|
||||
createdAt: "2026-05-29T08:00:00.000Z",
|
||||
updatedAt: "2026-05-29T08:05:00.000Z"
|
||||
func testChangesForExistingCursorApplyWithoutDuplicateTracks() async throws {
|
||||
let originalTrack = makeRemoteTrack(trackId: "track-123", title: "Old Title")
|
||||
let updatedTrack = makeRemoteTrack(trackId: "track-123", title: "New Title")
|
||||
let secondTrack = makeRemoteTrack(
|
||||
trackId: "track-456",
|
||||
title: "Second Track",
|
||||
createdAt: "2026-06-15T12:01:00.000Z"
|
||||
)
|
||||
let store = InMemoryRemoteLibraryStore(tracks: [originalTrack])
|
||||
let cursorStore = InMemoryRemoteLibrarySyncCursorStore(
|
||||
cursor: SyncCursor(value: "4")
|
||||
)
|
||||
let apiClient = MockVelodyAPIClient(
|
||||
changeResponses: [
|
||||
SyncChangesResponse(
|
||||
nextCursor: SyncCursor(value: "6"),
|
||||
hasMore: false,
|
||||
requiresBootstrap: false,
|
||||
events: [
|
||||
SyncEvent(
|
||||
cursor: SyncCursor(value: "5"),
|
||||
entityType: "TRACK",
|
||||
entityId: updatedTrack.trackId,
|
||||
action: "UPDATED",
|
||||
track: updatedTrack,
|
||||
createdAt: "2026-06-15T12:00:30.000Z"
|
||||
),
|
||||
SyncEvent(
|
||||
cursor: SyncCursor(value: "6"),
|
||||
entityType: "AUDIO_ASSET",
|
||||
entityId: secondTrack.assetId,
|
||||
action: "CREATED",
|
||||
track: secondTrack,
|
||||
createdAt: "2026-06-15T12:01:00.000Z"
|
||||
),
|
||||
],
|
||||
serverTime: "2026-06-15T12:01:00.000Z"
|
||||
),
|
||||
]
|
||||
)
|
||||
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore(
|
||||
states: [
|
||||
RemoteTrackDownloadState(
|
||||
remoteTrackId: "track-123",
|
||||
assetId: "asset-123",
|
||||
downloadStatus: .downloaded
|
||||
),
|
||||
]
|
||||
)
|
||||
let service = RemoteLibrarySyncService(
|
||||
repository: DefaultRemoteLibraryRepository(
|
||||
apiClient: MockVelodyAPIClient(
|
||||
remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: [])
|
||||
),
|
||||
store: store
|
||||
),
|
||||
downloadStateStore: downloadStateStore,
|
||||
audioFileStore: InMemoryOfflineAudioFileStore(),
|
||||
artworkStore: InMemoryArtworkStore()
|
||||
let service = makeSyncService(
|
||||
apiClient: apiClient,
|
||||
store: store,
|
||||
cursorStore: cursorStore
|
||||
)
|
||||
|
||||
let tracks = try await service.syncRemoteLibrary(deviceId: "device-123")
|
||||
let cachedTracks = try await service.loadCachedRemoteTracks()
|
||||
let downloadStates = try await service.loadDownloadStates()
|
||||
let storedCursor = try await cursorStore.loadCursor()
|
||||
|
||||
XCTAssertEqual(tracks, [])
|
||||
XCTAssertEqual(cachedTracks, [])
|
||||
XCTAssertEqual(downloadStates.count, 1)
|
||||
XCTAssertEqual(tracks, [updatedTrack, secondTrack])
|
||||
XCTAssertEqual(Set(tracks.map(\.trackId)), ["track-123", "track-456"])
|
||||
XCTAssertEqual(storedCursor, SyncCursor(value: "6"))
|
||||
let changeCursors = await apiClient.recordedChangeCursors()
|
||||
XCTAssertEqual(changeCursors, ["4"])
|
||||
}
|
||||
|
||||
func testNetworkFailureLeavesCachedRemoteLibraryIntact() async throws {
|
||||
let cachedTrack = RemoteTrack(
|
||||
trackId: "track-123",
|
||||
title: "Cached",
|
||||
artist: "Artist",
|
||||
durationSeconds: 100,
|
||||
sha256: String(repeating: "c", count: 64),
|
||||
assetId: "asset-123",
|
||||
createdAt: "2026-05-29T08:00:00.000Z",
|
||||
updatedAt: "2026-05-29T08:05:00.000Z"
|
||||
func testPaginationAndReplayEventsDoNotDuplicateTracks() async throws {
|
||||
let originalTrack = makeRemoteTrack(trackId: "track-123", title: "Old Title")
|
||||
let updatedTrack = makeRemoteTrack(trackId: "track-123", title: "Updated Once")
|
||||
let secondUpdate = makeRemoteTrack(trackId: "track-123", title: "Updated Twice")
|
||||
let secondTrack = makeRemoteTrack(
|
||||
trackId: "track-456",
|
||||
title: "Another Track",
|
||||
createdAt: "2026-06-15T12:02:00.000Z"
|
||||
)
|
||||
let store = InMemoryRemoteLibraryStore(tracks: [originalTrack])
|
||||
let cursorStore = InMemoryRemoteLibrarySyncCursorStore(
|
||||
cursor: SyncCursor(value: "3")
|
||||
)
|
||||
let apiClient = MockVelodyAPIClient(
|
||||
changeResponses: [
|
||||
SyncChangesResponse(
|
||||
nextCursor: SyncCursor(value: "5"),
|
||||
hasMore: true,
|
||||
requiresBootstrap: false,
|
||||
events: [
|
||||
SyncEvent(
|
||||
cursor: SyncCursor(value: "4"),
|
||||
entityType: "TRACK",
|
||||
entityId: updatedTrack.trackId,
|
||||
action: "UPDATED",
|
||||
track: updatedTrack,
|
||||
createdAt: "2026-06-15T12:00:30.000Z"
|
||||
),
|
||||
SyncEvent(
|
||||
cursor: SyncCursor(value: "5"),
|
||||
entityType: "TRACK",
|
||||
entityId: secondUpdate.trackId,
|
||||
action: "UPDATED",
|
||||
track: secondUpdate,
|
||||
createdAt: "2026-06-15T12:01:00.000Z"
|
||||
),
|
||||
],
|
||||
serverTime: "2026-06-15T12:01:00.000Z"
|
||||
),
|
||||
SyncChangesResponse(
|
||||
nextCursor: SyncCursor(value: "6"),
|
||||
hasMore: false,
|
||||
requiresBootstrap: false,
|
||||
events: [
|
||||
SyncEvent(
|
||||
cursor: SyncCursor(value: "6"),
|
||||
entityType: "TRACK",
|
||||
entityId: secondTrack.trackId,
|
||||
action: "CREATED",
|
||||
track: secondTrack,
|
||||
createdAt: "2026-06-15T12:02:00.000Z"
|
||||
),
|
||||
],
|
||||
serverTime: "2026-06-15T12:02:00.000Z"
|
||||
),
|
||||
]
|
||||
)
|
||||
let service = makeSyncService(
|
||||
apiClient: apiClient,
|
||||
store: store,
|
||||
cursorStore: cursorStore
|
||||
)
|
||||
|
||||
let tracks = try await service.syncRemoteLibrary(deviceId: "device-123")
|
||||
|
||||
XCTAssertEqual(tracks, [secondUpdate, secondTrack])
|
||||
XCTAssertEqual(Set(tracks.map(\.trackId)).count, 2)
|
||||
let changeCursors = await apiClient.recordedChangeCursors()
|
||||
XCTAssertEqual(changeCursors, ["3", "5"])
|
||||
}
|
||||
|
||||
func testRequiresBootstrapFallbackReplacesCachedLibraryAndCursor() async throws {
|
||||
let staleTrack = makeRemoteTrack(trackId: "track-stale", title: "Stale")
|
||||
let freshTrack = makeRemoteTrack(trackId: "track-fresh", title: "Fresh")
|
||||
let store = InMemoryRemoteLibraryStore(tracks: [staleTrack])
|
||||
let cursorStore = InMemoryRemoteLibrarySyncCursorStore(
|
||||
cursor: SyncCursor(value: "2")
|
||||
)
|
||||
let apiClient = MockVelodyAPIClient(
|
||||
bootstrapResponse: SyncBootstrapResponse(
|
||||
nextCursor: SyncCursor(value: "9"),
|
||||
tracks: [freshTrack],
|
||||
serverTime: "2026-06-15T12:05:00.000Z"
|
||||
),
|
||||
changeResponses: [
|
||||
SyncChangesResponse(
|
||||
nextCursor: SyncCursor(value: "2"),
|
||||
hasMore: false,
|
||||
requiresBootstrap: true,
|
||||
reason: "cursor_too_old",
|
||||
events: [],
|
||||
serverTime: "2026-06-15T12:04:00.000Z"
|
||||
),
|
||||
]
|
||||
)
|
||||
let service = makeSyncService(
|
||||
apiClient: apiClient,
|
||||
store: store,
|
||||
cursorStore: cursorStore
|
||||
)
|
||||
|
||||
let tracks = try await service.syncRemoteLibrary(deviceId: "device-123")
|
||||
let storedCursor = try await cursorStore.loadCursor()
|
||||
|
||||
XCTAssertEqual(tracks, [freshTrack])
|
||||
XCTAssertEqual(storedCursor, SyncCursor(value: "9"))
|
||||
let changeCursors = await apiClient.recordedChangeCursors()
|
||||
XCTAssertEqual(changeCursors, ["2"])
|
||||
}
|
||||
|
||||
func testCursorPersistsAcrossRepositoryInstances() 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-bootstrap")
|
||||
let changedTrack = makeRemoteTrack(trackId: "track-bootstrap", title: "Track Bootstrap Updated")
|
||||
|
||||
defer {
|
||||
try? fileManager.removeItem(at: tempDirectory)
|
||||
}
|
||||
|
||||
let firstClient = MockVelodyAPIClient(
|
||||
bootstrapResponse: SyncBootstrapResponse(
|
||||
nextCursor: SyncCursor(value: "2"),
|
||||
tracks: [bootstrapTrack],
|
||||
serverTime: "2026-06-15T12:00:00.000Z"
|
||||
)
|
||||
)
|
||||
let firstService = makeSyncService(
|
||||
apiClient: firstClient,
|
||||
store: try FileRemoteLibraryStore(fileURL: remoteLibraryFileURL),
|
||||
cursorStore: try FileRemoteLibrarySyncCursorStore(fileURL: syncCursorFileURL)
|
||||
)
|
||||
|
||||
_ = try await firstService.syncRemoteLibrary(deviceId: "device-123")
|
||||
|
||||
let secondClient = MockVelodyAPIClient(
|
||||
changeResponses: [
|
||||
SyncChangesResponse(
|
||||
nextCursor: SyncCursor(value: "3"),
|
||||
hasMore: false,
|
||||
requiresBootstrap: false,
|
||||
events: [
|
||||
SyncEvent(
|
||||
cursor: SyncCursor(value: "3"),
|
||||
entityType: "TRACK",
|
||||
entityId: changedTrack.trackId,
|
||||
action: "UPDATED",
|
||||
track: changedTrack,
|
||||
createdAt: "2026-06-15T12:01:00.000Z"
|
||||
),
|
||||
],
|
||||
serverTime: "2026-06-15T12:01:00.000Z"
|
||||
),
|
||||
]
|
||||
)
|
||||
let secondService = makeSyncService(
|
||||
apiClient: secondClient,
|
||||
store: try FileRemoteLibraryStore(fileURL: remoteLibraryFileURL),
|
||||
cursorStore: try FileRemoteLibrarySyncCursorStore(fileURL: syncCursorFileURL)
|
||||
)
|
||||
|
||||
let tracks = try await secondService.syncRemoteLibrary(deviceId: "device-123")
|
||||
let storedCursor = try await FileRemoteLibrarySyncCursorStore(fileURL: syncCursorFileURL)
|
||||
.loadCursor()
|
||||
|
||||
XCTAssertEqual(tracks, [changedTrack])
|
||||
XCTAssertEqual(storedCursor, SyncCursor(value: "3"))
|
||||
let changeCursors = await secondClient.recordedChangeCursors()
|
||||
XCTAssertEqual(changeCursors, ["2"])
|
||||
}
|
||||
|
||||
func testNetworkFailureLeavesCachedLibraryAndCursorIntact() async throws {
|
||||
let cachedTrack = makeRemoteTrack(trackId: "track-123", title: "Cached")
|
||||
let store = InMemoryRemoteLibraryStore(tracks: [cachedTrack])
|
||||
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore()
|
||||
let service = RemoteLibrarySyncService(
|
||||
repository: DefaultRemoteLibraryRepository(
|
||||
apiClient: MockVelodyAPIClient(
|
||||
remoteLibraryError: VelodyAPIError.requestFailed("Offline")
|
||||
),
|
||||
store: store
|
||||
let cursorStore = InMemoryRemoteLibrarySyncCursorStore(
|
||||
cursor: SyncCursor(value: "7")
|
||||
)
|
||||
let service = makeSyncService(
|
||||
apiClient: MockVelodyAPIClient(
|
||||
changeError: VelodyAPIError.requestFailed("Offline")
|
||||
),
|
||||
downloadStateStore: downloadStateStore,
|
||||
audioFileStore: InMemoryOfflineAudioFileStore(),
|
||||
artworkStore: InMemoryArtworkStore()
|
||||
store: store,
|
||||
cursorStore: cursorStore
|
||||
)
|
||||
|
||||
await XCTAssertThrowsErrorAsync {
|
||||
@@ -120,7 +285,48 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
||||
}
|
||||
|
||||
let cachedTracks = try await service.loadCachedRemoteTracks()
|
||||
let storedCursor = try await cursorStore.loadCursor()
|
||||
XCTAssertEqual(cachedTracks, [cachedTrack])
|
||||
XCTAssertEqual(storedCursor, SyncCursor(value: "7"))
|
||||
}
|
||||
|
||||
func testSyncFailurePreservesDownloadedStateAndLocalFile() async throws {
|
||||
let track = makeRemoteTrack(trackId: "track-offline", assetId: "asset-offline")
|
||||
let localFilePath = "/in-memory/\(track.assetId).mp3"
|
||||
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore(states: [
|
||||
RemoteTrackDownloadState(
|
||||
remoteTrackId: track.trackId,
|
||||
assetId: track.assetId,
|
||||
localFilePath: localFilePath,
|
||||
downloadedAt: Date(timeIntervalSince1970: 3_000),
|
||||
downloadStatus: .downloaded
|
||||
),
|
||||
])
|
||||
let audioFileStore = InMemoryOfflineAudioFileStore(files: [
|
||||
localFilePath: sampleMp3Data(seed: "network-safe"),
|
||||
])
|
||||
let service = makeSyncService(
|
||||
apiClient: MockVelodyAPIClient(
|
||||
changeError: VelodyAPIError.requestFailed("Offline")
|
||||
),
|
||||
store: InMemoryRemoteLibraryStore(tracks: [track]),
|
||||
cursorStore: InMemoryRemoteLibrarySyncCursorStore(
|
||||
cursor: SyncCursor(value: "7")
|
||||
),
|
||||
downloadStateStore: downloadStateStore,
|
||||
audioFileStore: audioFileStore
|
||||
)
|
||||
|
||||
await XCTAssertThrowsErrorAsync {
|
||||
_ = try await service.syncRemoteLibrary(deviceId: "device-123")
|
||||
}
|
||||
|
||||
let recoveredState = try await service.loadDownloadStates().first
|
||||
let fileExists = await audioFileStore.fileExists(at: localFilePath)
|
||||
|
||||
XCTAssertEqual(recoveredState?.downloadStatus, .downloaded)
|
||||
XCTAssertEqual(recoveredState?.localFilePath, localFilePath)
|
||||
XCTAssertTrue(fileExists)
|
||||
}
|
||||
|
||||
func testDownloadTrackPersistsDownloadedStateAndFile() async throws {
|
||||
@@ -129,34 +335,29 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
||||
let service = RemoteLibrarySyncService(
|
||||
repository: DefaultRemoteLibraryRepository(
|
||||
apiClient: MockVelodyAPIClient(
|
||||
remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: []),
|
||||
audioAssetData: sampleMp3Data(seed: "download-success")
|
||||
),
|
||||
store: InMemoryRemoteLibraryStore()
|
||||
store: InMemoryRemoteLibraryStore(),
|
||||
syncCursorStore: InMemoryRemoteLibrarySyncCursorStore()
|
||||
),
|
||||
downloadStateStore: downloadStateStore,
|
||||
audioFileStore: audioFileStore,
|
||||
artworkStore: InMemoryArtworkStore()
|
||||
)
|
||||
let track = RemoteTrack(
|
||||
let track = makeRemoteTrack(
|
||||
trackId: "track-123",
|
||||
title: "Remote Title",
|
||||
artist: "Remote Artist",
|
||||
durationSeconds: 245,
|
||||
sha256: sha256Hex(sampleMp3Data(seed: "download-success")),
|
||||
assetId: "asset-456",
|
||||
createdAt: "2026-05-29T08:00:00.000Z",
|
||||
updatedAt: "2026-05-29T08:05:00.000Z"
|
||||
assetId: "asset-456"
|
||||
)
|
||||
|
||||
let state = try await service.downloadTrack(track, deviceId: "device-123")
|
||||
let storedStates = try await service.loadDownloadStates()
|
||||
let fileExists = await audioFileStore.fileExists(at: state.localFilePath)
|
||||
|
||||
XCTAssertEqual(state.downloadStatus, .downloaded)
|
||||
XCTAssertEqual(state.downloadStatus, RemoteTrackDownloadStatus.downloaded)
|
||||
XCTAssertEqual(state.assetId, "asset-456")
|
||||
XCTAssertFalse(state.localFilePath.isEmpty)
|
||||
XCTAssertEqual(storedStates.first?.downloadStatus, .downloaded)
|
||||
XCTAssertEqual(storedStates.first?.downloadStatus, RemoteTrackDownloadStatus.downloaded)
|
||||
XCTAssertTrue(fileExists)
|
||||
}
|
||||
|
||||
@@ -164,35 +365,129 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
||||
let service = RemoteLibrarySyncService(
|
||||
repository: DefaultRemoteLibraryRepository(
|
||||
apiClient: MockVelodyAPIClient(
|
||||
remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: []),
|
||||
downloadError: VelodyAPIError.server(statusCode: 404, message: "Missing")
|
||||
),
|
||||
store: InMemoryRemoteLibraryStore()
|
||||
store: InMemoryRemoteLibraryStore(),
|
||||
syncCursorStore: InMemoryRemoteLibrarySyncCursorStore()
|
||||
),
|
||||
downloadStateStore: InMemoryRemoteTrackDownloadStateStore(),
|
||||
audioFileStore: InMemoryOfflineAudioFileStore(),
|
||||
artworkStore: InMemoryArtworkStore()
|
||||
)
|
||||
let track = RemoteTrack(
|
||||
trackId: "track-123",
|
||||
title: "Remote Title",
|
||||
artist: "Remote Artist",
|
||||
durationSeconds: 245,
|
||||
sha256: sha256Hex(sampleMp3Data(seed: "download-failure")),
|
||||
assetId: "asset-456",
|
||||
createdAt: "2026-05-29T08:00:00.000Z",
|
||||
updatedAt: "2026-05-29T08:05:00.000Z"
|
||||
)
|
||||
let track = makeRemoteTrack(trackId: "track-123", assetId: "asset-456")
|
||||
|
||||
await XCTAssertThrowsErrorAsync {
|
||||
_ = try await service.downloadTrack(track, deviceId: "device-123")
|
||||
}
|
||||
|
||||
let storedStates = try await service.loadDownloadStates()
|
||||
XCTAssertEqual(storedStates.first?.downloadStatus, .failed)
|
||||
XCTAssertEqual(storedStates.first?.downloadStatus, RemoteTrackDownloadStatus.failed)
|
||||
XCTAssertEqual(storedStates.first?.remoteTrackId, "track-123")
|
||||
}
|
||||
|
||||
func testRetryAfterFailureCanSucceedAndPersistDownloadedState() async throws {
|
||||
let audioData = sampleMp3Data(seed: "retry-success")
|
||||
let track = makeRemoteTrack(
|
||||
trackId: "track-retry",
|
||||
sha256: sha256Hex(audioData),
|
||||
assetId: "asset-retry"
|
||||
)
|
||||
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore()
|
||||
let audioFileStore = InMemoryOfflineAudioFileStore()
|
||||
let service = RemoteLibrarySyncService(
|
||||
repository: SequencedDownloadRepository(
|
||||
downloadResults: [
|
||||
.failure(VelodyAPIError.server(statusCode: 503, message: "Try Again")),
|
||||
.success(audioData),
|
||||
]
|
||||
),
|
||||
downloadStateStore: downloadStateStore,
|
||||
audioFileStore: audioFileStore,
|
||||
artworkStore: InMemoryArtworkStore()
|
||||
)
|
||||
|
||||
await XCTAssertThrowsErrorAsync {
|
||||
_ = try await service.downloadTrack(track, deviceId: "device-123")
|
||||
}
|
||||
|
||||
let failedState = try await service.loadDownloadStates().first
|
||||
XCTAssertEqual(failedState?.downloadStatus, .failed)
|
||||
|
||||
let recoveredState = try await service.downloadTrack(track, deviceId: "device-123")
|
||||
let persistedState = try await downloadStateStore.loadDownloadStates().first
|
||||
let fileExists = await audioFileStore.fileExists(at: recoveredState.localFilePath)
|
||||
|
||||
XCTAssertEqual(recoveredState.downloadStatus, .downloaded)
|
||||
XCTAssertEqual(persistedState?.downloadStatus, .downloaded)
|
||||
XCTAssertFalse(recoveredState.localFilePath.isEmpty)
|
||||
XCTAssertTrue(fileExists)
|
||||
}
|
||||
|
||||
func testLoadDownloadStatesRecoversInterruptedDownloadToFailedRetryStateWhenFileIsMissing() async throws {
|
||||
let interruptedState = RemoteTrackDownloadState(
|
||||
remoteTrackId: "track-123",
|
||||
assetId: "asset-456",
|
||||
localFilePath: "/in-memory/asset-456.mp3",
|
||||
downloadedAt: Date(timeIntervalSince1970: 1_000),
|
||||
downloadStatus: .downloading
|
||||
)
|
||||
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore(states: [interruptedState])
|
||||
let service = makeSyncService(
|
||||
apiClient: MockVelodyAPIClient(),
|
||||
store: InMemoryRemoteLibraryStore(),
|
||||
cursorStore: InMemoryRemoteLibrarySyncCursorStore(),
|
||||
downloadStateStore: downloadStateStore,
|
||||
audioFileStore: InMemoryOfflineAudioFileStore()
|
||||
)
|
||||
|
||||
let recoveredStates = try await service.loadDownloadStates()
|
||||
let recoveredState = try XCTUnwrap(recoveredStates.first)
|
||||
let persistedState = try await downloadStateStore.loadDownloadStates().first
|
||||
|
||||
XCTAssertEqual(recoveredState.downloadStatus, .failed)
|
||||
XCTAssertEqual(
|
||||
recoveredState.lastDownloadError,
|
||||
"The previous download did not finish. Try again."
|
||||
)
|
||||
XCTAssertEqual(recoveredState.localFilePath, "")
|
||||
XCTAssertNil(recoveredState.downloadedAt)
|
||||
XCTAssertEqual(persistedState, recoveredState)
|
||||
}
|
||||
|
||||
func testLoadDownloadStatesRecoversInterruptedDownloadToDownloadedWhenFileExists() async throws {
|
||||
let recoveredFilePath = "/in-memory/asset-456.mp3"
|
||||
let recoveredDate = Date(timeIntervalSince1970: 2_000)
|
||||
let interruptedState = RemoteTrackDownloadState(
|
||||
remoteTrackId: "track-123",
|
||||
assetId: "asset-456",
|
||||
localFilePath: "",
|
||||
downloadedAt: recoveredDate,
|
||||
downloadStatus: .downloading,
|
||||
lastDownloadError: "Interrupted"
|
||||
)
|
||||
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore(states: [interruptedState])
|
||||
let audioFileStore = InMemoryOfflineAudioFileStore(files: [
|
||||
recoveredFilePath: sampleMp3Data(seed: "interrupted-file"),
|
||||
])
|
||||
let service = makeSyncService(
|
||||
apiClient: MockVelodyAPIClient(),
|
||||
store: InMemoryRemoteLibraryStore(),
|
||||
cursorStore: InMemoryRemoteLibrarySyncCursorStore(),
|
||||
downloadStateStore: downloadStateStore,
|
||||
audioFileStore: audioFileStore
|
||||
)
|
||||
|
||||
let recoveredStates = try await service.loadDownloadStates()
|
||||
let recoveredState = try XCTUnwrap(recoveredStates.first)
|
||||
let persistedState = try await downloadStateStore.loadDownloadStates().first
|
||||
|
||||
XCTAssertEqual(recoveredState.downloadStatus, .downloaded)
|
||||
XCTAssertEqual(recoveredState.localFilePath, recoveredFilePath)
|
||||
XCTAssertEqual(recoveredState.downloadedAt, recoveredDate)
|
||||
XCTAssertNil(recoveredState.lastDownloadError)
|
||||
XCTAssertEqual(persistedState, recoveredState)
|
||||
}
|
||||
|
||||
func testLoadDownloadStatesRepairsStaleLocalFilePathAfterStoreRecreation() async throws {
|
||||
let fileManager = FileManager.default
|
||||
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
|
||||
@@ -203,15 +498,11 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
||||
let secondAudioDirectory = tempDirectory.appendingPathComponent("audio-v2", isDirectory: true)
|
||||
let stateFileURL = tempDirectory.appendingPathComponent("remote-download-states.json")
|
||||
let audioData = sampleMp3Data(seed: "relaunch-repair")
|
||||
let track = RemoteTrack(
|
||||
let track = makeRemoteTrack(
|
||||
trackId: "track-123",
|
||||
title: "1 Mai 2026",
|
||||
artist: "Remote Artist",
|
||||
durationSeconds: 245,
|
||||
sha256: sha256Hex(audioData),
|
||||
assetId: "asset-456",
|
||||
createdAt: "2026-05-29T08:00:00.000Z",
|
||||
updatedAt: "2026-05-29T08:05:00.000Z"
|
||||
assetId: "asset-456"
|
||||
)
|
||||
|
||||
defer {
|
||||
@@ -220,11 +511,9 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
||||
|
||||
let firstService = RemoteLibrarySyncService(
|
||||
repository: DefaultRemoteLibraryRepository(
|
||||
apiClient: MockVelodyAPIClient(
|
||||
remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: []),
|
||||
audioAssetData: audioData
|
||||
),
|
||||
store: InMemoryRemoteLibraryStore()
|
||||
apiClient: MockVelodyAPIClient(audioAssetData: audioData),
|
||||
store: InMemoryRemoteLibraryStore(),
|
||||
syncCursorStore: InMemoryRemoteLibrarySyncCursorStore()
|
||||
),
|
||||
downloadStateStore: try FileRemoteTrackDownloadStateStore(fileURL: stateFileURL),
|
||||
audioFileStore: try FileOfflineAudioFileStore(baseDirectoryURL: firstAudioDirectory),
|
||||
@@ -240,8 +529,9 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
||||
let relaunchedAudioStore = try FileOfflineAudioFileStore(baseDirectoryURL: secondAudioDirectory)
|
||||
let relaunchedService = RemoteLibrarySyncService(
|
||||
repository: DefaultRemoteLibraryRepository(
|
||||
apiClient: MockVelodyAPIClient(remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: [])),
|
||||
store: InMemoryRemoteLibraryStore()
|
||||
apiClient: MockVelodyAPIClient(),
|
||||
store: InMemoryRemoteLibraryStore(),
|
||||
syncCursorStore: InMemoryRemoteLibrarySyncCursorStore()
|
||||
),
|
||||
downloadStateStore: try FileRemoteTrackDownloadStateStore(fileURL: stateFileURL),
|
||||
audioFileStore: relaunchedAudioStore,
|
||||
@@ -255,7 +545,7 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
||||
.loadDownloadStates()
|
||||
.first
|
||||
|
||||
XCTAssertEqual(restoredState.downloadStatus, .downloaded)
|
||||
XCTAssertEqual(restoredState.downloadStatus, RemoteTrackDownloadStatus.downloaded)
|
||||
XCTAssertEqual(restoredState.localFilePath, recreatedStoreFileURL.standardizedFileURL.path)
|
||||
XCTAssertEqual(persistedRestoredState?.localFilePath, recreatedStoreFileURL.standardizedFileURL.path)
|
||||
XCTAssertTrue(fileManager.fileExists(atPath: restoredState.localFilePath))
|
||||
@@ -270,37 +560,22 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
||||
width: 1,
|
||||
height: 1
|
||||
)
|
||||
let track = makeRemoteTrack(
|
||||
trackId: "track-123",
|
||||
artwork: artwork
|
||||
)
|
||||
let artworkStore = InMemoryArtworkStore()
|
||||
let service = RemoteLibrarySyncService(
|
||||
repository: DefaultRemoteLibraryRepository(
|
||||
apiClient: MockVelodyAPIClient(
|
||||
remoteLibraryResponse: RemoteLibraryResponseDTO(
|
||||
tracks: [
|
||||
RemoteTrackDTO(
|
||||
trackId: "track-123",
|
||||
title: "Remote Title",
|
||||
artist: "Remote Artist",
|
||||
durationSeconds: 245,
|
||||
sha256: String(repeating: "a", count: 64),
|
||||
assetId: "asset-456",
|
||||
createdAt: "2026-05-29T08:00:00.000Z",
|
||||
updatedAt: "2026-05-29T08:05:00.000Z",
|
||||
artwork: RemoteArtworkDTO(
|
||||
artworkId: artwork.artworkId,
|
||||
sha256: artwork.sha256,
|
||||
mimeType: artwork.mimeType,
|
||||
width: artwork.width,
|
||||
height: artwork.height
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
artworkData: sampleArtworkData()
|
||||
let service = makeSyncService(
|
||||
apiClient: MockVelodyAPIClient(
|
||||
bootstrapResponse: SyncBootstrapResponse(
|
||||
nextCursor: SyncCursor(value: "1"),
|
||||
tracks: [track],
|
||||
serverTime: "2026-06-15T12:00:00.000Z"
|
||||
),
|
||||
store: InMemoryRemoteLibraryStore()
|
||||
artworkData: sampleArtworkData()
|
||||
),
|
||||
downloadStateStore: InMemoryRemoteTrackDownloadStateStore(),
|
||||
audioFileStore: InMemoryOfflineAudioFileStore(),
|
||||
store: InMemoryRemoteLibraryStore(),
|
||||
cursorStore: InMemoryRemoteLibrarySyncCursorStore(),
|
||||
artworkStore: artworkStore
|
||||
)
|
||||
|
||||
@@ -315,24 +590,53 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
private struct MockVelodyAPIClient: VelodyAPIClient {
|
||||
let remoteLibraryResponse: RemoteLibraryResponseDTO?
|
||||
let remoteLibraryError: VelodyAPIError?
|
||||
let audioAssetData: Data?
|
||||
let downloadError: VelodyAPIError?
|
||||
let artworkData: Data?
|
||||
let artworkDownloadError: VelodyAPIError?
|
||||
private func makeSyncService(
|
||||
apiClient: any VelodyAPIClient,
|
||||
store: any RemoteLibraryStore,
|
||||
cursorStore: any RemoteLibrarySyncCursorStore,
|
||||
downloadStateStore: any RemoteTrackDownloadStateStore = InMemoryRemoteTrackDownloadStateStore(),
|
||||
audioFileStore: any OfflineAudioFileStore = InMemoryOfflineAudioFileStore(),
|
||||
artworkStore: any ArtworkStore = InMemoryArtworkStore()
|
||||
) -> RemoteLibrarySyncService {
|
||||
RemoteLibrarySyncService(
|
||||
repository: DefaultRemoteLibraryRepository(
|
||||
apiClient: apiClient,
|
||||
store: store,
|
||||
syncCursorStore: cursorStore
|
||||
),
|
||||
downloadStateStore: downloadStateStore,
|
||||
audioFileStore: audioFileStore,
|
||||
artworkStore: artworkStore
|
||||
)
|
||||
}
|
||||
|
||||
private actor MockVelodyAPIClient: VelodyAPIClient {
|
||||
private let bootstrapResponse: SyncBootstrapResponse
|
||||
private let changeResponses: [SyncChangesResponse]
|
||||
private let changeError: VelodyAPIError?
|
||||
private let audioAssetData: Data?
|
||||
private let downloadError: VelodyAPIError?
|
||||
private let artworkData: Data?
|
||||
private let artworkDownloadError: VelodyAPIError?
|
||||
private var changeResponseIndex = 0
|
||||
private var changeCursors: [String] = []
|
||||
|
||||
init(
|
||||
remoteLibraryResponse: RemoteLibraryResponseDTO? = nil,
|
||||
remoteLibraryError: VelodyAPIError? = nil,
|
||||
bootstrapResponse: SyncBootstrapResponse = SyncBootstrapResponse(
|
||||
nextCursor: SyncCursor(value: "0"),
|
||||
tracks: [],
|
||||
serverTime: "2026-06-15T12:00:00.000Z"
|
||||
),
|
||||
changeResponses: [SyncChangesResponse] = [],
|
||||
changeError: VelodyAPIError? = nil,
|
||||
audioAssetData: Data? = nil,
|
||||
downloadError: VelodyAPIError? = nil,
|
||||
artworkData: Data? = nil,
|
||||
artworkDownloadError: VelodyAPIError? = nil
|
||||
) {
|
||||
self.remoteLibraryResponse = remoteLibraryResponse
|
||||
self.remoteLibraryError = remoteLibraryError
|
||||
self.bootstrapResponse = bootstrapResponse
|
||||
self.changeResponses = changeResponses
|
||||
self.changeError = changeError
|
||||
self.audioAssetData = audioAssetData
|
||||
self.downloadError = downloadError
|
||||
self.artworkData = artworkData
|
||||
@@ -347,7 +651,7 @@ private struct MockVelodyAPIClient: VelodyAPIClient {
|
||||
deviceId: UUID().uuidString,
|
||||
deviceAccessToken: UUID().uuidString,
|
||||
bootstrapToken: UUID().uuidString,
|
||||
serverTime: "2026-05-29T08:00:00.000Z"
|
||||
serverTime: "2026-06-15T12:00:00.000Z"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -357,30 +661,43 @@ private struct MockVelodyAPIClient: VelodyAPIClient {
|
||||
_ = payload
|
||||
return DeviceHeartbeatResponse(
|
||||
ok: true,
|
||||
serverTime: "2026-05-29T08:00:00.000Z"
|
||||
serverTime: "2026-06-15T12:00:00.000Z"
|
||||
)
|
||||
}
|
||||
|
||||
func fetchSyncBootstrap() async throws -> SyncBootstrapResponse {
|
||||
SyncBootstrapResponse(
|
||||
nextCursor: SyncCursor(value: "0"),
|
||||
tracks: [],
|
||||
events: [],
|
||||
deletedTrackIds: [],
|
||||
serverTime: "2026-05-29T08:00:00.000Z"
|
||||
)
|
||||
bootstrapResponse
|
||||
}
|
||||
|
||||
func fetchSyncChanges(
|
||||
cursor: SyncCursor
|
||||
) async throws -> SyncChangesResponse {
|
||||
changeCursors.append(cursor.value)
|
||||
|
||||
if let changeError {
|
||||
throw changeError
|
||||
}
|
||||
|
||||
guard !changeResponses.isEmpty else {
|
||||
return SyncChangesResponse(
|
||||
nextCursor: cursor,
|
||||
hasMore: false,
|
||||
requiresBootstrap: false,
|
||||
events: [],
|
||||
serverTime: "2026-06-15T12:00:00.000Z"
|
||||
)
|
||||
}
|
||||
|
||||
let response = changeResponses[min(changeResponseIndex, changeResponses.count - 1)]
|
||||
changeResponseIndex += 1
|
||||
return response
|
||||
}
|
||||
|
||||
func fetchRemoteLibrary(
|
||||
deviceId: String
|
||||
) async throws -> RemoteLibraryResponseDTO {
|
||||
_ = deviceId
|
||||
|
||||
if let remoteLibraryError {
|
||||
throw remoteLibraryError
|
||||
}
|
||||
|
||||
return remoteLibraryResponse ?? RemoteLibraryResponseDTO(tracks: [])
|
||||
return RemoteLibraryResponseDTO(tracks: [])
|
||||
}
|
||||
|
||||
func downloadAudioAsset(
|
||||
@@ -457,6 +774,79 @@ private struct MockVelodyAPIClient: VelodyAPIClient {
|
||||
assetId: UUID().uuidString
|
||||
)
|
||||
}
|
||||
|
||||
func recordedChangeCursors() -> [String] {
|
||||
changeCursors
|
||||
}
|
||||
}
|
||||
|
||||
private actor SequencedDownloadRepository: RemoteLibraryRepository {
|
||||
private var downloadResults: [Result<Data, VelodyAPIError>]
|
||||
private let tracks: [RemoteTrack]
|
||||
|
||||
init(
|
||||
downloadResults: [Result<Data, VelodyAPIError>],
|
||||
tracks: [RemoteTrack] = []
|
||||
) {
|
||||
self.downloadResults = downloadResults
|
||||
self.tracks = tracks
|
||||
}
|
||||
|
||||
func loadCachedRemoteTracks() async throws -> [RemoteTrack] {
|
||||
tracks
|
||||
}
|
||||
|
||||
func syncRemoteTracks(deviceId: String) async throws -> [RemoteTrack] {
|
||||
_ = deviceId
|
||||
return tracks
|
||||
}
|
||||
|
||||
func downloadAudioAsset(assetId: String, deviceId: String) async throws -> Data {
|
||||
_ = assetId
|
||||
_ = deviceId
|
||||
|
||||
guard !downloadResults.isEmpty else {
|
||||
return Data()
|
||||
}
|
||||
|
||||
let result = downloadResults.removeFirst()
|
||||
switch result {
|
||||
case let .success(data):
|
||||
return data
|
||||
case let .failure(error):
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func downloadArtwork(artworkId: String, deviceId: String) async throws -> Data {
|
||||
_ = artworkId
|
||||
_ = deviceId
|
||||
return Data()
|
||||
}
|
||||
}
|
||||
|
||||
private func makeRemoteTrack(
|
||||
trackId: String,
|
||||
title: String = "Remote Title",
|
||||
artist: String = "Remote Artist",
|
||||
durationSeconds: Int = 245,
|
||||
sha256: String = String(repeating: "a", count: 64),
|
||||
assetId: String? = nil,
|
||||
createdAt: String = "2026-06-15T12:00:00.000Z",
|
||||
updatedAt: String = "2026-06-15T12:05:00.000Z",
|
||||
artwork: RemoteArtwork? = nil
|
||||
) -> RemoteTrack {
|
||||
RemoteTrack(
|
||||
trackId: trackId,
|
||||
title: title,
|
||||
artist: artist,
|
||||
durationSeconds: durationSeconds,
|
||||
sha256: sha256,
|
||||
assetId: assetId ?? "asset-\(trackId)",
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
artwork: artwork
|
||||
)
|
||||
}
|
||||
|
||||
private func sampleMp3Data(seed: String) -> Data {
|
||||
|
||||
Reference in New Issue
Block a user