velody/packages/apple/VelodySync/Tests/VelodySyncTests/OfflineLibraryServiceTests.swift
2026-05-30 08:45:13 +02:00

524 lines
20 KiB
Swift

import CryptoKit
import Foundation
import XCTest
import VelodyDomain
import VelodyNetworking
import VelodyPersistence
@testable import VelodySync
final class OfflineLibraryServiceTests: XCTestCase {
func testOfflineLibraryContainsOnlyTracksWithExistingLocalFiles() async throws {
let fileManager = FileManager.default
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
UUID().uuidString,
isDirectory: true
)
let audioDirectory = tempDirectory.appendingPathComponent("audio", isDirectory: true)
let availableTrack = makeRemoteTrack(
trackId: "track-available",
assetId: "asset-available",
title: "1 Mai 2026"
)
let missingTrack = makeRemoteTrack(
trackId: "track-missing",
assetId: "asset-missing",
title: "2 Mai 2026"
)
let availableBytes = sampleMp3Data(seed: availableTrack.assetId)
let audioFileStore = try FileOfflineAudioFileStore(baseDirectoryURL: audioDirectory)
let availableLocalFilePath = try await audioFileStore.saveAudioFile(
availableBytes,
assetId: availableTrack.assetId,
sha256: availableTrack.sha256
)
let snapshot = try await makeOfflineLibraryService(
remoteTracks: [availableTrack, missingTrack],
downloadStates: [
RemoteTrackDownloadState(
remoteTrackId: availableTrack.trackId,
assetId: availableTrack.assetId,
localFilePath: availableLocalFilePath,
downloadedAt: Date(timeIntervalSince1970: 1_000),
downloadStatus: .downloaded
),
RemoteTrackDownloadState(
remoteTrackId: missingTrack.trackId,
assetId: missingTrack.assetId,
localFilePath: audioDirectory.appendingPathComponent("asset-missing.mp3").path,
downloadedAt: Date(timeIntervalSince1970: 2_000),
downloadStatus: .downloaded
),
],
audioFileStore: audioFileStore
).loadSnapshot()
defer {
try? fileManager.removeItem(at: tempDirectory)
}
XCTAssertEqual(snapshot.availableTracks.map(\.remoteTrackId), [availableTrack.trackId])
XCTAssertEqual(
snapshot.remoteTracks.first(where: { $0.remoteTrack.trackId == availableTrack.trackId })?.status,
.downloaded
)
XCTAssertEqual(
snapshot.remoteTracks.first(where: { $0.remoteTrack.trackId == missingTrack.trackId })?.status,
.missing
)
}
func testMissingDownloadedFileBecomesMissingAndNotAvailable() async throws {
let missingTrack = makeRemoteTrack(
trackId: "track-missing",
assetId: "asset-missing",
title: "Missing Track"
)
let missingState = RemoteTrackDownloadState(
remoteTrackId: missingTrack.trackId,
assetId: missingTrack.assetId,
localFilePath: "/tmp/missing-track.mp3",
downloadedAt: Date(timeIntervalSince1970: 1_000),
downloadStatus: .downloaded
)
let snapshot = try await makeOfflineLibraryService(
remoteTracks: [missingTrack],
downloadStates: [missingState],
audioFileStore: InMemoryOfflineAudioFileStore()
).loadSnapshot()
let remoteTrack = try XCTUnwrap(snapshot.remoteTracks.first)
XCTAssertEqual(remoteTrack.status, .missing)
XCTAssertEqual(remoteTrack.lastDownloadError, "The downloaded MP3 file is missing.")
XCTAssertTrue(snapshot.availableTracks.isEmpty)
}
func testOfflineLibraryRepairsStaleLocalPathsWhenCurrentAudioDirectoryHasFile() async throws {
let fileManager = FileManager.default
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
UUID().uuidString,
isDirectory: true
)
let stateFileURL = tempDirectory.appendingPathComponent("remote-download-states.json")
let firstAudioDirectory = tempDirectory.appendingPathComponent("audio-v1", isDirectory: true)
let secondAudioDirectory = tempDirectory.appendingPathComponent("audio-v2", isDirectory: true)
let track = makeRemoteTrack(
trackId: "track-123",
assetId: "asset-456",
title: "1 Mai 2026"
)
let audioData = sampleMp3Data(seed: track.assetId)
let firstAudioStore = try FileOfflineAudioFileStore(baseDirectoryURL: firstAudioDirectory)
let firstLocalFilePath = try await firstAudioStore.saveAudioFile(
audioData,
assetId: track.assetId,
sha256: track.sha256
)
let recreatedFileURL = secondAudioDirectory.appendingPathComponent("\(track.assetId).mp3")
defer {
try? fileManager.removeItem(at: tempDirectory)
}
try await FileRemoteTrackDownloadStateStore(fileURL: stateFileURL).saveDownloadStates([
RemoteTrackDownloadState(
remoteTrackId: track.trackId,
assetId: track.assetId,
localFilePath: firstLocalFilePath,
downloadedAt: Date(timeIntervalSince1970: 1_000),
downloadStatus: .downloaded
),
])
try fileManager.createDirectory(at: secondAudioDirectory, withIntermediateDirectories: true)
try fileManager.moveItem(
at: URL(fileURLWithPath: firstLocalFilePath),
to: recreatedFileURL
)
let downloadStateStore = try FileRemoteTrackDownloadStateStore(fileURL: stateFileURL)
let repository = InMemoryRemoteLibraryRepository(tracks: [track])
let relaunchedAudioStore = try FileOfflineAudioFileStore(baseDirectoryURL: secondAudioDirectory)
let syncService = RemoteLibrarySyncService(
repository: repository,
downloadStateStore: downloadStateStore,
audioFileStore: relaunchedAudioStore
)
let offlineLibraryService = OfflineLibraryService(
syncService: syncService,
audioFileStore: relaunchedAudioStore
)
let snapshot = try await offlineLibraryService.loadSnapshot()
let availableTrack = try XCTUnwrap(snapshot.availableTracks.first)
let persistedState = try await downloadStateStore.loadDownloadStates().first
XCTAssertEqual(availableTrack.localFilePath, recreatedFileURL.standardizedFileURL.path)
XCTAssertEqual(persistedState?.localFilePath, recreatedFileURL.standardizedFileURL.path)
}
func testRedownloadAfterMissingFileRestoresPlayableOfflineState() async throws {
let fileManager = FileManager.default
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
UUID().uuidString,
isDirectory: true
)
let audioDirectory = tempDirectory.appendingPathComponent("audio", isDirectory: true)
let track = makeRemoteTrack(
trackId: "track-redownload",
assetId: "asset-redownload",
title: "Re-download Me"
)
let remoteLibraryStore = InMemoryRemoteLibraryStore(tracks: [track])
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore()
let audioFileStore = try FileOfflineAudioFileStore(baseDirectoryURL: audioDirectory)
let syncService = RemoteLibrarySyncService(
repository: DefaultRemoteLibraryRepository(
apiClient: OfflineLibraryMockAPIClient(audioAssetData: sampleMp3Data(seed: track.assetId)),
store: remoteLibraryStore
),
downloadStateStore: downloadStateStore,
audioFileStore: audioFileStore
)
let offlineLibraryService = OfflineLibraryService(
syncService: syncService,
audioFileStore: audioFileStore
)
defer {
try? fileManager.removeItem(at: tempDirectory)
}
let originalState = try await syncService.downloadTrack(track, deviceId: "device-123")
try fileManager.removeItem(at: URL(fileURLWithPath: originalState.localFilePath))
let missingSnapshot = try await offlineLibraryService.loadSnapshot()
XCTAssertEqual(missingSnapshot.remoteTracks.first?.status, .missing)
XCTAssertTrue(missingSnapshot.availableTracks.isEmpty)
_ = try await syncService.downloadTrack(track, deviceId: "device-123")
let restoredSnapshot = try await offlineLibraryService.loadSnapshot()
XCTAssertEqual(restoredSnapshot.remoteTracks.first?.status, .downloaded)
XCTAssertEqual(restoredSnapshot.availableTracks.map(\.remoteTrackId), [track.trackId])
let restoredLocalFilePath = try XCTUnwrap(restoredSnapshot.availableTracks.first?.localFilePath)
let fileExists = await audioFileStore.fileExists(at: restoredLocalFilePath)
XCTAssertTrue(fileExists)
}
func testMetadataSyncDoesNotEraseDownloadedOfflineAvailability() async throws {
let fileManager = FileManager.default
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
UUID().uuidString,
isDirectory: true
)
let audioDirectory = tempDirectory.appendingPathComponent("audio", isDirectory: true)
let track = makeRemoteTrack(
trackId: "track-sync",
assetId: "asset-sync",
title: "Sync Safe"
)
let remoteLibraryStore = InMemoryRemoteLibraryStore()
let audioData = sampleMp3Data(seed: track.assetId)
let apiClient = OfflineLibraryMockAPIClient(
remoteLibraryResponse: RemoteLibraryResponseDTO(
tracks: [makeRemoteTrackDTO(from: track)]
),
audioAssetData: audioData
)
let downloadStateStore = InMemoryRemoteTrackDownloadStateStore()
let audioFileStore = try FileOfflineAudioFileStore(baseDirectoryURL: audioDirectory)
let syncService = RemoteLibrarySyncService(
repository: DefaultRemoteLibraryRepository(
apiClient: apiClient,
store: remoteLibraryStore
),
downloadStateStore: downloadStateStore,
audioFileStore: audioFileStore
)
let offlineLibraryService = OfflineLibraryService(
syncService: syncService,
audioFileStore: audioFileStore
)
defer {
try? fileManager.removeItem(at: tempDirectory)
}
_ = try await syncService.syncRemoteLibrary(deviceId: "device-123")
_ = try await syncService.downloadTrack(track, deviceId: "device-123")
let beforeResync = try await offlineLibraryService.loadSnapshot()
_ = try await syncService.syncRemoteLibrary(deviceId: "device-123")
let afterResync = try await offlineLibraryService.loadSnapshot()
XCTAssertEqual(beforeResync.availableTracks.map(\.remoteTrackId), [track.trackId])
XCTAssertEqual(afterResync.availableTracks.map(\.remoteTrackId), [track.trackId])
XCTAssertEqual(afterResync.remoteTracks.first?.status, .downloaded)
}
func testRelaunchSimulationRebuildsOfflineLibraryAccurately() async throws {
let fileManager = FileManager.default
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
UUID().uuidString,
isDirectory: true
)
let remoteLibraryFileURL = tempDirectory.appendingPathComponent("remote-library.json")
let downloadStateFileURL = tempDirectory.appendingPathComponent("remote-download-states.json")
let audioDirectory = tempDirectory.appendingPathComponent("audio", isDirectory: true)
let tracks = [
makeRemoteTrack(trackId: "track-1", assetId: "asset-1", title: "Track 1"),
makeRemoteTrack(trackId: "track-2", assetId: "asset-2", title: "Track 2"),
]
let apiClient = OfflineLibraryMockAPIClient(
remoteLibraryResponse: RemoteLibraryResponseDTO(
tracks: tracks.map { makeRemoteTrackDTO(from: $0) }
),
audioAssetDataByAssetID: [
"asset-1": sampleMp3Data(seed: "asset-1"),
"asset-2": sampleMp3Data(seed: "asset-2"),
]
)
defer {
try? fileManager.removeItem(at: tempDirectory)
}
let firstRepository = DefaultRemoteLibraryRepository(
apiClient: apiClient,
store: try FileRemoteLibraryStore(fileURL: remoteLibraryFileURL)
)
let firstDownloadStateStore = try FileRemoteTrackDownloadStateStore(fileURL: downloadStateFileURL)
let firstAudioStore = try FileOfflineAudioFileStore(baseDirectoryURL: audioDirectory)
let firstSyncService = RemoteLibrarySyncService(
repository: firstRepository,
downloadStateStore: firstDownloadStateStore,
audioFileStore: firstAudioStore
)
let firstOfflineLibraryService = OfflineLibraryService(
syncService: firstSyncService,
audioFileStore: firstAudioStore
)
_ = try await firstSyncService.syncRemoteLibrary(deviceId: "device-123")
for track in tracks {
_ = try await firstSyncService.downloadTrack(track, deviceId: "device-123")
}
let beforeRelaunch = try await firstOfflineLibraryService.loadSnapshot()
let relaunchedRepository = DefaultRemoteLibraryRepository(
apiClient: apiClient,
store: try FileRemoteLibraryStore(fileURL: remoteLibraryFileURL)
)
let relaunchedDownloadStateStore = try FileRemoteTrackDownloadStateStore(fileURL: downloadStateFileURL)
let relaunchedAudioStore = try FileOfflineAudioFileStore(baseDirectoryURL: audioDirectory)
let relaunchedSyncService = RemoteLibrarySyncService(
repository: relaunchedRepository,
downloadStateStore: relaunchedDownloadStateStore,
audioFileStore: relaunchedAudioStore
)
let relaunchedOfflineLibraryService = OfflineLibraryService(
syncService: relaunchedSyncService,
audioFileStore: relaunchedAudioStore
)
let afterRelaunch = try await relaunchedOfflineLibraryService.loadSnapshot()
XCTAssertEqual(beforeRelaunch.availableTracks.map(\.remoteTrackId), tracks.map(\.trackId))
XCTAssertEqual(afterRelaunch.availableTracks.map(\.remoteTrackId), tracks.map(\.trackId))
XCTAssertEqual(afterRelaunch.remoteTracks.map(\.status), [.downloaded, .downloaded])
}
}
private func makeOfflineLibraryService(
remoteTracks: [RemoteTrack],
downloadStates: [RemoteTrackDownloadState],
audioFileStore: any OfflineAudioFileStore
) -> OfflineLibraryService {
let syncService = RemoteLibrarySyncService(
repository: InMemoryRemoteLibraryRepository(tracks: remoteTracks),
downloadStateStore: InMemoryRemoteTrackDownloadStateStore(states: downloadStates),
audioFileStore: audioFileStore
)
return OfflineLibraryService(
syncService: syncService,
audioFileStore: audioFileStore
)
}
private actor InMemoryRemoteLibraryRepository: RemoteLibraryRepository {
private var tracks: [RemoteTrack]
init(tracks: [RemoteTrack]) {
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
return Data()
}
}
private struct OfflineLibraryMockAPIClient: VelodyAPIClient {
let remoteLibraryResponse: RemoteLibraryResponseDTO?
let audioAssetData: Data?
let audioAssetDataByAssetID: [String: Data]
init(
remoteLibraryResponse: RemoteLibraryResponseDTO? = nil,
audioAssetData: Data? = nil,
audioAssetDataByAssetID: [String: Data] = [:]
) {
self.remoteLibraryResponse = remoteLibraryResponse
self.audioAssetData = audioAssetData
self.audioAssetDataByAssetID = audioAssetDataByAssetID
}
func registerDevice(
_ payload: DeviceRegistrationPayload
) async throws -> DeviceRegistrationResponse {
_ = payload
return DeviceRegistrationResponse(
deviceId: UUID().uuidString,
bootstrapToken: UUID().uuidString,
serverTime: "2026-05-30T08:00:00.000Z"
)
}
func sendHeartbeat(
_ payload: DeviceHeartbeatPayload
) async throws -> DeviceHeartbeatResponse {
_ = payload
return DeviceHeartbeatResponse(
ok: true,
serverTime: "2026-05-30T08:00:00.000Z"
)
}
func fetchSyncBootstrap() async throws -> SyncBootstrapResponse {
SyncBootstrapResponse(
nextCursor: SyncCursor(value: "0"),
tracks: [],
events: [],
deletedTrackIds: [],
serverTime: "2026-05-30T08:00:00.000Z"
)
}
func fetchRemoteLibrary(
deviceId: String
) async throws -> RemoteLibraryResponseDTO {
_ = deviceId
return remoteLibraryResponse ?? RemoteLibraryResponseDTO(tracks: [])
}
func downloadAudioAsset(
assetId: String,
deviceId: String
) async throws -> Data {
_ = deviceId
return audioAssetDataByAssetID[assetId] ?? audioAssetData ?? Data()
}
func prepareUpload(
_ payload: UploadPrepareRequest
) async throws -> UploadPrepareResponse {
_ = payload
return UploadPrepareResponse(status: .uploadRequired, uploadId: UUID().uuidString, nextOffset: 0)
}
func fetchUploadStatus(
uploadId: String
) async throws -> UploadSessionStatusResponse {
UploadSessionStatusResponse(
uploadId: uploadId,
status: .completed,
receivedBytes: "0",
expectedSizeBytes: "0",
nextOffset: "0"
)
}
func uploadFile(
uploadId: String,
fileURL: URL,
mimeType: String
) async throws -> UploadSessionStatusResponse {
_ = uploadId
_ = fileURL
_ = mimeType
return UploadSessionStatusResponse(
uploadId: UUID().uuidString,
status: .completed,
receivedBytes: "0",
expectedSizeBytes: "0",
nextOffset: "0"
)
}
func finalizeUpload(
uploadId: String,
payload: UploadFinalizeRequest
) async throws -> UploadFinalizeResponse {
_ = uploadId
_ = payload
return UploadFinalizeResponse(
trackId: UUID().uuidString,
assetId: UUID().uuidString
)
}
}
private func makeRemoteTrack(
trackId: String,
assetId: String,
title: String
) -> RemoteTrack {
let bytes = sampleMp3Data(seed: assetId)
return RemoteTrack(
trackId: trackId,
title: title,
artist: "Remote Artist",
durationSeconds: 245,
sha256: sha256Hex(bytes),
assetId: assetId,
createdAt: "2026-05-30T08:00:00.000Z",
updatedAt: "2026-05-30T08:05:00.000Z"
)
}
private func makeRemoteTrackDTO(from track: RemoteTrack) -> RemoteTrackDTO {
RemoteTrackDTO(
trackId: track.trackId,
title: track.title,
artist: track.artist,
durationSeconds: track.durationSeconds,
sha256: track.sha256,
assetId: track.assetId,
createdAt: track.createdAt,
updatedAt: track.updatedAt
)
}
private func sampleMp3Data(seed: String) -> Data {
Data([
0x49, 0x44, 0x33, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x21,
] + Array(seed.utf8))
}
private func sha256Hex(_ data: Data) -> String {
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
}