491 lines
18 KiB
Swift
491 lines
18 KiB
Swift
import CryptoKit
|
|
import Foundation
|
|
import XCTest
|
|
import VelodyDomain
|
|
import VelodyNetworking
|
|
import VelodyPersistence
|
|
@testable import VelodySync
|
|
|
|
final class RemoteLibrarySyncServiceTests: XCTestCase {
|
|
func testSuccessfulSyncPersistsRemoteTracks() 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
|
|
),
|
|
downloadStateStore: downloadStateStore,
|
|
audioFileStore: InMemoryOfflineAudioFileStore(),
|
|
artworkStore: InMemoryArtworkStore()
|
|
)
|
|
|
|
let tracks = try await service.syncRemoteLibrary(deviceId: "device-123")
|
|
let cachedTracks = try await service.loadCachedRemoteTracks()
|
|
let downloadStates = try await service.loadDownloadStates()
|
|
|
|
XCTAssertEqual(tracks.count, 1)
|
|
XCTAssertEqual(cachedTracks, tracks)
|
|
XCTAssertEqual(cachedTracks.first?.trackId, "track-123")
|
|
XCTAssertEqual(downloadStates.first?.downloadStatus, .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"
|
|
),
|
|
]
|
|
)
|
|
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 tracks = try await service.syncRemoteLibrary(deviceId: "device-123")
|
|
let cachedTracks = try await service.loadCachedRemoteTracks()
|
|
let downloadStates = try await service.loadDownloadStates()
|
|
|
|
XCTAssertEqual(tracks, [])
|
|
XCTAssertEqual(cachedTracks, [])
|
|
XCTAssertEqual(downloadStates.count, 1)
|
|
}
|
|
|
|
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"
|
|
)
|
|
let store = InMemoryRemoteLibraryStore(tracks: [cachedTrack])
|
|
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore()
|
|
let service = RemoteLibrarySyncService(
|
|
repository: DefaultRemoteLibraryRepository(
|
|
apiClient: MockVelodyAPIClient(
|
|
remoteLibraryError: VelodyAPIError.requestFailed("Offline")
|
|
),
|
|
store: store
|
|
),
|
|
downloadStateStore: downloadStateStore,
|
|
audioFileStore: InMemoryOfflineAudioFileStore(),
|
|
artworkStore: InMemoryArtworkStore()
|
|
)
|
|
|
|
await XCTAssertThrowsErrorAsync {
|
|
_ = try await service.syncRemoteLibrary(deviceId: "device-123")
|
|
}
|
|
|
|
let cachedTracks = try await service.loadCachedRemoteTracks()
|
|
XCTAssertEqual(cachedTracks, [cachedTrack])
|
|
}
|
|
|
|
func testDownloadTrackPersistsDownloadedStateAndFile() async throws {
|
|
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore()
|
|
let audioFileStore = InMemoryOfflineAudioFileStore()
|
|
let service = RemoteLibrarySyncService(
|
|
repository: DefaultRemoteLibraryRepository(
|
|
apiClient: MockVelodyAPIClient(
|
|
remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: []),
|
|
audioAssetData: sampleMp3Data(seed: "download-success")
|
|
),
|
|
store: InMemoryRemoteLibraryStore()
|
|
),
|
|
downloadStateStore: downloadStateStore,
|
|
audioFileStore: audioFileStore,
|
|
artworkStore: InMemoryArtworkStore()
|
|
)
|
|
let track = RemoteTrack(
|
|
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"
|
|
)
|
|
|
|
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.assetId, "asset-456")
|
|
XCTAssertFalse(state.localFilePath.isEmpty)
|
|
XCTAssertEqual(storedStates.first?.downloadStatus, .downloaded)
|
|
XCTAssertTrue(fileExists)
|
|
}
|
|
|
|
func testDownloadTrackPersistsFailureState() async throws {
|
|
let service = RemoteLibrarySyncService(
|
|
repository: DefaultRemoteLibraryRepository(
|
|
apiClient: MockVelodyAPIClient(
|
|
remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: []),
|
|
downloadError: VelodyAPIError.server(statusCode: 404, message: "Missing")
|
|
),
|
|
store: InMemoryRemoteLibraryStore()
|
|
),
|
|
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"
|
|
)
|
|
|
|
await XCTAssertThrowsErrorAsync {
|
|
_ = try await service.downloadTrack(track, deviceId: "device-123")
|
|
}
|
|
|
|
let storedStates = try await service.loadDownloadStates()
|
|
XCTAssertEqual(storedStates.first?.downloadStatus, .failed)
|
|
XCTAssertEqual(storedStates.first?.remoteTrackId, "track-123")
|
|
}
|
|
|
|
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 = RemoteTrack(
|
|
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"
|
|
)
|
|
|
|
defer {
|
|
try? fileManager.removeItem(at: tempDirectory)
|
|
}
|
|
|
|
let firstService = RemoteLibrarySyncService(
|
|
repository: DefaultRemoteLibraryRepository(
|
|
apiClient: MockVelodyAPIClient(
|
|
remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: []),
|
|
audioAssetData: audioData
|
|
),
|
|
store: InMemoryRemoteLibraryStore()
|
|
),
|
|
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(remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: [])),
|
|
store: InMemoryRemoteLibraryStore()
|
|
),
|
|
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, .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 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()
|
|
),
|
|
store: InMemoryRemoteLibraryStore()
|
|
),
|
|
downloadStateStore: InMemoryRemoteTrackDownloadStateStore(),
|
|
audioFileStore: InMemoryOfflineAudioFileStore(),
|
|
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 struct MockVelodyAPIClient: VelodyAPIClient {
|
|
let remoteLibraryResponse: RemoteLibraryResponseDTO?
|
|
let remoteLibraryError: VelodyAPIError?
|
|
let audioAssetData: Data?
|
|
let downloadError: VelodyAPIError?
|
|
let artworkData: Data?
|
|
let artworkDownloadError: VelodyAPIError?
|
|
|
|
init(
|
|
remoteLibraryResponse: RemoteLibraryResponseDTO? = nil,
|
|
remoteLibraryError: VelodyAPIError? = nil,
|
|
audioAssetData: Data? = nil,
|
|
downloadError: VelodyAPIError? = nil,
|
|
artworkData: Data? = nil,
|
|
artworkDownloadError: VelodyAPIError? = nil
|
|
) {
|
|
self.remoteLibraryResponse = remoteLibraryResponse
|
|
self.remoteLibraryError = remoteLibraryError
|
|
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-05-29T08:00:00.000Z"
|
|
)
|
|
}
|
|
|
|
func sendHeartbeat(
|
|
_ payload: DeviceHeartbeatPayload
|
|
) async throws -> DeviceHeartbeatResponse {
|
|
_ = payload
|
|
return DeviceHeartbeatResponse(
|
|
ok: true,
|
|
serverTime: "2026-05-29T08:00:00.000Z"
|
|
)
|
|
}
|
|
|
|
func fetchSyncBootstrap() async throws -> SyncBootstrapResponse {
|
|
SyncBootstrapResponse(
|
|
nextCursor: SyncCursor(value: "0"),
|
|
tracks: [],
|
|
events: [],
|
|
deletedTrackIds: [],
|
|
serverTime: "2026-05-29T08:00:00.000Z"
|
|
)
|
|
}
|
|
|
|
func fetchRemoteLibrary(
|
|
deviceId: String
|
|
) async throws -> RemoteLibraryResponseDTO {
|
|
_ = deviceId
|
|
|
|
if let remoteLibraryError {
|
|
throw remoteLibraryError
|
|
}
|
|
|
|
return remoteLibraryResponse ?? 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
|
|
)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|