import Foundation import Testing @testable import Music // Verifies the save orchestration: DB always updated; file writeback best-effort; // stats refreshed on success; warnings on unsupported format / writer failure. struct TrackEditServiceTests { // A spy writer we can make succeed or throw. struct SpyWriter: TagWriter { let shouldThrow: Bool func write(_ fields: EditableTrackFields, to url: URL) throws { if shouldThrow { throw TagWriterError.exportFailed } // simulate a real write by appending a byte so size/mtime change. let h = try FileHandle(forWritingTo: url); try h.seekToEnd() try h.write(contentsOf: Data([0])); try h.close() } } private func tempTrack(ext: String) throws -> Track { let url = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent(UUID().uuidString + "." + ext) try Data(repeating: 1, count: 100).write(to: url) return .fixture(fileURL: url.absoluteString, fileFormat: ext) } @Test func supportedFormatSuccessUpdatesDBAndRefreshesStats() throws { // Step 1: DB + a real temp file + an edit changing the title. let db = try DatabaseService(inMemory: true) var t = try tempTrack(ext: "mp3"); try db.insert(&t) let original = EditableTrackFields(from: t) var edited = original; edited.title = "Edited" // Step 2: save via a succeeding writer (injected). let svc = TrackEditService(database: db, writerFactory: { _ in SpyWriter(shouldThrow: false) }) let warnings = svc.save(edited, editing: original.changedFields(to: edited), to: [t]) // Step 3: no warnings; DB has new title and refreshed hash (file changed). #expect(warnings.isEmpty) let f = try #require(db.fetchTracksByIds([t.id!]).first) #expect(f.title == "Edited") #expect(f.fileHash != t.fileHash) try? FileManager.default.removeItem(at: URL(string: t.fileURL)!) } @Test func unsupportedFormatSavesDBOnlyWithWarning() throws { let db = try DatabaseService(inMemory: true) var t = try tempTrack(ext: "flac"); try db.insert(&t) var edited = EditableTrackFields(from: t); edited.album = "DB Only" let svc = TrackEditService(database: db, writerFactory: TagWriterFactory.writer) // nil for flac let warnings = svc.save(edited, editing: [.album], to: [t]) #expect(warnings.count == 1) #expect(warnings.first?.kind == .dbOnlyUnsupported) #expect(try #require(db.fetchTracksByIds([t.id!]).first).album == "DB Only") try? FileManager.default.removeItem(at: URL(string: t.fileURL)!) } @Test func writerThrowsSavesDBOnlyWithFailureWarning() throws { let db = try DatabaseService(inMemory: true) var t = try tempTrack(ext: "mp3"); try db.insert(&t) var edited = EditableTrackFields(from: t); edited.genre = "Still Saved" let svc = TrackEditService(database: db, writerFactory: { _ in SpyWriter(shouldThrow: true) }) let warnings = svc.save(edited, editing: [.genre], to: [t]) #expect(warnings.first?.kind == .fileWriteFailed) #expect(try #require(db.fetchTracksByIds([t.id!]).first).genre == "Still Saved") try? FileManager.default.removeItem(at: URL(string: t.fileURL)!) } @Test func dateAddedOnlyEditIsDBOnlyNoFileWrite() throws { // Step 1: insert an mp3 track and remember its file hash. let db = try DatabaseService(inMemory: true) var t = try tempTrack(ext: "mp3"); try db.insert(&t) let originalHash = t.fileHash // Step 2: edit ONLY dateAdded, with a writer that THROWS if ever called — // proving dateAdded is DB-only and triggers no file write. var edited = EditableTrackFields(from: t) let newDate = Date(timeIntervalSince1970: 1_000_000) edited.dateAdded = newDate let svc = TrackEditService(database: db, writerFactory: { _ in SpyWriter(shouldThrow: true) }) let warnings = svc.save(edited, editing: [.dateAdded], to: [t]) // Step 3: no warnings (no write attempted); DB has the new date; file untouched. #expect(warnings.isEmpty) let f = try #require(db.fetchTracksByIds([t.id!]).first) #expect(f.dateAdded == newDate) #expect(f.fileHash == originalHash) try? FileManager.default.removeItem(at: URL(string: t.fileURL)!) } @Test func multiTrackAppliesOnlyEditedFields() throws { let db = try DatabaseService(inMemory: true) var a = try tempTrack(ext: "flac"); a.album = "OldA"; a.genre = "RockA"; try db.insert(&a) var b = try tempTrack(ext: "flac"); b.album = "OldB"; b.genre = "RockB"; try db.insert(&b) var edited = EditableTrackFields(from: a); edited.album = "Shared" let svc = TrackEditService(database: db, writerFactory: TagWriterFactory.writer) _ = svc.save(edited, editing: [.album], to: [a, b]) // album applied to both; each genre untouched. #expect(try #require(db.fetchTracksByIds([a.id!]).first).album == "Shared") #expect(try #require(db.fetchTracksByIds([b.id!]).first).album == "Shared") #expect(try #require(db.fetchTracksByIds([b.id!]).first).genre == "RockB") try? FileManager.default.removeItem(at: URL(string: a.fileURL)!) try? FileManager.default.removeItem(at: URL(string: b.fileURL)!) } }