From 56f030e65183cc1280f040b2b4d73ef9c7ae7d82 Mon Sep 17 00:00:00 2001 From: diyaa Date: Sat, 30 May 2026 07:11:20 +0200 Subject: [PATCH] Implement Milestone 7.2 offline audio downloads --- .../Sources/iPhoneLibraryView.swift | 106 +++++- .../Sources/iPhoneLibraryViewModel.swift | 335 +++++++++++++++++- backend/openapi/velody.openapi.json | 40 +++ backend/src/app.module.ts | 2 + .../src/modules/assets/assets.controller.ts | 39 ++ backend/src/modules/assets/assets.dto.ts | 8 + backend/src/modules/assets/assets.module.ts | 12 + .../src/modules/assets/assets.service.spec.ts | 126 +++++++ backend/src/modules/assets/assets.service.ts | 74 ++++ backend/test/e2e/app.e2e-spec.ts | 175 ++++++++- .../RemoteLibraryDownloadModels.swift | 37 ++ .../VelodyNetworking/VelodyAPIClient.swift | 53 ++- .../OfflineAudioFileStore.swift | 209 +++++++++++ .../RemoteTrackDownloadStateStore.swift | 93 +++++ .../OfflineAudioFileStoreTests.swift | 133 +++++++ .../RemoteTrackDownloadStateStoreTests.swift | 38 ++ .../VelodySync/RemoteLibraryRepository.swift | 8 + .../VelodySync/RemoteLibrarySyncService.swift | 170 ++++++++- .../RemoteLibrarySyncServiceTests.swift | 195 +++++++++- 19 files changed, 1822 insertions(+), 31 deletions(-) create mode 100644 backend/src/modules/assets/assets.controller.ts create mode 100644 backend/src/modules/assets/assets.dto.ts create mode 100644 backend/src/modules/assets/assets.module.ts create mode 100644 backend/src/modules/assets/assets.service.spec.ts create mode 100644 backend/src/modules/assets/assets.service.ts create mode 100644 packages/apple/VelodyDomain/Sources/VelodyDomain/RemoteLibraryDownloadModels.swift create mode 100644 packages/apple/VelodyPersistence/Sources/VelodyPersistence/OfflineAudioFileStore.swift create mode 100644 packages/apple/VelodyPersistence/Sources/VelodyPersistence/RemoteTrackDownloadStateStore.swift create mode 100644 packages/apple/VelodyPersistence/Tests/VelodyPersistenceTests/OfflineAudioFileStoreTests.swift create mode 100644 packages/apple/VelodyPersistence/Tests/VelodyPersistenceTests/RemoteTrackDownloadStateStoreTests.swift diff --git a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift index d2b75ff..fbeb140 100644 --- a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift +++ b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift @@ -6,13 +6,53 @@ struct iPhoneLibraryView: View { var body: some View { NavigationStack { List { + if let currentTitle = viewModel.nowPlaying.title { + Section("Now Playing") { + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 4) { + Text(currentTitle) + .font(.headline) + if let artist = viewModel.nowPlaying.artist { + Text(artist) + .foregroundStyle(.secondary) + } + Text(viewModel.nowPlaying.isPlaying ? "Playing offline" : "Paused") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + if let trackID = viewModel.nowPlaying.trackID { + Button(viewModel.nowPlaying.isPlaying ? "Pause" : "Play") { + viewModel.togglePlayback(trackID: trackID) + } + .buttonStyle(.borderedProminent) + } + } + } + } + Section("Remote tracks: \(viewModel.remoteTracks.count)") { ForEach(viewModel.remoteTracks) { track in VStack(alignment: .leading, spacing: 6) { - Text(track.title) - .font(.headline) - Text(track.artist) - .foregroundStyle(.secondary) + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text(track.title) + .font(.headline) + Text(track.artist) + .foregroundStyle(.secondary) + } + + Spacer() + + Text(track.statusText) + .font(.caption.weight(.semibold)) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(statusColor(for: track.statusText), in: Capsule()) + .foregroundStyle(.white) + } Text("Duration: \(track.durationText)") .font(.subheadline) .foregroundStyle(.secondary) @@ -20,6 +60,31 @@ struct iPhoneLibraryView: View { .font(.caption) .foregroundStyle(.tertiary) .textSelection(.enabled) + + HStack { + Button("Download") { + Task { + await viewModel.downloadTrack(trackID: track.id) + } + } + .buttonStyle(.bordered) + .disabled(!track.canDownload) + + if track.canPlay { + Button(track.playButtonTitle) { + viewModel.togglePlayback(trackID: track.id) + } + .buttonStyle(.borderedProminent) + } + } + + if let error = track.lastDownloadError, + track.statusText == "Failed" + { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } } .padding(.vertical, 4) } @@ -40,12 +105,20 @@ struct iPhoneLibraryView: View { } } .safeAreaInset(edge: .bottom) { - Text(viewModel.syncStatus) - .font(.footnote) - .foregroundStyle(.secondary) - .padding() - .frame(maxWidth: .infinity, alignment: .leading) - .background(.ultraThinMaterial) + VStack(alignment: .leading, spacing: 4) { + if let playbackError = viewModel.nowPlaying.errorMessage { + Text(playbackError) + .font(.footnote) + .foregroundStyle(.red) + } + + Text(viewModel.syncStatus) + .font(.footnote) + .foregroundStyle(.secondary) + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(.ultraThinMaterial) } } .task { @@ -84,4 +157,17 @@ struct iPhoneLibraryView: View { } } } + + private func statusColor(for status: String) -> Color { + switch status { + case "Downloading": + return .orange + case "Downloaded": + return .green + case "Failed": + return .red + default: + return .gray + } + } } diff --git a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift index 9bb637a..cc0fcde 100644 --- a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift +++ b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift @@ -6,9 +6,163 @@ import VelodyPersistence import VelodySync import VelodyUtilities #if canImport(UIKit) +import AVFoundation import UIKit #endif +@MainActor +protocol iPhoneLocalAudioPlaying: AnyObject { + var onStateChange: ((iPhoneNowPlayingState) -> Void)? { get set } + var state: iPhoneNowPlayingState { get } + + func play( + trackID: String, + title: String, + artist: String, + fileURL: URL + ) throws + func resume() throws + func pause() +} + +struct iPhoneNowPlayingState: Equatable { + var trackID: String? + var title: String? + var artist: String? + var isPlaying: Bool + var errorMessage: String? +} + +@MainActor +final class iPhoneLocalAudioPlayer: NSObject, iPhoneLocalAudioPlaying, AVAudioPlayerDelegate { + var onStateChange: ((iPhoneNowPlayingState) -> Void)? + private(set) var state = iPhoneNowPlayingState( + trackID: nil, + title: nil, + artist: nil, + isPlaying: false, + errorMessage: nil + ) { + didSet { + onStateChange?(state) + } + } + + private var audioPlayer: AVAudioPlayer? + + func play( + trackID: String, + title: String, + artist: String, + fileURL: URL + ) throws { + guard FileManager.default.fileExists(atPath: fileURL.path) else { + state = iPhoneNowPlayingState( + trackID: trackID, + title: title, + artist: artist, + isPlaying: false, + errorMessage: "The downloaded file could not be found." + ) + throw NSError( + domain: "VelodyiPhonePlayback", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "The downloaded file could not be found."] + ) + } + + do { + try configureAudioSession() + audioPlayer?.stop() + let audioPlayer = try AVAudioPlayer(contentsOf: fileURL) + audioPlayer.delegate = self + audioPlayer.prepareToPlay() + self.audioPlayer = audioPlayer + + guard audioPlayer.play() else { + state = iPhoneNowPlayingState( + trackID: trackID, + title: title, + artist: artist, + isPlaying: false, + errorMessage: "Playback could not be started." + ) + throw NSError( + domain: "VelodyiPhonePlayback", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "Playback could not be started."] + ) + } + + state = iPhoneNowPlayingState( + trackID: trackID, + title: title, + artist: artist, + isPlaying: true, + errorMessage: nil + ) + } catch { + if state.trackID == nil { + state = iPhoneNowPlayingState( + trackID: trackID, + title: title, + artist: artist, + isPlaying: false, + errorMessage: "The downloaded audio file could not be opened." + ) + } + throw error + } + } + + func resume() throws { + guard let audioPlayer else { + state.errorMessage = "No downloaded track is loaded." + throw NSError( + domain: "VelodyiPhonePlayback", + code: 3, + userInfo: [NSLocalizedDescriptionKey: "No downloaded track is loaded."] + ) + } + + guard audioPlayer.play() else { + state.errorMessage = "Playback could not be resumed." + throw NSError( + domain: "VelodyiPhonePlayback", + code: 4, + userInfo: [NSLocalizedDescriptionKey: "Playback could not be resumed."] + ) + } + + state.isPlaying = true + state.errorMessage = nil + } + + func pause() { + audioPlayer?.pause() + state.isPlaying = false + } + + nonisolated func audioPlayerDidFinishPlaying( + _ player: AVAudioPlayer, + successfully flag: Bool + ) { + guard flag else { + return + } + + Task { @MainActor [weak self] in + self?.state.isPlaying = false + } + } + + private func configureAudioSession() throws { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playback, mode: .default) + try session.setActive(true) + } +} + @MainActor @Observable final class iPhoneLibraryViewModel { @@ -23,14 +177,25 @@ final class iPhoneLibraryViewModel { var remoteTracks: [RemoteTrackRowViewData] = [] var syncStatus = "Remote library not synced yet." var state: ViewState = .idle + var nowPlaying = iPhoneNowPlayingState( + trackID: nil, + title: nil, + artist: nil, + isPlaying: false, + errorMessage: nil + ) private let environment: ServerEnvironment private let apiClient: any VelodyAPIClient private let syncService: RemoteLibrarySyncService private let keychainService: any KeychainService + private let player: any iPhoneLocalAudioPlaying + private var cachedRemoteTracks: [RemoteTrack] = [] + private var downloadStatesByTrackID: [String: RemoteTrackDownloadState] = [:] private var hasLoaded = false init( + player: (any iPhoneLocalAudioPlaying)? = nil, keychainService: any KeychainService = SystemKeychainService( service: "de.diyaa.velody.iphone" ) @@ -41,16 +206,24 @@ final class iPhoneLibraryViewModel { ) let apiClient = URLSessionVelodyAPIClient(environment: environment) let store = Self.makeRemoteLibraryStore() + let downloadStateStore = Self.makeRemoteTrackDownloadStateStore() + let audioFileStore = Self.makeOfflineAudioFileStore() self.environment = environment self.apiClient = apiClient self.keychainService = keychainService + self.player = player ?? iPhoneLocalAudioPlayer() self.syncService = RemoteLibrarySyncService( repository: DefaultRemoteLibraryRepository( apiClient: apiClient, store: store - ) + ), + downloadStateStore: downloadStateStore, + audioFileStore: audioFileStore ) + self.player.onStateChange = { [weak self] state in + self?.handleNowPlayingStateChange(state) + } } func loadIfNeeded() async { @@ -58,8 +231,10 @@ final class iPhoneLibraryViewModel { hasLoaded = true do { - let persistedTracks = try await syncService.loadCachedRemoteTracks() - applyRestoredTracks(persistedTracks) + cachedRemoteTracks = try await syncService.loadCachedRemoteTracks() + downloadStatesByTrackID = try await loadDownloadStateDictionary() + rebuildRows() + applyRestoredTracks(cachedRemoteTracks) } catch { state = .idle syncStatus = "Failed to load cached remote library: \(error.localizedDescription)" @@ -72,14 +247,89 @@ final class iPhoneLibraryViewModel { do { let deviceId = try await currentOrRegisterDeviceID() - let tracks = try await syncService.syncRemoteLibrary(deviceId: deviceId) - applySyncedTracks(tracks) + cachedRemoteTracks = try await syncService.syncRemoteLibrary(deviceId: deviceId) + downloadStatesByTrackID = try await loadDownloadStateDictionary() + rebuildRows() + applySyncedTracks(cachedRemoteTracks) } catch { state = .networkError("Remote library sync failed.") syncStatus = "Remote library sync failed: \(error.localizedDescription)" } } + func downloadTrack(trackID: String) async { + guard let track = cachedRemoteTracks.first(where: { $0.trackId == trackID }) else { + return + } + + let currentState = downloadStatesByTrackID[trackID] + if currentState?.downloadStatus == .downloaded { + return + } + + downloadStatesByTrackID[trackID] = RemoteTrackDownloadState( + remoteTrackId: track.trackId, + assetId: track.assetId, + localFilePath: currentState?.localFilePath ?? "", + downloadedAt: currentState?.downloadedAt, + downloadStatus: .downloading, + lastDownloadError: nil + ) + rebuildRows() + syncStatus = "Downloading \(track.title)..." + + do { + let deviceId = try await currentOrRegisterDeviceID() + let downloadState = try await syncService.downloadTrack(track, deviceId: deviceId) + downloadStatesByTrackID[track.trackId] = downloadState + rebuildRows() + syncStatus = "Downloaded \(track.title)." + } catch { + downloadStatesByTrackID = (try? await loadDownloadStateDictionary()) ?? downloadStatesByTrackID + rebuildRows() + syncStatus = "Download failed for \(track.title): \(error.localizedDescription)" + } + } + + func togglePlayback(trackID: String) { + guard let track = cachedRemoteTracks.first(where: { $0.trackId == trackID }) else { + return + } + + guard let downloadState = downloadStatesByTrackID[track.trackId], + downloadState.downloadStatus == .downloaded, + downloadState.hasLocalFile + else { + syncStatus = "Download the track before playing it offline." + return + } + + let fileURL = URL(fileURLWithPath: downloadState.localFilePath) + guard FileManager.default.fileExists(atPath: fileURL.path) else { + syncStatus = "The downloaded file for \(track.title) is missing." + return + } + + do { + if nowPlaying.trackID == track.trackId { + if nowPlaying.isPlaying { + player.pause() + } else { + try player.resume() + } + } else { + try player.play( + trackID: track.trackId, + title: track.title, + artist: track.artist, + fileURL: fileURL + ) + } + } catch { + syncStatus = "Playback failed for \(track.title): \(error.localizedDescription)" + } + } + private func currentOrRegisterDeviceID() async throws -> String { if let existingDeviceID = try await keychainService.loadValue( forKey: Self.deviceIDKey @@ -105,8 +355,6 @@ final class iPhoneLibraryViewModel { } private func applyRestoredTracks(_ tracks: [RemoteTrack]) { - remoteTracks = tracks.map(RemoteTrackRowViewData.init(track:)) - if tracks.isEmpty { state = .idle syncStatus = "Tap Sync Remote Library to load remote metadata." @@ -117,8 +365,6 @@ final class iPhoneLibraryViewModel { } private func applySyncedTracks(_ tracks: [RemoteTrack]) { - remoteTracks = tracks.map(RemoteTrackRowViewData.init(track:)) - if tracks.isEmpty { state = .empty syncStatus = "Remote library is empty." @@ -136,6 +382,45 @@ final class iPhoneLibraryViewModel { return InMemoryRemoteLibraryStore() } + private static func makeRemoteTrackDownloadStateStore() -> any RemoteTrackDownloadStateStore { + if let store = try? FileRemoteTrackDownloadStateStore() { + return store + } + + return InMemoryRemoteTrackDownloadStateStore() + } + + private static func makeOfflineAudioFileStore() -> any OfflineAudioFileStore { + if let store = try? FileOfflineAudioFileStore() { + return store + } + + return InMemoryOfflineAudioFileStore() + } + + private func loadDownloadStateDictionary() async throws -> [String: RemoteTrackDownloadState] { + Dictionary( + uniqueKeysWithValues: try await syncService + .loadDownloadStates() + .map { ($0.remoteTrackId, $0) } + ) + } + + private func rebuildRows() { + remoteTracks = cachedRemoteTracks.map { track in + RemoteTrackRowViewData( + track: track, + downloadState: downloadStatesByTrackID[track.trackId], + nowPlaying: nowPlaying + ) + } + } + + private func handleNowPlayingStateChange(_ state: iPhoneNowPlayingState) { + nowPlaying = state + rebuildRows() + } + #if canImport(UIKit) private static var currentDeviceName: String { UIDevice.current.name @@ -154,13 +439,30 @@ struct RemoteTrackRowViewData: Identifiable, Equatable { let artist: String let durationText: String let remoteTrackID: String + let statusText: String + let canDownload: Bool + let canPlay: Bool + let playButtonTitle: String + let lastDownloadError: String? - init(track: RemoteTrack) { + init( + track: RemoteTrack, + downloadState: RemoteTrackDownloadState?, + nowPlaying: iPhoneNowPlayingState + ) { id = track.trackId title = track.title artist = track.artist durationText = Self.formatDuration(seconds: track.durationSeconds) remoteTrackID = track.trackId + let status = downloadState?.downloadStatus ?? .notDownloaded + statusText = Self.statusText(for: status) + canDownload = status == .notDownloaded || status == .failed + canPlay = status == .downloaded + playButtonTitle = nowPlaying.trackID == track.trackId && nowPlaying.isPlaying + ? "Pause" + : "Play" + lastDownloadError = downloadState?.lastDownloadError } private static func formatDuration(seconds: Int) -> String { @@ -168,4 +470,17 @@ struct RemoteTrackRowViewData: Identifiable, Equatable { let remainingSeconds = seconds % 60 return "\(minutes):\(String(format: "%02d", remainingSeconds))" } + + private static func statusText(for status: RemoteTrackDownloadStatus) -> String { + switch status { + case .notDownloaded: + return "Not downloaded" + case .downloading: + return "Downloading" + case .downloaded: + return "Downloaded" + case .failed: + return "Failed" + } + } } diff --git a/backend/openapi/velody.openapi.json b/backend/openapi/velody.openapi.json index 5820a9d..0699b3d 100644 --- a/backend/openapi/velody.openapi.json +++ b/backend/openapi/velody.openapi.json @@ -1,6 +1,46 @@ { "openapi": "3.0.0", "paths": { + "/api/v1/assets/{assetId}/download": { + "get": { + "operationId": "AssetsController_download_v1", + "parameters": [ + { + "name": "assetId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "deviceId", + "required": true, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "audio/mpeg": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + }, + "tags": [ + "assets" + ] + } + }, "/api/v1/health": { "get": { "operationId": "HealthController_getHealth_v1", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 4fbc95b..11fce91 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { AssetsModule } from './modules/assets/assets.module'; import { AppConfigModule } from './modules/config/config.module'; import { DevicesModule } from './modules/devices/devices.module'; import { HealthModule } from './modules/health/health.module'; @@ -9,6 +10,7 @@ import { UploadsModule } from './modules/uploads/uploads.module'; @Module({ imports: [ AppConfigModule, + AssetsModule, HealthModule, DevicesModule, UploadsModule, diff --git a/backend/src/modules/assets/assets.controller.ts b/backend/src/modules/assets/assets.controller.ts new file mode 100644 index 0000000..f129012 --- /dev/null +++ b/backend/src/modules/assets/assets.controller.ts @@ -0,0 +1,39 @@ +import { Controller, Get, Param, Query, Res, StreamableFile } from '@nestjs/common'; +import type { Response } from 'express'; +import { ApiOkResponse, ApiProduces, ApiTags } from '@nestjs/swagger'; +import { createReadStream } from 'node:fs'; +import { AssetDownloadQueryDto } from './assets.dto'; +import { AssetsService } from './assets.service'; + +@ApiTags('assets') +@Controller({ + path: 'assets', + version: '1', +}) +export class AssetsController { + constructor(private readonly assetsService: AssetsService) {} + + @Get(':assetId/download') + @ApiProduces('audio/mpeg') + @ApiOkResponse({ + schema: { + type: 'string', + format: 'binary', + }, + }) + async download( + @Param('assetId') assetId: string, + @Query() query: AssetDownloadQueryDto, + @Res({ passthrough: true }) response: Response, + ): Promise { + const download = await this.assetsService.getOwnedAudioAssetDownload( + assetId, + query.deviceId, + ); + + response.setHeader('Content-Type', 'audio/mpeg'); + response.setHeader('Content-Length', String(download.contentLength)); + + return new StreamableFile(createReadStream(download.filePath)); + } +} diff --git a/backend/src/modules/assets/assets.dto.ts b/backend/src/modules/assets/assets.dto.ts new file mode 100644 index 0000000..d3bc0e4 --- /dev/null +++ b/backend/src/modules/assets/assets.dto.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsUUID } from 'class-validator'; + +export class AssetDownloadQueryDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + deviceId!: string; +} diff --git a/backend/src/modules/assets/assets.module.ts b/backend/src/modules/assets/assets.module.ts new file mode 100644 index 0000000..d4dce9e --- /dev/null +++ b/backend/src/modules/assets/assets.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../../infrastructure/database/prisma.module'; +import { StorageModule } from '../storage/storage.module'; +import { AssetsController } from './assets.controller'; +import { AssetsService } from './assets.service'; + +@Module({ + imports: [PrismaModule, StorageModule], + controllers: [AssetsController], + providers: [AssetsService], +}) +export class AssetsModule {} diff --git a/backend/src/modules/assets/assets.service.spec.ts b/backend/src/modules/assets/assets.service.spec.ts new file mode 100644 index 0000000..403a481 --- /dev/null +++ b/backend/src/modules/assets/assets.service.spec.ts @@ -0,0 +1,126 @@ +import { randomUUID } from 'node:crypto'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; +import { AppConfigService } from '../config/config.service'; +import { LocalFilesystemStorageService } from '../storage/storage.service'; +import { AssetsService } from './assets.service'; + +type MockState = ReturnType['state']; + +function createPrismaMock() { + const devices = new Map(); + const audioAssets = new Map(); + + return { + prismaMock: { + device: { + findUnique: jest.fn().mockImplementation(async ({ where }) => { + return devices.get(where.id) ?? null; + }), + }, + audioAsset: { + findUnique: jest.fn().mockImplementation(async ({ where }) => { + return audioAssets.get(where.id) ?? null; + }), + }, + } as unknown as PrismaService, + state: { + devices, + audioAssets, + }, + }; +} + +function createAppConfig(storageRoot: string): AppConfigService { + return { + maxUploadSizeBytes: 10 * 1024 * 1024, + storageRoot, + } as AppConfigService; +} + +describe('AssetsService', () => { + let service: AssetsService; + let state: MockState; + let storageRoot: string; + let storageService: LocalFilesystemStorageService; + + beforeEach(async () => { + const mock = createPrismaMock(); + state = mock.state; + storageRoot = await mkdtemp(join(tmpdir(), 'velody-assets-spec-')); + storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot)); + service = new AssetsService(mock.prismaMock, storageService); + }); + + afterEach(async () => { + await rm(storageRoot, { recursive: true, force: true }); + }); + + it('returns a local file path and content length for the owning device user', async () => { + const userId = randomUUID(); + const deviceId = randomUUID(); + const assetId = randomUUID(); + const storageKey = join('users', userId, 'audio', 'owner.mp3'); + const assetBytes = Buffer.from('ID3-owner-track', 'utf8'); + + state.devices.set(deviceId, { id: deviceId, userId }); + state.audioAssets.set(assetId, { + id: assetId, + userId, + storageKey, + }); + + const filePath = storageService.resolve(storageKey); + await storageService.ensureParentDirectory(filePath); + await writeFile(filePath, assetBytes); + + const download = await service.getOwnedAudioAssetDownload(assetId, deviceId); + + expect(download.filePath).toBe(filePath); + expect(download.contentLength).toBe(assetBytes.length); + }); + + it('rejects download attempts from a different user device', async () => { + const ownerId = randomUUID(); + const otherUserId = randomUUID(); + const ownerDeviceId = randomUUID(); + const assetId = randomUUID(); + + state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: otherUserId }); + state.audioAssets.set(assetId, { + id: assetId, + userId: ownerId, + storageKey: join('users', ownerId, 'audio', 'owner.mp3'), + }); + + await expect( + service.getOwnedAudioAssetDownload(assetId, ownerDeviceId), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('returns not found when the asset file is missing from storage', async () => { + const userId = randomUUID(); + const deviceId = randomUUID(); + const assetId = randomUUID(); + + state.devices.set(deviceId, { id: deviceId, userId }); + state.audioAssets.set(assetId, { + id: assetId, + userId, + storageKey: join('users', userId, 'audio', 'missing.mp3'), + }); + + await expect( + service.getOwnedAudioAssetDownload(assetId, deviceId), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('returns not found when the device does not exist', async () => { + await expect( + service.getOwnedAudioAssetDownload(randomUUID(), randomUUID()), + ).rejects.toBeInstanceOf(NotFoundException); + }); +}); diff --git a/backend/src/modules/assets/assets.service.ts b/backend/src/modules/assets/assets.service.ts new file mode 100644 index 0000000..b860760 --- /dev/null +++ b/backend/src/modules/assets/assets.service.ts @@ -0,0 +1,74 @@ +import { + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { stat } from 'node:fs/promises'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; +import { LocalFilesystemStorageService } from '../storage/storage.service'; + +export interface AudioAssetDownload { + filePath: string; + contentLength: number; +} + +@Injectable() +export class AssetsService { + constructor( + private readonly prismaService: PrismaService, + private readonly storageService: LocalFilesystemStorageService, + ) {} + + async getOwnedAudioAssetDownload( + assetId: string, + deviceId: string, + ): Promise { + const device = await this.prismaService.device.findUnique({ + where: { id: deviceId }, + select: { + userId: true, + }, + }); + + if (!device) { + throw new NotFoundException('Device not found'); + } + + const asset = await this.prismaService.audioAsset.findUnique({ + where: { id: assetId }, + select: { + userId: true, + storageKey: true, + }, + }); + + if (!asset) { + throw new NotFoundException('Audio asset not found'); + } + + if (asset.userId !== device.userId) { + throw new ForbiddenException('Audio asset does not belong to this device user.'); + } + + const filePath = this.storageService.resolve(asset.storageKey); + + try { + const fileStats = await stat(filePath); + + if (!fileStats.isFile()) { + throw new NotFoundException('Audio asset file not found'); + } + + return { + filePath, + contentLength: fileStats.size, + }; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + + throw new NotFoundException('Audio asset file not found'); + } + } +} diff --git a/backend/test/e2e/app.e2e-spec.ts b/backend/test/e2e/app.e2e-spec.ts index 4b77ecc..eff535f 100644 --- a/backend/test/e2e/app.e2e-spec.ts +++ b/backend/test/e2e/app.e2e-spec.ts @@ -1,11 +1,19 @@ import { randomUUID, createHash } from 'node:crypto'; -import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { Readable } from 'node:stream'; -import { INestApplication, ValidationPipe, VersioningType } from '@nestjs/common'; +import { + ForbiddenException, + INestApplication, + NotFoundException, + ValidationPipe, + VersioningType, +} from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { AppModule } from '../../src/app.module'; +import { AssetsController } from '../../src/modules/assets/assets.controller'; +import { AssetDownloadQueryDto } from '../../src/modules/assets/assets.dto'; import { AppConfigService } from '../../src/modules/config/config.service'; import { DevicesController } from '../../src/modules/devices/devices.controller'; import { HealthController } from '../../src/modules/health/health.controller'; @@ -37,6 +45,18 @@ function createUploadRequest(data: Buffer): any { return request; } +async function streamToBuffer(stream: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = []; + + for await (const chunkValue of stream) { + chunks.push( + Buffer.isBuffer(chunkValue) ? chunkValue : Buffer.from(chunkValue), + ); + } + + return Buffer.concat(chunks); +} + function createPrismaMock() { const users = new Map(); const devices = new Map(); @@ -254,6 +274,7 @@ function createPrismaMock() { describe('Velody API wiring (e2e)', () => { let app: INestApplication; + let assetsController: AssetsController; let healthController: HealthController; let devicesController: DevicesController; let libraryController: LibraryController; @@ -293,6 +314,7 @@ describe('Velody API wiring (e2e)', () => { ); await app.init(); + assetsController = moduleRef.get(AssetsController); healthController = moduleRef.get(HealthController); devicesController = moduleRef.get(DevicesController); libraryController = moduleRef.get(LibraryController); @@ -344,6 +366,153 @@ describe('Velody API wiring (e2e)', () => { expect(changesResponse.nextCursor).toBe('0'); }); + it('downloads audio asset bytes for the owning device user', async () => { + const registerResponse = await devicesController.register({ + platform: 'IPHONE', + deviceName: 'Playback iPhone', + appVersion: '0.1.0', + }); + const assetId = randomUUID(); + const trackId = randomUUID(); + const bytes = sampleMp3Bytes('owner-download'); + const storageKey = join( + 'users', + prismaState.defaultUser.id, + 'audio', + 'owner-download.mp3', + ); + + prismaState.audioAssets.set(assetId, { + id: assetId, + userId: prismaState.defaultUser.id, + trackId, + sha256: sha256Hex(bytes), + storageKey, + originalFilename: 'owner-download.mp3', + mimeType: 'audio/mpeg', + fileExtension: 'mp3', + fileSizeBytes: BigInt(bytes.length), + durationMs: 180000, + sourceDeviceId: registerResponse.deviceId, + createdAt: new Date('2026-05-29T08:00:00.000Z'), + }); + + const filePath = join(storageRoot, storageKey); + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, bytes); + + const headers = new Map(); + const responseMock = { + setHeader(name: string, value: string) { + headers.set(name.toLowerCase(), String(value)); + }, + } as any; + + const streamable = await assetsController.download( + assetId, + { deviceId: registerResponse.deviceId }, + responseMock, + ); + const downloadedBytes = await streamToBuffer(streamable.getStream()); + + expect(downloadedBytes.equals(bytes)).toBe(true); + expect(headers.get('content-type')).toBe('audio/mpeg'); + expect(headers.get('content-length')).toBe(String(bytes.length)); + }); + + it('rejects unauthorized asset download requests for another user asset', async () => { + const registerResponse = await devicesController.register({ + platform: 'IPHONE', + deviceName: 'Playback iPhone', + appVersion: '0.1.0', + }); + const assetId = randomUUID(); + const otherUserId = randomUUID(); + + prismaState.audioAssets.set(assetId, { + id: assetId, + userId: otherUserId, + trackId: randomUUID(), + sha256: 'sha-other', + storageKey: join('users', otherUserId, 'audio', 'other.mp3'), + originalFilename: 'other.mp3', + mimeType: 'audio/mpeg', + fileExtension: 'mp3', + fileSizeBytes: BigInt(10), + durationMs: 180000, + sourceDeviceId: randomUUID(), + createdAt: new Date('2026-05-29T08:00:00.000Z'), + }); + + await expect( + assetsController.download( + assetId, + { deviceId: registerResponse.deviceId }, + { setHeader() {} } as any, + ), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('handles missing audio asset files cleanly', async () => { + const registerResponse = await devicesController.register({ + platform: 'IPHONE', + deviceName: 'Playback iPhone', + appVersion: '0.1.0', + }); + const assetId = randomUUID(); + + prismaState.audioAssets.set(assetId, { + id: assetId, + userId: prismaState.defaultUser.id, + trackId: randomUUID(), + sha256: 'sha-missing-file', + storageKey: join( + 'users', + prismaState.defaultUser.id, + 'audio', + 'missing-file.mp3', + ), + originalFilename: 'missing-file.mp3', + mimeType: 'audio/mpeg', + fileExtension: 'mp3', + fileSizeBytes: BigInt(10), + durationMs: 180000, + sourceDeviceId: registerResponse.deviceId, + createdAt: new Date('2026-05-29T08:00:00.000Z'), + }); + + await expect( + assetsController.download( + assetId, + { deviceId: registerResponse.deviceId }, + { setHeader() {} } as any, + ), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('rejects an invalid asset download device id query', async () => { + const validationPipe = new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }); + + await expect( + validationPipe.transform( + { deviceId: 'not-a-uuid' }, + { + type: 'query', + metatype: AssetDownloadQueryDto, + data: '', + }, + ), + ).rejects.toMatchObject({ + response: { + message: expect.arrayContaining(['deviceId must be a UUID']), + }, + }); + }); + it('returns remote library metadata for the requesting device owner', async () => { const primaryDevice = await devicesController.register({ platform: 'IPHONE', diff --git a/packages/apple/VelodyDomain/Sources/VelodyDomain/RemoteLibraryDownloadModels.swift b/packages/apple/VelodyDomain/Sources/VelodyDomain/RemoteLibraryDownloadModels.swift new file mode 100644 index 0000000..5ae57aa --- /dev/null +++ b/packages/apple/VelodyDomain/Sources/VelodyDomain/RemoteLibraryDownloadModels.swift @@ -0,0 +1,37 @@ +import Foundation + +public enum RemoteTrackDownloadStatus: String, Codable, Hashable, Sendable, CaseIterable { + case notDownloaded + case downloading + case downloaded + case failed +} + +public struct RemoteTrackDownloadState: Codable, Hashable, Sendable { + public var remoteTrackId: String + public var assetId: String + public var localFilePath: String + public var downloadedAt: Date? + public var downloadStatus: RemoteTrackDownloadStatus + public var lastDownloadError: String? + + public init( + remoteTrackId: String, + assetId: String, + localFilePath: String = "", + downloadedAt: Date? = nil, + downloadStatus: RemoteTrackDownloadStatus, + lastDownloadError: String? = nil + ) { + self.remoteTrackId = remoteTrackId + self.assetId = assetId + self.localFilePath = localFilePath + self.downloadedAt = downloadedAt + self.downloadStatus = downloadStatus + self.lastDownloadError = lastDownloadError + } + + public var hasLocalFile: Bool { + !localFilePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } +} diff --git a/packages/apple/VelodyNetworking/Sources/VelodyNetworking/VelodyAPIClient.swift b/packages/apple/VelodyNetworking/Sources/VelodyNetworking/VelodyAPIClient.swift index e4c5da4..9b25cdf 100644 --- a/packages/apple/VelodyNetworking/Sources/VelodyNetworking/VelodyAPIClient.swift +++ b/packages/apple/VelodyNetworking/Sources/VelodyNetworking/VelodyAPIClient.swift @@ -43,6 +43,11 @@ public protocol VelodyAPIClient: Sendable { deviceId: String ) async throws -> RemoteLibraryResponseDTO + func downloadAudioAsset( + assetId: String, + deviceId: String + ) async throws -> Data + func prepareUpload( _ payload: UploadPrepareRequest ) async throws -> UploadPrepareResponse @@ -123,6 +128,23 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient { ) } + public func downloadAudioAsset( + assetId: String, + deviceId: String + ) async throws -> Data { + let request = try buildRequest( + method: "GET", + pathComponents: ["api", "v1", "assets", assetId, "download"], + queryItems: [ + URLQueryItem(name: "deviceId", value: deviceId), + ], + bodyData: nil, + acceptType: "audio/mpeg" + ) + + return try await executeData(request) + } + public func prepareUpload( _ payload: UploadPrepareRequest ) async throws -> UploadPrepareResponse { @@ -236,7 +258,8 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient { pathComponents: [String], queryItems: [URLQueryItem], bodyData: Data?, - contentType: String? = nil + contentType: String? = nil, + acceptType: String = "application/json" ) throws -> URLRequest { guard let url = endpointURL( pathComponents: pathComponents, @@ -247,7 +270,7 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient { var request = URLRequest(url: url) request.httpMethod = method - request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(acceptType, forHTTPHeaderField: "Accept") if let bodyData { request.httpBody = bodyData @@ -280,6 +303,20 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient { ) } + private func executeData(_ request: URLRequest) async throws -> Data { + let data: Data + let response: URLResponse + + do { + (data, response) = try await session.data(for: request) + } catch { + throw VelodyAPIError.requestFailed(error.localizedDescription) + } + + try validate(response: response, data: data) + return data + } + private func decodeResponse( data: Data, response: URLResponse, @@ -401,6 +438,18 @@ public struct StubVelodyAPIClient: VelodyAPIClient { ) } + public func downloadAudioAsset( + assetId: String, + deviceId: String + ) async throws -> Data { + _ = assetId + _ = deviceId + + return Data([ + 0x49, 0x44, 0x33, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x21, + ]) + } + public func prepareUpload( _ payload: UploadPrepareRequest ) async throws -> UploadPrepareResponse { diff --git a/packages/apple/VelodyPersistence/Sources/VelodyPersistence/OfflineAudioFileStore.swift b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/OfflineAudioFileStore.swift new file mode 100644 index 0000000..c354e7c --- /dev/null +++ b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/OfflineAudioFileStore.swift @@ -0,0 +1,209 @@ +import CryptoKit +import Foundation + +public enum OfflineAudioFileStoreError: LocalizedError, Equatable, Sendable { + case emptyAudioData + case sha256Mismatch(expected: String, actual: String) + case missingLocalFile(path: String) + + public var errorDescription: String? { + switch self { + case .emptyAudioData: + return "The downloaded audio file was empty." + case let .sha256Mismatch(expected, actual): + return "The downloaded audio file hash did not match. Expected \(expected), received \(actual)." + case let .missingLocalFile(path): + return "The local audio file is missing: \(path)" + } + } +} + +public protocol OfflineAudioFileStore: Actor { + func saveAudioFile( + _ data: Data, + assetId: String, + sha256: String? + ) async throws -> String + func readAudioFile(at localFilePath: String) async throws -> Data + func fileExists(at localFilePath: String) async -> Bool + func resolveLocalFilePath( + persistedLocalFilePath: String, + assetId: String + ) async -> String? +} + +public actor FileOfflineAudioFileStore: OfflineAudioFileStore { + private let baseDirectoryURL: URL + private let fileManager: FileManager + + public init( + baseDirectoryURL: URL? = nil, + fileManager: FileManager = .default + ) throws { + self.fileManager = fileManager + if let baseDirectoryURL { + self.baseDirectoryURL = baseDirectoryURL + } else { + self.baseDirectoryURL = try Self.defaultBaseDirectoryURL(fileManager: fileManager) + } + } + + public func saveAudioFile( + _ data: Data, + assetId: String, + sha256: String? + ) async throws -> String { + guard !data.isEmpty else { + throw OfflineAudioFileStoreError.emptyAudioData + } + + try fileManager.createDirectory( + at: baseDirectoryURL, + withIntermediateDirectories: true + ) + + let fileURL = localFileURL(for: assetId) + try data.write(to: fileURL, options: .atomic) + + let storedData = try Data(contentsOf: fileURL) + guard !storedData.isEmpty else { + try? fileManager.removeItem(at: fileURL) + throw OfflineAudioFileStoreError.emptyAudioData + } + + if let sha256 { + let actualHash = Self.sha256Hex(for: storedData) + if actualHash != sha256 { + try? fileManager.removeItem(at: fileURL) + throw OfflineAudioFileStoreError.sha256Mismatch( + expected: sha256, + actual: actualHash + ) + } + } + + return fileURL.standardizedFileURL.path + } + + public func readAudioFile(at localFilePath: String) async throws -> Data { + guard let resolvedLocalFilePath = await resolveLocalFilePath( + persistedLocalFilePath: localFilePath, + assetId: URL(fileURLWithPath: localFilePath).deletingPathExtension().lastPathComponent + ) else { + throw OfflineAudioFileStoreError.missingLocalFile(path: localFilePath) + } + + return try Data(contentsOf: URL(fileURLWithPath: resolvedLocalFilePath)) + } + + public func fileExists(at localFilePath: String) async -> Bool { + let resolvedLocalFilePath = URL(fileURLWithPath: localFilePath).standardizedFileURL.path + return fileManager.fileExists(atPath: resolvedLocalFilePath) + } + + public func resolveLocalFilePath( + persistedLocalFilePath: String, + assetId: String + ) async -> String? { + let trimmedPersistedPath = persistedLocalFilePath + .trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedPersistedPath.isEmpty { + let persistedURL = URL(fileURLWithPath: trimmedPersistedPath).standardizedFileURL + if fileManager.fileExists(atPath: persistedURL.path) { + return persistedURL.path + } + } + + let currentFileURL = localFileURL(for: assetId).standardizedFileURL + guard fileManager.fileExists(atPath: currentFileURL.path) else { + return nil + } + + return currentFileURL.path + } + + private static func defaultBaseDirectoryURL(fileManager: FileManager) throws -> URL { + guard let applicationSupportURL = fileManager.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first else { + throw CocoaError(.fileNoSuchFile) + } + + return applicationSupportURL + .appendingPathComponent("Velody", isDirectory: true) + .appendingPathComponent("audio", isDirectory: true) + } + + private func localFileURL(for assetId: String) -> URL { + baseDirectoryURL.appendingPathComponent("\(assetId).mp3") + } + + private static func sha256Hex(for data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } +} + +public actor InMemoryOfflineAudioFileStore: OfflineAudioFileStore { + private var files: [String: Data] + + public init(files: [String: Data] = [:]) { + self.files = files + } + + public func saveAudioFile( + _ data: Data, + assetId: String, + sha256: String? + ) async throws -> String { + guard !data.isEmpty else { + throw OfflineAudioFileStoreError.emptyAudioData + } + + if let sha256 { + let actualHash = SHA256.hash(data: data) + .map { String(format: "%02x", $0) } + .joined() + if actualHash != sha256 { + throw OfflineAudioFileStoreError.sha256Mismatch( + expected: sha256, + actual: actualHash + ) + } + } + + let localFilePath = "/in-memory/\(assetId).mp3" + files[localFilePath] = data + return localFilePath + } + + public func readAudioFile(at localFilePath: String) async throws -> Data { + guard let data = files[localFilePath] else { + throw OfflineAudioFileStoreError.missingLocalFile(path: localFilePath) + } + + return data + } + + public func fileExists(at localFilePath: String) async -> Bool { + files[localFilePath] != nil + } + + public func resolveLocalFilePath( + persistedLocalFilePath: String, + assetId: String + ) async -> String? { + let trimmedPersistedPath = persistedLocalFilePath + .trimmingCharacters(in: .whitespacesAndNewlines) + if files[trimmedPersistedPath] != nil { + return trimmedPersistedPath + } + + let fallbackLocalFilePath = "/in-memory/\(assetId).mp3" + guard files[fallbackLocalFilePath] != nil else { + return nil + } + + return fallbackLocalFilePath + } +} diff --git a/packages/apple/VelodyPersistence/Sources/VelodyPersistence/RemoteTrackDownloadStateStore.swift b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/RemoteTrackDownloadStateStore.swift new file mode 100644 index 0000000..7a40a5b --- /dev/null +++ b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/RemoteTrackDownloadStateStore.swift @@ -0,0 +1,93 @@ +import Foundation +import VelodyDomain + +public protocol RemoteTrackDownloadStateStore: Actor { + func loadDownloadStates() async throws -> [RemoteTrackDownloadState] + func saveDownloadStates(_ states: [RemoteTrackDownloadState]) async throws +} + +public extension RemoteTrackDownloadStateStore { + func saveDownloadState(_ state: RemoteTrackDownloadState) async throws { + var states = try await loadDownloadStates() + + if let existingIndex = states.firstIndex(where: { $0.remoteTrackId == state.remoteTrackId }) { + states[existingIndex] = state + } else { + states.append(state) + } + + try await saveDownloadStates(states) + } +} + +public actor FileRemoteTrackDownloadStateStore: RemoteTrackDownloadStateStore { + private let fileURL: URL + private let fileManager: FileManager + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + public init( + fileURL: URL? = nil, + fileManager: FileManager = .default + ) throws { + self.fileManager = fileManager + if let fileURL { + self.fileURL = fileURL + } else { + self.fileURL = try Self.defaultFileURL(fileManager: fileManager) + } + encoder.dateEncodingStrategy = .iso8601 + decoder.dateDecodingStrategy = .iso8601 + } + + public func loadDownloadStates() async throws -> [RemoteTrackDownloadState] { + guard fileManager.fileExists(atPath: fileURL.path) else { + return [] + } + + let data = try Data(contentsOf: fileURL) + return try decoder.decode([RemoteTrackDownloadState].self, from: data) + } + + public func saveDownloadStates(_ states: [RemoteTrackDownloadState]) async throws { + try fileManager.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + + let sortedStates = states.sorted { lhs, rhs in + lhs.remoteTrackId.localizedCaseInsensitiveCompare(rhs.remoteTrackId) == .orderedAscending + } + let data = try encoder.encode(sortedStates) + try data.write(to: fileURL, options: .atomic) + } + + private static func defaultFileURL(fileManager: FileManager) throws -> URL { + guard let applicationSupportURL = fileManager.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first else { + throw CocoaError(.fileNoSuchFile) + } + + return applicationSupportURL + .appendingPathComponent("Velody", isDirectory: true) + .appendingPathComponent("remote-download-states.json") + } +} + +public actor InMemoryRemoteTrackDownloadStateStore: RemoteTrackDownloadStateStore { + private var states: [RemoteTrackDownloadState] + + public init(states: [RemoteTrackDownloadState] = []) { + self.states = states + } + + public func loadDownloadStates() async throws -> [RemoteTrackDownloadState] { + states + } + + public func saveDownloadStates(_ states: [RemoteTrackDownloadState]) async throws { + self.states = states + } +} diff --git a/packages/apple/VelodyPersistence/Tests/VelodyPersistenceTests/OfflineAudioFileStoreTests.swift b/packages/apple/VelodyPersistence/Tests/VelodyPersistenceTests/OfflineAudioFileStoreTests.swift new file mode 100644 index 0000000..6b97674 --- /dev/null +++ b/packages/apple/VelodyPersistence/Tests/VelodyPersistenceTests/OfflineAudioFileStoreTests.swift @@ -0,0 +1,133 @@ +import CryptoKit +import Foundation +import XCTest +@testable import VelodyPersistence + +final class OfflineAudioFileStoreTests: XCTestCase { + func testFileOfflineAudioFileStoreWritesAndReadsAudioData() async throws { + let fileManager = FileManager.default + let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true + ) + + defer { + try? fileManager.removeItem(at: tempDirectory) + } + + let store = try FileOfflineAudioFileStore(baseDirectoryURL: tempDirectory) + let bytes = sampleMp3Data(seed: "offline-audio") + + let localFilePath = try await store.saveAudioFile( + bytes, + assetId: "asset-123", + sha256: sha256Hex(bytes) + ) + let storedBytes = try await store.readAudioFile(at: localFilePath) + let fileExists = await store.fileExists(at: localFilePath) + + XCTAssertEqual(storedBytes, bytes) + XCTAssertTrue(fileExists) + } + + func testFileOfflineAudioFileStoreRejectsEmptyAudioData() async throws { + let store = try FileOfflineAudioFileStore( + baseDirectoryURL: FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + ) + + await XCTAssertThrowsErrorAsync { + _ = try await store.saveAudioFile( + Data(), + assetId: "asset-123", + sha256: nil + ) + } assertion: { error in + XCTAssertEqual(error as? OfflineAudioFileStoreError, .emptyAudioData) + } + } + + func testFileOfflineAudioFileStoreRejectsShaMismatch() async throws { + let fileManager = FileManager.default + let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true + ) + + defer { + try? fileManager.removeItem(at: tempDirectory) + } + + let store = try FileOfflineAudioFileStore(baseDirectoryURL: tempDirectory) + let bytes = sampleMp3Data(seed: "sha-mismatch") + + await XCTAssertThrowsErrorAsync { + _ = try await store.saveAudioFile( + bytes, + assetId: "asset-123", + sha256: String(repeating: "f", count: 64) + ) + } assertion: { error in + guard case .sha256Mismatch = error as? OfflineAudioFileStoreError else { + return XCTFail("Expected a sha256Mismatch error.") + } + } + } + + func testFileOfflineAudioFileStoreResolvesCurrentBaseDirectoryWhenPersistedPathIsStale() 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 bytes = sampleMp3Data(seed: "path-repair") + + defer { + try? fileManager.removeItem(at: tempDirectory) + } + + let staleFilePath = firstAudioDirectory + .appendingPathComponent("asset-123.mp3") + .standardizedFileURL + .path + let secondStore = try FileOfflineAudioFileStore(baseDirectoryURL: secondAudioDirectory) + let currentFilePath = try await secondStore.saveAudioFile( + bytes, + assetId: "asset-123", + sha256: sha256Hex(bytes) + ) + + let resolvedFilePath = await secondStore.resolveLocalFilePath( + persistedLocalFilePath: staleFilePath, + assetId: "asset-123" + ) + + XCTAssertEqual(resolvedFilePath, currentFilePath) + } +} + +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, + assertion: (Error) -> Void, + file: StaticString = #filePath, + line: UInt = #line +) async { + do { + try await expression() + XCTFail("Expected expression to throw an error.", file: file, line: line) + } catch { + assertion(error) + } +} diff --git a/packages/apple/VelodyPersistence/Tests/VelodyPersistenceTests/RemoteTrackDownloadStateStoreTests.swift b/packages/apple/VelodyPersistence/Tests/VelodyPersistenceTests/RemoteTrackDownloadStateStoreTests.swift new file mode 100644 index 0000000..ffaa447 --- /dev/null +++ b/packages/apple/VelodyPersistence/Tests/VelodyPersistenceTests/RemoteTrackDownloadStateStoreTests.swift @@ -0,0 +1,38 @@ +import Foundation +import XCTest +import VelodyDomain +@testable import VelodyPersistence + +final class RemoteTrackDownloadStateStoreTests: XCTestCase { + func testFileDownloadStateStorePersistsAcrossInstances() async throws { + let fileManager = FileManager.default + let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true + ) + let fileURL = tempDirectory.appendingPathComponent("remote-download-states.json") + + defer { + try? fileManager.removeItem(at: tempDirectory) + } + + let firstStore = try FileRemoteTrackDownloadStateStore(fileURL: fileURL) + let states = [ + RemoteTrackDownloadState( + remoteTrackId: "track-123", + assetId: "asset-456", + localFilePath: "/tmp/asset-456.mp3", + downloadedAt: Date(timeIntervalSince1970: 1_000), + downloadStatus: .downloaded, + lastDownloadError: nil + ), + ] + + try await firstStore.saveDownloadStates(states) + + let secondStore = try FileRemoteTrackDownloadStateStore(fileURL: fileURL) + let restoredStates = try await secondStore.loadDownloadStates() + + XCTAssertEqual(restoredStates, states) + } +} diff --git a/packages/apple/VelodySync/Sources/VelodySync/RemoteLibraryRepository.swift b/packages/apple/VelodySync/Sources/VelodySync/RemoteLibraryRepository.swift index d6ba6bb..7a6c925 100644 --- a/packages/apple/VelodySync/Sources/VelodySync/RemoteLibraryRepository.swift +++ b/packages/apple/VelodySync/Sources/VelodySync/RemoteLibraryRepository.swift @@ -6,6 +6,7 @@ import VelodyPersistence public protocol RemoteLibraryRepository: Actor { func loadCachedRemoteTracks() async throws -> [RemoteTrack] func syncRemoteTracks(deviceId: String) async throws -> [RemoteTrack] + func downloadAudioAsset(assetId: String, deviceId: String) async throws -> Data } public actor DefaultRemoteLibraryRepository: RemoteLibraryRepository { @@ -30,4 +31,11 @@ public actor DefaultRemoteLibraryRepository: RemoteLibraryRepository { try await store.replaceRemoteTracks(tracks) return tracks } + + public func downloadAudioAsset( + assetId: String, + deviceId: String + ) async throws -> Data { + try await apiClient.downloadAudioAsset(assetId: assetId, deviceId: deviceId) + } } diff --git a/packages/apple/VelodySync/Sources/VelodySync/RemoteLibrarySyncService.swift b/packages/apple/VelodySync/Sources/VelodySync/RemoteLibrarySyncService.swift index 8103776..4b7b9cc 100644 --- a/packages/apple/VelodySync/Sources/VelodySync/RemoteLibrarySyncService.swift +++ b/packages/apple/VelodySync/Sources/VelodySync/RemoteLibrarySyncService.swift @@ -1,18 +1,184 @@ import Foundation import VelodyDomain +import VelodyPersistence public actor RemoteLibrarySyncService { private let repository: any RemoteLibraryRepository + private let downloadStateStore: any RemoteTrackDownloadStateStore + private let audioFileStore: any OfflineAudioFileStore - public init(repository: any RemoteLibraryRepository) { + public init( + repository: any RemoteLibraryRepository, + downloadStateStore: any RemoteTrackDownloadStateStore, + audioFileStore: any OfflineAudioFileStore + ) { self.repository = repository + self.downloadStateStore = downloadStateStore + self.audioFileStore = audioFileStore } public func loadCachedRemoteTracks() async throws -> [RemoteTrack] { try await repository.loadCachedRemoteTracks() } + public func loadDownloadStates() async throws -> [RemoteTrackDownloadState] { + let states = try await downloadStateStore.loadDownloadStates() + return try await reconcileDownloadedLocalFilePaths(in: states) + } + public func syncRemoteLibrary(deviceId: String) async throws -> [RemoteTrack] { - try await repository.syncRemoteTracks(deviceId: deviceId) + let tracks = try await repository.syncRemoteTracks(deviceId: deviceId) + try await ensureDownloadStates(for: tracks) + return tracks + } + + public func downloadTrack( + _ track: RemoteTrack, + deviceId: String + ) async throws -> RemoteTrackDownloadState { + let currentState = try await currentDownloadState(for: track) + + if currentState.downloadStatus == .downloaded, + currentState.assetId == track.assetId, + currentState.hasLocalFile, + await audioFileStore.fileExists(at: currentState.localFilePath) + { + return currentState + } + + let downloadingState = RemoteTrackDownloadState( + remoteTrackId: track.trackId, + assetId: track.assetId, + localFilePath: currentState.assetId == track.assetId ? currentState.localFilePath : "", + downloadedAt: currentState.assetId == track.assetId ? currentState.downloadedAt : nil, + downloadStatus: .downloading, + lastDownloadError: nil + ) + try await downloadStateStore.saveDownloadState(downloadingState) + + do { + let audioData = try await repository.downloadAudioAsset( + assetId: track.assetId, + deviceId: deviceId + ) + let localFilePath = try await audioFileStore.saveAudioFile( + audioData, + assetId: track.assetId, + sha256: track.sha256 + ) + let downloadedState = RemoteTrackDownloadState( + remoteTrackId: track.trackId, + assetId: track.assetId, + localFilePath: localFilePath, + downloadedAt: Date(), + downloadStatus: .downloaded, + lastDownloadError: nil + ) + try await downloadStateStore.saveDownloadState(downloadedState) + return downloadedState + } catch { + let failedState = RemoteTrackDownloadState( + remoteTrackId: track.trackId, + assetId: track.assetId, + localFilePath: "", + downloadedAt: nil, + downloadStatus: .failed, + lastDownloadError: error.localizedDescription + ) + try await downloadStateStore.saveDownloadState(failedState) + throw error + } + } + + private func ensureDownloadStates(for tracks: [RemoteTrack]) async throws { + guard !tracks.isEmpty else { + return + } + + var statesByTrackID = Dictionary( + uniqueKeysWithValues: try await loadDownloadStates() + .map { ($0.remoteTrackId, $0) } + ) + var didChange = false + + for track in tracks { + guard var existingState = statesByTrackID[track.trackId] else { + statesByTrackID[track.trackId] = RemoteTrackDownloadState( + remoteTrackId: track.trackId, + assetId: track.assetId, + downloadStatus: .notDownloaded + ) + didChange = true + continue + } + + if existingState.assetId != track.assetId { + existingState.assetId = track.assetId + existingState.localFilePath = "" + existingState.downloadedAt = nil + existingState.downloadStatus = .notDownloaded + existingState.lastDownloadError = nil + statesByTrackID[track.trackId] = existingState + didChange = true + } + } + + if didChange { + try await downloadStateStore.saveDownloadStates(Array(statesByTrackID.values)) + } + } + + private func currentDownloadState( + for track: RemoteTrack + ) async throws -> RemoteTrackDownloadState { + if let existingState = try await loadDownloadStates() + .first(where: { $0.remoteTrackId == track.trackId }) + { + if existingState.assetId == track.assetId { + return existingState + } + } + + return RemoteTrackDownloadState( + remoteTrackId: track.trackId, + assetId: track.assetId, + downloadStatus: .notDownloaded + ) + } + + private func reconcileDownloadedLocalFilePaths( + in states: [RemoteTrackDownloadState] + ) async throws -> [RemoteTrackDownloadState] { + guard !states.isEmpty else { + return states + } + + var reconciledStates = states + var didChange = false + + for index in reconciledStates.indices { + let state = reconciledStates[index] + guard state.downloadStatus == .downloaded else { + continue + } + + guard let resolvedLocalFilePath = await audioFileStore.resolveLocalFilePath( + persistedLocalFilePath: state.localFilePath, + assetId: state.assetId + ) else { + continue + } + + if state.localFilePath != resolvedLocalFilePath { + reconciledStates[index].localFilePath = resolvedLocalFilePath + didChange = true + } + } + + if didChange { + try await downloadStateStore.saveDownloadStates(reconciledStates) + } + + return reconciledStates } } diff --git a/packages/apple/VelodySync/Tests/VelodySyncTests/RemoteLibrarySyncServiceTests.swift b/packages/apple/VelodySync/Tests/VelodySyncTests/RemoteLibrarySyncServiceTests.swift index 56283b9..a1e2162 100644 --- a/packages/apple/VelodySync/Tests/VelodySyncTests/RemoteLibrarySyncServiceTests.swift +++ b/packages/apple/VelodySync/Tests/VelodySyncTests/RemoteLibrarySyncServiceTests.swift @@ -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,