velody/packages/apple/VelodyPersistence/Tests/VelodyPersistenceTests/LocalCatalogServiceTests.swift

420 lines
15 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 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)
}
}