Implement incremental sync and offline recovery
This commit is contained in:
parent
fa7727d572
commit
295c6c1d9b
@ -476,13 +476,15 @@ final class iPhoneLibraryViewModel {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
let store = Self.makeRemoteLibraryStore()
|
let store = Self.makeRemoteLibraryStore()
|
||||||
|
let syncCursorStore = Self.makeRemoteLibrarySyncCursorStore()
|
||||||
let downloadStateStore = Self.makeRemoteTrackDownloadStateStore()
|
let downloadStateStore = Self.makeRemoteTrackDownloadStateStore()
|
||||||
let audioFileStore = Self.makeOfflineAudioFileStore()
|
let audioFileStore = Self.makeOfflineAudioFileStore()
|
||||||
let artworkStore = Self.makeArtworkStore()
|
let artworkStore = Self.makeArtworkStore()
|
||||||
let favoriteTrackStore = Self.makeFavoriteTrackStore()
|
let favoriteTrackStore = Self.makeFavoriteTrackStore()
|
||||||
let repository = DefaultRemoteLibraryRepository(
|
let repository = DefaultRemoteLibraryRepository(
|
||||||
apiClient: apiClient,
|
apiClient: apiClient,
|
||||||
store: store
|
store: store,
|
||||||
|
syncCursorStore: syncCursorStore
|
||||||
)
|
)
|
||||||
let syncService = RemoteLibrarySyncService(
|
let syncService = RemoteLibrarySyncService(
|
||||||
repository: repository,
|
repository: repository,
|
||||||
@ -726,6 +728,14 @@ final class iPhoneLibraryViewModel {
|
|||||||
return InMemoryRemoteTrackDownloadStateStore()
|
return InMemoryRemoteTrackDownloadStateStore()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func makeRemoteLibrarySyncCursorStore() -> any RemoteLibrarySyncCursorStore {
|
||||||
|
if let store = try? FileRemoteLibrarySyncCursorStore() {
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
return InMemoryRemoteLibrarySyncCursorStore()
|
||||||
|
}
|
||||||
|
|
||||||
private static func makeOfflineAudioFileStore() -> any OfflineAudioFileStore {
|
private static func makeOfflineAudioFileStore() -> any OfflineAudioFileStore {
|
||||||
if let store = try? FileOfflineAudioFileStore() {
|
if let store = try? FileOfflineAudioFileStore() {
|
||||||
return store
|
return store
|
||||||
|
|||||||
@ -153,6 +153,36 @@ final class iPhoneLibraryViewModelFavoritesTests: XCTestCase {
|
|||||||
XCTAssertTrue(try XCTUnwrap(remoteRow(in: viewModel, trackID: secondTrack.trackId)).isFavorite)
|
XCTAssertTrue(try XCTUnwrap(remoteRow(in: viewModel, trackID: secondTrack.trackId)).isFavorite)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testFavoritesRemainIntactAcrossLibrarySync() async throws {
|
||||||
|
let track = makeRemoteTrack(
|
||||||
|
trackId: "remote-sync-favorite",
|
||||||
|
assetId: "asset-sync-favorite",
|
||||||
|
title: "Sync Favorite"
|
||||||
|
)
|
||||||
|
let favoriteStore = InMemoryFavoriteTrackStore()
|
||||||
|
let viewModel = makeViewModel(
|
||||||
|
remoteTracks: [track],
|
||||||
|
downloadStates: [makeDownloadedState(for: track)],
|
||||||
|
favoriteTrackStore: favoriteStore,
|
||||||
|
audioFiles: [localFilePath(for: track): Data([0x5])],
|
||||||
|
apiClient: StubVelodyAPIClient(
|
||||||
|
environment: ServerEnvironment(
|
||||||
|
baseURL: ServerEnvironment.defaultLocalBaseURL,
|
||||||
|
appVersion: "Tests"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await viewModel.loadIfNeeded()
|
||||||
|
await viewModel.toggleFavorite(trackID: track.trackId)
|
||||||
|
await viewModel.refreshSync()
|
||||||
|
|
||||||
|
XCTAssertTrue(try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId)).isFavorite)
|
||||||
|
XCTAssertTrue(try XCTUnwrap(offlineRow(in: viewModel, trackID: track.trackId)).isFavorite)
|
||||||
|
let savedFavorites = try await favoriteStore.loadFavoriteTracks()
|
||||||
|
XCTAssertEqual(savedFavorites.map(\.remoteTrackId), [track.trackId])
|
||||||
|
}
|
||||||
|
|
||||||
func testToggleFavoriteRepeatedlyLeavesSingleStableRecord() async throws {
|
func testToggleFavoriteRepeatedlyLeavesSingleStableRecord() async throws {
|
||||||
let track = makeRemoteTrack(
|
let track = makeRemoteTrack(
|
||||||
trackId: "remote-repeat",
|
trackId: "remote-repeat",
|
||||||
@ -285,4 +315,41 @@ final class iPhoneLibraryViewModelFavoritesTests: XCTestCase {
|
|||||||
XCTAssertTrue(remoteTrack.isFavorite)
|
XCTAssertTrue(remoteTrack.isFavorite)
|
||||||
XCTAssertTrue(viewModel.availableOfflineTracks.isEmpty)
|
XCTAssertTrue(viewModel.availableOfflineTracks.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testFailedDownloadKeepsFavoriteAndShowsRetry() async throws {
|
||||||
|
let track = makeRemoteTrack(
|
||||||
|
trackId: "remote-interrupted-favorite",
|
||||||
|
assetId: "asset-interrupted-favorite",
|
||||||
|
title: "Interrupted Favorite"
|
||||||
|
)
|
||||||
|
let viewModel = makeViewModel(
|
||||||
|
remoteTracks: [track],
|
||||||
|
downloadStates: [
|
||||||
|
RemoteTrackDownloadState(
|
||||||
|
remoteTrackId: track.trackId,
|
||||||
|
assetId: track.assetId,
|
||||||
|
localFilePath: "",
|
||||||
|
downloadedAt: nil,
|
||||||
|
downloadStatus: .failed,
|
||||||
|
lastDownloadError: "The previous download did not finish. Try again."
|
||||||
|
),
|
||||||
|
],
|
||||||
|
favoriteTrackStore: InMemoryFavoriteTrackStore(tracks: [
|
||||||
|
FavoriteTrackRecord(
|
||||||
|
remoteTrackId: track.trackId,
|
||||||
|
favoritedAt: Date(timeIntervalSince1970: 7_000)
|
||||||
|
),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
|
||||||
|
await viewModel.loadIfNeeded()
|
||||||
|
|
||||||
|
let remoteTrack = try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId))
|
||||||
|
|
||||||
|
XCTAssertEqual(remoteTrack.status, .failed)
|
||||||
|
XCTAssertEqual(remoteTrack.downloadButtonTitle, "Retry")
|
||||||
|
XCTAssertTrue(remoteTrack.canDownload)
|
||||||
|
XCTAssertTrue(remoteTrack.isFavorite)
|
||||||
|
XCTAssertTrue(viewModel.availableOfflineTracks.isEmpty)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import VelodyDomain
|
|||||||
import VelodyNetworking
|
import VelodyNetworking
|
||||||
import VelodyPlayback
|
import VelodyPlayback
|
||||||
import VelodyPersistence
|
import VelodyPersistence
|
||||||
|
import VelodyUtilities
|
||||||
@testable import VelodyiPhone
|
@testable import VelodyiPhone
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@ -449,9 +450,35 @@ final class iPhoneLibraryViewModelPolishTests: XCTestCase {
|
|||||||
let remoteTrack = try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId))
|
let remoteTrack = try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId))
|
||||||
XCTAssertEqual(remoteTrack.status, .missing)
|
XCTAssertEqual(remoteTrack.status, .missing)
|
||||||
XCTAssertEqual(remoteTrack.statusBadgeTitle, "Missing")
|
XCTAssertEqual(remoteTrack.statusBadgeTitle, "Missing")
|
||||||
|
XCTAssertTrue(remoteTrack.canDownload)
|
||||||
|
XCTAssertEqual(remoteTrack.downloadButtonTitle, "Re-download")
|
||||||
|
XCTAssertFalse(remoteTrack.canPlay)
|
||||||
XCTAssertTrue(viewModel.availableOfflineTracks.isEmpty)
|
XCTAssertTrue(viewModel.availableOfflineTracks.isEmpty)
|
||||||
XCTAssertEqual(viewModel.availableOfflineSectionTitle, "Available Offline (0)")
|
XCTAssertEqual(viewModel.availableOfflineSectionTitle, "Available Offline (0)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testDownloadedTrackAppearsInAvailableOfflineState() async throws {
|
||||||
|
let track = makeRemoteTrack(
|
||||||
|
trackId: "remote-recovered-download",
|
||||||
|
assetId: "asset-recovered-download",
|
||||||
|
title: "Recovered Download"
|
||||||
|
)
|
||||||
|
let viewModel = makeViewModel(
|
||||||
|
remoteTracks: [track],
|
||||||
|
downloadStates: [makeDownloadedState(for: track)],
|
||||||
|
audioFiles: [localFilePath(for: track): Data([0x1, 0x2, 0x3])]
|
||||||
|
)
|
||||||
|
|
||||||
|
await viewModel.loadIfNeeded()
|
||||||
|
|
||||||
|
let remoteTrack = try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId))
|
||||||
|
let offlineTrack = try XCTUnwrap(offlineRow(in: viewModel, trackID: track.trackId))
|
||||||
|
|
||||||
|
XCTAssertEqual(remoteTrack.status, .downloaded)
|
||||||
|
XCTAssertEqual(remoteTrack.statusBadgeTitle, "Downloaded")
|
||||||
|
XCTAssertEqual(offlineTrack.statusBadgeTitle, "Downloaded")
|
||||||
|
XCTAssertEqual(viewModel.availableOfflineSectionTitle, "Available Offline (1)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@ -479,7 +506,8 @@ final class iPhoneLibraryViewModelDeviceAuthTests: XCTestCase {
|
|||||||
forKey: "velody.iphone.device-access-token"
|
forKey: "velody.iphone.device-access-token"
|
||||||
)
|
)
|
||||||
|
|
||||||
XCTAssertEqual(await counter.count, 1)
|
let registerCount = await counter.currentCount()
|
||||||
|
XCTAssertEqual(registerCount, 1)
|
||||||
XCTAssertFalse((storedDeviceID ?? "").isEmpty)
|
XCTAssertFalse((storedDeviceID ?? "").isEmpty)
|
||||||
XCTAssertFalse((storedDeviceAccessToken ?? "").isEmpty)
|
XCTAssertFalse((storedDeviceAccessToken ?? "").isEmpty)
|
||||||
}
|
}
|
||||||
@ -513,7 +541,8 @@ final class iPhoneLibraryViewModelDeviceAuthTests: XCTestCase {
|
|||||||
forKey: "velody.iphone.device-access-token"
|
forKey: "velody.iphone.device-access-token"
|
||||||
)
|
)
|
||||||
|
|
||||||
XCTAssertEqual(await counter.count, 1)
|
let registerCount = await counter.currentCount()
|
||||||
|
XCTAssertEqual(registerCount, 1)
|
||||||
XCTAssertNotEqual(storedDeviceID, legacyDeviceID)
|
XCTAssertNotEqual(storedDeviceID, legacyDeviceID)
|
||||||
XCTAssertFalse((storedDeviceAccessToken ?? "").isEmpty)
|
XCTAssertFalse((storedDeviceAccessToken ?? "").isEmpty)
|
||||||
}
|
}
|
||||||
@ -525,6 +554,10 @@ private actor RegisterCallCounter {
|
|||||||
func increment() {
|
func increment() {
|
||||||
count += 1
|
count += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func currentCount() -> Int {
|
||||||
|
count
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct TestRegisterAPIClient: VelodyAPIClient {
|
private struct TestRegisterAPIClient: VelodyAPIClient {
|
||||||
@ -563,6 +596,12 @@ private struct TestRegisterAPIClient: VelodyAPIClient {
|
|||||||
try await stubClient.fetchSyncBootstrap()
|
try await stubClient.fetchSyncBootstrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func fetchSyncChanges(
|
||||||
|
cursor: SyncCursor
|
||||||
|
) async throws -> SyncChangesResponse {
|
||||||
|
try await stubClient.fetchSyncChanges(cursor: cursor)
|
||||||
|
}
|
||||||
|
|
||||||
func fetchRemoteLibrary(
|
func fetchRemoteLibrary(
|
||||||
deviceId: String
|
deviceId: String
|
||||||
) async throws -> RemoteLibraryResponseDTO {
|
) async throws -> RemoteLibraryResponseDTO {
|
||||||
|
|||||||
@ -395,18 +395,7 @@
|
|||||||
"/api/v1/sync/bootstrap": {
|
"/api/v1/sync/bootstrap": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "SyncController_bootstrap_v1",
|
"operationId": "SyncController_bootstrap_v1",
|
||||||
"parameters": [
|
"parameters": [],
|
||||||
{
|
|
||||||
"name": "deviceId",
|
|
||||||
"required": false,
|
|
||||||
"in": "query",
|
|
||||||
"description": "Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access.",
|
|
||||||
"schema": {
|
|
||||||
"format": "uuid",
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "",
|
"description": "",
|
||||||
@ -434,23 +423,24 @@
|
|||||||
"operationId": "SyncController_changes_v1",
|
"operationId": "SyncController_changes_v1",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "deviceId",
|
"name": "cursor",
|
||||||
"required": false,
|
|
||||||
"in": "query",
|
|
||||||
"description": "Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access.",
|
|
||||||
"schema": {
|
|
||||||
"format": "uuid",
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "after",
|
|
||||||
"required": false,
|
"required": false,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
"schema": {
|
"schema": {
|
||||||
"example": "0",
|
"example": "0",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "limit",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 500,
|
||||||
|
"example": 100,
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@ -918,26 +908,37 @@
|
|||||||
"tracks"
|
"tracks"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"LibraryTrackDto": {
|
"SyncBootstrapResponseDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": {
|
"nextCursor": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "uuid"
|
"example": "7"
|
||||||
},
|
},
|
||||||
"title": {
|
"tracks": {
|
||||||
"type": "string",
|
"type": "array",
|
||||||
"example": "Placeholder Track"
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/RemoteLibraryTrackDto"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"artist": {
|
"serverTime": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"example": "Velody"
|
"example": "2026-06-15T12:00:00.000Z"
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"required": [
|
||||||
|
"nextCursor",
|
||||||
|
"tracks",
|
||||||
|
"serverTime"
|
||||||
|
]
|
||||||
|
},
|
||||||
"SyncEventDto": {
|
"SyncEventDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"cursor": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "3"
|
||||||
|
},
|
||||||
"entityType": {
|
"entityType": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"example": "TRACK"
|
"example": "TRACK"
|
||||||
@ -948,56 +949,33 @@
|
|||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"example": "CREATED"
|
"example": "UPDATED"
|
||||||
},
|
},
|
||||||
"eventId": {
|
"track": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "object",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/RemoteLibraryTrackDto"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"deletedTrackId": {
|
||||||
|
"type": "object",
|
||||||
|
"format": "uuid",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"example": "0"
|
"example": "2026-06-15T12:00:00.000Z"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
"cursor",
|
||||||
"entityType",
|
"entityType",
|
||||||
"entityId",
|
"entityId",
|
||||||
"action",
|
"action",
|
||||||
"eventId"
|
"createdAt"
|
||||||
]
|
|
||||||
},
|
|
||||||
"SyncBootstrapResponseDto": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"nextCursor": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "0"
|
|
||||||
},
|
|
||||||
"tracks": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/LibraryTrackDto"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"events": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/SyncEventDto"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"deletedTrackIds": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"serverTime": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "2026-05-24T20:00:00.000Z"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"nextCursor",
|
|
||||||
"tracks",
|
|
||||||
"events",
|
|
||||||
"deletedTrackIds",
|
|
||||||
"serverTime"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"SyncChangesResponseDto": {
|
"SyncChangesResponseDto": {
|
||||||
@ -1005,13 +983,20 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"nextCursor": {
|
"nextCursor": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"example": "0"
|
"example": "7"
|
||||||
},
|
},
|
||||||
"tracks": {
|
"hasMore": {
|
||||||
"type": "array",
|
"type": "boolean",
|
||||||
"items": {
|
"example": false
|
||||||
"$ref": "#/components/schemas/LibraryTrackDto"
|
},
|
||||||
}
|
"requiresBootstrap": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": false
|
||||||
|
},
|
||||||
|
"reason": {
|
||||||
|
"type": "object",
|
||||||
|
"nullable": true,
|
||||||
|
"example": "cursor_too_old"
|
||||||
},
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
@ -1019,22 +1004,16 @@
|
|||||||
"$ref": "#/components/schemas/SyncEventDto"
|
"$ref": "#/components/schemas/SyncEventDto"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"deletedTrackIds": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"serverTime": {
|
"serverTime": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"example": "2026-05-24T20:00:00.000Z"
|
"example": "2026-06-15T12:00:00.000Z"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"nextCursor",
|
"nextCursor",
|
||||||
"tracks",
|
"hasMore",
|
||||||
|
"requiresBootstrap",
|
||||||
"events",
|
"events",
|
||||||
"deletedTrackIds",
|
|
||||||
"serverTime"
|
"serverTime"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,128 @@
|
|||||||
|
ALTER TABLE "users"
|
||||||
|
ADD COLUMN "library_cursor" BIGINT NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
ALTER TABLE "library_events"
|
||||||
|
ADD COLUMN "cursor" BIGINT,
|
||||||
|
ADD COLUMN "payload" JSONB;
|
||||||
|
|
||||||
|
WITH ranked_events AS (
|
||||||
|
SELECT
|
||||||
|
"id",
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY "user_id"
|
||||||
|
ORDER BY "created_at" ASC, "id" ASC
|
||||||
|
)::BIGINT AS next_cursor
|
||||||
|
FROM "library_events"
|
||||||
|
)
|
||||||
|
UPDATE "library_events" AS "event"
|
||||||
|
SET "cursor" = ranked_events.next_cursor
|
||||||
|
FROM ranked_events
|
||||||
|
WHERE ranked_events."id" = "event"."id";
|
||||||
|
|
||||||
|
UPDATE "library_events" AS "event"
|
||||||
|
SET "payload" = CASE
|
||||||
|
WHEN track."status" = 'DELETED'::"TrackStatus" THEN jsonb_build_object(
|
||||||
|
'deletedTrackId',
|
||||||
|
track."id"
|
||||||
|
)
|
||||||
|
WHEN audio_asset."id" IS NOT NULL THEN jsonb_build_object(
|
||||||
|
'track',
|
||||||
|
jsonb_build_object(
|
||||||
|
'trackId',
|
||||||
|
track."id",
|
||||||
|
'title',
|
||||||
|
track."title",
|
||||||
|
'artist',
|
||||||
|
track."artist",
|
||||||
|
'durationSeconds',
|
||||||
|
GREATEST(
|
||||||
|
0,
|
||||||
|
ROUND(COALESCE(track."duration_ms", audio_asset."duration_ms", 0)::numeric / 1000.0)
|
||||||
|
)::INTEGER,
|
||||||
|
'sha256',
|
||||||
|
audio_asset."sha256",
|
||||||
|
'assetId',
|
||||||
|
audio_asset."id",
|
||||||
|
'createdAt',
|
||||||
|
to_char(track."created_at" AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
|
||||||
|
'updatedAt',
|
||||||
|
to_char(track."updated_at" AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
|
||||||
|
'artwork',
|
||||||
|
CASE
|
||||||
|
WHEN artwork_asset."id" IS NULL THEN NULL
|
||||||
|
ELSE jsonb_build_object(
|
||||||
|
'artworkId',
|
||||||
|
artwork_asset."id",
|
||||||
|
'sha256',
|
||||||
|
artwork_asset."sha256",
|
||||||
|
'mimeType',
|
||||||
|
artwork_asset."mime_type",
|
||||||
|
'width',
|
||||||
|
artwork_asset."width",
|
||||||
|
'height',
|
||||||
|
artwork_asset."height"
|
||||||
|
)
|
||||||
|
END
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ELSE '{}'::jsonb
|
||||||
|
END
|
||||||
|
FROM "tracks" AS track
|
||||||
|
LEFT JOIN "audio_assets" AS audio_asset
|
||||||
|
ON audio_asset."id" = track."primary_audio_asset_id"
|
||||||
|
LEFT JOIN "artwork_assets" AS artwork_asset
|
||||||
|
ON artwork_asset."id" = track."artwork_asset_id"
|
||||||
|
WHERE "event"."entity_type" = 'TRACK'::"EntityType"
|
||||||
|
AND "event"."entity_id" = track."id";
|
||||||
|
|
||||||
|
UPDATE "library_events"
|
||||||
|
SET "payload" = '{}'::jsonb
|
||||||
|
WHERE "payload" IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE "library_events"
|
||||||
|
ALTER COLUMN "cursor" SET NOT NULL,
|
||||||
|
ALTER COLUMN "payload" SET NOT NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "library_events_user_id_cursor_key"
|
||||||
|
ON "library_events"("user_id", "cursor");
|
||||||
|
|
||||||
|
CREATE INDEX "library_events_user_id_cursor_idx"
|
||||||
|
ON "library_events"("user_id", "cursor");
|
||||||
|
|
||||||
|
UPDATE "users" AS "user"
|
||||||
|
SET "library_cursor" = COALESCE(cursor_summary."max_cursor", 0)
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
"user_id",
|
||||||
|
MAX("cursor") AS "max_cursor"
|
||||||
|
FROM "library_events"
|
||||||
|
GROUP BY "user_id"
|
||||||
|
) AS cursor_summary
|
||||||
|
WHERE cursor_summary."user_id" = "user"."id";
|
||||||
|
|
||||||
|
ALTER TABLE "device_sync_cursors"
|
||||||
|
ADD COLUMN "user_id" UUID,
|
||||||
|
ADD COLUMN "cursor" BIGINT NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
UPDATE "device_sync_cursors" AS "sync_cursor"
|
||||||
|
SET
|
||||||
|
"user_id" = device."user_id",
|
||||||
|
"cursor" = "sync_cursor"."last_event_id"
|
||||||
|
FROM "devices" AS device
|
||||||
|
WHERE device."id" = "sync_cursor"."device_id";
|
||||||
|
|
||||||
|
ALTER TABLE "device_sync_cursors"
|
||||||
|
ALTER COLUMN "user_id" SET NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE "device_sync_cursors"
|
||||||
|
DROP COLUMN "last_event_id",
|
||||||
|
DROP COLUMN "last_full_sync_at";
|
||||||
|
|
||||||
|
CREATE INDEX "device_sync_cursors_user_id_idx"
|
||||||
|
ON "device_sync_cursors"("user_id");
|
||||||
|
|
||||||
|
ALTER TABLE "device_sync_cursors"
|
||||||
|
ADD CONSTRAINT "device_sync_cursors_user_id_fkey"
|
||||||
|
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||||||
|
ON DELETE CASCADE
|
||||||
|
ON UPDATE CASCADE;
|
||||||
@ -12,6 +12,7 @@ model User {
|
|||||||
slug String @unique
|
slug String @unique
|
||||||
displayName String @map("display_name")
|
displayName String @map("display_name")
|
||||||
isDefault Boolean @default(false) @map("is_default")
|
isDefault Boolean @default(false) @map("is_default")
|
||||||
|
libraryCursor BigInt @default(0) @map("library_cursor")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
devices Device[]
|
devices Device[]
|
||||||
@ -20,6 +21,7 @@ model User {
|
|||||||
artworkAssets ArtworkAsset[]
|
artworkAssets ArtworkAsset[]
|
||||||
uploadSessions UploadSession[]
|
uploadSessions UploadSession[]
|
||||||
libraryEvents LibraryEvent[]
|
libraryEvents LibraryEvent[]
|
||||||
|
syncCursors DeviceSyncCursor[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@ -145,24 +147,30 @@ model UploadSession {
|
|||||||
model LibraryEvent {
|
model LibraryEvent {
|
||||||
id BigInt @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
userId String @db.Uuid @map("user_id")
|
userId String @db.Uuid @map("user_id")
|
||||||
|
cursor BigInt
|
||||||
entityType EntityType @map("entity_type")
|
entityType EntityType @map("entity_type")
|
||||||
entityId String @db.Uuid @map("entity_id")
|
entityId String @db.Uuid @map("entity_id")
|
||||||
action EventAction
|
action EventAction
|
||||||
|
payload Json
|
||||||
payloadVersion Int @default(1) @map("payload_version")
|
payloadVersion Int @default(1) @map("payload_version")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
|
@@unique([userId, cursor])
|
||||||
|
@@index([userId, cursor])
|
||||||
@@map("library_events")
|
@@map("library_events")
|
||||||
}
|
}
|
||||||
|
|
||||||
model DeviceSyncCursor {
|
model DeviceSyncCursor {
|
||||||
deviceId String @id @db.Uuid @map("device_id")
|
deviceId String @id @db.Uuid @map("device_id")
|
||||||
lastEventId BigInt @default(0) @map("last_event_id")
|
userId String @db.Uuid @map("user_id")
|
||||||
lastFullSyncAt DateTime? @map("last_full_sync_at")
|
cursor BigInt @default(0)
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
@@map("device_sync_cursors")
|
@@map("device_sync_cursors")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -41,6 +41,12 @@ export class LibraryService {
|
|||||||
const { userId: ownerUserId } =
|
const { userId: ownerUserId } =
|
||||||
this.deviceAuthService.getAuthenticatedDeviceOrThrow();
|
this.deviceAuthService.getAuthenticatedDeviceOrThrow();
|
||||||
|
|
||||||
|
return this.getRemoteLibraryTracksForUser(ownerUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRemoteLibraryTracksForUser(
|
||||||
|
ownerUserId: string,
|
||||||
|
): Promise<RemoteLibraryTrackDto[]> {
|
||||||
const tracks = await this.prismaService.track.findMany({
|
const tracks = await this.prismaService.track.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId: ownerUserId,
|
userId: ownerUserId,
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
|||||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { DeviceAuthGuard } from '../auth/device-auth.guard';
|
import { DeviceAuthGuard } from '../auth/device-auth.guard';
|
||||||
import {
|
import {
|
||||||
SyncBootstrapQueryDto,
|
|
||||||
SyncBootstrapResponseDto,
|
SyncBootstrapResponseDto,
|
||||||
SyncChangesQueryDto,
|
SyncChangesQueryDto,
|
||||||
SyncChangesResponseDto,
|
SyncChangesResponseDto,
|
||||||
@ -21,9 +20,7 @@ export class SyncController {
|
|||||||
|
|
||||||
@Get('bootstrap')
|
@Get('bootstrap')
|
||||||
@ApiOkResponse({ type: SyncBootstrapResponseDto })
|
@ApiOkResponse({ type: SyncBootstrapResponseDto })
|
||||||
async bootstrap(
|
async bootstrap(): Promise<SyncBootstrapResponseDto> {
|
||||||
@Query() _query?: SyncBootstrapQueryDto,
|
|
||||||
): Promise<SyncBootstrapResponseDto> {
|
|
||||||
return this.syncService.bootstrap();
|
return this.syncService.bootstrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -32,6 +29,6 @@ export class SyncController {
|
|||||||
async changes(
|
async changes(
|
||||||
@Query() query: SyncChangesQueryDto,
|
@Query() query: SyncChangesQueryDto,
|
||||||
): Promise<SyncChangesResponseDto> {
|
): Promise<SyncChangesResponseDto> {
|
||||||
return this.syncService.changes(query.after ?? '0');
|
return this.syncService.changes(query.cursor ?? '0', query.limit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { IsOptional, IsString, IsUUID, Matches } from 'class-validator';
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsInt, IsOptional, IsString, Matches, Max, Min } from 'class-validator';
|
||||||
|
import { RemoteLibraryTrackDto } from '../library/library.dto';
|
||||||
|
|
||||||
export class LibraryTrackDto {
|
export class LibraryTrackDto {
|
||||||
@ApiProperty({ format: 'uuid', required: false })
|
@ApiProperty({ format: 'uuid', required: false })
|
||||||
@ -13,54 +15,83 @@ export class LibraryTrackDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class SyncEventDto {
|
export class SyncEventDto {
|
||||||
|
@ApiProperty({ example: '3' })
|
||||||
|
cursor!: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'TRACK' })
|
@ApiProperty({ example: 'TRACK' })
|
||||||
entityType!: string;
|
entityType!: string;
|
||||||
|
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ format: 'uuid' })
|
||||||
entityId!: string;
|
entityId!: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'CREATED' })
|
@ApiProperty({ example: 'UPDATED' })
|
||||||
action!: string;
|
action!: string;
|
||||||
|
|
||||||
@ApiProperty({ example: '0' })
|
@ApiProperty({
|
||||||
eventId!: string;
|
type: RemoteLibraryTrackDto,
|
||||||
}
|
required: false,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
track!: RemoteLibraryTrackDto | null;
|
||||||
|
|
||||||
export class SyncBootstrapResponseDto {
|
|
||||||
@ApiProperty({ example: '0' })
|
|
||||||
nextCursor!: string;
|
|
||||||
|
|
||||||
@ApiProperty({ type: [LibraryTrackDto] })
|
|
||||||
tracks!: LibraryTrackDto[];
|
|
||||||
|
|
||||||
@ApiProperty({ type: [SyncEventDto] })
|
|
||||||
events!: SyncEventDto[];
|
|
||||||
|
|
||||||
@ApiProperty({ type: [String] })
|
|
||||||
deletedTrackIds!: string[];
|
|
||||||
|
|
||||||
@ApiProperty({ example: '2026-05-24T20:00:00.000Z' })
|
|
||||||
serverTime!: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SyncBootstrapQueryDto {
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
format: 'uuid',
|
format: 'uuid',
|
||||||
required: false,
|
required: false,
|
||||||
description:
|
nullable: true,
|
||||||
'Optional client metadata. Authorization: Bearer <deviceAccessToken> is required and determines access.',
|
|
||||||
})
|
})
|
||||||
@IsOptional()
|
deletedTrackId!: string | null;
|
||||||
@IsUUID()
|
|
||||||
deviceId?: string;
|
@ApiProperty({ example: '2026-06-15T12:00:00.000Z' })
|
||||||
|
createdAt!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SyncChangesQueryDto extends SyncBootstrapQueryDto {
|
export class SyncBootstrapResponseDto {
|
||||||
|
@ApiProperty({ example: '7' })
|
||||||
|
nextCursor!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [RemoteLibraryTrackDto] })
|
||||||
|
tracks!: RemoteLibraryTrackDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ example: '2026-06-15T12:00:00.000Z' })
|
||||||
|
serverTime!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SyncChangesQueryDto {
|
||||||
@ApiProperty({ required: false, example: '0' })
|
@ApiProperty({ required: false, example: '0' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@Matches(/^\d+$/)
|
@Matches(/^\d+$/)
|
||||||
after?: string;
|
cursor?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, example: 100, minimum: 1, maximum: 500 })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(500)
|
||||||
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SyncChangesResponseDto extends SyncBootstrapResponseDto {}
|
export class SyncChangesResponseDto {
|
||||||
|
@ApiProperty({ example: '7' })
|
||||||
|
nextCursor!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: false })
|
||||||
|
hasMore!: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ example: false })
|
||||||
|
requiresBootstrap!: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
required: false,
|
||||||
|
nullable: true,
|
||||||
|
example: 'cursor_too_old',
|
||||||
|
})
|
||||||
|
reason!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [SyncEventDto] })
|
||||||
|
events!: SyncEventDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ example: '2026-06-15T12:00:00.000Z' })
|
||||||
|
serverTime!: string;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,58 +1,300 @@
|
|||||||
import { Test } from '@nestjs/testing';
|
|
||||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
|
||||||
import { LibraryService } from '../library/library.service';
|
|
||||||
import { OwnerContext } from '../users/owner-context.service';
|
|
||||||
import { SyncService } from './sync.service';
|
import { SyncService } from './sync.service';
|
||||||
|
|
||||||
|
function makeEvent(params: {
|
||||||
|
userId: string;
|
||||||
|
cursor: bigint;
|
||||||
|
entityType?: string;
|
||||||
|
entityId?: string;
|
||||||
|
action?: string;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
|
createdAt?: Date;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
id: params.cursor,
|
||||||
|
userId: params.userId,
|
||||||
|
cursor: params.cursor,
|
||||||
|
entityType: params.entityType ?? 'TRACK',
|
||||||
|
entityId: params.entityId ?? `track-${params.cursor.toString()}`,
|
||||||
|
action: params.action ?? 'UPDATED',
|
||||||
|
payloadVersion: 1,
|
||||||
|
payload: params.payload ?? {},
|
||||||
|
createdAt:
|
||||||
|
params.createdAt ??
|
||||||
|
new Date(`2026-06-15T12:00:0${params.cursor.toString()}.000Z`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('SyncService', () => {
|
describe('SyncService', () => {
|
||||||
it('uses OwnerContext to scope the bootstrap cursor lookup', async () => {
|
it('returns the bootstrap snapshot and persists the device cursor', async () => {
|
||||||
const ownerContextMock = {
|
const upsert = jest.fn();
|
||||||
resolve: jest.fn().mockResolvedValue({
|
const service = new SyncService(
|
||||||
userId: 'bootstrap-owner-id',
|
{
|
||||||
}),
|
|
||||||
};
|
|
||||||
const prismaMock = {
|
|
||||||
libraryEvent: {
|
libraryEvent: {
|
||||||
findFirst: jest.fn().mockResolvedValue({
|
findFirst: jest.fn().mockResolvedValue({ cursor: 7n }),
|
||||||
id: 7n,
|
},
|
||||||
|
deviceSyncCursor: {
|
||||||
|
upsert,
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
{
|
||||||
|
getRemoteLibraryTracksForUser: jest.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
trackId: 'track-123',
|
||||||
|
title: 'Remote Title',
|
||||||
|
artist: 'Remote Artist',
|
||||||
|
durationSeconds: 245,
|
||||||
|
sha256: 'a'.repeat(64),
|
||||||
|
assetId: 'asset-123',
|
||||||
|
createdAt: '2026-06-15T10:00:00.000Z',
|
||||||
|
updatedAt: '2026-06-15T10:05:00.000Z',
|
||||||
|
artwork: null,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
} as any,
|
||||||
|
{
|
||||||
|
getAuthenticatedDeviceOrThrow: jest.fn().mockReturnValue({
|
||||||
|
deviceId: 'device-123',
|
||||||
|
userId: 'owner-123',
|
||||||
}),
|
}),
|
||||||
},
|
} as any,
|
||||||
};
|
);
|
||||||
const libraryServiceMock = {
|
|
||||||
getBootstrapTracks: jest.fn().mockResolvedValue([]),
|
|
||||||
};
|
|
||||||
|
|
||||||
const moduleRef = await Test.createTestingModule({
|
await expect(service.bootstrap()).resolves.toMatchObject({
|
||||||
providers: [
|
|
||||||
SyncService,
|
|
||||||
{
|
|
||||||
provide: PrismaService,
|
|
||||||
useValue: prismaMock,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: LibraryService,
|
|
||||||
useValue: libraryServiceMock,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: OwnerContext,
|
|
||||||
useValue: ownerContextMock,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
const service = moduleRef.get(SyncService);
|
|
||||||
|
|
||||||
await expect(service.changes('0')).resolves.toMatchObject({
|
|
||||||
nextCursor: '7',
|
nextCursor: '7',
|
||||||
|
tracks: [
|
||||||
|
expect.objectContaining({
|
||||||
|
trackId: 'track-123',
|
||||||
|
}),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
expect(ownerContextMock.resolve).toHaveBeenCalledTimes(1);
|
expect(upsert).toHaveBeenCalledWith({
|
||||||
expect(prismaMock.libraryEvent.findFirst).toHaveBeenCalledWith({
|
|
||||||
where: {
|
where: {
|
||||||
userId: 'bootstrap-owner-id',
|
deviceId: 'device-123',
|
||||||
},
|
},
|
||||||
orderBy: {
|
update: {
|
||||||
id: 'desc',
|
userId: 'owner-123',
|
||||||
|
cursor: 7n,
|
||||||
},
|
},
|
||||||
|
create: {
|
||||||
|
deviceId: 'device-123',
|
||||||
|
userId: 'owner-123',
|
||||||
|
cursor: 7n,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns ordered changes after the requested cursor', async () => {
|
||||||
|
const ownerId = 'owner-123';
|
||||||
|
const foreignId = 'owner-999';
|
||||||
|
const events = [
|
||||||
|
makeEvent({
|
||||||
|
userId: foreignId,
|
||||||
|
cursor: 1n,
|
||||||
|
}),
|
||||||
|
makeEvent({
|
||||||
|
userId: ownerId,
|
||||||
|
cursor: 2n,
|
||||||
|
payload: {
|
||||||
|
track: {
|
||||||
|
trackId: 'track-2',
|
||||||
|
title: 'Two',
|
||||||
|
artist: 'Owner',
|
||||||
|
durationSeconds: 200,
|
||||||
|
sha256: 'b'.repeat(64),
|
||||||
|
assetId: 'asset-2',
|
||||||
|
createdAt: '2026-06-15T10:00:02.000Z',
|
||||||
|
updatedAt: '2026-06-15T10:00:02.000Z',
|
||||||
|
artwork: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
makeEvent({
|
||||||
|
userId: ownerId,
|
||||||
|
cursor: 3n,
|
||||||
|
action: 'DELETED',
|
||||||
|
payload: {
|
||||||
|
deletedTrackId: 'track-3',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const upsert = jest.fn();
|
||||||
|
const findFirst = jest.fn().mockImplementation(async ({ where, orderBy }) => {
|
||||||
|
const filteredEvents = events.filter((event) => event.userId === where.userId);
|
||||||
|
const direction = orderBy.cursor;
|
||||||
|
const sortedEvents = [...filteredEvents].sort((lhs, rhs) =>
|
||||||
|
direction === 'asc'
|
||||||
|
? Number(lhs.cursor - rhs.cursor)
|
||||||
|
: Number(rhs.cursor - lhs.cursor),
|
||||||
|
);
|
||||||
|
return sortedEvents[0] ?? null;
|
||||||
|
});
|
||||||
|
const findMany = jest.fn().mockImplementation(async ({ where, take }) => {
|
||||||
|
return events
|
||||||
|
.filter(
|
||||||
|
(event) =>
|
||||||
|
event.userId === where.userId && event.cursor > where.cursor.gt,
|
||||||
|
)
|
||||||
|
.sort((lhs, rhs) => Number(lhs.cursor - rhs.cursor))
|
||||||
|
.slice(0, take);
|
||||||
|
});
|
||||||
|
const service = new SyncService(
|
||||||
|
{
|
||||||
|
libraryEvent: {
|
||||||
|
findFirst,
|
||||||
|
findMany,
|
||||||
|
},
|
||||||
|
deviceSyncCursor: {
|
||||||
|
upsert,
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
{} as any,
|
||||||
|
{
|
||||||
|
getAuthenticatedDeviceOrThrow: jest.fn().mockReturnValue({
|
||||||
|
deviceId: 'device-123',
|
||||||
|
userId: ownerId,
|
||||||
|
}),
|
||||||
|
} as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await service.changes('1');
|
||||||
|
|
||||||
|
expect(response.requiresBootstrap).toBe(false);
|
||||||
|
expect(response.hasMore).toBe(false);
|
||||||
|
expect(response.nextCursor).toBe('3');
|
||||||
|
expect(response.events.map((event) => event.cursor)).toEqual(['2', '3']);
|
||||||
|
expect(response.events[0]?.track?.trackId).toBe('track-2');
|
||||||
|
expect(response.events[1]?.deletedTrackId).toBe('track-3');
|
||||||
|
expect(upsert).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
deviceId: 'device-123',
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
userId: ownerId,
|
||||||
|
cursor: 3n,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
deviceId: 'device-123',
|
||||||
|
userId: ownerId,
|
||||||
|
cursor: 3n,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('paginates deterministically and reports hasMore', async () => {
|
||||||
|
jest.useFakeTimers().setSystemTime(new Date('2026-06-15T08:24:36.011Z'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ownerId = 'owner-123';
|
||||||
|
const events = [1n, 2n, 3n].map((cursor) =>
|
||||||
|
makeEvent({
|
||||||
|
userId: ownerId,
|
||||||
|
cursor,
|
||||||
|
payload: {
|
||||||
|
track: {
|
||||||
|
trackId: `track-${cursor.toString()}`,
|
||||||
|
title: `Track ${cursor.toString()}`,
|
||||||
|
artist: 'Owner',
|
||||||
|
durationSeconds: 180,
|
||||||
|
sha256: 'c'.repeat(64),
|
||||||
|
assetId: `asset-${cursor.toString()}`,
|
||||||
|
createdAt: '2026-06-15T10:00:00.000Z',
|
||||||
|
updatedAt: '2026-06-15T10:00:00.000Z',
|
||||||
|
artwork: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const findFirst = jest.fn().mockImplementation(async ({ where, orderBy }) => {
|
||||||
|
const filteredEvents = events.filter((event) => event.userId === where.userId);
|
||||||
|
const direction = orderBy.cursor;
|
||||||
|
const sortedEvents = [...filteredEvents].sort((lhs, rhs) =>
|
||||||
|
direction === 'asc'
|
||||||
|
? Number(lhs.cursor - rhs.cursor)
|
||||||
|
: Number(rhs.cursor - lhs.cursor),
|
||||||
|
);
|
||||||
|
return sortedEvents[0] ?? null;
|
||||||
|
});
|
||||||
|
const findMany = jest.fn().mockImplementation(async ({ where, take }) => {
|
||||||
|
return events
|
||||||
|
.filter(
|
||||||
|
(event) =>
|
||||||
|
event.userId === where.userId && event.cursor > where.cursor.gt,
|
||||||
|
)
|
||||||
|
.sort((lhs, rhs) => Number(lhs.cursor - rhs.cursor))
|
||||||
|
.slice(0, take);
|
||||||
|
});
|
||||||
|
const upsert = jest.fn();
|
||||||
|
const service = new SyncService(
|
||||||
|
{
|
||||||
|
libraryEvent: {
|
||||||
|
findFirst,
|
||||||
|
findMany,
|
||||||
|
},
|
||||||
|
deviceSyncCursor: {
|
||||||
|
upsert,
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
{} as any,
|
||||||
|
{
|
||||||
|
getAuthenticatedDeviceOrThrow: jest.fn().mockReturnValue({
|
||||||
|
deviceId: 'device-123',
|
||||||
|
userId: ownerId,
|
||||||
|
}),
|
||||||
|
} as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstResponse = await service.changes('0', 2);
|
||||||
|
const replayResponse = await service.changes('0', 2);
|
||||||
|
|
||||||
|
expect(firstResponse.hasMore).toBe(true);
|
||||||
|
expect(firstResponse.nextCursor).toBe('2');
|
||||||
|
expect(firstResponse.events.map((event) => event.cursor)).toEqual(['1', '2']);
|
||||||
|
expect(replayResponse).toEqual(firstResponse);
|
||||||
|
} finally {
|
||||||
|
jest.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires bootstrap when the requested cursor is older than retained history', async () => {
|
||||||
|
const service = new SyncService(
|
||||||
|
{
|
||||||
|
libraryEvent: {
|
||||||
|
findFirst: jest.fn().mockImplementation(async ({ where, orderBy }) => {
|
||||||
|
if (orderBy.cursor === 'asc') {
|
||||||
|
return makeEvent({
|
||||||
|
userId: where.userId,
|
||||||
|
cursor: 5n,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return makeEvent({
|
||||||
|
userId: where.userId,
|
||||||
|
cursor: 9n,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
findMany: jest.fn(),
|
||||||
|
},
|
||||||
|
deviceSyncCursor: {
|
||||||
|
upsert: jest.fn(),
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
{} as any,
|
||||||
|
{
|
||||||
|
getAuthenticatedDeviceOrThrow: jest.fn().mockReturnValue({
|
||||||
|
deviceId: 'device-123',
|
||||||
|
userId: 'owner-123',
|
||||||
|
}),
|
||||||
|
} as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(service.changes('3')).resolves.toMatchObject({
|
||||||
|
requiresBootstrap: true,
|
||||||
|
reason: 'cursor_too_old',
|
||||||
|
events: [],
|
||||||
|
hasMore: false,
|
||||||
|
nextCursor: '3',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,57 +1,209 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
|
import { DeviceAuthService } from '../auth/device-auth.service';
|
||||||
|
import { RemoteLibraryTrackDto } from '../library/library.dto';
|
||||||
import { LibraryService } from '../library/library.service';
|
import { LibraryService } from '../library/library.service';
|
||||||
import { OwnerContext } from '../users/owner-context.service';
|
import {
|
||||||
import { SyncBootstrapResponseDto, SyncChangesResponseDto } from './sync.dto';
|
SyncBootstrapResponseDto,
|
||||||
|
SyncChangesResponseDto,
|
||||||
|
SyncEventDto,
|
||||||
|
} from './sync.dto';
|
||||||
|
|
||||||
|
interface LibraryEventPayload {
|
||||||
|
track?: RemoteLibraryTrackDto | null;
|
||||||
|
deletedTrackId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SYNC_PAGE_SIZE = 100;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SyncService {
|
export class SyncService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly libraryService: LibraryService,
|
private readonly libraryService: LibraryService,
|
||||||
private readonly ownerContext: OwnerContext,
|
private readonly deviceAuthService: DeviceAuthService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async bootstrap(): Promise<SyncBootstrapResponseDto> {
|
async bootstrap(): Promise<SyncBootstrapResponseDto> {
|
||||||
const latestCursor = await this.getLatestCursor();
|
const device = this.deviceAuthService.getAuthenticatedDeviceOrThrow();
|
||||||
|
const [tracks, latestCursor] = await Promise.all([
|
||||||
|
this.libraryService.getRemoteLibraryTracksForUser(device.userId),
|
||||||
|
this.getLatestCursor(device.userId),
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
const response: SyncBootstrapResponseDto = {
|
||||||
nextCursor: latestCursor,
|
nextCursor: latestCursor,
|
||||||
tracks: await this.libraryService.getBootstrapTracks(),
|
tracks,
|
||||||
events: [],
|
|
||||||
deletedTrackIds: [],
|
|
||||||
serverTime: new Date().toISOString(),
|
serverTime: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
await this.updateDeviceSyncCursor(
|
||||||
|
device.deviceId,
|
||||||
|
device.userId,
|
||||||
|
response.nextCursor,
|
||||||
|
);
|
||||||
|
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async changes(after: string): Promise<SyncChangesResponseDto> {
|
async changes(
|
||||||
const latestCursor = await this.getLatestCursor();
|
cursor: string,
|
||||||
const normalizedCursor =
|
limit = DEFAULT_SYNC_PAGE_SIZE,
|
||||||
BigInt(latestCursor) > BigInt(after) ? latestCursor : after;
|
): Promise<SyncChangesResponseDto> {
|
||||||
|
const device = this.deviceAuthService.getAuthenticatedDeviceOrThrow();
|
||||||
|
const requestedCursor = BigInt(cursor);
|
||||||
|
const earliestRetainedCursor = await this.getEarliestRetainedCursor(
|
||||||
|
device.userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
requestedCursor > 0n &&
|
||||||
|
earliestRetainedCursor !== null &&
|
||||||
|
requestedCursor < earliestRetainedCursor - 1n
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
nextCursor: normalizedCursor,
|
nextCursor: cursor,
|
||||||
tracks: [],
|
hasMore: false,
|
||||||
|
requiresBootstrap: true,
|
||||||
|
reason: 'cursor_too_old',
|
||||||
events: [],
|
events: [],
|
||||||
deletedTrackIds: [],
|
|
||||||
serverTime: new Date().toISOString(),
|
serverTime: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getLatestCursor(): Promise<string> {
|
const events = await this.prismaService.libraryEvent.findMany({
|
||||||
const owner = await this.ownerContext.resolve({
|
|
||||||
allowLegacyDeviceFallback: false,
|
|
||||||
allowBootstrapFallback: false,
|
|
||||||
});
|
|
||||||
const latest = await this.prismaService.libraryEvent.findFirst({
|
|
||||||
where: {
|
where: {
|
||||||
userId: owner.userId,
|
userId: device.userId,
|
||||||
|
cursor: {
|
||||||
|
gt: requestedCursor,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
id: 'desc',
|
cursor: 'asc',
|
||||||
|
},
|
||||||
|
take: limit + 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasMore = events.length > limit;
|
||||||
|
const visibleEvents = hasMore ? events.slice(0, limit) : events;
|
||||||
|
const nextCursor =
|
||||||
|
visibleEvents.at(-1)?.cursor.toString() ?? requestedCursor.toString();
|
||||||
|
|
||||||
|
const response: SyncChangesResponseDto = {
|
||||||
|
nextCursor,
|
||||||
|
hasMore,
|
||||||
|
requiresBootstrap: false,
|
||||||
|
reason: null,
|
||||||
|
events: visibleEvents.map((event) => this.toSyncEventDto(event)),
|
||||||
|
serverTime: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.updateDeviceSyncCursor(
|
||||||
|
device.deviceId,
|
||||||
|
device.userId,
|
||||||
|
response.nextCursor,
|
||||||
|
);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getLatestCursor(userId: string): Promise<string> {
|
||||||
|
const latest = await this.prismaService.libraryEvent.findFirst({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
cursor: 'desc',
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
cursor: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return latest?.id.toString() ?? '0';
|
return latest?.cursor.toString() ?? '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getEarliestRetainedCursor(
|
||||||
|
userId: string,
|
||||||
|
): Promise<bigint | null> {
|
||||||
|
const earliest = await this.prismaService.libraryEvent.findFirst({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
cursor: 'asc',
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
cursor: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return earliest?.cursor ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateDeviceSyncCursor(
|
||||||
|
deviceId: string,
|
||||||
|
userId: string,
|
||||||
|
cursor: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.prismaService.deviceSyncCursor.upsert({
|
||||||
|
where: {
|
||||||
|
deviceId,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
userId,
|
||||||
|
cursor: BigInt(cursor),
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
deviceId,
|
||||||
|
userId,
|
||||||
|
cursor: BigInt(cursor),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private toSyncEventDto(event: {
|
||||||
|
cursor: bigint;
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
action: string;
|
||||||
|
payload: unknown;
|
||||||
|
createdAt: Date;
|
||||||
|
}): SyncEventDto {
|
||||||
|
const payload = this.parsePayload(event.payload);
|
||||||
|
const deletedTrackId =
|
||||||
|
payload.deletedTrackId ??
|
||||||
|
(event.action === 'DELETED' && event.entityType === 'TRACK'
|
||||||
|
? event.entityId
|
||||||
|
: null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cursor: event.cursor.toString(),
|
||||||
|
entityType: event.entityType,
|
||||||
|
entityId: event.entityId,
|
||||||
|
action: event.action,
|
||||||
|
track: payload.track ?? null,
|
||||||
|
deletedTrackId,
|
||||||
|
createdAt: event.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private parsePayload(payload: unknown): LibraryEventPayload {
|
||||||
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = payload as Record<string, unknown>;
|
||||||
|
const track =
|
||||||
|
record.track && typeof record.track === 'object' && !Array.isArray(record.track)
|
||||||
|
? (record.track as RemoteLibraryTrackDto)
|
||||||
|
: null;
|
||||||
|
const deletedTrackId =
|
||||||
|
typeof record.deletedTrackId === 'string' ? record.deletedTrackId : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
track,
|
||||||
|
deletedTrackId,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,6 +28,7 @@ function createPrismaMock() {
|
|||||||
slug: 'default-owner',
|
slug: 'default-owner',
|
||||||
displayName: 'Default Owner',
|
displayName: 'Default Owner',
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
|
libraryCursor: 0n,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
@ -38,6 +39,24 @@ function createPrismaMock() {
|
|||||||
$transaction: jest.fn().mockImplementation(async (callback: any) => callback(prismaMock)),
|
$transaction: jest.fn().mockImplementation(async (callback: any) => callback(prismaMock)),
|
||||||
user: {
|
user: {
|
||||||
upsert: jest.fn().mockResolvedValue(defaultUser),
|
upsert: jest.fn().mockResolvedValue(defaultUser),
|
||||||
|
update: jest.fn().mockImplementation(async ({ where, data, select }) => {
|
||||||
|
const current = users.get(where.id);
|
||||||
|
const incrementBy = BigInt(data.libraryCursor?.increment ?? 0);
|
||||||
|
const updated = {
|
||||||
|
...current,
|
||||||
|
libraryCursor: BigInt(current.libraryCursor ?? 0) + incrementBy,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
users.set(where.id, updated);
|
||||||
|
|
||||||
|
if (select?.libraryCursor) {
|
||||||
|
return {
|
||||||
|
libraryCursor: updated.libraryCursor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
device: {
|
device: {
|
||||||
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
||||||
@ -218,11 +237,17 @@ function createPrismaMock() {
|
|||||||
nextLibraryEventId += 1n;
|
nextLibraryEventId += 1n;
|
||||||
return record;
|
return record;
|
||||||
}),
|
}),
|
||||||
findFirst: jest.fn().mockImplementation(async ({ where }) => {
|
findFirst: jest.fn().mockImplementation(async ({ where, orderBy }) => {
|
||||||
const filteredEvents = [...libraryEvents.values()].filter((event) =>
|
const filteredEvents = [...libraryEvents.values()].filter((event) =>
|
||||||
where?.userId ? event.userId === where.userId : true,
|
where?.userId ? event.userId === where.userId : true,
|
||||||
);
|
);
|
||||||
return filteredEvents.sort((lhs, rhs) => Number(rhs.id - lhs.id))[0] ?? null;
|
const direction = orderBy?.cursor ?? 'desc';
|
||||||
|
return filteredEvents
|
||||||
|
.sort((lhs, rhs) =>
|
||||||
|
direction === 'asc'
|
||||||
|
? Number(lhs.cursor - rhs.cursor)
|
||||||
|
: Number(rhs.cursor - lhs.cursor),
|
||||||
|
)[0] ?? null;
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
state: {
|
state: {
|
||||||
@ -463,15 +488,27 @@ describe('UploadsService', () => {
|
|||||||
expect(finalizeResponse.assetId).toBeDefined();
|
expect(finalizeResponse.assetId).toBeDefined();
|
||||||
expect(state.tracks.size).toBe(1);
|
expect(state.tracks.size).toBe(1);
|
||||||
expect(state.audioAssets.size).toBe(1);
|
expect(state.audioAssets.size).toBe(1);
|
||||||
expect(state.libraryEvents.size).toBe(1);
|
expect(state.libraryEvents.size).toBe(2);
|
||||||
|
|
||||||
const track = [...state.tracks.values()][0];
|
const track = [...state.tracks.values()][0];
|
||||||
const audioAsset = [...state.audioAssets.values()][0];
|
const audioAsset = [...state.audioAssets.values()][0];
|
||||||
const libraryEvent = [...state.libraryEvents.values()][0];
|
const libraryEvents = [...state.libraryEvents.values()].sort((lhs, rhs) =>
|
||||||
|
Number(lhs.cursor - rhs.cursor),
|
||||||
|
);
|
||||||
|
|
||||||
expect(track.userId).toBe(state.defaultUser.id);
|
expect(track.userId).toBe(state.defaultUser.id);
|
||||||
expect(audioAsset.userId).toBe(state.defaultUser.id);
|
expect(audioAsset.userId).toBe(state.defaultUser.id);
|
||||||
expect(libraryEvent.userId).toBe(state.defaultUser.id);
|
expect(libraryEvents.map((event) => event.entityType)).toEqual([
|
||||||
|
'TRACK',
|
||||||
|
'AUDIO_ASSET',
|
||||||
|
]);
|
||||||
|
expect(libraryEvents.map((event) => event.action)).toEqual([
|
||||||
|
'CREATED',
|
||||||
|
'CREATED',
|
||||||
|
]);
|
||||||
|
expect(libraryEvents[0]?.payload.track.trackId).toBe(track.id);
|
||||||
|
expect(libraryEvents[1]?.payload.track.assetId).toBe(audioAsset.id);
|
||||||
|
expect(state.users.get(state.defaultUser.id)?.libraryCursor).toBe(2n);
|
||||||
|
|
||||||
const session = state.uploadSessions.get(response.uploadId!);
|
const session = state.uploadSessions.get(response.uploadId!);
|
||||||
expect(session.finalizedAt).toBeInstanceOf(Date);
|
expect(session.finalizedAt).toBeInstanceOf(Date);
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
EntityType,
|
EntityType,
|
||||||
EventAction,
|
EventAction,
|
||||||
|
Prisma,
|
||||||
type UploadSession,
|
type UploadSession,
|
||||||
UploadSessionStatus,
|
UploadSessionStatus,
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
@ -18,6 +19,7 @@ import { extname } from 'node:path';
|
|||||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
import { AppConfigService } from '../config/config.service';
|
import { AppConfigService } from '../config/config.service';
|
||||||
import { DeviceAuthService } from '../auth/device-auth.service';
|
import { DeviceAuthService } from '../auth/device-auth.service';
|
||||||
|
import { RemoteLibraryTrackDto } from '../library/library.dto';
|
||||||
import { LocalFilesystemStorageService } from '../storage/storage.service';
|
import { LocalFilesystemStorageService } from '../storage/storage.service';
|
||||||
import { OwnerContext } from '../users/owner-context.service';
|
import { OwnerContext } from '../users/owner-context.service';
|
||||||
import {
|
import {
|
||||||
@ -38,6 +40,11 @@ interface PreparedArtworkAssetInput {
|
|||||||
fileSizeBytes: bigint;
|
fileSizeBytes: bigint;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LibraryEventPayload {
|
||||||
|
track?: RemoteLibraryTrackDto;
|
||||||
|
deletedTrackId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UploadsService {
|
export class UploadsService {
|
||||||
constructor(
|
constructor(
|
||||||
@ -367,6 +374,8 @@ export class UploadsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const createdTrack = !track;
|
const createdTrack = !track;
|
||||||
|
let trackMetadataChanged = false;
|
||||||
|
|
||||||
if (!track) {
|
if (!track) {
|
||||||
track = await tx.track.create({
|
track = await tx.track.create({
|
||||||
data: {
|
data: {
|
||||||
@ -378,8 +387,34 @@ export class UploadsService {
|
|||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
const nextTrackDurationMs = body.durationMs ?? track.durationMs;
|
||||||
|
const shouldUpdateTrack =
|
||||||
|
track.title !== title ||
|
||||||
|
track.artist !== artist ||
|
||||||
|
(track.album ?? null) !== album ||
|
||||||
|
(track.durationMs ?? null) !== (nextTrackDurationMs ?? null) ||
|
||||||
|
track.status !== 'ACTIVE' ||
|
||||||
|
track.deletedAt !== null;
|
||||||
|
|
||||||
|
if (shouldUpdateTrack) {
|
||||||
|
track = await tx.track.update({
|
||||||
|
where: { id: track.id },
|
||||||
|
data: {
|
||||||
|
title,
|
||||||
|
artist,
|
||||||
|
album,
|
||||||
|
durationMs: nextTrackDurationMs,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
trackMetadataChanged = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createdAudioAsset = !audioAsset;
|
||||||
|
let audioAssetChanged = createdAudioAsset;
|
||||||
if (audioAsset) {
|
if (audioAsset) {
|
||||||
const nextDurationMs = body.durationMs ?? audioAsset.durationMs;
|
const nextDurationMs = body.durationMs ?? audioAsset.durationMs;
|
||||||
const shouldUpdateAsset =
|
const shouldUpdateAsset =
|
||||||
@ -404,6 +439,7 @@ export class UploadsService {
|
|||||||
durationMs: nextDurationMs,
|
durationMs: nextDurationMs,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
audioAssetChanged = true;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
audioAsset = await tx.audioAsset.create({
|
audioAsset = await tx.audioAsset.create({
|
||||||
@ -422,6 +458,7 @@ export class UploadsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let primaryAudioAssetChanged = false;
|
||||||
if (track.primaryAudioAssetId !== audioAsset.id) {
|
if (track.primaryAudioAssetId !== audioAsset.id) {
|
||||||
track = await tx.track.update({
|
track = await tx.track.update({
|
||||||
where: { id: track.id },
|
where: { id: track.id },
|
||||||
@ -429,18 +466,21 @@ export class UploadsService {
|
|||||||
primaryAudioAssetId: audioAsset.id,
|
primaryAudioAssetId: audioAsset.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
primaryAudioAssetChanged = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const artworkAssetId = preparedArtwork
|
const priorArtworkAssetId = track.artworkAssetId ?? null;
|
||||||
? (
|
const artworkResult = preparedArtwork
|
||||||
await this.findOrCreateArtworkAsset(
|
? await this.findOrCreateArtworkAsset(
|
||||||
tx,
|
tx,
|
||||||
ownerUserId,
|
ownerUserId,
|
||||||
preparedArtwork,
|
preparedArtwork,
|
||||||
)
|
)
|
||||||
).id
|
|
||||||
: null;
|
: null;
|
||||||
|
const artworkAsset = artworkResult?.artworkAsset ?? null;
|
||||||
|
const artworkAssetId = artworkAsset?.id ?? null;
|
||||||
|
|
||||||
|
let artworkLinkChanged = false;
|
||||||
if ((track.artworkAssetId ?? null) !== artworkAssetId) {
|
if ((track.artworkAssetId ?? null) !== artworkAssetId) {
|
||||||
track = await tx.track.update({
|
track = await tx.track.update({
|
||||||
where: { id: track.id },
|
where: { id: track.id },
|
||||||
@ -448,16 +488,57 @@ export class UploadsService {
|
|||||||
artworkAssetId,
|
artworkAssetId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
artworkLinkChanged = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
await tx.libraryEvent.create({
|
const finalTrackSnapshot = this.buildRemoteLibraryTrackDto(
|
||||||
data: {
|
track,
|
||||||
|
audioAsset,
|
||||||
|
artworkAssetId ? artworkAsset : null,
|
||||||
|
);
|
||||||
|
const eventPayload: LibraryEventPayload = {
|
||||||
|
track: finalTrackSnapshot,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (createdTrack || trackMetadataChanged) {
|
||||||
|
await this.appendLibraryEvent(tx, {
|
||||||
userId: ownerUserId,
|
userId: ownerUserId,
|
||||||
entityType: EntityType.TRACK,
|
entityType: EntityType.TRACK,
|
||||||
entityId: track.id,
|
entityId: track.id,
|
||||||
action: createdTrack ? EventAction.CREATED : EventAction.UPDATED,
|
action: createdTrack ? EventAction.CREATED : EventAction.UPDATED,
|
||||||
},
|
payload: eventPayload,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioAssetChanged || primaryAudioAssetChanged) {
|
||||||
|
await this.appendLibraryEvent(tx, {
|
||||||
|
userId: ownerUserId,
|
||||||
|
entityType: EntityType.AUDIO_ASSET,
|
||||||
|
entityId: audioAsset.id,
|
||||||
|
action: createdAudioAsset ? EventAction.CREATED : EventAction.UPDATED,
|
||||||
|
payload: eventPayload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
artworkResult?.wasCreated ||
|
||||||
|
artworkResult?.wasUpdated ||
|
||||||
|
artworkLinkChanged ||
|
||||||
|
(priorArtworkAssetId !== null && artworkAssetId === null)
|
||||||
|
) {
|
||||||
|
await this.appendLibraryEvent(tx, {
|
||||||
|
userId: ownerUserId,
|
||||||
|
entityType: EntityType.ARTWORK_ASSET,
|
||||||
|
entityId: artworkAssetId ?? priorArtworkAssetId!,
|
||||||
|
action:
|
||||||
|
artworkAssetId == null
|
||||||
|
? EventAction.DELETED
|
||||||
|
: artworkResult?.wasCreated
|
||||||
|
? EventAction.CREATED
|
||||||
|
: EventAction.UPDATED,
|
||||||
|
payload: eventPayload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await tx.uploadSession.update({
|
await tx.uploadSession.update({
|
||||||
where: { id: currentSession.id },
|
where: { id: currentSession.id },
|
||||||
@ -542,6 +623,87 @@ export class UploadsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildRemoteLibraryTrackDto(
|
||||||
|
track: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
durationMs: number | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
},
|
||||||
|
audioAsset: {
|
||||||
|
id: string;
|
||||||
|
sha256: string;
|
||||||
|
durationMs: number | null;
|
||||||
|
},
|
||||||
|
artworkAsset: {
|
||||||
|
id: string;
|
||||||
|
sha256: string;
|
||||||
|
mimeType: string;
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
} | null,
|
||||||
|
): RemoteLibraryTrackDto {
|
||||||
|
const durationMs = track.durationMs ?? audioAsset.durationMs ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
trackId: track.id,
|
||||||
|
title: track.title,
|
||||||
|
artist: track.artist,
|
||||||
|
durationSeconds: Math.max(0, Math.round(durationMs / 1000)),
|
||||||
|
sha256: audioAsset.sha256,
|
||||||
|
assetId: audioAsset.id,
|
||||||
|
createdAt: track.createdAt.toISOString(),
|
||||||
|
updatedAt: track.updatedAt.toISOString(),
|
||||||
|
artwork: artworkAsset
|
||||||
|
? {
|
||||||
|
artworkId: artworkAsset.id,
|
||||||
|
sha256: artworkAsset.sha256,
|
||||||
|
mimeType: artworkAsset.mimeType,
|
||||||
|
width: artworkAsset.width,
|
||||||
|
height: artworkAsset.height,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async appendLibraryEvent(
|
||||||
|
tx: Pick<PrismaService, 'user' | 'libraryEvent'>,
|
||||||
|
params: {
|
||||||
|
userId: string;
|
||||||
|
entityType: EntityType;
|
||||||
|
entityId: string;
|
||||||
|
action: EventAction;
|
||||||
|
payload: LibraryEventPayload;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
const owner = await tx.user.update({
|
||||||
|
where: {
|
||||||
|
id: params.userId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
libraryCursor: {
|
||||||
|
increment: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
libraryCursor: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.libraryEvent.create({
|
||||||
|
data: {
|
||||||
|
userId: params.userId,
|
||||||
|
cursor: owner.libraryCursor,
|
||||||
|
entityType: params.entityType,
|
||||||
|
entityId: params.entityId,
|
||||||
|
action: params.action,
|
||||||
|
payload: params.payload as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private toStatusResponse(
|
private toStatusResponse(
|
||||||
uploadSession: Pick<
|
uploadSession: Pick<
|
||||||
UploadSession,
|
UploadSession,
|
||||||
@ -636,12 +798,22 @@ export class UploadsService {
|
|||||||
fileSizeBytes: artwork.fileSizeBytes,
|
fileSizeBytes: artwork.fileSizeBytes,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
artworkAsset,
|
||||||
|
wasCreated: false,
|
||||||
|
wasUpdated: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return artworkAsset;
|
return {
|
||||||
|
artworkAsset,
|
||||||
|
wasCreated: false,
|
||||||
|
wasUpdated: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return tx.artworkAsset.create({
|
const createdArtworkAsset = await tx.artworkAsset.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
sha256: artwork.sha256,
|
sha256: artwork.sha256,
|
||||||
@ -652,6 +824,12 @@ export class UploadsService {
|
|||||||
fileSizeBytes: artwork.fileSizeBytes,
|
fileSizeBytes: artwork.fileSizeBytes,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
artworkAsset: createdArtworkAsset,
|
||||||
|
wasCreated: true,
|
||||||
|
wasUpdated: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private assertMp3Filename(filename: string): void {
|
private assertMp3Filename(filename: string): void {
|
||||||
|
|||||||
@ -8,6 +8,7 @@ describe('DefaultUserService', () => {
|
|||||||
slug: DefaultUserService.defaultOwnerSlug,
|
slug: DefaultUserService.defaultOwnerSlug,
|
||||||
displayName: DefaultUserService.defaultOwnerDisplayName,
|
displayName: DefaultUserService.defaultOwnerDisplayName,
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
|
libraryCursor: 0n,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
@ -48,6 +49,7 @@ describe('DefaultUserService', () => {
|
|||||||
slug: DefaultUserService.defaultOwnerSlug,
|
slug: DefaultUserService.defaultOwnerSlug,
|
||||||
displayName: DefaultUserService.defaultOwnerDisplayName,
|
displayName: DefaultUserService.defaultOwnerDisplayName,
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
|
libraryCursor: 0n,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -84,6 +84,7 @@ async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
|
|||||||
function createPrismaMock() {
|
function createPrismaMock() {
|
||||||
const users = new Map<string, any>();
|
const users = new Map<string, any>();
|
||||||
const devices = new Map<string, any>();
|
const devices = new Map<string, any>();
|
||||||
|
const deviceSyncCursors = new Map<string, any>();
|
||||||
const tracks = new Map<string, any>();
|
const tracks = new Map<string, any>();
|
||||||
const audioAssets = new Map<string, any>();
|
const audioAssets = new Map<string, any>();
|
||||||
const artworkAssets = new Map<string, any>();
|
const artworkAssets = new Map<string, any>();
|
||||||
@ -91,21 +92,81 @@ function createPrismaMock() {
|
|||||||
const libraryEvents = new Map<bigint, any>();
|
const libraryEvents = new Map<bigint, any>();
|
||||||
let nextLibraryEventId = 1n;
|
let nextLibraryEventId = 1n;
|
||||||
|
|
||||||
const defaultUser = {
|
const createUserRecord = (data: Record<string, any>) => {
|
||||||
|
const now = new Date();
|
||||||
|
return {
|
||||||
|
id: data.id ?? randomUUID(),
|
||||||
|
createdAt: data.createdAt ?? now,
|
||||||
|
updatedAt: data.updatedAt ?? now,
|
||||||
|
...data,
|
||||||
|
libraryCursor:
|
||||||
|
data.libraryCursor == null ? 0n : BigInt(data.libraryCursor),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultUser = createUserRecord({
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
slug: 'default-owner',
|
slug: 'default-owner',
|
||||||
displayName: 'Default Owner',
|
displayName: 'Default Owner',
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
createdAt: new Date(),
|
libraryCursor: 0n,
|
||||||
updatedAt: new Date(),
|
});
|
||||||
};
|
|
||||||
users.set(defaultUser.id, defaultUser);
|
users.set(defaultUser.id, defaultUser);
|
||||||
|
|
||||||
const prismaMock: any = {
|
const prismaMock: any = {
|
||||||
$queryRawUnsafe: jest.fn().mockResolvedValue([{ '?column?': 1 }]),
|
$queryRawUnsafe: jest.fn().mockResolvedValue([{ '?column?': 1 }]),
|
||||||
$transaction: jest.fn().mockImplementation(async (callback: any) => callback(prismaMock)),
|
$transaction: jest.fn().mockImplementation(async (callback: any) => callback(prismaMock)),
|
||||||
user: {
|
user: {
|
||||||
upsert: jest.fn().mockResolvedValue(defaultUser),
|
upsert: jest.fn().mockImplementation(async ({ where, update, create }) => {
|
||||||
|
const current =
|
||||||
|
[...users.values()].find((user) => user.slug === where.slug) ?? null;
|
||||||
|
|
||||||
|
if (current) {
|
||||||
|
const updated = createUserRecord({
|
||||||
|
...current,
|
||||||
|
...update,
|
||||||
|
id: current.id,
|
||||||
|
createdAt: current.createdAt,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
libraryCursor: current.libraryCursor,
|
||||||
|
});
|
||||||
|
users.set(updated.id, updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = createUserRecord(create);
|
||||||
|
users.set(created.id, created);
|
||||||
|
return created;
|
||||||
|
}),
|
||||||
|
create: jest.fn().mockImplementation(async ({ data }) => {
|
||||||
|
const created = createUserRecord(data);
|
||||||
|
users.set(created.id, created);
|
||||||
|
return created;
|
||||||
|
}),
|
||||||
|
update: jest.fn().mockImplementation(async ({ where, data, select }) => {
|
||||||
|
const current = users.get(where.id);
|
||||||
|
if (!current) {
|
||||||
|
throw new Error(
|
||||||
|
`Test Prisma mock invariant failed: user ${where.id} not found for update`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const incrementBy = BigInt(data.libraryCursor?.increment ?? 0);
|
||||||
|
const updated = createUserRecord({
|
||||||
|
...current,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
libraryCursor: current.libraryCursor + incrementBy,
|
||||||
|
});
|
||||||
|
users.set(where.id, updated);
|
||||||
|
|
||||||
|
if (select?.libraryCursor) {
|
||||||
|
return {
|
||||||
|
libraryCursor: updated.libraryCursor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
device: {
|
device: {
|
||||||
create: jest.fn().mockImplementation(async ({ data }) => {
|
create: jest.fn().mockImplementation(async ({ data }) => {
|
||||||
@ -314,11 +375,44 @@ function createPrismaMock() {
|
|||||||
nextLibraryEventId += 1n;
|
nextLibraryEventId += 1n;
|
||||||
return record;
|
return record;
|
||||||
}),
|
}),
|
||||||
findFirst: jest.fn().mockImplementation(async ({ where }) => {
|
findFirst: jest.fn().mockImplementation(async ({ where, orderBy }) => {
|
||||||
const filteredEvents = [...libraryEvents.values()].filter((event) =>
|
const filteredEvents = [...libraryEvents.values()].filter((event) =>
|
||||||
where?.userId ? event.userId === where.userId : true,
|
where?.userId ? event.userId === where.userId : true,
|
||||||
);
|
);
|
||||||
return filteredEvents.sort((lhs, rhs) => Number(rhs.id - lhs.id))[0] ?? null;
|
const direction = orderBy?.cursor ?? 'desc';
|
||||||
|
return filteredEvents
|
||||||
|
.sort((lhs, rhs) =>
|
||||||
|
direction === 'asc'
|
||||||
|
? Number(lhs.cursor - rhs.cursor)
|
||||||
|
: Number(rhs.cursor - lhs.cursor),
|
||||||
|
)[0] ?? null;
|
||||||
|
}),
|
||||||
|
findMany: jest.fn().mockImplementation(async ({ where, take }) => {
|
||||||
|
return [...libraryEvents.values()]
|
||||||
|
.filter(
|
||||||
|
(event) =>
|
||||||
|
(where?.userId ? event.userId === where.userId : true) &&
|
||||||
|
(where?.cursor?.gt != null ? event.cursor > where.cursor.gt : true),
|
||||||
|
)
|
||||||
|
.sort((lhs, rhs) => Number(lhs.cursor - rhs.cursor))
|
||||||
|
.slice(0, take);
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
deviceSyncCursor: {
|
||||||
|
upsert: jest.fn().mockImplementation(async ({ where, update, create }) => {
|
||||||
|
const current = deviceSyncCursors.get(where.deviceId);
|
||||||
|
const nextRecord = current
|
||||||
|
? {
|
||||||
|
...current,
|
||||||
|
...update,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
...create,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
deviceSyncCursors.set(where.deviceId, nextRecord);
|
||||||
|
return nextRecord;
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -327,7 +421,9 @@ function createPrismaMock() {
|
|||||||
prismaMock,
|
prismaMock,
|
||||||
state: {
|
state: {
|
||||||
defaultUser,
|
defaultUser,
|
||||||
|
users,
|
||||||
devices,
|
devices,
|
||||||
|
deviceSyncCursors,
|
||||||
tracks,
|
tracks,
|
||||||
audioAssets,
|
audioAssets,
|
||||||
artworkAssets,
|
artworkAssets,
|
||||||
@ -696,12 +792,20 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
syncController.bootstrap(),
|
syncController.bootstrap(),
|
||||||
);
|
);
|
||||||
const changesResponse = await runAsDevice(device.deviceAccessToken, () =>
|
const changesResponse = await runAsDevice(device.deviceAccessToken, () =>
|
||||||
syncController.changes({ after: '0' }),
|
syncController.changes({ cursor: '0' }),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(bootstrapResponse.tracks).toEqual([]);
|
expect(bootstrapResponse.tracks).toEqual([]);
|
||||||
|
expect(bootstrapResponse.nextCursor).toBe('0');
|
||||||
expect(changesResponse.events).toEqual([]);
|
expect(changesResponse.events).toEqual([]);
|
||||||
expect(changesResponse.nextCursor).toBe('0');
|
expect(changesResponse.nextCursor).toBe('0');
|
||||||
|
expect(changesResponse.hasMore).toBe(false);
|
||||||
|
expect(changesResponse.requiresBootstrap).toBe(false);
|
||||||
|
expect(prismaState.deviceSyncCursors.get(device.deviceId)).toMatchObject({
|
||||||
|
deviceId: device.deviceId,
|
||||||
|
userId: prismaState.defaultUser.id,
|
||||||
|
cursor: 0n,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sync bootstrap and changes do not expose foreign-owner data', async () => {
|
it('sync bootstrap and changes do not expose foreign-owner data', async () => {
|
||||||
@ -734,10 +838,24 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
});
|
});
|
||||||
prismaState.libraryEvents.set(1n, {
|
prismaState.libraryEvents.set(1n, {
|
||||||
id: 1n,
|
id: 1n,
|
||||||
|
cursor: 1n,
|
||||||
userId: foreignUserId,
|
userId: foreignUserId,
|
||||||
entityType: 'TRACK',
|
entityType: 'TRACK',
|
||||||
entityId: foreignTrackId,
|
entityId: foreignTrackId,
|
||||||
action: 'CREATED',
|
action: 'CREATED',
|
||||||
|
payload: {
|
||||||
|
track: {
|
||||||
|
trackId: foreignTrackId,
|
||||||
|
title: 'Foreign Bootstrap Track',
|
||||||
|
artist: 'Elsewhere',
|
||||||
|
durationSeconds: 180,
|
||||||
|
sha256: 'f'.repeat(64),
|
||||||
|
assetId: randomUUID(),
|
||||||
|
createdAt: '2026-05-29T08:00:00.000Z',
|
||||||
|
updatedAt: '2026-05-29T08:01:00.000Z',
|
||||||
|
artwork: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
payloadVersion: 1,
|
payloadVersion: 1,
|
||||||
createdAt: new Date('2026-05-29T08:02:00.000Z'),
|
createdAt: new Date('2026-05-29T08:02:00.000Z'),
|
||||||
});
|
});
|
||||||
@ -746,7 +864,7 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
syncController.bootstrap(),
|
syncController.bootstrap(),
|
||||||
);
|
);
|
||||||
const changesResponse = await runAsDevice(device.deviceAccessToken, () =>
|
const changesResponse = await runAsDevice(device.deviceAccessToken, () =>
|
||||||
syncController.changes({ after: '0' }),
|
syncController.changes({ cursor: '0' }),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(bootstrapResponse.tracks).toEqual([]);
|
expect(bootstrapResponse.tracks).toEqual([]);
|
||||||
@ -1590,6 +1708,18 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
|
|
||||||
it('makes an upload from one device visible to another linked device under the same owner', async () => {
|
it('makes an upload from one device visible to another linked device under the same owner', async () => {
|
||||||
const identityUserId = randomUUID();
|
const identityUserId = randomUUID();
|
||||||
|
prismaState.users.set(
|
||||||
|
identityUserId,
|
||||||
|
{
|
||||||
|
id: identityUserId,
|
||||||
|
slug: `identity-${identityUserId}`,
|
||||||
|
displayName: 'Identity Owner',
|
||||||
|
isDefault: false,
|
||||||
|
libraryCursor: 0n,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
);
|
||||||
const primaryDevice = seedDevice({
|
const primaryDevice = seedDevice({
|
||||||
userId: identityUserId,
|
userId: identityUserId,
|
||||||
deviceAccessToken: 'linked-upload-primary-token',
|
deviceAccessToken: 'linked-upload-primary-token',
|
||||||
@ -1713,7 +1843,7 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
expect(duplicatePrepare.status).toBe('exists');
|
expect(duplicatePrepare.status).toBe('exists');
|
||||||
expect(duplicatePrepare.uploadId).toBeDefined();
|
expect(duplicatePrepare.uploadId).toBeDefined();
|
||||||
expect(prismaState.audioAssets.size).toBe(1);
|
expect(prismaState.audioAssets.size).toBe(1);
|
||||||
expect(prismaState.libraryEvents.size).toBe(1);
|
expect(prismaState.libraryEvents.size).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('supports upload finalize with embedded artwork and exposes remote artwork metadata', async () => {
|
it('supports upload finalize with embedded artwork and exposes remote artwork metadata', async () => {
|
||||||
|
|||||||
@ -280,96 +280,157 @@ public struct SyncCursor: Codable, Hashable, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public struct SyncEvent: Codable, Hashable, Sendable {
|
public struct SyncEvent: Codable, Hashable, Sendable {
|
||||||
|
public var cursor: SyncCursor
|
||||||
public var entityType: String
|
public var entityType: String
|
||||||
public var entityId: String
|
public var entityId: String
|
||||||
public var action: String
|
public var action: String
|
||||||
public var eventId: String
|
public var track: RemoteTrack?
|
||||||
|
public var deletedTrackId: String?
|
||||||
|
public var createdAt: String
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
|
cursor: SyncCursor,
|
||||||
entityType: String,
|
entityType: String,
|
||||||
entityId: String,
|
entityId: String,
|
||||||
action: String,
|
action: String,
|
||||||
eventId: String
|
track: RemoteTrack? = nil,
|
||||||
|
deletedTrackId: String? = nil,
|
||||||
|
createdAt: String
|
||||||
) {
|
) {
|
||||||
|
self.cursor = cursor
|
||||||
self.entityType = entityType
|
self.entityType = entityType
|
||||||
self.entityId = entityId
|
self.entityId = entityId
|
||||||
self.action = action
|
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 struct SyncBootstrapResponse: Codable, Hashable, Sendable {
|
||||||
public var nextCursor: SyncCursor
|
public var nextCursor: SyncCursor
|
||||||
public var tracks: [LibraryTrack]
|
public var tracks: [RemoteTrack]
|
||||||
public var events: [SyncEvent]
|
|
||||||
public var deletedTrackIds: [String]
|
|
||||||
public var serverTime: String
|
public var serverTime: String
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
nextCursor: SyncCursor,
|
nextCursor: SyncCursor,
|
||||||
tracks: [LibraryTrack],
|
tracks: [RemoteTrack],
|
||||||
events: [SyncEvent],
|
|
||||||
deletedTrackIds: [String],
|
|
||||||
serverTime: String
|
serverTime: String
|
||||||
) {
|
) {
|
||||||
self.nextCursor = nextCursor
|
self.nextCursor = nextCursor
|
||||||
self.tracks = tracks
|
self.tracks = tracks
|
||||||
self.events = events
|
|
||||||
self.deletedTrackIds = deletedTrackIds
|
|
||||||
self.serverTime = serverTime
|
self.serverTime = serverTime
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case nextCursor
|
case nextCursor
|
||||||
case tracks
|
case tracks
|
||||||
case events
|
|
||||||
case deletedTrackIds
|
|
||||||
case serverTime
|
case serverTime
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct WireTrack: Codable {
|
|
||||||
var id: String?
|
|
||||||
var title: String?
|
|
||||||
var artist: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
public init(from decoder: Decoder) throws {
|
public init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
let wireTracks = try container.decode([WireTrack].self, forKey: .tracks)
|
|
||||||
|
|
||||||
nextCursor = SyncCursor(
|
nextCursor = SyncCursor(
|
||||||
value: try container.decode(String.self, forKey: .nextCursor)
|
value: try container.decode(String.self, forKey: .nextCursor)
|
||||||
)
|
)
|
||||||
tracks = wireTracks.map { track in
|
tracks = try container.decode([RemoteTrack].self, forKey: .tracks)
|
||||||
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)
|
|
||||||
serverTime = try container.decode(String.self, forKey: .serverTime)
|
serverTime = try container.decode(String.self, forKey: .serverTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func encode(to encoder: Encoder) throws {
|
public func encode(to encoder: Encoder) throws {
|
||||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
let wireTracks = tracks.map { track in
|
try container.encode(nextCursor.value, forKey: .nextCursor)
|
||||||
WireTrack(
|
try container.encode(tracks, forKey: .tracks)
|
||||||
id: track.id,
|
try container.encode(serverTime, forKey: .serverTime)
|
||||||
title: track.title,
|
}
|
||||||
artist: track.artist
|
}
|
||||||
)
|
|
||||||
|
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(nextCursor.value, forKey: .nextCursor)
|
||||||
try container.encode(wireTracks, forKey: .tracks)
|
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(events, forKey: .events)
|
||||||
try container.encode(deletedTrackIds, forKey: .deletedTrackIds)
|
|
||||||
try container.encode(serverTime, forKey: .serverTime)
|
try container.encode(serverTime, forKey: .serverTime)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,6 +39,10 @@ public protocol VelodyAPIClient: Sendable {
|
|||||||
|
|
||||||
func fetchSyncBootstrap() async throws -> SyncBootstrapResponse
|
func fetchSyncBootstrap() async throws -> SyncBootstrapResponse
|
||||||
|
|
||||||
|
func fetchSyncChanges(
|
||||||
|
cursor: SyncCursor
|
||||||
|
) async throws -> SyncChangesResponse
|
||||||
|
|
||||||
func fetchRemoteLibrary(
|
func fetchRemoteLibrary(
|
||||||
deviceId: String
|
deviceId: String
|
||||||
) async throws -> RemoteLibraryResponseDTO
|
) 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(
|
public func fetchRemoteLibrary(
|
||||||
deviceId: String
|
deviceId: String
|
||||||
) async throws -> RemoteLibraryResponseDTO {
|
) async throws -> RemoteLibraryResponseDTO {
|
||||||
@ -467,15 +485,31 @@ public struct StubVelodyAPIClient: VelodyAPIClient {
|
|||||||
return SyncBootstrapResponse(
|
return SyncBootstrapResponse(
|
||||||
nextCursor: SyncCursor(value: "0"),
|
nextCursor: SyncCursor(value: "0"),
|
||||||
tracks: [
|
tracks: [
|
||||||
LibraryTrack(
|
RemoteTrack(
|
||||||
|
trackId: UUID().uuidString,
|
||||||
title: "Velody Placeholder",
|
title: "Velody Placeholder",
|
||||||
artist: "Private Library",
|
artist: "Private Library",
|
||||||
album: "Phase 1",
|
durationSeconds: 245,
|
||||||
localFilePath: ""
|
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: [],
|
events: [],
|
||||||
deletedTrackIds: [],
|
|
||||||
serverTime: ISO8601DateFormatter().string(from: .now)
|
serverTime: ISO8601DateFormatter().string(from: .now)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -77,4 +77,46 @@ final class RemoteLibraryDTOTests: XCTestCase {
|
|||||||
XCTAssertEqual(decoded.tracks.first?.durationSeconds, 245)
|
XCTAssertEqual(decoded.tracks.first?.durationSeconds, 245)
|
||||||
XCTAssertEqual(decoded.tracks.first?.artwork?.artworkId, "artwork-789")
|
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)
|
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 {
|
func testRegisterDeviceDoesNotSendAuthorizationHeader() async throws {
|
||||||
RecordingURLProtocol.handler = { request in
|
RecordingURLProtocol.handler = { request in
|
||||||
XCTAssertNil(request.value(forHTTPHeaderField: "Authorization"))
|
XCTAssertNil(request.value(forHTTPHeaderField: "Authorization"))
|
||||||
|
|||||||
@ -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 {
|
public func performInitialSync() async throws -> SyncResult {
|
||||||
let bootstrap = try await apiClient.fetchSyncBootstrap()
|
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()
|
let persistedTracks = try await store.loadTracks()
|
||||||
|
|
||||||
return SyncResult(
|
return SyncResult(
|
||||||
|
|||||||
@ -13,13 +13,16 @@ public protocol RemoteLibraryRepository: Actor {
|
|||||||
public actor DefaultRemoteLibraryRepository: RemoteLibraryRepository {
|
public actor DefaultRemoteLibraryRepository: RemoteLibraryRepository {
|
||||||
private let apiClient: any VelodyAPIClient
|
private let apiClient: any VelodyAPIClient
|
||||||
private let store: any RemoteLibraryStore
|
private let store: any RemoteLibraryStore
|
||||||
|
private let syncCursorStore: any RemoteLibrarySyncCursorStore
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
apiClient: any VelodyAPIClient,
|
apiClient: any VelodyAPIClient,
|
||||||
store: any RemoteLibraryStore
|
store: any RemoteLibraryStore,
|
||||||
|
syncCursorStore: any RemoteLibrarySyncCursorStore
|
||||||
) {
|
) {
|
||||||
self.apiClient = apiClient
|
self.apiClient = apiClient
|
||||||
self.store = store
|
self.store = store
|
||||||
|
self.syncCursorStore = syncCursorStore
|
||||||
}
|
}
|
||||||
|
|
||||||
public func loadCachedRemoteTracks() async throws -> [RemoteTrack] {
|
public func loadCachedRemoteTracks() async throws -> [RemoteTrack] {
|
||||||
@ -27,10 +30,13 @@ public actor DefaultRemoteLibraryRepository: RemoteLibraryRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func syncRemoteTracks(deviceId: String) async throws -> [RemoteTrack] {
|
public func syncRemoteTracks(deviceId: String) async throws -> [RemoteTrack] {
|
||||||
let response = try await apiClient.fetchRemoteLibrary(deviceId: deviceId)
|
_ = deviceId
|
||||||
let tracks = response.tracks.map(\.remoteTrack)
|
|
||||||
try await store.replaceRemoteTracks(tracks)
|
if let currentCursor = try await syncCursorStore.loadCursor() {
|
||||||
return tracks
|
return try await syncIncrementally(from: currentCursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await bootstrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
public func downloadAudioAsset(
|
public func downloadAudioAsset(
|
||||||
@ -46,4 +52,74 @@ public actor DefaultRemoteLibraryRepository: RemoteLibraryRepository {
|
|||||||
) async throws -> Data {
|
) async throws -> Data {
|
||||||
try await apiClient.downloadArtwork(artworkId: artworkId, deviceId: deviceId)
|
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] {
|
public func loadDownloadStates() async throws -> [RemoteTrackDownloadState] {
|
||||||
let states = try await downloadStateStore.loadDownloadStates()
|
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] {
|
public func syncRemoteLibrary(deviceId: String) async throws -> [RemoteTrack] {
|
||||||
@ -150,7 +150,7 @@ public actor RemoteLibrarySyncService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func reconcileDownloadedLocalFilePaths(
|
private func reconcilePersistedDownloadStates(
|
||||||
in states: [RemoteTrackDownloadState]
|
in states: [RemoteTrackDownloadState]
|
||||||
) async throws -> [RemoteTrackDownloadState] {
|
) async throws -> [RemoteTrackDownloadState] {
|
||||||
guard !states.isEmpty else {
|
guard !states.isEmpty else {
|
||||||
@ -162,14 +162,16 @@ public actor RemoteLibrarySyncService {
|
|||||||
|
|
||||||
for index in reconciledStates.indices {
|
for index in reconciledStates.indices {
|
||||||
let state = reconciledStates[index]
|
let state = reconciledStates[index]
|
||||||
guard state.downloadStatus == .downloaded else {
|
let resolvedLocalFilePath = await audioFileStore.resolveLocalFilePath(
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let resolvedLocalFilePath = await audioFileStore.resolveLocalFilePath(
|
|
||||||
persistedLocalFilePath: state.localFilePath,
|
persistedLocalFilePath: state.localFilePath,
|
||||||
assetId: state.assetId
|
assetId: state.assetId
|
||||||
) else {
|
)
|
||||||
|
|
||||||
|
switch state.downloadStatus {
|
||||||
|
case .notDownloaded, .failed:
|
||||||
|
continue
|
||||||
|
case .downloaded:
|
||||||
|
guard let resolvedLocalFilePath else {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -177,6 +179,21 @@ public actor RemoteLibrarySyncService {
|
|||||||
reconciledStates[index].localFilePath = resolvedLocalFilePath
|
reconciledStates[index].localFilePath = resolvedLocalFilePath
|
||||||
didChange = true
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if didChange {
|
if didChange {
|
||||||
@ -186,6 +203,8 @@ public actor RemoteLibrarySyncService {
|
|||||||
return reconciledStates
|
return reconciledStates
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static let interruptedDownloadErrorMessage = "The previous download did not finish. Try again."
|
||||||
|
|
||||||
private func cacheArtwork(
|
private func cacheArtwork(
|
||||||
for tracks: [RemoteTrack],
|
for tracks: [RemoteTrack],
|
||||||
deviceId: String
|
deviceId: String
|
||||||
|
|||||||
@ -176,7 +176,8 @@ final class OfflineLibraryServiceTests: XCTestCase {
|
|||||||
let syncService = RemoteLibrarySyncService(
|
let syncService = RemoteLibrarySyncService(
|
||||||
repository: DefaultRemoteLibraryRepository(
|
repository: DefaultRemoteLibraryRepository(
|
||||||
apiClient: OfflineLibraryMockAPIClient(audioAssetData: sampleMp3Data(seed: track.assetId)),
|
apiClient: OfflineLibraryMockAPIClient(audioAssetData: sampleMp3Data(seed: track.assetId)),
|
||||||
store: remoteLibraryStore
|
store: remoteLibraryStore,
|
||||||
|
syncCursorStore: InMemoryRemoteLibrarySyncCursorStore()
|
||||||
),
|
),
|
||||||
downloadStateStore: downloadStateStore,
|
downloadStateStore: downloadStateStore,
|
||||||
audioFileStore: audioFileStore,
|
audioFileStore: audioFileStore,
|
||||||
@ -226,8 +227,10 @@ final class OfflineLibraryServiceTests: XCTestCase {
|
|||||||
let remoteLibraryStore = InMemoryRemoteLibraryStore()
|
let remoteLibraryStore = InMemoryRemoteLibraryStore()
|
||||||
let audioData = sampleMp3Data(seed: track.assetId)
|
let audioData = sampleMp3Data(seed: track.assetId)
|
||||||
let apiClient = OfflineLibraryMockAPIClient(
|
let apiClient = OfflineLibraryMockAPIClient(
|
||||||
remoteLibraryResponse: RemoteLibraryResponseDTO(
|
bootstrapResponse: SyncBootstrapResponse(
|
||||||
tracks: [makeRemoteTrackDTO(from: track)]
|
nextCursor: SyncCursor(value: "1"),
|
||||||
|
tracks: [track],
|
||||||
|
serverTime: "2026-05-30T08:00:00.000Z"
|
||||||
),
|
),
|
||||||
audioAssetData: audioData,
|
audioAssetData: audioData,
|
||||||
artworkDataByArtworkID: [
|
artworkDataByArtworkID: [
|
||||||
@ -240,7 +243,8 @@ final class OfflineLibraryServiceTests: XCTestCase {
|
|||||||
let syncService = RemoteLibrarySyncService(
|
let syncService = RemoteLibrarySyncService(
|
||||||
repository: DefaultRemoteLibraryRepository(
|
repository: DefaultRemoteLibraryRepository(
|
||||||
apiClient: apiClient,
|
apiClient: apiClient,
|
||||||
store: remoteLibraryStore
|
store: remoteLibraryStore,
|
||||||
|
syncCursorStore: InMemoryRemoteLibrarySyncCursorStore()
|
||||||
),
|
),
|
||||||
downloadStateStore: downloadStateStore,
|
downloadStateStore: downloadStateStore,
|
||||||
audioFileStore: audioFileStore,
|
audioFileStore: audioFileStore,
|
||||||
@ -284,8 +288,10 @@ final class OfflineLibraryServiceTests: XCTestCase {
|
|||||||
makeRemoteTrack(trackId: "track-2", assetId: "asset-2", title: "Track 2", artworkId: "artwork-2"),
|
makeRemoteTrack(trackId: "track-2", assetId: "asset-2", title: "Track 2", artworkId: "artwork-2"),
|
||||||
]
|
]
|
||||||
let apiClient = OfflineLibraryMockAPIClient(
|
let apiClient = OfflineLibraryMockAPIClient(
|
||||||
remoteLibraryResponse: RemoteLibraryResponseDTO(
|
bootstrapResponse: SyncBootstrapResponse(
|
||||||
tracks: tracks.map { makeRemoteTrackDTO(from: $0) }
|
nextCursor: SyncCursor(value: "1"),
|
||||||
|
tracks: tracks,
|
||||||
|
serverTime: "2026-05-30T08:00:00.000Z"
|
||||||
),
|
),
|
||||||
audioAssetDataByAssetID: [
|
audioAssetDataByAssetID: [
|
||||||
"asset-1": sampleMp3Data(seed: "asset-1"),
|
"asset-1": sampleMp3Data(seed: "asset-1"),
|
||||||
@ -304,7 +310,8 @@ final class OfflineLibraryServiceTests: XCTestCase {
|
|||||||
|
|
||||||
let firstRepository = DefaultRemoteLibraryRepository(
|
let firstRepository = DefaultRemoteLibraryRepository(
|
||||||
apiClient: apiClient,
|
apiClient: apiClient,
|
||||||
store: try FileRemoteLibraryStore(fileURL: remoteLibraryFileURL)
|
store: try FileRemoteLibraryStore(fileURL: remoteLibraryFileURL),
|
||||||
|
syncCursorStore: InMemoryRemoteLibrarySyncCursorStore()
|
||||||
)
|
)
|
||||||
let firstDownloadStateStore = try FileRemoteTrackDownloadStateStore(fileURL: downloadStateFileURL)
|
let firstDownloadStateStore = try FileRemoteTrackDownloadStateStore(fileURL: downloadStateFileURL)
|
||||||
let firstAudioStore = try FileOfflineAudioFileStore(baseDirectoryURL: audioDirectory)
|
let firstAudioStore = try FileOfflineAudioFileStore(baseDirectoryURL: audioDirectory)
|
||||||
@ -329,7 +336,8 @@ final class OfflineLibraryServiceTests: XCTestCase {
|
|||||||
|
|
||||||
let relaunchedRepository = DefaultRemoteLibraryRepository(
|
let relaunchedRepository = DefaultRemoteLibraryRepository(
|
||||||
apiClient: apiClient,
|
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 relaunchedDownloadStateStore = try FileRemoteTrackDownloadStateStore(fileURL: downloadStateFileURL)
|
||||||
let relaunchedAudioStore = try FileOfflineAudioFileStore(baseDirectoryURL: audioDirectory)
|
let relaunchedAudioStore = try FileOfflineAudioFileStore(baseDirectoryURL: audioDirectory)
|
||||||
@ -404,18 +412,22 @@ private actor InMemoryRemoteLibraryRepository: RemoteLibraryRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private struct OfflineLibraryMockAPIClient: VelodyAPIClient {
|
private struct OfflineLibraryMockAPIClient: VelodyAPIClient {
|
||||||
let remoteLibraryResponse: RemoteLibraryResponseDTO?
|
let bootstrapResponse: SyncBootstrapResponse
|
||||||
let audioAssetData: Data?
|
let audioAssetData: Data?
|
||||||
let audioAssetDataByAssetID: [String: Data]
|
let audioAssetDataByAssetID: [String: Data]
|
||||||
let artworkDataByArtworkID: [String: Data]
|
let artworkDataByArtworkID: [String: Data]
|
||||||
|
|
||||||
init(
|
init(
|
||||||
remoteLibraryResponse: RemoteLibraryResponseDTO? = nil,
|
bootstrapResponse: SyncBootstrapResponse = SyncBootstrapResponse(
|
||||||
|
nextCursor: SyncCursor(value: "0"),
|
||||||
|
tracks: [],
|
||||||
|
serverTime: "2026-05-30T08:00:00.000Z"
|
||||||
|
),
|
||||||
audioAssetData: Data? = nil,
|
audioAssetData: Data? = nil,
|
||||||
audioAssetDataByAssetID: [String: Data] = [:],
|
audioAssetDataByAssetID: [String: Data] = [:],
|
||||||
artworkDataByArtworkID: [String: Data] = [:]
|
artworkDataByArtworkID: [String: Data] = [:]
|
||||||
) {
|
) {
|
||||||
self.remoteLibraryResponse = remoteLibraryResponse
|
self.bootstrapResponse = bootstrapResponse
|
||||||
self.audioAssetData = audioAssetData
|
self.audioAssetData = audioAssetData
|
||||||
self.audioAssetDataByAssetID = audioAssetDataByAssetID
|
self.audioAssetDataByAssetID = audioAssetDataByAssetID
|
||||||
self.artworkDataByArtworkID = artworkDataByArtworkID
|
self.artworkDataByArtworkID = artworkDataByArtworkID
|
||||||
@ -444,11 +456,17 @@ private struct OfflineLibraryMockAPIClient: VelodyAPIClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func fetchSyncBootstrap() async throws -> SyncBootstrapResponse {
|
func fetchSyncBootstrap() async throws -> SyncBootstrapResponse {
|
||||||
SyncBootstrapResponse(
|
bootstrapResponse
|
||||||
nextCursor: SyncCursor(value: "0"),
|
}
|
||||||
tracks: [],
|
|
||||||
|
func fetchSyncChanges(
|
||||||
|
cursor: SyncCursor
|
||||||
|
) async throws -> SyncChangesResponse {
|
||||||
|
SyncChangesResponse(
|
||||||
|
nextCursor: cursor,
|
||||||
|
hasMore: false,
|
||||||
|
requiresBootstrap: false,
|
||||||
events: [],
|
events: [],
|
||||||
deletedTrackIds: [],
|
|
||||||
serverTime: "2026-05-30T08:00:00.000Z"
|
serverTime: "2026-05-30T08:00:00.000Z"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -457,7 +475,9 @@ private struct OfflineLibraryMockAPIClient: VelodyAPIClient {
|
|||||||
deviceId: String
|
deviceId: String
|
||||||
) async throws -> RemoteLibraryResponseDTO {
|
) async throws -> RemoteLibraryResponseDTO {
|
||||||
_ = deviceId
|
_ = deviceId
|
||||||
return remoteLibraryResponse ?? RemoteLibraryResponseDTO(tracks: [])
|
return RemoteLibraryResponseDTO(
|
||||||
|
tracks: bootstrapResponse.tracks.map { makeRemoteTrackDTO(from: $0) }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func downloadAudioAsset(
|
func downloadAudioAsset(
|
||||||
|
|||||||
@ -7,112 +7,277 @@ import VelodyPersistence
|
|||||||
@testable import VelodySync
|
@testable import VelodySync
|
||||||
|
|
||||||
final class RemoteLibrarySyncServiceTests: XCTestCase {
|
final class RemoteLibrarySyncServiceTests: XCTestCase {
|
||||||
func testSuccessfulSyncPersistsRemoteTracks() async throws {
|
func testBootstrapFirstSyncPersistsRemoteTracksAndCursor() async throws {
|
||||||
let store = InMemoryRemoteLibraryStore()
|
let store = InMemoryRemoteLibraryStore()
|
||||||
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore()
|
let cursorStore = InMemoryRemoteLibrarySyncCursorStore()
|
||||||
let service = RemoteLibrarySyncService(
|
let track = makeRemoteTrack(trackId: "track-123")
|
||||||
repository: DefaultRemoteLibraryRepository(
|
let service = makeSyncService(
|
||||||
apiClient: MockVelodyAPIClient(
|
apiClient: MockVelodyAPIClient(
|
||||||
remoteLibraryResponse: RemoteLibraryResponseDTO(
|
bootstrapResponse: SyncBootstrapResponse(
|
||||||
tracks: [
|
nextCursor: SyncCursor(value: "4"),
|
||||||
RemoteTrackDTO(
|
tracks: [track],
|
||||||
trackId: "track-123",
|
serverTime: "2026-06-15T12:00:00.000Z"
|
||||||
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
|
store: store,
|
||||||
),
|
cursorStore: cursorStore
|
||||||
downloadStateStore: downloadStateStore,
|
|
||||||
audioFileStore: InMemoryOfflineAudioFileStore(),
|
|
||||||
artworkStore: InMemoryArtworkStore()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
let tracks = try await service.syncRemoteLibrary(deviceId: "device-123")
|
let tracks = try await service.syncRemoteLibrary(deviceId: "device-123")
|
||||||
let cachedTracks = try await service.loadCachedRemoteTracks()
|
let cachedTracks = try await service.loadCachedRemoteTracks()
|
||||||
|
let storedCursor = try await cursorStore.loadCursor()
|
||||||
let downloadStates = try await service.loadDownloadStates()
|
let downloadStates = try await service.loadDownloadStates()
|
||||||
|
|
||||||
XCTAssertEqual(tracks.count, 1)
|
XCTAssertEqual(tracks, [track])
|
||||||
XCTAssertEqual(cachedTracks, tracks)
|
XCTAssertEqual(cachedTracks, [track])
|
||||||
XCTAssertEqual(cachedTracks.first?.trackId, "track-123")
|
XCTAssertEqual(storedCursor, SyncCursor(value: "4"))
|
||||||
XCTAssertEqual(downloadStates.first?.downloadStatus, .notDownloaded)
|
XCTAssertEqual(downloadStates.first?.downloadStatus, RemoteTrackDownloadStatus.notDownloaded)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testEmptyResponseClearsCachedRemoteLibrary() async throws {
|
func testChangesForExistingCursorApplyWithoutDuplicateTracks() async throws {
|
||||||
let store = InMemoryRemoteLibraryStore(
|
let originalTrack = makeRemoteTrack(trackId: "track-123", title: "Old Title")
|
||||||
tracks: [
|
let updatedTrack = makeRemoteTrack(trackId: "track-123", title: "New Title")
|
||||||
RemoteTrack(
|
let secondTrack = makeRemoteTrack(
|
||||||
trackId: "track-123",
|
trackId: "track-456",
|
||||||
title: "Old",
|
title: "Second Track",
|
||||||
artist: "Artist",
|
createdAt: "2026-06-15T12:01:00.000Z"
|
||||||
durationSeconds: 100,
|
)
|
||||||
sha256: String(repeating: "b", count: 64),
|
let store = InMemoryRemoteLibraryStore(tracks: [originalTrack])
|
||||||
assetId: "asset-123",
|
let cursorStore = InMemoryRemoteLibrarySyncCursorStore(
|
||||||
createdAt: "2026-05-29T08:00:00.000Z",
|
cursor: SyncCursor(value: "4")
|
||||||
updatedAt: "2026-05-29T08:05:00.000Z"
|
)
|
||||||
|
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(
|
let service = makeSyncService(
|
||||||
states: [
|
apiClient: apiClient,
|
||||||
RemoteTrackDownloadState(
|
store: store,
|
||||||
remoteTrackId: "track-123",
|
cursorStore: cursorStore
|
||||||
assetId: "asset-123",
|
|
||||||
downloadStatus: .downloaded
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
let service = RemoteLibrarySyncService(
|
|
||||||
repository: DefaultRemoteLibraryRepository(
|
|
||||||
apiClient: MockVelodyAPIClient(
|
|
||||||
remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: [])
|
|
||||||
),
|
|
||||||
store: store
|
|
||||||
),
|
|
||||||
downloadStateStore: downloadStateStore,
|
|
||||||
audioFileStore: InMemoryOfflineAudioFileStore(),
|
|
||||||
artworkStore: InMemoryArtworkStore()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
let tracks = try await service.syncRemoteLibrary(deviceId: "device-123")
|
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, [])
|
XCTAssertEqual(tracks, [updatedTrack, secondTrack])
|
||||||
XCTAssertEqual(cachedTracks, [])
|
XCTAssertEqual(Set(tracks.map(\.trackId)), ["track-123", "track-456"])
|
||||||
XCTAssertEqual(downloadStates.count, 1)
|
XCTAssertEqual(storedCursor, SyncCursor(value: "6"))
|
||||||
|
let changeCursors = await apiClient.recordedChangeCursors()
|
||||||
|
XCTAssertEqual(changeCursors, ["4"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func testNetworkFailureLeavesCachedRemoteLibraryIntact() async throws {
|
func testPaginationAndReplayEventsDoNotDuplicateTracks() async throws {
|
||||||
let cachedTrack = RemoteTrack(
|
let originalTrack = makeRemoteTrack(trackId: "track-123", title: "Old Title")
|
||||||
trackId: "track-123",
|
let updatedTrack = makeRemoteTrack(trackId: "track-123", title: "Updated Once")
|
||||||
title: "Cached",
|
let secondUpdate = makeRemoteTrack(trackId: "track-123", title: "Updated Twice")
|
||||||
artist: "Artist",
|
let secondTrack = makeRemoteTrack(
|
||||||
durationSeconds: 100,
|
trackId: "track-456",
|
||||||
sha256: String(repeating: "c", count: 64),
|
title: "Another Track",
|
||||||
assetId: "asset-123",
|
createdAt: "2026-06-15T12:02:00.000Z"
|
||||||
createdAt: "2026-05-29T08:00:00.000Z",
|
|
||||||
updatedAt: "2026-05-29T08:05: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 store = InMemoryRemoteLibraryStore(tracks: [cachedTrack])
|
||||||
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore()
|
let cursorStore = InMemoryRemoteLibrarySyncCursorStore(
|
||||||
let service = RemoteLibrarySyncService(
|
cursor: SyncCursor(value: "7")
|
||||||
repository: DefaultRemoteLibraryRepository(
|
)
|
||||||
|
let service = makeSyncService(
|
||||||
apiClient: MockVelodyAPIClient(
|
apiClient: MockVelodyAPIClient(
|
||||||
remoteLibraryError: VelodyAPIError.requestFailed("Offline")
|
changeError: VelodyAPIError.requestFailed("Offline")
|
||||||
),
|
),
|
||||||
store: store
|
store: store,
|
||||||
),
|
cursorStore: cursorStore
|
||||||
downloadStateStore: downloadStateStore,
|
|
||||||
audioFileStore: InMemoryOfflineAudioFileStore(),
|
|
||||||
artworkStore: InMemoryArtworkStore()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await XCTAssertThrowsErrorAsync {
|
await XCTAssertThrowsErrorAsync {
|
||||||
@ -120,7 +285,48 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let cachedTracks = try await service.loadCachedRemoteTracks()
|
let cachedTracks = try await service.loadCachedRemoteTracks()
|
||||||
|
let storedCursor = try await cursorStore.loadCursor()
|
||||||
XCTAssertEqual(cachedTracks, [cachedTrack])
|
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 {
|
func testDownloadTrackPersistsDownloadedStateAndFile() async throws {
|
||||||
@ -129,34 +335,29 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
|||||||
let service = RemoteLibrarySyncService(
|
let service = RemoteLibrarySyncService(
|
||||||
repository: DefaultRemoteLibraryRepository(
|
repository: DefaultRemoteLibraryRepository(
|
||||||
apiClient: MockVelodyAPIClient(
|
apiClient: MockVelodyAPIClient(
|
||||||
remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: []),
|
|
||||||
audioAssetData: sampleMp3Data(seed: "download-success")
|
audioAssetData: sampleMp3Data(seed: "download-success")
|
||||||
),
|
),
|
||||||
store: InMemoryRemoteLibraryStore()
|
store: InMemoryRemoteLibraryStore(),
|
||||||
|
syncCursorStore: InMemoryRemoteLibrarySyncCursorStore()
|
||||||
),
|
),
|
||||||
downloadStateStore: downloadStateStore,
|
downloadStateStore: downloadStateStore,
|
||||||
audioFileStore: audioFileStore,
|
audioFileStore: audioFileStore,
|
||||||
artworkStore: InMemoryArtworkStore()
|
artworkStore: InMemoryArtworkStore()
|
||||||
)
|
)
|
||||||
let track = RemoteTrack(
|
let track = makeRemoteTrack(
|
||||||
trackId: "track-123",
|
trackId: "track-123",
|
||||||
title: "Remote Title",
|
|
||||||
artist: "Remote Artist",
|
|
||||||
durationSeconds: 245,
|
|
||||||
sha256: sha256Hex(sampleMp3Data(seed: "download-success")),
|
sha256: sha256Hex(sampleMp3Data(seed: "download-success")),
|
||||||
assetId: "asset-456",
|
assetId: "asset-456"
|
||||||
createdAt: "2026-05-29T08:00:00.000Z",
|
|
||||||
updatedAt: "2026-05-29T08:05:00.000Z"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
let state = try await service.downloadTrack(track, deviceId: "device-123")
|
let state = try await service.downloadTrack(track, deviceId: "device-123")
|
||||||
let storedStates = try await service.loadDownloadStates()
|
let storedStates = try await service.loadDownloadStates()
|
||||||
let fileExists = await audioFileStore.fileExists(at: state.localFilePath)
|
let fileExists = await audioFileStore.fileExists(at: state.localFilePath)
|
||||||
|
|
||||||
XCTAssertEqual(state.downloadStatus, .downloaded)
|
XCTAssertEqual(state.downloadStatus, RemoteTrackDownloadStatus.downloaded)
|
||||||
XCTAssertEqual(state.assetId, "asset-456")
|
XCTAssertEqual(state.assetId, "asset-456")
|
||||||
XCTAssertFalse(state.localFilePath.isEmpty)
|
XCTAssertFalse(state.localFilePath.isEmpty)
|
||||||
XCTAssertEqual(storedStates.first?.downloadStatus, .downloaded)
|
XCTAssertEqual(storedStates.first?.downloadStatus, RemoteTrackDownloadStatus.downloaded)
|
||||||
XCTAssertTrue(fileExists)
|
XCTAssertTrue(fileExists)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -164,35 +365,129 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
|||||||
let service = RemoteLibrarySyncService(
|
let service = RemoteLibrarySyncService(
|
||||||
repository: DefaultRemoteLibraryRepository(
|
repository: DefaultRemoteLibraryRepository(
|
||||||
apiClient: MockVelodyAPIClient(
|
apiClient: MockVelodyAPIClient(
|
||||||
remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: []),
|
|
||||||
downloadError: VelodyAPIError.server(statusCode: 404, message: "Missing")
|
downloadError: VelodyAPIError.server(statusCode: 404, message: "Missing")
|
||||||
),
|
),
|
||||||
store: InMemoryRemoteLibraryStore()
|
store: InMemoryRemoteLibraryStore(),
|
||||||
|
syncCursorStore: InMemoryRemoteLibrarySyncCursorStore()
|
||||||
),
|
),
|
||||||
downloadStateStore: InMemoryRemoteTrackDownloadStateStore(),
|
downloadStateStore: InMemoryRemoteTrackDownloadStateStore(),
|
||||||
audioFileStore: InMemoryOfflineAudioFileStore(),
|
audioFileStore: InMemoryOfflineAudioFileStore(),
|
||||||
artworkStore: InMemoryArtworkStore()
|
artworkStore: InMemoryArtworkStore()
|
||||||
)
|
)
|
||||||
let track = RemoteTrack(
|
let track = makeRemoteTrack(trackId: "track-123", assetId: "asset-456")
|
||||||
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"
|
|
||||||
)
|
|
||||||
|
|
||||||
await XCTAssertThrowsErrorAsync {
|
await XCTAssertThrowsErrorAsync {
|
||||||
_ = try await service.downloadTrack(track, deviceId: "device-123")
|
_ = try await service.downloadTrack(track, deviceId: "device-123")
|
||||||
}
|
}
|
||||||
|
|
||||||
let storedStates = try await service.loadDownloadStates()
|
let storedStates = try await service.loadDownloadStates()
|
||||||
XCTAssertEqual(storedStates.first?.downloadStatus, .failed)
|
XCTAssertEqual(storedStates.first?.downloadStatus, RemoteTrackDownloadStatus.failed)
|
||||||
XCTAssertEqual(storedStates.first?.remoteTrackId, "track-123")
|
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 {
|
func testLoadDownloadStatesRepairsStaleLocalFilePathAfterStoreRecreation() async throws {
|
||||||
let fileManager = FileManager.default
|
let fileManager = FileManager.default
|
||||||
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
|
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
|
||||||
@ -203,15 +498,11 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
|||||||
let secondAudioDirectory = tempDirectory.appendingPathComponent("audio-v2", isDirectory: true)
|
let secondAudioDirectory = tempDirectory.appendingPathComponent("audio-v2", isDirectory: true)
|
||||||
let stateFileURL = tempDirectory.appendingPathComponent("remote-download-states.json")
|
let stateFileURL = tempDirectory.appendingPathComponent("remote-download-states.json")
|
||||||
let audioData = sampleMp3Data(seed: "relaunch-repair")
|
let audioData = sampleMp3Data(seed: "relaunch-repair")
|
||||||
let track = RemoteTrack(
|
let track = makeRemoteTrack(
|
||||||
trackId: "track-123",
|
trackId: "track-123",
|
||||||
title: "1 Mai 2026",
|
title: "1 Mai 2026",
|
||||||
artist: "Remote Artist",
|
|
||||||
durationSeconds: 245,
|
|
||||||
sha256: sha256Hex(audioData),
|
sha256: sha256Hex(audioData),
|
||||||
assetId: "asset-456",
|
assetId: "asset-456"
|
||||||
createdAt: "2026-05-29T08:00:00.000Z",
|
|
||||||
updatedAt: "2026-05-29T08:05:00.000Z"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
defer {
|
defer {
|
||||||
@ -220,11 +511,9 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
|||||||
|
|
||||||
let firstService = RemoteLibrarySyncService(
|
let firstService = RemoteLibrarySyncService(
|
||||||
repository: DefaultRemoteLibraryRepository(
|
repository: DefaultRemoteLibraryRepository(
|
||||||
apiClient: MockVelodyAPIClient(
|
apiClient: MockVelodyAPIClient(audioAssetData: audioData),
|
||||||
remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: []),
|
store: InMemoryRemoteLibraryStore(),
|
||||||
audioAssetData: audioData
|
syncCursorStore: InMemoryRemoteLibrarySyncCursorStore()
|
||||||
),
|
|
||||||
store: InMemoryRemoteLibraryStore()
|
|
||||||
),
|
),
|
||||||
downloadStateStore: try FileRemoteTrackDownloadStateStore(fileURL: stateFileURL),
|
downloadStateStore: try FileRemoteTrackDownloadStateStore(fileURL: stateFileURL),
|
||||||
audioFileStore: try FileOfflineAudioFileStore(baseDirectoryURL: firstAudioDirectory),
|
audioFileStore: try FileOfflineAudioFileStore(baseDirectoryURL: firstAudioDirectory),
|
||||||
@ -240,8 +529,9 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
|||||||
let relaunchedAudioStore = try FileOfflineAudioFileStore(baseDirectoryURL: secondAudioDirectory)
|
let relaunchedAudioStore = try FileOfflineAudioFileStore(baseDirectoryURL: secondAudioDirectory)
|
||||||
let relaunchedService = RemoteLibrarySyncService(
|
let relaunchedService = RemoteLibrarySyncService(
|
||||||
repository: DefaultRemoteLibraryRepository(
|
repository: DefaultRemoteLibraryRepository(
|
||||||
apiClient: MockVelodyAPIClient(remoteLibraryResponse: RemoteLibraryResponseDTO(tracks: [])),
|
apiClient: MockVelodyAPIClient(),
|
||||||
store: InMemoryRemoteLibraryStore()
|
store: InMemoryRemoteLibraryStore(),
|
||||||
|
syncCursorStore: InMemoryRemoteLibrarySyncCursorStore()
|
||||||
),
|
),
|
||||||
downloadStateStore: try FileRemoteTrackDownloadStateStore(fileURL: stateFileURL),
|
downloadStateStore: try FileRemoteTrackDownloadStateStore(fileURL: stateFileURL),
|
||||||
audioFileStore: relaunchedAudioStore,
|
audioFileStore: relaunchedAudioStore,
|
||||||
@ -255,7 +545,7 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
|||||||
.loadDownloadStates()
|
.loadDownloadStates()
|
||||||
.first
|
.first
|
||||||
|
|
||||||
XCTAssertEqual(restoredState.downloadStatus, .downloaded)
|
XCTAssertEqual(restoredState.downloadStatus, RemoteTrackDownloadStatus.downloaded)
|
||||||
XCTAssertEqual(restoredState.localFilePath, recreatedStoreFileURL.standardizedFileURL.path)
|
XCTAssertEqual(restoredState.localFilePath, recreatedStoreFileURL.standardizedFileURL.path)
|
||||||
XCTAssertEqual(persistedRestoredState?.localFilePath, recreatedStoreFileURL.standardizedFileURL.path)
|
XCTAssertEqual(persistedRestoredState?.localFilePath, recreatedStoreFileURL.standardizedFileURL.path)
|
||||||
XCTAssertTrue(fileManager.fileExists(atPath: restoredState.localFilePath))
|
XCTAssertTrue(fileManager.fileExists(atPath: restoredState.localFilePath))
|
||||||
@ -270,37 +560,22 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
|||||||
width: 1,
|
width: 1,
|
||||||
height: 1
|
height: 1
|
||||||
)
|
)
|
||||||
let artworkStore = InMemoryArtworkStore()
|
let track = makeRemoteTrack(
|
||||||
let service = RemoteLibrarySyncService(
|
|
||||||
repository: DefaultRemoteLibraryRepository(
|
|
||||||
apiClient: MockVelodyAPIClient(
|
|
||||||
remoteLibraryResponse: RemoteLibraryResponseDTO(
|
|
||||||
tracks: [
|
|
||||||
RemoteTrackDTO(
|
|
||||||
trackId: "track-123",
|
trackId: "track-123",
|
||||||
title: "Remote Title",
|
artwork: artwork
|
||||||
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
|
|
||||||
)
|
)
|
||||||
),
|
let artworkStore = InMemoryArtworkStore()
|
||||||
]
|
let service = makeSyncService(
|
||||||
|
apiClient: MockVelodyAPIClient(
|
||||||
|
bootstrapResponse: SyncBootstrapResponse(
|
||||||
|
nextCursor: SyncCursor(value: "1"),
|
||||||
|
tracks: [track],
|
||||||
|
serverTime: "2026-06-15T12:00:00.000Z"
|
||||||
),
|
),
|
||||||
artworkData: sampleArtworkData()
|
artworkData: sampleArtworkData()
|
||||||
),
|
),
|
||||||
store: InMemoryRemoteLibraryStore()
|
store: InMemoryRemoteLibraryStore(),
|
||||||
),
|
cursorStore: InMemoryRemoteLibrarySyncCursorStore(),
|
||||||
downloadStateStore: InMemoryRemoteTrackDownloadStateStore(),
|
|
||||||
audioFileStore: InMemoryOfflineAudioFileStore(),
|
|
||||||
artworkStore: artworkStore
|
artworkStore: artworkStore
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -315,24 +590,53 @@ final class RemoteLibrarySyncServiceTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct MockVelodyAPIClient: VelodyAPIClient {
|
private func makeSyncService(
|
||||||
let remoteLibraryResponse: RemoteLibraryResponseDTO?
|
apiClient: any VelodyAPIClient,
|
||||||
let remoteLibraryError: VelodyAPIError?
|
store: any RemoteLibraryStore,
|
||||||
let audioAssetData: Data?
|
cursorStore: any RemoteLibrarySyncCursorStore,
|
||||||
let downloadError: VelodyAPIError?
|
downloadStateStore: any RemoteTrackDownloadStateStore = InMemoryRemoteTrackDownloadStateStore(),
|
||||||
let artworkData: Data?
|
audioFileStore: any OfflineAudioFileStore = InMemoryOfflineAudioFileStore(),
|
||||||
let artworkDownloadError: VelodyAPIError?
|
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(
|
init(
|
||||||
remoteLibraryResponse: RemoteLibraryResponseDTO? = nil,
|
bootstrapResponse: SyncBootstrapResponse = SyncBootstrapResponse(
|
||||||
remoteLibraryError: VelodyAPIError? = nil,
|
nextCursor: SyncCursor(value: "0"),
|
||||||
|
tracks: [],
|
||||||
|
serverTime: "2026-06-15T12:00:00.000Z"
|
||||||
|
),
|
||||||
|
changeResponses: [SyncChangesResponse] = [],
|
||||||
|
changeError: VelodyAPIError? = nil,
|
||||||
audioAssetData: Data? = nil,
|
audioAssetData: Data? = nil,
|
||||||
downloadError: VelodyAPIError? = nil,
|
downloadError: VelodyAPIError? = nil,
|
||||||
artworkData: Data? = nil,
|
artworkData: Data? = nil,
|
||||||
artworkDownloadError: VelodyAPIError? = nil
|
artworkDownloadError: VelodyAPIError? = nil
|
||||||
) {
|
) {
|
||||||
self.remoteLibraryResponse = remoteLibraryResponse
|
self.bootstrapResponse = bootstrapResponse
|
||||||
self.remoteLibraryError = remoteLibraryError
|
self.changeResponses = changeResponses
|
||||||
|
self.changeError = changeError
|
||||||
self.audioAssetData = audioAssetData
|
self.audioAssetData = audioAssetData
|
||||||
self.downloadError = downloadError
|
self.downloadError = downloadError
|
||||||
self.artworkData = artworkData
|
self.artworkData = artworkData
|
||||||
@ -347,7 +651,7 @@ private struct MockVelodyAPIClient: VelodyAPIClient {
|
|||||||
deviceId: UUID().uuidString,
|
deviceId: UUID().uuidString,
|
||||||
deviceAccessToken: UUID().uuidString,
|
deviceAccessToken: UUID().uuidString,
|
||||||
bootstrapToken: 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
|
_ = payload
|
||||||
return DeviceHeartbeatResponse(
|
return DeviceHeartbeatResponse(
|
||||||
ok: true,
|
ok: true,
|
||||||
serverTime: "2026-05-29T08:00:00.000Z"
|
serverTime: "2026-06-15T12:00:00.000Z"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchSyncBootstrap() async throws -> SyncBootstrapResponse {
|
func fetchSyncBootstrap() async throws -> SyncBootstrapResponse {
|
||||||
SyncBootstrapResponse(
|
bootstrapResponse
|
||||||
nextCursor: SyncCursor(value: "0"),
|
}
|
||||||
tracks: [],
|
|
||||||
|
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: [],
|
events: [],
|
||||||
deletedTrackIds: [],
|
serverTime: "2026-06-15T12:00:00.000Z"
|
||||||
serverTime: "2026-05-29T08:00:00.000Z"
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let response = changeResponses[min(changeResponseIndex, changeResponses.count - 1)]
|
||||||
|
changeResponseIndex += 1
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
func fetchRemoteLibrary(
|
func fetchRemoteLibrary(
|
||||||
deviceId: String
|
deviceId: String
|
||||||
) async throws -> RemoteLibraryResponseDTO {
|
) async throws -> RemoteLibraryResponseDTO {
|
||||||
_ = deviceId
|
_ = deviceId
|
||||||
|
return RemoteLibraryResponseDTO(tracks: [])
|
||||||
if let remoteLibraryError {
|
|
||||||
throw remoteLibraryError
|
|
||||||
}
|
|
||||||
|
|
||||||
return remoteLibraryResponse ?? RemoteLibraryResponseDTO(tracks: [])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func downloadAudioAsset(
|
func downloadAudioAsset(
|
||||||
@ -457,6 +774,79 @@ private struct MockVelodyAPIClient: VelodyAPIClient {
|
|||||||
assetId: UUID().uuidString
|
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 {
|
private func sampleMp3Data(seed: String) -> Data {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user