import Foundation 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) } 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 ) } }