636 lines
22 KiB
Swift
636 lines
22 KiB
Swift
import CryptoKit
|
|
import Foundation
|
|
import XCTest
|
|
import VelodyDomain
|
|
import VelodyNetworking
|
|
import VelodyPersistence
|
|
import VelodySync
|
|
import VelodyUtilities
|
|
@testable import VelodyiPhone
|
|
|
|
@MainActor
|
|
final class iPhoneLibraryViewModelOfflineResilienceTests: XCTestCase {
|
|
func testRelaunchWhileBackendUnavailableRestoresFavoriteDownloadedArtworkAndOfflinePlayback() async throws {
|
|
let fixture = try await seedPersistedLibrary()
|
|
defer {
|
|
try? FileManager.default.removeItem(at: fixture.tempDirectory)
|
|
}
|
|
|
|
let player = TestPlayer()
|
|
let offlineClient = ScenarioVelodyAPIClient(
|
|
bootstrapResponse: fixture.bootstrapResponse,
|
|
changeError: .requestFailed("Offline")
|
|
)
|
|
let relaunchedViewModel = try makePersistedViewModel(
|
|
fixture: fixture,
|
|
apiClient: offlineClient,
|
|
player: player
|
|
)
|
|
|
|
await relaunchedViewModel.loadIfNeeded()
|
|
|
|
let remoteTrack = try XCTUnwrap(
|
|
remoteRow(in: relaunchedViewModel, trackID: fixture.track.trackId)
|
|
)
|
|
let offlineTrack = try XCTUnwrap(
|
|
offlineRow(in: relaunchedViewModel, trackID: fixture.track.trackId)
|
|
)
|
|
|
|
XCTAssertEqual(relaunchedViewModel.syncStatus, "Library restored from local cache.")
|
|
XCTAssertTrue(remoteTrack.isFavorite)
|
|
XCTAssertEqual(remoteTrack.status, .downloaded)
|
|
XCTAssertEqual(remoteTrack.artworkLocalFilePath, fixture.initialArtworkPath)
|
|
XCTAssertTrue(offlineTrack.isFavorite)
|
|
XCTAssertEqual(offlineTrack.statusBadgeTitle, "Downloaded")
|
|
|
|
relaunchedViewModel.togglePlayback(trackID: fixture.track.trackId)
|
|
|
|
let nowPlayingCard = try XCTUnwrap(relaunchedViewModel.nowPlayingCard)
|
|
XCTAssertEqual(relaunchedViewModel.nowPlaying.trackID, fixture.track.trackId)
|
|
XCTAssertEqual(relaunchedViewModel.nowPlaying.playbackState, .playing)
|
|
XCTAssertEqual(nowPlayingCard.artworkLocalFilePath, fixture.initialArtworkPath)
|
|
XCTAssertEqual(nowPlayingCard.downloadBadge, .downloaded)
|
|
XCTAssertEqual(nowPlayingCard.playbackStateText, "Playing")
|
|
}
|
|
|
|
func testBackendFailureAfterRelaunchKeepsCachedLibraryUsableAndReusesPersistedCursor() async throws {
|
|
let fixture = try await seedPersistedLibrary()
|
|
defer {
|
|
try? FileManager.default.removeItem(at: fixture.tempDirectory)
|
|
}
|
|
|
|
let offlineClient = ScenarioVelodyAPIClient(
|
|
bootstrapResponse: fixture.bootstrapResponse,
|
|
changeError: .requestFailed("Offline")
|
|
)
|
|
let relaunchedViewModel = try makePersistedViewModel(
|
|
fixture: fixture,
|
|
apiClient: offlineClient,
|
|
player: TestPlayer()
|
|
)
|
|
|
|
await relaunchedViewModel.loadIfNeeded()
|
|
await relaunchedViewModel.refreshSync()
|
|
|
|
XCTAssertEqual(
|
|
relaunchedViewModel.syncStatus,
|
|
"Could not reach the backend. Check that the server is running and try again."
|
|
)
|
|
XCTAssertEqual(relaunchedViewModel.remoteTracks.map(\.id), [fixture.track.trackId])
|
|
XCTAssertEqual(relaunchedViewModel.availableOfflineTracks.map(\.id), [fixture.track.trackId])
|
|
let recordedChangeCursors = await offlineClient.recordedChangeCursors()
|
|
XCTAssertEqual(recordedChangeCursors, ["1"])
|
|
let persistedCursor = try await FileRemoteLibrarySyncCursorStore(
|
|
fileURL: fixture.syncCursorFileURL
|
|
).loadCursor()
|
|
XCTAssertEqual(persistedCursor, SyncCursor(value: "1"))
|
|
}
|
|
|
|
func testArtworkReplacementSyncKeepsFavoriteAndDownloadedState() async throws {
|
|
let fixture = try await seedPersistedLibrary()
|
|
defer {
|
|
try? FileManager.default.removeItem(at: fixture.tempDirectory)
|
|
}
|
|
|
|
let replacementArtwork = RemoteArtwork(
|
|
artworkId: "artwork-replacement",
|
|
sha256: sha256Hex(sampleArtworkData(seed: "replacement-artwork")),
|
|
mimeType: "image/png",
|
|
width: 1,
|
|
height: 1
|
|
)
|
|
var replacementTrack = fixture.track
|
|
replacementTrack.updatedAt = "2026-06-16T09:30:00.000Z"
|
|
replacementTrack.artwork = replacementArtwork
|
|
let replacementArtworkPath = expectedArtworkFilePath(
|
|
in: fixture.artworkDirectory,
|
|
artwork: replacementArtwork
|
|
)
|
|
let refreshClient = ScenarioVelodyAPIClient(
|
|
bootstrapResponse: fixture.bootstrapResponse,
|
|
changeResponses: [
|
|
SyncChangesResponse(
|
|
nextCursor: SyncCursor(value: "2"),
|
|
hasMore: false,
|
|
requiresBootstrap: false,
|
|
events: [
|
|
SyncEvent(
|
|
cursor: SyncCursor(value: "2"),
|
|
entityType: "ARTWORK_ASSET",
|
|
entityId: replacementArtwork.artworkId,
|
|
action: "UPDATED",
|
|
track: replacementTrack,
|
|
createdAt: "2026-06-16T09:30:00.000Z"
|
|
),
|
|
],
|
|
serverTime: "2026-06-16T09:30:00.000Z"
|
|
),
|
|
],
|
|
artworkDataByArtworkID: [
|
|
replacementArtwork.artworkId: sampleArtworkData(seed: "replacement-artwork"),
|
|
]
|
|
)
|
|
let viewModel = try makePersistedViewModel(
|
|
fixture: fixture,
|
|
apiClient: refreshClient,
|
|
player: TestPlayer()
|
|
)
|
|
|
|
await viewModel.loadIfNeeded()
|
|
await viewModel.refreshSync()
|
|
|
|
let remoteTrack = try XCTUnwrap(remoteRow(in: viewModel, trackID: fixture.track.trackId))
|
|
let offlineTrack = try XCTUnwrap(offlineRow(in: viewModel, trackID: fixture.track.trackId))
|
|
|
|
XCTAssertTrue(remoteTrack.isFavorite)
|
|
XCTAssertEqual(remoteTrack.status, .downloaded)
|
|
XCTAssertEqual(remoteTrack.artworkLocalFilePath, replacementArtworkPath)
|
|
XCTAssertNotEqual(remoteTrack.artworkLocalFilePath, fixture.initialArtworkPath)
|
|
XCTAssertEqual(offlineTrack.statusBadgeTitle, "Downloaded")
|
|
|
|
viewModel.togglePlayback(trackID: fixture.track.trackId)
|
|
|
|
let nowPlayingCard = try XCTUnwrap(viewModel.nowPlayingCard)
|
|
XCTAssertEqual(nowPlayingCard.artworkLocalFilePath, replacementArtworkPath)
|
|
XCTAssertEqual(nowPlayingCard.downloadBadge, .downloaded)
|
|
}
|
|
|
|
func testAssetReplacementSyncKeepsFavoriteAndDownloadedState() async throws {
|
|
let fixture = try await seedPersistedLibrary()
|
|
defer {
|
|
try? FileManager.default.removeItem(at: fixture.tempDirectory)
|
|
}
|
|
|
|
let replacementAssetId = "asset-replacement"
|
|
var replacementTrack = makeRemoteTrack(
|
|
trackId: fixture.track.trackId,
|
|
assetId: replacementAssetId,
|
|
title: fixture.track.title,
|
|
artwork: fixture.track.artwork
|
|
)
|
|
replacementTrack.sha256 = sha256Hex(sampleAudioData(seed: replacementAssetId))
|
|
replacementTrack.updatedAt = "2026-06-16T10:00:00.000Z"
|
|
let refreshClient = ScenarioVelodyAPIClient(
|
|
bootstrapResponse: fixture.bootstrapResponse,
|
|
changeResponses: [
|
|
SyncChangesResponse(
|
|
nextCursor: SyncCursor(value: "2"),
|
|
hasMore: false,
|
|
requiresBootstrap: false,
|
|
events: [
|
|
SyncEvent(
|
|
cursor: SyncCursor(value: "2"),
|
|
entityType: "AUDIO_ASSET",
|
|
entityId: replacementAssetId,
|
|
action: "UPDATED",
|
|
track: replacementTrack,
|
|
createdAt: "2026-06-16T10:00:00.000Z"
|
|
),
|
|
],
|
|
serverTime: "2026-06-16T10:00:00.000Z"
|
|
),
|
|
]
|
|
)
|
|
let viewModel = try makePersistedViewModel(
|
|
fixture: fixture,
|
|
apiClient: refreshClient,
|
|
player: TestPlayer()
|
|
)
|
|
|
|
await viewModel.loadIfNeeded()
|
|
await viewModel.refreshSync()
|
|
|
|
let remoteTrack = try XCTUnwrap(remoteRow(in: viewModel, trackID: fixture.track.trackId))
|
|
let offlineTrack = try XCTUnwrap(offlineRow(in: viewModel, trackID: fixture.track.trackId))
|
|
|
|
XCTAssertTrue(remoteTrack.isFavorite)
|
|
XCTAssertEqual(remoteTrack.status, .downloaded)
|
|
XCTAssertEqual(offlineTrack.statusBadgeTitle, "Downloaded")
|
|
XCTAssertEqual(viewModel.availableOfflineTracks.map(\.id), [fixture.track.trackId])
|
|
|
|
viewModel.togglePlayback(trackID: fixture.track.trackId)
|
|
|
|
XCTAssertEqual(viewModel.nowPlaying.trackID, fixture.track.trackId)
|
|
XCTAssertEqual(viewModel.nowPlaying.playbackState, .playing)
|
|
}
|
|
|
|
func testTrackDeletionSyncRemovesDeletedTrackGracefully() async throws {
|
|
let fixture = try await seedPersistedLibrary()
|
|
defer {
|
|
try? FileManager.default.removeItem(at: fixture.tempDirectory)
|
|
}
|
|
|
|
let refreshClient = ScenarioVelodyAPIClient(
|
|
bootstrapResponse: fixture.bootstrapResponse,
|
|
changeResponses: [
|
|
SyncChangesResponse(
|
|
nextCursor: SyncCursor(value: "2"),
|
|
hasMore: false,
|
|
requiresBootstrap: false,
|
|
events: [
|
|
SyncEvent(
|
|
cursor: SyncCursor(value: "2"),
|
|
entityType: "TRACK",
|
|
entityId: fixture.track.trackId,
|
|
action: "DELETED",
|
|
deletedTrackId: fixture.track.trackId,
|
|
createdAt: "2026-06-16T10:30:00.000Z"
|
|
),
|
|
],
|
|
serverTime: "2026-06-16T10:30:00.000Z"
|
|
),
|
|
]
|
|
)
|
|
let viewModel = try makePersistedViewModel(
|
|
fixture: fixture,
|
|
apiClient: refreshClient,
|
|
player: TestPlayer()
|
|
)
|
|
|
|
await viewModel.loadIfNeeded()
|
|
await viewModel.refreshSync()
|
|
|
|
XCTAssertTrue(viewModel.remoteTracks.isEmpty)
|
|
XCTAssertTrue(viewModel.availableOfflineTracks.isEmpty)
|
|
XCTAssertEqual(viewModel.syncStatus, "No music was returned for this library.")
|
|
}
|
|
}
|
|
|
|
private struct PersistedOfflineLibraryFixture {
|
|
let tempDirectory: URL
|
|
let remoteLibraryFileURL: URL
|
|
let downloadStateFileURL: URL
|
|
let syncCursorFileURL: URL
|
|
let favoriteTrackFileURL: URL
|
|
let audioDirectory: URL
|
|
let artworkDirectory: URL
|
|
let keychainService: MemoryKeychainService
|
|
let track: RemoteTrack
|
|
let bootstrapResponse: SyncBootstrapResponse
|
|
let initialArtworkPath: String
|
|
}
|
|
|
|
@MainActor
|
|
private func seedPersistedLibrary() async throws -> PersistedOfflineLibraryFixture {
|
|
let fileManager = FileManager.default
|
|
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
|
|
UUID().uuidString,
|
|
isDirectory: true
|
|
)
|
|
let remoteLibraryFileURL = tempDirectory.appendingPathComponent("remote-library.json")
|
|
let downloadStateFileURL = tempDirectory.appendingPathComponent("remote-download-states.json")
|
|
let syncCursorFileURL = tempDirectory.appendingPathComponent("remote-library-sync-cursor.json")
|
|
let favoriteTrackFileURL = tempDirectory.appendingPathComponent("favorite-tracks.json")
|
|
let audioDirectory = tempDirectory.appendingPathComponent("audio", isDirectory: true)
|
|
let artworkDirectory = tempDirectory.appendingPathComponent("artwork", isDirectory: true)
|
|
let keychainService = MemoryKeychainService()
|
|
let artworkData = sampleArtworkData(seed: "seed-artwork")
|
|
let artwork = RemoteArtwork(
|
|
artworkId: "artwork-seed",
|
|
sha256: sha256Hex(artworkData),
|
|
mimeType: "image/png",
|
|
width: 1,
|
|
height: 1
|
|
)
|
|
let track = makeConsistentRemoteTrack(
|
|
trackId: "remote-offline-resilience",
|
|
assetId: "asset-offline-resilience",
|
|
title: "Offline Resilience",
|
|
artwork: artwork
|
|
)
|
|
let bootstrapResponse = SyncBootstrapResponse(
|
|
nextCursor: SyncCursor(value: "1"),
|
|
tracks: [track],
|
|
serverTime: "2026-06-16T09:00:00.000Z"
|
|
)
|
|
let onlineClient = ScenarioVelodyAPIClient(
|
|
bootstrapResponse: bootstrapResponse,
|
|
audioAssetDataByAssetID: [
|
|
track.assetId: sampleAudioData(seed: track.assetId),
|
|
],
|
|
artworkDataByArtworkID: [
|
|
artwork.artworkId: artworkData,
|
|
]
|
|
)
|
|
let firstViewModel = try makePersistedViewModel(
|
|
remoteLibraryFileURL: remoteLibraryFileURL,
|
|
downloadStateFileURL: downloadStateFileURL,
|
|
syncCursorFileURL: syncCursorFileURL,
|
|
favoriteTrackFileURL: favoriteTrackFileURL,
|
|
audioDirectory: audioDirectory,
|
|
artworkDirectory: artworkDirectory,
|
|
apiClient: onlineClient,
|
|
keychainService: keychainService,
|
|
player: TestPlayer()
|
|
)
|
|
|
|
await firstViewModel.loadIfNeeded()
|
|
await firstViewModel.refreshSync()
|
|
await firstViewModel.downloadTrack(trackID: track.trackId)
|
|
await firstViewModel.toggleFavorite(trackID: track.trackId)
|
|
|
|
return PersistedOfflineLibraryFixture(
|
|
tempDirectory: tempDirectory,
|
|
remoteLibraryFileURL: remoteLibraryFileURL,
|
|
downloadStateFileURL: downloadStateFileURL,
|
|
syncCursorFileURL: syncCursorFileURL,
|
|
favoriteTrackFileURL: favoriteTrackFileURL,
|
|
audioDirectory: audioDirectory,
|
|
artworkDirectory: artworkDirectory,
|
|
keychainService: keychainService,
|
|
track: track,
|
|
bootstrapResponse: bootstrapResponse,
|
|
initialArtworkPath: expectedArtworkFilePath(in: artworkDirectory, artwork: artwork)
|
|
)
|
|
}
|
|
|
|
@MainActor
|
|
private func makePersistedViewModel(
|
|
fixture: PersistedOfflineLibraryFixture,
|
|
apiClient: any VelodyAPIClient,
|
|
player: any iPhoneLocalAudioPlaying
|
|
) throws -> iPhoneLibraryViewModel {
|
|
try makePersistedViewModel(
|
|
remoteLibraryFileURL: fixture.remoteLibraryFileURL,
|
|
downloadStateFileURL: fixture.downloadStateFileURL,
|
|
syncCursorFileURL: fixture.syncCursorFileURL,
|
|
favoriteTrackFileURL: fixture.favoriteTrackFileURL,
|
|
audioDirectory: fixture.audioDirectory,
|
|
artworkDirectory: fixture.artworkDirectory,
|
|
apiClient: apiClient,
|
|
keychainService: fixture.keychainService,
|
|
player: player
|
|
)
|
|
}
|
|
|
|
@MainActor
|
|
private func makePersistedViewModel(
|
|
remoteLibraryFileURL: URL,
|
|
downloadStateFileURL: URL,
|
|
syncCursorFileURL: URL,
|
|
favoriteTrackFileURL: URL,
|
|
audioDirectory: URL,
|
|
artworkDirectory: URL,
|
|
apiClient: any VelodyAPIClient,
|
|
keychainService: any KeychainService,
|
|
player: any iPhoneLocalAudioPlaying
|
|
) throws -> iPhoneLibraryViewModel {
|
|
let environment = ServerEnvironment(
|
|
baseURL: ServerEnvironment.defaultLocalBaseURL,
|
|
appVersion: "Tests"
|
|
)
|
|
let repository = DefaultRemoteLibraryRepository(
|
|
apiClient: apiClient,
|
|
store: try FileRemoteLibraryStore(fileURL: remoteLibraryFileURL),
|
|
syncCursorStore: try FileRemoteLibrarySyncCursorStore(fileURL: syncCursorFileURL)
|
|
)
|
|
let audioFileStore = try FileOfflineAudioFileStore(baseDirectoryURL: audioDirectory)
|
|
let artworkStore = try FileArtworkStore(baseDirectoryURL: artworkDirectory)
|
|
let syncService = RemoteLibrarySyncService(
|
|
repository: repository,
|
|
downloadStateStore: try FileRemoteTrackDownloadStateStore(fileURL: downloadStateFileURL),
|
|
audioFileStore: audioFileStore,
|
|
artworkStore: artworkStore
|
|
)
|
|
let offlineLibraryService = OfflineLibraryService(
|
|
syncService: syncService,
|
|
audioFileStore: audioFileStore,
|
|
artworkStore: artworkStore
|
|
)
|
|
|
|
return iPhoneLibraryViewModel(
|
|
environment: environment,
|
|
apiClient: apiClient,
|
|
syncService: syncService,
|
|
offlineLibraryService: offlineLibraryService,
|
|
favoriteTrackStore: try FileFavoriteTrackStore(fileURL: favoriteTrackFileURL),
|
|
player: player,
|
|
keychainService: keychainService
|
|
)
|
|
}
|
|
|
|
private actor ScenarioVelodyAPIClient: VelodyAPIClient {
|
|
private let bootstrapResponse: SyncBootstrapResponse
|
|
private var changeResponses: [SyncChangesResponse]
|
|
private let registerError: VelodyAPIError?
|
|
private let changeError: VelodyAPIError?
|
|
private let audioAssetDataByAssetID: [String: Data]
|
|
private let artworkDataByArtworkID: [String: Data]
|
|
private var observedChangeCursors: [String] = []
|
|
|
|
init(
|
|
bootstrapResponse: SyncBootstrapResponse,
|
|
changeResponses: [SyncChangesResponse] = [],
|
|
registerError: VelodyAPIError? = nil,
|
|
changeError: VelodyAPIError? = nil,
|
|
audioAssetDataByAssetID: [String: Data] = [:],
|
|
artworkDataByArtworkID: [String: Data] = [:]
|
|
) {
|
|
self.bootstrapResponse = bootstrapResponse
|
|
self.changeResponses = changeResponses
|
|
self.registerError = registerError
|
|
self.changeError = changeError
|
|
self.audioAssetDataByAssetID = audioAssetDataByAssetID
|
|
self.artworkDataByArtworkID = artworkDataByArtworkID
|
|
}
|
|
|
|
func registerDevice(
|
|
_ payload: DeviceRegistrationPayload
|
|
) async throws -> DeviceRegistrationResponse {
|
|
_ = payload
|
|
|
|
if let registerError {
|
|
throw registerError
|
|
}
|
|
|
|
return DeviceRegistrationResponse(
|
|
deviceId: "device-offline-resilience",
|
|
deviceAccessToken: "token-offline-resilience",
|
|
bootstrapToken: "bootstrap-offline-resilience",
|
|
serverTime: bootstrapResponse.serverTime
|
|
)
|
|
}
|
|
|
|
func sendHeartbeat(
|
|
_ payload: DeviceHeartbeatPayload
|
|
) async throws -> DeviceHeartbeatResponse {
|
|
_ = payload
|
|
|
|
return DeviceHeartbeatResponse(
|
|
ok: true,
|
|
serverTime: bootstrapResponse.serverTime
|
|
)
|
|
}
|
|
|
|
func fetchSyncBootstrap() async throws -> SyncBootstrapResponse {
|
|
bootstrapResponse
|
|
}
|
|
|
|
func fetchSyncChanges(
|
|
cursor: SyncCursor
|
|
) async throws -> SyncChangesResponse {
|
|
observedChangeCursors.append(cursor.value)
|
|
|
|
if let changeError {
|
|
throw changeError
|
|
}
|
|
|
|
guard !changeResponses.isEmpty else {
|
|
return SyncChangesResponse(
|
|
nextCursor: cursor,
|
|
hasMore: false,
|
|
requiresBootstrap: false,
|
|
events: [],
|
|
serverTime: bootstrapResponse.serverTime
|
|
)
|
|
}
|
|
|
|
return changeResponses.removeFirst()
|
|
}
|
|
|
|
func fetchRemoteLibrary(
|
|
deviceId: String
|
|
) async throws -> RemoteLibraryResponseDTO {
|
|
_ = deviceId
|
|
|
|
return RemoteLibraryResponseDTO(
|
|
tracks: bootstrapResponse.tracks.map { track in
|
|
RemoteTrackDTO(
|
|
trackId: track.trackId,
|
|
title: track.title,
|
|
artist: track.artist,
|
|
durationSeconds: track.durationSeconds,
|
|
sha256: track.sha256,
|
|
assetId: track.assetId,
|
|
createdAt: track.createdAt,
|
|
updatedAt: track.updatedAt,
|
|
artwork: track.artwork.map { artwork in
|
|
RemoteArtworkDTO(
|
|
artworkId: artwork.artworkId,
|
|
sha256: artwork.sha256,
|
|
mimeType: artwork.mimeType,
|
|
width: artwork.width,
|
|
height: artwork.height
|
|
)
|
|
}
|
|
)
|
|
}
|
|
)
|
|
}
|
|
|
|
func downloadAudioAsset(
|
|
assetId: String,
|
|
deviceId: String
|
|
) async throws -> Data {
|
|
_ = deviceId
|
|
return audioAssetDataByAssetID[assetId] ?? Data()
|
|
}
|
|
|
|
func downloadArtwork(
|
|
artworkId: String,
|
|
deviceId: String
|
|
) async throws -> Data {
|
|
_ = deviceId
|
|
return artworkDataByArtworkID[artworkId] ?? Data()
|
|
}
|
|
|
|
func prepareUpload(
|
|
_ payload: UploadPrepareRequest
|
|
) async throws -> UploadPrepareResponse {
|
|
_ = payload
|
|
|
|
return UploadPrepareResponse(
|
|
status: .uploadRequired,
|
|
uploadId: UUID().uuidString,
|
|
nextOffset: 0
|
|
)
|
|
}
|
|
|
|
func fetchUploadStatus(
|
|
uploadId: String
|
|
) async throws -> UploadSessionStatusResponse {
|
|
UploadSessionStatusResponse(
|
|
uploadId: uploadId,
|
|
status: .completed,
|
|
receivedBytes: "0",
|
|
expectedSizeBytes: "0",
|
|
nextOffset: "0"
|
|
)
|
|
}
|
|
|
|
func uploadFile(
|
|
uploadId: String,
|
|
fileURL: URL,
|
|
mimeType: String
|
|
) async throws -> UploadSessionStatusResponse {
|
|
_ = fileURL
|
|
_ = mimeType
|
|
|
|
return UploadSessionStatusResponse(
|
|
uploadId: uploadId,
|
|
status: .completed,
|
|
receivedBytes: "0",
|
|
expectedSizeBytes: "0",
|
|
nextOffset: "0"
|
|
)
|
|
}
|
|
|
|
func finalizeUpload(
|
|
uploadId: String,
|
|
payload: UploadFinalizeRequest
|
|
) async throws -> UploadFinalizeResponse {
|
|
_ = payload
|
|
|
|
return UploadFinalizeResponse(
|
|
trackId: uploadId,
|
|
assetId: uploadId
|
|
)
|
|
}
|
|
|
|
func recordedChangeCursors() -> [String] {
|
|
observedChangeCursors
|
|
}
|
|
}
|
|
|
|
private func makeConsistentRemoteTrack(
|
|
trackId: String,
|
|
assetId: String,
|
|
title: String,
|
|
artwork: RemoteArtwork?
|
|
) -> RemoteTrack {
|
|
var track = makeRemoteTrack(
|
|
trackId: trackId,
|
|
assetId: assetId,
|
|
title: title,
|
|
artwork: artwork
|
|
)
|
|
track.sha256 = sha256Hex(sampleAudioData(seed: assetId))
|
|
return track
|
|
}
|
|
|
|
private func sampleAudioData(seed: String) -> Data {
|
|
Data([
|
|
0x49, 0x44, 0x33, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x21,
|
|
] + Array(seed.utf8))
|
|
}
|
|
|
|
private func sampleArtworkData(seed: String) -> Data {
|
|
Data([
|
|
0x89, 0x50, 0x4E, 0x47,
|
|
] + Array(seed.utf8))
|
|
}
|
|
|
|
private func sha256Hex(_ data: Data) -> String {
|
|
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
|
|
}
|
|
|
|
private func expectedArtworkFilePath(
|
|
in artworkDirectory: URL,
|
|
artwork: RemoteArtwork
|
|
) -> String {
|
|
artworkDirectory
|
|
.appendingPathComponent("\(artwork.artworkId).png")
|
|
.standardizedFileURL
|
|
.path
|
|
}
|