Implement incremental sync and offline recovery
This commit is contained in:
@@ -280,96 +280,157 @@ public struct SyncCursor: Codable, Hashable, Sendable {
|
||||
}
|
||||
|
||||
public struct SyncEvent: Codable, Hashable, Sendable {
|
||||
public var cursor: SyncCursor
|
||||
public var entityType: String
|
||||
public var entityId: String
|
||||
public var action: String
|
||||
public var eventId: String
|
||||
public var track: RemoteTrack?
|
||||
public var deletedTrackId: String?
|
||||
public var createdAt: String
|
||||
|
||||
public init(
|
||||
cursor: SyncCursor,
|
||||
entityType: String,
|
||||
entityId: String,
|
||||
action: String,
|
||||
eventId: String
|
||||
track: RemoteTrack? = nil,
|
||||
deletedTrackId: String? = nil,
|
||||
createdAt: String
|
||||
) {
|
||||
self.cursor = cursor
|
||||
self.entityType = entityType
|
||||
self.entityId = entityId
|
||||
self.action = action
|
||||
self.eventId = eventId
|
||||
self.track = track
|
||||
self.deletedTrackId = deletedTrackId
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case cursor
|
||||
case entityType
|
||||
case entityId
|
||||
case action
|
||||
case track
|
||||
case deletedTrackId
|
||||
case createdAt
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
cursor = SyncCursor(
|
||||
value: try container.decode(String.self, forKey: .cursor)
|
||||
)
|
||||
entityType = try container.decode(String.self, forKey: .entityType)
|
||||
entityId = try container.decode(String.self, forKey: .entityId)
|
||||
action = try container.decode(String.self, forKey: .action)
|
||||
track = try container.decodeIfPresent(RemoteTrack.self, forKey: .track)
|
||||
deletedTrackId = try container.decodeIfPresent(String.self, forKey: .deletedTrackId)
|
||||
createdAt = try container.decode(String.self, forKey: .createdAt)
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(cursor.value, forKey: .cursor)
|
||||
try container.encode(entityType, forKey: .entityType)
|
||||
try container.encode(entityId, forKey: .entityId)
|
||||
try container.encode(action, forKey: .action)
|
||||
try container.encodeIfPresent(track, forKey: .track)
|
||||
try container.encodeIfPresent(deletedTrackId, forKey: .deletedTrackId)
|
||||
try container.encode(createdAt, forKey: .createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
public struct SyncBootstrapResponse: Codable, Hashable, Sendable {
|
||||
public var nextCursor: SyncCursor
|
||||
public var tracks: [LibraryTrack]
|
||||
public var events: [SyncEvent]
|
||||
public var deletedTrackIds: [String]
|
||||
public var tracks: [RemoteTrack]
|
||||
public var serverTime: String
|
||||
|
||||
public init(
|
||||
nextCursor: SyncCursor,
|
||||
tracks: [LibraryTrack],
|
||||
events: [SyncEvent],
|
||||
deletedTrackIds: [String],
|
||||
tracks: [RemoteTrack],
|
||||
serverTime: String
|
||||
) {
|
||||
self.nextCursor = nextCursor
|
||||
self.tracks = tracks
|
||||
self.events = events
|
||||
self.deletedTrackIds = deletedTrackIds
|
||||
self.serverTime = serverTime
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case nextCursor
|
||||
case tracks
|
||||
case events
|
||||
case deletedTrackIds
|
||||
case serverTime
|
||||
}
|
||||
|
||||
private struct WireTrack: Codable {
|
||||
var id: String?
|
||||
var title: String?
|
||||
var artist: String?
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let wireTracks = try container.decode([WireTrack].self, forKey: .tracks)
|
||||
|
||||
nextCursor = SyncCursor(
|
||||
value: try container.decode(String.self, forKey: .nextCursor)
|
||||
)
|
||||
tracks = wireTracks.map { track in
|
||||
LibraryTrack(
|
||||
id: track.id ?? UUID().uuidString,
|
||||
title: track.title ?? "Unknown Title",
|
||||
artist: track.artist ?? "Unknown Artist",
|
||||
album: nil,
|
||||
durationSeconds: nil,
|
||||
localFilePath: "",
|
||||
sha256: nil
|
||||
)
|
||||
}
|
||||
events = try container.decode([SyncEvent].self, forKey: .events)
|
||||
deletedTrackIds = try container.decode([String].self, forKey: .deletedTrackIds)
|
||||
tracks = try container.decode([RemoteTrack].self, forKey: .tracks)
|
||||
serverTime = try container.decode(String.self, forKey: .serverTime)
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
let wireTracks = tracks.map { track in
|
||||
WireTrack(
|
||||
id: track.id,
|
||||
title: track.title,
|
||||
artist: track.artist
|
||||
)
|
||||
}
|
||||
|
||||
try container.encode(nextCursor.value, forKey: .nextCursor)
|
||||
try container.encode(wireTracks, forKey: .tracks)
|
||||
try container.encode(tracks, forKey: .tracks)
|
||||
try container.encode(serverTime, forKey: .serverTime)
|
||||
}
|
||||
}
|
||||
|
||||
public struct SyncChangesResponse: Codable, Hashable, Sendable {
|
||||
public var nextCursor: SyncCursor
|
||||
public var hasMore: Bool
|
||||
public var requiresBootstrap: Bool
|
||||
public var reason: String?
|
||||
public var events: [SyncEvent]
|
||||
public var serverTime: String
|
||||
|
||||
public init(
|
||||
nextCursor: SyncCursor,
|
||||
hasMore: Bool,
|
||||
requiresBootstrap: Bool,
|
||||
reason: String? = nil,
|
||||
events: [SyncEvent],
|
||||
serverTime: String
|
||||
) {
|
||||
self.nextCursor = nextCursor
|
||||
self.hasMore = hasMore
|
||||
self.requiresBootstrap = requiresBootstrap
|
||||
self.reason = reason
|
||||
self.events = events
|
||||
self.serverTime = serverTime
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case nextCursor
|
||||
case hasMore
|
||||
case requiresBootstrap
|
||||
case reason
|
||||
case events
|
||||
case serverTime
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
nextCursor = SyncCursor(
|
||||
value: try container.decode(String.self, forKey: .nextCursor)
|
||||
)
|
||||
hasMore = try container.decode(Bool.self, forKey: .hasMore)
|
||||
requiresBootstrap = try container.decode(Bool.self, forKey: .requiresBootstrap)
|
||||
reason = try container.decodeIfPresent(String.self, forKey: .reason)
|
||||
events = try container.decode([SyncEvent].self, forKey: .events)
|
||||
serverTime = try container.decode(String.self, forKey: .serverTime)
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(nextCursor.value, forKey: .nextCursor)
|
||||
try container.encode(hasMore, forKey: .hasMore)
|
||||
try container.encode(requiresBootstrap, forKey: .requiresBootstrap)
|
||||
try container.encodeIfPresent(reason, forKey: .reason)
|
||||
try container.encode(events, forKey: .events)
|
||||
try container.encode(deletedTrackIds, forKey: .deletedTrackIds)
|
||||
try container.encode(serverTime, forKey: .serverTime)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,10 @@ public protocol VelodyAPIClient: Sendable {
|
||||
|
||||
func fetchSyncBootstrap() async throws -> SyncBootstrapResponse
|
||||
|
||||
func fetchSyncChanges(
|
||||
cursor: SyncCursor
|
||||
) async throws -> SyncChangesResponse
|
||||
|
||||
func fetchRemoteLibrary(
|
||||
deviceId: String
|
||||
) async throws -> RemoteLibraryResponseDTO
|
||||
@@ -127,6 +131,20 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
||||
)
|
||||
}
|
||||
|
||||
public func fetchSyncChanges(
|
||||
cursor: SyncCursor
|
||||
) async throws -> SyncChangesResponse {
|
||||
try await sendRequest(
|
||||
method: "GET",
|
||||
pathComponents: ["api", "v1", "sync", "changes"],
|
||||
queryItems: [
|
||||
URLQueryItem(name: "cursor", value: cursor.value),
|
||||
],
|
||||
includesDeviceAuthorization: true,
|
||||
responseType: SyncChangesResponse.self
|
||||
)
|
||||
}
|
||||
|
||||
public func fetchRemoteLibrary(
|
||||
deviceId: String
|
||||
) async throws -> RemoteLibraryResponseDTO {
|
||||
@@ -467,15 +485,31 @@ public struct StubVelodyAPIClient: VelodyAPIClient {
|
||||
return SyncBootstrapResponse(
|
||||
nextCursor: SyncCursor(value: "0"),
|
||||
tracks: [
|
||||
LibraryTrack(
|
||||
RemoteTrack(
|
||||
trackId: UUID().uuidString,
|
||||
title: "Velody Placeholder",
|
||||
artist: "Private Library",
|
||||
album: "Phase 1",
|
||||
localFilePath: ""
|
||||
durationSeconds: 245,
|
||||
sha256: String(repeating: "a", count: 64),
|
||||
assetId: UUID().uuidString,
|
||||
createdAt: ISO8601DateFormatter().string(from: .now),
|
||||
updatedAt: ISO8601DateFormatter().string(from: .now)
|
||||
),
|
||||
],
|
||||
serverTime: ISO8601DateFormatter().string(from: .now)
|
||||
)
|
||||
}
|
||||
|
||||
public func fetchSyncChanges(
|
||||
cursor: SyncCursor
|
||||
) async throws -> SyncChangesResponse {
|
||||
_ = cursor
|
||||
|
||||
return SyncChangesResponse(
|
||||
nextCursor: SyncCursor(value: "0"),
|
||||
hasMore: false,
|
||||
requiresBootstrap: false,
|
||||
events: [],
|
||||
deletedTrackIds: [],
|
||||
serverTime: ISO8601DateFormatter().string(from: .now)
|
||||
)
|
||||
}
|
||||
|
||||
+42
@@ -77,4 +77,46 @@ final class RemoteLibraryDTOTests: XCTestCase {
|
||||
XCTAssertEqual(decoded.tracks.first?.durationSeconds, 245)
|
||||
XCTAssertEqual(decoded.tracks.first?.artwork?.artworkId, "artwork-789")
|
||||
}
|
||||
|
||||
func testSyncChangesResponseDecodesTrackPayloadAndCursor() throws {
|
||||
let data = Data(
|
||||
"""
|
||||
{
|
||||
"nextCursor": "12",
|
||||
"hasMore": false,
|
||||
"requiresBootstrap": false,
|
||||
"events": [
|
||||
{
|
||||
"cursor": "12",
|
||||
"entityType": "TRACK",
|
||||
"entityId": "track-123",
|
||||
"action": "UPDATED",
|
||||
"track": {
|
||||
"trackId": "track-123",
|
||||
"title": "Remote Title",
|
||||
"artist": "Remote Artist",
|
||||
"durationSeconds": 245,
|
||||
"sha256": "\(String(repeating: "a", count: 64))",
|
||||
"assetId": "asset-456",
|
||||
"createdAt": "2026-06-15T12:00:00.000Z",
|
||||
"updatedAt": "2026-06-15T12:05:00.000Z",
|
||||
"artwork": null
|
||||
},
|
||||
"deletedTrackId": null,
|
||||
"createdAt": "2026-06-15T12:05:00.000Z"
|
||||
}
|
||||
],
|
||||
"serverTime": "2026-06-15T12:05:00.000Z"
|
||||
}
|
||||
""".utf8
|
||||
)
|
||||
|
||||
let decoded = try JSONDecoder().decode(SyncChangesResponse.self, from: data)
|
||||
|
||||
XCTAssertEqual(decoded.nextCursor.value, "12")
|
||||
XCTAssertFalse(decoded.hasMore)
|
||||
XCTAssertFalse(decoded.requiresBootstrap)
|
||||
XCTAssertEqual(decoded.events.first?.cursor.value, "12")
|
||||
XCTAssertEqual(decoded.events.first?.track?.trackId, "track-123")
|
||||
}
|
||||
}
|
||||
|
||||
+46
@@ -44,6 +44,52 @@ final class URLSessionVelodyAPIClientAuthorizationTests: XCTestCase {
|
||||
XCTAssertEqual(response.tracks.count, 0)
|
||||
}
|
||||
|
||||
func testFetchSyncChangesSendsAuthorizationHeaderAndCursorQuery() async throws {
|
||||
RecordingURLProtocol.handler = { request in
|
||||
XCTAssertEqual(
|
||||
request.value(forHTTPHeaderField: "Authorization"),
|
||||
"Bearer test-device-access-token"
|
||||
)
|
||||
XCTAssertEqual(request.url?.path, "/api/v1/sync/changes")
|
||||
XCTAssertEqual(request.url?.query, "cursor=12")
|
||||
|
||||
return (
|
||||
HTTPURLResponse(
|
||||
url: try XCTUnwrap(request.url),
|
||||
statusCode: 200,
|
||||
httpVersion: nil,
|
||||
headerFields: ["Content-Type": "application/json"]
|
||||
)!,
|
||||
Data(
|
||||
"""
|
||||
{
|
||||
"nextCursor": "12",
|
||||
"hasMore": false,
|
||||
"requiresBootstrap": false,
|
||||
"events": [],
|
||||
"serverTime": "2026-06-15T12:00:00.000Z"
|
||||
}
|
||||
""".utf8
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
let client = URLSessionVelodyAPIClient(
|
||||
environment: ServerEnvironment(
|
||||
baseURL: URL(string: "http://127.0.0.1:3007")!,
|
||||
appVersion: "Tests"
|
||||
),
|
||||
session: makeSession(),
|
||||
deviceAccessTokenProvider: {
|
||||
"test-device-access-token"
|
||||
}
|
||||
)
|
||||
|
||||
let response = try await client.fetchSyncChanges(cursor: SyncCursor(value: "12"))
|
||||
XCTAssertEqual(response.nextCursor.value, "12")
|
||||
XCTAssertEqual(response.events.count, 0)
|
||||
}
|
||||
|
||||
func testRegisterDeviceDoesNotSendAuthorizationHeader() async throws {
|
||||
RecordingURLProtocol.handler = { request in
|
||||
XCTAssertNil(request.value(forHTTPHeaderField: "Authorization"))
|
||||
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
import Foundation
|
||||
import VelodyDomain
|
||||
|
||||
public protocol RemoteLibrarySyncCursorStore: Actor {
|
||||
func loadCursor() async throws -> SyncCursor?
|
||||
func saveCursor(_ cursor: SyncCursor) async throws
|
||||
func clearCursor() async throws
|
||||
}
|
||||
|
||||
private struct StoredRemoteLibrarySyncCursor: Codable {
|
||||
var value: String
|
||||
}
|
||||
|
||||
public actor FileRemoteLibrarySyncCursorStore: RemoteLibrarySyncCursorStore {
|
||||
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 loadCursor() async throws -> SyncCursor? {
|
||||
guard fileManager.fileExists(atPath: fileURL.path) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let data = try Data(contentsOf: fileURL)
|
||||
let storedCursor = try decoder.decode(StoredRemoteLibrarySyncCursor.self, from: data)
|
||||
return SyncCursor(value: storedCursor.value)
|
||||
}
|
||||
|
||||
public func saveCursor(_ cursor: SyncCursor) async throws {
|
||||
try fileManager.createDirectory(
|
||||
at: fileURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
|
||||
let data = try encoder.encode(
|
||||
StoredRemoteLibrarySyncCursor(value: cursor.value)
|
||||
)
|
||||
try data.write(to: fileURL, options: .atomic)
|
||||
}
|
||||
|
||||
public func clearCursor() async throws {
|
||||
guard fileManager.fileExists(atPath: fileURL.path) else {
|
||||
return
|
||||
}
|
||||
|
||||
try fileManager.removeItem(at: fileURL)
|
||||
}
|
||||
|
||||
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-sync-cursor.json")
|
||||
}
|
||||
}
|
||||
|
||||
public actor InMemoryRemoteLibrarySyncCursorStore: RemoteLibrarySyncCursorStore {
|
||||
private var cursor: SyncCursor?
|
||||
|
||||
public init(cursor: SyncCursor? = nil) {
|
||||
self.cursor = cursor
|
||||
}
|
||||
|
||||
public func loadCursor() async throws -> SyncCursor? {
|
||||
cursor
|
||||
}
|
||||
|
||||
public func saveCursor(_ cursor: SyncCursor) async throws {
|
||||
self.cursor = cursor
|
||||
}
|
||||
|
||||
public func clearCursor() async throws {
|
||||
cursor = nil
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,18 @@ public actor PlaceholderSyncCoordinator: SyncCoordinator {
|
||||
|
||||
public func performInitialSync() async throws -> SyncResult {
|
||||
let bootstrap = try await apiClient.fetchSyncBootstrap()
|
||||
try await store.replaceTracks(bootstrap.tracks)
|
||||
try await store.replaceTracks(
|
||||
bootstrap.tracks.map { track in
|
||||
LibraryTrack(
|
||||
id: track.trackId,
|
||||
title: track.title,
|
||||
artist: track.artist,
|
||||
durationSeconds: Double(track.durationSeconds),
|
||||
localFilePath: "",
|
||||
sha256: track.sha256
|
||||
)
|
||||
}
|
||||
)
|
||||
let persistedTracks = try await store.loadTracks()
|
||||
|
||||
return SyncResult(
|
||||
|
||||
@@ -13,13 +13,16 @@ public protocol RemoteLibraryRepository: Actor {
|
||||
public actor DefaultRemoteLibraryRepository: RemoteLibraryRepository {
|
||||
private let apiClient: any VelodyAPIClient
|
||||
private let store: any RemoteLibraryStore
|
||||
private let syncCursorStore: any RemoteLibrarySyncCursorStore
|
||||
|
||||
public init(
|
||||
apiClient: any VelodyAPIClient,
|
||||
store: any RemoteLibraryStore
|
||||
store: any RemoteLibraryStore,
|
||||
syncCursorStore: any RemoteLibrarySyncCursorStore
|
||||
) {
|
||||
self.apiClient = apiClient
|
||||
self.store = store
|
||||
self.syncCursorStore = syncCursorStore
|
||||
}
|
||||
|
||||
public func loadCachedRemoteTracks() async throws -> [RemoteTrack] {
|
||||
@@ -27,10 +30,13 @@ public actor DefaultRemoteLibraryRepository: RemoteLibraryRepository {
|
||||
}
|
||||
|
||||
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
|
||||
_ = deviceId
|
||||
|
||||
if let currentCursor = try await syncCursorStore.loadCursor() {
|
||||
return try await syncIncrementally(from: currentCursor)
|
||||
}
|
||||
|
||||
return try await bootstrap()
|
||||
}
|
||||
|
||||
public func downloadAudioAsset(
|
||||
@@ -46,4 +52,74 @@ public actor DefaultRemoteLibraryRepository: RemoteLibraryRepository {
|
||||
) async throws -> Data {
|
||||
try await apiClient.downloadArtwork(artworkId: artworkId, deviceId: deviceId)
|
||||
}
|
||||
|
||||
private func bootstrap() async throws -> [RemoteTrack] {
|
||||
let response = try await apiClient.fetchSyncBootstrap()
|
||||
let tracks = orderTracks(response.tracks)
|
||||
try await store.replaceRemoteTracks(tracks)
|
||||
try await syncCursorStore.saveCursor(response.nextCursor)
|
||||
return tracks
|
||||
}
|
||||
|
||||
private func syncIncrementally(
|
||||
from cursor: SyncCursor
|
||||
) async throws -> [RemoteTrack] {
|
||||
let cachedTracks = try await store.loadRemoteTracks()
|
||||
var mergedTracks = Dictionary(
|
||||
uniqueKeysWithValues: cachedTracks.map { ($0.trackId, $0) }
|
||||
)
|
||||
var currentCursor = cursor
|
||||
|
||||
while true {
|
||||
let response = try await apiClient.fetchSyncChanges(cursor: currentCursor)
|
||||
|
||||
if response.requiresBootstrap {
|
||||
return try await bootstrap()
|
||||
}
|
||||
|
||||
mergedTracks = apply(events: response.events, to: mergedTracks)
|
||||
currentCursor = response.nextCursor
|
||||
|
||||
if !response.hasMore {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
let orderedTracks = orderTracks(Array(mergedTracks.values))
|
||||
try await store.replaceRemoteTracks(orderedTracks)
|
||||
try await syncCursorStore.saveCursor(currentCursor)
|
||||
return orderedTracks
|
||||
}
|
||||
|
||||
private func apply(
|
||||
events: [SyncEvent],
|
||||
to tracksByID: [String: RemoteTrack]
|
||||
) -> [String: RemoteTrack] {
|
||||
var nextTracksByID = tracksByID
|
||||
|
||||
for event in events {
|
||||
if let deletedTrackID = event.deletedTrackId, !deletedTrackID.isEmpty {
|
||||
nextTracksByID.removeValue(forKey: deletedTrackID)
|
||||
continue
|
||||
}
|
||||
|
||||
guard let track = event.track else {
|
||||
continue
|
||||
}
|
||||
|
||||
nextTracksByID[track.trackId] = track
|
||||
}
|
||||
|
||||
return nextTracksByID
|
||||
}
|
||||
|
||||
private func orderTracks(_ tracks: [RemoteTrack]) -> [RemoteTrack] {
|
||||
tracks.sorted { lhs, rhs in
|
||||
if lhs.createdAt == rhs.createdAt {
|
||||
return lhs.trackId < rhs.trackId
|
||||
}
|
||||
|
||||
return lhs.createdAt < rhs.createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ public actor RemoteLibrarySyncService {
|
||||
|
||||
public func loadDownloadStates() async throws -> [RemoteTrackDownloadState] {
|
||||
let states = try await downloadStateStore.loadDownloadStates()
|
||||
return try await reconcileDownloadedLocalFilePaths(in: states)
|
||||
return try await reconcilePersistedDownloadStates(in: states)
|
||||
}
|
||||
|
||||
public func syncRemoteLibrary(deviceId: String) async throws -> [RemoteTrack] {
|
||||
@@ -150,7 +150,7 @@ public actor RemoteLibrarySyncService {
|
||||
)
|
||||
}
|
||||
|
||||
private func reconcileDownloadedLocalFilePaths(
|
||||
private func reconcilePersistedDownloadStates(
|
||||
in states: [RemoteTrackDownloadState]
|
||||
) async throws -> [RemoteTrackDownloadState] {
|
||||
guard !states.isEmpty else {
|
||||
@@ -162,19 +162,36 @@ public actor RemoteLibrarySyncService {
|
||||
|
||||
for index in reconciledStates.indices {
|
||||
let state = reconciledStates[index]
|
||||
guard state.downloadStatus == .downloaded else {
|
||||
continue
|
||||
}
|
||||
|
||||
guard let resolvedLocalFilePath = await audioFileStore.resolveLocalFilePath(
|
||||
let resolvedLocalFilePath = await audioFileStore.resolveLocalFilePath(
|
||||
persistedLocalFilePath: state.localFilePath,
|
||||
assetId: state.assetId
|
||||
) else {
|
||||
continue
|
||||
}
|
||||
)
|
||||
|
||||
if state.localFilePath != resolvedLocalFilePath {
|
||||
reconciledStates[index].localFilePath = resolvedLocalFilePath
|
||||
switch state.downloadStatus {
|
||||
case .notDownloaded, .failed:
|
||||
continue
|
||||
case .downloaded:
|
||||
guard let resolvedLocalFilePath else {
|
||||
continue
|
||||
}
|
||||
|
||||
if state.localFilePath != resolvedLocalFilePath {
|
||||
reconciledStates[index].localFilePath = resolvedLocalFilePath
|
||||
didChange = true
|
||||
}
|
||||
case .downloading:
|
||||
if let resolvedLocalFilePath {
|
||||
if state.localFilePath != resolvedLocalFilePath {
|
||||
reconciledStates[index].localFilePath = resolvedLocalFilePath
|
||||
}
|
||||
reconciledStates[index].downloadStatus = .downloaded
|
||||
reconciledStates[index].lastDownloadError = nil
|
||||
} else {
|
||||
reconciledStates[index].localFilePath = ""
|
||||
reconciledStates[index].downloadedAt = nil
|
||||
reconciledStates[index].downloadStatus = .failed
|
||||
reconciledStates[index].lastDownloadError = Self.interruptedDownloadErrorMessage
|
||||
}
|
||||
didChange = true
|
||||
}
|
||||
}
|
||||
@@ -186,6 +203,8 @@ public actor RemoteLibrarySyncService {
|
||||
return reconciledStates
|
||||
}
|
||||
|
||||
private static let interruptedDownloadErrorMessage = "The previous download did not finish. Try again."
|
||||
|
||||
private func cacheArtwork(
|
||||
for tracks: [RemoteTrack],
|
||||
deviceId: String
|
||||
|
||||
@@ -176,7 +176,8 @@ final class OfflineLibraryServiceTests: XCTestCase {
|
||||
let syncService = RemoteLibrarySyncService(
|
||||
repository: DefaultRemoteLibraryRepository(
|
||||
apiClient: OfflineLibraryMockAPIClient(audioAssetData: sampleMp3Data(seed: track.assetId)),
|
||||
store: remoteLibraryStore
|
||||
store: remoteLibraryStore,
|
||||
syncCursorStore: InMemoryRemoteLibrarySyncCursorStore()
|
||||
),
|
||||
downloadStateStore: downloadStateStore,
|
||||
audioFileStore: audioFileStore,
|
||||
@@ -226,8 +227,10 @@ final class OfflineLibraryServiceTests: XCTestCase {
|
||||
let remoteLibraryStore = InMemoryRemoteLibraryStore()
|
||||
let audioData = sampleMp3Data(seed: track.assetId)
|
||||
let apiClient = OfflineLibraryMockAPIClient(
|
||||
remoteLibraryResponse: RemoteLibraryResponseDTO(
|
||||
tracks: [makeRemoteTrackDTO(from: track)]
|
||||
bootstrapResponse: SyncBootstrapResponse(
|
||||
nextCursor: SyncCursor(value: "1"),
|
||||
tracks: [track],
|
||||
serverTime: "2026-05-30T08:00:00.000Z"
|
||||
),
|
||||
audioAssetData: audioData,
|
||||
artworkDataByArtworkID: [
|
||||
@@ -240,7 +243,8 @@ final class OfflineLibraryServiceTests: XCTestCase {
|
||||
let syncService = RemoteLibrarySyncService(
|
||||
repository: DefaultRemoteLibraryRepository(
|
||||
apiClient: apiClient,
|
||||
store: remoteLibraryStore
|
||||
store: remoteLibraryStore,
|
||||
syncCursorStore: InMemoryRemoteLibrarySyncCursorStore()
|
||||
),
|
||||
downloadStateStore: downloadStateStore,
|
||||
audioFileStore: audioFileStore,
|
||||
@@ -284,8 +288,10 @@ final class OfflineLibraryServiceTests: XCTestCase {
|
||||
makeRemoteTrack(trackId: "track-2", assetId: "asset-2", title: "Track 2", artworkId: "artwork-2"),
|
||||
]
|
||||
let apiClient = OfflineLibraryMockAPIClient(
|
||||
remoteLibraryResponse: RemoteLibraryResponseDTO(
|
||||
tracks: tracks.map { makeRemoteTrackDTO(from: $0) }
|
||||
bootstrapResponse: SyncBootstrapResponse(
|
||||
nextCursor: SyncCursor(value: "1"),
|
||||
tracks: tracks,
|
||||
serverTime: "2026-05-30T08:00:00.000Z"
|
||||
),
|
||||
audioAssetDataByAssetID: [
|
||||
"asset-1": sampleMp3Data(seed: "asset-1"),
|
||||
@@ -304,7 +310,8 @@ final class OfflineLibraryServiceTests: XCTestCase {
|
||||
|
||||
let firstRepository = DefaultRemoteLibraryRepository(
|
||||
apiClient: apiClient,
|
||||
store: try FileRemoteLibraryStore(fileURL: remoteLibraryFileURL)
|
||||
store: try FileRemoteLibraryStore(fileURL: remoteLibraryFileURL),
|
||||
syncCursorStore: InMemoryRemoteLibrarySyncCursorStore()
|
||||
)
|
||||
let firstDownloadStateStore = try FileRemoteTrackDownloadStateStore(fileURL: downloadStateFileURL)
|
||||
let firstAudioStore = try FileOfflineAudioFileStore(baseDirectoryURL: audioDirectory)
|
||||
@@ -329,7 +336,8 @@ final class OfflineLibraryServiceTests: XCTestCase {
|
||||
|
||||
let relaunchedRepository = DefaultRemoteLibraryRepository(
|
||||
apiClient: apiClient,
|
||||
store: try FileRemoteLibraryStore(fileURL: remoteLibraryFileURL)
|
||||
store: try FileRemoteLibraryStore(fileURL: remoteLibraryFileURL),
|
||||
syncCursorStore: InMemoryRemoteLibrarySyncCursorStore(cursor: SyncCursor(value: "1"))
|
||||
)
|
||||
let relaunchedDownloadStateStore = try FileRemoteTrackDownloadStateStore(fileURL: downloadStateFileURL)
|
||||
let relaunchedAudioStore = try FileOfflineAudioFileStore(baseDirectoryURL: audioDirectory)
|
||||
@@ -404,18 +412,22 @@ private actor InMemoryRemoteLibraryRepository: RemoteLibraryRepository {
|
||||
}
|
||||
|
||||
private struct OfflineLibraryMockAPIClient: VelodyAPIClient {
|
||||
let remoteLibraryResponse: RemoteLibraryResponseDTO?
|
||||
let bootstrapResponse: SyncBootstrapResponse
|
||||
let audioAssetData: Data?
|
||||
let audioAssetDataByAssetID: [String: Data]
|
||||
let artworkDataByArtworkID: [String: Data]
|
||||
|
||||
init(
|
||||
remoteLibraryResponse: RemoteLibraryResponseDTO? = nil,
|
||||
bootstrapResponse: SyncBootstrapResponse = SyncBootstrapResponse(
|
||||
nextCursor: SyncCursor(value: "0"),
|
||||
tracks: [],
|
||||
serverTime: "2026-05-30T08:00:00.000Z"
|
||||
),
|
||||
audioAssetData: Data? = nil,
|
||||
audioAssetDataByAssetID: [String: Data] = [:],
|
||||
artworkDataByArtworkID: [String: Data] = [:]
|
||||
) {
|
||||
self.remoteLibraryResponse = remoteLibraryResponse
|
||||
self.bootstrapResponse = bootstrapResponse
|
||||
self.audioAssetData = audioAssetData
|
||||
self.audioAssetDataByAssetID = audioAssetDataByAssetID
|
||||
self.artworkDataByArtworkID = artworkDataByArtworkID
|
||||
@@ -444,11 +456,17 @@ private struct OfflineLibraryMockAPIClient: VelodyAPIClient {
|
||||
}
|
||||
|
||||
func fetchSyncBootstrap() async throws -> SyncBootstrapResponse {
|
||||
SyncBootstrapResponse(
|
||||
nextCursor: SyncCursor(value: "0"),
|
||||
tracks: [],
|
||||
bootstrapResponse
|
||||
}
|
||||
|
||||
func fetchSyncChanges(
|
||||
cursor: SyncCursor
|
||||
) async throws -> SyncChangesResponse {
|
||||
SyncChangesResponse(
|
||||
nextCursor: cursor,
|
||||
hasMore: false,
|
||||
requiresBootstrap: false,
|
||||
events: [],
|
||||
deletedTrackIds: [],
|
||||
serverTime: "2026-05-30T08:00:00.000Z"
|
||||
)
|
||||
}
|
||||
@@ -457,7 +475,9 @@ private struct OfflineLibraryMockAPIClient: VelodyAPIClient {
|
||||
deviceId: String
|
||||
) async throws -> RemoteLibraryResponseDTO {
|
||||
_ = deviceId
|
||||
return remoteLibraryResponse ?? RemoteLibraryResponseDTO(tracks: [])
|
||||
return RemoteLibraryResponseDTO(
|
||||
tracks: bootstrapResponse.tracks.map { makeRemoteTrackDTO(from: $0) }
|
||||
)
|
||||
}
|
||||
|
||||
func downloadAudioAsset(
|
||||
|
||||
+566
-176
@@ -7,112 +7,277 @@ import VelodyPersistence
|
||||
@testable import VelodySync
|
||||
|
||||
final class RemoteLibrarySyncServiceTests: XCTestCase {
|
||||
func testSuccessfulSyncPersistsRemoteTracks() async throws {
|
||||
func testBootstrapFirstSyncPersistsRemoteTracksAndCursor() async throws {
|
||||
let store = InMemoryRemoteLibraryStore()
|
||||
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore()
|
||||
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 cursorStore = InMemoryRemoteLibrarySyncCursorStore()
|
||||
let track = makeRemoteTrack(trackId: "track-123")
|
||||
let service = makeSyncService(
|
||||
apiClient: MockVelodyAPIClient(
|
||||
bootstrapResponse: SyncBootstrapResponse(
|
||||
nextCursor: SyncCursor(value: "4"),
|
||||
tracks: [track],
|
||||
serverTime: "2026-06-15T12:00:00.000Z"
|
||||
)
|
||||
),
|
||||
downloadStateStore: downloadStateStore,
|
||||
audioFileStore: InMemoryOfflineAudioFileStore(),
|
||||
artworkStore: InMemoryArtworkStore()
|
||||
store: store,
|
||||
cursorStore: cursorStore
|
||||
)
|
||||
|
||||
let tracks = try await service.syncRemoteLibrary(deviceId: "device-123")
|
||||
let cachedTracks = try await service.loadCachedRemoteTracks()
|
||||
let storedCursor = try await cursorStore.loadCursor()
|
||||
let downloadStates = try await service.loadDownloadStates()
|
||||
|
||||
XCTAssertEqual(tracks.count, 1)
|
||||
XCTAssertEqual(cachedTracks, tracks)
|
||||
XCTAssertEqual(cachedTracks.first?.trackId, "track-123")
|
||||
XCTAssertEqual(downloadStates.first?.downloadStatus, .notDownloaded)
|
||||
XCTAssertEqual(tracks, [track])
|
||||
XCTAssertEqual(cachedTracks, [track])
|
||||
XCTAssertEqual(storedCursor, SyncCursor(value: "4"))
|
||||
XCTAssertEqual(downloadStates.first?.downloadStatus, RemoteTrackDownloadStatus.notDownloaded)
|
||||
}
|
||||
|
||||
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"
|
||||
func testChangesForExistingCursorApplyWithoutDuplicateTracks() async throws {
|
||||
let originalTrack = makeRemoteTrack(trackId: "track-123", title: "Old Title")
|
||||
let updatedTrack = makeRemoteTrack(trackId: "track-123", title: "New Title")
|
||||
let secondTrack = makeRemoteTrack(
|
||||
trackId: "track-456",
|
||||
title: "Second Track",
|
||||
createdAt: "2026-06-15T12:01:00.000Z"
|
||||
)
|
||||
let store = InMemoryRemoteLibraryStore(tracks: [originalTrack])
|
||||
let cursorStore = InMemoryRemoteLibrarySyncCursorStore(
|
||||
cursor: SyncCursor(value: "4")
|
||||
)
|
||||
let apiClient = MockVelodyAPIClient(
|
||||
changeResponses: [
|
||||
SyncChangesResponse(
|
||||
nextCursor: SyncCursor(value: "6"),
|
||||
hasMore: false,
|
||||
requiresBootstrap: false,
|
||||
events: [
|
||||
SyncEvent(
|
||||
cursor: SyncCursor(value: "5"),
|
||||
entityType: "TRACK",
|
||||
entityId: updatedTrack.trackId,
|
||||
action: "UPDATED",
|
||||
track: updatedTrack,
|
||||
createdAt: "2026-06-15T12:00:30.000Z"
|
||||
),
|
||||
SyncEvent(
|
||||
cursor: SyncCursor(value: "6"),
|
||||
entityType: "AUDIO_ASSET",
|
||||
entityId: secondTrack.assetId,
|
||||
action: "CREATED",
|
||||
track: secondTrack,
|
||||
createdAt: "2026-06-15T12:01:00.000Z"
|
||||
),
|
||||
],
|
||||
serverTime: "2026-06-15T12:01:00.000Z"
|
||||
),
|
||||
]
|
||||
)
|
||||
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore(
|
||||
states: [
|
||||
RemoteTrackDownloadState(
|
||||
remoteTrackId: "track-123",
|
||||
assetId: "asset-123",
|
||||
downloadStatus: .downloaded
|
||||
),
|
||||
]
|
||||
)
|
||||
let service = RemoteLibrarySyncService(
|
||||
repository: DefaultRemoteLibraryRepository(
|
||||
apiClient: MockVelodyAPIClient(
|
||||
remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: [])
|
||||
),
|
||||
store: store
|
||||
),
|
||||
downloadStateStore: downloadStateStore,
|
||||
audioFileStore: InMemoryOfflineAudioFileStore(),
|
||||
artworkStore: InMemoryArtworkStore()
|
||||
let service = makeSyncService(
|
||||
apiClient: apiClient,
|
||||
store: store,
|
||||
cursorStore: cursorStore
|
||||
)
|
||||
|
||||
let tracks = try await service.syncRemoteLibrary(deviceId: "device-123")
|
||||
let cachedTracks = try await service.loadCachedRemoteTracks()
|
||||
let downloadStates = try await service.loadDownloadStates()
|
||||
let storedCursor = try await cursorStore.loadCursor()
|
||||
|
||||
XCTAssertEqual(tracks, [])
|
||||
XCTAssertEqual(cachedTracks, [])
|
||||
XCTAssertEqual(downloadStates.count, 1)
|
||||
XCTAssertEqual(tracks, [updatedTrack, secondTrack])
|
||||
XCTAssertEqual(Set(tracks.map(\.trackId)), ["track-123", "track-456"])
|
||||
XCTAssertEqual(storedCursor, SyncCursor(value: "6"))
|
||||
let changeCursors = await apiClient.recordedChangeCursors()
|
||||
XCTAssertEqual(changeCursors, ["4"])
|
||||
}
|
||||
|
||||
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"
|
||||
func testPaginationAndReplayEventsDoNotDuplicateTracks() async throws {
|
||||
let originalTrack = makeRemoteTrack(trackId: "track-123", title: "Old Title")
|
||||
let updatedTrack = makeRemoteTrack(trackId: "track-123", title: "Updated Once")
|
||||
let secondUpdate = makeRemoteTrack(trackId: "track-123", title: "Updated Twice")
|
||||
let secondTrack = makeRemoteTrack(
|
||||
trackId: "track-456",
|
||||
title: "Another Track",
|
||||
createdAt: "2026-06-15T12:02:00.000Z"
|
||||
)
|
||||
let store = InMemoryRemoteLibraryStore(tracks: [originalTrack])
|
||||
let cursorStore = InMemoryRemoteLibrarySyncCursorStore(
|
||||
cursor: SyncCursor(value: "3")
|
||||
)
|
||||
let apiClient = MockVelodyAPIClient(
|
||||
changeResponses: [
|
||||
SyncChangesResponse(
|
||||
nextCursor: SyncCursor(value: "5"),
|
||||
hasMore: true,
|
||||
requiresBootstrap: false,
|
||||
events: [
|
||||
SyncEvent(
|
||||
cursor: SyncCursor(value: "4"),
|
||||
entityType: "TRACK",
|
||||
entityId: updatedTrack.trackId,
|
||||
action: "UPDATED",
|
||||
track: updatedTrack,
|
||||
createdAt: "2026-06-15T12:00:30.000Z"
|
||||
),
|
||||
SyncEvent(
|
||||
cursor: SyncCursor(value: "5"),
|
||||
entityType: "TRACK",
|
||||
entityId: secondUpdate.trackId,
|
||||
action: "UPDATED",
|
||||
track: secondUpdate,
|
||||
createdAt: "2026-06-15T12:01:00.000Z"
|
||||
),
|
||||
],
|
||||
serverTime: "2026-06-15T12:01:00.000Z"
|
||||
),
|
||||
SyncChangesResponse(
|
||||
nextCursor: SyncCursor(value: "6"),
|
||||
hasMore: false,
|
||||
requiresBootstrap: false,
|
||||
events: [
|
||||
SyncEvent(
|
||||
cursor: SyncCursor(value: "6"),
|
||||
entityType: "TRACK",
|
||||
entityId: secondTrack.trackId,
|
||||
action: "CREATED",
|
||||
track: secondTrack,
|
||||
createdAt: "2026-06-15T12:02:00.000Z"
|
||||
),
|
||||
],
|
||||
serverTime: "2026-06-15T12:02:00.000Z"
|
||||
),
|
||||
]
|
||||
)
|
||||
let service = makeSyncService(
|
||||
apiClient: apiClient,
|
||||
store: store,
|
||||
cursorStore: cursorStore
|
||||
)
|
||||
|
||||
let tracks = try await service.syncRemoteLibrary(deviceId: "device-123")
|
||||
|
||||
XCTAssertEqual(tracks, [secondUpdate, secondTrack])
|
||||
XCTAssertEqual(Set(tracks.map(\.trackId)).count, 2)
|
||||
let changeCursors = await apiClient.recordedChangeCursors()
|
||||
XCTAssertEqual(changeCursors, ["3", "5"])
|
||||
}
|
||||
|
||||
func testRequiresBootstrapFallbackReplacesCachedLibraryAndCursor() async throws {
|
||||
let staleTrack = makeRemoteTrack(trackId: "track-stale", title: "Stale")
|
||||
let freshTrack = makeRemoteTrack(trackId: "track-fresh", title: "Fresh")
|
||||
let store = InMemoryRemoteLibraryStore(tracks: [staleTrack])
|
||||
let cursorStore = InMemoryRemoteLibrarySyncCursorStore(
|
||||
cursor: SyncCursor(value: "2")
|
||||
)
|
||||
let apiClient = MockVelodyAPIClient(
|
||||
bootstrapResponse: SyncBootstrapResponse(
|
||||
nextCursor: SyncCursor(value: "9"),
|
||||
tracks: [freshTrack],
|
||||
serverTime: "2026-06-15T12:05:00.000Z"
|
||||
),
|
||||
changeResponses: [
|
||||
SyncChangesResponse(
|
||||
nextCursor: SyncCursor(value: "2"),
|
||||
hasMore: false,
|
||||
requiresBootstrap: true,
|
||||
reason: "cursor_too_old",
|
||||
events: [],
|
||||
serverTime: "2026-06-15T12:04:00.000Z"
|
||||
),
|
||||
]
|
||||
)
|
||||
let service = makeSyncService(
|
||||
apiClient: apiClient,
|
||||
store: store,
|
||||
cursorStore: cursorStore
|
||||
)
|
||||
|
||||
let tracks = try await service.syncRemoteLibrary(deviceId: "device-123")
|
||||
let storedCursor = try await cursorStore.loadCursor()
|
||||
|
||||
XCTAssertEqual(tracks, [freshTrack])
|
||||
XCTAssertEqual(storedCursor, SyncCursor(value: "9"))
|
||||
let changeCursors = await apiClient.recordedChangeCursors()
|
||||
XCTAssertEqual(changeCursors, ["2"])
|
||||
}
|
||||
|
||||
func testCursorPersistsAcrossRepositoryInstances() async throws {
|
||||
let fileManager = FileManager.default
|
||||
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
|
||||
UUID().uuidString,
|
||||
isDirectory: true
|
||||
)
|
||||
let remoteLibraryFileURL = tempDirectory.appendingPathComponent("remote-library.json")
|
||||
let syncCursorFileURL = tempDirectory.appendingPathComponent("remote-library-sync-cursor.json")
|
||||
let bootstrapTrack = makeRemoteTrack(trackId: "track-bootstrap")
|
||||
let changedTrack = makeRemoteTrack(trackId: "track-bootstrap", title: "Track Bootstrap Updated")
|
||||
|
||||
defer {
|
||||
try? fileManager.removeItem(at: tempDirectory)
|
||||
}
|
||||
|
||||
let firstClient = MockVelodyAPIClient(
|
||||
bootstrapResponse: SyncBootstrapResponse(
|
||||
nextCursor: SyncCursor(value: "2"),
|
||||
tracks: [bootstrapTrack],
|
||||
serverTime: "2026-06-15T12:00:00.000Z"
|
||||
)
|
||||
)
|
||||
let firstService = makeSyncService(
|
||||
apiClient: firstClient,
|
||||
store: try FileRemoteLibraryStore(fileURL: remoteLibraryFileURL),
|
||||
cursorStore: try FileRemoteLibrarySyncCursorStore(fileURL: syncCursorFileURL)
|
||||
)
|
||||
|
||||
_ = try await firstService.syncRemoteLibrary(deviceId: "device-123")
|
||||
|
||||
let secondClient = MockVelodyAPIClient(
|
||||
changeResponses: [
|
||||
SyncChangesResponse(
|
||||
nextCursor: SyncCursor(value: "3"),
|
||||
hasMore: false,
|
||||
requiresBootstrap: false,
|
||||
events: [
|
||||
SyncEvent(
|
||||
cursor: SyncCursor(value: "3"),
|
||||
entityType: "TRACK",
|
||||
entityId: changedTrack.trackId,
|
||||
action: "UPDATED",
|
||||
track: changedTrack,
|
||||
createdAt: "2026-06-15T12:01:00.000Z"
|
||||
),
|
||||
],
|
||||
serverTime: "2026-06-15T12:01:00.000Z"
|
||||
),
|
||||
]
|
||||
)
|
||||
let secondService = makeSyncService(
|
||||
apiClient: secondClient,
|
||||
store: try FileRemoteLibraryStore(fileURL: remoteLibraryFileURL),
|
||||
cursorStore: try FileRemoteLibrarySyncCursorStore(fileURL: syncCursorFileURL)
|
||||
)
|
||||
|
||||
let tracks = try await secondService.syncRemoteLibrary(deviceId: "device-123")
|
||||
let storedCursor = try await FileRemoteLibrarySyncCursorStore(fileURL: syncCursorFileURL)
|
||||
.loadCursor()
|
||||
|
||||
XCTAssertEqual(tracks, [changedTrack])
|
||||
XCTAssertEqual(storedCursor, SyncCursor(value: "3"))
|
||||
let changeCursors = await secondClient.recordedChangeCursors()
|
||||
XCTAssertEqual(changeCursors, ["2"])
|
||||
}
|
||||
|
||||
func testNetworkFailureLeavesCachedLibraryAndCursorIntact() async throws {
|
||||
let cachedTrack = makeRemoteTrack(trackId: "track-123", title: "Cached")
|
||||
let store = InMemoryRemoteLibraryStore(tracks: [cachedTrack])
|
||||
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore()
|
||||
let service = RemoteLibrarySyncService(
|
||||
repository: DefaultRemoteLibraryRepository(
|
||||
apiClient: MockVelodyAPIClient(
|
||||
remoteLibraryError: VelodyAPIError.requestFailed("Offline")
|
||||
),
|
||||
store: store
|
||||
let cursorStore = InMemoryRemoteLibrarySyncCursorStore(
|
||||
cursor: SyncCursor(value: "7")
|
||||
)
|
||||
let service = makeSyncService(
|
||||
apiClient: MockVelodyAPIClient(
|
||||
changeError: VelodyAPIError.requestFailed("Offline")
|
||||
),
|
||||
downloadStateStore: downloadStateStore,
|
||||
audioFileStore: InMemoryOfflineAudioFileStore(),
|
||||
artworkStore: InMemoryArtworkStore()
|
||||
store: store,
|
||||
cursorStore: cursorStore
|
||||
)
|
||||
|
||||
await XCTAssertThrowsErrorAsync {
|
||||
@@ -120,7 +285,48 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
||||
}
|
||||
|
||||
let cachedTracks = try await service.loadCachedRemoteTracks()
|
||||
let storedCursor = try await cursorStore.loadCursor()
|
||||
XCTAssertEqual(cachedTracks, [cachedTrack])
|
||||
XCTAssertEqual(storedCursor, SyncCursor(value: "7"))
|
||||
}
|
||||
|
||||
func testSyncFailurePreservesDownloadedStateAndLocalFile() async throws {
|
||||
let track = makeRemoteTrack(trackId: "track-offline", assetId: "asset-offline")
|
||||
let localFilePath = "/in-memory/\(track.assetId).mp3"
|
||||
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore(states: [
|
||||
RemoteTrackDownloadState(
|
||||
remoteTrackId: track.trackId,
|
||||
assetId: track.assetId,
|
||||
localFilePath: localFilePath,
|
||||
downloadedAt: Date(timeIntervalSince1970: 3_000),
|
||||
downloadStatus: .downloaded
|
||||
),
|
||||
])
|
||||
let audioFileStore = InMemoryOfflineAudioFileStore(files: [
|
||||
localFilePath: sampleMp3Data(seed: "network-safe"),
|
||||
])
|
||||
let service = makeSyncService(
|
||||
apiClient: MockVelodyAPIClient(
|
||||
changeError: VelodyAPIError.requestFailed("Offline")
|
||||
),
|
||||
store: InMemoryRemoteLibraryStore(tracks: [track]),
|
||||
cursorStore: InMemoryRemoteLibrarySyncCursorStore(
|
||||
cursor: SyncCursor(value: "7")
|
||||
),
|
||||
downloadStateStore: downloadStateStore,
|
||||
audioFileStore: audioFileStore
|
||||
)
|
||||
|
||||
await XCTAssertThrowsErrorAsync {
|
||||
_ = try await service.syncRemoteLibrary(deviceId: "device-123")
|
||||
}
|
||||
|
||||
let recoveredState = try await service.loadDownloadStates().first
|
||||
let fileExists = await audioFileStore.fileExists(at: localFilePath)
|
||||
|
||||
XCTAssertEqual(recoveredState?.downloadStatus, .downloaded)
|
||||
XCTAssertEqual(recoveredState?.localFilePath, localFilePath)
|
||||
XCTAssertTrue(fileExists)
|
||||
}
|
||||
|
||||
func testDownloadTrackPersistsDownloadedStateAndFile() async throws {
|
||||
@@ -129,34 +335,29 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
||||
let service = RemoteLibrarySyncService(
|
||||
repository: DefaultRemoteLibraryRepository(
|
||||
apiClient: MockVelodyAPIClient(
|
||||
remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: []),
|
||||
audioAssetData: sampleMp3Data(seed: "download-success")
|
||||
),
|
||||
store: InMemoryRemoteLibraryStore()
|
||||
store: InMemoryRemoteLibraryStore(),
|
||||
syncCursorStore: InMemoryRemoteLibrarySyncCursorStore()
|
||||
),
|
||||
downloadStateStore: downloadStateStore,
|
||||
audioFileStore: audioFileStore,
|
||||
artworkStore: InMemoryArtworkStore()
|
||||
)
|
||||
let track = RemoteTrack(
|
||||
let track = makeRemoteTrack(
|
||||
trackId: "track-123",
|
||||
title: "Remote Title",
|
||||
artist: "Remote Artist",
|
||||
durationSeconds: 245,
|
||||
sha256: sha256Hex(sampleMp3Data(seed: "download-success")),
|
||||
assetId: "asset-456",
|
||||
createdAt: "2026-05-29T08:00:00.000Z",
|
||||
updatedAt: "2026-05-29T08:05:00.000Z"
|
||||
assetId: "asset-456"
|
||||
)
|
||||
|
||||
let state = try await service.downloadTrack(track, deviceId: "device-123")
|
||||
let storedStates = try await service.loadDownloadStates()
|
||||
let fileExists = await audioFileStore.fileExists(at: state.localFilePath)
|
||||
|
||||
XCTAssertEqual(state.downloadStatus, .downloaded)
|
||||
XCTAssertEqual(state.downloadStatus, RemoteTrackDownloadStatus.downloaded)
|
||||
XCTAssertEqual(state.assetId, "asset-456")
|
||||
XCTAssertFalse(state.localFilePath.isEmpty)
|
||||
XCTAssertEqual(storedStates.first?.downloadStatus, .downloaded)
|
||||
XCTAssertEqual(storedStates.first?.downloadStatus, RemoteTrackDownloadStatus.downloaded)
|
||||
XCTAssertTrue(fileExists)
|
||||
}
|
||||
|
||||
@@ -164,35 +365,129 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
||||
let service = RemoteLibrarySyncService(
|
||||
repository: DefaultRemoteLibraryRepository(
|
||||
apiClient: MockVelodyAPIClient(
|
||||
remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: []),
|
||||
downloadError: VelodyAPIError.server(statusCode: 404, message: "Missing")
|
||||
),
|
||||
store: InMemoryRemoteLibraryStore()
|
||||
store: InMemoryRemoteLibraryStore(),
|
||||
syncCursorStore: InMemoryRemoteLibrarySyncCursorStore()
|
||||
),
|
||||
downloadStateStore: InMemoryRemoteTrackDownloadStateStore(),
|
||||
audioFileStore: InMemoryOfflineAudioFileStore(),
|
||||
artworkStore: InMemoryArtworkStore()
|
||||
)
|
||||
let track = RemoteTrack(
|
||||
trackId: "track-123",
|
||||
title: "Remote Title",
|
||||
artist: "Remote Artist",
|
||||
durationSeconds: 245,
|
||||
sha256: sha256Hex(sampleMp3Data(seed: "download-failure")),
|
||||
assetId: "asset-456",
|
||||
createdAt: "2026-05-29T08:00:00.000Z",
|
||||
updatedAt: "2026-05-29T08:05:00.000Z"
|
||||
)
|
||||
let track = makeRemoteTrack(trackId: "track-123", assetId: "asset-456")
|
||||
|
||||
await XCTAssertThrowsErrorAsync {
|
||||
_ = try await service.downloadTrack(track, deviceId: "device-123")
|
||||
}
|
||||
|
||||
let storedStates = try await service.loadDownloadStates()
|
||||
XCTAssertEqual(storedStates.first?.downloadStatus, .failed)
|
||||
XCTAssertEqual(storedStates.first?.downloadStatus, RemoteTrackDownloadStatus.failed)
|
||||
XCTAssertEqual(storedStates.first?.remoteTrackId, "track-123")
|
||||
}
|
||||
|
||||
func testRetryAfterFailureCanSucceedAndPersistDownloadedState() async throws {
|
||||
let audioData = sampleMp3Data(seed: "retry-success")
|
||||
let track = makeRemoteTrack(
|
||||
trackId: "track-retry",
|
||||
sha256: sha256Hex(audioData),
|
||||
assetId: "asset-retry"
|
||||
)
|
||||
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore()
|
||||
let audioFileStore = InMemoryOfflineAudioFileStore()
|
||||
let service = RemoteLibrarySyncService(
|
||||
repository: SequencedDownloadRepository(
|
||||
downloadResults: [
|
||||
.failure(VelodyAPIError.server(statusCode: 503, message: "Try Again")),
|
||||
.success(audioData),
|
||||
]
|
||||
),
|
||||
downloadStateStore: downloadStateStore,
|
||||
audioFileStore: audioFileStore,
|
||||
artworkStore: InMemoryArtworkStore()
|
||||
)
|
||||
|
||||
await XCTAssertThrowsErrorAsync {
|
||||
_ = try await service.downloadTrack(track, deviceId: "device-123")
|
||||
}
|
||||
|
||||
let failedState = try await service.loadDownloadStates().first
|
||||
XCTAssertEqual(failedState?.downloadStatus, .failed)
|
||||
|
||||
let recoveredState = try await service.downloadTrack(track, deviceId: "device-123")
|
||||
let persistedState = try await downloadStateStore.loadDownloadStates().first
|
||||
let fileExists = await audioFileStore.fileExists(at: recoveredState.localFilePath)
|
||||
|
||||
XCTAssertEqual(recoveredState.downloadStatus, .downloaded)
|
||||
XCTAssertEqual(persistedState?.downloadStatus, .downloaded)
|
||||
XCTAssertFalse(recoveredState.localFilePath.isEmpty)
|
||||
XCTAssertTrue(fileExists)
|
||||
}
|
||||
|
||||
func testLoadDownloadStatesRecoversInterruptedDownloadToFailedRetryStateWhenFileIsMissing() async throws {
|
||||
let interruptedState = RemoteTrackDownloadState(
|
||||
remoteTrackId: "track-123",
|
||||
assetId: "asset-456",
|
||||
localFilePath: "/in-memory/asset-456.mp3",
|
||||
downloadedAt: Date(timeIntervalSince1970: 1_000),
|
||||
downloadStatus: .downloading
|
||||
)
|
||||
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore(states: [interruptedState])
|
||||
let service = makeSyncService(
|
||||
apiClient: MockVelodyAPIClient(),
|
||||
store: InMemoryRemoteLibraryStore(),
|
||||
cursorStore: InMemoryRemoteLibrarySyncCursorStore(),
|
||||
downloadStateStore: downloadStateStore,
|
||||
audioFileStore: InMemoryOfflineAudioFileStore()
|
||||
)
|
||||
|
||||
let recoveredStates = try await service.loadDownloadStates()
|
||||
let recoveredState = try XCTUnwrap(recoveredStates.first)
|
||||
let persistedState = try await downloadStateStore.loadDownloadStates().first
|
||||
|
||||
XCTAssertEqual(recoveredState.downloadStatus, .failed)
|
||||
XCTAssertEqual(
|
||||
recoveredState.lastDownloadError,
|
||||
"The previous download did not finish. Try again."
|
||||
)
|
||||
XCTAssertEqual(recoveredState.localFilePath, "")
|
||||
XCTAssertNil(recoveredState.downloadedAt)
|
||||
XCTAssertEqual(persistedState, recoveredState)
|
||||
}
|
||||
|
||||
func testLoadDownloadStatesRecoversInterruptedDownloadToDownloadedWhenFileExists() async throws {
|
||||
let recoveredFilePath = "/in-memory/asset-456.mp3"
|
||||
let recoveredDate = Date(timeIntervalSince1970: 2_000)
|
||||
let interruptedState = RemoteTrackDownloadState(
|
||||
remoteTrackId: "track-123",
|
||||
assetId: "asset-456",
|
||||
localFilePath: "",
|
||||
downloadedAt: recoveredDate,
|
||||
downloadStatus: .downloading,
|
||||
lastDownloadError: "Interrupted"
|
||||
)
|
||||
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore(states: [interruptedState])
|
||||
let audioFileStore = InMemoryOfflineAudioFileStore(files: [
|
||||
recoveredFilePath: sampleMp3Data(seed: "interrupted-file"),
|
||||
])
|
||||
let service = makeSyncService(
|
||||
apiClient: MockVelodyAPIClient(),
|
||||
store: InMemoryRemoteLibraryStore(),
|
||||
cursorStore: InMemoryRemoteLibrarySyncCursorStore(),
|
||||
downloadStateStore: downloadStateStore,
|
||||
audioFileStore: audioFileStore
|
||||
)
|
||||
|
||||
let recoveredStates = try await service.loadDownloadStates()
|
||||
let recoveredState = try XCTUnwrap(recoveredStates.first)
|
||||
let persistedState = try await downloadStateStore.loadDownloadStates().first
|
||||
|
||||
XCTAssertEqual(recoveredState.downloadStatus, .downloaded)
|
||||
XCTAssertEqual(recoveredState.localFilePath, recoveredFilePath)
|
||||
XCTAssertEqual(recoveredState.downloadedAt, recoveredDate)
|
||||
XCTAssertNil(recoveredState.lastDownloadError)
|
||||
XCTAssertEqual(persistedState, recoveredState)
|
||||
}
|
||||
|
||||
func testLoadDownloadStatesRepairsStaleLocalFilePathAfterStoreRecreation() async throws {
|
||||
let fileManager = FileManager.default
|
||||
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
|
||||
@@ -203,15 +498,11 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
||||
let secondAudioDirectory = tempDirectory.appendingPathComponent("audio-v2", isDirectory: true)
|
||||
let stateFileURL = tempDirectory.appendingPathComponent("remote-download-states.json")
|
||||
let audioData = sampleMp3Data(seed: "relaunch-repair")
|
||||
let track = RemoteTrack(
|
||||
let track = makeRemoteTrack(
|
||||
trackId: "track-123",
|
||||
title: "1 Mai 2026",
|
||||
artist: "Remote Artist",
|
||||
durationSeconds: 245,
|
||||
sha256: sha256Hex(audioData),
|
||||
assetId: "asset-456",
|
||||
createdAt: "2026-05-29T08:00:00.000Z",
|
||||
updatedAt: "2026-05-29T08:05:00.000Z"
|
||||
assetId: "asset-456"
|
||||
)
|
||||
|
||||
defer {
|
||||
@@ -220,11 +511,9 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
||||
|
||||
let firstService = RemoteLibrarySyncService(
|
||||
repository: DefaultRemoteLibraryRepository(
|
||||
apiClient: MockVelodyAPIClient(
|
||||
remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: []),
|
||||
audioAssetData: audioData
|
||||
),
|
||||
store: InMemoryRemoteLibraryStore()
|
||||
apiClient: MockVelodyAPIClient(audioAssetData: audioData),
|
||||
store: InMemoryRemoteLibraryStore(),
|
||||
syncCursorStore: InMemoryRemoteLibrarySyncCursorStore()
|
||||
),
|
||||
downloadStateStore: try FileRemoteTrackDownloadStateStore(fileURL: stateFileURL),
|
||||
audioFileStore: try FileOfflineAudioFileStore(baseDirectoryURL: firstAudioDirectory),
|
||||
@@ -240,8 +529,9 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
||||
let relaunchedAudioStore = try FileOfflineAudioFileStore(baseDirectoryURL: secondAudioDirectory)
|
||||
let relaunchedService = RemoteLibrarySyncService(
|
||||
repository: DefaultRemoteLibraryRepository(
|
||||
apiClient: MockVelodyAPIClient(remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: [])),
|
||||
store: InMemoryRemoteLibraryStore()
|
||||
apiClient: MockVelodyAPIClient(),
|
||||
store: InMemoryRemoteLibraryStore(),
|
||||
syncCursorStore: InMemoryRemoteLibrarySyncCursorStore()
|
||||
),
|
||||
downloadStateStore: try FileRemoteTrackDownloadStateStore(fileURL: stateFileURL),
|
||||
audioFileStore: relaunchedAudioStore,
|
||||
@@ -255,7 +545,7 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
||||
.loadDownloadStates()
|
||||
.first
|
||||
|
||||
XCTAssertEqual(restoredState.downloadStatus, .downloaded)
|
||||
XCTAssertEqual(restoredState.downloadStatus, RemoteTrackDownloadStatus.downloaded)
|
||||
XCTAssertEqual(restoredState.localFilePath, recreatedStoreFileURL.standardizedFileURL.path)
|
||||
XCTAssertEqual(persistedRestoredState?.localFilePath, recreatedStoreFileURL.standardizedFileURL.path)
|
||||
XCTAssertTrue(fileManager.fileExists(atPath: restoredState.localFilePath))
|
||||
@@ -270,37 +560,22 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
||||
width: 1,
|
||||
height: 1
|
||||
)
|
||||
let track = makeRemoteTrack(
|
||||
trackId: "track-123",
|
||||
artwork: artwork
|
||||
)
|
||||
let artworkStore = InMemoryArtworkStore()
|
||||
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",
|
||||
artwork: RemoteArtworkDTO(
|
||||
artworkId: artwork.artworkId,
|
||||
sha256: artwork.sha256,
|
||||
mimeType: artwork.mimeType,
|
||||
width: artwork.width,
|
||||
height: artwork.height
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
artworkData: sampleArtworkData()
|
||||
let service = makeSyncService(
|
||||
apiClient: MockVelodyAPIClient(
|
||||
bootstrapResponse: SyncBootstrapResponse(
|
||||
nextCursor: SyncCursor(value: "1"),
|
||||
tracks: [track],
|
||||
serverTime: "2026-06-15T12:00:00.000Z"
|
||||
),
|
||||
store: InMemoryRemoteLibraryStore()
|
||||
artworkData: sampleArtworkData()
|
||||
),
|
||||
downloadStateStore: InMemoryRemoteTrackDownloadStateStore(),
|
||||
audioFileStore: InMemoryOfflineAudioFileStore(),
|
||||
store: InMemoryRemoteLibraryStore(),
|
||||
cursorStore: InMemoryRemoteLibrarySyncCursorStore(),
|
||||
artworkStore: artworkStore
|
||||
)
|
||||
|
||||
@@ -315,24 +590,53 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
private struct MockVelodyAPIClient: VelodyAPIClient {
|
||||
let remoteLibraryResponse: RemoteLibraryResponseDTO?
|
||||
let remoteLibraryError: VelodyAPIError?
|
||||
let audioAssetData: Data?
|
||||
let downloadError: VelodyAPIError?
|
||||
let artworkData: Data?
|
||||
let artworkDownloadError: VelodyAPIError?
|
||||
private func makeSyncService(
|
||||
apiClient: any VelodyAPIClient,
|
||||
store: any RemoteLibraryStore,
|
||||
cursorStore: any RemoteLibrarySyncCursorStore,
|
||||
downloadStateStore: any RemoteTrackDownloadStateStore = InMemoryRemoteTrackDownloadStateStore(),
|
||||
audioFileStore: any OfflineAudioFileStore = InMemoryOfflineAudioFileStore(),
|
||||
artworkStore: any ArtworkStore = InMemoryArtworkStore()
|
||||
) -> RemoteLibrarySyncService {
|
||||
RemoteLibrarySyncService(
|
||||
repository: DefaultRemoteLibraryRepository(
|
||||
apiClient: apiClient,
|
||||
store: store,
|
||||
syncCursorStore: cursorStore
|
||||
),
|
||||
downloadStateStore: downloadStateStore,
|
||||
audioFileStore: audioFileStore,
|
||||
artworkStore: artworkStore
|
||||
)
|
||||
}
|
||||
|
||||
private actor MockVelodyAPIClient: VelodyAPIClient {
|
||||
private let bootstrapResponse: SyncBootstrapResponse
|
||||
private let changeResponses: [SyncChangesResponse]
|
||||
private let changeError: VelodyAPIError?
|
||||
private let audioAssetData: Data?
|
||||
private let downloadError: VelodyAPIError?
|
||||
private let artworkData: Data?
|
||||
private let artworkDownloadError: VelodyAPIError?
|
||||
private var changeResponseIndex = 0
|
||||
private var changeCursors: [String] = []
|
||||
|
||||
init(
|
||||
remoteLibraryResponse: RemoteLibraryResponseDTO? = nil,
|
||||
remoteLibraryError: VelodyAPIError? = nil,
|
||||
bootstrapResponse: SyncBootstrapResponse = SyncBootstrapResponse(
|
||||
nextCursor: SyncCursor(value: "0"),
|
||||
tracks: [],
|
||||
serverTime: "2026-06-15T12:00:00.000Z"
|
||||
),
|
||||
changeResponses: [SyncChangesResponse] = [],
|
||||
changeError: VelodyAPIError? = nil,
|
||||
audioAssetData: Data? = nil,
|
||||
downloadError: VelodyAPIError? = nil,
|
||||
artworkData: Data? = nil,
|
||||
artworkDownloadError: VelodyAPIError? = nil
|
||||
) {
|
||||
self.remoteLibraryResponse = remoteLibraryResponse
|
||||
self.remoteLibraryError = remoteLibraryError
|
||||
self.bootstrapResponse = bootstrapResponse
|
||||
self.changeResponses = changeResponses
|
||||
self.changeError = changeError
|
||||
self.audioAssetData = audioAssetData
|
||||
self.downloadError = downloadError
|
||||
self.artworkData = artworkData
|
||||
@@ -347,7 +651,7 @@ private struct MockVelodyAPIClient: VelodyAPIClient {
|
||||
deviceId: UUID().uuidString,
|
||||
deviceAccessToken: UUID().uuidString,
|
||||
bootstrapToken: UUID().uuidString,
|
||||
serverTime: "2026-05-29T08:00:00.000Z"
|
||||
serverTime: "2026-06-15T12:00:00.000Z"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -357,30 +661,43 @@ private struct MockVelodyAPIClient: VelodyAPIClient {
|
||||
_ = payload
|
||||
return DeviceHeartbeatResponse(
|
||||
ok: true,
|
||||
serverTime: "2026-05-29T08:00:00.000Z"
|
||||
serverTime: "2026-06-15T12:00:00.000Z"
|
||||
)
|
||||
}
|
||||
|
||||
func fetchSyncBootstrap() async throws -> SyncBootstrapResponse {
|
||||
SyncBootstrapResponse(
|
||||
nextCursor: SyncCursor(value: "0"),
|
||||
tracks: [],
|
||||
events: [],
|
||||
deletedTrackIds: [],
|
||||
serverTime: "2026-05-29T08:00:00.000Z"
|
||||
)
|
||||
bootstrapResponse
|
||||
}
|
||||
|
||||
func fetchSyncChanges(
|
||||
cursor: SyncCursor
|
||||
) async throws -> SyncChangesResponse {
|
||||
changeCursors.append(cursor.value)
|
||||
|
||||
if let changeError {
|
||||
throw changeError
|
||||
}
|
||||
|
||||
guard !changeResponses.isEmpty else {
|
||||
return SyncChangesResponse(
|
||||
nextCursor: cursor,
|
||||
hasMore: false,
|
||||
requiresBootstrap: false,
|
||||
events: [],
|
||||
serverTime: "2026-06-15T12:00:00.000Z"
|
||||
)
|
||||
}
|
||||
|
||||
let response = changeResponses[min(changeResponseIndex, changeResponses.count - 1)]
|
||||
changeResponseIndex += 1
|
||||
return response
|
||||
}
|
||||
|
||||
func fetchRemoteLibrary(
|
||||
deviceId: String
|
||||
) async throws -> RemoteLibraryResponseDTO {
|
||||
_ = deviceId
|
||||
|
||||
if let remoteLibraryError {
|
||||
throw remoteLibraryError
|
||||
}
|
||||
|
||||
return remoteLibraryResponse ?? RemoteLibraryResponseDTO(tracks: [])
|
||||
return RemoteLibraryResponseDTO(tracks: [])
|
||||
}
|
||||
|
||||
func downloadAudioAsset(
|
||||
@@ -457,6 +774,79 @@ private struct MockVelodyAPIClient: VelodyAPIClient {
|
||||
assetId: UUID().uuidString
|
||||
)
|
||||
}
|
||||
|
||||
func recordedChangeCursors() -> [String] {
|
||||
changeCursors
|
||||
}
|
||||
}
|
||||
|
||||
private actor SequencedDownloadRepository: RemoteLibraryRepository {
|
||||
private var downloadResults: [Result<Data, VelodyAPIError>]
|
||||
private let tracks: [RemoteTrack]
|
||||
|
||||
init(
|
||||
downloadResults: [Result<Data, VelodyAPIError>],
|
||||
tracks: [RemoteTrack] = []
|
||||
) {
|
||||
self.downloadResults = downloadResults
|
||||
self.tracks = tracks
|
||||
}
|
||||
|
||||
func loadCachedRemoteTracks() async throws -> [RemoteTrack] {
|
||||
tracks
|
||||
}
|
||||
|
||||
func syncRemoteTracks(deviceId: String) async throws -> [RemoteTrack] {
|
||||
_ = deviceId
|
||||
return tracks
|
||||
}
|
||||
|
||||
func downloadAudioAsset(assetId: String, deviceId: String) async throws -> Data {
|
||||
_ = assetId
|
||||
_ = deviceId
|
||||
|
||||
guard !downloadResults.isEmpty else {
|
||||
return Data()
|
||||
}
|
||||
|
||||
let result = downloadResults.removeFirst()
|
||||
switch result {
|
||||
case let .success(data):
|
||||
return data
|
||||
case let .failure(error):
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func downloadArtwork(artworkId: String, deviceId: String) async throws -> Data {
|
||||
_ = artworkId
|
||||
_ = deviceId
|
||||
return Data()
|
||||
}
|
||||
}
|
||||
|
||||
private func makeRemoteTrack(
|
||||
trackId: String,
|
||||
title: String = "Remote Title",
|
||||
artist: String = "Remote Artist",
|
||||
durationSeconds: Int = 245,
|
||||
sha256: String = String(repeating: "a", count: 64),
|
||||
assetId: String? = nil,
|
||||
createdAt: String = "2026-06-15T12:00:00.000Z",
|
||||
updatedAt: String = "2026-06-15T12:05:00.000Z",
|
||||
artwork: RemoteArtwork? = nil
|
||||
) -> RemoteTrack {
|
||||
RemoteTrack(
|
||||
trackId: trackId,
|
||||
title: title,
|
||||
artist: artist,
|
||||
durationSeconds: durationSeconds,
|
||||
sha256: sha256,
|
||||
assetId: assetId ?? "asset-\(trackId)",
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
artwork: artwork
|
||||
)
|
||||
}
|
||||
|
||||
private func sampleMp3Data(seed: String) -> Data {
|
||||
|
||||
Reference in New Issue
Block a user