ios: add share extension to queue and play video links
Embed an iOS and Catalyst share extension using existing discovery and player APIs. Share saved players through an App Group snapshot, preserve the queue, and verify advancing playback before reporting success. Cover URL provider transport, settings migration, discovery addresses, duplicate handling, cancellation, and partial enqueue/play outcomes with focused tests. Document authentication and signing limits.
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import QueueCubeShareCore
|
||||
|
||||
final class RequestBuilderTests: XCTestCase {
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
URLProtocol.registerClass(StubURLProtocol.self)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
StubURLProtocol.setResponse(status: 200, body: Data())
|
||||
URLProtocol.unregisterClass(StubURLProtocol.self)
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testConfiguredTimeoutAndBodySurviveEndpointBuilding() throws {
|
||||
let baseURL = URL(string: "https://queuecube-test.invalid/api")!
|
||||
let request = RequestBuilder(url: baseURL, timeout: 7)
|
||||
.path("/playlist")
|
||||
.body(["url": "https://video.example/watch?v=one"])
|
||||
.method(.post)
|
||||
.build()
|
||||
|
||||
XCTAssertEqual(request.url?.path, "/api/playlist")
|
||||
XCTAssertEqual(request.timeoutInterval, 7)
|
||||
XCTAssertEqual(request.httpMethod, "POST")
|
||||
XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "application/json")
|
||||
XCTAssertEqual(request.value(forHTTPHeaderField: "Cache-Control"), "no-cache")
|
||||
XCTAssertEqual(try JSONDecoder().decode([String: String].self, from: XCTUnwrap(request.httpBody)), ["url": "https://video.example/watch?v=one"])
|
||||
XCTAssertEqual(RequestBuilder(url: baseURL, timeout: 7).pathString("/search?q=one").build().timeoutInterval, 7)
|
||||
}
|
||||
|
||||
func testPOSTRejectsAuthenticationFailureInsteadOfReportingSuccess() async throws {
|
||||
StubURLProtocol.setResponse(status: 401, body: Data(#"{"error":"Authentication required"}"#.utf8))
|
||||
do {
|
||||
try await RequestBuilder(url: URL(string: "https://queuecube-test.invalid/api/playlist")!).post()
|
||||
XCTFail("A non-2xx mutation must fail")
|
||||
} catch RequestBuilder.RequestError.httpError(let status, let message) {
|
||||
XCTAssertEqual(status, 401)
|
||||
XCTAssertEqual(message, "Authentication required")
|
||||
}
|
||||
}
|
||||
|
||||
func testNonJSONServerFailureStillReportsItsStatus() async throws {
|
||||
StubURLProtocol.setResponse(status: 502, body: Data("upstream unavailable".utf8))
|
||||
do {
|
||||
try await RequestBuilder(url: URL(string: "https://queuecube-test.invalid/api/play")!).post()
|
||||
XCTFail("A non-JSON server failure must fail")
|
||||
} catch RequestBuilder.RequestError.httpError(let status, let message) {
|
||||
XCTAssertEqual(status, 502)
|
||||
XCTAssertNil(message)
|
||||
}
|
||||
}
|
||||
|
||||
func testSuccessfulEmptyMutationResponseIsAccepted() async throws {
|
||||
StubURLProtocol.setResponse(status: 204, body: Data())
|
||||
try await RequestBuilder(url: URL(string: "https://queuecube-test.invalid/api/play")!).post()
|
||||
}
|
||||
|
||||
func testAPIReadsExistingPlaylistEndpointAndDecodesIDs() async throws {
|
||||
StubURLProtocol.setResponse(status: 200, body: Data(#"[{"id":42,"filename":"https://video.example/one"}]"#.utf8))
|
||||
let api = API(baseURL: URL(string: "https://queuecube-test.invalid/api")!, requestTimeout: 7)
|
||||
let playlist = try await api.fetchPlaylist()
|
||||
XCTAssertEqual(playlist.map(\.id), [42])
|
||||
let request = try XCTUnwrap(StubURLProtocol.lastRequest())
|
||||
XCTAssertEqual(request.url?.path, "/api/playlist")
|
||||
XCTAssertEqual(request.httpMethod, "GET")
|
||||
XCTAssertEqual(request.timeoutInterval, 7)
|
||||
}
|
||||
}
|
||||
|
||||
// Intercepts only this test's reserved host. No network or real player is used.
|
||||
private final class StubURLProtocol: URLProtocol {
|
||||
private static let lock = NSLock()
|
||||
private static var status = 200
|
||||
private static var body = Data()
|
||||
private static var request: URLRequest?
|
||||
|
||||
static func setResponse(status: Int, body: Data) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
Self.status = status
|
||||
Self.body = body
|
||||
Self.request = nil
|
||||
}
|
||||
|
||||
static func lastRequest() -> URLRequest? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return request
|
||||
}
|
||||
|
||||
override class func canInit(with request: URLRequest) -> Bool {
|
||||
request.url?.host == "queuecube-test.invalid"
|
||||
}
|
||||
|
||||
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
||||
|
||||
override func startLoading() {
|
||||
Self.lock.lock()
|
||||
let status = Self.status
|
||||
let body = Self.body
|
||||
Self.request = request
|
||||
Self.lock.unlock()
|
||||
let response = HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: "HTTP/1.1", headerFields: [:])!
|
||||
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
||||
client?.urlProtocol(self, didLoad: body)
|
||||
client?.urlProtocolDidFinishLoading(self)
|
||||
}
|
||||
|
||||
override func stopLoading() {}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import QueueCubeShareCore
|
||||
|
||||
final class ServerTests: XCTestCase {
|
||||
func testScopedIPv4FromNetworkFrameworkUsesResolvableHost() throws {
|
||||
let server = try XCTUnwrap(Server(serviceName: nil, host: "192.168.1.153%en1", port: 18731))
|
||||
XCTAssertEqual(server.baseURL.absoluteString, "http://192.168.1.153:18731/api")
|
||||
XCTAssertEqual(server.baseURL.host, "192.168.1.153")
|
||||
}
|
||||
|
||||
func testUnscopedIPv4AndHostnameKeepHostAndPort() throws {
|
||||
for host in ["192.168.1.153", "queuecube.local"] {
|
||||
let server = try XCTUnwrap(Server(serviceName: nil, host: host, port: 3000))
|
||||
XCTAssertEqual(server.baseURL.absoluteString, "http://\(host):3000/api")
|
||||
XCTAssertEqual(server.baseURL.host, host)
|
||||
}
|
||||
}
|
||||
|
||||
func testIPv6LiteralsAreBracketedAndPreserveEncodedScope() throws {
|
||||
let cases = [
|
||||
("2001:db8::1", "http://[2001:db8::1]:3000/api"),
|
||||
("fe80::1%en1", "http://[fe80::1%25en1]:3000/api"),
|
||||
("[2001:db8::1]", "http://[2001:db8::1]:3000/api"),
|
||||
("[fe80::1%en1]", "http://[fe80::1%25en1]:3000/api")
|
||||
]
|
||||
for (host, expected) in cases {
|
||||
let server = try XCTUnwrap(Server(serviceName: nil, host: host, port: 3000))
|
||||
XCTAssertEqual(server.baseURL.absoluteString, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func testHostnameUsesURLHostEncoding() throws {
|
||||
let server = try XCTUnwrap(Server(serviceName: nil, host: "café.local", port: 3000))
|
||||
XCTAssertEqual(server.baseURL.absoluteString, "http://xn--caf-dma.local:3000/api")
|
||||
XCTAssertNil(Server(serviceName: nil, host: "queuecube.local/path", port: 3000))
|
||||
}
|
||||
|
||||
func testPlayerDisplayNameUsesBonjourNameAndFallsBackToHost() throws {
|
||||
let advertised = try XCTUnwrap(Server(serviceName: "QueueCube Media Server (Living Room)", host: "192.168.1.153%en1", port: 3000))
|
||||
XCTAssertEqual(advertised.displayName, "Living Room")
|
||||
let custom = try XCTUnwrap(Server(serviceName: "Kitchen", host: "queuecube.local", port: 3000))
|
||||
XCTAssertEqual(custom.displayName, "Kitchen")
|
||||
let unnamed = try XCTUnwrap(Server(serviceName: nil, host: "queuecube.local", port: 3000))
|
||||
XCTAssertEqual(unnamed.displayName, "queuecube.local")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import QueueCubeShareCore
|
||||
|
||||
final class SettingsTests: XCTestCase {
|
||||
func testExistingSettingsArePublishedOnReadWithoutChangingLegacyStorage() throws {
|
||||
try withDefaults { legacy, shared, legacyName, _ in
|
||||
let first = Server(baseURL: URL(string: "http://living-room.invalid:8080/api")!)
|
||||
let selected = Server(baseURL: URL(string: "https://bedroom.invalid/api")!)
|
||||
let original = Settings(selectedServer: selected, configuredServers: [first, selected])
|
||||
original.save(defaults: legacy, sharedDefaults: nil)
|
||||
let legacyBefore = legacy.persistentDomain(forName: legacyName)! as NSDictionary
|
||||
|
||||
XCTAssertFalse(Settings.fromSharedDefaults(defaults: shared).isConfigured)
|
||||
let loaded = Settings.fromDefaults(defaults: legacy, sharedDefaults: shared)
|
||||
let snapshot = Settings.fromSharedDefaults(defaults: shared)
|
||||
|
||||
XCTAssertEqual(loaded.configuredServers, original.configuredServers)
|
||||
XCTAssertEqual(loaded.selectedServer, selected)
|
||||
XCTAssertEqual(snapshot.configuredServers, original.configuredServers)
|
||||
XCTAssertEqual(snapshot.selectedServer, selected)
|
||||
XCTAssertEqual(legacy.persistentDomain(forName: legacyName)! as NSDictionary, legacyBefore)
|
||||
}
|
||||
}
|
||||
|
||||
func testSavePublishesOneSnapshotAndExtensionReadsLeaveItUnchanged() throws {
|
||||
try withDefaults { legacy, shared, _, sharedName in
|
||||
let first = Server(baseURL: URL(string: "http://living-room.invalid:8080/api")!)
|
||||
let second = Server(baseURL: URL(string: "http://bedroom.invalid:8080/api")!)
|
||||
let settings = Settings(selectedServer: first, configuredServers: [first, second])
|
||||
settings.save(defaults: legacy, sharedDefaults: shared)
|
||||
settings.selectedServer(second).save(defaults: legacy, sharedDefaults: shared)
|
||||
let before = shared.persistentDomain(forName: sharedName)!
|
||||
XCTAssertEqual(before.count, 1, "Player list and selection must be published atomically")
|
||||
|
||||
let snapshot = Settings.fromSharedDefaults(defaults: shared)
|
||||
|
||||
XCTAssertEqual(snapshot.selectedServer, second)
|
||||
XCTAssertEqual(snapshot.configuredServers, [first, second])
|
||||
XCTAssertEqual(shared.persistentDomain(forName: sharedName)! as NSDictionary, before as NSDictionary)
|
||||
}
|
||||
}
|
||||
|
||||
func testMissingOrInvalidSharedSnapshotIsAnUnconfiguredState() throws {
|
||||
XCTAssertFalse(Settings.fromSharedDefaults(defaults: nil).isConfigured)
|
||||
try withDefaults { _, shared, _, _ in
|
||||
XCTAssertNil(Settings.fromSharedDefaults(defaults: shared).selectedServer)
|
||||
shared.set(Data("not a property list".utf8), forKey: "shareSettings.v1")
|
||||
let invalid = Settings.fromSharedDefaults(defaults: shared)
|
||||
XCTAssertTrue(invalid.configuredServers.isEmpty)
|
||||
XCTAssertNil(invalid.selectedServer)
|
||||
}
|
||||
}
|
||||
|
||||
func testSelectionTracksFirstAddedAndRemovedConfiguredPlayers() {
|
||||
let first = Server(baseURL: URL(string: "http://living-room.invalid/api")!)
|
||||
let second = Server(baseURL: URL(string: "http://bedroom.invalid/api")!)
|
||||
var settings = Settings(selectedServer: nil, configuredServers: [])
|
||||
settings.configuredServers = [first, second]
|
||||
XCTAssertEqual(settings.selectedServer, first)
|
||||
settings.configuredServers = [second]
|
||||
XCTAssertEqual(settings.selectedServer, second)
|
||||
settings.configuredServers = []
|
||||
XCTAssertNil(settings.selectedServer)
|
||||
}
|
||||
|
||||
private func withDefaults(_ body: (UserDefaults, UserDefaults, String, String) throws -> Void) throws {
|
||||
let prefix = "QueueCubeShareTests.\(UUID().uuidString)"
|
||||
let legacyName = "\(prefix).legacy"
|
||||
let sharedName = "\(prefix).shared"
|
||||
let legacy = try XCTUnwrap(UserDefaults(suiteName: legacyName))
|
||||
let shared = try XCTUnwrap(UserDefaults(suiteName: sharedName))
|
||||
defer {
|
||||
legacy.removePersistentDomain(forName: legacyName)
|
||||
shared.removePersistentDomain(forName: sharedName)
|
||||
}
|
||||
try body(legacy, shared, legacyName, sharedName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import QueueCubeShareCore
|
||||
|
||||
final class SharePlaybackTests: XCTestCase {
|
||||
private let url = URL(string: "https://video.example/watch?v=shared")!
|
||||
|
||||
func testSelectsNewDuplicateByIDAtItsLatestQueueIndex() async throws {
|
||||
let existing = item(10)
|
||||
let other = item(11, filename: "https://video.example/other")
|
||||
let appended = item(12)
|
||||
let api = FakePlayer(
|
||||
playlists: [.success([existing, other]), .success([existing, other, appended]), .success([other, appended, existing])],
|
||||
nowPlaying: [
|
||||
.success(nowPlaying(appended, paused: true)),
|
||||
.success(nowPlaying(appended, position: 0)),
|
||||
.success(nowPlaying(appended, position: 0.25))
|
||||
]
|
||||
)
|
||||
|
||||
try await enqueue(using: api)
|
||||
|
||||
let calls = await api.calls
|
||||
XCTAssertEqual(calls.addedURLs, [url.absoluteString])
|
||||
XCTAssertEqual(calls.skippedIndices, [1])
|
||||
XCTAssertEqual(calls.playCount, 1)
|
||||
XCTAssertEqual(calls.nowPlayingReads, 3, "A paused player or a single position sample cannot prove playback")
|
||||
}
|
||||
|
||||
func testEmptyQueueWaitsForAsynchronousAppendThenStartsPlayer() async throws {
|
||||
let appended = item(42)
|
||||
let api = FakePlayer(
|
||||
playlists: [.success([]), .success([]), .success([]), .success([appended]), .success([appended])],
|
||||
nowPlaying: [
|
||||
.success(nowPlaying(nil)),
|
||||
.success(nowPlaying(appended, position: 0)),
|
||||
.success(nowPlaying(appended, position: 0.25))
|
||||
]
|
||||
)
|
||||
|
||||
try await enqueue(using: api)
|
||||
|
||||
let calls = await api.calls
|
||||
XCTAssertEqual(calls.addedURLs.count, 1)
|
||||
XCTAssertEqual(calls.skippedIndices, [0])
|
||||
XCTAssertEqual(calls.playCount, 1)
|
||||
XCTAssertEqual(calls.playlistReads, 5)
|
||||
}
|
||||
|
||||
func testPreflightFailureAllowsRetryAndDoesNotMutatePlayer() async throws {
|
||||
let api = FakePlayer(playlists: [.failure(.connection)], nowPlaying: [])
|
||||
let failure = await failure(using: api)
|
||||
XCTAssertTrue(failure.canRetry)
|
||||
XCTAssertTrue(failure.message.contains("No link was added"))
|
||||
let calls = await api.calls
|
||||
XCTAssertTrue(calls.addedURLs.isEmpty)
|
||||
XCTAssertTrue(calls.skippedIndices.isEmpty)
|
||||
XCTAssertEqual(calls.playCount, 0)
|
||||
}
|
||||
|
||||
func testUncertainAppendResponseDoesNotRepeatMutationOrAllowRetry() async throws {
|
||||
let api = FakePlayer(playlists: [.success([])], nowPlaying: [], addError: .connection)
|
||||
let failure = await failure(using: api)
|
||||
XCTAssertFalse(failure.canRetry)
|
||||
XCTAssertTrue(failure.message.contains("may already be queued"))
|
||||
let calls = await api.calls
|
||||
XCTAssertEqual(calls.addedURLs, [url.absoluteString])
|
||||
XCTAssertTrue(calls.skippedIndices.isEmpty)
|
||||
XCTAssertEqual(calls.playCount, 0)
|
||||
}
|
||||
|
||||
func testAppendNeverAppearsDoesNotRepeatMutation() async throws {
|
||||
let api = FakePlayer(playlists: [.success([])], nowPlaying: [])
|
||||
let failure = await failure(using: api)
|
||||
XCTAssertFalse(failure.canRetry)
|
||||
let calls = await api.calls
|
||||
XCTAssertEqual(calls.addedURLs.count, 1)
|
||||
XCTAssertTrue(calls.skippedIndices.isEmpty)
|
||||
XCTAssertEqual(calls.playCount, 0)
|
||||
}
|
||||
|
||||
func testConcurrentIdenticalAppendsFailWithoutSelectingAnArbitraryItem() async throws {
|
||||
let api = FakePlayer(playlists: [.success([]), .success([item(1), item(2)])], nowPlaying: [])
|
||||
let failure = await failure(using: api)
|
||||
XCTAssertFalse(failure.canRetry)
|
||||
let calls = await api.calls
|
||||
XCTAssertEqual(calls.addedURLs.count, 1)
|
||||
XCTAssertTrue(calls.skippedIndices.isEmpty)
|
||||
XCTAssertEqual(calls.playCount, 0)
|
||||
}
|
||||
|
||||
func testDuplicateAppearingInFinalSnapshotDoesNotSelectAnArbitraryItem() async throws {
|
||||
let api = FakePlayer(
|
||||
playlists: [.success([]), .success([item(1)]), .success([item(1), item(2)])],
|
||||
nowPlaying: []
|
||||
)
|
||||
let failure = await failure(using: api)
|
||||
XCTAssertFalse(failure.canRetry)
|
||||
let calls = await api.calls
|
||||
XCTAssertEqual(calls.addedURLs.count, 1)
|
||||
XCTAssertTrue(calls.skippedIndices.isEmpty)
|
||||
XCTAssertEqual(calls.playCount, 0)
|
||||
}
|
||||
|
||||
func testDifferentCandidateInFinalSnapshotDoesNotSelectAnotherClientsAppend() async throws {
|
||||
let api = FakePlayer(playlists: [.success([]), .success([item(1)]), .success([item(2)])], nowPlaying: [])
|
||||
let failure = await failure(using: api)
|
||||
XCTAssertFalse(failure.canRetry)
|
||||
let calls = await api.calls
|
||||
XCTAssertTrue(calls.skippedIndices.isEmpty)
|
||||
XCTAssertEqual(calls.playCount, 0)
|
||||
}
|
||||
|
||||
func testAppendedItemRemovedBeforeSelectionDoesNotSkipAnotherItem() async throws {
|
||||
let api = FakePlayer(playlists: [.success([]), .success([item(1)]), .success([])], nowPlaying: [])
|
||||
let failure = await failure(using: api)
|
||||
XCTAssertFalse(failure.canRetry)
|
||||
XCTAssertTrue(failure.message.contains("was queued"))
|
||||
let calls = await api.calls
|
||||
XCTAssertTrue(calls.skippedIndices.isEmpty)
|
||||
XCTAssertEqual(calls.playCount, 0)
|
||||
}
|
||||
|
||||
func testPlayRequestFailureReportsPartialSuccessWithoutRetry() async throws {
|
||||
let appended = item(1)
|
||||
let api = FakePlayer(playlists: [.success([]), .success([appended])], nowPlaying: [], playError: .connection)
|
||||
let failure = await failure(using: api)
|
||||
XCTAssertFalse(failure.canRetry)
|
||||
XCTAssertTrue(failure.message.contains("was queued, but playback could not be confirmed"))
|
||||
let calls = await api.calls
|
||||
XCTAssertEqual(calls.addedURLs.count, 1)
|
||||
XCTAssertEqual(calls.skippedIndices, [0])
|
||||
XCTAssertEqual(calls.playCount, 1)
|
||||
}
|
||||
|
||||
func testPlaybackErrorIsNotReportedAsSuccess() async throws {
|
||||
let appended = item(1)
|
||||
let errored = item(1, playbackError: "Unsupported video")
|
||||
let api = FakePlayer(
|
||||
playlists: [.success([]), .success([appended])],
|
||||
nowPlaying: [.success(nowPlaying(errored))]
|
||||
)
|
||||
let failure = await failure(using: api)
|
||||
XCTAssertFalse(failure.canRetry)
|
||||
let calls = await api.calls
|
||||
XCTAssertEqual(calls.nowPlayingReads, 1)
|
||||
XCTAssertEqual(calls.addedURLs.count, 1)
|
||||
}
|
||||
|
||||
func testWrongPlayingItemAndPausedPlaybackDoNotReportSuccess() async throws {
|
||||
for info in [nowPlaying(item(8)), nowPlaying(item(1), paused: true)] {
|
||||
let api = FakePlayer(playlists: [.success([]), .success([item(1)])], nowPlaying: [.success(info)])
|
||||
let failure = await failure(using: api)
|
||||
XCTAssertFalse(failure.canRetry)
|
||||
let calls = await api.calls
|
||||
XCTAssertEqual(calls.nowPlayingReads, 4)
|
||||
XCTAssertEqual(calls.playCount, 1)
|
||||
}
|
||||
}
|
||||
|
||||
func testLoadingItemFollowedByLoadErrorDoesNotReportSuccess() async throws {
|
||||
let appended = item(1)
|
||||
let errored = item(1, playbackError: "Failed to load video")
|
||||
let api = FakePlayer(
|
||||
playlists: [.success([]), .success([appended])],
|
||||
nowPlaying: [.success(nowPlaying(appended)), .success(nowPlaying(errored))]
|
||||
)
|
||||
let failure = await failure(using: api)
|
||||
XCTAssertFalse(failure.canRetry)
|
||||
let calls = await api.calls
|
||||
XCTAssertEqual(calls.nowPlayingReads, 2)
|
||||
XCTAssertEqual(calls.playCount, 1)
|
||||
}
|
||||
|
||||
func testLoadingItemFollowedByDifferentCurrentItemDoesNotReportSuccess() async throws {
|
||||
let appended = item(1)
|
||||
let api = FakePlayer(
|
||||
playlists: [.success([]), .success([appended])],
|
||||
nowPlaying: [
|
||||
.success(nowPlaying(appended)),
|
||||
.success(nowPlaying(item(2), position: 0)),
|
||||
.success(nowPlaying(item(2), position: 0.25))
|
||||
]
|
||||
)
|
||||
let failure = await failure(using: api)
|
||||
XCTAssertFalse(failure.canRetry)
|
||||
let calls = await api.calls
|
||||
XCTAssertEqual(calls.nowPlayingReads, 4)
|
||||
}
|
||||
|
||||
func testLoadingItemFollowedByMatchingPlaybackProgressSucceeds() async throws {
|
||||
let appended = item(1)
|
||||
let api = FakePlayer(
|
||||
playlists: [.success([]), .success([appended])],
|
||||
nowPlaying: [
|
||||
.success(nowPlaying(appended)),
|
||||
.success(nowPlaying(appended, position: 0)),
|
||||
.success(nowPlaying(appended, position: 0.25))
|
||||
]
|
||||
)
|
||||
try await enqueue(using: api)
|
||||
let calls = await api.calls
|
||||
XCTAssertEqual(calls.nowPlayingReads, 3)
|
||||
XCTAssertEqual(calls.addedURLs.count, 1)
|
||||
XCTAssertEqual(calls.playCount, 1)
|
||||
}
|
||||
|
||||
func testFrozenPlaybackPositionDoesNotReportSuccess() async throws {
|
||||
let appended = item(1)
|
||||
let api = FakePlayer(
|
||||
playlists: [.success([]), .success([appended])],
|
||||
nowPlaying: [.success(nowPlaying(appended, position: 0))]
|
||||
)
|
||||
let failure = await failure(using: api)
|
||||
XCTAssertFalse(failure.canRetry)
|
||||
let calls = await api.calls
|
||||
XCTAssertEqual(calls.nowPlayingReads, 4)
|
||||
}
|
||||
|
||||
func testInvalidPositionCannotEstablishPlaybackProgress() async throws {
|
||||
let appended = item(1)
|
||||
for invalidPosition in [-1, Double.infinity, -Double.infinity, Double.nan] {
|
||||
let api = FakePlayer(
|
||||
playlists: [.success([]), .success([appended])],
|
||||
nowPlaying: [
|
||||
.success(nowPlaying(appended, position: invalidPosition)),
|
||||
.success(nowPlaying(appended, position: 1))
|
||||
]
|
||||
)
|
||||
let failure = await failure(using: api)
|
||||
XCTAssertFalse(failure.canRetry)
|
||||
let calls = await api.calls
|
||||
XCTAssertEqual(calls.nowPlayingReads, 4)
|
||||
}
|
||||
}
|
||||
|
||||
func testInterruptedPlaybackObservationsResetProgressBaseline() async throws {
|
||||
let appended = item(1)
|
||||
let interruptions = [
|
||||
nowPlaying(appended),
|
||||
nowPlaying(appended, paused: true, position: 0.25),
|
||||
nowPlaying(item(2), position: 0.25)
|
||||
]
|
||||
for interruption in interruptions {
|
||||
let api = FakePlayer(
|
||||
playlists: [.success([]), .success([appended])],
|
||||
nowPlaying: [
|
||||
.success(nowPlaying(appended, position: 0)),
|
||||
.success(interruption),
|
||||
.success(nowPlaying(appended, position: 1))
|
||||
]
|
||||
)
|
||||
let failure = await failure(using: api)
|
||||
XCTAssertFalse(failure.canRetry)
|
||||
let calls = await api.calls
|
||||
XCTAssertEqual(calls.nowPlayingReads, 4)
|
||||
}
|
||||
}
|
||||
|
||||
func testCancellationBeforeAppendDoesNotMutateAndAllowsRetry() async throws {
|
||||
let api = FakePlayer(playlists: [.success([])], nowPlaying: [], cancellation: .preflight)
|
||||
let failure = await cancelledFailure(using: api)
|
||||
XCTAssertTrue(failure.canRetry)
|
||||
XCTAssertTrue(failure.message.contains("No link was added"))
|
||||
let calls = await api.calls
|
||||
XCTAssertTrue(calls.addedURLs.isEmpty)
|
||||
XCTAssertTrue(calls.skippedIndices.isEmpty)
|
||||
XCTAssertEqual(calls.playCount, 0)
|
||||
}
|
||||
|
||||
func testCancellationAfterAppendDoesNotRepeatMutationOrAllowRetry() async throws {
|
||||
let api = FakePlayer(playlists: [.success([])], nowPlaying: [], cancellation: .afterAppend)
|
||||
let failure = await cancelledFailure(using: api)
|
||||
XCTAssertFalse(failure.canRetry)
|
||||
XCTAssertTrue(failure.message.contains("may already be queued"))
|
||||
let calls = await api.calls
|
||||
XCTAssertEqual(calls.addedURLs, [url.absoluteString])
|
||||
XCTAssertTrue(calls.skippedIndices.isEmpty)
|
||||
XCTAssertEqual(calls.playCount, 0)
|
||||
}
|
||||
|
||||
private func cancelledFailure(using api: FakePlayer) async -> SharePlayback.Failure {
|
||||
// Cancel only this child operation, leaving XCTest's task unaffected.
|
||||
await Task { await failure(using: api) }.value
|
||||
}
|
||||
|
||||
private func enqueue(using api: FakePlayer) async throws {
|
||||
try await SharePlayback.enqueueAndPlay(url, using: api, attempts: 4, pollInterval: .zero)
|
||||
}
|
||||
|
||||
private func failure(using api: FakePlayer, file: StaticString = #filePath, line: UInt = #line) async -> SharePlayback.Failure {
|
||||
do {
|
||||
try await enqueue(using: api)
|
||||
XCTFail("Expected playback to fail", file: file, line: line)
|
||||
} catch let failure as SharePlayback.Failure {
|
||||
return failure
|
||||
} catch {
|
||||
XCTFail("Unexpected error: \(error)", file: file, line: line)
|
||||
}
|
||||
return .init(message: "Test did not receive the expected failure", canRetry: true)
|
||||
}
|
||||
|
||||
private func item(_ id: Int, filename: String? = nil, playbackError: String? = nil) -> MediaItem {
|
||||
MediaItem(filename: filename ?? url.absoluteString, title: nil, id: id, current: nil, playing: nil, metadata: nil, playbackError: playbackError)
|
||||
}
|
||||
|
||||
private func nowPlaying(_ item: MediaItem?, paused: Bool = false, position: Double? = nil) -> NowPlayingInfo {
|
||||
NowPlayingInfo(playingItem: item, isPaused: paused, volume: 50, timePosition: position, duration: nil, seekable: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private enum TestError: Error { case connection }
|
||||
|
||||
private actor FakePlayer: SharePlayerAPI {
|
||||
enum Cancellation { case preflight, afterAppend }
|
||||
|
||||
struct Calls {
|
||||
var addedURLs: [String] = []
|
||||
var skippedIndices: [Int?] = []
|
||||
var playCount = 0
|
||||
var playlistReads = 0
|
||||
var nowPlayingReads = 0
|
||||
}
|
||||
|
||||
private var playlists: [Result<[MediaItem], TestError>]
|
||||
private var nowPlaying: [Result<NowPlayingInfo, TestError>]
|
||||
private let addError: TestError?
|
||||
private let playError: TestError?
|
||||
private let cancellation: Cancellation?
|
||||
private(set) var calls = Calls()
|
||||
|
||||
init(playlists: [Result<[MediaItem], TestError>], nowPlaying: [Result<NowPlayingInfo, TestError>], addError: TestError? = nil, playError: TestError? = nil, cancellation: Cancellation? = nil) {
|
||||
self.playlists = playlists
|
||||
self.nowPlaying = nowPlaying
|
||||
self.addError = addError
|
||||
self.playError = playError
|
||||
self.cancellation = cancellation
|
||||
}
|
||||
|
||||
func fetchPlaylist() async throws -> [MediaItem] {
|
||||
calls.playlistReads += 1
|
||||
if cancellation == .preflight, calls.playlistReads == 1 {
|
||||
withUnsafeCurrentTask { $0?.cancel() }
|
||||
}
|
||||
guard let next = playlists.first else { throw TestError.connection }
|
||||
if playlists.count > 1 { playlists.removeFirst() }
|
||||
return try next.get()
|
||||
}
|
||||
|
||||
func add(mediaURL: String) async throws {
|
||||
calls.addedURLs.append(mediaURL)
|
||||
if cancellation == .afterAppend { withUnsafeCurrentTask { $0?.cancel() } }
|
||||
if let addError { throw addError }
|
||||
}
|
||||
|
||||
func skip(_ to: Int?) async throws { calls.skippedIndices.append(to) }
|
||||
|
||||
func play() async throws {
|
||||
calls.playCount += 1
|
||||
if let playError { throw playError }
|
||||
}
|
||||
|
||||
func fetchNowPlayingInfo() async throws -> NowPlayingInfo {
|
||||
calls.nowPlayingReads += 1
|
||||
guard let next = nowPlaying.first else { throw TestError.connection }
|
||||
if nowPlaying.count > 1 { nowPlaying.removeFirst() }
|
||||
return try next.get()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import Foundation
|
||||
import UniformTypeIdentifiers
|
||||
import XCTest
|
||||
@testable import QueueCubeShareCore
|
||||
|
||||
final class SharedInputTests: XCTestCase {
|
||||
private let url = URL(string: "https://video.example/watch?v=shared")!
|
||||
|
||||
func testURLDataRepresentationLoadsWithTitleOnlyCompanion() async throws {
|
||||
let result = try await SharedInput.load([extensionItem([
|
||||
dataProvider(Data(url.absoluteString.utf8), type: .url),
|
||||
textProvider("A video worth watching")
|
||||
])])
|
||||
XCTAssertEqual(result, url)
|
||||
}
|
||||
|
||||
func testURLObjectRepresentationLoads() async throws {
|
||||
let provider = NSItemProvider(item: url as NSURL, typeIdentifier: UTType.url.identifier)
|
||||
let result = try await SharedInput.load([extensionItem([provider])])
|
||||
XCTAssertEqual(result, url)
|
||||
}
|
||||
|
||||
func testModernNSURLObjectProviderRoundTrips() async throws {
|
||||
let provider = NSItemProvider(object: url as NSURL)
|
||||
let result = try await SharedInput.load([extensionItem([
|
||||
provider, textProvider("A video worth watching")
|
||||
])])
|
||||
XCTAssertEqual(result, url)
|
||||
}
|
||||
|
||||
func testTypedFileAndCredentialURLsDoNotFallBackToCompanionText() async throws {
|
||||
for invalid in [
|
||||
URL(fileURLWithPath: "/tmp/video.mp4"),
|
||||
URL(string: "https://user:synthetic@video.example/video")!
|
||||
] {
|
||||
await assertFailure(.noLink, items: [extensionItem([
|
||||
NSItemProvider(object: invalid as NSURL),
|
||||
textProvider(url.absoluteString)
|
||||
])])
|
||||
}
|
||||
}
|
||||
|
||||
func testLegacyURLStringProviderPreservesTitleAndLinkFallback() async throws {
|
||||
let provider = NSItemProvider(item: "Watch this video: \(url.absoluteString)" as NSString, typeIdentifier: UTType.url.identifier)
|
||||
let result = try await SharedInput.load([extensionItem([provider])])
|
||||
XCTAssertEqual(result, url)
|
||||
}
|
||||
|
||||
func testMultipleLinksInsideOneURLDataProviderAreRejected() async throws {
|
||||
await assertFailure(.multipleLinks, items: [extensionItem([
|
||||
dataProvider(Data("\(url.absoluteString) https://other.example/video".utf8), type: .url)
|
||||
])])
|
||||
}
|
||||
|
||||
func testUIKitURLPropertyListRepresentationLoadsWithTitleOnlyCompanion() async throws {
|
||||
let data = try propertyList([url.absoluteString, "", [:]] as [Any])
|
||||
let result = try await SharedInput.load([extensionItem([
|
||||
dataProvider(data, type: .url), textProvider("A video worth watching")
|
||||
])])
|
||||
XCTAssertEqual(result, url)
|
||||
}
|
||||
|
||||
func testUIKitURLPropertyListRejectsNonmatchingShapes() async throws {
|
||||
let shapes: [Any] = [
|
||||
url.absoluteString,
|
||||
["URL": url.absoluteString],
|
||||
[url.absoluteString],
|
||||
[url.absoluteString, ""],
|
||||
[url.absoluteString, "", [:], "extra"] as [Any],
|
||||
[Data(url.absoluteString.utf8), "", [:]] as [Any],
|
||||
[url.absoluteString, 0, [:]] as [Any],
|
||||
[url.absoluteString, "", []] as [Any],
|
||||
[url.absoluteString, "https://base.example/", [:]] as [Any],
|
||||
[url.absoluteString, "", ["unexpected": "value"]] as [Any]
|
||||
]
|
||||
for (index, shape) in shapes.enumerated() {
|
||||
await assertFailure(.noLink, items: [extensionItem([
|
||||
dataProvider(try propertyList(shape), type: .url),
|
||||
textProvider(url.absoluteString)
|
||||
])], context: "plist shape \(index)")
|
||||
}
|
||||
}
|
||||
|
||||
func testUIKitURLPropertyListRejectsFilesAndCredentials() async throws {
|
||||
for value in ["file:///tmp/video.mp4", "https://user:synthetic@video.example/video"] {
|
||||
await assertFailure(.noLink, items: [extensionItem([
|
||||
dataProvider(try propertyList([value, "", [:]] as [Any]), type: .url),
|
||||
textProvider(url.absoluteString)
|
||||
])])
|
||||
}
|
||||
}
|
||||
|
||||
func testUIKitURLPropertyListPreservesExactOneLinkRequirement() async throws {
|
||||
let another = "https://other.example/video"
|
||||
let combined = try propertyList(["\(url.absoluteString) \(another)", "", [:]] as [Any])
|
||||
await assertFailure(.multipleLinks, items: [extensionItem([dataProvider(combined, type: .url)])])
|
||||
await assertFailure(.multipleLinks, items: [extensionItem([
|
||||
dataProvider(try propertyList([url.absoluteString, "", [:]] as [Any]), type: .url),
|
||||
dataProvider(try propertyList([another, "", [:]] as [Any]), type: .url)
|
||||
])])
|
||||
}
|
||||
|
||||
func testUIKitURLPropertyListIsNotDecodedForPlainTextProviders() async throws {
|
||||
await assertFailure(.noLink, items: [extensionItem([
|
||||
dataProvider(try propertyList([url.absoluteString, "", [:]] as [Any]), type: .plainText)
|
||||
])])
|
||||
}
|
||||
|
||||
func testArbitraryKeyedURLArchivesAreNotDecoded() async throws {
|
||||
for value in [url as NSURL, url.absoluteString as NSString, Data(url.absoluteString.utf8) as NSData] as [Any] {
|
||||
let archive = try NSKeyedArchiver.archivedData(withRootObject: value, requiringSecureCoding: true)
|
||||
await assertFailure(.noLink, items: [extensionItem([
|
||||
dataProvider(archive, type: .url), textProvider(url.absoluteString)
|
||||
])])
|
||||
}
|
||||
}
|
||||
|
||||
func testTruncatedUIKitURLPropertyListIsRejected() async throws {
|
||||
let data = try propertyList([url.absoluteString, "", [:]] as [Any])
|
||||
await assertFailure(.noLink, items: [extensionItem([
|
||||
dataProvider(data.dropLast(5), type: .url), textProvider(url.absoluteString)
|
||||
])])
|
||||
}
|
||||
|
||||
func testExplicitURLTakesPrecedenceOverLinksInCompanionText() async throws {
|
||||
let result = try await SharedInput.load([extensionItem([
|
||||
dataProvider(Data(url.absoluteString.utf8), type: .url),
|
||||
textProvider("Visit https://other.example/channel for more")
|
||||
])])
|
||||
XCTAssertEqual(result, url)
|
||||
}
|
||||
|
||||
func testMultipleDistinctURLDataProvidersAreRejected() async throws {
|
||||
await assertFailure(.multipleLinks, items: [extensionItem([
|
||||
dataProvider(Data(url.absoluteString.utf8), type: .url),
|
||||
dataProvider(Data("https://video.example/another".utf8), type: .url)
|
||||
])])
|
||||
}
|
||||
|
||||
func testRepeatedObjectAndDataURLRepresentationsAreOneLink() async throws {
|
||||
let result = try await SharedInput.load([extensionItem([
|
||||
NSItemProvider(item: url as NSURL, typeIdentifier: UTType.url.identifier),
|
||||
dataProvider(Data(url.absoluteString.utf8), type: .url)
|
||||
])])
|
||||
XCTAssertEqual(result, url)
|
||||
}
|
||||
|
||||
func testInvalidURLDataDoesNotFallBackToCompanionText() async throws {
|
||||
for data in [Data([0xff, 0xfe, 0x80]), Data("not a URL".utf8), Data("file:///tmp/video.mp4".utf8)] {
|
||||
await assertFailure(.noLink, items: [extensionItem([
|
||||
dataProvider(data, type: .url),
|
||||
textProvider(url.absoluteString)
|
||||
])])
|
||||
}
|
||||
}
|
||||
|
||||
func testPlainTextDataFallbackExtractsLinkAndIgnoresTitleOnlyProvider() async throws {
|
||||
let result = try await SharedInput.load([extensionItem([
|
||||
textProvider("A video worth watching"),
|
||||
dataProvider(Data("Watch this: \(url.absoluteString)".utf8), type: .plainText)
|
||||
])])
|
||||
XCTAssertEqual(result, url)
|
||||
}
|
||||
|
||||
func testAttributedTextFallbackAndEmptyInput() async throws {
|
||||
let item = NSExtensionItem()
|
||||
item.attributedContentText = NSAttributedString(string: "Watch this: \(url.absoluteString)")
|
||||
let result = try await SharedInput.load([item])
|
||||
XCTAssertEqual(result, url)
|
||||
await assertFailure(.noLink, items: [])
|
||||
}
|
||||
|
||||
private func assertFailure(_ expected: SharedVideo.Error, items: [Any], context: String = "attachment", file: StaticString = #filePath, line: UInt = #line) async {
|
||||
do {
|
||||
_ = try await SharedInput.load(items)
|
||||
XCTFail("Expected \(context) loading to fail", file: file, line: line)
|
||||
} catch let error as SharedVideo.Error {
|
||||
switch (expected, error) {
|
||||
case (.noLink, .noLink), (.multipleLinks, .multipleLinks): break
|
||||
default: XCTFail("Unexpected error: \(error)", file: file, line: line)
|
||||
}
|
||||
} catch {
|
||||
XCTFail("Unexpected provider error: \(error)", file: file, line: line)
|
||||
}
|
||||
}
|
||||
|
||||
private func extensionItem(_ providers: [NSItemProvider]) -> NSExtensionItem {
|
||||
let item = NSExtensionItem()
|
||||
item.attachments = providers
|
||||
return item
|
||||
}
|
||||
|
||||
private func textProvider(_ text: String) -> NSItemProvider {
|
||||
NSItemProvider(item: text as NSString, typeIdentifier: UTType.plainText.identifier)
|
||||
}
|
||||
|
||||
private func dataProvider(_ data: Data, type: UTType) -> NSItemProvider {
|
||||
let provider = NSItemProvider()
|
||||
provider.registerDataRepresentation(forTypeIdentifier: type.identifier, visibility: .all) { completion in
|
||||
completion(data, nil)
|
||||
return nil
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
private func propertyList(_ value: Any) throws -> Data {
|
||||
try PropertyListSerialization.data(fromPropertyList: value, format: .binary, options: 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import QueueCubeShareCore
|
||||
|
||||
final class SharedVideoTests: XCTestCase {
|
||||
func testAcceptsHTTPAndHTTPSURLsWithoutChangingTheirContents() throws {
|
||||
for value in ["https://video.example/watch?v=one&t=12", "http://video.example/a%20b"] {
|
||||
let url = try XCTUnwrap(URL(string: value))
|
||||
XCTAssertEqual(try SharedVideo.validate(url), url)
|
||||
}
|
||||
}
|
||||
|
||||
func testExtractsLinkFromSharedTitleAndText() throws {
|
||||
let url = try SharedVideo.extract(from: "A video to watch: https://video.example/watch?v=one\nSent from my phone")
|
||||
XCTAssertEqual(url.absoluteString, "https://video.example/watch?v=one")
|
||||
}
|
||||
|
||||
func testRepeatedURLInTextAndAttachmentsIsOneLink() throws {
|
||||
let url = try XCTUnwrap(URL(string: "https://video.example/watch?v=one"))
|
||||
XCTAssertEqual(try SharedVideo.extract(from: "\(url)\nWatch it: \(url)"), url)
|
||||
XCTAssertEqual(try SharedVideo.single([url, url]), url)
|
||||
}
|
||||
|
||||
func testRejectsMultipleDistinctLinks() throws {
|
||||
XCTAssertThrowsError(try SharedVideo.extract(from: "https://video.example/one https://video.example/two")) {
|
||||
guard case SharedVideo.Error.multipleLinks = $0 else { return XCTFail("Unexpected error: \($0)") }
|
||||
}
|
||||
XCTAssertThrowsError(try SharedVideo.single([
|
||||
XCTUnwrap(URL(string: "https://video.example/one")),
|
||||
XCTUnwrap(URL(string: "https://video.example/two"))
|
||||
]))
|
||||
}
|
||||
|
||||
func testRejectsFilesOtherSchemesCredentialsAndMissingHosts() throws {
|
||||
for value in [
|
||||
"file:///tmp/video.mp4", "ftp://video.example/video.mp4", "javascript:alert(1)",
|
||||
"https://user:password@video.example/video", "https://user@video.example/video",
|
||||
"https:///video", "/relative/video"
|
||||
] {
|
||||
let url = try XCTUnwrap(URL(string: value))
|
||||
XCTAssertThrowsError(try SharedVideo.validate(url), "Accepted \(value)") {
|
||||
guard case SharedVideo.Error.noLink = $0 else { return XCTFail("Unexpected error: \($0)") }
|
||||
}
|
||||
}
|
||||
XCTAssertThrowsError(try SharedVideo.extract(from: "This title contains no video link"))
|
||||
XCTAssertThrowsError(try SharedVideo.single([]))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user