ChadMusicTests.swift 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434
  1. import XCTest
  2. @testable import MixBoard
  3. // MARK: - Chad Music Model Tests
  4. final class ChadTrackModelTests: XCTestCase {
  5. func testChadTrackDecoding() throws {
  6. let json = """
  7. {
  8. "id": "a1b2c3d4e5f6",
  9. "title": "Echoes",
  10. "artist": "Pink Floyd",
  11. "album_artist": "Pink Floyd",
  12. "album": "Meddle",
  13. "duration": 1410.5,
  14. "no": 6,
  15. "url": "/music/Pink Floyd/Meddle/06 Echoes.flac",
  16. "bit_rate": 1024,
  17. "year": 1971,
  18. "cover": "/covers/42.jpg"
  19. }
  20. """.data(using: .utf8)!
  21. let track = try JSONDecoder().decode(ChadTrack.self, from: json)
  22. XCTAssertEqual(track.id, "a1b2c3d4e5f6")
  23. XCTAssertEqual(track.title, "Echoes")
  24. XCTAssertEqual(track.artist, "Pink Floyd")
  25. XCTAssertEqual(track.albumArtist, "Pink Floyd")
  26. XCTAssertEqual(track.album, "Meddle")
  27. XCTAssertEqual(track.duration, 1410.5)
  28. XCTAssertEqual(track.trackNumber, 6)
  29. XCTAssertEqual(track.no, 6)
  30. XCTAssertEqual(track.url, "/music/Pink Floyd/Meddle/06 Echoes.flac")
  31. XCTAssertEqual(track.bitRate, 1024)
  32. XCTAssertEqual(track.year, 1971)
  33. XCTAssertEqual(track.cover, "/covers/42.jpg")
  34. }
  35. func testChadTrackDecodingMinimalFields() throws {
  36. let json = """
  37. {
  38. "id": "abc123",
  39. "title": "Unknown",
  40. "url": "/music/unknown.mp3"
  41. }
  42. """.data(using: .utf8)!
  43. let track = try JSONDecoder().decode(ChadTrack.self, from: json)
  44. XCTAssertEqual(track.id, "abc123")
  45. XCTAssertEqual(track.title, "Unknown")
  46. XCTAssertEqual(track.url, "/music/unknown.mp3")
  47. XCTAssertNil(track.artist)
  48. XCTAssertNil(track.album)
  49. XCTAssertNil(track.duration)
  50. XCTAssertNil(track.trackNumber)
  51. XCTAssertNil(track.bitRate)
  52. XCTAssertNil(track.year)
  53. XCTAssertNil(track.cover)
  54. }
  55. func testFormattedDuration() {
  56. let json = """
  57. {"id": "x1", "title": "T", "url": "/t", "duration": 185.0}
  58. """.data(using: .utf8)!
  59. let track = try! JSONDecoder().decode(ChadTrack.self, from: json)
  60. XCTAssertEqual(track.formattedDuration, "3:05")
  61. }
  62. func testFormattedDurationNil() {
  63. let json = """
  64. {"id": "x1", "title": "T", "url": "/t"}
  65. """.data(using: .utf8)!
  66. let track = try! JSONDecoder().decode(ChadTrack.self, from: json)
  67. XCTAssertEqual(track.formattedDuration, "—")
  68. }
  69. func testFormattedDurationZero() {
  70. let json = """
  71. {"id": "x1", "title": "T", "url": "/t", "duration": 0}
  72. """.data(using: .utf8)!
  73. let track = try! JSONDecoder().decode(ChadTrack.self, from: json)
  74. XCTAssertEqual(track.formattedDuration, "0:00")
  75. }
  76. func testFormattedDurationLongTrack() {
  77. let json = """
  78. {"id": "x1", "title": "T", "url": "/t", "duration": 3661}
  79. """.data(using: .utf8)!
  80. let track = try! JSONDecoder().decode(ChadTrack.self, from: json)
  81. XCTAssertEqual(track.formattedDuration, "61:01")
  82. }
  83. func testChadTrackHashable() throws {
  84. let json1 = """
  85. {"id": "t1", "title": "A", "url": "/a"}
  86. """.data(using: .utf8)!
  87. let json2 = """
  88. {"id": "t2", "title": "B", "url": "/b"}
  89. """.data(using: .utf8)!
  90. let track1 = try JSONDecoder().decode(ChadTrack.self, from: json1)
  91. let track2 = try JSONDecoder().decode(ChadTrack.self, from: json2)
  92. let set: Set<ChadTrack> = [track1, track2, track1]
  93. XCTAssertEqual(set.count, 2)
  94. }
  95. }
  96. final class ChadAlbumModelTests: XCTestCase {
  97. func testChadAlbumDecoding() throws {
  98. let json = """
  99. {
  100. "id": "f8fbe6f9d852485b",
  101. "album": "Meddle",
  102. "artist": "Pink Floyd",
  103. "year": 1971,
  104. "genre": "Progressive Rock",
  105. "track_count": 6,
  106. "cover": "/covers/10.jpg",
  107. "publisher": "Harvest",
  108. "country": "UK",
  109. "total_duration": 2820.0
  110. }
  111. """.data(using: .utf8)!
  112. let album = try JSONDecoder().decode(ChadAlbum.self, from: json)
  113. XCTAssertEqual(album.id, "f8fbe6f9d852485b")
  114. XCTAssertEqual(album.title, "Meddle")
  115. XCTAssertEqual(album.album, "Meddle")
  116. XCTAssertEqual(album.artist, "Pink Floyd")
  117. XCTAssertEqual(album.year, 1971)
  118. XCTAssertEqual(album.genre, "Progressive Rock")
  119. XCTAssertEqual(album.trackCount, 6)
  120. XCTAssertEqual(album.cover, "/covers/10.jpg")
  121. XCTAssertEqual(album.publisher, "Harvest")
  122. XCTAssertEqual(album.country, "UK")
  123. XCTAssertEqual(album.totalDuration, 2820.0)
  124. }
  125. func testChadAlbumMinimalDecoding() throws {
  126. let json = """
  127. {"id": "abc", "album": "Untitled"}
  128. """.data(using: .utf8)!
  129. let album = try JSONDecoder().decode(ChadAlbum.self, from: json)
  130. XCTAssertEqual(album.id, "abc")
  131. XCTAssertEqual(album.title, "Untitled")
  132. XCTAssertNil(album.artist)
  133. XCTAssertNil(album.year)
  134. XCTAssertNil(album.trackCount)
  135. XCTAssertNil(album.cover)
  136. }
  137. }
  138. final class ChadCategoryModelTests: XCTestCase {
  139. func testChadCategoryDecoding() throws {
  140. let json = """
  141. {"item": "Rock", "count": 42}
  142. """.data(using: .utf8)!
  143. let cat = try JSONDecoder().decode(ChadCategory.self, from: json)
  144. XCTAssertEqual(cat.id, "Rock")
  145. XCTAssertEqual(cat.name, "Rock")
  146. XCTAssertEqual(cat.item, "Rock")
  147. XCTAssertEqual(cat.count, 42)
  148. }
  149. func testChadCategoryDecodingNoCount() throws {
  150. let json = """
  151. {"item": "Jazz"}
  152. """.data(using: .utf8)!
  153. let cat = try JSONDecoder().decode(ChadCategory.self, from: json)
  154. XCTAssertEqual(cat.name, "Jazz")
  155. XCTAssertNil(cat.count)
  156. }
  157. func testChadCategoryArrayDecoding() throws {
  158. let json = """
  159. [
  160. {"item": "Rock", "count": 100},
  161. {"item": "Jazz", "count": 50},
  162. {"item": "Electronic", "count": 75}
  163. ]
  164. """.data(using: .utf8)!
  165. let categories = try JSONDecoder().decode([ChadCategory].self, from: json)
  166. XCTAssertEqual(categories.count, 3)
  167. XCTAssertEqual(categories[0].name, "Rock")
  168. XCTAssertEqual(categories[2].count, 75)
  169. }
  170. }
  171. final class ChadStatsModelTests: XCTestCase {
  172. func testChadStatsDecoding() throws {
  173. let json = """
  174. {
  175. "tracks": 1500,
  176. "albums": 120,
  177. "artists": 85,
  178. "duration": "3d 14h 22m"
  179. }
  180. """.data(using: .utf8)!
  181. let stats = try JSONDecoder().decode(ChadStats.self, from: json)
  182. XCTAssertEqual(stats.tracks, 1500)
  183. XCTAssertEqual(stats.albums, 120)
  184. XCTAssertEqual(stats.artists, 85)
  185. XCTAssertEqual(stats.duration, "3d 14h 22m")
  186. }
  187. func testChadStatsPartialDecoding() throws {
  188. let json = """
  189. {"tracks": 500}
  190. """.data(using: .utf8)!
  191. let stats = try JSONDecoder().decode(ChadStats.self, from: json)
  192. XCTAssertEqual(stats.tracks, 500)
  193. XCTAssertNil(stats.albums)
  194. XCTAssertNil(stats.artists)
  195. XCTAssertNil(stats.duration)
  196. }
  197. }
  198. /// Album tracks endpoint returns a plain [ChadTrack] array — tested in ChadTrackModelTests.
  199. // MARK: - ChadCategoryType Tests
  200. final class ChadCategoryTypeTests: XCTestCase {
  201. func testAllCasesExist() {
  202. XCTAssertEqual(ChadCategoryType.allCases.count, 8)
  203. }
  204. func testRawValues() {
  205. XCTAssertEqual(ChadCategoryType.album.rawValue, "album")
  206. XCTAssertEqual(ChadCategoryType.artist.rawValue, "artist")
  207. XCTAssertEqual(ChadCategoryType.genre.rawValue, "genre")
  208. XCTAssertEqual(ChadCategoryType.year.rawValue, "year")
  209. XCTAssertEqual(ChadCategoryType.publisher.rawValue, "publisher")
  210. }
  211. func testDisplayNames() {
  212. XCTAssertEqual(ChadCategoryType.album.displayName, "Albums")
  213. XCTAssertEqual(ChadCategoryType.artist.displayName, "Artists")
  214. XCTAssertEqual(ChadCategoryType.genre.displayName, "Genres")
  215. }
  216. func testIcons() {
  217. XCTAssertFalse(ChadCategoryType.album.icon.isEmpty)
  218. XCTAssertFalse(ChadCategoryType.artist.icon.isEmpty)
  219. // Every category should have an icon
  220. for category in ChadCategoryType.allCases {
  221. XCTAssertFalse(category.icon.isEmpty, "\(category) should have an icon")
  222. }
  223. }
  224. func testIdentifiable() {
  225. let category = ChadCategoryType.album
  226. XCTAssertEqual(category.id, "album")
  227. }
  228. }
  229. // MARK: - Keychain Service Tests
  230. final class KeychainServiceTests: XCTestCase {
  231. override func tearDown() {
  232. super.tearDown()
  233. KeychainService.deleteAPIKey()
  234. }
  235. func testSaveAndLoadAPIKey() throws {
  236. try KeychainService.saveAPIKey("test-api-key-12345")
  237. let loaded = KeychainService.loadAPIKey()
  238. XCTAssertEqual(loaded, "test-api-key-12345")
  239. }
  240. func testLoadMissingAPIKey() {
  241. KeychainService.deleteAPIKey()
  242. let loaded = KeychainService.loadAPIKey()
  243. XCTAssertNil(loaded)
  244. }
  245. func testOverwriteAPIKey() throws {
  246. try KeychainService.saveAPIKey("old-key")
  247. XCTAssertEqual(KeychainService.loadAPIKey(), "old-key")
  248. try KeychainService.saveAPIKey("new-key")
  249. XCTAssertEqual(KeychainService.loadAPIKey(), "new-key")
  250. }
  251. func testDeleteAPIKey() throws {
  252. try KeychainService.saveAPIKey("to-delete")
  253. XCTAssertNotNil(KeychainService.loadAPIKey())
  254. KeychainService.deleteAPIKey()
  255. XCTAssertNil(KeychainService.loadAPIKey())
  256. }
  257. func testSaveEmptyKey() throws {
  258. try KeychainService.saveAPIKey("")
  259. // Empty string is still valid UTF-8 data
  260. let loaded = KeychainService.loadAPIKey()
  261. XCTAssertEqual(loaded, "")
  262. }
  263. func testSaveUnicodeKey() throws {
  264. let key = "api-key-with-ünîcödé-чëрт"
  265. try KeychainService.saveAPIKey(key)
  266. XCTAssertEqual(KeychainService.loadAPIKey(), key)
  267. }
  268. }
  269. // MARK: - ChadMusicAPIClient Tests (URL composition, no network)
  270. final class ChadMusicAPIClientTests: XCTestCase {
  271. override func tearDown() {
  272. super.tearDown()
  273. UserDefaults.standard.removeObject(forKey: "chadMusic.serverURL")
  274. KeychainService.deleteAPIKey()
  275. }
  276. @MainActor
  277. func testStreamURLAbsolutePath() {
  278. let client = ChadMusicAPIClient()
  279. client.serverURL = "https://music.example.com"
  280. let url = client.streamURL(for: "/music/Artist/Album/track.flac")
  281. XCTAssertNotNil(url)
  282. XCTAssertEqual(url?.scheme, "https")
  283. XCTAssertEqual(url?.host, "music.example.com")
  284. XCTAssertTrue(url?.path.contains("music/Artist/Album/track.flac") ?? false)
  285. }
  286. @MainActor
  287. func testStreamURLRelativePath() {
  288. let client = ChadMusicAPIClient()
  289. client.serverURL = "https://music.example.com"
  290. let url = client.streamURL(for: "music/Artist/Album/track.flac")
  291. XCTAssertNotNil(url)
  292. XCTAssertTrue(url?.path.contains("music/Artist/Album/track.flac") ?? false)
  293. }
  294. @MainActor
  295. func testStreamURLWithTrailingSlash() {
  296. let client = ChadMusicAPIClient()
  297. client.serverURL = "https://music.example.com/"
  298. let url = client.streamURL(for: "/music/track.flac")
  299. XCTAssertNotNil(url)
  300. XCTAssertTrue(url?.path.contains("music/track.flac") ?? false)
  301. }
  302. @MainActor
  303. func testStreamURLNotConfigured() {
  304. let client = ChadMusicAPIClient()
  305. client.serverURL = ""
  306. let url = client.streamURL(for: "/music/track.flac")
  307. XCTAssertNil(url)
  308. }
  309. @MainActor
  310. func testIsConfiguredFalseWhenEmpty() {
  311. let client = ChadMusicAPIClient()
  312. client.serverURL = ""
  313. UserDefaults.standard.removeObject(forKey: "chadMusic.apiKey")
  314. XCTAssertFalse(client.isConfigured)
  315. }
  316. @MainActor
  317. func testIsConfiguredFalseWithoutKey() {
  318. let client = ChadMusicAPIClient()
  319. client.serverURL = "https://music.example.com"
  320. UserDefaults.standard.removeObject(forKey: "chadMusic.apiKey")
  321. XCTAssertFalse(client.isConfigured)
  322. }
  323. @MainActor
  324. func testIsConfiguredTrue() {
  325. let client = ChadMusicAPIClient()
  326. client.serverURL = "https://music.example.com"
  327. UserDefaults.standard.set("test-key", forKey: "chadMusic.apiKey")
  328. XCTAssertTrue(client.isConfigured)
  329. }
  330. @MainActor
  331. func testAuthHeaders() {
  332. let client = ChadMusicAPIClient()
  333. UserDefaults.standard.set("my-secret-key", forKey: "chadMusic.apiKey")
  334. let headers = client.authHeaders
  335. XCTAssertEqual(headers["Authorization"], "Bearer my-secret-key")
  336. }
  337. @MainActor
  338. func testAuthHeadersEmpty() {
  339. let client = ChadMusicAPIClient()
  340. UserDefaults.standard.removeObject(forKey: "chadMusic.apiKey")
  341. let headers = client.authHeaders
  342. XCTAssertTrue(headers.isEmpty)
  343. }
  344. }
  345. // MARK: - ChadMusicError Tests
  346. final class ChadMusicErrorTests: XCTestCase {
  347. func testErrorDescriptions() {
  348. XCTAssertNotNil(ChadMusicError.notConfigured.errorDescription)
  349. XCTAssertNotNil(ChadMusicError.unauthorized.errorDescription)
  350. XCTAssertNotNil(ChadMusicError.forbidden.errorDescription)
  351. XCTAssertNotNil(ChadMusicError.notFound("test").errorDescription)
  352. XCTAssertNotNil(ChadMusicError.httpError(500).errorDescription)
  353. XCTAssertNotNil(ChadMusicError.invalidResponse.errorDescription)
  354. // All errors should have non-empty descriptions
  355. XCTAssertFalse(ChadMusicError.notConfigured.errorDescription!.isEmpty)
  356. XCTAssertTrue(ChadMusicError.notFound("/api/test").errorDescription!.contains("/api/test"))
  357. XCTAssertTrue(ChadMusicError.httpError(503).errorDescription!.contains("503"))
  358. }
  359. func testUnauthorizedDescription() {
  360. let error = ChadMusicError.unauthorized
  361. XCTAssertTrue(error.errorDescription!.contains("401"))
  362. }
  363. }