Implement incremental sync and offline recovery

This commit is contained in:
diyaa
2026-06-15 22:31:23 +02:00
parent fa7727d572
commit 295c6c1d9b
25 changed files with 2293 additions and 494 deletions
@@ -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)
)
}
@@ -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")
}
}
@@ -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"))