Implement incremental sync and offline recovery
This commit is contained in:
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user