mirror of
https://github.com/yattee/yattee.git
synced 2026-02-19 17:29:45 +00:00
1220 lines
40 KiB
Swift
1220 lines
40 KiB
Swift
//
|
|
// APIParsingTests.swift
|
|
// YatteeTests
|
|
//
|
|
// Tests for API response parsing including Piped and PeerTube responses.
|
|
//
|
|
|
|
import Testing
|
|
import Foundation
|
|
@testable import Yattee
|
|
|
|
// MARK: - Piped Response Parsing Tests
|
|
|
|
@Suite("Piped API Parsing Tests")
|
|
@MainActor
|
|
struct PipedAPIParsingTests {
|
|
|
|
@Test("Parse trending video response")
|
|
func parseTrendingVideo() throws {
|
|
let json = """
|
|
{
|
|
"url": "/watch?v=dQw4w9WgXcQ",
|
|
"title": "Rick Astley - Never Gonna Give You Up",
|
|
"uploaderName": "Rick Astley",
|
|
"uploaderUrl": "/channel/UCuAXFkgsw1L7xaCfnd5JJOw",
|
|
"uploaderAvatar": "https://example.com/avatar.jpg",
|
|
"duration": 213,
|
|
"uploaded": 562732800000,
|
|
"views": 1400000000,
|
|
"thumbnail": "https://example.com/thumb.jpg",
|
|
"uploaderVerified": true,
|
|
"isShort": false
|
|
}
|
|
""".data(using: .utf8)!
|
|
|
|
// Verify JSON is valid and can be decoded
|
|
let decoded = try JSONSerialization.jsonObject(with: json)
|
|
#expect(decoded is [String: Any])
|
|
|
|
let dict = decoded as! [String: Any]
|
|
#expect(dict["url"] as? String == "/watch?v=dQw4w9WgXcQ")
|
|
#expect(dict["title"] as? String == "Rick Astley - Never Gonna Give You Up")
|
|
#expect(dict["duration"] as? Int == 213)
|
|
#expect(dict["views"] as? Int64 == 1400000000)
|
|
}
|
|
|
|
@Test("Parse search response structure")
|
|
func parseSearchResponse() throws {
|
|
let json = """
|
|
{
|
|
"items": [
|
|
{
|
|
"type": "stream",
|
|
"url": "/watch?v=test123",
|
|
"title": "Test Video",
|
|
"uploaderName": "Test Channel",
|
|
"duration": 120
|
|
},
|
|
{
|
|
"type": "channel",
|
|
"url": "/channel/UC123",
|
|
"name": "Test Channel",
|
|
"subscribers": 1000
|
|
}
|
|
],
|
|
"nextpage": "token123"
|
|
}
|
|
""".data(using: .utf8)!
|
|
|
|
let decoded = try JSONSerialization.jsonObject(with: json) as! [String: Any]
|
|
let items = decoded["items"] as! [[String: Any]]
|
|
|
|
#expect(items.count == 2)
|
|
#expect(items[0]["type"] as? String == "stream")
|
|
#expect(items[1]["type"] as? String == "channel")
|
|
#expect(decoded["nextpage"] as? String == "token123")
|
|
}
|
|
|
|
@Test("Parse stream response with HLS")
|
|
func parseStreamResponseWithHLS() throws {
|
|
let json = """
|
|
{
|
|
"title": "Test Video",
|
|
"description": "A test video",
|
|
"uploader": "Test Channel",
|
|
"duration": 300,
|
|
"views": 5000,
|
|
"likes": 100,
|
|
"hls": "https://example.com/stream.m3u8",
|
|
"livestream": false,
|
|
"videoStreams": [
|
|
{
|
|
"url": "https://example.com/video.mp4",
|
|
"format": "MPEG_4",
|
|
"quality": "1080p",
|
|
"mimeType": "video/mp4",
|
|
"width": 1920,
|
|
"height": 1080
|
|
}
|
|
],
|
|
"audioStreams": [
|
|
{
|
|
"url": "https://example.com/audio.m4a",
|
|
"format": "M4A",
|
|
"quality": "128kbps",
|
|
"mimeType": "audio/mp4",
|
|
"bitrate": 128000
|
|
}
|
|
]
|
|
}
|
|
""".data(using: .utf8)!
|
|
|
|
let decoded = try JSONSerialization.jsonObject(with: json) as! [String: Any]
|
|
|
|
#expect(decoded["hls"] as? String == "https://example.com/stream.m3u8")
|
|
#expect(decoded["livestream"] as? Bool == false)
|
|
|
|
let videoStreams = decoded["videoStreams"] as! [[String: Any]]
|
|
#expect(videoStreams.count == 1)
|
|
#expect(videoStreams[0]["width"] as? Int == 1920)
|
|
#expect(videoStreams[0]["height"] as? Int == 1080)
|
|
|
|
let audioStreams = decoded["audioStreams"] as! [[String: Any]]
|
|
#expect(audioStreams.count == 1)
|
|
#expect(audioStreams[0]["bitrate"] as? Int == 128000)
|
|
}
|
|
|
|
@Test("Parse comments response")
|
|
func parseCommentsResponse() throws {
|
|
let json = """
|
|
{
|
|
"comments": [
|
|
{
|
|
"commentId": "comment123",
|
|
"author": "Test User",
|
|
"commentorUrl": "/channel/UC123",
|
|
"commentText": "Great video!",
|
|
"commentedTime": "2 days ago",
|
|
"likeCount": 50,
|
|
"pinned": false,
|
|
"hearted": true,
|
|
"replyCount": 5
|
|
}
|
|
],
|
|
"nextpage": "nextToken"
|
|
}
|
|
""".data(using: .utf8)!
|
|
|
|
let decoded = try JSONSerialization.jsonObject(with: json) as! [String: Any]
|
|
let comments = decoded["comments"] as! [[String: Any]]
|
|
|
|
#expect(comments.count == 1)
|
|
#expect(comments[0]["commentId"] as? String == "comment123")
|
|
#expect(comments[0]["likeCount"] as? Int == 50)
|
|
#expect(comments[0]["pinned"] as? Bool == false)
|
|
#expect(comments[0]["hearted"] as? Bool == true)
|
|
}
|
|
|
|
@Test("Video ID extraction from URL")
|
|
func videoIdExtraction() {
|
|
// Test the URL pattern used by Piped
|
|
let watchURL = "/watch?v=dQw4w9WgXcQ"
|
|
let videoId = watchURL.replacingOccurrences(of: "/watch?v=", with: "")
|
|
#expect(videoId == "dQw4w9WgXcQ")
|
|
|
|
let channelURL = "/channel/UCuAXFkgsw1L7xaCfnd5JJOw"
|
|
let channelId = channelURL.replacingOccurrences(of: "/channel/", with: "")
|
|
#expect(channelId == "UCuAXFkgsw1L7xaCfnd5JJOw")
|
|
}
|
|
}
|
|
|
|
// MARK: - PeerTube Response Parsing Tests
|
|
|
|
@Suite("PeerTube API Parsing Tests")
|
|
@MainActor
|
|
struct PeerTubeAPIParsingTests {
|
|
|
|
@Test("Parse video list response")
|
|
func parseVideoListResponse() throws {
|
|
let json = """
|
|
{
|
|
"total": 100,
|
|
"data": [
|
|
{
|
|
"id": 123,
|
|
"uuid": "abc-123-def",
|
|
"name": "Test Video",
|
|
"duration": 300,
|
|
"views": 1000,
|
|
"likes": 50,
|
|
"thumbnailPath": "/static/thumbnails/thumb.jpg",
|
|
"publishedAt": "2024-01-15T10:30:00.000Z",
|
|
"channel": {
|
|
"id": 1,
|
|
"name": "testchannel",
|
|
"displayName": "Test Channel"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
""".data(using: .utf8)!
|
|
|
|
let decoded = try JSONSerialization.jsonObject(with: json) as! [String: Any]
|
|
|
|
#expect(decoded["total"] as? Int == 100)
|
|
|
|
let data = decoded["data"] as! [[String: Any]]
|
|
#expect(data.count == 1)
|
|
#expect(data[0]["id"] as? Int == 123)
|
|
#expect(data[0]["uuid"] as? String == "abc-123-def")
|
|
#expect(data[0]["name"] as? String == "Test Video")
|
|
|
|
let channel = data[0]["channel"] as! [String: Any]
|
|
#expect(channel["displayName"] as? String == "Test Channel")
|
|
}
|
|
|
|
@Test("Parse video details with streams")
|
|
func parseVideoDetailsWithStreams() throws {
|
|
let json = """
|
|
{
|
|
"id": 456,
|
|
"uuid": "video-uuid-456",
|
|
"name": "Video with Streams",
|
|
"description": "A video with multiple stream formats",
|
|
"duration": 600,
|
|
"views": 5000,
|
|
"files": [
|
|
{
|
|
"fileUrl": "https://example.com/video-720p.mp4",
|
|
"resolution": {"id": 720, "label": "720p"},
|
|
"size": 104857600
|
|
}
|
|
],
|
|
"streamingPlaylists": [
|
|
{
|
|
"id": 1,
|
|
"type": 1,
|
|
"playlistUrl": "https://example.com/master.m3u8",
|
|
"files": [
|
|
{
|
|
"fileUrl": "https://example.com/1080p.mp4",
|
|
"resolution": {"id": 1080, "label": "1080p"}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
""".data(using: .utf8)!
|
|
|
|
let decoded = try JSONSerialization.jsonObject(with: json) as! [String: Any]
|
|
|
|
#expect(decoded["id"] as? Int == 456)
|
|
|
|
let files = decoded["files"] as! [[String: Any]]
|
|
#expect(files.count == 1)
|
|
let resolution = files[0]["resolution"] as! [String: Any]
|
|
#expect(resolution["id"] as? Int == 720)
|
|
|
|
let streamingPlaylists = decoded["streamingPlaylists"] as! [[String: Any]]
|
|
#expect(streamingPlaylists.count == 1)
|
|
#expect(streamingPlaylists[0]["playlistUrl"] as? String == "https://example.com/master.m3u8")
|
|
}
|
|
|
|
@Test("Parse channel response")
|
|
func parseChannelResponse() throws {
|
|
let json = """
|
|
{
|
|
"id": 10,
|
|
"name": "mychannel",
|
|
"displayName": "My Channel",
|
|
"description": "Channel description",
|
|
"followersCount": 1500,
|
|
"videosCount": 42,
|
|
"avatar": {
|
|
"path": "/lazy-static/avatars/avatar.png"
|
|
},
|
|
"banner": {
|
|
"path": "/lazy-static/banners/banner.png"
|
|
}
|
|
}
|
|
""".data(using: .utf8)!
|
|
|
|
let decoded = try JSONSerialization.jsonObject(with: json) as! [String: Any]
|
|
|
|
#expect(decoded["name"] as? String == "mychannel")
|
|
#expect(decoded["displayName"] as? String == "My Channel")
|
|
#expect(decoded["followersCount"] as? Int == 1500)
|
|
#expect(decoded["videosCount"] as? Int == 42)
|
|
|
|
let avatar = decoded["avatar"] as! [String: Any]
|
|
#expect(avatar["path"] as? String == "/lazy-static/avatars/avatar.png")
|
|
}
|
|
|
|
@Test("Parse comment list response")
|
|
func parseCommentListResponse() throws {
|
|
let json = """
|
|
{
|
|
"total": 25,
|
|
"data": [
|
|
{
|
|
"id": 100,
|
|
"threadId": 100,
|
|
"text": "This is a comment",
|
|
"createdAt": "2024-01-20T15:00:00.000Z",
|
|
"account": {
|
|
"id": 5,
|
|
"name": "commenter",
|
|
"displayName": "Comment User"
|
|
},
|
|
"totalReplies": 3
|
|
}
|
|
]
|
|
}
|
|
""".data(using: .utf8)!
|
|
|
|
let decoded = try JSONSerialization.jsonObject(with: json) as! [String: Any]
|
|
|
|
#expect(decoded["total"] as? Int == 25)
|
|
|
|
let data = decoded["data"] as! [[String: Any]]
|
|
#expect(data.count == 1)
|
|
#expect(data[0]["text"] as? String == "This is a comment")
|
|
#expect(data[0]["totalReplies"] as? Int == 3)
|
|
|
|
let account = data[0]["account"] as! [String: Any]
|
|
#expect(account["displayName"] as? String == "Comment User")
|
|
}
|
|
|
|
@Test("Parse playlist with videos")
|
|
func parsePlaylistWithVideos() throws {
|
|
let json = """
|
|
{
|
|
"id": 50,
|
|
"uuid": "playlist-uuid-50",
|
|
"displayName": "My Playlist",
|
|
"description": "A test playlist",
|
|
"videosLength": 10,
|
|
"thumbnailPath": "/static/thumbnails/playlist.jpg",
|
|
"ownerAccount": {
|
|
"id": 1,
|
|
"name": "owner",
|
|
"displayName": "Playlist Owner"
|
|
}
|
|
}
|
|
""".data(using: .utf8)!
|
|
|
|
let decoded = try JSONSerialization.jsonObject(with: json) as! [String: Any]
|
|
|
|
#expect(decoded["displayName"] as? String == "My Playlist")
|
|
#expect(decoded["videosLength"] as? Int == 10)
|
|
|
|
let owner = decoded["ownerAccount"] as! [String: Any]
|
|
#expect(owner["displayName"] as? String == "Playlist Owner")
|
|
}
|
|
|
|
@Test("Pagination calculation")
|
|
func paginationCalculation() {
|
|
// PeerTube uses offset-based pagination (start parameter)
|
|
let page = 3
|
|
let count = 20
|
|
let start = (page - 1) * count
|
|
|
|
#expect(start == 40)
|
|
|
|
// Test hasMore logic
|
|
let total = 100
|
|
let currentOffset = 40
|
|
let itemsReturned = 20
|
|
let nextOffset = currentOffset + itemsReturned
|
|
let hasMore = nextOffset < total
|
|
|
|
#expect(hasMore == true)
|
|
|
|
// Edge case: last page
|
|
let lastPageOffset = 80
|
|
let lastPageNextOffset = lastPageOffset + 20
|
|
let noMore = lastPageNextOffset < total
|
|
|
|
#expect(noMore == false)
|
|
}
|
|
}
|
|
|
|
// MARK: - Instance Detector Response Parsing Tests
|
|
|
|
@Suite("Instance Detector Parsing Tests")
|
|
@MainActor
|
|
struct InstanceDetectorParsingTests {
|
|
|
|
@Test("Parse Yattee Server info response")
|
|
func parseYatteeServerInfo() throws {
|
|
let json = """
|
|
{
|
|
"name": "Yattee Server",
|
|
"version": "1.0.0",
|
|
"description": "A self-hosted video API server"
|
|
}
|
|
""".data(using: .utf8)!
|
|
|
|
let decoded = try JSONDecoder().decode(InstanceDetectorModels.YatteeServerInfo.self, from: json)
|
|
|
|
#expect(decoded.name == "Yattee Server")
|
|
#expect(decoded.version == "1.0.0")
|
|
#expect(decoded.description == "A self-hosted video API server")
|
|
}
|
|
|
|
@Test("Parse PeerTube config response")
|
|
func parsePeerTubeConfig() throws {
|
|
let json = """
|
|
{
|
|
"instance": {
|
|
"name": "PeerTube Instance",
|
|
"shortDescription": "A federated video platform"
|
|
},
|
|
"serverVersion": "6.0.0"
|
|
}
|
|
""".data(using: .utf8)!
|
|
|
|
let decoded = try JSONDecoder().decode(InstanceDetectorModels.PeerTubeConfig.self, from: json)
|
|
|
|
#expect(decoded.instance?.name == "PeerTube Instance")
|
|
#expect(decoded.serverVersion == "6.0.0")
|
|
}
|
|
|
|
@Test("Parse Invidious stats response")
|
|
func parseInvidiousStats() throws {
|
|
let json = """
|
|
{
|
|
"software": {
|
|
"name": "invidious",
|
|
"version": "2.20240101.0"
|
|
}
|
|
}
|
|
""".data(using: .utf8)!
|
|
|
|
let decoded = try JSONDecoder().decode(InstanceDetectorModels.InvidiousStats.self, from: json)
|
|
|
|
#expect(decoded.software?.name == "invidious")
|
|
#expect(decoded.software?.version == "2.20240101.0")
|
|
}
|
|
|
|
@Test("Parse Piped config response")
|
|
func parsePipedConfig() throws {
|
|
let json = """
|
|
{
|
|
"donationUrl": "https://donate.example.com",
|
|
"statusPageUrl": "https://status.example.com",
|
|
"s3Enabled": true
|
|
}
|
|
""".data(using: .utf8)!
|
|
|
|
let decoded = try JSONDecoder().decode(InstanceDetectorModels.PipedConfig.self, from: json)
|
|
|
|
#expect(decoded.donationUrl == "https://donate.example.com")
|
|
#expect(decoded.statusPageUrl == "https://status.example.com")
|
|
#expect(decoded.s3Enabled == true)
|
|
}
|
|
|
|
@Test("Detection logic - Yattee Server identification")
|
|
func yatteeServerIdentification() {
|
|
// Name contains "yattee" (case insensitive)
|
|
let yatteeServer = "Yattee Server"
|
|
#expect(yatteeServer.lowercased().contains("yattee") == true)
|
|
|
|
let otherServer = "Some Other Server"
|
|
#expect(otherServer.lowercased().contains("yattee") == false)
|
|
}
|
|
|
|
@Test("Detection logic - Invidious identification")
|
|
func invidiousIdentification() {
|
|
// Software name must be exactly "invidious"
|
|
let invidiousSoftware = "invidious"
|
|
#expect(invidiousSoftware.lowercased() == "invidious")
|
|
|
|
let otherSoftware = "peertube"
|
|
#expect(otherSoftware.lowercased() != "invidious") // This should be true (not equal)
|
|
}
|
|
}
|
|
|
|
// MARK: - Search Filters Tests
|
|
|
|
@Suite("SearchFilters Tests")
|
|
@MainActor
|
|
struct SearchFiltersTests {
|
|
|
|
@Test("Default search filters")
|
|
func defaultFilters() {
|
|
let filters = SearchFilters.defaults
|
|
|
|
#expect(filters.sort == .relevance)
|
|
#expect(filters.date == .any)
|
|
#expect(filters.duration == .any)
|
|
#expect(filters.type == .video)
|
|
#expect(filters.isDefault == true)
|
|
}
|
|
|
|
@Test("SearchFilters with custom values")
|
|
func customFilters() {
|
|
var filters = SearchFilters()
|
|
filters.sort = .date
|
|
filters.date = .week
|
|
filters.duration = .long
|
|
|
|
#expect(filters.isDefault == false)
|
|
}
|
|
|
|
@Test("SearchFilters Codable")
|
|
func filtersCodable() throws {
|
|
var filters = SearchFilters()
|
|
filters.sort = .views
|
|
filters.date = .month
|
|
filters.duration = .short
|
|
|
|
let encoded = try JSONEncoder().encode(filters)
|
|
let decoded = try JSONDecoder().decode(SearchFilters.self, from: encoded)
|
|
|
|
#expect(decoded == filters)
|
|
#expect(decoded.sort == .views)
|
|
#expect(decoded.date == .month)
|
|
#expect(decoded.duration == .short)
|
|
}
|
|
|
|
@Test("SearchFilters Equatable")
|
|
func filtersEquatable() {
|
|
let filters1 = SearchFilters.defaults
|
|
let filters2 = SearchFilters.defaults
|
|
|
|
#expect(filters1 == filters2)
|
|
|
|
var filters3 = SearchFilters.defaults
|
|
filters3.sort = .rating
|
|
|
|
#expect(filters1 != filters3)
|
|
}
|
|
|
|
@Test("SearchSortOption all cases")
|
|
func sortOptionAllCases() {
|
|
let allCases = SearchSortOption.allCases
|
|
|
|
#expect(allCases.contains(.relevance))
|
|
#expect(allCases.contains(.rating))
|
|
#expect(allCases.contains(.date))
|
|
#expect(allCases.contains(.views))
|
|
#expect(allCases.count == 4)
|
|
}
|
|
|
|
@Test("SearchSortOption raw values")
|
|
func sortOptionRawValues() {
|
|
#expect(SearchSortOption.relevance.rawValue == "relevance")
|
|
#expect(SearchSortOption.rating.rawValue == "rating")
|
|
#expect(SearchSortOption.date.rawValue == "date")
|
|
#expect(SearchSortOption.views.rawValue == "views")
|
|
}
|
|
|
|
@Test("SearchDateFilter all cases")
|
|
func dateFilterAllCases() {
|
|
let allCases = SearchDateFilter.allCases
|
|
|
|
#expect(allCases.count == 6)
|
|
#expect(SearchDateFilter.any.rawValue == "")
|
|
#expect(SearchDateFilter.hour.rawValue == "hour")
|
|
#expect(SearchDateFilter.today.rawValue == "today")
|
|
}
|
|
|
|
@Test("SearchDurationFilter all cases")
|
|
func durationFilterAllCases() {
|
|
let allCases = SearchDurationFilter.allCases
|
|
|
|
#expect(allCases.count == 4)
|
|
#expect(SearchDurationFilter.any.rawValue == "")
|
|
#expect(SearchDurationFilter.short.rawValue == "short")
|
|
#expect(SearchDurationFilter.medium.rawValue == "medium")
|
|
#expect(SearchDurationFilter.long.rawValue == "long")
|
|
}
|
|
|
|
@Test("SearchContentType all cases")
|
|
func contentTypeAllCases() {
|
|
let allCases = SearchContentType.allCases
|
|
|
|
#expect(allCases.count == 4)
|
|
#expect(allCases.contains(.all))
|
|
#expect(allCases.contains(.video))
|
|
#expect(allCases.contains(.playlist))
|
|
#expect(allCases.contains(.channel))
|
|
}
|
|
}
|
|
|
|
// MARK: - MediaSource Tests
|
|
|
|
@Suite("MediaSource Tests")
|
|
@MainActor
|
|
struct MediaSourceTests {
|
|
|
|
@Test("Create WebDAV media source")
|
|
func createWebDAVSource() {
|
|
let source = MediaSource.webdav(
|
|
name: "My NAS",
|
|
url: URL(string: "https://nas.local/webdav")!,
|
|
username: "user"
|
|
)
|
|
|
|
#expect(source.name == "My NAS")
|
|
#expect(source.type == .webdav)
|
|
#expect(source.username == "user")
|
|
#expect(source.requiresAuthentication == true)
|
|
#expect(source.isEnabled == true)
|
|
}
|
|
|
|
@Test("Create local folder media source")
|
|
func createLocalFolderSource() {
|
|
let source = MediaSource.localFolder(
|
|
name: "Downloads",
|
|
url: URL(fileURLWithPath: "/Users/test/Downloads")
|
|
)
|
|
|
|
#expect(source.name == "Downloads")
|
|
#expect(source.type == .localFolder)
|
|
#expect(source.requiresAuthentication == false)
|
|
}
|
|
|
|
@Test("MediaSourceType display names")
|
|
func mediaSourceTypeDisplayNames() {
|
|
#expect(!MediaSourceType.webdav.displayName.isEmpty)
|
|
#expect(!MediaSourceType.localFolder.displayName.isEmpty)
|
|
}
|
|
|
|
@Test("MediaSourceType system images")
|
|
func mediaSourceTypeSystemImages() {
|
|
#expect(MediaSourceType.webdav.systemImage == "externaldrive.connected.to.line.below")
|
|
#expect(MediaSourceType.localFolder.systemImage == "folder")
|
|
#expect(MediaSourceType.smb.systemImage == "network")
|
|
}
|
|
|
|
@Test("MediaSource URL display string")
|
|
func urlDisplayString() {
|
|
let webdavSource = MediaSource.webdav(
|
|
name: "Test",
|
|
url: URL(string: "https://example.com/webdav")!
|
|
)
|
|
#expect(webdavSource.urlDisplayString == "example.com")
|
|
|
|
let localSource = MediaSource.localFolder(
|
|
name: "Test",
|
|
url: URL(fileURLWithPath: "/Users/test/Videos")
|
|
)
|
|
#expect(localSource.urlDisplayString == "Videos")
|
|
}
|
|
|
|
@Test("MediaSource Codable")
|
|
func mediaSourceCodable() throws {
|
|
let source = MediaSource.webdav(
|
|
name: "Test NAS",
|
|
url: URL(string: "https://nas.example.com")!,
|
|
username: "admin"
|
|
)
|
|
|
|
let encoded = try JSONEncoder().encode(source)
|
|
let decoded = try JSONDecoder().decode(MediaSource.self, from: encoded)
|
|
|
|
#expect(decoded.name == source.name)
|
|
#expect(decoded.type == source.type)
|
|
#expect(decoded.url == source.url)
|
|
#expect(decoded.username == source.username)
|
|
}
|
|
|
|
@Test("MediaSource Identifiable")
|
|
func mediaSourceIdentifiable() {
|
|
let source1 = MediaSource.webdav(name: "Test1", url: URL(string: "https://a.com")!)
|
|
let source2 = MediaSource.webdav(name: "Test2", url: URL(string: "https://b.com")!)
|
|
|
|
#expect(source1.id != source2.id)
|
|
}
|
|
|
|
@Test("MediaSource Hashable")
|
|
func mediaSourceHashable() {
|
|
let source = MediaSource.webdav(name: "Test", url: URL(string: "https://a.com")!)
|
|
|
|
var set = Set<MediaSource>()
|
|
set.insert(source)
|
|
|
|
#expect(set.contains(source))
|
|
}
|
|
|
|
@Test("MediaSourceType all cases")
|
|
func mediaSourceTypeAllCases() {
|
|
let allCases = MediaSourceType.allCases
|
|
|
|
#expect(allCases.count == 3)
|
|
#expect(allCases.contains(.webdav))
|
|
#expect(allCases.contains(.localFolder))
|
|
#expect(allCases.contains(.smb))
|
|
}
|
|
}
|
|
|
|
// MARK: - Content Service Routing Tests
|
|
|
|
@Suite("Content Service Routing Tests")
|
|
@MainActor
|
|
struct ContentServiceRoutingTests {
|
|
|
|
@Test("Invidious instance routes correctly")
|
|
func invidiousRouting() {
|
|
let instance = Instance(type: .invidious, url: URL(string: "https://inv.example.com")!)
|
|
#expect(instance.type == .invidious)
|
|
#expect(instance.isYouTubeInstance == true)
|
|
#expect(instance.isPeerTubeInstance == false)
|
|
}
|
|
|
|
@Test("Piped instance routes correctly")
|
|
func pipedRouting() {
|
|
let instance = Instance(type: .piped, url: URL(string: "https://piped.example.com")!)
|
|
#expect(instance.type == .piped)
|
|
#expect(instance.isYouTubeInstance == true)
|
|
#expect(instance.isPeerTubeInstance == false)
|
|
}
|
|
|
|
@Test("PeerTube instance routes correctly")
|
|
func peerTubeRouting() {
|
|
let instance = Instance(type: .peertube, url: URL(string: "https://pt.example.com")!)
|
|
#expect(instance.type == .peertube)
|
|
#expect(instance.isYouTubeInstance == false)
|
|
#expect(instance.isPeerTubeInstance == true)
|
|
}
|
|
|
|
@Test("Yattee Server instance routes correctly")
|
|
func yatteeServerRouting() {
|
|
let instance = Instance(type: .yatteeServer, url: URL(string: "https://ys.example.com")!)
|
|
#expect(instance.type == .yatteeServer)
|
|
#expect(instance.isYouTubeInstance == true)
|
|
#expect(instance.isPeerTubeInstance == false)
|
|
}
|
|
|
|
@Test("All instance types have unique raw values")
|
|
func instanceTypesUnique() {
|
|
let types = InstanceType.allCases
|
|
let rawValues = Set(types.map(\.rawValue))
|
|
|
|
#expect(rawValues.count == types.count)
|
|
}
|
|
|
|
@Test("Instance types are Codable")
|
|
func instanceTypesCodable() throws {
|
|
for type in InstanceType.allCases {
|
|
let encoded = try JSONEncoder().encode(type)
|
|
let decoded = try JSONDecoder().decode(InstanceType.self, from: encoded)
|
|
#expect(type == decoded)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Piped Authentication Parsing Tests
|
|
|
|
@Suite("Piped Authentication Parsing Tests")
|
|
@MainActor
|
|
struct PipedAuthenticationParsingTests {
|
|
|
|
@Test("Parse login response")
|
|
func parseLoginResponse() throws {
|
|
let json = """
|
|
{
|
|
"token": "abc123-auth-token-xyz789"
|
|
}
|
|
""".data(using: .utf8)!
|
|
|
|
let decoded = try JSONSerialization.jsonObject(with: json) as! [String: Any]
|
|
|
|
#expect(decoded["token"] as? String == "abc123-auth-token-xyz789")
|
|
}
|
|
|
|
@Test("Parse subscription list response")
|
|
func parseSubscriptionListResponse() throws {
|
|
let json = """
|
|
[
|
|
{
|
|
"url": "/channel/UCuAXFkgsw1L7xaCfnd5JJOw",
|
|
"name": "Rick Astley",
|
|
"avatar": "https://example.com/avatar1.jpg",
|
|
"verified": true
|
|
},
|
|
{
|
|
"url": "/channel/UC-lHJZR3Gqxm24_Vd_AJ5Yw",
|
|
"name": "PewDiePie",
|
|
"avatar": "https://example.com/avatar2.jpg",
|
|
"verified": true
|
|
}
|
|
]
|
|
""".data(using: .utf8)!
|
|
|
|
let decoded = try JSONSerialization.jsonObject(with: json) as! [[String: Any]]
|
|
|
|
#expect(decoded.count == 2)
|
|
#expect(decoded[0]["url"] as? String == "/channel/UCuAXFkgsw1L7xaCfnd5JJOw")
|
|
#expect(decoded[0]["name"] as? String == "Rick Astley")
|
|
#expect(decoded[0]["verified"] as? Bool == true)
|
|
#expect(decoded[1]["name"] as? String == "PewDiePie")
|
|
}
|
|
|
|
@Test("Parse feed response (array of videos)")
|
|
func parseFeedResponse() throws {
|
|
let json = """
|
|
[
|
|
{
|
|
"url": "/watch?v=dQw4w9WgXcQ",
|
|
"title": "Never Gonna Give You Up",
|
|
"uploaderName": "Rick Astley",
|
|
"uploaderUrl": "/channel/UCuAXFkgsw1L7xaCfnd5JJOw",
|
|
"duration": 213,
|
|
"views": 1400000000,
|
|
"thumbnail": "https://example.com/thumb1.jpg"
|
|
},
|
|
{
|
|
"url": "/watch?v=video123",
|
|
"title": "Another Video",
|
|
"uploaderName": "Channel Name",
|
|
"uploaderUrl": "/channel/UCxyz123",
|
|
"duration": 600,
|
|
"views": 50000,
|
|
"thumbnail": "https://example.com/thumb2.jpg"
|
|
}
|
|
]
|
|
""".data(using: .utf8)!
|
|
|
|
let decoded = try JSONSerialization.jsonObject(with: json) as! [[String: Any]]
|
|
|
|
#expect(decoded.count == 2)
|
|
#expect(decoded[0]["url"] as? String == "/watch?v=dQw4w9WgXcQ")
|
|
#expect(decoded[0]["title"] as? String == "Never Gonna Give You Up")
|
|
#expect(decoded[0]["duration"] as? Int == 213)
|
|
}
|
|
|
|
@Test("PipedSubscription model decoding")
|
|
func pipedSubscriptionDecoding() throws {
|
|
let json = """
|
|
{
|
|
"url": "/channel/UCuAXFkgsw1L7xaCfnd5JJOw",
|
|
"name": "Rick Astley",
|
|
"avatar": "https://example.com/avatar.jpg",
|
|
"verified": true
|
|
}
|
|
""".data(using: .utf8)!
|
|
|
|
let subscription = try JSONDecoder().decode(PipedSubscription.self, from: json)
|
|
|
|
#expect(subscription.url == "/channel/UCuAXFkgsw1L7xaCfnd5JJOw")
|
|
#expect(subscription.name == "Rick Astley")
|
|
#expect(subscription.avatar == "https://example.com/avatar.jpg")
|
|
#expect(subscription.verified == true)
|
|
}
|
|
|
|
@Test("PipedSubscription channelId extraction")
|
|
func pipedSubscriptionChannelIdExtraction() throws {
|
|
let json = """
|
|
{
|
|
"url": "/channel/UCuAXFkgsw1L7xaCfnd5JJOw",
|
|
"name": "Test Channel"
|
|
}
|
|
""".data(using: .utf8)!
|
|
|
|
let subscription = try JSONDecoder().decode(PipedSubscription.self, from: json)
|
|
|
|
#expect(subscription.channelId == "UCuAXFkgsw1L7xaCfnd5JJOw")
|
|
}
|
|
|
|
@Test("PipedSubscription toChannel conversion")
|
|
func pipedSubscriptionToChannel() throws {
|
|
let json = """
|
|
{
|
|
"url": "/channel/UCuAXFkgsw1L7xaCfnd5JJOw",
|
|
"name": "Rick Astley",
|
|
"avatar": "https://example.com/avatar.jpg",
|
|
"verified": true
|
|
}
|
|
""".data(using: .utf8)!
|
|
|
|
let subscription = try JSONDecoder().decode(PipedSubscription.self, from: json)
|
|
let channel = subscription.toChannel()
|
|
|
|
#expect(channel.name == "Rick Astley")
|
|
#expect(channel.thumbnailURL?.absoluteString == "https://example.com/avatar.jpg")
|
|
#expect(channel.isVerified == true)
|
|
}
|
|
|
|
@Test("PipedSubscription with nil optional fields")
|
|
func pipedSubscriptionNilOptionals() throws {
|
|
let json = """
|
|
{
|
|
"url": "/channel/UC123",
|
|
"name": "Basic Channel"
|
|
}
|
|
""".data(using: .utf8)!
|
|
|
|
let subscription = try JSONDecoder().decode(PipedSubscription.self, from: json)
|
|
|
|
#expect(subscription.name == "Basic Channel")
|
|
#expect(subscription.avatar == nil)
|
|
#expect(subscription.verified == nil)
|
|
|
|
let channel = subscription.toChannel()
|
|
#expect(channel.thumbnailURL == nil)
|
|
#expect(channel.isVerified == false)
|
|
}
|
|
|
|
@Test("Login error response structure")
|
|
func loginErrorResponseStructure() throws {
|
|
// When login fails, Piped returns 401 or 403
|
|
// The app should map this to APIError.unauthorized
|
|
let statusCodes = [401, 403]
|
|
|
|
for statusCode in statusCodes {
|
|
#expect(statusCode == 401 || statusCode == 403)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Stream Resolution Tests
|
|
|
|
@Suite("Stream Resolution Tests")
|
|
@MainActor
|
|
struct StreamResolutionTests {
|
|
|
|
@Test("StreamResolution from width and height")
|
|
func resolutionFromDimensions() {
|
|
let resolution = StreamResolution(width: 1920, height: 1080)
|
|
#expect(resolution.height == 1080)
|
|
#expect(resolution.width == 1920)
|
|
}
|
|
|
|
@Test("StreamResolution static constants")
|
|
func staticConstants() {
|
|
#expect(StreamResolution.p720.height == 720)
|
|
#expect(StreamResolution.p1080.height == 1080)
|
|
#expect(StreamResolution.p2160.height == 2160)
|
|
}
|
|
|
|
@Test("StreamResolution comparison")
|
|
func resolutionComparison() {
|
|
let r720 = StreamResolution(width: 1280, height: 720)
|
|
let r1080 = StreamResolution(width: 1920, height: 1080)
|
|
let r4k = StreamResolution(width: 3840, height: 2160)
|
|
|
|
#expect(r720 < r1080)
|
|
#expect(r1080 < r4k)
|
|
#expect(r4k > r720)
|
|
}
|
|
|
|
@Test("StreamResolution description")
|
|
func resolutionDescription() {
|
|
let resolution = StreamResolution(width: 1920, height: 1080)
|
|
// description returns "heightp" format
|
|
#expect(resolution.description == "1080p")
|
|
}
|
|
|
|
@Test("StreamResolution Codable")
|
|
func resolutionCodable() throws {
|
|
let resolution = StreamResolution(width: 1920, height: 1080)
|
|
let encoded = try JSONEncoder().encode(resolution)
|
|
let decoded = try JSONDecoder().decode(StreamResolution.self, from: encoded)
|
|
|
|
#expect(decoded.width == resolution.width)
|
|
#expect(decoded.height == resolution.height)
|
|
}
|
|
|
|
@Test("StreamResolution Hashable")
|
|
func resolutionHashable() {
|
|
let r1 = StreamResolution(width: 1920, height: 1080)
|
|
let r2 = StreamResolution(width: 1920, height: 1080)
|
|
|
|
#expect(r1 == r2)
|
|
|
|
var set = Set<StreamResolution>()
|
|
set.insert(r1)
|
|
#expect(set.contains(r2))
|
|
}
|
|
}
|
|
|
|
// MARK: - Playlist Parsing Error Handling Tests
|
|
|
|
@Suite("Playlist Parsing Error Handling Tests")
|
|
@MainActor
|
|
struct PlaylistParsingErrorHandlingTests {
|
|
|
|
@Test("Invidious playlist with parse-error items structure")
|
|
func invidiousPlaylistWithParseErrors() throws {
|
|
// Simulates the real Invidious API response with parse-error items
|
|
// This validates that our code can handle this JSON structure
|
|
let json = """
|
|
{
|
|
"playlistId": "PLtest123",
|
|
"title": "Test Playlist",
|
|
"description": "A playlist with some parse errors",
|
|
"author": "Test Author",
|
|
"authorId": "UCtest123",
|
|
"videoCount": 3,
|
|
"videos": [
|
|
{
|
|
"type": "video",
|
|
"videoId": "video1",
|
|
"title": "First Video",
|
|
"author": "Author 1",
|
|
"authorId": "UC1",
|
|
"lengthSeconds": 120,
|
|
"liveNow": false
|
|
},
|
|
{
|
|
"type": "parse-error",
|
|
"errorMessage": "Missing hash key: \\"browseEndpoint\\"",
|
|
"errorBacktrace": "Some stack trace..."
|
|
},
|
|
{
|
|
"type": "video",
|
|
"videoId": "video2",
|
|
"title": "Second Video",
|
|
"author": "Author 2",
|
|
"authorId": "UC2",
|
|
"lengthSeconds": 240,
|
|
"liveNow": false
|
|
}
|
|
]
|
|
}
|
|
""".data(using: .utf8)!
|
|
|
|
// Verify the JSON structure is valid
|
|
let decoded = try JSONSerialization.jsonObject(with: json) as! [String: Any]
|
|
let videos = decoded["videos"] as! [[String: Any]]
|
|
|
|
#expect(videos.count == 3)
|
|
#expect(videos[0]["type"] as? String == "video")
|
|
#expect(videos[0]["videoId"] as? String == "video1")
|
|
#expect(videos[1]["type"] as? String == "parse-error")
|
|
#expect(videos[1]["errorMessage"] != nil)
|
|
#expect(videos[2]["type"] as? String == "video")
|
|
#expect(videos[2]["videoId"] as? String == "video2")
|
|
}
|
|
|
|
@Test("Invidious playlist videos without type field default to video")
|
|
func invidiousPlaylistVideosWithoutType() throws {
|
|
// Some Invidious responses don't include type field for videos
|
|
let json = """
|
|
{
|
|
"playlistId": "PLtest456",
|
|
"title": "Test Playlist",
|
|
"videoCount": 1,
|
|
"videos": [
|
|
{
|
|
"videoId": "video1",
|
|
"title": "Video Without Type",
|
|
"author": "Author",
|
|
"authorId": "UC1",
|
|
"lengthSeconds": 180,
|
|
"liveNow": false
|
|
}
|
|
]
|
|
}
|
|
""".data(using: .utf8)!
|
|
|
|
let decoded = try JSONSerialization.jsonObject(with: json) as! [String: Any]
|
|
let videos = decoded["videos"] as! [[String: Any]]
|
|
|
|
#expect(videos.count == 1)
|
|
#expect(videos[0]["type"] == nil)
|
|
#expect(videos[0]["videoId"] as? String == "video1")
|
|
}
|
|
|
|
@Test("Piped playlist with malformed items structure")
|
|
func pipedPlaylistWithMalformedItems() throws {
|
|
let json = """
|
|
{
|
|
"name": "Test Playlist",
|
|
"description": "A test playlist",
|
|
"uploader": "Test Uploader",
|
|
"videos": 2,
|
|
"relatedStreams": [
|
|
{
|
|
"url": "/watch?v=valid123",
|
|
"title": "Valid Video",
|
|
"uploaderName": "Uploader",
|
|
"duration": 300
|
|
},
|
|
{
|
|
"invalid": "item",
|
|
"missing": "required fields"
|
|
}
|
|
]
|
|
}
|
|
""".data(using: .utf8)!
|
|
|
|
let decoded = try JSONSerialization.jsonObject(with: json) as! [String: Any]
|
|
let streams = decoded["relatedStreams"] as! [[String: Any]]
|
|
|
|
#expect(streams.count == 2)
|
|
#expect(streams[0]["url"] as? String == "/watch?v=valid123")
|
|
#expect(streams[1]["url"] == nil) // Missing required field
|
|
}
|
|
|
|
@Test("Empty playlist videos array should be handled")
|
|
func emptyPlaylistVideos() throws {
|
|
let json = """
|
|
{
|
|
"playlistId": "PL_empty",
|
|
"title": "Empty Playlist",
|
|
"videoCount": 0,
|
|
"videos": []
|
|
}
|
|
""".data(using: .utf8)!
|
|
|
|
let decoded = try JSONSerialization.jsonObject(with: json) as! [String: Any]
|
|
let videos = decoded["videos"] as! [[String: Any]]
|
|
|
|
#expect(videos.isEmpty)
|
|
}
|
|
|
|
@Test("Playlist with null videos array should be handled")
|
|
func nullPlaylistVideos() throws {
|
|
let json = """
|
|
{
|
|
"playlistId": "PL_null",
|
|
"title": "Playlist with null videos",
|
|
"videoCount": 5
|
|
}
|
|
""".data(using: .utf8)!
|
|
|
|
let decoded = try JSONSerialization.jsonObject(with: json) as! [String: Any]
|
|
|
|
#expect(decoded["videos"] == nil)
|
|
}
|
|
|
|
@Test("Unknown video type should be skipped")
|
|
func unknownVideoType() throws {
|
|
let json = """
|
|
{
|
|
"playlistId": "PL_unknown",
|
|
"title": "Playlist with unknown types",
|
|
"videoCount": 2,
|
|
"videos": [
|
|
{
|
|
"type": "future-type",
|
|
"someField": "someValue"
|
|
},
|
|
{
|
|
"type": "video",
|
|
"videoId": "valid",
|
|
"title": "Valid",
|
|
"author": "Author",
|
|
"authorId": "UC1",
|
|
"lengthSeconds": 60,
|
|
"liveNow": false
|
|
}
|
|
]
|
|
}
|
|
""".data(using: .utf8)!
|
|
|
|
let decoded = try JSONSerialization.jsonObject(with: json) as! [String: Any]
|
|
let videos = decoded["videos"] as! [[String: Any]]
|
|
|
|
#expect(videos.count == 2)
|
|
#expect(videos[0]["type"] as? String == "future-type")
|
|
#expect(videos[1]["type"] as? String == "video")
|
|
}
|
|
|
|
@Test("Multiple parse-error items in playlist")
|
|
func multipleParseErrors() throws {
|
|
// Real-world scenario where multiple videos fail to parse
|
|
let json = """
|
|
{
|
|
"playlistId": "PLmulti",
|
|
"title": "Playlist with multiple errors",
|
|
"videoCount": 5,
|
|
"videos": [
|
|
{"type": "parse-error", "errorMessage": "Error 1"},
|
|
{"type": "video", "videoId": "v1", "title": "V1", "author": "A", "authorId": "UC1", "lengthSeconds": 60, "liveNow": false},
|
|
{"type": "parse-error", "errorMessage": "Error 2"},
|
|
{"type": "parse-error", "errorMessage": "Error 3"},
|
|
{"type": "video", "videoId": "v2", "title": "V2", "author": "A", "authorId": "UC1", "lengthSeconds": 120, "liveNow": false}
|
|
]
|
|
}
|
|
""".data(using: .utf8)!
|
|
|
|
let decoded = try JSONSerialization.jsonObject(with: json) as! [String: Any]
|
|
let videos = decoded["videos"] as! [[String: Any]]
|
|
|
|
#expect(videos.count == 5)
|
|
|
|
// Count valid videos vs parse errors
|
|
let validCount = videos.filter { ($0["type"] as? String) == "video" }.count
|
|
let errorCount = videos.filter { ($0["type"] as? String) == "parse-error" }.count
|
|
|
|
#expect(validCount == 2)
|
|
#expect(errorCount == 3)
|
|
}
|
|
|
|
@Test("Piped playlist with empty relatedStreams")
|
|
func pipedEmptyRelatedStreams() throws {
|
|
let json = """
|
|
{
|
|
"name": "Empty Piped Playlist",
|
|
"videos": 0,
|
|
"relatedStreams": []
|
|
}
|
|
""".data(using: .utf8)!
|
|
|
|
let decoded = try JSONSerialization.jsonObject(with: json) as! [String: Any]
|
|
let streams = decoded["relatedStreams"] as! [[String: Any]]
|
|
|
|
#expect(streams.isEmpty)
|
|
}
|
|
|
|
@Test("Piped playlist with null relatedStreams")
|
|
func pipedNullRelatedStreams() throws {
|
|
let json = """
|
|
{
|
|
"name": "Piped Playlist without streams",
|
|
"videos": 10
|
|
}
|
|
""".data(using: .utf8)!
|
|
|
|
let decoded = try JSONSerialization.jsonObject(with: json) as! [String: Any]
|
|
|
|
#expect(decoded["relatedStreams"] == nil)
|
|
}
|
|
}
|