import XCTest @testable import MusicBridge @MainActor final class ResilienceTests: XCTestCase { private func connection(_ token: String) throws -> Connection { let url = try XCTUnwrap(Bundle(for: Self.self).url(forResource: "tls-fixture", withExtension: "json")) let fixtures = try JSONDecoder().decode([String: [String: String]].self, from: Data(contentsOf: url)) guard let fixture = fixtures["valid"] else { throw XCTSkip("Start tools/tls_fixture.py first.") } return try Connection(endpoint: XCTUnwrap(fixture["endpoint"]), fingerprint: XCTUnwrap(fixture["fingerprint"]), token: token) } private func store(_ token: String) throws -> PlayerStore { let saved = try connection(token) return PlayerStore(storage: ConnectionStorage(load: { saved }, save: { _ in }, delete: {}), retryUnit: 20_000_000, artworkRetryUnit: 20_000_000, discoveryEnabled: false) } private func eventually(_ condition: () -> Bool, file: StaticString = #filePath, line: UInt = #line) async throws { for _ in 0..<200 { if condition() { return } try await Task.sleep(nanoseconds: 20_000_000) } XCTFail("Condition did not become true", file: file, line: line) } func testRecoversAfterTemporaryServerFailureAndForegroundReturn() async throws { let store = try store("recover") defer { store.setActive(false) } store.setActive(true) try await eventually { store.connected } store.setActive(false) XCTAssertFalse(store.connected) XCTAssertNil(store.state) store.setActive(true) try await eventually { store.connected } } func testRevokedAccessStopsRetriesAndSurvivesForegroundReturn() async throws { let store = try store("revoked") defer { store.setActive(false) } store.setActive(true) try await eventually { store.needsPairing } XCTAssertFalse(store.connected) store.setActive(false); store.setActive(true) try await Task.sleep(nanoseconds: 100_000_000) XCTAssertEqual(store.status, "Нужно повторное сопряжение") XCTAssertTrue(store.needsPairing) XCTAssertFalse(store.connected) } func testArtworkRetriesAfter404WithoutTrackChange() async throws { let store = try store("artwork") defer { store.setActive(false) } store.setActive(true) try await eventually { store.artwork != nil } XCTAssertEqual(store.state?.artworkId, String(repeating: "A", count: 64)) XCTAssertNil(store.artworkError) } func testArtworkFailureStopsAtBoundedRetryAndAllowsManualRetry() async throws { let store = try store("artwork-fail") defer { store.setActive(false) } store.setActive(true) try await eventually { store.artworkError?.contains("вручную") == true } XCTAssertTrue(store.connected) store.retryArtwork() try await eventually { store.artworkError?.contains("Повторяем") == true } store.setActive(false) XCTAssertNil(store.artworkError) } func testLateArtworkDoesNotReplaceNewTrackImage() async throws { let store = try store("late-art") defer { store.setActive(false) } store.setActive(true) try await eventually { store.artwork?.size.width == 2 } try await Task.sleep(nanoseconds: 600_000_000) XCTAssertEqual(store.state?.artworkId, String(repeating: "B", count: 64)) XCTAssertEqual(store.artwork?.size.width, 2) } func testStreamUpdatesDoNotEraseRejectedCommandMessage() async throws { let store = try store("command-reject") defer { store.setActive(false) } store.setActive(true) try await eventually { store.connected } await store.send("next") try await Task.sleep(nanoseconds: 200_000_000) XCTAssertEqual(store.error, "Плеер не поддерживает эту команду.") XCTAssertFalse(store.busy) } func testInterruptedPairingDoesNotSaveLateResponse() async throws { let target = try connection("") var saved = false let store = PlayerStore(storage: ConnectionStorage(load: { nil }, save: { _ in saved = true }, delete: {}), discoveryEnabled: false) store.setActive(true) let task = Task { await store.pair(endpoint: target.endpoint.absoluteString, fingerprint: target.fingerprint, code: "33333333") } try await eventually { store.pairing } try await Task.sleep(nanoseconds: 80_000_000) store.setActive(false) await task.value XCTAssertFalse(saved) XCTAssertNil(store.connection) XCTAssertFalse(store.pairing) XCTAssertTrue(store.error?.contains("прервано") == true) } func testRedirectIsRejectedAndPairingServerCodesArePreserved() async throws { let client = BridgeClient(try connection("redirect")) defer { client.close() } do { _ = try await client.state(); XCTFail("Followed redirect") } catch { guard case BridgeError.http(status: 302, code: _) = error else { return XCTFail("Unexpected error: \(error)") } } for (code, expected) in [("11111111", "invalid_code"), ("22222222", "pairing_closed")] { do { _ = try await client.pair(code: code); XCTFail("Accepted invalid pairing") } catch { guard case BridgeError.http(status: 403, code: let actual) = error else { return XCTFail("Unexpected error: \(error)") } XCTAssertEqual(actual, expected) } } } }