Implement Milestone 7.2 offline audio downloads
This commit is contained in:
+191
-4
@@ -1,3 +1,4 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import XCTest
|
||||
import VelodyDomain
|
||||
@@ -8,6 +9,7 @@ import VelodyPersistence
|
||||
final class RemoteLibrarySyncServiceTests: XCTestCase {
|
||||
func testSuccessfulSyncPersistsRemoteTracks() async throws {
|
||||
let store = InMemoryRemoteLibraryStore()
|
||||
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore()
|
||||
let service = RemoteLibrarySyncService(
|
||||
repository: DefaultRemoteLibraryRepository(
|
||||
apiClient: MockVelodyAPIClient(
|
||||
@@ -27,15 +29,19 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
||||
)
|
||||
),
|
||||
store: store
|
||||
)
|
||||
),
|
||||
downloadStateStore: downloadStateStore,
|
||||
audioFileStore: InMemoryOfflineAudioFileStore()
|
||||
)
|
||||
|
||||
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 {
|
||||
@@ -53,20 +59,33 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
||||
),
|
||||
]
|
||||
)
|
||||
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()
|
||||
)
|
||||
|
||||
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 {
|
||||
@@ -81,13 +100,16 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
||||
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()
|
||||
)
|
||||
|
||||
await XCTAssertThrowsErrorAsync {
|
||||
@@ -97,18 +119,159 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
||||
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
|
||||
)
|
||||
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()
|
||||
)
|
||||
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)
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
private struct MockVelodyAPIClient: VelodyAPIClient {
|
||||
let remoteLibraryResponse: RemoteLibraryResponseDTO?
|
||||
let remoteLibraryError: VelodyAPIError?
|
||||
let audioAssetData: Data?
|
||||
let downloadError: VelodyAPIError?
|
||||
|
||||
init(
|
||||
remoteLibraryResponse: RemoteLibraryResponseDTO? = nil,
|
||||
remoteLibraryError: VelodyAPIError? = nil
|
||||
remoteLibraryError: VelodyAPIError? = nil,
|
||||
audioAssetData: Data? = nil,
|
||||
downloadError: VelodyAPIError? = nil
|
||||
) {
|
||||
self.remoteLibraryResponse = remoteLibraryResponse
|
||||
self.remoteLibraryError = remoteLibraryError
|
||||
self.audioAssetData = audioAssetData
|
||||
self.downloadError = downloadError
|
||||
}
|
||||
|
||||
func registerDevice(
|
||||
@@ -154,6 +317,20 @@ private struct MockVelodyAPIClient: VelodyAPIClient {
|
||||
return remoteLibraryResponse ?? RemoteLibraryResponseDTO(tracks: [])
|
||||
}
|
||||
|
||||
func downloadAudioAsset(
|
||||
assetId: String,
|
||||
deviceId: String
|
||||
) async throws -> Data {
|
||||
_ = assetId
|
||||
_ = deviceId
|
||||
|
||||
if let downloadError {
|
||||
throw downloadError
|
||||
}
|
||||
|
||||
return audioAssetData ?? Data()
|
||||
}
|
||||
|
||||
func prepareUpload(
|
||||
_ payload: UploadPrepareRequest
|
||||
) async throws -> UploadPrepareResponse {
|
||||
@@ -202,6 +379,16 @@ private struct MockVelodyAPIClient: VelodyAPIClient {
|
||||
}
|
||||
}
|
||||
|
||||
private func sampleMp3Data(seed: String) -> Data {
|
||||
Data([
|
||||
0x49, 0x44, 0x33, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x21,
|
||||
] + Array(seed.utf8))
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user