import Foundation import Testing import GRDB @testable import Music struct DatabaseServiceTests { // Creates an in-memory DatabaseService, inserts a track, and fetches it back. @Test func insertAndFetchTrack() throws { let db = try DatabaseService(inMemory: true) var track = Track.fixture(title: "Test Song", artist: "Test Artist") try db.insert(&track) let tracks = try db.fetchTracks(search: "", sortColumn: "title", ascending: true) #expect(tracks.count == 1) #expect(tracks[0].title == "Test Song") } // Inserts multiple tracks and verifies sorting by different columns. @Test func fetchTracksSortedByArtist() throws { let db = try DatabaseService(inMemory: true) var t1 = Track.fixture(fileURL: "/a.mp3", title: "Zebra", artist: "Alpha") var t2 = Track.fixture(fileURL: "/b.mp3", title: "Alpha", artist: "Zebra") try db.insert(&t1) try db.insert(&t2) let ascending = try db.fetchTracks(search: "", sortColumn: "artist", ascending: true) #expect(ascending[0].artist == "Alpha") #expect(ascending[1].artist == "Zebra") let descending = try db.fetchTracks(search: "", sortColumn: "artist", ascending: false) #expect(descending[0].artist == "Zebra") } // Searches using FTS5 and verifies only matching tracks are returned. @Test func fts5Search() throws { let db = try DatabaseService(inMemory: true) var t1 = Track.fixture(fileURL: "/a.mp3", title: "Bohemian Rhapsody", artist: "Queen") var t2 = Track.fixture(fileURL: "/b.mp3", title: "Hotel California", artist: "Eagles") var t3 = Track.fixture(fileURL: "/c.mp3", title: "Stairway to Heaven", artist: "Led Zeppelin") try db.insert(&t1) try db.insert(&t2) try db.insert(&t3) let results = try db.fetchTracks(search: "queen", sortColumn: "title", ascending: true) #expect(results.count == 1) #expect(results[0].title == "Bohemian Rhapsody") } // Searches with a prefix to verify autocomplete-style matching. @Test func fts5PrefixSearch() throws { let db = try DatabaseService(inMemory: true) var t1 = Track.fixture(fileURL: "/a.mp3", title: "Bohemian Rhapsody", artist: "Queen") var t2 = Track.fixture(fileURL: "/b.mp3", title: "Hotel California", artist: "Eagles") try db.insert(&t1) try db.insert(&t2) let results = try db.fetchTracks(search: "boh", sortColumn: "title", ascending: true) #expect(results.count == 1) #expect(results[0].title == "Bohemian Rhapsody") } // Inserts a batch of tracks and verifies duplicates are silently ignored. @Test func batchInsertIgnoresDuplicates() throws { let db = try DatabaseService(inMemory: true) let tracks = [ Track.fixture(fileURL: "/a.mp3", title: "Song A"), Track.fixture(fileURL: "/b.mp3", title: "Song B"), Track.fixture(fileURL: "/a.mp3", title: "Song A Duplicate"), ] try db.insertBatch(tracks) let all = try db.fetchTracks(search: "", sortColumn: "title", ascending: true) #expect(all.count == 2) } // Updates play stats and verifies they persist. @Test func updatePlayStats() throws { let db = try DatabaseService(inMemory: true) var track = Track.fixture() try db.insert(&track) let now = Date() try db.updatePlayStats(trackId: track.id!, playCount: 5, lastPlayedAt: now) let fetched = try db.fetchTracks(search: "", sortColumn: "title", ascending: true) #expect(fetched[0].playCount == 5) #expect(fetched[0].lastPlayedAt != nil) } // Validates that an invalid sort column falls back to "title". @Test func invalidSortColumnFallsBackToTitle() throws { let db = try DatabaseService(inMemory: true) var t1 = Track.fixture(fileURL: "/a.mp3", title: "Zebra") var t2 = Track.fixture(fileURL: "/b.mp3", title: "Alpha") try db.insert(&t1) try db.insert(&t2) let result = try db.fetchTracks(search: "", sortColumn: "DROP TABLE tracks", ascending: true) #expect(result[0].title == "Alpha") } // Creates a playlist and verifies it can be fetched back. @Test func createAndFetchPlaylist() throws { let db = try DatabaseService(inMemory: true) let playlist = try db.createPlaylist(name: "Chill Vibes") #expect(playlist.id != nil) #expect(playlist.name == "Chill Vibes") let all = try db.fetchPlaylists() #expect(all.count == 1) #expect(all[0].name == "Chill Vibes") } // Renames a playlist and verifies the new name persists. @Test func renamePlaylist() throws { let db = try DatabaseService(inMemory: true) let playlist = try db.createPlaylist(name: "Old Name") try db.renamePlaylist(id: playlist.id!, name: "New Name") let all = try db.fetchPlaylists() #expect(all[0].name == "New Name") } // Deletes a playlist and verifies it's gone. @Test func deletePlaylist() throws { let db = try DatabaseService(inMemory: true) let playlist = try db.createPlaylist(name: "To Delete") try db.deletePlaylist(id: playlist.id!) let all = try db.fetchPlaylists() #expect(all.isEmpty) } // Adds tracks to a playlist and verifies order is preserved. @Test func addTracksToPlaylist() throws { let db = try DatabaseService(inMemory: true) var t1 = Track.fixture(fileURL: "/a.mp3", title: "Song A") var t2 = Track.fixture(fileURL: "/b.mp3", title: "Song B") try db.insert(&t1) try db.insert(&t2) let playlist = try db.createPlaylist(name: "My Playlist") try db.addTrackToPlaylist(trackId: t1.id!, playlistId: playlist.id!) try db.addTrackToPlaylist(trackId: t2.id!, playlistId: playlist.id!) let tracks = try db.fetchPlaylistTracks(playlistId: playlist.id!) #expect(tracks.count == 2) #expect(tracks[0].title == "Song A") #expect(tracks[1].title == "Song B") } // Removes a track from a playlist and verifies positions are compacted. @Test func removeTrackFromPlaylist() throws { let db = try DatabaseService(inMemory: true) var t1 = Track.fixture(fileURL: "/a.mp3", title: "Song A") var t2 = Track.fixture(fileURL: "/b.mp3", title: "Song B") var t3 = Track.fixture(fileURL: "/c.mp3", title: "Song C") try db.insert(&t1) try db.insert(&t2) try db.insert(&t3) let playlist = try db.createPlaylist(name: "My Playlist") try db.addTrackToPlaylist(trackId: t1.id!, playlistId: playlist.id!) try db.addTrackToPlaylist(trackId: t2.id!, playlistId: playlist.id!) try db.addTrackToPlaylist(trackId: t3.id!, playlistId: playlist.id!) // Remove middle track try db.removeTrackFromPlaylist(trackId: t2.id!, playlistId: playlist.id!) let tracks = try db.fetchPlaylistTracks(playlistId: playlist.id!) #expect(tracks.count == 2) #expect(tracks[0].title == "Song A") #expect(tracks[1].title == "Song C") } // Reorders tracks in a playlist (move last to first). @Test func reorderPlaylistTracks() throws { let db = try DatabaseService(inMemory: true) var t1 = Track.fixture(fileURL: "/a.mp3", title: "Song A") var t2 = Track.fixture(fileURL: "/b.mp3", title: "Song B") var t3 = Track.fixture(fileURL: "/c.mp3", title: "Song C") try db.insert(&t1) try db.insert(&t2) try db.insert(&t3) let playlist = try db.createPlaylist(name: "My Playlist") try db.addTrackToPlaylist(trackId: t1.id!, playlistId: playlist.id!) try db.addTrackToPlaylist(trackId: t2.id!, playlistId: playlist.id!) try db.addTrackToPlaylist(trackId: t3.id!, playlistId: playlist.id!) // Move Song C (position 2) to position 0 try db.reorderPlaylistTrack(playlistId: playlist.id!, fromPosition: 2, toPosition: 0) let tracks = try db.fetchPlaylistTracks(playlistId: playlist.id!) #expect(tracks[0].title == "Song C") #expect(tracks[1].title == "Song A") #expect(tracks[2].title == "Song B") } // Verifies that deleting a playlist cascades to playlist_tracks. @Test func deletePlaylistCascadesJoinRows() throws { let db = try DatabaseService(inMemory: true) var track = Track.fixture() try db.insert(&track) let playlist = try db.createPlaylist(name: "Temp") try db.addTrackToPlaylist(trackId: track.id!, playlistId: playlist.id!) try db.deletePlaylist(id: playlist.id!) // Re-create a playlist to verify no orphan join rows cause issues let playlist2 = try db.createPlaylist(name: "Temp2") let tracks = try db.fetchPlaylistTracks(playlistId: playlist2.id!) #expect(tracks.isEmpty) } // Verifies adding a duplicate track to a playlist is silently ignored. @Test func addDuplicateTrackToPlaylistIsIgnored() throws { let db = try DatabaseService(inMemory: true) var track = Track.fixture() try db.insert(&track) let playlist = try db.createPlaylist(name: "My Playlist") try db.addTrackToPlaylist(trackId: track.id!, playlistId: playlist.id!) try db.addTrackToPlaylist(trackId: track.id!, playlistId: playlist.id!) let tracks = try db.fetchPlaylistTracks(playlistId: playlist.id!) #expect(tracks.count == 1) } // Inserts three tracks with different dateAdded values and verifies fetchRecentlyAdded // returns them in descending order capped to the given limit. @Test func fetchRecentlyAdded() throws { let db = try DatabaseService(inMemory: true) var t1 = Track.fixture(fileURL: "/a.mp3", title: "Old", dateAdded: Date(timeIntervalSince1970: 1000)) var t2 = Track.fixture(fileURL: "/b.mp3", title: "Mid", dateAdded: Date(timeIntervalSince1970: 2000)) var t3 = Track.fixture(fileURL: "/c.mp3", title: "New", dateAdded: Date(timeIntervalSince1970: 3000)) try db.insert(&t1) try db.insert(&t2) try db.insert(&t3) let recent = try db.fetchRecentlyAdded(limit: 2) #expect(recent.count == 2) #expect(recent[0].title == "New") #expect(recent[1].title == "Mid") } // Inserts two tracks with known durations and verifies totalDuration sums them correctly. @Test func totalDuration() throws { let db = try DatabaseService(inMemory: true) var t1 = Track.fixture(fileURL: "/a.mp3", duration: 120.0) var t2 = Track.fixture(fileURL: "/b.mp3", duration: 300.5) try db.insert(&t1) try db.insert(&t2) let total = try db.totalDuration() #expect(abs(total - 420.5) < 0.01) } // Verifies totalDuration returns 0 when the library is empty. @Test func totalDurationEmptyLibrary() throws { let db = try DatabaseService(inMemory: true) let total = try db.totalDuration() #expect(total == 0) } // Inserts 5 tracks, fetches 3 by ID, verifies only those 3 are returned // in the order of the requested IDs. @Test func fetchTracksByIds() throws { let db = try DatabaseService(inMemory: true) var tracks = (0..<5).map { i in Track.fixture(fileURL: "/track\(i).mp3", title: "Track \(i)") } for i in tracks.indices { try db.insert(&tracks[i]) } let ids: [Int64] = [tracks[2].id!, tracks[0].id!, tracks[4].id!] let result = try db.fetchTracksByIds(ids) #expect(result.count == 3) #expect(result[0].id == tracks[2].id) #expect(result[1].id == tracks[0].id) #expect(result[2].id == tracks[4].id) } // Inserts tracks in different months and verifies fetchMonthlyAdditions returns // the correct per-month counts covering the requested range including empty months. // Uses a UTC calendar to match the implementation, which uses UTC month boundaries // because GRDB stores dates as UTC ISO8601 strings. @Test func fetchMonthlyAdditions() throws { let db = try DatabaseService(inMemory: true) var utcCal = Calendar(identifier: .gregorian) utcCal.timeZone = TimeZone(identifier: "UTC")! let now = Date() let thisMonth = utcCal.dateInterval(of: .month, for: now)!.start let twoMonthsAgo = utcCal.date(byAdding: .month, value: -2, to: thisMonth)! var t1 = Track.fixture(fileURL: "/a.mp3", dateAdded: thisMonth) var t2 = Track.fixture(fileURL: "/b.mp3", dateAdded: thisMonth.addingTimeInterval(86400)) var t3 = Track.fixture(fileURL: "/c.mp3", dateAdded: twoMonthsAgo) try db.insert(&t1) try db.insert(&t2) try db.insert(&t3) let results = try db.fetchMonthlyAdditions(months: 3) #expect(results.count == 3) #expect(results[0].count == 1) #expect(results[1].count == 0) #expect(results[2].count == 2) } }