456 lines
16 KiB
Swift
456 lines
16 KiB
Swift
import Foundation
|
|
import SwiftData
|
|
import XCTest
|
|
@testable import VelodyPersistence
|
|
import VelodyDomain
|
|
|
|
final class LocalCatalogServiceTests: XCTestCase {
|
|
func testScanPersistsTracksAndSkipsDuplicateSHA() async throws {
|
|
let repository = try SwiftDataTrackRepository(isStoredInMemoryOnly: true)
|
|
let service = DefaultLocalCatalogService(repository: repository)
|
|
let modifiedAt = Date(timeIntervalSince1970: 1_000)
|
|
|
|
let result = try await service.reconcileScanResults(
|
|
[
|
|
makeScannedTrack(
|
|
title: "Alpha",
|
|
path: "/Music/Alpha.mp3",
|
|
sha256: "sha-alpha",
|
|
modifiedAt: modifiedAt
|
|
),
|
|
makeScannedTrack(
|
|
title: "Alpha Copy",
|
|
path: "/Music/Copies/Alpha.mp3",
|
|
sha256: "sha-alpha",
|
|
modifiedAt: modifiedAt
|
|
),
|
|
],
|
|
in: URL(fileURLWithPath: "/Music"),
|
|
scannedAt: Date(timeIntervalSince1970: 1_100)
|
|
)
|
|
|
|
XCTAssertEqual(result.insertedTrackCount, 1)
|
|
XCTAssertEqual(result.skippedDuplicateTrackCount, 1)
|
|
XCTAssertEqual(result.deletedTrackCount, 0)
|
|
XCTAssertEqual(result.tracks.count, 1)
|
|
XCTAssertEqual(result.tracks.first?.localFilePath, "/Music/Alpha.mp3")
|
|
|
|
let storedTracks = try await repository.loadLocalTracks(
|
|
origin: .localScan,
|
|
includeDeleted: true
|
|
)
|
|
XCTAssertEqual(storedTracks.count, 1)
|
|
XCTAssertEqual(storedTracks.first?.sha256, "sha-alpha")
|
|
XCTAssertEqual(storedTracks.first?.fileModifiedAt, modifiedAt)
|
|
}
|
|
|
|
func testModifiedFileAtSamePathUpdatesExistingTrackInsteadOfDuplicating() async throws {
|
|
let repository = try SwiftDataTrackRepository(isStoredInMemoryOnly: true)
|
|
let service = DefaultLocalCatalogService(repository: repository)
|
|
let folderURL = URL(fileURLWithPath: "/Music")
|
|
|
|
_ = try await service.reconcileScanResults(
|
|
[
|
|
makeScannedTrack(
|
|
title: "Original",
|
|
path: "/Music/Track.mp3",
|
|
sha256: "sha-original",
|
|
modifiedAt: Date(timeIntervalSince1970: 2_000)
|
|
),
|
|
],
|
|
in: folderURL,
|
|
scannedAt: Date(timeIntervalSince1970: 2_100)
|
|
)
|
|
|
|
let result = try await service.reconcileScanResults(
|
|
[
|
|
makeScannedTrack(
|
|
title: "Updated Title",
|
|
path: "/Music/Track.mp3",
|
|
sha256: "sha-updated",
|
|
modifiedAt: Date(timeIntervalSince1970: 2_200)
|
|
),
|
|
],
|
|
in: folderURL,
|
|
scannedAt: Date(timeIntervalSince1970: 2_300)
|
|
)
|
|
|
|
XCTAssertEqual(result.insertedTrackCount, 0)
|
|
XCTAssertEqual(result.updatedTrackCount, 1)
|
|
XCTAssertEqual(result.deletedTrackCount, 0)
|
|
XCTAssertEqual(result.tracks.count, 1)
|
|
XCTAssertEqual(result.tracks.first?.title, "Updated Title")
|
|
XCTAssertEqual(result.tracks.first?.sha256, "sha-updated")
|
|
|
|
let storedTracks = try await repository.loadLocalTracks(
|
|
origin: .localScan,
|
|
includeDeleted: true
|
|
)
|
|
XCTAssertEqual(storedTracks.count, 1)
|
|
XCTAssertEqual(storedTracks.first?.title, "Updated Title")
|
|
XCTAssertEqual(storedTracks.first?.sha256, "sha-updated")
|
|
}
|
|
|
|
func testScanPersistsEmbeddedArtworkReferenceWithoutStoringArtworkBytesInSwiftData() async throws {
|
|
let repository = try SwiftDataTrackRepository(isStoredInMemoryOnly: true)
|
|
let service = DefaultLocalCatalogService(repository: repository)
|
|
let modifiedAt = Date(timeIntervalSince1970: 2_500)
|
|
let artwork = LocalTrackArtwork(
|
|
localFilePath: "/Application Support/Velody/local-artwork/artwork-sha.png",
|
|
sha256: "artwork-sha",
|
|
mimeType: "image/png",
|
|
width: 512,
|
|
height: 512
|
|
)
|
|
|
|
let result = try await service.reconcileScanResults(
|
|
[
|
|
ScannedLocalTrack(
|
|
title: "Art Track",
|
|
artist: "Artist",
|
|
album: "Album",
|
|
durationSeconds: 180,
|
|
localFilePath: "/Music/ArtTrack.mp3",
|
|
sha256: "sha-art-track",
|
|
artwork: artwork,
|
|
fileModifiedAt: modifiedAt
|
|
),
|
|
],
|
|
in: URL(fileURLWithPath: "/Music"),
|
|
scannedAt: Date(timeIntervalSince1970: 2_600)
|
|
)
|
|
|
|
let storedTrack = try XCTUnwrap(result.tracks.first)
|
|
XCTAssertEqual(storedTrack.artwork, artwork)
|
|
|
|
let reloadedTrack = try await repository.findTrack(trackID: storedTrack.id)
|
|
XCTAssertEqual(reloadedTrack?.artwork, artwork)
|
|
}
|
|
|
|
func testRescanMarksDeletedTracksAndReactivatesExistingSHA() async throws {
|
|
let repository = try SwiftDataTrackRepository(isStoredInMemoryOnly: true)
|
|
let service = DefaultLocalCatalogService(repository: repository)
|
|
let folderURL = URL(fileURLWithPath: "/Music")
|
|
|
|
_ = try await service.reconcileScanResults(
|
|
[
|
|
makeScannedTrack(
|
|
title: "Alpha",
|
|
path: "/Music/Alpha.mp3",
|
|
sha256: "sha-alpha",
|
|
modifiedAt: Date(timeIntervalSince1970: 3_000)
|
|
),
|
|
makeScannedTrack(
|
|
title: "Beta",
|
|
path: "/Music/Beta.mp3",
|
|
sha256: "sha-beta",
|
|
modifiedAt: Date(timeIntervalSince1970: 3_000)
|
|
),
|
|
],
|
|
in: folderURL,
|
|
scannedAt: Date(timeIntervalSince1970: 3_100)
|
|
)
|
|
|
|
let deleteResult = try await service.reconcileScanResults(
|
|
[
|
|
makeScannedTrack(
|
|
title: "Alpha",
|
|
path: "/Music/Alpha.mp3",
|
|
sha256: "sha-alpha",
|
|
modifiedAt: Date(timeIntervalSince1970: 3_200)
|
|
),
|
|
],
|
|
in: folderURL,
|
|
scannedAt: Date(timeIntervalSince1970: 3_300)
|
|
)
|
|
|
|
XCTAssertEqual(deleteResult.deletedTrackCount, 1)
|
|
XCTAssertEqual(deleteResult.tracks.count, 1)
|
|
|
|
let reactivationResult = try await service.reconcileScanResults(
|
|
[
|
|
makeScannedTrack(
|
|
title: "Alpha",
|
|
path: "/Music/Alpha.mp3",
|
|
sha256: "sha-alpha",
|
|
modifiedAt: Date(timeIntervalSince1970: 3_400)
|
|
),
|
|
makeScannedTrack(
|
|
title: "Beta",
|
|
path: "/Music/Recovered/Beta.mp3",
|
|
sha256: "sha-beta",
|
|
modifiedAt: Date(timeIntervalSince1970: 3_400)
|
|
),
|
|
],
|
|
in: folderURL,
|
|
scannedAt: Date(timeIntervalSince1970: 3_500)
|
|
)
|
|
|
|
XCTAssertEqual(reactivationResult.reactivatedTrackCount, 1)
|
|
XCTAssertEqual(reactivationResult.deletedTrackCount, 0)
|
|
XCTAssertEqual(reactivationResult.tracks.count, 2)
|
|
|
|
let storedTracks = try await repository.loadLocalTracks(
|
|
origin: .localScan,
|
|
includeDeleted: true
|
|
)
|
|
XCTAssertEqual(storedTracks.count, 2)
|
|
XCTAssertEqual(
|
|
storedTracks.first(where: { $0.sha256 == "sha-beta" })?.localFilePath,
|
|
"/Music/Recovered/Beta.mp3"
|
|
)
|
|
XCTAssertEqual(
|
|
storedTracks.first(where: { $0.sha256 == "sha-beta" })?.isDeleted,
|
|
false
|
|
)
|
|
}
|
|
|
|
func testRepositoryPersistsTracksAcrossInstances() async throws {
|
|
let fileManager = FileManager.default
|
|
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
|
|
UUID().uuidString,
|
|
isDirectory: true
|
|
)
|
|
let databaseURL = tempDirectory.appendingPathComponent("catalog.store")
|
|
let folderURL = URL(fileURLWithPath: "/Music")
|
|
|
|
defer {
|
|
try? fileManager.removeItem(at: tempDirectory)
|
|
}
|
|
|
|
let firstRepository = try SwiftDataTrackRepository(databaseURL: databaseURL)
|
|
let firstService = DefaultLocalCatalogService(repository: firstRepository)
|
|
|
|
_ = try await firstService.reconcileScanResults(
|
|
[
|
|
makeScannedTrack(
|
|
title: "Persisted",
|
|
path: "/Music/Persisted.mp3",
|
|
sha256: "sha-persisted",
|
|
modifiedAt: Date(timeIntervalSince1970: 4_000)
|
|
),
|
|
],
|
|
in: folderURL,
|
|
scannedAt: Date(timeIntervalSince1970: 4_100)
|
|
)
|
|
|
|
let secondRepository = try SwiftDataTrackRepository(databaseURL: databaseURL)
|
|
let storedTracks = try await secondRepository.loadLocalTracks(
|
|
origin: .localScan,
|
|
includeDeleted: false
|
|
)
|
|
|
|
XCTAssertEqual(storedTracks.count, 1)
|
|
XCTAssertEqual(storedTracks.first?.title, "Persisted")
|
|
XCTAssertEqual(storedTracks.first?.sha256, "sha-persisted")
|
|
}
|
|
|
|
func testSyncBootstrapReplacementLeavesLocalCatalogIntact() async throws {
|
|
let repository = try SwiftDataTrackRepository(isStoredInMemoryOnly: true)
|
|
let service = DefaultLocalCatalogService(repository: repository)
|
|
|
|
_ = try await service.reconcileScanResults(
|
|
[
|
|
makeScannedTrack(
|
|
title: "Local Track",
|
|
path: "/Music/Local.mp3",
|
|
sha256: "sha-local",
|
|
modifiedAt: Date(timeIntervalSince1970: 5_000)
|
|
),
|
|
],
|
|
in: URL(fileURLWithPath: "/Music"),
|
|
scannedAt: Date(timeIntervalSince1970: 5_100)
|
|
)
|
|
|
|
try await repository.replaceTracks(
|
|
[
|
|
LibraryTrack(
|
|
id: "remote-track-1",
|
|
title: "Remote Track",
|
|
artist: "Remote Artist",
|
|
album: "Remote Album",
|
|
durationSeconds: 180,
|
|
localFilePath: "",
|
|
sha256: nil
|
|
),
|
|
]
|
|
)
|
|
|
|
let localTracks = try await repository.loadLocalTracks(
|
|
origin: .localScan,
|
|
includeDeleted: false
|
|
)
|
|
let remoteTracks = try await repository.loadLocalTracks(
|
|
origin: .syncBootstrap,
|
|
includeDeleted: false
|
|
)
|
|
let allTracks = try await repository.loadTracks()
|
|
|
|
XCTAssertEqual(localTracks.count, 1)
|
|
XCTAssertEqual(remoteTracks.count, 1)
|
|
XCTAssertEqual(allTracks.count, 2)
|
|
}
|
|
|
|
func testConcurrentScanCallsDoNotCrashAndRetainCatalogEntries() async throws {
|
|
let repository = try SwiftDataTrackRepository(isStoredInMemoryOnly: true)
|
|
let service = DefaultLocalCatalogService(repository: repository)
|
|
let folderURL = URL(fileURLWithPath: "/Music")
|
|
|
|
try await withThrowingTaskGroup(of: Void.self) { group in
|
|
for index in 0..<12 {
|
|
group.addTask {
|
|
_ = try await service.reconcileScanResults(
|
|
[
|
|
self.makeScannedTrack(
|
|
title: "Track \(index)",
|
|
path: "/Music/Track-\(index).mp3",
|
|
sha256: "sha-\(index)",
|
|
modifiedAt: Date(timeIntervalSince1970: 6_000 + Double(index))
|
|
),
|
|
],
|
|
in: folderURL,
|
|
scannedAt: Date(timeIntervalSince1970: 6_100 + Double(index))
|
|
)
|
|
}
|
|
}
|
|
|
|
try await group.waitForAll()
|
|
}
|
|
|
|
let storedTracks = try await repository.loadLocalTracks(
|
|
origin: .localScan,
|
|
includeDeleted: true
|
|
)
|
|
let activeTracks = try await repository.loadLocalTracks(
|
|
origin: .localScan,
|
|
includeDeleted: false
|
|
)
|
|
|
|
XCTAssertEqual(storedTracks.count, 12)
|
|
XCTAssertLessThanOrEqual(activeTracks.count, 1)
|
|
}
|
|
|
|
func testConcurrentSaveCallsDoNotCrash() async throws {
|
|
let repository = try SwiftDataTrackRepository(isStoredInMemoryOnly: true)
|
|
|
|
try await withThrowingTaskGroup(of: Void.self) { group in
|
|
for index in 0..<24 {
|
|
group.addTask {
|
|
try await repository.saveLocalTrack(
|
|
self.makeLocalTrack(
|
|
id: "track-\(index)",
|
|
title: "Track \(index)",
|
|
path: "/Music/Track-\(index).mp3",
|
|
sha256: "sha-save-\(index)",
|
|
observedAt: Date(timeIntervalSince1970: 7_000 + Double(index))
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
try await group.waitForAll()
|
|
}
|
|
|
|
let storedTracks = try await repository.loadLocalTracks(
|
|
origin: .localScan,
|
|
includeDeleted: false
|
|
)
|
|
|
|
XCTAssertEqual(storedTracks.count, 24)
|
|
}
|
|
|
|
func testUploadStatusPersistenceSurvivesReload() async throws {
|
|
let repository = try SwiftDataTrackRepository(isStoredInMemoryOnly: true)
|
|
let observedAt = Date(timeIntervalSince1970: 8_000)
|
|
var track = makeLocalTrack(
|
|
id: "upload-track",
|
|
title: "Uploadable",
|
|
path: "/Music/Uploadable.mp3",
|
|
sha256: "sha-uploadable",
|
|
observedAt: observedAt
|
|
)
|
|
|
|
try await repository.saveLocalTrack(track)
|
|
|
|
track.uploadStatus = .uploaded
|
|
track.remoteTrackId = "remote-track-42"
|
|
track.lastUploadError = nil
|
|
track.updatedAt = Date(timeIntervalSince1970: 8_100)
|
|
|
|
try await repository.saveLocalTrack(track)
|
|
|
|
let persistedTrack = try await repository.findTrack(trackID: track.id)
|
|
let reloadedTrack = try XCTUnwrap(persistedTrack)
|
|
|
|
XCTAssertEqual(reloadedTrack.uploadStatus, .uploaded)
|
|
XCTAssertEqual(reloadedTrack.remoteTrackId, "remote-track-42")
|
|
XCTAssertNil(reloadedTrack.lastUploadError)
|
|
}
|
|
|
|
func testRepositoryAndCatalogServiceAreActorTypes() throws {
|
|
let repository = try SwiftDataTrackRepository(isStoredInMemoryOnly: true)
|
|
let service = DefaultLocalCatalogService(repository: repository)
|
|
|
|
assertActorIsolation(repository)
|
|
assertActorIsolation(service)
|
|
}
|
|
|
|
func testRepositoryDoesNotStoreModelContext() throws {
|
|
let repository = try SwiftDataTrackRepository(isStoredInMemoryOnly: true)
|
|
let storesModelContext = Mirror(reflecting: repository).children.contains { child in
|
|
child.value is ModelContext
|
|
}
|
|
|
|
XCTAssertFalse(storesModelContext)
|
|
}
|
|
|
|
private func makeScannedTrack(
|
|
title: String,
|
|
path: String,
|
|
sha256: String,
|
|
modifiedAt: Date
|
|
) -> ScannedLocalTrack {
|
|
ScannedLocalTrack(
|
|
title: title,
|
|
artist: "Artist",
|
|
album: "Album",
|
|
durationSeconds: 180,
|
|
localFilePath: path,
|
|
sha256: sha256,
|
|
fileModifiedAt: modifiedAt
|
|
)
|
|
}
|
|
|
|
private func makeLocalTrack(
|
|
id: String,
|
|
title: String,
|
|
path: String,
|
|
sha256: String,
|
|
observedAt: Date
|
|
) -> LocalTrack {
|
|
LocalTrack(
|
|
id: id,
|
|
origin: .localScan,
|
|
title: title,
|
|
artist: "Artist",
|
|
album: "Album",
|
|
durationSeconds: 180,
|
|
localFilePath: path,
|
|
sha256: sha256,
|
|
uploadStatus: .localOnly,
|
|
remoteTrackId: nil,
|
|
lastUploadError: nil,
|
|
fileModifiedAt: observedAt,
|
|
lastScannedAt: observedAt,
|
|
isDeleted: false,
|
|
deletedAt: nil,
|
|
createdAt: observedAt,
|
|
updatedAt: observedAt
|
|
)
|
|
}
|
|
|
|
private func assertActorIsolation<T: Actor>(_ value: T) {
|
|
XCTAssertNotNil(value as AnyObject)
|
|
}
|
|
}
|