You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
356 lines
16 KiB
356 lines
16 KiB
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")
|
|
}
|
|
|
|
// Verifies that sorting by album orders tracks within an album by disc then track
|
|
// number ascending — and that this secondary order stays ascending even when the
|
|
// album sort direction is descending (so an album always reads in playing order).
|
|
@Test func fetchTracksByAlbumOrdersByDiscAndTrackNumber() throws {
|
|
// 1. Insert one album's tracks out of order, spanning two discs, so only a
|
|
// secondary disc/track sort can restore playing order.
|
|
let db = try DatabaseService(inMemory: true)
|
|
let fixtures = [
|
|
Track.fixture(fileURL: "/3.mp3", title: "C", album: "Greatest Hits", trackNumber: 3, discNumber: 1),
|
|
Track.fixture(fileURL: "/1.mp3", title: "A", album: "Greatest Hits", trackNumber: 1, discNumber: 1),
|
|
Track.fixture(fileURL: "/4.mp3", title: "D", album: "Greatest Hits", trackNumber: 1, discNumber: 2),
|
|
Track.fixture(fileURL: "/2.mp3", title: "B", album: "Greatest Hits", trackNumber: 2, discNumber: 1),
|
|
]
|
|
for var t in fixtures { try db.insert(&t) }
|
|
|
|
// 2. Sort by album ascending → disc1/1, disc1/2, disc1/3, disc2/1.
|
|
let asc = try db.fetchTracks(search: "", sortColumn: "album", ascending: true)
|
|
#expect(asc.map(\.title) == ["A", "B", "C", "D"])
|
|
|
|
// 3. Sort by album descending → same within-album order, because the disc/track
|
|
// secondary sort is always ascending regardless of the album direction.
|
|
let desc = try db.fetchTracks(search: "", sortColumn: "album", ascending: false)
|
|
#expect(desc.map(\.title) == ["A", "B", "C", "D"])
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// Verifies updateTrack persists edited fields and that the tracks_ft index
|
|
// stays in sync (the synchronize-installed triggers fire on UPDATE).
|
|
@Test func updateTrackPersistsFieldsAndSyncsFTS() throws {
|
|
// Step 1: insert a track.
|
|
let db = try DatabaseService(inMemory: true)
|
|
var t = Track.fixture(title: "Original Title", artist: "X")
|
|
try db.insert(&t)
|
|
// Step 2: edit fields and update.
|
|
t.title = "Renamed Title"; t.album = "New Album"
|
|
try db.updateTrack(t)
|
|
// Step 3: re-fetch and assert persisted.
|
|
let fetched = try #require(db.fetchTracksByIds([t.id!]).first)
|
|
#expect(fetched.title == "Renamed Title")
|
|
#expect(fetched.album == "New Album")
|
|
// Step 4: FTS reflects the new title and not the old (triggers keep it synced).
|
|
#expect(try db.fetchTracks(search: "Renamed", sortColumn: "title", ascending: true).count == 1)
|
|
#expect(try db.fetchTracks(search: "Original", sortColumn: "title", ascending: true).count == 0)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|