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) } }