Implement remote library metadata sync for iPhone
This commit is contained in:
parent
ebc187f4a1
commit
6e73c1878e
@ -5,31 +5,38 @@ struct iPhoneLibraryView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
List(viewModel.tracks) { track in
|
List {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
Section("Remote tracks: \(viewModel.remoteTracks.count)") {
|
||||||
Text(track.title)
|
ForEach(viewModel.remoteTracks) { track in
|
||||||
.font(.headline)
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text(track.artist)
|
Text(track.title)
|
||||||
.foregroundStyle(.secondary)
|
.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 {
|
.overlay {
|
||||||
if viewModel.tracks.isEmpty {
|
overlayView
|
||||||
ContentUnavailableView(
|
|
||||||
"No Local Tracks Yet",
|
|
||||||
systemImage: "music.note.list",
|
|
||||||
description: Text("This iPhone target currently exposes the offline catalog shell only.")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.navigationTitle("Velody")
|
.navigationTitle("Velody")
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
Button("Sync") {
|
Button("Sync Remote Library") {
|
||||||
Task {
|
Task {
|
||||||
await viewModel.refreshSync()
|
await viewModel.refreshSync()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.disabled(viewModel.state == .loading)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.safeAreaInset(edge: .bottom) {
|
.safeAreaInset(edge: .bottom) {
|
||||||
@ -45,4 +52,36 @@ struct iPhoneLibraryView: View {
|
|||||||
await viewModel.loadIfNeeded()
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,28 +4,52 @@ import VelodyDomain
|
|||||||
import VelodyNetworking
|
import VelodyNetworking
|
||||||
import VelodyPersistence
|
import VelodyPersistence
|
||||||
import VelodySync
|
import VelodySync
|
||||||
|
import VelodyUtilities
|
||||||
|
#if canImport(UIKit)
|
||||||
|
import UIKit
|
||||||
|
#endif
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
final class iPhoneLibraryViewModel {
|
final class iPhoneLibraryViewModel {
|
||||||
var tracks: [LibraryTrack] = []
|
enum ViewState: Equatable {
|
||||||
var syncStatus = "Offline library not synced yet"
|
case idle
|
||||||
|
case loading
|
||||||
|
case success
|
||||||
|
case empty
|
||||||
|
case networkError(String)
|
||||||
|
}
|
||||||
|
|
||||||
private let store: any LocalLibraryStore
|
var remoteTracks: [RemoteTrackRowViewData] = []
|
||||||
private let syncCoordinator: PlaceholderSyncCoordinator
|
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
|
private var hasLoaded = false
|
||||||
|
|
||||||
init() {
|
init(
|
||||||
let repository = Self.makeTrackRepository()
|
keychainService: any KeychainService = SystemKeychainService(
|
||||||
|
service: "de.diyaa.velody.iphone"
|
||||||
|
)
|
||||||
|
) {
|
||||||
let environment = ServerEnvironment(
|
let environment = ServerEnvironment(
|
||||||
baseURL: ServerEnvironment.defaultLocalBaseURL,
|
baseURL: ServerEnvironment.defaultLocalBaseURL,
|
||||||
appVersion: "0.1.0"
|
appVersion: "0.1.0"
|
||||||
)
|
)
|
||||||
let apiClient = StubVelodyAPIClient(environment: environment)
|
let apiClient = URLSessionVelodyAPIClient(environment: environment)
|
||||||
self.store = repository
|
let store = Self.makeRemoteLibraryStore()
|
||||||
self.syncCoordinator = PlaceholderSyncCoordinator(
|
|
||||||
apiClient: apiClient,
|
self.environment = environment
|
||||||
store: repository
|
self.apiClient = apiClient
|
||||||
|
self.keychainService = keychainService
|
||||||
|
self.syncService = RemoteLibrarySyncService(
|
||||||
|
repository: DefaultRemoteLibraryRepository(
|
||||||
|
apiClient: apiClient,
|
||||||
|
store: store
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -34,33 +58,114 @@ final class iPhoneLibraryViewModel {
|
|||||||
hasLoaded = true
|
hasLoaded = true
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let persistedTracks = try await store.loadTracks()
|
let persistedTracks = try await syncService.loadCachedRemoteTracks()
|
||||||
if !persistedTracks.isEmpty {
|
applyRestoredTracks(persistedTracks)
|
||||||
tracks = persistedTracks
|
|
||||||
syncStatus = "Loaded \(persistedTracks.count) cached track(s) from local storage."
|
|
||||||
}
|
|
||||||
} catch {
|
} 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 {
|
func refreshSync() async {
|
||||||
|
state = .loading
|
||||||
|
syncStatus = "Syncing remote library..."
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let result = try await syncCoordinator.performInitialSync()
|
let deviceId = try await currentOrRegisterDeviceID()
|
||||||
tracks = result.tracks
|
let tracks = try await syncService.syncRemoteLibrary(deviceId: deviceId)
|
||||||
syncStatus = result.statusMessage
|
applySyncedTracks(tracks)
|
||||||
} catch {
|
} 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 {
|
private func currentOrRegisterDeviceID() async throws -> String {
|
||||||
if let repository = try? SwiftDataTrackRepository() {
|
if let existingDeviceID = try await keychainService.loadValue(
|
||||||
return repository
|
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))"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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": {
|
"/api/v1/sync/bootstrap": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "SyncController_bootstrap_v1",
|
"operationId": "SyncController_bootstrap_v1",
|
||||||
@ -582,6 +613,67 @@
|
|||||||
"assetId"
|
"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": {
|
"LibraryTrackDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
26
backend/src/modules/library/library.controller.ts
Normal file
26
backend/src/modules/library/library.controller.ts
Normal file
@ -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<LibraryTracksResponseDto> {
|
||||||
|
return {
|
||||||
|
tracks: await this.libraryService.getRemoteLibraryTracks(query.deviceId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
42
backend/src/modules/library/library.dto.ts
Normal file
42
backend/src/modules/library/library.dto.ts
Normal file
@ -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[];
|
||||||
|
}
|
||||||
@ -1,10 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
|
import { LibraryController } from './library.controller';
|
||||||
import { LibraryService } from './library.service';
|
import { LibraryService } from './library.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, UsersModule],
|
imports: [PrismaModule, UsersModule],
|
||||||
|
controllers: [LibraryController],
|
||||||
providers: [LibraryService],
|
providers: [LibraryService],
|
||||||
exports: [LibraryService],
|
exports: [LibraryService],
|
||||||
})
|
})
|
||||||
|
|||||||
201
backend/src/modules/library/library.service.spec.ts
Normal file
201
backend/src/modules/library/library.service.spec.ts
Normal file
@ -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<string, any>();
|
||||||
|
const tracks = new Map<string, any>();
|
||||||
|
const audioAssets = new Map<string, any>();
|
||||||
|
|
||||||
|
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<typeof createPrismaMock>['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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,7 +1,8 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
import { LibraryTrackDto } from '../sync/sync.dto';
|
import { LibraryTrackDto } from '../sync/sync.dto';
|
||||||
import { DefaultUserService } from '../users/default-user.service';
|
import { DefaultUserService } from '../users/default-user.service';
|
||||||
|
import { RemoteLibraryTrackDto } from './library.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LibraryService {
|
export class LibraryService {
|
||||||
@ -33,4 +34,69 @@ export class LibraryService {
|
|||||||
artist: track.artist,
|
artist: track.artist,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getRemoteLibraryTracks(
|
||||||
|
deviceId: string,
|
||||||
|
): Promise<RemoteLibraryTrackDto[]> {
|
||||||
|
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(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,8 @@ import { AppModule } from '../../src/app.module';
|
|||||||
import { AppConfigService } from '../../src/modules/config/config.service';
|
import { AppConfigService } from '../../src/modules/config/config.service';
|
||||||
import { DevicesController } from '../../src/modules/devices/devices.controller';
|
import { DevicesController } from '../../src/modules/devices/devices.controller';
|
||||||
import { HealthController } from '../../src/modules/health/health.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 { SyncController } from '../../src/modules/sync/sync.controller';
|
||||||
import { UploadsController } from '../../src/modules/uploads/uploads.controller';
|
import { UploadsController } from '../../src/modules/uploads/uploads.controller';
|
||||||
import { UploadsService } from '../../src/modules/uploads/uploads.service';
|
import { UploadsService } from '../../src/modules/uploads/uploads.service';
|
||||||
@ -72,7 +74,16 @@ function createPrismaMock() {
|
|||||||
return record;
|
return record;
|
||||||
}),
|
}),
|
||||||
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
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 }) => {
|
update: jest.fn().mockImplementation(async ({ where, data }) => {
|
||||||
const current = devices.get(where.id);
|
const current = devices.get(where.id);
|
||||||
@ -91,9 +102,18 @@ function createPrismaMock() {
|
|||||||
.filter((track) => {
|
.filter((track) => {
|
||||||
const userMatches = where?.userId ? track.userId === where.userId : true;
|
const userMatches = where?.userId ? track.userId === where.userId : true;
|
||||||
const statusMatches = where?.status ? track.status === where.status : 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 }) => {
|
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
||||||
return tracks.get(where.id) ?? null;
|
return tracks.get(where.id) ?? null;
|
||||||
@ -236,6 +256,7 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
let healthController: HealthController;
|
let healthController: HealthController;
|
||||||
let devicesController: DevicesController;
|
let devicesController: DevicesController;
|
||||||
|
let libraryController: LibraryController;
|
||||||
let syncController: SyncController;
|
let syncController: SyncController;
|
||||||
let uploadsController: UploadsController;
|
let uploadsController: UploadsController;
|
||||||
let uploadsService: UploadsService;
|
let uploadsService: UploadsService;
|
||||||
@ -274,6 +295,7 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
|
|
||||||
healthController = moduleRef.get(HealthController);
|
healthController = moduleRef.get(HealthController);
|
||||||
devicesController = moduleRef.get(DevicesController);
|
devicesController = moduleRef.get(DevicesController);
|
||||||
|
libraryController = moduleRef.get(LibraryController);
|
||||||
syncController = moduleRef.get(SyncController);
|
syncController = moduleRef.get(SyncController);
|
||||||
uploadsController = moduleRef.get(UploadsController);
|
uploadsController = moduleRef.get(UploadsController);
|
||||||
uploadsService = moduleRef.get(UploadsService);
|
uploadsService = moduleRef.get(UploadsService);
|
||||||
@ -322,6 +344,142 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
expect(changesResponse.nextCursor).toBe('0');
|
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 () => {
|
it('supports the MP3 upload pipeline through the Nest app wiring', async () => {
|
||||||
const registerResponse = await devicesController.register({
|
const registerResponse = await devicesController.register({
|
||||||
platform: 'MACOS',
|
platform: 'MACOS',
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -21,5 +21,12 @@ let package = Package(
|
|||||||
name: "VelodyNetworking",
|
name: "VelodyNetworking",
|
||||||
dependencies: ["VelodyDomain"]
|
dependencies: ["VelodyDomain"]
|
||||||
),
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "VelodyNetworkingTests",
|
||||||
|
dependencies: [
|
||||||
|
"VelodyDomain",
|
||||||
|
"VelodyNetworking",
|
||||||
|
]
|
||||||
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -39,6 +39,10 @@ public protocol VelodyAPIClient: Sendable {
|
|||||||
|
|
||||||
func fetchSyncBootstrap() async throws -> SyncBootstrapResponse
|
func fetchSyncBootstrap() async throws -> SyncBootstrapResponse
|
||||||
|
|
||||||
|
func fetchRemoteLibrary(
|
||||||
|
deviceId: String
|
||||||
|
) async throws -> RemoteLibraryResponseDTO
|
||||||
|
|
||||||
func prepareUpload(
|
func prepareUpload(
|
||||||
_ payload: UploadPrepareRequest
|
_ payload: UploadPrepareRequest
|
||||||
) async throws -> UploadPrepareResponse
|
) 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(
|
public func prepareUpload(
|
||||||
_ payload: UploadPrepareRequest
|
_ payload: UploadPrepareRequest
|
||||||
) async throws -> UploadPrepareResponse {
|
) async throws -> UploadPrepareResponse {
|
||||||
@ -139,6 +156,7 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
|||||||
let request = try buildRequest(
|
let request = try buildRequest(
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
pathComponents: ["api", "v1", "uploads", uploadId, "file"],
|
pathComponents: ["api", "v1", "uploads", uploadId, "file"],
|
||||||
|
queryItems: [],
|
||||||
bodyData: nil,
|
bodyData: nil,
|
||||||
contentType: mimeType
|
contentType: mimeType
|
||||||
)
|
)
|
||||||
@ -174,11 +192,13 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
|||||||
private func sendRequest<Response: Decodable>(
|
private func sendRequest<Response: Decodable>(
|
||||||
method: String,
|
method: String,
|
||||||
pathComponents: [String],
|
pathComponents: [String],
|
||||||
|
queryItems: [URLQueryItem] = [],
|
||||||
responseType: Response.Type
|
responseType: Response.Type
|
||||||
) async throws -> Response {
|
) async throws -> Response {
|
||||||
let request = try buildRequest(
|
let request = try buildRequest(
|
||||||
method: method,
|
method: method,
|
||||||
pathComponents: pathComponents,
|
pathComponents: pathComponents,
|
||||||
|
queryItems: queryItems,
|
||||||
bodyData: nil
|
bodyData: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -188,6 +208,7 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
|||||||
private func sendRequest<Body: Encodable, Response: Decodable>(
|
private func sendRequest<Body: Encodable, Response: Decodable>(
|
||||||
method: String,
|
method: String,
|
||||||
pathComponents: [String],
|
pathComponents: [String],
|
||||||
|
queryItems: [URLQueryItem] = [],
|
||||||
body: Body,
|
body: Body,
|
||||||
responseType: Response.Type
|
responseType: Response.Type
|
||||||
) async throws -> Response {
|
) async throws -> Response {
|
||||||
@ -202,6 +223,7 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
|||||||
let request = try buildRequest(
|
let request = try buildRequest(
|
||||||
method: method,
|
method: method,
|
||||||
pathComponents: pathComponents,
|
pathComponents: pathComponents,
|
||||||
|
queryItems: queryItems,
|
||||||
bodyData: bodyData,
|
bodyData: bodyData,
|
||||||
contentType: "application/json"
|
contentType: "application/json"
|
||||||
)
|
)
|
||||||
@ -212,10 +234,14 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
|||||||
private func buildRequest(
|
private func buildRequest(
|
||||||
method: String,
|
method: String,
|
||||||
pathComponents: [String],
|
pathComponents: [String],
|
||||||
|
queryItems: [URLQueryItem],
|
||||||
bodyData: Data?,
|
bodyData: Data?,
|
||||||
contentType: String? = nil
|
contentType: String? = nil
|
||||||
) throws -> URLRequest {
|
) throws -> URLRequest {
|
||||||
guard let url = endpointURL(pathComponents: pathComponents) else {
|
guard let url = endpointURL(
|
||||||
|
pathComponents: pathComponents,
|
||||||
|
queryItems: queryItems
|
||||||
|
) else {
|
||||||
throw VelodyAPIError.invalidServerURL(environment.baseURL.absoluteString)
|
throw VelodyAPIError.invalidServerURL(environment.baseURL.absoluteString)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -284,10 +310,24 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func endpointURL(pathComponents: [String]) -> URL? {
|
private func endpointURL(
|
||||||
pathComponents.reduce(environment.baseURL) { partialURL, component in
|
pathComponents: [String],
|
||||||
|
queryItems: [URLQueryItem]
|
||||||
|
) -> URL? {
|
||||||
|
let baseURL = pathComponents.reduce(environment.baseURL) { partialURL, component in
|
||||||
partialURL.appendingPathComponent(component, isDirectory: false)
|
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(
|
public func prepareUpload(
|
||||||
_ payload: UploadPrepareRequest
|
_ payload: UploadPrepareRequest
|
||||||
) async throws -> UploadPrepareResponse {
|
) async throws -> UploadPrepareResponse {
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -27,5 +27,14 @@ let package = Package(
|
|||||||
"VelodyPersistence",
|
"VelodyPersistence",
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "VelodySyncTests",
|
||||||
|
dependencies: [
|
||||||
|
"VelodyDomain",
|
||||||
|
"VelodyNetworking",
|
||||||
|
"VelodyPersistence",
|
||||||
|
"VelodySync",
|
||||||
|
]
|
||||||
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user