From 6e73c1878e0126693ec3cc5869116d0099997799 Mon Sep 17 00:00:00 2001 From: diyaa Date: Fri, 29 May 2026 08:54:09 +0200 Subject: [PATCH] Implement remote library metadata sync for iPhone --- .../Sources/iPhoneLibraryView.swift | 67 ++++-- .../Sources/iPhoneLibraryViewModel.swift | 159 ++++++++++--- backend/openapi/velody.openapi.json | 92 ++++++++ .../src/modules/library/library.controller.ts | 26 +++ backend/src/modules/library/library.dto.ts | 42 ++++ backend/src/modules/library/library.module.ts | 2 + .../modules/library/library.service.spec.ts | 201 ++++++++++++++++ .../src/modules/library/library.service.ts | 68 +++++- backend/test/e2e/app.e2e-spec.ts | 164 ++++++++++++- .../VelodyDomain/RemoteLibraryModels.swift | 34 +++ packages/apple/VelodyNetworking/Package.swift | 7 + .../VelodyNetworking/RemoteLibraryDTOs.swift | 54 +++++ .../VelodyNetworking/VelodyAPIClient.swift | 67 +++++- .../RemoteLibraryDTOTests.swift | 54 +++++ .../RemoteLibraryStore.swift | 74 ++++++ .../RemoteLibraryStoreTests.swift | 40 ++++ packages/apple/VelodySync/Package.swift | 9 + .../VelodySync/RemoteLibraryRepository.swift | 33 +++ .../VelodySync/RemoteLibrarySyncService.swift | 18 ++ .../RemoteLibrarySyncServiceTests.swift | 216 ++++++++++++++++++ 20 files changed, 1379 insertions(+), 48 deletions(-) create mode 100644 backend/src/modules/library/library.controller.ts create mode 100644 backend/src/modules/library/library.dto.ts create mode 100644 backend/src/modules/library/library.service.spec.ts create mode 100644 packages/apple/VelodyDomain/Sources/VelodyDomain/RemoteLibraryModels.swift create mode 100644 packages/apple/VelodyNetworking/Sources/VelodyNetworking/RemoteLibraryDTOs.swift create mode 100644 packages/apple/VelodyNetworking/Tests/VelodyNetworkingTests/RemoteLibraryDTOTests.swift create mode 100644 packages/apple/VelodyPersistence/Sources/VelodyPersistence/RemoteLibraryStore.swift create mode 100644 packages/apple/VelodyPersistence/Tests/VelodyPersistenceTests/RemoteLibraryStoreTests.swift create mode 100644 packages/apple/VelodySync/Sources/VelodySync/RemoteLibraryRepository.swift create mode 100644 packages/apple/VelodySync/Sources/VelodySync/RemoteLibrarySyncService.swift create mode 100644 packages/apple/VelodySync/Tests/VelodySyncTests/RemoteLibrarySyncServiceTests.swift diff --git a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift index 6d77966..d2b75ff 100644 --- a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift +++ b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift @@ -5,31 +5,38 @@ struct iPhoneLibraryView: View { var body: some View { NavigationStack { - List(viewModel.tracks) { track in - VStack(alignment: .leading, spacing: 4) { - Text(track.title) - .font(.headline) - Text(track.artist) - .foregroundStyle(.secondary) + List { + 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) + Text("Duration: \(track.durationText)") + .font(.subheadline) + .foregroundStyle(.secondary) + Text("Remote track ID: \(track.remoteTrackID)") + .font(.caption) + .foregroundStyle(.tertiary) + .textSelection(.enabled) + } + .padding(.vertical, 4) + } } } .overlay { - if viewModel.tracks.isEmpty { - ContentUnavailableView( - "No Local Tracks Yet", - systemImage: "music.note.list", - description: Text("This iPhone target currently exposes the offline catalog shell only.") - ) - } + overlayView } .navigationTitle("Velody") .toolbar { ToolbarItem(placement: .topBarTrailing) { - Button("Sync") { + Button("Sync Remote Library") { Task { await viewModel.refreshSync() } } + .disabled(viewModel.state == .loading) } } .safeAreaInset(edge: .bottom) { @@ -45,4 +52,36 @@ struct iPhoneLibraryView: View { await viewModel.loadIfNeeded() } } + + @ViewBuilder + private var overlayView: some View { + switch viewModel.state { + case .idle: + if viewModel.remoteTracks.isEmpty { + ContentUnavailableView( + "No Remote Library Yet", + systemImage: "music.note.list", + description: Text("Tap Sync Remote Library to fetch metadata from the backend.") + ) + } + case .loading: + ProgressView("Syncing remote library...") + case .success: + EmptyView() + case .empty: + ContentUnavailableView( + "Empty Remote Library", + systemImage: "music.note.list", + description: Text("The backend returned no remote tracks for this iPhone.") + ) + case .networkError(let message): + if viewModel.remoteTracks.isEmpty { + ContentUnavailableView( + "Network Error", + systemImage: "wifi.exclamationmark", + description: Text(message) + ) + } + } + } } diff --git a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift index 753a3c6..9bb637a 100644 --- a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift +++ b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift @@ -4,28 +4,52 @@ import VelodyDomain import VelodyNetworking import VelodyPersistence import VelodySync +import VelodyUtilities +#if canImport(UIKit) +import UIKit +#endif @MainActor @Observable final class iPhoneLibraryViewModel { - var tracks: [LibraryTrack] = [] - var syncStatus = "Offline library not synced yet" + enum ViewState: Equatable { + case idle + case loading + case success + case empty + case networkError(String) + } - private let store: any LocalLibraryStore - private let syncCoordinator: PlaceholderSyncCoordinator + var remoteTracks: [RemoteTrackRowViewData] = [] + var syncStatus = "Remote library not synced yet." + var state: ViewState = .idle + + private let environment: ServerEnvironment + private let apiClient: any VelodyAPIClient + private let syncService: RemoteLibrarySyncService + private let keychainService: any KeychainService private var hasLoaded = false - init() { - let repository = Self.makeTrackRepository() + init( + keychainService: any KeychainService = SystemKeychainService( + service: "de.diyaa.velody.iphone" + ) + ) { let environment = ServerEnvironment( baseURL: ServerEnvironment.defaultLocalBaseURL, appVersion: "0.1.0" ) - let apiClient = StubVelodyAPIClient(environment: environment) - self.store = repository - self.syncCoordinator = PlaceholderSyncCoordinator( - apiClient: apiClient, - store: repository + let apiClient = URLSessionVelodyAPIClient(environment: environment) + let store = Self.makeRemoteLibraryStore() + + self.environment = environment + self.apiClient = apiClient + self.keychainService = keychainService + self.syncService = RemoteLibrarySyncService( + repository: DefaultRemoteLibraryRepository( + apiClient: apiClient, + store: store + ) ) } @@ -34,33 +58,114 @@ final class iPhoneLibraryViewModel { hasLoaded = true do { - let persistedTracks = try await store.loadTracks() - if !persistedTracks.isEmpty { - tracks = persistedTracks - syncStatus = "Loaded \(persistedTracks.count) cached track(s) from local storage." - } + let persistedTracks = try await syncService.loadCachedRemoteTracks() + applyRestoredTracks(persistedTracks) } catch { - syncStatus = "Failed to load cached catalog: \(error.localizedDescription)" + state = .idle + syncStatus = "Failed to load cached remote library: \(error.localizedDescription)" } - - await refreshSync() } func refreshSync() async { + state = .loading + syncStatus = "Syncing remote library..." + do { - let result = try await syncCoordinator.performInitialSync() - tracks = result.tracks - syncStatus = result.statusMessage + let deviceId = try await currentOrRegisterDeviceID() + let tracks = try await syncService.syncRemoteLibrary(deviceId: deviceId) + applySyncedTracks(tracks) } catch { - syncStatus = "Sync placeholder failed: \(error.localizedDescription)" + state = .networkError("Remote library sync failed.") + syncStatus = "Remote library sync failed: \(error.localizedDescription)" } } - private static func makeTrackRepository() -> any TrackRepository { - if let repository = try? SwiftDataTrackRepository() { - return repository + private func currentOrRegisterDeviceID() async throws -> String { + if let existingDeviceID = try await keychainService.loadValue( + forKey: Self.deviceIDKey + ), !existingDeviceID.isEmpty { + return existingDeviceID } - return InMemoryTrackRepository() + let response = try await apiClient.registerDevice( + DeviceRegistrationPayload( + platform: .iphone, + deviceName: Self.currentDeviceName, + appVersion: environment.appVersion + ) + ) + + try await keychainService.save(response.deviceId, forKey: Self.deviceIDKey) + try await keychainService.save( + response.bootstrapToken, + forKey: Self.bootstrapTokenKey + ) + + return response.deviceId + } + + 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." + } else { + state = .success + syncStatus = "Restored \(tracks.count) cached remote track(s)." + } + } + + private func applySyncedTracks(_ tracks: [RemoteTrack]) { + remoteTracks = tracks.map(RemoteTrackRowViewData.init(track:)) + + if tracks.isEmpty { + state = .empty + syncStatus = "Remote library is empty." + } else { + state = .success + syncStatus = "Sync Remote Library completed. Remote tracks: \(tracks.count)." + } + } + + private static func makeRemoteLibraryStore() -> any RemoteLibraryStore { + if let store = try? FileRemoteLibraryStore() { + return store + } + + return InMemoryRemoteLibraryStore() + } + + #if canImport(UIKit) + private static var currentDeviceName: String { + UIDevice.current.name + } + #else + private static let currentDeviceName = "Velody iPhone" + #endif + + private static let deviceIDKey = "velody.iphone.device-id" + private static let bootstrapTokenKey = "velody.iphone.bootstrap-token" +} + +struct RemoteTrackRowViewData: Identifiable, Equatable { + let id: String + let title: String + let artist: String + let durationText: String + let remoteTrackID: String + + init(track: RemoteTrack) { + id = track.trackId + title = track.title + artist = track.artist + durationText = Self.formatDuration(seconds: track.durationSeconds) + remoteTrackID = track.trackId + } + + private static func formatDuration(seconds: Int) -> String { + let minutes = seconds / 60 + let remainingSeconds = seconds % 60 + return "\(minutes):\(String(format: "%02d", remainingSeconds))" } } diff --git a/backend/openapi/velody.openapi.json b/backend/openapi/velody.openapi.json index f8827a0..5820a9d 100644 --- a/backend/openapi/velody.openapi.json +++ b/backend/openapi/velody.openapi.json @@ -238,6 +238,37 @@ ] } }, + "/api/v1/library/tracks": { + "get": { + "operationId": "LibraryController_getTracks_v1", + "parameters": [ + { + "name": "deviceId", + "required": true, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LibraryTracksResponseDto" + } + } + } + } + }, + "tags": [ + "library" + ] + } + }, "/api/v1/sync/bootstrap": { "get": { "operationId": "SyncController_bootstrap_v1", @@ -582,6 +613,67 @@ "assetId" ] }, + "RemoteLibraryTrackDto": { + "type": "object", + "properties": { + "trackId": { + "type": "string", + "format": "uuid" + }, + "title": { + "type": "string", + "example": "Track Title" + }, + "artist": { + "type": "string", + "example": "Track Artist" + }, + "durationSeconds": { + "type": "number", + "example": 245 + }, + "sha256": { + "type": "string", + "example": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "assetId": { + "type": "string", + "format": "uuid" + }, + "createdAt": { + "type": "string", + "example": "2026-05-29T08:00:00.000Z" + }, + "updatedAt": { + "type": "string", + "example": "2026-05-29T08:05:00.000Z" + } + }, + "required": [ + "trackId", + "title", + "artist", + "durationSeconds", + "sha256", + "assetId", + "createdAt", + "updatedAt" + ] + }, + "LibraryTracksResponseDto": { + "type": "object", + "properties": { + "tracks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RemoteLibraryTrackDto" + } + } + }, + "required": [ + "tracks" + ] + }, "LibraryTrackDto": { "type": "object", "properties": { diff --git a/backend/src/modules/library/library.controller.ts b/backend/src/modules/library/library.controller.ts new file mode 100644 index 0000000..049f84f --- /dev/null +++ b/backend/src/modules/library/library.controller.ts @@ -0,0 +1,26 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { + LibraryTracksQueryDto, + LibraryTracksResponseDto, +} from './library.dto'; +import { LibraryService } from './library.service'; + +@ApiTags('library') +@Controller({ + path: 'library', + version: '1', +}) +export class LibraryController { + constructor(private readonly libraryService: LibraryService) {} + + @Get('tracks') + @ApiOkResponse({ type: LibraryTracksResponseDto }) + async getTracks( + @Query() query: LibraryTracksQueryDto, + ): Promise { + return { + tracks: await this.libraryService.getRemoteLibraryTracks(query.deviceId), + }; + } +} diff --git a/backend/src/modules/library/library.dto.ts b/backend/src/modules/library/library.dto.ts new file mode 100644 index 0000000..fb1d8a4 --- /dev/null +++ b/backend/src/modules/library/library.dto.ts @@ -0,0 +1,42 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsUUID } from 'class-validator'; + +export class LibraryTracksQueryDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + deviceId!: string; +} + +export class RemoteLibraryTrackDto { + @ApiProperty({ format: 'uuid' }) + trackId!: string; + + @ApiProperty({ example: 'Track Title' }) + title!: string; + + @ApiProperty({ example: 'Track Artist' }) + artist!: string; + + @ApiProperty({ example: 245 }) + durationSeconds!: number; + + @ApiProperty({ + example: + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }) + sha256!: string; + + @ApiProperty({ format: 'uuid' }) + assetId!: string; + + @ApiProperty({ example: '2026-05-29T08:00:00.000Z' }) + createdAt!: string; + + @ApiProperty({ example: '2026-05-29T08:05:00.000Z' }) + updatedAt!: string; +} + +export class LibraryTracksResponseDto { + @ApiProperty({ type: [RemoteLibraryTrackDto] }) + tracks!: RemoteLibraryTrackDto[]; +} diff --git a/backend/src/modules/library/library.module.ts b/backend/src/modules/library/library.module.ts index 4019c03..4b3d74c 100644 --- a/backend/src/modules/library/library.module.ts +++ b/backend/src/modules/library/library.module.ts @@ -1,10 +1,12 @@ import { Module } from '@nestjs/common'; import { PrismaModule } from '../../infrastructure/database/prisma.module'; import { UsersModule } from '../users/users.module'; +import { LibraryController } from './library.controller'; import { LibraryService } from './library.service'; @Module({ imports: [PrismaModule, UsersModule], + controllers: [LibraryController], providers: [LibraryService], exports: [LibraryService], }) diff --git a/backend/src/modules/library/library.service.spec.ts b/backend/src/modules/library/library.service.spec.ts new file mode 100644 index 0000000..db90d15 --- /dev/null +++ b/backend/src/modules/library/library.service.spec.ts @@ -0,0 +1,201 @@ +import { randomUUID } from 'node:crypto'; +import { NotFoundException } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; +import { DefaultUserService } from '../users/default-user.service'; +import { LibraryService } from './library.service'; + +function createPrismaMock() { + const devices = new Map(); + const tracks = new Map(); + const audioAssets = new Map(); + + return { + prismaMock: { + device: { + findUnique: jest.fn().mockImplementation(async ({ where }) => { + const device = devices.get(where.id) ?? null; + return device ? { userId: device.userId } : null; + }), + }, + track: { + findMany: jest.fn().mockImplementation(async ({ where }) => { + return [...tracks.values()] + .filter((track) => { + const userMatches = track.userId === where.userId; + const statusMatches = track.status === where.status; + const assetMatches = where.primaryAudioAssetId?.not + ? track.primaryAudioAssetId != null + : true; + return userMatches && statusMatches && assetMatches; + }) + .sort((lhs, rhs) => lhs.createdAt.getTime() - rhs.createdAt.getTime()) + .map((track) => ({ + ...track, + primaryAudioAsset: track.primaryAudioAssetId + ? audioAssets.get(track.primaryAudioAssetId) ?? null + : null, + })); + }), + }, + }, + state: { + devices, + tracks, + audioAssets, + }, + }; +} + +describe('LibraryService', () => { + let libraryService: LibraryService; + let state: ReturnType['state']; + + beforeEach(async () => { + const { prismaMock, state: nextState } = createPrismaMock(); + state = nextState; + + const moduleRef = await Test.createTestingModule({ + providers: [ + LibraryService, + { + provide: PrismaService, + useValue: prismaMock, + }, + { + provide: DefaultUserService, + useValue: {}, + }, + ], + }).compile(); + + libraryService = moduleRef.get(LibraryService); + }); + + it('returns only tracks owned by the requesting device user in created order', async () => { + const ownerId = randomUUID(); + const otherUserId = randomUUID(); + const ownerDeviceId = randomUUID(); + const ownerTrackId = randomUUID(); + const ownerAssetId = randomUUID(); + const secondOwnerTrackId = randomUUID(); + const secondOwnerAssetId = randomUUID(); + const otherTrackId = randomUUID(); + const otherAssetId = randomUUID(); + + state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId }); + + state.audioAssets.set(ownerAssetId, { + id: ownerAssetId, + sha256: 'sha-owner-a', + durationMs: 181000, + }); + state.audioAssets.set(secondOwnerAssetId, { + id: secondOwnerAssetId, + sha256: 'sha-owner-b', + durationMs: 182000, + }); + state.audioAssets.set(otherAssetId, { + id: otherAssetId, + sha256: 'sha-other', + durationMs: 183000, + }); + + state.tracks.set(secondOwnerTrackId, { + id: secondOwnerTrackId, + userId: ownerId, + title: 'Second', + artist: 'Owner', + durationMs: 182000, + status: 'ACTIVE', + primaryAudioAssetId: secondOwnerAssetId, + createdAt: new Date('2026-05-29T08:05:00.000Z'), + updatedAt: new Date('2026-05-29T08:06:00.000Z'), + }); + state.tracks.set(ownerTrackId, { + id: ownerTrackId, + userId: ownerId, + title: 'First', + artist: 'Owner', + durationMs: 181000, + status: 'ACTIVE', + primaryAudioAssetId: ownerAssetId, + createdAt: new Date('2026-05-29T08:00:00.000Z'), + updatedAt: new Date('2026-05-29T08:01:00.000Z'), + }); + state.tracks.set(otherTrackId, { + id: otherTrackId, + userId: otherUserId, + title: 'Other', + artist: 'Elsewhere', + durationMs: 183000, + status: 'ACTIVE', + primaryAudioAssetId: otherAssetId, + createdAt: new Date('2026-05-29T07:59:00.000Z'), + updatedAt: new Date('2026-05-29T08:02:00.000Z'), + }); + + const tracks = await libraryService.getRemoteLibraryTracks(ownerDeviceId); + + expect(tracks).toEqual([ + { + trackId: ownerTrackId, + title: 'First', + artist: 'Owner', + durationSeconds: 181, + sha256: 'sha-owner-a', + assetId: ownerAssetId, + createdAt: '2026-05-29T08:00:00.000Z', + updatedAt: '2026-05-29T08:01:00.000Z', + }, + { + trackId: secondOwnerTrackId, + title: 'Second', + artist: 'Owner', + durationSeconds: 182, + sha256: 'sha-owner-b', + assetId: secondOwnerAssetId, + createdAt: '2026-05-29T08:05:00.000Z', + updatedAt: '2026-05-29T08:06:00.000Z', + }, + ]); + }); + + it('returns an empty library when the user has no remote tracks', async () => { + const ownerId = randomUUID(); + const ownerDeviceId = randomUUID(); + state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId }); + + await expect( + libraryService.getRemoteLibraryTracks(ownerDeviceId), + ).resolves.toEqual([]); + }); + + it('throws for an unknown device', async () => { + await expect( + libraryService.getRemoteLibraryTracks(randomUUID()), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('skips tracks without a primary audio asset', async () => { + const ownerId = randomUUID(); + const ownerDeviceId = randomUUID(); + state.devices.set(ownerDeviceId, { id: ownerDeviceId, userId: ownerId }); + + state.tracks.set(randomUUID(), { + id: randomUUID(), + userId: ownerId, + title: 'Incomplete', + artist: 'Owner', + durationMs: 181000, + status: 'ACTIVE', + primaryAudioAssetId: null, + createdAt: new Date('2026-05-29T08:00:00.000Z'), + updatedAt: new Date('2026-05-29T08:01:00.000Z'), + }); + + await expect( + libraryService.getRemoteLibraryTracks(ownerDeviceId), + ).resolves.toEqual([]); + }); +}); diff --git a/backend/src/modules/library/library.service.ts b/backend/src/modules/library/library.service.ts index fbe616a..f47cea8 100644 --- a/backend/src/modules/library/library.service.ts +++ b/backend/src/modules/library/library.service.ts @@ -1,7 +1,8 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../../infrastructure/database/prisma.service'; import { LibraryTrackDto } from '../sync/sync.dto'; import { DefaultUserService } from '../users/default-user.service'; +import { RemoteLibraryTrackDto } from './library.dto'; @Injectable() export class LibraryService { @@ -33,4 +34,69 @@ export class LibraryService { artist: track.artist, })); } + + async getRemoteLibraryTracks( + 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 tracks = await this.prismaService.track.findMany({ + where: { + userId: device.userId, + status: 'ACTIVE', + primaryAudioAssetId: { + not: null, + }, + }, + orderBy: { + createdAt: 'asc', + }, + select: { + id: true, + title: true, + artist: true, + durationMs: true, + createdAt: true, + updatedAt: true, + primaryAudioAsset: { + select: { + id: true, + sha256: true, + durationMs: true, + }, + }, + }, + }); + + return tracks.flatMap((track) => { + if (!track.primaryAudioAsset) { + return []; + } + + const durationMs = + track.durationMs ?? track.primaryAudioAsset.durationMs ?? 0; + + return [ + { + trackId: track.id, + title: track.title, + artist: track.artist, + durationSeconds: Math.max(0, Math.round(durationMs / 1000)), + sha256: track.primaryAudioAsset.sha256, + assetId: track.primaryAudioAsset.id, + createdAt: track.createdAt.toISOString(), + updatedAt: track.updatedAt.toISOString(), + }, + ]; + }); + } } diff --git a/backend/test/e2e/app.e2e-spec.ts b/backend/test/e2e/app.e2e-spec.ts index 471166e..4b77ecc 100644 --- a/backend/test/e2e/app.e2e-spec.ts +++ b/backend/test/e2e/app.e2e-spec.ts @@ -9,6 +9,8 @@ import { AppModule } from '../../src/app.module'; import { AppConfigService } from '../../src/modules/config/config.service'; import { DevicesController } from '../../src/modules/devices/devices.controller'; import { HealthController } from '../../src/modules/health/health.controller'; +import { LibraryController } from '../../src/modules/library/library.controller'; +import { LibraryTracksQueryDto } from '../../src/modules/library/library.dto'; import { SyncController } from '../../src/modules/sync/sync.controller'; import { UploadsController } from '../../src/modules/uploads/uploads.controller'; import { UploadsService } from '../../src/modules/uploads/uploads.service'; @@ -72,7 +74,16 @@ function createPrismaMock() { return record; }), findUnique: jest.fn().mockImplementation(async ({ where }) => { - return devices.get(where.id) ?? null; + const device = devices.get(where.id) ?? null; + if (!device) { + return null; + } + + if (where.id && typeof where.id === 'string') { + return device; + } + + return device; }), update: jest.fn().mockImplementation(async ({ where, data }) => { const current = devices.get(where.id); @@ -91,9 +102,18 @@ function createPrismaMock() { .filter((track) => { const userMatches = where?.userId ? track.userId === where.userId : true; const statusMatches = where?.status ? track.status === where.status : true; - return userMatches && statusMatches; + const assetMatches = where?.primaryAudioAssetId?.not + ? track.primaryAudioAssetId != null + : true; + return userMatches && statusMatches && assetMatches; }) - .sort((lhs, rhs) => lhs.createdAt.getTime() - rhs.createdAt.getTime()); + .sort((lhs, rhs) => lhs.createdAt.getTime() - rhs.createdAt.getTime()) + .map((track) => ({ + ...track, + primaryAudioAsset: track.primaryAudioAssetId + ? audioAssets.get(track.primaryAudioAssetId) ?? null + : null, + })); }), findUnique: jest.fn().mockImplementation(async ({ where }) => { return tracks.get(where.id) ?? null; @@ -236,6 +256,7 @@ describe('Velody API wiring (e2e)', () => { let app: INestApplication; let healthController: HealthController; let devicesController: DevicesController; + let libraryController: LibraryController; let syncController: SyncController; let uploadsController: UploadsController; let uploadsService: UploadsService; @@ -274,6 +295,7 @@ describe('Velody API wiring (e2e)', () => { healthController = moduleRef.get(HealthController); devicesController = moduleRef.get(DevicesController); + libraryController = moduleRef.get(LibraryController); syncController = moduleRef.get(SyncController); uploadsController = moduleRef.get(UploadsController); uploadsService = moduleRef.get(UploadsService); @@ -322,6 +344,142 @@ describe('Velody API wiring (e2e)', () => { expect(changesResponse.nextCursor).toBe('0'); }); + it('returns remote library metadata for the requesting device owner', async () => { + const primaryDevice = await devicesController.register({ + platform: 'IPHONE', + deviceName: 'iPhone', + appVersion: '0.1.0', + }); + const secondUserId = randomUUID(); + const secondaryDeviceId = randomUUID(); + const primaryTrackId = randomUUID(); + const primaryAssetId = randomUUID(); + const secondaryTrackId = randomUUID(); + const secondaryAssetId = randomUUID(); + + prismaState.devices.set(secondaryDeviceId, { + id: secondaryDeviceId, + userId: secondUserId, + platform: 'IPHONE', + deviceName: 'Other iPhone', + appVersion: '0.1.0', + installTokenHash: 'other-device-hash', + lastSeenAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }); + + prismaState.audioAssets.set(primaryAssetId, { + id: primaryAssetId, + userId: prismaState.defaultUser.id, + trackId: primaryTrackId, + sha256: 'sha-default', + storageKey: 'users/default/audio/sha-default.mp3', + originalFilename: 'default.mp3', + mimeType: 'audio/mpeg', + fileExtension: 'mp3', + fileSizeBytes: BigInt(42), + durationMs: 245000, + sourceDeviceId: primaryDevice.deviceId, + createdAt: new Date('2026-05-29T08:00:00.000Z'), + }); + prismaState.audioAssets.set(secondaryAssetId, { + id: secondaryAssetId, + userId: secondUserId, + trackId: secondaryTrackId, + sha256: 'sha-other-user', + storageKey: 'users/other/audio/sha-other-user.mp3', + originalFilename: 'other.mp3', + mimeType: 'audio/mpeg', + fileExtension: 'mp3', + fileSizeBytes: BigInt(24), + durationMs: 180000, + sourceDeviceId: secondaryDeviceId, + createdAt: new Date('2026-05-29T08:01:00.000Z'), + }); + + prismaState.tracks.set(primaryTrackId, { + id: primaryTrackId, + userId: prismaState.defaultUser.id, + primaryAudioAssetId: primaryAssetId, + artworkAssetId: null, + title: 'Default User Track', + artist: 'Velody', + album: null, + albumArtist: null, + genre: null, + discNumber: null, + trackNumber: null, + year: null, + durationMs: 245000, + status: 'ACTIVE', + deletedAt: null, + createdAt: new Date('2026-05-29T08:00:00.000Z'), + updatedAt: new Date('2026-05-29T08:02:00.000Z'), + }); + prismaState.tracks.set(secondaryTrackId, { + id: secondaryTrackId, + userId: secondUserId, + primaryAudioAssetId: secondaryAssetId, + artworkAssetId: null, + title: 'Other User Track', + artist: 'Elsewhere', + album: null, + albumArtist: null, + genre: null, + discNumber: null, + trackNumber: null, + year: null, + durationMs: 180000, + status: 'ACTIVE', + deletedAt: null, + createdAt: new Date('2026-05-29T08:01:00.000Z'), + updatedAt: new Date('2026-05-29T08:03:00.000Z'), + }); + + const response = await libraryController.getTracks({ + deviceId: primaryDevice.deviceId, + }); + + expect(response).toEqual({ + tracks: [ + { + trackId: primaryTrackId, + title: 'Default User Track', + artist: 'Velody', + durationSeconds: 245, + sha256: 'sha-default', + assetId: primaryAssetId, + createdAt: '2026-05-29T08:00:00.000Z', + updatedAt: '2026-05-29T08:02:00.000Z', + }, + ], + }); + }); + + it('rejects an invalid remote library 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: LibraryTracksQueryDto, + data: '', + }, + ), + ).rejects.toMatchObject({ + response: { + message: expect.arrayContaining(['deviceId must be a UUID']), + }, + }); + }); + it('supports the MP3 upload pipeline through the Nest app wiring', async () => { const registerResponse = await devicesController.register({ platform: 'MACOS', diff --git a/packages/apple/VelodyDomain/Sources/VelodyDomain/RemoteLibraryModels.swift b/packages/apple/VelodyDomain/Sources/VelodyDomain/RemoteLibraryModels.swift new file mode 100644 index 0000000..69acb81 --- /dev/null +++ b/packages/apple/VelodyDomain/Sources/VelodyDomain/RemoteLibraryModels.swift @@ -0,0 +1,34 @@ +import Foundation + +public struct RemoteTrack: Identifiable, Codable, Hashable, Sendable { + public var id: String { trackId } + + public var trackId: String + public var title: String + public var artist: String + public var durationSeconds: Int + public var sha256: String + public var assetId: String + public var createdAt: String + public var updatedAt: String + + public init( + trackId: String, + title: String, + artist: String, + durationSeconds: Int, + sha256: String, + assetId: String, + createdAt: String, + updatedAt: String + ) { + self.trackId = trackId + self.title = title + self.artist = artist + self.durationSeconds = durationSeconds + self.sha256 = sha256 + self.assetId = assetId + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} diff --git a/packages/apple/VelodyNetworking/Package.swift b/packages/apple/VelodyNetworking/Package.swift index 28bc4df..e5b87e6 100644 --- a/packages/apple/VelodyNetworking/Package.swift +++ b/packages/apple/VelodyNetworking/Package.swift @@ -21,5 +21,12 @@ let package = Package( name: "VelodyNetworking", dependencies: ["VelodyDomain"] ), + .testTarget( + name: "VelodyNetworkingTests", + dependencies: [ + "VelodyDomain", + "VelodyNetworking", + ] + ), ] ) diff --git a/packages/apple/VelodyNetworking/Sources/VelodyNetworking/RemoteLibraryDTOs.swift b/packages/apple/VelodyNetworking/Sources/VelodyNetworking/RemoteLibraryDTOs.swift new file mode 100644 index 0000000..95b86ba --- /dev/null +++ b/packages/apple/VelodyNetworking/Sources/VelodyNetworking/RemoteLibraryDTOs.swift @@ -0,0 +1,54 @@ +import Foundation +import VelodyDomain + +public struct RemoteTrackDTO: Codable, Hashable, Sendable { + public var trackId: String + public var title: String + public var artist: String + public var durationSeconds: Int + public var sha256: String + public var assetId: String + public var createdAt: String + public var updatedAt: String + + public init( + trackId: String, + title: String, + artist: String, + durationSeconds: Int, + sha256: String, + assetId: String, + createdAt: String, + updatedAt: String + ) { + self.trackId = trackId + self.title = title + self.artist = artist + self.durationSeconds = durationSeconds + self.sha256 = sha256 + self.assetId = assetId + self.createdAt = createdAt + self.updatedAt = updatedAt + } + + public var remoteTrack: RemoteTrack { + RemoteTrack( + trackId: trackId, + title: title, + artist: artist, + durationSeconds: durationSeconds, + sha256: sha256, + assetId: assetId, + createdAt: createdAt, + updatedAt: updatedAt + ) + } +} + +public struct RemoteLibraryResponseDTO: Codable, Hashable, Sendable { + public var tracks: [RemoteTrackDTO] + + public init(tracks: [RemoteTrackDTO]) { + self.tracks = tracks + } +} diff --git a/packages/apple/VelodyNetworking/Sources/VelodyNetworking/VelodyAPIClient.swift b/packages/apple/VelodyNetworking/Sources/VelodyNetworking/VelodyAPIClient.swift index fd8c191..e4c5da4 100644 --- a/packages/apple/VelodyNetworking/Sources/VelodyNetworking/VelodyAPIClient.swift +++ b/packages/apple/VelodyNetworking/Sources/VelodyNetworking/VelodyAPIClient.swift @@ -39,6 +39,10 @@ public protocol VelodyAPIClient: Sendable { func fetchSyncBootstrap() async throws -> SyncBootstrapResponse + func fetchRemoteLibrary( + deviceId: String + ) async throws -> RemoteLibraryResponseDTO + func prepareUpload( _ payload: UploadPrepareRequest ) async throws -> UploadPrepareResponse @@ -106,6 +110,19 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient { ) } + public func fetchRemoteLibrary( + deviceId: String + ) async throws -> RemoteLibraryResponseDTO { + try await sendRequest( + method: "GET", + pathComponents: ["api", "v1", "library", "tracks"], + queryItems: [ + URLQueryItem(name: "deviceId", value: deviceId), + ], + responseType: RemoteLibraryResponseDTO.self + ) + } + public func prepareUpload( _ payload: UploadPrepareRequest ) async throws -> UploadPrepareResponse { @@ -139,6 +156,7 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient { let request = try buildRequest( method: "PUT", pathComponents: ["api", "v1", "uploads", uploadId, "file"], + queryItems: [], bodyData: nil, contentType: mimeType ) @@ -174,11 +192,13 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient { private func sendRequest( method: String, pathComponents: [String], + queryItems: [URLQueryItem] = [], responseType: Response.Type ) async throws -> Response { let request = try buildRequest( method: method, pathComponents: pathComponents, + queryItems: queryItems, bodyData: nil ) @@ -188,6 +208,7 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient { private func sendRequest( method: String, pathComponents: [String], + queryItems: [URLQueryItem] = [], body: Body, responseType: Response.Type ) async throws -> Response { @@ -202,6 +223,7 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient { let request = try buildRequest( method: method, pathComponents: pathComponents, + queryItems: queryItems, bodyData: bodyData, contentType: "application/json" ) @@ -212,10 +234,14 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient { private func buildRequest( method: String, pathComponents: [String], + queryItems: [URLQueryItem], bodyData: Data?, contentType: String? = nil ) throws -> URLRequest { - guard let url = endpointURL(pathComponents: pathComponents) else { + guard let url = endpointURL( + pathComponents: pathComponents, + queryItems: queryItems + ) else { throw VelodyAPIError.invalidServerURL(environment.baseURL.absoluteString) } @@ -284,10 +310,24 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient { } } - private func endpointURL(pathComponents: [String]) -> URL? { - pathComponents.reduce(environment.baseURL) { partialURL, component in + private func endpointURL( + pathComponents: [String], + queryItems: [URLQueryItem] + ) -> URL? { + let baseURL = pathComponents.reduce(environment.baseURL) { partialURL, component in partialURL.appendingPathComponent(component, isDirectory: false) } + + guard !queryItems.isEmpty else { + return baseURL + } + + guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else { + return nil + } + + components.queryItems = queryItems + return components.url } } @@ -340,6 +380,27 @@ public struct StubVelodyAPIClient: VelodyAPIClient { ) } + public func fetchRemoteLibrary( + deviceId: String + ) async throws -> RemoteLibraryResponseDTO { + _ = deviceId + + return RemoteLibraryResponseDTO( + tracks: [ + RemoteTrackDTO( + trackId: UUID().uuidString, + title: "Velody Remote Placeholder", + artist: "Private Library", + durationSeconds: 245, + sha256: String(repeating: "a", count: 64), + assetId: UUID().uuidString, + createdAt: ISO8601DateFormatter().string(from: .now), + updatedAt: ISO8601DateFormatter().string(from: .now) + ), + ] + ) + } + public func prepareUpload( _ payload: UploadPrepareRequest ) async throws -> UploadPrepareResponse { diff --git a/packages/apple/VelodyNetworking/Tests/VelodyNetworkingTests/RemoteLibraryDTOTests.swift b/packages/apple/VelodyNetworking/Tests/VelodyNetworkingTests/RemoteLibraryDTOTests.swift new file mode 100644 index 0000000..f657c9a --- /dev/null +++ b/packages/apple/VelodyNetworking/Tests/VelodyNetworkingTests/RemoteLibraryDTOTests.swift @@ -0,0 +1,54 @@ +import Foundation +import XCTest +@testable import VelodyNetworking + +final class RemoteLibraryDTOTests: XCTestCase { + func testRemoteTrackDTOMapsToRemoteTrack() { + let dto = RemoteTrackDTO( + trackId: "track-123", + title: "Remote Title", + artist: "Remote Artist", + durationSeconds: 245, + sha256: String(repeating: "a", count: 64), + assetId: "asset-456", + createdAt: "2026-05-29T08:00:00.000Z", + updatedAt: "2026-05-29T08:05:00.000Z" + ) + + let track = dto.remoteTrack + + XCTAssertEqual(track.trackId, "track-123") + XCTAssertEqual(track.title, "Remote Title") + XCTAssertEqual(track.artist, "Remote Artist") + XCTAssertEqual(track.durationSeconds, 245) + XCTAssertEqual(track.sha256, String(repeating: "a", count: 64)) + XCTAssertEqual(track.assetId, "asset-456") + } + + func testRemoteLibraryResponseDTODecodesFromAPIResponse() throws { + let data = Data( + """ + { + "tracks": [ + { + "trackId": "track-123", + "title": "Remote Title", + "artist": "Remote Artist", + "durationSeconds": 245, + "sha256": "\(String(repeating: "a", count: 64))", + "assetId": "asset-456", + "createdAt": "2026-05-29T08:00:00.000Z", + "updatedAt": "2026-05-29T08:05:00.000Z" + } + ] + } + """.utf8 + ) + + let decoded = try JSONDecoder().decode(RemoteLibraryResponseDTO.self, from: data) + + XCTAssertEqual(decoded.tracks.count, 1) + XCTAssertEqual(decoded.tracks.first?.trackId, "track-123") + XCTAssertEqual(decoded.tracks.first?.durationSeconds, 245) + } +} diff --git a/packages/apple/VelodyPersistence/Sources/VelodyPersistence/RemoteLibraryStore.swift b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/RemoteLibraryStore.swift new file mode 100644 index 0000000..89eaaa9 --- /dev/null +++ b/packages/apple/VelodyPersistence/Sources/VelodyPersistence/RemoteLibraryStore.swift @@ -0,0 +1,74 @@ +import Foundation +import VelodyDomain + +public protocol RemoteLibraryStore: Actor { + func loadRemoteTracks() async throws -> [RemoteTrack] + func replaceRemoteTracks(_ tracks: [RemoteTrack]) async throws +} + +public actor FileRemoteLibraryStore: RemoteLibraryStore { + 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) + } + } + + public func loadRemoteTracks() async throws -> [RemoteTrack] { + guard fileManager.fileExists(atPath: fileURL.path) else { + return [] + } + + let data = try Data(contentsOf: fileURL) + return try decoder.decode([RemoteTrack].self, from: data) + } + + public func replaceRemoteTracks(_ tracks: [RemoteTrack]) async throws { + try fileManager.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + + let data = try encoder.encode(tracks) + 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-library.json") + } +} + +public actor InMemoryRemoteLibraryStore: RemoteLibraryStore { + private var tracks: [RemoteTrack] + + public init(tracks: [RemoteTrack] = []) { + self.tracks = tracks + } + + public func loadRemoteTracks() async throws -> [RemoteTrack] { + tracks + } + + public func replaceRemoteTracks(_ tracks: [RemoteTrack]) async throws { + self.tracks = tracks + } +} diff --git a/packages/apple/VelodyPersistence/Tests/VelodyPersistenceTests/RemoteLibraryStoreTests.swift b/packages/apple/VelodyPersistence/Tests/VelodyPersistenceTests/RemoteLibraryStoreTests.swift new file mode 100644 index 0000000..e429378 --- /dev/null +++ b/packages/apple/VelodyPersistence/Tests/VelodyPersistenceTests/RemoteLibraryStoreTests.swift @@ -0,0 +1,40 @@ +import Foundation +import XCTest +import VelodyDomain +@testable import VelodyPersistence + +final class RemoteLibraryStoreTests: XCTestCase { + func testFileRemoteLibraryStorePersistsTracksAcrossInstances() async throws { + let fileManager = FileManager.default + let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true + ) + let fileURL = tempDirectory.appendingPathComponent("remote-library.json") + + defer { + try? fileManager.removeItem(at: tempDirectory) + } + + let firstStore = try FileRemoteLibraryStore(fileURL: fileURL) + let tracks = [ + RemoteTrack( + trackId: "track-123", + title: "Remote Title", + artist: "Remote Artist", + durationSeconds: 245, + sha256: String(repeating: "a", count: 64), + assetId: "asset-456", + createdAt: "2026-05-29T08:00:00.000Z", + updatedAt: "2026-05-29T08:05:00.000Z" + ), + ] + + try await firstStore.replaceRemoteTracks(tracks) + + let secondStore = try FileRemoteLibraryStore(fileURL: fileURL) + let restoredTracks = try await secondStore.loadRemoteTracks() + + XCTAssertEqual(restoredTracks, tracks) + } +} diff --git a/packages/apple/VelodySync/Package.swift b/packages/apple/VelodySync/Package.swift index 75a3334..f3ea3f8 100644 --- a/packages/apple/VelodySync/Package.swift +++ b/packages/apple/VelodySync/Package.swift @@ -27,5 +27,14 @@ let package = Package( "VelodyPersistence", ] ), + .testTarget( + name: "VelodySyncTests", + dependencies: [ + "VelodyDomain", + "VelodyNetworking", + "VelodyPersistence", + "VelodySync", + ] + ), ] ) diff --git a/packages/apple/VelodySync/Sources/VelodySync/RemoteLibraryRepository.swift b/packages/apple/VelodySync/Sources/VelodySync/RemoteLibraryRepository.swift new file mode 100644 index 0000000..d6ba6bb --- /dev/null +++ b/packages/apple/VelodySync/Sources/VelodySync/RemoteLibraryRepository.swift @@ -0,0 +1,33 @@ +import Foundation +import VelodyDomain +import VelodyNetworking +import VelodyPersistence + +public protocol RemoteLibraryRepository: Actor { + func loadCachedRemoteTracks() async throws -> [RemoteTrack] + func syncRemoteTracks(deviceId: String) async throws -> [RemoteTrack] +} + +public actor DefaultRemoteLibraryRepository: RemoteLibraryRepository { + private let apiClient: any VelodyAPIClient + private let store: any RemoteLibraryStore + + public init( + apiClient: any VelodyAPIClient, + store: any RemoteLibraryStore + ) { + self.apiClient = apiClient + self.store = store + } + + public func loadCachedRemoteTracks() async throws -> [RemoteTrack] { + try await store.loadRemoteTracks() + } + + public func syncRemoteTracks(deviceId: String) async throws -> [RemoteTrack] { + let response = try await apiClient.fetchRemoteLibrary(deviceId: deviceId) + let tracks = response.tracks.map(\.remoteTrack) + try await store.replaceRemoteTracks(tracks) + return tracks + } +} diff --git a/packages/apple/VelodySync/Sources/VelodySync/RemoteLibrarySyncService.swift b/packages/apple/VelodySync/Sources/VelodySync/RemoteLibrarySyncService.swift new file mode 100644 index 0000000..8103776 --- /dev/null +++ b/packages/apple/VelodySync/Sources/VelodySync/RemoteLibrarySyncService.swift @@ -0,0 +1,18 @@ +import Foundation +import VelodyDomain + +public actor RemoteLibrarySyncService { + private let repository: any RemoteLibraryRepository + + public init(repository: any RemoteLibraryRepository) { + self.repository = repository + } + + public func loadCachedRemoteTracks() async throws -> [RemoteTrack] { + try await repository.loadCachedRemoteTracks() + } + + public func syncRemoteLibrary(deviceId: String) async throws -> [RemoteTrack] { + try await repository.syncRemoteTracks(deviceId: deviceId) + } +} diff --git a/packages/apple/VelodySync/Tests/VelodySyncTests/RemoteLibrarySyncServiceTests.swift b/packages/apple/VelodySync/Tests/VelodySyncTests/RemoteLibrarySyncServiceTests.swift new file mode 100644 index 0000000..56283b9 --- /dev/null +++ b/packages/apple/VelodySync/Tests/VelodySyncTests/RemoteLibrarySyncServiceTests.swift @@ -0,0 +1,216 @@ +import Foundation +import XCTest +import VelodyDomain +import VelodyNetworking +import VelodyPersistence +@testable import VelodySync + +final class RemoteLibrarySyncServiceTests: XCTestCase { + func testSuccessfulSyncPersistsRemoteTracks() async throws { + let store = InMemoryRemoteLibraryStore() + let service = RemoteLibrarySyncService( + repository: DefaultRemoteLibraryRepository( + apiClient: MockVelodyAPIClient( + remoteLibraryResponse: RemoteLibraryResponseDTO( + tracks: [ + RemoteTrackDTO( + trackId: "track-123", + title: "Remote Title", + artist: "Remote Artist", + durationSeconds: 245, + sha256: String(repeating: "a", count: 64), + assetId: "asset-456", + createdAt: "2026-05-29T08:00:00.000Z", + updatedAt: "2026-05-29T08:05:00.000Z" + ), + ] + ) + ), + store: store + ) + ) + + let tracks = try await service.syncRemoteLibrary(deviceId: "device-123") + let cachedTracks = try await service.loadCachedRemoteTracks() + + XCTAssertEqual(tracks.count, 1) + XCTAssertEqual(cachedTracks, tracks) + XCTAssertEqual(cachedTracks.first?.trackId, "track-123") + } + + func testEmptyResponseClearsCachedRemoteLibrary() async throws { + let store = InMemoryRemoteLibraryStore( + tracks: [ + RemoteTrack( + trackId: "track-123", + title: "Old", + artist: "Artist", + durationSeconds: 100, + sha256: String(repeating: "b", count: 64), + assetId: "asset-123", + createdAt: "2026-05-29T08:00:00.000Z", + updatedAt: "2026-05-29T08:05:00.000Z" + ), + ] + ) + let service = RemoteLibrarySyncService( + repository: DefaultRemoteLibraryRepository( + apiClient: MockVelodyAPIClient( + remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: []) + ), + store: store + ) + ) + + let tracks = try await service.syncRemoteLibrary(deviceId: "device-123") + let cachedTracks = try await service.loadCachedRemoteTracks() + + XCTAssertEqual(tracks, []) + XCTAssertEqual(cachedTracks, []) + } + + func testNetworkFailureLeavesCachedRemoteLibraryIntact() async throws { + let cachedTrack = RemoteTrack( + trackId: "track-123", + title: "Cached", + artist: "Artist", + durationSeconds: 100, + sha256: String(repeating: "c", count: 64), + assetId: "asset-123", + createdAt: "2026-05-29T08:00:00.000Z", + updatedAt: "2026-05-29T08:05:00.000Z" + ) + let store = InMemoryRemoteLibraryStore(tracks: [cachedTrack]) + let service = RemoteLibrarySyncService( + repository: DefaultRemoteLibraryRepository( + apiClient: MockVelodyAPIClient( + remoteLibraryError: VelodyAPIError.requestFailed("Offline") + ), + store: store + ) + ) + + await XCTAssertThrowsErrorAsync { + _ = try await service.syncRemoteLibrary(deviceId: "device-123") + } + + let cachedTracks = try await service.loadCachedRemoteTracks() + XCTAssertEqual(cachedTracks, [cachedTrack]) + } +} + +private struct MockVelodyAPIClient: VelodyAPIClient { + let remoteLibraryResponse: RemoteLibraryResponseDTO? + let remoteLibraryError: VelodyAPIError? + + init( + remoteLibraryResponse: RemoteLibraryResponseDTO? = nil, + remoteLibraryError: VelodyAPIError? = nil + ) { + self.remoteLibraryResponse = remoteLibraryResponse + self.remoteLibraryError = remoteLibraryError + } + + func registerDevice( + _ payload: DeviceRegistrationPayload + ) async throws -> DeviceRegistrationResponse { + _ = payload + return DeviceRegistrationResponse( + deviceId: UUID().uuidString, + bootstrapToken: UUID().uuidString, + serverTime: "2026-05-29T08:00:00.000Z" + ) + } + + func sendHeartbeat( + _ payload: DeviceHeartbeatPayload + ) async throws -> DeviceHeartbeatResponse { + _ = payload + return DeviceHeartbeatResponse( + ok: true, + serverTime: "2026-05-29T08:00:00.000Z" + ) + } + + func fetchSyncBootstrap() async throws -> SyncBootstrapResponse { + SyncBootstrapResponse( + nextCursor: SyncCursor(value: "0"), + tracks: [], + events: [], + deletedTrackIds: [], + serverTime: "2026-05-29T08:00:00.000Z" + ) + } + + func fetchRemoteLibrary( + deviceId: String + ) async throws -> RemoteLibraryResponseDTO { + _ = deviceId + + if let remoteLibraryError { + throw remoteLibraryError + } + + return remoteLibraryResponse ?? RemoteLibraryResponseDTO(tracks: []) + } + + func prepareUpload( + _ payload: UploadPrepareRequest + ) async throws -> UploadPrepareResponse { + _ = payload + return UploadPrepareResponse(status: .uploadRequired, uploadId: UUID().uuidString, nextOffset: 0) + } + + func fetchUploadStatus( + uploadId: String + ) async throws -> UploadSessionStatusResponse { + UploadSessionStatusResponse( + uploadId: uploadId, + status: .completed, + receivedBytes: "0", + expectedSizeBytes: "0", + nextOffset: "0" + ) + } + + func uploadFile( + uploadId: String, + fileURL: URL, + mimeType: String + ) async throws -> UploadSessionStatusResponse { + _ = fileURL + _ = mimeType + return UploadSessionStatusResponse( + uploadId: uploadId, + status: .completed, + receivedBytes: "0", + expectedSizeBytes: "0", + nextOffset: "0" + ) + } + + func finalizeUpload( + uploadId: String, + payload: UploadFinalizeRequest + ) async throws -> UploadFinalizeResponse { + _ = uploadId + _ = payload + return UploadFinalizeResponse( + trackId: UUID().uuidString, + assetId: UUID().uuidString + ) + } +} + +private func XCTAssertThrowsErrorAsync( + _ expression: @escaping () async throws -> Void, + file: StaticString = #filePath, + line: UInt = #line +) async { + do { + try await expression() + XCTFail("Expected expression to throw an error.", file: file, line: line) + } catch { + XCTAssertTrue(true) + } +}