524 lines
20 KiB
Swift
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()
|
|
}
|