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(_ value: T) { XCTAssertNotNil(value as AnyObject) } }