velody/packages/apple/VelodySync/Tests/VelodySyncTests/RemoteLibrarySyncServiceTests.swift
2026-06-19 04:08:36 +02:00

961 lines
37 KiB
Swift

import CryptoKit
import Foundation
import XCTest
import VelodyDomain
import VelodyNetworking
import VelodyPersistence
@testable import VelodySync
final class RemoteLibrarySyncServiceTests: XCTestCase {
func testBootstrapFirstSyncPersistsRemoteTracksAndCursor() async throws {
let store = InMemoryRemoteLibraryStore()
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"
)
),
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, [track])
XCTAssertEqual(cachedTracks, [track])
XCTAssertEqual(storedCursor, SyncCursor(value: "4"))
XCTAssertEqual(downloadStates.first?.downloadStatus, RemoteTrackDownloadStatus.notDownloaded)
}
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 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, [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 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 cursorStore = InMemoryRemoteLibrarySyncCursorStore(
cursor: SyncCursor(value: "7")
)
let service = makeSyncService(
apiClient: MockVelodyAPIClient(
changeError: VelodyAPIError.requestFailed("Offline")
),
store: store,
cursorStore: cursorStore
)
await XCTAssertThrowsErrorAsync {
_ = try await service.syncRemoteLibrary(deviceId: "device-123")
}
let cachedTracks = try await service.loadCachedRemoteTracks()
let storedCursor = try await cursorStore.loadCursor()
XCTAssertEqual(cachedTracks, [cachedTrack])
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"
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 {
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore()
let audioFileStore = InMemoryOfflineAudioFileStore()
let service = RemoteLibrarySyncService(
repository: DefaultRemoteLibraryRepository(
apiClient: MockVelodyAPIClient(
audioAssetData: sampleMp3Data(seed: "download-success")
),
store: InMemoryRemoteLibraryStore(),
syncCursorStore: InMemoryRemoteLibrarySyncCursorStore()
),
downloadStateStore: downloadStateStore,
audioFileStore: audioFileStore,
artworkStore: InMemoryArtworkStore()
)
let track = makeRemoteTrack(
trackId: "track-123",
sha256: sha256Hex(sampleMp3Data(seed: "download-success")),
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, RemoteTrackDownloadStatus.downloaded)
XCTAssertEqual(state.assetId, "asset-456")
XCTAssertFalse(state.localFilePath.isEmpty)
XCTAssertEqual(storedStates.first?.downloadStatus, RemoteTrackDownloadStatus.downloaded)
XCTAssertTrue(fileExists)
}
func testDownloadTrackPersistsFailureState() async throws {
let service = RemoteLibrarySyncService(
repository: DefaultRemoteLibraryRepository(
apiClient: MockVelodyAPIClient(
downloadError: VelodyAPIError.server(statusCode: 404, message: "Missing")
),
store: InMemoryRemoteLibraryStore(),
syncCursorStore: InMemoryRemoteLibrarySyncCursorStore()
),
downloadStateStore: InMemoryRemoteTrackDownloadStateStore(),
audioFileStore: InMemoryOfflineAudioFileStore(),
artworkStore: InMemoryArtworkStore()
)
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, 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(
UUID().uuidString,
isDirectory: true
)
let firstAudioDirectory = tempDirectory.appendingPathComponent("audio-v1", isDirectory: true)
let secondAudioDirectory = tempDirectory.appendingPathComponent("audio-v2", isDirectory: true)
let stateFileURL = tempDirectory.appendingPathComponent("remote-download-states.json")
let audioData = sampleMp3Data(seed: "relaunch-repair")
let track = makeRemoteTrack(
trackId: "track-123",
title: "1 Mai 2026",
sha256: sha256Hex(audioData),
assetId: "asset-456"
)
defer {
try? fileManager.removeItem(at: tempDirectory)
}
let firstService = RemoteLibrarySyncService(
repository: DefaultRemoteLibraryRepository(
apiClient: MockVelodyAPIClient(audioAssetData: audioData),
store: InMemoryRemoteLibraryStore(),
syncCursorStore: InMemoryRemoteLibrarySyncCursorStore()
),
downloadStateStore: try FileRemoteTrackDownloadStateStore(fileURL: stateFileURL),
audioFileStore: try FileOfflineAudioFileStore(baseDirectoryURL: firstAudioDirectory),
artworkStore: InMemoryArtworkStore()
)
let originalState = try await firstService.downloadTrack(track, deviceId: "device-123")
let originalFileURL = URL(fileURLWithPath: originalState.localFilePath)
let recreatedStoreFileURL = secondAudioDirectory.appendingPathComponent("asset-456.mp3")
try fileManager.createDirectory(at: secondAudioDirectory, withIntermediateDirectories: true)
try fileManager.moveItem(at: originalFileURL, to: recreatedStoreFileURL)
let relaunchedAudioStore = try FileOfflineAudioFileStore(baseDirectoryURL: secondAudioDirectory)
let relaunchedService = RemoteLibrarySyncService(
repository: DefaultRemoteLibraryRepository(
apiClient: MockVelodyAPIClient(),
store: InMemoryRemoteLibraryStore(),
syncCursorStore: InMemoryRemoteLibrarySyncCursorStore()
),
downloadStateStore: try FileRemoteTrackDownloadStateStore(fileURL: stateFileURL),
audioFileStore: relaunchedAudioStore,
artworkStore: InMemoryArtworkStore()
)
let restoredStates = try await relaunchedService.loadDownloadStates()
let restoredState = try XCTUnwrap(restoredStates.first)
let restoredBytes = try await relaunchedAudioStore.readAudioFile(at: restoredState.localFilePath)
let persistedRestoredState = try await FileRemoteTrackDownloadStateStore(fileURL: stateFileURL)
.loadDownloadStates()
.first
XCTAssertEqual(restoredState.downloadStatus, RemoteTrackDownloadStatus.downloaded)
XCTAssertEqual(restoredState.localFilePath, recreatedStoreFileURL.standardizedFileURL.path)
XCTAssertEqual(persistedRestoredState?.localFilePath, recreatedStoreFileURL.standardizedFileURL.path)
XCTAssertTrue(fileManager.fileExists(atPath: restoredState.localFilePath))
XCTAssertEqual(restoredBytes, audioData)
}
func testSyncCachesArtworkIndependentlyFromAudioDownloads() async throws {
let artwork = RemoteArtwork(
artworkId: "artwork-123",
sha256: String(repeating: "d", count: 64),
mimeType: "image/png",
width: 1,
height: 1
)
let track = makeRemoteTrack(
trackId: "track-123",
artwork: artwork
)
let artworkStore = InMemoryArtworkStore()
let service = makeSyncService(
apiClient: MockVelodyAPIClient(
bootstrapResponse: SyncBootstrapResponse(
nextCursor: SyncCursor(value: "1"),
tracks: [track],
serverTime: "2026-06-15T12:00:00.000Z"
),
artworkData: sampleArtworkData()
),
store: InMemoryRemoteLibraryStore(),
cursorStore: InMemoryRemoteLibrarySyncCursorStore(),
artworkStore: artworkStore
)
let tracks = try await service.syncRemoteLibrary(deviceId: "device-123")
let cachedArtworkPath = await artworkStore.cachedFilePath(for: artwork)
let cachedArtworkBytes = try await artworkStore.readArtwork(
at: try XCTUnwrap(cachedArtworkPath)
)
XCTAssertEqual(tracks.first?.artwork, artwork)
XCTAssertEqual(cachedArtworkBytes, sampleArtworkData())
}
}
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(
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.bootstrapResponse = bootstrapResponse
self.changeResponses = changeResponses
self.changeError = changeError
self.audioAssetData = audioAssetData
self.downloadError = downloadError
self.artworkData = artworkData
self.artworkDownloadError = artworkDownloadError
}
func registerDevice(
_ payload: DeviceRegistrationPayload
) async throws -> DeviceRegistrationResponse {
_ = payload
return DeviceRegistrationResponse(
deviceId: UUID().uuidString,
deviceAccessToken: UUID().uuidString,
bootstrapToken: UUID().uuidString,
serverTime: "2026-06-15T12:00:00.000Z"
)
}
func sendHeartbeat(
_ payload: DeviceHeartbeatPayload
) async throws -> DeviceHeartbeatResponse {
_ = payload
return DeviceHeartbeatResponse(
ok: true,
serverTime: "2026-06-15T12:00:00.000Z"
)
}
func fetchSyncBootstrap() async throws -> SyncBootstrapResponse {
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
return RemoteLibraryResponseDTO(tracks: [])
}
func downloadAudioAsset(
assetId: String,
deviceId: String
) async throws -> Data {
_ = assetId
_ = deviceId
if let downloadError {
throw downloadError
}
return audioAssetData ?? Data()
}
func downloadArtwork(
artworkId: String,
deviceId: String
) async throws -> Data {
_ = artworkId
_ = deviceId
if let artworkDownloadError {
throw artworkDownloadError
}
return artworkData ?? 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 {
_ = uploadId
_ = payload
return UploadFinalizeResponse(
trackId: UUID().uuidString,
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 {
Data([
0x49, 0x44, 0x33, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x21,
] + Array(seed.utf8))
}
private func sampleArtworkData() -> Data {
Data(
base64Encoded:
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P8z8DwHwAFgwJ/lBi4NwAAAABJRU5ErkJggg=="
)!
}
private func sha256Hex(_ data: Data) -> String {
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
}
private func XCTAssertThrowsErrorAsync(
_ expression: @escaping () async throws -> Void,
file: StaticString = #filePath,
line: UInt = #line
) async {
do {
try await expression()
XCTFail("Expected expression to throw an error.", file: file, line: line)
} catch {
XCTAssertTrue(true)
}
}